diff --git a/cmd/gh/main.go b/cmd/gh/main.go index e20452808..e8471da8d 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -13,6 +13,7 @@ import ( "time" surveyCore "github.com/AlecAivazis/survey/v2/core" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/cli/cli/api" "github.com/cli/cli/internal/build" "github.com/cli/cli/internal/config" @@ -32,7 +33,21 @@ import ( var updaterEnabled = "" +type exitCode int + +const ( + exitOK exitCode = 0 + exitError exitCode = 1 + exitCancel exitCode = 2 + exitAuth exitCode = 4 +) + func main() { + code := mainRun() + os.Exit(int(code)) +} + +func mainRun() exitCode { buildDate := build.Date buildVersion := build.Version @@ -78,7 +93,7 @@ func main() { cfg, err := cmdFactory.Config() if err != nil { fmt.Fprintf(stderr, "failed to read configuration: %s\n", err) - os.Exit(2) + return exitError } if prompt, _ := cfg.Get("", "prompt"); prompt == "disabled" { @@ -102,7 +117,7 @@ func main() { expandedArgs, isShell, err = expand.ExpandAlias(cfg, os.Args, nil) if err != nil { fmt.Fprintf(stderr, "failed to process aliases: %s\n", err) - os.Exit(2) + return exitError } if hasDebug { @@ -113,7 +128,7 @@ func main() { exe, err := safeexec.LookPath(expandedArgs[0]) if err != nil { fmt.Fprintf(stderr, "failed to run external command: %s", err) - os.Exit(3) + return exitError } externalCmd := exec.Command(exe, expandedArgs[1:]...) @@ -125,14 +140,14 @@ func main() { err = preparedCmd.Run() if err != nil { if ee, ok := err.(*exec.ExitError); ok { - os.Exit(ee.ExitCode()) + return exitCode(ee.ExitCode()) } fmt.Fprintf(stderr, "failed to run external command: %s", err) - os.Exit(3) + return exitError } - os.Exit(0) + return exitOK } } @@ -142,34 +157,41 @@ func main() { fmt.Fprintln(stderr, cs.Bold("Welcome to GitHub CLI!")) fmt.Fprintln(stderr) fmt.Fprintln(stderr, "To authenticate, please run `gh auth login`.") - os.Exit(4) + return exitAuth } rootCmd.SetArgs(expandedArgs) if cmd, err := rootCmd.ExecuteC(); err != nil { + if err == cmdutil.SilentError { + return exitError + } else if cmdutil.IsUserCancellation(err) { + if errors.Is(err, terminal.InterruptErr) { + // ensure the next shell prompt will start on its own line + fmt.Fprint(stderr, "\n") + } + return exitCancel + } + printError(stderr, err, cmd, hasDebug) var httpErr api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == 401 { - fmt.Println("hint: try authenticating with `gh auth login`") + fmt.Fprintln(stderr, "hint: try authenticating with `gh auth login`") } - os.Exit(1) + return exitError } if root.HasFailed() { - os.Exit(1) + return exitError } newRelease := <-updateMessageChan if newRelease != nil { - isHomebrew := false - if ghExe, err := os.Executable(); err == nil { - isHomebrew = isUnderHomebrew(ghExe) - } + isHomebrew := isUnderHomebrew(cmdFactory.Executable) if isHomebrew && isRecentRelease(newRelease.PublishedAt) { // do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core - return + return exitOK } fmt.Fprintf(stderr, "\n\n%s %s → %s\n", ansi.Color("A new release of gh is available:", "yellow"), @@ -181,13 +203,11 @@ func main() { fmt.Fprintf(stderr, "%s\n\n", ansi.Color(newRelease.URL, "yellow")) } + + return exitOK } func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) { - if err == cmdutil.SilentError { - return - } - var dnsError *net.DNSError if errors.As(err, &dnsError) { fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name) diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 7d7f23a92..106a564d2 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -38,6 +38,7 @@ type ApiOptions struct { MagicFields []string RawFields []string RequestHeaders []string + Previews []string ShowResponseHeaders bool Paginate bool Silent bool @@ -119,7 +120,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command $ gh api -X GET search/issues -f q='repo:cli/cli is:open remote' # set a custom HTTP header - $ gh api -H 'Accept: application/vnd.github.XYZ-preview+json' ... + $ gh api -H 'Accept: application/vnd.github.v3.raw+json' ... + + # opt into GitHub API previews + $ gh api --preview baptiste,nebula ... # use a template for the output $ gh api repos/:owner/:repo/issues --template \ @@ -192,6 +196,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a typed parameter in `key=value` format") cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format") cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add a HTTP request header in `key:value` format") + cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "Opt into GitHub API previews") cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output") cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results") cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request") @@ -237,6 +242,10 @@ func apiRun(opts *ApiOptions) error { } } + if len(opts.Previews) > 0 { + requestHeaders = append(requestHeaders, "Accept: "+previewNamesToMIMETypes(opts.Previews)) + } + httpClient, err := opts.HttpClient() if err != nil { return err @@ -555,3 +564,11 @@ func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error) return bodyCopy, "", nil } + +func previewNamesToMIMETypes(names []string) string { + types := []string{fmt.Sprintf("application/vnd.github.%s-preview+json", names[0])} + for _, p := range names[1:] { + types = append(types, fmt.Sprintf("application/vnd.github.%s-preview", p)) + } + return strings.Join(types, ", ") +} diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index 7dc508e64..16df36c20 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -913,6 +913,32 @@ func Test_fillPlaceholders(t *testing.T) { } } +func Test_previewNamesToMIMETypes(t *testing.T) { + tests := []struct { + name string + previews []string + want string + }{ + { + name: "single", + previews: []string{"nebula"}, + want: "application/vnd.github.nebula-preview+json", + }, + { + name: "multiple", + previews: []string{"nebula", "baptiste", "squirrel-girl"}, + want: "application/vnd.github.nebula-preview+json, application/vnd.github.baptiste-preview, application/vnd.github.squirrel-girl-preview", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := previewNamesToMIMETypes(tt.previews); got != tt.want { + t.Errorf("previewNamesToMIMETypes() = %q, want %q", got, tt.want) + } + }) + } +} + func Test_processResponse_template(t *testing.T) { io, _, stdout, stderr := iostreams.Test() diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 64baae10f..995405021 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -23,6 +23,8 @@ type LoginOptions struct { Config func() (config.Config, error) HttpClient func() (*http.Client, error) + MainExecutable string + Interactive bool Hostname string @@ -36,6 +38,8 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, + + MainExecutable: f.Executable, } var tokenStdin bool @@ -189,6 +193,7 @@ func loginRun(opts *LoginOptions) error { Interactive: opts.Interactive, Web: opts.Web, Scopes: opts.Scopes, + Executable: opts.MainExecutable, }) } diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 32f236ca9..58267182c 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -19,6 +19,8 @@ type RefreshOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) + MainExecutable string + Hostname string Scopes []string AuthFlow func(config.Config, *iostreams.IOStreams, string, []string) error @@ -34,6 +36,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. _, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes) return err }, + MainExecutable: f.Executable, } cmd := &cobra.Command{ diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go index e67d95a6e..d95004469 100644 --- a/pkg/cmd/auth/shared/git_credential.go +++ b/pkg/cmd/auth/shared/git_credential.go @@ -15,6 +15,8 @@ import ( ) type GitCredentialFlow struct { + Executable string + shouldSetup bool helper string scopes []string @@ -50,13 +52,26 @@ func (flow *GitCredentialFlow) ShouldSetup() bool { } func (flow *GitCredentialFlow) Setup(hostname, username, authToken string) error { - return GitCredentialSetup(hostname, username, authToken, flow.helper) + return flow.gitCredentialSetup(hostname, username, authToken) } -func GitCredentialSetup(hostname, username, password, helper string) error { - if helper == "" { +func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password string) error { + if flow.helper == "" { + // first use a blank value to indicate to git we want to sever the chain of credential helpers + preConfigureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "") + if err != nil { + return err + } + if err = run.PrepareCmd(preConfigureCmd).Run(); err != nil { + return err + } + // use GitHub CLI as a credential helper (for this host only) - configureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "!gh auth git-credential") + configureCmd, err := git.GitCommand( + "config", "--global", "--add", + gitCredentialHelperKey(hostname), + fmt.Sprintf("!%s auth git-credential", shellQuote(flow.Executable)), + ) if err != nil { return err } @@ -124,3 +139,10 @@ func isOurCredentialHelper(cmd string) bool { return strings.TrimSuffix(filepath.Base(args[0]), ".exe") == "gh" } + +func shellQuote(s string) string { + if strings.ContainsAny(s, " $") { + return "'" + s + "'" + } + return s +} diff --git a/pkg/cmd/auth/shared/git_credential_test.go b/pkg/cmd/auth/shared/git_credential_test.go index debcf2d12..5a0b8b10b 100644 --- a/pkg/cmd/auth/shared/git_credential_test.go +++ b/pkg/cmd/auth/shared/git_credential_test.go @@ -12,7 +12,12 @@ func TestGitCredentialSetup_configureExisting(t *testing.T) { cs.Register(`git credential reject`, 0, "") cs.Register(`git credential approve`, 0, "") - if err := GitCredentialSetup("example.com", "monalisa", "PASSWD", "osxkeychain"); err != nil { + f := GitCredentialFlow{ + Executable: "gh", + helper: "osxkeychain", + } + + if err := f.gitCredentialSetup("example.com", "monalisa", "PASSWD"); err != nil { t.Errorf("GitCredentialSetup() error = %v", err) } } @@ -20,13 +25,29 @@ func TestGitCredentialSetup_configureExisting(t *testing.T) { func TestGitCredentialSetup_setOurs(t *testing.T) { cs, restoreRun := run.Stub() defer restoreRun(t) - cs.Register(`git config --global credential\.https://example\.com\.helper`, 0, "", func(args []string) { - if val := args[len(args)-1]; val != "!gh auth git-credential" { + cs.Register(`git config --global credential\.`, 0, "", func(args []string) { + if key := args[len(args)-2]; key != "credential.https://example.com.helper" { + t.Errorf("git config key was %q", key) + } + if val := args[len(args)-1]; val != "" { + t.Errorf("global credential helper configured to %q", val) + } + }) + cs.Register(`git config --global --add credential\.`, 0, "", func(args []string) { + if key := args[len(args)-2]; key != "credential.https://example.com.helper" { + t.Errorf("git config key was %q", key) + } + if val := args[len(args)-1]; val != "!/path/to/gh auth git-credential" { t.Errorf("global credential helper configured to %q", val) } }) - if err := GitCredentialSetup("example.com", "monalisa", "PASSWD", ""); err != nil { + f := GitCredentialFlow{ + Executable: "/path/to/gh", + helper: "", + } + + if err := f.gitCredentialSetup("example.com", "monalisa", "PASSWD"); err != nil { t.Errorf("GitCredentialSetup() error = %v", err) } } diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 6f75a784f..42369c466 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -28,6 +28,7 @@ type LoginOptions struct { Interactive bool Web bool Scopes []string + Executable string sshContext sshContext } @@ -56,7 +57,7 @@ func Login(opts *LoginOptions) error { var additionalScopes []string - credentialFlow := &GitCredentialFlow{} + credentialFlow := &GitCredentialFlow{Executable: opts.Executable} if opts.Interactive && gitProtocol == "https" { if err := credentialFlow.Prompt(hostname); err != nil { return err diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 2c70ab5cd..ff8ca8ac9 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -44,6 +44,11 @@ func New(appVersion string) *cmdutil.Factory { } remotesFunc := rr.Resolver(hostOverride) + ghExecutable := "gh" + if exe, err := os.Executable(); err == nil { + ghExecutable = exe + } + return &cmdutil.Factory{ IOStreams: io, Config: configFunc, @@ -70,5 +75,6 @@ func New(appVersion string) *cmdutil.Factory { } return currentBranch, nil }, + Executable: ghExecutable, } } diff --git a/pkg/cmd/gist/edit/edit.go b/pkg/cmd/gist/edit/edit.go index 27df4a104..bafc0095d 100644 --- a/pkg/cmd/gist/edit/edit.go +++ b/pkg/cmd/gist/edit/edit.go @@ -195,7 +195,7 @@ func editRun(opts *EditOptions) error { case "Submit": stop = true case "Cancel": - return cmdutil.SilentError + return cmdutil.CancelError } if stop { diff --git a/pkg/cmd/gist/edit/edit_test.go b/pkg/cmd/gist/edit/edit_test.go index 73a53e523..5f6f0b7f2 100644 --- a/pkg/cmd/gist/edit/edit_test.go +++ b/pkg/cmd/gist/edit/edit_test.go @@ -194,7 +194,7 @@ func Test_editRun(t *testing.T) { as.StubOne("unix.md") as.StubOne("Cancel") }, - wantErr: "SilentError", + wantErr: "CancelError", gist: &shared.Gist{ ID: "1234", Files: map[string]*shared.GistFile{ diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 6bf9356c6..44ae3eb98 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -260,6 +260,7 @@ func createRun(opts *CreateOptions) (err error) { if action == prShared.CancelAction { fmt.Fprintln(opts.IO.ErrOut, "Discarding.") + err = cmdutil.CancelError return } } else { diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 08ea04d94..0de13bd49 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -311,7 +311,8 @@ func createRun(opts *CreateOptions) (err error) { if action == shared.CancelAction { fmt.Fprintln(opts.IO.ErrOut, "Discarding.") - return nil + err = cmdutil.CancelError + return } err = handlePush(*opts, *ctx) @@ -553,7 +554,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { } else if pushOptions[selectedOption] == "Skip pushing the branch" { isPushEnabled = false } else if pushOptions[selectedOption] == "Cancel" { - return nil, cmdutil.SilentError + return nil, cmdutil.CancelError } else { // "Create a fork of ..." if baseRepo.IsPrivate { diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index c979a7f91..3b5a10b5b 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -227,7 +227,7 @@ func mergeRun(opts *MergeOptions) error { } if action == shared.CancelAction { fmt.Fprintln(opts.IO.ErrOut, "Cancelled.") - return cmdutil.SilentError + return cmdutil.CancelError } } diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 538f75056..78f195497 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -895,7 +895,7 @@ func TestPRMerge_interactiveCancelled(t *testing.T) { as.StubOne("Cancel") // Confirm submit survey output, err := runCommand(http, "blueberries", true, "") - if !errors.Is(err, cmdutil.SilentError) { + if !errors.Is(err, cmdutil.CancelError) { t.Fatalf("got error %v", err) } diff --git a/pkg/cmd/pr/shared/preserve.go b/pkg/cmd/pr/shared/preserve.go index 4105823cd..6d3c32965 100644 --- a/pkg/cmd/pr/shared/preserve.go +++ b/pkg/cmd/pr/shared/preserve.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" ) @@ -18,6 +19,11 @@ func PreserveInput(io *iostreams.IOStreams, state *IssueMetadataState, createErr return } + if cmdutil.IsUserCancellation(*createErr) { + // these errors are user-initiated cancellations + return + } + out := io.ErrOut // this extra newline guards against appending to the end of a survey line diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index cab6292c7..18f06d2ac 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -256,7 +256,7 @@ func createRun(opts *CreateOptions) error { case "Save as draft": opts.Draft = true case "Cancel": - return cmdutil.SilentError + return cmdutil.CancelError default: return fmt.Errorf("invalid action: %v", opts.SubmitAction) } diff --git a/pkg/cmd/release/delete/delete.go b/pkg/cmd/release/delete/delete.go index fde3fbcee..c880f9d58 100644 --- a/pkg/cmd/release/delete/delete.go +++ b/pkg/cmd/release/delete/delete.go @@ -78,7 +78,7 @@ func deleteRun(opts *DeleteOptions) error { } if !confirmed { - return cmdutil.SilentError + return cmdutil.CancelError } } diff --git a/pkg/cmdutil/errors.go b/pkg/cmdutil/errors.go index 77ca58340..aa33e98ef 100644 --- a/pkg/cmdutil/errors.go +++ b/pkg/cmdutil/errors.go @@ -1,6 +1,10 @@ package cmdutil -import "errors" +import ( + "errors" + + "github.com/AlecAivazis/survey/v2/terminal" +) // FlagError is the kind of error raised in flag processing type FlagError struct { @@ -17,3 +21,10 @@ func (fe FlagError) Unwrap() error { // SilentError is an error that triggers exit code 1 without any error messaging var SilentError = errors.New("SilentError") + +// CancelError signals user-initiated cancellation +var CancelError = errors.New("CancelError") + +func IsUserCancellation(err error) bool { + return errors.Is(err, CancelError) || errors.Is(err, terminal.InterruptErr) +} diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index 62ed5d802..eb9546b52 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -16,4 +16,7 @@ type Factory struct { Remotes func() (context.Remotes, error) Config func() (config.Config, error) Branch func() (string, error) + + // Executable is the path to the currently invoked gh binary + Executable string }