github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/extension/command.go (about) 1 package extension 2 3 import ( 4 "errors" 5 "fmt" 6 "os" 7 "strings" 8 "time" 9 10 "github.com/MakeNowJust/heredoc" 11 "github.com/ungtb10d/cli/v2/api" 12 "github.com/ungtb10d/cli/v2/git" 13 "github.com/ungtb10d/cli/v2/internal/ghrepo" 14 "github.com/ungtb10d/cli/v2/internal/tableprinter" 15 "github.com/ungtb10d/cli/v2/internal/text" 16 "github.com/ungtb10d/cli/v2/pkg/cmd/extension/browse" 17 "github.com/ungtb10d/cli/v2/pkg/cmdutil" 18 "github.com/ungtb10d/cli/v2/pkg/extensions" 19 "github.com/ungtb10d/cli/v2/pkg/search" 20 "github.com/ungtb10d/cli/v2/utils" 21 "github.com/spf13/cobra" 22 ) 23 24 func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { 25 m := f.ExtensionManager 26 io := f.IOStreams 27 prompter := f.Prompter 28 config := f.Config 29 browser := f.Browser 30 httpClient := f.HttpClient 31 32 extCmd := cobra.Command{ 33 Use: "extension", 34 Short: "Manage gh extensions", 35 Long: heredoc.Docf(` 36 GitHub CLI extensions are repositories that provide additional gh commands. 37 38 The name of the extension repository must start with "gh-" and it must contain an 39 executable of the same name. All arguments passed to the %[1]sgh <extname>%[1]s invocation 40 will be forwarded to the %[1]sgh-<extname>%[1]s executable of the extension. 41 42 An extension cannot override any of the core gh commands. If an extension name conflicts 43 with a core gh command you can use %[1]sgh extension exec <extname>%[1]s. 44 45 See the list of available extensions at <https://github.com/topics/gh-extension>. 46 `, "`"), 47 Aliases: []string{"extensions", "ext"}, 48 } 49 50 extCmd.AddCommand( 51 func() *cobra.Command { 52 query := search.Query{ 53 Kind: search.KindRepositories, 54 } 55 qualifiers := search.Qualifiers{ 56 Topic: []string{"gh-extension"}, 57 } 58 var order string 59 var sort string 60 var webMode bool 61 var exporter cmdutil.Exporter 62 63 cmd := &cobra.Command{ 64 Use: "search [<query>]", 65 Short: "Search extensions to the GitHub CLI", 66 Long: heredoc.Doc(` 67 Search for gh extensions. 68 69 With no arguments, this command prints out the first 30 extensions 70 available to install sorted by number of stars. More extensions can 71 be fetched by specifying a higher limit with the --limit flag. 72 73 When connected to a terminal, this command prints out three columns. 74 The first has a ✓ if the extension is already installed locally. The 75 second is the full name of the extension repository in NAME/OWNER 76 format. The third is the extension's description. 77 78 When not connected to a terminal, the ✓ character is rendered as the 79 word "installed" but otherwise the order and content of the columns 80 is the same. 81 82 This command behaves similarly to 'gh search repos' but does not 83 support as many search qualifiers. For a finer grained search of 84 extensions, try using: 85 86 gh search repos --topic "gh-extension" 87 88 and adding qualifiers as needed. See 'gh help search repos' to learn 89 more about repository search. 90 91 For listing just the extensions that are already installed locally, 92 see: 93 94 gh ext list 95 `), 96 Example: heredoc.Doc(` 97 # List the first 30 extensions sorted by star count, descending 98 $ gh ext search 99 100 # List more extensions 101 $ gh ext search --limit 300 102 103 # List extensions matching the term "branch" 104 $ gh ext search branch 105 106 # List extensions owned by organization "github" 107 $ gh ext search --owner github 108 109 # List extensions, sorting by recently updated, ascending 110 $ gh ext search --sort updated --order asc 111 112 # List extensions, filtering by license 113 $ gh ext search --license MIT 114 115 # Open search results in the browser 116 $ gh ext search -w 117 `), 118 RunE: func(cmd *cobra.Command, args []string) error { 119 cfg, err := config() 120 if err != nil { 121 return err 122 } 123 client, err := httpClient() 124 if err != nil { 125 return err 126 } 127 128 if cmd.Flags().Changed("order") { 129 query.Order = order 130 } 131 if cmd.Flags().Changed("sort") { 132 query.Sort = sort 133 } 134 135 query.Keywords = args 136 query.Qualifiers = qualifiers 137 138 host, _ := cfg.DefaultHost() 139 searcher := search.NewSearcher(client, host) 140 141 if webMode { 142 url := searcher.URL(query) 143 if io.IsStdoutTTY() { 144 fmt.Fprintf(io.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(url)) 145 } 146 return browser.Browse(url) 147 } 148 149 io.StartProgressIndicator() 150 result, err := searcher.Repositories(query) 151 io.StopProgressIndicator() 152 if err != nil { 153 return err 154 } 155 156 if exporter != nil { 157 return exporter.Write(io, result.Items) 158 } 159 160 if io.IsStdoutTTY() { 161 if len(result.Items) == 0 { 162 return errors.New("no extensions found") 163 } 164 fmt.Fprintf(io.Out, "Showing %d of %d extensions\n", len(result.Items), result.Total) 165 fmt.Fprintln(io.Out) 166 } 167 168 cs := io.ColorScheme() 169 installedExts := m.List() 170 171 isInstalled := func(repo search.Repository) bool { 172 searchRepo, err := ghrepo.FromFullName(repo.FullName) 173 if err != nil { 174 return false 175 } 176 for _, e := range installedExts { 177 // TODO consider a Repo() on Extension interface 178 if u, err := git.ParseURL(e.URL()); err == nil { 179 if r, err := ghrepo.FromURL(u); err == nil { 180 if ghrepo.IsSame(searchRepo, r) { 181 return true 182 } 183 } 184 } 185 } 186 return false 187 } 188 189 tp := tableprinter.New(io) 190 tp.HeaderRow("", "REPO", "DESCRIPTION") 191 192 for _, repo := range result.Items { 193 if !strings.HasPrefix(repo.Name, "gh-") { 194 continue 195 } 196 197 installed := "" 198 if isInstalled(repo) { 199 if io.IsStdoutTTY() { 200 installed = "✓" 201 } else { 202 installed = "installed" 203 } 204 } 205 206 tp.AddField(installed, tableprinter.WithColor(cs.Green)) 207 tp.AddField(repo.FullName, tableprinter.WithColor(cs.Bold)) 208 tp.AddField(repo.Description) 209 tp.EndRow() 210 } 211 212 return tp.Render() 213 }, 214 } 215 216 // Output flags 217 cmd.Flags().BoolVarP(&webMode, "web", "w", false, "Open the search query in the web browser") 218 cmdutil.AddJSONFlags(cmd, &exporter, search.RepositoryFields) 219 220 // Query parameter flags 221 cmd.Flags().IntVarP(&query.Limit, "limit", "L", 30, "Maximum number of extensions to fetch") 222 cmdutil.StringEnumFlag(cmd, &order, "order", "", "desc", []string{"asc", "desc"}, "Order of repositories returned, ignored unless '--sort' flag is specified") 223 cmdutil.StringEnumFlag(cmd, &sort, "sort", "", "best-match", []string{"forks", "help-wanted-issues", "stars", "updated"}, "Sort fetched repositories") 224 225 // Qualifier flags 226 cmd.Flags().StringSliceVar(&qualifiers.License, "license", nil, "Filter based on license type") 227 cmd.Flags().StringVar(&qualifiers.User, "owner", "", "Filter on owner") 228 229 return cmd 230 }(), 231 &cobra.Command{ 232 Use: "list", 233 Short: "List installed extension commands", 234 Aliases: []string{"ls"}, 235 Args: cobra.NoArgs, 236 RunE: func(cmd *cobra.Command, args []string) error { 237 cmds := m.List() 238 if len(cmds) == 0 { 239 return cmdutil.NewNoResultsError("no installed extensions found") 240 } 241 cs := io.ColorScheme() 242 //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter 243 t := utils.NewTablePrinter(io) 244 for _, c := range cmds { 245 // TODO consider a Repo() on Extension interface 246 var repo string 247 if u, err := git.ParseURL(c.URL()); err == nil { 248 if r, err := ghrepo.FromURL(u); err == nil { 249 repo = ghrepo.FullName(r) 250 } 251 } 252 253 t.AddField(fmt.Sprintf("gh %s", c.Name()), nil, nil) 254 t.AddField(repo, nil, nil) 255 version := displayExtensionVersion(c, c.CurrentVersion()) 256 if c.IsPinned() { 257 t.AddField(version, nil, cs.Cyan) 258 } else { 259 t.AddField(version, nil, nil) 260 } 261 262 t.EndRow() 263 } 264 return t.Render() 265 }, 266 }, 267 func() *cobra.Command { 268 var pinFlag string 269 cmd := &cobra.Command{ 270 Use: "install <repository>", 271 Short: "Install a gh extension from a repository", 272 Long: heredoc.Doc(` 273 Install a GitHub repository locally as a GitHub CLI extension. 274 275 The repository argument can be specified in "owner/repo" format as well as a full URL. 276 The URL format is useful when the repository is not hosted on github.com. 277 278 To install an extension in development from the current directory, use "." as the 279 value of the repository argument. 280 281 See the list of available extensions at <https://github.com/topics/gh-extension>. 282 `), 283 Example: heredoc.Doc(` 284 $ gh extension install owner/gh-extension 285 $ gh extension install https://git.example.com/owner/gh-extension 286 $ gh extension install . 287 `), 288 Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"), 289 RunE: func(cmd *cobra.Command, args []string) error { 290 if args[0] == "." { 291 if pinFlag != "" { 292 return fmt.Errorf("local extensions cannot be pinned") 293 } 294 wd, err := os.Getwd() 295 if err != nil { 296 return err 297 } 298 return m.InstallLocal(wd) 299 } 300 301 repo, err := ghrepo.FromFullName(args[0]) 302 if err != nil { 303 return err 304 } 305 306 if err := checkValidExtension(cmd.Root(), m, repo.RepoName()); err != nil { 307 return err 308 } 309 310 cs := io.ColorScheme() 311 if err := m.Install(repo, pinFlag); err != nil { 312 if errors.Is(err, releaseNotFoundErr) { 313 return fmt.Errorf("%s Could not find a release of %s for %s", 314 cs.FailureIcon(), args[0], cs.Cyan(pinFlag)) 315 } else if errors.Is(err, commitNotFoundErr) { 316 return fmt.Errorf("%s %s does not exist in %s", 317 cs.FailureIcon(), cs.Cyan(pinFlag), args[0]) 318 } else if errors.Is(err, repositoryNotFoundErr) { 319 return fmt.Errorf("%s Could not find extension '%s' on host %s", 320 cs.FailureIcon(), args[0], repo.RepoHost()) 321 } 322 return err 323 } 324 325 if io.IsStdoutTTY() { 326 fmt.Fprintf(io.Out, "%s Installed extension %s\n", cs.SuccessIcon(), args[0]) 327 if pinFlag != "" { 328 fmt.Fprintf(io.Out, "%s Pinned extension at %s\n", cs.SuccessIcon(), cs.Cyan(pinFlag)) 329 } 330 } 331 return nil 332 }, 333 } 334 cmd.Flags().StringVar(&pinFlag, "pin", "", "pin extension to a release tag or commit ref") 335 return cmd 336 }(), 337 func() *cobra.Command { 338 var flagAll bool 339 var flagForce bool 340 var flagDryRun bool 341 cmd := &cobra.Command{ 342 Use: "upgrade {<name> | --all}", 343 Short: "Upgrade installed extensions", 344 Args: func(cmd *cobra.Command, args []string) error { 345 if len(args) == 0 && !flagAll { 346 return cmdutil.FlagErrorf("specify an extension to upgrade or `--all`") 347 } 348 if len(args) > 0 && flagAll { 349 return cmdutil.FlagErrorf("cannot use `--all` with extension name") 350 } 351 if len(args) > 1 { 352 return cmdutil.FlagErrorf("too many arguments") 353 } 354 return nil 355 }, 356 RunE: func(cmd *cobra.Command, args []string) error { 357 var name string 358 if len(args) > 0 { 359 name = normalizeExtensionSelector(args[0]) 360 } 361 if flagDryRun { 362 m.EnableDryRunMode() 363 } 364 cs := io.ColorScheme() 365 err := m.Upgrade(name, flagForce) 366 if err != nil && !errors.Is(err, upToDateError) { 367 if name != "" { 368 fmt.Fprintf(io.ErrOut, "%s Failed upgrading extension %s: %s\n", cs.FailureIcon(), name, err) 369 } else if errors.Is(err, noExtensionsInstalledError) { 370 return cmdutil.NewNoResultsError("no installed extensions found") 371 } else { 372 fmt.Fprintf(io.ErrOut, "%s Failed upgrading extensions\n", cs.FailureIcon()) 373 } 374 return cmdutil.SilentError 375 } 376 if io.IsStdoutTTY() { 377 successStr := "Successfully" 378 if flagDryRun { 379 successStr = "Would have" 380 } 381 if errors.Is(err, upToDateError) { 382 fmt.Fprintf(io.Out, "%s Extension already up to date\n", cs.SuccessIcon()) 383 } else if name != "" { 384 fmt.Fprintf(io.Out, "%s %s upgraded extension %s\n", cs.SuccessIcon(), successStr, name) 385 } else { 386 fmt.Fprintf(io.Out, "%s %s upgraded extensions\n", cs.SuccessIcon(), successStr) 387 } 388 } 389 return nil 390 }, 391 } 392 cmd.Flags().BoolVar(&flagAll, "all", false, "Upgrade all extensions") 393 cmd.Flags().BoolVar(&flagForce, "force", false, "Force upgrade extension") 394 cmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "Only display upgrades") 395 return cmd 396 }(), 397 &cobra.Command{ 398 Use: "remove <name>", 399 Short: "Remove an installed extension", 400 Args: cobra.ExactArgs(1), 401 RunE: func(cmd *cobra.Command, args []string) error { 402 extName := normalizeExtensionSelector(args[0]) 403 if err := m.Remove(extName); err != nil { 404 return err 405 } 406 if io.IsStdoutTTY() { 407 cs := io.ColorScheme() 408 fmt.Fprintf(io.Out, "%s Removed extension %s\n", cs.SuccessIcon(), extName) 409 } 410 return nil 411 }, 412 }, 413 func() *cobra.Command { 414 var debug bool 415 cmd := &cobra.Command{ 416 Use: "browse", 417 Short: "Enter a UI for browsing, adding, and removing extensions", 418 Long: heredoc.Doc(` 419 This command will take over your terminal and run a fully interactive 420 interface for browsing, adding, and removing gh extensions. 421 422 The extension list is navigated with the arrow keys or with j/k. 423 Space and control+space (or control + j/k) page the list up and down. 424 Extension readmes can be scrolled with page up/page down keys 425 (fn + arrow up/down on a mac keyboard). 426 427 For highlighted extensions, you can press: 428 429 - w to open the extension in your web browser 430 - i to install the extension 431 - r to remove the extension 432 433 Press / to focus the filter input. Press enter to scroll the results. 434 Press Escape to clear the filter and return to the full list. 435 436 Press q to quit. 437 438 The output of this command may be difficult to navigate for screen reader 439 users, users operating at high zoom and other users of assistive technology. It 440 is also not advised for automation scripts. We advise those users to use the 441 alternative command: 442 443 gh ext search 444 445 along with gh ext install, gh ext remove, and gh repo view. 446 `), 447 Args: cobra.NoArgs, 448 RunE: func(cmd *cobra.Command, args []string) error { 449 if !io.CanPrompt() { 450 return errors.New("this command runs an interactive UI and needs to be run in a terminal") 451 } 452 cfg, err := config() 453 if err != nil { 454 return err 455 } 456 host, _ := cfg.DefaultHost() 457 client, err := f.HttpClient() 458 if err != nil { 459 return err 460 } 461 462 searcher := search.NewSearcher(api.NewCachedHTTPClient(client, time.Hour*24), host) 463 464 opts := browse.ExtBrowseOpts{ 465 Cmd: cmd, 466 IO: io, 467 Browser: browser, 468 Searcher: searcher, 469 Em: m, 470 Client: client, 471 Cfg: cfg, 472 Debug: debug, 473 } 474 475 return browse.ExtBrowse(opts) 476 }, 477 } 478 cmd.Flags().BoolVar(&debug, "debug", false, "log to /tmp/extBrowse-*") 479 return cmd 480 }(), 481 &cobra.Command{ 482 Use: "exec <name> [args]", 483 Short: "Execute an installed extension", 484 Long: heredoc.Doc(` 485 Execute an extension using the short name. For example, if the extension repository is 486 "owner/gh-extension", you should pass "extension". You can use this command when 487 the short name conflicts with a core gh command. 488 489 All arguments after the extension name will be forwarded to the executable 490 of the extension. 491 `), 492 Example: heredoc.Doc(` 493 # execute a label extension instead of the core gh label command 494 $ gh extension exec label 495 `), 496 Args: cobra.MinimumNArgs(1), 497 DisableFlagParsing: true, 498 RunE: func(cmd *cobra.Command, args []string) error { 499 if found, err := m.Dispatch(args, io.In, io.Out, io.ErrOut); !found { 500 return fmt.Errorf("extension %q not found", args[0]) 501 } else { 502 return err 503 } 504 }, 505 }, 506 func() *cobra.Command { 507 promptCreate := func() (string, extensions.ExtTemplateType, error) { 508 extName, err := prompter.Input("Extension name:", "") 509 if err != nil { 510 return extName, -1, err 511 } 512 options := []string{"Script (Bash, Ruby, Python, etc)", "Go", "Other Precompiled (C++, Rust, etc)"} 513 extTmplType, err := prompter.Select("What kind of extension?", 514 options[0], 515 options) 516 return extName, extensions.ExtTemplateType(extTmplType), err 517 } 518 var flagType string 519 cmd := &cobra.Command{ 520 Use: "create [<name>]", 521 Short: "Create a new extension", 522 Example: heredoc.Doc(` 523 # Use interactively 524 gh extension create 525 526 # Create a script-based extension 527 gh extension create foobar 528 529 # Create a Go extension 530 gh extension create --precompiled=go foobar 531 532 # Create a non-Go precompiled extension 533 gh extension create --precompiled=other foobar 534 `), 535 Args: cobra.MaximumNArgs(1), 536 RunE: func(cmd *cobra.Command, args []string) error { 537 if cmd.Flags().Changed("precompiled") { 538 if flagType != "go" && flagType != "other" { 539 return cmdutil.FlagErrorf("value for --precompiled must be 'go' or 'other'. Got '%s'", flagType) 540 } 541 } 542 var extName string 543 var err error 544 tmplType := extensions.GitTemplateType 545 if len(args) == 0 { 546 if io.IsStdoutTTY() { 547 extName, tmplType, err = promptCreate() 548 if err != nil { 549 return fmt.Errorf("could not prompt: %w", err) 550 } 551 } 552 } else { 553 extName = args[0] 554 if flagType == "go" { 555 tmplType = extensions.GoBinTemplateType 556 } else if flagType == "other" { 557 tmplType = extensions.OtherBinTemplateType 558 } 559 } 560 561 var fullName string 562 563 if strings.HasPrefix(extName, "gh-") { 564 fullName = extName 565 extName = extName[3:] 566 } else { 567 fullName = "gh-" + extName 568 } 569 if err := m.Create(fullName, tmplType); err != nil { 570 return err 571 } 572 if !io.IsStdoutTTY() { 573 return nil 574 } 575 576 var goBinChecks string 577 578 steps := fmt.Sprintf( 579 "- run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action", 580 fullName, extName) 581 582 cs := io.ColorScheme() 583 if tmplType == extensions.GoBinTemplateType { 584 goBinChecks = heredoc.Docf(` 585 %[1]s Downloaded Go dependencies 586 %[1]s Built %[2]s binary 587 `, cs.SuccessIcon(), fullName) 588 steps = heredoc.Docf(` 589 - run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action 590 - use 'go build && gh %[2]s' to see changes in your code as you develop`, fullName, extName) 591 } else if tmplType == extensions.OtherBinTemplateType { 592 steps = heredoc.Docf(` 593 - run 'cd %[1]s; gh extension install .' to install your extension locally 594 - fill in script/build.sh with your compilation script for automated builds 595 - compile a %[1]s binary locally and run 'gh %[2]s' to see changes`, fullName, extName) 596 } 597 link := "https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions" 598 out := heredoc.Docf(` 599 %[1]s Created directory %[2]s 600 %[1]s Initialized git repository 601 %[1]s Set up extension scaffolding 602 %[6]s 603 %[2]s is ready for development! 604 605 %[4]s 606 %[5]s 607 - commit and use 'gh repo create' to share your extension with others 608 609 For more information on writing extensions: 610 %[3]s 611 `, cs.SuccessIcon(), fullName, link, cs.Bold("Next Steps"), steps, goBinChecks) 612 fmt.Fprint(io.Out, out) 613 return nil 614 }, 615 } 616 cmd.Flags().StringVar(&flagType, "precompiled", "", "Create a precompiled extension. Possible values: go, other") 617 return cmd 618 }(), 619 ) 620 621 return &extCmd 622 } 623 624 func checkValidExtension(rootCmd *cobra.Command, m extensions.ExtensionManager, extName string) error { 625 if !strings.HasPrefix(extName, "gh-") { 626 return errors.New("extension repository name must start with `gh-`") 627 } 628 629 commandName := strings.TrimPrefix(extName, "gh-") 630 if c, _, err := rootCmd.Traverse([]string{commandName}); err != nil { 631 return err 632 } else if c != rootCmd { 633 return fmt.Errorf("%q matches the name of a built-in command", commandName) 634 } 635 636 for _, ext := range m.List() { 637 if ext.Name() == commandName { 638 return fmt.Errorf("there is already an installed extension that provides the %q command", commandName) 639 } 640 } 641 642 return nil 643 } 644 645 func normalizeExtensionSelector(n string) string { 646 if idx := strings.IndexRune(n, '/'); idx >= 0 { 647 n = n[idx+1:] 648 } 649 return strings.TrimPrefix(n, "gh-") 650 } 651 652 func displayExtensionVersion(ext extensions.Extension, version string) string { 653 if !ext.IsBinary() && len(version) > 8 { 654 return version[:8] 655 } 656 return version 657 }