diff --git a/src/commands/install-github-app/install-github-app.tsx b/src/commands/install-github-app/install-github-app.tsx index c57be920c..7d9a46af3 100644 --- a/src/commands/install-github-app/install-github-app.tsx +++ b/src/commands/install-github-app/install-github-app.tsx @@ -11,6 +11,7 @@ import { type KeyboardEvent, Box } from '@anthropic/ink' import type { LocalJSXCommandOnDone } from '../../types/command.js' import { getAnthropicApiKey, isAnthropicAuthEnabled } from '../../utils/auth.js' import { openBrowser } from '../../utils/browser.js' +import { OFFICIAL_GITHUB_ORG } from '../../utils/plugins/schemas.js' import { execFileNoThrow } from '../../utils/execFileNoThrow.js' import { getGithubRepo } from '../../utils/git.js' import { plural } from '../../utils/stringUtils.js' @@ -28,6 +29,61 @@ import { setupGitHubActions } from './setupGitHubActions.js' import type { State, Warning, Workflow } from './types.js' import { WarningsStep } from './WarningsStep.js' +type ParsedGitHubRepoUrl = + | { ok: true; repoName: string } + | { ok: false } + +function parsedGitHubRepoFromParts( + owner: string | undefined, + repo: string | undefined, +): ParsedGitHubRepoUrl { + const normalizedRepo = repo?.replace(/\.git$/i, '') + if (!owner || !normalizedRepo) { + return { ok: false } + } + + return { ok: true, repoName: `${owner}/${normalizedRepo}` } +} + +function parseGitHubRepoUrl(repoName: string): ParsedGitHubRepoUrl | null { + let url: URL + + try { + url = new URL(repoName) + } catch { + return null + } + + const hostname = url.hostname.toLowerCase() + if (!hostname) { + const sshMatch = repoName.match(/^git@([^:]+):([^/]+)\/([^/]+)$/i) + if (!sshMatch) { + return null + } + + if (sshMatch[1]?.toLowerCase() !== 'github.com') { + return { ok: false } + } + + return parsedGitHubRepoFromParts(sshMatch[2], sshMatch[3]) + } + + if (hostname !== 'github.com') { + return { ok: false } + } + + if (url.search || url.hash) { + return { ok: false } + } + + const pathParts = url.pathname.split('/').filter(Boolean) + if (pathParts.length !== 2) { + return { ok: false } + } + + return parsedGitHubRepoFromParts(pathParts[0], pathParts[1]) +} + const INITIAL_STATE: State = { step: 'check-gh', selectedRepoName: '', @@ -363,19 +419,19 @@ function InstallGitHubApp(props: { const repoWarnings: Warning[] = [] - if (repoName.includes('github.com')) { - const match = repoName.match(/github\.com[:/]([^/]+\/[^/]+)(\.git)?$/) - if (!match) { + const parsedGitHubRepoUrl = parseGitHubRepoUrl(repoName) + if (parsedGitHubRepoUrl) { + if (!parsedGitHubRepoUrl.ok) { repoWarnings.push({ title: 'Invalid GitHub URL format', message: 'The repository URL format appears to be invalid.', instructions: [ 'Use format: owner/repo or https://github.com/owner/repo', - 'Example: anthropics/claude-cli', + `Example: ${OFFICIAL_GITHUB_ORG}/claude-cli`, ], }) } else { - repoName = match[1]?.replace(/\.git$/, '') || '' + repoName = parsedGitHubRepoUrl.repoName } } @@ -385,7 +441,7 @@ function InstallGitHubApp(props: { message: 'Repository should be in format "owner/repo"', instructions: [ 'Use format: owner/repo', - 'Example: anthropics/claude-cli', + `Example: ${OFFICIAL_GITHUB_ORG}/claude-cli`, ], }) } diff --git a/src/utils/plugins/schemas.ts b/src/utils/plugins/schemas.ts index b92fb80e9..e04037bd8 100644 --- a/src/utils/plugins/schemas.ts +++ b/src/utils/plugins/schemas.ts @@ -106,6 +106,32 @@ export function isBlockedOfficialName(name: string): boolean { */ export const OFFICIAL_GITHUB_ORG = 'anthropics' +function isOfficialGitHubOrgUrl(urlString: string): boolean { + try { + const url = new URL(urlString) + const hostname = url.hostname.toLowerCase() + const pathname = url.pathname.toLowerCase() + + if (hostname !== 'github.com') { + return false + } + + return pathname.startsWith(`/${OFFICIAL_GITHUB_ORG}/`) + } catch { + const sshMatch = urlString.match(/^git@([^:]+):(.+)$/i) + if (!sshMatch) { + return false + } + + const hostname = sshMatch[1]?.toLowerCase() + const pathname = sshMatch[2]?.toLowerCase() + return ( + hostname === 'github.com' && + pathname?.startsWith(`${OFFICIAL_GITHUB_ORG}/`) === true + ) + } +} + /** * Validate that a marketplace with a reserved name comes from the official source. * @@ -139,13 +165,7 @@ export function validateOfficialNameSource( // Check for git URL source type if (source.source === 'git' && source.url) { - const url = source.url.toLowerCase() - // Check for HTTPS URL format: https://github.com/anthropics/... - // or SSH format: git@github.com:anthropics/... - const isHttpsAnthropics = url.includes('github.com/anthropics/') - const isSshAnthropics = url.includes('git@github.com:anthropics/') - - if (isHttpsAnthropics || isSshAnthropics) { + if (isOfficialGitHubOrgUrl(source.url)) { return null // Valid: reserved name from official git URL }