• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / cli / 18391266662

09 Oct 2025 10:55PM UTC coverage: 54.931% (+0.2%) from 54.696%
18391266662

Pull #4276

github

web-flow
Merge 3b1adbd9e into bdf03d6ae
Pull Request #4276: feat: grouped pretty print status output

111 of 121 new or added lines in 2 files covered. (91.74%)

5 existing lines in 1 file now uncovered.

6489 of 11813 relevant lines covered (54.93%)

6.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.16
/internal/status/status.go
1
package status
2

3
import (
4
        "context"
5
        "crypto/tls"
6
        "crypto/x509"
7
        _ "embed"
8
        "fmt"
9
        "io"
10
        "net/http"
11
        "net/url"
12
        "os"
13
        "sync"
14
        "time"
15

16
        "github.com/docker/docker/api/types"
17
        "github.com/docker/docker/api/types/container"
18
        "github.com/go-errors/errors"
19
        "github.com/olekukonko/tablewriter"
20
        "github.com/olekukonko/tablewriter/tw"
21
        "github.com/spf13/afero"
22
        "github.com/supabase/cli/internal/utils"
23
        "github.com/supabase/cli/internal/utils/flags"
24
        "github.com/supabase/cli/pkg/fetcher"
25
)
26

27
type CustomName struct {
28
        ApiURL                   string `env:"api.url,default=API_URL"`
29
        RestURL                  string `env:"api.rest_url,default=REST_URL"`
30
        GraphqlURL               string `env:"api.graphql_url,default=GRAPHQL_URL"`
31
        StorageS3URL             string `env:"api.storage_s3_url,default=STORAGE_S3_URL"`
32
        McpURL                   string `env:"api.mcp_url,default=MCP_URL"`
33
        FunctionsURL             string `env:"api.functions_url,default=FUNCTIONS_URL"`
34
        DbURL                    string `env:"db.url,default=DB_URL"`
35
        StudioURL                string `env:"studio.url,default=STUDIO_URL"`
36
        InbucketURL              string `env:"inbucket.url,default=INBUCKET_URL,deprecated"`
37
        MailpitURL               string `env:"mailpit.url,default=MAILPIT_URL"`
38
        PublishableKey           string `env:"auth.publishable_key,default=PUBLISHABLE_KEY"`
39
        SecretKey                string `env:"auth.secret_key,default=SECRET_KEY"`
40
        JWTSecret                string `env:"auth.jwt_secret,default=JWT_SECRET,deprecated"`
41
        AnonKey                  string `env:"auth.anon_key,default=ANON_KEY,deprecated"`
42
        ServiceRoleKey           string `env:"auth.service_role_key,default=SERVICE_ROLE_KEY,deprecated"`
43
        StorageS3AccessKeyId     string `env:"storage.s3_access_key_id,default=S3_PROTOCOL_ACCESS_KEY_ID"`
44
        StorageS3SecretAccessKey string `env:"storage.s3_secret_access_key,default=S3_PROTOCOL_ACCESS_KEY_SECRET"`
45
        StorageS3Region          string `env:"storage.s3_region,default=S3_PROTOCOL_REGION"`
46
}
47
type OutputType string
48

49
const (
50
        Text OutputType = "text"
51
        Link OutputType = "link"
52
        Key  OutputType = "key"
53
)
54

55
type OutputItem struct {
56
        Label string
57
        Value string
58
        Type  OutputType
59
}
60

61
type OutputGroup struct {
62
        Name  string
63
        Items []OutputItem
64
}
65

