github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/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/MakeNowJust/heredoc"
    17  	"github.com/ungtb10d/cli/v2/api"
    18  	"github.com/ungtb10d/cli/v2/git"
    19  	"github.com/ungtb10d/cli/v2/internal/build"
    20  	"github.com/ungtb10d/cli/v2/internal/config"
    21  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    22  	"github.com/ungtb10d/cli/v2/internal/run"
    23  	"github.com/ungtb10d/cli/v2/internal/text"
    24  	"github.com/ungtb10d/cli/v2/internal/update"
    25  	"github.com/ungtb10d/cli/v2/pkg/cmd/alias/expand"
    26  	"github.com/ungtb10d/cli/v2/pkg/cmd/factory"
    27  	"github.com/ungtb10d/cli/v2/pkg/cmd/root"
    28  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    29  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    30  	"github.com/ungtb10d/cli/v2/utils"
    31  	"github.com/cli/safeexec"
    32  	"github.com/mattn/go-isatty"
    33  	"github.com/mgutz/ansi"
    34  	"github.com/spf13/cobra"
    35  )
    36  
    37  var updaterEnabled = ""
    38  
    39  type exitCode int
    40  
    41  const (
    42  	exitOK     exitCode = 0
    43  	exitError  exitCode = 1
    44  	exitCancel exitCode = 2
    45  	exitAuth   exitCode = 4
    46  )
    47  
    48  func main() {
    49  	code := mainRun()
    50  	os.Exit(int(code))
    51  }
    52  
    53  func mainRun() exitCode {
    54  	buildDate := build.Date
    55  	buildVersion := build.Version
    56  
    57  	updateMessageChan := make(chan *update.ReleaseInfo)
    58  	go func() {
    59  		rel, _ := checkForUpdate(buildVersion)
    60  		updateMessageChan <- rel
    61  	}()
    62  
    63  	hasDebug, _ := utils.IsDebugEnabled()
    64  
    65  	cmdFactory := factory.New(buildVersion)
    66  	stderr := cmdFactory.IOStreams.ErrOut
    67  	if !cmdFactory.IOStreams.ColorEnabled() {
    68  		surveyCore.DisableColor = true
    69  		ansi.DisableColors(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  	expandedArgs := []string{}
   100  	if len(os.Args) > 0 {
   101  		expandedArgs = os.Args[1:]
   102  	}
   103  
   104  	// translate `gh help <command>` to `gh <command> --help` for extensions
   105  	if len(expandedArgs) == 2 && expandedArgs[0] == "help" && !hasCommand(rootCmd, expandedArgs[1:]) {
   106  		expandedArgs = []string{expandedArgs[1], "--help"}
   107  	}
   108  
   109  	if !hasCommand(rootCmd, expandedArgs) {
   110  		originalArgs := expandedArgs
   111  		isShell := false
   112  
   113  		argsForExpansion := append([]string{"gh"}, expandedArgs...)
   114  		expandedArgs, isShell, err = expand.ExpandAlias(cfg, argsForExpansion, nil)
   115  		if err != nil {
   116  			fmt.Fprintf(stderr, "failed to process aliases:  %s\n", err)
   117  			return exitError
   118  		}
   119  
   120  		if hasDebug {
   121  			fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs)
   122  		}
   123  
   124  		if isShell {
   125  			exe, err := safeexec.LookPath(expandedArgs[0])
   126  			if err != nil {
   127  				fmt.Fprintf(stderr, "failed to run external command: %s", err)
   128  				return exitError
   129  			}
   130  
   131  			externalCmd := exec.Command(exe, expandedArgs[1:]...)
   132  			externalCmd.Stderr = os.Stderr
   133  			externalCmd.Stdout = os.Stdout
   134  			externalCmd.Stdin = os.Stdin
   135  			preparedCmd := run.PrepareCmd(externalCmd)
   136  
   137  			err = preparedCmd.Run()
   138  			if err != nil {
   139  				var execError *exec.ExitError
   140  				if errors.As(err, &execError) {
   141  					return exitCode(execError.ExitCode())
   142  				}
   143  				fmt.Fprintf(stderr, "failed to run external command: %s\n", err)
   144  				return exitError
   145  			}
   146  
   147  			return exitOK
   148  		} else if len(expandedArgs) > 0 && !hasCommand(rootCmd, expandedArgs) {
   149  			extensionManager := cmdFactory.ExtensionManager
   150  			if found, err := extensionManager.Dispatch(expandedArgs, os.Stdin, os.Stdout, os.Stderr); err != nil {
   151  				var execError *exec.ExitError
   152  				if errors.As(err, &execError) {
   153  					return exitCode(execError.ExitCode())
   154  				}
   155  				fmt.Fprintf(stderr, "failed to run extension: %s\n", err)
   156  				return exitError
   157  			} else if found {
   158  				return exitOK
   159  			}
   160  		}
   161  	}
   162  
   163  	// provide completions for aliases and extensions
   164  	rootCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
   165  		var results []string
   166  		aliases := cfg.Aliases()
   167  		for aliasName, aliasValue := range aliases.All() {
   168  			if strings.HasPrefix(aliasName, toComplete) {
   169  				var s string
   170  				if strings.HasPrefix(aliasValue, "!") {
   171  					s = fmt.Sprintf("%s\tShell alias", aliasName)
   172  				} else {
   173  					aliasValue = text.Truncate(80, aliasValue)
   174  					s = fmt.Sprintf("%s\tAlias for %s", aliasName, aliasValue)
   175  				}
   176  				results = append(results, s)
   177  			}
   178  		}
   179  		for _, ext := range cmdFactory.ExtensionManager.List() {
   180  			if strings.HasPrefix(ext.Name(), toComplete) {
   181  				var s string
   182  				if ext.IsLocal() {
   183  					s = fmt.Sprintf("%s\tLocal extension gh-%s", ext.Name(), ext.Name())
   184  				} else {
   185  					path := ext.URL()
   186  					if u, err := git.ParseURL(ext.URL()); err == nil {
   187  						if r, err := ghrepo.FromURL(u); err == nil {
   188  							path = ghrepo.FullName(r)
   189  						}
   190  					}
   191  					s = fmt.Sprintf("%s\tExtension %s", ext.Name(), path)
   192  				}
   193  				results = append(results, s)
   194  			}
   195  		}
   196  		return results, cobra.ShellCompDirectiveNoFileComp
   197  	}
   198  
   199  	authError := errors.New("authError")
   200  	rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
   201  		// require that the user is authenticated before running most commands
   202  		if cmdutil.IsAuthCheckEnabled(cmd) && !cmdutil.CheckAuth(cfg) {
   203  			fmt.Fprint(stderr, authHelp())
   204  			return authError
   205  		}
   206  
   207  		return nil
   208  	}
   209  
   210  	rootCmd.SetArgs(expandedArgs)
   211  
   212  	if cmd, err := rootCmd.ExecuteC(); err != nil {
   213  		var pagerPipeError *iostreams.ErrClosedPagerPipe
   214  		var noResultsError cmdutil.NoResultsError
   215  		if err == cmdutil.SilentError {
   216  			return exitError
   217  		} else if cmdutil.IsUserCancellation(err) {
   218  			if errors.Is(err, terminal.InterruptErr) {
   219  				// ensure the next shell prompt will start on its own line
   220  				fmt.Fprint(stderr, "\n")
   221  			}
   222  			return exitCancel
   223  		} else if errors.Is(err, authError) {
   224  			return exitAuth
   225  		} else if errors.As(err, &pagerPipeError) {
   226  			// ignore the error raised when piping to a closed pager
   227  			return exitOK
   228  		} else if errors.As(err, &noResultsError) {
   229  			if cmdFactory.IOStreams.IsStdoutTTY() {
   230  				fmt.Fprintln(stderr, noResultsError.Error())
   231  			}
   232  			// no results is not a command failure
   233  			return exitOK
   234  		}
   235  
   236  		printError(stderr, err, cmd, hasDebug)
   237  
   238  		if strings.Contains(err.Error(), "Incorrect function") {
   239  			fmt.Fprintln(stderr, "You appear to be running in MinTTY without pseudo terminal support.")
   240  			fmt.Fprintln(stderr, "To learn about workarounds for this error, run:  gh help mintty")
   241  			return exitError
   242  		}
   243  
   244  		var httpErr api.HTTPError
   245  		if errors.As(err, &httpErr) && httpErr.StatusCode == 401 {
   246  			fmt.Fprintln(stderr, "Try authenticating with:  gh auth login")
   247  		} else if u := factory.SSOURL(); u != "" {
   248  			// handles organization SAML enforcement error
   249  			fmt.Fprintf(stderr, "Authorize in your web browser:  %s\n", u)
   250  		} else if msg := httpErr.ScopesSuggestion(); msg != "" {
   251  			fmt.Fprintln(stderr, msg)
   252  		}
   253  
   254  		return exitError
   255  	}
   256  	if root.HasFailed() {
   257  		return exitError
   258  	}
   259  
   260  	newRelease := <-updateMessageChan
   261  	if newRelease != nil {
   262  		isHomebrew := isUnderHomebrew(cmdFactory.Executable())
   263  		if isHomebrew && isRecentRelease(newRelease.PublishedAt) {
   264  			// do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core
   265  			return exitOK
   266  		}
   267  		fmt.Fprintf(stderr, "\n\n%s %s → %s\n",
   268  			ansi.Color("A new release of gh is available:", "yellow"),
   269  			ansi.Color(strings.TrimPrefix(buildVersion, "v"), "cyan"),
   270  			ansi.Color(strings.TrimPrefix(newRelease.Version, "v"), "cyan"))
   271  		if isHomebrew {
   272  			fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew upgrade gh")
   273  		}
   274  		fmt.Fprintf(stderr, "%s\n\n",
   275  			ansi.Color(newRelease.URL, "yellow"))
   276  	}
   277  
   278  	return exitOK
   279  }
   280  
   281  // hasCommand returns true if args resolve to a built-in command
   282  func hasCommand(rootCmd *cobra.Command, args []string) bool {
   283  	c, _, err := rootCmd.Traverse(args)
   284  	return err == nil && c != rootCmd
   285  }
   286  
   287  func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
   288  	var dnsError *net.DNSError
   289  	if errors.As(err, &dnsError) {
   290  		fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name)
   291  		if debug {
   292  			fmt.Fprintln(out, dnsError)
   293  		}
   294  		fmt.Fprintln(out, "check your internet connection or https://githubstatus.com")
   295  		return
   296  	}
   297  
   298  	fmt.Fprintln(out, err)
   299  
   300  	var flagError *cmdutil.FlagError
   301  	if errors.As(err, &flagError) || strings.HasPrefix(err.Error(), "unknown command ") {
   302  		if !strings.HasSuffix(err.Error(), "\n") {
   303  			fmt.Fprintln(out)
   304  		}
   305  		fmt.Fprintln(out, cmd.UsageString())
   306  	}
   307  }
   308  
   309  func authHelp() string {
   310  	if os.Getenv("GITHUB_ACTIONS") == "true" {
   311  		return heredoc.Doc(`
   312  			gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Example:
   313  			  env:
   314  			    GH_TOKEN: ${{ github.token }}
   315  		`)
   316  	}
   317  
   318  	if os.Getenv("CI") != "" {
   319  		return heredoc.Doc(`
   320  			gh: To use GitHub CLI in automation, set the GH_TOKEN environment variable.
   321  		`)
   322  	}
   323  
   324  	return heredoc.Doc(`
   325  		To get started with GitHub CLI, please run:  gh auth login
   326  		Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.
   327  	`)
   328  }
   329  
   330  func shouldCheckForUpdate() bool {
   331  	if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
   332  		return false
   333  	}
   334  	if os.Getenv("CODESPACES") != "" {
   335  		return false
   336  	}
   337  	return updaterEnabled != "" && !isCI() && isTerminal(os.Stdout) && isTerminal(os.Stderr)
   338  }
   339  
   340  func isTerminal(f *os.File) bool {
   341  	return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
   342  }
   343  
   344  // based on https://github.com/watson/ci-info/blob/HEAD/index.js
   345  func isCI() bool {
   346  	return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari
   347  		os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity
   348  		os.Getenv("RUN_ID") != "" // TaskCluster, dsari
   349  }
   350  
   351  func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) {
   352  	if !shouldCheckForUpdate() {
   353  		return nil, nil
   354  	}
   355  	httpClient, err := api.NewHTTPClient(api.HTTPClientOptions{
   356  		AppVersion: currentVersion,
   357  		Log:        os.Stderr,
   358  	})
   359  	if err != nil {
   360  		return nil, err
   361  	}
   362  	client := api.NewClientFromHTTP(httpClient)
   363  	repo := updaterEnabled
   364  	stateFilePath := filepath.Join(config.StateDir(), "state.yml")
   365  	return update.CheckForUpdate(client, stateFilePath, repo, currentVersion)
   366  }
   367  
   368  func isRecentRelease(publishedAt time.Time) bool {
   369  	return !publishedAt.IsZero() && time.Since(publishedAt) < time.Hour*24
   370  }
   371  
   372  // Check whether the gh binary was found under the Homebrew prefix
   373  func isUnderHomebrew(ghBinary string) bool {
   374  	brewExe, err := safeexec.LookPath("brew")
   375  	if err != nil {
   376  		return false
   377  	}
   378  
   379  	brewPrefixBytes, err := exec.Command(brewExe, "--prefix").Output()
   380  	if err != nil {
   381  		return false
   382  	}
   383  
   384  	brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator)
   385  	return strings.HasPrefix(ghBinary, brewBinPrefix)
   386  }