diff --git a/CLAUDE.md b/CLAUDE.md index f5af977d..07ee4620 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,12 +62,14 @@ Uses Viper with TOML format. lstk uses the first `config.toml` found in this ord When no config file exists, lstk creates one at `$HOME/.config/lstk/config.toml` if `$HOME/.config/` already exists, otherwise at the OS default (#3). This means #3 is only reached on macOS when `$HOME/.config/` didn't exist at first run. Use `lstk config path` to print the resolved config file path currently in use. -When adding a new command that depends on configuration, wire config initialization explicitly in that command (`PreRunE: initConfig`). Keep side-effect-free commands (e.g., `version`, `config path`) without config initialization. +When adding a new command that depends on configuration, wire config initialization explicitly in that command (`PreRunE: initConfigDeferCreate`). Keep side-effect-free commands (e.g., `version`, `config path`) without config initialization. A parent command that only groups subcommands (e.g. `config`, `setup`, `volume`, `snapshot`) must call `requireSubcommand(cmd)` (in `cmd/root.go`). Cobra otherwise prints help and exits 0 for an unknown/missing subcommand of a non-runnable parent; `requireSubcommand` sets `cobra.NoArgs` plus a help-printing `RunE` so a bare invocation still shows help (exit 0) while an unknown subcommand exits non-zero. Cobra's autogenerated `completion` command is the same shape, but it is created lazily during `Execute`, so `NewRootCmd` calls `root.InitDefaultCompletionCmd()` to materialize it before applying `requireSubcommand` (the call is idempotent — Cobra skips re-adding it). Created automatically on first run with defaults. Supports emulator types: `aws`, `snowflake`, and `azure`. +`initConfigDeferCreate` (wrapping `config.Load`) only ever *reads* config — it never writes the default config.toml to disk. That's deliberate: the emulator-selection prompt (`container.SelectEmulator`) is shown only when `firstRun` is still true, and only bare `lstk` and `lstk start` wire it in (`NeedsEmulatorSelection: firstRun` in `startEmulator`). If some other command eagerly persisted a default (`type = "aws"`) config on its own first run, the selector would never get a chance to show on a genuinely fresh install — every command must use `initConfigDeferCreate`, never a hypothetical eager-create variant, so that only a real emulator start (interactive selection, or the non-interactive default-emulator path) ever writes the file. `EnsureCreated()` therefore has exactly two legitimate callers: the non-interactive first-run path in `cmd/root.go` (after a successful default start) and `container.SelectEmulator` (after the user picks one). + Only one `[[containers]]` block may be enabled at a time. `container.Start` rejects a config with more than one block up front (before health/auth checks and image pulls), since running multiple emulators together (e.g. AWS + Snowflake) is unsupported and would otherwise fail later during startup with container-name conflicts or port collisions. The guard lives on the start path (not `config.Get()`) on purpose: recovery/reporting commands like `stop`, `status`, and `logout` must still enumerate multiple running emulators. Each `[[containers]]` block may set an optional `image` to override the default Docker Hub image (e.g. an internal registry mirror or a locally loaded offline image). `ContainerConfig.Image()` returns `image` as-is when it already carries a tag (so the separately-configured `tag` is dropped in that case), otherwise it appends `tag` (or `latest`); the default `localstack/:` is used when `image` is unset. diff --git a/cmd/aws.go b/cmd/aws.go index 686ee86c..28048a3b 100644 --- a/cmd/aws.go +++ b/cmd/aws.go @@ -53,7 +53,7 @@ Examples: return err } } - return initConfig(nil)(cmd, args) + return initConfigDeferCreate(nil)(cmd, args) }, RunE: func(cmd *cobra.Command, _ []string) error { sink := output.NewPlainSink(os.Stdout) diff --git a/cmd/az.go b/cmd/az.go index 82aa03c2..5b99d357 100644 --- a/cmd/az.go +++ b/cmd/az.go @@ -40,7 +40,7 @@ Examples: if jsonPrecedesCommandName(cmd.CalledAs()) { cfg.JSON = true } - return initConfig(nil)(cmd, args) + return initConfigDeferCreate(nil)(cmd, args) }, RunE: func(cmd *cobra.Command, args []string) error { sink := output.NewPlainSink(os.Stdout) @@ -90,7 +90,7 @@ func newAzStartInterceptionCmd(cfg *env.Env) *cobra.Command { Short: "Redirect global 'az' to the LocalStack Azure emulator", Long: "Register and activate a custom 'LocalStack' cloud in your global Azure CLI configuration (~/.azure) so that plain 'az' commands in any terminal target the LocalStack Azure emulator. This lets existing 'az' scripts run unmodified against LocalStack. It changes global state affecting every 'az' invocation until you run 'lstk az stop-interception'; this is independent of the isolated 'lstk az' setup.", Args: cobra.NoArgs, - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { preflight := func(ctx context.Context, sink output.Sink) (string, error) { return azPreflight(ctx, cfg, sink) @@ -119,7 +119,7 @@ func newAzStopInterceptionCmd(cfg *env.Env) *cobra.Command { Short: "Switch global 'az' back to real Azure", Long: "Switch your global Azure CLI cloud away from the LocalStack emulator back to real Azure (AzureCloud by default; use --cloud to choose another registered cloud) and re-enable instance discovery. To avoid clobbering an unrelated selection, it only changes the active cloud when 'LocalStack' is currently active; otherwise it reports the current cloud and does nothing.", Args: cobra.NoArgs, - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { if isInteractiveMode(cfg) { return ui.RunStopInterception(cmd.Context(), cloud) diff --git a/cmd/cdk.go b/cmd/cdk.go index da430a65..7a756021 100644 --- a/cmd/cdk.go +++ b/cmd/cdk.go @@ -54,7 +54,7 @@ Examples: return err } } - return initConfig(nil)(cmd, args) + return initConfigDeferCreate(nil)(cmd, args) }, RunE: func(cmd *cobra.Command, _ []string) error { sink := output.NewPlainSink(os.Stdout) diff --git a/cmd/login.go b/cmd/login.go index 55028bc6..0f26aafc 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -19,7 +19,7 @@ func newLoginCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra. Use: "login", Short: "Manage login", Long: "Manage login and store credentials in system keyring", - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { if !isInteractiveMode(cfg) { return fmt.Errorf("login requires an interactive terminal") diff --git a/cmd/logout.go b/cmd/logout.go index e573b047..1b6ccfbc 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -21,7 +21,7 @@ func newLogoutCmd(cfg *env.Env, logger log.Logger) *cobra.Command { return &cobra.Command{ Use: "logout", Short: "Remove stored authentication credentials", - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { platformClient := api.NewPlatformClient(cfg.APIEndpoint, logger) appConfig, err := config.Get() diff --git a/cmd/logs.go b/cmd/logs.go index 3f43a70b..823ebdec 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -18,7 +18,7 @@ func newLogsCmd(cfg *env.Env) *cobra.Command { Use: "logs", Short: "Show emulator logs", Long: "Show logs from the emulator. Use --follow to stream in real-time.", - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { follow, err := cmd.Flags().GetBool("follow") if err != nil { diff --git a/cmd/reset.go b/cmd/reset.go index 4933eb80..b5a449bf 100644 --- a/cmd/reset.go +++ b/cmd/reset.go @@ -27,7 +27,7 @@ func newResetCmd(cfg *env.Env) *cobra.Command { All resources created in the emulator (S3 buckets, Lambda functions, etc.) are discarded. The emulator keeps running; only its state is cleared. To wipe the on-disk volume (certificates, persistence data, cached tools) instead, stop the emulator and run "lstk volume clear".`, - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { appConfig, err := config.Get() if err != nil { diff --git a/cmd/restart.go b/cmd/restart.go index 754d9425..bb987185 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -20,7 +20,7 @@ func newRestartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobr Use: "restart", Short: "Restart emulator", Long: "Stop and restart emulator and services.", - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { diff --git a/cmd/root.go b/cmd/root.go index 1ee95575..ad6095f3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -476,10 +476,6 @@ func newLogger() (log.Logger, func(), error) { return log.New(f), func() { _ = f.Close() }, nil } -func initConfig(firstRun *bool) func(*cobra.Command, []string) error { - return initConfigWith(firstRun, config.Init) -} - func initConfigDeferCreate(firstRun *bool) func(*cobra.Command, []string) error { return initConfigWith(firstRun, config.Load) } diff --git a/cmd/sam.go b/cmd/sam.go index 2627649c..f1652043 100644 --- a/cmd/sam.go +++ b/cmd/sam.go @@ -56,7 +56,7 @@ Examples: return err } } - return initConfig(nil)(cmd, args) + return initConfigDeferCreate(nil)(cmd, args) }, RunE: func(cmd *cobra.Command, _ []string) error { sink := output.NewPlainSink(os.Stdout) diff --git a/cmd/setup.go b/cmd/setup.go index c2064a45..18f0ea09 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -31,7 +31,7 @@ func newSetupAWSCmd(cfg *env.Env) *cobra.Command { Use: "aws", Short: "Set up the LocalStack AWS profile", Long: "Set up the LocalStack AWS profile in ~/.aws/config and ~/.aws/credentials for use with AWS CLI and SDKs.", - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { appConfig, err := config.Get() if err != nil { @@ -68,7 +68,7 @@ func newSetupAzureCmd(cfg *env.Env) *cobra.Command { Aliases: []string{"az"}, Short: "Set up Azure CLI integration with LocalStack", Long: "Prepare an isolated Azure CLI config directory that routes 'lstk az' commands to the LocalStack Azure emulator. Your global ~/.azure configuration is left untouched. Requires the `az` CLI and a running LocalStack Azure emulator.", - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { appConfig, err := config.Get() if err != nil { diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 8ddc3cd0..52346872 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -199,7 +199,7 @@ func newSnapshotLoadCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) Short: "Load a snapshot into the running emulator", Long: snapshotLoadLong(snapshotLoadCanonical), Args: cobra.RangeArgs(1, 2), - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: runSnapshotLoad(cfg, tel, logger), } addMergeFlag(cmd) @@ -213,7 +213,7 @@ func newLoadCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C Short: "Load a snapshot into the running emulator", Long: snapshotLoadLong("load"), Args: cobra.RangeArgs(1, 2), - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: runSnapshotLoad(cfg, tel, logger), Annotations: map[string]string{canonicalCommandAnnotation: snapshotLoadCanonical}, } @@ -309,7 +309,7 @@ func newSnapshotRemoveCmd(cfg *env.Env) *cobra.Command { Short: "Delete a cloud snapshot from the LocalStack platform", Long: snapshotRemoveLong, Args: cobra.ExactArgs(1), - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: runSnapshotRemove(cfg), } cmd.Flags().Bool("force", false, "Skip confirmation prompt") @@ -457,7 +457,7 @@ func newSnapshotListCmd(cfg *env.Env, logger log.Logger) *cobra.Command { Short: "List Cloud Pod snapshots available on the LocalStack platform", Long: snapshotListLong, Args: cobra.MaximumNArgs(1), - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: runSnapshotList(cfg, logger), } cmd.Flags().Bool("all", false, "List all snapshots in the organisation") @@ -522,7 +522,7 @@ func newSnapshotShowCmd(cfg *env.Env, logger log.Logger) *cobra.Command { Short: "Show metadata for a cloud snapshot", Long: snapshotShowLong, Args: cobra.ExactArgs(1), - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: runSnapshotShow(cfg, logger), } } @@ -558,7 +558,7 @@ func newSnapshotSaveCmd(cfg *env.Env) *cobra.Command { Short: "Save a snapshot of the emulator state", Long: snapshotSaveLong(snapshotSaveCanonical), Args: cobra.MaximumNArgs(2), - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: runSnapshotSave(cfg), } addProfileFlag(cmd) @@ -571,7 +571,7 @@ func newSaveCmd(cfg *env.Env) *cobra.Command { Short: "Save a snapshot of the emulator state", Long: snapshotSaveLong("save"), Args: cobra.MaximumNArgs(2), - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: runSnapshotSave(cfg), Annotations: map[string]string{canonicalCommandAnnotation: snapshotSaveCanonical}, } diff --git a/cmd/status.go b/cmd/status.go index 62d1c1c9..f100f995 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -22,7 +22,7 @@ func newStatusCmd(cfg *env.Env) *cobra.Command { Use: "status", Short: "Show emulator status and deployed resources", Long: "Show the status of a running emulator and its deployed resources", - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { diff --git a/cmd/stop.go b/cmd/stop.go index 79f5ef59..4be94a39 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -19,7 +19,7 @@ func newStopCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { Use: "stop", Short: "Stop emulator", Long: "Stop emulator and services", - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { diff --git a/cmd/terraform.go b/cmd/terraform.go index 9c3fe631..47bc51d7 100644 --- a/cmd/terraform.go +++ b/cmd/terraform.go @@ -54,7 +54,7 @@ Examples: return err } } - return initConfig(nil)(cmd, args) + return initConfigDeferCreate(nil)(cmd, args) }, RunE: func(cmd *cobra.Command, _ []string) error { sink := output.NewPlainSink(os.Stdout) diff --git a/cmd/update.go b/cmd/update.go index 3235469b..de96be13 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -17,7 +17,7 @@ func newUpdateCmd(cfg *env.Env) *cobra.Command { Use: "update", Short: "Update lstk to the latest version", Long: "Check for and apply updates to the lstk CLI. Respects the original installation method (Homebrew, npm, or direct binary).", - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { if isInteractiveMode(cfg) { return ui.RunUpdate(cmd.Context(), checkOnly, cfg.GitHubToken) diff --git a/cmd/volume.go b/cmd/volume.go index 1791c6e8..95adb019 100644 --- a/cmd/volume.go +++ b/cmd/volume.go @@ -27,7 +27,7 @@ func newVolumePathCmd(cfg *env.Env) *cobra.Command { return &cobra.Command{ Use: "path", Short: "Print the volume directory path", - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { appConfig, err := config.Get() if err != nil { @@ -57,7 +57,7 @@ func newVolumeClearCmd(cfg *env.Env) *cobra.Command { Use: "clear", Short: "Clear emulator volume data", Long: "Remove all data from the emulator volume directory. This resets cached state such as certificates, downloaded tools, and persistence data.", - PreRunE: initConfig(nil), + PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { appConfig, err := config.Get() if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 34d4cc34..8a9fb544 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,7 +52,9 @@ func InitFromPath(path string) error { return loadConfig(path) } -// Load reads config.toml without creating it; Init creates on first run. +// Load reads config.toml without creating it; callers that need to create the +// default config on first run should call EnsureCreated once ready to persist it +// (e.g. after an emulator-selection prompt, or after a successful default start). func Load() (firstRun bool, err error) { viper.Reset() setDefaults() @@ -80,14 +82,6 @@ func Load() (firstRun bool, err error) { return false, nil } -func Init() (firstRun bool, err error) { - firstRun, err = Load() - if err != nil || !firstRun { - return firstRun, err - } - return true, EnsureCreated() -} - func EnsureCreated() error { if resolvedConfigPath() != "" { return nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 113e99c5..ef8b0be1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -41,6 +41,67 @@ func TestLoadFirstRunAfterConfigDirRecreated(t *testing.T) { assert.True(t, firstRun, "Load() should return firstRun=true when the config directory exists but config.toml does not") } +// TestEnsureCreatedPrefersHomeConfigDirWhenPresent and +// TestEnsureCreatedFallsBackToOSConfigDirWhenHomeConfigMissing cover the +// config-creation path-resolution policy directly (rather than through an +// arbitrary CLI command): $HOME/.config/lstk when $HOME/.config already +// exists, otherwise the OS default. Only `start`/bare `lstk` ever call +// EnsureCreated on a genuine first run — see the "Choosing an emulator" note +// in CLAUDE.md — so this is tested at the config-package level instead of by +// running some other command that happens to trigger it. +func TestEnsureCreatedPrefersHomeConfigDirWhenPresent(t *testing.T) { + // Cannot run in parallel: mutates process-wide HOME env and viper state. + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + t.Setenv("XDG_CONFIG_HOME", "") + viper.Reset() + t.Cleanup(viper.Reset) + + require.NoError(t, os.MkdirAll(filepath.Join(fakeHome, ".config"), 0755)) + + firstRun, err := Load() + require.NoError(t, err) + require.True(t, firstRun) + require.NoError(t, EnsureCreated()) + + expectedConfigFile := filepath.Join(fakeHome, ".config", "lstk", "config.toml") + assert.FileExists(t, expectedConfigFile) + assertDefaultConfigContent(t, expectedConfigFile) +} + +func TestEnsureCreatedFallsBackToOSConfigDirWhenHomeConfigMissing(t *testing.T) { + // Cannot run in parallel: mutates process-wide HOME env and viper state. + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + t.Setenv("XDG_CONFIG_HOME", "") + viper.Reset() + t.Cleanup(viper.Reset) + + firstRun, err := Load() + require.NoError(t, err) + require.True(t, firstRun) + require.NoError(t, EnsureCreated()) + + osConfigDir, err := osConfigDir() + require.NoError(t, err) + expectedConfigFile := filepath.Join(osConfigDir, "config.toml") + assert.FileExists(t, expectedConfigFile) + assertDefaultConfigContent(t, expectedConfigFile) +} + +func assertDefaultConfigContent(t *testing.T, path string) { + t.Helper() + content, err := os.ReadFile(path) + require.NoError(t, err) + configStr := string(content) + assert.Contains(t, configStr, "type") + assert.Contains(t, configStr, "aws") + assert.Contains(t, configStr, "tag") + assert.Contains(t, configStr, "latest") + assert.Contains(t, configStr, "port") + assert.Contains(t, configStr, "4566") +} + func TestSetInFileAppendsWhenKeyAbsent(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") original := `# User comment diff --git a/test/integration/awsconfig_test.go b/test/integration/awsconfig_test.go index a8513e8b..e216435d 100644 --- a/test/integration/awsconfig_test.go +++ b/test/integration/awsconfig_test.go @@ -21,6 +21,11 @@ import ( // isolated temp directory, so tests never touch the real ~/.aws files. Both HOME // (Unix) and USERPROFILE (Windows) are overridden because os.UserHomeDir — which // awsconfig uses to locate ~/.aws — reads USERPROFILE, not HOME, on Windows. +// +// A minimal AWS-emulator config.toml is pre-written so `lstk start` doesn't hit +// the first-run emulator-selection prompt (config.toml already exists, so it's +// not a first run) — these tests are about the post-start AWS-profile flow, not +// emulator selection, which is covered separately in emulator_select_test.go. func awsConfigEnv(t *testing.T) (env.Environ, string) { t.Helper() tmpHome := t.TempDir() @@ -34,6 +39,7 @@ func awsConfigEnv(t *testing.T) (env.Environ, string) { _ = exec.Command("docker", "run", "--rm", "-v", volumeDir+":/d", "alpine", "sh", "-c", "rm -rf /d/*").Run() } }) + writeConfigFile(t, filepath.Join(tmpHome, ".config", "lstk", "config.toml")) e := env.With(env.AuthToken, env.Get(env.AuthToken)).With(env.Home, tmpHome).With(env.UserProfile, tmpHome) return e, tmpHome } diff --git a/test/integration/config_test.go b/test/integration/config_test.go index 402a4ed0..337ce500 100644 --- a/test/integration/config_test.go +++ b/test/integration/config_test.go @@ -13,42 +13,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestConfigFileCreatedOnStartup(t *testing.T) { - t.Parallel() - t.Run("creates in home .config when present", func(t *testing.T) { - t.Parallel() - tmpHome := t.TempDir() - workDir := t.TempDir() - xdgOverride := filepath.Join(tmpHome, "xdg-config-home") - require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) - - e := testEnvWithHome(tmpHome, xdgOverride) - _, stderr, err := runLstk(t, testContext(t), workDir, e, "logout") - require.NoError(t, err, stderr) - requireExitCode(t, 0, err) - - expectedConfigFile := filepath.Join(tmpHome, ".config", "lstk", "config.toml") - assert.FileExists(t, expectedConfigFile) - assertDefaultConfigContent(t, expectedConfigFile) - }) - - t.Run("falls back to os user config dir when home .config is missing", func(t *testing.T) { - t.Parallel() - tmpHome := t.TempDir() - workDir := t.TempDir() - xdgOverride := filepath.Join(tmpHome, "xdg-config-home") - - e := testEnvWithHome(tmpHome, xdgOverride) - _, stderr, err := runLstk(t, testContext(t), workDir, e, "logout") - require.NoError(t, err, stderr) - requireExitCode(t, 0, err) - - expectedConfigFile := filepath.Join(expectedOSConfigDir(tmpHome, xdgOverride), "config.toml") - assert.FileExists(t, expectedConfigFile) - assertDefaultConfigContent(t, expectedConfigFile) - }) -} - func TestConfigFlagEnvVarsPassedToContainer(t *testing.T) { requireDocker(t) _ = env.Require(t, env.AuthToken) @@ -298,19 +262,6 @@ func writeConfigFile(t *testing.T, path string) { require.NoError(t, os.WriteFile(path, []byte(content), 0644)) } -func assertDefaultConfigContent(t *testing.T, path string) { - t.Helper() - content, err := os.ReadFile(path) - require.NoError(t, err) - configStr := string(content) - assert.Contains(t, configStr, "type") - assert.Contains(t, configStr, "aws") - assert.Contains(t, configStr, "tag") - assert.Contains(t, configStr, "latest") - assert.Contains(t, configStr, "port") - assert.Contains(t, configStr, "4566") -} - func assertSamePath(t *testing.T, expectedPath, actualPath string) { t.Helper() assert.Equal( diff --git a/test/integration/emulator_select_test.go b/test/integration/emulator_select_test.go index deb638c5..49175df9 100644 --- a/test/integration/emulator_select_test.go +++ b/test/integration/emulator_select_test.go @@ -115,6 +115,61 @@ func TestFirstRunShowsEmulatorSelectionPrompt(t *testing.T) { <-outputCh } +// Running an unrelated command (one that doesn't itself start the emulator) +// before ever running `lstk start` must not silently lock in a default +// emulator. Every command other than bare `lstk`/`lstk start` defers config +// creation (initConfigDeferCreate), so it must not write config.toml on its +// own first run — otherwise firstRun would be false by the time the user +// finally runs `lstk start`, and the selector would never appear. +func TestFirstRunStillShowsSelectionPromptAfterRunningAnotherCommand(t *testing.T) { + requireDocker(t) + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tmpHome := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) + e := env.Environ(testEnvWithHome(tmpHome, tmpHome)). + With(env.DisableEvents, "1") + + configPath, _, err := runLstk(t, testContext(t), "", e, "config", "path") + require.NoError(t, err) + require.NoFileExists(t, configPath) + + // Run a command that doesn't start the emulator — this used to eagerly + // create the default (type = "aws") config via config.Init, consuming + // firstRun before the user ever saw the selector. + _, _, err = runLstk(t, testContext(t), "", e, "volume", "path") + require.NoError(t, err) + require.NoFileExists(t, configPath, "running an unrelated command must not create the default config") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binaryPath(), "start") + cmd.Env = e + + ptmx, err := pty.Start(cmd) + require.NoError(t, err, "failed to start lstk in PTY") + defer func() { _ = ptmx.Close() }() + + out := &syncBuffer{} + outputCh := make(chan struct{}) + go func() { + _, _ = io.Copy(out, ptmx) + close(outputCh) + }() + + require.Eventually(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("Which emulator would you like to use?")) + }, 10*time.Second, 100*time.Millisecond, + "emulator selection prompt should still appear on first `start` even after running another command first") + + cancel() + <-outputCh +} + func TestFirstRunCanSelectAzureEmulator(t *testing.T) { requireDocker(t) t.Parallel()