Skip to content
Open
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: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<product>:<tag>` is used when `image` is unset.
Expand Down
2 changes: 1 addition & 1 deletion cmd/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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 @@ -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 {
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 @@ -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)
}
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 @@ -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 {
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 @@ -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)
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
6 changes: 6 additions & 0 deletions test/integration/awsconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
Expand Down
Loading
Loading