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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Notes:
- `env/` - Process environment snapshot/injection helper (also used to isolate test envs)
- `extension/` - Git-style `lstk-<name>` extension resolution and exec
- `iac/` - Wrappers for third-party infrastructure as code tools (`terraform/`, `cdk/`, `sam/`)
- `inspect/` - `lstk inspect` domain logic (App Inspector on/off; `--persist` saves it to config)
- `log/` - Internal diagnostic logging (not for user-facing output — use `output/` for that)
- `output/` - Generic event and sink abstractions for CLI/TUI/non-interactive rendering
- `ports/` - Port availability checks
Expand All @@ -79,7 +80,7 @@ Notes:
- `version/` - Version info
- `volume/` - `lstk volume` domain logic

Commands are registered in `cmd/root.go` in two Cobra groups: the `commands` group (start, stop, restart, login, logout, status, logs, setup, config, volume, update, docs, snapshot, reset, save, load) and the `tools` group of proxy commands (aws, terraform/tf, cdk, sam, az). Shared helpers: `cmd/root.go` (wiring, groups, `requireSubcommand`, `initConfig`), `cmd/help.go` (help template), `cmd/iac.go` (IaC command boundary), `cmd/extension.go` (extension dispatch).
Commands are registered in `cmd/root.go` in two Cobra groups: the `commands` group (start, stop, restart, login, logout, status, logs, inspect, setup, config, volume, update, docs, snapshot, reset, save, load) and the `tools` group of proxy commands (aws, terraform/tf, cdk, sam, az). Shared helpers: `cmd/root.go` (wiring, groups, `requireSubcommand`, `initConfig`), `cmd/help.go` (help template), `cmd/iac.go` (IaC command boundary), `cmd/extension.go` (extension dispatch).

## Container runtime discovery

Expand Down Expand Up @@ -144,7 +145,7 @@ There is no `--offline` flag. Instead `container.Start` degrades gracefully when

# Targeting an External Emulator (`--endpoint-url`)

`--endpoint-url <url>` (a root persistent flag) lets `aws`, `az`, `terraform`/`tf`, `cdk`, `sam`, `snapshot save`/`load` (incl. the `lstk save`/`lstk load` aliases), `snapshot remove`, `snapshot list s3://...`, `reset`, and `status` target an emulator lstk did not start — docker compose, host-network mode, CI, a different machine, or a LocalStack cloud-hosted **ephemeral instance** — instead of discovering one via local Docker. `LSTK_ENDPOINT_URL` is the equivalent environment variable. Both `http://` and `https://` are accepted (any other scheme is rejected); the resolved scheme is preserved end-to-end all the way to whatever ultimately makes the request — the wrapped `aws`/`terraform`/`cdk`/`sam` tools, and the emulator API calls behind `snapshot`/`reset`/`status` — never normalized to `http`, which is what makes `https://` ephemeral instances work.
`--endpoint-url <url>` (a root persistent flag) lets `aws`, `az`, `terraform`/`tf`, `cdk`, `sam`, `snapshot save`/`load` (incl. the `lstk save`/`lstk load` aliases), `snapshot remove`, `snapshot list s3://...`, `reset`, `inspect` (not `--persist`), and `status` target an emulator lstk did not start — docker compose, host-network mode, CI, a different machine, or a LocalStack cloud-hosted **ephemeral instance** — instead of discovering one via local Docker. `LSTK_ENDPOINT_URL` is the equivalent environment variable. Both `http://` and `https://` are accepted (any other scheme is rejected); the resolved scheme is preserved end-to-end all the way to whatever ultimately makes the request — the wrapped `aws`/`terraform`/`cdk`/`sam` tools, and the emulator API calls behind `snapshot`/`reset`/`status` — never normalized to `http`, which is what makes `https://` ephemeral instances work.

`lstk logs`, `stop`, `restart`, `volume` (`path`/`clear`), `start`, and the bare `lstk` root command (which starts the emulator the same way `start` does) have no remote equivalent and reject *any* resolved endpoint source.

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Running `lstk` will automatically handle authentication, configuration, and cont
- **Interactive TUI** — a Bubble Tea-powered terminal UI in interactive terminals, plain output for CI/CD and scripting
- **Browser-based login** — authenticate via browser and store credentials securely in the system keyring, or use `LOCALSTACK_AUTH_TOKEN` for CI (it takes precedence over stored credentials)
- **Snapshots** — save, load, and manage emulator state as local files, cloud snapshots, or in your own S3 bucket
- **App Inspector**: `lstk inspect enable` turns on App Inspector for the AWS emulator; add `--persist` to keep it on after restarts
- **Cloud CLI proxies** — run `aws`, `az`, `terraform`, `cdk`, and `sam` commands against LocalStack with the endpoint, credentials, and region pre-configured
- **Target an external emulator** — pass `--endpoint-url <url>` (or set `LSTK_ENDPOINT_URL`) to point most commands at an already-running LocalStack instance — docker compose, host-network mode, CI, a different machine, or a cloud-hosted ephemeral instance (`https://` is supported) — instead of one lstk manages locally
- **Extensions** — Git-style `lstk-<name>` executables extend the CLI with new commands; see [extension authoring](https://github.com/localstack/lstk/blob/main/docs/extensions-authoring.md)
Expand Down
149 changes: 149 additions & 0 deletions cmd/inspect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package cmd

import (
"errors"
"fmt"
"os"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/emulator/aws"
"github.com/localstack/lstk/internal/endpoint"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/inspect"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/ui"
"github.com/spf13/cobra"
)

func newInspectCmd(cfg *env.Env) *cobra.Command {
cmd := &cobra.Command{
Use: "inspect",
Short: "Turn App Inspector on or off",
Long: "Turn App Inspector on or off for the AWS emulator. App Inspector records the operations your application sends to the emulator, with their payloads, the services they connect, and IAM permission results. View them in the LocalStack Web Application or the LocalStack Toolkit for VS Code.",
}
requireSubcommand(cmd)
cmd.AddCommand(newInspectSetCmd(cfg, true), newInspectSetCmd(cfg, false), newInspectStatusCmd(cfg))
return cmd
}

func newInspectSetCmd(cfg *env.Env, enabled bool) *cobra.Command {
var persist bool
use, title, state := "enable", "Enable", "enabled"
if !enabled {
use, title, state = "disable", "Disable", "disabled"
}

cmd := &cobra.Command{
Use: use,
Short: title + " App Inspector",
Long: title + " App Inspector on the running AWS emulator.\n\n" +
"The change lasts until the emulator restarts. With --persist, lstk also saves it to the config file (as APP_INSPECTOR in an [env.app-inspector] profile), so App Inspector is " + state + " on every start. --persist works while the emulator is stopped too.",
Args: cobra.NoArgs,
PreRunE: initConfigDeferCreate(nil),
Annotations: map[string]string{jsonSupportedAnnotation: "true"},
RunE: func(cmd *cobra.Command, args []string) error {
sink := jsonAwareSink(cmd, cfg, os.Stdout)
t, err := resolveInspectTarget(cmd, cfg, sink, persist)
if err != nil {
return err
}
if isInteractiveMode(cfg) {
return ui.RunInspectSet(cmd.Context(), t, enabled, persist)
}
return inspect.Set(cmd.Context(), sink, t, enabled, persist)
},
}
cmd.Flags().BoolVar(&persist, "persist", false, "Also save the setting to the config file so it applies on every start")
return cmd
}

func newInspectStatusCmd(cfg *env.Env) *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show whether App Inspector is enabled",
Args: cobra.NoArgs,
PreRunE: initConfigDeferCreate(nil),
Annotations: map[string]string{jsonSupportedAnnotation: "true"},
RunE: func(cmd *cobra.Command, args []string) error {
sink := jsonAwareSink(cmd, cfg, os.Stdout)
t, err := resolveInspectTarget(cmd, cfg, sink, false)
if err != nil {
return err
}
if isInteractiveMode(cfg) {
return ui.RunInspectStatus(cmd.Context(), t)
}
return inspect.Status(cmd.Context(), sink, t)
},
}
}