66
func (c *CustomName) toValues(exclude ...string) map[string]string {
6✔
67
        values := map[string]string{
6✔
68
                c.DbURL: fmt.Sprintf("postgresql://%s@%s:%d/postgres", url.UserPassword("postgres", utils.Config.Db.Password), utils.Config.Hostname, utils.Config.Db.Port),
6✔
69
        }
6✔
70

6✔
71
        apiEnabled := utils.Config.Api.Enabled && !utils.SliceContains(exclude, utils.RestId) && !utils.SliceContains(exclude, utils.ShortContainerImageName(utils.Config.Api.Image))
6✔
72
        studioEnabled := utils.Config.Studio.Enabled && !utils.SliceContains(exclude, utils.StudioId) && !utils.SliceContains(exclude, utils.ShortContainerImageName(utils.Config.Studio.Image))
6✔
73
        authEnabled := utils.Config.Auth.Enabled && !utils.SliceContains(exclude, utils.GotrueId) && !utils.SliceContains(exclude, utils.ShortContainerImageName(utils.Config.Auth.Image))
6✔
74
        inbucketEnabled := utils.Config.Inbucket.Enabled && !utils.SliceContains(exclude, utils.InbucketId) && !utils.SliceContains(exclude, utils.ShortContainerImageName(utils.Config.Inbucket.Image))
6✔
75
        storageEnabled := utils.Config.Storage.Enabled && !utils.SliceContains(exclude, utils.StorageId) && !utils.SliceContains(exclude, utils.ShortContainerImageName(utils.Config.Storage.Image))
6✔
76

6✔
77
        if apiEnabled {
6✔
78
                values[c.ApiURL] = utils.Config.Api.ExternalUrl
×
NEW
79
                values[c.RestURL] = utils.GetApiUrl("/rest/v1")
×
80
                values[c.GraphqlURL] = utils.GetApiUrl("/graphql/v1")
×
NEW
81
                values[c.FunctionsURL] = utils.GetApiUrl("/functions/v1")
×
82
                if studioEnabled {
×
83
                        values[c.McpURL] = utils.GetApiUrl("/mcp")
×
84
                }
×
85
        }
86
        if studioEnabled {
6✔
87
                values[c.StudioURL] = fmt.Sprintf("http://%s:%d", utils.Config.Hostname, utils.Config.Studio.Port)
×
88
        }
×
89
        if authEnabled {
6✔
90
                values[c.PublishableKey] = utils.Config.Auth.PublishableKey.Value
×
91
                values[c.SecretKey] = utils.Config.Auth.SecretKey.Value
×
92
                values[c.JWTSecret] = utils.Config.Auth.JwtSecret.Value
×
93
                values[c.AnonKey] = utils.Config.Auth.AnonKey.Value
×
94
                values[c.ServiceRoleKey] = utils.Config.Auth.ServiceRoleKey.Value
×
95
        }
×
96
        if inbucketEnabled {
6✔
97
                values[c.MailpitURL] = fmt.Sprintf("http://%s:%d", utils.Config.Hostname, utils.Config.Inbucket.Port)
×
98
                values[c.InbucketURL] = fmt.Sprintf("http://%s:%d", utils.Config.Hostname, utils.Config.Inbucket.Port)
×
99
        }
×
100
        if storageEnabled {
6✔
101
                values[c.StorageS3URL] = utils.GetApiUrl("/storage/v1/s3")
×
102
                values[c.StorageS3AccessKeyId] = utils.Config.Storage.S3Credentials.AccessKeyId
×
103
                values[c.StorageS3SecretAccessKey] = utils.Config.Storage.S3Credentials.SecretAccessKey
×
104
                values[c.StorageS3Region] = utils.Config.Storage.S3Credentials.Region
×
105
        }
×
106
        return values
6✔
107
}
108

109
func Run(ctx context.Context, names CustomName, format string, fsys afero.Fs) error {
4✔
110
        // Sanity checks.
4✔
111
        if err := flags.LoadConfig(fsys); err != nil {
5✔
112
                return err
1✔
113
        }
1✔
114
        if err := assertContainerHealthy(ctx, utils.DbId); err != nil {
4✔
115
                return err
1✔
116
        }
1✔
117
        stopped, err := checkServiceHealth(ctx)
2✔
118
        if err != nil {
2✔
119
                return err
×
120
        }
×
121
        if len(stopped) > 0 {
4✔
122
                fmt.Fprintln(os.Stderr, "Stopped services:", stopped)
2✔
123
        }
2✔
124
        if format == utils.OutputPretty {
4✔
125
                fmt.Fprintf(os.Stderr, "%s local development setup is running.\n\n", utils.Aqua("supabase"))
2✔
126
                PrettyPrint(os.Stdout, stopped...)
2✔
127
                return nil
2✔
128
        }
2✔
129
        return printStatus(names, format, os.Stdout, stopped...)
×
130
}
131

132
func checkServiceHealth(ctx context.Context) ([]string, error) {
5✔
133
        resp, err := utils.Docker.ContainerList(ctx, container.ListOptions{
5✔
134
                Filters: utils.CliProjectFilter(utils.Config.ProjectId),
5✔
135
        })
5✔
136
        if err != nil {
6✔
137
                return nil, errors.Errorf("failed to list running containers: %w", err)
1✔
138
        }
1✔
139
        running := make(map[string]struct{}, len(resp))
4✔
140
        for _, c := range resp {
43✔
141
                for _, n := range c.Names {
78✔
142
                        running[n] = struct{}{}
39✔
143
                }
39✔
144
        }
145
        var stopped []string
4✔
146
        for _, containerId := range utils.GetDockerIds() {
56✔
147
                if _, ok := running["/"+containerId]; !ok {
91✔
148
                        stopped = append(stopped, containerId)
39✔
149
                }
39✔
150
        }
151
        return stopped, nil
4✔
152
}
153

154
func assertContainerHealthy(ctx context.Context, container string) error {
30✔
155
        if resp, err := utils.Docker.ContainerInspect(ctx, container); err != nil {
31✔
156
                return errors.Errorf("failed to inspect container health: %w", err)
1✔
157
        } else if !resp.State.Running {
33✔
158
                return errors.Errorf("%s container is not running: %s", container, resp.State.Status)
3✔
159
        } else if resp.State.Health != nil && resp.State.Health.Status != types.Healthy {
29✔
160
                return errors.Errorf("%s container is not ready: %s", container, resp.State.Health.Status)
×
161
        }
×
162
        return nil
26✔
163
}
164

165
func IsServiceReady(ctx context.Context, container string) error {
29✔
166
        if container == utils.RestId {
30✔
167
                // PostgREST does not support native health checks
1✔
168
                return checkHTTPHead(ctx, "/rest-admin/v1/ready")
1✔
169
        }
1✔
170
        if container == utils.EdgeRuntimeId {
29✔
171
                // Native health check logs too much hyper::Error(IncompleteMessage)
1✔
172
                return checkHTTPHead(ctx, "/functions/v1/_internal/health")
1✔
173
        }
1✔
174
        return assertContainerHealthy(ctx, container)
27✔
175
}
176

177
// To regenerate local certificate pair:
178
//
179
//        openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 \
180
//          -nodes -keyout kong.local.key -out kong.local.crt -subj "/CN=localhost" \
181
//          -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
182
func NewKongClient() *http.Client {
5✔
183
        client := &http.Client{
5✔
184
                Timeout: 10 * time.Second,
5✔
185
        }
5✔
186
        if t, ok := http.DefaultTransport.(*http.Transport); ok {
5✔
187
                pool, err := x509.SystemCertPool()
×
188
                if err != nil {
×
189
                        fmt.Fprintln(utils.GetDebugLogger(), err)
×
190
                        pool = x509.NewCertPool()
×
191
                }
×
192
                // No need to replace TLS config if we fail to append cert
193
                if pool.AppendCertsFromPEM(utils.Config.Api.Tls.CertContent) {
×
194
                        rt := t.Clone()
×
195
                        rt.TLSClientConfig = &tls.Config{
×
196
                                MinVersion: tls.VersionTLS12,
×
197
                                RootCAs:    pool,
×
198
                        }
×
199
                        client.Transport = rt
×
200
                }
×
201
        }
202
        return client
5✔
203
}
204

205
var (
206
        healthClient *fetcher.Fetcher
207
        healthOnce   sync.Once
208
)
209

