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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ Environment variables:
- `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set). It takes precedence over credentials stored in the keyring, so a per-invocation token overrides a previous `lstk login` without a `lstk logout` first; resolution order is env var → keyring → browser login (`auth.GetToken`, mirrored in `cmd/root.go`'s telemetry token resolution).
- `LSTK_STARTUP_TIMEOUT` - Startup readiness deadline for `lstk start` (Go duration). Zero/unset uses the per-mode default resolved in `resolveStartupTimeout` (`internal/container/start.go`): 20s interactive (deadline only shows a recoverable keep-waiting/stop prompt, re-armed by "keep waiting"), 60s non-interactive (fatal; the container is left running for inspection). Container exits are detected separately — and instantly, with the exit code — via the exit wait `runtime.Runtime.Start` registers between create and start. `lstk start --timeout <duration>` (also on the bare root) overrides this for a single run; the flag wins over the env var when explicitly set, and `--timeout 0` falls back to the per-mode default (`addTimeoutFlag`/`applyTimeoutFlag` in `cmd/root.go`). `restart` and the snapshot auto-start path do not expose the flag.
- `LSTK_OTEL=1` - Enables OpenTelemetry trace export (disabled by default); when enabled, standard `OTEL_EXPORTER_OTLP_*` env vars are respected by the SDK. Requires an OTLP-compatible backend to receive and visualize telemetry — for local development, `make otel` starts one (UI at http://localhost:16686).
- `LSTK_CHECK_FOR_UPDATE_ON_STARTUP` - Overrides `[cli] check_for_update_on_startup` for one run (`true`/`false`); see Update Checks below.
- `LSTK_MERGE_STRATEGY` - Default merge strategy for `snapshot load` / `load` (`account-region-merge`, `overwrite`, or `service-merge`) when `--merge` is not passed; an explicit `--merge` always wins. Resolved in `resolveMergeStrategy` (`cmd/snapshot.go`).

# Infrastructure as Code Commands
Expand Down Expand Up @@ -221,6 +222,12 @@ The release job (`.github/workflows/ci.yml`) builds the npm packages with `gorel
- Do not call `config.Get()` from domain/business-logic packages. Instead, extract the values you need at the command boundary (`cmd/`) and pass them as explicit function arguments. This keeps domain functions testable without requiring Viper/config initialization.
- Validate user/agent-supplied values where they are first accepted (the command boundary, or the domain parser that owns the format) via `internal/validate` — never an inline one-off regexp. Route by value class: pod names → `validate.PodName`; opaque secrets → only loose malformed-ness checks (`validate.AuthToken` style — no charset restriction); paths and URLs → their existing parsers (`filepath`, `net/url`). For other identifiers, follow the owning API's documented contract and add a dedicated validator if needed. If no validator fits, add one to `internal/validate` with rule-code tests instead of forking rules locally — parallel validators for the same value class drift (the pod-name rules forked exactly this way before #293 re-unified them).

# Update Checks

`lstk start` (and the bare root) checks for a newer lstk release and, interactively, prompts to install it. `[cli] check_for_update_on_startup` in config.toml (default true) gates that, with `LSTK_CHECK_FOR_UPDATE_ON_STARTUP` overriding it for one run. It is a boolean rather than a prompt/notify/off enum: install detection already decides between a prompt and a non-blocking notice, so the only choice left to the user is whether to check at all. `lstk update` itself is never gated by the setting; it is a direct request, not a background nag (DEVX-1029).

Resolution happens at the command boundary (`resolveUpdateCheckEnabled` in `cmd/root.go`), never inside `internal/update` — which imports no config package at all. Externally-managed installs (recognized from path-segment markers in `classifyPath`) get the note rather than the prompt, since applying the update is refused there anyway, and are never updated in place without `--force`. Every path that replaces the binary goes through `applyUpdate`, the single choke point for that guard. Install detection runs only when the check is enabled, and only once an update is known to exist, so it stays off the common start path. Mechanism and rationale for each piece live on the declarations: `config.ParseCheckForUpdateOnStartup`, `env.Env.CheckForUpdateOnStartup`, `NotifyOptions.CanPrompt`, `externalMarkers`, `classifyPath`, `blockSelfUpdate`, and the `--force` flag.

