Skip to content
Draft
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
40 changes: 31 additions & 9 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
// <name>` falls through to extension dispatch. Built-in commands are still
// matched by Cobra's command resolution first, so they always win.
Args: cobra.ArbitraryArgs,
// JSON-capable because the bare invocation runs start. Extension
// dispatch is exempt via isExtensionDispatch, so `lstk --json <ext>`
// still forwards rather than wrapping the extension's own output.
Annotations: map[string]string{jsonSupportedAnnotation: "true"},
RunE: func(cmd *cobra.Command, args []string) error {
// A non-empty arg here means the first positional was not a built-in
// command (Cobra would have routed those to their own command), so it
Expand All @@ -74,10 +78,11 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
_, endpointURL, _ := endpoint.ResolvedSource(cmd)
return dispatchExtension(cmd.Context(), cfg, tel, logger, args, endpointURL)
}
sink := jsonAwareSink(cmd, cfg, os.Stdout)
// The bare root command starts the emulator via the same
// startEmulator path as `lstk start` below, so it rejects
// --endpoint-url the same way.
if err := rejectEndpointURL(cmd, output.NewPlainSink(os.Stdout), "start"); err != nil {
if err := rejectEndpointURL(cmd, sink, "start"); err != nil {
return err
}
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
Expand All @@ -101,7 +106,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
if err := applyTimeoutFlag(cmd, cfg); err != nil {
return err
}
return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot, emulatorType)
return startEmulator(cmd.Context(), rt, cfg, tel, logger, sink, persist, firstRun, snapshotFlag, noSnapshot, emulatorType)
},
}

Expand Down Expand Up @@ -335,7 +340,10 @@ 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, persist bool, firstRun bool, snapshotFlag string, noSnapshot bool, emulatorType config.EmulatorType) error {
// startEmulator is shared by `lstk start` and the bare root command. The caller
// supplies sink (via jsonAwareSink) so that under --json every message on this
// path lands in the envelope instead of printing alongside it.
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)
Expand All @@ -347,11 +355,11 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
}

// 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
// everything downstream reflects the selected emulator. Messages bypass the
// TUI 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).
if emulatorType != "" {
newContainers, applyErr := container.ApplyEmulatorType(ctx, rt, output.NewPlainSink(os.Stdout), emulatorType, appConfig.Containers, firstRun, configPath)
newContainers, applyErr := container.ApplyEmulatorType(ctx, rt, sink, emulatorType, appConfig.Containers, firstRun, configPath)
if applyErr != nil {
return applyErr
}
Expand All @@ -361,14 +369,29 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
firstRun = false
}

// Classified here because this is the innermost scope with a sink. Only
// --json needs the ErrorEvent; in plain mode these stay bare errors so they
// keep reaching stderr as "Error: <msg>" rather than styled stdout.
failWithCode := func(err error, code output.ErrorCode) error {
if !cfg.JSON {
return err
}
sink.Emit(output.ErrorEvent{Title: err.Error(), Code: code})
return output.NewSilentError(err)
}

ref, err := resolveStartSnapshotRef(appConfig, snapshotFlag, noSnapshot)
if err != nil {
return err
return failWithCode(err, output.ErrValidationError)
}
// Parse the REF eagerly so an invalid snapshot fails before the emulator starts.
autoLoad, err := newSnapshotAutoLoader(cfg, rt, appConfig, ref)
if err != nil {
return err
if errors.Is(err, errSnapshotAutoLoadNotAWS) {
return failWithCode(err, output.ErrEmulatorNotConfigured)
}
// Anything else here is a REF that failed to parse.
return failWithCode(err, output.ErrSnapshotInvalidRef)
}

opts := buildStartOptions(cfg, appConfig, logger, tel, persist)
Expand All @@ -393,7 +416,6 @@ 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{
Expand Down
11 changes: 9 additions & 2 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,19 @@ func snapshotFlags(cmd *cobra.Command) (snapshotFlag string, noSnapshot bool, er
return snapshotFlag, noSnapshot, nil
}

// Sentinels so the start path can classify these into error codes without
// re-matching the message text.
var (
errSnapshotFlagConflict = errors.New("--snapshot and --no-snapshot cannot be used together")
errSnapshotAutoLoadNotAWS = errors.New("snapshot auto-load is only supported for the AWS emulator")
)

// resolveStartSnapshotRef resolves the snapshot REF to auto-load on start.
// Precedence: --no-snapshot disables it; otherwise --snapshot wins over the
// AWS container's configured snapshot. Returns "" when nothing should be loaded.
func resolveStartSnapshotRef(appConfig *config.Config, snapshotFlag string, noSnapshot bool) (string, error) {
if noSnapshot && snapshotFlag != "" {
return "", errors.New("--snapshot and --no-snapshot cannot be used together")
return "", errSnapshotFlagConflict
}
if noSnapshot {
return "", nil
Expand Down Expand Up @@ -185,7 +192,7 @@ func newSnapshotAutoLoader(cfg *env.Env, rt runtime.Runtime, appConfig *config.C
}
}
if !found {
return nil, fmt.Errorf("snapshot auto-load is only supported for the AWS emulator")
return nil, errSnapshotAutoLoadNotAWS
}

home, _ := os.UserHomeDir()
Expand Down
9 changes: 5 additions & 4 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (

"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/telemetry"
"github.com/spf13/cobra"
Expand All @@ -30,9 +29,11 @@ If a snapshot is configured for the AWS emulator (the snapshot field in [[contai
}
return nil
},
PreRunE: initConfigDeferCreate(&firstRun),
PreRunE: initConfigDeferCreate(&firstRun),
Annotations: map[string]string{jsonSupportedAnnotation: "true"},
RunE: func(c *cobra.Command, args []string) error {
if err := rejectEndpointURL(c, output.NewPlainSink(os.Stdout), "start"); err != nil {
sink := jsonAwareSink(c, cfg, os.Stdout)
if err := rejectEndpointURL(c, sink, "start"); err != nil {
return err
}

Expand All @@ -55,7 +56,7 @@ If a snapshot is configured for the AWS emulator (the snapshot field in [[contai
if err := applyTimeoutFlag(c, cfg); err != nil {
return err
}
return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot, emulatorType)
return startEmulator(c.Context(), rt, cfg, tel, logger, sink, persist, firstRun, snapshotFlag, noSnapshot, emulatorType)
},
}
cmd.Flags().Bool("persist", false, "Persist emulator state across restarts")
Expand Down
90 changes: 68 additions & 22 deletions docs/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The contract described here (a shared envelope shape, a shared error-code vocabu

`--json` support is being rolled out per command, not all at once. The [Command Catalog](#command-catalog) below is split into two parts:

- **[Implemented in this PR](#implemented-in-this-pr)** — `stop`, `reset`, `update`. These accept `--json` today and produce exactly the shapes documented below.
- **[Implemented](#implemented)** — `start` (and the bare `lstk` invocation), `stop`, `reset`, `update`. These accept `--json` today and produce exactly the shapes documented below.
- **[Proposed for future work](#proposed-for-future-work-draft)** — every other built-in command. Attempting `--json` on any of these today is rejected with `NOT_JSON_CAPABLE`. This part is a **first-draft proposal only** — see the warning at the top of that section before relying on any of it.

## The envelope
Expand Down Expand Up @@ -187,11 +187,34 @@ A `USAGE_ERROR` that *was* successfully rendered as an envelope (because `--json

## Command Catalog

There are many commands supported by `lstk`, but they'll be addressed in phases. Initially we've focused on `stop`, `reset`, and `update` commands, simply to test the generation of JSON output. The remaining commands will follow in later work, where their specific JSON schema will be considered in more depth (for now, they're simply a rough proposal)
There are many commands supported by `lstk`, but they'll be addressed in phases. `stop`, `reset`, and `update` came first, simply to test the generation of JSON output; `start` (and the bare `lstk` invocation, which shares its behavior) followed. The remaining commands will arrive in later work, where their specific JSON schema will be considered in more depth (for now, they're simply a rough proposal).

### Implemented in this PR
### Implemented

These three ship in this PR with `--json` support. The shapes below are real — they match what the code actually produces, not a proposal.
The shapes below are real — they match what the code actually produces, not a proposal.

**`lstk start`** — one emulator entry per configured container, plus whether a configured snapshot was auto-loaded. The bare `lstk --json` runs the same behavior and reports `"command": "start"`.
```json
{
"schemaVersion": 1,
"command": "start",
"status": "ok",
"data": {
"emulators": [
{"type": "aws", "name": "localstack-aws", "host": "localhost.localstack.cloud:4566", "version": "3.9.0", "alreadyRunning": false, "persist": false}
],
"snapshotLoaded": null
},
"warnings": [],
"error": null
}
```

`emulators` is an array for consistency with `stop`/`restart`/`status`, but it holds exactly one entry today: `start` rejects a config with more than one enabled `[[containers]]` block up front (`CONFIG_INVALID`), since running two emulators together is unsupported. `alreadyRunning` is `true` when the emulator was already up — including when it is one lstk did not start (host-network mode, docker compose, a foreign container), which is still a success. `version` is empty only when it could not be determined: a pinned tag that never went through the platform license flow, or an already-running emulator whose `/_localstack/info` did not answer. `snapshotLoaded` is always present, `null` unless a configured snapshot was auto-loaded, otherwise `{"source": "...", "services": [...]}`.

Codes: `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED` (no token resolvable and no interactive terminal for the device flow — exit code `4`), `LICENSE_INVALID`, `IMAGE_PULL_FAILED`, `EMULATOR_START_FAILED` (startup timeout, a container that exited, or a port/name conflict), `EMULATOR_WRONG_TYPE`, `EMULATOR_ALREADY_RUNNING` (running on a different port than configured), `CONFIG_INVALID` (more than one enabled `[[containers]]` block), `VALIDATION_ERROR` (`--snapshot` with `--no-snapshot`), `SNAPSHOT_INVALID_REF` (malformed `--snapshot` REF), `EMULATOR_NOT_CONFIGURED` (`--snapshot` with no AWS container configured), `CONFIG_NOT_FOUND` (bad or missing `--config` path).

`LICENSE_UNSUPPORTED_TAG` is deliberately **not** in that list, though earlier drafts included it: an unsupported tag is a non-fatal degradation on this path (the container validates its own bundled license) and surfaces as a warning, never as a failure.

**`lstk stop`** — which configured emulators were actually running and got stopped.
```json
Expand Down Expand Up @@ -264,24 +287,6 @@ Codes: `NETWORK_ERROR` (GitHub API unreachable), `INTERNAL_ERROR` (archive downl

#### Emulator lifecycle

**`lstk start`** — one emulator entry per configured container, plus whether a configured snapshot was auto-loaded.
```json
{
"schemaVersion": 1,
"command": "start",
"status": "ok",
"data": {
"emulators": [
{"type": "aws", "name": "localstack-aws", "host": "localhost:4566", "version": "3.9.0", "alreadyRunning": false, "persist": false}
],
"snapshotLoaded": null
},
"warnings": [],
"error": null
}
```
Codes: `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED`, `LICENSE_INVALID`, `LICENSE_UNSUPPORTED_TAG`, `IMAGE_PULL_FAILED`, `EMULATOR_START_FAILED`, `SNAPSHOT_NOT_FOUND` (bad `--snapshot`), `VALIDATION_ERROR` (`--snapshot` with `--no-snapshot`).

**`lstk restart`** — the stop result and the start result, reusing both shapes above.
```json
{
Expand Down Expand Up @@ -592,3 +597,44 @@ Codes: `AUTH_REQUIRED`, `SNAPSHOT_NOT_FOUND`, `SNAPSHOT_INVALID_REF`, `CONFIRMAT
- **`login`** — requires an interactive terminal unconditionally (browser-based OAuth) and has no defined non-interactive behavior at all today, so there's no output to render as JSON.
- **`-v`/`--version`** — Cobra's built-in version flag is handled before any of lstk's own command dispatch runs at all (`Command.execute()` checks it before `PreRunE`/`RunE`), so there is no hook to intercept it without dropping Cobra's own version mechanism — which would newly couple `--version` to config-file loading, breaking the property (shared with `git --version`/`docker --version`) that a version check should work even against a broken environment. This is a deliberate, permanent limitation, not a gap waiting on a future PR.
- **Proxy commands** (`aws`, `terraform`, `cdk`, `sam`, `az` passthrough) and **extension dispatch** — both already have a settled, separate `--json` contract: `--json` before the proxy command's name is rejected the same as any unsupported command, while `--json` from the command name onward is forwarded to the wrapped tool untouched (Terraform, for instance, has its own real `-json` flag). Extensions receive the resolved `--json` value in their runtime context and decide for themselves.

## Adding `--json` support to a command

Six steps. `start` ([cmd/start.go](../cmd/start.go), [internal/container/start.go](../internal/container/start.go)) is the most complete worked example; `stop` is the simplest.

**1. Opt the command in.** Add the annotation to the `cobra.Command`:

```go
Annotations: map[string]string{jsonSupportedAnnotation: "true"},
```

Without it, `requireJSONSupport` (`cmd/root.go`) rejects `--json` with `NOT_JSON_CAPABLE`. There is no central registry — the annotation is the whole opt-in.

**2. Build the sink at the command boundary.** As the first statement in `RunE`:

```go
sink := jsonAwareSink(cmd, cfg, os.Stdout)
```

This returns an `EnvelopeSink` under `--json` and a `PlainSink` otherwise, and registers the envelope sink on the command's context so the wrapper can finalize it after `RunE` returns. Pass this one sink everywhere — **every** helper on the command's path must use it. A `output.NewPlainSink(os.Stdout)` constructed anywhere downstream prints human text to stdout *alongside* the envelope and breaks the "exactly one JSON object" guarantee. This is the single most common mistake; `start` had four such sites.

**3. Skip the TUI.** Guard the Bubble Tea branch with `isInteractiveMode(cfg)`, which is already false whenever `cfg.JSON` is set. Anything that waits on `UserInputRequestEvent.ResponseCh` must be guarded too — a `PlainSink`/`EnvelopeSink` never answers a prompt, so an unguarded one hangs until the context is cancelled.

**4. Emit a result-bearing event.** Domain code must not know about JSON. Add a typed event to `internal/output/events.go` carrying domain facts (not pre-rendered strings), a `data` struct to `envelope_data.go`, a case in `EnvelopeSink.Emit` mapping one to the other, and a formatter in `plain_format.go`. Never add an event whose formatter returns `("", false)` while a neighbouring `MessageEvent` renders the same fact — that dead-formatter pattern was removed once already; the result event should own the line.

**5. Classify every error the command can produce.** An `ErrorEvent` without a `Code` becomes `INTERNAL_ERROR`, which tells a script nothing. Set `Code` at each existing emission site:

```go
sink.Emit(output.ErrorEvent{Code: output.ErrEmulatorStartFailed, Title: ..., Summary: ...})
return output.NewSilentError(err)
```

`Title` is the headline (it becomes `error.message`), `Summary`/`Detail` become `error.details`, and `Actions` become machine-usable `error.actions`. `CONFIRMATION_REQUIRED` and `AUTH_REQUIRED` carry reserved exit codes (`3`, `4`), so classifying them correctly is what makes those exit codes work.

Watch for paths that return a bare `error` with no `ErrorEvent` at all — they fall back to `INTERNAL_ERROR`. Where the error originates in a shared package whose other callers must keep their existing plain-text output, export a sentinel and classify it at the point where a sink exists (`auth.ErrAuthenticationRequired` is the precedent), rather than emitting from inside the shared package.

Adding a *new* code means updating `allErrorCodes` and `categoryByCode` in `internal/output/error_code.go` — completeness tests fail otherwise.

**6. Test it, and keep plain text byte-identical.** Write the integration test first. `decodeEnvelope` in `test/integration/json_envelope_test.go` already enforces the single-object guarantee; assert `error.code`, `error.category`, `error.retryable`, and the exit code for every documented failure, not just the happy path. Most error paths need no Docker — flag validation, config errors, and `DOCKER_HOST=tcp://localhost:1` (`unreachableDockerHost`) all work on Windows CI. Then run the full integration suite: JSON support usually means moving output between sinks, and the existing plain-text assertions are what catch an accidental change in what non-JSON users see.

Finally, move the command's entry from the draft section of the Command Catalog above into [Implemented](#implemented), correcting anything the draft got wrong.
10 changes: 8 additions & 2 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import (

var ErrNotLoggedIn = errors.New("not logged in")

// ErrAuthenticationRequired reports that no token could be resolved and the
// caller is non-interactive, so the device flow is unavailable. A sentinel so
// callers can classify it (e.g. output.ErrAuthRequired); emitting an ErrorEvent
// here instead would change plain-text output for every other caller.
var ErrAuthenticationRequired = errors.New("authentication required: set LOCALSTACK_AUTH_TOKEN or run in interactive mode")

type Auth struct {
tokenStorage AuthTokenStorage
login LoginProvider
Expand Down Expand Up @@ -71,7 +77,7 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) {
}

if !a.allowLogin {
return "", fmt.Errorf("authentication required: set LOCALSTACK_AUTH_TOKEN or run in interactive mode")
return "", ErrAuthenticationRequired
}

return a.loginAndStore(ctx)
Expand All @@ -83,7 +89,7 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) {
// `lstk logout` manually before retrying.
func (a *Auth) Relogin(ctx context.Context) (string, error) {
if !a.allowLogin {
return "", fmt.Errorf("authentication required: set LOCALSTACK_AUTH_TOKEN or run in interactive mode")
return "", ErrAuthenticationRequired
}

if err := a.tokenStorage.DeleteAuthToken(); err != nil && !errors.Is(err, ErrTokenNotFound) {
Expand Down
Loading
Loading