diff --git a/packages/cli/src/config/extensionRegistryClient.test.ts b/packages/cli/src/config/extensionRegistryClient.test.ts new file mode 100644 index 0000000000..187390ceb0 --- /dev/null +++ b/packages/cli/src/config/extensionRegistryClient.test.ts @@ -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', + ); + }); +}); diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts new file mode 100644 index 0000000000..8104b8aeac --- /dev/null +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -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 | 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 { + 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 { + const allExtensions = await this.fetchAllExtensions(); + return allExtensions.find((ext) => ext.id === id); + } + + private async fetchAllExtensions(): Promise { + 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; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41c11961fd..b06a416176 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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';