diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 674c2a390..2ff361b2c 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -99,15 +99,32 @@ func Run(args []string) (err error) { if errors.Is(err, flag.ErrHelp) { return nil } + if noexec := (ffcli.NoExecError{}); errors.As(err, &noexec) { + // When the user enters an unknown subcommand, ffcli tries to run + // the closest valid parent subcommand with everything else as args, + // returning NoExecError if it doesn't have an Exec function. + cmd := noexec.Command + args := cmd.FlagSet.Args() + if len(cmd.Subcommands) > 0 { + if len(args) > 0 { + return fmt.Errorf("%s: unknown subcommand: %s", fullCmd(rootCmd, cmd), args[0]) + } + subs := make([]string, 0, len(cmd.Subcommands)) + for _, sub := range cmd.Subcommands { + subs = append(subs, sub.Name) + } + return fmt.Errorf("%s: missing subcommand: %s", fullCmd(rootCmd, cmd), strings.Join(subs, ", ")) + } + } return err } if envknob.Bool("TS_DUMP_HELP") { walkCommands(rootCmd, func(w cmdWalk) bool { fmt.Println("===") - c := w.cmd // UsageFuncs are typically called during Command.Run which ensures // FlagSet is not nil. + c := w.Command if c.FlagSet == nil { c.FlagSet = flag.NewFlagSet(c.Name, flag.ContinueOnError) } @@ -182,7 +199,12 @@ change in the future. driveCmd, }, FlagSet: rootfs, - Exec: func(context.Context, []string) error { return flag.ErrHelp }, + Exec: func(ctx context.Context, args []string) error { + if len(args) > 0 { + return fmt.Errorf("tailscale: unknown subcommand: %s", args[0]) + } + return flag.ErrHelp + }, } if envknob.UseWIPCode() { rootCmd.Subcommands = append(rootCmd.Subcommands, @@ -195,8 +217,8 @@ change in the future. } walkCommands(rootCmd, func(w cmdWalk) bool { - if w.cmd.UsageFunc == nil { - w.cmd.UsageFunc = usageFunc + if w.UsageFunc == nil { + w.UsageFunc = usageFunc } return true }) @@ -220,10 +242,24 @@ var rootArgs struct { } type cmdWalk struct { - cmd *ffcli.Command + *ffcli.Command parents []*ffcli.Command } +func (w cmdWalk) Path() string { + if len(w.parents) == 0 { + return w.Name + } + + var sb strings.Builder + for _, p := range w.parents { + sb.WriteString(p.Name) + sb.WriteString(" ") + } + sb.WriteString(w.Name) + return sb.String() +} + // walkCommands calls f for root and all of its nested subcommands until f // returns false or all have been visited. func walkCommands(root *ffcli.Command, f func(w cmdWalk) (more bool)) { @@ -243,6 +279,21 @@ func walkCommands(root *ffcli.Command, f func(w cmdWalk) (more bool)) { walk(root, nil, f) } +// fullCmd returns the full "tailscale ... cmd" invocation for a subcommand. +func fullCmd(root, cmd *ffcli.Command) (full string) { + walkCommands(root, func(w cmdWalk) bool { + if w.Command == cmd { + full = w.Path() + return false + } + return true + }) + if full == "" { + return cmd.Name + } + return full +} + // usageFuncNoDefaultValues is like usageFunc but doesn't print default values. func usageFuncNoDefaultValues(c *ffcli.Command) string { return usageFuncOpt(c, false) diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go index 3d7cca51b..90a7063f4 100644 --- a/cmd/tailscale/cli/cli_test.go +++ b/cmd/tailscale/cli/cli_test.go @@ -40,7 +40,7 @@ func TestShortUsage(t *testing.T) { } walkCommands(newRootCmd(), func(w cmdWalk) bool { - c, parents := w.cmd, w.parents + c, parents := w.Command, w.parents // Words that we expect to be in the usage. words := make([]string, len(parents)+1) diff --git a/cmd/tailscale/cli/configure.go b/cmd/tailscale/cli/configure.go index 0b4703e79..fd136d766 100644 --- a/cmd/tailscale/cli/configure.go +++ b/cmd/tailscale/cli/configure.go @@ -4,7 +4,6 @@ package cli import ( - "context" "flag" "runtime" "strings" @@ -26,9 +25,6 @@ services on the host to use Tailscale in more ways. return fs })(), Subcommands: configureSubcommands(), - Exec: func(ctx context.Context, args []string) error { - return flag.ErrHelp - }, } func configureSubcommands() (out []*ffcli.Command) { diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go index 3c95b75b8..f54b694b9 100644 --- a/cmd/tailscale/cli/debug.go +++ b/cmd/tailscale/cli/debug.go @@ -346,7 +346,7 @@ func outName(dst string) string { func runDebug(ctx context.Context, args []string) error { if len(args) > 0 { - return errors.New("unknown arguments") + return fmt.Errorf("tailscale debug: unknown subcommand: %s", args[0]) } var usedFlag bool if out := debugArgs.cpuFile; out != "" { @@ -401,7 +401,7 @@ func runDebug(ctx context.Context, args []string) error { // to subcommands. return nil } - return errors.New("see 'tailscale debug --help") + return errors.New("tailscale debug: subcommand or flag required") } func runLocalCreds(ctx context.Context, args []string) error { diff --git a/cmd/tailscale/cli/drive.go b/cmd/tailscale/cli/drive.go index f40742091..d05685152 100644 --- a/cmd/tailscale/cli/drive.go +++ b/cmd/tailscale/cli/drive.go @@ -5,7 +5,6 @@ package cli import ( "context" - "errors" "fmt" "strings" @@ -57,9 +56,6 @@ var driveCmd = &ffcli.Command{ Exec: runDriveList, }, }, - Exec: func(context.Context, []string) error { - return errors.New("drive subcommand required; run 'tailscale drive -h' for details") - }, } // runDriveShare is the entry point for the "tailscale drive share" command. diff --git a/cmd/tailscale/cli/exitnode.go b/cmd/tailscale/cli/exitnode.go index fc82fbf0a..fed2d64c1 100644 --- a/cmd/tailscale/cli/exitnode.go +++ b/cmd/tailscale/cli/exitnode.go @@ -25,10 +25,6 @@ func exitNodeCmd() *ffcli.Command { Name: "exit-node", ShortUsage: "tailscale exit-node [flags]", ShortHelp: "Show machines on your tailnet configured as exit nodes", - LongHelp: "Show machines on your tailnet configured as exit nodes", - Exec: func(context.Context, []string) error { - return errors.New("exit-node subcommand required; run 'tailscale exit-node -h' for details") - }, Subcommands: append([]*ffcli.Command{ { Name: "list", diff --git a/cmd/tailscale/cli/file.go b/cmd/tailscale/cli/file.go index c76ad80bb..4f769eee3 100644 --- a/cmd/tailscale/cli/file.go +++ b/cmd/tailscale/cli/file.go @@ -44,12 +44,6 @@ var fileCmd = &ffcli.Command{ fileCpCmd, fileGetCmd, }, - Exec: func(context.Context, []string) error { - // TODO(bradfitz): is there a better ffcli way to - // annotate subcommand-required commands that don't - // have an exec body of their own? - return errors.New("file subcommand required; run 'tailscale file -h' for details") - }, } type countingReader struct { diff --git a/cmd/tailscale/cli/network-lock.go b/cmd/tailscale/cli/network-lock.go index ff256992a..26bc3857c 100644 --- a/cmd/tailscale/cli/network-lock.go +++ b/cmd/tailscale/cli/network-lock.go @@ -26,7 +26,7 @@ import ( var netlockCmd = &ffcli.Command{ Name: "lock", - ShortUsage: "tailscale lock ", + ShortUsage: "tailscale lock [arguments...]", ShortHelp: "Manage tailnet lock", LongHelp: "Manage tailnet lock", Subcommands: []*ffcli.Command{ @@ -49,6 +49,9 @@ func runNetworkLockNoSubcommand(ctx context.Context, args []string) error { if len(args) >= 2 && args[0] == "tskey-wrap" { return runTskeyWrapCmd(ctx, args[1:]) } + if len(args) > 0 { + return fmt.Errorf("tailscale lock: unknown subcommand: %s", args[0]) + } return runNetworkLockStatus(ctx, args) } @@ -195,6 +198,10 @@ var nlStatusCmd = &ffcli.Command{ } func runNetworkLockStatus(ctx context.Context, args []string) error { + if len(args) > 0 { + return fmt.Errorf("tailscale lock status: unexpected argument") + } + st, err := localClient.NetworkLockStatus(ctx) if err != nil { return fixTailscaledConnectError(err)