Skip to content

Commit c6e059f

Browse files
committed
feat: add command to clone project to local
1 parent 677e5c2 commit c6e059f

File tree

3 files changed

+177
-9
lines changed

3 files changed

+177
-9
lines changed

cmd/clone.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/signal"
7+
8+
"github.com/spf13/afero"
9+
"github.com/spf13/cobra"
10+
"github.com/spf13/viper"
11+
"github.com/supabase/cli/internal/clone"
12+
"github.com/supabase/cli/internal/utils"
13+
"github.com/supabase/cli/internal/utils/flags"
14+
)
15+
16+
var (
17+
cloneCmd = &cobra.Command{
18+
GroupID: groupQuickStart,
19+
Use: "clone",
20+
Short: "Clones a Supabase project to your local environment",
21+
Args: cobra.NoArgs,
22+
RunE: func(cmd *cobra.Command, args []string) error {
23+
ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
24+
if !viper.IsSet("WORKDIR") {
25+
title := fmt.Sprintf("Enter a directory to clone your project to (or leave blank to use %s): ", utils.Bold(utils.CurrentDirAbs))
26+
if workdir, err := utils.NewConsole().PromptText(ctx, title); err != nil {
27+
return err
28+
} else {
29+
viper.Set("WORKDIR", workdir)
30+
}
31+
}
32+
return clone.Run(ctx, afero.NewOsFs())
33+
},
34+
}
35+
)
36+
37+
func init() {
38+
cloneFlags := cloneCmd.Flags()
39+
cloneFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
40+
rootCmd.AddCommand(cloneCmd)
41+
}

internal/clone/clone.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package clone
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/cenkalti/backoff/v4"
10+
"github.com/go-errors/errors"
11+
"github.com/jackc/pgconn"
12+
"github.com/jackc/pgx/v4"
13+
"github.com/spf13/afero"
14+
"github.com/spf13/viper"
15+
"github.com/supabase/cli/internal/db/pull"
16+
"github.com/supabase/cli/internal/link"
17+
"github.com/supabase/cli/internal/login"
18+
"github.com/supabase/cli/internal/migration/new"
19+
"github.com/supabase/cli/internal/migration/repair"
20+
"github.com/supabase/cli/internal/projects/apiKeys"
21+
"github.com/supabase/cli/internal/utils"
22+
"github.com/supabase/cli/internal/utils/flags"
23+
"github.com/supabase/cli/internal/utils/tenant"
24+
"github.com/supabase/cli/pkg/api"
25+
"golang.org/x/term"
26+
)
27+
28+
func Run(ctx context.Context, fsys afero.Fs) error {
29+
if err := changeWorkDir(ctx, fsys); err != nil {
30+
return err
31+
}
32+
// 1. Login
33+
if err := checkLogin(ctx, fsys); err != nil {
34+
return err
35+
}
36+
// 2. Link project
37+
if err := linkProject(ctx, fsys); err != nil {
38+
return err
39+
}
40+
// 3. Pull migrations
41+
dbConfig := flags.NewDbConfigWithPassword(ctx, flags.ProjectRef)
42+
if err := dumpRemoteSchema(ctx, dbConfig, fsys); err != nil {
43+
return err
44+
}
45+
return nil
46+
}
47+
48+
func changeWorkDir(ctx context.Context, fsys afero.Fs) error {
49+
workdir := viper.GetString("WORKDIR")
50+
if !filepath.IsAbs(workdir) {
51+
workdir = filepath.Join(utils.CurrentDirAbs, workdir)
52+
}
53+
if err := utils.MkdirIfNotExistFS(fsys, workdir); err != nil {
54+
return err
55+
}
56+
if empty, err := afero.IsEmpty(fsys, workdir); err != nil {
57+
return errors.Errorf("failed to read workdir: %w", err)
58+
} else if !empty {
59+
title := fmt.Sprintf("Do you want to overwrite existing files in %s directory?", utils.Bold(workdir))
60+
if shouldOverwrite, err := utils.NewConsole().PromptYesNo(ctx, title, true); err != nil {
61+
return err
62+
} else if !shouldOverwrite {
63+
return errors.New(context.Canceled)
64+
}
65+
}
66+
return utils.ChangeWorkDir(fsys)
67+
}
68+
69+
func checkLogin(ctx context.Context, fsys afero.Fs) error {
70+
if _, err := utils.LoadAccessTokenFS(fsys); !errors.Is(err, utils.ErrMissingToken) {
71+
return err
72+
}
73+
params := login.RunParams{
74+
OpenBrowser: term.IsTerminal(int(os.Stdin.Fd())),
75+
Fsys: fsys,
76+
}
77+
return login.Run(ctx, os.Stdout, params)
78+
}
79+
80+
func linkProject(ctx context.Context, fsys afero.Fs) error {
81+
// Use an empty fs to skip loading from file
82+
if err := flags.ParseProjectRef(ctx, afero.NewMemMapFs()); err != nil {
83+
return err
84+
}
85+
policy := utils.NewBackoffPolicy(ctx)
86+
keys, err := backoff.RetryNotifyWithData(func() ([]api.ApiKeyResponse, error) {
87+
fmt.Fprintln(os.Stderr, "Linking project...")
88+
return apiKeys.RunGetApiKeys(ctx, flags.ProjectRef)
89+
}, policy, utils.NewErrorCallback())
90+
if err != nil {
91+
return err
92+
}
93+
// Load default config to update docker id
94+
if err := flags.LoadConfig(fsys); err != nil {
95+
return err
96+
}
97+
link.LinkServices(ctx, flags.ProjectRef, tenant.NewApiKey(keys).ServiceRole, fsys)
98+
return utils.WriteFile(utils.ProjectRefPath, []byte(flags.ProjectRef), fsys)
99+
}
100+
101+
func dumpRemoteSchema(ctx context.Context, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error {
102+
// 1. Check postgres connection
103+
conn, err := utils.ConnectByConfig(ctx, config, options...)
104+
if err != nil {
105+
return err
106+
}
107+
defer conn.Close(context.Background())
108+
// 2. Pull schema
109+
timestamp := utils.GetCurrentTimestamp()
110+
path := new.GetMigrationPath(timestamp, "remote_schema")
111+
// Ignore schemas flag when working on the initial pull
112+
if err = pull.CloneRemoteSchema(ctx, path, config, fsys); err != nil {
113+
return err
114+
}
115+
// 3. Insert a row to `schema_migrations`
116+
fmt.Fprintln(os.Stderr, "Schema written to "+utils.Bold(path))
117+
if shouldUpdate, err := utils.NewConsole().PromptYesNo(ctx, "Update remote migration history table?", true); err != nil {
118+
return err
119+
} else if shouldUpdate {
120+
return repair.UpdateMigrationTable(ctx, conn, []string{timestamp}, repair.Applied, true, fsys)
121+
}
122+
return nil
123+
}

internal/db/pull/pull.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,7 @@ func run(ctx context.Context, schema []string, path string, conn *pgx.Conn, fsys
5858
config := conn.Config().Config
5959
// 1. Assert `supabase/migrations` and `schema_migrations` are in sync.
6060
if err := assertRemoteInSync(ctx, conn, fsys); errors.Is(err, errMissing) {
61-
// Ignore schemas flag when working on the initial pull
62-
if err = dumpRemoteSchema(ctx, path, config, fsys); err != nil {
63-
return err
64-
}
65-
// Pull changes in managed schemas automatically
66-
if err = diffRemoteSchema(ctx, managedSchemas, path, config, fsys); errors.Is(err, errInSync) {
67-
err = nil
68-
}
69-
return err
61+
return CloneRemoteSchema(ctx, path, config, fsys)
7062
} else if err != nil {
7163
return err
7264
}
@@ -82,6 +74,18 @@ func run(ctx context.Context, schema []string, path string, conn *pgx.Conn, fsys
8274
return diffUserSchemas(ctx, schema, path, config, fsys)
8375
}
8476

77+
func CloneRemoteSchema(ctx context.Context, path string, config pgconn.Config, fsys afero.Fs) error {
78+
// Ignore schemas flag when working on the initial pull
79+
if err := dumpRemoteSchema(ctx, path, config, fsys); err != nil {
80+
return err
81+
}
82+
// Pull changes in managed schemas automatically
83+
if err := diffRemoteSchema(ctx, managedSchemas, path, config, fsys); err != nil && !errors.Is(err, errInSync) {
84+
return err
85+
}
86+
return nil
87+
}
88+
8589
func dumpRemoteSchema(ctx context.Context, path string, config pgconn.Config, fsys afero.Fs) error {
8690
// Special case if this is the first migration
8791
fmt.Fprintln(os.Stderr, "Dumping schema from remote database...")

0 commit comments

Comments
 (0)