From 57a0be63585b920a259884ed25d21cb954e796cd Mon Sep 17 00:00:00 2001 From: Evan Vetere Date: Sun, 12 Jul 2026 14:32:16 -0400 Subject: [PATCH] fix: hard-error on unknown 'auth' subcommands Running 'datumctl auth login' or 'datumctl auth logout' silently reprinted the auth group help and exited 0, giving users no signal the command was invalid. Login and logout moved to the top level in the context-discovery redesign (#149), but older docs and muscle memory still reach for the auth-scoped spelling. The auth command now validates its args: an unrecognized subcommand returns a UserError (exit 1) instead of a success. Names that were promoted to the top level get a targeted hint pointing at the replacement command; other unknown names get a generic error directing users to --help. Both carry a machine-readable Code (AUTH_COMMAND_MOVED, UNKNOWN_SUBCOMMAND) for structured error consumers. Bare 'datumctl auth' still prints help and exits 0; valid subcommands route unchanged. Refs #243 --- internal/cmd/auth/auth.go | 42 +++++++++++++++- internal/cmd/auth/auth_test.go | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/auth/auth_test.go diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go index d9bd72d..5574c94 100644 --- a/internal/cmd/auth/auth.go +++ b/internal/cmd/auth/auth.go @@ -1,11 +1,24 @@ package auth import ( + "fmt" + "github.com/spf13/cobra" + + customerrors "go.datum.net/datumctl/internal/errors" ) -// Command creates the base "auth" command and adds subcommands for login, -// logout, token retrieval, etc. +// movedCommands maps subcommands that used to live under "auth" to their +// current top-level spelling. Login and logout were promoted to top-level +// commands in the context-discovery redesign (#149); users following older +// docs or muscle memory still reach for "datumctl auth login". +var movedCommands = map[string]string{ + "login": "datumctl login", + "logout": "datumctl logout", +} + +// Command creates the base "auth" command and adds subcommands for session +// management, token retrieval, and kubectl integration. func Command() *cobra.Command { cmd := &cobra.Command{ Use: "auth", @@ -33,6 +46,13 @@ Advanced — kubectl integration: # Log out a specific account datumctl logout user@example.com`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + return unknownSubcommandError(args[0]) + }, } cmd.AddCommand( @@ -44,3 +64,21 @@ Advanced — kubectl integration: return cmd } + +// unknownSubcommandError turns an unrecognized "datumctl auth " into a +// hard error instead of a silently-successful help dump. Names that were moved +// to the top level get a hint pointing at the replacement command. +func unknownSubcommandError(name string) error { + if replacement, moved := movedCommands[name]; moved { + return &customerrors.UserError{ + Message: fmt.Sprintf("'datumctl auth %s' is now '%s'", name, replacement), + Hint: fmt.Sprintf("Login and logout are top-level commands — run '%s'.", replacement), + Code: "AUTH_COMMAND_MOVED", + } + } + return &customerrors.UserError{ + Message: fmt.Sprintf("unknown command %q for 'datumctl auth'", name), + Hint: "Run 'datumctl auth --help' for available commands.", + Code: "UNKNOWN_SUBCOMMAND", + } +} diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go new file mode 100644 index 0000000..16167a5 --- /dev/null +++ b/internal/cmd/auth/auth_test.go @@ -0,0 +1,87 @@ +package auth + +import ( + "bytes" + "strings" + "testing" + + customerrors "go.datum.net/datumctl/internal/errors" +) + +func TestAuthUnknownSubcommand(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + wantCode string + wantHint string + }{ + { + name: "bare auth shows help", + args: []string{}, + }, + { + name: "moved login errors with top-level hint", + args: []string{"login"}, + wantErr: true, + wantCode: "AUTH_COMMAND_MOVED", + wantHint: "datumctl login", + }, + { + name: "moved logout errors with top-level hint", + args: []string{"logout"}, + wantErr: true, + wantCode: "AUTH_COMMAND_MOVED", + wantHint: "datumctl logout", + }, + { + name: "unknown subcommand errors generically", + args: []string{"bogus"}, + wantErr: true, + wantCode: "UNKNOWN_SUBCOMMAND", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cmd := Command() + cmd.SetArgs(tc.args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + + if !tc.wantErr { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + + if err == nil { + t.Fatal("expected error, got nil") + } + userErr, ok := customerrors.IsUserError(err) + if !ok { + t.Fatalf("expected *UserError, got %T: %v", err, err) + } + if userErr.Code != tc.wantCode { + t.Errorf("Code = %q, want %q", userErr.Code, tc.wantCode) + } + if tc.wantHint != "" && !strings.Contains(userErr.Hint, tc.wantHint) { + t.Errorf("Hint = %q, want it to contain %q", userErr.Hint, tc.wantHint) + } + }) + } +} + +func TestAuthValidSubcommandStillRoutes(t *testing.T) { + cmd := Command() + sub, _, err := cmd.Find([]string{"list"}) + if err != nil { + t.Fatalf("Find(list) returned error: %v", err) + } + if sub.Name() != "list" { + t.Fatalf("expected to resolve the 'list' subcommand, got %q", sub.Name()) + } +}