Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ Environment variables:
lstk proxies third-party IaC tools at the AWS emulator so they run against LocalStack with no `*local` wrapper installed. Each command forwards its args to the real tool after configuring the environment; domain logic lives under `internal/iac/<tool>/cli/`, wiring in `cmd/<tool>.go`, with shared command-boundary helpers in `cmd/iac.go`. Siblings: `lstk terraform` (alias `tf`), `lstk cdk`, `lstk sam`.


# Extensions

lstk supports Git-style extensions: when `lstk <name>` is not a built-in command or alias, lstk resolves and execs an external `lstk-<name>` executable, forwarding all arguments after `<name>` verbatim, passing stdin/stdout/stderr through, and propagating the child's exit code. Built-ins always win (dispatch happens only on the unknown-command path). Domain logic lives in `internal/extension/`; the unknown-command dispatch, the help listing, and the runtime-context wiring are in `cmd/extension.go`, hooked from `cmd/root.go`.

Resolution order is built-ins → bundled dir → `PATH`. The bundled dir is the directory containing the symlink-resolved lstk executable (`filepath.EvalSymlinks(os.Executable())`), so bundled extensions are found through npm/Homebrew shims; a bundled extension wins over a same-named `PATH` executable. Windows executable extensions (`PATHEXT`) are honored. There is no manifest — any resolvable `lstk-<name>` is the `<name>` extension.

Runtime context is conveyed in two environment variables: `LSTK_EXT_API_VERSION` (a flat integer the extension checks before parsing) and `LSTK_EXT_CONTEXT` (a single JSON object: `configDir`, optional `authToken`, `nonInteractive`, and an `emulators` array of `{type, endpoint, port}` — `[]` when none running, multiple entries when several emulators run at once). The `extension.Context` type and `Environ` builder live in `internal/extension/context.go`; the command boundary (`cmd/extension.go`) discovers all running emulators and populates it. `Invoke` wraps each exec in an OTEL span (extension name, bundled, exit code), so invocations are recorded as telemetry when `LSTK_OTEL` is enabled and cost nothing when it is not.

Scope: the first release **runs** extensions (PATH and bundled-dir resolution) and conveys context. Automated **distribution and atomic co-update** of LocalStack's bundled extensions are deferred to the `add-bundled-extension-distribution` change — the first release validates bundled extensions by manual placement next to `lstk`.

See [extensions-authoring.md](docs/extensions-authoring.md) for the author-facing contract.


# Snapshots

`lstk snapshot` captures and restores the running emulator's state. For Snowflake and Azure, snapshot support is still maturing, so these commands surface a friendly heads-up that results may be incomplete. Domain logic lives in `internal/snapshot/`; `cmd/snapshot.go` is wiring + output-mode selection.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,18 @@ lstk start --snapshot pod:other-baseline # load a different snapshot this run
lstk start --no-snapshot # skip auto-loading this run
```

## Extensions

lstk supports Git-style extensions: running `lstk <name>`, for a name that isn't a built-in command, delegates to an external `lstk-<name>` executable found on your `PATH`, forwarding all arguments and passing stdin/stdout/stderr through.

```bash
lstk my-tool --flag # resolves and runs lstk-my-tool, if it exists
```

Extensions receive context about the current lstk setup (config dir, auth token, running emulators) via environment variables, so they can integrate without reimplementing discovery.

See [docs/extensions-authoring.md](docs/extensions-authoring.md) for the extension contract and how to author your own.

## Reporting bugs

Feedback is welcome! Use the repository issue tracker for bug reports or feature requests.
195 changes: 195 additions & 0 deletions cmd/extension.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package cmd

import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/endpoint"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/extension"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/telemetry"
"github.com/spf13/cobra"
)

// dispatchExtension is the unknown-command fallthrough: when Cobra finds no
// built-in command or alias for args[0], lstk resolves an `lstk-<name>`
// executable (bundled dir first, then PATH) and execs it, forwarding the
// remaining args verbatim and conveying runtime context via LSTK_EXT_*. Built-in
// commands never reach here because Cobra routes them to their own command, so
// they always take precedence. When no extension resolves, lstk emits its
// standard unknown-command error (to stderr, matching Cobra's own) and returns a
// silent, non-zero error. A resolved extension's invocation is recorded as a
// product-telemetry command event named "ext:<name>" so the analytics pipeline
// can track which extension ran; this is separate from the OTel span emitted
// inside extension.Invoke (see internal/extension/exec.go).
func dispatchExtension(ctx context.Context, cfg *env.Env, tel *telemetry.Client, logger log.Logger, args []string) error {
name, extArgs := args[0], args[1:]

resolver := extension.NewResolver(logger)
ext, err := resolver.Resolve(name)
if err != nil {
if errors.Is(err, extension.ErrNotFound) {
// Errors go to stderr, like Cobra's own unknown-command output.
output.NewPlainSink(os.Stderr).Emit(output.ErrorEvent{
Title: fmt.Sprintf("unknown command %q for lstk", name),
Actions: []output.ErrorAction{{Label: "See help:", Value: "lstk -h"}},
})
return output.NewSilentError(fmt.Errorf("unknown command %q for lstk", name))
}
return err
}

emulators := resolveEmulators(ctx, cfg, logger)
configDir, err := config.ConfigDir()
if err != nil {
return fmt.Errorf("resolving config directory: %w", err)
}

runCtx := extension.Context{
ConfigDir: configDir,
AuthToken: cfg.AuthToken,
NonInteractive: !isInteractiveMode(cfg),
Emulators: emulators,
}

logger.Info("extension: dispatching %q (bundled=%v) at %s", name, ext.Bundled, ext.Path)
start := time.Now()
runErr := extension.Invoke(ctx, ext, extArgs, runCtx)

exitCode, errorMsg := 0, ""
if runErr != nil {
exitCode, errorMsg = 1, runErr.Error()
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
exitCode = exitErr.ExitCode()
}
}
tel.EmitCommand(ctx, "ext:"+name, nil, time.Since(start).Milliseconds(), exitCode, errorMsg)

return runErr
}

// resolveEmulators best-effort discovers every running LocalStack emulator and
// returns them for the LSTK_EXT_CONTEXT `emulators` array. lstk can run several
// emulators at once (e.g. AWS + Snowflake + Azure), so all running ones are
// reported, not just the first. When no emulator is running (or the runtime is
// unavailable) it returns nil, which Environ renders as an empty array; the
// extension is still executed.
func resolveEmulators(ctx context.Context, cfg *env.Env, logger log.Logger) []extension.Emulator {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
logger.Info("extension: runtime unavailable, omitting emulator context: %v", err)
return nil
}
if err := rt.IsHealthy(ctx); err != nil {
logger.Info("extension: runtime not healthy, omitting emulator context: %v", err)
return nil
}

var emulators []extension.Emulator
for _, c := range emulatorCandidates() {
name, err := container.ResolveRunningContainerName(ctx, rt, c)
if err != nil || name == "" {
continue
}
// Ask the runtime for the actual bound port rather than trusting config:
// the user may have changed the config port while the container still
// runs on the old one (mirrors `lstk status`).
hostPort := c.Port
if containerPort, err := c.ContainerPort(); err == nil {
if actual, err := rt.GetBoundPort(ctx, name, containerPort); err == nil {
hostPort = actual
}
}
host, _ := endpoint.ResolveHost(ctx, hostPort, cfg.LocalStackHost)
emulators = append(emulators, extension.Emulator{
Type: string(c.Type),
Endpoint: "http://" + host,
Port: hostPort,
})
}
return emulators
}

// emulatorCandidates returns the containers to probe for a running emulator: the
// configured containers first, then a default container for every other
// selectable emulator type, so a running emulator is found even if the config
// names a different one.
func emulatorCandidates() []config.ContainerConfig {
var candidates []config.ContainerConfig
seen := map[config.EmulatorType]struct{}{}

if appCfg, err := config.Get(); err == nil {
for _, c := range appCfg.Containers {
candidates = append(candidates, c)
seen[c.Type] = struct{}{}
}
}
for _, t := range config.SelectableEmulatorTypes {
if _, ok := seen[t]; ok {
continue
}
candidates = append(candidates, config.ContainerConfig{Type: t, Port: config.DefaultPort})
}
return candidates
}

// registerExtensionHelp wires an "extensions" template function that renders the
// Extensions section of `lstk --help`. It scans the bundled dir + PATH for
// `lstk-*` executables (de-duplicated, bundled wins) and attaches descriptions
// for bundled extensions from the hand-authored descriptions file; PATH and
// custom extensions, and bundled names missing from the file, are name-only.
// Rendering never executes an extension. A scan happens on each help render so
// freshly installed extensions appear without restarting.
func registerExtensionHelp(logger log.Logger) {
cobra.AddTemplateFunc("extensions", func(namePadding int) string {
resolver := extension.NewResolver(logger)
list := resolver.List()
if len(list) == 0 {
return ""
}
descriptions := extension.LoadDescriptions(resolver.BundledDir, logger)
return formatExtensionList(list, descriptions, namePadding)
})
}

// formatExtensionList renders the extension help lines so they align with the
// command sections above them. It mirrors Cobra's own scheme (see the usage
// template's "{{rpad .Name .NamePadding}} {{.Short}}"): each name is right-padded
// to namePadding, then a single space, then its description (bundled extensions
// only, from the descriptions file). namePadding is the root command's
// .NamePadding, so the description column matches the Commands/Tools sections; a
// name longer than namePadding widens its own row exactly as Cobra's per-row
// rpad does. Lines are sorted by name (List already sorts).
func formatExtensionList(list []extension.Extension, descriptions map[string]string, namePadding int) string {
width := namePadding
for _, ext := range list {
if len(ext.Name) > width {
width = len(ext.Name)
}
}

var b strings.Builder
for _, ext := range list {
desc := ""
if ext.Bundled {
desc = descriptions[ext.Name]
}
if desc != "" {
fmt.Fprintf(&b, " %-*s %s\n", width, ext.Name, desc)
} else {
fmt.Fprintf(&b, " %s\n", ext.Name)
}
}
return strings.TrimRight(b.String(), "\n")
}
5 changes: 4 additions & 1 deletion cmd/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}

Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if not .HasParent}}{{if extensions .NamePadding}}

Extensions:
{{extensions .NamePadding}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}

Options:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Expand Down
30 changes: 30 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,18 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
Short: "LocalStack CLI",
Long: "lstk is the command-line interface for LocalStack.",
PreRunE: initConfigDeferCreate(&firstRun),
// ArbitraryArgs stops Cobra from rejecting an unknown first arg with its
// own "unknown command" error before RunE runs, so an unknown `lstk
// <name>` falls through to extension dispatch. Built-in commands are still
// matched by Cobra's command resolution first, so they always win.
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// A non-empty arg here means the first positional was not a built-in
// command (Cobra would have routed those to their own command), so it
// is an extension name; everything after it is forwarded verbatim.
if len(args) > 0 {
return dispatchExtension(cmd.Context(), cfg, tel, logger, args)
}
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
Expand All @@ -74,7 +85,18 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
root.Flags().Bool("persist", false, "Persist emulator state across restarts")
addSnapshotStartFlags(root)

// Parse lstk's global flags only when they precede the command name: with
// interspersing disabled, Cobra consumes leading flags and hands everything
// from the first positional (the command/extension name) onward to the
// dispatch path verbatim. This gives Git-style "globals only before the
// command" and lets an extension own its entire flag space — a flag after the
// name (even one named like an lstk global) is forwarded untouched. Only the
// root's own flag set is affected; built-in subcommands keep their own
// (interspersing) flag parsing.
root.Flags().SetInterspersed(false)

configureHelp(root)
registerExtensionHelp(logger)

root.InitDefaultVersionFlag()
root.Flags().Lookup("version").Shorthand = "v"
Expand Down Expand Up @@ -305,6 +327,14 @@ func instrumentCommands(cmd *cobra.Command, tel *telemetry.Client) {
startTime := time.Now()
runErr := original(c, args)

// Extension dispatch (the root command invoked with a positional arg)
// records its own command event in dispatchExtension, which knows the
// resolved extension name; skip the generic emit here so the invocation
// is not mislabeled as "start".
if c == c.Root() && len(args) > 0 {
return runErr
}

var flags []string
c.Flags().Visit(func(f *pflag.Flag) {
flags = append(flags, "--"+f.Name)
Expand Down
Loading