Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions pkg/cmd/extension/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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])
Expand Down
38 changes: 38 additions & 0 deletions pkg/cmd/extension/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
29 changes: 28 additions & 1 deletion pkg/cmd/extension/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
57 changes: 57 additions & 0 deletions pkg/cmd/extension/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down