github.com/supabase/cli@v1.168.1/internal/bootstrap/bootstrap.go (about) 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 "time" 13 14 "github.com/cenkalti/backoff/v4" 15 "github.com/go-errors/errors" 16 "github.com/google/go-github/v62/github" 17 "github.com/jackc/pgconn" 18 "github.com/jackc/pgx/v4" 19 "github.com/joho/godotenv" 20 "github.com/spf13/afero" 21 "github.com/spf13/viper" 22 "github.com/supabase/cli/internal/db/push" 23 initBlank "github.com/supabase/cli/internal/init" 24 "github.com/supabase/cli/internal/link" 25 "github.com/supabase/cli/internal/login" 26 "github.com/supabase/cli/internal/projects/apiKeys" 27 "github.com/supabase/cli/internal/projects/create" 28 "github.com/supabase/cli/internal/utils" 29 "github.com/supabase/cli/internal/utils/flags" 30 "github.com/supabase/cli/internal/utils/tenant" 31 "github.com/supabase/cli/pkg/api" 32 "github.com/supabase/cli/pkg/fetcher" 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 !utils.NewConsole().PromptYesNo(title, true) { 49 return context.Canceled 50 } 51 } 52 if err := utils.ChangeWorkDir(fsys); err != nil { 53 return err 54 } 55 // 0. Download starter template 56 if len(starter.Url) > 0 { 57 client := utils.GetGtihubClient(ctx) 58 if err := downloadSample(ctx, client, starter.Url, fsys); err != nil { 59 return err 60 } 61 } else if err := initBlank.Run(fsys, nil, nil, utils.InitParams{Overwrite: true}); err != nil { 62 return err 63 } 64 // 1. Login 65 _, err := utils.LoadAccessTokenFS(fsys) 66 if errors.Is(err, utils.ErrMissingToken) { 67 if err := login.Run(ctx, os.Stdout, login.RunParams{ 68 OpenBrowser: term.IsTerminal(int(os.Stdin.Fd())), 69 Fsys: fsys, 70 }); err != nil { 71 return err 72 } 73 } else if err != nil { 74 return err 75 } 76 // 2. Create project 77 params := api.V1CreateProjectBody{ 78 Name: filepath.Base(workdir), 79 TemplateUrl: &starter.Url, 80 } 81 if err := create.Run(ctx, params, fsys); err != nil { 82 return err 83 } 84 // 3. Get api keys 85 var keys []api.ApiKeyResponse 86 policy := newBackoffPolicy(ctx) 87 if err := backoff.RetryNotify(func() error { 88 fmt.Fprintln(os.Stderr, "Linking project...") 89 keys, err = apiKeys.RunGetApiKeys(ctx, flags.ProjectRef) 90 return err 91 }, policy, newErrorCallback()); err != nil { 92 return err 93 } 94 // 4. Link project 95 if err := utils.LoadConfigFS(fsys); err != nil { 96 return err 97 } 98 link.LinkServices(ctx, flags.ProjectRef, tenant.NewApiKey(keys).Anon, fsys) 99 if err := utils.WriteFile(utils.ProjectRefPath, []byte(flags.ProjectRef), fsys); err != nil { 100 return err 101 } 102 // 5. Wait for project healthy 103 policy.Reset() 104 if err := backoff.RetryNotify(func() error { 105 fmt.Fprintln(os.Stderr, "Checking project health...") 106 return checkProjectHealth(ctx) 107 }, policy, newErrorCallback()); err != nil { 108 return err 109 } 110 // 6. Push migrations 111 config := flags.NewDbConfigWithPassword(flags.ProjectRef) 112 if err := writeDotEnv(keys, config, fsys); err != nil { 113 fmt.Fprintln(os.Stderr, "Failed to create .env file:", err) 114 } 115 policy.Reset() 116 if err := backoff.RetryNotify(func() error { 117 return push.Run(ctx, false, false, true, true, config, fsys) 118 }, policy, newErrorCallback()); err != nil { 119 return err 120 } 121 // 7. TODO: deploy functions 122 utils.CmdSuggestion = suggestAppStart(utils.CurrentDirAbs, starter.Start) 123 return nil 124 } 125 126 func suggestAppStart(cwd, command string) string { 127 logger := utils.GetDebugLogger() 128 workdir, err := os.Getwd() 129 if err != nil { 130 fmt.Fprintln(logger, err) 131 } 132 workdir, err = filepath.Rel(cwd, workdir) 133 if err != nil { 134 fmt.Fprintln(logger, err) 135 } 136 var cmd []string 137 if len(workdir) > 0 && workdir != "." { 138 cmd = append(cmd, "cd "+workdir) 139 } 140 if len(command) > 0 { 141 cmd = append(cmd, command) 142 } 143 suggestion := "To start your app:" 144 for _, c := range cmd { 145 suggestion += fmt.Sprintf("\n %s", utils.Aqua(c)) 146 } 147 return suggestion 148 } 149 150 func checkProjectHealth(ctx context.Context) error { 151 params := api.CheckServiceHealthParams{ 152 Services: []api.CheckServiceHealthParamsServices{ 153 api.CheckServiceHealthParamsServicesDb, 154 }, 155 } 156 resp, err := utils.GetSupabase().CheckServiceHealthWithResponse(ctx, flags.ProjectRef, ¶ms) 157 if err != nil { 158 return err 159 } 160 if resp.JSON200 == nil { 161 return errors.Errorf("Error status %d: %s", resp.StatusCode(), resp.Body) 162 } 163 for _, service := range *resp.JSON200 { 164 if !service.Healthy { 165 return errors.Errorf("Service not healthy: %s (%s)", service.Name, service.Status) 166 } 167 } 168 return nil 169 } 170 171 const maxRetries = 8 172 173 func newBackoffPolicy(ctx context.Context) backoff.BackOffContext { 174 b := backoff.ExponentialBackOff{ 175 InitialInterval: 3 * time.Second, 176 RandomizationFactor: backoff.DefaultRandomizationFactor, 177 Multiplier: backoff.DefaultMultiplier, 178 MaxInterval: backoff.DefaultMaxInterval, 179 MaxElapsedTime: backoff.DefaultMaxElapsedTime, 180 Stop: backoff.Stop, 181 Clock: backoff.SystemClock, 182 } 183 b.Reset() 184 return backoff.WithContext(backoff.WithMaxRetries(&b, maxRetries), ctx) 185 } 186 187 func newErrorCallback() backoff.Notify { 188 failureCount := 0 189 logger := utils.GetDebugLogger() 190 return func(err error, d time.Duration) { 191 failureCount += 1 192 fmt.Fprintln(logger, err) 193 fmt.Fprintf(os.Stderr, "Retry (%d/%d): ", failureCount, maxRetries) 194 } 195 } 196 197 const ( 198 SUPABASE_SERVICE_ROLE_KEY = "SUPABASE_SERVICE_ROLE_KEY" 199 SUPABASE_ANON_KEY = "SUPABASE_ANON_KEY" 200 SUPABASE_URL = "SUPABASE_URL" 201 POSTGRES_URL = "POSTGRES_URL" 202 // Derived keys 203 POSTGRES_PRISMA_URL = "POSTGRES_PRISMA_URL" 204 POSTGRES_URL_NON_POOLING = "POSTGRES_URL_NON_POOLING" 205 POSTGRES_USER = "POSTGRES_USER" 206 POSTGRES_HOST = "POSTGRES_HOST" 207 POSTGRES_PASSWORD = "POSTGRES_PASSWORD" //nolint:gosec 208 POSTGRES_DATABASE = "POSTGRES_DATABASE" 209 NEXT_PUBLIC_SUPABASE_ANON_KEY = "NEXT_PUBLIC_SUPABASE_ANON_KEY" 210 NEXT_PUBLIC_SUPABASE_URL = "NEXT_PUBLIC_SUPABASE_URL" 211 EXPO_PUBLIC_SUPABASE_ANON_KEY = "EXPO_PUBLIC_SUPABASE_ANON_KEY" 212 EXPO_PUBLIC_SUPABASE_URL = "EXPO_PUBLIC_SUPABASE_URL" 213 ) 214 215 func writeDotEnv(keys []api.ApiKeyResponse, config pgconn.Config, fsys afero.Fs) error { 216 // Initialise default envs 217 transactionMode := *config.Copy() 218 transactionMode.Port = 6543 219 initial := map[string]string{ 220 SUPABASE_URL: "https://" + utils.GetSupabaseHost(flags.ProjectRef), 221 POSTGRES_URL: utils.ToPostgresURL(transactionMode), 222 } 223 for _, entry := range keys { 224 name := strings.ToUpper(entry.Name) 225 key := fmt.Sprintf("SUPABASE_%s_KEY", name) 226 initial[key] = entry.ApiKey 227 } 228 // Populate from .env.example if exists 229 envs, err := parseExampleEnv(fsys) 230 if err != nil { 231 return err 232 } 233 for k, v := range envs { 234 switch k { 235 case SUPABASE_SERVICE_ROLE_KEY: 236 case SUPABASE_ANON_KEY: 237 case SUPABASE_URL: 238 case POSTGRES_URL: 239 // Derived keys 240 case POSTGRES_PRISMA_URL: 241 initial[k] = initial[POSTGRES_URL] 242 case POSTGRES_URL_NON_POOLING: 243 initial[k] = utils.ToPostgresURL(config) 244 case POSTGRES_USER: 245 initial[k] = config.User 246 case POSTGRES_HOST: 247 initial[k] = config.Host 248 case POSTGRES_PASSWORD: 249 initial[k] = config.Password 250 case POSTGRES_DATABASE: 251 initial[k] = config.Database 252 case NEXT_PUBLIC_SUPABASE_ANON_KEY: 253 fallthrough 254 case EXPO_PUBLIC_SUPABASE_ANON_KEY: 255 initial[k] = initial[SUPABASE_ANON_KEY] 256 case NEXT_PUBLIC_SUPABASE_URL: 257 fallthrough 258 case EXPO_PUBLIC_SUPABASE_URL: 259 initial[k] = initial[SUPABASE_URL] 260 default: 261 initial[k] = v 262 } 263 } 264 // Write to .env file 265 out, err := godotenv.Marshal(initial) 266 if err != nil { 267 return errors.Errorf("failed to marshal env map: %w", err) 268 } 269 return utils.WriteFile(".env", []byte(out), fsys) 270 } 271 272 func parseExampleEnv(fsys afero.Fs) (map[string]string, error) { 273 path := ".env.example" 274 f, err := fsys.Open(path) 275 if errors.Is(err, os.ErrNotExist) { 276 return nil, nil 277 } else if err != nil { 278 return nil, errors.Errorf("failed to open %s: %w", path, err) 279 } 280 defer f.Close() 281 envs, err := godotenv.Parse(f) 282 if err != nil { 283 return nil, errors.Errorf("failed to parse %s: %w", path, err) 284 } 285 return envs, nil 286 } 287 288 type samplesRepo struct { 289 Samples []StarterTemplate `json:"samples"` 290 } 291 292 type StarterTemplate struct { 293 Name string `json:"name"` 294 Description string `json:"description"` 295 Url string `json:"url"` 296 Start string `json:"start"` 297 } 298 299 func ListSamples(ctx context.Context, client *github.Client) ([]StarterTemplate, error) { 300 owner := "supabase-community" 301 repo := "supabase-samples" 302 path := "samples.json" 303 ref := "main" 304 opts := github.RepositoryContentGetOptions{Ref: ref} 305 file, _, _, err := client.Repositories.GetContents(ctx, owner, repo, path, &opts) 306 if err != nil { 307 return nil, errors.Errorf("failed to list samples: %w", err) 308 } 309 content, err := file.GetContent() 310 if err != nil { 311 return nil, errors.Errorf("failed to decode samples: %w", err) 312 } 313 var data samplesRepo 314 if err := json.Unmarshal([]byte(content), &data); err != nil { 315 return nil, errors.Errorf("failed to unmarshal samples: %w", err) 316 } 317 return data.Samples, nil 318 } 319 320 func downloadSample(ctx context.Context, client *github.Client, templateUrl string, fsys afero.Fs) error { 321 fmt.Println("Downloading:", templateUrl) 322 // https://github.com/supabase/supabase/tree/master/examples/user-management/nextjs-user-management 323 parsed, err := url.Parse(templateUrl) 324 if err != nil { 325 return errors.Errorf("failed to parse template url: %w", err) 326 } 327 parts := strings.Split(parsed.Path, "/") 328 owner := parts[1] 329 repo := parts[2] 330 ref := parts[4] 331 root := strings.Join(parts[5:], "/") 332 opts := github.RepositoryContentGetOptions{Ref: ref} 333 queue := make([]string, 0) 334 queue = append(queue, root) 335 download := NewDownloader(5, fsys) 336 for len(queue) > 0 { 337 contentPath := queue[0] 338 queue = queue[1:] 339 _, directory, _, err := client.Repositories.GetContents(ctx, owner, repo, contentPath, &opts) 340 if err != nil { 341 return errors.Errorf("failed to download template: %w", err) 342 } 343 for _, file := range directory { 344 switch file.GetType() { 345 case "file": 346 path := strings.TrimPrefix(file.GetPath(), root) 347 hostPath := filepath.Join(".", filepath.FromSlash(path)) 348 if err := download.Start(ctx, hostPath, file.GetDownloadURL()); err != nil { 349 return err 350 } 351 case "dir": 352 queue = append(queue, file.GetPath()) 353 default: 354 fmt.Fprintf(os.Stderr, "Ignoring %s: %s\n", file.GetType(), file.GetPath()) 355 } 356 } 357 } 358 return download.Wait() 359 } 360 361 type Downloader struct { 362 api *fetcher.Fetcher 363 queue *utils.JobQueue 364 fsys afero.Fs 365 } 366 367 func NewDownloader(concurrency uint, fsys afero.Fs) *Downloader { 368 return &Downloader{ 369 api: fetcher.NewFetcher(""), 370 queue: utils.NewJobQueue(concurrency), 371 fsys: fsys, 372 } 373 } 374 375 func (d *Downloader) Start(ctx context.Context, localPath, remotePath string) error { 376 job := func() error { 377 resp, err := d.api.Send(ctx, http.MethodGet, remotePath, nil) 378 if err != nil { 379 return err 380 } 381 defer resp.Body.Close() 382 if err := afero.WriteReader(d.fsys, localPath, resp.Body); err != nil { 383 return errors.Errorf("failed to write file: %w", err) 384 } 385 return nil 386 } 387 return d.queue.Put(job) 388 } 389 390 func (d *Downloader) Wait() error { 391 return d.queue.Collect() 392 }