384 lines
11 KiB
Go
384 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/MakeNowJust/heredoc"
|
|
"github.com/cli/cli/v2/api"
|
|
"github.com/cli/cli/v2/git"
|
|
"github.com/cli/cli/v2/internal/build"
|
|
"github.com/cli/cli/v2/internal/config"
|
|
"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/pkg/text"
|
|
"github.com/cli/cli/v2/utils"
|
|
"github.com/cli/safeexec"
|
|
"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, _ := utils.IsDebugEnabled()
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
aliases := cfg.Aliases()
|
|
for aliasName, aliasValue := range aliases.All() {
|
|
if strings.HasPrefix(aliasName, toComplete) {
|
|
var s string
|
|
if strings.HasPrefix(aliasValue, "!") {
|
|
s = fmt.Sprintf("%s\tShell alias", aliasName)
|
|
} else {
|
|
aliasValue = text.Truncate(80, aliasValue)
|
|
s = fmt.Sprintf("%s\tAlias for %s", aliasName, aliasValue)
|
|
}
|
|
results = append(results, s)
|
|
}
|
|
}
|
|
for _, ext := range cmdFactory.ExtensionManager.List() {
|
|
if strings.HasPrefix(ext.Name(), toComplete) {
|
|
var s string
|
|
if ext.IsLocal() {
|
|
s = fmt.Sprintf("%s\tLocal extension gh-%s", ext.Name(), ext.Name())
|
|
} else {
|
|
path := ext.URL()
|
|
if u, err := git.ParseURL(ext.URL()); err == nil {
|
|
if r, err := ghrepo.FromURL(u); err == nil {
|
|
path = ghrepo.FullName(r)
|
|
}
|
|
}
|
|
s = fmt.Sprintf("%s\tExtension %s", ext.Name(), path)
|
|
}
|
|
results = append(results, s)
|
|
}
|
|
}
|
|
return results, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
|
|
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.Fprint(stderr, authHelp())
|
|
return authError
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
rootCmd.SetArgs(expandedArgs)
|
|
|
|
if cmd, err := rootCmd.ExecuteC(); err != nil {
|
|
var pagerPipeError *iostreams.ErrClosedPagerPipe
|
|
var noResultsError cmdutil.NoResultsError
|
|
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
|
|
} else if errors.As(err, &noResultsError) {
|
|
if cmdFactory.IOStreams.IsStdoutTTY() {
|
|
fmt.Fprintln(stderr, noResultsError.Error())
|
|
}
|
|
// no results is not a command failure
|
|
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(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
|
|
}
|
|
|
|
// 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 authHelp() string {
|
|
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
|
return heredoc.Doc(`
|
|
gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Example:
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
`)
|
|
}
|
|
|
|
if os.Getenv("CI") != "" {
|
|
return heredoc.Doc(`
|
|
gh: To use GitHub CLI in automation, set the GH_TOKEN environment variable.
|
|
`)
|
|
}
|
|
|
|
return heredoc.Doc(`
|
|
To get started with GitHub CLI, please run: gh auth login
|
|
Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.
|
|
`)
|
|
}
|
|
|
|
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
|
|
}
|
|
httpClient, err := api.NewHTTPClient(api.HTTPClientOptions{
|
|
AppVersion: currentVersion,
|
|
Log: os.Stderr,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client := api.NewClientFromHTTP(httpClient)
|
|
repo := updaterEnabled
|
|
stateFilePath := filepath.Join(config.StateDir(), "state.yml")
|
|
return update.CheckForUpdate(client, stateFilePath, repo, 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)
|
|
}
|