mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-10 21:30:40 -07:00
Add extensionRegistryURI setting to change where the registry is read from (#20463)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user