feat(agents): implement team registry client and installation service

- Add RegistryTeam interface and TeamRegistryClient for remote/local discovery.
- Enhance TeamScaffolder with installRegistryTeam supporting Git and metadata-only teams.
- Refactor GitHub/Git utilities from cli to core for shared usage.
- Add comprehensive unit tests for new services.

TASK-09, TASK-10
This commit is contained in:
Taylor Mullen
2026-04-06 16:20:00 -07:00
parent 59153cc1c5
commit 19b9e3e8e4
24 changed files with 3195 additions and 1206 deletions
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import * as fs from 'node:fs/promises';
import { TeamRegistryClient } from './teamRegistryClient.js';
import { type RegistryTeam } from './types.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { resolveToRealPath } from '../utils/paths.js';
vi.mock('../utils/fetch.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/fetch.js')>();
return {
...actual,
fetchWithTimeout: vi.fn(),
isPrivateIp: vi.fn(),
};
});
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
}));
const mockTeams: RegistryTeam[] = [
{
id: 'team1',
name: 'frontend-experts',
displayName: 'Frontend Experts',
description: 'A team of UI/UX and React specialists.',
instructions: 'Focus on clean code and accessibility.',
agents: [
{ name: 'ui-agent', provider: 'gemini', description: 'UI specialist' },
{ name: 'ux-agent', provider: 'gemini', description: 'UX specialist' },
],
author: 'Google',
stars: 150,
lastUpdated: '2025-01-01T00:00:00Z',
version: '1.0.0',
},
{
id: 'team2',
name: 'backend-pros',
displayName: 'Backend Pros',
description: 'Experts in Node.js, databases, and APIs.',
instructions: 'Prioritize performance and security.',
agents: [
{ name: 'api-agent', provider: 'gemini', description: 'API specialist' },
{ name: 'db-agent', provider: 'gemini', description: 'DB specialist' },
],
author: 'Community',
stars: 80,
lastUpdated: '2025-01-02T00:00:00Z',
version: '0.9.0',
},
];
describe('TeamRegistryClient', () => {
let client: TeamRegistryClient;
let fetchMock: Mock;
let isPrivateIpMock: Mock;
beforeEach(() => {
TeamRegistryClient.resetCache();
client = new TeamRegistryClient();
fetchMock = fetchWithTimeout as Mock;
isPrivateIpMock = isPrivateIp as Mock;
fetchMock.mockReset();
isPrivateIpMock.mockReset();
isPrivateIpMock.mockReturnValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should fetch and return all teams from remote registry', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockTeams,
});
const result = await client.fetchAllTeams();
expect(result).toHaveLength(2);
expect(result[0].id).toBe('team1');
expect(result[1].id).toBe('team2');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
'https://geminicli.com/teams.json',
10000,
);
});
it('should search teams by name or description', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockTeams,
});
const results = await client.searchTeams('frontend');
expect(results.length).toBeGreaterThanOrEqual(1);
expect(results[0].id).toBe('team1');
});
it('should search teams by agent name', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockTeams,
});
const results = await client.searchTeams('api-agent');
expect(results.length).toBeGreaterThanOrEqual(1);
expect(results[0].id).toBe('team2');
});
it('should get a team by ID', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockTeams,
});
const result = await client.getTeam('team2');
expect(result).toBeDefined();
expect(result?.id).toBe('team2');
});
it('should return undefined if team not found', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockTeams,
});
const result = await client.getTeam('non-existent');
expect(result).toBeUndefined();
});
it('should cache the fetch result', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockTeams,
});
await client.fetchAllTeams();
await client.fetchAllTeams();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('should throw an error if fetch fails', async () => {
fetchMock.mockResolvedValue({
ok: false,
statusText: 'Internal Server Error',
});
await expect(client.fetchAllTeams()).rejects.toThrow(
'Failed to fetch teams: Internal Server Error',
);
});
it('should block private IP addresses for remote registry', async () => {
isPrivateIpMock.mockReturnValue(true);
await expect(client.fetchAllTeams()).rejects.toThrow(
'Private IP addresses are not allowed for the team registry.',
);
});
it('should fetch teams from a local file path', async () => {
const filePath = '/path/to/teams.json';
const clientWithFile = new TeamRegistryClient(filePath);
const mockReadFile = vi.mocked(fs.readFile);
mockReadFile.mockResolvedValue(JSON.stringify(mockTeams));
const result = await clientWithFile.fetchAllTeams();
expect(result).toHaveLength(2);
expect(mockReadFile).toHaveBeenCalledWith(
resolveToRealPath(filePath),
'utf-8',
);
});
});
@@ -0,0 +1,107 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import { AsyncFzf } from 'fzf';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { resolveToRealPath } from '../utils/paths.js';
import { type RegistryTeam } from './types.js';
/**
* Client for fetching and searching agent teams from a remote or local registry.
*/
export class TeamRegistryClient {
static readonly DEFAULT_REGISTRY_URL = 'https://geminicli.com/teams.json';
private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds
private static fetchPromise: Promise<RegistryTeam[]> | null = null;
private readonly registryURI: string;
constructor(registryURI?: string) {
this.registryURI = registryURI || TeamRegistryClient.DEFAULT_REGISTRY_URL;
}
/**
* Resets the internal fetch cache.
* @internal
*/
static resetCache() {
TeamRegistryClient.fetchPromise = null;
}
/**
* Fetches all teams from the configured registry URI.
* Supports both remote (https://) and local file paths.
*/
async fetchAllTeams(): Promise<RegistryTeam[]> {
if (TeamRegistryClient.fetchPromise) {
return TeamRegistryClient.fetchPromise;
}
const uri = this.registryURI;
TeamRegistryClient.fetchPromise = (async () => {
try {
if (uri.startsWith('http')) {
if (isPrivateIp(uri)) {
throw new Error(
'Private IP addresses are not allowed for the team registry.',
);
}
const response = await fetchWithTimeout(
uri,
TeamRegistryClient.FETCH_TIMEOUT_MS,
);
if (!response.ok) {
throw new Error(`Failed to fetch teams: ${response.statusText}`);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as RegistryTeam[];
} else {
// Handle local file path
const filePath = resolveToRealPath(uri);
const content = await fs.readFile(filePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(content) as RegistryTeam[];
}
} catch (error) {
TeamRegistryClient.fetchPromise = null;
throw error;
}
})();
return TeamRegistryClient.fetchPromise;
}
/**
* Searches for teams matching the given query using fuzzy matching.
* @param query The search query string.
*/
async searchTeams(query: string): Promise<RegistryTeam[]> {
const allTeams = await this.fetchAllTeams();
if (!query.trim()) {
return allTeams;
}
const fzf = new AsyncFzf(allTeams, {
selector: (team: RegistryTeam) =>
`${team.name} ${team.displayName} ${team.description} ${team.agents.map((a) => a.name).join(' ')}`,
fuzzy: true,
});
const results = await fzf.find(query);
return results.map((r: { item: RegistryTeam }) => r.item);
}
/**
* Fetches a single team by its ID.
* @param id The unique identifier of the team.
*/
async getTeam(id: string): Promise<RegistryTeam | undefined> {
const allTeams = await this.fetchAllTeams();
return allTeams.find((team) => team.id === id);
}
}
+112 -4
View File
@@ -4,11 +4,28 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { scaffoldTeam, type ScaffoldTeamOptions } from './teamScaffolder.js';
import {
scaffoldTeam,
installRegistryTeam,
type ScaffoldTeamOptions,
} from './teamScaffolder.js';
import { type RegistryTeam } from './types.js';
vi.mock('../utils/github.js', () => ({
cloneFromGit: vi.fn(),
downloadFromGitHubRelease: vi.fn(),
tryParseGithubUrl: vi.fn(),
}));
import {
cloneFromGit,
downloadFromGitHubRelease,
tryParseGithubUrl,
} from '../utils/github.js';
describe('teamScaffolder', () => {
let tempDir: string;
@@ -17,6 +34,7 @@ describe('teamScaffolder', () => {
tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'gemini-team-scaffolder-test-'),
);
vi.clearAllMocks();
});
afterEach(async () => {
@@ -49,8 +67,12 @@ describe('teamScaffolder', () => {
expect(content).toContain('name: test-team');
expect(content).toContain('display_name: The Test Team');
expect(content).toContain('description: A team for testing.');
expect(content).toContain('provider: claude-code');
expect(content).toContain('Do some testing.');
const agentPath = path.join(teamPath, 'agents', 'claude-coder.md');
const agentContent = await fs.readFile(agentPath, 'utf-8');
expect(agentContent).toContain('name: claude-coder');
expect(agentContent).toContain('provider: claude-code');
});
it('should scaffold a team with standalone local agents', async () => {
@@ -83,7 +105,7 @@ describe('teamScaffolder', () => {
const teamPath = await scaffoldTeam(options);
const agentsDir = path.join(teamPath, 'agents');
const copiedAgentPath = path.join(agentsDir, 'dummy-agent.md');
const copiedAgentPath = path.join(agentsDir, 'dummy.md');
expect(
await fs
@@ -94,4 +116,90 @@ describe('teamScaffolder', () => {
const copiedContent = await fs.readFile(copiedAgentPath, 'utf-8');
expect(copiedContent).toContain('name: dummy');
});
describe('installRegistryTeam', () => {
it('should install a definition-only team from registry', async () => {
const team: RegistryTeam = {
id: 'team-1',
name: 'registry-team',
displayName: 'Registry Team',
description: 'A team from the registry.',
instructions: 'Follow these instructions.',
agents: [
{
name: 'agent-1',
provider: 'gemini',
description: 'First agent.',
},
],
};
const teamPath = await installRegistryTeam(team, tempDir);
expect(teamPath).toBe(path.join(tempDir, 'registry-team'));
const teamMdPath = path.join(teamPath, 'TEAM.md');
const content = await fs.readFile(teamMdPath, 'utf-8');
expect(content).toContain('display_name: Registry Team');
expect(content).toContain('Follow these instructions.');
const agentPath = path.join(teamPath, 'agents', 'agent-1.md');
const agentContent = await fs.readFile(agentPath, 'utf-8');
expect(agentContent).toContain('name: agent-1');
expect(agentContent).toContain('provider: gemini');
});
it('should install a Git-based team using cloneFromGit', async () => {
const team: RegistryTeam = {
id: 'team-git',
name: 'git-team',
displayName: 'Git Team',
description: 'A team from git.',
instructions: 'Use git.',
agents: [],
sourceUrl: 'https://github.com/user/git-team.git',
};
vi.mocked(tryParseGithubUrl).mockReturnValue({
owner: 'user',
repo: 'git-team',
});
vi.mocked(downloadFromGitHubRelease).mockResolvedValue({
success: false,
failureReason: 'no release data',
errorMessage: 'Fail',
type: 'github-release',
});
vi.mocked(cloneFromGit).mockResolvedValue();
const teamPath = await installRegistryTeam(team, tempDir);
expect(teamPath).toBe(path.join(tempDir, 'git-team'));
expect(cloneFromGit).toHaveBeenCalled();
});
it('should install a GitHub-based team using downloadFromGitHubRelease', async () => {
const team: RegistryTeam = {
id: 'team-gh',
name: 'gh-team',
displayName: 'GitHub Team',
description: 'A team from GitHub.',
instructions: 'Use GitHub.',
agents: [],
sourceUrl: 'https://github.com/user/gh-team',
};
vi.mocked(tryParseGithubUrl).mockReturnValue({
owner: 'user',
repo: 'gh-team',
});
vi.mocked(downloadFromGitHubRelease).mockResolvedValue({
success: true,
type: 'github-release',
});
const teamPath = await installRegistryTeam(team, tempDir);
expect(teamPath).toBe(path.join(tempDir, 'gh-team'));
expect(downloadFromGitHubRelease).toHaveBeenCalled();
expect(cloneFromGit).not.toHaveBeenCalled();
});
});
});
@@ -8,6 +8,12 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { dump } from 'js-yaml';
import { getErrorMessage } from '../utils/errors.js';
import { type RegistryTeam } from './types.js';
import {
cloneFromGit,
downloadFromGitHubRelease,
tryParseGithubUrl,
} from '../utils/github.js';
export interface ScaffoldTeamAgent {
name: string;
@@ -105,3 +111,82 @@ ${dump(agentFrontmatter).trim()}
);
}
}
/**
* Installs an Agent Team from a registry entry.
* Supports both source-based (Git/GitHub) and definition-only (metadata) teams.
*
* @param team The registry entry for the team.
* @param targetDir The root directory where teams are stored (e.g. .gemini/teams/).
* @returns The absolute path to the installed team directory.
*/
export async function installRegistryTeam(
team: RegistryTeam,
targetDir: string,
): Promise<string> {
const teamSlug = team.name.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
const teamDirPath = path.resolve(targetDir, teamSlug);
try {
// 1. Handle source-based installation if sourceUrl is provided
if (team.sourceUrl) {
await fs.mkdir(teamDirPath, { recursive: true });
const githubInfo = tryParseGithubUrl(team.sourceUrl);
if (githubInfo) {
// Try GitHub release download first, fallback to Git clone
const result = await downloadFromGitHubRelease(
{
source: team.sourceUrl,
type: 'github-release',
},
teamDirPath,
githubInfo,
'TEAM.md',
);
if (!result.success) {
await cloneFromGit(
{
source: team.sourceUrl,
type: 'git',
},
teamDirPath,
);
}
} else {
// Not a GitHub URL, use standard Git clone
await cloneFromGit(
{
source: team.sourceUrl,
type: 'git',
},
teamDirPath,
);
}
} else {
// 2. Handle definition-only installation (scaffold from metadata)
const scaffoldOptions: ScaffoldTeamOptions = {
name: team.name,
displayName: team.displayName,
description: team.description,
instructions: team.instructions,
agents: team.agents.map((agent) => ({
name: agent.name,
kind: 'external' as const,
provider: agent.provider,
description: agent.description,
})),
targetDir: targetDir,
};
await scaffoldTeam(scaffoldOptions);
}
return teamDirPath;
} catch (error) {
throw new Error(
`Failed to install team "${team.displayName}": ${getErrorMessage(error)}`,
);
}
}
+21
View File
@@ -396,3 +396,24 @@ export interface TeamDefinition {
filePath?: string;
};
}
/**
* Represents a team entry in a remote or local registry.
*/
export interface RegistryTeam {
id: string;
name: string; // The slug
displayName: string;
description: string;
instructions: string;
agents: Array<{
name: string;
provider: string; // 'gemini', 'claude-code', etc.
description: string;
}>;
author?: string;
stars?: number;
lastUpdated?: string;
version?: string;
sourceUrl?: string; // Optional Git/GitHub source
}
+7
View File
@@ -673,6 +673,7 @@ export interface ConfigParameters {
shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean;
extensionRegistryURI?: string;
teamRegistryURI?: string;
truncateToolOutputThreshold?: number;
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
@@ -889,6 +890,7 @@ export class Config implements McpContext, AgentLoopContext {
private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true;
private readonly extensionRegistryURI: string | undefined;
private readonly teamRegistryURI: string | undefined;
private readonly truncateToolOutputThreshold: number;
private compressionTruncationCounter = 0;
private initialized = false;
@@ -1269,6 +1271,7 @@ export class Config implements McpContext, AgentLoopContext {
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
this.extensionManagement = params.extensionManagement ?? true;
this.extensionRegistryURI = params.extensionRegistryURI;
this.teamRegistryURI = params.teamRegistryURI;
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
this.storage = new Storage(this.targetDir, this._sessionId);
this.storage.setCustomPlansDir(params.planSettings?.directory);
@@ -2274,6 +2277,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.extensionRegistryURI;
}
getTeamRegistryURI(): string | undefined {
return this.teamRegistryURI;
}
getMcpClientManager(): McpClientManager | undefined {
return this.mcpClientManager;
}
+2
View File
@@ -128,6 +128,8 @@ export * from './utils/constants.js';
export * from './utils/sessionUtils.js';
export * from './utils/cache.js';
export * from './utils/markdownUtils.js';
export * from './utils/github.js';
export * from './utils/github_fetch.js';
// Export services
export * from './services/fileDiscoveryService.js';
+223
View File
@@ -0,0 +1,223 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
cloneFromGit,
tryParseGithubUrl,
fetchReleaseFromGithub,
downloadFromGitHubRelease,
findReleaseAsset,
downloadFile,
extractFile,
} from './github.js';
import { simpleGit, type SimpleGit } from 'simple-git';
import * as os from 'node:os';
import * as fs from 'node:fs';
import * as https from 'node:https';
import * as tar from 'tar';
import extract from 'extract-zip';
import { fetchJson } from './github_fetch.js';
import { EventEmitter } from 'node:events';
import { type ExtensionInstallMetadata } from '../config/config.js';
vi.mock('simple-git');
vi.mock('node:os');
vi.mock('node:fs');
vi.mock('node:https');
vi.mock('tar');
vi.mock('extract-zip');
vi.mock('./github_fetch.js');
describe('github.ts', () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe('cloneFromGit', () => {
let mockGit: {
clone: ReturnType<typeof vi.fn>;
getRemotes: ReturnType<typeof vi.fn>;
fetch: ReturnType<typeof vi.fn>;
checkout: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
};
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
// Mock destination directory existence check in simple-git if needed,
// but here we are mocking simpleGit itself.
});
it('should clone, fetch and checkout a repo', async () => {
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
await cloneFromGit(
{
type: 'git',
source: 'https://github.com/owner/repo.git',
ref: 'v1.0.0',
},
'/dest',
);
expect(mockGit.clone).toHaveBeenCalledWith(
'https://github.com/owner/repo.git',
'./',
['--depth', '1'],
);
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'v1.0.0');
expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD');
});
it('should throw if no remotes found', async () => {
mockGit.getRemotes.mockResolvedValue([]);
await expect(
cloneFromGit({ type: 'git', source: 'src' } as ExtensionInstallMetadata, '/dest'),
).rejects.toThrow('Unable to find any remotes');
});
it('should throw on clone error', async () => {
mockGit.clone.mockRejectedValue(new Error('Clone failed'));
await expect(
cloneFromGit({ type: 'git', source: 'src' } as ExtensionInstallMetadata, '/dest'),
).rejects.toThrow('Failed to clone Git repository');
});
});
describe('tryParseGithubUrl', () => {
it.each([
['https://github.com/owner/repo', 'owner', 'repo'],
['https://github.com/owner/repo.git', 'owner', 'repo'],
['git@github.com:owner/repo.git', 'owner', 'repo'],
['owner/repo', 'owner', 'repo'],
])('should parse %s to %s/%s', (url, owner, repo) => {
expect(tryParseGithubUrl(url)).toEqual({ owner, repo });
});
it.each([
'https://gitlab.com/owner/repo',
'https://my-git-host.com/owner/group/repo',
'git@gitlab.com:some-group/some-project/some-repo.git',
])('should return null for non-GitHub URLs', (url) => {
expect(tryParseGithubUrl(url)).toBeNull();
});
it('should throw for invalid formats', () => {
expect(() => tryParseGithubUrl('invalid')).toThrow(
'Invalid GitHub repository source',
);
});
});
describe('fetchReleaseFromGithub', () => {
it('should fetch latest release if no ref provided', async () => {
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
await fetchReleaseFromGithub('owner', 'repo');
expect(fetchJson).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/latest',
);
});
it('should fetch specific ref if provided', async () => {
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
await fetchReleaseFromGithub('owner', 'repo', 'v1.0.0');
expect(fetchJson).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/tags/v1.0.0',
);
});
});
describe('downloadFromGitHubRelease', () => {
it('should fail if no release data found', async () => {
vi.mocked(fetchJson).mockRejectedValue(new Error('Not found'));
const result = await downloadFromGitHubRelease(
{
type: 'github-release',
source: 'owner/repo',
ref: 'v1',
} as unknown as ExtensionInstallMetadata,
'/dest',
{ owner: 'owner', repo: 'repo' },
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.failureReason).toBe('failed to fetch release data');
}
});
});
describe('findReleaseAsset', () => {
it('should find platform/arch specific asset', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.mocked(os.arch).mockReturnValue('arm64');
const assets = [
{ name: 'darwin.arm64.tar.gz', url: 'url1' },
{ name: 'linux.x64.tar.gz', url: 'url2' },
];
expect(findReleaseAsset(assets)).toEqual(assets[0]);
});
it('should find generic asset', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
const assets = [{ name: 'generic.tar.gz', url: 'url' }];
expect(findReleaseAsset(assets)).toEqual(assets[0]);
});
});
describe('downloadFile', () => {
it('should download file successfully', async () => {
const mockReq = new EventEmitter();
const mockRes =
new EventEmitter() as unknown as import('node:http').IncomingMessage;
Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
vi.mocked(https.get).mockImplementation((url, options, cb) => {
if (typeof options === 'function') {
cb = options;
}
if (cb) cb(mockRes);
return mockReq as unknown as import('node:http').ClientRequest;
});
const mockStream = new EventEmitter() as unknown as fs.WriteStream;
Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
const promise = downloadFile('url', '/dest');
mockRes.emit('end');
mockStream.emit('finish');
await expect(promise).resolves.toBeUndefined();
});
});
describe('extractFile', () => {
it('should extract tar.gz using tar', async () => {
await extractFile('file.tar.gz', '/dest');
expect(tar.x).toHaveBeenCalled();
});
it('should extract zip using extract-zip', async () => {
vi.mocked(extract).mockResolvedValue(undefined);
await extractFile('file.zip', '/dest');
expect(extract).toHaveBeenCalled();
});
});
});
+423
View File
@@ -0,0 +1,423 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { simpleGit } from 'simple-git';
import * as os from 'node:os';
import * as https from 'node:https';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as tar from 'tar';
import extract from 'extract-zip';
import { getErrorMessage } from './errors.js';
import { fetchJson, getGitHubToken } from './github_fetch.js';
import { type ExtensionInstallMetadata } from '../config/config.js';
/**
* Clones a Git repository to a specified local path.
* @param installMetadata The metadata for the item to install.
* @param destination The destination path to clone the repository to.
*/
export async function cloneFromGit(
installMetadata: ExtensionInstallMetadata,
destination: string,
): Promise<void> {
try {
const git = simpleGit(destination);
let sourceUrl = installMetadata.source;
const token = getGitHubToken();
if (token) {
try {
const parsedUrl = new URL(sourceUrl);
if (
parsedUrl.protocol === 'https:' &&
parsedUrl.hostname === 'github.com'
) {
if (!parsedUrl.username) {
parsedUrl.username = token;
}
sourceUrl = parsedUrl.toString();
}
} catch {
// If source is not a valid URL, we don't inject the token.
// We let git handle the source as is.
}
}
await git.clone(sourceUrl, './', ['--depth', '1']);
const remotes = await git.getRemotes(true);
if (remotes.length === 0) {
throw new Error(
`Unable to find any remotes for repo ${installMetadata.source}`,
);
}
const refToFetch = installMetadata.ref || 'HEAD';
await git.fetch(remotes[0].name, refToFetch);
// After fetching, checkout FETCH_HEAD to get the content of the fetched ref.
// This results in a detached HEAD state, which is fine for this purpose.
await git.checkout('FETCH_HEAD');
} catch (error) {
throw new Error(
`Failed to clone Git repository from ${installMetadata.source}: ${getErrorMessage(error)}`,
{
cause: error,
},
);
}
}
export interface GithubRepoInfo {
owner: string;
repo: string;
}
export function tryParseGithubUrl(source: string): GithubRepoInfo | null {
// Handle SCP-style SSH URLs.
if (source.startsWith('git@')) {
if (source.startsWith('git@github.com:')) {
// It's a GitHub SSH URL, so normalize it for the URL parser.
source = source.replace('git@github.com:', '');
} else {
// It's another provider's SSH URL (e.g., gitlab), so not a GitHub repo.
return null;
}
}
// Default to a github repo path, so `source` can be just an org/repo
let parsedUrl: URL;
try {
// Use the standard URL constructor for backward compatibility.
parsedUrl = new URL(source, 'https://github.com');
} catch (e) {
// Throw a TypeError to maintain a consistent error contract for invalid URLs.
throw new TypeError(`Invalid repo URL: ${source}`, { cause: e });
}
if (!parsedUrl || parsedUrl.host !== 'github.com') {
return null;
}
// The pathname should be "/owner/repo".
const parts = parsedUrl.pathname
.split('/')
// Remove the empty segments, fixes trailing and leading slashes
.filter((part) => part !== '');
if (parts.length < 2) {
throw new Error(
`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
);
}
const owner = parts[0];
const repo = parts[1].replace('.git', '');
return {
owner,
repo,
};
}
export interface GithubReleaseData {
assets: Asset[];
tag_name: string;
tarball_url?: string;
zipball_url?: string;
}
export interface Asset {
name: string;
url: string;
}
export async function fetchReleaseFromGithub(
owner: string,
repo: string,
ref?: string,
allowPreRelease?: boolean,
): Promise<GithubReleaseData | null> {
if (ref) {
return fetchJson<GithubReleaseData>(
`https://api.github.com/repos/${owner}/${repo}/releases/tags/${ref}`,
);
}
if (!allowPreRelease) {
try {
return await fetchJson<GithubReleaseData>(
`https://api.github.com/repos/${owner}/${repo}/releases/latest`,
);
} catch (_) {
// Fallback to pre-release logic
}
}
const releases = await fetchJson<GithubReleaseData[]>(
`https://api.github.com/repos/${owner}/${repo}/releases?per_page=1`,
);
if (releases.length === 0) {
return null;
}
return releases[0];
}
export type GitHubDownloadResult =
| {
tagName?: string;
type: 'git' | 'github-release';
success: false;
failureReason:
| 'failed to fetch release data'
| 'no release data'
| 'no release asset found'
| 'failed to download asset'
| 'failed to extract asset'
| 'unknown';
errorMessage: string;
}
| {
tagName?: string;
type: 'git' | 'github-release';
success: true;
};
/**
* Downloads a release from GitHub and extracts it to the destination.
*/
export async function downloadFromGitHubRelease(
installMetadata: ExtensionInstallMetadata,
destination: string,
githubRepoInfo: GithubRepoInfo,
configFileName?: string, // e.g. EXTENSIONS_CONFIG_FILENAME
): Promise<GitHubDownloadResult> {
const { ref, allowPreRelease: preRelease } = installMetadata;
const { owner, repo } = githubRepoInfo;
let releaseData: GithubReleaseData | null = null;
try {
try {
releaseData = await fetchReleaseFromGithub(owner, repo, ref, preRelease);
if (!releaseData) {
return {
failureReason: 'no release data',
success: false,
type: 'github-release',
errorMessage: `No release data found for ${owner}/${repo} at tag ${ref}`,
};
}
} catch (error) {
return {
failureReason: 'failed to fetch release data',
success: false,
type: 'github-release',
errorMessage: `Failed to fetch release data for ${owner}/${repo} at tag ${ref}: ${getErrorMessage(error)}`,
};
}
const asset = findReleaseAsset(releaseData.assets);
let archiveUrl: string | undefined;
let isTar = false;
let isZip = false;
let fileName: string | undefined;
if (asset) {
archiveUrl = asset.url;
fileName = asset.name;
} else {
if (releaseData.tarball_url) {
archiveUrl = releaseData.tarball_url;
isTar = true;
} else if (releaseData.zipball_url) {
archiveUrl = releaseData.zipball_url;
isZip = true;
}
}
if (!archiveUrl) {
return {
failureReason: 'no release asset found',
success: false,
type: 'github-release',
tagName: releaseData.tag_name,
errorMessage: `No assets found for release with tag ${releaseData.tag_name}`,
};
}
if (!fileName) {
fileName = path.basename(new URL(archiveUrl).pathname);
}
let downloadedAssetPath = path.join(destination, fileName);
if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) {
downloadedAssetPath += '.tar.gz';
} else if (isZip && !downloadedAssetPath.endsWith('.zip')) {
downloadedAssetPath += '.zip';
}
try {
const headers = {
...(asset
? { Accept: 'application/octet-stream' }
: { Accept: 'application/vnd.github+json' }),
};
await downloadFile(archiveUrl, downloadedAssetPath, { headers });
} catch (error) {
return {
failureReason: 'failed to download asset',
success: false,
type: 'github-release',
tagName: releaseData.tag_name,
errorMessage: `Failed to download asset from ${archiveUrl}: ${getErrorMessage(error)}`,
};
}
try {
await extractFile(downloadedAssetPath, destination);
} catch (error) {
return {
failureReason: 'failed to extract asset',
success: false,
type: 'github-release',
tagName: releaseData.tag_name,
errorMessage: `Failed to extract asset from ${downloadedAssetPath}: ${getErrorMessage(error)}`,
};
}
// Post-extraction cleanup: move nested repository files to top level
if (configFileName) {
const entries = await fs.promises.readdir(destination, { withFileTypes: true });
if (entries.length === 2) {
const lonelyDir = entries.find((entry) => entry.isDirectory());
if (lonelyDir && fs.existsSync(path.join(destination, lonelyDir.name, configFileName))) {
const dirPathToExtract = path.join(destination, lonelyDir.name);
const extractedDirFiles = await fs.promises.readdir(dirPathToExtract);
for (const file of extractedDirFiles) {
await fs.promises.rename(
path.join(dirPathToExtract, file),
path.join(destination, file),
);
}
await fs.promises.rmdir(dirPathToExtract);
}
}
}
await fs.promises.unlink(downloadedAssetPath);
return {
tagName: releaseData.tag_name,
type: 'github-release',
success: true,
};
} catch (error) {
return {
failureReason: 'unknown',
success: false,
type: 'github-release',
tagName: releaseData?.tag_name,
errorMessage: `Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`,
};
}
}
export function findReleaseAsset(assets: Asset[]): Asset | undefined {
const platform = os.platform();
const arch = os.arch();
const platformArchPrefix = `${platform}.${arch}.`;
const platformPrefix = `${platform}.`;
// Check for platform + architecture specific asset
const platformArchAsset = assets.find((asset) =>
asset.name.toLowerCase().startsWith(platformArchPrefix),
);
if (platformArchAsset) {
return platformArchAsset;
}
// Check for platform specific asset
const platformAsset = assets.find((asset) =>
asset.name.toLowerCase().startsWith(platformPrefix),
);
if (platformAsset) {
return platformAsset;
}
// Check for generic asset if only one is available
const genericAsset = assets.find(
(asset) =>
!asset.name.toLowerCase().includes('darwin') &&
!asset.name.toLowerCase().includes('linux') &&
!asset.name.toLowerCase().includes('win32'),
);
if (assets.length === 1) {
return genericAsset;
}
return undefined;
}
export interface DownloadOptions {
headers?: Record<string, string>;
}
export async function downloadFile(
url: string,
dest: string,
options?: DownloadOptions,
redirectCount: number = 0,
): Promise<void> {
const headers: Record<string, string> = {
'User-agent': 'gemini-cli',
Accept: 'application/octet-stream',
...options?.headers,
};
const token = getGitHubToken();
if (token) {
headers['Authorization'] = `token ${token}`;
}
return new Promise((resolve, reject) => {
https
.get(url, { headers }, (res) => {
if (res.statusCode === 302 || res.statusCode === 301) {
if (redirectCount >= 10) {
return reject(new Error('Too many redirects'));
}
if (!res.headers.location) {
return reject(
new Error('Redirect response missing Location header'),
);
}
downloadFile(res.headers.location, dest, options, redirectCount + 1)
.then(resolve)
.catch(reject);
return;
}
if (res.statusCode !== 200) {
return reject(
new Error(`Request failed with status code ${res.statusCode}`),
);
}
const file = fs.createWriteStream(dest);
res.pipe(file);
file.on('finish', () => file.close(resolve as () => void));
})
.on('error', reject);
});
}
export async function extractFile(file: string, dest: string): Promise<void> {
if (file.endsWith('.tar.gz')) {
await tar.x({
file,
cwd: dest,
});
} else if (file.endsWith('.zip')) {
await extract(file, { dir: dest });
} else {
throw new Error(`Unsupported file extension for extraction: ${file}`);
}
}
@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, beforeEach, type Mock } from 'vitest';
import { fetchJson, getGitHubToken } from './github_fetch.js';
import { fetchWithTimeout } from './fetch.js';
vi.mock('./fetch.js', () => ({
fetchWithTimeout: vi.fn(),
}));
describe('getGitHubToken', () => {
const originalToken = process.env['GITHUB_TOKEN'];
afterEach(() => {
if (originalToken) {
process.env['GITHUB_TOKEN'] = originalToken;
} else {
delete process.env['GITHUB_TOKEN'];
}
});
it('should return the token if GITHUB_TOKEN is set', () => {
process.env['GITHUB_TOKEN'] = 'test-token';
expect(getGitHubToken()).toBe('test-token');
});
it('should return undefined if GITHUB_TOKEN is not set', () => {
delete process.env['GITHUB_TOKEN'];
expect(getGitHubToken()).toBeUndefined();
});
});
describe('fetchJson', () => {
const fetchMock = fetchWithTimeout as Mock;
afterEach(() => {
vi.resetAllMocks();
});
it('should fetch and parse JSON successfully', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ foo: 'bar' }),
});
await expect(fetchJson('https://example.com/data.json')).resolves.toEqual({
foo: 'bar',
});
expect(fetchMock).toHaveBeenCalledWith(
'https://example.com/data.json',
10000,
expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': 'gemini-cli',
Accept: 'application/vnd.github+json',
}),
}),
);
});
it('should reject on non-ok status code', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
});
await expect(fetchJson('https://example.com/error')).rejects.toThrow(
'Request failed with status code 404',
);
});
describe('with GITHUB_TOKEN', () => {
const originalToken = process.env['GITHUB_TOKEN'];
beforeEach(() => {
process.env['GITHUB_TOKEN'] = 'my-secret-token';
});
afterEach(() => {
if (originalToken) {
process.env['GITHUB_TOKEN'] = originalToken;
} else {
delete process.env['GITHUB_TOKEN'];
}
});
it('should include Authorization header if token is present', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ foo: 'bar' }),
});
await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({
foo: 'bar',
});
expect(fetchMock).toHaveBeenCalledWith(
'https://api.github.com/user',
10000,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'token my-secret-token',
}),
}),
);
});
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { fetchWithTimeout } from './fetch.js';
export function getGitHubToken(): string | undefined {
return process.env['GITHUB_TOKEN'];
}
const GITHUB_API_TIMEOUT = 10000; // 10 seconds
/**
* Fetches JSON data from a GitHub API URL.
* Handles redirection and authentication via GITHUB_TOKEN.
*/
export async function fetchJson<T>(
url: string,
_redirectCount: number = 0,
): Promise<T> {
const headers: Record<string, string> = {
'User-Agent': 'gemini-cli',
Accept: 'application/vnd.github+json',
};
const token = getGitHubToken();
if (token) {
headers['Authorization'] = `token ${token}`;
}
const response = await fetchWithTimeout(url, GITHUB_API_TIMEOUT, { headers });
if (!response.ok) {
throw new Error(`Request failed with status code ${response.status}: ${response.statusText}`);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as T;
}