From fce93d60809ba97487b1268de1cd3977b59034fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 28 May 2021 19:54:22 +0200 Subject: [PATCH 1/2] Experimental command extensions support Extensions are looked up as `~/.config/gh/extensions/gh-*`. Additionally, any executables found in PATH named `gh-*` are available as `gh `. --- cmd/gh/main.go | 19 ++++- pkg/cmd/extensions/command.go | 69 ++++++++++++++++ pkg/cmd/extensions/manager.go | 150 ++++++++++++++++++++++++++++++++++ pkg/cmd/root/root.go | 2 + 4 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 pkg/cmd/extensions/command.go create mode 100644 pkg/cmd/extensions/manager.go diff --git a/cmd/gh/main.go b/cmd/gh/main.go index b9dc52858..874c2e715 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..5794a3806 --- /dev/null +++ b/pkg/cmd/extensions/command.go @@ -0,0 +1,69 @@ +package extensions + +import ( + "errors" + "fmt" + "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 { + 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..d4fc39916 --- /dev/null +++ b/pkg/cmd/extensions/manager.go @@ -0,0 +1,150 @@ +package extensions + +import ( + "errors" + "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) + pathEnv string +} + +func NewManager() *Manager { + return &Manager{ + dataDir: config.ConfigDir, + lookPath: safeexec.LookPath, + pathEnv: os.Getenv("PATH"), + } +} + +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.listInstalled() { + if filepath.Base(e) == extName { + exe = e + break + } + } + if exe == "" { + var err error + exe, err = m.lookPath(extName) + if err != nil { + 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) listInstalled() []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() { + continue + } + results = append(results, filepath.Join(dir, f.Name(), f.Name())) + } + return results +} + +func (m *Manager) List() []string { + results := m.listInstalled() + seen := make(map[string]struct{}) + for _, f := range results { + seen[filepath.Base(f)] = struct{}{} + } + + for _, p := range filepath.SplitList(m.pathEnv) { + entries, err := ioutil.ReadDir(p) + if err != nil { + continue + } + for _, f := range entries { + if _, ok := seen[f.Name()]; ok { + continue + } + if !strings.HasPrefix(f.Name(), "gh-") || !isExecutable(f) { + continue + } + results = append(results, filepath.Join(p, f.Name())) + seen[f.Name()] = struct{}{} + } + } + + return results +} + +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.listInstalled() + if len(exts) == 0 { + return errors.New("no extensions installed") + } + + for _, f := range exts { + externalCmd := exec.Command(exe, "-C", filepath.Dir(f), "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") +} + +// TODO: ignore file mode on Windows +func isExecutable(f os.FileInfo) bool { + return !f.IsDir() && f.Mode()&0111 != 0 +} 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)) From 4bdddd72d34b61c391397ab3332989a6f85f6b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Thu, 3 Jun 2021 19:06:28 +0200 Subject: [PATCH 2/2] Allow installing local extensions via symlinks This also quits searching for local extensions in PATH. --- pkg/cmd/extensions/command.go | 8 +++++ pkg/cmd/extensions/manager.go | 55 +++++++++-------------------------- 2 files changed, 21 insertions(+), 42 deletions(-) diff --git a/pkg/cmd/extensions/command.go b/pkg/cmd/extensions/command.go index 5794a3806..298787dcb 100644 --- a/pkg/cmd/extensions/command.go +++ b/pkg/cmd/extensions/command.go @@ -3,6 +3,7 @@ package extensions import ( "errors" "fmt" + "os" "path/filepath" "strings" @@ -43,6 +44,13 @@ func NewCmdExtensions(io *iostreams.IOStreams) *cobra.Command { 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 diff --git a/pkg/cmd/extensions/manager.go b/pkg/cmd/extensions/manager.go index d4fc39916..c3988b483 100644 --- a/pkg/cmd/extensions/manager.go +++ b/pkg/cmd/extensions/manager.go @@ -2,6 +2,7 @@ package extensions import ( "errors" + "fmt" "io" "io/ioutil" "os" @@ -17,14 +18,12 @@ import ( type Manager struct { dataDir func() string lookPath func(string) (string, error) - pathEnv string } func NewManager() *Manager { return &Manager{ dataDir: config.ConfigDir, lookPath: safeexec.LookPath, - pathEnv: os.Getenv("PATH"), } } @@ -37,18 +36,14 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri extName := "gh-" + args[0] forwardArgs := args[1:] - for _, e := range m.listInstalled() { + for _, e := range m.List() { if filepath.Base(e) == extName { exe = e break } } if exe == "" { - var err error - exe, err = m.lookPath(extName) - if err != nil { - return false, nil - } + return false, nil } // TODO: parse the shebang on Windows and invoke the correct interpreter instead of invoking directly @@ -59,7 +54,7 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri return true, externalCmd.Run() } -func (m *Manager) listInstalled() []string { +func (m *Manager) List() []string { dir := m.installDir() entries, err := ioutil.ReadDir(dir) if err != nil { @@ -68,7 +63,7 @@ func (m *Manager) listInstalled() []string { var results []string for _, f := range entries { - if !strings.HasPrefix(f.Name(), "gh-") || !f.IsDir() { + if !strings.HasPrefix(f.Name(), "gh-") || !(f.IsDir() || f.Mode()&os.ModeSymlink != 0) { continue } results = append(results, filepath.Join(dir, f.Name(), f.Name())) @@ -76,31 +71,10 @@ func (m *Manager) listInstalled() []string { return results } -func (m *Manager) List() []string { - results := m.listInstalled() - seen := make(map[string]struct{}) - for _, f := range results { - seen[filepath.Base(f)] = struct{}{} - } - - for _, p := range filepath.SplitList(m.pathEnv) { - entries, err := ioutil.ReadDir(p) - if err != nil { - continue - } - for _, f := range entries { - if _, ok := seen[f.Name()]; ok { - continue - } - if !strings.HasPrefix(f.Name(), "gh-") || !isExecutable(f) { - continue - } - results = append(results, filepath.Join(p, f.Name())) - seen[f.Name()] = struct{}{} - } - } - - 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 { @@ -124,13 +98,15 @@ func (m *Manager) Upgrade(stdout, stderr io.Writer) error { return err } - exts := m.listInstalled() + exts := m.List() if len(exts) == 0 { return errors.New("no extensions installed") } for _, f := range exts { - externalCmd := exec.Command(exe, "-C", filepath.Dir(f), "pull", "--ff-only") + 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 { @@ -143,8 +119,3 @@ func (m *Manager) Upgrade(stdout, stderr io.Writer) error { func (m *Manager) installDir() string { return filepath.Join(m.dataDir(), "extensions") } - -// TODO: ignore file mode on Windows -func isExecutable(f os.FileInfo) bool { - return !f.IsDir() && f.Mode()&0111 != 0 -}