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 }