Add extension registry client (#18396)

This commit is contained in:
christine betts
2026-02-06 12:14:14 -05:00
committed by GitHub
parent 1d70aa5c1b
commit 099fea2434
3 changed files with 346 additions and 0 deletions

View File

@@ -0,0 +1,227 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import {
ExtensionRegistryClient,
type RegistryExtension,
} from './extensionRegistryClient.js';
import { fetchWithTimeout } from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', () => ({
fetchWithTimeout: vi.fn(),
}));
const mockExtensions: RegistryExtension[] = [
{
id: 'ext1',
rank: 1,
url: 'https://github.com/test/ext1',
fullName: 'test/ext1',
repoDescription: 'Test extension 1',
stars: 100,
lastUpdated: '2025-01-01T00:00:00Z',
extensionName: 'extension-one',
extensionVersion: '1.0.0',
extensionDescription: 'First test extension',
avatarUrl: 'https://example.com/avatar1.png',
hasMCP: true,
hasContext: false,
isGoogleOwned: false,
licenseKey: 'mit',
hasHooks: false,
hasCustomCommands: false,
hasSkills: false,
},
{
id: 'ext2',
rank: 2,
url: 'https://github.com/test/ext2',
fullName: 'test/ext2',
repoDescription: 'Test extension 2',
stars: 50,
lastUpdated: '2025-01-02T00:00:00Z',
extensionName: 'extension-two',
extensionVersion: '0.5.0',
extensionDescription: 'Second test extension',
avatarUrl: 'https://example.com/avatar2.png',
hasMCP: false,
hasContext: true,
isGoogleOwned: true,
licenseKey: 'apache-2.0',
hasHooks: false,
hasCustomCommands: false,
hasSkills: false,
},
{
id: 'ext3',
rank: 3,
url: 'https://github.com/test/ext3',
fullName: 'test/ext3',
repoDescription: 'Test extension 3',
stars: 10,
lastUpdated: '2025-01-03T00:00:00Z',
extensionName: 'extension-three',
extensionVersion: '0.1.0',
extensionDescription: 'Third test extension',
avatarUrl: 'https://example.com/avatar3.png',
hasMCP: true,
hasContext: true,
isGoogleOwned: false,
licenseKey: 'gpl-3.0',
hasHooks: false,
hasCustomCommands: false,
hasSkills: false,
},
];
describe('ExtensionRegistryClient', () => {
let client: ExtensionRegistryClient;
let fetchMock: Mock;
beforeEach(() => {
ExtensionRegistryClient.resetCache();
client = new ExtensionRegistryClient();
fetchMock = fetchWithTimeout as Mock;
fetchMock.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should fetch and return extensions with pagination (default ranking)', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const result = await client.getExtensions(1, 2);
expect(result.extensions).toHaveLength(2);
expect(result.extensions[0].id).toBe('ext1'); // rank 1
expect(result.extensions[1].id).toBe('ext2'); // rank 2
expect(result.total).toBe(3);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
'https://geminicli.com/extensions.json',
10000,
);
});
it('should return extensions sorted alphabetically', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const result = await client.getExtensions(1, 3, 'alphabetical');
expect(result.extensions).toHaveLength(3);
expect(result.extensions[0].id).toBe('ext1');
expect(result.extensions[1].id).toBe('ext3');
expect(result.extensions[2].id).toBe('ext2');
});
it('should return the second page of extensions', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const result = await client.getExtensions(2, 2);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].id).toBe('ext3');
expect(result.total).toBe(3);
});
it('should search extensions by name', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const results = await client.searchExtensions('one');
expect(results.length).toBeGreaterThanOrEqual(1);
expect(results[0].id).toBe('ext1');
});
it('should search extensions by description', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const results = await client.searchExtensions('Second');
expect(results.length).toBeGreaterThanOrEqual(1);
expect(results[0].id).toBe('ext2');
});
it('should get an extension by ID', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const result = await client.getExtension('ext2');
expect(result).toBeDefined();
expect(result?.id).toBe('ext2');
});
it('should return undefined if extension not found', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const result = await client.getExtension('non-existent');
expect(result).toBeUndefined();
});
it('should cache the fetch result', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
await client.getExtensions();
await client.getExtensions();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('should share the fetch result across instances', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const client1 = new ExtensionRegistryClient();
const client2 = new ExtensionRegistryClient();
await client1.getExtensions();
await client2.getExtensions();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('should throw an error if fetch fails', async () => {
fetchMock.mockResolvedValue({
ok: false,
statusText: 'Not Found',
});
await expect(client.getExtensions()).rejects.toThrow(
'Failed to fetch extensions: Not Found',
);
});
});

