Repository: supabase/cli
Pull request #649 (2022-11-29 12:11:30)

feat: store db pass in credentials store
## What kind of change does this PR introduce?

feature

## What is the current behavior?

db password needs to be entered every time

## What is the new behavior?

- uses https://github.com/zalando/go-keyring to store db credentials
- support storing credentials for multiple projects (might add `unlink` in the future)
- credentials are saved under `Supabase CLI` namespace

## Additional context

Alternative libraries considered
- https://github.com/docker/docker-credential-helpers
These helper binaries are distributed with docker desktop installation. They talk to each platform's native C APIs. The only downside is credentials are labeled with `Docker Credentials` and will easily confuse with actual docker credentials.

- https://github.com/99designs/keyring
It uses an outdated linux dbus api. Not sure if it's actively maintained.

- implement our own
On **macOS**, we can call `/usr/bin/security` to access keychain. It supports simple CRUD commands, ie. `add|find|delete-generic-password`. Note that passwords should be base64 encoded and passed via stdin.
On **Windows**, we can use https://github.com/danieljoos/wincred library. It's a simple KV store so we need to partition the secret keys ourselves, for eg. `<namespace>:<project>:<username>`. Not as nice as macOS but also not a deal breaker.
**Linux** is the least trivial because we need to access `org.freedesktop.secrets.service` via dbus. There are many [hardcoded constants](https://github.com/ppacher/go-dbus-keyring/blob/master/service.go#L12) involved in using this protocol. Not sure if it's worth the effort to implement ourselves.

coveralls: ## Pull Request Test Coverage Report for [Build 3573939739](https://coveralls.io/builds/54655561)

* **30** of **94**   **(31.91%)**  changed or added relevant lines in **8** files are covered.
* **1** unchanged line in **1** file lost coverage.
* Overall coverage increased (+**1.1%**) to **56.052%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [cmd/link.go](https://coveralls.io/builds/54655561/source?filename=cmd%2Flink.go#L27) | 1 | 2 | 50.0%
| [cmd/projects.go](https://coveralls.io/builds/54655561/source?filename=cmd%2Fprojects.go#L150) | 0 | 1 | 0.0%
| [internal/db/push/push.go](https://coveralls.io/builds/54655561/source?filename=internal%2Fdb%2Fpush%2Fpush.go#L14) | 0 | 2 | 0.0%
| [internal/utils/credentials/store.go](https://coveralls.io/builds/54655561/source?filename=internal%2Futils%2Fcredentials%2Fstore.go#L10) | 0 | 9 | 0.0%
| [internal/link/link.go](https://coveralls.io/builds/54655561/source?filename=internal%2Flink%2Flink.go#L44) | 0 | 11 | 0.0%
| [cmd/migration.go](https://coveralls.io/builds/54655561/source?filename=cmd%2Fmigration.go#L27) | 0 | 15 | 0.0%
| [cmd/db.go](https://coveralls.io/builds/54655561/source?filename=cmd%2Fdb.go#L83) | 0 | 25 | 0.0%
<!-- | **Total:** | **30** | **94** | **31.91%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [cmd/db.go](https://coveralls.io/builds/54655561/source?filename=cmd%2Fdb.go#L88) | 1 | 39.45% |
<!-- | **Total:** | **1** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/54655561/badge)](https://coveralls.io/builds/54655561) |
| :-- | --: |
| Change from base [Build 3568126960](https://coveralls.io/builds/54629304): |  1.1% |
| Covered Lines: | 3135 |
| Relevant Lines: | 5593 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/cmd/db.go b/cmd/db.go
index f9653ee6..3398b391 100644
--- a/cmd/db.go
+++ b/cmd/db.go
@@ -80,17 +80,17 @@ var (
 		Use:   "diff",
 		Short: "Diffs the local database for schema changes",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			var password string
+			fsys := afero.NewOsFs()
 			if linked {
-				password = viper.GetString("DB_PASSWORD")
-				if password == "" {
-					password = PromptPassword(os.Stdin)
+				projectRef, err := utils.LoadProjectRef(fsys)
+				if err != nil {
+					return err
 				}
+				dbPassword = getPassword(projectRef)
 			}
-			fsys := afero.NewOsFs()
 			if useMigra {
 				ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
-				return diff.RunMigra(ctx, schema, file, password, fsys)
+				return diff.RunMigra(ctx, schema, file, dbPassword, fsys)
 			}
 			return diff.Run(file, fsys)
 		},
@@ -102,12 +102,15 @@ var (
 		Use:   "push",
 		Short: "Push new migrations to the remote database",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			password := viper.GetString("DB_PASSWORD")
-			if password == "" {
-				password = PromptPassword(os.Stdin)
+			fsys := afero.NewOsFs()
+			projectRef, err := utils.LoadProjectRef(fsys)
+			if err != nil {
+				return err
 			}
+			password := getPassword(projectRef)
+			host := utils.GetSupabaseDbHost(projectRef)
 			ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
-			return push.Run(ctx, dryRun, username, password, database, afero.NewOsFs())
+			return push.Run(ctx, dryRun, username, password, database, host, fsys)
 		},
 	}
 
@@ -122,11 +125,13 @@ var (
 		Short:      "Show changes on the remote database",
 		Long:       "Show changes on the remote database since last migration.",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			password := viper.GetString("DB_PASSWORD")
-			if password == "" {
-				password = PromptPassword(os.Stdin)
+			fsys := afero.NewOsFs()
+			projectRef, err := utils.LoadProjectRef(fsys)
+			if err != nil {
+				return err
 			}
-			return changes.Run(cmd.Context(), username, password, database, afero.NewOsFs())
+			password := getPassword(projectRef)
+			return changes.Run(cmd.Context(), username, password, database, fsys)
 		},
 	}
 
@@ -134,12 +139,14 @@ var (
 		Use:   "commit",
 		Short: "Commit remote changes as a new migration",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			password := viper.GetString("DB_PASSWORD")
-			if password == "" {
-				password = PromptPassword(os.Stdin)
+			fsys := afero.NewOsFs()
+			projectRef, err := utils.LoadProjectRef(fsys)
+			if err != nil {
+				return err
 			}
+			password := getPassword(projectRef)
 			ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
-			return commit.Run(ctx, username, password, database, afero.NewOsFs())
+			return commit.Run(ctx, username, password, database, fsys)
 		},
 	}
 
diff --git a/cmd/link.go b/cmd/link.go
index 168c6a09..770c355b 100644
--- a/cmd/link.go
+++ b/cmd/link.go
@@ -10,7 +10,6 @@ import (
 	"github.com/spf13/viper"
 	"github.com/supabase/cli/internal/link"
 	"github.com/supabase/cli/internal/utils"
-	"golang.org/x/term"
 )
 
 var (
@@ -23,14 +22,9 @@ var (
 		Use:     "link",
 		Short:   "Link to a Supabase project",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			projectRef, err := cmd.Flags().GetString("project-ref")
-			if err != nil {
-				return err
-			}
-
 			password := viper.GetString("DB_PASSWORD")
 			if password == "" {
-				password = PromptPassword(os.Stdin)
+				password = link.PromptPassword(os.Stdin)
 			}
 
 			fsys := afero.NewOsFs()
@@ -47,19 +41,9 @@ var (
 
 func init() {
 	flags := linkCmd.Flags()
-	flags.String("project-ref", "", "Project ref of the Supabase project.")
+	flags.StringVar(&projectRef, "project-ref", "", "Project ref of the Supabase project.")
 	flags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database.")
 	cobra.CheckErr(viper.BindPFlag("DB_PASSWORD", flags.Lookup("password")))
 	cobra.CheckErr(linkCmd.MarkFlagRequired("project-ref"))
 	rootCmd.AddCommand(linkCmd)
 }
-
-func PromptPassword(stdin *os.File) string {
-	fmt.Print("Enter your database password: ")
-	bytepw, err := term.ReadPassword(int(stdin.Fd()))
-	fmt.Println()
-	if err != nil {
-		return ""
-	}
-	return string(bytepw)
-}
diff --git a/cmd/migration.go b/cmd/migration.go
index a74e33b2..d9e3bdb1 100644
--- a/cmd/migration.go
+++ b/cmd/migration.go
@@ -6,8 +6,11 @@ import (
 	"github.com/spf13/afero"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
+	"github.com/supabase/cli/internal/link"
 	"github.com/supabase/cli/internal/migration/list"
 	"github.com/supabase/cli/internal/migration/new"
+	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/internal/utils/credentials"
 )
 
 var (
@@ -21,11 +24,14 @@ var (
 		Use:   "list",
 		Short: "List local and remote migrations",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			password := viper.GetString("DB_PASSWORD")
-			if password == "" {
-				password = PromptPassword(os.Stdin)
+			fsys := afero.NewOsFs()
+			projectRef, err := utils.LoadProjectRef(fsys)
+			if err != nil {
+				return err
 			}
-			return list.Run(cmd.Context(), username, password, database, afero.NewOsFs())
+			password := getPassword(projectRef)
+			host := utils.GetSupabaseDbHost(projectRef)
+			return list.Run(cmd.Context(), username, password, database, host, fsys)
 		},
 	}
 
@@ -47,3 +53,13 @@ func init() {
 	migrationCmd.AddCommand(migrationNewCmd)
 	rootCmd.AddCommand(migrationCmd)
 }
+
+func getPassword(projectRef string) string {
+	if password := viper.GetString("DB_PASSWORD"); len(password) > 0 {
+		return password
+	}
+	if password, err := credentials.Get(projectRef); err == nil {
+		return password
+	}
+	return link.PromptPassword(os.Stdin)
+}
diff --git a/cmd/projects.go b/cmd/projects.go
index dba2b7aa..81bb2c32 100644
--- a/cmd/projects.go
+++ b/cmd/projects.go
@@ -10,6 +10,7 @@ import (
 	"github.com/spf13/afero"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
+	"github.com/supabase/cli/internal/link"
 	"github.com/supabase/cli/internal/projects/create"
 	"github.com/supabase/cli/internal/projects/list"
 	"github.com/supabase/cli/internal/utils"
@@ -146,7 +147,7 @@ func PromptCreateFlags(cmd *cobra.Command) error {
 	}
 	fmt.Fprintln(os.Stderr, printKeyValue("Selected plan", plan.Value))
 	if dbPassword == "" {
-		dbPassword = PromptPassword(os.Stdin)
+		dbPassword = link.PromptPassword(os.Stdin)
 	}
 	return nil
 }
diff --git a/go.mod b/go.mod
index 21ed979e..a2f07c68 100644
--- a/go.mod
+++ b/go.mod
@@ -8,10 +8,13 @@ require (
 	github.com/charmbracelet/bubbletea v0.22.1
 	github.com/charmbracelet/glamour v0.6.0
 	github.com/charmbracelet/lipgloss v0.6.0
+	github.com/danieljoos/wincred v1.1.2
 	github.com/deepmap/oapi-codegen v1.12.3
 	github.com/docker/cli v20.10.21+incompatible
 	github.com/docker/docker v20.10.21+incompatible
+	github.com/docker/docker-credential-helpers v0.7.0
 	github.com/docker/go-connections v0.4.0
+	github.com/godbus/dbus/v5 v5.0.6
 	github.com/jackc/pgx/v4 v4.17.2
 	github.com/joho/godotenv v1.4.0
 	github.com/matoous/go-nanoid/v2 v2.0.0
@@ -20,15 +23,16 @@ require (
 	github.com/spf13/cobra v1.6.1
 	github.com/spf13/viper v1.14.0
 	github.com/withfig/autocomplete-tools/packages/cobra v1.2.0
+	github.com/zalando/go-keyring v0.2.1
 	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
+	github.com/alessio/shellescape v1.4.1 // indirect
 	github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
 	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymanbagabas/go-osc52 v1.0.3 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/docker/docker-credential-helpers v0.7.0 // indirect
 	github.com/getkin/kin-openapi v0.107.0 // indirect
 	github.com/gin-contrib/sse v0.1.0 // indirect
 	github.com/go-openapi/jsonpointer v0.19.5 // indirect
diff --git a/go.sum b/go.sum
index 21e09ae5..a3e2a896 100644
--- a/go.sum
+++ b/go.sum
@@ -50,6 +50,8 @@ github.com/Netflix/go-env v0.0.0-20220526054621-78278af1949d/go.mod h1:9XMFaCeRy
 github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
 github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
 github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
+github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
+github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
 github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
 github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -90,6 +92,9 @@ github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7Do
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
 github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7hqDjlFjiygg=
+github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
+github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -146,6 +151,8 @@ github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
 github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk=
 github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
+github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
 github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@@ -432,6 +439,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -463,6 +471,8 @@ github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
 github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
 github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
+github.com/zalando/go-keyring v0.2.1 h1:MBRN/Z8H4U5wEKXiD67YbDAr5cj/DOStmSga70/2qKc=
+github.com/zalando/go-keyring v0.2.1/go.mod h1:g63M2PPn0w5vjmEbwAX3ib5I+41zdm4esSETOn9Y6Dw=
 github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
@@ -633,6 +643,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/internal/db/push/push.go b/internal/db/push/push.go
index bdd13a39..a949c436 100644
--- a/internal/db/push/push.go
+++ b/internal/db/push/push.go
@@ -11,19 +11,14 @@ import (
 	"github.com/supabase/cli/internal/utils"
 )
 
-func Run(ctx context.Context, dryRun bool, username, password, database string, fsys afero.Fs) error {
+func Run(ctx context.Context, dryRun bool, username, password, database, host string, fsys afero.Fs) error {
 	if dryRun {
 		fmt.Println("DRY RUN: migrations will *not* be pushed to the database.")
 	}
 	if err := utils.LoadConfigFS(fsys); err != nil {
 		return err
 	}
-
-	projectRef, err := utils.LoadProjectRef(fsys)
-	if err != nil {
-		return err
-	}
-	conn, err := commit.ConnectRemotePostgres(ctx, username, password, database, utils.GetSupabaseDbHost(projectRef))
+	conn, err := commit.ConnectRemotePostgres(ctx, username, password, database, host)
 	if err != nil {
 		return err
 	}
diff --git a/internal/link/link.go b/internal/link/link.go
index 402f832c..bf309e31 100644
--- a/internal/link/link.go
+++ b/internal/link/link.go
@@ -3,11 +3,15 @@ package link
 import (
 	"context"
 	"errors"
+	"fmt"
+	"os"
 	"path/filepath"
 
 	"github.com/spf13/afero"
 	"github.com/supabase/cli/internal/db/remote/commit"
 	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/internal/utils/credentials"
+	"golang.org/x/term"
 )
 
 func Run(ctx context.Context, projectRef, username, password, database string, fsys afero.Fs) error {
@@ -36,6 +40,10 @@ func Run(ctx context.Context, projectRef, username, password, database string, f
 				return err
 			}
 		}
+		// Save database password
+		if err := credentials.Set(projectRef, password); err != nil {
+			fmt.Fprintln(os.Stderr, "Failed to save database password:", err)
+		}
 	}
 
 	// 2. Save project ref
@@ -67,3 +75,13 @@ func validateProjectRef(ctx context.Context, projectRef string, fsys afero.Fs) e
 
 	return nil
 }
+
+func PromptPassword(stdin *os.File) string {
+	fmt.Print("Enter your database password: ")
+	bytepw, err := term.ReadPassword(int(stdin.Fd()))
+	fmt.Println()
+	if err != nil {
+		return ""
+	}
+	return string(bytepw)
+}
diff --git a/internal/migration/list/list.go b/internal/migration/list/list.go
index 86e04c2a..229dbc41 100644
--- a/internal/migration/list/list.go
+++ b/internal/migration/list/list.go
@@ -8,46 +8,68 @@ import (
 	"time"
 
 	"github.com/charmbracelet/glamour"
+	"github.com/jackc/pgx/v4"
 	"github.com/spf13/afero"
 	"github.com/supabase/cli/internal/db/remote/commit"
 	"github.com/supabase/cli/internal/utils"
 )
 
-func Run(ctx context.Context, username, password, database string, fsys afero.Fs) error {
-	// Sanity checks.
-	if err := utils.LoadConfigFS(fsys); err != nil {
+func Run(ctx context.Context, username, password, database, host string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error {
+	versions, err := loadRemoteMigrations(ctx, username, password, database, host, options...)
+	if err != nil {
+		return err
+	}
+	// Render table
+	table, err := makeTable(versions, fsys)
+	if err != nil {
 		return err
 	}
-	// Connect to remote db
-	projectRef, err := utils.LoadProjectRef(fsys)
+	r, err := glamour.NewTermRenderer(
+		glamour.WithAutoStyle(),
+		glamour.WithWordWrap(-1),
+	)
 	if err != nil {
 		return err
 	}
-	conn, err := commit.ConnectRemotePostgres(ctx, username, password, database, utils.GetSupabaseDbHost(projectRef))
+	out, err := r.Render(table)
 	if err != nil {
 		return err
 	}
+	fmt.Print(out)
+
+	return nil
+}
+
+func loadRemoteMigrations(ctx context.Context, username, password, database, host string, options ...func(*pgx.ConnConfig)) ([]string, error) {
+	conn, err := commit.ConnectRemotePostgres(ctx, username, password, database, host, options...)
+	if err != nil {
+		return nil, err
+	}
 	defer conn.Close(context.Background())
 	// Load remote migrations
 	rows, err := conn.Query(ctx, commit.LIST_MIGRATION_VERSION)
 	if err != nil {
-		return err
+		return nil, err
 	}
-	remoteMigrations := []string{}
+	versions := []string{}
 	for rows.Next() {
 		var version string
 		if err := rows.Scan(&version); err != nil {
-			return err
+			return nil, err
 		}
-		remoteMigrations = append(remoteMigrations, version)
+		versions = append(versions, version)
 	}
+	return versions, nil
+}
+
+func makeTable(remoteMigrations []string, fsys afero.Fs) (string, error) {
 	// Load local migrations
 	if err := utils.MkdirIfNotExistFS(fsys, utils.MigrationsDir); err != nil {
-		return err
+		return "", err
 	}
 	localMigrations, err := afero.ReadDir(fsys, utils.MigrationsDir)
 	if err != nil {
-		return err
+		return "", err
 	}
 	// Render table
 	layoutVersion := "20060102150405"
@@ -85,19 +107,5 @@ func Run(ctx context.Context, username, password, database string, fsys afero.Fs
 			j++
 		}
 	}
-
-	r, err := glamour.NewTermRenderer(
-		glamour.WithAutoStyle(),
-		glamour.WithWordWrap(-1),
-	)
-	if err != nil {
-		return err
-	}
-	out, err := r.Render(table)
-	if err != nil {
-		return err
-	}
-	fmt.Print(out)
-
-	return nil
+	return table, nil
 }
diff --git a/internal/utils/credentials/store.go b/internal/utils/credentials/store.go
new file mode 100644
index 00000000..98a954e4
--- /dev/null
+++ b/internal/utils/credentials/store.go
@@ -0,0 +1,22 @@
+package credentials
+
+import (
+	"github.com/zalando/go-keyring"
+)
+
+const namespace = "Supabase CLI"
+
+// Retrieves the stored password of a project and username
+func Get(project string) (string, error) {
+	return keyring.Get(namespace, project)
+}
+
+// Stores the password of a project and username
+func Set(project, password string) error {
+	return keyring.Set(namespace, project, password)
+}
+
+// Erases the stored password of a project and username
+func Delete(project string) error {
+	return keyring.Delete(namespace, project)
+}


Test patch:
diff --git a/internal/migration/list/list_test.go b/internal/migration/list/list_test.go
new file mode 100644
index 00000000..7e92ad2c
--- /dev/null
+++ b/internal/migration/list/list_test.go
@@ -0,0 +1,151 @@
+package list
+
+import (
+	"context"
+	"io/fs"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/jackc/pgerrcode"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/supabase/cli/internal/db/remote/commit"
+	"github.com/supabase/cli/internal/testing/pgtest"
+	"github.com/supabase/cli/internal/utils"
+)
+
+func TestMigrationList(t *testing.T) {
+	t.Run("lists remote migrations", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Setup mock postgres
+		conn := pgtest.NewConn()
+		defer conn.Close(t)
+		conn.Query(commit.LIST_MIGRATION_VERSION).
+			Reply("SELECT 0")
+		// Run test
+		err := Run(context.Background(), "admin", "password", "postgres", "localhost", fsys, conn.Intercept)
+		// Check error
+		assert.NoError(t, err)
+	})
+
+	t.Run("throws error on remote failure", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Run test
+		err := Run(context.Background(), "admin", "password", "postgres", "localhost:0", fsys)
+		// Check error
+		assert.ErrorContains(t, err, "hostname resolving error (lookup localhost:0: no such host)")
+	})
+
+	t.Run("throws error on local failure", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Setup mock postgres
+		conn := pgtest.NewConn()
+		defer conn.Close(t)
+		conn.Query(commit.LIST_MIGRATION_VERSION).
+			Reply("SELECT 0")
+		// Run test
+		err := Run(context.Background(), "admin", "password", "postgres", "localhost", afero.NewReadOnlyFs(fsys), conn.Intercept)
+		// Check error
+		assert.ErrorContains(t, err, "operation not permitted")
+	})
+}
+
+func TestRemoteMigrations(t *testing.T) {
+	user := "admin"
+	pass := "password"
+	host := "localhost"
+	db := "postgres"
+
+	t.Run("loads migration versions", func(t *testing.T) {
+		// Setup mock postgres
+		conn := pgtest.NewConn()
+		defer conn.Close(t)
+		conn.Query(commit.LIST_MIGRATION_VERSION).
+			Reply("SELECT 1", []interface{}{"20220727064247"})
+		// Run test
+		versions, err := loadRemoteMigrations(context.Background(), user, pass, db, host, conn.Intercept)
+		// Check error
+		assert.NoError(t, err)
+		assert.ElementsMatch(t, []string{"20220727064247"}, versions)
+	})
+
+	t.Run("throws error on connect failure", func(t *testing.T) {
+		// Run test
+		_, err := loadRemoteMigrations(context.Background(), user, pass, db, host+":0")
+		// Check error
+		assert.ErrorContains(t, err, "hostname resolving error (lookup localhost:0: no such host)")
+	})
+
+	t.Run("throws error on missing schema", func(t *testing.T) {
+		// Setup mock postgres
+		conn := pgtest.NewConn()
+		defer conn.Close(t)
+		conn.Query(commit.LIST_MIGRATION_VERSION).
+			ReplyError(pgerrcode.UndefinedTable, "relation \"supabase_migrations.schema_migrations\" does not exist")
+		// Run test
+		_, err := loadRemoteMigrations(context.Background(), user, pass, db, host, conn.Intercept)
+		// Check error
+		assert.ErrorContains(t, err, `ERROR: relation "supabase_migrations.schema_migrations" does not exist (SQLSTATE 42P01)`)
+	})
+
+	t.Run("throws error on invalid row", func(t *testing.T) {
+		// Setup mock postgres
+		conn := pgtest.NewConn()
+		defer conn.Close(t)
+		conn.Query(commit.LIST_MIGRATION_VERSION).
+			Reply("SELECT 1", nil)
+		// Run test
+		_, err := loadRemoteMigrations(context.Background(), user, pass, db, host, conn.Intercept)
+		// Check error
+		assert.ErrorContains(t, err, "number of field descriptions must equal number of destinations, got 0 and 1")
+	})
+}
+
+type MockFs struct {
+	afero.MemMapFs
+	DenyPath string
+}
+
+func (m *MockFs) Open(name string) (afero.File, error) {
+	if strings.HasPrefix(name, m.DenyPath) {
+		return nil, fs.ErrPermission
+	}
+	return m.MemMapFs.Open(name)
+}
+func TestMakeTable(t *testing.T) {
+	t.Run("lists local and remote", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		path := filepath.Join(utils.MigrationsDir, "20220727064246_test.sql")
+		require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0644))
+		path = filepath.Join(utils.MigrationsDir, "20220727064248_test.sql")
+		require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0644))
+		// Run test
+		_, err := makeTable([]string{"20220727064246", "20220727064247"}, fsys)
+		// Check error
+		assert.NoError(t, err)
+	})
+
+	t.Run("throws error on permission denied", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Run test
+		_, err := makeTable(nil, afero.NewReadOnlyFs(fsys))
+		// Check error
+		assert.ErrorContains(t, err, "operation not permitted")
+	})
+
+	t.Run("throws error on open failure", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := MockFs{DenyPath: utils.MigrationsDir}
+		// Run test
+		_, err := makeTable(nil, &fsys)
+		// Check error
+		assert.ErrorContains(t, err, "permission denied")
+	})
+}