Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/add-command/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -42,7 +42,7 @@ Read these files before writing anything — they are the source of truth for pa
Create `cmd/$ARGUMENTS.go` with:

- A `new<Name>Cmd()` 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<Name>(...)` or TUI path
- Non-interactive: call domain function with `output.NewPlainSink(os.Stdout)`
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

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`.
Expand Down
4 changes: 2 additions & 2 deletions cmd/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,12 @@ 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
}
}
return initConfig(nil)(cmd, args)
return initConfigDeferCreate(nil)(cmd, args)
Comment thread
anisaoshafi marked this conversation as resolved.
},
RunE: func(cmd *cobra.Command, _ []string) error {
sink := output.NewPlainSink(os.Stdout)
Expand Down
6 changes: 3 additions & 3 deletions cmd/az.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/cdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion cmd/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/restart.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 0 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/sam.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 7 additions & 7 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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},
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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),
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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},
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions cmd/volume.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 3 additions & 9 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading