diff --git a/cmd/clone.go b/cmd/clone.go new file mode 100644 index 000000000..19116c44e --- /dev/null +++ b/cmd/clone.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + "os" + "os/signal" + + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/supabase/cli/internal/clone" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" +) + +var ( + cloneCmd = &cobra.Command{ + GroupID: groupQuickStart, + Use: "clone", + Short: "Clones a Supabase project to your local environment", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt) + if !viper.IsSet("WORKDIR") { + title := fmt.Sprintf("Enter a directory to clone your project to (or leave blank to use %s): ", utils.Bold(utils.CurrentDirAbs)) + if workdir, err := utils.NewConsole().PromptText(ctx, title); err != nil { + return err + } else { + viper.Set("WORKDIR", workdir) + } + } + return clone.Run(ctx, afero.NewOsFs()) + }, + } +) + +func init() { + cloneFlags := cloneCmd.Flags() + cloneFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") + rootCmd.AddCommand(cloneCmd) +} diff --git a/internal/clone/clone.go b/internal/clone/clone.go new file mode 100644 index 000000000..a9682ca07 --- /dev/null +++ b/internal/clone/clone.go @@ -0,0 +1,106 @@ +package clone + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/cenkalti/backoff/v4" + "github.com/go-errors/errors" + "github.com/jackc/pgconn" + "github.com/spf13/afero" + "github.com/spf13/viper" + "github.com/supabase/cli/internal/db/pull" + "github.com/supabase/cli/internal/link" + "github.com/supabase/cli/internal/login" + "github.com/supabase/cli/internal/projects/apiKeys" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" + "github.com/supabase/cli/internal/utils/tenant" + "github.com/supabase/cli/pkg/api" + "golang.org/x/term" +) + +func Run(ctx context.Context, fsys afero.Fs) error { + if err := changeWorkDir(ctx, fsys); err != nil { + return err + } + // 1. Login + if err := checkLogin(ctx, fsys); err != nil { + return err + } + // 2. Link project + if err := linkProject(ctx, fsys); err != nil { + return err + } + // 3. Pull migrations + dbConfig := flags.NewDbConfigWithPassword(ctx, flags.ProjectRef) + if err := dumpRemoteSchema(ctx, dbConfig, fsys); err != nil { + return err + } + return nil +} + +func changeWorkDir(ctx context.Context, fsys afero.Fs) error { + workdir := viper.GetString("WORKDIR") + if !filepath.IsAbs(workdir) { + workdir = filepath.Join(utils.CurrentDirAbs, workdir) + } + if err := utils.MkdirIfNotExistFS(fsys, workdir); err != nil { + return err + } + if empty, err := afero.IsEmpty(fsys, workdir); err != nil { + return errors.Errorf("failed to read workdir: %w", err) + } else if !empty { + title := fmt.Sprintf("Do you want to overwrite existing files in %s directory?", utils.Bold(workdir)) + if shouldOverwrite, err := utils.NewConsole().PromptYesNo(ctx, title, true); err != nil { + return err + } else if !shouldOverwrite { + return errors.New(context.Canceled) + } + } + return utils.ChangeWorkDir(fsys) +} + +func checkLogin(ctx context.Context, fsys afero.Fs) error { + if _, err := utils.LoadAccessTokenFS(fsys); !errors.Is(err, utils.ErrMissingToken) { + return err + } + params := login.RunParams{ + OpenBrowser: term.IsTerminal(int(os.Stdin.Fd())), + Fsys: fsys, + } + return login.Run(ctx, os.Stdout, params) +} + +func linkProject(ctx context.Context, fsys afero.Fs) error { + // Use an empty fs to skip loading from file + if err := flags.ParseProjectRef(ctx, afero.NewMemMapFs()); err != nil { + return err + } + policy := utils.NewBackoffPolicy(ctx) + keys, err := backoff.RetryNotifyWithData(func() ([]api.ApiKeyResponse, error) { + fmt.Fprintln(os.Stderr, "Linking project...") + return apiKeys.RunGetApiKeys(ctx, flags.ProjectRef) + }, policy, utils.NewErrorCallback()) + if err != nil { + return err + } + // Load default config to update docker id + if err := flags.LoadConfig(fsys); err != nil { + return err + } + link.LinkServices(ctx, flags.ProjectRef, tenant.NewApiKey(keys).ServiceRole, fsys) + return utils.WriteFile(utils.ProjectRefPath, []byte(flags.ProjectRef), fsys) +} + +func dumpRemoteSchema(ctx context.Context, config pgconn.Config, fsys afero.Fs) error { + schemaPath := filepath.Join(utils.SchemasDir, "remote.sql") + utils.Config.Db.Migrations.SchemaPaths = append(utils.Config.Db.Migrations.SchemaPaths, filepath.ToSlash(schemaPath)) + if err := pull.CloneRemoteSchema(ctx, schemaPath, config, fsys); err != nil { + return err + } + fmt.Fprintln(os.Stderr, "Schema written to "+utils.Bold(schemaPath)) + return nil +} diff --git a/internal/db/diff/diff.go b/internal/db/diff/diff.go index af19faa5a..a15cd57dd 100644 --- a/internal/db/diff/diff.go +++ b/internal/db/diff/diff.go @@ -133,7 +133,21 @@ func MigrateShadowDatabase(ctx context.Context, container string, fsys afero.Fs, if _, err := conn.Exec(ctx, CREATE_TEMPLATE); err != nil { return errors.Errorf("failed to create template database: %w", err) } - return migration.ApplyMigrations(ctx, migrations, conn, afero.NewIOFS(fsys)) + // Migrations take precedence over declarative schemas + if len(migrations) > 0 { + return migration.ApplyMigrations(ctx, migrations, conn, afero.NewIOFS(fsys)) + } + declared, err := loadDeclaredSchemas(fsys) + if err != nil || len(declared) == 0 { + return err + } + fmt.Fprintln(os.Stderr, "Creating local database from declarative schemas:") + msg := make([]string, len(declared)) + for i, m := range declared { + msg[i] = fmt.Sprintf(" • %s", utils.Bold(m)) + } + fmt.Fprintln(os.Stderr, strings.Join(msg, "\n")) + return migration.SeedGlobals(ctx, declared, conn, afero.NewIOFS(fsys)) } func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w io.Writer, fsys afero.Fs, differ DiffFunc, options ...func(*pgx.ConnConfig)) (string, error) { diff --git a/internal/db/pull/pull.go b/internal/db/pull/pull.go index feffe2ce2..fd7bd5575 100644 --- a/internal/db/pull/pull.go +++ b/internal/db/pull/pull.go @@ -58,15 +58,7 @@ func run(ctx context.Context, schema []string, path string, conn *pgx.Conn, fsys config := conn.Config().Config // 1. Assert `supabase/migrations` and `schema_migrations` are in sync. if err := assertRemoteInSync(ctx, conn, fsys); errors.Is(err, errMissing) { - // Ignore schemas flag when working on the initial pull - if err = dumpRemoteSchema(ctx, path, config, fsys); err != nil { - return err - } - // Pull changes in managed schemas automatically - if err = diffRemoteSchema(ctx, managedSchemas, path, config, fsys); errors.Is(err, errInSync) { - err = nil - } - return err + return CloneRemoteSchema(ctx, path, config, fsys) } else if err != nil { return err } @@ -82,6 +74,18 @@ func run(ctx context.Context, schema []string, path string, conn *pgx.Conn, fsys return diffUserSchemas(ctx, schema, path, config, fsys) } +func CloneRemoteSchema(ctx context.Context, path string, config pgconn.Config, fsys afero.Fs) error { + // Ignore schemas flag when working on the initial pull + if err := dumpRemoteSchema(ctx, path, config, fsys); err != nil { + return err + } + // Pull changes in managed schemas automatically + if err := diffRemoteSchema(ctx, managedSchemas, path, config, fsys); err != nil && !errors.Is(err, errInSync) { + return err + } + return nil +} + func dumpRemoteSchema(ctx context.Context, path string, config pgconn.Config, fsys afero.Fs) error { // Special case if this is the first migration fmt.Fprintln(os.Stderr, "Dumping schema from remote database...") diff --git a/pkg/migration/scripts/dump_schema.sh b/pkg/migration/scripts/dump_schema.sh index 7f61c5866..71c3f0cba 100755 --- a/pkg/migration/scripts/dump_schema.sh +++ b/pkg/migration/scripts/dump_schema.sh @@ -48,6 +48,7 @@ pg_dump \ | sed -E "s/^REVOKE (.+) ON (.+) \"(${EXCLUDED_SCHEMAS:-})\"/-- &/" \ | sed -E 's/^(CREATE EXTENSION IF NOT EXISTS "pg_tle").+/\1;/' \ | sed -E 's/^(CREATE EXTENSION IF NOT EXISTS "pgsodium").+/\1;/' \ +| sed -E 's/^(CREATE EXTENSION IF NOT EXISTS "pgmq").+/\1;/' \ | sed -E 's/^COMMENT ON EXTENSION (.+)/-- &/' \ | sed -E 's/^CREATE POLICY "cron_job_/-- &/' \ | sed -E 's/^ALTER TABLE "cron"/-- &/' \