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:
commit
e2422b7e7e
4 changed files with 155 additions and 7 deletions
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue