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

supabase / cli / 19694860430

26 Nov 2025 06:37AM UTC coverage: 55.536% (+0.1%) from 55.403%
19694860430

Pull #4276

github

web-flow
Merge 73fd1eafc into dbc6cf6d4
Pull Request #4276: feat: grouped pretty print status output

92 of 102 new or added lines in 2 files covered. (90.2%)

8 existing lines in 2 files now uncovered.

6721 of 12102 relevant lines covered (55.54%)

6.23 hits per line

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

77.41
/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
        "slices"
14
        "sync"
15
        "time"
16

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

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

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

6✔
55
        apiEnabled := utils.Config.Api.Enabled && !slices.Contains(exclude, utils.RestId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Api.Image))
6✔
56
        studioEnabled := utils.Config.Studio.Enabled && !slices.Contains(exclude, utils.StudioId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Studio.Image))
6✔
57
        authEnabled := utils.Config.Auth.Enabled && !slices.Contains(exclude, utils.GotrueId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Auth.Image))
6✔
58
        inbucketEnabled := utils.Config.Inbucket.Enabled && !slices.Contains(exclude, utils.InbucketId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Inbucket.Image))
6✔
59
        storageEnabled := utils.Config.Storage.Enabled && !slices.Contains(exclude, utils.StorageId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Storage.Image))
6✔
60
        functionsEnabled := utils.Config.EdgeRuntime.Enabled && !slices.Contains(exclude, utils.EdgeRuntimeId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.EdgeRuntime.Image))
6✔
61

6✔
62
        if apiEnabled {
6✔
63
                values[c.ApiURL] = utils.Config.Api.ExternalUrl
×
NEW
64
                values[c.RestURL] = utils.GetApiUrl("/rest/v1")
×
65
                values[c.GraphqlURL] = utils.GetApiUrl("/graphql/v1")
×
NEW
66
                if functionsEnabled {
×
NEW
67
                        values[c.FunctionsURL] = utils.GetApiUrl("/functions/v1")
×
NEW
68
                }
×
69
                if studioEnabled {
×
70
                        values[c.McpURL] = utils.GetApiUrl("/mcp")
×
71
                }
×
72
        }
73
        if studioEnabled {
6✔
74
                values[c.StudioURL] = fmt.Sprintf("http://%s:%d", utils.Config.Hostname, utils.Config.Studio.Port)
×
75
        }
×
76
        if authEnabled {
6✔
77
                values[c.PublishableKey] = utils.Config.Auth.PublishableKey.Value
×
78
                values[c.SecretKey] = utils.Config.Auth.SecretKey.Value
×
79
                values[c.JWTSecret] = utils.Config.Auth.JwtSecret.Value
×
80
                values[c.AnonKey] = utils.Config.Auth.AnonKey.Value
×
81
                values[c.ServiceRoleKey] = utils.Config.Auth.ServiceRoleKey.Value
×
82
        }
×
83
        if inbucketEnabled {
6✔
84
                values[c.MailpitURL] = fmt.Sprintf("http://%s:%d", utils.Config.Hostname, utils.Config.Inbucket.Port)
×
85
                values[c.InbucketURL] = fmt.Sprintf("http://%s:%d", utils.Config.Hostname, utils.Config.Inbucket.Port)
×
86
        }
×
87
        if storageEnabled {
6✔
88
                values[c.StorageS3URL] = utils.GetApiUrl("/storage/v1/s3")
×
89
                values[c.StorageS3AccessKeyId] = utils.Config.Storage.S3Credentials.AccessKeyId
×
90
                values[c.StorageS3SecretAccessKey] = utils.Config.Storage.S3Credentials.SecretAccessKey
×
91
                values[c.StorageS3Region] = utils.Config.Storage.S3Credentials.Region
×
92
        }
×
93
        return values
6✔
94
}
95

96
func Run(ctx context.Context, names CustomName, format string, fsys afero.Fs) error {
4✔
97
        // Sanity checks.
4✔
98
        if err := flags.LoadConfig(fsys); err != nil {
5✔
99
                return err
1✔
100
        }
1✔
101
        if err := assertContainerHealthy(ctx, utils.DbId); err != nil {
4✔
102
                return err
1✔
103
        }
1✔
104
        stopped, err := checkServiceHealth(ctx)
2✔
105
        if err != nil {
2✔
106
                return err
×
107
        }
×
108
        if len(stopped) > 0 {
4✔
109
                fmt.Fprintln(os.Stderr, "Stopped services:", stopped)
2✔
110
        }
2✔
111
        if format == utils.OutputPretty {
4✔
112
                fmt.Fprintf(os.Stderr, "%s local development setup is running.\n\n", utils.Aqua("supabase"))
2✔
113
                PrettyPrint(os.Stdout, stopped...)
2✔
114
                return nil
2✔
115
        }
2✔
116
        return printStatus(names, format, os.Stdout, stopped...)
×
117
}
118

