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

supabase / cli / 8558203996

04 Apr 2024 04:37PM UTC coverage: 57.809% (-0.04%) from 57.852%
8558203996

Pull #2120

github

hf
feat: add `template_url` param to `bootstrap`
Pull Request #2120: feat: add `template_url` param to `bootstrap`

0 of 4 new or added lines in 1 file covered. (0.0%)

5 existing lines in 1 file now uncovered.

6374 of 11026 relevant lines covered (57.81%)

663.95 hits per line

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

28.47
/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
        "sync"
13
        "time"
14

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

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

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

151
func checkProjectHealth(ctx context.Context) error {
×
152
        params := api.CheckServiceHealthParams{
×
153
                Services: []api.CheckServiceHealthParamsServices{
×
154
                        api.CheckServiceHealthParamsServicesDb,
×
155
                },
×
156
        }
×
157
        resp, err := utils.GetSupabase().CheckServiceHealthWithResponse(ctx, flags.ProjectRef, &params)
×
158
        if err != nil {
×
159
                return err
×
160
        }
×
161
        if resp.JSON200 == nil {
×
162
                return errors.Errorf("Error status %d: %s", resp.StatusCode(), resp.Body)
×
163
        }
×
164
        for _, service := range *resp.JSON200 {
×
165
                if !service.Healthy {
×
166
                        return errors.Errorf("Service not healthy: %s (%s)", service.Name, service.Status)
×
167
                }
×
168
        }
169
        return nil
×
170
}
171

172
const maxRetries = 8
173

174
func newBackoffPolicy(ctx context.Context) backoff.BackOffContext {
×
175
        b := backoff.ExponentialBackOff{
×
176
                InitialInterval:     3 * time.Second,
×
177
                RandomizationFactor: backoff.DefaultRandomizationFactor,
×
178
                Multiplier:          backoff.DefaultMultiplier,
×
179
                MaxInterval:         backoff.DefaultMaxInterval,
×
180
                MaxElapsedTime:      backoff.DefaultMaxElapsedTime,
×
181
                Stop:                backoff.Stop,
×
182
                Clock:               backoff.SystemClock,
×
183
        }
×
184
        b.Reset()
×
185
        return backoff.WithContext(backoff.WithMaxRetries(&b, maxRetries), ctx)
×
186
}
×
187

188
func newErrorCallback() backoff.Notify {
×
189
        failureCount := 0
×
190
        logger := utils.GetDebugLogger()
×
191
        return func(err error, d time.Duration) {
×
192
                failureCount += 1
×
193
                fmt.Fprintln(logger, err)
×
194
                fmt.Fprintf(os.Stderr, "Retry (%d/%d): ", failureCount, maxRetries)
×
195
        }
×
196
}
197

198
const (
199
        SUPABASE_SERVICE_ROLE_KEY = "SUPABASE_SERVICE_ROLE_KEY"
200
        SUPABASE_ANON_KEY         = "SUPABASE_ANON_KEY"
201
        SUPABASE_URL              = "SUPABASE_URL"
202
        POSTGRES_URL              = "POSTGRES_URL"
203
        // Derived keys
204
        POSTGRES_PRISMA_URL           = "POSTGRES_PRISMA_URL"
205
        POSTGRES_URL_NON_POOLING      = "POSTGRES_URL_NON_POOLING"
206
        POSTGRES_USER                 = "POSTGRES_USER"
207
        POSTGRES_HOST                 = "POSTGRES_HOST"
208
        POSTGRES_PASSWORD             = "POSTGRES_PASSWORD" //nolint:gosec
209
        POSTGRES_DATABASE             = "POSTGRES_DATABASE"
210
        NEXT_PUBLIC_SUPABASE_ANON_KEY = "NEXT_PUBLIC_SUPABASE_ANON_KEY"
211
        NEXT_PUBLIC_SUPABASE_URL      = "NEXT_PUBLIC_SUPABASE_URL"
212
)
213

214
func writeDotEnv(keys []api.ApiKeyResponse, config pgconn.Config, fsys afero.Fs) error {
215
        // Initialise default envs
216
        transactionMode := *config.Copy()
4✔
217
        transactionMode.Port = 6543
4✔
218
        initial := map[string]string{
4✔
219
                SUPABASE_URL: "https://" + utils.GetSupabaseHost(flags.ProjectRef),
4✔
220
                POSTGRES_URL: utils.ToPostgresURL(transactionMode),
4✔
221
        }
4✔
222
        for _, entry := range keys {
4✔
223
                name := strings.ToUpper(entry.Name)
4✔
224
                key := fmt.Sprintf("SUPABASE_%s_KEY", name)
8✔
225
                initial[key] = entry.ApiKey
4✔
226
        }
4✔
227
        // Populate from .env.example if exists
4✔
228
        envs, err := parseExampleEnv(fsys)
4✔
229
        if err != nil {
230
                return err
4✔
231
        }
5✔
232
        for k, v := range envs {
1✔
233
                switch k {
1✔
234
                case SUPABASE_SERVICE_ROLE_KEY:
16✔
235
                case SUPABASE_ANON_KEY:
13✔
236
                case SUPABASE_URL:
1✔
237
                case POSTGRES_URL:
1✔
238
                // Derived keys
1✔
239
                case POSTGRES_PRISMA_URL:
1✔
240
                        initial[k] = initial[POSTGRES_URL]
241
                case POSTGRES_URL_NON_POOLING:
1✔
242
                        initial[k] = utils.ToPostgresURL(config)
1✔
243
                case POSTGRES_USER:
1✔
244
                        initial[k] = config.User
1✔
245
                case POSTGRES_HOST:
1✔
246
                        initial[k] = config.Host
1✔
247
                case POSTGRES_PASSWORD:
1✔
248
                        initial[k] = config.Password
1✔
249
                case POSTGRES_DATABASE:
1✔
250
                        initial[k] = config.Database
1✔
251
                case NEXT_PUBLIC_SUPABASE_ANON_KEY:
1✔
252
                        initial[k] = initial[SUPABASE_ANON_KEY]
1✔
253
                case NEXT_PUBLIC_SUPABASE_URL:
1✔
254
                        initial[k] = initial[SUPABASE_URL]
1✔
255
                default:
1✔
256
                        initial[k] = v
1✔
257
                }
1✔
258
        }
1✔
259
        // Write to .env file
1✔
260
        out, err := godotenv.Marshal(initial)
1✔
261
        if err != nil {
1✔
262
                return errors.Errorf("failed to marshal env map: %w", err)
1✔
263
        }
264
        return utils.WriteFile(".env", []byte(out), fsys)
265
}
266

