From c244432695122a113b87849482e91266a3437073 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 9 Jul 2026 18:35:34 +0200 Subject: [PATCH 1/4] fix: emulator selection not prompting on fresh install --- CLAUDE.md | 4 +- cmd/aws.go | 2 +- cmd/az.go | 6 +-- cmd/cdk.go | 2 +- cmd/login.go | 2 +- cmd/logout.go | 2 +- cmd/logs.go | 2 +- cmd/reset.go | 2 +- cmd/restart.go | 2 +- cmd/root.go | 4 -- cmd/sam.go | 2 +- cmd/setup.go | 4 +- cmd/snapshot.go | 14 +++--- cmd/status.go | 2 +- cmd/stop.go | 2 +- cmd/terraform.go | 2 +- cmd/update.go | 2 +- cmd/volume.go | 4 +- internal/config/config.go | 12 ++---- test/integration/emulator_select_test.go | 55 ++++++++++++++++++++++++ 20 files changed, 87 insertions(+), 40 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ef6ba1f1..d56cc5a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,12 +102,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(nil)`; the root command uses `initConfigDeferCreate(&firstRun)`). 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` (override the default Docker Hub image) and a `volumes` list of Docker-style bind specs (persistence dir, init hooks, arbitrary mounts). Image/tag precedence, `volume` vs `volumes` semantics, and path-resolution rules are documented in `internal/config/CLAUDE.md`. diff --git a/cmd/aws.go b/cmd/aws.go index b6741e0d..cf978cae 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 1d36b5c1..65420f48 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 aa9f52d9..5363fec2 100644 --- a/cmd/reset.go +++ b/cmd/reset.go @@ -26,7 +26,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), Annotations: map[string]string{jsonSupportedAnnotation: "true"}, RunE: func(cmd *cobra.Command, args []string) error { sink := jsonAwareSink(cmd, cfg, os.Stdout) 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 80aedddd..d056bdd2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -581,10 +581,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 20b99b0a..097fb585 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -17,7 +17,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), Annotations: map[string]string{jsonSupportedAnnotation: "true"}, RunE: func(cmd *cobra.Command, args []string) error { sink := jsonAwareSink(cmd, cfg, os.Stdout) 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 34b30590..2ecfa878 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -16,7 +16,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), Annotations: map[string]string{jsonSupportedAnnotation: "true"}, RunE: func(cmd *cobra.Command, args []string) error { sink := jsonAwareSink(cmd, cfg, os.Stdout) 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/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() From 3feaa536333ca5858c290bc46b754f9780571737 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 9 Jul 2026 19:28:24 +0200 Subject: [PATCH 2/4] test: fix AWS-profile tests hanging on emulator selector, unit-test config creation --- internal/config/config_test.go | 61 ++++++++++++++++++++++++++++++ test/integration/awsconfig_test.go | 6 +++ test/integration/config_test.go | 49 ------------------------ 3 files changed, 67 insertions(+), 49 deletions(-) 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( From c18d5c3f8d84b807f446945c5fe5cd93d6f290dc Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 21 Jul 2026 11:21:16 +0200 Subject: [PATCH 3/4] Address comments --- CLAUDE.md | 2 +- cmd/aws.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d56cc5a9..81fe598b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ A parent command that only groups subcommands (e.g. `config`, `setup`, `volume`, 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). +`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 three legitimate callers: the non-interactive first-run path in `cmd/root.go` (after a successful default start), `container.SelectEmulator` (after the user picks one), and `container.ApplyEmulatorType` (the `--type` flag's first-run path). 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. diff --git a/cmd/aws.go b/cmd/aws.go index cf978cae..299bcc5b 100644 --- a/cmd/aws.go +++ b/cmd/aws.go @@ -48,7 +48,7 @@ Examples: cfg.JSON = true } if gf.configPath != "" { - // initConfig reads the "config" flag, so feed the value back to it. + // initConfigDeferCreate reads the "config" flag, so feed the value back to it. if err := cmd.Flags().Set("config", gf.configPath); err != nil { return err } From 0382867d7a9f66fb26d1beaab0e1040fb913d454 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 21 Jul 2026 11:27:16 +0200 Subject: [PATCH 4/4] Update stale reference in skill --- .claude/skills/add-command/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-command/SKILL.md b/.claude/skills/add-command/SKILL.md index 853b0709..b26000c4 100644 --- a/.claude/skills/add-command/SKILL.md +++ b/.claude/skills/add-command/SKILL.md @@ -16,7 +16,7 @@ Before writing any code, understand what the command should do and whether the a **Core questions:** 1. **What does this command do?** (one sentence — e.g., "shows the status of running emulators") 2. **Does it need to talk to Docker/the runtime?** (determines whether `runtime.Runtime` is a dependency) -3. **Does it need configuration?** (determines whether `PreRunE: initConfig(nil)` is needed) +3. **Does it need configuration?** (determines whether `PreRunE: initConfigDeferCreate(nil)` is needed) 4. **Does it need authentication?** (determines whether auth flow is involved) 5. **Does it need any new event types?** (e.g., a new kind of progress, a new status phase — if yes, use `/add-event` for each) @@ -42,7 +42,7 @@ Read these files before writing anything — they are the source of truth for pa Create `cmd/$ARGUMENTS.go` with: - A `newCmd()` factory function returning `*cobra.Command` -- `PreRunE: initConfig(nil)` if the command needs configuration (only the root command uses `initConfigDeferCreate(&firstRun)`) +- `PreRunE: initConfigDeferCreate(nil)` if the command needs configuration (only the root and `start` commands use `initConfigDeferCreate(&firstRun)`) - Output mode decision at the boundary, gated on `isInteractiveMode(cfg)` (covers non-TTY and the `--non-interactive` flag): - Interactive: delegate to `ui.Run(...)` or TUI path - Non-interactive: call domain function with `output.NewPlainSink(os.Stdout)`