Skip to content

Add Custom Config File Support #3092

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 10 commits into
base: develop
Choose a base branch
from
9 changes: 5 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/flags"
flagsutils "github.com/supabase/cli/internal/utils/flags"
"golang.org/x/mod/semver"
)

Expand Down Expand Up @@ -104,12 +104,12 @@ var (
}
ctx, _ = signal.NotifyContext(ctx, os.Interrupt)
if cmd.Flags().Lookup("project-ref") != nil {
if err := flags.ParseProjectRef(ctx, fsys); err != nil {
if err := flagsutils.ParseProjectRef(ctx, fsys); err != nil {
return err
}
}
}
if err := flags.ParseDatabaseConfig(cmd.Flags(), fsys); err != nil {
if err := flagsutils.ParseDatabaseConfig(cmd.Flags(), fsys); err != nil {
return err
}
// Prepare context
Expand Down Expand Up @@ -231,6 +231,7 @@ func init() {
flags.String("workdir", "", "path to a Supabase project directory")
flags.Bool("experimental", false, "enable experimental features")
flags.String("network-id", "", "use the specified docker network instead of a generated one")
flags.StringVar(&flagsutils.ConfigFile, "config-file", "", "path to config file (default: supabase/config.toml)")
flags.Var(&utils.OutputFormat, "output", "output format of status variables")
flags.Var(&utils.DNSResolver, "dns-resolver", "lookup domain names using the specified resolver")
flags.BoolVar(&createTicket, "create-ticket", false, "create a support ticket for any CLI error")
Expand Down Expand Up @@ -263,6 +264,6 @@ func addSentryScope(scope *sentry.Scope) {
scope.SetContext("Services", imageToVersion)
scope.SetContext("Config", map[string]interface{}{
"Image Registry": utils.GetRegistry(),
"Project ID": flags.ProjectRef,
"Project ID": flagsutils.ProjectRef,
})
}
197 changes: 197 additions & 0 deletions internal/start/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"testing"

Expand All @@ -15,10 +17,12 @@ import (
"github.com/h2non/gock"
"github.com/jackc/pgconn"
"github.com/spf13/afero"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/supabase/cli/internal/testing/apitest"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/flags"
"github.com/supabase/cli/pkg/config"
"github.com/supabase/cli/pkg/pgtest"
"github.com/supabase/cli/pkg/storage"
Expand Down Expand Up @@ -91,6 +95,178 @@ func TestStartCommand(t *testing.T) {
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
})

t.Run("loads custom config path", func(t *testing.T) {
fsys := afero.NewMemMapFs()
customPath := filepath.ToSlash("custom/path/config.toml")
projectId := "test_project"

// Create directories and required files
require.NoError(t, fsys.MkdirAll(filepath.Dir(customPath), 0755))
require.NoError(t, fsys.MkdirAll("supabase", 0755))
require.NoError(t, afero.WriteFile(fsys, "supabase/seed.sql", []byte(""), 0644))
require.NoError(t, afero.WriteFile(fsys, "supabase/roles.sql", []byte(""), 0644))

// Store original values
originalDbId := utils.DbId
originalConfigFile := flags.ConfigFile
originalWorkdir := viper.GetString("WORKDIR")

t.Cleanup(func() {
utils.DbId = originalDbId
flags.ConfigFile = originalConfigFile
viper.Set("WORKDIR", originalWorkdir)
gock.Off()
})

// Write config file
require.NoError(t, afero.WriteFile(fsys, customPath, []byte(`
project_id = "`+projectId+`"
[db]
port = 54332
major_version = 15`), 0644))

// Setup mock docker
require.NoError(t, apitest.MockDocker(utils.Docker))

// Mock container list check
gock.New(utils.Docker.DaemonHost()).
Get("/v" + utils.Docker.ClientVersion() + "/containers/json").
Reply(http.StatusOK).
JSON([]types.Container{})

// Mock container health check
utils.DbId = "supabase_db_" + projectId
gock.New(utils.Docker.DaemonHost()).
Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json").
Times(2).
Reply(http.StatusOK).
JSON(types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
State: &types.ContainerState{
Running: true,
Health: &types.Health{Status: types.Healthy},
},
},
})

flags.ConfigFile = customPath

err := Run(context.Background(), fsys, []string{}, false)
assert.NoError(t, err)
assert.Equal(t, filepath.ToSlash("custom/path"), viper.GetString("WORKDIR"))
assert.Empty(t, apitest.ListUnmatchedRequests())
})

