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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ When no config file exists, lstk creates one at `$HOME/.config/lstk/config.toml`
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.

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`.

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
1 change: 1 addition & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func newConfigCmd(cfg *env.Env) *cobra.Command {
Use: "config",
Short: "Manage configuration",
}
requireSubcommand(cmd)
cmd.AddCommand(newConfigProfileCmd(cfg))
cmd.AddCommand(newConfigPathCmd())
return cmd
Expand Down
21 changes: 21 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,30 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
root.SetHelpCommandGroupID(groupCommands)
root.SetCompletionCommandGroupID(groupCommands)

// Cobra's autogenerated completion command is itself a subcommand-grouping
// parent (bash/zsh/fish/powershell) with no RunE, so an unknown shell (e.g.
// `lstk completion bogus`) hits the same exit-0 path requireSubcommand fixes.
// It is created lazily during Execute, so force it now to wire it up too.
root.InitDefaultCompletionCmd()
if completionCmd, _, err := root.Find([]string{"completion"}); err == nil && completionCmd.Name() == "completion" {
requireSubcommand(completionCmd)
}

return root
}

// requireSubcommand configures a parent command that only groups subcommands so
// an unknown or missing subcommand exits non-zero instead of Cobra's default of
// printing help and exiting 0. Cobra only validates args (and so rejects unknown
// subcommands via cobra.NoArgs) when the command is runnable, hence the RunE that
// prints help for a bare invocation.
func requireSubcommand(cmd *cobra.Command) {
cmd.Args = cobra.NoArgs
cmd.RunE = func(c *cobra.Command, _ []string) error {
return c.Help()
}
}

func Execute(ctx context.Context) error {
if len(os.Args) > 1 && os.Args[1] == telemetry.FlushCommandName {
return runFlushTelemetry(ctx, os.Args[2:])
Expand Down
1 change: 1 addition & 0 deletions cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func newSetupCmd(cfg *env.Env) *cobra.Command {
Short: "Set up emulator CLI integration",
Long: "Set up emulator CLI integration for AWS or Azure.",
}
requireSubcommand(cmd)
cmd.AddCommand(newSetupAWSCmd(cfg))
cmd.AddCommand(newSetupAzureCmd(cfg))
return cmd
Expand Down
1 change: 1 addition & 0 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ func newSnapshotCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cob
Use: "snapshot",
Short: "Manage emulator snapshots",
}
requireSubcommand(cmd)
cmd.AddCommand(newSnapshotSaveCmd(cfg))
cmd.AddCommand(newSnapshotLoadCmd(cfg, tel, logger))
cmd.AddCommand(newSnapshotListCmd(cfg, logger))
Expand Down
1 change: 1 addition & 0 deletions cmd/volume.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func newVolumeCmd(cfg *env.Env) *cobra.Command {
Use: "volume",
Short: "Manage emulator volume",
}
requireSubcommand(cmd)
cmd.AddCommand(newVolumePathCmd(cfg))
cmd.AddCommand(newVolumeClearCmd(cfg))
return cmd
Expand Down
65 changes: 65 additions & 0 deletions test/integration/exit_code_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package integration_test

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestInvalidUsageExitsNonZero guards against regressions of DEVX-941, where
// lstk exited 0 even when a command failed. It covers two failure classes that
// must surface a non-zero exit code: an unknown flag, and an unknown subcommand
// of a parent command that only groups subcommands (config/setup/volume/
// snapshot, plus Cobra's autogenerated completion). The latter used to exit 0
// because Cobra prints help and returns nil for an unrecognized subcommand of a
// non-runnable parent.
func TestInvalidUsageExitsNonZero(t *testing.T) {
t.Parallel()

cases := []struct {
name string
args []string
wantText string
}{
{"unknown flag on start", []string{"start", "--bogus-flag-xyz"}, "unknown flag"},
{"unknown flag on root", []string{"--bogus-flag-xyz"}, "unknown flag"},
{"unknown command", []string{"bogus-command"}, "unknown command"},
{"unknown config subcommand", []string{"config", "bogus"}, `unknown command "bogus"`},
{"unknown setup subcommand", []string{"setup", "bogus"}, `unknown command "bogus"`},
{"unknown volume subcommand", []string{"volume", "bogus"}, `unknown command "bogus"`},
{"unknown snapshot subcommand", []string{"snapshot", "bogus"}, `unknown command "bogus"`},
{"unknown completion shell", []string{"completion", "bogus"}, `unknown command "bogus"`},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
e := testEnvWithHome(t.TempDir(), "")
_, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, tc.args...)
require.Error(t, err, "expected %v to fail", tc.args)
requireExitCode(t, 1, err)
assert.Contains(t, stderr, tc.wantText)
})
}
}

// TestBareParentCommandExitsZero confirms that invoking a subcommand-grouping
// parent with no subcommand still prints help and exits 0 — the fix for
// DEVX-941 must reject unknown subcommands without breaking bare invocations.
func TestBareParentCommandExitsZero(t *testing.T) {
t.Parallel()

for _, parent := range []string{"config", "setup", "volume", "snapshot", "completion"} {
parent := parent
t.Run(parent, func(t *testing.T) {
t.Parallel()
e := testEnvWithHome(t.TempDir(), "")
stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, parent)
require.NoError(t, err)
requireExitCode(t, 0, err)
assert.Contains(t, stdout, "Usage:")
})
}
}
Loading