package ghcmd import ( "context" "errors" "fmt" "io" "net" "os" "os/exec" "path/filepath" "slices" "strconv" "strings" "time" surveyCore "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/agents" "github.com/cli/cli/v2/internal/build" "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/internal/update" "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" ghauth "github.com/cli/go-gh/v2/pkg/auth" xcolor "github.com/cli/go-gh/v2/pkg/x/color" "github.com/cli/safeexec" "github.com/mgutz/ansi" "github.com/spf13/cobra" ) type exitCode int const ( exitOK exitCode = 0 exitError exitCode = 1 exitCancel exitCode = 2 exitAuth exitCode = 4 exitPending exitCode = 8 ) func Main() exitCode { buildDate := build.Date buildVersion := build.Version hasDebug, _ := utils.IsDebugEnabled() cfg, cfgErr := config.NewConfig() if cfgErr != nil { fmt.Fprintf(os.Stderr, "warning: failed to load config: %s\n", cfgErr) } cfgFunc := func() (gh.Config, error) { return cfg, cfgErr } var ioStreams *iostreams.IOStreams if cfgErr == nil { ioStreams = newIOStreams(cfg) } else { ioStreams = iostreams.System() } stderr := ioStreams.ErrOut ghExecutablePath := executablePath("gh") additionalCommonDimensions := ghtelemetry.Dimensions{ "version": strings.TrimPrefix(buildVersion, "v"), "is_tty": strconv.FormatBool(ioStreams.IsStdoutTTY()), "agent": string(agents.Detect()), "ci": strconv.FormatBool(ci.IsCI()), "github_actions": strconv.FormatBool(ci.IsGitHubActions()), "accessible_colors": strconv.FormatBool(ioStreams.AccessibleColorsEnabled()), "accessible_prompter": strconv.FormatBool(ioStreams.AccessiblePrompterEnabled()), "color_labels": strconv.FormatBool(ioStreams.ColorLabels()), "spinner_disabled": strconv.FormatBool(ioStreams.GetSpinnerDisabled()), } var telemetryService ghtelemetry.Service switch { case cfgErr != nil: // Without a valid on-disk config we can't honour user telemetry preferences, so disable it to be safe. telemetryService = &telemetry.NoOpService{} default: telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) telemetryDisabled := mightBeGHESUser(cfg) switch telemetryState { case telemetry.Disabled: telemetryService = &telemetry.NoOpService{} case telemetry.Logged: // Always construct the real service in log mode so that the log // flusher runs and surfaces an explicit "Telemetry payload: none" // marker when no events will be sent. This gives the user an // observable signal that telemetry is wired up even when their // context (e.g. GHES) causes events to be dropped. telemetryService = telemetry.NewService( telemetry.LogFlusher(ioStreams.ErrOut, ioStreams.ColorEnabled()), telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), ) if telemetryDisabled { telemetryService.Disable() } case telemetry.Enabled: if telemetryDisabled { telemetryService = &telemetry.NoOpService{} break } sampleRate := 1 if v, err := strconv.Atoi(os.Getenv("GH_TELEMETRY_SAMPLE_RATE")); err == nil && v >= 0 && v <= 100 { sampleRate = v } additionalCommonDimensions["sample_rate"] = strconv.Itoa(sampleRate) telemetryService = telemetry.NewService( telemetry.GitHubFlusher(ghExecutablePath), telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), telemetry.WithSampleRate(sampleRate), ) default: fmt.Fprintf(stderr, "invalid telemetry configuration: %q\n", cfg.Telemetry().Value) return exitError } } defer telemetryService.Flush() cmdFactory := factory.New(buildVersion, string(agents.Detect()), cfgFunc, ioStreams, ghExecutablePath, telemetryService) if cfgErr == nil { var m migration.MultiAccount if err := cfg.Migrate(m); err != nil { fmt.Fprintln(stderr, err) return exitError } } ctx := context.Background() updateCtx, updateCancel := context.WithCancel(ctx) defer updateCancel() updateMessageChan := make(chan *update.ReleaseInfo) go func() { rel, err := checkForUpdate(updateCtx, cmdFactory, buildVersion) if err != nil && hasDebug { fmt.Fprintf(stderr, "warning: checking for update failed: %v", err) } updateMessageChan <- rel }() if !cmdFactory.IOStreams.ColorEnabled() { surveyCore.DisableColor = true ansi.DisableColors(true) } else { // override survey's poor choice of color surveyCore.TemplateFuncsWithColor["color"] = func(style string) string { switch style { case "white": return ansi.ColorCode("default") default: return ansi.ColorCode(style) } } } // Enable running gh from Windows File Explorer's address bar. Without this, the user is told to stop and run from a // terminal. With this, a user can clone a repo (or take other actions) directly from explorer. if len(os.Args) > 1 && os.Args[1] != "" { cobra.MousetrapHelpText = "" } rootCmd, err := root.NewCmdRoot(cmdFactory, telemetryService, buildVersion, buildDate) if err != nil { fmt.Fprintf(stderr, "failed to create root command: %s\n", err) return exitError } expandedArgs := []string{} if len(os.Args) > 0 { expandedArgs = os.Args[1:] } // 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) if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil { var pagerPipeError *iostreams.ErrClosedPagerPipe var noResultsError cmdutil.NoResultsError var extError *root.ExternalCommandExitError var authError *root.AuthError if err == cmdutil.SilentError { return exitError } else if err == cmdutil.PendingError { return exitPending } 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 } else if errors.As(err, &authError) { return exitAuth } else if errors.As(err, &pagerPipeError) { // ignore the error raised when piping to a closed pager return exitOK } else if errors.As(err, &noResultsError) { if cmdFactory.IOStreams.IsStdoutTTY() { fmt.Fprintln(stderr, noResultsError.Error()) } // no results is not a command failure return exitOK } else if errors.As(err, &extError) { // pass on exit codes from extensions and shell aliases return exitCode(extError.ExitCode()) } printError(stderr, err, cmd, hasDebug) if strings.Contains(err.Error(), "Incorrect function") { fmt.Fprintln(stderr, "You appear to be running in MinTTY without pseudo terminal support.") fmt.Fprintln(stderr, "To learn about workarounds for this error, run: gh help mintty") return exitError } var httpErr api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == 401 { authCommand := "gh auth login" if cfg, cfgErr := cmdFactory.Config(); cfgErr == nil { authCommand = authRecoveryCommand(cfg, httpErr) } fmt.Fprintf(stderr, "Try authenticating with: %s\n", authCommand) } else if u := factory.SSOURL(); u != "" { // handles organization SAML enforcement error fmt.Fprintf(stderr, "Authorize in your web browser: %s\n", u) } else if msg := httpErr.ScopesSuggestion(); msg != "" { fmt.Fprintln(stderr, msg) } return exitError } if root.HasFailed() { return exitError } updateCancel() // if the update checker hasn't completed by now, abort it newRelease := <-updateMessageChan if newRelease != nil { isHomebrew := isUnderHomebrew(cmdFactory.ExecutablePath) if isHomebrew && isRecentRelease(newRelease.PublishedAt) { // do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core return exitOK } fmt.Fprintf(stderr, "\n\n%s %s → %s\n", ansi.Color("A new release of gh is available:", "yellow"), ansi.Color(strings.TrimPrefix(buildVersion, "v"), "cyan"), ansi.Color(strings.TrimPrefix(newRelease.Version, "v"), "cyan")) if isHomebrew { fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew upgrade gh") } fmt.Fprintf(stderr, "%s\n\n", ansi.Color(newRelease.URL, "yellow")) } return exitOK } // 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) { var dnsError *net.DNSError if errors.As(err, &dnsError) { fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name) if debug { fmt.Fprintln(out, dnsError) } fmt.Fprintln(out, "check your internet connection or https://githubstatus.com") return } fmt.Fprintln(out, err) var flagError *cmdutil.FlagError if errors.As(err, &flagError) || strings.HasPrefix(err.Error(), "unknown command ") { if !strings.HasSuffix(err.Error(), "\n") { fmt.Fprintln(out) } fmt.Fprintln(out, cmd.UsageString()) } } func authRecoveryCommand(cfg gh.Config, httpErr api.HTTPError) string { if httpErr.RequestURL == nil { return "gh auth login" } hostname := ghauth.NormalizeHostname(httpErr.RequestURL.Hostname()) token, source := cfg.Authentication().ActiveToken(hostname) if shared.AuthTokenRefreshable(token, source) { return fmt.Sprintf("gh auth refresh -h %s", hostname) } return fmt.Sprintf("gh auth login -h %s", hostname) } func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) { if updaterEnabled == "" || !update.ShouldCheckForUpdate() { return nil, nil } httpClient, err := f.HttpClient() if err != nil { return nil, err } stateFilePath := filepath.Join(config.StateDir(), "state.yml") return update.CheckForUpdate(ctx, httpClient, stateFilePath, updaterEnabled, currentVersion) } func isRecentRelease(publishedAt time.Time) bool { return !publishedAt.IsZero() && time.Since(publishedAt) < time.Hour*24 } // Check whether the gh binary was found under the Homebrew prefix func isUnderHomebrew(ghBinary string) bool { brewExe, err := safeexec.LookPath("brew") if err != nil { return false } brewPrefixBytes, err := exec.Command(brewExe, "--prefix").Output() if err != nil { return false } brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator) return strings.HasPrefix(ghBinary, brewBinPrefix) } func newIOStreams(cfg gh.Config) *iostreams.IOStreams { io := iostreams.System() if _, ghPromptDisabled := os.LookupEnv("GH_PROMPT_DISABLED"); ghPromptDisabled { io.SetNeverPrompt(true) } else if prompt := cfg.Prompt(""); prompt.Value == "disabled" { io.SetNeverPrompt(true) } falseyValues := []string{"false", "0", "no", ""} accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER") if accessiblePrompterIsSet { if !slices.Contains(falseyValues, accessiblePrompterValue) { io.SetAccessiblePrompterEnabled(true) } } else if prompt := cfg.AccessiblePrompter(""); prompt.Value == "enabled" { io.SetAccessiblePrompterEnabled(true) } experimentalPrompterValue, experimentalPrompterIsSet := os.LookupEnv("GH_EXPERIMENTAL_PROMPTER") if experimentalPrompterIsSet { if !slices.Contains(falseyValues, experimentalPrompterValue) { io.SetExperimentalPrompterEnabled(true) } } ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") if ghSpinnerDisabledIsSet { if !slices.Contains(falseyValues, ghSpinnerDisabledValue) { io.SetSpinnerDisabled(true) } } else if spinnerDisabled := cfg.Spinner(""); spinnerDisabled.Value == "disabled" { io.SetSpinnerDisabled(true) } // Pager precedence // 1. GH_PAGER // 2. pager from config // 3. PAGER if ghPager, ghPagerExists := os.LookupEnv("GH_PAGER"); ghPagerExists { io.SetPager(ghPager) } else if pager := cfg.Pager(""); pager.Value != "" { io.SetPager(pager.Value) } if ghColorLabels, ghColorLabelsExists := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabelsExists { switch ghColorLabels { case "", "0", "false", "no": io.SetColorLabels(false) default: io.SetColorLabels(true) } } else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" { io.SetColorLabels(true) } io.SetAccessibleColorsEnabled(xcolor.IsAccessibleColorsEnabled()) return io } // Executable is the path to the currently invoked binary func executablePath(executableName string) string { ghPath := os.Getenv("GH_PATH") if ghPath != "" { return ghPath } if strings.ContainsRune(executableName, os.PathSeparator) { return executableName } return executable(executableName) } // Finds the location of the executable for the current process as it's found in PATH, respecting symlinks. // If the process couldn't determine its location, return fallbackName. If the executable wasn't found in // PATH, return the absolute location to the program. // // The idea is that the result of this function is callable in the future and refers to the same // installation of gh, even across upgrades. This is needed primarily for Homebrew, which installs software // under a location such as `/usr/local/Cellar/gh/1.13.1/bin/gh` and symlinks it from `/usr/local/bin/gh`. // When the version is upgraded, Homebrew will often delete older versions, but keep the symlink. Because of // this, we want to refer to the `gh` binary as `/usr/local/bin/gh` and not as its internal Homebrew // location. // // None of this would be needed if we could just refer to GitHub CLI as `gh`, i.e. without using an absolute // path. However, for some reason Homebrew does not include `/usr/local/bin` in PATH when it invokes git // commands to update its taps. If `gh` (no path) is being used as git credential helper, as set up by `gh // auth login`, running `brew update` will print out authentication errors as git is unable to locate // Homebrew-installed `gh` func executable(fallback string) string { exe, err := os.Executable() if err != nil { return fallback } base := filepath.Base(exe) path := os.Getenv("PATH") for _, dir := range filepath.SplitList(path) { p, err := filepath.Abs(filepath.Join(dir, base)) if err != nil { continue } f, err := os.Lstat(p) if err != nil { continue } if p == exe { return p } else if f.Mode()&os.ModeSymlink != 0 { realP, err := filepath.EvalSymlinks(p) if err != nil { continue } realExe, err := filepath.EvalSymlinks(exe) if err != nil { continue } if realP == realExe { return p } } } return exe } func mightBeGHESUser(cfg gh.Config) bool { if os.Getenv("GH_ENTERPRISE_TOKEN") != "" || os.Getenv("GITHUB_ENTERPRISE_TOKEN") != "" { return true } if host := os.Getenv("GH_HOST"); host != "" && ghauth.IsEnterprise(host) { return true } // If any targeted host is Enterprise, then the user is likely a GHES user. return slices.ContainsFunc(cfg.Authentication().Hosts(), func(host string) bool { return ghauth.IsEnterprise(host) }) }