diff --git a/cmd/completion_version_test.go b/cmd/completion_version_test.go new file mode 100644 index 0000000..3731f2a --- /dev/null +++ b/cmd/completion_version_test.go @@ -0,0 +1,93 @@ +package cmd + +// completion_version_test.go — covers the cobra-override hygiene in +// root.go's init(): +// - `instant completion` (no shell arg) returns a non-zero exit +// code (BUG-CLI-016, QA 2026-05-29). +// - Every default completion sub-shell (`bash | zsh | fish | +// powershell`) still returns nil — only the bare invocation +// changes (rule 18 registry-iterating). +// - `instant version` alias is registered and exits 0 with the +// same one-line shape `instant version ` cobra emits +// for `--version` (BUG-CLI-041). + +import ( + "errors" + "strings" + "testing" +) + +// TestCompletion_NoShellArg_ReturnsError pins BUG-CLI-016: the bare +// `instant completion` invocation must return a non-nil error (which +// main.go translates to exit code 1 via ExitCodeFor). Pre-fix cobra +// printed help and exited 0, which a CI script reading `$?` could not +// distinguish from a successful shell-completion-script generation. +func TestCompletion_NoShellArg_ReturnsError(t *testing.T) { + err := ExecuteWithArgs([]string{"completion"}) + if err == nil { + t.Fatalf("BUG-CLI-016: `instant completion` must return a non-nil error; got nil (would exit 0)") + } + // Error message contract: must name `instant completion` and list + // the supported shells. A script grepping for "shell argument + // required" should branch on the error. + got := err.Error() + if !strings.Contains(got, "shell argument required") { + t.Errorf("BUG-CLI-016: error message must mention 'shell argument required'; got %q", got) + } + for _, shell := range []string{"bash", "zsh", "fish", "powershell"} { + if !strings.Contains(got, shell) { + t.Errorf("BUG-CLI-016: error message must list %q as a supported shell; got %q", shell, got) + } + } + // ExitCodeFor must classify this as ExitGeneric (1), not ExitOK (0). + if code := ExitCodeFor(err); code != ExitGeneric { + t.Errorf("BUG-CLI-016: ExitCodeFor(...) = %d, want %d (ExitGeneric)", code, ExitGeneric) + } +} + +// TestCompletion_EveryShellSubcommandStillSucceeds is the rule-18 +// registry-iterating guard against the "I fixed the bare command but +// broke every sub-shell" regression. The list mirrors cobra's +// InitDefaultCompletionCmd registry — bash, zsh, fish, powershell. +// Adding a new shell to cobra without adding it here means the new +// shell escapes the test net (the fix could silently turn it into the +// same usage-error path as the bare command). +func TestCompletion_EveryShellSubcommandStillSucceeds(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "powershell"} { + shell := shell // capture + t.Run(shell, func(t *testing.T) { + err := ExecuteWithArgs([]string{"completion", shell}) + if err != nil { + t.Errorf("BUG-CLI-016: `instant completion %s` must still succeed (script generation); got %v", + shell, err) + } + }) + } +} + +// TestVersion_AliasExitsZero pins BUG-CLI-041: `instant version` is +// the convention alias for `instant --version`. Pre-fix cobra +// rejected it with "unknown command 'version'" (exit=1). Now it must +// behave like --version: exit 0, no error. +func TestVersion_AliasExitsZero(t *testing.T) { + err := ExecuteWithArgs([]string{"version"}) + if err != nil { + t.Fatalf("BUG-CLI-041: `instant version` alias must exit 0; got %v", err) + } + if code := ExitCodeFor(err); code != ExitOK { + t.Errorf("BUG-CLI-041: ExitCodeFor(...) = %d, want %d (ExitOK)", code, ExitOK) + } +} + +// TestVersion_AliasExtraArgsRejected confirms cobra.NoArgs is enforced — +// `instant version foo` should fail with a usage-style error so a +// script can detect typos. +func TestVersion_AliasExtraArgsRejected(t *testing.T) { + err := ExecuteWithArgs([]string{"version", "foo"}) + if err == nil { + t.Fatalf("BUG-CLI-041: `instant version foo` must reject extra args; got nil") + } + if !errors.Is(err, err) { // sanity (always true) — keep the import live + _ = err + } +} diff --git a/cmd/root.go b/cmd/root.go index 98e4b51..8afac6f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -175,6 +175,48 @@ func init() { // flag parsing — so the auth transport sees the flag value. rootCmd.PersistentFlags().StringVar(&adHocToken, "token", "", "Bearer token for this invocation (overrides INSTANT_TOKEN and saved login)") + + // BUG-CLI-016 (QA 2026-05-29): cobra's default `completion` parent + // command prints its help and exits 0 when invoked with no shell + // argument. That's the wrong contract for CI/wrapper scripts — + // "no shell selected" is a usage error, not success. We need to + // force-init the default completion subtree (cobra normally adds + // it lazily inside Execute()), then stamp a RunE that returns an + // error so cobra-emitted exit propagates to main.go::ExitCodeFor. + // Sub-shells (`completion bash`, etc.) keep their original RunE — + // only the bare `completion` invocation changes. + rootCmd.InitDefaultCompletionCmd() + for _, c := range rootCmd.Commands() { + if c.Name() == "completion" { + c.RunE = func(cmd *cobra.Command, args []string) error { + _ = cmd.Help() + // Plain error → ExitCodeFor falls through to ExitGeneric (1). + // "shell argument required" is a usage error, not a runtime + // failure of the requested shell-completion generation. + return fmt.Errorf("instant completion: shell argument required (bash | zsh | fish | powershell)") + } + break + } + } + + // BUG-CLI-041 (QA 2026-05-29): many CLIs accept both `version` and + // `--version`. Cobra wires `--version`/`-v` via rootCmd.Version, + // but `instant version` returned "unknown command 'version'" with + // exit=1 — confusing for users muscle-memorying `git version` / + // `node version` patterns. Register an explicit `version` alias + // that prints the same line cobra emits for `--version`. + rootCmd.AddCommand(&cobra.Command{ + Use: "version", + Short: "Print version, commit SHA, build time (alias for `instant --version`)", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + // rootCmd.Version is shaped "vX.Y.Z (sha, buildtime)" by + // SetBuildInfo. Mirror cobra's default `--version` output + // ("instant version vX.Y.Z (sha, buildtime)") so a script + // that grep-greps either path sees the same string. + fmt.Printf("%s version %s\n", rootCmd.Name(), rootCmd.Version) + }, + }) } func initConfig() {