3✔
267
func parseExampleEnv(fsys afero.Fs) (map[string]string, error) {
3✔
268
        path := ".env.example"
×
269
        f, err := fsys.Open(path)
×
270
        if errors.Is(err, os.ErrNotExist) {
3✔
271
                return nil, nil
272
        } else if err != nil {
273
                return nil, errors.Errorf("failed to open %s: %w", path, err)
4✔
274
        }
4✔
275
        defer f.Close()
4✔
276
        envs, err := godotenv.Parse(f)
6✔
277
        if err != nil {
2✔
278
                return nil, errors.Errorf("failed to parse %s: %w", path, err)
4✔
279
        }
×
280
        return envs, nil
×
281
}
2✔
282

2✔
283
var (
3✔
284
        githubClient *github.Client
1✔
285
        clientOnce   sync.Once
1✔
286
)
1✔
287

288
func GetGtihubClient(ctx context.Context) *github.Client {
289
        clientOnce.Do(func() {
290
                var client *http.Client
291
                token := os.Getenv("GITHUB_TOKEN")
292
                if len(token) > 0 {
293
                        ts := oauth2.StaticTokenSource(
294
                                &oauth2.Token{AccessToken: token},
×
295
                        )
×
296
                        client = oauth2.NewClient(ctx, ts)
×
297
                }
×
298
                githubClient = github.NewClient(client)
×
299
        })
×
300
        return githubClient
×
301
}
×
302

×
303
type samplesRepo struct {
×
304
        Samples []StarterTemplate `json:"samples"`
×
305
}
306

×
307
type StarterTemplate struct {
308
        Name        string `json:"name"`
309
        Description string `json:"description"`
310
        Url         string `json:"url"`
311
}
312

313
func ListSamples(ctx context.Context, client *github.Client) ([]StarterTemplate, error) {
314
        owner := "supabase-community"
315
        repo := "supabase-samples"
316
        path := "samples.json"
317
        ref := "main"
318
        opts := github.RepositoryContentGetOptions{Ref: ref}
319
        file, _, _, err := client.Repositories.GetContents(ctx, owner, repo, path, &opts)
×
320
        if err != nil {
×
321
                return nil, errors.Errorf("failed to list samples: %w", err)
×
322
        }
×
323
        content, err := file.GetContent()
×
324
        if err != nil {
×
325
                return nil, errors.Errorf("failed to decode samples: %w", err)
×
326
        }
×
327
        var data samplesRepo
×
328
        if err := json.Unmarshal([]byte(content), &data); err != nil {
×
329
                return nil, errors.Errorf("failed to unmarshal samples: %w", err)
×
330
        }
×
331
        return data.Samples, nil
×
332
}
×
333

×
334
func downloadSample(ctx context.Context, client *github.Client, templateUrl string, fsys afero.Fs) error {
×
335
        fmt.Println("Downloading:", templateUrl)
×
336
        // https://github.com/supabase/supabase/tree/master/examples/user-management/nextjs-user-management
×
337
        parsed, err := url.Parse(templateUrl)
×
338
        if err != nil {
339
                return errors.Errorf("failed to parse template url: %w", err)
340
        }
×
341
        parts := strings.Split(parsed.Path, "/")
×
342
        owner := parts[1]
×
343
        repo := parts[2]
×
344
        ref := parts[4]
×
345
        root := strings.Join(parts[5:], "/")
×
346
        opts := github.RepositoryContentGetOptions{Ref: ref}
×
347
        queue := make([]string, 0)
×
348
        queue = append(queue, root)
×
349
        jq := utils.NewJobQueue(5)
×
350
        for len(queue) > 0 {
×
351
                contentPath := queue[0]
×
352
                queue = queue[1:]
×
353
                _, directory, _, err := client.Repositories.GetContents(ctx, owner, repo, contentPath, &opts)
×
354
                if err != nil {
×
355
                        return errors.Errorf("failed to download template: %w", err)
×
356
                }
×
357
                for _, file := range directory {
×
358
                        switch file.GetType() {
×
359
                        case "file":
×
360
                                path := strings.TrimPrefix(file.GetPath(), root)
×
361
                                hostPath := filepath.FromSlash("." + path)
×
362
                                if err := jq.Put(func() error {
×
363
                                        return utils.DownloadFile(ctx, hostPath, file.GetDownloadURL(), fsys)
×
364
                                }); err != nil {
×
365
                                        return err
×
366
                                }
×
367
                        case "dir":
×
368
                                queue = append(queue, file.GetPath())
×
369
                        default:
×
370
                                fmt.Fprintf(os.Stderr, "Ignoring %s: %s\n", file.GetType(), file.GetPath())
×
371
                        }
×
372
                }
×
373
        }
×
374
        return jq.Collect()
×
375
}
×
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