diff --git a/cmd/gh/main.go b/cmd/gh/main.go index d4459dd0c..72b635810 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -21,6 +21,7 @@ import ( "github.com/cli/cli/internal/run" "github.com/cli/cli/internal/update" "github.com/cli/cli/pkg/cmd/alias/expand" + "github.com/cli/cli/pkg/cmd/extensions" "github.com/cli/cli/pkg/cmd/factory" "github.com/cli/cli/pkg/cmd/root" "github.com/cli/cli/pkg/cmdutil" @@ -140,15 +141,27 @@ func mainRun() exitCode { err = preparedCmd.Run() if err != nil { - if ee, ok := err.(*exec.ExitError); ok { - return exitCode(ee.ExitCode()) + var execError *exec.ExitError + if errors.As(err, &execError) { + return exitCode(execError.ExitCode()) } - fmt.Fprintf(stderr, "failed to run external command: %s", err) return exitError } return exitOK + } else if c, _, err := rootCmd.Traverse(expandedArgs); err == nil && c == rootCmd && len(expandedArgs) > 0 { + extensionManager := extensions.NewManager() + 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", err) + return exitError + } else if found { + return exitOK + } } } diff --git a/pkg/cmd/extensions/command.go b/pkg/cmd/extensions/command.go new file mode 100644 index 000000000..298787dcb --- /dev/null +++ b/pkg/cmd/extensions/command.go @@ -0,0 +1,77 @@ +package extensions + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +func NewCmdExtensions(io *iostreams.IOStreams) *cobra.Command { + m := NewManager() + + extCmd := cobra.Command{ + Use: "extensions", + Short: "Manage gh extensions", + } + + extCmd.AddCommand( + &cobra.Command{ + Use: "list", + Short: "List installed extension commands", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cmds := m.List() + if len(cmds) == 0 { + return errors.New("no extensions installed") + } + for _, c := range cmds { + name := filepath.Base(c) + parts := strings.SplitN(name, "-", 2) + fmt.Fprintf(io.Out, "%s %s\n", parts[0], parts[1]) + } + return nil + }, + }, + &cobra.Command{ + Use: "install ", + Short: "Install a gh extension from a repository", + Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"), + RunE: func(cmd *cobra.Command, args []string) error { + if args[0] == "." { + wd, err := os.Getwd() + if err != nil { + return err + } + return m.InstallLocal(wd) + } + repo, err := ghrepo.FromFullName(args[0]) + if err != nil { + return err + } + if !strings.HasPrefix(repo.RepoName(), "gh-") { + return errors.New("the repository name must start with `gh-`") + } + protocol := "https" // TODO: respect user's preferred protocol + return m.Install(ghrepo.FormatRemoteURL(repo, protocol), io.Out, io.ErrOut) + }, + }, + &cobra.Command{ + Use: "upgrade", + Short: "Upgrade installed extensions", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return m.Upgrade(io.Out, io.ErrOut) + }, + }, + ) + + extCmd.Hidden = true + return &extCmd +} diff --git a/pkg/cmd/extensions/manager.go b/pkg/cmd/extensions/manager.go new file mode 100644 index 000000000..c3988b483 --- /dev/null +++ b/pkg/cmd/extensions/manager.go @@ -0,0 +1,121 @@ +package extensions + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + + "github.com/cli/cli/internal/config" + "github.com/cli/safeexec" +) + +type Manager struct { + dataDir func() string + lookPath func(string) (string, error) +} + +func NewManager() *Manager { + return &Manager{ + dataDir: config.ConfigDir, + lookPath: safeexec.LookPath, + } +} + +func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) { + if len(args) == 0 { + return false, errors.New("too few arguments in list") + } + + var exe string + extName := "gh-" + args[0] + forwardArgs := args[1:] + + for _, e := range m.List() { + if filepath.Base(e) == extName { + exe = e + break + } + } + if exe == "" { + return false, nil + } + + // TODO: parse the shebang on Windows and invoke the correct interpreter instead of invoking directly + externalCmd := exec.Command(exe, forwardArgs...) + externalCmd.Stdin = stdin + externalCmd.Stdout = stdout + externalCmd.Stderr = stderr + return true, externalCmd.Run() +} + +func (m *Manager) List() []string { + dir := m.installDir() + entries, err := ioutil.ReadDir(dir) + if err != nil { + return nil + } + + var results []string + for _, f := range entries { + if !strings.HasPrefix(f.Name(), "gh-") || !(f.IsDir() || f.Mode()&os.ModeSymlink != 0) { + continue + } + results = append(results, filepath.Join(dir, f.Name(), f.Name())) + } + return results +} + +func (m *Manager) InstallLocal(dir string) error { + name := filepath.Base(dir) + targetDir := filepath.Join(m.installDir(), name) + return os.Symlink(dir, targetDir) +} + +func (m *Manager) Install(cloneURL string, stdout, stderr io.Writer) error { + exe, err := m.lookPath("git") + if err != nil { + return err + } + + name := strings.TrimSuffix(path.Base(cloneURL), ".git") + targetDir := filepath.Join(m.installDir(), name) + + externalCmd := exec.Command(exe, "clone", cloneURL, targetDir) + externalCmd.Stdout = stdout + externalCmd.Stderr = stderr + return externalCmd.Run() +} + +func (m *Manager) Upgrade(stdout, stderr io.Writer) error { + exe, err := m.lookPath("git") + if err != nil { + return err + } + + exts := m.List() + if len(exts) == 0 { + return errors.New("no extensions installed") + } + + for _, f := range exts { + fmt.Fprintf(stdout, "[%s]: ", filepath.Base(f)) + dir := filepath.Dir(f) + externalCmd := exec.Command(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "pull", "--ff-only") + externalCmd.Stdout = stdout + externalCmd.Stderr = stderr + if e := externalCmd.Run(); e != nil { + err = e + } + } + return err +} + +func (m *Manager) installDir() string { + return filepath.Join(m.dataDir(), "extensions") +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index b60d16012..cc971a0d4 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -13,6 +13,7 @@ import ( authCmd "github.com/cli/cli/pkg/cmd/auth" completionCmd "github.com/cli/cli/pkg/cmd/completion" configCmd "github.com/cli/cli/pkg/cmd/config" + extensionsCmd "github.com/cli/cli/pkg/cmd/extensions" "github.com/cli/cli/pkg/cmd/factory" gistCmd "github.com/cli/cli/pkg/cmd/gist" issueCmd "github.com/cli/cli/pkg/cmd/issue" @@ -80,6 +81,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) cmd.AddCommand(gistCmd.NewCmdGist(f)) cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams)) + cmd.AddCommand(extensionsCmd.NewCmdExtensions(f.IOStreams)) cmd.AddCommand(secretCmd.NewCmdSecret(f)) cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))