t.Run("handles absolute config path", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
absPath := "/absolute/path/config.toml"
projectId := "abs_project"

// Create directories and required files
require.NoError(t, fsys.MkdirAll("/absolute/path", 0755))
require.NoError(t, fsys.MkdirAll("/supabase", 0755))
require.NoError(t, afero.WriteFile(fsys, "/supabase/seed.sql", []byte(""), 0644))
require.NoError(t, afero.WriteFile(fsys, "/supabase/roles.sql", []byte(""), 0644))

// Store original values
originalDbId := utils.DbId
originalConfigFile := flags.ConfigFile
originalWorkdir := viper.GetString("WORKDIR")

t.Cleanup(func() {
utils.DbId = originalDbId
flags.ConfigFile = originalConfigFile
viper.Set("WORKDIR", originalWorkdir)
gock.Off()
})

// Write config file
require.NoError(t, afero.WriteFile(fsys, absPath, []byte(`
project_id = "`+projectId+`"
[db]
port = 54332
major_version = 15`), 0644))

// Setup mock docker
require.NoError(t, apitest.MockDocker(utils.Docker))
defer gock.OffAll()

// Mock container list check
gock.New(utils.Docker.DaemonHost()).
Get("/v" + utils.Docker.ClientVersion() + "/containers/json").
Reply(http.StatusOK).
JSON([]types.Container{})

// Mock container health check
utils.DbId = "supabase_db_" + projectId
gock.New(utils.Docker.DaemonHost()).
Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json").
Times(2).
Reply(http.StatusOK).
JSON(types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
State: &types.ContainerState{
Running: true,
Health: &types.Health{Status: types.Healthy},
},
},
})

// Set the custom config path
flags.ConfigFile = absPath

// Run test
err := Run(context.Background(), fsys, []string{}, false)
assert.NoError(t, err)
assert.Equal(t, "/absolute/path", viper.GetString("WORKDIR"))
assert.Empty(t, apitest.ListUnmatchedRequests())
})

t.Run("handles non-existent config directory", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
nonExistentPath := "non/existent/path/config.toml"

// Store original values
originalConfigFile := flags.ConfigFile

t.Cleanup(func() {
flags.ConfigFile = originalConfigFile
})

// Set the custom config path
flags.ConfigFile = nonExistentPath

// Run test
err := Run(context.Background(), fsys, []string{}, false)
assert.ErrorIs(t, err, os.ErrNotExist)
})

t.Run("handles malformed config in custom path", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
customPath := "custom/config.toml"

// Create directory and malformed config
require.NoError(t, fsys.MkdirAll("custom", 0755))
require.NoError(t, afero.WriteFile(fsys, customPath, []byte("malformed toml content"), 0644))

// Store original values
originalConfigFile := flags.ConfigFile

t.Cleanup(func() {
flags.ConfigFile = originalConfigFile
})

// Set the custom config path
flags.ConfigFile = customPath

// Run test
err := Run(context.Background(), fsys, []string{}, false)
assert.ErrorContains(t, err, "toml: ")
})
}

func TestDatabaseStart(t *testing.T) {
Expand Down Expand Up @@ -283,3 +459,24 @@ func TestFormatMapForEnvConfig(t *testing.T) {
}
})
}

// Helper function to reduce duplication
func setupTestConfig(t *testing.T, fsys afero.Fs, configPath, projectId string, isAbsolute bool) {
// Create directories and required files
require.NoError(t, fsys.MkdirAll(filepath.Dir(configPath), 0755))
supabasePath := "supabase"
if isAbsolute {
supabasePath = "/supabase"
}
require.NoError(t, fsys.MkdirAll(supabasePath, 0755))
require.NoError(t, afero.WriteFile(fsys, filepath.Join(supabasePath, "seed.sql"), []byte(""), 0644))
require.NoError(t, afero.WriteFile(fsys, filepath.Join(supabasePath, "roles.sql"), []byte(""), 0644))

// Write minimal config file
configContent := fmt.Sprintf(`
project_id = "%s"
[db]
port = 54332
major_version = 15`, projectId)
require.NoError(t, afero.WriteFile(fsys, configPath, []byte(configContent), 0644))
}
44 changes: 43 additions & 1 deletion internal/utils/flags/config_path.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,62 @@ package flags
import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/go-errors/errors"
"github.com/spf13/afero"
"github.com/spf13/viper"
"github.com/supabase/cli/internal/utils"
)

var ConfigFile string

func LoadConfig(fsys afero.Fs) error {
// Early return if no config file specified
if ConfigFile == "" {
utils.Config.ProjectId = ProjectRef
return nil
}

utils.Config.ProjectId = ProjectRef
if err := utils.Config.Load("", utils.NewRootFS(fsys)); err != nil {

// Step 1: Normalize the config path
configPath := filepath.ToSlash(ConfigFile)

// Step 2: Handle absolute paths and set workdir
var workdir string
if filepath.IsAbs(ConfigFile) {
// Remove drive letter if present (Windows)
if i := strings.Index(configPath, ":"); i > 0 {
configPath = configPath[i+1:]
}
// Ensure path starts with /
if !strings.HasPrefix(configPath, "/") {
configPath = "/" + configPath
}
workdir = filepath.Dir(configPath)
} else {
workdir = filepath.Dir(configPath)
}

// Step 3: Normalize workdir
workdir = filepath.ToSlash(workdir)
if filepath.IsAbs(ConfigFile) && !strings.HasPrefix(workdir, "/") {
workdir = "/" + workdir
}

// Step 4: Set workdir in viper
viper.Set("WORKDIR", workdir)

// Step 5: Load and validate config
if err := utils.Config.Load(configPath, utils.NewRootFS(fsys)); err != nil {
if errors.Is(err, os.ErrNotExist) {
utils.CmdSuggestion = fmt.Sprintf("Have you set up the project with %s?", utils.Aqua("supabase init"))
}
return err
}

utils.UpdateDockerIds()
return nil
}