Repository: supabase/cli
Pull request #358 (2022-08-12 08:20:55)

feat: add interactive mode for creating project
## What kind of change does this PR introduce?

feature

## What is the current behavior?

creating project requires passing in many flags

## What is the new behavior?

```bash
supabase projects create -i hello
```

## Additional context

Add any other context or screenshots.


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

* **50** of **215**   **(23.26%)**  changed or added relevant lines in **5** files are covered.
* **2** unchanged lines in **1** file lost coverage.
* Overall coverage decreased (**-0.8%**) to **24.241%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [internal/utils/enum.go](https://coveralls.io/builds/51617176/source?filename=internal%2Futils%2Fenum.go#L18) | 3 | 18 | 16.67%
| [cmd/projects.go](https://coveralls.io/builds/51617176/source?filename=cmd%2Fprojects.go#L34) | 14 | 84 | 16.67%
| [internal/utils/prompt.go](https://coveralls.io/builds/51617176/source?filename=internal%2Futils%2Fprompt.go#L27) | 0 | 80 | 0.0%
<!-- | **Total:** | **50** | **215** | **23.26%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [cmd/projects.go](https://coveralls.io/builds/51617176/source?filename=cmd%2Fprojects.go#L54) | 2 | 20.0% |
<!-- | **Total:** | **2** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/51617176/badge)](https://coveralls.io/builds/51617176) |
| :-- | --: |
| Change from base [Build 2844988063](https://coveralls.io/builds/51615569): |  -0.8% |
| Covered Lines: | 1189 |
| Relevant Lines: | 4905 |

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


Patch:
diff --git a/cmd/projects.go b/cmd/projects.go
index 27321087..41834668 100644
--- a/cmd/projects.go
+++ b/cmd/projects.go
@@ -1,10 +1,17 @@
 package cmd
 
 import (
+	"fmt"
+	"os"
+	"os/signal"
+	"strings"
+
 	"github.com/spf13/afero"
 	"github.com/spf13/cobra"
 	"github.com/supabase/cli/internal/projects/create"
 	"github.com/supabase/cli/internal/projects/list"
+	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/pkg/api"
 )
 
 var (
@@ -13,36 +20,43 @@ var (
 		Short: "Manage Supabase projects",
 	}
 
+	interactive bool
+	orgId       string
+	dbPassword  string
+
+	region = utils.EnumFlag{
+		Allowed: make([]string, len(utils.RegionMap)),
+	}
+	plan = utils.EnumFlag{
+		Allowed: []string{string(api.Free), string(api.Pro)},
+		Value:   string(api.Free),
+	}
+
 	projectsCreateCmd = &cobra.Command{
-		Use:     "create",
+		Use:     "create <project name>",
 		Short:   "Create a new project",
 		Args:    cobra.ExactArgs(1),
 		Example: `supabase projects create my-project --org-id cool-green-pqdr0qc --db-password ******** --region us-east-1`,
+		PreRun: func(cmd *cobra.Command, args []string) {
+			if !interactive {
+				cobra.CheckErr(cmd.MarkFlagRequired("org-id"))
+				cobra.CheckErr(cmd.MarkFlagRequired("db-password"))
+				cobra.CheckErr(cmd.MarkFlagRequired("region"))
+			}
+		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			name := args[0]
-			orgId, err := cmd.Flags().GetString("org-id")
-			if err != nil {
-				return err
-			}
-			dbPassword, err := cmd.Flags().GetString("db-password")
-			if err != nil {
-				return err
-			}
-			region, err := cmd.Flags().GetString("region")
-			if err != nil {
-				return err
+			if interactive {
+				fmt.Fprintln(os.Stderr, printKeyValue("Creating project", name))
+				cobra.CheckErr(PromptCreateFlags(cmd))
 			}
-			plan, err := cmd.Flags().GetString("plan")
-			if err != nil {
-				return err
-			}
-
-			return create.Run(create.RequestParam{
-				Name:   name,
-				OrgId:  orgId,
-				DbPass: dbPassword,
-				Region: region,
-				Plan:   plan,
+			ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
+			return create.Run(ctx, api.CreateProjectBody{
+				Name:           name,
+				OrganizationId: orgId,
+				DbPass:         dbPassword,
+				Region:         api.CreateProjectBodyRegion(region.Value),
+				Plan:           api.CreateProjectBodyPlan(plan.Value),
 			}, afero.NewOsFs())
 		},
 	}
@@ -51,27 +65,86 @@ var (
 		Use:   "list",
 		Short: "List all projects",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			return list.Run(afero.NewOsFs())
+			ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
+			return list.Run(ctx, afero.NewOsFs())
 		},
 	}
 )
 
 func init() {
-	// TODO: Make these optional once we implement prompting missing flags.
-	projectsCreateCmd.Flags().String("org-id", "", "Organization ID to create the project in.")
-	if err := projectsCreateCmd.MarkFlagRequired("org-id"); err != nil {
-		panic(err)
+	// Setup enum flags
+	i := 0
+	for k := range utils.RegionMap {
+		region.Allowed[i] = k
+		i++
 	}
-	projectsCreateCmd.Flags().String("db-password", "", "Database password of the project.")
-	if err := projectsCreateCmd.MarkFlagRequired("db-password"); err != nil {
-		panic(err)
-	}
-	projectsCreateCmd.Flags().String("region", "", "Select a region close to you for the best performance.")
-	if err := projectsCreateCmd.MarkFlagRequired("region"); err != nil {
-		panic(err)
-	}
-	projectsCreateCmd.Flags().String("plan", "free", `Select a plan that suits your needs. Can be "free" or "pro".`)
+	// Add flags to cobra command
+	createFlags := projectsCreateCmd.Flags()
+	createFlags.BoolVarP(&interactive, "interactive", "i", false, "Enables interactive mode.")
+	createFlags.StringVar(&orgId, "org-id", "", "Organization ID to create the project in.")
+	createFlags.StringVar(&dbPassword, "db-password", "", "Database password of the project.")
+	createFlags.Var(&region, "region", "Select a region close to you for the best performance.")
+	createFlags.Var(&plan, "plan", "Select a plan that suits your needs.")
+	// Add commands to root
 	projectsCmd.AddCommand(projectsCreateCmd)
 	projectsCmd.AddCommand(projectsListCmd)
 	rootCmd.AddCommand(projectsCmd)
 }
+
+func PromptCreateFlags(cmd *cobra.Command) error {
+	ctx := cmd.Context()
+	if !cmd.Flags().Changed("org-id") {
+		title := "Which organisation do you want to create the project for?"
+		resp, err := utils.GetSupabase().GetOrganizationsWithResponse(ctx)
+		if err != nil {
+			return err
+		}
+		items := make([]utils.PromptItem, len(*resp.JSON200))
+		for i, org := range *resp.JSON200 {
+			items[i] = utils.PromptItem{Summary: org.Name, Details: org.Id}
+		}
+		choice, err := utils.PromptChoice(ctx, title, items)
+		if err != nil {
+			return err
+		}
+		orgId = choice.Details
+	}
+	fmt.Fprintln(os.Stderr, printKeyValue("Selected org-id", orgId))
+	if !cmd.Flags().Changed("region") {
+		title := "Which region do you want to host the project in?"
+		items := make([]utils.PromptItem, len(utils.RegionMap))
+		i := 0
+		for k, v := range utils.RegionMap {
+			items[i] = utils.PromptItem{Summary: k, Details: v}
+			i++
+		}
+		choice, err := utils.PromptChoice(ctx, title, items)
+		if err != nil {
+			return err
+		}
+		region.Value = choice.Summary
+	}
+	fmt.Fprintln(os.Stderr, printKeyValue("Selected region", region.Value))
+	if !cmd.Flags().Changed("plan") {
+		title := "Do you want a free or pro plan?"
+		choice, err := utils.PromptChoice(ctx, title, []utils.PromptItem{
+			{Summary: string(api.Free)},
+			{Summary: string(api.Pro)},
+		})
+		if err != nil {
+			return err
+		}
+		plan.Value = choice.Summary
+	}
+	fmt.Fprintln(os.Stderr, printKeyValue("Selected plan", plan.Value))
+	if dbPassword == "" {
+		dbPassword = PromptPassword(os.Stdin)
+	}
+	return nil
+}
+
+func printKeyValue(key, value string) string {
+	indent := 20 - len(key)
+	spaces := strings.Repeat(" ", indent)
+	return key + ":" + spaces + value
+}
diff --git a/go.mod b/go.mod
index 3953feb6..a29ddf14 100644
--- a/go.mod
+++ b/go.mod
@@ -4,8 +4,8 @@ go 1.18
 
 require (
 	github.com/BurntSushi/toml v1.0.0
-	github.com/charmbracelet/bubbles v0.10.3
-	github.com/charmbracelet/bubbletea v0.20.0
+	github.com/charmbracelet/bubbles v0.13.0
+	github.com/charmbracelet/bubbletea v0.21.0
 	github.com/charmbracelet/glamour v0.5.0
 	github.com/charmbracelet/lipgloss v0.5.0
 	github.com/deepmap/oapi-codegen v1.11.0
@@ -22,6 +22,7 @@ require (
 )
 
 require (
+	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/getkin/kin-openapi v0.94.0 // indirect
 	github.com/ghodss/yaml v1.0.0 // indirect
@@ -43,8 +44,10 @@ require (
 	github.com/mattn/go-colorable v0.1.12 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/muesli/cancelreader v0.2.0 // indirect
 	github.com/pelletier/go-toml/v2 v2.0.1 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/sahilm/fuzzy v0.1.0 // indirect
 	github.com/ugorji/go/codec v1.2.7 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/valyala/fasttemplate v1.2.1 // indirect
@@ -59,7 +62,7 @@ require (
 	github.com/Microsoft/go-winio v0.5.2 // indirect
 	github.com/alecthomas/chroma v0.10.0 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
-	github.com/charmbracelet/harmonica v0.1.0 // indirect
+	github.com/charmbracelet/harmonica v0.2.0 // indirect
 	github.com/containerd/console v1.0.3 // indirect
 	github.com/containerd/containerd v1.6.2 // indirect
 	github.com/dlclark/regexp2 v1.4.0 // indirect
diff --git a/go.sum b/go.sum
index ffd5311d..a71fbbe2 100644
--- a/go.sum
+++ b/go.sum
@@ -48,21 +48,20 @@ github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v
 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/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho=
-github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
-github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
-github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc=
-github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM=
+github.com/charmbracelet/bubbles v0.13.0 h1:zP/ROH3wJEBqZWKIsD50ZKKlx3ydLInq3LdD/Nrlb8w=
+github.com/charmbracelet/bubbles v0.13.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
+github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
+github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
 github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g=
 github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
-github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0=
-github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
-github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
+github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
 github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
 github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -78,7 +77,6 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH
 github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
-github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
 github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/containerd/containerd v1.6.2 h1:pcaPUGbYW8kBw6OgIZwIVIeEhdWVrBzsoCfVJ5BjrLU=
@@ -308,6 +306,7 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/labstack/echo/v4 v4.7.2 h1:Kv2/p8OaQ+M6Ex4eGimg9b9e6icoxA42JSlOR3msKtI=
 github.com/labstack/echo/v4 v4.7.2/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
@@ -376,6 +375,8 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P
 github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
 github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA=
 github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
+github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q=
+github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
@@ -416,6 +417,7 @@ github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
 github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
 github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
 github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
@@ -657,11 +659,12 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/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-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a h1:N2T1jUrTQE9Re6TFF5PhvEHXHCguynGhKjWVsIUt5cY=
 golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/internal/projects/create/create.go b/internal/projects/create/create.go
index 0d2b7aee..9e1f0c0b 100644
--- a/internal/projects/create/create.go
+++ b/internal/projects/create/create.go
@@ -1,79 +1,37 @@
 package create
 
 import (
-	"bytes"
-	"encoding/json"
+	"context"
 	"errors"
 	"fmt"
-	"io"
-	"net/http"
 
 	"github.com/spf13/afero"
-	"github.com/supabase/cli/internal/projects/list"
 	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/pkg/api"
 )
 
-type RequestParam struct {
-	OrgId  string `json:"organization_id"`
-	Name   string `json:"name"`
-	DbPass string `json:"db_pass"`
-	Region string `json:"region"`
-	Plan   string `json:"plan"`
-}
-
-func Run(params RequestParam, fsys afero.Fs) error {
-	accessToken, err := utils.LoadAccessTokenFS(fsys)
-	if err != nil {
-		return err
-	}
-
+func Run(ctx context.Context, params api.CreateProjectBody, fsys afero.Fs) error {
 	// TODO: Prompt missing args.
 	{
 	}
 
-	// POST request, check errors
-	var project list.Project
-	{
-		jsonBytes, err := json.Marshal(params)
-		if err != nil {
-			return err
-		}
-
-		req, err := http.NewRequest("POST", utils.GetSupabaseAPIHost()+"/v1/projects", bytes.NewReader(jsonBytes))
-		if err != nil {
-			return err
-		}
-		req.Header.Add("Authorization", "Bearer "+string(accessToken))
-		req.Header.Add("Content-Type", "application/json")
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			return err
-		}
-		defer resp.Body.Close()
-
-		// TODO: remove the StatusOK case after 2022-08-20
-		if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
-			body, err := io.ReadAll(resp.Body)
-			if err != nil {
-				return fmt.Errorf("Unexpected error creating project: %w", err)
-			}
-
-			return errors.New("Unexpected error creating project: " + string(body))
-		}
+	resp, err := utils.GetSupabase().CreateProjectWithResponse(ctx, params)
+	if err != nil {
+		return err
+	}
 
-		body, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return err
-		}
-		if err := json.Unmarshal(body, &project); err != nil {
-			return fmt.Errorf("Failed to create a new project: %w", err)
-		}
+	if resp.JSON201 == nil {
+		return errors.New("Unexpected error creating project: " + string(resp.Body))
 	}
 
 	// TODO: Poll until PostgREST is reachable.
 	{
 	}
 
-	fmt.Printf("Created a new project %s at %s\n", utils.Aqua(project.Name), utils.Aqua(utils.GetSupabaseDashboardURL()+"/project/"+project.Id))
+	fmt.Printf(
+		"Created a new project %s at %s\n",
+		utils.Aqua(resp.JSON201.Name),
+		utils.Aqua(utils.GetSupabaseDashboardURL()+"/project/"+resp.JSON201.Id),
+	)
 	return nil
 }
diff --git a/internal/projects/list/list.go b/internal/projects/list/list.go
index e0054fa3..890e0e51 100644
--- a/internal/projects/list/list.go
+++ b/internal/projects/list/list.go
@@ -1,68 +1,45 @@
 package list
 
 import (
-	"encoding/json"
+	"context"
 	"errors"
 	"fmt"
-	"io"
-	"net/http"
 	"strings"
+	"time"
 
 	"github.com/charmbracelet/glamour"
 	"github.com/spf13/afero"
 	"github.com/supabase/cli/internal/utils"
 )
 
-type Project struct {
-	OrgId     string `json:"organization_id"`
-	Id        string `json:"id"`
-	Name      string `json:"name"`
-	Region    string `json:"region"`
-	CreatedAt string `json:"created_at"`
-}
-
-func Run(fsys afero.Fs) error {
-	accessToken, err := utils.LoadAccessTokenFS(fsys)
-	if err != nil {
-		return err
-	}
-
-	req, err := http.NewRequest("GET", utils.GetSupabaseAPIHost()+"/v1/projects", nil)
-	if err != nil {
-		return err
-	}
-	req.Header.Add("Authorization", "Bearer "+string(accessToken))
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		body, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return fmt.Errorf("Unexpected error retrieving projects: %w", err)
-		}
-
-		return errors.New("Unexpected error retrieving projects: " + string(body))
-	}
-
-	body, err := io.ReadAll(resp.Body)
+func Run(ctx context.Context, fsys afero.Fs) error {
+	resp, err := utils.GetSupabase().GetProjectsWithResponse(ctx)
 	if err != nil {
 		return err
 	}
 
-	var projects []Project
-	if err := json.Unmarshal(body, &projects); err != nil {
-		return err
+	if resp.JSON200 == nil {
+		return errors.New("Unexpected error retrieving projects: " + string(resp.Body))
 	}
 
-	// TODO: Add CREATED AT
-	table := `|ORG ID|ID|NAME|REGION|
-|-|-|-|-|
+	table := `|ORG ID|ID|NAME|REGION|CREATED AT (UTC)|
+|-|-|-|-|-|
 `
-	for _, project := range projects {
-		table += fmt.Sprintf("|`%s`|`%s`|`%s`|`%s`|\n", project.OrgId, project.Id, strings.ReplaceAll(project.Name, "|", "\\|"), utils.RegionMap[project.Region])
+	for _, project := range *resp.JSON200 {
+		if t, err := time.Parse(time.RFC3339, project.CreatedAt); err == nil {
+			project.CreatedAt = t.UTC().Format("2006-01-02 15:04:05")
+		}
+		if region, ok := utils.RegionMap[project.Region]; ok {
+			project.Region = region
+		}
+		table += fmt.Sprintf(
+			"|`%s`|`%s`|`%s`|`%s`|`%s`|\n",
+			project.OrganizationId,
+			project.Id,
+			strings.ReplaceAll(project.Name, "|", "\\|"),
+			project.Region,
+			project.CreatedAt,
+		)
 	}
 
 	r, err := glamour.NewTermRenderer(
diff --git a/internal/utils/enum.go b/internal/utils/enum.go
new file mode 100644
index 00000000..e756c6aa
--- /dev/null
+++ b/internal/utils/enum.go
@@ -0,0 +1,36 @@
+package utils
+
+import (
+	"fmt"
+	"strings"
+)
+
+// Ref: https://github.com/spf13/pflag/issues/236#issuecomment-931600452
+type EnumFlag struct {
+	Allowed []string
+	Value   string
+}
+
+func (a EnumFlag) String() string {
+	return a.Value
+}
+
+func (a *EnumFlag) Set(p string) error {
+	isIncluded := func(opts []string, val string) bool {
+		for _, opt := range opts {
+			if val == opt {
+				return true
+			}
+		}
+		return false
+	}
+	if !isIncluded(a.Allowed, p) {
+		return fmt.Errorf("must be one of [ %s ]", strings.Join(a.Allowed, " | "))
+	}
+	a.Value = p
+	return nil
+}
+
+func (a *EnumFlag) Type() string {
+	return "string"
+}
diff --git a/internal/utils/prompt.go b/internal/utils/prompt.go
new file mode 100644
index 00000000..f8cf0830
--- /dev/null
+++ b/internal/utils/prompt.go
@@ -0,0 +1,137 @@
+package utils
+
+import (
+	"context"
+	"fmt"
+	"io"
+
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+var (
+	titleStyle        = lipgloss.NewStyle().MarginLeft(2)
+	itemStyle         = lipgloss.NewStyle().PaddingLeft(4)
+	selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170"))
+	paginationStyle   = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
+	helpStyle         = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
+)
+
+// PromptItem is exposed as prompt input, empty summary + details will be excluded.
+type PromptItem struct {
+	Summary string
+	Details string
+}
+
+func (i PromptItem) Title() string       { return i.Summary }
+func (i PromptItem) Description() string { return i.Details }
+func (i PromptItem) FilterValue() string { return i.Summary + " " + i.Details }
+
+// Item delegate is used to finetune the list item renderer.
+type itemDelegate struct{}
+
+func (d itemDelegate) Height() int                               { return 1 }
+func (d itemDelegate) Spacing() int                              { return 0 }
+func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	i, ok := listItem.(PromptItem)
+	if !ok {
+		return
+	}
+
+	str := fmt.Sprintf("%d. %s", index+1, i.Summary)
+	if i.Details != "" {
+		str += fmt.Sprintf(" [%s]", i.Details)
+	}
+
+	fn := itemStyle.Render
+	if index == m.Index() {
+		fn = func(s string) string {
+			return selectedItemStyle.Render("> " + s)
+		}
+	}
+
+	fmt.Fprint(w, fn(str))
+}
+
+// Model is used to store state of user choices.
+type model struct {
+	cancel context.CancelFunc
+	list   list.Model
+	choice PromptItem
+}
+
+func (m model) Init() tea.Cmd {
+	return nil
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.list.SetWidth(msg.Width)
+		return m, nil
+
+	case tea.KeyMsg:
+		switch msg.Type {
+		case tea.KeyCtrlC:
+			m.cancel()
+			return m, tea.Quit
+
+		case tea.KeyEnter:
+			choice, ok := m.list.SelectedItem().(PromptItem)
+			if ok {
+				m.choice = choice
+			}
+			return m, tea.Quit
+		}
+	}
+
+	var cmd tea.Cmd
+	m.list, cmd = m.list.Update(msg)
+	return m, cmd
+}
+
+func (m model) View() string {
+	if m.choice.Summary != "" {
+		return ""
+	}
+	return "\n" + m.list.View()
+}
+
+// Prompt user to choose from a list of items, returns the chosen index.
+func PromptChoice(ctx context.Context, title string, items []PromptItem) (PromptItem, error) {
+	// Create list items
+	var listItems []list.Item
+	for _, v := range items {
+		if v.FilterValue() == "" {
+			continue
+		}
+		listItems = append(listItems, v)
+	}
+	// Create list model
+	height := len(listItems) * 4
+	if height > 14 {
+		height = 14
+	}
+	l := list.New(listItems, itemDelegate{}, 0, height)
+	l.Title = title
+	l.SetShowStatusBar(false)
+	l.Styles.Title = titleStyle
+	l.Styles.PaginationStyle = paginationStyle
+	l.Styles.HelpStyle = helpStyle
+	// Create our model
+	ctx, cancel := context.WithCancel(ctx)
+	prog := tea.NewProgram(model{cancel: cancel, list: l})
+	state, err := prog.StartReturningModel()
+	if err != nil {
+		return PromptItem{}, err
+	}
+	if ctx.Err() != nil {
+		return PromptItem{}, ctx.Err()
+	}
+	if m, ok := state.(model); ok {
+		return m.choice, nil
+	}
+	return PromptItem{}, err
+}


Test patch:
diff --git a/internal/projects/create/create_test.go b/internal/projects/create/create_test.go
index cf633772..57739315 100644
--- a/internal/projects/create/create_test.go
+++ b/internal/projects/create/create_test.go
@@ -1,23 +1,24 @@
 package create
 
 import (
+	"context"
 	"errors"
 	"testing"
 
 	"github.com/spf13/afero"
 	"github.com/stretchr/testify/assert"
-	"github.com/supabase/cli/internal/projects/list"
 	"github.com/supabase/cli/internal/testing/apitest"
+	"github.com/supabase/cli/pkg/api"
 	"gopkg.in/h2non/gock.v1"
 )
 
 func TestProjectCreateCommand(t *testing.T) {
-	var params = RequestParam{
-		Name:   "Test Project",
-		OrgId:  "combined-fuchsia-lion",
-		DbPass: "redacted",
-		Region: "us-west-1",
-		Plan:   "free",
+	var params = api.CreateProjectBody{
+		Name:           "Test Project",
+		OrganizationId: "combined-fuchsia-lion",
+		DbPass:         "redacted",
+		Region:         api.UsWest1,
+		Plan:           api.Free,
 	}
 
 	t.Run("creates a new project", func(t *testing.T) {
@@ -33,19 +34,19 @@ func TestProjectCreateCommand(t *testing.T) {
 			MatchType("json").
 			JSON(params).
 			Reply(201).
-			JSON(list.Project{
-				Id:        "bcnzkuicchyuaswrezwk",
-				OrgId:     params.OrgId,
-				Name:      params.Name,
-				Region:    params.Region,
-				CreatedAt: "2022-04-25T02:14:55.906498Z",
+			JSON(api.ProjectResponse{
+				Id:             apitest.RandomProjectRef(),
+				OrganizationId: params.OrganizationId,
+				Name:           params.Name,
+				Region:         string(params.Region),
+				CreatedAt:      "2022-04-25T02:14:55.906498Z",
 			})
 		// Run test
-		assert.NoError(t, Run(params, fsys))
+		assert.NoError(t, Run(context.Background(), params, fsys))
 	})
 
 	t.Run("throws error on failure to load token", func(t *testing.T) {
-		assert.Error(t, Run(params, afero.NewMemMapFs()))
+		assert.Error(t, Run(context.Background(), params, afero.NewMemMapFs()))
 	})
 
 	t.Run("throws error on network error", func(t *testing.T) {
@@ -62,7 +63,7 @@ func TestProjectCreateCommand(t *testing.T) {
 			JSON(params).
 			ReplyError(errors.New("network error"))
 		// Run test
-		assert.Error(t, Run(params, fsys))
+		assert.Error(t, Run(context.Background(), params, fsys))
 	})
 
 	t.Run("throws error on server unavailable", func(t *testing.T) {
@@ -80,7 +81,7 @@ func TestProjectCreateCommand(t *testing.T) {
 			Reply(500).
 			JSON(map[string]string{"message": "unavailable"})
 		// Run test
-		assert.Error(t, Run(params, fsys))
+		assert.Error(t, Run(context.Background(), params, fsys))
 	})
 
 	t.Run("throws error on malformed json", func(t *testing.T) {
@@ -98,6 +99,6 @@ func TestProjectCreateCommand(t *testing.T) {
 			Reply(200).
 			JSON([]string{})
 		// Run test
-		assert.Error(t, Run(params, fsys))
+		assert.Error(t, Run(context.Background(), params, fsys))
 	})
 }
diff --git a/internal/projects/list/list_test.go b/internal/projects/list/list_test.go
index 4bf2382e..cceb6361 100644
--- a/internal/projects/list/list_test.go
+++ b/internal/projects/list/list_test.go
@@ -1,12 +1,14 @@
 package list
 
 import (
+	"context"
 	"errors"
 	"testing"
 
 	"github.com/spf13/afero"
 	"github.com/stretchr/testify/assert"
 	"github.com/supabase/cli/internal/testing/apitest"
+	"github.com/supabase/cli/pkg/api"
 	"gopkg.in/h2non/gock.v1"
 )
 
@@ -22,21 +24,21 @@ func TestProjectListCommand(t *testing.T) {
 		gock.New("https://api.supabase.io").
 			Get("/v1/projects").
 			Reply(200).
-			JSON([]Project{
+			JSON([]api.ProjectResponse{
 				{
-					Id:        "bcnzkuicchyuaswrezwk",
-					OrgId:     "combined-fuchsia-lion",
-					Name:      "Test Project",
-					Region:    "us-west-1",
-					CreatedAt: "2022-04-25T02:14:55.906498Z",
+					Id:             apitest.RandomProjectRef(),
+					OrganizationId: "combined-fuchsia-lion",
+					Name:           "Test Project",
+					Region:         "us-west-1",
+					CreatedAt:      "2022-04-25T02:14:55.906498Z",
 				},
 			})
 		// Run test
-		assert.NoError(t, Run(fsys))
+		assert.NoError(t, Run(context.Background(), fsys))
 	})
 
 	t.Run("throws error on failure to load token", func(t *testing.T) {
-		assert.Error(t, Run(afero.NewMemMapFs()))
+		assert.Error(t, Run(context.Background(), afero.NewMemMapFs()))
 	})
 
 	t.Run("throws error on network error", func(t *testing.T) {
@@ -51,7 +53,7 @@ func TestProjectListCommand(t *testing.T) {
 			Get("/v1/projects").
 			ReplyError(errors.New("network error"))
 		// Run test
-		assert.Error(t, Run(fsys))
+		assert.Error(t, Run(context.Background(), fsys))
 	})
 
 	t.Run("throws error on server unavailable", func(t *testing.T) {
@@ -67,7 +69,7 @@ func TestProjectListCommand(t *testing.T) {
 			Reply(500).
 			JSON(map[string]string{"message": "unavailable"})
 		// Run test
-		assert.Error(t, Run(fsys))
+		assert.Error(t, Run(context.Background(), fsys))
 	})
 
 	t.Run("throws error on malformed json", func(t *testing.T) {
@@ -83,6 +85,6 @@ func TestProjectListCommand(t *testing.T) {
 			Reply(200).
 			JSON(map[string]string{})
 		// Run test
-		assert.Error(t, Run(fsys))
+		assert.Error(t, Run(context.Background(), fsys))
 	})
 }