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