210
func checkHTTPHead(ctx context.Context, path string) error {
2✔
211
        healthOnce.Do(func() {
3✔
212
                healthClient = fetcher.NewServiceGateway(
1✔
213
                        utils.Config.Api.ExternalUrl,
1✔
214
                        utils.Config.Auth.SecretKey.Value,
1✔
215
                        fetcher.WithHTTPClient(NewKongClient()),
1✔
216
                        fetcher.WithUserAgent("SupabaseCLI/"+utils.Version),
1✔
217
                )
1✔
218
        })
1✔
219
        // HEAD method does not return response body
220
        resp, err := healthClient.Send(ctx, http.MethodHead, path, nil)
2✔
221
        if err != nil {
2✔
222
                return err
×
223
        }
×
224
        defer resp.Body.Close()
2✔
225
        return nil
2✔
226
}
227

228
func printStatus(names CustomName, format string, w io.Writer, exclude ...string) (err error) {
4✔
229
        values := names.toValues(exclude...)
4✔
230
        return utils.EncodeOutput(format, w, values)
4✔
231
}
4✔
232

233
func PrettyPrint(w io.Writer, exclude ...string) {
2✔
234
        names := CustomName{
2✔
235
                ApiURL:                   "API_URL",
2✔
236
                RestURL:                  "REST_URL",
2✔
237
                GraphqlURL:               "GRAPHQL_URL",
2✔
238
                FunctionsURL:             "FUNCTIONS_URL",
2✔
239
                StorageS3URL:             "STORAGE_S3_URL",
2✔
240
                McpURL:                   "MCP_URL",
2✔
241
                DbURL:                    "DB_URL",
2✔
242
                StudioURL:                "STUDIO_URL",
2✔
243
                InbucketURL:              "INBUCKET_URL",
2✔
244
                MailpitURL:               "MAILPIT_URL",
2✔
245
                PublishableKey:           "PUBLISHABLE_KEY",
2✔
246
                SecretKey:                "SECRET_KEY",
2✔
247
                JWTSecret:                "JWT_SECRET",
2✔
248
                AnonKey:                  "ANON_KEY",
2✔
249
                ServiceRoleKey:           "SERVICE_ROLE_KEY",
2✔
250
                StorageS3AccessKeyId:     "S3_PROTOCOL_ACCESS_KEY_ID",
2✔
251
                StorageS3SecretAccessKey: "S3_PROTOCOL_SECRET_ACCESS_KEY",
2✔
252
                StorageS3Region:          "S3_PROTOCOL_REGION",
2✔
253
        }
2✔
254
        values := names.toValues(exclude...)
2✔
255

2✔
256
        groups := []OutputGroup{
2✔
257
                {
2✔
258
                        Name: "🛠️  Development Tools",
2✔
259
                        Items: []OutputItem{
2✔
260
                                {Label: "Studio", Value: values[names.StudioURL], Type: Link},
2✔
261
                                {Label: "Mailpit", Value: values[names.MailpitURL], Type: Link},
2✔
262
                                {Label: "MCP", Value: values[names.McpURL], Type: Link},
2✔
263
                        },
2✔
264
                },
2✔
265
                {
2✔
266
                        Name: "🌐 APIs",
2✔
267
                        Items: []OutputItem{
2✔
268
                                {Label: "Project URL", Value: values[names.ApiURL], Type: Link},
2✔
269
                                {Label: "REST", Value: values[names.RestURL], Type: Link},
2✔
270
                                {Label: "GraphQL", Value: values[names.GraphqlURL], Type: Link},
2✔
271
                                {Label: "Edge Functions", Value: values[names.FunctionsURL], Type: Link},
2✔
272
                        },
2✔
273
                },
2✔
274
                {
2✔
275
                        Name: "🗄️  Database",
2✔
276
                        Items: []OutputItem{
2✔
277
                                {Label: "URL", Value: values[names.DbURL], Type: Link},
2✔
278
                        },
2✔
279
                },
2✔
280
                {
2✔
281
                        Name: "🔑 Authentication Keys",
2✔
282
                        Items: []OutputItem{
2✔
283
                                {Label: "Publishable", Value: values[names.PublishableKey], Type: Key},
2✔
284
                                {Label: "Secret", Value: values[names.SecretKey], Type: Key},
2✔
285
                        },
2✔
286
                },
2✔
287
                {
2✔
288
                        Name: "📦 Storage (S3)",
2✔
289
                        Items: []OutputItem{
2✔
290
                                {Label: "URL", Value: values[names.StorageS3URL], Type: Link},
2✔
291
                                {Label: "Access Key", Value: values[names.StorageS3AccessKeyId], Type: Key},
2✔
292
                                {Label: "Secret Key", Value: values[names.StorageS3SecretAccessKey], Type: Key},
2✔
293
                                {Label: "Region", Value: values[names.StorageS3Region], Type: Text},
2✔
294
                        },
2✔
295
                },
2✔
296
        }
2✔
297

2✔
298
        for _, group := range groups {
12✔
299
                // ensure at least one item in the group is non-empty
10✔
300
                shouldPrint := false
10✔
301
                for _, item := range group.Items {
38✔
302
                        if item.Value != "" {
30✔
303
                                shouldPrint = true
2✔
304
                                break
2✔
305
                        }
306
                }
307
                if shouldPrint {
12✔
308
                        printTable(w, group.Name, group.Items)
2✔
309
                        fmt.Fprintln(w)
2✔
310
                }
2✔
311
        }
312
}
313

