diff --git a/CHANGELOG.md b/CHANGELOG.md index 762e438f52..b42d5f8b22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to `src-cli` are documented in this file. - HTTP requests now fail instead of hanging forever if the server does not start responding within 1 minute. Set the `SRC_RESPONSE_HEADER_TIMEOUT` environment variable to change this timeout, or to `0` to disable it. Responses that stream data for a long time (for example, large search job results) are not affected. - `src search-jobs logs` and `src search-jobs results` now use the standard API client, gaining proxy support, `-insecure-skip-verify`, and cross-host redirect protection, and now report an error on non-200 responses instead of writing the error page into the output. +- The command list in `src help` is now generated from the registered commands instead of being maintained by hand. `src debug`, `src snapshot`, and `src lsp` now appear in it; aliases are shown after each description. ### Fixed diff --git a/cmd/src/batch.go b/cmd/src/batch.go index fd42257f7d..82ecaba9a3 100644 --- a/cmd/src/batch.go +++ b/cmd/src/batch.go @@ -37,7 +37,8 @@ Use "src batch [command] -h" for more information about a command. // Register the command. commands = append(commands, &command{ - flagSet: flagSet, + flagSet: flagSet, + description: "manages batch changes", aliases: []string{ "batchchange", "batch-change", diff --git a/cmd/src/cmd.go b/cmd/src/cmd.go index 9182dc57ea..65d1d9982a 100644 --- a/cmd/src/cmd.go +++ b/cmd/src/cmd.go @@ -22,6 +22,14 @@ type command struct { // flagSet.Usage function to invoke on e.g. -h flag. If nil, a default one is // used. usageFunc func() + + // description is the one-line summary shown next to the command in + // 'src help'. Required for top-level commands unless hidden is set. + description string + + // hidden excludes the command from 'src help' and from the reference + // documentation generated by 'src doc'. It can still be run. + hidden bool } // matches tells if the given name matches this command or one of its aliases. diff --git a/cmd/src/code_intel.go b/cmd/src/code_intel.go index 3dc3f28d8c..7e11db9a4d 100644 --- a/cmd/src/code_intel.go +++ b/cmd/src/code_intel.go @@ -28,9 +28,10 @@ Use "src code-intel [command] -h" for more information about a command. // Register the command. commands = append(commands, &command{ - flagSet: flagSet, - aliases: []string{"code-intel"}, - handler: handler, + flagSet: flagSet, + description: "manages code intelligence data", + aliases: []string{"code-intel"}, + handler: handler, usageFunc: func() { fmt.Println(usage) }, diff --git a/cmd/src/config.go b/cmd/src/config.go index f04966b972..e6e7dfc937 100644 --- a/cmd/src/config.go +++ b/cmd/src/config.go @@ -45,8 +45,9 @@ Use "src config [command] -h" for more information about a command. // Register the command. commands = append(commands, &command{ - flagSet: flagSet, - handler: handler, + flagSet: flagSet, + description: "manages global, org, and user settings", + handler: handler, usageFunc: func() { fmt.Println(usage) }, diff --git a/cmd/src/debug.go b/cmd/src/debug.go index 2a513cb1c4..4ee47d3781 100644 --- a/cmd/src/debug.go +++ b/cmd/src/debug.go @@ -34,9 +34,9 @@ src debug has access to flags on src -- Ex: src -v kube -o foo.zip // Register the command. commands = append(commands, &command{ - flagSet: flagSet, - aliases: []string{}, - handler: handler, - usageFunc: func() { fmt.Println(usage) }, + flagSet: flagSet, + description: "gathers and bundles debug data from a Sourcegraph deployment for troubleshooting", + handler: handler, + usageFunc: func() { fmt.Println(usage) }, }) } diff --git a/cmd/src/doc.go b/cmd/src/doc.go index ebc3018dba..d8108c4aa1 100644 --- a/cmd/src/doc.go +++ b/cmd/src/doc.go @@ -82,7 +82,7 @@ Examples: name, }, " ")) - if fqcn == "doc" || fqcn == "publish" { + if cmd.hidden { continue } @@ -176,6 +176,7 @@ Examples: commands = append(commands, &command{ flagSet: flagSet, + hidden: true, handler: handler, usageFunc: func() { fmt.Fprintln(flag.CommandLine.Output(), usage) diff --git a/cmd/src/doc_test.go b/cmd/src/doc_test.go index 10d5b0189e..e4a16ca7eb 100644 --- a/cmd/src/doc_test.go +++ b/cmd/src/doc_test.go @@ -187,9 +187,10 @@ func TestDocLegacyGroupsHaveSubcommandPages(t *testing.T) { } } -// The root index must link every top-level command, both legacy (commander) -// and migrated (urfave/cli) ones. -func TestDocRootIndexListsAllCommands(t *testing.T) { +// The root index.md written by 'src doc' must link exactly the commands that +// 'src help' lists, which in turn must be exactly the registered commands. +// A command that is registered but missing from either is a bug. +func TestDocRootIndexMatchesHelp(t *testing.T) { dir, _ := runDocCommand(t) index, err := os.ReadFile(filepath.Join(dir, "index.md")) @@ -197,18 +198,23 @@ func TestDocRootIndexListsAllCommands(t *testing.T) { t.Fatal(err) } - var missing []string - for _, cmd := range commands { - name := cmd.flagSet.Name() - if name == "doc" || name == "publish" { + var indexed []string + for _, line := range strings.Split(string(index), "\n") { + // Lines look like: * [`name`](name.md) or * [`name`](name/index.md) + rest, ok := strings.CutPrefix(strings.TrimSpace(line), "* [`") + if !ok { continue } - if !strings.Contains(string(index), "[`"+name+"`](") { - missing = append(missing, name) - } + name, _, _ := strings.Cut(rest, "`") + indexed = append(indexed, name) + } + sort.Strings(indexed) + + registered := registeredRootCommandNames() + if diff := cmp.Diff(registered, indexed); diff != "" { + t.Errorf("'src doc' root index does not match the registered commands (-registered +index):\n%s", diff) } - if len(missing) > 0 { - sort.Strings(missing) - t.Errorf("root index.md is missing legacy commands: %v", missing) + if diff := cmp.Diff(helpCommandNames(t, usageText()), indexed); diff != "" { + t.Errorf("'src doc' root index does not match 'src help' (-help +index):\n%s", diff) } } diff --git a/cmd/src/extsvc.go b/cmd/src/extsvc.go index c3a9aed37f..3b068162e8 100644 --- a/cmd/src/extsvc.go +++ b/cmd/src/extsvc.go @@ -36,9 +36,10 @@ Use "src extsvc [command] -h" for more information about a command. // Register the command. commands = append(commands, &command{ - flagSet: flagSet, - aliases: []string{"extsvc", "external-service"}, - handler: handler, + flagSet: flagSet, + description: "manages external services", + aliases: []string{"extsvc", "external-service"}, + handler: handler, usageFunc: func() { fmt.Println(usage) }, diff --git a/cmd/src/help.go b/cmd/src/help.go new file mode 100644 index 0000000000..5c104e8001 --- /dev/null +++ b/cmd/src/help.go @@ -0,0 +1,121 @@ +package main + +import ( + "cmp" + "fmt" + "slices" + "strings" + + "github.com/sourcegraph/sourcegraph/lib/docgen" +) + +// rootCommand is a top-level 'src' command as shown in 'src help'. It is the +// single source for the command list in the help text and for the tests that +// keep 'src help' and the 'src doc' root index in sync. +type rootCommand struct { + name string + aliases []string + description string +} + +// rootCommands returns every visible top-level command, whether it is +// registered with the legacy commander (commands) or with urfave/cli +// (migratedCommands), sorted by name. +func rootCommands() []rootCommand { + var root []rootCommand + + for _, cmd := range commands { + if cmd.hidden { + continue + } + name := cmd.flagSet.Name() + var aliases []string + for _, alias := range cmd.aliases { + // Some legacy commands register their own name as an alias. + if alias != name { + aliases = append(aliases, alias) + } + } + root = append(root, rootCommand{ + name: name, + aliases: aliases, + description: cmd.description, + }) + } + + for _, cmd := range docgen.VisibleCommands(migratedRootCommand().Commands) { + root = append(root, rootCommand{ + name: cmd.Name, + aliases: slices.Clone(cmd.Aliases), + description: cmd.Usage, + }) + } + + slices.SortFunc(root, func(a, b rootCommand) int { + return cmp.Compare(a.name, b.name) + }) + return root +} + +// formatCommandList renders the "The commands are:" block of 'src help': +// one tab-indented line per command with the name padded to a common width, +// the description, and any aliases in parentheses. +func formatCommandList(cmds []rootCommand) string { + width := 0 + for _, cmd := range cmds { + width = max(width, len(cmd.name)) + } + + var b strings.Builder + for _, cmd := range cmds { + fmt.Fprintf(&b, "\t%-*s %s", width, cmd.name, cmd.description) + if len(cmd.aliases) > 0 { + fmt.Fprintf(&b, " (alias: %s)", strings.Join(cmd.aliases, ", ")) + } + b.WriteString("\n") + } + return b.String() +} + +// usageText renders the top-level 'src help' output. +func usageText() string { + return usageHeader + formatCommandList(rootCommands()) + usageFooter +} + +const usageHeader = `src is a tool that provides access to Sourcegraph instances. +For more information, see https://github.com/sourcegraph/src-cli + +Usage: + + src [options] command [command options] + +Environment variables + SRC_ACCESS_TOKEN Sourcegraph access token + SRC_ENDPOINT endpoint to use, if unset will default to "https://sourcegraph.com" + SRC_PROXY A proxy to use for proxying requests to the Sourcegraph endpoint. + Supports HTTP(S), SOCKS5/5h, and UNIX Domain Socket proxies. + If a UNIX Domain Socket, the path can be either an absolute path, + or can start with ~/ or %USERPROFILE%\ for a path in the user's home directory. + Examples: + - https://localhost:3080 + - https://:localhost:8080 + - socks5h://localhost:1080 + - socks5://:@localhost:1080 + - unix://~/src-proxy.sock + - unix://%USERPROFILE%\src-proxy.sock + - ~/src-proxy.sock + - %USERPROFILE%\src-proxy.sock + - C:\some\path\src-proxy.sock + +The options are: + + -v print verbose output + +The commands are: + +` + +const usageFooter = ` +Use "src [command] -h" for more information about a command. + +` diff --git a/cmd/src/help_test.go b/cmd/src/help_test.go new file mode 100644 index 0000000000..4260f381fd --- /dev/null +++ b/cmd/src/help_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "sort" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// registeredRootCommandNames computes the set of visible top-level command +// names straight from the two registries, independently of rootCommands(), so +// the tests below catch a command that is registered but left out of the help +// text or the docs. +func registeredRootCommandNames() []string { + seen := map[string]bool{} + for _, cmd := range commands { + if !cmd.hidden { + seen[cmd.flagSet.Name()] = true + } + } + for _, cmd := range migratedCommands { + if !cmd.Hidden { + seen[cmd.Name] = true + } + } + names := make([]string, 0, len(seen)) + for name := range seen { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// helpCommandNames parses the "The commands are:" block of the given 'src +// help' output and returns the command names (without aliases), sorted. +func helpCommandNames(t *testing.T, help string) []string { + t.Helper() + + _, block, ok := strings.Cut(help, "The commands are:\n") + if !ok { + t.Fatalf("help text has no \"The commands are:\" block:\n%s", help) + } + block, _, _ = strings.Cut(block, "\nUse \"src [command] -h\"") + + var names []string + for _, line := range strings.Split(block, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + name, _, _ := strings.Cut(line, " ") + names = append(names, name) + } + sort.Strings(names) + return names +} + +func TestHelpListsAllRegisteredCommands(t *testing.T) { + got := helpCommandNames(t, usageText()) + if diff := cmp.Diff(registeredRootCommandNames(), got); diff != "" { + t.Errorf("'src help' command list does not match the registered commands (-registered +help):\n%s", diff) + } +} + +func TestHelpHidesHiddenCommands(t *testing.T) { + help := usageText() + for _, cmd := range commands { + if cmd.hidden && strings.Contains(help, "\t"+cmd.flagSet.Name()+" ") { + t.Errorf("hidden command %q is listed in 'src help'", cmd.flagSet.Name()) + } + } + if !strings.Contains(help, "\tabc ") || !strings.Contains(help, "\tbatch") { + t.Errorf("expected both a urfave/cli command (abc) and a legacy command (batch) in help:\n%s", help) + } +} + +func TestRootCommandsAreWellFormed(t *testing.T) { + names := map[string]bool{} + for _, cmd := range rootCommands() { + if cmd.description == "" { + t.Errorf("command %q has no description: set description on the legacy command or Usage on the urfave/cli command", cmd.name) + } + if names[cmd.name] { + t.Errorf("command %q is registered more than once", cmd.name) + } + names[cmd.name] = true + for _, alias := range cmd.aliases { + if alias == cmd.name { + t.Errorf("command %q lists its own name as an alias", cmd.name) + } + } + } + for _, cmd := range rootCommands() { + for _, alias := range cmd.aliases { + if names[alias] { + t.Errorf("alias %q of command %q collides with another command's name", alias, cmd.name) + } + } + } +} + +func TestFormatCommandList(t *testing.T) { + got := formatCommandList([]rootCommand{ + {name: "a", description: "first"}, + {name: "longer", aliases: []string{"l", "lg"}, description: "second"}, + }) + want := "\ta first\n" + + "\tlonger second (alias: l, lg)\n" + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("formatCommandList mismatch (-want +got):\n%s", diff) + } +} diff --git a/cmd/src/lsp.go b/cmd/src/lsp.go index e6d5608f13..64475bef9e 100644 --- a/cmd/src/lsp.go +++ b/cmd/src/lsp.go @@ -63,8 +63,9 @@ Example Neovim configuration (0.11+): } commands = append(commands, &command{ - flagSet: flagSet, - handler: handler, - usageFunc: usageFunc, + flagSet: flagSet, + description: "runs a Language Server Protocol server that proxies requests to Sourcegraph code intelligence", + handler: handler, + usageFunc: usageFunc, }) } diff --git a/cmd/src/main.go b/cmd/src/main.go index 9cc205a7ec..8635301b8a 100644 --- a/cmd/src/main.go +++ b/cmd/src/main.go @@ -21,58 +21,6 @@ import ( const SGDotComEndpoint = "https://sourcegraph.com" -const usageText = `src is a tool that provides access to Sourcegraph instances. -For more information, see https://github.com/sourcegraph/src-cli - -Usage: - - src [options] command [command options] - -Environment variables - SRC_ACCESS_TOKEN Sourcegraph access token - SRC_ENDPOINT endpoint to use, if unset will default to "https://sourcegraph.com" - SRC_PROXY A proxy to use for proxying requests to the Sourcegraph endpoint. - Supports HTTP(S), SOCKS5/5h, and UNIX Domain Socket proxies. - If a UNIX Domain Socket, the path can be either an absolute path, - or can start with ~/ or %USERPROFILE%\ for a path in the user's home directory. - Examples: - - https://localhost:3080 - - https://:localhost:8080 - - socks5h://localhost:1080 - - socks5://:@localhost:1080 - - unix://~/src-proxy.sock - - unix://%USERPROFILE%\src-proxy.sock - - ~/src-proxy.sock - - %USERPROFILE%\src-proxy.sock - - C:\some\path\src-proxy.sock - -The options are: - - -v print verbose output - -The commands are: - - abc manages agentic batch changes - auth authentication helper commands - api interacts with the Sourcegraph GraphQL API - batch manages batch changes - code-intel manages code intelligence data - config manages global, org, and user settings - extsvc manages external services - login authenticate to a Sourcegraph instance with your user credentials - orgs,org manages organizations - repos,repo manages repositories - search search for results on Sourcegraph - search-jobs manages search jobs - serve-git serves your local git repositories over HTTP for Sourcegraph to pull - users,user manages users - codeowners manages code ownership information - version display and compare the src-cli version against the recommended version for your instance - -Use "src [command] -h" for more information about a command. - -` - var ( verbose = flag.Bool("v", false, "print verbose output") @@ -102,7 +50,7 @@ func main() { } // if we didn't run a migrated command, then lets try running the legacy version - commands.run(flag.CommandLine, "src", usageText, normalizeDashHelp(os.Args[1:])) + commands.run(flag.CommandLine, "src", usageText(), normalizeDashHelp(os.Args[1:])) } // normalizeDashHelp converts --help to -help since Go's flag parser only supports single dash. diff --git a/cmd/src/repos.go b/cmd/src/repos.go index b839f6d1e8..b456457882 100644 --- a/cmd/src/repos.go +++ b/cmd/src/repos.go @@ -40,9 +40,10 @@ Use "src repos [command] -h" for more information about a command. // Register the command. commands = append(commands, &command{ - flagSet: flagSet, - aliases: []string{"repo"}, - handler: handler, + flagSet: flagSet, + description: "manages repositories", + aliases: []string{"repo"}, + handler: handler, usageFunc: func() { fmt.Println(usage) }, diff --git a/cmd/src/search.go b/cmd/src/search.go index 9cbb050a4d..6266fd6daa 100644 --- a/cmd/src/search.go +++ b/cmd/src/search.go @@ -291,8 +291,9 @@ Other tips: // Register the command. commands = append(commands, &command{ - flagSet: flagSet, - handler: handler, + flagSet: flagSet, + description: "search for results on Sourcegraph", + handler: handler, usageFunc: func() { fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src %s':\n", flagSet.Name()) flagSet.PrintDefaults() diff --git a/cmd/src/search_jobs.go b/cmd/src/search_jobs.go index 986aaf1603..4f6c15233b 100644 --- a/cmd/src/search_jobs.go +++ b/cmd/src/search_jobs.go @@ -309,9 +309,10 @@ func init() { } commands = append(commands, &command{ - flagSet: flagSet, - aliases: []string{"search-job"}, - handler: handler, + flagSet: flagSet, + description: "manages search jobs", + aliases: []string{"search-job"}, + handler: handler, usageFunc: func() { fmt.Println(usage) }, diff --git a/cmd/src/servegit.go b/cmd/src/servegit.go index 11c4c30c31..2b6a36fcef 100644 --- a/cmd/src/servegit.go +++ b/cmd/src/servegit.go @@ -88,9 +88,10 @@ Documentation at https://sourcegraph.com/docs/admin/code_hosts/src_serve_git // Register the command. commands = append(commands, &command{ - aliases: []string{"servegit"}, - flagSet: flagSet, - handler: handler, - usageFunc: usageFunc, + aliases: []string{"servegit"}, + flagSet: flagSet, + description: "serves your local git repositories over HTTP for Sourcegraph to pull", + handler: handler, + usageFunc: usageFunc, }) } diff --git a/cmd/src/snapshot.go b/cmd/src/snapshot.go index 837ed3c21a..57e3e9396e 100644 --- a/cmd/src/snapshot.go +++ b/cmd/src/snapshot.go @@ -29,7 +29,8 @@ Use "src snapshot [command] -h" for more information about a command. flagSet := flag.NewFlagSet("snapshot", flag.ExitOnError) commands = append(commands, &command{ - flagSet: flagSet, + flagSet: flagSet, + description: "manages snapshots of Sourcegraph instance databases (EXPERIMENTAL)", handler: func(args []string) error { snapshotCommands.run(flagSet, "src snapshot", usage, args) return nil diff --git a/cmd/src/version.go b/cmd/src/version.go index 646ac6b6e1..fad3a42f78 100644 --- a/cmd/src/version.go +++ b/cmd/src/version.go @@ -29,6 +29,7 @@ $ src version var versionCommand = clicompat.Wrap(&cli.Command{ Name: "version", + Usage: "display and compare the src-cli version against the recommended version for your instance", UsageText: "src version [options]", OnUsageError: clicompat.OnUsageError, Description: versionExamples,