Repository: supabase/cli
Pull request #452 (2022-09-13 08:30:40)

feat: support output variables with status command
## What kind of change does this PR introduce?

Add an `--value-file` flag to `supabase start`. This will add the ability to export values from supabase start to an `.env.local` file as discussed in #451.

## What is the new behavior?

The `config.toml` now has support for the following section
```toml
[value_file]
path = "./.env.local"
type = "env"
anon_key = "NEXT_PUBLIC_SUPABASE_ANON_KEY"
service_role_key = "SUPABASE_SERVICE_ROLE_KEY"
api_url = "NEXT_PUBLIC_SUPABASE_URL"
```

This config is used when you use the `--value-file` to make a new file. in this case it would output a file like the following
```sh
# ./.env.local

NEXT_PUBLIC_SUPABASE_ANON_KEY=some_anon_key
SUPABASE_SERVICE_ROLE_KEY=some_service_role_key
NEXT_PUBLIC_SUPABASE_URL=some_api_url
```

At the moment I have only added support for `env` files. Also this is the first time I have used golang so any suggestions for improvements or corrections would be appreciated.


NatoNathan: I have synced this pr with main, and have fixed the issue with the logs getting printed to `stdout` instead of `stderr`
sweatybridge: Thank you very much. Would it work for you if we move this `--env-file` flag to `supabase status` command instead?

Something like

```bash
supabase start
supabase status --env-file > .env.local
```

I think it's more flexible and if it works for you, I can update your PR directly. 
NatoNathan: Yeah that could work.
coveralls: ## Pull Request Test Coverage Report for [Build 3467353780](https://coveralls.io/builds/54215212)

