Add extensionRegistryURI setting to change where the registry is read from (#20463)

This commit is contained in:
kevinjwang1
2026-03-10 13:22:03 -07:00
committed by GitHub
parent 5caa192cfc
commit 5b8ad9cd65
10 changed files with 134 additions and 16 deletions

View File

@@ -7,6 +7,7 @@
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import * as path from 'node:path';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
import { skillsCommand } from '../commands/skills.js';
@@ -33,6 +34,7 @@ import {
getAdminErrorMessage,
isHeadlessMode,
Config,
resolveToRealPath,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
type HookDefinition,
@@ -488,6 +490,15 @@ export async function loadCliConfig(
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let extensionRegistryURI: string | undefined = trustedFolder
? settings.experimental?.extensionRegistryURI
: undefined;
if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) {
extensionRegistryURI = resolveToRealPath(
path.resolve(cwd, resolvePath(extensionRegistryURI)),
);
}
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
let filePaths: string[] = [];
@@ -764,6 +775,7 @@ export async function loadCliConfig(
deleteSession: argv.deleteSession,
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
extensionRegistryURI,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,

View File

@@ -13,14 +13,24 @@ import {
afterEach,
type Mock,
} from 'vitest';
import * as fs from 'node:fs/promises';
import {
ExtensionRegistryClient,
type RegistryExtension,
} from './extensionRegistryClient.js';
import { fetchWithTimeout } from '@google/gemini-cli-core';
import { fetchWithTimeout, resolveToRealPath } from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', () => ({
fetchWithTimeout: vi.fn(),
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
fetchWithTimeout: vi.fn(),
};
});
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
}));
const mockExtensions: RegistryExtension[] = [
@@ -279,4 +289,32 @@ describe('ExtensionRegistryClient', () => {
expect(ids).not.toContain('dataplex');
expect(ids).toContain('conductor');
});
it('should fetch extensions from a local file path', async () => {
const filePath = '/path/to/extensions.json';
const clientWithFile = new ExtensionRegistryClient(filePath);
const mockReadFile = vi.mocked(fs.readFile);
mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions));
const result = await clientWithFile.getExtensions();
expect(result.extensions).toHaveLength(3);
expect(mockReadFile).toHaveBeenCalledWith(
resolveToRealPath(filePath),
'utf-8',
);
});
it('should fetch extensions from a file:// URL', async () => {
const fileUrl = 'file:///path/to/extensions.json';
const clientWithFileUrl = new ExtensionRegistryClient(fileUrl);
const mockReadFile = vi.mocked(fs.readFile);
mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions));
const result = await clientWithFileUrl.getExtensions();
expect(result.extensions).toHaveLength(3);
expect(mockReadFile).toHaveBeenCalledWith(
resolveToRealPath(fileUrl),
'utf-8',
);
});
});

View File

@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { fetchWithTimeout } from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import {
fetchWithTimeout,
resolveToRealPath,
isPrivateIp,
} from '@google/gemini-cli-core';
import { AsyncFzf } from 'fzf';
export interface RegistryExtension {
@@ -29,12 +34,19 @@ export interface RegistryExtension {
}
export class ExtensionRegistryClient {
private static readonly REGISTRY_URL =
static readonly DEFAULT_REGISTRY_URL =
'https://geminicli.com/extensions.json';
private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds
private static fetchPromise: Promise<RegistryExtension[]> | null = null;
private readonly registryURI: string;
constructor(registryURI?: string) {
this.registryURI =
registryURI || ExtensionRegistryClient.DEFAULT_REGISTRY_URL;
}
/** @internal */
static resetCache() {
ExtensionRegistryClient.fetchPromise = null;
@@ -97,18 +109,34 @@ export class ExtensionRegistryClient {
return ExtensionRegistryClient.fetchPromise;
}
const uri = this.registryURI;
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}`);
}
if (uri.startsWith('http')) {
if (isPrivateIp(uri)) {
throw new Error(
'Private IP addresses are not allowed for the extension registry.',
);
}
const response = await fetchWithTimeout(
uri,
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
);
if (!response.ok) {
throw new Error(
`Failed to fetch extensions: ${response.statusText}`,
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as RegistryExtension[];
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as RegistryExtension[];
} 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 RegistryExtension[];
}
} catch (error) {
ExtensionRegistryClient.fetchPromise = null;
throw error;

View File

@@ -1791,6 +1791,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable extension registry explore UI.',
showInDialog: false,
},
extensionRegistryURI: {
type: 'string',
label: 'Extension Registry URI',
category: 'Experimental',
requiresRestart: true,
default: 'https://geminicli.com/extensions.json',
description:
'The URI (web URL or local file path) of the extension registry.',
showInDialog: false,
},
extensionReloading: {
type: 'boolean',
label: 'Extension Reloading',

View File

@@ -132,6 +132,9 @@ describe('ExtensionRegistryView', () => {
vi.mocked(useConfig).mockReturnValue({
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
getExtensionRegistryURI: vi
.fn()
.mockReturnValue('https://geminicli.com/extensions.json'),
} as unknown as ReturnType<typeof useConfig>);
});

View File

@@ -39,8 +39,11 @@ export function ExtensionRegistryView({
onClose,
extensionManager,
}: ExtensionRegistryViewProps): React.JSX.Element {
const { extensions, loading, error, search } = useExtensionRegistry();
const config = useConfig();
const { extensions, loading, error, search } = useExtensionRegistry(
'',
config.getExtensionRegistryURI(),
);
const { terminalHeight, staticExtraHeight } = useUIState();
const { extensionsUpdateState } = useExtensionUpdates(

View File

@@ -19,12 +19,16 @@ export interface UseExtensionRegistryResult {
export function useExtensionRegistry(
initialQuery = '',
registryURI?: string,
): UseExtensionRegistryResult {
const [extensions, setExtensions] = useState<RegistryExtension[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const client = useMemo(() => new ExtensionRegistryClient(), []);
const client = useMemo(
() => new ExtensionRegistryClient(registryURI),
[registryURI],
);
// Ref to track the latest query to avoid race conditions
const latestQueryRef = useRef(initialQuery);