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>",
Short: "Install a gh extension from a repository",
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.
To install an extension in development from the current directory, use %[1]s.%[1]s as the
value of the repository argument.
For local repositories, often used while developing extensions, use %[1]s.%[1]s as the
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>.
`, "`"),
Example: heredoc.Doc(`
# Install an extension from a remote repository hosted on GitHub
$ 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 .
`),
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 {
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])

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",
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.
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"
type Manager struct {
@ -194,10 +203,28 @@ func (m *Manager) populateLatestVersions(exts []*Extension) {
func (m *Manager) InstallLocal(dir string) error {
name := filepath.Base(dir)
targetLink := filepath.Join(m.installDir(), name)
if err := os.MkdirAll(filepath.Dir(targetLink), 0755); err != nil {
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 {

View file

@ -719,6 +719,63 @@ func TestManager_UpgradeExtension_GitExtension_Pinned(t *testing.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) {
tempDir := t.TempDir()