119
func checkServiceHealth(ctx context.Context) ([]string, error) {
5✔
120
        resp, err := utils.Docker.ContainerList(ctx, container.ListOptions{
5✔
121
                Filters: utils.CliProjectFilter(utils.Config.ProjectId),
5✔
122
        })
5✔
123
        if err != nil {
6✔
124
                return nil, errors.Errorf("failed to list running containers: %w", err)
1✔
125
        }
1✔
126
        running := make(map[string]struct{}, len(resp))
4✔
127
        for _, c := range resp {
43✔
128
                for _, n := range c.Names {
78✔
129
                        running[n] = struct{}{}
39✔
130
                }
39✔
131
        }
132
        var stopped []string
4✔
133
        for _, containerId := range utils.GetDockerIds() {
56✔
134
                if _, ok := running["/"+containerId]; !ok {
91✔
135
                        stopped = append(stopped, containerId)
39✔
136
                }
39✔
137
        }
138
        return stopped, nil
4✔
139
}
140

141
func assertContainerHealthy(ctx context.Context, container string) error {
30✔
142
        if resp, err := utils.Docker.ContainerInspect(ctx, container); err != nil {
31✔
143
                return errors.Errorf("failed to inspect container health: %w", err)
1✔
144
        } else if !resp.State.Running {
33✔
145
                return errors.Errorf("%s container is not running: %s", container, resp.State.Status)
3✔
146
        } else if resp.State.Health != nil && resp.State.Health.Status != types.Healthy {
29✔
147
                return errors.Errorf("%s container is not ready: %s", container, resp.State.Health.Status)
×
148
        }
×
149
        return nil
26✔
150
}
151

152
func IsServiceReady(ctx context.Context, container string) error {
29✔
153
        if container == utils.RestId {
30✔
154
                // PostgREST does not support native health checks
1✔
155
                return checkHTTPHead(ctx, "/rest-admin/v1/ready")
1✔
156
        }
1✔
157
        if container == utils.EdgeRuntimeId {
29✔
158
                // Native health check logs too much hyper::Error(IncompleteMessage)
1✔
159
                return checkHTTPHead(ctx, "/functions/v1/_internal/health")
1✔
160
        }
1✔
161
        return assertContainerHealthy(ctx, container)
27✔
162
}
163

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

192
var (
193
        healthClient *fetcher.Fetcher
194
        healthOnce   sync.Once
195
)
196

197
func checkHTTPHead(ctx context.Context, path string) error {
2✔
198
        healthOnce.Do(func() {
3✔
199
                healthClient = fetcher.NewServiceGateway(
1✔
200
                        utils.Config.Api.ExternalUrl,
1✔
201
                        utils.Config.Auth.SecretKey.Value,
1✔
202
                        fetcher.WithHTTPClient(NewKongClient()),
1✔
203
                        fetcher.WithUserAgent("SupabaseCLI/"+utils.Version),
1✔
204
                )
1✔
205
        })
1✔
206
        // HEAD method does not return response body
207
        resp, err := healthClient.Send(ctx, http.MethodHead, path, nil)
2✔
208
        if err != nil {
2✔
209
                return err
×
210
        }
×
211
        defer resp.Body.Close()
2✔
212
        return nil
2✔
213
}
214

215
func printStatus(names CustomName, format string, w io.Writer, exclude ...string) (err error) {
4✔
216
        values := names.toValues(exclude...)
4✔
217
        return utils.EncodeOutput(format, w, values)
4✔
218
}
4✔
219

