diff --git a/cmd/root.go b/cmd/root.go index 35540a897..ae60e14cb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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" ) @@ -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 @@ -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") @@ -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, }) } diff --git a/internal/start/start_test.go b/internal/start/start_test.go index bbd73ab93..90efd2410 100644 --- a/internal/start/start_test.go +++ b/internal/start/start_test.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "errors" + "fmt" "net/http" "os" + "path/filepath" "regexp" "testing" @@ -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" @@ -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) { @@ -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)) +} diff --git a/internal/utils/flags/config_path.go b/internal/utils/flags/config_path.go index d0ed105c7..843f8576a 100644 --- a/internal/utils/flags/config_path.go +++ b/internal/utils/flags/config_path.go @@ -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 }