488 lines
15 KiB
Go
488 lines
15 KiB
Go
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()),
|
|
}
|
|
|
|
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 <command>` to `gh <command> --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)
|
|
})
|
|
}
|