1package extension 2 3import ( 4 "errors" 5 "fmt" 6 "os" 7 "strings" 8 9 "github.com/AlecAivazis/survey/v2" 10 "github.com/MakeNowJust/heredoc" 11 "github.com/cli/cli/v2/git" 12 "github.com/cli/cli/v2/internal/ghrepo" 13 "github.com/cli/cli/v2/pkg/cmdutil" 14 "github.com/cli/cli/v2/pkg/extensions" 15 "github.com/cli/cli/v2/pkg/prompt" 16 "github.com/cli/cli/v2/utils" 17 "github.com/spf13/cobra" 18) 19 20func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { 21 m := f.ExtensionManager 22 io := f.IOStreams 23 24 extCmd := cobra.Command{ 25 Use: "extension", 26 Short: "Manage gh extensions", 27 Long: heredoc.Docf(` 28 GitHub CLI extensions are repositories that provide additional gh commands. 29 30 The name of the extension repository must start with "gh-" and it must contain an 31 executable of the same name. All arguments passed to the %[1]sgh <extname>%[1]s invocation 32 will be forwarded to the %[1]sgh-<extname>%[1]s executable of the extension. 33 34 An extension cannot override any of the core gh commands. 35 36 See the list of available extensions at <https://github.com/topics/gh-extension> 37 `, "`"), 38 Aliases: []string{"extensions"}, 39 } 40 41 extCmd.AddCommand( 42 &cobra.Command{ 43 Use: "list", 44 Short: "List installed extension commands", 45 Args: cobra.NoArgs, 46 RunE: func(cmd *cobra.Command, args []string) error { 47 cmds := m.List(true) 48 if len(cmds) == 0 { 49 return errors.New("no extensions installed") 50 } 51 cs := io.ColorScheme() 52 t := utils.NewTablePrinter(io) 53 for _, c := range cmds { 54 var repo string 55 if u, err := git.ParseURL(c.URL()); err == nil { 56 if r, err := ghrepo.FromURL(u); err == nil { 57 repo = ghrepo.FullName(r) 58 } 59 } 60 61 t.AddField(fmt.Sprintf("gh %s", c.Name()), nil, nil) 62 t.AddField(repo, nil, nil) 63 var updateAvailable string 64 if c.UpdateAvailable() { 65 updateAvailable = "Upgrade available" 66 } 67 t.AddField(updateAvailable, nil, cs.Green) 68 t.EndRow() 69 } 70 return t.Render() 71 }, 72 }, 73 &cobra.Command{ 74 Use: "install <repository>", 75 Short: "Install a gh extension from a repository", 76 Long: heredoc.Doc(` 77 Install a GitHub repository locally as a GitHub CLI extension. 78 79 The repository argument can be specified in "owner/repo" format as well as a full URL. 80 The URL format is useful when the repository is not hosted on github.com. 81 82 To install an extension in development from the current directory, use "." as the 83 value of the repository argument. 84 85 See the list of available extensions at <https://github.com/topics/gh-extension> 86 `), 87 Example: heredoc.Doc(` 88 $ gh extension install owner/gh-extension 89 $ gh extension install https://git.example.com/owner/gh-extension 90 $ gh extension install . 91 `), 92 Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"), 93 RunE: func(cmd *cobra.Command, args []string) error { 94 if args[0] == "." { 95 wd, err := os.Getwd() 96 if err != nil { 97 return err 98 } 99 return m.InstallLocal(wd) 100 } 101 102 repo, err := ghrepo.FromFullName(args[0]) 103 if err != nil { 104 return err 105 } 106 107 if err := checkValidExtension(cmd.Root(), m, repo.RepoName()); err != nil { 108 return err 109 } 110 111 if err := m.Install(repo); err != nil { 112 return err 113 } 114 115 if io.IsStdoutTTY() { 116 cs := io.ColorScheme() 117 fmt.Fprintf(io.Out, "%s Installed extension %s\n", cs.SuccessIcon(), args[0]) 118 } 119 return nil 120 }, 121 }, 122 func() *cobra.Command { 123 var flagAll bool 124 var flagForce bool 125 cmd := &cobra.Command{ 126 Use: "upgrade {<name> | --all}", 127 Short: "Upgrade installed extensions", 128 Args: func(cmd *cobra.Command, args []string) error { 129 if len(args) == 0 && !flagAll { 130 return cmdutil.FlagErrorf("specify an extension to upgrade or `--all`") 131 } 132 if len(args) > 0 && flagAll { 133 return cmdutil.FlagErrorf("cannot use `--all` with extension name") 134 } 135 if len(args) > 1 { 136 return cmdutil.FlagErrorf("too many arguments") 137 } 138 return nil 139 }, 140 RunE: func(cmd *cobra.Command, args []string) error { 141 var name string 142 if len(args) > 0 { 143 name = normalizeExtensionSelector(args[0]) 144 } 145 cs := io.ColorScheme() 146 err := m.Upgrade(name, flagForce) 147 if err != nil && !errors.Is(err, upToDateError) { 148 if name != "" { 149 fmt.Fprintf(io.ErrOut, "%s Failed upgrading extension %s: %s\n", cs.FailureIcon(), name, err) 150 } else { 151 fmt.Fprintf(io.ErrOut, "%s Failed upgrading extensions\n", cs.FailureIcon()) 152 } 153 return cmdutil.SilentError 154 } 155 if io.IsStdoutTTY() { 156 if errors.Is(err, upToDateError) { 157 fmt.Fprintf(io.Out, "%s Extension already up to date\n", cs.SuccessIcon()) 158 } else if name != "" { 159 fmt.Fprintf(io.Out, "%s Successfully upgraded extension %s\n", cs.SuccessIcon(), name) 160 } else { 161 fmt.Fprintf(io.Out, "%s Successfully upgraded extensions\n", cs.SuccessIcon()) 162 } 163 } 164 return nil 165 }, 166 } 167 cmd.Flags().BoolVar(&flagAll, "all", false, "Upgrade all extensions") 168 cmd.Flags().BoolVar(&flagForce, "force", false, "Force upgrade extension") 169 return cmd 170 }(), 171 &cobra.Command{ 172 Use: "remove <name>", 173 Short: "Remove an installed extension", 174 Args: cobra.ExactArgs(1), 175 RunE: func(cmd *cobra.Command, args []string) error { 176 extName := normalizeExtensionSelector(args[0]) 177 if err := m.Remove(extName); err != nil { 178 return err 179 } 180 if io.IsStdoutTTY() { 181 cs := io.ColorScheme() 182 fmt.Fprintf(io.Out, "%s Removed extension %s\n", cs.SuccessIcon(), extName) 183 } 184 return nil 185 }, 186 }, 187 func() *cobra.Command { 188 promptCreate := func() (string, extensions.ExtTemplateType, error) { 189 var extName string 190 var extTmplType int 191 err := prompt.SurveyAskOne(&survey.Input{ 192 Message: "Extension name:", 193 }, &extName) 194 if err != nil { 195 return extName, -1, err 196 } 197 err = prompt.SurveyAskOne(&survey.Select{ 198 Message: "What kind of extension?", 199 Options: []string{ 200 "Script (Bash, Ruby, Python, etc)", 201 "Go", 202 "Other Precompiled (C++, Rust, etc)", 203 }, 204 }, &extTmplType) 205 return extName, extensions.ExtTemplateType(extTmplType), err 206 } 207 var flagType string 208 cmd := &cobra.Command{ 209 Use: "create [<name>]", 210 Short: "Create a new extension", 211 Example: heredoc.Doc(` 212 # Use interactively 213 gh extension create 214 215 # Create a script-based extension 216 gh extension create foobar 217 218 # Create a Go extension 219 gh extension create --precompiled=go foobar 220 221 # Create a non-Go precompiled extension 222 gh extension create --precompiled=other foobar 223 `), 224 Args: cobra.MaximumNArgs(1), 225 RunE: func(cmd *cobra.Command, args []string) error { 226 if cmd.Flags().Changed("precompiled") { 227 if flagType != "go" && flagType != "other" { 228 return cmdutil.FlagErrorf("value for --precompiled must be 'go' or 'other'. Got '%s'", flagType) 229 } 230 } 231 var extName string 232 var err error 233 tmplType := extensions.GitTemplateType 234 if len(args) == 0 { 235 if io.IsStdoutTTY() { 236 extName, tmplType, err = promptCreate() 237 if err != nil { 238 return fmt.Errorf("could not prompt: %w", err) 239 } 240 } 241 } else { 242 extName = args[0] 243 if flagType == "go" { 244 tmplType = extensions.GoBinTemplateType 245 } else if flagType == "other" { 246 tmplType = extensions.OtherBinTemplateType 247 } 248 } 249 250 var fullName string 251 252 if strings.HasPrefix(extName, "gh-") { 253 fullName = extName 254 extName = extName[3:] 255 } else { 256 fullName = "gh-" + extName 257 } 258 if err := m.Create(fullName, tmplType); err != nil { 259 return err 260 } 261 if !io.IsStdoutTTY() { 262 return nil 263 } 264 265 var goBinChecks string 266 267 steps := fmt.Sprintf( 268 "- run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action", 269 fullName, extName) 270 271 cs := io.ColorScheme() 272 if tmplType == extensions.GoBinTemplateType { 273 goBinChecks = heredoc.Docf(` 274 %[1]s Downloaded Go dependencies 275 %[1]s Built %[2]s binary 276 `, cs.SuccessIcon(), fullName) 277 steps = heredoc.Docf(` 278 - run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action 279 - use 'go build && gh %[2]s' to see changes in your code as you develop`, fullName, extName) 280 } else if tmplType == extensions.OtherBinTemplateType { 281 steps = heredoc.Docf(` 282 - run 'cd %[1]s; gh extension install .' to install your extension locally 283 - fill in script/build.sh with your compilation script for automated builds 284 - compile a %[1]s binary locally and run 'gh %[2]s' to see changes`, fullName, extName) 285 } 286 link := "https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions" 287 out := heredoc.Docf(` 288 %[1]s Created directory %[2]s 289 %[1]s Initialized git repository 290 %[1]s Set up extension scaffolding 291 %[6]s 292 %[2]s is ready for development! 293 294 %[4]s 295 %[5]s 296 - commit and use 'gh repo create' to share your extension with others 297 298 For more information on writing extensions: 299 %[3]s 300 `, cs.SuccessIcon(), fullName, link, cs.Bold("Next Steps"), steps, goBinChecks) 301 fmt.Fprint(io.Out, out) 302 return nil 303 }, 304 } 305 cmd.Flags().StringVar(&flagType, "precompiled", "", "Create a precompiled extension. Possible values: go, other") 306 return cmd 307 }(), 308 ) 309 310 return &extCmd 311} 312 313func checkValidExtension(rootCmd *cobra.Command, m extensions.ExtensionManager, extName string) error { 314 if !strings.HasPrefix(extName, "gh-") { 315 return errors.New("extension repository name must start with `gh-`") 316 } 317 318 commandName := strings.TrimPrefix(extName, "gh-") 319 if c, _, err := rootCmd.Traverse([]string{commandName}); err != nil { 320 return err 321 } else if c != rootCmd { 322 return fmt.Errorf("%q matches the name of a built-in command", commandName) 323 } 324 325 for _, ext := range m.List(false) { 326 if ext.Name() == commandName { 327 return fmt.Errorf("there is already an installed extension that provides the %q command", commandName) 328 } 329 } 330 331 return nil 332} 333 334func normalizeExtensionSelector(n string) string { 335 if idx := strings.IndexRune(n, '/'); idx >= 0 { 336 n = n[idx+1:] 337 } 338 return strings.TrimPrefix(n, "gh-") 339} 340