diff --git a/CLAUDE.md b/CLAUDE.md index b5e23402..016cb62e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` 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). @@ -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/`. diff --git a/cmd/completion.go b/cmd/completion.go index db5161bd..a1ff4c86 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -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() { @@ -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: + + # 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) + } } diff --git a/cmd/completion_test.go b/cmd/completion_test.go index 18e1404c..ae2035ca 100644 --- a/cmd/completion_test.go +++ b/cmd/completion_test.go @@ -1,6 +1,7 @@ package cmd import ( + "strings" "testing" ) @@ -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") + } +} diff --git a/cmd/restart.go b/cmd/restart.go index 06964ff2..c93ada9d 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -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) diff --git a/cmd/root.go b/cmd/root.go index fa4064c7..46eae9b9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 @@ -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, @@ -337,6 +338,7 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger StartupTimeout: cfg.StartupTimeout, Logger: logger, Telemetry: tel, + FirstRun: firstRun, } } @@ -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, @@ -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, }) } diff --git a/cmd/snapshot.go b/cmd/snapshot.go index c44b3a65..d61bbc7b 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -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 } diff --git a/internal/container/start.go b/internal/container/start.go index 99f99dd2..9acd1055 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "math/rand/v2" "net/http" "os" "path/filepath" @@ -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. @@ -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) { diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 84325562..00834a95 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -121,7 +121,6 @@ func TestEmitPostStartPointers_WithWebApp(t *testing.T) { got := out.String() assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n") - assert.Contains(t, got, "> Tip:") assert.NotContains(t, got, "• Snowflake endpoint:", "AWS path must not show the snowflake-prefixed endpoint") assert.NotContains(t, got, "• Persistence:", @@ -136,7 +135,6 @@ func TestEmitPostStartPointers_WithoutWebApp(t *testing.T) { got := out.String() assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n") - assert.Contains(t, got, "> Tip:") } func TestEmitPostStartPointers_WithPersist(t *testing.T) { @@ -235,7 +233,6 @@ func TestEmitPostStartPointers_Snowflake_ReplacesEndpointWithSnowflakeEndpoint(t assert.NotContains(t, got, "• Endpoint: localhost.localstack.cloud:4566", "Snowflake should not show the bare endpoint — clients connect via the snowflake-prefixed host") assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n") - assert.Contains(t, got, "> Tip:") } func TestEmitPostStartPointers_Snowflake_OmitsPersistenceBullet(t *testing.T) { @@ -259,7 +256,6 @@ func TestEmitPostStartPointers_Snowflake_FallsBackToBareEndpointForIPHost(t *tes assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n", "falls back to bare endpoint when snowflake. would be invalid") assert.NotContains(t, got, "• Snowflake endpoint:") - assert.Contains(t, got, "> Tip:") } func TestSelectContainersToStart_AttachesWhenExternalContainerOnConfiguredPort(t *testing.T) { @@ -396,23 +392,10 @@ func TestEmitPostStartPointers_Azure(t *testing.T) { got := out.String() assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n") - assert.Contains(t, got, "> Tip:") assert.NotContains(t, got, "• Snowflake endpoint:", "Azure must not show the snowflake-prefixed endpoint") } -func TestEmitPostStartPointers_UnknownEmulator_NoTip(t *testing.T) { - var out bytes.Buffer - sink := output.NewPlainSink(&out) - - emitPostStartPointers(sink, config.EmulatorType("other"), "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false) - - got := out.String() - assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") - assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n") - assert.NotContains(t, got, "> Tip:") -} - func TestServicePortRange_ReturnsExpectedPorts(t *testing.T) { ports := servicePortRange() diff --git a/internal/container/tips.go b/internal/container/tips.go new file mode 100644 index 00000000..3ffe22a3 --- /dev/null +++ b/internal/container/tips.go @@ -0,0 +1,66 @@ +package container + +import ( + "math/rand/v2" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" +) + +// completionTip fires on first run, not install: no install path has a usable +// hook (npm's package.json is generated, binaries have none). It names the bare +// command rather than a URL — `lstk completion` carries the per-shell setup +// itself (completionShells in cmd/completion.go), so there is nothing to follow +// and nothing to keep in sync. Must stay a plain MessageEvent — ui.Run renders +// no DeferredOutput, so a deferred event is lost. +const completionTip = "> Tip: Set up tab completion: lstk completion" + +// emitPostStartTip emits this run's tip. Start is its only caller: one emit site +// is what makes selectTip's limit hold. +func emitPostStartTip(sink output.Sink, emulatorType config.EmulatorType, firstRun, interactive bool) { + // Nothing came up (no containers configured), so nothing to tip about. + if emulatorType == "" { + return + } + if tip := selectTip(emulatorType, firstRun, interactive); tip != "" { + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: tip}) + } +} + +// selectTip returns the one tip to show after a start, or "" for none. +// +// One tip per run, never two: side by side they compete and neither lands (#484 +// review). Rank a new tip in here, don't emit it separately. firstRun wins — it +// happens once per install, the rotating tips return on every later start. +func selectTip(emulatorType config.EmulatorType, firstRun, interactive bool) string { + // Interactive only: completion means nothing to CI, agents, or --json. + if firstRun && interactive { + return completionTip + } + tips := tipsForType(emulatorType) + if len(tips) == 0 { + return "" + } + return 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 +} diff --git a/internal/container/tips_test.go b/internal/container/tips_test.go new file mode 100644 index 00000000..2f870f17 --- /dev/null +++ b/internal/container/tips_test.go @@ -0,0 +1,89 @@ +package container + +import ( + "bytes" + "strings" + "testing" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The tip names the command instead of linking to docs (#495 review): a URL in +// terminal output cannot be clicked, rots, and drifts from the CLI, while +// `lstk completion` documents itself. +func TestCompletionTip_NamesTheCommandWithoutAURL(t *testing.T) { + assert.Contains(t, completionTip, "lstk completion") + assert.NotContains(t, completionTip, "http") +} + +func TestSelectTip_FirstRunInteractive_PrefersCompletionTip(t *testing.T) { + got := selectTip(config.EmulatorAWS, true, true) + + assert.Equal(t, completionTip, got, "first run is the only moment the completion pointer is worth a line") +} + +func TestSelectTip_FirstRunNonInteractive_FallsBackToRotatingTip(t *testing.T) { + got := selectTip(config.EmulatorAWS, true, false) + + assert.NotEqual(t, completionTip, got, "shell completion is irrelevant to CI and agents") + assert.Contains(t, tipsForType(config.EmulatorAWS), got) +} + +func TestSelectTip_SubsequentRun_ReturnsRotatingTip(t *testing.T) { + for range 20 { + got := selectTip(config.EmulatorAWS, false, true) + + assert.NotEqual(t, completionTip, got, "the completion tip must not repeat past the first run") + assert.Contains(t, tipsForType(config.EmulatorAWS), got) + } +} + +func TestSelectTip_UnknownEmulator_ReturnsNoTip(t *testing.T) { + assert.Empty(t, selectTip(config.EmulatorType("other"), false, true)) +} + +func TestSelectTip_UnknownEmulatorOnFirstRun_StillReturnsCompletionTip(t *testing.T) { + assert.Equal(t, completionTip, selectTip(config.EmulatorType("other"), true, true), + "the completion tip is about lstk itself, not the emulator that happens to be configured") +} + +// No input combination can produce two lines. +func TestEmitPostStartTip_EmitsAtMostOneTipLine(t *testing.T) { + for _, tc := range []struct { + name string + emulatorType config.EmulatorType + firstRun bool + interactive bool + wantTipLineCount int + }{ + {"first run interactive", config.EmulatorAWS, true, true, 1}, + {"first run non-interactive", config.EmulatorAWS, true, false, 1}, + {"subsequent run", config.EmulatorAWS, false, true, 1}, + {"unknown emulator", config.EmulatorType("other"), false, true, 0}, + {"nothing started", config.EmulatorType(""), true, true, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + emitPostStartTip(sink, tc.emulatorType, tc.firstRun, tc.interactive) + + assert.Equal(t, tc.wantTipLineCount, strings.Count(out.String(), "> Tip:")) + }) + } +} + +// The pointers block must not carry a tip of its own: Start is the single emit site. +func TestEmitPostStartPointers_EmitsNoTip(t *testing.T) { + for _, emulatorType := range []config.EmulatorType{config.EmulatorAWS, config.EmulatorSnowflake, config.EmulatorAzure} { + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + emitPostStartPointers(sink, emulatorType, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", true) + + require.NotContains(t, out.String(), "> Tip:", "%s pointers block emitted a tip", emulatorType) + } +} diff --git a/internal/ui/run.go b/internal/ui/run.go index 8cdd763d..558f5b09 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -41,25 +41,12 @@ type RunOptions struct { // auto-load a configured snapshot). It is skipped when the emulator was // already running. PostStart func(ctx context.Context, sink output.Sink) error - // CompletionTip shows completionTip once the emulator is up. The caller - // decides when it applies (first run), so this package holds no policy. - CompletionTip bool } -// completionTip points at the completion scripts lstk ships. Only Homebrew -// installs wire them up automatically (homebrew_casks.completions in -// .goreleaser.yaml), so npm and binary users never find them. First run is the -// trigger because no install path offers a usable hook (generated npm -// package.json, no hook at all for binaries) and it needs no new persisted -// state: config.toml was absent, and that same run creates it. +// Run drives an interactive emulator start through the Bubble Tea program. // -// Must stay a plain MessageEvent, not a DeferredEvent — Run does not render -// DeferredOutput (only runWithTUI does), so a deferred event would be dropped. -// The "> Tip: " prefix, SeveritySecondary, and verb-colon-command wording match -// tipsForType (internal/container/start.go), whose tip renders right above it. -const completionTip = "> Tip: Enable tab completion for your shell: lstk completion [bash|zsh|fish|powershell] " + - "See https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/#shell-completions" - +// It emits no "> Tip:" line — container.Start owns the run's single post-start +// tip (see selectTip there). func Run(parentCtx context.Context, runOpts RunOptions) error { ctx, cancel := context.WithCancel(parentCtx) defer cancel() @@ -155,9 +142,6 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { } else { go container.ResolveAndCacheLabel(ctx, runOpts.StartOptions, result.Version, labelCh) } - if runOpts.CompletionTip { - sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: completionTip}) - } p.Send(runDoneMsg{}) }() diff --git a/test/integration/__snapshots__/exit_code_test.snap b/test/integration/__snapshots__/exit_code_test.snap index 9dc0ecd7..85bb0e62 100644 --- a/test/integration/__snapshots__/exit_code_test.snap +++ b/test/integration/__snapshots__/exit_code_test.snap @@ -2,16 +2,55 @@ Snapshots created by internal/snap. UPDATE_SNAPS=true go test rewrites this file. [TestBareParentCommandExitsZero_completion_1] -Generate the autocompletion script for lstk for the specified shell. -See each sub-command's help for details on how to use the generated script. +Generate shell completion scripts for lstk. + +To load completions: + +Bash: + + # Load in current session + eval "$(lstk completion bash)" + + # Load in new sessions (Linux) + echo 'eval "$(lstk completion bash)"' >> ~/.bashrc + + # Load in new sessions (macOS) + echo 'eval "$(lstk completion bash)"' >> ~/.bash_profile + +Zsh: + + # Load in current session + autoload -Uz compinit && compinit + source <(lstk completion zsh) + + # Load in new sessions (Linux, macOS) + echo 'autoload -Uz compinit && compinit' >> ~/.zshrc + echo 'source <(lstk completion zsh)' >> ~/.zshrc + +Fish: + + # Load in current session + lstk completion fish | source + + # Load in new sessions (Linux, macOS) + lstk completion fish > ~/.config/fish/completions/lstk.fish + +PowerShell: + + # Load in current session + lstk completion powershell | Out-String | Invoke-Expression + + # Load in new sessions (Windows, Linux, macOS) + if (!(Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force } + lstk completion powershell | Out-File -Append -Encoding utf8 $PROFILE Usage: lstk completion [flags] Commands: - bash Generate the autocompletion script for bash - fish Generate the autocompletion script for fish - powershell Generate the autocompletion script for powershell - zsh Generate the autocompletion script for zsh + bash Generate the tab-completion script for Bash + fish Generate the tab-completion script for Fish + powershell Generate the tab-completion script for PowerShell + zsh Generate the tab-completion script for Zsh Options: -h, --help help for completion diff --git a/test/integration/__snapshots__/extension_test.snap b/test/integration/__snapshots__/extension_test.snap index 80efdd3b..58ea2cc2 100644 --- a/test/integration/__snapshots__/extension_test.snap +++ b/test/integration/__snapshots__/extension_test.snap @@ -7,7 +7,7 @@ Usage: lstk [options] [command] LSTK - LocalStack command-line interface Commands: - completion Generate the autocompletion script for the specified shell + completion Set up tab completion for your shell config Manage configuration help Help about any command load Load a snapshot into the running emulator @@ -67,7 +67,7 @@ Usage: lstk [options] [command] LSTK - LocalStack command-line interface Commands: - completion Generate the autocompletion script for the specified shell + completion Set up tab completion for your shell config Manage configuration help Help about any command load Load a snapshot into the running emulator @@ -116,7 +116,7 @@ Usage: lstk [options] [command] LSTK - LocalStack command-line interface Commands: - completion Generate the autocompletion script for the specified shell + completion Set up tab completion for your shell config Manage configuration help Help about any command load Load a snapshot into the running emulator diff --git a/test/integration/completion_tip_test.go b/test/integration/completion_tip_test.go index efca0c76..193f25c5 100644 --- a/test/integration/completion_tip_test.go +++ b/test/integration/completion_tip_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "os" "path/filepath" + "slices" + "strings" "testing" "time" @@ -13,10 +15,9 @@ import ( ) // completionTipText is asserted verbatim, "> Tip: " prefix included: that -// prefix is the convention the neighbouring post-start tips use (tipsForType in -// internal/container/start.go), so it is observable behavior, not styling. -const completionTipText = "> Tip: Enable tab completion for your shell: lstk completion [bash|zsh|fish|powershell] " + - "See https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/#shell-completions" +// prefix is the convention the other post-start tips use (tipsForType in +// internal/container/tips.go), so it is observable behavior, not styling. +const completionTipText = "> Tip: Set up tab completion: lstk completion" // firstRunHome returns an isolated home with no lstk config, so the run under // test is a first run (config.toml absent is what firstRun means). @@ -35,6 +36,23 @@ func firstRunHome(t *testing.T) (env.Environ, string) { return e, configPath } +// distinctTips returns the unique "> Tip:" lines in out. Bubble Tea repaints +// lines, so a raw occurrence count would overstate. +func distinctTips(out string) []string { + var tips []string + for _, line := range strings.Split(out, "\n") { + i := strings.Index(line, "> Tip:") + if i < 0 { + continue + } + tip := strings.TrimSpace(line[i:]) + if !slices.Contains(tips, tip) { + tips = append(tips, tip) + } + } + return tips +} + func TestFirstRunShowsCompletionTip(t *testing.T) { requireDocker(t) _ = env.Require(t, env.AuthToken) @@ -60,7 +78,9 @@ func TestFirstRunShowsCompletionTip(t *testing.T) { out, err := p.wait() require.NoError(t, err, "lstk start should exit successfully") - assert.Contains(t, out, completionTipText, "first successful interactive start should point at shell completion setup") + // Exactly one tip, and it is this one — two tips compete and neither lands (#484). + assert.Equal(t, []string{completionTipText}, distinctTips(out), + "first successful interactive start should point at shell completion setup, and show no other tip") } // --type answers the first-run picker, so it suppresses it — but the run is @@ -117,6 +137,7 @@ func TestSubsequentRunDoesNotShowCompletionTip(t *testing.T) { require.NoError(t, err, "lstk start should exit successfully") assert.NotContains(t, out, completionTipText, "the tip must not repeat once lstk has been configured") + assert.Len(t, distinctTips(out), 1, "a configured run should show the rotating tip, and only that") } func TestFirstRunNonInteractiveShowsNoCompletionTip(t *testing.T) {