Skip to content
Closed
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
106 changes: 106 additions & 0 deletions packages/cli/src/ui/commands/setupGithubCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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"',
);
});
});
44 changes: 41 additions & 3 deletions packages/cli/src/ui/commands/setupGithubCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down