cli/cmd/gh/main.go
Mislav Marohnić 0a5e220231 Ignore EPIPE errors when writing to a closed pager
While a gh command is writing stdout to a pager, the user may choose to
close the pager program before the pager has read all the data on its
standard input. In that case, the parent gh process will receive an
EPIPE error, which would bubble up its error handling and cause it to
print something like:

    write |1: broken pipe

Since this was caused by an explicit user action of closing the pager,
and since the user probably doesn't want to see this uninformative
error, this informs our global error handling of this error and causes
it to be ignored.
2022-02-10 16:42:00 +01:00

371 lines
11 KiB
Go

package main
import (
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"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/build"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/run"
"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"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/utils"
"github.com/cli/safeexec"
"github.com/mattn/go-colorable"
"github.com/mgutz/ansi"
"github.com/spf13/cobra"
)
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
updateMessageChan := make(chan *update.ReleaseInfo)
go func() {
rel, _ := checkForUpdate(buildVersion)
updateMessageChan <- rel
}()
hasDebug := os.Getenv("DEBUG") != ""
cmdFactory := factory.New(buildVersion)
stderr := cmdFactory.IOStreams.ErrOut
if spec := os.Getenv("GH_FORCE_TTY"); spec != "" {
cmdFactory.IOStreams.ForceTerminal(spec)
}
if !cmdFactory.IOStreams.ColorEnabled() {
surveyCore.DisableColor = true
} else {
// override survey's poor choice of color
surveyCore.TemplateFuncsWithColor["color"] = func(style string) string {
switch style {
case "white":
if cmdFactory.IOStreams.ColorSupport256() {
return fmt.Sprintf("\x1b[%d;5;%dm", 38, 242)
}
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 := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
cfg, err := cmdFactory.Config()
if err != nil {
fmt.Fprintf(stderr, "failed to read configuration: %s\n", err)
return exitError
}
// TODO: remove after FromFullName has been revisited
if host, err := cfg.DefaultHost(); err == nil {
ghrepo.SetDefaultHost(host)
}
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" && !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
if aliases, err := cfg.Aliases(); err == nil {
for aliasName := range aliases.All() {
if strings.HasPrefix(aliasName, toComplete) {
results = append(results, aliasName)
}
}
}
for _, ext := range cmdFactory.ExtensionManager.List(false) {
if strings.HasPrefix(ext.Name(), toComplete) {
results = append(results, ext.Name())
}
}
return results, cobra.ShellCompDirectiveNoFileComp
}
cs := cmdFactory.IOStreams.ColorScheme()
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.Fprintln(stderr, cs.Bold("Welcome to GitHub CLI!"))
fmt.Fprintln(stderr)
fmt.Fprintln(stderr, "To authenticate, please run `gh auth login`.")
return authError
}
return nil
}
rootCmd.SetArgs(expandedArgs)
if cmd, err := rootCmd.ExecuteC(); err != nil {
var pagerPipeError *iostreams.ErrClosedPagerPipe
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
} else if errors.Is(err, authError) {
return exitAuth
} else if errors.As(err, &pagerPipeError) {
// ignore the error raised when piping to a closed pager
return exitOK
}
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 {
fmt.Fprintln(stderr, "Try authenticating with: gh auth login")
} 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
}
newRelease := <-updateMessageChan
if newRelease != nil {
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 exitOK
}
fmt.Fprintf(stderr, "\n\n%s %s → %s\n",
ansi.Color("A new release of gh is available:", "yellow"),
ansi.Color(buildVersion, "cyan"),
ansi.Color(newRelease.Version, "cyan"))
if isHomebrew {
fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew update && brew upgrade gh")
}
fmt.Fprintf(stderr, "%s\n\n",
ansi.Color(newRelease.URL, "yellow"))
}
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
}
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 shouldCheckForUpdate() bool {
if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
return false
}
if os.Getenv("CODESPACES") != "" {
return false
}
return updaterEnabled != "" && !isCI() && utils.IsTerminal(os.Stdout) && utils.IsTerminal(os.Stderr)
}
// based on https://github.com/watson/ci-info/blob/HEAD/index.js
func isCI() bool {
return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari
os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity
os.Getenv("RUN_ID") != "" // TaskCluster, dsari
}
func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) {
if !shouldCheckForUpdate() {
return nil, nil
}
client, err := basicClient(currentVersion)
if err != nil {
return nil, err
}
repo := updaterEnabled
stateFilePath := filepath.Join(config.StateDir(), "state.yml")
return update.CheckForUpdate(client, stateFilePath, repo, currentVersion)
}
// BasicClient returns an API client for github.com only that borrows from but
// does not depend on user configuration
func basicClient(currentVersion string) (*api.Client, error) {
var opts []api.ClientOption
if verbose := os.Getenv("DEBUG"); verbose != "" {
opts = append(opts, apiVerboseLog())
}
opts = append(opts, api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", currentVersion)))
token, _ := config.AuthTokenFromEnv(ghinstance.Default())
if token == "" {
if c, err := config.ParseDefaultConfig(); err == nil {
token, _ = c.Get(ghinstance.Default(), "oauth_token")
}
}
if token != "" {
opts = append(opts, api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
}
return api.NewClient(opts...), nil
}
func apiVerboseLog() api.ClientOption {
logTraffic := strings.Contains(os.Getenv("DEBUG"), "api")
colorize := utils.IsTerminal(os.Stderr)
return api.VerboseLog(colorable.NewColorable(os.Stderr), logTraffic, colorize)
}
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)
}