Skip to content
Open
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
14 changes: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,9 @@ The release job (`.github/workflows/ci.yml`) builds the npm packages with `gorel

Cobra's generated bash completion script requires `_get_comp_words_by_ref` from the bash-completion package on both of its init paths, and stock macOS (bash 3.2) ships without that package — so completion failed with "command not found" on every Tab (DEVX-950). `selfContainBashCompletion` in `cmd/completion.go` wraps the autogenerated `completion bash` command to prepend a guarded pure-bash fallback (defined only when the package is absent, the git-completion.bash approach) and replaces the help text. The fallback body must stay bash 3.2 compatible (no `declare -A`, namerefs, `mapfile`, case-conversion expansions). It covers only `_get_comp_words_by_ref`; Cobra's script still calls bash-completion's `_filedir` for `ShellCompDirectiveFilterFileExt`/`ShellCompDirectiveFilterDirs` (`MarkFlagFilename`/`MarkFlagDirname`) and the ActiveHelp second-Tab path — lstk uses none of these today, so adopting one means growing the fallback. In docs/help, never recommend `source <(lstk completion bash)` — it is a silent no-op on bash 3.2; recommend `eval "$(lstk completion bash)"` instead. Zsh/fish/powershell scripts are self-contained upstream and untouched.

Only Homebrew installs wire up completion automatically (`homebrew_casks.completions` in `.goreleaser.yaml`), so the first successful *interactive* start emits a one-line `> Tip:` pointing at `lstk completion [bash|zsh|fish|powershell]` and the docs. It is only a pointer — lstk never writes to the user's shell config, and there is no install flag (rationale on the `completionTip` const in `internal/ui/run.go`). A second such nudge must be gated the same way: `firstRun` only, interactive only, after the emulator is up.
Only Homebrew installs wire up completion automatically (`homebrew_casks.completions` in `.goreleaser.yaml`), so the first successful *interactive* start emits a one-line `> Tip:` naming `lstk completion`. It is only a pointer — lstk never writes to the user's shell config, and there is no install flag (rationale on the `completionTip` const in `internal/container/tips.go`). On that run it is also the *only* tip shown — see [Post-start tips](#post-start-tips).

Per-shell setup instructions live in `completionSetup` (`cmd/completion.go`) and nowhere else: it is the `completion` command's own help, the per-shell subcommands forward to it rather than keeping a second copy, and `lstk docs` renders it. The tip carries no URL on purpose — the command answers the question on its own, so terminal output never points at a link that can rot or drift from the shipped binary. Keep the docs site's shell-completions section in step with that help, not the reverse.

`lstk aws <TAB>` completes AWS services/operations/parameters by delegating to the AWS CLI's own `aws_completer` from a `ValidArgsFunction` on the `aws` command (DEVX-846) — `awscli.Complete` in `internal/awscli/complete.go`, wired in `cmd/aws.go`. Going through Cobra rather than registering `complete -C aws_completer lstk` is what makes it work in every shell `lstk completion` supports (the native registration is bash/zsh-only) and keeps the bash fallback above unchanged. `aws_completer` speaks bash's `complete -C` protocol: `COMP_LINE`/`COMP_POINT` in, candidates one-per-line out. Two constraints it imposes: the line must start with the literal word `aws` (the completer drops the first word before matching, so `lstk aws s3 l` returns nothing), and `COMP_POINT` is a **character** offset, not a byte one. Cobra never runs `PreRunE` on the `__complete` path, so completion stays offline — no config load, no Docker health check, no endpoint resolution — which matches the completer, which never contacts an endpoint. A missing or failing completer must return `ShellCompDirectiveDefault` and print nothing: any output on this path is read by the shell as a candidate. The Tab-press timeout is set by the caller in `cmd/aws.go` (`awsCompletionTimeout`), not inside `awscli.Complete` — an internal deadline made the unit test flaky on cold CI runs. That deadline only actually bounds a Tab press because `Complete` also sets `cmd.WaitDelay`: `exec.CommandContext` kills the completer on expiry but does not close a pipe the completer's own children still hold, so a completer leaving a grandchild on stdout (a wrapper script that forks instead of exec'ing — what `/bin/sh` does on Linux, where it is dash) made `cmd.Output()` block for the grandchild's full lifetime regardless of the context. Keep `WaitDelay` set on any similar short captured-output exec. `lstk az` needs the same treatment but a different protocol (argcomplete: `_ARGCOMPLETE=1`, output on fd 8).

Expand Down Expand Up @@ -270,6 +272,16 @@ When drafting Slack messages, PR descriptions, review replies, release notes, or
- `internal/output/plain_format.go` (line formatting fallback)
- tests in `internal/output/*_test.go` for formatter/sink behavior parity

## Post-start tips

**At most one `> Tip:` line per run, ever.** Two tips side by side compete for attention and neither lands, and every new nudge otherwise grows its own emit site and races the existing ones (the regression raised on [#484](https://github.com/localstack/lstk/pull/484)). The limit is structural, not a convention to remember:

- `selectTip` (`internal/container/tips.go`) is the only place that decides which tip to show, and it returns one string. Add a new tip **there**, ranked against the others — never as a new `sink.Emit`.
- `container.Start` is the only place that emits it. No other package emits a tip; `internal/ui` deliberately emits none.
- Priority: the first-run tip (shell completion) outranks the rotating per-emulator tips, because first run happens once per install while the rotating tips come back on every later start.

A first-run nudge is gated `firstRun` only, interactive only, after the emulator is up. `firstRun` (config.toml was absent) reaches the domain layer as `StartOptions.FirstRun`, resolved at the command boundary in `cmd/root.go`.

## 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/`.
Expand Down
89 changes: 73 additions & 16 deletions cmd/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,8 @@ fi
// package (DEVX-950). Cobra's own RunE writes to a writer captured when
// InitDefaultCompletionCmd ran, so both halves are generated here against the
// writer resolved at execution time — otherwise SetOut after NewRootCmd would
// split them across two destinations. It also replaces the help text: Cobra's
// default recommends 'source <(lstk completion bash)', which is a silent
// no-op on macOS's stock bash 3.2, and states a package dependency that no
// longer holds.
// split them across two destinations. Help text is documentCompletionCommands'
// job, not this function's.
func selfContainBashCompletion(completionCmd *cobra.Command) {
var bashCmd *cobra.Command
for _, sub := range completionCmd.Commands() {
Expand All @@ -132,26 +130,85 @@ func selfContainBashCompletion(completionCmd *cobra.Command) {
}
return cmd.Root().GenBashCompletionV2(out, !noDesc)
}
}

// completionSetup is the whole of lstk's shell-completion setup documentation.
// It lives on the `completion` command alone — the first-start tip points at
// bare `lstk completion` (PR #495 review), so that command has to answer the
// question with no URL to follow, and the per-shell subcommands forward here
// rather than keeping a second copy. `lstk docs` renders it too, so the docs
// site follows the CLI instead of being kept in step by hand.
//
// %[1]s is the binary name. Command lines are indented so wrapText
// (cmd/help.go) leaves them intact — it reflows unindented prose to the
// terminal width, which would break a command mid-word on a narrow one.
const completionSetup = `Generate shell completion scripts for lstk.

To load completions:

Bash:
Comment thread
joe4dev marked this conversation as resolved.

# Load in current session
eval "$(%[1]s completion bash)"

# Load in new sessions (Linux)
echo 'eval "$(%[1]s completion bash)"' >> ~/.bashrc

# Load in new sessions (macOS)
echo 'eval "$(%[1]s completion bash)"' >> ~/.bash_profile

name := bashCmd.Root().Name()
bashCmd.Long = fmt.Sprintf(`Generate the autocompletion script for the bash shell.
Zsh:

The script works with or without the 'bash-completion' package: when the package is absent (e.g. stock macOS bash), a bundled fallback is used instead.
# Load in current session
autoload -Uz compinit && compinit
source <(%[1]s completion zsh)

To load completions in your current shell session:
# Load in new sessions (Linux, macOS)
echo 'autoload -Uz compinit && compinit' >> ~/.zshrc
echo 'source <(%[1]s completion zsh)' >> ~/.zshrc

eval "$(%[1]s completion bash)"
Fish:

To load completions for every new session, add the line above to ~/.bashrc (or ~/.bash_profile on macOS), or execute once:
# Load in current session
%[1]s completion fish | source

#### Linux:
# Load in new sessions (Linux, macOS)
%[1]s completion fish > ~/.config/fish/completions/%[1]s.fish

%[1]s completion bash > /etc/bash_completion.d/%[1]s
PowerShell:

#### macOS (with the bash-completion Homebrew package):
# Load in current session
%[1]s completion powershell | Out-String | Invoke-Expression

%[1]s completion bash > $(brew --prefix)/etc/bash_completion.d/%[1]s
# Load in new sessions (Windows, Linux, macOS)
if (!(Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force }
%[1]s completion powershell | Out-File -Append -Encoding utf8 $PROFILE`

You will need to start a new shell for this setup to take effect.
`, name)
// completionShellTitles spells the shells the way their projects do, for the
// per-shell help and command list.
var completionShellTitles = map[string]string{
"bash": "Bash",
"zsh": "Zsh",
"fish": "Fish",
"powershell": "PowerShell",
}

// documentCompletionCommands replaces Cobra's autogenerated help on the
// `completion` command and its per-shell subcommands. Cobra's own text
// recommends process substitution for bash — a silent no-op on macOS bash 3.2
// (DEVX-950) — and offers no persist path for PowerShell.
func documentCompletionCommands(completionCmd *cobra.Command) {
name := completionCmd.Root().Name()

completionCmd.Short = "Set up tab completion for your shell"
completionCmd.Long = fmt.Sprintf(completionSetup, name)

for _, sub := range completionCmd.Commands() {
title, ok := completionShellTitles[sub.Name()]
if !ok {
continue
}
sub.Short = fmt.Sprintf("Generate the tab-completion script for %s", title)
sub.Long = fmt.Sprintf("Generate the tab-completion script for %s.\n\nRun '%s completion --help' for setup instructions.", title, name)
}
}
77 changes: 77 additions & 0 deletions cmd/completion_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"strings"
"testing"
)

Expand Down Expand Up @@ -31,3 +32,79 @@ func TestCompletionBashNoDescriptionsFlagStillHonored(t *testing.T) {
assertContains(t, out, "_get_comp_words_by_ref()")
assertContains(t, out, "__completeNoDesc")
}

// The first-start tip says only `lstk completion` (PR #495 review), so that one
// command has to answer the whole question — these are the commands a user must
// be able to copy straight out of it. Asserted on the indented command lines
// only: wrapText reflows unindented prose to the terminal width.
var completionShellHelp = []struct {
shell string
title string
lines []string
}{
{"bash", "Bash:", []string{
`eval "$(lstk completion bash)"`,
`echo 'eval "$(lstk completion bash)"' >> ~/.bashrc`,
`echo 'eval "$(lstk completion bash)"' >> ~/.bash_profile`,
}},
{"zsh", "Zsh:", []string{
"source <(lstk completion zsh)",
"echo 'autoload -Uz compinit && compinit' >> ~/.zshrc",
"echo 'source <(lstk completion zsh)' >> ~/.zshrc",
}},
{"fish", "Fish:", []string{
"lstk completion fish | source",
"lstk completion fish > ~/.config/fish/completions/lstk.fish",
}},
{"powershell", "PowerShell:", []string{
"lstk completion powershell | Out-String | Invoke-Expression",
"lstk completion powershell | Out-File -Append -Encoding utf8 $PROFILE",
}},
}

func TestCompletionHelpDocumentsEveryShell(t *testing.T) {
out, err := executeWithArgs(t, "completion")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}

for _, tc := range completionShellHelp {
assertContains(t, out, tc.title)
for _, line := range tc.lines {
assertContains(t, out, line)
}
}
assertContains(t, out, "# Load in current session")
assertContains(t, out, "# Load in new sessions (Linux)")
assertContains(t, out, "# Load in new sessions (macOS)")

// Process substitution is a silent no-op on stock macOS bash 3.2, and the
// eval form needs no bash-completion package at all — that is what DEVX-950's
// bundled fallback bought.
assertNotContains(t, out, "source <(lstk completion bash)")
assertNotContains(t, out, "bash_completion.d")

// Cobra's zsh script calls compdef on line 2, which does not exist until
// compinit has run: sourcing it first fails with "compdef: command not found"
// and registers nothing.
zsh := out[strings.Index(out, "Zsh:"):strings.Index(out, "Fish:")]
for _, recipe := range strings.Split(zsh, "\n\n") {
if strings.Contains(recipe, "completion zsh") && !strings.Contains(recipe, "compinit") {
t.Fatalf("zsh recipe loads the script without compinit:\n%s", recipe)
}
}
}

// Per-shell help forwards rather than repeating the instructions, so there is
// one copy to read and one to maintain.
func TestCompletionShellHelpForwardsToParent(t *testing.T) {
for _, tc := range completionShellHelp {
out, err := executeWithArgs(t, "completion", tc.shell, "--help")
if err != nil {
t.Fatalf("completion %s --help: expected no error, got %v", tc.shell, err)
}

assertContains(t, out, "lstk completion --help")
assertNotContains(t, out, "# Load in current session")
}
}
2 changes: 1 addition & 1 deletion cmd/restart.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func newRestartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobr
stopOpts := container.StopOptions{
Telemetry: tel,
}
startOpts := buildStartOptions(cfg, appConfig, logger, tel, persist)
startOpts := buildStartOptions(cfg, appConfig, logger, tel, persist, false)

if isInteractiveMode(cfg) {
return ui.RunRestart(cmd.Context(), rt, stopOpts, startOpts)
Expand Down
7 changes: 4 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
if completionCmd, _, err := root.Find([]string{"completion"}); err == nil && completionCmd.Name() == "completion" {
requireSubcommand(completionCmd)
selfContainBashCompletion(completionCmd)
documentCompletionCommands(completionCmd)
}

return root
Expand Down Expand Up @@ -324,7 +325,7 @@ func configureCommandExecution(root *cobra.Command, cfg *env.Env, tel *telemetry
wrapPreRunEForJSON(root, cfg, stdout)
}

func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger, tel *telemetry.Client, persist bool) container.StartOptions {
func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger, tel *telemetry.Client, persist, firstRun bool) container.StartOptions {
return container.StartOptions{
PlatformClient: api.NewPlatformClient(cfg.APIEndpoint, logger),
AuthToken: cfg.AuthToken,
Expand All @@ -337,6 +338,7 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger
StartupTimeout: cfg.StartupTimeout,
Logger: logger,
Telemetry: tel,
FirstRun: firstRun,
}
}

Expand Down Expand Up @@ -381,7 +383,7 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
return err
}

opts := buildStartOptions(cfg, appConfig, logger, tel, persist)
opts := buildStartOptions(cfg, appConfig, logger, tel, persist, wasFirstRun)

notifyOpts := update.NotifyOptions{
GitHubToken: cfg.GitHubToken,
Expand All @@ -399,7 +401,6 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
ConfigPath: configPath,
EmulatorLabel: config.CachedPlanLabel(),
NeedsEmulatorSelection: firstRun,
CompletionTip: wasFirstRun,
PostStart: autoLoad,
})
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ func newSnapshotAutoLoader(cfg *env.Env, rt runtime.Runtime, appConfig *config.C

func buildStarter(cfg *env.Env, rt runtime.Runtime, appConfig *config.Config, logger log.Logger, tel *telemetry.Client) snapshot.Starter {
return func(ctx context.Context, sink output.Sink) error {
opts := buildStartOptions(cfg, appConfig, logger, tel, false)
opts := buildStartOptions(cfg, appConfig, logger, tel, false, false)
_, err := container.Start(ctx, rt, sink, opts, false)
return err
}
Expand Down
42 changes: 17 additions & 25 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"math/rand/v2"
"net/http"
"os"
"path/filepath"
Expand Down Expand Up @@ -72,9 +71,26 @@ type StartOptions struct {
// AuthOptions is passed through to auth.New; tests use it to inject a fake
// browser opener so a re-login flow never opens a real tab.
AuthOptions []auth.Option
// FirstRun reports that lstk had no config.toml when this run began; it
// selects the first-run tip.
FirstRun bool
}

// Start brings up the configured emulator, recovering from a definitive license
// rejection with an in-place re-login when interactive.
//
// The post-start tip is emitted here, on the single entry point, so a run can
// only ever show one — see selectTip.
func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool) (StartResult, error) {
result, err := start(ctx, rt, sink, opts, interactive)
if err != nil {
return result, err
}
emitPostStartTip(sink, result.Type, opts.FirstRun, interactive)
return result, nil
}

func start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool) (StartResult, error) {
// Fail fast on unsupported multi-container configs before any health/auth
// checks or image pulls, so we don't leave a partial startup that later dies
// on container-name conflicts or shared port collisions.
Expand Down Expand Up @@ -468,30 +484,6 @@ func emitPostStartPointers(sink output.Sink, emulatorType config.EmulatorType, r
if webAppURL != "" {
sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Web app: %s", strings.TrimRight(webAppURL, "/"))})
}
if tips := tipsForType(emulatorType); len(tips) > 0 {
sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: tips[rand.IntN(len(tips))]})
}
}

func tipsForType(t config.EmulatorType) []string {
switch t {
case config.EmulatorAWS:
return []string{
"> Tip: View emulator logs: lstk logs --follow",
"> Tip: View deployed resources: lstk status",
}
case config.EmulatorSnowflake:
return []string{
"> Tip: View emulator logs: lstk logs --follow",
"> Tip: Check emulator status: lstk status",
}
case config.EmulatorAzure:
return []string{
"> Tip: View emulator logs: lstk logs --follow",
"> Tip: Check emulator status: lstk status",
}
}
return nil
}

func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, interactive bool) (map[string]bool, error) {
Expand Down
Loading
Loading