From b575fe8ebc195b39300342e4e31d98c3003be63a Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Sun, 17 Nov 2024 16:44:08 -0700 Subject: [PATCH 01/15] Warn when installing local ext with no executable --- pkg/cmd/extension/manager.go | 17 +++++++++- pkg/cmd/extension/manager_test.go | 56 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index 2431c6b83e5..5b840649078 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -28,6 +28,7 @@ import ( // ErrInitialCommitFailed indicates the initial commit when making a new extension failed. var ErrInitialCommitFailed = errors.New("initial commit failed") +var ErrExtensionExecutableNotFound = errors.New("an extension has been installed but there is no executable") const darwinAmd64 = "darwin-amd64" @@ -194,10 +195,24 @@ func (m *Manager) populateLatestVersions(exts []*Extension) { func (m *Manager) InstallLocal(dir string) error { name := filepath.Base(dir) targetLink := filepath.Join(m.installDir(), name) + cs := m.io.ColorScheme() + 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 { + errMsg := fmt.Errorf("%v %w: expected executable file named \"%s\" in %s, perhaps you need to build it?", cs.WarningIcon(), ErrExtensionExecutableNotFound, name, dir) + return errMsg + } + return nil } type binManifest struct { diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index e25bc1496ec..2ccb619f88d 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -719,6 +719,62 @@ 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 symlinks on Windows, so check if we made a file + // with the correct contents + 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, _ := 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.ErrorIs(t, err, ErrExtensionExecutableNotFound) + assert.Equal(t, "", stdout.String()) +} + func TestManager_Install_git(t *testing.T) { tempDir := t.TempDir() From c5497b4d28f98ea9d626ef61190dbdfe8e67b89d Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Sun, 17 Nov 2024 17:15:15 -0700 Subject: [PATCH 02/15] Document requirements for local extensions --- pkg/cmd/extension/command.go | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 30a7cb50824..7ab40b9f1ae 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -293,19 +293,35 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { Use: "install ", 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 repository. - The repository argument can be specified in %[1]sOWNER/REPO%[1]s format as well as a full URL. + 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. + To install an in-development extension from a locally cloned repository, 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 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 . `, "`"), Example: heredoc.Doc(` + # Install an extension from a remote repository hosted on GitHub.com $ gh extension install owner/gh-extension + + # Install an extension from a remote repository on a different GitHub host $ gh extension install https://git.example.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"), From 2ec27ff9fe4096f0c1beaa008820383573c4bd66 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:21:14 -0700 Subject: [PATCH 03/15] Update test comments about Windows behavior Co-authored-by: Tyler McGoffin --- pkg/cmd/extension/manager_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index 2ccb619f88d..fd1d93f429e 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -742,8 +742,8 @@ func TestManager_Install_local(t *testing.T) { extensionLinkFile := filepath.Join(extManagerDir, "extensions", fakeExtensionName) if runtime.GOOS == "windows" { - // We don't create symlinks on Windows, so check if we made a file - // with the correct contents + // 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)) From bb3b64ad0630ef81865665199933f1ce95a276c1 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:25:07 -0700 Subject: [PATCH 04/15] Update language for missing extension executable Co-authored-by: Tyler McGoffin --- pkg/cmd/extension/manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index 5b840649078..cc5d5ca643a 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -209,7 +209,7 @@ func (m *Manager) InstallLocal(dir string) error { // 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 { - errMsg := fmt.Errorf("%v %w: expected executable file named \"%s\" in %s, perhaps you need to build it?", cs.WarningIcon(), ErrExtensionExecutableNotFound, name, dir) + errMsg := fmt.Errorf("%v %w: executable file named \"%s\" in %s is required for the extension to run after install. Perhaps you need to build it?", cs.WarningIcon(), ErrExtensionExecutableNotFound, name, dir) return errMsg } return nil From 1bc2bb8059c4bc3fbc27268e1745c5f94ac42c7b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:45:57 -0700 Subject: [PATCH 05/15] Improve docs on installing extensions Better clarify the two extension types in the `extension install` docs. Co-authored-by: Tyler McGoffin --- pkg/cmd/extension/command.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 7ab40b9f1ae..0030b507820 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -293,12 +293,12 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { Use: "install ", Short: "Install a gh extension from a repository", Long: heredoc.Docf(` - Install a GitHub CLI extension from a GitHub repository. + Install a GitHub CLI extension from a GitHub or local repository. - The repository argument can be specified in %[1]sOWNER/REPO%[1]s format or as a full repository 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 in-development extension from a locally cloned repository, 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. Note the following: - After installing an extension from a locally cloned repository, the GitHub CLI will From 4b73e55fd23e4dfbe2b49d10e31e7ad94fcf39f7 Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Tue, 19 Nov 2024 16:25:13 -0700 Subject: [PATCH 06/15] Change: exit zero, still print warning to stderr Instead of returning `ErrExtensionExecutableNotFound` error which causes `gh` to have a non-zero exit code, catch it and print the message to stderr, returning nil. Only print the warning to stderr when there is a TTY. --- pkg/cmd/extension/command.go | 10 +++++++++- pkg/cmd/extension/command_test.go | 32 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 0030b507820..7f2ec0ac118 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -338,7 +338,15 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { if err != nil { return err } - return m.InstallLocal(wd) + + err = m.InstallLocal(wd) + if errors.Is(err, ErrExtensionExecutableNotFound) { + if io.IsStdoutTTY() { + fmt.Fprintln(io.ErrOut, err.Error()) + } + return nil + } + return err } repo, err := ghrepo.FromFullName(args[0]) diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index e26e70690fc..dccd487ea96 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -286,6 +286,38 @@ func TestNewCmdExtension(t *testing.T) { } }, }, + { + name: "install local extension without executable TTY", + args: []string{"install", "."}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.InstallLocalFunc = func(dir string) error { + return ErrExtensionExecutableNotFound + } + em.ListFunc = func() []extensions.Extension { + return []extensions.Extension{} + } + return nil + }, + wantStderr: fmt.Sprintln(ErrExtensionExecutableNotFound.Error()), + wantErr: false, + isTTY: true, + }, + { + name: "install local extension without executable no TTY", + args: []string{"install", "."}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.InstallLocalFunc = func(dir string) error { + return ErrExtensionExecutableNotFound + } + 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"}, From 83c5bf35899dfa6419b67ca7256d1fa6cbe5ed46 Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Tue, 19 Nov 2024 16:25:22 -0700 Subject: [PATCH 07/15] Update error message wording --- pkg/cmd/extension/manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index cc5d5ca643a..f8d9f189746 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -209,7 +209,7 @@ func (m *Manager) InstallLocal(dir string) error { // 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 { - errMsg := fmt.Errorf("%v %w: executable file named \"%s\" in %s is required for the extension to run after install. Perhaps you need to build it?", cs.WarningIcon(), ErrExtensionExecutableNotFound, name, dir) + errMsg := fmt.Errorf("%v %w: executable file named \"%s\" in %s is required to run the extension after install. Perhaps you need to build it?", cs.WarningIcon(), ErrExtensionExecutableNotFound, name, dir) return errMsg } return nil From e5eedefd093afa784d5f39323be31916738d02a3 Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Tue, 19 Nov 2024 16:28:44 -0700 Subject: [PATCH 08/15] Assert stderr is empty in manager_test.go --- pkg/cmd/extension/manager_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index fd1d93f429e..912a55f08cf 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -759,7 +759,7 @@ func TestManager_Install_local(t *testing.T) { func TestManager_Install_local_no_executable_found(t *testing.T) { tempDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() + ios, _, stdout, stderr := iostreams.Test() m := newTestManager(tempDir, nil, nil, ios) fakeExtensionName := "local-ext" @@ -773,6 +773,7 @@ func TestManager_Install_local_no_executable_found(t *testing.T) { err := m.InstallLocal(localDir) require.ErrorIs(t, err, ErrExtensionExecutableNotFound) assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) } func TestManager_Install_git(t *testing.T) { From b5f3463b7530f4e1286a5bb3b5e0c4c4eb22309b Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Wed, 20 Nov 2024 16:03:03 -0700 Subject: [PATCH 09/15] Improve error handling for missing executable Check for executable file existence using os.IsNotExist for clearer error handling --- pkg/cmd/extension/manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index f8d9f189746..0dd24f5c8b9 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -208,7 +208,7 @@ func (m *Manager) InstallLocal(dir string) error { // 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 _, err := os.Stat(filepath.Join(dir, name)); os.IsNotExist(err) { errMsg := fmt.Errorf("%v %w: executable file named \"%s\" in %s is required to run the extension after install. Perhaps you need to build it?", cs.WarningIcon(), ErrExtensionExecutableNotFound, name, dir) return errMsg } From b2ab7b84f84887e2757fe28be6ca616e2582f853 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Sun, 8 Dec 2024 15:55:09 -0700 Subject: [PATCH 10/15] Refactor extension executable error handling --- pkg/cmd/extension/command.go | 6 ++++-- pkg/cmd/extension/command_test.go | 25 ++++++++++++++++++------- pkg/cmd/extension/manager.go | 17 +++++++++++++---- pkg/cmd/extension/manager_test.go | 2 +- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 7f2ec0ac118..874eb818b4b 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -340,9 +340,11 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { } err = m.InstallLocal(wd) - if errors.Is(err, ErrExtensionExecutableNotFound) { + var ErrExtensionExecutableNotFound *ErrExtensionExecutableNotFound + if errors.As(err, &ErrExtensionExecutableNotFound) { + cs := io.ColorScheme() if io.IsStdoutTTY() { - fmt.Fprintln(io.ErrOut, err.Error()) + fmt.Fprintf(io.ErrOut, "%s %s", cs.WarningIcon(), ErrExtensionExecutableNotFound.Error()) } return nil } diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index dccd487ea96..696f247aefa 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -287,27 +287,38 @@ func TestNewCmdExtension(t *testing.T) { }, }, { - name: "install local extension without executable TTY", + 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 + return &ErrExtensionExecutableNotFound{ + Dir: tempDir, + Name: "gh-test", + } } em.ListFunc = func() []extensions.Extension { return []extensions.Extension{} } return nil }, - wantStderr: fmt.Sprintln(ErrExtensionExecutableNotFound.Error()), - wantErr: false, - isTTY: true, + wantStderr: fmt.Sprintf( + "! %s", + (&ErrExtensionExecutableNotFound{ + Dir: tempDir, + Name: "gh-test", + }).Error()), + wantErr: false, + isTTY: true, }, { - name: "install local extension without executable no TTY", + 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 + return &ErrExtensionExecutableNotFound{ + Dir: tempDir, + Name: "gh-test", + } } em.ListFunc = func() []extensions.Extension { return []extensions.Extension{} diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index 0dd24f5c8b9..db4bac84ee8 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -28,7 +28,15 @@ import ( // ErrInitialCommitFailed indicates the initial commit when making a new extension failed. var ErrInitialCommitFailed = errors.New("initial commit failed") -var ErrExtensionExecutableNotFound = errors.New("an extension has been installed but there is no executable") + +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" @@ -195,7 +203,6 @@ func (m *Manager) populateLatestVersions(exts []*Extension) { func (m *Manager) InstallLocal(dir string) error { name := filepath.Base(dir) targetLink := filepath.Join(m.installDir(), name) - cs := m.io.ColorScheme() if err := os.MkdirAll(filepath.Dir(targetLink), 0755); err != nil { return err @@ -209,8 +216,10 @@ func (m *Manager) InstallLocal(dir string) error { // 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)); os.IsNotExist(err) { - errMsg := fmt.Errorf("%v %w: executable file named \"%s\" in %s is required to run the extension after install. Perhaps you need to build it?", cs.WarningIcon(), ErrExtensionExecutableNotFound, name, dir) - return errMsg + return &ErrExtensionExecutableNotFound{ + Dir: dir, + Name: name, + } } return nil } diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index 912a55f08cf..025d6e12598 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -771,7 +771,7 @@ func TestManager_Install_local_no_executable_found(t *testing.T) { // to simulate an attempt to install a local extension without an executable err := m.InstallLocal(localDir) - require.ErrorIs(t, err, ErrExtensionExecutableNotFound) + require.ErrorAs(t, err, new(*ErrExtensionExecutableNotFound)) assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) } From b9b60637b94c85f5d9ddb7e6508e75c5082be38d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 12 Dec 2024 06:35:13 -0700 Subject: [PATCH 11/15] Clarify hosts in ext install help text --- pkg/cmd/extension/command.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 874eb818b4b..b9f32276818 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -315,11 +315,11 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { For the list of available extensions, see . `, "`"), Example: heredoc.Doc(` - # Install an extension from a remote repository hosted on GitHub.com + # Install an extension from a remote repository hosted on GitHub $ gh extension install owner/gh-extension - # Install an extension from a remote repository on a different GitHub host - $ 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 . From ec4b2dfe1b6770845874bd78433c843083e0e2b3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 12 Dec 2024 06:38:04 -0700 Subject: [PATCH 12/15] Assert on err msg directly in ext install tests --- pkg/cmd/extension/command_test.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index 696f247aefa..76741714aad 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -301,14 +301,9 @@ func TestNewCmdExtension(t *testing.T) { } return nil }, - wantStderr: fmt.Sprintf( - "! %s", - (&ErrExtensionExecutableNotFound{ - Dir: tempDir, - Name: "gh-test", - }).Error()), - wantErr: false, - isTTY: true, + 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", From 3b93e28910a8902d348a24842e8f07f6738b88ba Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 12 Dec 2024 06:40:06 -0700 Subject: [PATCH 13/15] Fix error mishandling in local ext install --- pkg/cmd/extension/manager.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index db4bac84ee8..269d58ae055 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -215,11 +215,14 @@ func (m *Manager) InstallLocal(dir string) error { // 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)); os.IsNotExist(err) { - return &ErrExtensionExecutableNotFound{ - Dir: dir, - Name: name, + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + if os.IsNotExist(err) { + return &ErrExtensionExecutableNotFound{ + Dir: dir, + Name: name, + } } + return err } return nil } From 32abca8c381c34a349d7d829d2fcfda2a4da91e6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 12 Dec 2024 06:42:34 -0700 Subject: [PATCH 14/15] Mention Windows quirk in ext install help text --- pkg/cmd/extension/command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index b9f32276818..3e0bcfdb69a 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -302,7 +302,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { 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 pointing to an executable file with the same + 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. From 46862f96d96680fe011eee9e8dcf1ea4e93dba12 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 12 Dec 2024 14:57:34 +0100 Subject: [PATCH 15/15] Reformat ext install long --- pkg/cmd/extension/command.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 3e0bcfdb69a..410e96f5336 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -302,10 +302,10 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { 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. + 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.