From b455075f3ef2fb426721dad1b1e4417168fcaaad Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 13 Oct 2026 06:40:15 +0000 Subject: [PATCH] Make a mistyped subcommand under a group command an error (closes #319) snapshot, config, remote, database and completion had no Run, and cobra checks a command's arguments only when it has one, so `vaultik snapshot craete` printed the group's help and exited 0. Each group now prints its help only when given no argument; any other word fails as an unknown command with exit 1, as at the top level. cobra adds the completion command only when Execute runs, so the root command now adds it itself to give it a Run. Its scripts then go to os.Stdout as it was at that moment; a later SetOut does not redirect them. Judgement call: the error has no "Did you mean" suggestion, which the top level gives. Model: opus-5-5 --- internal/cli/config.go | 6 +++ internal/cli/database.go | 6 +++ internal/cli/entry_unknown_subcommand_test.go | 43 +++++++++++++++++++ internal/cli/remote.go | 6 +++ internal/cli/root.go | 15 +++++++ internal/cli/snapshot.go | 6 +++ 6 files changed, 82 insertions(+) create mode 100644 internal/cli/entry_unknown_subcommand_test.go diff --git a/internal/cli/config.go b/internal/cli/config.go index 545f81a..ea477bd 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -251,6 +251,12 @@ func NewConfigCommand() *cobra.Command { Use: "config", Short: "Manage the configuration file", Long: "Commands for creating, editing, and querying the vaultik config file.", + // cobra checks Args only on a command that has a Run: without + // one, a mistyped subcommand printed help and exited 0. + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + _ = cmd.Help() + }, } cmd.AddCommand(newConfigInitCommand()) diff --git a/internal/cli/database.go b/internal/cli/database.go index 04605df..1ae477b 100644 --- a/internal/cli/database.go +++ b/internal/cli/database.go @@ -15,6 +15,12 @@ func NewDatabaseCommand() *cobra.Command { Use: "database", Short: "Manage the local state database", Long: `Commands for managing the local SQLite state database.`, + // cobra checks Args only on a command that has a Run: without + // one, a mistyped subcommand printed help and exited 0. + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + _ = cmd.Help() + }, } cmd.AddCommand( diff --git a/internal/cli/entry_unknown_subcommand_test.go b/internal/cli/entry_unknown_subcommand_test.go new file mode 100644 index 0000000..08c9647 --- /dev/null +++ b/internal/cli/entry_unknown_subcommand_test.go @@ -0,0 +1,43 @@ +package cli //nolint:testpackage // shares runEntry and the command-word constants + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestEntryGroupRejectsUnknownSubcommand runs each group command with a +// mistyped subcommand, as a crontab line might carry one. It must fail +// with exit 1 and name the word, as `vaultik bogus` does at the top +// level, instead of printing the group's help and exiting 0 having done +// nothing. The group name alone still prints its help. +// +//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr +func TestEntryGroupRejectsUnknownSubcommand(t *testing.T) { + for _, testCase := range []struct { + group string + mistyped string + }{ + {group: cmdSnapshot, mistyped: "craete"}, + {group: "config", mistyped: "inti"}, + {group: cmdRemote, mistyped: "ifno"}, + {group: "database", mistyped: "delte"}, + {group: "completion", mistyped: "nosuchshell"}, + } { + t.Run(testCase.group, func(t *testing.T) { + code, stdout, stderr := runEntry(t, + testCase.group, testCase.mistyped) + + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, + `unknown command "`+testCase.mistyped+`"`) + + code, stdout, _ = runEntry(t, testCase.group) + + assert.Equal(t, 0, code) + assert.Contains(t, stdout, + programName+" "+testCase.group+" [command]") + }) + } +} diff --git a/internal/cli/remote.go b/internal/cli/remote.go index dd72e9f..4bf403f 100644 --- a/internal/cli/remote.go +++ b/internal/cli/remote.go @@ -18,6 +18,12 @@ func NewRemoteCommand() *cobra.Command { Use: "remote", Short: "Remote storage management commands", Long: "Commands for inspecting and managing remote storage", + // cobra checks Args only on a command that has a Run: without + // one, a mistyped subcommand printed help and exited 0. + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + _ = cmd.Help() + }, } // Add subcommands diff --git a/internal/cli/root.go b/internal/cli/root.go index 463eb1d..ba1fcbc 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -74,6 +74,21 @@ on the source system.`, NewDatabaseCommand(), ) + // cobra adds the completion command only when Execute runs, and with + // no Run, so it skipped its own cobra.NoArgs and a mistyped shell name + // printed help and exited 0. Adding it here lets it have a Run. Its + // scripts go to os.Stdout as it is now: a later SetOut does not + // redirect them. + cmd.InitDefaultCompletionCmd() + + for _, sub := range cmd.Commands() { + if sub.Name() == "completion" { + sub.Run = func(completion *cobra.Command, _ []string) { + _ = completion.Help() + } + } + } + return cmd } diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index f157102..0997140 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -41,6 +41,12 @@ func NewSnapshotCommand() *cobra.Command { Use: "snapshot", Short: "Snapshot management commands", Long: "Commands for creating, listing, and managing snapshots", + // cobra checks Args only on a command that has a Run: without + // one, a mistyped subcommand printed help and exited 0. + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + _ = cmd.Help() + }, } // Add subcommands