Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
105 changes: 105 additions & 0 deletions cmd/json_envelope.go
Original file line number Diff line number Diff line change
@@ -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)})
}
})
}
19 changes: 15 additions & 4 deletions cmd/reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
},
}

Expand Down
59 changes: 54 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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))
}
}
Expand Down
14 changes: 8 additions & 6 deletions cmd/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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)
},
}
}
14 changes: 8 additions & 6 deletions cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
},
}

Expand Down
Loading
Loading