diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index 0125ae70bd5..f326fa653fe 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -14,6 +14,9 @@ import { setupGithubCommand, updateGitignore, GITHUB_WORKFLOW_PATHS, + isValidGitHubOwner, + isValidGitHubRepoName, + getOpenUrlsCommands, } from './setupGithubCommand.js'; import type { CommandContext } from './types.js'; import * as commandUtils from '../utils/commandUtils.js'; @@ -339,3 +342,106 @@ describe('updateGitignore', () => { consoleSpy.mockRestore(); }); }); + +describe('isValidGitHubOwner', () => { + it('returns true for valid owner names', () => { + expect(isValidGitHubOwner('google')).toBe(true); + expect(isValidGitHubOwner('google-github')).toBe(true); + expect(isValidGitHubOwner('abc-123')).toBe(true); + expect(isValidGitHubOwner('a')).toBe(true); + expect(isValidGitHubOwner('1')).toBe(true); + expect(isValidGitHubOwner('A-b-C-1-2-3')).toBe(true); + }); + + it('returns false for invalid owner names', () => { + expect(isValidGitHubOwner('-google')).toBe(false); // Starts with hyphen + expect(isValidGitHubOwner('google-')).toBe(false); // Ends with hyphen + expect(isValidGitHubOwner('google--github')).toBe(false); // Multiple hyphens + expect(isValidGitHubOwner('google_github')).toBe(false); // Underscore + expect(isValidGitHubOwner('google.github')).toBe(false); // Dot + expect( + isValidGitHubOwner( + 'VeryLongNameThatIsOverThirtyNineCharactersLongAndShouldFail', + ), + ).toBe(false); // Too long + }); +}); + +describe('isValidGitHubRepoName', () => { + it('returns true for valid repo names', () => { + expect(isValidGitHubRepoName('gemini-cli')).toBe(true); + expect(isValidGitHubRepoName('Gemini_CLI')).toBe(true); + expect(isValidGitHubRepoName('repo.name')).toBe(true); + expect(isValidGitHubRepoName('123')).toBe(true); + expect(isValidGitHubRepoName('a')).toBe(true); + }); + + it('returns false for invalid repo names', () => { + expect(isValidGitHubRepoName('.')).toBe(false); + expect(isValidGitHubRepoName('..')).toBe(false); + expect(isValidGitHubRepoName('repo/name')).toBe(false); + expect(isValidGitHubRepoName('repo\\name')).toBe(false); + expect(isValidGitHubRepoName('repo name')).toBe(false); + }); +}); + +describe('getOpenUrlsCommands', () => { + const readmeUrl = 'https://readme.url'; + + it('returns only readme URL if repo info cannot be determined', () => { + vi.mocked(gitUtils.getGitHubRepoInfo).mockImplementationOnce(() => { + throw new Error('No repo info'); + }); + vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValue('open'); + + const result = getOpenUrlsCommands(readmeUrl); + expect(result).toHaveLength(2); + expect(result[0]).toBe('open "https://readme.url"'); + expect(result[1]).toContain('Could not determine repository info'); + }); + + it('returns only readme URL if in upstream repository', () => { + vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({ + owner: 'google-gemini', + repo: 'gemini-cli', + }); + vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValue('open'); + + const result = getOpenUrlsCommands(readmeUrl); + expect(result).toHaveLength(2); + expect(result[0]).toBe('open "https://readme.url"'); + expect(result[1]).toContain( + 'Secrets page skipped. (You are in the upstream repo', + ); + }); + + it('returns only readme URL if repo info is invalid', () => { + vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({ + owner: '-invalid-', + repo: 'repo', + }); + vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValue('open'); + + const result = getOpenUrlsCommands(readmeUrl); + expect(result).toHaveLength(2); + expect(result[0]).toBe('open "https://readme.url"'); + expect(result[1]).toContain( + 'Secrets page skipped. (You are in the upstream repo or the repo info is invalid)', + ); + }); + + it('returns both readme and secrets URL for a safe, non-upstream repo', () => { + vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({ + owner: 'my-org', + repo: 'my-repo', + }); + vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValue('open'); + + const result = getOpenUrlsCommands(readmeUrl); + expect(result).toHaveLength(2); + expect(result[0]).toBe('open "https://readme.url"'); + expect(result[1]).toBe( + 'open "https://github.com/my-org/my-repo/settings/secrets/actions"', + ); + }); +}); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 83b9531c9d4..4b8812a137d 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -40,19 +40,57 @@ export const GITHUB_COMMANDS_PATHS = [ const REPO_DOWNLOAD_URL = 'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli'; const SOURCE_DIR = 'examples/workflows'; + +// Verifies if github name is command-execution safe +export function isValidGitHubOwner(name: string): boolean { + return /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i.test(name); +} + +// does the same for repo name +export function isValidGitHubRepoName(name: string): boolean { + if (name === '.' || name === '..') return false; + return /^[a-z0-9_.-]+$/i.test(name); +} + // Generate OS-specific commands to open the GitHub pages needed for setup. -function getOpenUrlsCommands(readmeUrl: string): string[] { +export function getOpenUrlsCommands(readmeUrl: string): string[] { // Determine the OS-specific command to open URLs, ex: 'open', 'xdg-open', etc const openCmd = getUrlOpenCommand(); // Build a list of URLs to open const urlsToOpen = [readmeUrl]; - const repoInfo = getGitHubRepoInfo(); - if (repoInfo) { + let repoInfo: { owner: string; repo: string } | null = null; + try { + repoInfo = getGitHubRepoInfo(); + } catch { + /* ignore */ + } + + if (!repoInfo) { + return [ + `${openCmd} "${readmeUrl}"`, + `echo "\nℹ️ Gemini CLI: Could not determine repository info. Secrets page skipped."`, + ]; + } + + const UpstreamRepo = 'google-gemini/gemini-cli'; + const isUpstream = + `${repoInfo.owner}/${repoInfo.repo}`.toLowerCase() === UpstreamRepo; + const isSafeRepo = + isValidGitHubOwner(repoInfo.owner) && isValidGitHubRepoName(repoInfo.repo); + + // Only push the secrets URL if we are NOT in the upstream repo and the name is valid + if (!isUpstream && isSafeRepo) { urlsToOpen.push( `https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`, ); + } else { + // Skip secrets page if in upstream repo or invalid names + return [ + `${openCmd} "${readmeUrl}"`, + `echo "\nℹ️ Gemini CLI: Secrets page skipped. (You are in the upstream repo or the repo info is invalid)."`, + ]; } // Create and join the individual commands