Skip to content
Closed
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
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Notes:
- `terminal/` - Plain-mode terminal helpers (spinner, TTY detection)
- `tracing/` - OpenTelemetry setup (`LSTK_OTEL=1`)
- `ui/` - Bubble Tea views for interactive output
- `update/` - Self-update logic: version check via GitHub API, binary/Homebrew/npm update paths, archive extraction; the binary path verifies the downloaded archive's SHA-256 against the release's `checksums.txt` before replacing the executable (hard fail on missing/malformed manifest or mismatch)
- `update/` - Self-update logic: version check via GitHub API, binary/Homebrew/npm update paths, archive extraction; the binary path verifies the downloaded archive's SHA-256 against the release's `checksums.txt` before replacing the executable (hard fail on missing/malformed manifest or mismatch). `classifyPath` also detects installs owned by another package manager (mise, asdf, Nix, Scoop, Chocolatey) from the resolved binary path — those default to a one-line notice instead of the prompt, and `lstk update` refuses on them rather than replacing a file it does not own; the automatic check's policy is `update.CheckMode`, resolved at the command boundary (see Automatic Update Check below)
- `validate/` - Reusable input validators for user-supplied CLI values (pod names, env var names, auth tokens) rejecting malformed/hostile input (control chars, path traversal, percent-encoding, shell metacharacters)
- `version/` - Version info
- `volume/` - `lstk volume` domain logic
Expand Down Expand Up @@ -130,6 +130,14 @@ Each `[[containers]]` block may set an optional `container_name` (override the d

`GATEWAY_LISTEN` (host exposure and published ports) is read from the container's resolved env, not hardcoded; parsing and derivation live in `internal/container/gateway.go`.

# Automatic Update Check

`lstk start` (and the bare root) checks GitHub for a newer release. The policy is `[cli] update_check` — `prompt` (default), `notify` (one-line note, no input wait), or `off` (no request at all) — overridable by `LSTK_UPDATE_CHECK`. Precedence and fallback rules live in `resolveUpdateCheckMode` (`cmd/update_check.go`): an unparsable value is warned about and skipped rather than failing the command, and `prompt` is downgraded to `notify` off a TTY, since only the TUI answers a `UserInputRequestEvent`.

`buildNotifyOptions` (same file) is the one place the policy is resolved, shared by both start paths — they previously built separate `NotifyOptions`, which is how the non-interactive path came to ignore `cli.update_skipped_version`. `internal/update` never reads config; everything is injected.

Installs owned by another package manager default to `notify` and are never self-updated (`lstk update` refuses with `UPDATE_EXTERNALLY_MANAGED`); an explicit `update_check` overrides that in both directions. Explicit `lstk update` always checks. Detection rules and per-manager wording are documented on `classifyPath` and `ExternalManager` (`internal/update/install_method.go`).

# Offline / Enterprise Environments

There is no `--offline` flag. Instead `container.Start` degrades gracefully when internet requests fail (Docker Hub unreachable, proxy/TLS interception, license server unreachable): local images are used when pulls fail, and the license pre-flight is skipped on transport-level failures, non-definitive server responses (5xx/407), or unsupported-tag rejections so the container validates its own bundled license. Definitive license rejections (HTTP 400/401/403) drop the cached license and offer an in-place re-login instead of requiring a manual `lstk logout` (DEVX-658). The exact fallback and retry rules live in `tryPrePullLicenseValidation`/`validateLicense`/`startWithLicenseRetry` (`internal/container/start.go`); pair them with a custom `image` in the config to point at a locally loaded image or an internal-registry mirror.
Expand All @@ -154,6 +162,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_UPDATE_CHECK` - Policy for the automatic update check on the start path: `prompt` (default), `notify` (one-line note, no input wait), or `off` (no check, no request). Overrides `[cli] update_check` in config.toml; see Automatic Update Check 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Running `lstk` will automatically handle authentication, configuration, and cont
- **Cloud CLI proxies** — run `aws`, `az`, `terraform`, `cdk`, and `sam` commands against LocalStack with the endpoint, credentials, and region pre-configured
- **Target an external emulator** — pass `--endpoint-url <url>` (or set `LSTK_ENDPOINT_URL`) to point most commands at an already-running LocalStack instance — docker compose, host-network mode, CI, a different machine, or a cloud-hosted ephemeral instance (`https://` is supported) — instead of one lstk manages locally
- **Extensions** — Git-style `lstk-<name>` executables extend the CLI with new commands; see [extension authoring](https://github.com/localstack/lstk/blob/main/docs/extensions-authoring.md)
- **Self-update** — `lstk update` checks for and installs the latest release
- **Self-update** — `lstk update` checks for and installs the latest release. The automatic check on start is configurable via `[cli] update_check` (`prompt` / `notify` / `off`) or `LSTK_UPDATE_CHECK`; installs managed by mise, asdf, Nix, Scoop or Chocolatey are only ever notified about and left to their own manager
- **Structured JSON output** — pass `--json` to a supported command for a machine-readable envelope instead of formatted text; see [structured output](https://github.com/localstack/lstk/blob/main/docs/structured-output.md)

For the full command reference, configuration options, environment variables, and troubleshooting, see the **[lstk documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/)**.
Expand Down
32 changes: 15 additions & 17 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,12 +346,14 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
logger.Info("could not resolve friendly config path: %v", err)
}

// Apply the --type flag before resolving snapshot and start options so
// everything downstream reflects the selected emulator. Messages go to a plain
// sink even in interactive mode because the config mutation has to happen before
// the TUI starts (the auto-load loader and start options are built from it).
// Anything reported before the TUI can start goes through this sink, in
// interactive mode too.
plainSink := output.NewPlainSink(os.Stdout)

// Apply --type before resolving snapshot and start options so everything
// downstream reflects the selected emulator.
if emulatorType != "" {
newContainers, applyErr := container.ApplyEmulatorType(ctx, rt, output.NewPlainSink(os.Stdout), emulatorType, appConfig.Containers, firstRun, configPath)
newContainers, applyErr := container.ApplyEmulatorType(ctx, rt, plainSink, emulatorType, appConfig.Containers, firstRun, configPath)
if applyErr != nil {
return applyErr
}
Expand All @@ -373,14 +375,11 @@ 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,
}
// Resolved once so the two output paths cannot disagree about the policy.
interactive := isInteractiveMode(cfg)
notifyOpts := buildNotifyOptions(plainSink, cfg, appConfig, configPath, firstRun, interactive)

if isInteractiveMode(cfg) {
if interactive {
return ui.Run(ctx, ui.RunOptions{
Runtime: rt,
Version: version.Version(),
Expand All @@ -393,24 +392,23 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
})
}

sink := output.NewPlainSink(os.Stdout)
if firstRun && len(appConfig.Containers) > 0 {
emName := appConfig.Containers[0].Type.ShortName()
sink.Emit(output.MessageEvent{
plainSink.Emit(output.MessageEvent{
Severity: output.SeverityNote,
Text: fmt.Sprintf("Configured with default emulator %s.", emName),
})
}
update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken})
resolvedVersion, err := container.Start(ctx, rt, sink, opts, false)
update.NotifyUpdate(ctx, plainSink, notifyOpts)
resolvedVersion, err := container.Start(ctx, rt, plainSink, opts, false)
if err != nil {
return err
}
// Auto-load the configured snapshot only when the emulator was freshly started
// this run (resolvedVersion is empty when it was already running). This mirrors
// v1's AUTO_LOAD_POD: state is loaded as the emulator comes up, not on every invocation.
if autoLoad != nil && resolvedVersion != "" {
if err := autoLoad(ctx, sink); err != nil {
if err := autoLoad(ctx, plainSink); err != nil {
return err
}
}
Expand Down
7 changes: 4 additions & 3 deletions cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ func newUpdateCmd(cfg *env.Env) *cobra.Command {
var checkOnly 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), and refuses to touch an install owned by another package manager (mise, asdf, Nix, Scoop, Chocolatey) — those report that manager's own upgrade command instead.\n\n" +
"Running this command always checks, regardless of the [cli] update_check setting, which governs only the automatic check on 'lstk start'.",
PreRunE: initConfigDeferCreate(nil),
Annotations: map[string]string{jsonSupportedAnnotation: "true"},
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down
99 changes: 99 additions & 0 deletions cmd/update_check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package cmd

import (
"fmt"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/update"
)

// updateCheckContext is what resolveUpdateCheckMode needs to pick a policy,
// gathered at the boundary so the resolution stays a pure function.
type updateCheckContext struct {
EnvValue string // LSTK_UPDATE_CHECK, empty when unset
ConfigValue string // [cli] update_check, empty when unset
ExternallyManaged bool // another package manager owns the binary
Interactive bool // a prompt could actually be answered
}

// resolveUpdateCheckMode resolves the policy in precedence order:
// LSTK_UPDATE_CHECK, [cli] update_check, then the install-implied default
// (notify when a package manager owns the binary, prompt otherwise).
//
// An unparsable value is reported and skipped, never fatal: the setting governs
// a best-effort background check, so a typo must not stop `lstk start` — and
// there is no `lstk config set` to fix it with. Falling through source by source
// keeps the documented precedence true even when one source is garbage.
//
// Off a terminal, prompt is downgraded to notify: only the TUI answers a
// UserInputRequestEvent, so a plain sink would block until context cancellation.
func resolveUpdateCheckMode(sink output.Sink, checkCtx updateCheckContext) update.CheckMode {
mode := resolveConfiguredCheckMode(sink, checkCtx)
if mode == update.CheckModePrompt && !checkCtx.Interactive {
return update.CheckModeNotify
}
return mode
}

func resolveConfiguredCheckMode(sink output.Sink, checkCtx updateCheckContext) update.CheckMode {
sources := []struct {
label string
value string
}{
{"LSTK_UPDATE_CHECK", checkCtx.EnvValue},
{"update_check in [cli]", checkCtx.ConfigValue},
}

for _, source := range sources {
if source.value == "" {
continue
}
mode, err := update.ParseCheckMode(source.value)
if err != nil {
sink.Emit(output.MessageEvent{
Severity: output.SeverityWarning,
Text: fmt.Sprintf("Ignoring %s: %v", source.label, err),
})
continue
}
return mode
}

if checkCtx.ExternallyManaged {
return update.CheckModeNotify
}
return update.CheckModePrompt
}

// buildNotifyOptions resolves the one policy both start paths use. Building it
// once is what keeps them in sync: the non-interactive path used to construct
// its own NotifyOptions and so ignored the skipped version (DEVX-1029).
func buildNotifyOptions(sink output.Sink, cfg *env.Env, appConfig *config.Config, configPath string, firstRun, interactive bool) update.NotifyOptions {
info := update.DetectInstallMethod()

opts := update.NotifyOptions{
Mode: resolveUpdateCheckMode(sink, updateCheckContext{
EnvValue: cfg.UpdateCheck,
ConfigValue: appConfig.CLI.UpdateCheck,
ExternallyManaged: info.ExternallyManaged(),
Interactive: interactive,
}),
GitHubToken: cfg.GitHubToken,
SkippedVersion: appConfig.CLI.UpdateSkippedVersion,
PersistSkipVersion: config.SetUpdateSkippedVersion,
Install: info,
ConfigPath: configPath,
}

// No config file to write to on a first run, and creating one here would
// suppress the emulator picker — so withhold the option (see NotifyOptions).
if !firstRun {
opts.PersistCheckMode = func(mode update.CheckMode) error {
return config.SetUpdateCheck(string(mode))
}
}

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

import (
"bytes"
"testing"

"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/update"
"github.com/stretchr/testify/assert"
)

// TestResolveUpdateCheckMode covers the seam between what the user configured
// (LSTK_UPDATE_CHECK, [cli] update_check, the install-implied default) and the
// policy handed to update.NotifyUpdate.
func TestResolveUpdateCheckMode(t *testing.T) {
t.Parallel()

tests := []struct {
name string
checkCtx updateCheckContext
want update.CheckMode
wantWarnings []string
}{
{
name: "nothing set defaults to prompt",
checkCtx: updateCheckContext{Interactive: true},
want: update.CheckModePrompt,
},
{
name: "nothing set on an externally managed install defaults to notify",
checkCtx: updateCheckContext{ExternallyManaged: true, Interactive: true},
want: update.CheckModeNotify,
},
{
name: "config value is used when the env var is unset",
checkCtx: updateCheckContext{ConfigValue: "off", Interactive: true},
want: update.CheckModeOff,
},
{
name: "env var beats config",
checkCtx: updateCheckContext{EnvValue: "prompt", ConfigValue: "off", Interactive: true},
want: update.CheckModePrompt,
},
{
name: "explicit prompt overrides the externally managed default",
checkCtx: updateCheckContext{ConfigValue: "prompt", ExternallyManaged: true, Interactive: true},
want: update.CheckModePrompt,
},
{
name: "explicit off overrides the externally managed default",
checkCtx: updateCheckContext{EnvValue: "off", ExternallyManaged: true, Interactive: true},
want: update.CheckModeOff,
},
{
// Only the TUI answers a prompt, so a non-interactive run notifies.
name: "prompt is downgraded to notify when not interactive",
checkCtx: updateCheckContext{ConfigValue: "prompt"},
want: update.CheckModeNotify,
},
{
name: "off is honored when not interactive",
checkCtx: updateCheckContext{ConfigValue: "off"},
want: update.CheckModeOff,
},
{
name: "invalid env value warns and falls through to config",
checkCtx: updateCheckContext{EnvValue: "yes", ConfigValue: "notify", Interactive: true},
want: update.CheckModeNotify,
wantWarnings: []string{`> Warning: Ignoring LSTK_UPDATE_CHECK: invalid update_check value "yes" (must be one of: prompt, notify, off)`},
},
{
name: "invalid values in both sources warn and fall through to the default",
checkCtx: updateCheckContext{EnvValue: "yes", ConfigValue: "disabled", ExternallyManaged: true, Interactive: true},
want: update.CheckModeNotify,
wantWarnings: []string{
`> Warning: Ignoring LSTK_UPDATE_CHECK: invalid update_check value "yes" (must be one of: prompt, notify, off)`,
`> Warning: Ignoring update_check in [cli]: invalid update_check value "disabled" (must be one of: prompt, notify, off)`,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var buf bytes.Buffer
got := resolveUpdateCheckMode(output.NewPlainSink(&buf), tt.checkCtx)

assert.Equal(t, tt.want, got)
for _, warning := range tt.wantWarnings {
assert.Contains(t, buf.String(), warning)
}
if len(tt.wantWarnings) == 0 {
assert.Empty(t, buf.String(), "a valid configuration should print nothing")
}
})
}
}
Loading
Loading