View File

@@ -0,0 +1,118 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { fetchWithTimeout } from '@google/gemini-cli-core';
import { AsyncFzf } from 'fzf';
export interface RegistryExtension {
id: string;
rank: number;
url: string;
fullName: string;
repoDescription: string;
stars: number;
lastUpdated: string;
extensionName: string;
extensionVersion: string;
extensionDescription: string;
avatarUrl: string;
hasMCP: boolean;
hasContext: boolean;
hasHooks: boolean;
hasSkills: boolean;
hasCustomCommands: boolean;
isGoogleOwned: boolean;
licenseKey: string;
}
export class ExtensionRegistryClient {
private static readonly REGISTRY_URL =
'https://geminicli.com/extensions.json';
private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds
private static fetchPromise: Promise<RegistryExtension[]> | null = null;
/** @internal */
static resetCache() {
ExtensionRegistryClient.fetchPromise = null;
}
async getExtensions(
page: number = 1,
limit: number = 10,
orderBy: 'ranking' | 'alphabetical' = 'ranking',
): Promise<{ extensions: RegistryExtension[]; total: number }> {
const allExtensions = [...(await this.fetchAllExtensions())];
switch (orderBy) {
case 'ranking':
allExtensions.sort((a, b) => a.rank - b.rank);
break;
case 'alphabetical':
allExtensions.sort((a, b) =>
a.extensionName.localeCompare(b.extensionName),
);
break;
default: {
const _exhaustiveCheck: never = orderBy;
throw new Error(`Unhandled orderBy: ${_exhaustiveCheck}`);
}
}
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
return {
extensions: allExtensions.slice(startIndex, endIndex),
total: allExtensions.length,
};
}
async searchExtensions(query: string): Promise<RegistryExtension[]> {
const allExtensions = await this.fetchAllExtensions();
if (!query.trim()) {
return allExtensions;
}
const fzf = new AsyncFzf(allExtensions, {
selector: (ext: RegistryExtension) =>
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
fuzzy: 'v2',
});
const results = await fzf.find(query);
return results.map((r: { item: RegistryExtension }) => r.item);
}
async getExtension(id: string): Promise<RegistryExtension | undefined> {
const allExtensions = await this.fetchAllExtensions();
return allExtensions.find((ext) => ext.id === id);
}
private async fetchAllExtensions(): Promise<RegistryExtension[]> {
if (ExtensionRegistryClient.fetchPromise) {
return ExtensionRegistryClient.fetchPromise;
}
ExtensionRegistryClient.fetchPromise = (async () => {
try {
const response = await fetchWithTimeout(
ExtensionRegistryClient.REGISTRY_URL,
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
);
if (!response.ok) {
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
}
return (await response.json()) as RegistryExtension[];
} catch (error) {
// Clear the promise on failure so that subsequent calls can try again
ExtensionRegistryClient.fetchPromise = null;
throw error;
}
})();
return ExtensionRegistryClient.fetchPromise;
}
}

View File

@@ -54,6 +54,7 @@ export * from './code_assist/admin/admin_controls.js';
export * from './core/apiKeyCredentialStorage.js';
// Export utilities
export * from './utils/fetch.js';
export { homedir, tmpdir } from './utils/paths.js';
export * from './utils/paths.js';
export * from './utils/checks.js';