Merge pull request #9933 from cli/kw/improve-ext-installation-no-executable-error

Improve documentation and error messaging for local extension installations without executables
This commit is contained in:
Kynan Ware 2024-12-12 07:08:16 -07:00 committed by GitHub
commit e2422b7e7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 155 additions and 7 deletions

View file

@ -293,19 +293,35 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
Use: "install <repository>", Use: "install <repository>",
Short: "Install a gh extension from a repository", Short: "Install a gh extension from a repository",
Long: heredoc.Docf(` Long: heredoc.Docf(`
Install a GitHub repository locally as a GitHub CLI extension. Install a GitHub CLI extension from a GitHub or local repository.
The repository argument can be specified in %[1]sOWNER/REPO%[1]s format as well as a full URL. For GitHub repositories, the repository argument can be specified in %[1]sOWNER/REPO%[1]s format or as a full repository URL.
The URL format is useful when the repository is not hosted on github.com. The URL format is useful when the repository is not hosted on github.com.
To install an extension in development from the current directory, use %[1]s.%[1]s as the For local repositories, often used while developing extensions, use %[1]s.%[1]s as the
value of the repository argument. value of the repository argument. Note the following:
- After installing an extension from a locally cloned repository, the GitHub CLI will
manage this extension as a symbolic link (or equivalent mechanism on Windows) pointing
to an executable file with the same name as the repository in the repository's root.
For example, if the repository is named %[1]sgh-foobar%[1]s, the symbolic link will point
to %[1]sgh-foobar%[1]s in the extension repository's root.
- When executing the extension, the GitHub CLI will run the executable file found
by following the symbolic link. If no executable file is found, the extension
will fail to execute.
- If the extension is precompiled, the executable file must be built manually and placed
in the repository's root.
For the list of available extensions, see <https://github.com/topics/gh-extension>. For the list of available extensions, see <https://github.com/topics/gh-extension>.
`, "`"), `, "`"),
Example: heredoc.Doc(` Example: heredoc.Doc(`
# Install an extension from a remote repository hosted on GitHub
$ gh extension install owner/gh-extension $ gh extension install owner/gh-extension
$ gh extension install https://git.example.com/owner/gh-extension
# Install an extension from a remote repository via full URL
$ gh extension install https://my.ghes.com/owner/gh-extension
# Install an extension from a local repository in the current working directory
$ gh extension install . $ gh extension install .
`), `),
Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"), Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"),
@ -322,7 +338,17 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
return m.InstallLocal(wd)
err = m.InstallLocal(wd)
var ErrExtensionExecutableNotFound *ErrExtensionExecutableNotFound
if errors.As(err, &ErrExtensionExecutableNotFound) {
cs := io.ColorScheme()
if io.IsStdoutTTY() {
fmt.Fprintf(io.ErrOut, "%s %s", cs.WarningIcon(), ErrExtensionExecutableNotFound.Error())
}
return nil
}
return err
} }
repo, err := ghrepo.FromFullName(args[0]) repo, err := ghrepo.FromFullName(args[0])

View file

@ -286,6 +286,44 @@ func TestNewCmdExtension(t *testing.T) {
} }
}, },
}, },
{
name: "installing local extension without executable with TTY shows warning",
args: []string{"install", "."},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.InstallLocalFunc = func(dir string) error {
return &ErrExtensionExecutableNotFound{
Dir: tempDir,
Name: "gh-test",
}
}
em.ListFunc = func() []extensions.Extension {
return []extensions.Extension{}
}
return nil
},
wantStderr: fmt.Sprintf("! an extension has been installed but there is no executable: executable file named \"%s\" in %s is required to run the extension after install. Perhaps you need to build it?\n", "gh-test", tempDir),
wantErr: false,
isTTY: true,
},
{
name: "install local extension without executable with no TTY shows no warning",
args: []string{"install", "."},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.InstallLocalFunc = func(dir string) error {
return &ErrExtensionExecutableNotFound{
Dir: tempDir,
Name: "gh-test",
}
}
em.ListFunc = func() []extensions.Extension {
return []extensions.Extension{}
}
return nil
},
wantStderr: "",
wantErr: false,
isTTY: false,
},
{ {
name: "error extension not found", name: "error extension not found",
args: []string{"install", "owner/gh-some-ext"}, args: []string{"install", "owner/gh-some-ext"},

View file

@ -29,6 +29,15 @@ import (
// ErrInitialCommitFailed indicates the initial commit when making a new extension failed. // ErrInitialCommitFailed indicates the initial commit when making a new extension failed.
var ErrInitialCommitFailed = errors.New("initial commit failed") var ErrInitialCommitFailed = errors.New("initial commit failed")
type ErrExtensionExecutableNotFound struct {
Dir string
Name string
}
func (e *ErrExtensionExecutableNotFound) Error() string {
return fmt.Sprintf("an extension has been installed but there is no executable: executable file named \"%s\" in %s is required to run the extension after install. Perhaps you need to build it?\n", e.Name, e.Dir)
}
const darwinAmd64 = "darwin-amd64" const darwinAmd64 = "darwin-amd64"
type Manager struct { type Manager struct {
@ -194,10 +203,28 @@ func (m *Manager) populateLatestVersions(exts []*Extension) {
func (m *Manager) InstallLocal(dir string) error { func (m *Manager) InstallLocal(dir string) error {
name := filepath.Base(dir) name := filepath.Base(dir)
targetLink := filepath.Join(m.installDir(), name) targetLink := filepath.Join(m.installDir(), name)
if err := os.MkdirAll(filepath.Dir(targetLink), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(targetLink), 0755); err != nil {
return err return err
} }
return makeSymlink(dir, targetLink) if err := makeSymlink(dir, targetLink); err != nil {
return err
}
// Check if an executable of the same name exists in the target directory.
// An error here doesn't indicate a failed extension installation, but
// it does indicate that the user will not be able to run the extension until
// the executable file is built or created manually somehow.
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
if os.IsNotExist(err) {
return &ErrExtensionExecutableNotFound{
Dir: dir,
Name: name,
}
}
return err
}
return nil
} }
type binManifest struct { type binManifest struct {

View file

@ -719,6 +719,63 @@ func TestManager_UpgradeExtension_GitExtension_Pinned(t *testing.T) {
gcOne.AssertExpectations(t) gcOne.AssertExpectations(t)
} }
func TestManager_Install_local(t *testing.T) {
extManagerDir := t.TempDir()
ios, _, stdout, stderr := iostreams.Test()
m := newTestManager(extManagerDir, nil, nil, ios)
fakeExtensionName := "local-ext"
// Create a temporary directory to simulate the local extension repo
extensionLocalPath := filepath.Join(extManagerDir, fakeExtensionName)
require.NoError(t, os.MkdirAll(extensionLocalPath, 0755))
// Create a fake executable in the local extension directory
fakeExtensionExecutablePath := filepath.Join(extensionLocalPath, fakeExtensionName)
require.NoError(t, stubExtension(fakeExtensionExecutablePath))
err := m.InstallLocal(extensionLocalPath)
require.NoError(t, err)
// This is the path to a file:
// on windows this is a file whose contents is a string describing the path to the local extension dir.
// on other platforms this file is a real symlink to the local extension dir.
extensionLinkFile := filepath.Join(extManagerDir, "extensions", fakeExtensionName)
if runtime.GOOS == "windows" {
// We don't create true symlinks on Windows, so check if we made a
// file with the correct contents to produce the symlink-like behavior
b, err := os.ReadFile(extensionLinkFile)
require.NoError(t, err)
assert.Equal(t, extensionLocalPath, string(b))
} else {
// Verify the created symlink points to the correct directory
linkTarget, err := os.Readlink(extensionLinkFile)
require.NoError(t, err)
assert.Equal(t, extensionLocalPath, linkTarget)
}
assert.Equal(t, "", stdout.String())
assert.Equal(t, "", stderr.String())
}
func TestManager_Install_local_no_executable_found(t *testing.T) {
tempDir := t.TempDir()
ios, _, stdout, stderr := iostreams.Test()
m := newTestManager(tempDir, nil, nil, ios)
fakeExtensionName := "local-ext"
// Create a temporary directory to simulate the local extension repo
localDir := filepath.Join(tempDir, fakeExtensionName)
require.NoError(t, os.MkdirAll(localDir, 0755))
// Intentionally not creating an executable in the local extension repo
// to simulate an attempt to install a local extension without an executable
err := m.InstallLocal(localDir)
require.ErrorAs(t, err, new(*ErrExtensionExecutableNotFound))
assert.Equal(t, "", stdout.String())
assert.Equal(t, "", stderr.String())
}
func TestManager_Install_git(t *testing.T) { func TestManager_Install_git(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()