220
func PrettyPrint(w io.Writer, exclude ...string) {
2✔
221
        logger := utils.GetDebugLogger()
2✔
222

2✔
223
        names := CustomName{}
2✔
224
        if err := env.Unmarshal(env.EnvSet{}, &names); err != nil {
2✔
NEW
225
                fmt.Fprintln(logger, err)
×
UNCOV
226
        }
×
227
        values := names.toValues(exclude...)
2✔
228

2✔
229
        groups := []OutputGroup{
2✔
230
                {
2✔
231
                        Name: "🛠️  Development Tools",
2✔
232
                        Items: []OutputItem{
2✔
233
                                {Label: "Studio", Value: values[names.StudioURL], Type: Link},
2✔
234
                                {Label: "Mailpit", Value: values[names.MailpitURL], Type: Link},
2✔
235
                                {Label: "MCP", Value: values[names.McpURL], Type: Link},
2✔
236
                        },
2✔
237
                },
2✔
238
                {
2✔
239
                        Name: "🌐 APIs",
2✔
240
                        Items: []OutputItem{
2✔
241
                                {Label: "Project URL", Value: values[names.ApiURL], Type: Link},
2✔
242
                                {Label: "REST", Value: values[names.RestURL], Type: Link},
2✔
243
                                {Label: "GraphQL", Value: values[names.GraphqlURL], Type: Link},
2✔
244
                                {Label: "Edge Functions", Value: values[names.FunctionsURL], Type: Link},
2✔
245
                        },
2✔
246
                },
2✔
247
                {
2✔
248
                        Name: "🗄️  Database",
2✔
249
                        Items: []OutputItem{
2✔
250
                                {Label: "URL", Value: values[names.DbURL], Type: Link},
2✔
251
                        },
2✔
252
                },
2✔
253
                {
2✔
254
                        Name: "🔑 Authentication Keys",
2✔
255
                        Items: []OutputItem{
2✔
256
                                {Label: "Publishable", Value: values[names.PublishableKey], Type: Key},
2✔
257
                                {Label: "Secret", Value: values[names.SecretKey], Type: Key},
2✔
258
                        },
2✔
259
                },
2✔
260
                {
2✔
261
                        Name: "📦 Storage (S3)",
2✔
262
                        Items: []OutputItem{
2✔
263
                                {Label: "URL", Value: values[names.StorageS3URL], Type: Link},
2✔
264
                                {Label: "Access Key", Value: values[names.StorageS3AccessKeyId], Type: Key},
2✔
265
                                {Label: "Secret Key", Value: values[names.StorageS3SecretAccessKey], Type: Key},
2✔
266
                                {Label: "Region", Value: values[names.StorageS3Region], Type: Text},
2✔
267
                        },
2✔
268
                },
2✔
269
        }
2✔
270

2✔
271
        for _, group := range groups {
12✔
272
                if err := group.printTable(w); err != nil {
10✔
NEW
273
                        fmt.Fprintln(logger, err)
×
274
                } else {
10✔
275
                        fmt.Fprintln(w)
10✔
276
                }
10✔
277
        }
278
}
279

280
type OutputType string
281

282
const (
283
        Text OutputType = "text"
284
        Link OutputType = "link"
285
        Key  OutputType = "key"
286
)
287

288
type OutputItem struct {
289
        Label string
290
        Value string
291
        Type  OutputType
292
}
293

294
type OutputGroup struct {
295
        Name  string
296
        Items []OutputItem
297
}
298

299
func (g *OutputGroup) printTable(w io.Writer) error {
10✔
300
        table := tablewriter.NewTable(w,
10✔
301
                // Rounded corners
10✔
302
                tablewriter.WithSymbols(tw.NewSymbols(tw.StyleRounded)),
10✔
303

10✔
304
                // Table content formatting
10✔
305
                tablewriter.WithConfig(tablewriter.Config{
10✔
306
                        Header: tw.CellConfig{
10✔
307
                                Formatting: tw.CellFormatting{
10✔
308
                                        AutoFormat: tw.Off,
10✔
309
                                        MergeMode:  tw.MergeHorizontal,
10✔
310
                                },
10✔
311
                                Alignment: tw.CellAlignment{
10✔
312
                                        Global: tw.AlignLeft,
10✔
313
                                },
10✔
314
                                Filter: tw.CellFilter{
10✔
315
                                        Global: func(s []string) []string {
20✔
316
                                                for i := range s {
30✔
317
                                                        s[i] = utils.Bold(s[i])
20✔
318
                                                }
20✔
319
                                                return s
10✔
320
                                        },
321
                                },
322
                        },
323
                        Row: tw.CellConfig{
324
                                Alignment: tw.CellAlignment{
325
                                        Global: tw.AlignLeft,
326
                                },
327
                                ColMaxWidths: tw.CellWidth{
328
                                        PerColumn: map[int]int{0: 16},
329
                                },
330
                                Filter: tw.CellFilter{
331
                                        PerColumn: []func(string) string{
332
                                                func(s string) string {
2✔
333
                                                        return utils.Green(s)
2✔
334
                                                },
2✔
335
                                        },
336
                                },
337
                        },
338
                        Behavior: tw.Behavior{
339
                                Compact: tw.Compact{
340
                                        Merge: tw.On,
341
                                },
342
                        },
343
                }),
344

345
                // Set title as header (merged across all columns)
346
                tablewriter.WithHeader([]string{g.Name, g.Name}),
347
        )
348

349
        // Add data rows with values colored based on type
350
        shouldRender := false
10✔
351
        for _, row := range g.Items {
38✔
352
                if row.Value == "" {
54✔
353
                        continue
26✔
354
                }
355
                value := row.Value
2✔
356
                switch row.Type {
2✔
357
                case Link:
2✔
358
                        value = utils.Aqua(row.Value)
2✔
NEW
359
                case Key:
×
NEW
360
                        value = utils.Yellow(row.Value)
×
361
                }
362
                if err := table.Append(row.Label, value); err != nil {
2✔
NEW
363
                        return errors.Errorf("failed to append row: %w", err)
×
UNCOV
364
                }
×
365
                shouldRender = true
2✔
366
        }
367

368
        // Ensure at least one item in the group is non-empty
369
        if shouldRender {
12✔
370
                if err := table.Render(); err != nil {
2✔
NEW
371
                        return errors.Errorf("failed to render table: %w", err)
×
UNCOV
372
                }
×
373
        }
374

375
        return nil
10✔
376
}
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