mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-22 07:41:23 -07:00
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:
@@ -625,6 +625,16 @@ export async function loadCliConfig(
|
||||
);
|
||||
}
|
||||
|
||||
let teamRegistryURI =
|
||||
process.env['GEMINI_CLI_TEAM_REGISTRY_URI'] ??
|
||||
(trustedFolder ? settings.experimental?.teamRegistryURI : undefined);
|
||||
|
||||
if (teamRegistryURI && !teamRegistryURI.startsWith('http')) {
|
||||
teamRegistryURI = resolveToRealPath(
|
||||
path.resolve(cwd, resolvePath(teamRegistryURI)),
|
||||
);
|
||||
}
|
||||
|
||||
let memoryContent: string | HierarchicalMemory = '';
|
||||
let fileCount = 0;
|
||||
let filePaths: string[] = [];
|
||||
@@ -964,6 +974,7 @@ export async function loadCliConfig(
|
||||
enabledExtensions: argv.extensions,
|
||||
extensionLoader: extensionManager,
|
||||
extensionRegistryURI,
|
||||
teamRegistryURI,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.general?.plan?.enabled ?? true,
|
||||
|
||||
@@ -5,29 +5,13 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
cloneFromGit,
|
||||
tryParseGithubUrl,
|
||||
fetchReleaseFromGithub,
|
||||
checkForExtensionUpdate,
|
||||
downloadFromGitHubRelease,
|
||||
findReleaseAsset,
|
||||
downloadFile,
|
||||
extractFile,
|
||||
} from './github.js';
|
||||
import { checkForExtensionUpdate } from './github.js';
|
||||
import { simpleGit, type SimpleGit } from 'simple-git';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs';
|
||||
import * as https from 'node:https';
|
||||
import * as tar from 'tar';
|
||||
import * as extract from 'extract-zip';
|
||||
import type { ExtensionManager } from '../extension-manager.js';
|
||||
import { fetchJson } from './github_fetch.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type {
|
||||
GeminiCLIExtension,
|
||||
ExtensionInstallMetadata,
|
||||
import {
|
||||
fetchReleaseFromGithub,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
|
||||
@@ -45,161 +29,23 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
fetchJson: vi.fn(),
|
||||
fetchReleaseFromGithub: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
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');
|
||||
vi.mock('../extension-manager.js');
|
||||
// Mock settings.ts to avoid top-level side effects if possible, or just rely on Storage mock
|
||||
vi.mock('../settings.js', () => ({
|
||||
loadSettings: vi.fn(),
|
||||
USER_SETTINGS_PATH: '/mock/settings.json',
|
||||
}));
|
||||
|
||||
describe('github.ts', () => {
|
||||
describe('github.ts (CLI specific)', () => {
|
||||
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>;
|
||||
listRemote: ReturnType<typeof vi.fn>;
|
||||
revparse: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGit = {
|
||||
clone: vi.fn(),
|
||||
getRemotes: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
listRemote: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
});
|
||||
|
||||
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' }, '/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' }, '/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',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle pre-releases if allowed', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValueOnce([{ tag_name: 'v1.0.0-beta' }]);
|
||||
|
||||
const result = await fetchReleaseFromGithub(
|
||||
'owner',
|
||||
'repo',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ tag_name: 'v1.0.0-beta' });
|
||||
});
|
||||
|
||||
it('should return null if no releases found', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await fetchReleaseFromGithub(
|
||||
'owner',
|
||||
'repo',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForExtensionUpdate', () => {
|
||||
let mockExtensionManager: ExtensionManager;
|
||||
let mockGit: {
|
||||
@@ -302,348 +148,20 @@ describe('github.ts', () => {
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadFromGitHubRelease', () => {
|
||||
it('should fail if no release data found', async () => {
|
||||
// Mock fetchJson to throw for latest release check
|
||||
vi.mocked(fetchJson).mockRejectedValue(new Error('Not found'));
|
||||
it('should return UPDATE_AVAILABLE if github release tag differs', async () => {
|
||||
vi.mocked(fetchReleaseFromGithub).mockResolvedValue({ tag_name: 'v2.0.0' } as any);
|
||||
|
||||
const result = await downloadFromGitHubRelease(
|
||||
{
|
||||
type: 'github-release',
|
||||
source: 'owner/repo',
|
||||
ref: 'v1',
|
||||
} as unknown as ExtensionInstallMetadata,
|
||||
'/dest',
|
||||
{ owner: 'owner', repo: 'repo' },
|
||||
);
|
||||
const ext = {
|
||||
installMetadata: {
|
||||
type: 'github-release',
|
||||
source: 'owner/repo',
|
||||
releaseTag: 'v1.0.0'
|
||||
},
|
||||
} as unknown as GeminiCLIExtension;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.failureReason).toBe('failed to fetch release data');
|
||||
}
|
||||
});
|
||||
|
||||
it('should use correct headers for release assets', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValue({
|
||||
tag_name: 'v1.0.0',
|
||||
assets: [{ name: 'asset.tar.gz', url: 'http://asset.url' }],
|
||||
});
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(os.arch).mockReturnValue('x64');
|
||||
|
||||
// Mock https.get and fs.createWriteStream for downloadFile
|
||||
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);
|
||||
|
||||
// Mock fs.promises.readdir to return empty array (no cleanup needed)
|
||||
vi.mocked(fs.promises.readdir).mockResolvedValue([]);
|
||||
// Mock fs.promises.unlink
|
||||
vi.mocked(fs.promises.unlink).mockResolvedValue(undefined);
|
||||
|
||||
const promise = downloadFromGitHubRelease(
|
||||
{
|
||||
type: 'github-release',
|
||||
source: 'owner/repo',
|
||||
ref: 'v1.0.0',
|
||||
} as unknown as ExtensionInstallMetadata,
|
||||
'/dest',
|
||||
{ owner: 'owner', repo: 'repo' },
|
||||
);
|
||||
|
||||
// Wait for downloadFile to be called and stream to be created
|
||||
await vi.waitUntil(
|
||||
() => vi.mocked(fs.createWriteStream).mock.calls.length > 0,
|
||||
);
|
||||
|
||||
// Trigger stream events to complete download
|
||||
mockRes.emit('end');
|
||||
mockStream.emit('finish');
|
||||
|
||||
await promise;
|
||||
|
||||
expect(https.get).toHaveBeenCalledWith(
|
||||
'http://asset.url',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Accept: 'application/octet-stream',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use correct headers for source tarballs', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValue({
|
||||
tag_name: 'v1.0.0',
|
||||
assets: [],
|
||||
tarball_url: 'http://tarball.url',
|
||||
});
|
||||
|
||||
// Mock https.get and fs.createWriteStream for downloadFile
|
||||
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);
|
||||
|
||||
// Mock fs.promises.readdir to return empty array
|
||||
vi.mocked(fs.promises.readdir).mockResolvedValue([]);
|
||||
// Mock fs.promises.unlink
|
||||
vi.mocked(fs.promises.unlink).mockResolvedValue(undefined);
|
||||
|
||||
const promise = downloadFromGitHubRelease(
|
||||
{
|
||||
type: 'github-release',
|
||||
source: 'owner/repo',
|
||||
ref: 'v1.0.0',
|
||||
} as unknown as ExtensionInstallMetadata,
|
||||
'/dest',
|
||||
{ owner: 'owner', repo: 'repo' },
|
||||
);
|
||||
|
||||
// Wait for downloadFile to be called and stream to be created
|
||||
await vi.waitUntil(
|
||||
() => vi.mocked(fs.createWriteStream).mock.calls.length > 0,
|
||||
);
|
||||
|
||||
// Trigger stream events to complete download
|
||||
mockRes.emit('end');
|
||||
mockStream.emit('finish');
|
||||
|
||||
await promise;
|
||||
|
||||
expect(https.get).toHaveBeenCalledWith(
|
||||
'http://tarball.url',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Accept: 'application/vnd.github+json',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('should fail on non-200 status', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockRes =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockRes, { statusCode: 404 });
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
await expect(downloadFile('url', '/dest')).rejects.toThrow(
|
||||
'Request failed with status code 404',
|
||||
);
|
||||
});
|
||||
|
||||
it('should follow redirects', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockResRedirect =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockResRedirect, {
|
||||
statusCode: 302,
|
||||
headers: { location: 'new-url' },
|
||||
});
|
||||
|
||||
const mockResSuccess =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockResSuccess, { statusCode: 200, pipe: vi.fn() });
|
||||
|
||||
vi.mocked(https.get)
|
||||
.mockImplementationOnce((url, options, cb) => {
|
||||
if (typeof options === 'function') cb = options;
|
||||
if (cb) cb(mockResRedirect);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
})
|
||||
.mockImplementationOnce((url, options, cb) => {
|
||||
if (typeof options === 'function') cb = options;
|
||||
if (cb) cb(mockResSuccess);
|
||||
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');
|
||||
mockResSuccess.emit('end');
|
||||
mockStream.emit('finish');
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(https.get).toHaveBeenCalledTimes(2);
|
||||
expect(https.get).toHaveBeenLastCalledWith(
|
||||
'new-url',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail after too many redirects', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockResRedirect =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockResRedirect, {
|
||||
statusCode: 302,
|
||||
headers: { location: 'new-url' },
|
||||
});
|
||||
|
||||
vi.mocked(https.get).mockImplementation((url, options, cb) => {
|
||||
if (typeof options === 'function') cb = options;
|
||||
if (cb) cb(mockResRedirect);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
await expect(downloadFile('url', '/dest')).rejects.toThrow(
|
||||
'Too many redirects',
|
||||
);
|
||||
}, 10000); // Increase timeout for this test if needed, though with mocks it should be fast
|
||||
|
||||
it('should fail if redirect location is missing', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockResRedirect =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockResRedirect, {
|
||||
statusCode: 302,
|
||||
headers: {}, // No location
|
||||
});
|
||||
|
||||
vi.mocked(https.get).mockImplementation((url, options, cb) => {
|
||||
if (typeof options === 'function') cb = options;
|
||||
if (cb) cb(mockResRedirect);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
await expect(downloadFile('url', '/dest')).rejects.toThrow(
|
||||
'Redirect response missing Location header',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass custom headers', 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', {
|
||||
headers: { 'X-Custom': 'value' },
|
||||
});
|
||||
mockRes.emit('end');
|
||||
mockStream.emit('finish');
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(https.get).toHaveBeenCalledWith(
|
||||
'url',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'X-Custom': 'value' }),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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.default || extract).mockResolvedValue(undefined);
|
||||
await extractFile('file.zip', '/dest');
|
||||
// Check if extract was called. Note: extract-zip export might be default or named depending on mock
|
||||
});
|
||||
|
||||
it('should throw for unsupported extensions', async () => {
|
||||
await expect(extractFile('file.txt', '/dest')).rejects.toThrow(
|
||||
'Unsupported file extension',
|
||||
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,165 +8,39 @@ import { simpleGit } from 'simple-git';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type ExtensionInstallMetadata,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
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 { fetchJson, getGitHubToken } from './github_fetch.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import type { ExtensionManager } from '../extension-manager.js';
|
||||
import { EXTENSIONS_CONFIG_FILENAME } from './variables.js';
|
||||
|
||||
/**
|
||||
* Clones a Git repository to a specified local path.
|
||||
* @param installMetadata The metadata for the extension 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']);
|
||||
import {
|
||||
cloneFromGit,
|
||||
downloadFromGitHubRelease,
|
||||
tryParseGithubUrl,
|
||||
fetchReleaseFromGithub,
|
||||
findReleaseAsset,
|
||||
downloadFile,
|
||||
extractFile,
|
||||
fetchJson,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
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 {
|
||||
cloneFromGit,
|
||||
downloadFromGitHubRelease,
|
||||
tryParseGithubUrl,
|
||||
fetchReleaseFromGithub,
|
||||
findReleaseAsset,
|
||||
downloadFile,
|
||||
extractFile,
|
||||
fetchJson,
|
||||
};
|
||||
|
||||
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.
|
||||
// This avoids a breaking change for consumers who might expect a TypeError.
|
||||
throw new TypeError(`Invalid repo URL: ${source}`, { cause: e });
|
||||
}
|
||||
|
||||
if (!parsedUrl) {
|
||||
throw new Error(`Invalid repo URL: ${source}`);
|
||||
}
|
||||
if (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 async function fetchReleaseFromGithub(
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref?: string,
|
||||
allowPreRelease?: boolean,
|
||||
): Promise<GithubReleaseData | null> {
|
||||
if (ref) {
|
||||
return fetchJson(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases/tags/${ref}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowPreRelease) {
|
||||
// Grab the release that is tagged as the "latest", github does not allow
|
||||
// this to be a pre-release so we can blindly grab it.
|
||||
try {
|
||||
return await fetchJson(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases/latest`,
|
||||
);
|
||||
} catch (_) {
|
||||
// This can fail if there is no release marked latest. In that case
|
||||
// we want to just try the pre-release logic below.
|
||||
}
|
||||
}
|
||||
|
||||
// If pre-releases are allowed, we just grab the most recent release.
|
||||
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 async function checkForExtensionUpdate(
|
||||
extension: GeminiCLIExtension,
|
||||
extensionManager: ExtensionManager,
|
||||
@@ -296,280 +170,3 @@ export async function checkForExtensionUpdate(
|
||||
return ExtensionUpdateState.ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
export async function downloadFromGitHubRelease(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
destination: string,
|
||||
githubRepoInfo: GithubRepoInfo,
|
||||
): 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 {
|
||||
// GitHub API requires different Accept headers for different types of downloads:
|
||||
// 1. Binary Assets (e.g. release artifacts): Require 'application/octet-stream' to return the raw content.
|
||||
// 2. Source Tarballs (e.g. /tarball/{ref}): Require 'application/vnd.github+json' (or similar) to return
|
||||
// a 302 Redirect to the actual download location (codeload.github.com).
|
||||
// Sending 'application/octet-stream' for tarballs results in a 415 Unsupported Media Type error.
|
||||
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)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// For regular github releases, the repository is put inside of a top level
|
||||
// directory. In this case we should see exactly two file in the destination
|
||||
// dir, the archive and the directory. If we see that, validate that the
|
||||
// dir has a gemini extension configuration file and then move all files
|
||||
// from the directory up one level into the destination directory.
|
||||
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, EXTENSIONS_CONFIG_FILENAME),
|
||||
)
|
||||
) {
|
||||
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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface GithubReleaseData {
|
||||
assets: Asset[];
|
||||
tag_name: string;
|
||||
tarball_url?: string;
|
||||
zipball_url?: string;
|
||||
}
|
||||
|
||||
interface Asset {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import * as https from 'node:https';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { fetchJson, getGitHubToken } from './github_fetch.js';
|
||||
import type { ClientRequest, IncomingMessage } from 'node:http';
|
||||
|
||||
vi.mock('node:https');
|
||||
|
||||
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 getMock = vi.mocked(https.get);
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch and parse JSON successfully', async () => {
|
||||
getMock.mockImplementationOnce((_url, _options, callback) => {
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 200;
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('data', Buffer.from('{"foo":'));
|
||||
res.emit('data', Buffer.from('"bar"}'));
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
await expect(fetchJson('https://example.com/data.json')).resolves.toEqual({
|
||||
foo: 'bar',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle redirects (301 and 302)', async () => {
|
||||
// Test 302
|
||||
getMock.mockImplementationOnce((_url, _options, callback) => {
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 302;
|
||||
res.headers = { location: 'https://example.com/final' };
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
getMock.mockImplementationOnce((url, _options, callback) => {
|
||||
expect(url).toBe('https://example.com/final');
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 200;
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('data', Buffer.from('{"success": true}'));
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
|
||||
await expect(fetchJson('https://example.com/redirect')).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
|
||||
// Test 301
|
||||
getMock.mockImplementationOnce((_url, _options, callback) => {
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 301;
|
||||
res.headers = { location: 'https://example.com/final-permanent' };
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
getMock.mockImplementationOnce((url, _options, callback) => {
|
||||
expect(url).toBe('https://example.com/final-permanent');
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 200;
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('data', Buffer.from('{"permanent": true}'));
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchJson('https://example.com/redirect-perm'),
|
||||
).resolves.toEqual({ permanent: true });
|
||||
});
|
||||
|
||||
it('should reject on non-200/30x status code', async () => {
|
||||
getMock.mockImplementationOnce((_url, _options, callback) => {
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 404;
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
|
||||
await expect(fetchJson('https://example.com/error')).rejects.toThrow(
|
||||
'Request failed with status code 404',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject on request error', async () => {
|
||||
const error = new Error('Network error');
|
||||
getMock.mockImplementationOnce(() => {
|
||||
const req = new EventEmitter() as ClientRequest;
|
||||
req.emit('error', error);
|
||||
return req;
|
||||
});
|
||||
|
||||
await expect(fetchJson('https://example.com/error')).rejects.toThrow(
|
||||
'Network error',
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
getMock.mockImplementationOnce((_url, options, callback) => {
|
||||
expect(options.headers).toEqual({
|
||||
'User-Agent': 'gemini-cli',
|
||||
Authorization: 'token my-secret-token',
|
||||
});
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 200;
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('data', Buffer.from('{"foo": "bar"}'));
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({
|
||||
foo: 'bar',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('without GITHUB_TOKEN', () => {
|
||||
const originalToken = process.env['GITHUB_TOKEN'];
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env['GITHUB_TOKEN'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalToken) {
|
||||
process.env['GITHUB_TOKEN'] = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it('should not include Authorization header if token is not present', async () => {
|
||||
getMock.mockImplementationOnce((_url, options, callback) => {
|
||||
expect(options.headers).toEqual({
|
||||
'User-Agent': 'gemini-cli',
|
||||
});
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 200;
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('data', Buffer.from('{"foo": "bar"}'));
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
|
||||
await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({
|
||||
foo: 'bar',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,51 +4,4 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as https from 'node:https';
|
||||
|
||||
export function getGitHubToken(): string | undefined {
|
||||
return process.env['GITHUB_TOKEN'];
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(
|
||||
url: string,
|
||||
redirectCount: number = 0,
|
||||
): Promise<T> {
|
||||
const headers: { 'User-Agent': string; Authorization?: string } = {
|
||||
'User-Agent': 'gemini-cli',
|
||||
};
|
||||
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('No location header in redirect response'));
|
||||
}
|
||||
fetchJson<T>(res.headers.location, redirectCount++)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(
|
||||
new Error(`Request failed with status code ${res.statusCode}`),
|
||||
);
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const data = Buffer.concat(chunks).toString();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
resolve(JSON.parse(data) as T);
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
export { getGitHubToken, fetchJson } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -2009,6 +2009,16 @@ const SETTINGS_SCHEMA = {
|
||||
'The URI (web URL or local file path) of the extension registry.',
|
||||
showInDialog: false,
|
||||
},
|
||||
teamRegistryURI: {
|
||||
type: 'string',
|
||||
label: 'Team Registry URI',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 'https://geminicli.com/teams.json',
|
||||
description:
|
||||
'The URI (web URL or local file path) of the agent team registry.',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionReloading: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Reloading',
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
@@ -83,6 +84,7 @@
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"systeminformation": "^5.25.11",
|
||||
"tar": "^7.5.13",
|
||||
"tree-sitter-bash": "^0.25.0",
|
||||
"undici": "^7.10.0",
|
||||
"uuid": "^13.0.0",
|
||||
@@ -106,6 +108,7 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/json-stable-stringify": "^1.1.0",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"@types/tar": "^6.1.13",
|
||||
"chrome-devtools-mcp": "^0.19.0",
|
||||
"msw": "^2.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user