Suggest and install official extensions via stub commands
Register hidden stub commands for official GitHub extensions (gh-aw, gh-stack) that offer to install the extension when invoked. This replaces the error-string-matching approach from the original PR with proper cobra commands that: - Avoid false-positive matches on flag values or post-'--' args - Eliminate conflicting cobra 'Did you mean?' suggestions - Properly propagate prompt/install errors for correct exit codes - Are hidden from help output and shell completions - Use GroupID "extension" so checkValidExtension allows installing over them - Are registered after extensions and aliases so both take priority Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
75909ed2ba
commit
7ad1d7c0a1
6 changed files with 261 additions and 98 deletions
|
|
@ -14,18 +14,15 @@ import (
|
|||
|
||||
surveyCore "github.com/AlecAivazis/survey/v2/core"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/agents"
|
||||
"github.com/cli/cli/v2/internal/build"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/config/migration"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/internal/update"
|
||||
"github.com/cli/cli/v2/pkg/cmd/factory"
|
||||
"github.com/cli/cli/v2/pkg/cmd/root"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/extensions"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/utils"
|
||||
"github.com/cli/safeexec"
|
||||
|
|
@ -143,18 +140,6 @@ func Main() exitCode {
|
|||
return exitCode(extError.ExitCode())
|
||||
}
|
||||
|
||||
// Check if any of the provided args match a known official extension.
|
||||
// We scan all args rather than just the first because global flags
|
||||
// (e.g. --repo) may precede the unknown command name.
|
||||
if strings.HasPrefix(err.Error(), "unknown command ") {
|
||||
for _, arg := range expandedArgs {
|
||||
if ext := extensions.FindOfficialExtension(arg); ext != nil {
|
||||
handleOfficialExtension(cmdFactory.IOStreams, cmdFactory.Prompter, cmdFactory.ExtensionManager, ext, err)
|
||||
return exitError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printError(stderr, err, cmd, hasDebug)
|
||||
|
||||
if strings.Contains(err.Error(), "Incorrect function") {
|
||||
|
|
@ -260,41 +245,3 @@ func isUnderHomebrew(ghBinary string) bool {
|
|||
brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator)
|
||||
return strings.HasPrefix(ghBinary, brewBinPrefix)
|
||||
}
|
||||
|
||||
// handleOfficialExtension prints a suggestion for the matched official extension
|
||||
// and, in interactive TTY sessions, prompts the user to install it.
|
||||
func handleOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension, err error) {
|
||||
stderr := io.ErrOut
|
||||
|
||||
fmt.Fprintln(stderr, err)
|
||||
|
||||
if !io.CanPrompt() {
|
||||
fmt.Fprint(stderr, heredoc.Docf(`
|
||||
%q is also available as an official extension.
|
||||
To install it, run:
|
||||
gh extension install github.com/%s/%s
|
||||
`, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo))
|
||||
return
|
||||
}
|
||||
|
||||
prompt := heredoc.Docf(`
|
||||
%q is also available as an official extension.
|
||||
Would you like to install it now?
|
||||
`, fmt.Sprintf("gh %s", ext.Name))
|
||||
confirmed, promptErr := p.Confirm(prompt, true)
|
||||
if promptErr != nil || !confirmed {
|
||||
return
|
||||
}
|
||||
|
||||
repo := ext.Repository()
|
||||
io.StartProgressIndicatorWithLabel(fmt.Sprintf("Installing %s/%s...", ext.Owner, ext.Repo))
|
||||
defer io.StopProgressIndicator()
|
||||
installErr := em.Install(repo, "")
|
||||
io.StopProgressIndicator()
|
||||
if installErr != nil {
|
||||
fmt.Fprintf(stderr, "Failed to install extension: %s\n", installErr)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(stderr, "Successfully installed %s/%s\n", ext.Owner, ext.Repo)
|
||||
}
|
||||
|
|
|
|||
76
pkg/cmd/root/official_extension.go
Normal file
76
pkg/cmd/root/official_extension.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package root
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/pkg/extensions"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdOfficialExtension creates a hidden stub command for an official
|
||||
// extension that has not yet been installed. When invoked, it suggests
|
||||
// installing the extension and, in interactive sessions, offers to do so
|
||||
// immediately. After a successful install, the extension is dispatched with
|
||||
// the original arguments.
|
||||
func NewCmdOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: ext.Name,
|
||||
Short: fmt.Sprintf("Install the official %s extension", ext.Name),
|
||||
Hidden: true,
|
||||
GroupID: "extension",
|
||||
Annotations: map[string]string{
|
||||
"skipAuthCheck": "true",
|
||||
},
|
||||
// Accept any args/flags the user may have passed so we don't get
|
||||
// cobra validation errors before reaching RunE.
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return officialExtensionRun(io, p, em, ext, args)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func officialExtensionRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension, args []string) error {
|
||||
stderr := io.ErrOut
|
||||
|
||||
if !io.CanPrompt() {
|
||||
fmt.Fprint(stderr, heredoc.Docf(`
|
||||
%[1]s is available as an official extension.
|
||||
To install it, run:
|
||||
gh extension install %[2]s/%[3]s
|
||||
`, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo))
|
||||
return nil
|
||||
}
|
||||
|
||||
prompt := heredoc.Docf(`
|
||||
%[1]s is available as an official extension.
|
||||
Would you like to install it now?
|
||||
`, fmt.Sprintf("gh %s", ext.Name))
|
||||
confirmed, err := p.Confirm(prompt, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !confirmed {
|
||||
return nil
|
||||
}
|
||||
|
||||
repo := ext.Repository()
|
||||
io.StartProgressIndicatorWithLabel(fmt.Sprintf("Installing %s/%s...", ext.Owner, ext.Repo))
|
||||
installErr := em.Install(repo, "")
|
||||
io.StopProgressIndicator()
|
||||
if installErr != nil {
|
||||
return fmt.Errorf("failed to install extension: %w", installErr)
|
||||
}
|
||||
|
||||
fmt.Fprintf(stderr, "Successfully installed %s/%s\n", ext.Owner, ext.Repo)
|
||||
|
||||
// Dispatch the newly installed extension with the original arguments.
|
||||
dispatchArgs := append([]string{ext.Name}, args...)
|
||||
if _, dispatchErr := em.Dispatch(dispatchArgs, io.In, io.Out, stderr); dispatchErr != nil {
|
||||
return dispatchErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
168
pkg/cmd/root/official_extension_test.go
Normal file
168
pkg/cmd/root/official_extension_test.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package root
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/pkg/extensions"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOfficialExtensionRun_NonTTY(t *testing.T) {
|
||||
ios, _, _, stderr := iostreams.Test()
|
||||
// non-TTY by default
|
||||
|
||||
ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"}
|
||||
em := &extensions.ExtensionManagerMock{}
|
||||
p := &prompter.PrompterMock{}
|
||||
|
||||
err := officialExtensionRun(ios, p, em, ext, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, stderr.String(), "gh stack")
|
||||
assert.Contains(t, stderr.String(), "gh extension install github/gh-stack")
|
||||
}
|
||||
|
||||
func TestOfficialExtensionRun_TTY_Confirmed(t *testing.T) {
|
||||
ios, _, _, stderr := iostreams.Test()
|
||||
ios.SetStdinTTY(true)
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStderrTTY(true)
|
||||
|
||||
ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"}
|
||||
var installedRepo ghrepo.Interface
|
||||
var dispatchedArgs []string
|
||||
em := &extensions.ExtensionManagerMock{
|
||||
InstallFunc: func(repo ghrepo.Interface, pin string) error {
|
||||
installedRepo = repo
|
||||
return nil
|
||||
},
|
||||
DispatchFunc: func(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) {
|
||||
dispatchedArgs = args
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
p := &prompter.PrompterMock{
|
||||
ConfirmFunc: func(_ string, _ bool) (bool, error) {
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := officialExtensionRun(ios, p, em, ext, []string{"--help"})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, installedRepo)
|
||||
assert.Equal(t, "github", installedRepo.RepoOwner())
|
||||
assert.Equal(t, "gh-stack", installedRepo.RepoName())
|
||||
assert.Equal(t, "github.com", installedRepo.RepoHost())
|
||||
assert.Contains(t, stderr.String(), "Successfully installed github/gh-stack")
|
||||
assert.Equal(t, []string{"stack", "--help"}, dispatchedArgs)
|
||||
}
|
||||
|
||||
func TestOfficialExtensionRun_TTY_Declined(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
ios.SetStdinTTY(true)
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStderrTTY(true)
|
||||
|
||||
ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"}
|
||||
em := &extensions.ExtensionManagerMock{}
|
||||
p := &prompter.PrompterMock{
|
||||
ConfirmFunc: func(_ string, _ bool) (bool, error) {
|
||||
return false, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := officialExtensionRun(ios, p, em, ext, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, em.InstallCalls())
|
||||
}
|
||||
|
||||
func TestOfficialExtensionRun_TTY_PromptError(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
ios.SetStdinTTY(true)
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStderrTTY(true)
|
||||
|
||||
ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"}
|
||||
em := &extensions.ExtensionManagerMock{}
|
||||
p := &prompter.PrompterMock{
|
||||
ConfirmFunc: func(_ string, _ bool) (bool, error) {
|
||||
return false, fmt.Errorf("prompt interrupted")
|
||||
},
|
||||
}
|
||||
|
||||
err := officialExtensionRun(ios, p, em, ext, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "prompt interrupted")
|
||||
}
|
||||
|
||||
func TestOfficialExtensionRun_TTY_InstallError(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
ios.SetStdinTTY(true)
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStderrTTY(true)
|
||||
|
||||
ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"}
|
||||
em := &extensions.ExtensionManagerMock{
|
||||
InstallFunc: func(_ ghrepo.Interface, _ string) error {
|
||||
return fmt.Errorf("network error")
|
||||
},
|
||||
}
|
||||
p := &prompter.PrompterMock{
|
||||
ConfirmFunc: func(_ string, _ bool) (bool, error) {
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := officialExtensionRun(ios, p, em, ext, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "network error")
|
||||
}
|
||||
|
||||
func TestOfficialExtensionRun_TTY_DispatchError(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
ios.SetStdinTTY(true)
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStderrTTY(true)
|
||||
|
||||
ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"}
|
||||
em := &extensions.ExtensionManagerMock{
|
||||
InstallFunc: func(_ ghrepo.Interface, _ string) error {
|
||||
return nil
|
||||
},
|
||||
DispatchFunc: func(_ []string, _ io.Reader, _, _ io.Writer) (bool, error) {
|
||||
return false, fmt.Errorf("dispatch failed")
|
||||
},
|
||||
}
|
||||
p := &prompter.PrompterMock{
|
||||
ConfirmFunc: func(_ string, _ bool) (bool, error) {
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := officialExtensionRun(ios, p, em, ext, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "dispatch failed")
|
||||
}
|
||||
|
||||
func TestNewCmdOfficialExtension_Properties(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"}
|
||||
em := &extensions.ExtensionManagerMock{}
|
||||
p := &prompter.PrompterMock{}
|
||||
|
||||
cmd := NewCmdOfficialExtension(ios, p, em, ext)
|
||||
|
||||
assert.Equal(t, "stack", cmd.Use)
|
||||
assert.True(t, cmd.Hidden)
|
||||
assert.Equal(t, "extension", cmd.GroupID)
|
||||
assert.True(t, cmd.DisableFlagParsing)
|
||||
assert.Equal(t, "true", cmd.Annotations["skipAuthCheck"])
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ import (
|
|||
versionCmd "github.com/cli/cli/v2/pkg/cmd/version"
|
||||
workflowCmd "github.com/cli/cli/v2/pkg/cmd/workflow"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/extensions"
|
||||
"github.com/google/shlex"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
@ -229,6 +230,17 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command,
|
|||
}
|
||||
}
|
||||
|
||||
// Official extension stubs — hidden commands that suggest installing
|
||||
// GitHub-owned extensions when invoked. Registered after real extensions
|
||||
// and aliases so that both take priority over stubs.
|
||||
for i := range extensions.OfficialExtensions {
|
||||
ext := &extensions.OfficialExtensions[i]
|
||||
if _, _, err := cmd.Find([]string{ext.Name}); err == nil {
|
||||
continue
|
||||
}
|
||||
cmd.AddCommand(NewCmdOfficialExtension(io, f.Prompter, em, ext))
|
||||
}
|
||||
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
// The reference command produces paged output that displays information on every other command.
|
||||
|
|
|
|||
|
|
@ -12,29 +12,15 @@ type OfficialExtension struct {
|
|||
Repo string
|
||||
}
|
||||
|
||||
// Repository returns a ghrepo.Interface pinned to github.com for use with
|
||||
// ExtensionManager.Install.
|
||||
// Repository returns a ghrepo.Interface pinned to github.com so that GHES
|
||||
// users install from github.com rather than their enterprise host.
|
||||
func (e *OfficialExtension) Repository() ghrepo.Interface {
|
||||
return ghrepo.NewWithHost(e.Owner, e.Repo, "github.com")
|
||||
}
|
||||
|
||||
// officialExtensions is the hard-coded registry of GitHub-owned extensions
|
||||
// that gh will suggest installing when the user invokes an unknown command
|
||||
// matching one of their names.
|
||||
// Install suggestions include the "github.com/" host prefix so that GHES users
|
||||
// install from github.com rather than their enterprise host.
|
||||
var officialExtensions = []OfficialExtension{
|
||||
// OfficialExtensions is the registry of GitHub-owned extensions that gh will
|
||||
// offer to install when the user invokes the corresponding command name.
|
||||
var OfficialExtensions = []OfficialExtension{
|
||||
{Name: "aw", Owner: "github", Repo: "gh-aw"},
|
||||
{Name: "stack", Owner: "github", Repo: "gh-stack"},
|
||||
}
|
||||
|
||||
// FindOfficialExtension returns the matching official extension for
|
||||
// commandName, or nil if none matches.
|
||||
func FindOfficialExtension(commandName string) *OfficialExtension {
|
||||
for _, ext := range officialExtensions {
|
||||
if ext.Name == commandName {
|
||||
return &ext
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,34 +4,8 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFindOfficialExtension(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commandName string
|
||||
wantNil bool
|
||||
wantRepo string
|
||||
}{
|
||||
{name: "found", commandName: "stack", wantNil: false, wantRepo: "gh-stack"},
|
||||
{name: "not found", commandName: "xyzzy", wantNil: true},
|
||||
{name: "empty", commandName: "", wantNil: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ext := FindOfficialExtension(tt.commandName)
|
||||
if tt.wantNil {
|
||||
assert.Nil(t, ext)
|
||||
} else {
|
||||
require.NotNil(t, ext)
|
||||
assert.Equal(t, tt.wantRepo, ext.Repo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialExtension_Repository(t *testing.T) {
|
||||
ext := &OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"}
|
||||
repo := ext.Repository()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue