github.com/andrewhsu/cli/v2@v2.0.1-0.20210910131313-d4b4061f5b89/cmd/gh/main.go (about)

     1  package main
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"io"
     7  	"net"
     8  	"os"
     9  	"os/exec"
    10  	"path/filepath"
    11  	"strings"
    12  	"time"
    13  
    14  	surveyCore "github.com/AlecAivazis/survey/v2/core"
    15  	"github.com/AlecAivazis/survey/v2/terminal"
    16  	"github.com/andrewhsu/cli/v2/api"
    17  	"github.com/andrewhsu/cli/v2/internal/build"
    18  	"github.com/andrewhsu/cli/v2/internal/config"
    19  	"github.com/andrewhsu/cli/v2/internal/ghinstance"
    20  	"github.com/andrewhsu/cli/v2/internal/ghrepo"
    21  	"github.com/andrewhsu/cli/v2/internal/run"
    22  	"github.com/andrewhsu/cli/v2/internal/update"
    23  	"github.com/andrewhsu/cli/v2/pkg/cmd/alias/expand"
    24  	"github.com/andrewhsu/cli/v2/pkg/cmd/factory"
    25  	"github.com/andrewhsu/cli/v2/pkg/cmd/root"
    26  	"github.com/andrewhsu/cli/v2/pkg/cmdutil"
    27  	"github.com/andrewhsu/cli/v2/utils"
    28  	"github.com/cli/safeexec"
    29  	"github.com/mattn/go-colorable"
    30  	"github.com/mgutz/ansi"
    31  	"github.com/spf13/cobra"
    32  )
    33  
    34  var updaterEnabled = ""
    35  
    36  type exitCode int
    37  
    38  const (
    39  	exitOK     exitCode = 0
    40  	exitError  exitCode = 1
    41  	exitCancel exitCode = 2
    42  	exitAuth   exitCode = 4
    43  )
    44  
    45  func main() {
    46  	code := mainRun()
    47  	os.Exit(int(code))
    48  }
    49  
    50  func mainRun() exitCode {
    51  	buildDate := build.Date
    52  	buildVersion := build.Version
    53  
    54  	updateMessageChan := make(chan *update.ReleaseInfo)
    55  	go func() {
    56  		rel, _ := checkForUpdate(buildVersion)
    57  		updateMessageChan <- rel
    58  	}()
    59  
    60  	hasDebug := os.Getenv("DEBUG") != ""
    61  
    62  	cmdFactory := factory.New(buildVersion)
    63  	stderr := cmdFactory.IOStreams.ErrOut
    64  
    65  	if spec := os.Getenv("GH_FORCE_TTY"); spec != "" {
    66  		cmdFactory.IOStreams.ForceTerminal(spec)
    67  	}
    68  	if !cmdFactory.IOStreams.ColorEnabled() {
    69  		surveyCore.DisableColor = true
    70  	} else {
    71  		// override survey's poor choice of color
    72  		surveyCore.TemplateFuncsWithColor["color"] = func(style string) string {
    73  			switch style {
    74  			case "white":
    75  				if cmdFactory.IOStreams.ColorSupport256() {
    76  					return fmt.Sprintf("\x1b[%d;5;%dm", 38, 242)
    77  				}
    78  				return ansi.ColorCode("default")
    79  			default:
    80  				return ansi.ColorCode(style)
    81  			}
    82  		}
    83  	}
    84  
    85  	// Enable running gh from Windows File Explorer's address bar. Without this, the user is told to stop and run from a
    86  	// terminal. With this, a user can clone a repo (or take other actions) directly from explorer.
    87  	if len(os.Args) > 1 && os.Args[1] != "" {
    88  		cobra.MousetrapHelpText = ""
    89  	}
    90  
    91  	rootCmd := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
    92  
    93  	cfg, err := cmdFactory.Config()
    94  	if err != nil {
    95  		fmt.Fprintf(stderr, "failed to read configuration:  %s\n", err)
    96  		return exitError
    97  	}
    98  
    99  	// TODO: remove after FromFullName has been revisited
   100  	if host, err := cfg.DefaultHost(); err == nil {
   101  		ghrepo.SetDefaultHost(host)
   102  	}
   103  
   104  	expandedArgs := []string{}
   105  	if len(os.Args) > 0 {
   106  		expandedArgs = os.Args[1:]
   107  	}
   108  
   109  	// translate `gh help <command>` to `gh <command> --help` for extensions
   110  	if len(expandedArgs) == 2 && expandedArgs[0] == "help" && !hasCommand(rootCmd, expandedArgs[1:]) {
   111  		expandedArgs = []string{expandedArgs[1], "--help"}
   112  	}
   113  
   114  	if !hasCommand(rootCmd, expandedArgs) {
   115  		originalArgs := expandedArgs
   116  		isShell := false
   117  
   118  		argsForExpansion := append([]string{"gh"}, expandedArgs...)
   119  		expandedArgs, isShell, err = expand.ExpandAlias(cfg, argsForExpansion, nil)
   120  		if err != nil {
   121  			fmt.Fprintf(stderr, "failed to process aliases:  %s\n", err)
   122  			return exitError
   123  		}
   124  
   125  		if hasDebug {
   126  			fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs)
   127  		}
   128  
   129  		if isShell {
   130  			exe, err := safeexec.LookPath(expandedArgs[0])
   131  			if err != nil {
   132  				fmt.Fprintf(stderr, "failed to run external command: %s", err)
   133  				return exitError
   134  			}
   135  
   136  			externalCmd := exec.Command(exe, expandedArgs[1:]...)
   137  			externalCmd.Stderr = os.Stderr
   138  			externalCmd.Stdout = os.Stdout
   139  			externalCmd.Stdin = os.Stdin
   140  			preparedCmd := run.PrepareCmd(externalCmd)
   141  
   142  			err = preparedCmd.Run()
   143  			if err != nil {
   144  				var execError *exec.ExitError
   145  				if errors.As(err, &execError) {
   146  					return exitCode(execError.ExitCode())
   147  				}
   148  				fmt.Fprintf(stderr, "failed to run external command: %s", err)
   149  				return exitError
   150  			}
   151  
   152  			return exitOK
   153  		} else if len(expandedArgs) > 0 && !hasCommand(rootCmd, expandedArgs) {
   154  			extensionManager := cmdFactory.ExtensionManager
   155  			if found, err := extensionManager.Dispatch(expandedArgs, os.Stdin, os.Stdout, os.Stderr); err != nil {
   156  				var execError *exec.ExitError
   157  				if errors.As(err, &execError) {
   158  					return exitCode(execError.ExitCode())
   159  				}
   160  				fmt.Fprintf(stderr, "failed to run extension: %s", err)
   161  				return exitError
   162  			} else if found {
   163  				return exitOK
   164  			}
   165  		}
   166  	}
   167  
   168  	// provide completions for aliases and extensions
   169  	rootCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
   170  		var results []string
   171  		if aliases, err := cfg.Aliases(); err == nil {
   172  			for aliasName := range aliases.All() {
   173  				if strings.HasPrefix(aliasName, toComplete) {
   174  					results = append(results, aliasName)
   175  				}
   176  			}
   177  		}
   178  		for _, ext := range cmdFactory.ExtensionManager.List(false) {
   179  			if strings.HasPrefix(ext.Name(), toComplete) {
   180  				results = append(results, ext.Name())
   181  			}
   182  		}
   183  		return results, cobra.ShellCompDirectiveNoFileComp
   184  	}
   185  
   186  	cs := cmdFactory.IOStreams.ColorScheme()
   187  
   188  	authError := errors.New("authError")
   189  	rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
   190  		// require that the user is authenticated before running most commands
   191  		if cmdutil.IsAuthCheckEnabled(cmd) && !cmdutil.CheckAuth(cfg) {
   192  			fmt.Fprintln(stderr, cs.Bold("Welcome to GitHub CLI!"))
   193  			fmt.Fprintln(stderr)
   194  			fmt.Fprintln(stderr, "To authenticate, please run `gh auth login`.")
   195  			return authError
   196  		}
   197  
   198  		return nil
   199  	}
   200  
   201  	rootCmd.SetArgs(expandedArgs)
   202  
   203  	if cmd, err := rootCmd.ExecuteC(); err != nil {
   204  		if err == cmdutil.SilentError {
   205  			return exitError
   206  		} else if cmdutil.IsUserCancellation(err) {
   207  			if errors.Is(err, terminal.InterruptErr) {
   208  				// ensure the next shell prompt will start on its own line
   209  				fmt.Fprint(stderr, "\n")
   210  			}
   211  			return exitCancel
   212  		} else if errors.Is(err, authError) {
   213  			return exitAuth
   214  		}
   215  
   216  		printError(stderr, err, cmd, hasDebug)
   217  
   218  		if strings.Contains(err.Error(), "Incorrect function") {
   219  			fmt.Fprintln(stderr, "You appear to be running in MinTTY without pseudo terminal support.")
   220  			fmt.Fprintln(stderr, "To learn about workarounds for this error, run:  gh help mintty")
   221  			return exitError
   222  		}
   223  
   224  		var httpErr api.HTTPError
   225  		if errors.As(err, &httpErr) && httpErr.StatusCode == 401 {
   226  			fmt.Fprintln(stderr, "Try authenticating with:  gh auth login")
   227  		} else if strings.Contains(err.Error(), "Resource protected by organization SAML enforcement") {
   228  			fmt.Fprintln(stderr, "Try re-authenticating with:  gh auth refresh")
   229  		}
   230  
   231  		return exitError
   232  	}
   233  	if root.HasFailed() {
   234  		return exitError
   235  	}
   236  
   237  	newRelease := <-updateMessageChan
   238  	if newRelease != nil {
   239  		isHomebrew := isUnderHomebrew(cmdFactory.Executable)
   240  		if isHomebrew && isRecentRelease(newRelease.PublishedAt) {
   241  			// do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core
   242  			return exitOK
   243  		}
   244  		fmt.Fprintf(stderr, "\n\n%s %s → %s\n",
   245  			ansi.Color("A new release of gh is available:", "yellow"),
   246  			ansi.Color(buildVersion, "cyan"),
   247  			ansi.Color(newRelease.Version, "cyan"))
   248  		if isHomebrew {
   249  			fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew update && brew upgrade gh")
   250  		}
   251  		fmt.Fprintf(stderr, "%s\n\n",
   252  			ansi.Color(newRelease.URL, "yellow"))
   253  	}
   254  
   255  	return exitOK
   256  }
   257  
   258  // hasCommand returns true if args resolve to a built-in command
   259  func hasCommand(rootCmd *cobra.Command, args []string) bool {
   260  	c, _, err := rootCmd.Traverse(args)
   261  	return err == nil && c != rootCmd
   262  }
   263  
   264  func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
   265  	var dnsError *net.DNSError
   266  	if errors.As(err, &dnsError) {
   267  		fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name)
   268  		if debug {
   269  			fmt.Fprintln(out, dnsError)
   270  		}
   271  		fmt.Fprintln(out, "check your internet connection or https://githubstatus.com")
   272  		return
   273  	}
   274  
   275  	fmt.Fprintln(out, err)
   276  
   277  	var flagError *cmdutil.FlagError
   278  	if errors.As(err, &flagError) || strings.HasPrefix(err.Error(), "unknown command ") {
   279  		if !strings.HasSuffix(err.Error(), "\n") {
   280  			fmt.Fprintln(out)
   281  		}
   282  		fmt.Fprintln(out, cmd.UsageString())
   283  	}
   284  }
   285  
   286  func shouldCheckForUpdate() bool {
   287  	if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
   288  		return false
   289  	}
   290  	if os.Getenv("CODESPACES") != "" {
   291  		return false
   292  	}
   293  	return updaterEnabled != "" && !isCI() && utils.IsTerminal(os.Stdout) && utils.IsTerminal(os.Stderr)
   294  }
   295  
   296  // based on https://github.com/watson/ci-info/blob/HEAD/index.js
   297  func isCI() bool {
   298  	return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari
   299  		os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity
   300  		os.Getenv("RUN_ID") != "" // TaskCluster, dsari
   301  }
   302  
   303  func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) {
   304  	if !shouldCheckForUpdate() {
   305  		return nil, nil
   306  	}
   307  
   308  	client, err := basicClient(currentVersion)
   309  	if err != nil {
   310  		return nil, err
   311  	}
   312  
   313  	repo := updaterEnabled
   314  	stateFilePath := filepath.Join(config.StateDir(), "state.yml")
   315  	return update.CheckForUpdate(client, stateFilePath, repo, currentVersion)
   316  }
   317  
   318  // BasicClient returns an API client for github.com only that borrows from but
   319  // does not depend on user configuration
   320  func basicClient(currentVersion string) (*api.Client, error) {
   321  	var opts []api.ClientOption
   322  	if verbose := os.Getenv("DEBUG"); verbose != "" {
   323  		opts = append(opts, apiVerboseLog())
   324  	}
   325  	opts = append(opts, api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", currentVersion)))
   326  
   327  	token, _ := config.AuthTokenFromEnv(ghinstance.Default())
   328  	if token == "" {
   329  		if c, err := config.ParseDefaultConfig(); err == nil {
   330  			token, _ = c.Get(ghinstance.Default(), "oauth_token")
   331  		}
   332  	}
   333  	if token != "" {
   334  		opts = append(opts, api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
   335  	}
   336  	return api.NewClient(opts...), nil
   337  }
   338  
   339  func apiVerboseLog() api.ClientOption {
   340  	logTraffic := strings.Contains(os.Getenv("DEBUG"), "api")
   341  	colorize := utils.IsTerminal(os.Stderr)
   342  	return api.VerboseLog(colorable.NewColorable(os.Stderr), logTraffic, colorize)
   343  }
   344  
   345  func isRecentRelease(publishedAt time.Time) bool {
   346  	return !publishedAt.IsZero() && time.Since(publishedAt) < time.Hour*24
   347  }
   348  
   349  // Check whether the gh binary was found under the Homebrew prefix
   350  func isUnderHomebrew(ghBinary string) bool {
   351  	brewExe, err := safeexec.LookPath("brew")
   352  	if err != nil {
   353  		return false
   354  	}
   355  
   356  	brewPrefixBytes, err := exec.Command(brewExe, "--prefix").Output()
   357  	if err != nil {
   358  		return false
   359  	}
   360  
   361  	brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator)
   362  	return strings.HasPrefix(ghBinary, brewBinPrefix)
   363  }