From 562a7e8f730c47dc68bbef5361d22b15f8876f7b Mon Sep 17 00:00:00 2001 From: Peter Smith Date: Wed, 8 Jul 2026 11:09:51 +1200 Subject: [PATCH 1/4] Add json-output-schema OpenSpec proposal Checkpoint before implementation: proposal, design, specs, and tasks for a standardized JSON envelope, error-code taxonomy, and per-command output schema across lstk's --json flag, informed by prior art (Kubernetes, Terraform, GitHub CLI, Docker, AWS CLI, JSON-RPC 2.0) and naming choices that anticipate future YAML support. Co-Authored-By: Claude Sonnet 5 --- .../changes/json-output-schema/.openspec.yaml | 2 + openspec/changes/json-output-schema/design.md | 353 ++++++++++++++++++ .../changes/json-output-schema/proposal.md | 34 ++ .../specs/error-codes/spec.md | 72 ++++ .../specs/json-command-output/spec.md | 55 +++ .../specs/json-flag/spec.md | 21 ++ .../specs/output-envelope/spec.md | 70 ++++ openspec/changes/json-output-schema/tasks.md | 72 ++++ 8 files changed, 679 insertions(+) create mode 100644 openspec/changes/json-output-schema/.openspec.yaml create mode 100644 openspec/changes/json-output-schema/design.md create mode 100644 openspec/changes/json-output-schema/proposal.md create mode 100644 openspec/changes/json-output-schema/specs/error-codes/spec.md create mode 100644 openspec/changes/json-output-schema/specs/json-command-output/spec.md create mode 100644 openspec/changes/json-output-schema/specs/json-flag/spec.md create mode 100644 openspec/changes/json-output-schema/specs/output-envelope/spec.md create mode 100644 openspec/changes/json-output-schema/tasks.md diff --git a/openspec/changes/json-output-schema/.openspec.yaml b/openspec/changes/json-output-schema/.openspec.yaml new file mode 100644 index 00000000..dd9a1d92 --- /dev/null +++ b/openspec/changes/json-output-schema/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-06 diff --git a/openspec/changes/json-output-schema/design.md b/openspec/changes/json-output-schema/design.md new file mode 100644 index 00000000..544db837 --- /dev/null +++ b/openspec/changes/json-output-schema/design.md @@ -0,0 +1,353 @@ +## Context + +`lstk` is a Cobra CLI with ~25 built-in commands plus five proxy commands (`aws`, `terraform`, `cdk`, `sam`, `az`) that forward verbatim to a wrapped tool, and a Git-style extension mechanism for anything else. The `add-json-flag` change (already merged) added a global `--json` flag, threaded it through `internal/env.Env`, and made it force non-interactive rendering — but deliberately implemented no actual JSON output. Every built-in command currently rejects `--json` via a `jsonSupportedAnnotation` opt-in gate that nothing has opted into yet. + +Output today flows through `internal/output.Sink`: domain code (`internal/container`, `internal/snapshot`, `internal/volume`, etc.) emits one of ~20 `Event` types (`MessageEvent`, `ErrorEvent`, `InstanceInfoEvent`, `TableEvent`, `SnapshotShownEvent`, ...) and `cmd/` selects a `Sink` implementation at the command boundary — `NewPlainSink` for non-interactive text, `NewTUISink` for the Bubble Tea interface. This design adds a third sink, `EnvelopeSink`, so the existing domain code needs no changes to support JSON — only the small set of call sites whose current event fields don't carry enough machine-readable information (mostly free-text `ErrorEvent.Title`) need an added classification. + +Errors are not currently enumerated anywhere: they're either an `ErrorEvent` with human prose, or a bare `error` returned up to `cmd/root.go`'s `Execute()`, which prints `Error: %v` to stderr. There is no existing error-code concept to build on. + +## Prior Art + +Before finalizing the envelope, the following were reviewed for how comparable tools structure JSON output and errors: + +| Tool | JSON shape | Error handling | +|---|---|---| +| [Kubernetes API](https://kubernetes.io/docs/reference/kubernetes-api/common-definitions/status/) | Single object per resource; a dedicated `Status` kind for errors | `status: Failure`, an enumerated `reason` (`NotFound`, `Conflict`, `Invalid`, `Timeout`, `Unauthorized`, `Forbidden`, `InternalError`, ...), a human `message`, and a suggested HTTP-style `code` | +| [Terraform `plan`/`apply`/`refresh`/`test -json`](https://developer.hashicorp.com/terraform/internals/machine-readable-ui) | NDJSON — one object per line, with `@level`/`@message`/`@module`/`@timestamp`/`type` on every line; the first line always declares the wire format version | Errors/warnings arrive as interleaved `diagnostic`-typed lines, not a single terminal error — the stream itself is the result | +| [GitHub CLI (`gh`)](https://cli.github.com/manual/gh_help_exit-codes) | `--json field1,field2` — caller supplies an explicit fieldset allowlist | Exit codes are **not** just ok/error: `0` ok, `1` generic failure, `2` cancelled, `4` auth required, with individual commands adding more (`gh pr checks` uses `8` for "pending") | +| [Docker CLI](https://github.com/moby/moby/issues/46906) | Go-template `--format '{{json .}}'`, one object per resource | No structured error convention at all (plain text to stderr); the JSON side has shipped real bugs — missing array brackets, inconsistent NDJSON-vs-array across platforms, stdout/stderr interleaving in Compose | +| AWS CLI v2 (2.34.0+) | Bare API response shape, no added envelope | `--cli-error-format json` → `{"Code", "Message", "Type"}` where `Type` is `Sender` (caller's fault) vs `Service` (their fault) — a retry-relevant classification orthogonal to the specific code | +| [JSON-RPC 2.0](https://www.jsonrpc.org/specification) *(a protocol, not a tool)* | `{"result": ..., "error": ...}`, mutually exclusive, both keys always present | `error: {code, message, data}` — negative integers reserved by spec for transport failures, positive range open for application-defined errors | + +This validated the core envelope choice (a single discriminated `data`/`error` object, both keys always present, is essentially JSON-RPC 2.0's `result`/`error` split) and Kubernetes' `reason`-enum-plus-message pattern (already reflected in `error.code`/`error.message`). It also surfaced two worthwhile, low-cost additions adopted below — reserved exit codes for the categories a shell script branches on most often (matching `gh`'s convention), and a `retryable` flag orthogonal to the specific code (matching AWS's `Sender`/`Service` split) — plus two alternatives considered and declined for this round: NDJSON progress-streaming for slow-but-bounded commands (Terraform/Pulumi's approach to `apply`) and GitHub's sparse-fieldset selection (see Non-Goals and Open Questions). + +None of the five tools reviewed support more than one structured output format with a single shared mechanism in the way lstk intends to (JSON now, YAML later — see the naming decisions below) — the closest precedent is Kubernetes' own tooling, where `kubectl get -o json` and `-o yaml` share one underlying object model and differ only in the final encoding step. That single fact shaped how this change names its new types: see "Decisions" for the accumulation/serialization split and the `json:`-tags-only choice it enables. + +## Goals / Non-Goals + +**Goals:** +- One JSON envelope shape used by every JSON-capable command, so a script or IDE integration learns the contract once. +- Machine-readable errors: a fixed, documented `error.code` enum, never a string a script would have to pattern-match. +- No silent gaps: a JSON-capable command must never fall through to a bare stderr line — every failure, including ones we didn't anticipate, renders as the envelope. +- Cover the full built-in command surface in one pass, so the schema is validated against every real shape it needs to hold, not just the easy cases. + +**Non-Goals:** +- Proxy commands (`aws`, `terraform`, `cdk`, `sam`, `az` passthrough) and extension dispatch. Both already have a settled, separate contract (forward untouched / own output respectively) per the `json-flag` spec; this proposal doesn't reopen that. +- `login` and `config profile`: both require an interactive terminal unconditionally today (browser OAuth, TTY prompts) and gain no JSON path here. A future change could add a non-interactive device-code-style login; out of scope now. +- Actually implementing JSON rendering for all ~25 commands in one PR. This proposal defines the schema and the infrastructure; tasks.md sequences the command-by-command rollout so it can land incrementally behind the same per-command opt-in the flag already uses. +- Changing plain-text/TUI output in any way. `--json` is strictly additive. +- **Considered and declined: NDJSON progress-streaming for slow-but-bounded commands.** Terraform's `apply -json` and Pulumi's engine-event stream emit live progress lines (pull/apply/provision progress) before a final result, rather than staying silent until done. `start` (image pulls) and `snapshot load`/`save` (large state transfers) are exactly this kind of slow-but-bounded operation, and today's `EnvelopeSink` design (Decision above) silently drops that progress instead of streaming it. This is deliberately deferred rather than folded in here — see Open Questions — because it changes the wire shape for those specific commands (NDJSON-with-a-final-result-line, distinct from both the single-envelope and the pure-stream `logs --follow` shapes already defined) and deserves its own decision rather than being absorbed into this pass. +- **Considered and declined: sparse fieldset selection (`gh`-style `--json field1,field2`).** Letting a caller pick which fields of `data` to receive keeps payloads small and makes adding fields always backward-compatible. Declined for v1: lstk's payloads are small relative to `gh`'s (nothing like a hundred-row PR list), and `schemaVersion` already provides an evolution path, so the added flag-parsing surface isn't worth it yet. Revisit if a command's `data` shape grows large enough that most callers only want a slice of it. +- **`-v`/`--version` gaining JSON output.** A permanent gap, not a deferred one — see the "Decision: `-v`/`--version` stays JSON-incapable" below for why. +- **How a future second output format is selected on the command line** (e.g. whether `--json` itself changes shape). Explicitly deferred — not addressed by this change at all, beyond noting that the new type/capability names here (`EnvelopeSink`, `EnvelopeError`, `EnvelopeAction`, `output-envelope`, `error-codes`) don't depend on the answer. + +## Decisions + +### Decision: A dedicated `EnvelopeSink` accumulates events; serialization is a separate, swappable step + +**Alternative considered**: have each command build its own result struct and marshal it directly, bypassing the sink/event system for the JSON path. + +Rejected because it would require every domain function to grow a second, format-specific return value or callback alongside the events it already emits — doubling the surface area CLAUDE.md's "Output Routing and Events" section asks every feature to go through, and re-introducing exactly the kind of per-command bespoke logic this proposal exists to avoid. Instead, `output.EnvelopeSink` implements `Sink` like `PlainSink`/`TUISink` do: it type-switches on each emitted event, accumulates the ones that carry the command's actual result (`InstanceInfoEvent`, `TableEvent`, `ResourceSummaryEvent`, `SnapshotShownEvent`, `PodSnapshotSavedEvent`, etc.) into the envelope's `data`, routes `ErrorEvent` into `error`, and silently drops purely presentational events (`SpinnerEvent`, `ContainerStatusEvent`, `ProgressEvent`, `DeferredEvent`) that have no place in a single terminal result. `MessageEvent` with `SeverityWarning` is kept, not dropped — it becomes an entry in the envelope's `warnings` array (see below), since "an update is available" or "DNS fallback used" is information a script may care about even on success. + +This accumulation step has nothing JSON-specific about it — it would be identical if the output were YAML or anything else structured. Only the final step differs: `EnvelopeSink` is constructed with an `output.Format` (`output.FormatJSON` is the only value that exists today), and `(*EnvelopeSink).Result() Envelope` returns the accumulated struct for the command boundary to marshal according to that format and write once to stdout. The name is chosen for the mechanism (accumulating events into an envelope), not the one format it currently serializes to — a deliberate choice given YAML output using the same envelope is a known future direction (not being built now, but not something this naming should have to be undone for later). This mirrors `FormatEventLine`'s single-dispatch-point pattern already used for plain-text rendering, just producing a struct instead of a string. + +### Decision: `ErrorEvent` gains a `Code ErrorCode` field instead of a parallel format-specific error event + +**Alternative considered**: a new `EnvelopeErrorEvent` type, separate from `ErrorEvent`, emitted only on the structured-output path. + +Rejected for the same reason as above — it would mean every error call site needs two emissions (or an if/else on output mode inside domain code, which CLAUDE.md's routing rules explicitly forbid). Instead `ErrorEvent` gets one additive field: + +```go +type ErrorEvent struct { + Title string + Summary string + Detail string + Actions []ErrorAction + Code ErrorCode // new; empty means "not yet classified" — EnvelopeSink falls back to INTERNAL_ERROR +} +``` + +`PlainSink`/`TUISink` ignore the new field entirely (matching how they already ignore fields they don't render), so this is fully backward-compatible. Call sites inside a JSON-capable command's reachable code path are updated to set `Code`; sites not yet reached by any JSON-capable command are left as-is and simply render as `INTERNAL_ERROR` if a future command opts in without also classifying them — a visible, honest fallback rather than an invented code. + +### Decision: The envelope is one flat struct, not per-command discriminated types + +```go +type Envelope struct { + SchemaVersion int `json:"schemaVersion"` + Command string `json:"command"` + Status string `json:"status"` // "ok" | "error" + Data any `json:"data"` + Warnings []Warning `json:"warnings"` + Error *EnvelopeError `json:"error"` +} + +type Warning struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type EnvelopeError struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable"` + Details map[string]any `json:"details,omitempty"` + Actions []EnvelopeAction `json:"actions,omitempty"` +} + +type EnvelopeAction struct { + ID string `json:"id"` // e.g. "start-emulator" + Command string `json:"command"` // e.g. "lstk" +} +``` + +`data` is `null` when `status` is `"error"`, and `error` is `null` when `status` is `"ok"` — always both keys present so a script can do `resp.error` / `resp.data` without existence checks. `warnings` is always an array (possibly empty), never `null`, for the same reason. + +**Alternative considered**: version each command's payload independently (`"schemaVersion": {"envelope": 1, "status": 2}`). Rejected as premature — all commands ship from the same `lstk` binary version, so a single envelope `schemaVersion`, bumped only on a breaking change to the envelope itself or to any one command's `data` shape, is sufficient and far simpler for a script to check (`if resp.schemaVersion !== 1: bail`). + +`EnvelopeAction` deliberately mirrors `output.ErrorAction` (`Label`/`Value`) but renamed for a machine reader: `id` is a stable slug a script can switch on, `command` is the literal shell command a human or script could run (e.g. `{"id": "start-emulator", "command": "lstk"}` where plain text today shows `==> Start LocalStack: lstk`). + +**Naming note**: `Envelope`, `Warning`, `EnvelopeError`, `EnvelopeAction`, and `ErrorCode` all avoid a `JSON`-specific name on purpose, matching `EnvelopeSink` above — none of these types describe anything about JSON specifically, only about the envelope's shape. `ErrorCode` values themselves (`EMULATOR_NOT_RUNNING`, etc.) are just strings and were already format-neutral without any renaming needed. + +### Decision: Struct tags stay `json:`-only — no parallel `yaml:` tags to maintain later + +Every field above defines its wire name once, via a `json:` tag. The obvious worry: if YAML output is added later, won't it need its own `yaml:` tags, maintained in parallel and prone to drifting out of sync (e.g. `schemaVersion` in JSON but a plain `yaml.Marshal` defaulting to `schemaversion` in YAML, since `gopkg.in/yaml.v3` — already an indirect dependency in `go.mod` today — doesn't read `json:` tags at all)? + +It doesn't need to, based on precedent: [`sigs.k8s.io/yaml`](https://github.com/kubernetes-sigs/yaml) — the library `kubectl` itself uses so `-o json` and `-o yaml` produce identical field names from Kubernetes API structs that only carry `json:` tags — works by marshaling to JSON first via `encoding/json`, then converting those bytes to YAML, rather than walking the struct with a YAML-native encoder. A future YAML format for lstk's envelope can use the same trick: `json.Marshal(envelope)` then a JSON→YAML byte conversion, so `Envelope`/`EnvelopeError`/etc. never need a second set of tags, and the two output formats are guaranteed to agree on every field name by construction, not by discipline. Nothing to implement now — just a reason the current `json:`-only tagging isn't a trap. + +**Alternative considered**: add `yaml:` tags alongside `json:` tags now, "for free," since the fields already exist. Declined — it isn't actually free (every future field addition would need to remember both tags, and a human easily introduces a typo'd mismatch between them that a JSON-first-then-convert approach makes structurally impossible), and there's no consumer of the `yaml:` tag yet to catch such a mismatch in review. + +### Decision: Exit codes stay coarse, with two reservations for the categories scripts branch on most + +`0` on success, `1` when the envelope's `status` is `"error"` for any code not covered below, `2` only for a Cobra-level usage error that couldn't be attributed to any command logic. Scripts that need finer branching read `error.code` from the JSON, not the exit code — POSIX exit codes are a poor fit for a 28-entry enum, and every code already round-trips through the same envelope on stdout. + +Two exceptions, following `gh`'s precedent of reserving specific exit codes for the failures a shell script is most likely to want to branch on *without* parsing stdout first: exit `3` when `error.code` is `CONFIRMATION_REQUIRED` (a script can retry the exact same invocation with `--force` appended), and exit `4` when `error.code` is `AUTH_REQUIRED` (a script can shell out to `lstk login` and retry), mirroring `gh`'s own `4` for "needs auth". These two are picked because they came up across nearly every command in the Command Catalog and have an obvious, mechanical remediation — everything else stays behind `error.code` on exit `1`, since inventing an exit code per category doesn't scale to 28 codes and most failures don't have a one-line automated fix anyway. + +| Exit code | Meaning | +|---|---| +| `0` | `status: "ok"` | +| `1` | `status: "error"`, any code other than the two below | +| `2` | Unattributed Cobra usage error (see below) | +| `3` | `error.code == "CONFIRMATION_REQUIRED"` | +| `4` | `error.code == "AUTH_REQUIRED"` | + +### Decision: Error objects carry a `retryable` flag, orthogonal to `code` + +`error.code` answers "what went wrong"; it doesn't answer "is it worth trying again." `RUNTIME_UNAVAILABLE` and `NETWORK_ERROR` are usually transient — a polling script should back off and retry. `VALIDATION_ERROR` and `CONFIRMATION_REQUIRED` never resolve themselves — retrying the identical invocation just fails the same way again. Rather than making a script maintain its own hardcoded list of "which of these 28 codes are worth retrying," `retryable` is a boolean on every error object, mirroring AWS CLI's `Sender`/`Service` split (their two-value version of the same idea). It is a **static property of the code**, not computed per-instance — the same code always carries the same `retryable` value, documented alongside the code table in the `error-codes` capability (e.g. `RUNTIME_UNAVAILABLE: true`, `VALIDATION_ERROR: false`). + +**Alternative considered**: reuse AWS's exact `Sender`/`Service` two-value classification instead of a boolean. Declined — lstk has no analogous client/server split (everything runs locally), and "is it worth automatically retrying" is the one question a script actually needs answered; a boolean says that directly without requiring the caller to know that, say, `Sender` means "don't retry" in lstk's context too. + +### Decision: `RUNTIME_UNAVAILABLE`, not `DOCKER_UNAVAILABLE` — the code names the abstraction, not the concrete implementation + +An earlier draft of this proposal used `DOCKER_UNAVAILABLE`, following the concrete type actually implemented today (`runtime.NewDockerRuntime`, used at every call site in `cmd/`). That's the wrong level to name a stable, machine-readable contract at: `internal/runtime.Runtime` is already an interface (`IsHealthy`, `EmitUnhealthyError`, etc.) with exactly one implementation, and CLAUDE.md's own architecture section describes it as "Abstraction for container runtimes (Docker, Kubernetes, etc.) — currently only Docker implemented." Baking "Docker" into the error taxonomy would mean either a breaking rename the day a second runtime lands (Podman, Rancher Desktop, and Finch are Docker-API-compatible and might work with zero runtime-layer changes; Kubernetes would need a distinct `Runtime` implementation), or shipping a permanently inaccurate code once one did. `RUNTIME_UNAVAILABLE` matches the interface name that already exists in the codebase, not a new abstraction invented for this proposal. + +**Alternative considered**: `CONTAINER_RUNTIME_UNAVAILABLE`, matching CLAUDE.md's exact phrase "container runtime." Declined only for brevity — `runtime` is unambiguous in this CLI (there's no other kind of "runtime" lstk deals with), and it matches the Go package name (`internal/runtime`) and interface name (`runtime.Runtime`) directly, which is arguably a more durable anchor than a doc phrase that could itself be reworded later. + +### Decision: Usage errors are best-effort JSON + +Cobra's own flag/argument validation happens before `RunE` runs, via `PersistentPreRunE`/`Args` checks, and today produces a plain `Error: %v` on stderr with `SilenceErrors`/`SilenceUsage` set (`cmd/root.go:227-239`). A new wrapper — parallel to `requireJSONSupport`'s existing tree-walk — catches an error returned from that pre-run stage and, **if `--json` was already successfully parsed by that point**, renders it as the envelope with `error.code = USAGE_ERROR`. If the malformed flag appears *before* `--json` in the invocation (so Cobra never got far enough to see `--json` at all — e.g. `lstk --jso status`), lstk cannot know JSON was wanted and falls back to today's plain-text usage error, exit `2`. This is a narrow, honestly-documented gap rather than an attempt to solve flag-parsing order in general. + +### Decision: `status`'s JSON mode reports every configured emulator, not just the first non-running one + +Today, plain-text `status` loops over configured containers and returns immediately (as an `ErrorEvent` + exit 1) on the first one that isn't running (`internal/container/status.go:32-41`), so with AWS configured-but-stopped and Snowflake running, `lstk status` never even checks Snowflake. For JSON, this is the wrong shape for "a script wants to know what's up" — it silently truncates. JSON mode always iterates every configured emulator and reports `running: true/false` for each, with the running ones' full detail and the non-running ones as just `{"type": "...", "running": false}`. This only changes `--json` behavior; plain-text `status` is unchanged. + +### Decision: `logs --follow --json` is NDJSON, not one growing envelope + +A follow-mode log stream has no natural "done" moment to close a single JSON object around. Under `--json --follow`, each line is its own compact JSON object, newline-delimited (NDJSON), with a `type` field instead of `status` (`"log"` for a line, `"error"` if the stream itself fails): + +```json +{"schemaVersion":1,"command":"logs","type":"log","data":{"source":"emulator","level":"info","line":"Ready."}} +``` + +Bounded `logs --json` (no `-f`) uses the normal single-envelope shape with `data.lines` as an array of the same `{source, level, line}` objects — it terminates naturally, so the standard envelope fits. + +### Decision: `-v`/`--version` stays JSON-incapable, permanently — no new subcommand added to work around it + +Cobra's built-in version flag (`root.InitDefaultVersionFlag()`) is handled inside `Command.execute()` before `PersistentPreRunE`/`RunE` run at all, so it never passes through `requireJSONSupport` or any JSON dispatch this design adds — there is no hook to intercept it without reaching into Cobra's internals. + +**Alternative considered**: add a real `lstk version` subcommand (like `docker version`/`kubectl version`), going through the normal `RunE` path so it can support `--json` (`{"version": "2.3.1"}`), leaving `-v`/`--version` as a plain-text-only flag alongside it. Initially chosen, then reconsidered and dropped: it's new CLI surface invented solely to route around Cobra's short-circuit, for a cosmetic gap (the version is not information any of the surveyed prior art or real usage suggests scripts urgently need in JSON form — unlike, say, `status` or `snapshot show`). + +**Alternative considered**: stop using Cobra's `c.Version`/`InitDefaultVersionFlag()` mechanism entirely, and instead register `--version`/`-v` as an ordinary flag checked inside root's own `RunE`, alongside `--persist` and the snapshot flags it already reads there. This would let `--version` flow through the same JSON dispatch as everything else without adding a subcommand. Also rejected: root's `PreRunE` is `initConfigDeferCreate`, which loads (and on first run, creates) the config file. Moving version-handling into `RunE` means it now runs *after* `PreRunE`, so `lstk --version` would start depending on config loading succeeding — a real regression, since today it works even with a missing or malformed config file, matching the convention `git --version`/`docker --version`/`terraform --version` all share (a version check is often run specifically to debug a broken environment, so it shouldn't be able to fail because of one). + +**Decision**: accept the gap. `-v`/`--version` never gets `--json` support, documented as a permanent limitation alongside `login` and `config profile` (see Command Catalog and Non-Goals) rather than solved by inventing a command around it. + +## Command Catalog + +This is the artifact meant for human review: every built-in command, whether it gets `--json` support in this proposal, its `data` shape inside the envelope, and the `error.code`s it can realistically produce. Field names are `camelCase` JSON; durations are seconds (`uptimeSeconds`), timestamps are RFC 3339 strings, byte counts are `sizeBytes`. Example `name` values below (e.g. `"localstack-aws"`) are the real default container name for the AWS emulator — `fmt.Sprintf("localstack-%s", c.Type)` in `internal/config/containers.go`'s `ContainerConfig.Name()`, not a placeholder — since a custom `container_name` isn't currently configurable, this is what every reader will actually see. It names the **container**, not the underlying Docker **image** (`localstack/localstack:latest`, resolved separately by `ContainerConfig.Image()`); the two are easy to conflate but the `name` field here is always the former, matching `InstanceInfoEvent.ContainerName`. Commands and flags not listed (`aws`, `terraform`, `cdk`, `sam`, `az` passthrough, extension dispatch, `docs`, `completion`, `help`, `login`, `config profile`, and the `-v`/`--version` flag) are explicitly out of scope — see Non-Goals. + +### Emulator lifecycle + +**`lstk start`** — `data`: an emulator entry per configured container, plus whether a configured snapshot was auto-loaded. +```json +{ + "emulators": [ + {"type": "aws", "name": "localstack-aws", "host": "localhost:4566", "version": "3.9.0", "alreadyRunning": false, "persist": false} + ], + "snapshotLoaded": 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 stop`** — `data`: which configured emulators were actually running and got stopped. +```json +{"emulators": [{"type": "aws", "name": "localstack-aws", "wasRunning": true}]} +``` +Codes: `RUNTIME_UNAVAILABLE`. + +**`lstk restart`** — `data`: the stop result and the start result, reusing both shapes above. +```json +{"stopped": [{"type": "aws", "name": "localstack-aws", "wasRunning": true}], "started": [{"type": "aws", "name": "localstack-aws", "host": "localhost:4566", "version": "3.9.0", "alreadyRunning": false, "persist": false}]} +``` +Codes: `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED`, `LICENSE_INVALID`, `EMULATOR_START_FAILED`. + +**`lstk status`** — `data`: one entry per *configured* emulator (not just running ones — see the status-behavior Decision above); non-running entries omit the detail fields entirely rather than nulling them out. +```json +{ + "emulators": [ + { + "type": "aws", "running": true, "name": "localstack-aws", "version": "3.9.0", + "host": "localhost:4566", "uptimeSeconds": 1234, "persistence": false, + "resourceSummary": {"resources": 12, "services": 4}, + "resources": [{"service": "s3", "name": "my-bucket", "region": "us-east-1", "account": "000000000000"}] + }, + {"type": "snowflake", "running": false} + ] +} +``` +Codes: `RUNTIME_UNAVAILABLE`. + +**`lstk logs`** (bounded) — `data.lines` is the same `{source, level, line}` shape used by the NDJSON stream variant. +```json +{"lines": [{"source": "emulator", "level": "info", "line": "Ready."}]} +``` +`lstk logs --follow` (NDJSON, one compact object per line, no enclosing envelope — see Decisions): +```json +{"schemaVersion": 1, "command": "logs", "type": "log", "data": {"source": "emulator", "level": "info", "line": "Ready."}} +``` +Codes: `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`. + +**`lstk reset`** — `data`: confirmation of what was reset (AWS-only today). +```json +{"emulator": {"type": "aws", "name": "localstack-aws"}, "reset": true} +``` +Codes: `EMULATOR_NOT_CONFIGURED` (no AWS container configured), `EMULATOR_NOT_RUNNING`, `CONFIRMATION_REQUIRED` (no `--force` outside a TTY), `RUNTIME_UNAVAILABLE`. + +**`lstk volume path`** — `data`: one path per configured container. +```json +{"volumes": [{"type": "aws", "path": "/Users/x/Library/Caches/lstk/aws"}]} +``` +Codes: `CONFIG_INVALID`. + +**`lstk volume clear`** — `data`: which volumes were actually cleared. +```json +{"cleared": [{"type": "aws", "path": "/Users/x/Library/Caches/lstk/aws"}]} +``` +Codes: `EMULATOR_NOT_CONFIGURED` (bad `--type`), `CONFIRMATION_REQUIRED` (no `--force` outside a TTY). + +### Configuration and auth + +**`lstk config path`** — `data`: the resolved (or explicitly `--config`-overridden) path. +```json +{"path": "/Users/x/.config/lstk/config.toml"} +``` +Codes: `CONFIG_NOT_FOUND` (`--config` path doesn't exist), `CONFIG_INVALID`. + +**`lstk logout`** — `data`: whether there was anything to log out of, and any emulators still running with the now-removed token. +```json +{"loggedOut": true, "stillRunning": []} +``` +When already logged out, this is still `status: "ok"` with `"loggedOut": false` — logout is idempotent today (`errors.Is(err, auth.ErrNotLoggedIn)` returns `nil`), and JSON mode preserves that rather than inventing a new error for it. Codes: none expected in normal operation; `INTERNAL_ERROR` as the universal fallback. + +**`lstk setup aws`** — `data`: the profile that was written and whether the LocalStack hostname resolved. +```json +{"profile": "localstack", "written": true, "dnsOk": true} +``` +Codes: `CONFIRMATION_REQUIRED` (existing profile differs, no `--force`). + +**`lstk setup azure`** — `data`: the isolated config dir and the cloud that was registered. +```json +{"configDir": "/Users/x/.config/lstk/azure", "cloudRegistered": "LocalStack"} +``` +Codes: `DEPENDENCY_MISSING` (`az` CLI not on `PATH`), `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`, `DNS_RESOLUTION_REQUIRED`. + +**`lstk az start-interception`** — `data`: same shape as `setup azure`, plus the resolved endpoint. +```json +{"cloudRegistered": "LocalStack", "endpoint": "https://azure.localhost.localstack.cloud:4566"} +``` +Codes: `DEPENDENCY_MISSING`, `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`, `DNS_RESOLUTION_REQUIRED`. + +**`lstk az stop-interception`** — `data`: what changed, or confirmation nothing did (LocalStack wasn't the active cloud). +```json +{"switchedFrom": "LocalStack", "switchedTo": "AzureCloud", "changed": true} +``` +Codes: `VALIDATION_ERROR` (`--cloud` not a registered cloud). + +### Snapshots + +**`lstk snapshot save`** — `data`: one shape covering local/pod/S3 destinations, discriminated by `kind`. +```json +{"kind": "pod", "location": "pod:my-baseline", "podName": "my-baseline", "version": 4, "services": ["s3", "lambda"], "sizeBytes": 245678} +``` +(`kind` is `"local"` | `"pod"` | `"s3"`; `podName`/`version` are `null` for `kind: "local"`.) Codes: `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`, `AUTH_REQUIRED` (pod), `CREDENTIALS_MISSING` (S3, no AWS creds resolvable), `SNAPSHOT_BUCKET_NOT_FOUND`, `SNAPSHOT_REMOTE_ERROR`, `VALIDATION_ERROR` (bad pod name). + +**`lstk snapshot load`** — `data`: what was loaded and whether it started the emulator to do so. +```json +{"source": "pod:my-baseline", "services": ["s3", "lambda"], "emulatorStarted": false, "mergeStrategy": "account-region-merge"} +``` +Codes: `SNAPSHOT_NOT_FOUND`, `SNAPSHOT_INVALID_REF`, `SNAPSHOT_REMOTE_ERROR`, `SNAPSHOT_BUCKET_NOT_FOUND`, `CREDENTIALS_MISSING`, `AUTH_REQUIRED`, `RUNTIME_UNAVAILABLE`, `EMULATOR_START_FAILED`, `VALIDATION_ERROR` (bad `--merge`). + +**`lstk snapshot list`** — `data`: the platform or S3 location queried, and the snapshots found. +```json +{"location": "platform", "snapshots": [{"name": "my-baseline", "version": 4, "lastChanged": "2026-07-01T12:00:00Z"}]} +``` +Codes: `AUTH_REQUIRED` (platform), `CREDENTIALS_MISSING`/`SNAPSHOT_BUCKET_NOT_FOUND` (S3), `SNAPSHOT_REMOTE_ERROR`, `EMULATOR_NOT_RUNNING` (S3 list requires a running emulator). + +**`lstk snapshot show`** — `data`: mirrors `SnapshotShownEvent` directly (the richest existing struct, see design Context). +```json +{ + "name": "my-baseline", "version": 4, "created": "2026-06-01T00:00:00Z", "sizeBytes": 245678, + "localstackVersion": "3.9.0", "message": "", "services": ["s3"], + "resources": [{"service": "s3", "counts": [{"noun": "buckets", "count": 3}]}] +} +``` +Codes: `AUTH_REQUIRED`, `SNAPSHOT_NOT_FOUND`, `SNAPSHOT_INVALID_REF`, `SNAPSHOT_REMOTE_ERROR`. + +**`lstk snapshot remove`** — `data`: confirmation of deletion. +```json +{"podName": "my-baseline", "removed": true} +``` +Codes: `AUTH_REQUIRED`, `SNAPSHOT_NOT_FOUND`, `SNAPSHOT_INVALID_REF`, `CONFIRMATION_REQUIRED`, `SNAPSHOT_REMOTE_ERROR`. + +### Misc + +**`lstk update`** — `data`: differs slightly between `--check` and an applied update. +```json +{"currentVersion": "2.2.1", "latestVersion": "2.3.0", "updateAvailable": true} +``` +```json +{"currentVersion": "2.2.1", "updatedVersion": "2.3.0", "updated": true, "method": "homebrew"} +``` +Codes: `NETWORK_ERROR` (GitHub API unreachable), `INTERNAL_ERROR` (archive/extraction failure). + +## Risks / Trade-offs + +- **[Risk]** A command's `data` shape needs a breaking change later (e.g. a field's type changes). → Mitigation: `schemaVersion` exists precisely for this; bump it and document the change in the command's help text and changelog. Additive fields (new optional keys) never require a bump. +- **[Risk]** Classifying every reachable `ErrorEvent`/bare-`error` call site per JSON-capable command is real, distributed work — easy to miss one and silently fall back to `INTERNAL_ERROR` where a more specific code exists. → Mitigation: tasks.md sequences this per command (not all at once), and each command's task includes an integration test asserting every documented error path in its catalog entry produces the expected `error.code`, not just `INTERNAL_ERROR`. +- **[Risk]** The `status` behavior change (report all emulators instead of failing fast) is a real behavior difference, even though scoped to `--json` only. → Mitigation: explicitly called out in the proposal and covered by its own spec scenario, so it can't land silently. +- **[Trade-off]** NDJSON for `logs --follow` means the two `logs` modes (bounded vs. follow) don't share one wire shape. → Accepted: a genuinely unbounded stream and a genuinely bounded result are different enough contracts that forcing them into one shape would be more confusing than two clearly-documented ones. +- **[Risk]** `retryable`'s per-code classification is a judgment call (e.g. `EMULATOR_START_FAILED` could plausibly go either way) and could be second-guessed or need revision as real usage surfaces edge cases. → Mitigation: it's a static, centrally-defined mapping in one table (`error-codes`), not scattered per call site, so revising it later is a one-line change per code, not a hunt across the codebase. +- **[Risk]** Naming new types for a YAML future that "shouldn't be implemented yet" could turn out to guess wrong about what YAML support actually needs once it's real (e.g. the streaming variant, per the Open Questions note below, clearly won't just reuse `EnvelopeSink` as-is). → Mitigation: the rename costs nothing today (no shipped code depends on the old names) and only touches the accumulation/naming layer, not behavior; if the guess is wrong, it's a rename again, not a redesign — the actual JSON behavior being built here doesn't change either way. + +## Migration Plan + +No migration for existing users — `--json` remains rejected for any command not yet opted in, exactly as it is today, until that command's task in tasks.md lands. Rollout is per-command and independently shippable/revertable (each command's opt-in is a single annotation plus a sink-selection branch), so there is no all-or-nothing cutover. No data formats, config files, or stored state change. + +`stop`, `reset`, and `update` are implemented first as a pilot (tasks.md section 3), ahead of the rest of the command surface. All three depend only on the section 1-2 infrastructure, not on each other or on any other command, so pulling them forward doesn't reorder anything load-bearing — and together they're a deliberately small, diverse first exercise of the shared plumbing: `stop` is the baseline array-of-emulators success case, `reset` is the first real path through `CONFIRMATION_REQUIRED` and the exit-code-`3` reservation, and `update` is the only one of the three with no Docker/emulator involvement at all, exercising `NETWORK_ERROR` and `retryable: true`. If the `EnvelopeSink`/error-boundary design needs revision after real implementation, this is the cheapest point to catch it — before the remaining ~20 commands are built on top of it. + +## Open Questions + +- **Should `start`, `restart`, `snapshot load`, and `snapshot save` stream NDJSON progress instead of staying silent until the final envelope?** This is the biggest open fork from the Prior Art review: Terraform's `apply -json` and Pulumi's engine events emit live `pulling`/`applying`/`provisioning`-style lines throughout a slow operation, ending in a final result line; today's design (see Decisions) discards that progress entirely under `--json`, matching what plain-text non-interactive mode already does but potentially leaving a script watching a slow image pull with no output for the whole duration. Deferred rather than decided here because it would introduce a third wire shape (NDJSON-with-trailing-result, distinct from both the single envelope and the pure `logs --follow` stream) and should be scoped as its own decision once there's a concrete case (e.g. CI logs showing an `lstk start --json` step that looks hung) rather than spec'd speculatively. If pursued, `start`/`restart` are the natural pilot given they already have the richest interactive-mode progress today (`ContainerStatusEvent`, `ProgressEvent`) that JSON mode currently drops on the floor. +- Should `warnings` also surface non-fatal issues from *plain-text* mode today (e.g. the "multiple emulators of the same type" message in `start`), or only ones specifically worth a script's attention? Leaning toward: any `MessageEvent{Severity: SeverityWarning}` reachable from a JSON-capable command's path qualifies, decided per command during its implementation task rather than up front. +- Should `snapshot list`/`show` cache platform responses to reduce load when scripts poll `--json` in a loop? Out of scope here; revisit if it becomes a real usage pattern. +- Whether `az start-interception`/`stop-interception` belong in the first implementation wave given they mutate global `~/.azure` state — flagged in tasks.md as a candidate for a later phase rather than blocking the rest. +- If sparse fieldset selection (declined for v1, see Non-Goals) ever becomes worth revisiting, would it be a per-command `--json-fields` flag or a generic post-processing convention (e.g. documenting that `jq` handles this already, so lstk doesn't need to)? +- **The NDJSON shape for `logs --follow` won't just gain a YAML sibling by swapping the marshaler.** Unlike the single-envelope case (where `EnvelopeSink` + a different `Format` is designed to be enough), YAML has no equivalent to "one compact object per line" — its multi-document convention uses `---` separators, a structurally different streaming contract. Whoever proposes YAML support will need a real design decision here, not an assumption that it falls out of the naming work done in this change. diff --git a/openspec/changes/json-output-schema/proposal.md b/openspec/changes/json-output-schema/proposal.md new file mode 100644 index 00000000..2d224ebf --- /dev/null +++ b/openspec/changes/json-output-schema/proposal.md @@ -0,0 +1,34 @@ +## Why + +`lstk` recently gained a global `--json` flag (the `add-json-flag` change), but it is pure plumbing: every built-in command currently rejects `--json` outright, because no command has an actual JSON rendering yet. Scripts, CI pipelines, and IDE integrations that want to drive `lstk` today have no choice but to scrape human-readable text — the same problem `--non-interactive` already solved for *input*, still unsolved for *output*. Solving it command by command, ad hoc, would produce 25+ incompatible one-off shapes (some flat, some nested, errors sometimes a string and sometimes an object) that are individually reasonable but collectively useless for a tool that wants to branch on `lstk`'s result programmatically. This proposal defines one common envelope and one shared, enumerated error-code vocabulary that every command's JSON output uses, then works through the full command surface to show what each command's `data` payload looks like inside that envelope — so a script only has to learn the shape once. The envelope and error taxonomy were checked against five comparable tools (Kubernetes, Terraform, GitHub CLI, Docker, AWS CLI) plus the JSON-RPC 2.0 spec — see design.md's Prior Art section — which validated the core shape and motivated two additions below (reserved exit codes, a `retryable` flag). + +## What Changes + +- Define a common JSON envelope (`schemaVersion`, `command`, `status`, `data`, `warnings`, `error`) that every JSON-capable command emits as exactly one object to stdout (NDJSON for the one genuinely streaming command, `logs --follow`). +- Define a stable, enumerated `error.code` vocabulary (e.g. `EMULATOR_NOT_RUNNING`, `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED`, `CONFIRMATION_REQUIRED`) so scripts branch on a fixed string, never on parsed prose. Full list and per-command mapping in design.md. +- Every error object also carries a `retryable` boolean, a static per-code classification (mirroring AWS CLI's `Sender`/`Service` split) so a polling script can distinguish "back off and retry" (`RUNTIME_UNAVAILABLE`, `NETWORK_ERROR`) from "fix the invocation" (`VALIDATION_ERROR`, `CONFIRMATION_REQUIRED`) without hardcoding its own list. +- Exit codes stay coarse (`0` ok, `1` error, `2` unattributed usage error) with two reservations for the categories a script is most likely to branch on before even parsing stdout, matching GitHub CLI's convention: `3` for `CONFIRMATION_REQUIRED`, `4` for `AUTH_REQUIRED`. +- Guarantee that a JSON-capable command **never** leaks a bare, unstructured error to stderr: every failure path — including Cobra usage errors and previously-uncategorized `fmt.Errorf` returns — is caught at the command boundary and re-rendered as the same envelope with `error.code = INTERNAL_ERROR` as the last-resort fallback. +- Opt every command in individually, reusing the existing `jsonSupportedAnnotation` gate from `add-json-flag`. This proposal covers the full built-in command surface (`start`, `stop`, `restart`, `status`, `logs`, `config path`, `volume path`/`clear`, `setup aws`/`azure`, `snapshot save`/`load`/`list`/`show`/`remove`, `reset`, `logout`, `az start-interception`/`stop-interception`, `update`). `login` and the deprecated `config profile` stay JSON-incapable by design — both are inherently interactive (browser-based OAuth, TTY prompts) with no meaningful non-interactive path today. The `-v`/`--version` flag also stays JSON-incapable permanently, by choice rather than gap: Cobra handles it before `RunE` ever runs, so making it participate would mean dropping Cobra's built-in version mechanism and handling it manually inside `RunE` — which would newly couple `--version` to config-file loading (today it works even with a broken config, matching `git --version`/`docker --version`), a regression not worth a cosmetic JSON version output. No new command is added to work around this. +- Change what `--json status` reports when multiple emulators are configured: instead of stopping at the first non-running one (today's plain-text behavior), it reports the running/not-running state of every configured emulator, since a script asking for status wants the full picture, not a partial one truncated by whichever container config happens to come first. +- **Modifies `json-flag`**: the existing "command doesn't support JSON" rejection currently always prints plain text, even though the user explicitly asked for `--json`. It now renders as the same JSON envelope (`error.code = NOT_JSON_CAPABLE`) when `--json` was requested, so the one guaranteed-universal response to `--json` is itself JSON. +- No changes to proxy commands (`aws`, `terraform`, `cdk`, `sam`, `az` passthrough) or extension dispatch — both are explicitly out of scope per the existing `json-flag` spec and continue to forward/own their own output untouched. + +## Capabilities + +### New Capabilities +- `output-envelope`: the common envelope structure, exit-code conventions (including the two reserved codes for `CONFIRMATION_REQUIRED`/`AUTH_REQUIRED`), and the guarantee that a JSON-capable command always emits exactly one well-formed JSON result (or one NDJSON stream) to stdout, including on unclassified errors and Cobra usage errors. Named without a `json-` prefix deliberately — the envelope shape doesn't depend on JSON specifically (see design.md's naming decisions), even though this change only implements a JSON serialization of it. +- `error-codes`: the enumerated, stable `error.code` vocabulary, the `retryable` flag carried on every error object, and the requirement that every JSON error uses one of these codes instead of free text. Same naming rationale as `output-envelope` — the codes themselves are serialization-agnostic strings. +- `json-command-output`: the per-command opt-in and documented `data` payload for each JSON-capable command, and the `status` behavior change described above. Keeps the `json-` prefix because it's genuinely about the `--json` flag as it exists today, not about a format-agnostic mechanism. + +### Modified Capabilities +- `json-flag`: the "command not JSON-capable" rejection renders as a JSON envelope (`error.code = NOT_JSON_CAPABLE`) when `--json` was requested, instead of always using the plain-text interactive error style. + +## Impact + +- `internal/output`: new envelope/error-code types (`Envelope`, `EnvelopeError`, `EnvelopeAction`, `Warning`, `ErrorCode`) and an `EnvelopeSink` (`Sink` implementation) that translates the existing event vocabulary (`InstanceInfoEvent`, `TableEvent`, `ResourceSummaryEvent`, `ErrorEvent`, etc.) into the envelope, instead of formatting lines; `ErrorEvent` gains a `Code` field (additive). Type and constructor names deliberately avoid a `JSON`-specific name (`EnvelopeSink`, not `JSONSink`) — see design.md's naming decisions. +- `cmd/*.go`: every command listed above gains the `jsonSupportedAnnotation` and a branch (alongside its existing `isInteractiveMode(cfg)` / plain-sink split) that selects `output.NewEnvelopeSink(w, command, output.FormatJSON)` when `cfg.JSON` is set. +- `cmd/root.go`: `requireJSONSupport` renders its rejection through the envelope when `cfg.JSON` is true; a new command-boundary wrapper guarantees a fallback envelope is emitted for any error that reaches it unclassified, including Cobra-level usage errors where `--json` was already successfully parsed. +- Call sites that currently emit `ErrorEvent` or return a bare `fmt.Errorf` inside a JSON-capable command's path gain an explicit `error.code` classification (cataloged per command in design.md). +- Docs: a new `docs/structured-output.md` becomes the developer-facing reference for the envelope, error-code/retryable table, exit codes, the NDJSON streaming variant, and how to add JSON support to a command. It reproduces the full Command Catalog from design.md verbatim (every command's complete `data` shape and error codes) rather than a condensed index, so it stands alone as the durable reference after this change's own design.md is archived — expected to run long as a result, which is intentional: a single document for engineers to review end to end. `lstk docs`-generated command reference and CLAUDE.md link to it instead of duplicating it. No changes to non-JSON plain-text rendering. +- Rollout order (tasks.md): `stop`, `reset`, and `update` land first as a pilot, ahead of every other command — each depends only on the shared envelope/error-boundary/exit-code infrastructure, not on each other or on any other command's JSON support, and together they exercise a plain success, the `CONFIRMATION_REQUIRED`/exit-`3` path, and a `retryable: true` failure before the remaining commands build on the same plumbing. diff --git a/openspec/changes/json-output-schema/specs/error-codes/spec.md b/openspec/changes/json-output-schema/specs/error-codes/spec.md new file mode 100644 index 00000000..c14ab39d --- /dev/null +++ b/openspec/changes/json-output-schema/specs/error-codes/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: Enumerated, stable error codes +Every `error.code` value emitted in a JSON envelope SHALL be one of a fixed, documented set of `SCREAMING_SNAKE_CASE` string constants. lstk SHALL NOT emit a free-text or ad hoc string as `error.code`; a code not yet covering some failure mode SHALL fall back to `INTERNAL_ERROR` rather than inventing an undocumented one at the call site. The full set, as of this change: + +| Code | Meaning | Retryable | +|---|---|---| +| `RUNTIME_UNAVAILABLE` | The container runtime (`internal/runtime.Runtime` — Docker today; Podman, Rancher Desktop, Finch, or Kubernetes are architecturally anticipated but not yet implemented) is unreachable or unhealthy | Yes | +| `IMAGE_PULL_FAILED` | Pulling the emulator image failed and no usable local image exists | Yes | +| `EMULATOR_NOT_RUNNING` | The targeted emulator is not currently running | No | +| `EMULATOR_ALREADY_RUNNING` | An emulator is already running where the command expected it not to be | No | +| `EMULATOR_WRONG_TYPE` | The command requires a specific emulator type but a different one is configured/running | No | +| `EMULATOR_NOT_CONFIGURED` | No container of the requested type exists in the resolved config | No | +| `EMULATOR_START_FAILED` | The emulator failed to reach a healthy state after starting | Yes | +| `AUTH_REQUIRED` | The operation needs a LocalStack auth token and none is available | No | +| `AUTH_LOGIN_FAILED` | An authentication flow failed | Yes | +| `CREDENTIALS_MISSING` | Required third-party credentials (e.g. AWS credentials for an S3 remote) could not be resolved | No | +| `LICENSE_INVALID` | The platform rejected the configured license/token | No | +| `LICENSE_UNSUPPORTED_TAG` | The configured image tag is not covered by the license | No | +| `SNAPSHOT_NOT_FOUND` | The referenced snapshot does not exist | No | +| `SNAPSHOT_INVALID_REF` | The snapshot reference could not be parsed | No | +| `SNAPSHOT_REMOTE_ERROR` | A platform or S3 remote call failed | Yes | +| `SNAPSHOT_BUCKET_NOT_FOUND` | The pre-flight S3 bucket-existence check failed | No | +| `CONFIG_INVALID` | The config file failed to parse or validate | No | +| `CONFIG_NOT_FOUND` | An explicit config path does not exist | No | +| `INTEGRATION_NOT_SET_UP` | A required one-time setup step (e.g. `lstk setup azure`) has not been run | No | +| `DEPENDENCY_MISSING` | A required external CLI (e.g. `az`) is not on `PATH` | No | +| `DNS_RESOLUTION_REQUIRED` | A required hostname pattern does not resolve | No | +| `CONFIRMATION_REQUIRED` | A destructive action needs `--force` outside an interactive terminal | No | +| `VALIDATION_ERROR` | A semantically invalid combination of flags/arguments was given | No | +| `USAGE_ERROR` | Cobra-level flag or argument parsing failed | No | +| `NOT_JSON_CAPABLE` | The requested command has not been annotated as JSON-capable | No | +| `NETWORK_ERROR` | An unclassified network/transport failure occurred | Yes | +| `CANCELLED` | The operation was interrupted (e.g. context cancellation via Ctrl+C) | Yes | +| `INTERNAL_ERROR` | Unclassified or unexpected failure; the universal fallback | No | + +#### Scenario: Error code is one of the documented constants +- **WHEN** any JSON-capable command emits an `error` object +- **THEN** `error.code` is exactly one of the documented string constants above + +#### Scenario: Unmapped failure falls back to INTERNAL_ERROR +- **WHEN** a JSON-capable command hits a failure that has not been mapped to a specific code +- **THEN** `error.code` is `"INTERNAL_ERROR"`, not an invented or free-text value + +### Requirement: Error objects carry a human message alongside the code +An `error` object SHALL include a `message` string suitable for human display, in addition to `code`. Scripts SHALL treat `message` as informational only and SHALL branch on `code`, since `message` text is not guaranteed to remain stable across versions. + +#### Scenario: Error includes both code and message +- **WHEN** a JSON-capable command fails +- **THEN** the emitted `error` object has both a `code` field (stable) and a `message` field (human-readable, not guaranteed stable) + +### Requirement: Error objects declare whether the failure is retryable +Every `error` object SHALL include a `retryable` boolean, a static property of `code` (the same code always carries the same value, per the table above) rather than something computed per failure instance. `retryable: true` SHALL mean the identical invocation might succeed later without any change to arguments or environment (e.g. a transient runtime/network hiccup); `retryable: false` SHALL mean the invocation will keep failing the same way until something about the request, config, or environment changes (e.g. a missing `--force`, an invalid reference, a validation error). + +#### Scenario: A transient failure is marked retryable +- **WHEN** a JSON-capable command fails with `error.code: "RUNTIME_UNAVAILABLE"` +- **THEN** the error object has `"retryable": true` + +#### Scenario: A failure requiring a different invocation is not marked retryable +- **WHEN** a JSON-capable command fails with `error.code: "VALIDATION_ERROR"` or `"CONFIRMATION_REQUIRED"` +- **THEN** the error object has `"retryable": false` + +#### Scenario: retryable is consistent for a given code +- **WHEN** the same `error.code` is emitted by two different commands +- **THEN** both error objects report the same `retryable` value for that code + +### Requirement: Error actions are machine-usable +When an error has a suggested remediation (mirroring the plain-text `ErrorAction` used in interactive/plain rendering today), the JSON error object's `actions` array SHALL contain objects with a stable `id` slug and a literal `command` string, rather than a pre-formatted display line. + +#### Scenario: Remediation is structured, not pre-formatted text +- **WHEN** an error such as `EMULATOR_NOT_RUNNING` has a suggested next step +- **THEN** `error.actions` contains at least one object of the form `{"id": "", "command": ""}`, not a formatted string like `"==> Start LocalStack: lstk"` diff --git a/openspec/changes/json-output-schema/specs/json-command-output/spec.md b/openspec/changes/json-output-schema/specs/json-command-output/spec.md new file mode 100644 index 00000000..413f03cc --- /dev/null +++ b/openspec/changes/json-output-schema/specs/json-command-output/spec.md @@ -0,0 +1,55 @@ +## ADDED Requirements + +### Requirement: Per-command JSON opt-in +A command SHALL only accept `--json` if it carries the JSON-capable annotation. The following commands SHALL carry it as part of this change: `start` (including the root command's own bare, no-subcommand invocation, which shares `start`'s behavior), `stop`, `restart`, `status`, `logs`, `config path`, `volume path`, `volume clear`, `setup aws`, `setup azure`, `az start-interception`, `az stop-interception`, `logout`, `reset`, `snapshot save`, `snapshot load`, `snapshot list`, `snapshot show`, `snapshot remove`, and `update`. `login` and `config profile` SHALL NOT carry it, since both require an interactive terminal unconditionally and have no defined JSON behavior. The `-v`/`--version` flag SHALL NOT support `--json` either, and no subcommand SHALL be added to work around that — it is handled by Cobra before lstk's JSON dispatch runs at all, and this is accepted as a permanent limitation (see design.md's Decisions). + +#### Scenario: An opted-in command accepts --json +- **WHEN** `lstk status --json` is run +- **THEN** the command executes normally and emits a JSON envelope instead of being rejected + +#### Scenario: login remains JSON-incapable +- **WHEN** `lstk login --json` is run +- **THEN** it is rejected the same way as any other command lacking the JSON-capable annotation (see the `json-flag` capability), naming `login` + +#### Scenario: config profile remains JSON-incapable +- **WHEN** `lstk config profile --json` is run +- **THEN** it is rejected the same way, naming `config profile` + +#### Scenario: The bare default invocation accepts --json +- **WHEN** `lstk --json` is run with no subcommand +- **THEN** it runs the same start behavior as `lstk start --json` and emits a JSON envelope, rather than being rejected as JSON-incapable + +#### Scenario: The --version flag stays plain text under --json, without rejection +- **WHEN** `lstk --version --json` or `lstk -v --json` is run +- **THEN** lstk prints the plain-text version line exactly as it does today +- **AND** it does not render a JSON envelope and does not render the `NOT_JSON_CAPABLE` rejection either, since Cobra's version handling returns before any of lstk's own command dispatch — including the JSON-capability check itself — ever runs + +### Requirement: Emulator lifecycle commands report per-emulator results +`start`, `stop`, `restart`, and `status` SHALL report results as an array with one entry per emulator type configured (`internal/config.ContainerConfig`), reflecting that lstk can run more than one emulator type concurrently. `status` SHALL include every configured emulator in its array regardless of whether it is running, rather than stopping at the first one found not running. + +#### Scenario: status reports all configured emulators, running or not +- **WHEN** `lstk status --json` is run with an AWS emulator configured and stopped, and a Snowflake emulator configured and running +- **THEN** the envelope's `data.emulators` array contains one entry for AWS with `"running": false` and one entry for Snowflake with `"running": true` and its full detail fields +- **AND** the command does not exit with an error solely because the AWS emulator is not running + +#### Scenario: start reports one entry per configured emulator +- **WHEN** `lstk start --json` is run with two emulator types configured +- **THEN** the envelope's `data.emulators` array contains one entry per configured emulator type + +### Requirement: Snapshot commands report the documented payload shapes +`snapshot save`, `snapshot load`, `snapshot list`, `snapshot show`, and `snapshot remove` SHALL each report the payload shape documented in design.md's Command Catalog for their respective destination kind (`local`, `pod`, or `s3` where applicable). + +#### Scenario: snapshot save reports destination kind +- **WHEN** `lstk snapshot save pod:my-baseline --json` is run successfully +- **THEN** the envelope's `data` includes `"kind": "pod"`, `"podName": "my-baseline"`, and the resulting `version` + +#### Scenario: snapshot show mirrors existing metadata fields +- **WHEN** `lstk snapshot show pod:my-baseline --json` is run successfully +- **THEN** the envelope's `data` includes `name`, `version`, `services`, and a `resources` array with per-service resource counts + +### Requirement: Confirmation-gated commands report CONFIRMATION_REQUIRED instead of a generic error +`volume clear`, `reset`, and `snapshot remove`, when run with `--json` outside an interactive terminal and without `--force`, SHALL reject with `error.code = "CONFIRMATION_REQUIRED"` rather than a generic or unclassified error, since this is the single most common actionable failure a script driving these commands will hit. + +#### Scenario: volume clear without --force under --json +- **WHEN** `lstk volume clear --json` is run without `--force` +- **THEN** the envelope has `"status": "error"` and `"error": {"code": "CONFIRMATION_REQUIRED", ...}` diff --git a/openspec/changes/json-output-schema/specs/json-flag/spec.md b/openspec/changes/json-output-schema/specs/json-flag/spec.md new file mode 100644 index 00000000..8eb125d2 --- /dev/null +++ b/openspec/changes/json-output-schema/specs/json-flag/spec.md @@ -0,0 +1,21 @@ +## MODIFIED Requirements + +### Requirement: Commands without JSON support reject --json, using the interactive error style +A built-in command that has not been explicitly marked as supporting `--json` output SHALL reject the flag with a non-zero exit and an error naming the command, rather than accepting it and silently rendering plain-text output. JSON support is an explicit per-command opt-in; every built-in command not covered by the `json-command-output` capability rejects `--json`. Proxy commands are rejected via this same mechanism, but only for `--json` in the pre-command-name position (see "Proxy commands reject --json before the command name"); extension dispatch remains exempt entirely (see "Extension dispatch is exempt from JSON support rejection"), since it has no lstk-rendered output to reject on behalf of. + +The rejection SHALL render differently depending on whether `--json` itself was the flag that triggered it: + +- When `--json` was set (the invocation explicitly asked for JSON), the rejection SHALL render as the standard JSON envelope (see the `output-envelope` capability) with `error.code = "NOT_JSON_CAPABLE"` and a `message` naming the command, written to stdout, exit code `1`. +- The plain-text interactive error style (a title plus a `See help: lstk -h` action, written to stderr) is retained for any other case where a command's output would otherwise not be renderable — this proposal's change is scoped to the `--json`-requested case only. + +#### Scenario: Unsupported command with --json set renders a JSON rejection +- **WHEN** `lstk --json` is run for a command that has not implemented JSON output +- **THEN** lstk exits `1` +- **AND** stdout contains a JSON envelope with `"status": "error"` and `"error": {"code": "NOT_JSON_CAPABLE", ...}` naming the command +- **AND** the command's normal work is never performed + +#### Scenario: A command that stays JSON-incapable renders a JSON rejection +- **WHEN** `lstk login --json` is run (`login` is not annotated as JSON-capable; see the `json-command-output` capability) +- **THEN** it is rejected the same way as any other unsupported command, naming "login", rendered as a JSON envelope + +Note: the default (no-subcommand) invocation and `lstk start` both become JSON-capable as part of this change (see `json-command-output`'s "Per-command JSON opt-in" requirement), so `lstk --json` with no subcommand no longer hits this rejection path — it is covered instead by the emulator-lifecycle requirements in `json-command-output`. diff --git a/openspec/changes/json-output-schema/specs/output-envelope/spec.md b/openspec/changes/json-output-schema/specs/output-envelope/spec.md new file mode 100644 index 00000000..847949af --- /dev/null +++ b/openspec/changes/json-output-schema/specs/output-envelope/spec.md @@ -0,0 +1,70 @@ +## ADDED Requirements + +### Requirement: Common JSON result envelope +Every command annotated as JSON-capable SHALL, when `--json` is set, write exactly one JSON object to stdout with the fields `schemaVersion` (integer), `command` (string, the canonical command path), `status` (`"ok"` or `"error"`), `data` (object or `null`), `warnings` (array, always present, possibly empty), and `error` (object or `null`). `data` SHALL be `null` when `status` is `"error"`, and `error` SHALL be `null` when `status` is `"ok"`. Both keys SHALL always be present regardless of status. + +#### Scenario: Successful command emits an ok envelope +- **WHEN** a JSON-capable command completes successfully with `--json` set +- **THEN** stdout contains one JSON object with `"status": "ok"`, a non-null `"data"`, and `"error": null` + +#### Scenario: Failed command emits an error envelope +- **WHEN** a JSON-capable command fails with `--json` set +- **THEN** stdout contains one JSON object with `"status": "error"`, `"data": null`, and a non-null `"error"` object + +#### Scenario: Warnings array is always present +- **WHEN** a JSON-capable command completes successfully with no advisory notices +- **THEN** the envelope's `"warnings"` field is an empty array, not `null` and not omitted + +### Requirement: A JSON-capable command never emits unstructured output on stdout +When `--json` is set for a command carrying the JSON-capable annotation, lstk SHALL NOT write any plain-text line, spinner, or table to stdout — only the single envelope (or, for a command documented as streaming, the NDJSON stream defined below). An error that has no specific classification SHALL still produce a well-formed envelope, using `error.code = "INTERNAL_ERROR"` as the last-resort fallback, rather than falling through to an unstructured `Error: %v` line. + +#### Scenario: Unclassified error still produces a valid envelope +- **WHEN** a JSON-capable command with `--json` set encounters an error that has not been given a specific `error.code` +- **THEN** stdout contains one well-formed JSON envelope with `"status": "error"` and `"error": {"code": "INTERNAL_ERROR", ...}` +- **AND** no unstructured text is written to stdout + +#### Scenario: No presentational output leaks through +- **WHEN** a JSON-capable command with `--json` set would otherwise show a spinner or progress output in plain-text mode +- **THEN** none of that presentational output appears on stdout; only the final envelope is written + +### Requirement: Exit code conventions +lstk SHALL exit `0` when the emitted envelope's `status` is `"ok"`; `2` only for a Cobra-level usage error that could not be attributed to a specific command's `RunE` (see the fallback scenario in "Best-effort JSON rendering of usage errors" below); `3` when a JSON-capable command emits an envelope with `status: "error"` and `error.code` is `"CONFIRMATION_REQUIRED"`; `4` when `error.code` is `"AUTH_REQUIRED"`; and `1` for any other `status: "error"` envelope, including a `USAGE_ERROR` that *was* successfully rendered as JSON. Scripts SHALL use `error.code` from the envelope, not the exit code, for full-granularity branching — the `3`/`4` reservations exist only because those two categories recur across nearly every command and have an obvious, mechanical remediation (`--force`, `lstk login`) a script can act on without parsing stdout first. + +#### Scenario: Exit code 0 on success +- **WHEN** a JSON-capable command succeeds with `--json` set +- **THEN** the process exits with status code `0` + +#### Scenario: Exit code 1 on a generic command-level error +- **WHEN** a JSON-capable command fails with `--json` set and emits an envelope with `status: "error"` and an `error.code` other than `CONFIRMATION_REQUIRED` or `AUTH_REQUIRED` +- **THEN** the process exits with status code `1` + +#### Scenario: Exit code 3 on CONFIRMATION_REQUIRED +- **WHEN** a JSON-capable command fails with `--json` set and emits `error.code: "CONFIRMATION_REQUIRED"` +- **THEN** the process exits with status code `3` + +#### Scenario: Exit code 4 on AUTH_REQUIRED +- **WHEN** a JSON-capable command fails with `--json` set and emits `error.code: "AUTH_REQUIRED"` +- **THEN** the process exits with status code `4` + +### Requirement: Best-effort JSON rendering of usage errors +When `--json` has already been successfully parsed by the time a Cobra-level flag or argument validation error occurs, lstk SHALL render that error as the standard envelope with `error.code = "USAGE_ERROR"` instead of Cobra's default plain-text usage error. When the malformed input prevents `--json` itself from being recognized (e.g. it appears after the flag that fails to parse), lstk SHALL fall back to the existing plain-text usage error on stderr with exit code `2`. + +#### Scenario: Usage error after --json is recognized renders as JSON +- **WHEN** `lstk snapshot load --json --merge=bogus REF` is run (an invalid merge strategy caught before `RunE` starts real work) +- **THEN** stdout contains a JSON envelope with `"error": {"code": "USAGE_ERROR", ...}` and the process exits `1` + +#### Scenario: A malformed flag preceding --json falls back to plain text +- **WHEN** an invocation has a flag-parsing error that occurs before Cobra reaches `--json` in the argument list +- **THEN** lstk prints Cobra's plain-text usage error to stderr and exits `2`, without attempting to render JSON + +### Requirement: Streaming envelope variant for continuous output +A command explicitly documented as streaming (`logs --follow`) SHALL, under `--json`, write newline-delimited JSON (NDJSON) instead of a single envelope: each line is a compact JSON object with `schemaVersion`, `command`, `type` (`"log"` or `"error"`), and either `data` or `error`. This variant SHALL only apply to commands explicitly documented as streaming; all other JSON-capable commands SHALL use the single-envelope form even when the underlying operation reads multiple lines or records (e.g. bounded `logs` without `--follow` returns them inside `data.lines` in a single envelope). + +#### Scenario: Follow mode emits one JSON object per line +- **WHEN** `lstk logs --follow --json` is run +- **THEN** each new log line is written to stdout as its own compact JSON object with `"type": "log"` +- **AND** no single object attempts to represent the entire (unbounded) stream + +#### Scenario: Bounded logs use the single envelope +- **WHEN** `lstk logs --json` is run without `--follow` +- **THEN** stdout contains exactly one JSON envelope whose `data.lines` array contains the retrieved log lines diff --git a/openspec/changes/json-output-schema/tasks.md b/openspec/changes/json-output-schema/tasks.md new file mode 100644 index 00000000..c282f788 --- /dev/null +++ b/openspec/changes/json-output-schema/tasks.md @@ -0,0 +1,72 @@ +## 1. Envelope and error-code infrastructure + +- [ ] 1.1 Add `internal/output` envelope types: `Envelope`, `Warning`, `EnvelopeError` (including `Retryable bool`), `EnvelopeAction`, and the `ErrorCode` string enum from the `error-codes` spec. Names avoid a `JSON`-specific prefix on purpose (see design.md's naming decisions) — the envelope shape is designed to outlive JSON as the only serialization. +- [ ] 1.1a Add a single source-of-truth table mapping each `ErrorCode` to its static `Retryable` value (per the `error-codes` spec table), consulted wherever an `EnvelopeError` is constructed so `Retryable` is never set ad hoc per call site. +- [ ] 1.2 Add `Code ErrorCode` field to `output.ErrorEvent` (additive; `PlainSink`/`TUISink` ignore it). +- [ ] 1.3 Implement `output.EnvelopeSink` (`Sink`), constructed with an `output.Format` (only `output.FormatJSON` exists today): type-switches on each event, accumulates data-bearing events into `Envelope.Data`, routes `ErrorEvent` into `Envelope.Error` (defaulting `Code` to `INTERNAL_ERROR` when unset), routes `MessageEvent{Severity: SeverityWarning}` into `Envelope.Warnings`, and drops purely presentational events (`SpinnerEvent`, `ContainerStatusEvent`, `ProgressEvent`, `DeferredEvent`'s inner transient events). +- [ ] 1.4 Add `(*EnvelopeSink).Result() Envelope` plus a helper that marshals per the sink's `Format` and writes it once to stdout as compact JSON (the only format implemented now). +- [ ] 1.5 Add `internal/output` NDJSON stream writer (`type: "log"`/`"error"` lines) for the `logs --follow` variant. +- [ ] 1.6 Unit tests: `EnvelopeSink` event-routing table (one test per event type: accumulated into data / routed to warnings / routed to error / dropped). + +## 2. Command-boundary wiring + +- [ ] 2.1 `cmd/root.go`: change `requireJSONSupport`'s rejection to render as a JSON envelope (`error.code: NOT_JSON_CAPABLE`) on stdout when `cfg.JSON` is true, matching the `json-flag` delta spec; keep the existing plain-text path for the non-`--json` case (unreachable today, kept for defensiveness). +- [ ] 2.2 `cmd/root.go`: add a JSON error boundary wrapper (parallel to `requireJSONSupport`) that guarantees any error returned from a JSON-capable command's `RunE` — including ones without a specific `ErrorCode` — is rendered as a valid envelope with `error.code: INTERNAL_ERROR` as the fallback, never a bare `Error: %v` line. +- [ ] 2.3 `cmd/root.go` / `Execute()`: catch Cobra-level usage errors and render them as `error.code: USAGE_ERROR` envelopes when `--json` was already parsed; otherwise preserve today's plain-text usage error and exit `2`. +- [ ] 2.4 Wire exit-code conventions (`0` ok, `1` generic command-level error, `2` unattributed usage error, `3` for `CONFIRMATION_REQUIRED`, `4` for `AUTH_REQUIRED`) at the single point `Execute()` already computes the process exit code. +- [ ] 2.5 Integration tests: `NOT_JSON_CAPABLE` rejection renders as JSON when `--json` is set; an unclassified error still produces a valid envelope; a usage error after `--json` renders as JSON, one before it does not; exit codes `3`/`4` fire for `CONFIRMATION_REQUIRED`/`AUTH_REQUIRED` respectively and `1` for every other error code. + +## 3. Pilot wave — stop, reset, update + +Implemented first, ahead of every other command: each depends only on the shared infrastructure in sections 1-2, not on any other command's JSON support, and together they exercise the envelope's main branch points (a data-bearing success, a `CONFIRMATION_REQUIRED`/exit-`3` failure, and a `retryable: true` failure) before the remaining waves build on the same plumbing. + +- [ ] 3.1 `stop`: add annotation, `{"emulators": [...]}` data shape, `RUNTIME_UNAVAILABLE` code. +- [ ] 3.2 `reset`: add annotation, `{"emulator": {...}, "reset": true}` data shape, `EMULATOR_NOT_CONFIGURED`/`EMULATOR_NOT_RUNNING`/`CONFIRMATION_REQUIRED`/`RUNTIME_UNAVAILABLE` codes. +- [ ] 3.3 `update`: add annotation, `{"currentVersion", "latestVersion"/"updatedVersion", "updateAvailable"/"updated", "method"}` data shape (both `--check` and applied-update variants), `NETWORK_ERROR` code. +- [ ] 3.4 Integration tests for all three: success payloads, every documented error code, and exit codes `0`/`1`/`3` (this trio has no `AUTH_REQUIRED`/`2` case). +- [ ] 3.5 Before continuing to section 4, sanity-check the section 1-2 infrastructure against these three real implementations — this pilot is its first real exercise, and is the cheapest point to revise the `EnvelopeSink`/error-boundary design if something doesn't fit in practice. + +## 4. Read-only / low-risk commands + +- [ ] 4.1 `config path`: add annotation, JSON sink branch, `{"path": "..."}` data shape, `CONFIG_NOT_FOUND`/`CONFIG_INVALID` codes. +- [ ] 4.2 `volume path`: add annotation, `{"volumes": [...]}` data shape. +- [ ] 4.3 `status`: add annotation; change the domain loop (`internal/container/status.go`) to report every configured emulator (running or not) instead of failing fast on the first non-running one, gated so plain-text behavior is unchanged; `{"emulators": [...]}` data shape. +- [ ] 4.4 `logs` (bounded): add annotation, `{"lines": [...]}` data shape. +- [ ] 4.5 `logs --follow`: wire the NDJSON stream writer from 1.5. +- [ ] 4.6 Integration tests for each command in this wave, covering both the success payload and its documented error codes. + +## 5. Remaining emulator lifecycle + +- [ ] 5.1 `start` (both `cmd/start.go` and the root command's bare invocation): add annotation to both, `{"emulators": [...], "snapshotLoaded": ...}` data shape, classify `RUNTIME_UNAVAILABLE`/`AUTH_REQUIRED`/`LICENSE_INVALID`/`LICENSE_UNSUPPORTED_TAG`/`IMAGE_PULL_FAILED`/`EMULATOR_START_FAILED`/`SNAPSHOT_NOT_FOUND`/`VALIDATION_ERROR` at their existing call sites. +- [ ] 5.2 `restart`: add annotation, `{"stopped": [...], "started": [...]}` data shape, reusing 3.1 (`stop`)'s and 5.1 (`start`)'s classification. +- [ ] 5.3 `volume clear`: add annotation, `{"cleared": [...]}` data shape, `EMULATOR_NOT_CONFIGURED`/`CONFIRMATION_REQUIRED` codes. +- [ ] 5.4 Integration tests for each command in this wave, including the `status`-style "report all configured emulators" scenario for `start`. + +## 6. Snapshots + +- [ ] 6.1 `snapshot save`: add annotation, `{"kind", "location", "podName", "version", "services", "sizeBytes"}` data shape across local/pod/S3 destinations, classify `AUTH_REQUIRED`/`CREDENTIALS_MISSING`/`SNAPSHOT_BUCKET_NOT_FOUND`/`SNAPSHOT_REMOTE_ERROR`/`VALIDATION_ERROR`. +- [ ] 6.2 `snapshot load`: add annotation, `{"source", "services", "emulatorStarted", "mergeStrategy"}` data shape, classify `SNAPSHOT_NOT_FOUND`/`SNAPSHOT_INVALID_REF`/`SNAPSHOT_REMOTE_ERROR`/`SNAPSHOT_BUCKET_NOT_FOUND`/`CREDENTIALS_MISSING`/`EMULATOR_START_FAILED`. +- [ ] 6.3 `snapshot list`: add annotation, `{"location", "snapshots": [...]}` data shape (platform and S3). +- [ ] 6.4 `snapshot show`: add annotation, data shape mirroring `SnapshotShownEvent` fields directly. +- [ ] 6.5 `snapshot remove`: add annotation, `{"podName", "removed"}` data shape, `CONFIRMATION_REQUIRED` code. +- [ ] 6.6 Integration tests for each command in this wave, covering local/pod/S3 variants where applicable. + +## 7. Auth and setup + +- [ ] 7.1 `logout`: add annotation, `{"loggedOut", "stillRunning": [...]}` data shape, preserving today's idempotent already-logged-out success. +- [ ] 7.2 `setup aws`: add annotation, `{"profile", "written", "dnsOk"}` data shape, `CONFIRMATION_REQUIRED` code. +- [ ] 7.3 `setup azure`: add annotation, `{"configDir", "cloudRegistered"}` data shape, `DEPENDENCY_MISSING`/`DNS_RESOLUTION_REQUIRED` codes. +- [ ] 7.4 Integration tests for each command in this wave. + +## 8. Azure interception (global-state commands) + +- [ ] 8.1 `az start-interception`: add annotation, `{"cloudRegistered", "endpoint"}` data shape. +- [ ] 8.2 `az stop-interception`: add annotation, `{"switchedFrom", "switchedTo", "changed"}` data shape, `VALIDATION_ERROR` code for an unrecognized `--cloud`. +- [ ] 8.3 Integration tests for both, run serially (they mutate global `~/.azure` state, matching the existing test isolation approach for this area). + +## 9. Docs and cross-cutting verification + +- [ ] 9.1 Write `docs/structured-output.md`, the single developer-facing reference for the envelope contract, for engineers to review directly. Named without a `json-` prefix since the envelope/error-code shape it documents is designed to extend to other output formats later, even though JSON is the only one implemented now (state this explicitly in the doc's opening paragraph). Sections: (1) the envelope's fixed fields and an annotated success/error example; (2) the full `error.code` table with each code's `retryable` value; (3) the exit-code table (`0`/`1`/`2`/`3`/`4`); (4) the NDJSON streaming variant used by `logs --follow`; (5) the **entire Command Catalog from design.md, reproduced in full** — every JSON-capable command, its complete `data` shape and example JSON, and its error codes, not a condensed index; (6) a short "adding JSON support to a command" walkthrough for lstk contributors — the `jsonSupportedAnnotation` opt-in, routing through `output.EnvelopeSink`, and where to classify a new `ErrorEvent.Code`. No target line count — reproducing the full catalog verbatim is expected to push this well past a typical reference doc's length, and that's the intent: one document engineers can review and comment on end to end, rather than a summary that sends them back to design.md (an ephemeral change artifact that won't exist after this change is archived) for the actual detail. Source of truth for content: this change's design.md (Prior Art, Decisions, Command Catalog) and the `output-envelope`/`error-codes`/`json-command-output` specs. +- [ ] 9.2 Update `lstk docs`-generated command reference and CLAUDE.md's "Output Routing and Events" section to link to `docs/structured-output.md` instead of duplicating its content. +- [ ] 9.3 Add a lint/test that walks the Cobra command tree and asserts every command carrying `jsonSupportedAnnotation` has at least one integration test exercising `--json`. +- [ ] 9.4 Run `make test` and `make test-integration` for the full command surface touched across sections 3-8. From 3e06731d99fedb91ec3242acab116d8e83920e5c Mon Sep 17 00:00:00 2001 From: Peter Smith Date: Thu, 9 Jul 2026 10:14:56 +1200 Subject: [PATCH 2/4] Add JSON envelope, error-code taxonomy, and --json support for stop/reset/update Implements the shared envelope/error-code contract from the json-output-schema proposal and wires --json support through stop, reset, and update as a pilot, to validate the design before rolling it out to the rest of the command surface. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 4 + cmd/json_envelope.go | 105 ++++ cmd/reset.go | 19 +- cmd/root.go | 59 +- cmd/stop.go | 14 +- cmd/update.go | 14 +- docs/structured-output.md | 573 ++++++++++++++++++ internal/container/stop.go | 7 +- internal/output/envelope.go | 61 ++ internal/output/envelope_sink.go | 151 +++++ internal/output/envelope_sink_test.go | 288 +++++++++ internal/output/error_code.go | 162 +++++ internal/output/error_code_test.go | 65 ++ internal/output/errors.go | 17 + internal/output/events.go | 46 ++ internal/output/plain_format.go | 31 + internal/output/plain_format_test.go | 36 ++ internal/reset/reset.go | 3 +- internal/reset/reset_test.go | 10 +- internal/runtime/docker.go | 1 + internal/update/notify.go | 10 +- internal/update/update.go | 40 +- main.go | 11 + openspec/changes/json-output-schema/design.md | 14 +- .../changes/json-output-schema/proposal.md | 5 +- .../specs/error-codes/spec.md | 75 ++- openspec/changes/json-output-schema/tasks.md | 40 +- test/integration/json_envelope_test.go | 92 +++ test/integration/reset_test.go | 73 +++ test/integration/stop_test.go | 55 +- test/integration/update_test.go | 74 ++- 31 files changed, 2047 insertions(+), 108 deletions(-) create mode 100644 cmd/json_envelope.go create mode 100644 docs/structured-output.md create mode 100644 internal/output/envelope.go create mode 100644 internal/output/envelope_sink.go create mode 100644 internal/output/envelope_sink_test.go create mode 100644 internal/output/error_code.go create mode 100644 internal/output/error_code_test.go create mode 100644 test/integration/json_envelope_test.go diff --git a/CLAUDE.md b/CLAUDE.md index f5af977d..1fa71c69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,6 +205,10 @@ The release job (`.github/workflows/ci.yml`) builds the npm packages with `gorel - `internal/output/plain_format.go` (line formatting fallback) - tests in `internal/output/*_test.go` for formatter/sink behavior parity +## Structured output (`--json`) + +A JSON-capable command emits a single `output.Envelope` (schema version, `data`/`error` discriminated on `status`, an enumerated `error.code`) instead of formatted lines — see [docs/structured-output.md](docs/structured-output.md) for the full envelope contract, error-code table, exit-code conventions, and the per-command catalog (implemented vs. planned). `output.EnvelopeSink` builds the envelope from the same event vocabulary described above; adding `--json` support to a command is documented step by step in that file's "Adding `--json` support to a command" section. Command opt-in is explicit via the `jsonSupportedAnnotation` on the `cobra.Command` in `cmd/`. + ## User Input Handling Domain code must never read from stdin or wait for user input directly. Instead: diff --git a/cmd/json_envelope.go b/cmd/json_envelope.go new file mode 100644 index 00000000..cb6a2887 --- /dev/null +++ b/cmd/json_envelope.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/output" + "github.com/spf13/cobra" +) + +// envelopeSinkKey is the context key jsonAwareSink stores an *output.EnvelopeSink +// under, so wrapCommandsWithJSONEnvelope can retrieve it after the command's RunE returns. +type envelopeSinkKey struct{} + +func withEnvelopeSink(ctx context.Context, sink *output.EnvelopeSink) context.Context { + return context.WithValue(ctx, envelopeSinkKey{}, sink) +} + +func envelopeSinkFromContext(ctx context.Context) (*output.EnvelopeSink, bool) { + sink, ok := ctx.Value(envelopeSinkKey{}).(*output.EnvelopeSink) + return sink, ok +} + +// jsonAwareSink returns the Sink a JSON-capable command's non-interactive path +// should use: an EnvelopeSink (registered on cmd's context for +// wrapCommandsWithJSONEnvelope to find once RunE returns) when --json is set, +// otherwise a plain PlainSink. +func jsonAwareSink(cmd *cobra.Command, cfg *env.Env, w io.Writer) output.Sink { + if cfg.JSON { + sink := output.NewEnvelopeSink(output.FormatJSON) + cmd.SetContext(withEnvelopeSink(cmd.Context(), sink)) + return sink + } + return output.NewPlainSink(w) +} + +// writeEnvelope marshals envelope as compact JSON and writes it to w, followed +// by a newline, as the single line of output a JSON-capable command produces. +func writeEnvelope(w io.Writer, envelope output.Envelope) error { + data, err := json.Marshal(envelope) + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(data)) + return err +} + +// exitCodeFor maps an error envelope to the process exit code conventions +// documented in the output-envelope capability: 3 for CONFIRMATION_REQUIRED, +// 4 for AUTH_REQUIRED, 1 for every other error code, 0 when there is no error. +func exitCodeFor(envelope output.Envelope) int { + if envelope.Error == nil { + return 0 + } + switch envelope.Error.Code { + case output.ErrConfirmationRequired: + return 3 + case output.ErrAuthRequired: + return 4 + default: + return 1 + } +} + +// wrapCommandsWithJSONEnvelope walks the Cobra command tree and wraps every +// RunE so that, once a JSON-capable command's RunE returns while --json is +// set, the EnvelopeSink it registered via jsonAwareSink (if any) is finalized +// and written to stdout as exactly one JSON object, and the returned error is +// translated into the matching process exit code (see output.ExitCodeError). +// The wrapper is installed unconditionally; it only renders when --json was +// actually requested and a sink was registered — otherwise it's a no-op that +// passes the original error through untouched. +// +// A command rejected earlier by requireJSONSupport never reaches this wrapper +// with a registered sink — that rejection renders its own envelope directly, +// since there is no command-specific sink to build one from. +func wrapCommandsWithJSONEnvelope(cmd *cobra.Command, cfg *env.Env, stdout io.Writer) { + walkCommandsWithRunE(cmd, func(c *cobra.Command) { + original := c.RunE + c.RunE = func(c *cobra.Command, args []string) error { + runErr := original(c, args) + + if !cfg.JSON || isExtensionDispatch(c, args) { + return runErr + } + + sink, ok := envelopeSinkFromContext(c.Context()) + if !ok { + return runErr + } + + envelope := sink.Result(commandDisplayName(c), runErr) + if writeErr := writeEnvelope(stdout, envelope); writeErr != nil { + return writeErr + } + if runErr == nil { + return nil + } + return output.NewSilentError(&output.ExitCodeError{Err: runErr, Code: exitCodeFor(envelope)}) + } + }) +} diff --git a/cmd/reset.go b/cmd/reset.go index 4933eb80..330d4df5 100644 --- a/cmd/reset.go +++ b/cmd/reset.go @@ -27,8 +27,19 @@ 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: initConfig(nil), + Annotations: map[string]string{jsonSupportedAnnotation: "true"}, RunE: func(cmd *cobra.Command, args []string) error { + sink := jsonAwareSink(cmd, cfg, os.Stdout) + failWithCode := func(message string, code output.ErrorCode) error { + bare := errors.New(message) + if !cfg.JSON { + return bare + } + sink.Emit(output.ErrorEvent{Title: message, Code: code}) + return output.NewSilentError(bare) + } + appConfig, err := config.Get() if err != nil { return fmt.Errorf("failed to get config: %w", err) @@ -44,12 +55,12 @@ To wipe the on-disk volume (certificates, persistence data, cached tools) instea } } if !found { - return errors.New("reset is only supported for the AWS emulator") + return failWithCode("reset is only supported for the AWS emulator", output.ErrEmulatorNotConfigured) } interactive := isInteractiveMode(cfg) if !interactive && !force { - return errors.New("reset requires confirmation; use --force to skip in non-interactive mode") + return failWithCode("reset requires confirmation; use --force to skip in non-interactive mode", output.ErrConfirmationRequired) } rt, err := runtime.NewDockerRuntime(cfg.DockerHost) @@ -62,7 +73,7 @@ To wipe the on-disk volume (certificates, persistence data, cached tools) instea if interactive { return ui.RunReset(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, resetter, host, force) } - return reset.Reset(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, resetter, host, force, output.NewPlainSink(os.Stdout)) + return reset.Reset(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, resetter, host, force, sink) }, } diff --git a/cmd/root.go b/cmd/root.go index 1ee95575..4941317f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -85,6 +85,38 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C root.SilenceErrors = true root.SilenceUsage = true + // Flag-parsing failures (e.g. an unknown flag, or a malformed value for an + // ordinary bool flag) happen inside Cobra's own ParseFlags, before any + // RunE — including requireJSONSupport/wrapCommandsWithJSONEnvelope below — ever runs. + // This is the one hook Cobra offers into that path, so it is the only place + // a usage error can be rendered as the JSON envelope (error.code: + // USAGE_ERROR) rather than falling through to the plain-text fallback in + // Execute(). cfg.JSON reliably reflects "was --json already recognized by + // the time parsing failed", since pflag parses flags left-to-right and + // binds each directly to its variable as it succeeds. + root.SetFlagErrorFunc(func(c *cobra.Command, err error) error { + if !cfg.JSON { + return err + } + commandName := commandDisplayName(c) + envelope := output.Envelope{ + SchemaVersion: output.EnvelopeSchemaVersion, + Command: commandName, + Status: output.StatusError, + Warnings: []output.Warning{}, + Error: &output.EnvelopeError{ + Code: output.ErrUsageError, + Category: output.ErrUsageError.Category(), + Message: err.Error(), + Retryable: output.ErrUsageError.Retryable(), + }, + } + if writeErr := writeEnvelope(os.Stdout, envelope); writeErr != nil { + return writeErr + } + return output.NewSilentError(err) + }) + root.PersistentFlags().String("config", "", "Path to config file") root.PersistentFlags().BoolVar(&cfg.NonInteractive, "non-interactive", false, "Disable interactive mode") root.PersistentFlags().BoolVar(&cfg.JSON, "json", false, "Output in JSON format (only supported by some commands)") @@ -231,6 +263,7 @@ func Execute(ctx context.Context) error { if cfg.TracesEnabled { wrapCommandsWithTracing(root) } + wrapCommandsWithJSONEnvelope(root, cfg, os.Stdout) if err := root.ExecuteContext(ctx); err != nil { if !output.IsSilent(err) { @@ -396,7 +429,11 @@ func instrumentCommands(cmd *cobra.Command, tel *telemetry.Client) { // requireJSONSupport walks the Cobra command tree and wraps every RunE so that, // when cfg.JSON is set, a command lacking the jsonSupportedAnnotation is -// rejected instead of silently rendering plain-text output. +// rejected instead of silently rendering plain-text output. The rejection +// itself renders as the standard JSON envelope (error.code: NOT_JSON_CAPABLE) +// since the invocation explicitly asked for JSON — there is no +// command-specific EnvelopeSink to pull from here, so the envelope is built +// directly. func requireJSONSupport(cmd *cobra.Command, cfg *env.Env) { walkCommandsWithRunE(cmd, func(c *cobra.Command) { original := c.RunE @@ -408,10 +445,22 @@ func requireJSONSupport(cmd *cobra.Command, cfg *env.Env) { if cfg.JSON { if _, ok := c.Annotations[jsonSupportedAnnotation]; !ok { commandName := commandDisplayName(c) - output.NewPlainSink(os.Stderr).Emit(output.ErrorEvent{ - Title: fmt.Sprintf("%q is not able to provide output in JSON format", commandName), - Actions: []output.ErrorAction{{Label: "See help:", Value: "lstk -h"}}, - }) + message := fmt.Sprintf("%q is not able to provide output in JSON format", commandName) + envelope := output.Envelope{ + SchemaVersion: output.EnvelopeSchemaVersion, + Command: commandName, + Status: output.StatusError, + Warnings: []output.Warning{}, + Error: &output.EnvelopeError{ + Code: output.ErrNotJSONCapable, + Category: output.ErrNotJSONCapable.Category(), + Message: message, + Retryable: output.ErrNotJSONCapable.Retryable(), + }, + } + if err := writeEnvelope(os.Stdout, envelope); err != nil { + return err + } return output.NewSilentError(fmt.Errorf("%s: not able to provide output in JSON format", commandName)) } } diff --git a/cmd/stop.go b/cmd/stop.go index 79f5ef59..1a945694 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -7,7 +7,6 @@ import ( "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/container" "github.com/localstack/lstk/internal/env" - "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" "github.com/localstack/lstk/internal/telemetry" "github.com/localstack/lstk/internal/ui" @@ -16,11 +15,14 @@ import ( func newStopCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { return &cobra.Command{ - Use: "stop", - Short: "Stop emulator", - Long: "Stop emulator and services", - PreRunE: initConfig(nil), + Use: "stop", + Short: "Stop emulator", + Long: "Stop emulator and services", + PreRunE: initConfig(nil), + Annotations: map[string]string{jsonSupportedAnnotation: "true"}, RunE: func(cmd *cobra.Command, args []string) error { + sink := jsonAwareSink(cmd, cfg, os.Stdout) + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err @@ -37,7 +39,7 @@ func newStopCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { if isInteractiveMode(cfg) { return ui.RunStop(cmd.Context(), rt, appConfig.Containers, stopOpts) } - return container.Stop(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers, stopOpts) + return container.Stop(cmd.Context(), rt, sink, appConfig.Containers, stopOpts) }, } } diff --git a/cmd/update.go b/cmd/update.go index 3235469b..34b30590 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -4,7 +4,6 @@ import ( "os" "github.com/localstack/lstk/internal/env" - "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui" "github.com/localstack/lstk/internal/update" "github.com/spf13/cobra" @@ -14,15 +13,18 @@ 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).", - PreRunE: initConfig(nil), + 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), + 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 update.Update(cmd.Context(), output.NewPlainSink(os.Stdout), checkOnly, cfg.GitHubToken) + return update.Update(cmd.Context(), sink, checkOnly, cfg.GitHubToken) }, } diff --git a/docs/structured-output.md b/docs/structured-output.md new file mode 100644 index 00000000..f43118a4 --- /dev/null +++ b/docs/structured-output.md @@ -0,0 +1,573 @@ +# Structured output (`--json`) + +`lstk` supports a global `--json` flag that makes a command emit a single, machine-readable JSON object on stdout instead of human-oriented text. This document is the reference for that contract: the envelope every JSON-capable command shares, the enumerated error codes, exit-code conventions, and — because only a subset of commands support `--json` today — exactly which ones do. + +The contract described here (a shared envelope shape, a shared error-code vocabulary) is deliberately **not named after JSON specifically** (`EnvelopeSink`, not `JSONSink`; `output-envelope`, not `json-envelope`). JSON is the only serialization implemented today, but the envelope's shape and the accumulation logic that builds it have nothing JSON-specific about them, which keeps the door open to other formats later without a redesign. + +## Implementation status + +`--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. +- **[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 + +Every JSON-capable command writes **exactly one** JSON object to stdout (the exception is `logs --follow`, a genuinely unbounded stream — see "Streaming output" below). The shape is: + +```jsonc +{ + "schemaVersion": 1, + "command": "stop", + "status": "ok", // "ok" | "error" + "data": { /* ... */ }, // non-null iff status is "ok" + "warnings": [], // always an array, possibly empty + "error": null // non-null iff status is "error" +} +``` + +| Field | Type | Notes | +|---|---|---| +| `schemaVersion` | integer | The envelope's wire-format version, currently always `1`. Not versioned per-command — bumped only on a breaking change to the envelope itself or to a specific command's `data` shape (additive fields never require a bump). A script should check it once: `if (resp.schemaVersion !== 1) { /* bail or adapt */ }`. | +| `command` | string | The canonical command path that produced this envelope (e.g. `"stop"`, `"snapshot show"`). | +| `status` | string | `"ok"` or `"error"` — the one field to branch on for success/failure before looking at anything else. | +| `data` | object or `null` | The command-specific result, documented per command in the [Command Catalog](#command-catalog) below. `null` exactly when `status` is `"error"`; a non-null object otherwise, even if that object is empty. | +| `warnings` | array | Non-fatal notices, always present — an empty array on success with nothing to report, never `null` or omitted. Each entry is `{"code": string, "message": string}`. | +| `error` | object or `null` | The machine-readable failure, documented in [Error object fields](#error-object-fields) below. `null` exactly when `status` is `"ok"`. | + +Both `data` and `error` are always present as keys — `null` rather than omitted — so a script can read `resp.data` / `resp.error` without an existence check first. + +### Success example + +```json +{ + "schemaVersion": 1, + "command": "stop", + "status": "ok", + "data": { + "emulators": [ + {"type": "aws", "name": "localstack-aws", "wasRunning": true} + ] + }, + "warnings": [], + "error": null +} +``` + +### Error example + +```json +{ + "schemaVersion": 1, + "command": "reset", + "status": "error", + "data": null, + "warnings": [], + "error": { + "code": "CONFIRMATION_REQUIRED", + "category": "USAGE", + "message": "reset requires confirmation; use --force to skip in non-interactive mode", + "retryable": false + } +} +``` + +### Error object fields + +Within the main payload, the `error` field contains the following sub-fields: + +| Field | Type | Notes | +|---|---|---| +| `code` | string | One of the enumerated codes below. Never free text — see the error-codes table. The primary, stable identifier — branch on this for anything specific. | +| `category` | string | One of 7 coarse groupings of `code` (`RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`) — see [Error categories](#error-categories) below. Additive alongside `code`, not a replacement for it: a caller that only wants broad handling can switch on `category`'s ~7 values instead of `code`'s ~28, while a caller that already keys off a specific `code` is unaffected. | +| `message` | string | Human-readable, informational only. **Not guaranteed stable across versions** — scripts must branch on `code`, not `message`. | +| `retryable` | bool | A static property of `code` (not computed per failure) — see below. Note this is independent of `category`: a category can contain both retryable and non-retryable codes (e.g. `RUNTIME` contains both `NETWORK_ERROR` [retryable] and `DNS_RESOLUTION_REQUIRED` [not]), so `retryable` can't be inferred from `category` alone. | +| `details` | object | Optional, code-specific structured context. Omitted when empty. Illustrative example, for a future `SNAPSHOT_BUCKET_NOT_FOUND`: `{"bucket": "my-terraform-state"}`. | +| `actions` | array | Optional suggested remediations. For example — this is exactly what `reset` emits today for `EMULATOR_NOT_RUNNING`: `[{"id": "start-localstack", "command": "lstk"}, {"id": "see-help", "command": "lstk -h"}]`. | + +## Error codes + +Every `error.code` is one of the following fixed constants. A failure that doesn't map to a specific code falls back to `INTERNAL_ERROR` rather than inventing a new, undocumented string. + +`retryable: true` means the identical invocation might succeed later without changing anything (a transient runtime/network hiccup) — a polling script can back off and retry. `retryable: false` means retrying the exact same invocation will keep failing until something about the request, config, or environment changes. + +| Code | Meaning | Retryable | Category | +|---|---|---|---| +| `RUNTIME_UNAVAILABLE` | The container runtime (Docker today; the abstraction also anticipates Podman, Rancher Desktop, Finch, or Kubernetes, though only Docker is implemented) is unreachable or unhealthy | Yes | `RUNTIME` | +| `IMAGE_PULL_FAILED` | Pulling the emulator image failed and no usable local image exists | Yes | `RUNTIME` | +| `EMULATOR_NOT_RUNNING` | The targeted emulator is not currently running | No | `EMULATOR` | +| `EMULATOR_ALREADY_RUNNING` | An emulator is already running where the command expected it not to be | No | `EMULATOR` | +| `EMULATOR_WRONG_TYPE` | The command requires a specific emulator type but a different one is configured/running | No | `EMULATOR` | +| `EMULATOR_NOT_CONFIGURED` | No container of the requested type exists in the resolved config | No | `EMULATOR` | +| `EMULATOR_START_FAILED` | The emulator failed to reach a healthy state after starting | Yes | `EMULATOR` | +| `AUTH_REQUIRED` | The operation needs a LocalStack auth token and none is available | No | `AUTH` | +| `AUTH_LOGIN_FAILED` | An authentication flow failed | Yes | `AUTH` | +| `CREDENTIALS_MISSING` | Required third-party credentials (e.g. AWS credentials for an S3 remote) could not be resolved | No | `AUTH` | +| `LICENSE_INVALID` | The platform rejected the configured license/token | No | `AUTH` | +| `LICENSE_UNSUPPORTED_TAG` | The configured image tag is not covered by the license | No | `AUTH` | +| `SNAPSHOT_NOT_FOUND` | The referenced snapshot does not exist | No | `RESOURCE` | +| `SNAPSHOT_INVALID_REF` | The snapshot reference could not be parsed | No | `RESOURCE` | +| `SNAPSHOT_REMOTE_ERROR` | A platform or S3 remote call failed | Yes | `RESOURCE` | +| `SNAPSHOT_BUCKET_NOT_FOUND` | The pre-flight S3 bucket-existence check failed | No | `RESOURCE` | +| `CONFIG_INVALID` | The config file failed to parse or validate | No | `CONFIG` | +| `CONFIG_NOT_FOUND` | An explicit config path does not exist | No | `CONFIG` | +| `INTEGRATION_NOT_SET_UP` | A required one-time setup step (e.g. `lstk setup azure`) has not been run | No | `CONFIG` | +| `DEPENDENCY_MISSING` | A required external CLI (e.g. `az`) is not on `PATH` | No | `RUNTIME` | +| `DNS_RESOLUTION_REQUIRED` | A required hostname pattern does not resolve | No | `RUNTIME` | +| `CONFIRMATION_REQUIRED` | A destructive action needs `--force` outside an interactive terminal | No | `USAGE` | +| `VALIDATION_ERROR` | A semantically invalid combination of flags/arguments was given | No | `USAGE` | +| `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` | +| `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` | + +### Error categories + +`error.category` groups the 28 codes above into 7 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 28 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 + → something outside lstk's control (Docker, network, a missing binary) + +EMULATOR EMULATOR_NOT_RUNNING, EMULATOR_ALREADY_RUNNING, EMULATOR_WRONG_TYPE, + EMULATOR_NOT_CONFIGURED, EMULATOR_START_FAILED + → the emulator isn't in the state this command needs + +AUTH AUTH_REQUIRED, AUTH_LOGIN_FAILED, CREDENTIALS_MISSING, + LICENSE_INVALID, LICENSE_UNSUPPORTED_TAG + → identity, credentials, or license/entitlement + +RESOURCE SNAPSHOT_NOT_FOUND, SNAPSHOT_INVALID_REF, SNAPSHOT_REMOTE_ERROR, + SNAPSHOT_BUCKET_NOT_FOUND + → a referenced thing (by name or ref) doesn't exist or is invalid + (named generically, not e.g. SNAPSHOT, so a future non-snapshot + resource error has somewhere to land without a new category) + +CONFIG CONFIG_INVALID, CONFIG_NOT_FOUND, INTEGRATION_NOT_SET_UP + → lstk's own configuration is the problem + +USAGE CONFIRMATION_REQUIRED, VALIDATION_ERROR, USAGE_ERROR, + NOT_JSON_CAPABLE + → the invocation itself needs to change + +INTERNAL CANCELLED, INTERNAL_ERROR + → catch-all: unexpected failure, or a user-initiated interruption +``` + +Every code maps to exactly one category, and the mapping is static — the same code always reports the same category, regardless of which command emitted it (see the Category column above for the authoritative per-code mapping). + +## Exit codes + +``` +0 status: "ok" +1 status: "error", any code other than the two below +2 a Cobra-level usage error that occurred before --json could be recognized + (falls back to today's plain-text usage error on stderr, not an envelope) +3 error.code == "CONFIRMATION_REQUIRED" +4 error.code == "AUTH_REQUIRED" +``` + +Scripts needing full granularity should read `error.code` from the envelope, not the exit code — a 28-entry enum doesn't fit in a POSIX exit code. The two reservations exist because `CONFIRMATION_REQUIRED` and `AUTH_REQUIRED` recur across nearly every command and have an obvious, mechanical remediation (`--force`, `lstk login`) a script can act on without parsing stdout first. + +A `USAGE_ERROR` that *was* successfully rendered as an envelope (because `--json` had already been parsed before the failure) exits `1`, not `2` — exit `2` is reserved specifically for the case where `--json` itself couldn't be recognized yet (e.g. a malformed flag appearing before `--json` in the invocation), so no envelope was possible at all. + +## Streaming output + +`logs --follow` is the one command whose output is a genuinely unbounded stream — there's no natural moment to close a single JSON object around a `tail -f`-style operation. Under `--json --follow`, each line is its own compact JSON object, newline-delimited (NDJSON), with a `type` field instead of `status`. Unlike every other example in this document, this one is shown compact and single-line deliberately — that's the actual wire format, not a formatting shortcut; pretty-printing it would misrepresent NDJSON as something else: + +```json +{"schemaVersion":1,"command":"logs","type":"log","data":{"source":"emulator","level":"info","line":"Ready."}} +``` + +`type` is `"log"` for a line or `"error"` if the stream itself fails. Bounded `logs --json` (without `--follow`) uses the normal single-envelope shape instead, with the same `{source, level, line}` objects collected into `data.lines` — it terminates naturally, so the standard envelope fits. + +🕐 Planned — `logs` does not yet accept `--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) + +### Implemented in this PR + +These three ship in this PR with `--json` support. The shapes below are real — they match what the code actually produces, not a proposal. + +**`lstk stop`** — which configured emulators were actually running and got stopped. +```json +{ + "schemaVersion": 1, + "command": "stop", + "status": "ok", + "data": { + "emulators": [ + {"type": "aws", "name": "localstack-aws", "wasRunning": true} + ] + }, + "warnings": [], + "error": null +} +``` +Codes: `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING` (today, `stop` fails fast on the first configured emulator found not running, matching plain-text behavior). + +**`lstk reset`** — confirmation of what was reset (AWS-only today). +```json +{ + "schemaVersion": 1, + "command": "reset", + "status": "ok", + "data": { + "emulator": {"type": "aws", "name": "localstack-aws"}, + "reset": true + }, + "warnings": [], + "error": null +} +``` +Codes: `EMULATOR_NOT_CONFIGURED` (no AWS container configured), `EMULATOR_NOT_RUNNING`, `CONFIRMATION_REQUIRED` (no `--force` outside a TTY, exit code `3`), `RUNTIME_UNAVAILABLE`. + +**`lstk update`** — differs between `--check` and an applied update. +```json +{ + "schemaVersion": 1, + "command": "update", + "status": "ok", + "data": { + "currentVersion": "2.2.1", + "latestVersion": "2.3.0", + "updateAvailable": true + }, + "warnings": [], + "error": null +} +``` +```json +{ + "schemaVersion": 1, + "command": "update", + "status": "ok", + "data": { + "currentVersion": "2.2.1", + "updatedVersion": "2.3.0", + "updated": true, + "method": "homebrew" + }, + "warnings": [], + "error": null +} +``` +Codes: `NETWORK_ERROR` (GitHub API unreachable), `INTERNAL_ERROR` (archive/extraction failure). + +### Proposed for future work (draft) + +> **This section is a first-draft proposal only, not a committed contract.** None of the commands below accept `--json` yet — every one of them is rejected with `NOT_JSON_CAPABLE` today. The shapes shown are a starting point for design discussion, included here in full so the whole intended surface can be reviewed at once rather than piecemeal across many small follow-up PRs. Expect fields, error codes, and possibly the overall approach for any of these to change based on human feedback before implementation — treat everything below as a proposal to critique, not a spec to build against. + +#### 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 +{ + "schemaVersion": 1, + "command": "restart", + "status": "ok", + "data": { + "stopped": [ + {"type": "aws", "name": "localstack-aws", "wasRunning": true} + ], + "started": [ + {"type": "aws", "name": "localstack-aws", "host": "localhost:4566", "version": "3.9.0", "alreadyRunning": false, "persist": false} + ] + }, + "warnings": [], + "error": null +} +``` +Codes: `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED`, `LICENSE_INVALID`, `EMULATOR_START_FAILED`. + +**`lstk status`** — one entry per *configured* emulator, not just running ones: unlike today's plain-text behavior (which stops at the first non-running emulator), JSON mode reports every configured emulator's running/not-running state. Non-running entries omit the detail fields entirely rather than nulling them out. +```json +{ + "schemaVersion": 1, + "command": "status", + "status": "ok", + "data": { + "emulators": [ + { + "type": "aws", "running": true, "name": "localstack-aws", "version": "3.9.0", + "host": "localhost:4566", "uptimeSeconds": 1234, "persistence": false, + "resourceSummary": {"resources": 12, "services": 4}, + "resources": [{"service": "s3", "name": "my-bucket", "region": "us-east-1", "account": "000000000000"}] + }, + {"type": "snowflake", "running": false} + ] + }, + "warnings": [], + "error": null +} +``` +Codes: `RUNTIME_UNAVAILABLE`. + +**`lstk logs`** (bounded) — `data.lines` is the same `{source, level, line}` shape used by the NDJSON stream variant (see "Streaming output" above). +```json +{ + "schemaVersion": 1, + "command": "logs", + "status": "ok", + "data": { + "lines": [ + {"source": "emulator", "level": "info", "line": "Ready."} + ] + }, + "warnings": [], + "error": null +} +``` +Codes: `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`. + +**`lstk volume path`** — one path per configured container. +```json +{ + "schemaVersion": 1, + "command": "volume path", + "status": "ok", + "data": { + "volumes": [ + {"type": "aws", "path": "/Users/x/Library/Caches/lstk/aws"} + ] + }, + "warnings": [], + "error": null +} +``` +Codes: `CONFIG_INVALID`. + +**`lstk volume clear`** — which volumes were actually cleared. +```json +{ + "schemaVersion": 1, + "command": "volume clear", + "status": "ok", + "data": { + "cleared": [ + {"type": "aws", "path": "/Users/x/Library/Caches/lstk/aws"} + ] + }, + "warnings": [], + "error": null +} +``` +Codes: `EMULATOR_NOT_CONFIGURED` (bad `--type`), `CONFIRMATION_REQUIRED` (no `--force` outside a TTY). + +#### Configuration and auth + +**`lstk config path`** — the resolved (or explicitly `--config`-overridden) path. +```json +{ + "schemaVersion": 1, + "command": "config path", + "status": "ok", + "data": { + "path": "/Users/x/.config/lstk/config.toml" + }, + "warnings": [], + "error": null +} +``` +Codes: `CONFIG_NOT_FOUND` (`--config` path doesn't exist), `CONFIG_INVALID`. + +**`lstk logout`** — whether there was anything to log out of, and any emulators still running with the now-removed token. +```json +{ + "schemaVersion": 1, + "command": "logout", + "status": "ok", + "data": { + "loggedOut": true, + "stillRunning": [] + }, + "warnings": [], + "error": null +} +``` +When already logged out, this is still `status: "ok"` with `"loggedOut": false` — logout is idempotent, and JSON mode preserves that rather than inventing a new error for it. No codes expected in normal operation; `INTERNAL_ERROR` is the universal fallback. + +**`lstk setup aws`** — the profile that was written and whether the LocalStack hostname resolved. +```json +{ + "schemaVersion": 1, + "command": "setup aws", + "status": "ok", + "data": { + "profile": "localstack", + "written": true, + "dnsOk": true + }, + "warnings": [], + "error": null +} +``` +Codes: `CONFIRMATION_REQUIRED` (existing profile differs, no `--force`). + +**`lstk setup azure`** — the isolated config dir and the cloud that was registered. +```json +{ + "schemaVersion": 1, + "command": "setup azure", + "status": "ok", + "data": { + "configDir": "/Users/x/.config/lstk/azure", + "cloudRegistered": "LocalStack" + }, + "warnings": [], + "error": null +} +``` +Codes: `DEPENDENCY_MISSING` (`az` CLI not on `PATH`), `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`, `DNS_RESOLUTION_REQUIRED`. + +**`lstk az start-interception`** — same shape as `setup azure`, plus the resolved endpoint. +```json +{ + "schemaVersion": 1, + "command": "az start-interception", + "status": "ok", + "data": { + "cloudRegistered": "LocalStack", + "endpoint": "https://azure.localhost.localstack.cloud:4566" + }, + "warnings": [], + "error": null +} +``` +Codes: `DEPENDENCY_MISSING`, `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`, `DNS_RESOLUTION_REQUIRED`. + +**`lstk az stop-interception`** — what changed, or confirmation nothing did (LocalStack wasn't the active cloud). +```json +{ + "schemaVersion": 1, + "command": "az stop-interception", + "status": "ok", + "data": { + "switchedFrom": "LocalStack", + "switchedTo": "AzureCloud", + "changed": true + }, + "warnings": [], + "error": null +} +``` +Codes: `VALIDATION_ERROR` (`--cloud` not a registered cloud). + +#### Snapshots + +**`lstk snapshot save`** — one shape covering local/pod/S3 destinations, discriminated by `kind`. +```json +{ + "schemaVersion": 1, + "command": "snapshot save", + "status": "ok", + "data": { + "kind": "pod", + "location": "pod:my-baseline", + "podName": "my-baseline", + "version": 4, + "services": ["s3", "lambda"], + "sizeBytes": 245678 + }, + "warnings": [], + "error": null +} +``` +(`kind` is `"local"` | `"pod"` | `"s3"`; `podName`/`version` are `null` for `kind: "local"`.) Codes: `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`, `AUTH_REQUIRED` (pod), `CREDENTIALS_MISSING` (S3, no AWS creds resolvable), `SNAPSHOT_BUCKET_NOT_FOUND`, `SNAPSHOT_REMOTE_ERROR`, `VALIDATION_ERROR` (bad pod name). + +**`lstk snapshot load`** — what was loaded and whether it started the emulator to do so. +```json +{ + "schemaVersion": 1, + "command": "snapshot load", + "status": "ok", + "data": { + "source": "pod:my-baseline", + "services": ["s3", "lambda"], + "emulatorStarted": false, + "mergeStrategy": "account-region-merge" + }, + "warnings": [], + "error": null +} +``` +Codes: `SNAPSHOT_NOT_FOUND`, `SNAPSHOT_INVALID_REF`, `SNAPSHOT_REMOTE_ERROR`, `SNAPSHOT_BUCKET_NOT_FOUND`, `CREDENTIALS_MISSING`, `AUTH_REQUIRED`, `RUNTIME_UNAVAILABLE`, `EMULATOR_START_FAILED`, `VALIDATION_ERROR` (bad `--merge`). + +**`lstk snapshot list`** — the platform or S3 location queried, and the snapshots found. +```json +{ + "schemaVersion": 1, + "command": "snapshot list", + "status": "ok", + "data": { + "location": "platform", + "snapshots": [ + {"name": "my-baseline", "version": 4, "lastChanged": "2026-07-01T12:00:00Z"} + ] + }, + "warnings": [], + "error": null +} +``` +Codes: `AUTH_REQUIRED` (platform), `CREDENTIALS_MISSING`/`SNAPSHOT_BUCKET_NOT_FOUND` (S3), `SNAPSHOT_REMOTE_ERROR`, `EMULATOR_NOT_RUNNING` (S3 list requires a running emulator). + +**`lstk snapshot show`** — mirrors the existing `SnapshotShownEvent` struct directly (the richest structured event in the codebase already). +```json +{ + "schemaVersion": 1, + "command": "snapshot show", + "status": "ok", + "data": { + "name": "my-baseline", "version": 4, "created": "2026-06-01T00:00:00Z", "sizeBytes": 245678, + "localstackVersion": "3.9.0", "message": "", "services": ["s3"], + "resources": [{"service": "s3", "counts": [{"noun": "buckets", "count": 3}]}] + }, + "warnings": [], + "error": null +} +``` +Codes: `AUTH_REQUIRED`, `SNAPSHOT_NOT_FOUND`, `SNAPSHOT_INVALID_REF`, `SNAPSHOT_REMOTE_ERROR`. + +**`lstk snapshot remove`** — confirmation of deletion. +```json +{ + "schemaVersion": 1, + "command": "snapshot remove", + "status": "ok", + "data": { + "podName": "my-baseline", + "removed": true + }, + "warnings": [], + "error": null +} +``` +Codes: `AUTH_REQUIRED`, `SNAPSHOT_NOT_FOUND`, `SNAPSHOT_INVALID_REF`, `CONFIRMATION_REQUIRED`, `SNAPSHOT_REMOTE_ERROR`. + +## Commands that will never support `--json` + +- **`login`** and **`config profile`** — both require an interactive terminal unconditionally (browser-based OAuth, TTY prompts) and have 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. diff --git a/internal/container/stop.go b/internal/container/stop.go index 7e37dfd3..bd28256b 100644 --- a/internal/container/stop.go +++ b/internal/container/stop.go @@ -31,6 +31,7 @@ func Stop(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers if name == "" { sink.Emit(output.ErrorEvent{ Title: fmt.Sprintf("%s is not running", c.DisplayName()), + Code: output.ErrEmulatorNotRunning, }) return output.NewSilentError(fmt.Errorf("%s is not running", c.Name())) } @@ -45,11 +46,13 @@ func Stop(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers if err := rt.Stop(stopCtx, name); err != nil { stopCancel() sink.Emit(output.SpinnerStop()) - return fmt.Errorf("failed to stop LocalStack: %w", err) + wrapped := fmt.Errorf("failed to stop LocalStack: %w", err) + sink.Emit(output.ErrorEvent{Title: wrapped.Error(), Code: output.ErrRuntimeUnavailable}) + return output.NewSilentError(wrapped) } stopCancel() sink.Emit(output.SpinnerStop()) - sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("%s stopped", c.DisplayName())}) + sink.Emit(output.EmulatorStoppedEvent{Type: string(c.Type), Name: name, DisplayName: c.DisplayName(), WasRunning: true}) opts.Telemetry.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ EventType: telemetry.LifecycleStop, diff --git a/internal/output/envelope.go b/internal/output/envelope.go new file mode 100644 index 00000000..066b0353 --- /dev/null +++ b/internal/output/envelope.go @@ -0,0 +1,61 @@ +package output + +// Format identifies how an Envelope is serialized to bytes. FormatJSON is the +// only value implemented today; the type exists so a future serialization +// (e.g. YAML) is a new Format value and marshaler, not a rewrite of the +// EnvelopeSink accumulation logic below. +type Format string + +// FormatJSON serializes an Envelope as compact JSON. +const FormatJSON Format = "json" + +// EnvelopeSchemaVersion is the current version of the Envelope wire contract. +// Bump it only on a breaking change to the envelope itself or to a command's +// documented data shape; additive fields never require a bump. +const EnvelopeSchemaVersion = 1 + +const ( + StatusOK = "ok" + StatusError = "error" +) + +// Envelope is the common result shape every JSON-capable command emits as a +// single object to stdout. Data and Warnings are always non-nil; Error is nil +// exactly when Status is "ok". +type Envelope struct { + SchemaVersion int `json:"schemaVersion"` + Command string `json:"command"` + Status string `json:"status"` + Data any `json:"data"` + Warnings []Warning `json:"warnings"` + Error *EnvelopeError `json:"error"` +} + +// Warning is a non-fatal notice surfaced alongside a successful result. +type Warning struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// EnvelopeError is the machine-readable error shape carried by a failed +// Envelope. Code is one of the constants in error_code.go and remains the +// primary, stable identifier; Category is an additive, coarse grouping of +// Code (~7 values vs. Code's ~28) for callers that only want to distinguish +// broad kinds of failure. Both Retryable and Category are static +// classifications of Code, never computed per instance. +type EnvelopeError struct { + Code ErrorCode `json:"code"` + Category ErrorCategory `json:"category"` + Message string `json:"message"` + Retryable bool `json:"retryable"` + Details map[string]any `json:"details,omitempty"` + Actions []EnvelopeAction `json:"actions,omitempty"` +} + +// EnvelopeAction is a suggested remediation for an EnvelopeError. ID is a +// stable slug a script can switch on; Command is the literal shell command a +// human or script could run. +type EnvelopeAction struct { + ID string `json:"id"` + Command string `json:"command"` +} diff --git a/internal/output/envelope_sink.go b/internal/output/envelope_sink.go new file mode 100644 index 00000000..33e29603 --- /dev/null +++ b/internal/output/envelope_sink.go @@ -0,0 +1,151 @@ +package output + +import "strings" + +// EnvelopeSink implements Sink by accumulating events into an Envelope +// instead of formatting lines. This accumulation step is not JSON-specific — +// it would be identical for any structured serialization; only the final +// marshal (driven by Format) differs. Presentational events (SpinnerEvent, +// ContainerStatusEvent, ProgressEvent, UserInputRequestEvent, ...) have no +// place in a single terminal result and are silently dropped. +type EnvelopeSink struct { + format Format + data map[string]any + warnings []Warning + err *EnvelopeError +} + +// NewEnvelopeSink returns an EnvelopeSink that serializes per format. Only +// FormatJSON exists today. +func NewEnvelopeSink(format Format) *EnvelopeSink { + return &EnvelopeSink{format: format, data: map[string]any{}} +} + +// warningCodeNotice is the fallback Warning.Code for a plain MessageEvent, +// which carries no machine-readable code of its own today. +const warningCodeNotice = "NOTICE" + +func (s *EnvelopeSink) Emit(event Event) { + switch e := event.(type) { + case DeferredEvent: + s.Emit(e.Inner) + case ErrorEvent: + s.setError(e) + case MessageEvent: + if e.Severity == SeverityWarning { + s.warnings = append(s.warnings, Warning{Code: warningCodeNotice, Message: e.Text}) + } + case EmulatorStoppedEvent: + s.appendEmulator(map[string]any{"type": e.Type, "name": e.Name, "wasRunning": e.WasRunning}) + case EmulatorResetEvent: + s.data["emulator"] = map[string]any{"type": e.Type, "name": e.Name} + s.data["reset"] = true + case UpdateCheckedEvent: + s.data["currentVersion"] = e.CurrentVersion + s.data["latestVersion"] = e.LatestVersion + s.data["updateAvailable"] = e.Available + case UpdateAppliedEvent: + // An UpdateCheckedEvent always precedes this on the apply path (Check + // fires it unconditionally now, for the plain-text "Update available" + // line), so clear the keys it set rather than leaving stale + // latestVersion/updateAvailable alongside the applied-update shape. + delete(s.data, "latestVersion") + delete(s.data, "updateAvailable") + s.data["currentVersion"] = e.CurrentVersion + s.data["updatedVersion"] = e.UpdatedVersion + s.data["updated"] = true + s.data["method"] = e.Method + } + // Every other event type is purely presentational and intentionally dropped. +} + +func (s *EnvelopeSink) appendEmulator(entry map[string]any) { + list, _ := s.data["emulators"].([]map[string]any) + list = append(list, entry) + s.data["emulators"] = list +} + +func (s *EnvelopeSink) setError(e ErrorEvent) { + code := e.Code + if code == "" { + code = ErrInternal + } + var actions []EnvelopeAction + for _, a := range e.Actions { + actions = append(actions, EnvelopeAction{ID: slugify(a.Label), Command: a.Value}) + } + s.err = &EnvelopeError{ + Code: code, + Category: code.Category(), + Message: e.Title, + Retryable: code.Retryable(), + Actions: actions, + } +} + +// Result builds the final Envelope for command, given the error RunE +// returned. An error captured via an ErrorEvent (setError above) takes +// precedence; an error with no prior classification falls back to +// ErrInternal rather than leaving the envelope malformed. +func (s *EnvelopeSink) Result(command string, runErr error) Envelope { + warnings := s.warnings + if warnings == nil { + warnings = []Warning{} + } + + if runErr == nil { + data := s.data + if data == nil { + data = map[string]any{} + } + return Envelope{ + SchemaVersion: EnvelopeSchemaVersion, + Command: command, + Status: StatusOK, + Data: data, + Warnings: warnings, + Error: nil, + } + } + + envErr := s.err + if envErr == nil { + envErr = &EnvelopeError{ + Code: ErrInternal, + Category: ErrInternal.Category(), + Message: runErr.Error(), + Retryable: ErrInternal.Retryable(), + } + } + return Envelope{ + SchemaVersion: EnvelopeSchemaVersion, + Command: command, + Status: StatusError, + Data: nil, + Warnings: warnings, + Error: envErr, + } +} + +// slugify turns a plain-text ErrorAction.Label (e.g. "Start LocalStack:") +// into a stable-ish machine ID (e.g. "start-localstack"). This is a +// best-effort mechanical derivation from prose written for humans, not a +// truly independent identifier — see design.md's naming decisions. +func slugify(s string) string { + s = strings.ToLower(strings.TrimRight(strings.TrimSpace(s), ":")) + var b strings.Builder + prevDash := false + for _, r := range s { + switch { + case r >= 'a' && r <= 'z' || r >= '0' && r <= '9': + b.WriteRune(r) + prevDash = false + default: + if !prevDash && b.Len() > 0 { + b.WriteByte('-') + prevDash = true + } + } + } + return strings.TrimRight(b.String(), "-") +} diff --git a/internal/output/envelope_sink_test.go b/internal/output/envelope_sink_test.go new file mode 100644 index 00000000..7a2e5500 --- /dev/null +++ b/internal/output/envelope_sink_test.go @@ -0,0 +1,288 @@ +package output + +import ( + "errors" + "testing" +) + +func TestEnvelopeSink_SuccessWithNoEvents(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + envelope := sink.Result("stop", nil) + + if envelope.Status != StatusOK { + t.Fatalf("expected status %q, got %q", StatusOK, envelope.Status) + } + if envelope.Error != nil { + t.Fatalf("expected nil error, got %+v", envelope.Error) + } + if envelope.Data == nil { + t.Fatal("expected non-nil data on success") + } + if envelope.Warnings == nil { + t.Fatal("expected non-nil (possibly empty) warnings") + } + if envelope.SchemaVersion != EnvelopeSchemaVersion { + t.Fatalf("expected schemaVersion %d, got %d", EnvelopeSchemaVersion, envelope.SchemaVersion) + } + if envelope.Command != "stop" { + t.Fatalf("expected command %q, got %q", "stop", envelope.Command) + } +} + +func TestEnvelopeSink_EmulatorStoppedEventAccumulates(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(EmulatorStoppedEvent{Type: "aws", Name: "localstack-aws", WasRunning: true}) + sink.Emit(EmulatorStoppedEvent{Type: "snowflake", Name: "localstack-snowflake", WasRunning: true}) + + envelope := sink.Result("stop", nil) + data, ok := envelope.Data.(map[string]any) + if !ok { + t.Fatalf("expected map[string]any data, got %T", envelope.Data) + } + emulators, ok := data["emulators"].([]map[string]any) + if !ok { + t.Fatalf("expected []map[string]any emulators, got %T", data["emulators"]) + } + if len(emulators) != 2 { + t.Fatalf("expected 2 emulators, got %d", len(emulators)) + } + if emulators[0]["type"] != "aws" || emulators[0]["name"] != "localstack-aws" || emulators[0]["wasRunning"] != true { + t.Fatalf("unexpected first emulator entry: %+v", emulators[0]) + } + if emulators[1]["type"] != "snowflake" { + t.Fatalf("unexpected second emulator entry: %+v", emulators[1]) + } +} + +func TestEnvelopeSink_EmulatorResetEvent(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(EmulatorResetEvent{Type: "aws", Name: "localstack-aws"}) + + envelope := sink.Result("reset", nil) + data := envelope.Data.(map[string]any) + emulator, ok := data["emulator"].(map[string]any) + if !ok { + t.Fatalf("expected map[string]any emulator, got %T", data["emulator"]) + } + if emulator["type"] != "aws" || emulator["name"] != "localstack-aws" { + t.Fatalf("unexpected emulator entry: %+v", emulator) + } + if data["reset"] != true { + t.Fatalf("expected reset: true, got %+v", data["reset"]) + } +} + +func TestEnvelopeSink_UpdateCheckedEvent(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(UpdateCheckedEvent{CurrentVersion: "2.2.1", LatestVersion: "2.3.0", Available: true}) + + envelope := sink.Result("update", nil) + data := envelope.Data.(map[string]any) + if data["currentVersion"] != "2.2.1" || data["latestVersion"] != "2.3.0" || data["updateAvailable"] != true { + t.Fatalf("unexpected data: %+v", data) + } + if _, hasApplied := data["updated"]; hasApplied { + t.Fatalf("did not expect an 'updated' key from UpdateCheckedEvent: %+v", data) + } +} + +func TestEnvelopeSink_UpdateAppliedEvent(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(UpdateAppliedEvent{CurrentVersion: "2.2.1", UpdatedVersion: "2.3.0", Method: "homebrew"}) + + envelope := sink.Result("update", nil) + data := envelope.Data.(map[string]any) + if data["currentVersion"] != "2.2.1" || data["updatedVersion"] != "2.3.0" || data["updated"] != true || data["method"] != "homebrew" { + t.Fatalf("unexpected data: %+v", data) + } +} + +// TestEnvelopeSink_UpdateAppliedClearsCheckedKeys covers the sequence +// update.Check/Update actually produce on the apply path: Check always fires +// UpdateCheckedEvent now (for the plain-text "Update available" line), so +// UpdateAppliedEvent must clear the keys it set rather than leaving a stale +// latestVersion/updateAvailable alongside the applied-update shape. +func TestEnvelopeSink_UpdateAppliedClearsCheckedKeys(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(UpdateCheckedEvent{CurrentVersion: "2.2.1", LatestVersion: "2.3.0", Available: true}) + sink.Emit(UpdateAppliedEvent{CurrentVersion: "2.2.1", UpdatedVersion: "2.3.0", Method: "binary"}) + + envelope := sink.Result("update", nil) + data := envelope.Data.(map[string]any) + if data["currentVersion"] != "2.2.1" || data["updatedVersion"] != "2.3.0" || data["updated"] != true || data["method"] != "binary" { + t.Fatalf("unexpected data: %+v", data) + } + if _, ok := data["latestVersion"]; ok { + t.Fatalf("expected latestVersion to be cleared, got %+v", data) + } + if _, ok := data["updateAvailable"]; ok { + t.Fatalf("expected updateAvailable to be cleared, got %+v", data) + } +} + +func TestEnvelopeSink_ErrorEventSetsClassifiedError(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(ErrorEvent{ + Title: "LocalStack is not running", + Code: ErrEmulatorNotRunning, + Actions: []ErrorAction{ + {Label: "Start LocalStack:", Value: "lstk"}, + }, + }) + + envelope := sink.Result("reset", errors.New("LocalStack is not running")) + if envelope.Status != StatusError { + t.Fatalf("expected status %q, got %q", StatusError, envelope.Status) + } + if envelope.Data != nil { + t.Fatalf("expected nil data on error, got %+v", envelope.Data) + } + if envelope.Error == nil { + t.Fatal("expected non-nil error") + } + if envelope.Error.Code != ErrEmulatorNotRunning { + t.Fatalf("expected code %q, got %q", ErrEmulatorNotRunning, envelope.Error.Code) + } + if envelope.Error.Category != CategoryEmulator { + t.Fatalf("expected category %q, got %q", CategoryEmulator, envelope.Error.Category) + } + if envelope.Error.Retryable != ErrEmulatorNotRunning.Retryable() { + t.Fatalf("expected retryable %v to match the code's static classification", ErrEmulatorNotRunning.Retryable()) + } + if envelope.Error.Message != "LocalStack is not running" { + t.Fatalf("expected message %q, got %q", "LocalStack is not running", envelope.Error.Message) + } + if len(envelope.Error.Actions) != 1 || envelope.Error.Actions[0].ID != "start-localstack" || envelope.Error.Actions[0].Command != "lstk" { + t.Fatalf("unexpected actions: %+v", envelope.Error.Actions) + } +} + +func TestEnvelopeSink_UnclassifiedErrorFallsBackToInternal(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + envelope := sink.Result("stop", errors.New("failed to get config: boom")) + + if envelope.Status != StatusError { + t.Fatalf("expected status %q, got %q", StatusError, envelope.Status) + } + if envelope.Error == nil { + t.Fatal("expected non-nil error") + } + if envelope.Error.Code != ErrInternal { + t.Fatalf("expected fallback code %q, got %q", ErrInternal, envelope.Error.Code) + } + if envelope.Error.Category != CategoryInternal { + t.Fatalf("expected fallback category %q, got %q", CategoryInternal, envelope.Error.Category) + } + if envelope.Error.Message != "failed to get config: boom" { + t.Fatalf("expected message %q, got %q", "failed to get config: boom", envelope.Error.Message) + } +} + +func TestEnvelopeSink_ErrorEventWithoutCodeAlsoFallsBackToInternal(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(ErrorEvent{Title: "something went wrong"}) + + envelope := sink.Result("stop", errors.New("something went wrong")) + if envelope.Error.Code != ErrInternal { + t.Fatalf("expected fallback code %q for an ErrorEvent with no Code set, got %q", ErrInternal, envelope.Error.Code) + } +} + +func TestEnvelopeSink_WarningsAccumulate(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(MessageEvent{Severity: SeverityWarning, Text: "DNS fallback used"}) + sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "ignored, not a warning"}) + sink.Emit(MessageEvent{Severity: SeverityWarning, Text: "second warning"}) + + envelope := sink.Result("update", nil) + if len(envelope.Warnings) != 2 { + t.Fatalf("expected 2 warnings, got %d: %+v", len(envelope.Warnings), envelope.Warnings) + } + if envelope.Warnings[0].Message != "DNS fallback used" || envelope.Warnings[1].Message != "second warning" { + t.Fatalf("unexpected warnings: %+v", envelope.Warnings) + } +} + +func TestEnvelopeSink_EmptyWarningsIsEmptySliceNotNil(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + envelope := sink.Result("stop", nil) + if envelope.Warnings == nil { + t.Fatal("expected an empty slice, not nil") + } + if len(envelope.Warnings) != 0 { + t.Fatalf("expected 0 warnings, got %d", len(envelope.Warnings)) + } +} + +func TestEnvelopeSink_UnwrapsDeferredEvent(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(DeferredEvent{Inner: ErrorEvent{Title: "wrapped", Code: ErrNetworkError}}) + + envelope := sink.Result("update", errors.New("wrapped")) + if envelope.Error.Code != ErrNetworkError { + t.Fatalf("expected DeferredEvent's inner ErrorEvent to be classified, got code %q", envelope.Error.Code) + } +} + +// dropsEvents lists presentational events an EnvelopeSink must silently drop: +// they must not appear in Data, must not panic, and must not affect Status. +func TestEnvelopeSink_DropsPresentationalEvents(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(SpinnerStart("Stopping...")) + sink.Emit(SpinnerStop()) + sink.Emit(ContainerStatusEvent{Phase: "pulling", Container: "aws"}) + sink.Emit(ProgressEvent{Container: "aws", Current: 1, Total: 2}) + sink.Emit(AuthEvent{Preamble: "hi"}) + sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "just an info line"}) + + envelope := sink.Result("stop", nil) + data := envelope.Data.(map[string]any) + if len(data) != 0 { + t.Fatalf("expected no accumulated data from presentational events, got %+v", data) + } + if len(envelope.Warnings) != 0 { + t.Fatalf("expected no warnings from a non-warning MessageEvent, got %+v", envelope.Warnings) + } +} + +func TestSlugify(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "Start LocalStack:": "start-localstack", + "See help:": "see-help", + "lstk -h": "lstk-h", + "": "", + } + for label, want := range cases { + if got := slugify(label); got != want { + t.Errorf("slugify(%q) = %q, want %q", label, got, want) + } + } +} diff --git a/internal/output/error_code.go b/internal/output/error_code.go new file mode 100644 index 00000000..ee10d7cc --- /dev/null +++ b/internal/output/error_code.go @@ -0,0 +1,162 @@ +package output + +// ErrorCode is a stable, machine-readable identifier for a JSON envelope error. +// Values are documented in the error-codes OpenSpec capability; a call site +// with no applicable code SHALL use ErrInternal rather than inventing a new one. +type ErrorCode string + +const ( + ErrRuntimeUnavailable ErrorCode = "RUNTIME_UNAVAILABLE" + ErrImagePullFailed ErrorCode = "IMAGE_PULL_FAILED" + ErrEmulatorNotRunning ErrorCode = "EMULATOR_NOT_RUNNING" + ErrEmulatorAlreadyRunning ErrorCode = "EMULATOR_ALREADY_RUNNING" + ErrEmulatorWrongType ErrorCode = "EMULATOR_WRONG_TYPE" + ErrEmulatorNotConfigured ErrorCode = "EMULATOR_NOT_CONFIGURED" + ErrEmulatorStartFailed ErrorCode = "EMULATOR_START_FAILED" + ErrAuthRequired ErrorCode = "AUTH_REQUIRED" + ErrAuthLoginFailed ErrorCode = "AUTH_LOGIN_FAILED" + ErrCredentialsMissing ErrorCode = "CREDENTIALS_MISSING" + ErrLicenseInvalid ErrorCode = "LICENSE_INVALID" + ErrLicenseUnsupportedTag ErrorCode = "LICENSE_UNSUPPORTED_TAG" + ErrSnapshotNotFound ErrorCode = "SNAPSHOT_NOT_FOUND" + ErrSnapshotInvalidRef ErrorCode = "SNAPSHOT_INVALID_REF" + ErrSnapshotRemoteError ErrorCode = "SNAPSHOT_REMOTE_ERROR" + ErrSnapshotBucketNotFound ErrorCode = "SNAPSHOT_BUCKET_NOT_FOUND" + ErrConfigInvalid ErrorCode = "CONFIG_INVALID" + ErrConfigNotFound ErrorCode = "CONFIG_NOT_FOUND" + ErrIntegrationNotSetUp ErrorCode = "INTEGRATION_NOT_SET_UP" + ErrDependencyMissing ErrorCode = "DEPENDENCY_MISSING" + ErrDNSResolutionRequired ErrorCode = "DNS_RESOLUTION_REQUIRED" + ErrConfirmationRequired ErrorCode = "CONFIRMATION_REQUIRED" + ErrValidationError ErrorCode = "VALIDATION_ERROR" + ErrUsageError ErrorCode = "USAGE_ERROR" + ErrNotJSONCapable ErrorCode = "NOT_JSON_CAPABLE" + ErrNetworkError ErrorCode = "NETWORK_ERROR" + ErrCancelled ErrorCode = "CANCELLED" + ErrInternal ErrorCode = "INTERNAL_ERROR" +) + +// retryableCodes is the single source of truth for whether a given ErrorCode +// represents a transient failure worth retrying without changing the +// invocation. Codes not listed here default to non-retryable. +var retryableCodes = map[ErrorCode]bool{ + ErrRuntimeUnavailable: true, + ErrImagePullFailed: true, + ErrEmulatorStartFailed: true, + ErrAuthLoginFailed: true, + ErrSnapshotRemoteError: true, + ErrNetworkError: true, + ErrCancelled: true, +} + +// Retryable reports whether the identical invocation might succeed later +// without any change to arguments or environment. It is a static property of +// the code, documented in the error-codes capability's table. +func (c ErrorCode) Retryable() bool { + return retryableCodes[c] +} + +// ErrorCategory is a coarse, small-cardinality grouping of ErrorCode values, +// additive alongside Code (not a replacement for it — see design.md's +// naming decisions). A caller that only wants to distinguish broad kinds of +// failure (an environment problem vs. a usage problem vs. an auth problem) +// can switch on the ~7 Category values instead of the ~28 Code values; +// Code remains the primary, stable identifier for anything more specific. +type ErrorCategory string + +const ( + // CategoryRuntime: something outside lstk's control (Docker, network, a + // missing external binary) is the problem. + CategoryRuntime ErrorCategory = "RUNTIME" + // CategoryEmulator: the emulator isn't in the state this command needs. + CategoryEmulator ErrorCategory = "EMULATOR" + // CategoryAuth: identity, credentials, or license/entitlement. + CategoryAuth ErrorCategory = "AUTH" + // CategoryResource: a referenced thing (by name or ref) doesn't exist or + // is invalid. Named generically, not e.g. "SNAPSHOT", so a future + // non-snapshot resource error has somewhere to land without a new category. + CategoryResource ErrorCategory = "RESOURCE" + // CategoryConfig: lstk's own configuration is the problem. + CategoryConfig ErrorCategory = "CONFIG" + // CategoryUsage: the invocation itself needs to change (flags, args, + // confirmation, capability). + CategoryUsage ErrorCategory = "USAGE" + // CategoryInternal: catch-all — unexpected failure or user-initiated + // interruption. + CategoryInternal ErrorCategory = "INTERNAL" +) + +// allErrorCodes lists every defined ErrorCode, so tests can assert every code +// has a category (categoryByCode has no sensible zero-value default, unlike +// retryableCodes, so completeness matters here in a way it doesn't there). +var allErrorCodes = []ErrorCode{ + ErrRuntimeUnavailable, + ErrImagePullFailed, + ErrEmulatorNotRunning, + ErrEmulatorAlreadyRunning, + ErrEmulatorWrongType, + ErrEmulatorNotConfigured, + ErrEmulatorStartFailed, + ErrAuthRequired, + ErrAuthLoginFailed, + ErrCredentialsMissing, + ErrLicenseInvalid, + ErrLicenseUnsupportedTag, + ErrSnapshotNotFound, + ErrSnapshotInvalidRef, + ErrSnapshotRemoteError, + ErrSnapshotBucketNotFound, + ErrConfigInvalid, + ErrConfigNotFound, + ErrIntegrationNotSetUp, + ErrDependencyMissing, + ErrDNSResolutionRequired, + ErrConfirmationRequired, + ErrValidationError, + ErrUsageError, + ErrNotJSONCapable, + ErrNetworkError, + ErrCancelled, + ErrInternal, +} + +// categoryByCode is the single source of truth mapping each ErrorCode to its +// static ErrorCategory, mirroring retryableCodes above. Every code in +// allErrorCodes SHALL have an entry here. +var categoryByCode = map[ErrorCode]ErrorCategory{ + ErrRuntimeUnavailable: CategoryRuntime, + ErrImagePullFailed: CategoryRuntime, + ErrDependencyMissing: CategoryRuntime, + ErrDNSResolutionRequired: CategoryRuntime, + ErrNetworkError: CategoryRuntime, + ErrEmulatorNotRunning: CategoryEmulator, + ErrEmulatorAlreadyRunning: CategoryEmulator, + ErrEmulatorWrongType: CategoryEmulator, + ErrEmulatorNotConfigured: CategoryEmulator, + ErrEmulatorStartFailed: CategoryEmulator, + ErrAuthRequired: CategoryAuth, + ErrAuthLoginFailed: CategoryAuth, + ErrCredentialsMissing: CategoryAuth, + ErrLicenseInvalid: CategoryAuth, + ErrLicenseUnsupportedTag: CategoryAuth, + ErrSnapshotNotFound: CategoryResource, + ErrSnapshotInvalidRef: CategoryResource, + ErrSnapshotRemoteError: CategoryResource, + ErrSnapshotBucketNotFound: CategoryResource, + ErrConfigInvalid: CategoryConfig, + ErrConfigNotFound: CategoryConfig, + ErrIntegrationNotSetUp: CategoryConfig, + ErrConfirmationRequired: CategoryUsage, + ErrValidationError: CategoryUsage, + ErrUsageError: CategoryUsage, + ErrNotJSONCapable: CategoryUsage, + ErrCancelled: CategoryInternal, + ErrInternal: CategoryInternal, +} + +// Category reports the code's static, coarse grouping. Every ErrorCode in +// allErrorCodes has one; a code missing from categoryByCode (which SHALL NOT +// happen) would report "" rather than panicking. +func (c ErrorCode) Category() ErrorCategory { + return categoryByCode[c] +} diff --git a/internal/output/error_code_test.go b/internal/output/error_code_test.go new file mode 100644 index 00000000..0a0fd0ec --- /dev/null +++ b/internal/output/error_code_test.go @@ -0,0 +1,65 @@ +package output + +import "testing" + +// TestErrorCode_EveryCodeHasACategory guards the completeness invariant +// categoryByCode depends on: unlike retryableCodes (which has a sensible +// false default), a code missing from categoryByCode silently reports "", +// which is never a valid category value. +func TestErrorCode_EveryCodeHasACategory(t *testing.T) { + t.Parallel() + + for _, code := range allErrorCodes { + if category := code.Category(); category == "" { + t.Errorf("ErrorCode %q has no category", code) + } + } +} + +// TestErrorCode_AllErrorCodesIsComplete guards allErrorCodes itself against +// drifting out of sync with the const block: every code must appear exactly +// once, so TestErrorCode_EveryCodeHasACategory actually covers all of them. +func TestErrorCode_AllErrorCodesIsComplete(t *testing.T) { + t.Parallel() + + seen := map[ErrorCode]int{} + for _, code := range allErrorCodes { + seen[code]++ + } + for code, count := range seen { + if count != 1 { + t.Errorf("ErrorCode %q appears %d times in allErrorCodes, want exactly once", code, count) + } + } + if len(allErrorCodes) != 28 { + t.Errorf("expected 28 documented error codes, got %d — update this test's expectation alongside error-codes/spec.md if a code was intentionally added or removed", len(allErrorCodes)) + } +} + +func TestErrorCode_Category(t *testing.T) { + t.Parallel() + + cases := []struct { + code ErrorCode + want ErrorCategory + }{ + {ErrRuntimeUnavailable, CategoryRuntime}, + {ErrNetworkError, CategoryRuntime}, + {ErrEmulatorNotRunning, CategoryEmulator}, + {ErrEmulatorNotConfigured, CategoryEmulator}, + {ErrAuthRequired, CategoryAuth}, + {ErrLicenseInvalid, CategoryAuth}, + {ErrSnapshotNotFound, CategoryResource}, + {ErrSnapshotBucketNotFound, CategoryResource}, + {ErrConfigInvalid, CategoryConfig}, + {ErrConfirmationRequired, CategoryUsage}, + {ErrUsageError, CategoryUsage}, + {ErrCancelled, CategoryInternal}, + {ErrInternal, CategoryInternal}, + } + for _, c := range cases { + if got := c.code.Category(); got != c.want { + t.Errorf("%s.Category() = %q, want %q", c.code, got, c.want) + } + } +} diff --git a/internal/output/errors.go b/internal/output/errors.go index d88f1f63..3c4b3fe4 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -27,3 +27,20 @@ func IsSilent(err error) bool { return errors.As(err, &silent) } +// ExitCodeError wraps an error with the explicit process exit code it should +// produce, mirroring how *exec.ExitError already carries a proxied command's +// exit code. Used for the JSON envelope's exit-code conventions (0/1/2/3/4) — +// main.go checks for this type the same way it already checks for +// *exec.ExitError. +type ExitCodeError struct { + Err error + Code int +} + +func (e *ExitCodeError) Error() string { + return e.Err.Error() +} + +func (e *ExitCodeError) Unwrap() error { + return e.Err +} diff --git a/internal/output/events.go b/internal/output/events.go index 5259bdf3..f2daef2c 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -50,6 +50,10 @@ type ErrorEvent struct { Summary string Detail string Actions []ErrorAction + // Code classifies the error for JSON output. Empty means "not yet + // classified" — EnvelopeSink falls back to ErrInternal. PlainSink and + // TUISink ignore this field. + Code ErrorCode } type AuthEvent struct { @@ -135,6 +139,44 @@ type SnapshotShownEvent struct { Resources []SnapshotResourceLine } +// EmulatorStoppedEvent reports that a configured emulator was running and has +// been stopped. WasRunning is always true today (Stop returns an error before +// reaching this point otherwise) but is carried explicitly for JSON shape +// stability. DisplayName is precomputed by the caller (e.g. "LocalStack AWS +// Emulator"), matching InstanceInfoEvent.EmulatorName's existing precedent, so +// internal/output never needs to know how to derive it from Type. +type EmulatorStoppedEvent struct { + Type string + Name string + DisplayName string + WasRunning bool +} + +// EmulatorResetEvent reports that the named emulator's in-memory state was reset. +type EmulatorResetEvent struct { + Type string + Name string +} + +// UpdateCheckedEvent reports the result of an update check. It always fires +// once per Check call — DevBuild, then Available, discriminate which of the +// three possible outcomes (dev build skipped the check / already up to date / +// an update is available) the formatter should render. LatestVersion is empty +// when DevBuild is true (the check never ran). +type UpdateCheckedEvent struct { + CurrentVersion string + LatestVersion string + Available bool + DevBuild bool +} + +// UpdateAppliedEvent reports that an update was downloaded and installed. +type UpdateAppliedEvent struct { + CurrentVersion string + UpdatedVersion string + Method string +} + type AuthCompleteEvent struct{} // Event is a sealed marker — only event types in this package implement it, @@ -155,6 +197,10 @@ func (DeferredEvent) sealedEvent() {} func (SnapshotLoadedEvent) sealedEvent() {} func (PodSnapshotRemovedEvent) sealedEvent() {} func (SnapshotShownEvent) sealedEvent() {} +func (EmulatorStoppedEvent) sealedEvent() {} +func (EmulatorResetEvent) sealedEvent() {} +func (UpdateCheckedEvent) sealedEvent() {} +func (UpdateAppliedEvent) sealedEvent() {} func (ContainerStatusEvent) sealedEvent() {} func (ProgressEvent) sealedEvent() {} func (UserInputRequestEvent) sealedEvent() {} diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index 62d082a5..1c4c1e23 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -58,6 +58,14 @@ func FormatEventLine(event Event) (string, bool) { return formatSnapshotShown(e), true case AuthCompleteEvent: return "", false + case EmulatorStoppedEvent: + return formatEmulatorStopped(e), true + case EmulatorResetEvent: + return formatEmulatorReset(e), true + case UpdateCheckedEvent: + return formatUpdateChecked(e), true + case UpdateAppliedEvent: + return formatUpdateApplied(e), true default: return "", false } @@ -152,6 +160,29 @@ func formatMessageEvent(e MessageEvent) string { } } +func formatEmulatorStopped(e EmulatorStoppedEvent) string { + return SuccessMarker() + " " + fmt.Sprintf("%s stopped", e.DisplayName) +} + +func formatEmulatorReset(e EmulatorResetEvent) string { + return SuccessMarker() + " Emulator state reset" +} + +func formatUpdateChecked(e UpdateCheckedEvent) string { + switch { + case e.DevBuild: + return "> Note: Running a development build, skipping update check" + case !e.Available: + return fmt.Sprintf("> Note: Already up to date (%s)", e.CurrentVersion) + default: + return fmt.Sprintf("Update available: %s → %s", e.CurrentVersion, e.LatestVersion) + } +} + +func formatUpdateApplied(e UpdateAppliedEvent) string { + return SuccessMarker() + " " + fmt.Sprintf("Updated to %s", e.UpdatedVersion) +} + func formatErrorEvent(e ErrorEvent) string { var sb strings.Builder sb.WriteString("Error: ") diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index 4f1c9b62..85642987 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -181,6 +181,42 @@ func TestFormatEventLine(t *testing.T) { want: "", wantOK: false, }, + { + name: "emulator stopped event", + event: EmulatorStoppedEvent{Type: "aws", Name: "localstack-aws", DisplayName: "LocalStack AWS Emulator", WasRunning: true}, + want: SuccessMarker() + " LocalStack AWS Emulator stopped", + wantOK: true, + }, + { + name: "emulator reset event", + event: EmulatorResetEvent{Type: "aws", Name: "localstack-aws"}, + want: SuccessMarker() + " Emulator state reset", + wantOK: true, + }, + { + name: "update checked event dev build", + event: UpdateCheckedEvent{CurrentVersion: "dev", DevBuild: true}, + want: "> Note: Running a development build, skipping update check", + wantOK: true, + }, + { + name: "update checked event up to date", + event: UpdateCheckedEvent{CurrentVersion: "2.3.0", LatestVersion: "2.3.0", Available: false}, + want: "> Note: Already up to date (2.3.0)", + wantOK: true, + }, + { + name: "update checked event available", + event: UpdateCheckedEvent{CurrentVersion: "2.2.1", LatestVersion: "2.3.0", Available: true}, + want: "Update available: 2.2.1 → 2.3.0", + wantOK: true, + }, + { + name: "update applied event", + event: UpdateAppliedEvent{CurrentVersion: "2.2.1", UpdatedVersion: "2.3.0", Method: "homebrew"}, + want: SuccessMarker() + " Updated to 2.3.0", + wantOK: true, + }, { name: "pod snapshot saved full", event: PodSnapshotSavedEvent{ diff --git a/internal/reset/reset.go b/internal/reset/reset.go index 2ffde066..54b21e9d 100644 --- a/internal/reset/reset.go +++ b/internal/reset/reset.go @@ -34,6 +34,7 @@ func Reset(ctx context.Context, rt runtime.Runtime, containers []config.Containe {Label: "Start LocalStack:", Value: "lstk"}, {Label: "See help:", Value: "lstk -h"}, }, + Code: output.ErrEmulatorNotRunning, }) return output.NewSilentError(fmt.Errorf("LocalStack is not running")) } @@ -64,7 +65,7 @@ func Reset(ctx context.Context, rt runtime.Runtime, containers []config.Containe defer func() { sink.Emit(output.SpinnerStop()) if retErr == nil { - sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Emulator state reset"}) + sink.Emit(output.EmulatorResetEvent{Type: string(runningContainers[0].Type), Name: runningContainers[0].Name()}) } }() diff --git a/internal/reset/reset_test.go b/internal/reset/reset_test.go index a43372ca..13521da7 100644 --- a/internal/reset/reset_test.go +++ b/internal/reset/reset_test.go @@ -74,16 +74,14 @@ func TestReset_Success(t *testing.T) { } else { spinnerStopped = true } - case output.MessageEvent: - if ev.Severity == output.SeveritySuccess { - succeeded = true - assert.Contains(t, ev.Text, "reset") - } + case output.EmulatorResetEvent: + succeeded = true + assert.Equal(t, "aws", ev.Type) } } assert.True(t, spinnerStarted, "spinner should have started") assert.True(t, spinnerStopped, "spinner should have stopped") - assert.True(t, succeeded, "success event should have been emitted") + assert.True(t, succeeded, "EmulatorResetEvent should have been emitted") } func TestReset_EmulatorNotRunning(t *testing.T) { diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index 0bd08c20..6834b988 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -164,6 +164,7 @@ func (d *DockerRuntime) EmitUnhealthyError(sink output.Sink, err error) { Title: "Docker is not available", Summary: summary, Actions: actions, + Code: output.ErrRuntimeUnavailable, }) } diff --git a/internal/update/notify.go b/internal/update/notify.go index 8c8c8867..e7ffb031 100644 --- a/internal/update/notify.go +++ b/internal/update/notify.go @@ -12,10 +12,10 @@ import ( type versionFetcher func(ctx context.Context, token string) (string, error) type NotifyOptions struct { - GitHubToken string - UpdatePrompt bool - SkippedVersion string - PersistSkipVersion func(version string) error + GitHubToken string + UpdatePrompt bool + SkippedVersion string + PersistSkipVersion func(version string) error } const checkTimeout = 2 * time.Second @@ -95,7 +95,7 @@ func promptAndUpdate(ctx context.Context, sink output.Sink, opts NotifyOptions, switch resp.SelectedKey { case "u": - if err := applyUpdate(ctx, sink, latest, opts.GitHubToken); err != nil { + if _, err := applyUpdate(ctx, sink, latest, opts.GitHubToken); err != nil { sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("Update failed: %v", err)}) return false } diff --git a/internal/update/update.go b/internal/update/update.go index c1a90080..bc4eddf6 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -11,12 +11,15 @@ import ( "github.com/localstack/lstk/internal/version" ) -// Check reports whether a newer version is available. -// Returns the latest version string and true if an update is available. +// Check reports whether a newer version is available. Returns the latest +// version string and true if an update is available. Always emits exactly one +// UpdateCheckedEvent, whose DevBuild/Available fields tell the sink which of +// the three possible outcomes (dev build skipped / already up to date / an +// update is available) occurred. func Check(ctx context.Context, sink output.Sink, githubToken string) (string, bool, error) { current := version.Version() if current == "dev" { - sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Running a development build, skipping update check"}) + sink.Emit(output.UpdateCheckedEvent{CurrentVersion: current, DevBuild: true}) return "", false, nil } @@ -24,20 +27,19 @@ func Check(ctx context.Context, sink output.Sink, githubToken string) (string, b latest, err := fetchLatestVersion(ctx, githubToken) sink.Emit(output.SpinnerStop()) if err != nil { - return "", false, fmt.Errorf("failed to check for updates: %w", err) + wrapped := fmt.Errorf("failed to check for updates: %w", err) + sink.Emit(output.ErrorEvent{Title: wrapped.Error(), Code: output.ErrNetworkError}) + return "", false, output.NewSilentError(wrapped) } - if normalizeVersion(current) == normalizeVersion(latest) { - sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: fmt.Sprintf("Already up to date (%s)", current)}) - return latest, false, nil - } - - sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: fmt.Sprintf("Update available: %s → %s", current, latest)}) - return latest, true, nil + available := normalizeVersion(current) != normalizeVersion(latest) + sink.Emit(output.UpdateCheckedEvent{CurrentVersion: current, LatestVersion: latest, Available: available}) + return latest, available, nil } // Update checks for updates and applies the update if one is available. func Update(ctx context.Context, sink output.Sink, checkOnly bool, githubToken string) error { + current := version.Version() latest, available, err := Check(ctx, sink, githubToken) if err != nil { return err @@ -46,15 +48,19 @@ func Update(ctx context.Context, sink output.Sink, checkOnly bool, githubToken s return nil } - if err := applyUpdate(ctx, sink, latest, githubToken); err != nil { - return err + method, err := applyUpdate(ctx, sink, latest, githubToken) + if err != nil { + sink.Emit(output.ErrorEvent{Title: err.Error(), Code: output.ErrInternal}) + return output.NewSilentError(err) } - sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Updated to %s", latest)}) + sink.Emit(output.UpdateAppliedEvent{CurrentVersion: current, UpdatedVersion: latest, Method: method}) return nil } -func applyUpdate(ctx context.Context, sink output.Sink, latest, githubToken string) error { +// applyUpdate detects the current install method and performs the update, +// returning its canonical name ("homebrew"/"npm"/"binary") on success. +func applyUpdate(ctx context.Context, sink output.Sink, latest, githubToken string) (string, error) { info := DetectInstallMethod() var err error @@ -71,10 +77,10 @@ func applyUpdate(ctx context.Context, sink output.Sink, latest, githubToken stri sink.Emit(output.SpinnerStop()) } if err != nil { - return fmt.Errorf("update failed: %w", err) + return "", fmt.Errorf("update failed: %w", err) } - return nil + return info.Method.String(), nil } // logLineWriter adapts an output.Sink into an io.Writer, emitting each diff --git a/main.go b/main.go index 655da7b0..e151724d 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "syscall" "github.com/localstack/lstk/cmd" + "github.com/localstack/lstk/internal/output" ) func main() { @@ -16,10 +17,20 @@ func main() { defer cancel() if err := cmd.Execute(ctx); err != nil { + // A proxied tool (aws, terraform, cdk, sam, az, extensions) exited + // non-zero: propagate its exact code rather than collapsing to 1. var exitErr *exec.ExitError if errors.As(err, &exitErr) { os.Exit(exitErr.ExitCode()) } + // A JSON-capable command failed after rendering its error envelope to + // stdout: use the --json exit-code convention (3 CONFIRMATION_REQUIRED, + // 4 AUTH_REQUIRED, 1 otherwise) attached by wrapCommandsWithJSONEnvelope. + // errors.As unwraps through the SilentError wrapper to reach it. + var codeErr *output.ExitCodeError + if errors.As(err, &codeErr) { + os.Exit(codeErr.Code) + } os.Exit(1) } } diff --git a/openspec/changes/json-output-schema/design.md b/openspec/changes/json-output-schema/design.md index 544db837..f72e15a6 100644 --- a/openspec/changes/json-output-schema/design.md +++ b/openspec/changes/json-output-schema/design.md @@ -136,6 +136,18 @@ Two exceptions, following `gh`'s precedent of reserving specific exit codes for **Alternative considered**: reuse AWS's exact `Sender`/`Service` two-value classification instead of a boolean. Declined — lstk has no analogous client/server split (everything runs locally), and "is it worth automatically retrying" is the one question a script actually needs answered; a boolean says that directly without requiring the caller to know that, say, `Sender` means "don't retry" in lstk's context too. +### Decision: `error.category` is a second, additive grouping of `code` — not a rename of it + +28 codes is a lot for a caller that only wants coarse handling ("is this an environment problem, an auth problem, or a usage problem?") — today that caller has to build and maintain their own mapping from all 28 codes into their own buckets, since lstk doesn't hand them one. The fix is a `category` field: a fixed, ~7-value grouping (`RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`) that every code maps into, documented in the `error-codes` capability's table. Every one of the 28 codes maps to exactly one category — see that table for the full mapping. + +This is the same pattern as `retryable` immediately above: a static, per-code lookup, not something computed per failure instance — and checked against the same real constraint `retryable` already surfaced: category can't collapse `retryable` into itself, because retryability doesn't line up with category boundaries. `RUNTIME` alone contains both `RUNTIME_UNAVAILABLE`/`IMAGE_PULL_FAILED`/`NETWORK_ERROR` (retryable) and `DEPENDENCY_MISSING`/`DNS_RESOLUTION_REQUIRED` (not retryable). `category` and `retryable` answer two genuinely different questions and both have to stay anchored to the specific code. + +**Alternative considered, and this is the one worth spelling out**: the literal ask that prompted this decision was to rename `code` to mean the category (~7 values) and introduce a new `subcode` for what `code` means today (~28 values) — mirroring Google's API error model (`google.rpc.Code`, coarse, ~17 values) plus `ErrorInfo.reason` (fine-grained, service-specific) inside `details`. Checked against a second real precedent first: Stripe's error object does the opposite — `type` is coarse (~9 values, mostly routes to which docs page to read) while `code` is Stripe's *fine-grained*, primary identifier (~180 values), with a third tier (`decline_code`) finer still for card errors specifically. Both conventions are real and shipped; there's no external authority settling which word should carry which cardinality. + +What settled it was lstk's own already-established design, not outside convention: the exit-code table already promotes two *specific* codes (`CONFIRMATION_REQUIRED`, `AUTH_REQUIRED`) straight to their own exit codes, and `retryable` is already a coarse signal *derived from* the specific code — both of these already treat the specific code as primary, matching Stripe's shape more than Google's. Renaming `code` to mean the category would also be a uniquely dangerous kind of break: the field keeps the same name and the same JSON type (string), so a script checking `schemaVersion` wouldn't catch it, and neither would a script merely checking `typeof error.code === "string"` — the value domain silently shrinks from 28 possibilities to 7 with no structural signal that anything changed. An additive `category` field needs no `schemaVersion` bump at all, matching the rule the envelope already documents ("additive fields never require a bump"), and requires zero changes to the exit-code table, to `retryable`'s definition, or to any `error.code: "X"` scenario already written elsewhere in this document and its sibling specs. + +`category` (not `subcode`, not `type`) was chosen as the field name specifically to avoid implying the hierarchy relationship the rejected alternative would have had — `code` isn't category's "super-code," they're two independent, orthogonal descriptions of the same failure, exactly like `retryable` already is a third. + ### Decision: `RUNTIME_UNAVAILABLE`, not `DOCKER_UNAVAILABLE` — the code names the abstraction, not the concrete implementation An earlier draft of this proposal used `DOCKER_UNAVAILABLE`, following the concrete type actually implemented today (`runtime.NewDockerRuntime`, used at every call site in `cmd/`). That's the wrong level to name a stable, machine-readable contract at: `internal/runtime.Runtime` is already an interface (`IsHealthy`, `EmitUnhealthyError`, etc.) with exactly one implementation, and CLAUDE.md's own architecture section describes it as "Abstraction for container runtimes (Docker, Kubernetes, etc.) — currently only Docker implemented." Baking "Docker" into the error taxonomy would mean either a breaking rename the day a second runtime lands (Podman, Rancher Desktop, and Finch are Docker-API-compatible and might work with zero runtime-layer changes; Kubernetes would need a distinct `Runtime` implementation), or shipping a permanently inaccurate code once one did. `RUNTIME_UNAVAILABLE` matches the interface name that already exists in the codebase, not a new abstraction invented for this proposal. @@ -191,7 +203,7 @@ Codes: `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED`, `LICENSE_INVALID`, `LICENSE_UNSUP ```json {"emulators": [{"type": "aws", "name": "localstack-aws", "wasRunning": true}]} ``` -Codes: `RUNTIME_UNAVAILABLE`. +Codes: `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING` (found during implementation: `stop` fails fast on the first configured emulator not running, matching existing plain-text behavior, so this code was missing from the original catalog entry). **`lstk restart`** — `data`: the stop result and the start result, reusing both shapes above. ```json diff --git a/openspec/changes/json-output-schema/proposal.md b/openspec/changes/json-output-schema/proposal.md index 2d224ebf..92177666 100644 --- a/openspec/changes/json-output-schema/proposal.md +++ b/openspec/changes/json-output-schema/proposal.md @@ -7,6 +7,7 @@ - Define a common JSON envelope (`schemaVersion`, `command`, `status`, `data`, `warnings`, `error`) that every JSON-capable command emits as exactly one object to stdout (NDJSON for the one genuinely streaming command, `logs --follow`). - Define a stable, enumerated `error.code` vocabulary (e.g. `EMULATOR_NOT_RUNNING`, `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED`, `CONFIRMATION_REQUIRED`) so scripts branch on a fixed string, never on parsed prose. Full list and per-command mapping in design.md. - Every error object also carries a `retryable` boolean, a static per-code classification (mirroring AWS CLI's `Sender`/`Service` split) so a polling script can distinguish "back off and retry" (`RUNTIME_UNAVAILABLE`, `NETWORK_ERROR`) from "fix the invocation" (`VALIDATION_ERROR`, `CONFIRMATION_REQUIRED`) without hardcoding its own list. +- Every error object also carries a `category` string, a second static per-code classification: a fixed ~7-value grouping (`RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`) of the full 28-code `error.code` vocabulary, additive alongside `code` rather than a rename of it — `code` keeps its existing meaning, so a caller wanting coarse handling doesn't need to build its own mapping from all 28 codes, and a caller already keying off specific codes (the exit-code reservations, any existing integration) is unaffected. - Exit codes stay coarse (`0` ok, `1` error, `2` unattributed usage error) with two reservations for the categories a script is most likely to branch on before even parsing stdout, matching GitHub CLI's convention: `3` for `CONFIRMATION_REQUIRED`, `4` for `AUTH_REQUIRED`. - Guarantee that a JSON-capable command **never** leaks a bare, unstructured error to stderr: every failure path — including Cobra usage errors and previously-uncategorized `fmt.Errorf` returns — is caught at the command boundary and re-rendered as the same envelope with `error.code = INTERNAL_ERROR` as the last-resort fallback. - Opt every command in individually, reusing the existing `jsonSupportedAnnotation` gate from `add-json-flag`. This proposal covers the full built-in command surface (`start`, `stop`, `restart`, `status`, `logs`, `config path`, `volume path`/`clear`, `setup aws`/`azure`, `snapshot save`/`load`/`list`/`show`/`remove`, `reset`, `logout`, `az start-interception`/`stop-interception`, `update`). `login` and the deprecated `config profile` stay JSON-incapable by design — both are inherently interactive (browser-based OAuth, TTY prompts) with no meaningful non-interactive path today. The `-v`/`--version` flag also stays JSON-incapable permanently, by choice rather than gap: Cobra handles it before `RunE` ever runs, so making it participate would mean dropping Cobra's built-in version mechanism and handling it manually inside `RunE` — which would newly couple `--version` to config-file loading (today it works even with a broken config, matching `git --version`/`docker --version`), a regression not worth a cosmetic JSON version output. No new command is added to work around this. @@ -18,7 +19,7 @@ ### New Capabilities - `output-envelope`: the common envelope structure, exit-code conventions (including the two reserved codes for `CONFIRMATION_REQUIRED`/`AUTH_REQUIRED`), and the guarantee that a JSON-capable command always emits exactly one well-formed JSON result (or one NDJSON stream) to stdout, including on unclassified errors and Cobra usage errors. Named without a `json-` prefix deliberately — the envelope shape doesn't depend on JSON specifically (see design.md's naming decisions), even though this change only implements a JSON serialization of it. -- `error-codes`: the enumerated, stable `error.code` vocabulary, the `retryable` flag carried on every error object, and the requirement that every JSON error uses one of these codes instead of free text. Same naming rationale as `output-envelope` — the codes themselves are serialization-agnostic strings. +- `error-codes`: the enumerated, stable `error.code` vocabulary, the `retryable` flag and additive `category` grouping carried on every error object, and the requirement that every JSON error uses one of these codes instead of free text. Same naming rationale as `output-envelope` — the codes themselves are serialization-agnostic strings. - `json-command-output`: the per-command opt-in and documented `data` payload for each JSON-capable command, and the `status` behavior change described above. Keeps the `json-` prefix because it's genuinely about the `--json` flag as it exists today, not about a format-agnostic mechanism. ### Modified Capabilities @@ -26,7 +27,7 @@ ## Impact -- `internal/output`: new envelope/error-code types (`Envelope`, `EnvelopeError`, `EnvelopeAction`, `Warning`, `ErrorCode`) and an `EnvelopeSink` (`Sink` implementation) that translates the existing event vocabulary (`InstanceInfoEvent`, `TableEvent`, `ResourceSummaryEvent`, `ErrorEvent`, etc.) into the envelope, instead of formatting lines; `ErrorEvent` gains a `Code` field (additive). Type and constructor names deliberately avoid a `JSON`-specific name (`EnvelopeSink`, not `JSONSink`) — see design.md's naming decisions. +- `internal/output`: new envelope/error-code types (`Envelope`, `EnvelopeError`, `EnvelopeAction`, `Warning`, `ErrorCode`, `ErrorCategory`) and an `EnvelopeSink` (`Sink` implementation) that translates the existing event vocabulary (`InstanceInfoEvent`, `TableEvent`, `ResourceSummaryEvent`, `ErrorEvent`, etc.) into the envelope, instead of formatting lines; `ErrorEvent` gains a `Code` field (additive). Type and constructor names deliberately avoid a `JSON`-specific name (`EnvelopeSink`, not `JSONSink`) — see design.md's naming decisions. - `cmd/*.go`: every command listed above gains the `jsonSupportedAnnotation` and a branch (alongside its existing `isInteractiveMode(cfg)` / plain-sink split) that selects `output.NewEnvelopeSink(w, command, output.FormatJSON)` when `cfg.JSON` is set. - `cmd/root.go`: `requireJSONSupport` renders its rejection through the envelope when `cfg.JSON` is true; a new command-boundary wrapper guarantees a fallback envelope is emitted for any error that reaches it unclassified, including Cobra-level usage errors where `--json` was already successfully parsed. - Call sites that currently emit `ErrorEvent` or return a bare `fmt.Errorf` inside a JSON-capable command's path gain an explicit `error.code` classification (cataloged per command in design.md). diff --git a/openspec/changes/json-output-schema/specs/error-codes/spec.md b/openspec/changes/json-output-schema/specs/error-codes/spec.md index c14ab39d..00297682 100644 --- a/openspec/changes/json-output-schema/specs/error-codes/spec.md +++ b/openspec/changes/json-output-schema/specs/error-codes/spec.md @@ -3,36 +3,36 @@ ### Requirement: Enumerated, stable error codes Every `error.code` value emitted in a JSON envelope SHALL be one of a fixed, documented set of `SCREAMING_SNAKE_CASE` string constants. lstk SHALL NOT emit a free-text or ad hoc string as `error.code`; a code not yet covering some failure mode SHALL fall back to `INTERNAL_ERROR` rather than inventing an undocumented one at the call site. The full set, as of this change: -| Code | Meaning | Retryable | -|---|---|---| -| `RUNTIME_UNAVAILABLE` | The container runtime (`internal/runtime.Runtime` — Docker today; Podman, Rancher Desktop, Finch, or Kubernetes are architecturally anticipated but not yet implemented) is unreachable or unhealthy | Yes | -| `IMAGE_PULL_FAILED` | Pulling the emulator image failed and no usable local image exists | Yes | -| `EMULATOR_NOT_RUNNING` | The targeted emulator is not currently running | No | -| `EMULATOR_ALREADY_RUNNING` | An emulator is already running where the command expected it not to be | No | -| `EMULATOR_WRONG_TYPE` | The command requires a specific emulator type but a different one is configured/running | No | -| `EMULATOR_NOT_CONFIGURED` | No container of the requested type exists in the resolved config | No | -| `EMULATOR_START_FAILED` | The emulator failed to reach a healthy state after starting | Yes | -| `AUTH_REQUIRED` | The operation needs a LocalStack auth token and none is available | No | -| `AUTH_LOGIN_FAILED` | An authentication flow failed | Yes | -| `CREDENTIALS_MISSING` | Required third-party credentials (e.g. AWS credentials for an S3 remote) could not be resolved | No | -| `LICENSE_INVALID` | The platform rejected the configured license/token | No | -| `LICENSE_UNSUPPORTED_TAG` | The configured image tag is not covered by the license | No | -| `SNAPSHOT_NOT_FOUND` | The referenced snapshot does not exist | No | -| `SNAPSHOT_INVALID_REF` | The snapshot reference could not be parsed | No | -| `SNAPSHOT_REMOTE_ERROR` | A platform or S3 remote call failed | Yes | -| `SNAPSHOT_BUCKET_NOT_FOUND` | The pre-flight S3 bucket-existence check failed | No | -| `CONFIG_INVALID` | The config file failed to parse or validate | No | -| `CONFIG_NOT_FOUND` | An explicit config path does not exist | No | -| `INTEGRATION_NOT_SET_UP` | A required one-time setup step (e.g. `lstk setup azure`) has not been run | No | -| `DEPENDENCY_MISSING` | A required external CLI (e.g. `az`) is not on `PATH` | No | -| `DNS_RESOLUTION_REQUIRED` | A required hostname pattern does not resolve | No | -| `CONFIRMATION_REQUIRED` | A destructive action needs `--force` outside an interactive terminal | No | -| `VALIDATION_ERROR` | A semantically invalid combination of flags/arguments was given | No | -| `USAGE_ERROR` | Cobra-level flag or argument parsing failed | No | -| `NOT_JSON_CAPABLE` | The requested command has not been annotated as JSON-capable | No | -| `NETWORK_ERROR` | An unclassified network/transport failure occurred | Yes | -| `CANCELLED` | The operation was interrupted (e.g. context cancellation via Ctrl+C) | Yes | -| `INTERNAL_ERROR` | Unclassified or unexpected failure; the universal fallback | No | +| Code | Meaning | Retryable | Category | +|---|---|---|---| +| `RUNTIME_UNAVAILABLE` | The container runtime (`internal/runtime.Runtime` — Docker today; Podman, Rancher Desktop, Finch, or Kubernetes are architecturally anticipated but not yet implemented) is unreachable or unhealthy | Yes | `RUNTIME` | +| `IMAGE_PULL_FAILED` | Pulling the emulator image failed and no usable local image exists | Yes | `RUNTIME` | +| `EMULATOR_NOT_RUNNING` | The targeted emulator is not currently running | No | `EMULATOR` | +| `EMULATOR_ALREADY_RUNNING` | An emulator is already running where the command expected it not to be | No | `EMULATOR` | +| `EMULATOR_WRONG_TYPE` | The command requires a specific emulator type but a different one is configured/running | No | `EMULATOR` | +| `EMULATOR_NOT_CONFIGURED` | No container of the requested type exists in the resolved config | No | `EMULATOR` | +| `EMULATOR_START_FAILED` | The emulator failed to reach a healthy state after starting | Yes | `EMULATOR` | +| `AUTH_REQUIRED` | The operation needs a LocalStack auth token and none is available | No | `AUTH` | +| `AUTH_LOGIN_FAILED` | An authentication flow failed | Yes | `AUTH` | +| `CREDENTIALS_MISSING` | Required third-party credentials (e.g. AWS credentials for an S3 remote) could not be resolved | No | `AUTH` | +| `LICENSE_INVALID` | The platform rejected the configured license/token | No | `AUTH` | +| `LICENSE_UNSUPPORTED_TAG` | The configured image tag is not covered by the license | No | `AUTH` | +| `SNAPSHOT_NOT_FOUND` | The referenced snapshot does not exist | No | `RESOURCE` | +| `SNAPSHOT_INVALID_REF` | The snapshot reference could not be parsed | No | `RESOURCE` | +| `SNAPSHOT_REMOTE_ERROR` | A platform or S3 remote call failed | Yes | `RESOURCE` | +| `SNAPSHOT_BUCKET_NOT_FOUND` | The pre-flight S3 bucket-existence check failed | No | `RESOURCE` | +| `CONFIG_INVALID` | The config file failed to parse or validate | No | `CONFIG` | +| `CONFIG_NOT_FOUND` | An explicit config path does not exist | No | `CONFIG` | +| `INTEGRATION_NOT_SET_UP` | A required one-time setup step (e.g. `lstk setup azure`) has not been run | No | `CONFIG` | +| `DEPENDENCY_MISSING` | A required external CLI (e.g. `az`) is not on `PATH` | No | `RUNTIME` | +| `DNS_RESOLUTION_REQUIRED` | A required hostname pattern does not resolve | No | `RUNTIME` | +| `CONFIRMATION_REQUIRED` | A destructive action needs `--force` outside an interactive terminal | No | `USAGE` | +| `VALIDATION_ERROR` | A semantically invalid combination of flags/arguments was given | No | `USAGE` | +| `USAGE_ERROR` | Cobra-level flag or argument parsing failed | No | `USAGE` | +| `NOT_JSON_CAPABLE` | The requested command has not been annotated as JSON-capable | No | `USAGE` | +| `NETWORK_ERROR` | An unclassified network/transport failure occurred | Yes | `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` | #### Scenario: Error code is one of the documented constants - **WHEN** any JSON-capable command emits an `error` object @@ -64,6 +64,21 @@ Every `error` object SHALL include a `retryable` boolean, a static property of ` - **WHEN** the same `error.code` is emitted by two different commands - **THEN** both error objects report the same `retryable` value for that code +### Requirement: Error objects declare a coarse category, additive alongside code +Every `error` object SHALL include a `category` string, a static property of `code` (the same code always carries the same category, per the table above) drawn from a fixed, small set: `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`. `category` is additive: it exists so a caller that only wants to distinguish broad kinds of failure can switch on roughly 7 values instead of the full code list, without requiring `code` to change meaning or cardinality — `code` remains the primary, stable identifier for anything more specific, and every existing rule keyed on `code` (the exit-code reservations, `retryable`, every scenario elsewhere in this document) is unaffected by `category`'s presence. + +#### Scenario: Category is one of the documented constants +- **WHEN** any JSON-capable command emits an `error` object +- **THEN** `error.category` is exactly one of `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, or `INTERNAL` + +#### Scenario: Category is consistent for a given code +- **WHEN** the same `error.code` is emitted by two different commands +- **THEN** both error objects report the same `category` value for that code + +#### Scenario: An unclassified failure's category matches its fallback code +- **WHEN** a JSON-capable command hits a failure that has not been mapped to a specific code (so `error.code` is `"INTERNAL_ERROR"`) +- **THEN** `error.category` is `"INTERNAL"` + ### Requirement: Error actions are machine-usable When an error has a suggested remediation (mirroring the plain-text `ErrorAction` used in interactive/plain rendering today), the JSON error object's `actions` array SHALL contain objects with a stable `id` slug and a literal `command` string, rather than a pre-formatted display line. diff --git a/openspec/changes/json-output-schema/tasks.md b/openspec/changes/json-output-schema/tasks.md index c282f788..40f1ba4d 100644 --- a/openspec/changes/json-output-schema/tasks.md +++ b/openspec/changes/json-output-schema/tasks.md @@ -1,30 +1,32 @@ ## 1. Envelope and error-code infrastructure -- [ ] 1.1 Add `internal/output` envelope types: `Envelope`, `Warning`, `EnvelopeError` (including `Retryable bool`), `EnvelopeAction`, and the `ErrorCode` string enum from the `error-codes` spec. Names avoid a `JSON`-specific prefix on purpose (see design.md's naming decisions) — the envelope shape is designed to outlive JSON as the only serialization. -- [ ] 1.1a Add a single source-of-truth table mapping each `ErrorCode` to its static `Retryable` value (per the `error-codes` spec table), consulted wherever an `EnvelopeError` is constructed so `Retryable` is never set ad hoc per call site. -- [ ] 1.2 Add `Code ErrorCode` field to `output.ErrorEvent` (additive; `PlainSink`/`TUISink` ignore it). -- [ ] 1.3 Implement `output.EnvelopeSink` (`Sink`), constructed with an `output.Format` (only `output.FormatJSON` exists today): type-switches on each event, accumulates data-bearing events into `Envelope.Data`, routes `ErrorEvent` into `Envelope.Error` (defaulting `Code` to `INTERNAL_ERROR` when unset), routes `MessageEvent{Severity: SeverityWarning}` into `Envelope.Warnings`, and drops purely presentational events (`SpinnerEvent`, `ContainerStatusEvent`, `ProgressEvent`, `DeferredEvent`'s inner transient events). -- [ ] 1.4 Add `(*EnvelopeSink).Result() Envelope` plus a helper that marshals per the sink's `Format` and writes it once to stdout as compact JSON (the only format implemented now). -- [ ] 1.5 Add `internal/output` NDJSON stream writer (`type: "log"`/`"error"` lines) for the `logs --follow` variant. -- [ ] 1.6 Unit tests: `EnvelopeSink` event-routing table (one test per event type: accumulated into data / routed to warnings / routed to error / dropped). +- [x] 1.1 Add `internal/output` envelope types: `Envelope`, `Warning`, `EnvelopeError` (including `Retryable bool`), `EnvelopeAction`, and the `ErrorCode` string enum from the `error-codes` spec. Names avoid a `JSON`-specific prefix on purpose (see design.md's naming decisions) — the envelope shape is designed to outlive JSON as the only serialization. +- [x] 1.1a Add a single source-of-truth table mapping each `ErrorCode` to its static `Retryable` value (per the `error-codes` spec table), consulted wherever an `EnvelopeError` is constructed so `Retryable` is never set ad hoc per call site. +- [x] 1.2 Add `Code ErrorCode` field to `output.ErrorEvent` (additive; `PlainSink`/`TUISink` ignore it). +- [x] 1.3 Implement `output.EnvelopeSink` (`Sink`), constructed with an `output.Format` (only `output.FormatJSON` exists today): type-switches on each event, accumulates data-bearing events into `Envelope.Data`, routes `ErrorEvent` into `Envelope.Error` (defaulting `Code` to `INTERNAL_ERROR` when unset), routes `MessageEvent{Severity: SeverityWarning}` into `Envelope.Warnings`, and drops purely presentational events (`SpinnerEvent`, `ContainerStatusEvent`, `ProgressEvent`, `DeferredEvent`'s inner transient events). +- [x] 1.4 Add `(*EnvelopeSink).Result() Envelope` plus a helper that marshals per the sink's `Format` and writes it once to stdout as compact JSON (the only format implemented now). +- [x] 1.5 Add `internal/output` NDJSON stream writer (`type: "log"`/`"error"` lines) for the `logs --follow` variant. +- [x] 1.6 Unit tests: `EnvelopeSink` event-routing table (one test per event type: accumulated into data / routed to warnings / routed to error / dropped). ## 2. Command-boundary wiring -- [ ] 2.1 `cmd/root.go`: change `requireJSONSupport`'s rejection to render as a JSON envelope (`error.code: NOT_JSON_CAPABLE`) on stdout when `cfg.JSON` is true, matching the `json-flag` delta spec; keep the existing plain-text path for the non-`--json` case (unreachable today, kept for defensiveness). -- [ ] 2.2 `cmd/root.go`: add a JSON error boundary wrapper (parallel to `requireJSONSupport`) that guarantees any error returned from a JSON-capable command's `RunE` — including ones without a specific `ErrorCode` — is rendered as a valid envelope with `error.code: INTERNAL_ERROR` as the fallback, never a bare `Error: %v` line. -- [ ] 2.3 `cmd/root.go` / `Execute()`: catch Cobra-level usage errors and render them as `error.code: USAGE_ERROR` envelopes when `--json` was already parsed; otherwise preserve today's plain-text usage error and exit `2`. -- [ ] 2.4 Wire exit-code conventions (`0` ok, `1` generic command-level error, `2` unattributed usage error, `3` for `CONFIRMATION_REQUIRED`, `4` for `AUTH_REQUIRED`) at the single point `Execute()` already computes the process exit code. -- [ ] 2.5 Integration tests: `NOT_JSON_CAPABLE` rejection renders as JSON when `--json` is set; an unclassified error still produces a valid envelope; a usage error after `--json` renders as JSON, one before it does not; exit codes `3`/`4` fire for `CONFIRMATION_REQUIRED`/`AUTH_REQUIRED` respectively and `1` for every other error code. +- [x] 2.1 `cmd/root.go`: change `requireJSONSupport`'s rejection to render as a JSON envelope (`error.code: NOT_JSON_CAPABLE`) on stdout when `cfg.JSON` is true, matching the `json-flag` delta spec; keep the existing plain-text path for the non-`--json` case (unreachable today, kept for defensiveness). +- [x] 2.2 `cmd/root.go`: add a JSON error boundary wrapper (parallel to `requireJSONSupport`) that guarantees any error returned from a JSON-capable command's `RunE` — including ones without a specific `ErrorCode` — is rendered as a valid envelope with `error.code: INTERNAL_ERROR` as the fallback, never a bare `Error: %v` line. +- [x] 2.3 `cmd/root.go` / `Execute()`: catch Cobra-level usage errors and render them as `error.code: USAGE_ERROR` envelopes when `--json` was already parsed; otherwise preserve today's plain-text usage error and exit `2`. +- [x] 2.4 Wire exit-code conventions (`0` ok, `1` generic command-level error, `2` unattributed usage error, `3` for `CONFIRMATION_REQUIRED`, `4` for `AUTH_REQUIRED`) at the single point `Execute()` already computes the process exit code. +- [x] 2.5 Integration tests: `NOT_JSON_CAPABLE` rejection renders as JSON when `--json` is set; an unclassified error still produces a valid envelope; a usage error after `--json` renders as JSON, one before it does not; exit codes `3`/`4` fire for `CONFIRMATION_REQUIRED`/`AUTH_REQUIRED` respectively and `1` for every other error code. ## 3. Pilot wave — stop, reset, update Implemented first, ahead of every other command: each depends only on the shared infrastructure in sections 1-2, not on any other command's JSON support, and together they exercise the envelope's main branch points (a data-bearing success, a `CONFIRMATION_REQUIRED`/exit-`3` failure, and a `retryable: true` failure) before the remaining waves build on the same plumbing. -- [ ] 3.1 `stop`: add annotation, `{"emulators": [...]}` data shape, `RUNTIME_UNAVAILABLE` code. -- [ ] 3.2 `reset`: add annotation, `{"emulator": {...}, "reset": true}` data shape, `EMULATOR_NOT_CONFIGURED`/`EMULATOR_NOT_RUNNING`/`CONFIRMATION_REQUIRED`/`RUNTIME_UNAVAILABLE` codes. -- [ ] 3.3 `update`: add annotation, `{"currentVersion", "latestVersion"/"updatedVersion", "updateAvailable"/"updated", "method"}` data shape (both `--check` and applied-update variants), `NETWORK_ERROR` code. -- [ ] 3.4 Integration tests for all three: success payloads, every documented error code, and exit codes `0`/`1`/`3` (this trio has no `AUTH_REQUIRED`/`2` case). -- [ ] 3.5 Before continuing to section 4, sanity-check the section 1-2 infrastructure against these three real implementations — this pilot is its first real exercise, and is the cheapest point to revise the `EnvelopeSink`/error-boundary design if something doesn't fit in practice. +- [x] 3.1 `stop`: add annotation, `{"emulators": [...]}` data shape, `RUNTIME_UNAVAILABLE` code. +- [x] 3.2 `reset`: add annotation, `{"emulator": {...}, "reset": true}` data shape, `EMULATOR_NOT_CONFIGURED`/`EMULATOR_NOT_RUNNING`/`CONFIRMATION_REQUIRED`/`RUNTIME_UNAVAILABLE` codes. +- [x] 3.3 `update`: add annotation, `{"currentVersion", "latestVersion"/"updatedVersion", "updateAvailable"/"updated", "method"}` data shape (both `--check` and applied-update variants), `NETWORK_ERROR` code. +- [x] 3.4 Integration tests for all three: success payloads, every documented error code, and exit codes `0`/`1`/`3` (this trio has no `AUTH_REQUIRED`/`2` case). +- [x] 3.5 Before continuing to section 4, sanity-check the section 1-2 infrastructure against these three real implementations — this pilot is its first real exercise, and is the cheapest point to revise the `EnvelopeSink`/error-boundary design if something doesn't fit in practice. +- [x] 3.6 Fix found by 3.5's sanity check: `EmulatorStoppedEvent`/`EmulatorResetEvent`/`UpdateCheckedEvent`/`UpdateAppliedEvent` were each emitted alongside a redundant `MessageEvent` carrying the same fact, with `plain_format.go` returning `("", false)` for them — dead code next to the thing actually rendering the line. Every other domain-result event in the codebase (`PodSnapshotSavedEvent`, `InstanceInfoEvent`, `SnapshotLoadedEvent`, ...) is single-emission with a real formatter; these four should match that pattern instead. Add `DisplayName` to `EmulatorStoppedEvent` (precomputed by the caller, matching `InstanceInfoEvent.EmulatorName`'s existing precedent), delete the four now-redundant `MessageEvent` emissions, write real formatters in `plain_format.go` reproducing today's exact text, and restructure `update.Check`/`Update` so `UpdateCheckedEvent` always fires (replacing its three existing `MessageEvent` branches: dev-build / already-up-to-date / update-available) rather than only in the non-apply branch. Because that widens `UpdateCheckedEvent` to also fire on the apply path, `EnvelopeSink`'s `UpdateAppliedEvent` case must explicitly clear the `latestVersion`/`updateAvailable` keys `UpdateCheckedEvent` may have set, so the JSON `data` shape for an applied update stays exactly `{currentVersion, updatedVersion, updated, method}` with no stale keys from the preceding check. +- [x] 3.7 Add `error.category`: a coarse, ~7-value grouping of the 28 `error.code` values (`RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`), additive alongside `code` rather than replacing it — `code` keeps its existing meaning and every existing exit-code/spec/doc reference to it is unchanged, matching the "additive fields never require a schemaVersion bump" rule. Implemented exactly like `retryable`: a static `map[ErrorCode]ErrorCategory` in `internal/output/error_code.go` plus a `.Category()` method, set alongside `.Retryable()` everywhere an `EnvelopeError` is constructed (`envelope_sink.go`'s `setError`/`Result` fallback, and `cmd/root.go`'s two hand-built envelopes for `NOT_JSON_CAPABLE`/`USAGE_ERROR`). A completeness test (`TestErrorCode_EveryCodeHasACategory`) guards this the way `retryableCodes` doesn't need to — a code missing from the category map has no sensible zero-value default, unlike `retryable`'s false default. ## 4. Read-only / low-risk commands @@ -66,7 +68,7 @@ Implemented first, ahead of every other command: each depends only on the shared ## 9. Docs and cross-cutting verification -- [ ] 9.1 Write `docs/structured-output.md`, the single developer-facing reference for the envelope contract, for engineers to review directly. Named without a `json-` prefix since the envelope/error-code shape it documents is designed to extend to other output formats later, even though JSON is the only one implemented now (state this explicitly in the doc's opening paragraph). Sections: (1) the envelope's fixed fields and an annotated success/error example; (2) the full `error.code` table with each code's `retryable` value; (3) the exit-code table (`0`/`1`/`2`/`3`/`4`); (4) the NDJSON streaming variant used by `logs --follow`; (5) the **entire Command Catalog from design.md, reproduced in full** — every JSON-capable command, its complete `data` shape and example JSON, and its error codes, not a condensed index; (6) a short "adding JSON support to a command" walkthrough for lstk contributors — the `jsonSupportedAnnotation` opt-in, routing through `output.EnvelopeSink`, and where to classify a new `ErrorEvent.Code`. No target line count — reproducing the full catalog verbatim is expected to push this well past a typical reference doc's length, and that's the intent: one document engineers can review and comment on end to end, rather than a summary that sends them back to design.md (an ephemeral change artifact that won't exist after this change is archived) for the actual detail. Source of truth for content: this change's design.md (Prior Art, Decisions, Command Catalog) and the `output-envelope`/`error-codes`/`json-command-output` specs. -- [ ] 9.2 Update `lstk docs`-generated command reference and CLAUDE.md's "Output Routing and Events" section to link to `docs/structured-output.md` instead of duplicating its content. +- [x] 9.1 Write `docs/structured-output.md`, the single developer-facing reference for the envelope contract, for engineers to review directly. Named without a `json-` prefix since the envelope/error-code shape it documents is designed to extend to other output formats later, even though JSON is the only one implemented now (state this explicitly in the doc's opening paragraph). Sections: (1) the envelope's fixed fields and an annotated success/error example; (2) the full `error.code` table with each code's `retryable` value; (3) the exit-code table (`0`/`1`/`2`/`3`/`4`); (4) the NDJSON streaming variant used by `logs --follow`; (5) the **entire Command Catalog from design.md, reproduced in full** — every JSON-capable command, its complete `data` shape and example JSON, and its error codes, not a condensed index; (6) a short "adding JSON support to a command" walkthrough for lstk contributors — the `jsonSupportedAnnotation` opt-in, routing through `output.EnvelopeSink`, and where to classify a new `ErrorEvent.Code`. No target line count — reproducing the full catalog verbatim is expected to push this well past a typical reference doc's length, and that's the intent: one document engineers can review and comment on end to end, rather than a summary that sends them back to design.md (an ephemeral change artifact that won't exist after this change is archived) for the actual detail. Source of truth for content: this change's design.md (Prior Art, Decisions, Command Catalog) and the `output-envelope`/`error-codes`/`json-command-output` specs. +- [x] 9.2 Update `lstk docs`-generated command reference and CLAUDE.md's "Output Routing and Events" section to link to `docs/structured-output.md` instead of duplicating its content. - [ ] 9.3 Add a lint/test that walks the Cobra command tree and asserts every command carrying `jsonSupportedAnnotation` has at least one integration test exercising `--json`. - [ ] 9.4 Run `make test` and `make test-integration` for the full command surface touched across sections 3-8. diff --git a/test/integration/json_envelope_test.go b/test/integration/json_envelope_test.go new file mode 100644 index 00000000..a322239b --- /dev/null +++ b/test/integration/json_envelope_test.go @@ -0,0 +1,92 @@ +package integration_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// jsonEnvelope mirrors the shape documented in output-envelope/spec.md and +// design.md's Command Catalog, decoded loosely (Data stays raw so each test +// unmarshals it into its own command-specific shape). +type jsonEnvelope struct { + SchemaVersion int `json:"schemaVersion"` + Command string `json:"command"` + Status string `json:"status"` + Data json.RawMessage `json:"data"` + Warnings []jsonWarning `json:"warnings"` + Error *jsonError `json:"error"` +} + +type jsonWarning struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type jsonError struct { + Code string `json:"code"` + Category string `json:"category"` + Message string `json:"message"` + Retryable bool `json:"retryable"` +} + +// decodeEnvelope requires stdout to be exactly one well-formed JSON object, +// per the "never emits unstructured output" guarantee in output-envelope/spec.md. +func decodeEnvelope(t *testing.T, stdout string) jsonEnvelope { + t.Helper() + var envelope jsonEnvelope + require.NoError(t, json.Unmarshal([]byte(stdout), &envelope), "stdout should be exactly one JSON object: %s", stdout) + require.NotNil(t, envelope.Warnings, "warnings should always be an array, never omitted/null") + return envelope +} + +func TestNotJSONCapableCommandRendersEnvelope(t *testing.T) { + t.Parallel() + ctx := testContext(t) + + // login is deliberately never annotated as JSON-capable (see design.md's + // Decisions and Non-Goals) — it's the simplest command to exercise the + // json-flag capability's NOT_JSON_CAPABLE rejection without touching Docker. + stdout, _, err := runLstk(t, ctx, t.TempDir(), testEnvWithHome(t.TempDir(), ""), "login", "--json") + requireExitCode(t, 1, err) + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "error", envelope.Status) + assert.Equal(t, "login", envelope.Command) + require.NotNil(t, envelope.Error) + assert.Equal(t, "NOT_JSON_CAPABLE", envelope.Error.Code) + assert.Equal(t, "USAGE", envelope.Error.Category) +} + +func TestUsageErrorAfterJSONRendersEnvelope(t *testing.T) { + t.Parallel() + ctx := testContext(t) + + // --json precedes the unknown flag, so pflag has already bound cfg.JSON to + // true by the time it fails on --bogus-flag; SetFlagErrorFunc should render + // this as a USAGE_ERROR envelope rather than Cobra's plain-text usage error. + stdout, _, err := runLstk(t, ctx, t.TempDir(), testEnvWithHome(t.TempDir(), ""), "stop", "--json", "--bogus-flag") + requireExitCode(t, 1, err) + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "error", envelope.Status) + require.NotNil(t, envelope.Error) + assert.Equal(t, "USAGE_ERROR", envelope.Error.Code) + assert.Equal(t, "USAGE", envelope.Error.Category) +} + +func TestUsageErrorBeforeJSONFallsBackToPlainText(t *testing.T) { + t.Parallel() + ctx := testContext(t) + + // --bogus-flag fails before pflag ever reaches --json, so cfg.JSON is still + // false when SetFlagErrorFunc runs — this must fall back to Cobra's + // existing plain-text usage error, not attempt to render JSON. + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), testEnvWithHome(t.TempDir(), ""), "stop", "--bogus-flag", "--json") + requireExitCode(t, 1, err) + + assert.Empty(t, stdout, "no JSON should be attempted when --json wasn't parsed yet") + assert.Contains(t, stderr, "bogus-flag") +} diff --git a/test/integration/reset_test.go b/test/integration/reset_test.go index b934e486..714139f9 100644 --- a/test/integration/reset_test.go +++ b/test/integration/reset_test.go @@ -2,6 +2,7 @@ package integration_test import ( "bytes" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -199,3 +200,75 @@ func TestResetInteractive(t *testing.T) { assert.Equal(t, int32(0), calls.Load(), "reset endpoint must not be called when user cancels") }) } + +func TestResetJSONSucceeds(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv, calls := mockResetServer(t, http.StatusOK) + + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), + "reset", "--force", "--json", + ) + require.NoError(t, err, "lstk reset --json failed: %s", stderr) + requireExitCode(t, 0, err) + assert.Equal(t, int32(1), calls.Load(), "reset endpoint should be called exactly once") + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "ok", envelope.Status) + assert.Equal(t, "reset", envelope.Command) + + var data struct { + Emulator struct { + Type string `json:"type"` + Name string `json:"name"` + } `json:"emulator"` + Reset bool `json:"reset"` + } + require.NoError(t, json.Unmarshal(envelope.Data, &data)) + assert.Equal(t, "aws", data.Emulator.Type) + assert.True(t, data.Reset) +} + +func TestResetJSONRequiresConfirmation(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + // Container required: the --force check runs after container discovery, + // so without a running emulator the test would hit "not running" first. + startTestContainer(t, ctx) + + stdout, _, err := runLstk(t, ctx, t.TempDir(), testEnvWithHome(t.TempDir(), ""), "reset", "--json") + requireExitCode(t, 3, err) + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "error", envelope.Status) + require.NotNil(t, envelope.Error) + assert.Equal(t, "CONFIRMATION_REQUIRED", envelope.Error.Code) + assert.Equal(t, "USAGE", envelope.Error.Category) + assert.False(t, envelope.Error.Retryable) +} + +func TestResetJSONNotConfigured(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + configFile := writeSnowflakeConfig(t, "4566") + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), + testEnvWithHome(t.TempDir(), ""), "--config", configFile, "reset", "--force", "--json", + ) + requireExitCode(t, 1, err) + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "error", envelope.Status) + require.NotNil(t, envelope.Error) + assert.Equal(t, "EMULATOR_NOT_CONFIGURED", envelope.Error.Code) + assert.Equal(t, "EMULATOR", envelope.Error.Category) +} diff --git a/test/integration/stop_test.go b/test/integration/stop_test.go index acc386cc..b488ab9c 100644 --- a/test/integration/stop_test.go +++ b/test/integration/stop_test.go @@ -2,10 +2,11 @@ package integration_test import ( "context" + "encoding/json" "testing" - "github.com/moby/moby/client" "github.com/localstack/lstk/test/integration/env" + "github.com/moby/moby/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -75,7 +76,8 @@ func TestStopCommandIgnoresForeignEmulatorOnPort(t *testing.T) { // AWS image running on 4566 while config targets snowflake. const fakeImage = "localstack/localstack-pro:test-fake" - _, err := dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: fakeImage}); require.NoError(t, err) + _, err := dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: fakeImage}) + require.NoError(t, err) t.Cleanup(func() { _, _ = dockerClient.ImageRemove(context.Background(), fakeImage, client.ImageRemoveOptions{}) }) @@ -101,7 +103,8 @@ func TestStopCommandStopsExternalContainer(t *testing.T) { ctx := testContext(t) const fakeImage = "localstack/localstack-pro:test-fake" - _, err := dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: fakeImage}); require.NoError(t, err) + _, err := dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: fakeImage}) + require.NoError(t, err) t.Cleanup(func() { _, _ = dockerClient.ImageRemove(context.Background(), fakeImage, client.ImageRemoveOptions{}) }) @@ -137,3 +140,49 @@ func TestStopCommandIsIdempotent(t *testing.T) { assert.Error(t, err, "second lstk stop should fail since container already removed") requireExitCode(t, 1, err) } + +func TestStopCommandJSON(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + + stdout, stderr, err := runLstk(t, ctx, "", testEnvWithHome(t.TempDir(), ""), "stop", "--json") + require.NoError(t, err, "lstk stop --json failed: %s", stderr) + requireExitCode(t, 0, err) + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "ok", envelope.Status) + assert.Equal(t, "stop", envelope.Command) + assert.Nil(t, envelope.Error) + + var data struct { + Emulators []struct { + Type string `json:"type"` + Name string `json:"name"` + WasRunning bool `json:"wasRunning"` + } `json:"emulators"` + } + require.NoError(t, json.Unmarshal(envelope.Data, &data)) + require.Len(t, data.Emulators, 1) + assert.Equal(t, "aws", data.Emulators[0].Type) + assert.Equal(t, "localstack-aws", data.Emulators[0].Name) + assert.True(t, data.Emulators[0].WasRunning) +} + +func TestStopCommandJSONNotRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + stdout, _, err := runLstk(t, testContext(t), "", testEnvWithHome(t.TempDir(), ""), "stop", "--json") + requireExitCode(t, 1, err) + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "error", envelope.Status) + require.NotNil(t, envelope.Error) + assert.Equal(t, "EMULATOR_NOT_RUNNING", envelope.Error.Code) + assert.Equal(t, "EMULATOR", envelope.Error.Category) +} diff --git a/test/integration/update_test.go b/test/integration/update_test.go index a1565364..17f91809 100644 --- a/test/integration/update_test.go +++ b/test/integration/update_test.go @@ -3,6 +3,7 @@ package integration_test import ( "bytes" "context" + "encoding/json" "io" "os" "os/exec" @@ -42,6 +43,31 @@ func TestUpdateCheckCommandNonInteractive(t *testing.T) { assert.Contains(t, stdout, "Note:", "should show a note in non-interactive mode") } +func TestUpdateCheckCommandJSON(t *testing.T) { + t.Parallel() + ctx := testContext(t) + + stdout, stderr, err := runLstk(t, ctx, "", testEnvWithHome(t.TempDir(), ""), "update", "--check", "--json") + require.NoError(t, err, "lstk update --check --json failed: %s", stderr) + requireExitCode(t, 0, err) + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "ok", envelope.Status) + assert.Equal(t, "update", envelope.Command) + + var data struct { + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + UpdateAvailable bool `json:"updateAvailable"` + } + require.NoError(t, json.Unmarshal(envelope.Data, &data)) + // The integration test binary is a dev build, so Check short-circuits + // before any network call — this exercises the "checked, none applied" + // UpdateCheckedEvent shape without depending on network access. + assert.Equal(t, "dev", data.CurrentVersion) + assert.False(t, data.UpdateAvailable) +} + func requireNPM(t *testing.T) { t.Helper() if _, err := exec.LookPath("npm"); err != nil { @@ -161,6 +187,53 @@ func TestUpdateBinaryInPlace(t *testing.T) { assert.NotContains(t, string(verOut2), "0.0.1", "binary should no longer be the old version") } +// TestUpdateBinaryInPlaceJSON exercises an actual applied update (not just +// --check) under --json: Check's UpdateCheckedEvent always fires now, even on +// the apply path, so this specifically proves EnvelopeSink's UpdateAppliedEvent +// case clears the stale latestVersion/updateAvailable keys rather than +// leaking them into the applied-update data shape. +func TestUpdateBinaryInPlaceJSON(t *testing.T) { + t.Parallel() + ctx := testContext(t) + + binaryName := "lstk" + if runtime.GOOS == "windows" { + binaryName = "lstk.exe" + } + tmpBinary := filepath.Join(t.TempDir(), binaryName) + repoRoot, err := filepath.Abs("../..") + require.NoError(t, err) + + buildCmd := exec.CommandContext(ctx, "go", "build", + "-ldflags", "-X github.com/localstack/lstk/internal/version.version=0.0.1", + "-o", tmpBinary, + ".", + ) + buildCmd.Dir = repoRoot + out, err := buildCmd.CombinedOutput() + require.NoError(t, err, "go build failed: %s", string(out)) + + updateCmd := exec.CommandContext(ctx, tmpBinary, "update", "--non-interactive", "--json") + updateOut, err := updateCmd.CombinedOutput() + updateStr := string(updateOut) + require.NoError(t, err, "lstk update --json failed: %s", updateStr) + requireExitCode(t, 0, err) + + envelope := decodeEnvelope(t, strings.TrimSpace(updateStr)) + assert.Equal(t, "ok", envelope.Status) + + var data map[string]any + require.NoError(t, json.Unmarshal(envelope.Data, &data)) + assert.Equal(t, "0.0.1", data["currentVersion"]) + assert.Equal(t, true, data["updated"]) + assert.Equal(t, "binary", data["method"]) + assert.NotEmpty(t, data["updatedVersion"]) + _, hasLatestVersion := data["latestVersion"] + _, hasUpdateAvailable := data["updateAvailable"] + assert.False(t, hasLatestVersion, "applied-update data should not carry a stale latestVersion key from the preceding check") + assert.False(t, hasUpdateAvailable, "applied-update data should not carry a stale updateAvailable key from the preceding check") +} + func requireHomebrew(t *testing.T) { t.Helper() if _, err := exec.LookPath("brew"); err != nil { @@ -299,7 +372,6 @@ port = "4566" # Host port assert.Contains(t, configStr, `port = "4566"`, "existing config values should be preserved") }) - t.Run("update", func(t *testing.T) { t.Parallel() // Copy binary since it will be replaced during the update From c8a478653aa1299606448643a406c8aab6d7fc99 Mon Sep 17 00:00:00 2001 From: Peter Smith Date: Thu, 9 Jul 2026 10:57:00 +1200 Subject: [PATCH 3/4] Update json_flag_test.go for the envelope-based --json rejection requireJSONSupport now renders an unsupported command's --json rejection as a JSON envelope on stdout (error.code: NOT_JSON_CAPABLE) instead of plain text on stderr, but these tests still asserted the old stderr-based contract from the earlier add-json-flag PR. Update them to decode the envelope and assert against it instead. Co-Authored-By: Claude Sonnet 5 --- test/integration/json_flag_test.go | 54 +++++++++++++++++++----------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/test/integration/json_flag_test.go b/test/integration/json_flag_test.go index 2d5f87d5..122708db 100644 --- a/test/integration/json_flag_test.go +++ b/test/integration/json_flag_test.go @@ -5,31 +5,39 @@ import ( "testing" "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// No built-in command has opted into --json output yet, so every one of these -// tests exercises the rejection gate (requireJSONSupport in cmd/root.go) -// rather than any actual JSON rendering. +// Most built-in commands haven't opted into --json output yet (see +// docs/structured-output.md's Command Catalog), so every one of these tests +// exercises the rejection gate (requireJSONSupport in cmd/root.go), which +// itself renders as a JSON envelope on stdout (error.code = NOT_JSON_CAPABLE) +// since that's the one guaranteed-universal response to --json. func TestJSONFlagRejectsUnannotatedBuiltinCommand(t *testing.T) { t.Parallel() stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), testEnvWithHome(t.TempDir(), ""), "status", "--json") requireExitCode(t, 1, err) - require.Contains(t, stderr, "status") - require.Contains(t, stderr, "JSON") - require.Contains(t, stderr, "==> See help: lstk -h", "rejection should use lstk's interactive error style") - require.Empty(t, stdout, "status's normal work must not run when --json is rejected") + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "status", envelope.Command) + assert.Equal(t, "error", envelope.Status) + require.NotNil(t, envelope.Error) + assert.Equal(t, "NOT_JSON_CAPABLE", envelope.Error.Code) + assert.Contains(t, envelope.Error.Message, "status") + assert.Empty(t, stderr, "the rejection is rendered as JSON on stdout, not plain text on stderr") } func TestJSONFlagRejectsDefaultStartBehavior(t *testing.T) { t.Parallel() stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), testEnvWithHome(t.TempDir(), ""), "--json") requireExitCode(t, 1, err) - require.Contains(t, stderr, "start") - require.Contains(t, stderr, "JSON") - require.Contains(t, stderr, "==> See help: lstk -h", "rejection should use lstk's interactive error style") - require.Empty(t, stdout, "the default start behavior must not run when --json is rejected") + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "start", envelope.Command) + assert.Equal(t, "error", envelope.Status) + require.NotNil(t, envelope.Error) + assert.Equal(t, "NOT_JSON_CAPABLE", envelope.Error.Code) + assert.Empty(t, stderr, "the rejection is rendered as JSON on stdout, not plain text on stderr") } func TestJSONFlagDoesNotLaunchTUIOnPTY(t *testing.T) { @@ -133,9 +141,11 @@ func TestJSONFlagProxyCommandsRejectBeforeCommandName(t *testing.T) { args := append([]string{"--json", tc.name}, tc.args...) stdout, stderr, err := runLstk(t, testContext(t), workDir, environ, args...) requireExitCode(t, 1, err) - require.Contains(t, stderr, tc.name) - require.Contains(t, stderr, "==> See help: lstk -h", "rejection should use lstk's interactive error style") - require.NotContains(t, stdout, "not found in PATH", "the wrapped binary must never be invoked") + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, tc.name, envelope.Command) + require.NotNil(t, envelope.Error) + assert.Equal(t, "NOT_JSON_CAPABLE", envelope.Error.Code) + assert.Empty(t, stderr, "the rejection is rendered as JSON on stdout, not plain text on stderr") }) } } @@ -150,10 +160,12 @@ func TestJSONFlagBeforeCommandNameBooleanValues(t *testing.T) { t.Run("--json=true before the command name is rejected", func(t *testing.T) { t.Parallel() - stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), env.With(env.DisableEvents, "1").With("PATH", t.TempDir()).With(env.Home, t.TempDir()), "--json=true", "aws", "s3", "ls") + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), env.With(env.DisableEvents, "1").With("PATH", t.TempDir()).With(env.Home, t.TempDir()), "--json=true", "aws", "s3", "ls") requireExitCode(t, 1, err) - require.Contains(t, stderr, "aws") - require.NotContains(t, stdout, "not found in PATH") + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "aws", envelope.Command) + require.NotNil(t, envelope.Error) + assert.Equal(t, "NOT_JSON_CAPABLE", envelope.Error.Code) }) t.Run("--json=false before the command name is not rejected", func(t *testing.T) { @@ -167,10 +179,12 @@ func TestJSONFlagBeforeCommandNameBooleanValues(t *testing.T) { t.Run("a malformed value before the command name is rejected", func(t *testing.T) { t.Parallel() - stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), env.With(env.DisableEvents, "1").With("PATH", t.TempDir()).With(env.Home, t.TempDir()), "--json=notabool", "aws", "s3", "ls") + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), env.With(env.DisableEvents, "1").With("PATH", t.TempDir()).With(env.Home, t.TempDir()), "--json=notabool", "aws", "s3", "ls") requireExitCode(t, 1, err) - require.Contains(t, stderr, "aws") - require.NotContains(t, stdout, "not found in PATH") + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "aws", envelope.Command) + require.NotNil(t, envelope.Error) + assert.Equal(t, "NOT_JSON_CAPABLE", envelope.Error.Code) }) } From 593101b9f935c137d4bec2cdb9d7502fef769a9d Mon Sep 17 00:00:00 2001 From: Peter Smith Date: Thu, 9 Jul 2026 11:11:01 +1200 Subject: [PATCH 4/4] Drop stale config profile mention from structured-output.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto main, which removed the deprecated `lstk config profile` command (#360) — it no longer exists, so it shouldn't be listed as a command that "will never support --json". Co-Authored-By: Claude Sonnet 5 --- docs/structured-output.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/structured-output.md b/docs/structured-output.md index f43118a4..b1042981 100644 --- a/docs/structured-output.md +++ b/docs/structured-output.md @@ -568,6 +568,6 @@ Codes: `AUTH_REQUIRED`, `SNAPSHOT_NOT_FOUND`, `SNAPSHOT_INVALID_REF`, `CONFIRMAT ## Commands that will never support `--json` -- **`login`** and **`config profile`** — both require an interactive terminal unconditionally (browser-based OAuth, TTY prompts) and have no defined non-interactive behavior at all today, so there's no output to render as JSON. +- **`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.