diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index fae95dc7f2..b5d66d190b 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -9,6 +9,7 @@ import { checkForExtensionUpdate, cloneFromGit, findReleaseAsset, + parseGitHubRepoForReleases, } from './github.js'; import { simpleGit, type SimpleGit } from 'simple-git'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; @@ -231,4 +232,55 @@ describe('git extension helpers', () => { expect(result).toBeUndefined(); }); }); + + describe('parseGitHubRepoForReleases', () => { + it('should parse owner and repo from a full GitHub URL', () => { + const source = 'https://github.com/owner/repo.git'; + const { owner, repo } = parseGitHubRepoForReleases(source); + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); + }); + + it('should parse owner and repo from a full GitHub UR without .git', () => { + const source = 'https://github.com/owner/repo'; + const { owner, repo } = parseGitHubRepoForReleases(source); + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); + }); + + it('should fail on a GitHub SSH URL', () => { + const source = 'git@github.com:owner/repo.git'; + expect(() => parseGitHubRepoForReleases(source)).toThrow( + 'GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.', + ); + }); + + it('should parse owner and repo from a shorthand string', () => { + const source = 'owner/repo'; + const { owner, repo } = parseGitHubRepoForReleases(source); + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); + }); + + it('should handle .git suffix in repo name', () => { + const source = 'owner/repo.git'; + const { owner, repo } = parseGitHubRepoForReleases(source); + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); + }); + + it('should throw error for invalid source format', () => { + const source = 'invalid-format'; + expect(() => parseGitHubRepoForReleases(source)).toThrow( + 'Invalid GitHub repository source: invalid-format. Expected "owner/repo" or a github repo uri.', + ); + }); + + it('should throw error for source with too many parts', () => { + const source = 'https://github.com/owner/repo/extra'; + expect(() => parseGitHubRepoForReleases(source)).toThrow( + 'Invalid GitHub repository source: https://github.com/owner/repo/extra. Expected "owner/repo" or a github repo uri.', + ); + }); + }); }); diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index c1ac7365dd..f77e54edc9 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -74,20 +74,28 @@ export async function cloneFromGit( } } -function parseGitHubRepo(source: string): { owner: string; repo: string } { - // The source should be "owner/repo" or a full GitHub URL. - const parts = source.split('/'); - if (!source.includes('://') && parts.length !== 2) { +export function parseGitHubRepoForReleases(source: string): { + owner: string; + repo: string; +} { + // Default to a github repo path, so `source` can be just an org/repo + const parsedUrl = URL.parse(source, 'https://github.com'); + // The pathname should be "/owner/repo". + const parts = parsedUrl?.pathname.substring(1).split('/'); + if (parts?.length !== 2) { throw new Error( - `Invalid GitHub repository source: ${source}. Expected "owner/repo".`, + `Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`, ); } - const owner = parts.at(-2); - const repo = parts.at(-1)?.replace('.git', ''); + const owner = parts[0]; + const repo = parts[1].replace('.git', ''); - if (!owner || !repo) { - throw new Error(`Invalid GitHub repository source: ${source}`); + if (owner.startsWith('git@github.com')) { + throw new Error( + `GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.`, + ); } + return { owner, repo }; } @@ -155,7 +163,7 @@ export async function checkForExtensionUpdate( if (!source) { return ExtensionUpdateState.ERROR; } - const { owner, repo } = parseGitHubRepo(source); + const { owner, repo } = parseGitHubRepoForReleases(source); const releaseData = await fetchFromGithub( owner, @@ -180,7 +188,7 @@ export async function downloadFromGitHubRelease( destination: string, ): Promise { const { source, ref } = installMetadata; - const { owner, repo } = parseGitHubRepo(source); + const { owner, repo } = parseGitHubRepoForReleases(source); try { const releaseData = await fetchFromGithub(owner, repo, ref);