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

supabase / cli / 19699968033

26 Nov 2025 10:07AM UTC coverage: 54.995% (-0.4%) from 55.403%
19699968033

Pull #4368

github

web-flow
Merge b6a3e01eb into 6558d59e6
Pull Request #4368: feat: add deploy command to push all changes to linked project

45 of 181 new or added lines in 10 files covered. (24.86%)

15 existing lines in 2 files now uncovered.

6705 of 12192 relevant lines covered (55.0%)

6.2 hits per line

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

28.63
/internal/bootstrap/bootstrap.go
1
package bootstrap
2

3
import (
4
        "context"
5
        "encoding/json"
6
        "fmt"
7
        "net/http"
8
        "net/url"
9
        "os"
10
        "path/filepath"
11
        "strings"
12

13
        "github.com/cenkalti/backoff/v4"
14
        "github.com/go-errors/errors"
15
        "github.com/google/go-github/v62/github"
16
        "github.com/jackc/pgconn"
17
        "github.com/jackc/pgx/v4"
18
        "github.com/joho/godotenv"
19
        "github.com/spf13/afero"
20
        "github.com/spf13/viper"
21
        "github.com/supabase/cli/internal/db/push"
22
        initBlank "github.com/supabase/cli/internal/init"
23
        "github.com/supabase/cli/internal/link"
24
        "github.com/supabase/cli/internal/login"
25
        "github.com/supabase/cli/internal/projects/apiKeys"
26
        "github.com/supabase/cli/internal/projects/create"
27
        "github.com/supabase/cli/internal/utils"
28
        "github.com/supabase/cli/internal/utils/flags"
29
        "github.com/supabase/cli/internal/utils/tenant"
30
        "github.com/supabase/cli/pkg/api"
31
        "github.com/supabase/cli/pkg/fetcher"
32
        "github.com/supabase/cli/pkg/queue"
33
        "golang.org/x/term"
34
)
35

