Skip to content
Merged
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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,43 @@ Releases before `0.2.0` predate this file. Their notes are on the

## [Unreleased]

### Added
- `--wide` keeps every table column even when the table is wider than the
terminal.

### Fixed
- A mistyped subcommand now fails instead of succeeding. Cobra checks for
unknown commands only on the root command, so `namecom domain regsiter
example.com` printed the group's help and exited **0** — meaning
`namecom domain regsiter foo.com && deploy` ran `deploy`. Every command
group now rejects an unknown subcommand as a usage error (exit 2) and
offers the same "Did you mean this?" suggestion the root command does.
Invoking a group bare still prints its help and exits 0.
- Invocation mistakes now exit **2**, as the documented exit-code table has
always claimed. Flag-value validation returned unclassified errors and
cobra's own required-flag check runs where `SetFlagErrorFunc` cannot see
it, so `--type ZZZ` and a missing `--answer` both exited 1 — the same code
a script uses to detect a server error — while `--badflag` beside them
exited 2.
- Tables no longer overflow the terminal. They rendered at natural width
regardless of it (`domain list` came to 113 columns, `order list` 99,
`dns list` 87), so in an 80-column pane the rounded borders wrapped into
fragments. Trailing columns are now dropped until the table fits, with a
footer naming what was hidden; `--wide` restores them, and piped output is
unaffected.
- Relative dates widen their unit past a quarter, so a domain paid through
2034 reads `in 8 years` rather than `in 2750 days`.
- `--dry-run` said it printed "the API request that would be sent without
executing it", but only write operations honour it; reads always called the
API. The flag now says so rather than implying an invocation touches
nothing.
- `dns create --type` omitted `CAA` from its list of record types, which the
validator has always accepted.
- Help pages put `Examples:` directly under the usage line instead of below
the flag tables and footer, command groups show `namecom <group> <command>`
instead of the uninvokable `namecom <group> [flags]`, and non-string flag
defaults print unquoted (`default 300`, not `default "300"`).

## [0.2.4] - 2026-08-17

### Changed
Expand Down
1 change: 1 addition & 0 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ var logoutProfile string
func init() {
authLoginCmd.Flags().StringVar(&loginProfile, "profile", "default", "profile name to save credentials under")
authLogoutCmd.Flags().StringVar(&logoutProfile, "profile", "", "profile to remove (defaults to the active profile)")
cmdutil.GroupCmd(authCmd)
authCmd.AddCommand(authLoginCmd, authStatusCmd, authLogoutCmd)
rootCmd.AddCommand(authCmd)
}
Expand Down
45 changes: 45 additions & 0 deletions cmd/cmdutil/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,48 @@ func joinNames(names []string) string {
return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1]
}
}

// GroupCmd wires a command group — a parent that exists only to hold
// subcommands — so a mistyped subcommand fails instead of succeeding quietly.
//
// Cobra only checks for unknown commands on the ROOT command: legacyArgs()
// returns nil for any parent that itself has a parent. So `namecom domian`
// errored with a suggestion, while `namecom domain regsiter example.com`
// printed the group's help and exited 0. In a script that reads as success —
// `namecom domain regsiter foo.com && deploy` ran deploy.
//
// Bare `namecom domain` keeps its old behavior of printing help and exiting 0,
// which is what a user typing a group name to browse it expects.
func GroupCmd(cmd *cobra.Command) *cobra.Command {
// Cobra defaults this to 2, but only on the root command, inside the
// unknown-command path we are replacing here. Left at zero, SuggestionsFor
// matches nothing and every typo loses its "Did you mean" line.
if cmd.SuggestionsMinimumDistance <= 0 {
cmd.SuggestionsMinimumDistance = 2
}
cmd.Args = func(c *cobra.Command, args []string) error {
if len(args) == 0 {
return nil
}
return NewUsageError(fmt.Errorf("unknown command %q for %q%s",
args[0], c.CommandPath(), suggestionHint(c.SuggestionsFor(args[0]))))
}
cmd.RunE = func(c *cobra.Command, _ []string) error {
return c.Help()
}
return cmd
}

// suggestionHint renders cobra's near-miss list the way cobra renders it on the
// root command, so a typo reads the same wherever in the tree it happens.
func suggestionHint(suggestions []string) string {
if len(suggestions) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\nDid you mean this?\n")
for _, s := range suggestions {
fmt.Fprintf(&b, "\t%s\n", s)
}
return b.String()
}
66 changes: 66 additions & 0 deletions cmd/cmdutil/args_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmdutil

import (
"bytes"
"errors"
"strings"
"testing"

Expand Down Expand Up @@ -161,3 +163,67 @@ func TestExactArgs_HintContainsUseLine(t *testing.T) {
t.Errorf("error = %q, missing command path in hint", err.Error())
}
}

// TestGroupCmd guards the unknown-subcommand check on command groups.
//
// Cobra's legacyArgs only rejects unknown commands on the ROOT command — for
// any parent that itself has a parent it returns nil, so cobra fell through to
// "not runnable", printed help, and returned no error. `namecom domain
// regsiter example.com` therefore exited 0, and
// `namecom domain regsiter foo.com && deploy` deployed.
func TestGroupCmd(t *testing.T) {
newGroup := func() *cobra.Command {
root := &cobra.Command{Use: "namecom"}
group := GroupCmd(&cobra.Command{Use: "domain"})
group.AddCommand(&cobra.Command{Use: "register", Run: func(*cobra.Command, []string) {}})
group.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}})
root.AddCommand(group)
return group
}

t.Run("unknown subcommand is a usage error", func(t *testing.T) {
group := newGroup()
err := group.Args(group, []string{"regsiter", "example.com"})
if err == nil {
t.Fatal("unknown subcommand accepted; it would print help and exit 0")
}
var u *UsageError
if !errors.As(err, &u) {
t.Errorf("error is not a UsageError, so it would exit 1 not 2: %v", err)
}
if !strings.Contains(err.Error(), `unknown command "regsiter"`) {
t.Errorf("error does not name the typo: %v", err)
}
if !strings.Contains(err.Error(), "register") {
t.Errorf("error carries no suggestion: %v", err)
}
})

t.Run("no args is allowed so the group can print help", func(t *testing.T) {
group := newGroup()
if err := group.Args(group, nil); err != nil {
t.Errorf("bare group name rejected: %v", err)
}
})

t.Run("bare group prints help rather than erroring", func(t *testing.T) {
group := newGroup()
var buf bytes.Buffer
group.SetOut(&buf)
if err := group.RunE(group, nil); err != nil {
t.Fatalf("group RunE returned an error: %v", err)
}
if !strings.Contains(buf.String(), "register") {
t.Errorf("bare group did not print its subcommands:\n%s", buf.String())
}
})

t.Run("suggestion distance is set", func(t *testing.T) {
// Cobra defaults this to 2 only inside the root-command path being
// replaced here. Left at zero, SuggestionsFor matches nothing.
group := newGroup()
if group.SuggestionsMinimumDistance <= 0 {
t.Error("SuggestionsMinimumDistance not set; every typo loses its hint")
}
})
}
41 changes: 41 additions & 0 deletions cmd/cmdutil/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
"strings"

"github.com/patramsey/namecom-cli/internal/api"
)
Expand Down Expand Up @@ -88,3 +89,43 @@ func AsRestricted(err error, operation, program string) error {
}
return err
}

// cobraUsagePrefixes are the messages cobra produces for invocation mistakes it
// validates itself, after our own hooks have run.
//
// SetFlagErrorFunc in root.go covers flag *parsing*, but cobra checks required
// flags and flag groups later, inside execute(), and offers no hook for that
// path — a missing required flag surfaced as a bare error and exited 1 while
// `--badflag` right next to it exited 2. Matching the message is unpleasant but
// it is the only seam cobra exposes; the strings are stable and asserted by
// TestClassifyCobraUsage, which fails loudly if an upgrade rewords one.
var cobraUsagePrefixes = []string{
"required flag(s) ",
"if any flags in the group ",
"unknown command ",
"unknown flag: ",
"unknown shorthand flag: ",
"invalid argument ",
"flag needs an argument",
}

// ClassifyCobraUsage wraps cobra's own invocation errors as UsageError so they
// reach the documented exit code 2. Errors that are already classified, and
// errors from anywhere else, pass through untouched.
func ClassifyCobraUsage(err error) error {
if err == nil {
return nil
}
var usage *UsageError
var auth *AuthError
if errors.As(err, &usage) || errors.As(err, &auth) {
return err
}
msg := err.Error()
for _, p := range cobraUsagePrefixes {
if strings.Contains(msg, p) {
return NewUsageError(err)
}
}
return err
}
55 changes: 55 additions & 0 deletions cmd/cmdutil/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,58 @@ func TestNewUsageError(t *testing.T) {
t.Error("NewUsageError should preserve the original error in the chain")
}
}

// TestClassifyCobraUsage guards the message matching that maps cobra's own
// invocation errors onto exit code 2.
//
// Cobra validates required flags and flag groups inside execute(), after
// SetFlagErrorFunc has had its chance, and exposes no hook for that path. So
// `dns create example.com` with two required flags missing exited 1 while
// `dns list example.com --badflag` right beside it exited 2. Matching the
// message is the only seam available; these cases fail if a cobra upgrade
// rewords one, which is the point of asserting them.
func TestClassifyCobraUsage(t *testing.T) {
usage := func(err error) bool {
var u *UsageError
return errors.As(err, &u)
}

cobraMessages := []string{
`required flag(s) "answer", "type" not set`,
`if any flags in the group [a b] are set they must all be set; missing [b]`,
`unknown command "regsiter" for "namecom domain"`,
`unknown flag: --badflag`,
`unknown shorthand flag: 'z' in -z`,
`invalid argument "abc" for "--ttl" flag: strconv.ParseInt: parsing "abc": invalid syntax`,
`flag needs an argument: --type`,
}
for _, msg := range cobraMessages {
if got := ClassifyCobraUsage(errors.New(msg)); !usage(got) {
t.Errorf("ClassifyCobraUsage(%q) did not classify as a usage error", msg)
}
}

t.Run("leaves other errors alone", func(t *testing.T) {
runtime := errors.New("connection refused")
if got := ClassifyCobraUsage(runtime); usage(got) {
t.Errorf("misclassified a runtime error as usage: %v", got)
}
})

t.Run("nil stays nil", func(t *testing.T) {
if got := ClassifyCobraUsage(nil); got != nil {
t.Errorf("ClassifyCobraUsage(nil) = %v, want nil", got)
}
})

t.Run("does not re-wrap an already-classified error", func(t *testing.T) {
// An AuthError whose text happens to mention an unknown flag must keep
// its exit code 3 rather than being demoted to a usage error.
auth := NewAuthError(errors.New("unknown flag: --token was not usable"))
got := ClassifyCobraUsage(auth)
var a *AuthError
if !errors.As(got, &a) {
t.Errorf("ClassifyCobraUsage demoted an AuthError: %v", got)
}
})
}
Loading