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, &params)
   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  }