36
func Run(ctx context.Context, starter StarterTemplate, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error {
×
37
        workdir := viper.GetString("WORKDIR")
×
38
        if !filepath.IsAbs(workdir) {
×
39
                workdir = filepath.Join(utils.CurrentDirAbs, workdir)
×
40
        }
×
41
        if err := utils.MkdirIfNotExistFS(fsys, workdir); err != nil {
×
42
                return err
×
43
        }
×
44
        if empty, err := afero.IsEmpty(fsys, workdir); err != nil {
×
45
                return errors.Errorf("failed to read workdir: %w", err)
×
46
        } else if !empty {
×
47
                title := fmt.Sprintf("Do you want to overwrite existing files in %s directory?", utils.Bold(workdir))
×
48
                if shouldOverwrite, err := utils.NewConsole().PromptYesNo(ctx, title, true); err != nil {
×
49
                        return err
×
50
                } else if !shouldOverwrite {
×
51
                        return errors.New(context.Canceled)
×
52
                }
×
53
        }
54
        if err := utils.ChangeWorkDir(fsys); err != nil {
×
55
                return err
×
56
        }
×
57
        // 0. Download starter template
58
        if len(starter.Url) > 0 {
×
59
                client := utils.GetGitHubClient(ctx)
×
60
                if err := downloadSample(ctx, client, starter.Url, fsys); err != nil {
×
61
                        return err
×
62
                }
×
63
        } else if err := initBlank.Run(ctx, fsys, nil, nil, utils.InitParams{Overwrite: true}); err != nil {
×
64
                return err
×
65
        }
×
66
        // 1. Login
67
        _, err := utils.LoadAccessTokenFS(fsys)
×
68
        if errors.Is(err, utils.ErrMissingToken) {
×
69
                if err := login.Run(ctx, os.Stdout, login.RunParams{
×
70
                        OpenBrowser: term.IsTerminal(int(os.Stdin.Fd())),
×
71
                        Fsys:        fsys,
×
72
                }); err != nil {
×
73
                        return err
×
74
                }
×
75
        } else if err != nil {
×
76
                return err
×
77
        }
×
78
        // 2. Create project
79
        params := api.V1CreateProjectBody{
×
80
                Name: filepath.Base(workdir),
×
81
        }
×
82
        if len(starter.Url) > 0 {
×
83
                params.TemplateUrl = &starter.Url
×
84
        }
×
85
        if err := create.Run(ctx, params, fsys); err != nil {
×
86
                return err
×
87
        }
×
88
        // 3. Get api keys
89
        var keys []api.ApiKeyResponse
×
90
        policy := utils.NewBackoffPolicy(ctx)
×
91
        if err := backoff.RetryNotify(func() error {
×
92
                fmt.Fprintln(os.Stderr, "Linking project...")
×
93
                keys, err = apiKeys.RunGetApiKeys(ctx, flags.ProjectRef)
×
94
                return err
×
95
        }, policy, utils.NewErrorCallback()); err != nil {
×
96
                return err
×
97
        }
×
98
        // 4. Link project
99
        if err := flags.LoadConfig(fsys); err != nil {
×
100
                return err
×
101
        }
×
102
        link.LinkServices(ctx, flags.ProjectRef, tenant.NewApiKey(keys).Anon, false, fsys)
×
103
        if err := utils.WriteFile(utils.ProjectRefPath, []byte(flags.ProjectRef), fsys); err != nil {
×
104
                return err
×
105
        }
×
106
        // 5. Wait for project healthy
107
        policy.Reset()
×
108
        if err := backoff.RetryNotify(func() error {
×
109
                fmt.Fprintln(os.Stderr, "Checking project health...")
×
NEW
110
                return CheckProjectHealth(ctx, flags.ProjectRef, api.Db)
×
111
        }, policy, utils.NewErrorCallback()); err != nil {
×
112
                return err
×
113
        }
×
114
        // 6. Push migrations
115
        config, err := flags.NewDbConfigWithPassword(ctx, flags.ProjectRef)
×
116
        if err != nil {
×
117
                fmt.Fprintln(os.Stderr, err)
×
118
        }
×
119
        if err := writeDotEnv(keys, config, fsys); err != nil {
×
120
                fmt.Fprintln(os.Stderr, "Failed to create .env file:", err)
×
121
        }
×
122
        policy.Reset()
×
123
        if err := backoff.RetryNotify(func() error {
×
124
                return push.Run(ctx, false, false, true, true, config, fsys)
×
125
        }, policy, utils.NewErrorCallback()); err != nil {
×
126
                return err
×
127
        }
×
128
        // 7. TODO: deploy functions
129
        utils.CmdSuggestion = suggestAppStart(utils.CurrentDirAbs, starter.Start)
×
130
        return nil
×
131
}
132

133
func suggestAppStart(cwd, command string) string {
3✔
134
        logger := utils.GetDebugLogger()
3✔
135
        workdir, err := os.Getwd()
3✔
136
        if err != nil {
3✔
137
                fmt.Fprintln(logger, err)
×
138
        }
×
139
        workdir, err = filepath.Rel(cwd, workdir)
3✔
140
        if err != nil {
4✔
141
                fmt.Fprintln(logger, err)
1✔
142
        }
1✔
143
        var cmd []string
3✔
144
        if len(workdir) > 0 && workdir != "." {
4✔
145
                cmd = append(cmd, "cd "+workdir)
1✔
146
        }
1✔
147
        if len(command) > 0 {
6✔
148
                cmd = append(cmd, command)
3✔
149
        }
3✔
150
        suggestion := "To start your app:"
3✔
151
        for _, c := range cmd {
7✔
152
                suggestion += fmt.Sprintf("\n  %s", utils.Aqua(c))
4✔
153
        }
4✔
154
        return suggestion
3✔
155
}
156

NEW
157
func CheckProjectHealth(ctx context.Context, projectRef string, services ...api.V1GetServicesHealthParamsServices) error {
×
NEW
158
        params := api.V1GetServicesHealthParams{Services: services}
×
NEW
159
        resp, err := utils.GetSupabase().V1GetServicesHealthWithResponse(ctx, projectRef, &params)
×
160
        if err != nil {
×
NEW
161
                return errors.Errorf("failed to check health: %w", err)
×
NEW
162
        } else if resp.JSON200 == nil {
×
NEW
163
                return errors.Errorf("unexpected health check status %d: %s", resp.StatusCode(), string(resp.Body))
×
164
        }
×
NEW
165
        var allErrors []error
×
166
        for _, service := range *resp.JSON200 {
×
167
                if !service.Healthy {
×
NEW
168
                        msg := string(service.Status)
×
NEW
169
                        if service.Error != nil {
×
NEW
170
                                msg = *service.Error
×
NEW
171
                        }
×
NEW
172
                        err := errors.Errorf("%s service not healthy: %s", service.Name, msg)
×
NEW
173
                        allErrors = append(allErrors, err)
×
174
                }
175
        }
NEW
176
        return errors.Join(allErrors...)
×
177
}
178

179
const (
180
        SUPABASE_SERVICE_ROLE_KEY = "SUPABASE_SERVICE_ROLE_KEY"
181
        SUPABASE_ANON_KEY         = "SUPABASE_ANON_KEY"
182
        SUPABASE_URL              = "SUPABASE_URL"
183
        POSTGRES_URL              = "POSTGRES_URL"
184
        // Derived keys
185
        POSTGRES_PRISMA_URL           = "POSTGRES_PRISMA_URL"
186
        POSTGRES_URL_NON_POOLING      = "POSTGRES_URL_NON_POOLING"
187
        POSTGRES_USER                 = "POSTGRES_USER"
188
        POSTGRES_HOST                 = "POSTGRES_HOST"
189
        POSTGRES_PASSWORD             = "POSTGRES_PASSWORD" //nolint:gosec
190
        POSTGRES_DATABASE             = "POSTGRES_DATABASE"
191
        NEXT_PUBLIC_SUPABASE_ANON_KEY = "NEXT_PUBLIC_SUPABASE_ANON_KEY"
192
        NEXT_PUBLIC_SUPABASE_URL      = "NEXT_PUBLIC_SUPABASE_URL"
193
        EXPO_PUBLIC_SUPABASE_ANON_KEY = "EXPO_PUBLIC_SUPABASE_ANON_KEY"
194
        EXPO_PUBLIC_SUPABASE_URL      = "EXPO_PUBLIC_SUPABASE_URL"
195
)
196

197
func writeDotEnv(keys []api.ApiKeyResponse, config pgconn.Config, fsys afero.Fs) error {
4✔
198
        // Initialise default envs
4✔
199
        initial := apiKeys.ToEnv(keys)
4✔
200
        initial[SUPABASE_URL] = "https://" + utils.GetSupabaseHost(flags.ProjectRef)
4✔
201
        transactionMode := *config.Copy()
4✔
202
        transactionMode.Port = 6543
4✔
203
        initial[POSTGRES_URL] = utils.ToPostgresURL(transactionMode)
4✔
204
        // Populate from .env.example if exists
4✔
205
        envs, err := parseExampleEnv(fsys)
4✔
206
        if err != nil {
5✔
207
                return err
1✔
208
        }
1✔
209
        for k, v := range envs {
16✔
210
                switch k {
13✔
211
                case SUPABASE_SERVICE_ROLE_KEY:
1✔
212
                case SUPABASE_ANON_KEY:
1✔
213
                case SUPABASE_URL:
1✔
214
                case POSTGRES_URL:
1✔
215
                // Derived keys
216
                case POSTGRES_PRISMA_URL:
1✔
217
                        initial[k] = initial[POSTGRES_URL]
1✔
218
                case POSTGRES_URL_NON_POOLING:
1✔
219
                        initial[k] = utils.ToPostgresURL(config)
1✔
220
                case POSTGRES_USER:
1✔
221
                        initial[k] = config.User
1✔
222
                case POSTGRES_HOST:
1✔
223
                        initial[k] = config.Host
1✔
224
                case POSTGRES_PASSWORD:
1✔
225
                        initial[k] = config.Password
1✔
226
                case POSTGRES_DATABASE:
1✔
227
                        initial[k] = config.Database
1✔
228
                case NEXT_PUBLIC_SUPABASE_ANON_KEY:
1✔
229
                        fallthrough
1✔
230
                case EXPO_PUBLIC_SUPABASE_ANON_KEY:
1✔
231
                        initial[k] = initial[SUPABASE_ANON_KEY]
1✔
232
                case NEXT_PUBLIC_SUPABASE_URL:
1✔
233
                        fallthrough
1✔
234
                case EXPO_PUBLIC_SUPABASE_URL:
1✔
235
                        initial[k] = initial[SUPABASE_URL]
1✔
236
                default:
1✔
237
                        initial[k] = v
1✔
238
                }
239
        }
240
        // Write to .env file
241
        out, err := godotenv.Marshal(initial)
3✔
242
        if err != nil {
3✔
243
                return errors.Errorf("failed to marshal env map: %w", err)
×
244
        }
×
245
        return utils.WriteFile(".env", []byte(out), fsys)
3✔
246
}
247

248
func parseExampleEnv(fsys afero.Fs) (map[string]string, error) {
4✔
249
        path := ".env.example"
4✔
250
        f, err := fsys.Open(path)
4✔
251
        if errors.Is(err, os.ErrNotExist) {
6✔
252
                return nil, nil
2✔
253
        } else if err != nil {
4✔
254
                return nil, errors.Errorf("failed to open %s: %w", path, err)
×
255
        }
×
256
        defer f.Close()
2✔
257
        envs, err := godotenv.Parse(f)
2✔
258
        if err != nil {
3✔
259
                return nil, errors.Errorf("failed to parse %s: %w", path, err)
1✔
260
        }
1✔
261
        return envs, nil
1✔
262
}
263

264
type samplesRepo struct {
265
        Samples []StarterTemplate `json:"samples"`
266
}
267

268
type StarterTemplate struct {
269
        Name        string `json:"name"`
270
        Description string `json:"description"`
271
        Url         string `json:"url"`
272
        Start       string `json:"start"`
273
}
274

275
func ListSamples(ctx context.Context, client *github.Client) ([]StarterTemplate, error) {
×
276
        owner := "supabase-community"
×
277
        repo := "supabase-samples"
×
278
        path := "samples.json"
×
279
        ref := "main"
×
280
        opts := github.RepositoryContentGetOptions{Ref: ref}
×
281
        file, _, _, err := client.Repositories.GetContents(ctx, owner, repo, path, &opts)
×
282
        if err != nil {
×
283
                return nil, errors.Errorf("failed to list samples: %w", err)
×
284
        }
×
285
        content, err := file.GetContent()
×
286
        if err != nil {
×
287
                return nil, errors.Errorf("failed to decode samples: %w", err)
×
288
        }
×
289
        var data samplesRepo
×
290
        if err := json.Unmarshal([]byte(content), &data); err != nil {
×
291
                return nil, errors.Errorf("failed to unmarshal samples: %w", err)
×
292
        }
×
293
        return data.Samples, nil
×
294
}
295

296
func downloadSample(ctx context.Context, client *github.Client, templateUrl string, fsys afero.Fs) error {
×
297
        fmt.Println("Downloading:", templateUrl)
×
298
        // https://github.com/supabase/supabase/tree/master/examples/user-management/nextjs-user-management
×
299
        parsed, err := url.Parse(templateUrl)
×
300
        if err != nil {
×
301
                return errors.Errorf("failed to parse template url: %w", err)
×
302
        }
×
303
        parts := strings.Split(parsed.Path, "/")
×
304
        owner := parts[1]
×
305
        repo := parts[2]
×
306
        ref := parts[4]
×
307
        root := strings.Join(parts[5:], "/")
×
308
        opts := github.RepositoryContentGetOptions{Ref: ref}
×
309
        queue := make([]string, 0)
×
310
        queue = append(queue, root)
×
311
        download := NewDownloader(5, fsys)
×
312
        for len(queue) > 0 {
×
313
                contentPath := queue[0]
×
314
                queue = queue[1:]
×
315
                _, directory, _, err := client.Repositories.GetContents(ctx, owner, repo, contentPath, &opts)
×
316
                if err != nil {
×
317
                        return errors.Errorf("failed to download template: %w", err)
×
318
                }
×
319
                for _, file := range directory {
×
320
                        switch file.GetType() {
×
321
                        case "file":
×
322
                                path := strings.TrimPrefix(file.GetPath(), root)
×
323
                                hostPath := filepath.Join(".", filepath.FromSlash(path))
×
324
                                if err := download.Start(ctx, hostPath, file.GetDownloadURL()); err != nil {
×
325
                                        return err
×
326
                                }
×
327
                        case "dir":
×
328
                                queue = append(queue, file.GetPath())
×
329
                        default:
×
330
                                fmt.Fprintf(os.Stderr, "Ignoring %s: %s\n", file.GetType(), file.GetPath())
×
331
                        }
332
                }
333
        }
334
        return download.Wait()
×
335
}
336

337
type Downloader struct {
338
        api   *fetcher.Fetcher
339
        queue *queue.JobQueue
340
        fsys  afero.Fs
341
}
342

343
func NewDownloader(concurrency uint, fsys afero.Fs) *Downloader {
×
344
        return &Downloader{
×
345
                api:   fetcher.NewFetcher("", fetcher.WithExpectedStatus(http.StatusOK)),
×
346
                queue: queue.NewJobQueue(concurrency),
×
347
                fsys:  fsys,
×
348
        }
×
349
}
×
350

351
func (d *Downloader) Start(ctx context.Context, localPath, remotePath string) error {
×
352
        job := func() error {
×
353
                resp, err := d.api.Send(ctx, http.MethodGet, remotePath, nil)
×
354
                if err != nil {
×
355
                        return err
×
356
                }
×
357
                defer resp.Body.Close()
×
358
                if err := afero.WriteReader(d.fsys, localPath, resp.Body); err != nil {
×
359
                        return errors.Errorf("failed to write file: %w", err)
×
360
                }
×
361
                return nil
×
362
        }
363
        return d.queue.Put(job)
×
364
}
365

366
func (d *Downloader) Wait() error {
×
367
        return d.queue.Collect()
×
368
}
×
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