func resolveInspectTarget(cmd *cobra.Command, cfg *env.Env, sink output.Sink, persist bool) (inspect.Target, error) {
fail := func(e output.ErrorEvent) (inspect.Target, error) {
sink.Emit(e)
return inspect.Target{}, output.NewSilentError(errors.New(e.Title))
}

remote, err := endpoint.Resolve(cmd.Context(), cmd)
if err != nil {
return fail(output.ErrorEvent{Title: err.Error(), Code: output.ErrEmulatorNotRunning})
}
if remote != nil {
if persist {
source, _, _ := endpoint.ResolvedSource(cmd)
return fail(output.ErrorEvent{
Title: "--persist cannot be used with " + source,
Summary: "--persist saves to the lstk config, which does not start the emulator at " + remote.URL + ".",
Code: output.ErrValidationError,
})
}
if remote.Type != config.EmulatorAWS {
return fail(output.ErrorEvent{
Title: fmt.Sprintf("App Inspector is only available for the AWS emulator, but the endpoint at %s is a %s", remote.URL, remote.Type.DisplayName()),
Code: output.ErrEmulatorWrongType,
})
}
c := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultPort}
return inspect.Target{Runtime: runtime.NewExternalRuntime(c.Name()), Containers: []config.ContainerConfig{c}, Client: aws.NewClient(), Host: remote.URL}, nil
}

appConfig, err := config.Get()
if err != nil {
return inspect.Target{}, failGetConfig(sink, cfg, err)
}
var awsContainer *config.ContainerConfig
for i := range appConfig.Containers {
if appConfig.Containers[i].Type == config.EmulatorAWS {
awsContainer = &appConfig.Containers[i]
break
}
}
if awsContainer == nil {
return fail(output.ErrorEvent{Title: "App Inspector is only available for the AWS emulator", Code: output.ErrEmulatorNotConfigured})
}
if persist && !config.HasFile() {
return fail(output.ErrorEvent{
Title: "No config file to save the setting to yet",
Actions: []output.ErrorAction{{Label: "Create it by starting the emulator once:", Value: "lstk start"}},
Code: output.ErrConfigNotFound,
})
}

rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return inspect.Target{}, err
}
host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)
path, err := config.FriendlyConfigPath()
if err != nil {
path = "the config file"
}
return inspect.Target{
Runtime: rt,
Containers: []config.ContainerConfig{*awsContainer},
Client: aws.NewClient(),
Host: "http://" + host,
ConfigPath: path,
}, nil
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
newLogoutCmd(cfg, logger),
newStatusCmd(cfg),
newLogsCmd(cfg),
newInspectCmd(cfg),
newSetupCmd(cfg),
newConfigCmd(),
newVolumeCmd(cfg),
Expand Down
19 changes: 18 additions & 1 deletion docs/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The contract described here (a shared envelope shape, a shared error-code vocabu

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

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

## The envelope
Expand Down Expand Up @@ -318,6 +318,23 @@ Codes: `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED`, `LICENSE_INVALID` (bad/expired/re
```
Codes: `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`, `CONFIG_INVALID`, `CONFIG_NOT_FOUND` (bad or missing `--config` path).

**`lstk inspect enable`** / **`disable`** / **`status`**: App Inspector's state on the running emulator. `changed` is whether this command changed it (always `false` for `status`). With `--persist`, `saved` reports the setting in the config file. When `--persist` runs while the emulator is stopped, `enabled` and `changed` are omitted.
```json
{
"schemaVersion": 1,
"command": "inspect enable",
"status": "ok",
"data": {
"enabled": true,
"changed": true,
"saved": {"enabled": true, "changed": true}
},
"warnings": [],
"error": null
}
```
Codes: `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`, `EMULATOR_NOT_CONFIGURED`, `EMULATOR_WRONG_TYPE`, `VALIDATION_ERROR` (`--persist` with an endpoint URL), `CONFIG_NOT_FOUND` (`--persist` with no config file yet), `CONFIG_INVALID`.

### 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.
Expand Down
130 changes: 130 additions & 0 deletions internal/config/env_profile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package config

import (
"bytes"
"errors"
"fmt"
"os"
"regexp"
"strings"
)

var (
envListRe = regexp.MustCompile(`(?m)^[ \t]*env[ \t]*=[ \t]*\[`)
quotedRe = regexp.MustCompile(`"[^"\n]*"|'[^'\n]*'`)
)

// SetEnvProfileVar sets key in [env.<profile>] and lists that profile last in
// the first [[containers]] block, so it wins. The file is restored if the value
// does not take effect.
func SetEnvProfileVar(profile, key, value string) (bool, error) {
path := resolvedConfigPath()
if path == "" {
return false, errors.New("no config file to write to yet")
}
original, err := os.ReadFile(path)
if err != nil {
return false, fmt.Errorf("failed to read config file: %w", err)
}
updated, err := addEnvReference(original, profile)
if err != nil {
return false, err
}
updated = setProfileVar(updated, profile, key, value)
if bytes.Equal(updated, original) {
return false, nil
}
if err := os.WriteFile(path, updated, 0644); err != nil {
return false, fmt.Errorf("failed to write config file: %w", err)
}
if err := checkResolvedEnv(path, key, value); err != nil {
if restoreErr := os.WriteFile(path, original, 0644); restoreErr != nil {
return false, errors.Join(err, restoreErr)
}
return false, errors.Join(err, loadConfig(path))
}
return true, nil
}

func checkResolvedEnv(path, key, value string) error {
if err := loadConfig(path); err != nil {
return err
}
cfg, err := Get()
if err != nil {
return err
}
env, err := cfg.Containers[0].ResolvedEnv(cfg.Env)
if err != nil {
return err
}
got := ""
for _, kv := range env {
if k, v, _ := strings.Cut(kv, "="); k == strings.ToUpper(key) {
got = v
}
}
if got != value {
return fmt.Errorf("another env profile also sets %s and overrides this change", key)
}
return nil
}

func addEnvReference(data []byte, profile string) ([]byte, error) {
header := containersHeaderRe.FindIndex(data)
if header == nil {
return nil, errors.New("no [[containers]] block found in config")
}
start, end := header[1], len(data)
if next := tableHeaderRe.FindIndex(data[start:]); next != nil {
end = start + next[0]
}
quoted := `"` + profile + `"`
loc := envListRe.FindIndex(data[start:end])
if loc == nil {
return replace(data, start, start, "\nenv = ["+quoted+"]"), nil
}
open := start + loc[1]
closeAt := bytes.IndexByte(data[open:end], ']')
if closeAt < 0 {
return nil, errors.New("could not parse the env list in the [[containers]] block")
}
names := quotedRe.FindAllIndex(data[open:open+closeAt], -1)
for _, n := range names {
if string(data[open+n[0]+1:open+n[1]-1]) == profile {
return data, nil
}
}
if len(names) == 0 {
return replace(data, open, open, quoted), nil
}
at := open + names[len(names)-1][1]
return replace(data, at, at, ", "+quoted), nil
}

func setProfileVar(data []byte, profile, key, value string) []byte {
assignment := fmt.Sprintf("%s = %q", key, value)
headerRe := regexp.MustCompile(`(?m)^[ \t]*\[env\.` + regexp.QuoteMeta(profile) + `\][ \t]*$`)
header := headerRe.FindIndex(data)
if header == nil {
trimmed := len(bytes.TrimRight(data, "\n"))
return replace(data, trimmed, len(data), "\n\n[env."+profile+"]\n"+assignment+"\n")
}
start, end := header[1], len(data)
if next := tableHeaderRe.FindIndex(data[start:]); next != nil {
end = start + next[0]
}
// viper lowercases keys.
keyRe := regexp.MustCompile(`(?mi)^[ \t]*` + regexp.QuoteMeta(key) + `[ \t]*=[ \t]*("[^"\n]*"|'[^'\n]*'|[^ \t\n#]+)`)
if loc := keyRe.FindSubmatchIndex(data[start:end]); loc != nil {
return replace(data, start+loc[2], start+loc[3], fmt.Sprintf("%q", value))
}
return replace(data, start, start, "\n"+assignment)
}

func replace(data []byte, from, to int, s string) []byte {
out := make([]byte, 0, len(data)-(to-from)+len(s))
out = append(out, data[:from]...)
out = append(out, s...)
return append(out, data[to:]...)
}
Loading
Loading