# Shell Completion

Cobra's generated bash completion script requires `_get_comp_words_by_ref` from the bash-completion package on both of its init paths, and stock macOS (bash 3.2) ships without that package — so completion failed with "command not found" on every Tab (DEVX-950). `selfContainBashCompletion` in `cmd/completion.go` wraps the autogenerated `completion bash` command to prepend a guarded pure-bash fallback (defined only when the package is absent, the git-completion.bash approach) and replaces the help text. The fallback body must stay bash 3.2 compatible (no `declare -A`, namerefs, `mapfile`, case-conversion expansions). It covers only `_get_comp_words_by_ref`; Cobra's script still calls bash-completion's `_filedir` for `ShellCompDirectiveFilterFileExt`/`ShellCompDirectiveFilterDirs` (`MarkFlagFilename`/`MarkFlagDirname`) and the ActiveHelp second-Tab path — lstk uses none of these today, so adopting one means growing the fallback. In docs/help, never recommend `source <(lstk completion bash)` — it is a silent no-op on bash 3.2; recommend `eval "$(lstk completion bash)"` instead. Zsh/fish/powershell scripts are self-contained upstream and untouched.
Expand Down
55 changes: 49 additions & 6 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,23 @@ func configureCommandExecution(root *cobra.Command, cfg *env.Env, tel *telemetry
wrapPreRunEForJSON(root, cfg, stdout)
}

// resolveUpdateCheckEnabled applies the resolution order:
// LSTK_CHECK_FOR_UPDATE_ON_STARTUP wins over the [cli]
// check_for_update_on_startup key, which wins over the default.
func resolveUpdateCheckEnabled(cfg *env.Env, appConfig *config.Config) (bool, error) {
if cfg.CheckForUpdateOnStartup != "" {
enabled, err := config.ParseCheckForUpdateOnStartup(cfg.CheckForUpdateOnStartup)
if err != nil {
return false, fmt.Errorf("invalid %s: %w", env.CheckForUpdateOnStartupVar, err)
}
return enabled, nil
}
if appConfig.CLI.CheckForUpdateOnStartup != nil {
return *appConfig.CLI.CheckForUpdateOnStartup, nil
}
return config.CheckForUpdateOnStartupDefault, nil
}

