X Tutup
Skip to content

Commit fce93d6

Browse files
committed
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 <command>`.
1 parent 35e5c75 commit fce93d6

File tree

4 files changed

+237
-3
lines changed

4 files changed

+237
-3
lines changed

cmd/gh/main.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/cli/cli/internal/run"
2222
"github.com/cli/cli/internal/update"
2323
"github.com/cli/cli/pkg/cmd/alias/expand"
24+
"github.com/cli/cli/pkg/cmd/extensions"
2425
"github.com/cli/cli/pkg/cmd/factory"
2526
"github.com/cli/cli/pkg/cmd/root"
2627
"github.com/cli/cli/pkg/cmdutil"
@@ -140,15 +141,27 @@ func mainRun() exitCode {
140141

141142
err = preparedCmd.Run()
142143
if err != nil {
143-
if ee, ok := err.(*exec.ExitError); ok {
144-
return exitCode(ee.ExitCode())
144+
var execError *exec.ExitError
145+
if errors.As(err, &execError) {
146+
return exitCode(execError.ExitCode())
145147
}
146-
147148
fmt.Fprintf(stderr, "failed to run external command: %s", err)
148149
return exitError
149150
}
150151

151152
return exitOK
153+
} else if c, _, err := rootCmd.Traverse(expandedArgs); err == nil && c == rootCmd && len(expandedArgs) > 0 {
154+
extensionManager := extensions.NewManager()
155+
if found, err := extensionManager.Dispatch(expandedArgs, os.Stdin, os.Stdout, os.Stderr); err != nil {
156+
var execError *exec.ExitError
157+
if errors.As(err, &execError) {
158+
return exitCode(execError.ExitCode())
159+
}
160+
fmt.Fprintf(stderr, "failed to run extension: %s", err)
161+
return exitError
162+
} else if found {
163+
return exitOK
164+
}
152165
}
153166
}
154167

pkg/cmd/extensions/command.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package extensions
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"path/filepath"
7+
"strings"
8+
9+
"github.com/cli/cli/internal/ghrepo"
10+
"github.com/cli/cli/pkg/cmdutil"
11+
"github.com/cli/cli/pkg/iostreams"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
func NewCmdExtensions(io *iostreams.IOStreams) *cobra.Command {
16+
m := NewManager()
17+
18+
extCmd := cobra.Command{
19+
Use: "extensions",
20+
Short: "Manage gh extensions",
21+
}
22+
23+
extCmd.AddCommand(
24+
&cobra.Command{
25+
Use: "list",
26+
Short: "List installed extension commands",
27+
Args: cobra.NoArgs,
28+
RunE: func(cmd *cobra.Command, args []string) error {
29+
cmds := m.List()
30+
if len(cmds) == 0 {
31+
return errors.New("no extensions installed")
32+
}
33+
for _, c := range cmds {
34+
name := filepath.Base(c)
35+
parts := strings.SplitN(name, "-", 2)
36+
fmt.Fprintf(io.Out, "%s %s\n", parts[0], parts[1])
37+
}
38+
return nil
39+
},
40+
},
41+
&cobra.Command{
42+
Use: "install <repo>",
43+
Short: "Install a gh extension from a repository",
44+
Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"),
45+
RunE: func(cmd *cobra.Command, args []string) error {
46+
repo, err := ghrepo.FromFullName(args[0])
47+
if err != nil {
48+
return err
49+
}
50+
if !strings.HasPrefix(repo.RepoName(), "gh-") {
51+
return errors.New("the repository name must start with `gh-`")
52+
}
53+
protocol := "https" // TODO: respect user's preferred protocol
54+
return m.Install(ghrepo.FormatRemoteURL(repo, protocol), io.Out, io.ErrOut)
55+
},
56+
},
57+
&cobra.Command{
58+
Use: "upgrade",
59+
Short: "Upgrade installed extensions",
60+
Args: cobra.NoArgs,
61+
RunE: func(cmd *cobra.Command, args []string) error {
62+
return m.Upgrade(io.Out, io.ErrOut)
63+
},
64+
},
65+
)
66+
67+
extCmd.Hidden = true
68+
return &extCmd
69+
}

pkg/cmd/extensions/manager.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package extensions
2+
3+
import (
4+
"errors"
5+
"io"
6+
"io/ioutil"
7+
"os"
8+
"os/exec"
9+
"path"
10+
"path/filepath"
11+
"strings"
12+
13+
"github.com/cli/cli/internal/config"
14+
"github.com/cli/safeexec"
15+
)
16+
17+
type Manager struct {
18+
dataDir func() string
19+
lookPath func(string) (string, error)
20+
pathEnv string
21+
}
22+
23+
func NewManager() *Manager {
24+
return &Manager{
25+
dataDir: config.ConfigDir,
26+
lookPath: safeexec.LookPath,
27+
pathEnv: os.Getenv("PATH"),
28+
}
29+
}
30+
31+
func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) {
32+
if len(args) == 0 {
33+
return false, errors.New("too few arguments in list")
34+
}
35+
36+
var exe string
37+
extName := "gh-" + args[0]
38+
forwardArgs := args[1:]
39+
40+
for _, e := range m.listInstalled() {
41+
if filepath.Base(e) == extName {
42+
exe = e
43+
break
44+
}
45+
}
46+
if exe == "" {
47+
var err error
48+
exe, err = m.lookPath(extName)
49+
if err != nil {
50+
return false, nil
51+
}
52+
}
53+
54+
// TODO: parse the shebang on Windows and invoke the correct interpreter instead of invoking directly
55+
externalCmd := exec.Command(exe, forwardArgs...)
56+
externalCmd.Stdin = stdin
57+
externalCmd.Stdout = stdout
58+
externalCmd.Stderr = stderr
59+
return true, externalCmd.Run()
60+
}
61+
62+
func (m *Manager) listInstalled() []string {
63+
dir := m.installDir()
64+
entries, err := ioutil.ReadDir(dir)
65+
if err != nil {
66+
return nil
67+
}
68+
69+
var results []string
70+
for _, f := range entries {
71+
if !strings.HasPrefix(f.Name(), "gh-") || !f.IsDir() {
72+
continue
73+
}
74+
results = append(results, filepath.Join(dir, f.Name(), f.Name()))
75+
}
76+
return results
77+
}
78+
79+
func (m *Manager) List() []string {
80+
results := m.listInstalled()
81+
seen := make(map[string]struct{})
82+
for _, f := range results {
83+
seen[filepath.Base(f)] = struct{}{}
84+
}
85+
86+
for _, p := range filepath.SplitList(m.pathEnv) {
87+
entries, err := ioutil.ReadDir(p)
88+
if err != nil {
89+
continue
90+
}
91+
for _, f := range entries {
92+
if _, ok := seen[f.Name()]; ok {
93+
continue
94+
}
95+
if !strings.HasPrefix(f.Name(), "gh-") || !isExecutable(f) {
96+
continue
97+
}
98+
results = append(results, filepath.Join(p, f.Name()))
99+
seen[f.Name()] = struct{}{}
100+
}
101+
}
102+
103+
return results
104+
}
105+
106+
func (m *Manager) Install(cloneURL string, stdout, stderr io.Writer) error {
107+
exe, err := m.lookPath("git")
108+
if err != nil {
109+
return err
110+
}
111+
112+
name := strings.TrimSuffix(path.Base(cloneURL), ".git")
113+
targetDir := filepath.Join(m.installDir(), name)
114+
115+
externalCmd := exec.Command(exe, "clone", cloneURL, targetDir)
116+
externalCmd.Stdout = stdout
117+
externalCmd.Stderr = stderr
118+
return externalCmd.Run()
119+
}
120+
121+
func (m *Manager) Upgrade(stdout, stderr io.Writer) error {
122+
exe, err := m.lookPath("git")
123+
if err != nil {
124+
return err
125+
}
126+
127+
exts := m.listInstalled()
128+
if len(exts) == 0 {
129+
return errors.New("no extensions installed")
130+
}
131+
132+
for _, f := range exts {
133+
externalCmd := exec.Command(exe, "-C", filepath.Dir(f), "pull", "--ff-only")
134+
externalCmd.Stdout = stdout
135+
externalCmd.Stderr = stderr
136+
if e := externalCmd.Run(); e != nil {
137+
err = e
138+
}
139+
}
140+
return err
141+
}
142+
143+
func (m *Manager) installDir() string {
144+
return filepath.Join(m.dataDir(), "extensions")
145+
}
146+
147+
// TODO: ignore file mode on Windows
148+
func isExecutable(f os.FileInfo) bool {
149+
return !f.IsDir() && f.Mode()&0111 != 0
150+
}

pkg/cmd/root/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
authCmd "github.com/cli/cli/pkg/cmd/auth"
1414
completionCmd "github.com/cli/cli/pkg/cmd/completion"
1515
configCmd "github.com/cli/cli/pkg/cmd/config"
16+
extensionsCmd "github.com/cli/cli/pkg/cmd/extensions"
1617
"github.com/cli/cli/pkg/cmd/factory"
1718
gistCmd "github.com/cli/cli/pkg/cmd/gist"
1819
issueCmd "github.com/cli/cli/pkg/cmd/issue"
@@ -80,6 +81,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
8081
cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil))
8182
cmd.AddCommand(gistCmd.NewCmdGist(f))
8283
cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams))
84+
cmd.AddCommand(extensionsCmd.NewCmdExtensions(f.IOStreams))
8385
cmd.AddCommand(secretCmd.NewCmdSecret(f))
8486
cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))
8587

0 commit comments

Comments
 (0)
X Tutup