* **43** of **50**   **(86.0%)**  changed or added relevant lines in **3** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.3%**) to **49.368%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [internal/start/start.go](https://coveralls.io/builds/54215212/source?filename=internal%2Fstart%2Fstart.go#L74) | 0 | 1 | 0.0%
| [cmd/status.go](https://coveralls.io/builds/54215212/source?filename=cmd%2Fstatus.go#L32) | 7 | 13 | 53.85%
<!-- | **Total:** | **43** | **50** | **86.0%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/54215212/badge)](https://coveralls.io/builds/54215212) |
| :-- | --: |
| Change from base [Build 3449538505](https://coveralls.io/builds/54153105): |  0.3% |
| Covered Lines: | 2695 |
| Relevant Lines: | 5459 |

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


Patch:
diff --git a/cmd/status.go b/cmd/status.go
index 32cda0f7..2ee542f9 100644
--- a/cmd/status.go
+++ b/cmd/status.go
@@ -4,21 +4,50 @@ import (
 	"os"
 	"os/signal"
 
+	env "github.com/Netflix/go-env"
 	"github.com/spf13/afero"
 	"github.com/spf13/cobra"
 	"github.com/supabase/cli/internal/status"
+	"github.com/supabase/cli/internal/utils"
 )
 
-var statusCmd = &cobra.Command{
-	GroupID: groupLocalDev,
-	Use:     "status",
-	Short:   "Show status of local Supabase containers",
-	RunE: func(cmd *cobra.Command, args []string) error {
-		ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
-		return status.Run(ctx, afero.NewOsFs())
-	},
-}
+var (
+	override []string
+	names    status.CustomName
+	output   = utils.EnumFlag{
+		Allowed: []string{
+			status.OutputEnv,
+			status.OutputJson,
+			status.OutputPretty,
+			status.OutputToml,
+			status.OutputYaml,
+		},
+		Value: status.OutputPretty,
+	}
+
+	statusCmd = &cobra.Command{
+		GroupID: groupLocalDev,
+		Use:     "status",
+		Short:   "Show status of local Supabase containers",
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			es, err := env.EnvironToEnvSet(override)
+			if err != nil {
+				return err
+			}
+			return env.Unmarshal(es, &names)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
+			return status.Run(ctx, names, output.Value, afero.NewOsFs())
+		},
+		Example: `  supabase status -o env --override-name api.url=NEXT_PUBLIC_SUPABASE_URL
+  supabase status -o json`,
+	}
+)
 
 func init() {
+	flags := statusCmd.Flags()
+	flags.VarP(&output, "output", "o", "Output format of status variables.")
+	flags.StringSliceVar(&override, "override-name", []string{}, "Override specific variable names.")
 	rootCmd.AddCommand(statusCmd)
 }
diff --git a/go.mod b/go.mod
index 88c0ec9a..12253dde 100644
--- a/go.mod
+++ b/go.mod
@@ -64,6 +64,7 @@ require (
 require (
 	github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
 	github.com/Microsoft/go-winio v0.5.2 // indirect
+	github.com/Netflix/go-env v0.0.0-20220526054621-78278af1949d
 	github.com/alecthomas/chroma v0.10.0 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/charmbracelet/harmonica v0.2.0 // indirect
diff --git a/go.sum b/go.sum
index 08036963..1ea473eb 100644
--- a/go.sum
+++ b/go.sum
@@ -45,6 +45,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
 github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
 github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/Netflix/go-env v0.0.0-20220526054621-78278af1949d h1:wvStE9wLpws31NiWUx+38wny1msZ/tm+eL5xmm4Y7So=
+github.com/Netflix/go-env v0.0.0-20220526054621-78278af1949d/go.mod h1:9XMFaCeRyW7fC9XJOWQ+NdAv8VLG7ys7l3x4ozEGLUQ=
 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=
diff --git a/internal/status/status.go b/internal/status/status.go
index 600978b9..082d39fa 100644
--- a/internal/status/status.go
+++ b/internal/status/status.go
@@ -2,15 +2,47 @@ package status
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"io"
 	"os"
 
+	"github.com/BurntSushi/toml"
+	"github.com/joho/godotenv"
 	"github.com/spf13/afero"
 	"github.com/supabase/cli/internal/utils"
+	"gopkg.in/yaml.v3"
 )
 
-func Run(ctx context.Context, fsys afero.Fs) error {
+const (
+	OutputEnv    = "env"
+	OutputJson   = "json"
+	OutputPretty = "pretty"
+	OutputToml   = "toml"
+	OutputYaml   = "yaml"
+)
+
+type CustomName struct {
+	ApiURL         string `env:"api.url,default=API_URL"`
+	DbURL          string `env:"db.url,default=DB_URL"`
+	StudioURL      string `env:"studio.url,default=STUDIO_URL"`
+	InbucketURL    string `env:"inbucket.url,default=INBUCKET_URL"`
+	AnonKey        string `env:"auth.anon_key,default=ANON_KEY"`
+	ServiceRoleKey string `env:"auth.service_role_key,default=SERVICE_ROLE_KEY"`
+}
+
+func (c *CustomName) toValues() map[string]string {
+	return map[string]string{
+		c.ApiURL:         fmt.Sprintf("http://localhost:%d", utils.Config.Api.Port),
+		c.DbURL:          fmt.Sprintf("postgresql://postgres:postgres@localhost:%d/postgres", utils.Config.Db.Port),
+		c.StudioURL:      fmt.Sprintf("http://localhost:%d", utils.Config.Studio.Port),
+		c.InbucketURL:    fmt.Sprintf("http://localhost:%d", utils.Config.Inbucket.Port),
+		c.AnonKey:        utils.AnonKey,
+		c.ServiceRoleKey: utils.ServiceRoleKey,
+	}
+}
+
+func Run(ctx context.Context, names CustomName, format string, fsys afero.Fs) error {
 	// Sanity checks.
 	{
 		if err := utils.AssertSupabaseCliIsSetUpFS(fsys); err != nil {
@@ -35,21 +67,43 @@ func Run(ctx context.Context, fsys afero.Fs) error {
 		utils.PgmetaId,
 		utils.StudioId,
 	}
-	return showServiceHealth(ctx, services, os.Stderr)
+	if err := checkServiceHealth(ctx, services, os.Stderr); err != nil {
+		return err
+	}
+	return printStatus(names.toValues(), format, os.Stdout)
 }
 
-func showServiceHealth(ctx context.Context, services []string, stderr io.Writer) error {
+func checkServiceHealth(ctx context.Context, services []string, w io.Writer) error {
 	for _, name := range services {
 		resp, err := utils.Docker.ContainerInspect(ctx, name)
 		if err != nil {
 			return fmt.Errorf("%s container not found. Have your run %s?", name, utils.Aqua("supabase start"))
 		}
 		if !resp.State.Running {
-			fmt.Fprintln(stderr, name, "container is not running:", resp.State.Status)
+			fmt.Fprintln(w, name, "container is not running:", resp.State.Status)
 		}
 	}
-
-	fmt.Fprintln(stderr, utils.Aqua("supabase"), "local development setup is running.")
-	utils.ShowStatus()
 	return nil
 }
+
+func printStatus(values map[string]string, format string, w io.Writer) (err error) {
+	switch format {
+	case OutputEnv:
+		var out string
+		out, err = godotenv.Marshal(values)
+		fmt.Fprintln(w, out)
+	case OutputJson:
+		enc := json.NewEncoder(w)
+		err = enc.Encode(values)
+	case OutputYaml:
+		enc := yaml.NewEncoder(w)
+		err = enc.Encode(values)
+	case OutputToml:
+		enc := toml.NewEncoder(w)
+		err = enc.Encode(values)
+	default:
+		fmt.Fprintln(os.Stderr, utils.Aqua("supabase"), "local development setup is running.")
+		utils.ShowStatus()
+	}
+	return err
+}


Test patch:
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index adfdec7e..83252aca 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -4,11 +4,11 @@ import (
 	"bytes"
 	"context"
 	"errors"
+	"io"
 	"net/http"
 	"testing"
 
 	"github.com/docker/docker/api/types"
-	"github.com/docker/docker/client"
 	"github.com/spf13/afero"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -18,10 +18,45 @@ import (
 )
 
 func TestStatusCommand(t *testing.T) {
-	const version = "1.41"
+	t.Run("shows service status", func(t *testing.T) {
+		services := []string{
+			"supabase_db_",
+			"supabase_kong_",
+			"supabase_auth_",
+			"supabase_inbucket_",
+			"supabase_realtime_",
+			"supabase_rest_",
+			"supabase_storage_",
+			"supabase_pg_meta_",
+			"supabase_studio_",
+		}
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		require.NoError(t, utils.WriteConfig(fsys, false))
+		// Setup mock docker
+		require.NoError(t, apitest.MockDocker(utils.Docker))
+		defer gock.OffAll()
+		gock.New(utils.Docker.DaemonHost()).
+			Head("/_ping").
+			Reply(http.StatusOK).
+			SetHeader("API-Version", utils.Docker.ClientVersion()).
+			SetHeader("OSType", "linux")
+		for _, container := range services {
+			gock.New(utils.Docker.DaemonHost()).
+				Get("/v" + utils.Docker.ClientVersion() + "/containers/" + container).
+				Reply(http.StatusOK).
+				JSON(types.ContainerJSON{ContainerJSONBase: &types.ContainerJSONBase{
+					State: &types.ContainerState{Running: true},
+				}})
+		}
+		// Run test
+		assert.NoError(t, Run(context.Background(), CustomName{}, OutputPretty, fsys))
+		// Check error
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
 
 	t.Run("throws error on missing config", func(t *testing.T) {
-		err := Run(context.Background(), afero.NewMemMapFs())
+		err := Run(context.Background(), CustomName{}, OutputPretty, afero.NewMemMapFs())
 		assert.ErrorContains(t, err, "Have you set up the project with supabase init?")
 	})
 
@@ -30,7 +65,7 @@ func TestStatusCommand(t *testing.T) {
 		fsys := afero.NewMemMapFs()
 		require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("malformed"), 0644))
 		// Run test
-		err := Run(context.Background(), fsys)
+		err := Run(context.Background(), CustomName{}, OutputPretty, fsys)
 		// Check error
 		assert.ErrorContains(t, err, "Failed to read config: toml")
 	})
@@ -40,16 +75,16 @@ func TestStatusCommand(t *testing.T) {
 		fsys := afero.NewMemMapFs()
 		require.NoError(t, utils.WriteConfig(fsys, false))
 		// Setup mock docker
-		require.NoError(t, client.WithHTTPClient(http.DefaultClient)(utils.Docker))
+		require.NoError(t, apitest.MockDocker(utils.Docker))
 		defer gock.OffAll()
-		gock.New("http:///var/run/docker.sock").
+		gock.New(utils.Docker.DaemonHost()).
 			Head("/_ping").
-			ReplyError(errors.New("network error"))
-		gock.New("http:///var/run/docker.sock").
+			Reply(http.StatusServiceUnavailable)
+		gock.New(utils.Docker.DaemonHost()).
 			Get("/_ping").
 			ReplyError(errors.New("network error"))
 		// Run test
-		err := Run(context.Background(), fsys)
+		err := Run(context.Background(), CustomName{}, OutputPretty, fsys)
 		// Check error
 		assert.ErrorContains(t, err, "network error")
 		assert.Empty(t, apitest.ListUnmatchedRequests())
@@ -60,28 +95,18 @@ func TestStatusCommand(t *testing.T) {
 		fsys := afero.NewMemMapFs()
 		require.NoError(t, utils.WriteConfig(fsys, false))
 		// Setup mock docker
-		require.NoError(t, client.WithHTTPClient(http.DefaultClient)(utils.Docker))
+		require.NoError(t, apitest.MockDocker(utils.Docker))
 		defer gock.OffAll()
-		gock.New("http:///var/run/docker.sock").
+		gock.New(utils.Docker.DaemonHost()).
 			Head("/_ping").
 			Reply(http.StatusOK).
-			SetHeader("API-Version", version).
+			SetHeader("API-Version", utils.Docker.ClientVersion()).
 			SetHeader("OSType", "linux")
-		gock.New("http:///var/run/docker.sock").
-			Get("/_ping").
-			Reply(http.StatusOK).
-			SetHeader("API-Version", version).
-			SetHeader("OSType", "linux")
-		gock.New("http:///var/run/docker.sock").
-			Head("/_ping").
-			Reply(http.StatusOK).
-			SetHeader("API-Version", version).
-			SetHeader("OSType", "linux")
-		gock.New("http:///var/run/docker.sock").
-			Get("/v" + version + "/containers").
+		gock.New(utils.Docker.DaemonHost()).
+			Get("/v" + utils.Docker.ClientVersion() + "/containers").
 			Reply(http.StatusNotFound)
 		// Run test
-		err := Run(context.Background(), fsys)
+		err := Run(context.Background(), CustomName{}, OutputPretty, fsys)
 		// Check error
 		assert.ErrorContains(t, err, "container not found. Have your run supabase start?")
 		assert.Empty(t, apitest.ListUnmatchedRequests())
@@ -89,36 +114,80 @@ func TestStatusCommand(t *testing.T) {
 }
 
 func TestServiceHealth(t *testing.T) {
-	const version = "1.41"
+	services := []string{"supabase_db", "supabase_auth"}
 
 	t.Run("checks all services", func(t *testing.T) {
-		services := []string{"supabase_db", "supabase_auth"}
 		// Setup mock docker
-		require.NoError(t, client.WithHTTPClient(http.DefaultClient)(utils.Docker))
+		require.NoError(t, apitest.MockDocker(utils.Docker))
 		defer gock.OffAll()
-		gock.New("http:///var/run/docker.sock").
-			Head("/_ping").
-			Reply(http.StatusOK).
-			SetHeader("API-Version", version).
-			SetHeader("OSType", "linux")
-		gock.New("http:///var/run/docker.sock").
-			Get("/v" + version + "/containers/" + services[0] + "/json").
+		gock.New(utils.Docker.DaemonHost()).
+			Get("/v" + utils.Docker.ClientVersion() + "/containers/" + services[0] + "/json").
 			Reply(http.StatusOK).
 			JSON(types.ContainerJSON{ContainerJSONBase: &types.ContainerJSONBase{
 				State: &types.ContainerState{Running: true},
 			}})
-		gock.New("http:///var/run/docker.sock").
-			Get("/v" + version + "/containers/" + services[1] + "/json").
+		gock.New(utils.Docker.DaemonHost()).
+			Get("/v" + utils.Docker.ClientVersion() + "/containers/" + services[1] + "/json").
 			Reply(http.StatusOK).
 			JSON(types.ContainerJSON{ContainerJSONBase: &types.ContainerJSONBase{
 				State: &types.ContainerState{Status: "exited"},
 			}})
 		// Run test
 		var stderr bytes.Buffer
-		assert.NoError(t, showServiceHealth(context.Background(), services, &stderr))
+		assert.NoError(t, checkServiceHealth(context.Background(), services, &stderr))
 		// Check error
-		assert.Contains(t, stderr.String(), "supabase_auth container is not running: exited")
-		assert.Contains(t, stderr.String(), "supabase local development setup is running.")
+		assert.Equal(t, "supabase_auth container is not running: exited\n", stderr.String())
 		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})
+
+	t.Run("throws error on missing container", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		require.NoError(t, utils.WriteConfig(fsys, false))
+		// Setup mock docker
+		require.NoError(t, apitest.MockDocker(utils.Docker))
+		defer gock.OffAll()
+		gock.New(utils.Docker.DaemonHost()).
+			Get("/v" + utils.Docker.ClientVersion() + "/containers/" + services[0] + "/json").
+			Reply(http.StatusNotFound)
+		// Run test
+		err := checkServiceHealth(context.Background(), services, io.Discard)
+		// Check error
+		assert.ErrorContains(t, err, "supabase_db container not found. Have your run supabase start?")
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+}
+
+func TestPrintStatus(t *testing.T) {
+	t.Run("outputs env var", func(t *testing.T) {
+		// Run test
+		var stdout bytes.Buffer
+		assert.NoError(t, printStatus(map[string]string{"2": "b", "1": "a"}, OutputEnv, &stdout))
+		// Check error
+		assert.Equal(t, "1=\"a\"\n2=\"b\"\n", stdout.String())
+	})
+
+	t.Run("outputs json object", func(t *testing.T) {
+		// Run test
+		var stdout bytes.Buffer
+		assert.NoError(t, printStatus(map[string]string{"2": "b", "1": "a"}, OutputJson, &stdout))
+		// Check error
+		assert.Equal(t, "{\"1\":\"a\",\"2\":\"b\"}\n", stdout.String())
+	})
+
+	t.Run("outputs yaml properties", func(t *testing.T) {
+		// Run test
+		var stdout bytes.Buffer
+		assert.NoError(t, printStatus(map[string]string{"2": "b", "1": "a"}, OutputYaml, &stdout))
+		// Check error
+		assert.Equal(t, "\"1\": a\n\"2\": b\n", stdout.String())
+	})
+
+	t.Run("outputs toml fields", func(t *testing.T) {
+		// Run test
+		var stdout bytes.Buffer
+		assert.NoError(t, printStatus(map[string]string{"2": "b", "1": "a"}, OutputToml, &stdout))
+		// Check error
+		assert.Equal(t, "1 = \"a\"\n2 = \"b\"\n", stdout.String())
+	})
 }