func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger, tel *telemetry.Client, persist bool) container.StartOptions {
return container.StartOptions{
PlatformClient: api.NewPlatformClient(cfg.APIEndpoint, logger),
Expand All @@ -343,7 +360,22 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger
func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, sink output.Sink, persist bool, firstRun bool, snapshotFlag string, noSnapshot bool, emulatorType config.EmulatorType) error {
appConfig, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
return failGetConfig(sink, cfg, err)
}

// Resolved before anything is written or started, so a bad
// LSTK_CHECK_FOR_UPDATE_ON_STARTUP fails as early as a bad config key does.
updateCheckEnabled, err := resolveUpdateCheckEnabled(cfg, appConfig)
if err != nil {
sink.Emit(output.ErrorEvent{
Title: err.Error(),
Actions: []output.ErrorAction{{
Label: "Accepted values are true and false. Unset it with:",
Value: "unset " + env.CheckForUpdateOnStartupVar,
}},
Code: output.ErrConfigInvalid,
})
return output.NewSilentError(err)
}

configPath, err := config.FriendlyConfigPath()
Expand Down Expand Up @@ -384,10 +416,16 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
opts := buildStartOptions(cfg, appConfig, logger, tel, persist)

notifyOpts := update.NotifyOptions{
GitHubToken: cfg.GitHubToken,
UpdatePrompt: true,
SkippedVersion: appConfig.CLI.UpdateSkippedVersion,
PersistSkipVersion: config.SetUpdateSkippedVersion,
GitHubToken: cfg.GitHubToken,
CanPrompt: true,
CheckEnabled: updateCheckEnabled,
DetectInstall: update.DetectInstallMethod,
}
// Only offer to persist a preference when there is a file for it. On a
// genuine first run config.toml does not exist yet (the emulator picker
// creates it), so the option would be silently dropped.
if config.HasFile() {
notifyOpts.PersistUpdateCheck = config.SetCheckForUpdateOnStartup
}

if isInteractiveMode(cfg) {
Expand All @@ -411,7 +449,12 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
Text: fmt.Sprintf("Configured with default emulator %s.", emName),
})
}
update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken})
// Same options as the interactive path, minus the ability to prompt, so
// a disabled check silences a non-interactive start too, and its note
// still names an external manager.
nonInteractiveNotify := notifyOpts
nonInteractiveNotify.CanPrompt = false
update.NotifyUpdate(ctx, sink, nonInteractiveNotify)
result, err := container.Start(ctx, rt, sink, opts, false)
if err != nil {
return err
Expand Down
16 changes: 11 additions & 5 deletions cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,30 @@ import (

func newUpdateCmd(cfg *env.Env) *cobra.Command {
var checkOnly bool
var force bool

cmd := &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).",
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).\n\n" +
"An install managed by an external tool (mise, nix, guix, asdf, scoop, chocolatey), or one in a directory lstk cannot write to, is not updated in place — update it through that tool instead, or pass --force to replace the binary anyway. --check always reports whether a newer version exists, whatever the install.\n\n" +
"This command always checks for updates. The [cli] check_for_update_on_startup config key and LSTK_CHECK_FOR_UPDATE_ON_STARTUP only govern the automatic check on 'lstk start'.",
PreRunE: initConfigDeferCreate(nil),
Annotations: map[string]string{jsonSupportedAnnotation: "true"},
RunE: func(cmd *cobra.Command, args []string) error {
sink := jsonAwareSink(cmd, cfg, os.Stdout)

if isInteractiveMode(cfg) {
return ui.RunUpdate(cmd.Context(), checkOnly, cfg.GitHubToken)
return ui.RunUpdate(cmd.Context(), checkOnly, cfg.GitHubToken, force)
}
return update.Update(cmd.Context(), sink, checkOnly, cfg.GitHubToken)
return update.Update(cmd.Context(), sink, checkOnly, cfg.GitHubToken, force)
},
}

cmd.Flags().BoolVar(&checkOnly, "check", false, "Only check for updates without applying them")
// Detection is a path-marker heuristic that cannot know every packaging
// layout, so a user it misreads needs a way through.
cmd.Flags().BoolVar(&force, "force", false, "Replace the binary even when the install is externally managed or its directory looks unwritable")

return cmd
}
70 changes: 70 additions & 0 deletions cmd/update_check_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package cmd