314
func printTable(w io.Writer, title string, rows []OutputItem) {
2✔
315
        table := tablewriter.NewTable(w,
2✔
316
                // Rounded corners
2✔
317
                tablewriter.WithSymbols(tw.NewSymbols(tw.StyleRounded)),
2✔
318

2✔
319
                // Table content formatting
2✔
320
                tablewriter.WithConfig(tablewriter.Config{
2✔
321
                        Header: tw.CellConfig{
2✔
322
                                Formatting: tw.CellFormatting{
2✔
323
                                        AutoFormat: tw.Off,
2✔
324
                                        MergeMode:  tw.MergeHorizontal,
2✔
325
                                },
2✔
326
                                Alignment: tw.CellAlignment{
2✔
327
                                        Global: tw.AlignLeft,
2✔
328
                                },
2✔
329
                                Filter: tw.CellFilter{
2✔
330
                                        Global: func(s []string) []string {
4✔
331
                                                for i := range s {
6✔
332
                                                        s[i] = utils.Bold(s[i])
4✔
333
                                                }
4✔
334
                                                return s
2✔
335
                                        },
336
                                },
337
                        },
338
                        Row: tw.CellConfig{
339
                                Alignment: tw.CellAlignment{
340
                                        Global: tw.AlignLeft,
341
                                },
342
                                ColMaxWidths: tw.CellWidth{
343
                                        PerColumn: map[int]int{0: 16},
344
                                },
345
                                Filter: tw.CellFilter{
346
                                        PerColumn: []func(string) string{
347
                                                func(s string) string {
2✔
348
                                                        return utils.Green(s)
2✔
349
                                                },
2✔
350
                                        },
351
                                },
352
                        },
353
                        Behavior: tw.Behavior{
354
                                Compact: tw.Compact{
355
                                        Merge: tw.On,
356
                                },
357
                        },
358
                }),
359
        )
360

361
        // Set title as header (merged across all columns)
362
        table.Header(title, title)
2✔
363

2✔
364
        var appendError error
2✔
365

2✔
366
        // Add data rows with values colored based on type
2✔
367
        for _, row := range rows {
4✔
368
                if row.Value != "" {
4✔
369
                        switch row.Type {
2✔
370
                        case Link:
2✔
371
                                appendError = table.Append(row.Label, utils.Aqua(row.Value))
2✔
NEW
372
                        case Key:
×
NEW
373
                                appendError = table.Append(row.Label, utils.Yellow(row.Value))
×
NEW
374
                        case Text:
×
NEW
375
                                appendError = table.Append(row.Label, row.Value)
×
376
                        }
377
                }
378
        }
379

380
        if appendError != nil {
2✔
NEW
381
                fmt.Fprintln(utils.GetDebugLogger(), appendError)
×
NEW
382
        }
×
383

384
        renderError := table.Render()
2✔
385
        if renderError != nil {
2✔
NEW
386
                fmt.Fprintln(utils.GetDebugLogger(), renderError)
×
NEW
387
        }
×
388
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc