diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 7c5078df9..f3a12ef76 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -2,13 +2,17 @@ package main import ( "fmt" + "io" "os" "path/filepath" "strings" + "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/docs" + "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/pflag" ) @@ -41,9 +45,13 @@ func run(args []string) error { } ios, _, _, _ := iostreams.Test() - rootCmd := root.NewCmdRoot(&cmdutil.Factory{ + rootCmd, _ := root.NewCmdRoot(&cmdutil.Factory{ IOStreams: ios, Browser: &browser{}, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + ExtensionManager: &em{}, }, "", "") rootCmd.InitDefaultHelpCmd() @@ -79,8 +87,42 @@ func linkHandler(name string) string { return fmt.Sprintf("./%s", strings.TrimSuffix(name, ".md")) } +// Implements browser.Browser interface. type browser struct{} -func (b *browser) Browse(url string) error { +func (b *browser) Browse(_ string) error { return nil } + +// Implements extensions.ExtensionManager interface. +type em struct{} + +func (e *em) List() []extensions.Extension { + return nil +} + +func (e *em) Install(_ ghrepo.Interface, _ string) error { + return nil +} + +func (e *em) InstallLocal(_ string) error { + return nil +} + +func (e *em) Upgrade(_ string, _ bool) error { + return nil +} + +func (e *em) Remove(_ string) error { + return nil +} + +func (e *em) Dispatch(_ []string, _ io.Reader, _, _ io.Writer) (bool, error) { + return false, nil +} + +func (e *em) Create(_ string, _ extensions.ExtTemplateType) error { + return nil +} + +func (e *em) EnableDryRunMode() {} diff --git a/cmd/gh/main.go b/cmd/gh/main.go index f231d7d2c..c36a4e6e6 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -14,16 +14,10 @@ import ( surveyCore "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" - "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/build" "github.com/cli/cli/v2/internal/config" - "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/run" - "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/internal/update" - "github.com/cli/cli/v2/pkg/cmd/alias/expand" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" @@ -93,11 +87,9 @@ func mainRun() exitCode { cobra.MousetrapHelpText = "" } - rootCmd := root.NewCmdRoot(cmdFactory, buildVersion, buildDate) - - cfg, err := cmdFactory.Config() + rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate) if err != nil { - fmt.Fprintf(stderr, "failed to read configuration: %s\n", err) + fmt.Fprintf(stderr, "failed to create root command: %s\n", err) return exitError } @@ -106,110 +98,10 @@ func mainRun() exitCode { expandedArgs = os.Args[1:] } - // translate `gh help ` to `gh --help` for extensions - if len(expandedArgs) == 2 && expandedArgs[0] == "help" && !hasCommand(rootCmd, expandedArgs[1:]) { - expandedArgs = []string{expandedArgs[1], "--help"} - } - - if !hasCommand(rootCmd, expandedArgs) { - originalArgs := expandedArgs - isShell := false - - argsForExpansion := append([]string{"gh"}, expandedArgs...) - expandedArgs, isShell, err = expand.ExpandAlias(cfg, argsForExpansion, nil) - if err != nil { - fmt.Fprintf(stderr, "failed to process aliases: %s\n", err) - return exitError - } - - if hasDebug { - fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs) - } - - if isShell { - exe, err := safeexec.LookPath(expandedArgs[0]) - if err != nil { - fmt.Fprintf(stderr, "failed to run external command: %s", err) - return exitError - } - - externalCmd := exec.Command(exe, expandedArgs[1:]...) - externalCmd.Stderr = os.Stderr - externalCmd.Stdout = os.Stdout - externalCmd.Stdin = os.Stdin - preparedCmd := run.PrepareCmd(externalCmd) - - err = preparedCmd.Run() - if err != nil { - var execError *exec.ExitError - if errors.As(err, &execError) { - return exitCode(execError.ExitCode()) - } - fmt.Fprintf(stderr, "failed to run external command: %s\n", err) - return exitError - } - - return exitOK - } else if len(expandedArgs) > 0 && !hasCommand(rootCmd, expandedArgs) { - extensionManager := cmdFactory.ExtensionManager - if found, err := extensionManager.Dispatch(expandedArgs, os.Stdin, os.Stdout, os.Stderr); err != nil { - var execError *exec.ExitError - if errors.As(err, &execError) { - return exitCode(execError.ExitCode()) - } - fmt.Fprintf(stderr, "failed to run extension: %s\n", err) - return exitError - } else if found { - return exitOK - } - } - } - - // provide completions for aliases and extensions - rootCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - var results []string - aliases := cfg.Aliases() - for aliasName, aliasValue := range aliases.All() { - if strings.HasPrefix(aliasName, toComplete) { - var s string - if strings.HasPrefix(aliasValue, "!") { - s = fmt.Sprintf("%s\tShell alias", aliasName) - } else { - aliasValue = text.Truncate(80, aliasValue) - s = fmt.Sprintf("%s\tAlias for %s", aliasName, aliasValue) - } - results = append(results, s) - } - } - for _, ext := range cmdFactory.ExtensionManager.List() { - if strings.HasPrefix(ext.Name(), toComplete) { - var s string - if ext.IsLocal() { - s = fmt.Sprintf("%s\tLocal extension gh-%s", ext.Name(), ext.Name()) - } else { - path := ext.URL() - if u, err := git.ParseURL(ext.URL()); err == nil { - if r, err := ghrepo.FromURL(u); err == nil { - path = ghrepo.FullName(r) - } - } - s = fmt.Sprintf("%s\tExtension %s", ext.Name(), path) - } - results = append(results, s) - } - } - return results, cobra.ShellCompDirectiveNoFileComp - } - - authError := errors.New("authError") - rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - // require that the user is authenticated before running most commands - if cmdutil.IsAuthCheckEnabled(cmd) && !cmdutil.CheckAuth(cfg) { - fmt.Fprint(stderr, authHelp()) - return authError - } - - return nil + // translate `gh help ` to `gh --help` for extensions. + if len(expandedArgs) >= 2 && expandedArgs[0] == "help" && isExtensionCommand(rootCmd, expandedArgs[1:]) { + expandedArgs = expandedArgs[1:] + expandedArgs = append(expandedArgs, "--help") } rootCmd.SetArgs(expandedArgs) @@ -217,6 +109,8 @@ func mainRun() exitCode { if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil { var pagerPipeError *iostreams.ErrClosedPagerPipe var noResultsError cmdutil.NoResultsError + var execError *exec.ExitError + var authError *root.AuthError if err == cmdutil.SilentError { return exitError } else if cmdutil.IsUserCancellation(err) { @@ -225,7 +119,7 @@ func mainRun() exitCode { fmt.Fprint(stderr, "\n") } return exitCancel - } else if errors.Is(err, authError) { + } else if errors.As(err, &authError) { return exitAuth } else if errors.As(err, &pagerPipeError) { // ignore the error raised when piping to a closed pager @@ -236,6 +130,8 @@ func mainRun() exitCode { } // no results is not a command failure return exitOK + } else if errors.As(err, &execError) { + return exitCode(execError.ExitCode()) } printError(stderr, err, cmd, hasDebug) @@ -284,10 +180,10 @@ func mainRun() exitCode { return exitOK } -// hasCommand returns true if args resolve to a built-in command -func hasCommand(rootCmd *cobra.Command, args []string) bool { - c, _, err := rootCmd.Traverse(args) - return err == nil && c != rootCmd +// isExtensionCommand returns true if args resolve to an extension command. +func isExtensionCommand(rootCmd *cobra.Command, args []string) bool { + c, _, err := rootCmd.Find(args) + return err == nil && c != nil && c.GroupID == "extension" } func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) { @@ -312,27 +208,6 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) { } } -func authHelp() string { - if os.Getenv("GITHUB_ACTIONS") == "true" { - return heredoc.Doc(` - gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Example: - env: - GH_TOKEN: ${{ github.token }} - `) - } - - if os.Getenv("CI") != "" { - return heredoc.Doc(` - gh: To use GitHub CLI in automation, set the GH_TOKEN environment variable. - `) - } - - return heredoc.Doc(` - To get started with GitHub CLI, please run: gh auth login - Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token. - `) -} - func shouldCheckForUpdate() bool { if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" { return false diff --git a/pkg/cmd/alias/expand/expand.go b/pkg/cmd/alias/expand/expand.go deleted file mode 100644 index d0e2627fb..000000000 --- a/pkg/cmd/alias/expand/expand.go +++ /dev/null @@ -1,90 +0,0 @@ -package expand - -import ( - "errors" - "fmt" - "os/exec" - "regexp" - "runtime" - "strings" - - "github.com/cli/cli/v2/internal/config" - "github.com/cli/cli/v2/pkg/findsh" - "github.com/google/shlex" -) - -// ExpandAlias processes argv to see if it should be rewritten according to a user's aliases. The -// second return value indicates whether the alias should be executed in a new shell process instead -// of running gh itself. -func ExpandAlias(cfg config.Config, args []string, findShFunc func() (string, error)) (expanded []string, isShell bool, err error) { - if len(args) < 2 { - // the command is lacking a subcommand - return - } - expanded = args[1:] - - aliases := cfg.Aliases() - - expansion, getErr := aliases.Get(args[1]) - if getErr != nil { - return - } - - if strings.HasPrefix(expansion, "!") { - isShell = true - if findShFunc == nil { - findShFunc = findSh - } - shPath, shErr := findShFunc() - if shErr != nil { - err = shErr - return - } - - expanded = []string{shPath, "-c", expansion[1:]} - - if len(args[2:]) > 0 { - expanded = append(expanded, "--") - expanded = append(expanded, args[2:]...) - } - - return - } - - extraArgs := []string{} - for i, a := range args[2:] { - if !strings.Contains(expansion, "$") { - extraArgs = append(extraArgs, a) - } else { - expansion = strings.ReplaceAll(expansion, fmt.Sprintf("$%d", i+1), a) - } - } - lingeringRE := regexp.MustCompile(`\$\d`) - if lingeringRE.MatchString(expansion) { - err = fmt.Errorf("not enough arguments for alias: %s", expansion) - return - } - - var newArgs []string - newArgs, err = shlex.Split(expansion) - if err != nil { - return - } - - expanded = append(newArgs, extraArgs...) - return -} - -func findSh() (string, error) { - shPath, err := findsh.Find() - if err != nil { - if errors.Is(err, exec.ErrNotFound) { - if runtime.GOOS == "windows" { - return "", errors.New("unable to locate sh to execute the shell alias with. The sh.exe interpreter is typically distributed with Git for Windows.") - } - return "", errors.New("unable to locate sh to execute shell alias with") - } - return "", err - } - return shPath, nil -} diff --git a/pkg/cmd/alias/expand/expand_test.go b/pkg/cmd/alias/expand/expand_test.go deleted file mode 100644 index 33af4b073..000000000 --- a/pkg/cmd/alias/expand/expand_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package expand - -import ( - "errors" - "reflect" - "testing" - - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/internal/config" -) - -func TestExpandAlias(t *testing.T) { - findShFunc := func() (string, error) { - return "/usr/bin/sh", nil - } - - cfg := config.NewFromString(heredoc.Doc(` - aliases: - co: pr checkout - il: issue list --author="$1" --label="$2" - ia: issue list --author="$1" --assignee="$1" - `)) - - type args struct { - config config.Config - argv []string - } - tests := []struct { - name string - args args - wantExpanded []string - wantIsShell bool - wantErr error - }{ - { - name: "no arguments", - args: args{ - config: cfg, - argv: []string{}, - }, - wantExpanded: []string(nil), - wantIsShell: false, - wantErr: nil, - }, - { - name: "too few arguments", - args: args{ - config: cfg, - argv: []string{"gh"}, - }, - wantExpanded: []string(nil), - wantIsShell: false, - wantErr: nil, - }, - { - name: "no expansion", - args: args{ - config: cfg, - argv: []string{"gh", "pr", "status"}, - }, - wantExpanded: []string{"pr", "status"}, - wantIsShell: false, - wantErr: nil, - }, - { - name: "simple expansion", - args: args{ - config: cfg, - argv: []string{"gh", "co"}, - }, - wantExpanded: []string{"pr", "checkout"}, - wantIsShell: false, - wantErr: nil, - }, - { - name: "adding arguments after expansion", - args: args{ - config: cfg, - argv: []string{"gh", "co", "123"}, - }, - wantExpanded: []string{"pr", "checkout", "123"}, - wantIsShell: false, - wantErr: nil, - }, - { - name: "not enough arguments for expansion", - args: args{ - config: cfg, - argv: []string{"gh", "il"}, - }, - wantExpanded: []string{}, - wantIsShell: false, - wantErr: errors.New(`not enough arguments for alias: issue list --author="$1" --label="$2"`), - }, - { - name: "not enough arguments for expansion 2", - args: args{ - config: cfg, - argv: []string{"gh", "il", "vilmibm"}, - }, - wantExpanded: []string{}, - wantIsShell: false, - wantErr: errors.New(`not enough arguments for alias: issue list --author="vilmibm" --label="$2"`), - }, - { - name: "satisfy expansion arguments", - args: args{ - config: cfg, - argv: []string{"gh", "il", "vilmibm", "help wanted"}, - }, - wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=help wanted"}, - wantIsShell: false, - wantErr: nil, - }, - { - name: "mixed positional and non-positional arguments", - args: args{ - config: cfg, - argv: []string{"gh", "il", "vilmibm", "epic", "-R", "monalisa/testing"}, - }, - wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "monalisa/testing"}, - wantIsShell: false, - wantErr: nil, - }, - { - name: "dollar in expansion", - args: args{ - config: cfg, - argv: []string{"gh", "ia", "$coolmoney$"}, - }, - wantExpanded: []string{"issue", "list", "--author=$coolmoney$", "--assignee=$coolmoney$"}, - wantIsShell: false, - wantErr: nil, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotExpanded, gotIsShell, err := ExpandAlias(tt.args.config, tt.args.argv, findShFunc) - if tt.wantErr != nil { - if err == nil { - t.Fatal("expected error") - } - if tt.wantErr.Error() != err.Error() { - t.Fatalf("expected error %q, got %q", tt.wantErr, err) - } - return - } - if err != nil { - t.Fatalf("got error: %v", err) - } - if !reflect.DeepEqual(gotExpanded, tt.wantExpanded) { - t.Errorf("ExpandAlias() gotExpanded = %v, want %v", gotExpanded, tt.wantExpanded) - } - if gotIsShell != tt.wantIsShell { - t.Errorf("ExpandAlias() gotIsShell = %v, want %v", gotIsShell, tt.wantIsShell) - } - }) - } -} - -// cfg := `--- -// aliases: -// co: pr checkout -// il: issue list --author="$1" --label="$2" -// ia: issue list --author="$1" --assignee="$1" -// ` -// initBlankContext(cfg, "OWNER/REPO", "trunk") -// for _, c := range []struct { -// Args string -// ExpectedArgs []string -// Err string -// }{ -// {"gh co", []string{"pr", "checkout"}, ""}, -// {"gh il", nil, `not enough arguments for alias: issue list --author="$1" --label="$2"`}, -// {"gh il vilmibm", nil, `not enough arguments for alias: issue list --author="vilmibm" --label="$2"`}, -// {"gh co 123", []string{"pr", "checkout", "123"}, ""}, -// {"gh il vilmibm epic", []string{"issue", "list", `--author=vilmibm`, `--label=epic`}, ""}, -// {"gh ia vilmibm", []string{"issue", "list", `--author=vilmibm`, `--assignee=vilmibm`}, ""}, -// {"gh ia $coolmoney$", []string{"issue", "list", `--author=$coolmoney$`, `--assignee=$coolmoney$`}, ""}, -// {"gh pr status", []string{"pr", "status"}, ""}, -// {"gh il vilmibm epic -R vilmibm/testing", []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "vilmibm/testing"}, ""}, -// {"gh dne", []string{"dne"}, ""}, -// {"gh", []string{}, ""}, -// {"", []string{}, ""}, -// } { diff --git a/pkg/cmd/alias/imports/import.go b/pkg/cmd/alias/imports/import.go index f724e2699..f7de0bb78 100644 --- a/pkg/cmd/alias/imports/import.go +++ b/pkg/cmd/alias/imports/import.go @@ -21,7 +21,8 @@ type ImportOptions struct { Filename string OverwriteExisting bool - existingCommand func(string) bool + validAliasName func(string) bool + validAliasExpansion func(string) bool } func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Command { @@ -74,7 +75,8 @@ func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Co opts.Filename = args[0] } - opts.existingCommand = shared.ExistingCommandFunc(f, cmd) + opts.validAliasName = shared.ValidAliasNameFunc(cmd) + opts.validAliasExpansion = shared.ValidAliasExpansionFunc(cmd) if runF != nil { return runF(opts) @@ -120,9 +122,9 @@ func importRun(opts *ImportOptions) error { var msg strings.Builder for _, alias := range getSortedKeys(aliasMap) { - if opts.existingCommand(alias) { + if !opts.validAliasName(alias) { msg.WriteString( - fmt.Sprintf("%s Could not import alias %s: already a gh command\n", + fmt.Sprintf("%s Could not import alias %s: already a gh command, extension, or alias\n", cs.FailureIcon(), cs.Bold(alias), ), @@ -133,9 +135,9 @@ func importRun(opts *ImportOptions) error { expansion := aliasMap[alias] - if !(strings.HasPrefix(expansion, "!") || opts.existingCommand(expansion)) { + if !opts.validAliasExpansion(expansion) { msg.WriteString( - fmt.Sprintf("%s Could not import alias %s: expansion does not correspond to a gh command\n", + fmt.Sprintf("%s Could not import alias %s: expansion does not correspond to a gh command, extension, or alias\n", cs.FailureIcon(), cs.Bold(alias), ), diff --git a/pkg/cmd/alias/imports/import_test.go b/pkg/cmd/alias/imports/import_test.go index 8837a52db..0013429f6 100644 --- a/pkg/cmd/alias/imports/import_test.go +++ b/pkg/cmd/alias/imports/import_test.go @@ -13,7 +13,6 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/pkg/cmd/alias/shared" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/spf13/cobra" @@ -257,9 +256,9 @@ func TestImportRun(t *testing.T) { wantStderr: strings.Join( []string{ importFileMsg, - "X Could not import alias api: already a gh command", - "X Could not import alias issue: already a gh command", - "X Could not import alias pr: already a gh command\n\n", + "X Could not import alias api: already a gh command, extension, or alias", + "X Could not import alias issue: already a gh command, extension, or alias", + "X Could not import alias pr: already a gh command, extension, or alias\n\n", }, "\n", ), @@ -277,8 +276,8 @@ func TestImportRun(t *testing.T) { wantStderr: strings.Join( []string{ importFileMsg, - "X Could not import alias alias1: expansion does not correspond to a gh command", - "X Could not import alias alias2: expansion does not correspond to a gh command\n\n", + "X Could not import alias alias1: expansion does not correspond to a gh command, extension, or alias", + "X Could not import alias alias2: expansion does not correspond to a gh command, extension, or alias\n\n", }, "\n", ), @@ -304,16 +303,6 @@ func TestImportRun(t *testing.T) { return cfg, nil } - // Create fake command factory for testing. - f := &cmdutil.Factory{ - IOStreams: ios, - ExtensionManager: &extensions.ExtensionManagerMock{ - ListFunc: func() []extensions.Extension { - return []extensions.Extension{} - }, - }, - } - // Create fake command structure for testing. rootCmd := &cobra.Command{} prCmd := &cobra.Command{Use: "pr"} @@ -327,7 +316,8 @@ func TestImportRun(t *testing.T) { apiCmd.AddCommand(&cobra.Command{Use: "graphql"}) rootCmd.AddCommand(apiCmd) - tt.opts.existingCommand = shared.ExistingCommandFunc(f, rootCmd) + tt.opts.validAliasName = shared.ValidAliasNameFunc(rootCmd) + tt.opts.validAliasExpansion = shared.ValidAliasExpansionFunc(rootCmd) if tt.stdin != "" { stdin.WriteString(tt.stdin) diff --git a/pkg/cmd/alias/set/set.go b/pkg/cmd/alias/set/set.go index 871737881..20c86c473 100644 --- a/pkg/cmd/alias/set/set.go +++ b/pkg/cmd/alias/set/set.go @@ -21,7 +21,8 @@ type SetOptions struct { Expansion string IsShell bool - existingCommand func(string) bool + validAliasName func(string) bool + validAliasExpansion func(string) bool } func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command { @@ -59,6 +60,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command $ gh alias set homework 'issue list --assignee @me' $ gh homework + $ gh alias set 'issue mine' 'issue list --mention @me' + $ gh issue mine + $ gh alias set epicsBy 'issue list --author="$1" --label="epic"' $ gh epicsBy vilmibm #=> gh issue list --author="vilmibm" --label="epic" @@ -70,7 +74,8 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command opts.Name = args[0] opts.Expansion = args[1] - opts.existingCommand = shared.ExistingCommandFunc(f, cmd) + opts.validAliasName = shared.ValidAliasNameFunc(cmd) + opts.validAliasExpansion = shared.ValidAliasExpansionFunc(cmd) if runF != nil { return runF(opts) @@ -104,18 +109,16 @@ func setRun(opts *SetOptions) error { fmt.Fprintf(opts.IO.ErrOut, "- Adding alias for %s: %s\n", cs.Bold(opts.Name), cs.Bold(expansion)) } - isShell := opts.IsShell - if isShell && !strings.HasPrefix(expansion, "!") { + if opts.IsShell && !strings.HasPrefix(expansion, "!") { expansion = "!" + expansion } - isShell = strings.HasPrefix(expansion, "!") - if opts.existingCommand(opts.Name) { - return fmt.Errorf("could not create alias: %q is already a gh command", opts.Name) + if !opts.validAliasName(opts.Name) { + return fmt.Errorf("could not create alias: %q is already a gh command, extension, or alias", opts.Name) } - if !isShell && !opts.existingCommand(expansion) { - return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion) + if !opts.validAliasExpansion(expansion) { + return fmt.Errorf("could not create alias: %s does not correspond to a gh command, extension, or alias", expansion) } successMsg := fmt.Sprintf("%s Added alias.", cs.SuccessIcon()) diff --git a/pkg/cmd/alias/set/set_test.go b/pkg/cmd/alias/set/set_test.go index bec03cba4..54fcabf5b 100644 --- a/pkg/cmd/alias/set/set_test.go +++ b/pkg/cmd/alias/set/set_test.go @@ -73,7 +73,7 @@ func TestAliasSet_gh_command(t *testing.T) { cfg := config.NewFromString(``) _, err := runCommand(cfg, true, "pr 'pr status'", "") - assert.EqualError(t, err, `could not create alias: "pr" is already a gh command`) + assert.EqualError(t, err, `could not create alias: "pr" is already a gh command, extension, or alias`) } func TestAliasSet_empty_aliases(t *testing.T) { @@ -231,7 +231,7 @@ func TestAliasSet_invalid_command(t *testing.T) { cfg := config.NewFromString(``) _, err := runCommand(cfg, true, "co 'pe checkout'", "") - assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command") + assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command, extension, or alias") } func TestShellAlias_flag(t *testing.T) { diff --git a/pkg/cmd/alias/shared/validations.go b/pkg/cmd/alias/shared/validations.go index 6557f66ca..9a213ab98 100644 --- a/pkg/cmd/alias/shared/validations.go +++ b/pkg/cmd/alias/shared/validations.go @@ -1,14 +1,18 @@ package shared import ( - "github.com/cli/cli/v2/pkg/cmdutil" + "strings" + "github.com/google/shlex" "github.com/spf13/cobra" ) -// ExistingCommandFunc returns a function that will check if the given string -// corresponds to an existing command. -func ExistingCommandFunc(f *cmdutil.Factory, cmd *cobra.Command) func(string) bool { +// ValidAliasNameFunc returns a function that will check if the given string +// is a valid alias name. A name is valid if: +// - it does not shadow an existing command, +// - it is not nested under a command that is runnable, +// - it is not nested under a command that does not exist. +func ValidAliasNameFunc(cmd *cobra.Command) func(string) bool { return func(args string) bool { split, err := shlex.Split(args) if err != nil || len(split) == 0 { @@ -16,17 +20,32 @@ func ExistingCommandFunc(f *cmdutil.Factory, cmd *cobra.Command) func(string) bo } rootCmd := cmd.Root() - cmd, _, err = rootCmd.Traverse(split) - if err == nil && cmd != rootCmd { + foundCmd, foundArgs, _ := rootCmd.Find(split) + if foundCmd != nil && !foundCmd.Runnable() && len(foundArgs) == 1 { return true } - for _, ext := range f.ExtensionManager.List() { - if ext.Name() == split[0] { - return true - } - } - return false } } + +// ValidAliasExpansionFunc returns a function that will check if the given string +// is a valid alias expansion. An expansion is valid if: +// - it is a shell expansion, +// - it is a non-shell expansion that corresponds to an existing command, extension, or alias. +func ValidAliasExpansionFunc(cmd *cobra.Command) func(string) bool { + return func(expansion string) bool { + if strings.HasPrefix(expansion, "!") { + return true + } + + split, err := shlex.Split(expansion) + if err != nil || len(split) == 0 { + return false + } + + rootCmd := cmd.Root() + cmd, _, _ = rootCmd.Find(split) + return cmd != rootCmd + } +} diff --git a/pkg/cmd/alias/shared/validations_test.go b/pkg/cmd/alias/shared/validations_test.go index faa0e497d..72270a608 100644 --- a/pkg/cmd/alias/shared/validations_test.go +++ b/pkg/cmd/alias/shared/validations_test.go @@ -3,22 +3,11 @@ package shared import ( "testing" - "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/extensions" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" ) -func TestExistingCommandFunc(t *testing.T) { - // Create fake command factory for testing. - factory := &cmdutil.Factory{ - ExtensionManager: &extensions.ExtensionManagerMock{ - ListFunc: func() []extensions.Extension { - return []extensions.Extension{} - }, - }, - } - +func TestValidAliasNameFunc(t *testing.T) { // Create fake command structure for testing. issueCmd := &cobra.Command{Use: "issue"} prCmd := &cobra.Command{Use: "pr"} @@ -28,13 +17,38 @@ func TestExistingCommandFunc(t *testing.T) { cmd.AddCommand(prCmd) cmd.AddCommand(issueCmd) - f := ExistingCommandFunc(factory, cmd) + f := ValidAliasNameFunc(cmd) - assert.True(t, f("pr")) - assert.True(t, f("pr checkout")) - assert.True(t, f("issue")) + assert.False(t, f("pr")) + assert.False(t, f("pr checkout")) + assert.False(t, f("issue")) + assert.False(t, f("repo list")) + + assert.True(t, f("ps")) + assert.True(t, f("checkout")) + assert.True(t, f("issue erase")) + assert.True(t, f("pr erase")) + assert.True(t, f("pr checkout branch")) +} + +func TestValidAliasExpansionFunc(t *testing.T) { + // Create fake command structure for testing. + issueCmd := &cobra.Command{Use: "issue"} + prCmd := &cobra.Command{Use: "pr"} + prCmd.AddCommand(&cobra.Command{Use: "checkout"}) + + cmd := &cobra.Command{} + cmd.AddCommand(prCmd) + cmd.AddCommand(issueCmd) + + f := ValidAliasExpansionFunc(cmd) assert.False(t, f("ps")) assert.False(t, f("checkout")) assert.False(t, f("repo list")) + + assert.True(t, f("!git branch --show-current")) + assert.True(t, f("pr")) + assert.True(t, f("pr checkout")) + assert.True(t, f("issue")) } diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 0f2add8aa..6629e599d 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -643,10 +643,8 @@ func checkValidExtension(rootCmd *cobra.Command, m extensions.ExtensionManager, } commandName := strings.TrimPrefix(extName, "gh-") - if c, _, err := rootCmd.Traverse([]string{commandName}); err != nil { - return nil, err - } else if c != rootCmd { - return nil, fmt.Errorf("%q matches the name of a built-in command", commandName) + if c, _, _ := rootCmd.Find([]string{commandName}); c != rootCmd && c.GroupID != "extension" { + return nil, fmt.Errorf("%q matches the name of a built-in command or alias", commandName) } for _, ext := range m.List() { diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index ebc4716de..6080e8483 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -974,7 +974,7 @@ func Test_checkValidExtension(t *testing.T) { manager: m, extName: "gh-auth", }, - wantError: "\"auth\" matches the name of a built-in command", + wantError: "\"auth\" matches the name of a built-in command or alias", }, { name: "clashes with an installed extension", diff --git a/pkg/cmd/root/alias.go b/pkg/cmd/root/alias.go new file mode 100644 index 000000000..ba0bfe9e2 --- /dev/null +++ b/pkg/cmd/root/alias.go @@ -0,0 +1,126 @@ +package root + +import ( + "errors" + "fmt" + "os/exec" + "regexp" + "runtime" + "strings" + + "github.com/cli/cli/v2/internal/run" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/findsh" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/spf13/cobra" +) + +func NewCmdShellAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.Command { + return &cobra.Command{ + Use: aliasName, + Short: fmt.Sprintf("Shell alias for %q", text.Truncate(80, aliasValue)), + RunE: func(c *cobra.Command, args []string) error { + expandedArgs, err := expandShellAlias(aliasValue, args, nil) + if err != nil { + return err + } + externalCmd := exec.Command(expandedArgs[0], expandedArgs[1:]...) + externalCmd.Stderr = io.ErrOut + externalCmd.Stdout = io.Out + externalCmd.Stdin = io.In + preparedCmd := run.PrepareCmd(externalCmd) + if err = preparedCmd.Run(); err != nil { + return fmt.Errorf("failed to run external command: %w\n", err) + } + return nil + }, + GroupID: "alias", + Annotations: map[string]string{ + "skipAuthCheck": "true", + }, + DisableFlagParsing: true, + } +} + +func NewCmdAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.Command { + return &cobra.Command{ + Use: aliasName, + Short: fmt.Sprintf("Alias for %q", text.Truncate(80, aliasValue)), + RunE: func(c *cobra.Command, args []string) error { + expandedArgs, err := expandAlias(aliasValue, args) + if err != nil { + return err + } + root := c.Root() + root.SetArgs(expandedArgs) + return root.Execute() + }, + GroupID: "alias", + Annotations: map[string]string{ + "skipAuthCheck": "true", + }, + DisableFlagParsing: true, + } +} + +// ExpandAlias processes argv to see if it should be rewritten according to a user's aliases. +func expandAlias(expansion string, args []string) ([]string, error) { + extraArgs := []string{} + for i, a := range args { + if !strings.Contains(expansion, "$") { + extraArgs = append(extraArgs, a) + } else { + expansion = strings.ReplaceAll(expansion, fmt.Sprintf("$%d", i+1), a) + } + } + + lingeringRE := regexp.MustCompile(`\$\d`) + if lingeringRE.MatchString(expansion) { + return nil, fmt.Errorf("not enough arguments for alias: %s", expansion) + } + + newArgs, err := shlex.Split(expansion) + if err != nil { + return nil, err + } + + expanded := append(newArgs, extraArgs...) + + return expanded, nil +} + +// ExpandShellAlias processes argv to see if it should be rewritten according to a user's aliases. +func expandShellAlias(expansion string, args []string, findShFunc func() (string, error)) ([]string, error) { + if findShFunc == nil { + findShFunc = findSh + } + + shPath, shErr := findShFunc() + if shErr != nil { + return nil, shErr + } + + expanded := []string{shPath, "-c", expansion[1:]} + + if len(args) > 0 { + expanded = append(expanded, "--") + expanded = append(expanded, args...) + } + + return expanded, nil +} + +func findSh() (string, error) { + shPath, err := findsh.Find() + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + if runtime.GOOS == "windows" { + return "", errors.New("unable to locate sh to execute the shell alias with. The sh.exe interpreter is typically distributed with Git for Windows.") + } + return "", errors.New("unable to locate sh to execute shell alias with") + } + return "", err + } + return shPath, nil +} diff --git a/pkg/cmd/root/alias_test.go b/pkg/cmd/root/alias_test.go new file mode 100644 index 000000000..4a2d3c13e --- /dev/null +++ b/pkg/cmd/root/alias_test.go @@ -0,0 +1,123 @@ +package root + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExpandAlias(t *testing.T) { + tests := []struct { + name string + expansion string + args []string + wantExpanded []string + wantErr string + }{ + { + name: "no expansion", + expansion: "pr status", + args: []string{}, + wantExpanded: []string{"pr", "status"}, + }, + { + name: "adding arguments after expansion", + expansion: "pr checkout", + args: []string{"123"}, + wantExpanded: []string{"pr", "checkout", "123"}, + }, + { + name: "not enough arguments for expansion", + expansion: `issue list --author="$1" --label="$2"`, + args: []string{}, + wantErr: `not enough arguments for alias: issue list --author="$1" --label="$2"`, + }, + { + name: "not enough arguments for expansion 2", + expansion: `issue list --author="$1" --label="$2"`, + args: []string{"vilmibm"}, + wantErr: `not enough arguments for alias: issue list --author="vilmibm" --label="$2"`, + }, + { + name: "satisfy expansion arguments", + expansion: `issue list --author="$1" --label="$2"`, + args: []string{"vilmibm", "help wanted"}, + wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=help wanted"}, + }, + { + name: "mixed positional and non-positional arguments", + expansion: `issue list --author="$1" --label="$2"`, + args: []string{"vilmibm", "epic", "-R", "monalisa/testing"}, + wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "monalisa/testing"}, + }, + { + name: "dollar in expansion", + expansion: `issue list --author="$1" --assignee="$1"`, + args: []string{"$coolmoney$"}, + wantExpanded: []string{"issue", "list", "--author=$coolmoney$", "--assignee=$coolmoney$"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotExpanded, err := expandAlias(tt.expansion, tt.args) + if tt.wantErr != "" { + assert.Nil(t, gotExpanded) + assert.EqualError(t, err, tt.wantErr) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantExpanded, gotExpanded) + }) + } +} + +func TestExpandShellAlias(t *testing.T) { + findShFunc := func() (string, error) { + return "/usr/bin/sh", nil + } + tests := []struct { + name string + expansion string + args []string + findSh func() (string, error) + wantExpanded []string + wantErr string + }{ + { + name: "simple expansion", + expansion: "!git branch --show-current", + args: []string{}, + findSh: findShFunc, + wantExpanded: []string{"/usr/bin/sh", "-c", "git branch --show-current"}, + }, + { + name: "adding arguments after expansion", + expansion: "!git branch checkout", + args: []string{"123"}, + findSh: findShFunc, + wantExpanded: []string{"/usr/bin/sh", "-c", "git branch checkout", "--", "123"}, + }, + { + name: "unable to find sh", + expansion: "!git branch --show-current", + args: []string{}, + findSh: func() (string, error) { + return "", errors.New("unable to locate sh") + }, + wantErr: "unable to locate sh", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotExpanded, err := expandShellAlias(tt.expansion, tt.args, tt.findSh) + if tt.wantErr != "" { + assert.Nil(t, gotExpanded) + assert.EqualError(t, err, tt.wantErr) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantExpanded, gotExpanded) + }) + } +} diff --git a/pkg/cmd/root/extension.go b/pkg/cmd/root/extension.go new file mode 100644 index 000000000..52ef6b0a4 --- /dev/null +++ b/pkg/cmd/root/extension.go @@ -0,0 +1,42 @@ +package root + +import ( + "fmt" + + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ext extensions.Extension) *cobra.Command { + var short string + if ext.IsLocal() { + short = fmt.Sprintf("Local extension gh-%s", ext.Name()) + } else { + path := ext.URL() + if u, err := git.ParseURL(ext.URL()); err == nil { + if r, err := ghrepo.FromURL(u); err == nil { + path = ghrepo.FullName(r) + } + } + short = fmt.Sprintf("Extension %s", path) + } + return &cobra.Command{ + Use: ext.Name(), + Short: short, + RunE: func(c *cobra.Command, args []string) error { + args = append([]string{ext.Name()}, args...) + if _, err := em.Dispatch(args, io.In, io.Out, io.ErrOut); err != nil { + return fmt.Errorf("failed to run extension: %w\n", err) + } + return nil + }, + GroupID: "extension", + Annotations: map[string]string{ + "skipAuthCheck": "true", + }, + DisableFlagParsing: true, + } +} diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go index 9da57d6e9..c33095c1f 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -4,9 +4,11 @@ import ( "bytes" "fmt" "io" + "os" "sort" "strings" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -16,13 +18,17 @@ import ( func rootUsageFunc(w io.Writer, command *cobra.Command) error { fmt.Fprintf(w, "Usage: %s", command.UseLine()) - subcommands := command.Commands() + var subcommands []*cobra.Command + for _, c := range command.Commands() { + if !c.IsAvailableCommand() { + continue + } + subcommands = append(subcommands, c) + } + if len(subcommands) > 0 { fmt.Fprint(w, "\n\nAvailable commands:\n") for _, c := range subcommands { - if c.Hidden { - continue - } fmt.Fprintf(w, " %s\n", c.Name()) } return nil @@ -82,8 +88,10 @@ func isRootCmd(command *cobra.Command) bool { } func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) { + flags := command.Flags() + if isRootCmd(command) { - if versionVal, err := command.Flags().GetBool("version"); err == nil && versionVal { + if versionVal, err := flags.GetBool("version"); err == nil && versionVal { fmt.Fprint(f.IOStreams.Out, command.Annotations["versionInfo"]) return } else if err != nil { @@ -95,8 +103,8 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) { cs := f.IOStreams.ColorScheme() - if isRootCmd(command.Parent()) && len(args) >= 2 && args[1] != "--help" && args[1] != "-h" { - nestedSuggestFunc(f.IOStreams.ErrOut, command, args[1]) + if help, _ := flags.GetBool("help"); !help && !command.Runnable() && len(flags.Args()) > 0 { + nestedSuggestFunc(f.IOStreams.ErrOut, command, strings.Join(flags.Args(), " ")) hasFailed = true return } @@ -144,14 +152,6 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) { } sort.Strings(helpTopics) helpEntries = append(helpEntries, helpEntry{"HELP TOPICS", strings.Join(helpTopics, "\n")}) - - if exts := f.ExtensionManager.List(); len(exts) > 0 { - var names []string - for _, ext := range exts { - names = append(names, ext.Name()) - } - helpEntries = append(helpEntries, helpEntry{"EXTENSION COMMANDS", strings.Join(names, "\n")}) - } } flagUsages := command.LocalFlags().FlagUsages() @@ -189,6 +189,27 @@ Read the manual at https://cli.github.com/manual`}) } } +func authHelp() string { + if os.Getenv("GITHUB_ACTIONS") == "true" { + return heredoc.Doc(` + gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Example: + env: + GH_TOKEN: ${{ github.token }} + `) + } + + if os.Getenv("CI") != "" { + return heredoc.Doc(` + gh: To use GitHub CLI in automation, set the GH_TOKEN environment variable. + `) + } + + return heredoc.Doc(` + To get started with GitHub CLI, please run: gh auth login + Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token. + `) +} + func findCommand(cmd *cobra.Command, name string) *cobra.Command { for _, c := range cmd.Commands() { if c.Name() == name { diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 8d18a4db1..5a79e8aeb 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -1,8 +1,10 @@ package root import ( + "fmt" "net/http" "os" + "strings" "sync" "github.com/MakeNowJust/heredoc" @@ -10,6 +12,7 @@ import ( codespacesAPI "github.com/cli/cli/v2/internal/codespaces/api" actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions" aliasCmd "github.com/cli/cli/v2/pkg/cmd/alias" + "github.com/cli/cli/v2/pkg/cmd/alias/shared" apiCmd "github.com/cli/cli/v2/pkg/cmd/api" authCmd "github.com/cli/cli/v2/pkg/cmd/auth" browseCmd "github.com/cli/cli/v2/pkg/cmd/browse" @@ -36,15 +39,29 @@ import ( versionCmd "github.com/cli/cli/v2/pkg/cmd/version" workflowCmd "github.com/cli/cli/v2/pkg/cmd/workflow" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/google/shlex" "github.com/spf13/cobra" ) -func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { +type AuthError struct { + err error +} + +func (ae *AuthError) Error() string { + return ae.err.Error() +} + +func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, error) { + io := f.IOStreams + cfg, err := f.Config() + if err != nil { + return nil, fmt.Errorf("failed to read configuration: %s\n", err) + } + cmd := &cobra.Command{ Use: "gh [flags]", Short: "GitHub CLI", Long: `Work seamlessly with GitHub from the command line.`, - Example: heredoc.Doc(` $ gh issue create $ gh repo clone cli/cli @@ -53,6 +70,14 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { Annotations: map[string]string{ "versionInfo": versionCmd.Format(version, buildDate), }, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // require that the user is authenticated before running most commands + if cmdutil.IsAuthCheckEnabled(cmd) && !cmdutil.CheckAuth(cfg) { + fmt.Fprint(io.ErrOut, authHelp()) + return &AuthError{} + } + return nil + }, } // cmd.SetOut(f.IOStreams.Out) // can't use due to https://github.com/spf13/cobra/issues/1708 @@ -85,6 +110,10 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { ID: "actions", Title: "GitHub Actions commands", }) + cmd.AddGroup(&cobra.Group{ + ID: "extension", + Title: "Extension commands", + }) // Child commands cmd.AddCommand(versionCmd.NewCmdVersion(f, version, buildDate)) @@ -136,6 +165,51 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { } } + // Aliases + aliases := cfg.Aliases() + validAliasName := shared.ValidAliasNameFunc(cmd) + validAliasExpansion := shared.ValidAliasExpansionFunc(cmd) + for k, v := range aliases.All() { + aliasName := k + aliasValue := v + if validAliasName(aliasName) && validAliasExpansion(aliasValue) { + split, _ := shlex.Split(aliasName) + parentCmd, parentArgs, _ := cmd.Find(split) + if !parentCmd.ContainsGroup("alias") { + parentCmd.AddGroup(&cobra.Group{ + ID: "alias", + Title: "Alias commands", + }) + } + if strings.HasPrefix(aliasValue, "!") { + shellAliasCmd := NewCmdShellAlias(io, parentArgs[0], aliasValue) + parentCmd.AddCommand(shellAliasCmd) + parentCmd.ValidArgs = append(parentCmd.ValidArgs, fmt.Sprintf("%s\tShell alias", aliasName)) + } else { + aliasCmd := NewCmdAlias(io, parentArgs[0], aliasValue) + split, _ := shlex.Split(aliasValue) + child, _, _ := cmd.Find(split) + aliasCmd.SetUsageFunc(func(_ *cobra.Command) error { + return rootUsageFunc(f.IOStreams.ErrOut, child) + }) + aliasCmd.SetHelpFunc(func(_ *cobra.Command, args []string) { + rootHelpFunc(f, child, args) + }) + parentCmd.AddCommand(aliasCmd) + parentCmd.ValidArgs = append(parentCmd.ValidArgs, fmt.Sprintf("%s\tAlias for %s", aliasName, aliasValue)) + } + } + } + + // Extensions + em := f.ExtensionManager + for _, e := range em.List() { + extension := e + extensionCmd := NewCmdExtension(io, em, e) + cmd.AddCommand(extensionCmd) + cmd.ValidArgs = append(cmd.ValidArgs, fmt.Sprintf("%s\t%s", extension.Name(), extensionCmd.Short)) + } + cmdutil.DisableAuthCheck(cmd) // The reference command produces paged output that displays information on every other command. @@ -145,7 +219,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { // is special. referenceCmd.Long = stringifyReference(cmd) referenceCmd.SetHelpFunc(longPager(f.IOStreams)) - return cmd + return cmd, nil } func bareHTTPClient(f *cmdutil.Factory, version string) func() (*http.Client, error) {