import (
"testing"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/env"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func boolPtr(b bool) *bool { return &b }

func TestResolveUpdateCheckEnabled(t *testing.T) {
t.Parallel()

tests := []struct {
name string
envValue string
confValue *bool
want bool
}{
{"neither set defaults to enabled", "", nil, true},
{"config false", "", boolPtr(false), false},
{"config true", "", boolPtr(true), true},
{"env false", "false", nil, false},
{"env wins over config", "true", boolPtr(false), true},
{"env false wins over config true", "false", boolPtr(true), false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := resolveUpdateCheckEnabled(
&env.Env{CheckForUpdateOnStartup: tt.envValue},
&config.Config{CLI: config.CLIConfig{CheckForUpdateOnStartup: tt.confValue}},
)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

// "quiet" is not a boolean — it stands in for a plausible-sounding typo, to
// prove a bad value is reported rather than silently disabling the check.
func TestResolveUpdateCheckEnabledRejectsInvalidEnvValue(t *testing.T) {
t.Parallel()

_, err := resolveUpdateCheckEnabled(
&env.Env{CheckForUpdateOnStartup: "quiet"},
&config.Config{},
)

require.Error(t, err)
assert.Contains(t, err.Error(), env.CheckForUpdateOnStartupVar)
assert.Contains(t, err.Error(), "quiet")
}

// An invalid env value must be rejected even when the config file holds a good
// one: falling back would hide the typo and apply a setting not asked for.
func TestResolveUpdateCheckEnabledRejectsInvalidEnvValueOverValidConfig(t *testing.T) {
t.Parallel()

_, err := resolveUpdateCheckEnabled(
&env.Env{CheckForUpdateOnStartup: "quiet"},
&config.Config{CLI: config.CLIConfig{CheckForUpdateOnStartup: boolPtr(true)}},
)

require.Error(t, err)
}
7 changes: 4 additions & 3 deletions docs/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ Every `error.code` is one of the following fixed constants. A failure that doesn
| `USAGE_ERROR` | Cobra-level flag or argument parsing failed | No | `USAGE` |
| `NOT_JSON_CAPABLE` | The requested command has not been annotated as JSON-capable yet | No | `USAGE` |
| `NETWORK_ERROR` | An unclassified network/transport failure occurred | Yes | `RUNTIME` |
| `UPDATE_EXTERNALLY_MANAGED` | `lstk update` refused to replace a binary it does not own: the install is managed by an external tool (mise, nix, guix, asdf, scoop, chocolatey) or its directory is not writable. `--force` overrides | No | `RUNTIME` |
| `CANCELLED` | The operation was interrupted (e.g. context cancellation via Ctrl+C) | Yes | `INTERNAL` |
| `INTERNAL_ERROR` | Unclassified or unexpected failure; the universal fallback | No | `INTERNAL` |
| `IAC_FILE_NOT_FOUND` | A required infrastructure-as-code file or directory does not exist or cannot be read (e.g. the workspace `lstk deploy detect --dir` was pointed at) | No | `IAC` |
Expand All @@ -130,11 +131,11 @@ Every `error.code` is one of the following fixed constants. A failure that doesn

### Error categories

`error.category` groups the 29 codes above into 8 buckets, additive alongside `code` — it exists purely so a caller that only wants coarse handling doesn't have to build and maintain its own mapping from all 29 codes. `code` is unaffected and remains the primary, stable identifier for anything more specific.
`error.category` groups the 35 codes above into 8 buckets, additive alongside `code` — it exists purely so a caller that only wants coarse handling doesn't have to build and maintain its own mapping from all 35 codes. `code` is unaffected and remains the primary, stable identifier for anything more specific.

```
RUNTIME RUNTIME_UNAVAILABLE, IMAGE_PULL_FAILED, DEPENDENCY_MISSING,
DNS_RESOLUTION_REQUIRED, NETWORK_ERROR
DNS_RESOLUTION_REQUIRED, NETWORK_ERROR, UPDATE_EXTERNALLY_MANAGED
→ something outside lstk's control (Docker, network, a missing binary)

EMULATOR EMULATOR_NOT_RUNNING, EMULATOR_ALREADY_RUNNING, EMULATOR_WRONG_TYPE,
Expand Down Expand Up @@ -269,7 +270,7 @@ Codes: `EMULATOR_NOT_CONFIGURED` (no AWS container configured), `EMULATOR_NOT_RU
"error": null
}
```
Codes: `NETWORK_ERROR` (GitHub API unreachable), `INTERNAL_ERROR` (archive download verification, extraction, or replacement failure), `CONFIG_INVALID`, `CONFIG_NOT_FOUND` (bad or missing `--config` path).
Codes: `NETWORK_ERROR` (GitHub API unreachable), `UPDATE_EXTERNALLY_MANAGED` (the install is managed by an external tool or sits in a directory lstk cannot write to; emitted before any version check, and suppressed by `--check` and `--force`), `INTERNAL_ERROR` (archive download verification, extraction, or replacement failure), `CONFIG_INVALID`, `CONFIG_NOT_FOUND` (bad or missing `--config` path).

**`lstk start`** — a flat object, not an `emulators: [...]` list: only one `[[containers]]` block can be enabled at a time (`container.Start`'s `checkSingleContainer` guard), so `start` only ever acts on one emulator, unlike `stop`/`status` which genuinely enumerate multiple *configured* emulators. A version/config mismatch on an already-running instance surfaces as a `warnings[]` entry rather than a new field. The bare `lstk --json` invocation (no `start`) carries identical support: it runs through the same `startEmulator` path, so `--json` behaves the same way there, and the envelope's `command` field reads `"start"` either way.
```json
Expand Down
32 changes: 29 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import (
var defaultConfigTemplate string

type CLIConfig struct {
UpdateSkippedVersion string `mapstructure:"update_skipped_version"`
// Pointer so an unset key is distinguishable from an explicit false.
CheckForUpdateOnStartup *bool `mapstructure:"check_for_update_on_startup"`
}

type Config struct {
Expand Down Expand Up @@ -171,11 +172,36 @@ func setInFile(path, key string, value any) error {
return os.WriteFile(path, []byte(content), 0644)
}

func SetUpdateSkippedVersion(version string) error {
return Set("cli.update_skipped_version", version)
// SetCheckForUpdateOnStartup persists the update-check setting. Unlike Set, it
// fails when there is no config file rather than succeeding in memory only: it
// backs the prompt's opt-out, where a dropped write would tell the user their
// choice was saved and then prompt them again next run.
func SetCheckForUpdateOnStartup(enabled bool) error {
if resolvedConfigPath() == "" {
return errors.New("no config file to write to yet")
}
return Set("cli."+checkForUpdateOnStartupKey, enabled)
}

// HasFile reports whether a config file has been resolved, i.e. whether
// settings can be persisted at all. The command boundary uses it to decide
// whether to offer options that write config.
func HasFile() bool {
return resolvedConfigPath() != ""
}

func Get() (*Config, error) {
// Checked before unmarshal: mapstructure's own bool failure names the key
// but neither the offending value nor the accepted ones, and this is the
// same message the environment variable produces.
if raw := viper.Get("cli." + checkForUpdateOnStartupKey); raw != nil {
if _, ok := raw.(bool); !ok {
if _, err := ParseCheckForUpdateOnStartup(fmt.Sprint(raw)); err != nil {
return nil, fmt.Errorf("invalid [cli] config: %w", err)
}
}
}

var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
Expand Down
4 changes: 4 additions & 0 deletions internal/config/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ port = "4566" # Host port the emulator will be accessible on
# [env.ci]
# SERVICES = "s3,sqs"
# EAGER_SERVICE_LOADING = "1"

# CLI behavior
[cli]
# check_for_update_on_startup = false # Skip the update check on start (default: true)
25 changes: 25 additions & 0 deletions internal/config/update_check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package config

import (
"fmt"
"strconv"
)

// CheckForUpdateOnStartupDefault applies when neither the `[cli]
// check_for_update_on_startup` key nor LSTK_CHECK_FOR_UPDATE_ON_STARTUP is set.
const CheckForUpdateOnStartupDefault = true

// checkForUpdateOnStartupKey is the key's name within the [cli] table, shared
// by the reader, the writer and the pre-unmarshal validation.
const checkForUpdateOnStartupKey = "check_for_update_on_startup"

// ParseCheckForUpdateOnStartup parses the environment variable's raw value.
// Anything strconv.ParseBool rejects is reported rather than coerced, so a typo
// cannot silently disable update checks.
func ParseCheckForUpdateOnStartup(s string) (bool, error) {
enabled, err := strconv.ParseBool(s)
if err != nil {
return false, fmt.Errorf("invalid check_for_update_on_startup value %q (must be true or false)", s)
}
return enabled, nil
}
Loading
Loading