Create ExtensionManager class which manages all high level extension tasks (#11667)

This commit is contained in:
Jacob MacDonald
2025-10-23 11:39:36 -07:00
committed by GitHub
parent 3a501196f0
commit c4c0c0d182
31 changed files with 1450 additions and 1568 deletions
+14 -14
View File
@@ -5,11 +5,12 @@
*/
import { type CommandModule } from 'yargs';
import { disableExtension } from '../../config/extension.js';
import { SettingScope } from '../../config/settings.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface DisableArgs {
name: string;
@@ -17,20 +18,19 @@ interface DisableArgs {
}
export function handleDisable(args: DisableArgs) {
const extensionEnablementManager = new ExtensionEnablementManager();
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
try {
if (args.scope?.toLowerCase() === 'workspace') {
disableExtension(
args.name,
SettingScope.Workspace,
extensionEnablementManager,
);
extensionManager.disableExtension(args.name, SettingScope.Workspace);
} else {
disableExtension(
args.name,
SettingScope.User,
extensionEnablementManager,
);
extensionManager.disableExtension(args.name, SettingScope.User);
}
debugLogger.log(
`Extension "${args.name}" successfully disabled for scope "${args.scope}".`,
+13 -10
View File
@@ -5,14 +5,15 @@
*/
import { type CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
debugLogger,
FatalConfigError,
getErrorMessage,
} from '@google/gemini-cli-core';
import { enableExtension } from '../../config/extension.js';
import { SettingScope } from '../../config/settings.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface EnableArgs {
name: string;
@@ -20,16 +21,18 @@ interface EnableArgs {
}
export function handleEnable(args: EnableArgs) {
const extensionEnablementManager = new ExtensionEnablementManager();
const workingDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir: workingDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workingDir),
});
try {
if (args.scope?.toLowerCase() === 'workspace') {
enableExtension(
args.name,
SettingScope.Workspace,
extensionEnablementManager,
);
extensionManager.enableExtension(args.name, SettingScope.Workspace);
} else {
enableExtension(args.name, SettingScope.User, extensionEnablementManager);
extensionManager.enableExtension(args.name, SettingScope.User);
}
if (args.scope) {
debugLogger.log(
@@ -12,11 +12,21 @@ const mockInstallOrUpdateExtension = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
const mockStat = vi.hoisted(() => vi.fn());
vi.mock('../../config/extension.js', () => ({
installOrUpdateExtension: mockInstallOrUpdateExtension,
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
}));
vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
...actual,
ExtensionManager: vi.fn().mockImplementation(() => ({
installOrUpdateExtension: mockInstallOrUpdateExtension,
})),
};
});
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
@@ -54,7 +64,7 @@ describe('handleInstall', () => {
mockInstallOrUpdateExtension.mockClear();
mockRequestConsentNonInteractive.mockClear();
mockStat.mockClear();
vi.resetAllMocks();
vi.clearAllMocks();
});
it('should install an extension from a http source', async () => {
+15 -11
View File
@@ -5,17 +5,18 @@
*/
import type { CommandModule } from 'yargs';
import {
INSTALL_WARNING_MESSAGE,
installOrUpdateExtension,
requestConsentNonInteractive,
} from '../../config/extension.js';
import {
debugLogger,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { stat } from 'node:fs/promises';
import {
INSTALL_WARNING_MESSAGE,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface InstallArgs {
@@ -67,13 +68,16 @@ export async function handleInstall(args: InstallArgs) {
debugLogger.log('You have consented to the following:');
debugLogger.log(INSTALL_WARNING_MESSAGE);
}
const name = await installOrUpdateExtension(
installMetadata,
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent,
process.cwd(),
undefined,
promptForSetting,
);
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
const name =
await extensionManager.installOrUpdateExtension(installMetadata);
debugLogger.log(`Extension "${name}" installed successfully and enabled.`);
} catch (error) {
debugLogger.error(getErrorMessage(error));
+13 -8
View File
@@ -5,16 +5,16 @@
*/
import type { CommandModule } from 'yargs';
import {
installOrUpdateExtension,
requestConsentNonInteractive,
} from '../../config/extension.js';
import {
debugLogger,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface InstallArgs {
path: string;
@@ -26,10 +26,15 @@ export async function handleLink(args: InstallArgs) {
source: args.path,
type: 'link',
};
const extensionName = await installOrUpdateExtension(
installMetadata,
requestConsentNonInteractive,
);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
const extensionName =
await extensionManager.installOrUpdateExtension(installMetadata);
debugLogger.log(
`Extension "${extensionName}" linked successfully and enabled.`,
);
+15 -7
View File
@@ -5,24 +5,32 @@
*/
import type { CommandModule } from 'yargs';
import { loadExtensions, toOutputString } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
export async function handleList() {
try {
const extensions = loadExtensions(
new ExtensionEnablementManager(),
process.cwd(),
);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
const extensions = extensionManager.loadExtensions();
if (extensions.length === 0) {
debugLogger.log('No extensions installed.');
return;
}
debugLogger.log(
extensions
.map((extension, _): string => toOutputString(extension, process.cwd()))
.map((extension, _): string =>
extensionManager.toOutputString(extension),
)
.join('\n\n'),
);
} catch (error) {
@@ -5,9 +5,12 @@
*/
import type { CommandModule } from 'yargs';
import { uninstallExtension } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
import { debugLogger } from '@google/gemini-cli-core';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface UninstallArgs {
name: string; // can be extension name or source URL.
@@ -15,7 +18,14 @@ interface UninstallArgs {
export async function handleUninstall(args: UninstallArgs) {
try {
await uninstallExtension(args.name, false);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
await extensionManager.uninstallExtension(args.name, false);
debugLogger.log(`Extension "${args.name}" successfully uninstalled.`);
} catch (error) {
debugLogger.error(getErrorMessage(error));
+17 -21
View File
@@ -5,10 +5,6 @@
*/
import type { CommandModule } from 'yargs';
import {
loadExtensions,
requestConsentNonInteractive,
} from '../../config/extension.js';
import {
updateAllUpdatableExtensions,
type ExtensionUpdateInfo,
@@ -18,8 +14,11 @@ import {
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface UpdateArgs {
name?: string;
@@ -30,13 +29,15 @@ const updateOutput = (info: ExtensionUpdateInfo) =>
`Extension "${info.name}" successfully updated: ${info.originalVersion}${info.updatedVersion}.`;
export async function handleUpdate(args: UpdateArgs) {
const workingDir = process.cwd();
const extensionEnablementManager = new ExtensionEnablementManager(
// Force enable named extensions, otherwise we will only update the enabled
// ones.
args.name ? [args.name] : [],
);
const extensions = loadExtensions(extensionEnablementManager);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
const extensions = extensionManager.loadExtensions();
if (args.name) {
try {
const extension = extensions.find(
@@ -54,7 +55,7 @@ export async function handleUpdate(args: UpdateArgs) {
}
const updateState = await checkForExtensionUpdate(
extension,
extensionEnablementManager,
extensionManager,
);
if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) {
debugLogger.log(`Extension "${args.name}" is already up to date.`);
@@ -63,9 +64,7 @@ export async function handleUpdate(args: UpdateArgs) {
// TODO(chrstnb): we should list extensions if the requested extension is not installed.
const updatedExtensionInfo = (await updateExtension(
extension,
extensionEnablementManager,
workingDir,
requestConsentNonInteractive,
extensionManager,
updateState,
() => {},
))!;
@@ -88,7 +87,7 @@ export async function handleUpdate(args: UpdateArgs) {
const extensionState = new Map();
await checkForAllExtensionUpdates(
extensions,
extensionEnablementManager,
extensionManager,
(action) => {
if (action.type === 'SET_STATE') {
extensionState.set(action.payload.name, {
@@ -96,14 +95,11 @@ export async function handleUpdate(args: UpdateArgs) {
});
}
},
workingDir,
);
let updateInfos = await updateAllUpdatableExtensions(
workingDir,
requestConsentNonInteractive,
extensions,
extensionState,
extensionEnablementManager,
extensionManager,
() => {},
);
updateInfos = updateInfos.filter(
+16 -6
View File
@@ -7,19 +7,20 @@
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { listMcpServers } from './list.js';
import { loadSettings } from '../../config/settings.js';
import { ExtensionStorage, loadExtensions } from '../../config/extension.js';
import { createTransport, debugLogger } from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ExtensionStorage } from '../../config/extensions/storage.js';
import { ExtensionManager } from '../../config/extension-manager.js';
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(),
}));
vi.mock('../../config/extension.js', () => ({
loadExtensions: vi.fn(),
vi.mock('../../config/extensions/storage.js', () => ({
ExtensionStorage: {
getUserExtensionsDir: vi.fn(),
},
}));
vi.mock('../../config/extension-manager.js');
vi.mock('@google/gemini-cli-core', () => ({
createTransport: vi.fn(),
MCPServerStatus: {
@@ -46,9 +47,9 @@ vi.mock('@modelcontextprotocol/sdk/client/index.js');
const mockedGetUserExtensionsDir =
ExtensionStorage.getUserExtensionsDir as Mock;
const mockedLoadSettings = loadSettings as Mock;
const mockedLoadExtensions = loadExtensions as Mock;
const mockedCreateTransport = createTransport as Mock;
const MockedClient = Client as Mock;
const MockedExtensionManager = ExtensionManager as Mock;
interface MockClient {
connect: Mock;
@@ -56,12 +57,17 @@ interface MockClient {
close: Mock;
}
interface MockExtensionManager {
loadExtensions: Mock;
}
interface MockTransport {
close: Mock;
}
describe('mcp list command', () => {
let mockClient: MockClient;
let mockExtensionManager: MockExtensionManager;
let mockTransport: MockTransport;
beforeEach(() => {
@@ -73,10 +79,14 @@ describe('mcp list command', () => {
ping: vi.fn(),
close: vi.fn(),
};
mockExtensionManager = {
loadExtensions: vi.fn(),
};
MockedClient.mockImplementation(() => mockClient);
MockedExtensionManager.mockImplementation(() => mockExtensionManager);
mockedCreateTransport.mockResolvedValue(mockTransport);
mockedLoadExtensions.mockReturnValue([]);
mockExtensionManager.loadExtensions.mockReturnValue([]);
mockedGetUserExtensionsDir.mockReturnValue('/mocked/extensions/dir');
});
@@ -149,7 +159,7 @@ describe('mcp list command', () => {
},
});
mockedLoadExtensions.mockReturnValue([
mockExtensionManager.loadExtensions.mockReturnValue([
{
name: 'test-extension',
mcpServers: { 'extension-server': { command: '/ext/server' } },
+10 -3
View File
@@ -14,8 +14,9 @@ import {
debugLogger,
} from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { loadExtensions } from '../../config/extension.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
@@ -26,7 +27,13 @@ async function getMcpServersFromConfig(): Promise<
Record<string, MCPServerConfig>
> {
const settings = loadSettings();
const extensions = loadExtensions(new ExtensionEnablementManager());
const extensionManager = new ExtensionManager({
loadedSettings: settings,
workspaceDir: process.cwd(),
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
});
const extensions = extensionManager.loadExtensions();
const mcpServers = { ...(settings.merged.mcpServers || {}) };
for (const extension of extensions) {
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
@@ -0,0 +1,643 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import chalk from 'chalk';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { type LoadedSettings, SettingScope } from './settings.js';
import { createHash, randomUUID } from 'node:crypto';
import { loadInstallMetadata, type ExtensionConfig } from './extension.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import {
cloneFromGit,
downloadFromGitHubRelease,
tryParseGithubUrl,
} from './extensions/github.js';
import {
Config,
debugLogger,
ExtensionDisableEvent,
ExtensionEnableEvent,
ExtensionInstallEvent,
ExtensionUninstallEvent,
ExtensionUpdateEvent,
getErrorMessage,
logExtensionDisable,
logExtensionEnable,
logExtensionInstallEvent,
logExtensionUninstall,
logExtensionUpdateEvent,
type MCPServerConfig,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
import { maybeRequestConsentOrFail } from './extensions/consent.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { ExtensionStorage } from './extensions/storage.js';
import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
recursivelyHydrateStrings,
type JsonObject,
} from './extensions/variables.js';
import {
getEnvContents,
maybePromptForSettings,
type ExtensionSetting,
} from './extensions/extensionSettings.js';
interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
loadedSettings: LoadedSettings;
requestConsent: (consent: string) => Promise<boolean>;
requestSetting: ((setting: ExtensionSetting) => Promise<string>) | null;
workspaceDir: string;
}
export class ExtensionManager {
private extensionEnablementManager: ExtensionEnablementManager;
private loadedSettings: LoadedSettings;
private requestConsent: (consent: string) => Promise<boolean>;
private requestSetting:
| ((setting: ExtensionSetting) => Promise<string>)
| null;
private telemetryConfig: Config;
private workspaceDir: string;
constructor(options: ExtensionManagerParams) {
this.workspaceDir = options.workspaceDir;
this.extensionEnablementManager = new ExtensionEnablementManager(
options.enabledExtensionOverrides,
);
this.loadedSettings = options.loadedSettings;
this.telemetryConfig = new Config({
telemetry: options.loadedSettings.merged.telemetry,
interactive: false,
sessionId: randomUUID(),
targetDir: options.workspaceDir,
cwd: options.workspaceDir,
model: '',
debugMode: false,
});
this.requestConsent = options.requestConsent;
this.requestSetting = options.requestSetting;
}
async installOrUpdateExtension(
installMetadata: ExtensionInstallMetadata,
previousExtensionConfig?: ExtensionConfig,
): Promise<string> {
const isUpdate = !!previousExtensionConfig;
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
try {
const settings = this.loadedSettings.merged;
if (!isWorkspaceTrusted(settings).isTrusted) {
throw new Error(
`Could not install extension from untrusted folder at ${installMetadata.source}`,
);
}
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true });
if (
!path.isAbsolute(installMetadata.source) &&
(installMetadata.type === 'local' || installMetadata.type === 'link')
) {
installMetadata.source = path.resolve(
this.workspaceDir,
installMetadata.source,
);
}
let tempDir: string | undefined;
if (
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
tempDir = await ExtensionStorage.createTmpDir();
const parsedGithubParts = tryParseGithubUrl(installMetadata.source);
if (!parsedGithubParts) {
await cloneFromGit(installMetadata, tempDir);
installMetadata.type = 'git';
} else {
const result = await downloadFromGitHubRelease(
installMetadata,
tempDir,
parsedGithubParts,
);
if (result.success) {
installMetadata.type = result.type;
installMetadata.releaseTag = result.tagName;
} else if (
// This repo has no github releases, and wasn't explicitly installed
// from a github release, unconditionally just clone it.
(result.failureReason === 'no release data' &&
installMetadata.type === 'git') ||
// Otherwise ask the user if they would like to try a git clone.
(await this.requestConsent(
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.\n\nWould you like to attempt to install via "git clone" instead?`,
))
) {
await cloneFromGit(installMetadata, tempDir);
installMetadata.type = 'git';
} else {
throw new Error(
`Failed to install extension ${installMetadata.source}: ${result.errorMessage}`,
);
}
}
localSourcePath = tempDir;
} else if (
installMetadata.type === 'local' ||
installMetadata.type === 'link'
) {
localSourcePath = installMetadata.source;
} else {
throw new Error(`Unsupported install type: ${installMetadata.type}`);
}
try {
newExtensionConfig = this.loadExtensionConfig(localSourcePath);
if (isUpdate && installMetadata.autoUpdate) {
const oldSettings = new Set(
previousExtensionConfig.settings?.map((s) => s.name) || [],
);
const newSettings = new Set(
newExtensionConfig.settings?.map((s) => s.name) || [],
);
const settingsAreEqual =
oldSettings.size === newSettings.size &&
[...oldSettings].every((value) => newSettings.has(value));
if (!settingsAreEqual && installMetadata.autoUpdate) {
throw new Error(
`Extension "${newExtensionConfig.name}" has settings changes and cannot be auto-updated. Please update manually.`,
);
}
}
const newExtensionName = newExtensionConfig.name;
if (!isUpdate) {
const installedExtensions = this.loadExtensions();
if (
installedExtensions.some(
(installed) => installed.name === newExtensionName,
)
) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
}
}
await maybeRequestConsentOrFail(
newExtensionConfig,
this.requestConsent,
previousExtensionConfig,
);
const extensionStorage = new ExtensionStorage(newExtensionName);
const destinationPath = extensionStorage.getExtensionDir();
let previousSettings: Record<string, string> | undefined;
if (isUpdate) {
previousSettings = getEnvContents(extensionStorage);
await this.uninstallExtension(newExtensionName, isUpdate);
}
await fs.promises.mkdir(destinationPath, { recursive: true });
if (this.requestSetting) {
if (isUpdate) {
await maybePromptForSettings(
newExtensionConfig,
this.requestSetting,
previousExtensionConfig,
previousSettings,
);
} else {
await maybePromptForSettings(
newExtensionConfig,
this.requestSetting,
);
}
}
if (
installMetadata.type === 'local' ||
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
await copyExtension(localSourcePath, destinationPath);
}
const metadataString = JSON.stringify(installMetadata, null, 2);
const metadataPath = path.join(
destinationPath,
INSTALL_METADATA_FILENAME,
);
await fs.promises.writeFile(metadataPath, metadataString);
} finally {
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}
if (isUpdate) {
logExtensionUpdateEvent(
this.telemetryConfig,
new ExtensionUpdateEvent(
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
previousExtensionConfig.version,
installMetadata.type,
'success',
),
);
} else {
logExtensionInstallEvent(
this.telemetryConfig,
new ExtensionInstallEvent(
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
installMetadata.type,
'success',
),
);
this.enableExtension(newExtensionConfig.name, SettingScope.User);
}
return newExtensionConfig!.name;
} catch (error) {
// Attempt to load config from the source path even if installation fails
// to get the name and version for logging.
if (!newExtensionConfig && localSourcePath) {
try {
newExtensionConfig = this.loadExtensionConfig(localSourcePath);
} catch {
// Ignore error, this is just for logging.
}
}
const config = newExtensionConfig ?? previousExtensionConfig;
const extensionId = config
? getExtensionId(config, installMetadata)
: undefined;
if (isUpdate) {
logExtensionUpdateEvent(
this.telemetryConfig,
new ExtensionUpdateEvent(
hashValue(config?.name ?? ''),
extensionId ?? '',
newExtensionConfig?.version ?? '',
previousExtensionConfig.version,
installMetadata.type,
'error',
),
);
} else {
logExtensionInstallEvent(
this.telemetryConfig,
new ExtensionInstallEvent(
hashValue(newExtensionConfig?.name ?? ''),
extensionId ?? '',
newExtensionConfig?.version ?? '',
installMetadata.type,
'error',
),
);
}
throw error;
}
}
async uninstallExtension(
extensionIdentifier: string,
isUpdate: boolean,
): Promise<void> {
const installedExtensions = this.loadExtensions();
const extension = installedExtensions.find(
(installed) =>
installed.name.toLowerCase() === extensionIdentifier.toLowerCase() ||
installed.installMetadata?.source.toLowerCase() ===
extensionIdentifier.toLowerCase(),
);
if (!extension) {
throw new Error(`Extension not found.`);
}
const storage = new ExtensionStorage(extension.name);
await fs.promises.rm(storage.getExtensionDir(), {
recursive: true,
force: true,
});
// The rest of the cleanup below here is only for true uninstalls, not
// uninstalls related to updates.
if (isUpdate) return;
this.extensionEnablementManager.remove(extension.name);
logExtensionUninstall(
this.telemetryConfig,
new ExtensionUninstallEvent(
hashValue(extension.name),
extension.id,
'success',
),
);
}
loadExtensions(): GeminiCLIExtension[] {
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
if (!fs.existsSync(extensionsDir)) {
return [];
}
const extensions: GeminiCLIExtension[] = [];
for (const subdir of fs.readdirSync(extensionsDir)) {
const extensionDir = path.join(extensionsDir, subdir);
const extension = this.loadExtension(extensionDir);
if (extension != null) {
extensions.push(extension);
}
}
const uniqueExtensions = new Map<string, GeminiCLIExtension>();
for (const extension of extensions) {
if (!uniqueExtensions.has(extension.name)) {
uniqueExtensions.set(extension.name, extension);
}
}
return Array.from(uniqueExtensions.values());
}
loadExtension(extensionDir: string): GeminiCLIExtension | null {
if (!fs.statSync(extensionDir).isDirectory()) {
return null;
}
const installMetadata = loadInstallMetadata(extensionDir);
let effectiveExtensionPath = extensionDir;
if (installMetadata?.type === 'link') {
effectiveExtensionPath = installMetadata.source;
}
try {
let config = this.loadExtensionConfig(effectiveExtensionPath);
const customEnv = getEnvContents(new ExtensionStorage(config.name));
config = resolveEnvVarsInObject(config, customEnv);
if (config.mcpServers) {
config.mcpServers = Object.fromEntries(
Object.entries(config.mcpServers).map(([key, value]) => [
key,
filterMcpConfig(value),
]),
);
}
const contextFiles = getContextFileNames(config)
.map((contextFileName) =>
path.join(effectiveExtensionPath, contextFileName),
)
.filter((contextFilePath) => fs.existsSync(contextFilePath));
return {
name: config.name,
version: config.version,
path: effectiveExtensionPath,
contextFiles,
installMetadata,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
isActive: this.extensionEnablementManager.isEnabled(
config.name,
this.workspaceDir,
),
id: getExtensionId(config, installMetadata),
};
} catch (e) {
debugLogger.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
e,
)}`,
);
return null;
}
}
loadExtensionByName(name: string): GeminiCLIExtension | null {
const userExtensionsDir = ExtensionStorage.getUserExtensionsDir();
if (!fs.existsSync(userExtensionsDir)) {
return null;
}
for (const subdir of fs.readdirSync(userExtensionsDir)) {
const extensionDir = path.join(userExtensionsDir, subdir);
if (!fs.statSync(extensionDir).isDirectory()) {
continue;
}
const extension = this.loadExtension(extensionDir);
if (extension && extension.name.toLowerCase() === name.toLowerCase()) {
return extension;
}
}
return null;
}
loadExtensionConfig(extensionDir: string): ExtensionConfig {
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (!fs.existsSync(configFilePath)) {
throw new Error(`Configuration file not found at ${configFilePath}`);
}
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const rawConfig = JSON.parse(configContent) as ExtensionConfig;
if (!rawConfig.name || !rawConfig.version) {
throw new Error(
`Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`,
);
}
const installDir = new ExtensionStorage(rawConfig.name).getExtensionDir();
const config = recursivelyHydrateStrings(
rawConfig as unknown as JsonObject,
{
extensionPath: installDir,
workspacePath: this.workspaceDir,
'/': path.sep,
pathSeparator: path.sep,
},
) as unknown as ExtensionConfig;
validateName(config.name);
return config;
} catch (e) {
throw new Error(
`Failed to load extension config from ${configFilePath}: ${getErrorMessage(
e,
)}`,
);
}
}
toOutputString(extension: GeminiCLIExtension): string {
const userEnabled = this.extensionEnablementManager.isEnabled(
extension.name,
os.homedir(),
);
const workspaceEnabled = this.extensionEnablementManager.isEnabled(
extension.name,
this.workspaceDir,
);
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
let output = `${status} ${extension.name} (${extension.version})`;
output += `\n ID: ${extension.id}`;
output += `\n Path: ${extension.path}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
if (extension.installMetadata.ref) {
output += `\n Ref: ${extension.installMetadata.ref}`;
}
if (extension.installMetadata.releaseTag) {
output += `\n Release tag: ${extension.installMetadata.releaseTag}`;
}
}
output += `\n Enabled (User): ${userEnabled}`;
output += `\n Enabled (Workspace): ${workspaceEnabled}`;
if (extension.contextFiles.length > 0) {
output += `\n Context files:`;
extension.contextFiles.forEach((contextFile) => {
output += `\n ${contextFile}`;
});
}
if (extension.mcpServers) {
output += `\n MCP servers:`;
Object.keys(extension.mcpServers).forEach((key) => {
output += `\n ${key}`;
});
}
if (extension.excludeTools) {
output += `\n Excluded tools:`;
extension.excludeTools.forEach((tool) => {
output += `\n ${tool}`;
});
}
return output;
}
disableExtension(name: string, scope: SettingScope) {
if (
scope === SettingScope.System ||
scope === SettingScope.SystemDefaults
) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = this.loadExtensionByName(name);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const scopePath =
scope === SettingScope.Workspace ? this.workspaceDir : os.homedir();
this.extensionEnablementManager.disable(name, true, scopePath);
logExtensionDisable(
this.telemetryConfig,
new ExtensionDisableEvent(hashValue(name), extension.id, scope),
);
}
enableExtension(name: string, scope: SettingScope) {
if (
scope === SettingScope.System ||
scope === SettingScope.SystemDefaults
) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = this.loadExtensionByName(name);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const scopePath =
scope === SettingScope.Workspace ? this.workspaceDir : os.homedir();
this.extensionEnablementManager.enable(name, true, scopePath);
logExtensionEnable(
this.telemetryConfig,
new ExtensionEnableEvent(hashValue(name), extension.id, scope),
);
}
}
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { trust, ...rest } = original;
return Object.freeze(rest);
}
export async function copyExtension(
source: string,
destination: string,
): Promise<void> {
await fs.promises.cp(source, destination, { recursive: true });
}
function getContextFileNames(config: ExtensionConfig): string[] {
if (!config.contextFileName) {
return ['GEMINI.md'];
} else if (!Array.isArray(config.contextFileName)) {
return [config.contextFileName];
}
return config.contextFileName;
}
function validateName(name: string) {
if (!/^[a-zA-Z0-9-]+$/.test(name)) {
throw new Error(
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`,
);
}
}
export function getExtensionId(
config: ExtensionConfig,
installMetadata?: ExtensionInstallMetadata,
): string {
// IDs are created by hashing details of the installation source in order to
// deduplicate extensions with conflicting names and also obfuscate any
// potentially sensitive information such as private git urls, system paths,
// or project names.
let idValue = config.name;
const githubUrlParts =
installMetadata &&
(installMetadata.type === 'git' ||
installMetadata.type === 'github-release')
? tryParseGithubUrl(installMetadata.source)
: null;
if (githubUrlParts) {
// For github repos, we use the https URI to the repo as the ID.
idValue = `https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`;
} else {
idValue = installMetadata?.source ?? config.name;
}
return hashValue(idValue);
}
export function hashValue(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
File diff suppressed because it is too large Load Diff
+2 -844
View File
@@ -6,61 +6,12 @@
import type {
MCPServerConfig,
GeminiCLIExtension,
ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import {
GEMINI_DIR,
Storage,
Config,
ExtensionInstallEvent,
ExtensionUninstallEvent,
ExtensionUpdateEvent,
ExtensionDisableEvent,
ExtensionEnableEvent,
logExtensionEnable,
logExtensionInstallEvent,
logExtensionUninstall,
logExtensionUpdateEvent,
logExtensionDisable,
debugLogger,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { SettingScope, loadSettings } from '../config/settings.js';
import { getErrorMessage } from '../utils/errors.js';
import {
recursivelyHydrateStrings,
type JsonObject,
} from './extensions/variables.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { randomUUID, createHash } from 'node:crypto';
import {
cloneFromGit,
downloadFromGitHubRelease,
tryParseGithubUrl,
} from './extensions/github.js';
import type { LoadExtensionContext } from './extensions/variableSchema.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import chalk from 'chalk';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import type { ConfirmationRequest } from '../ui/types.js';
import { escapeAnsiCtrlCodes } from '../ui/utils/textUtils.js';
import {
getEnvContents,
maybePromptForSettings,
type ExtensionSetting,
} from './extensions/extensionSettings.js';
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
export const EXTENSION_SETTINGS_FILENAME = '.env';
export const INSTALL_WARNING_MESSAGE =
'**The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.**';
import { INSTALL_METADATA_FILENAME } from './extensions/variables.js';
import type { ExtensionSetting } from './extensions/extensionSettings.js';
/**
* Extension definition as written to disk in gemini-extension.json files.
@@ -84,190 +35,6 @@ export interface ExtensionUpdateInfo {
updatedVersion: string;
}
export class ExtensionStorage {
private readonly extensionName: string;
constructor(extensionName: string) {
this.extensionName = extensionName;
}
getExtensionDir(): string {
return path.join(
ExtensionStorage.getUserExtensionsDir(),
this.extensionName,
);
}
getConfigPath(): string {
return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME);
}
getEnvFilePath(): string {
return path.join(this.getExtensionDir(), EXTENSION_SETTINGS_FILENAME);
}
static getUserExtensionsDir(): string {
const storage = new Storage(os.homedir());
return storage.getExtensionsDir();
}
static async createTmpDir(): Promise<string> {
return await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'gemini-extension'),
);
}
}
export async function copyExtension(
source: string,
destination: string,
): Promise<void> {
await fs.promises.cp(source, destination, { recursive: true });
}
function getTelemetryConfig(cwd: string) {
const settings = loadSettings(cwd);
const config = new Config({
telemetry: settings.merged.telemetry,
interactive: false,
sessionId: randomUUID(),
targetDir: cwd,
cwd,
model: '',
debugMode: false,
});
return config;
}
export function loadExtensions(
extensionEnablementManager: ExtensionEnablementManager,
workspaceDir: string = process.cwd(),
): GeminiCLIExtension[] {
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
if (!fs.existsSync(extensionsDir)) {
return [];
}
const extensions: GeminiCLIExtension[] = [];
for (const subdir of fs.readdirSync(extensionsDir)) {
const extensionDir = path.join(extensionsDir, subdir);
const extension = loadExtension({
extensionDir,
workspaceDir,
extensionEnablementManager,
});
if (extension != null) {
extensions.push(extension);
}
}
const uniqueExtensions = new Map<string, GeminiCLIExtension>();
for (const extension of extensions) {
if (!uniqueExtensions.has(extension.name)) {
uniqueExtensions.set(extension.name, extension);
}
}
return Array.from(uniqueExtensions.values());
}
export function loadExtension(
context: LoadExtensionContext,
): GeminiCLIExtension | null {
const { extensionDir, workspaceDir, extensionEnablementManager } = context;
if (!fs.statSync(extensionDir).isDirectory()) {
return null;
}
const installMetadata = loadInstallMetadata(extensionDir);
let effectiveExtensionPath = extensionDir;
if (installMetadata?.type === 'link') {
effectiveExtensionPath = installMetadata.source;
}
try {
let config = loadExtensionConfig({
extensionDir: effectiveExtensionPath,
workspaceDir,
extensionEnablementManager,
});
const customEnv = getEnvContents(new ExtensionStorage(config.name));
config = resolveEnvVarsInObject(config, customEnv);
if (config.mcpServers) {
config.mcpServers = Object.fromEntries(
Object.entries(config.mcpServers).map(([key, value]) => [
key,
filterMcpConfig(value),
]),
);
}
const contextFiles = getContextFileNames(config)
.map((contextFileName) =>
path.join(effectiveExtensionPath, contextFileName),
)
.filter((contextFilePath) => fs.existsSync(contextFilePath));
return {
name: config.name,
version: config.version,
path: effectiveExtensionPath,
contextFiles,
installMetadata,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
isActive: extensionEnablementManager.isEnabled(config.name, workspaceDir),
id: getExtensionId(config, installMetadata),
};
} catch (e) {
debugLogger.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
e,
)}`,
);
return null;
}
}
export function loadExtensionByName(
name: string,
extensionEnablementManager: ExtensionEnablementManager,
workspaceDir: string = process.cwd(),
): GeminiCLIExtension | null {
const userExtensionsDir = ExtensionStorage.getUserExtensionsDir();
if (!fs.existsSync(userExtensionsDir)) {
return null;
}
for (const subdir of fs.readdirSync(userExtensionsDir)) {
const extensionDir = path.join(userExtensionsDir, subdir);
if (!fs.statSync(extensionDir).isDirectory()) {
continue;
}
const extension = loadExtension({
extensionDir,
workspaceDir,
extensionEnablementManager,
});
if (extension && extension.name.toLowerCase() === name.toLowerCase()) {
return extension;
}
}
return null;
}
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { trust, ...rest } = original;
return Object.freeze(rest);
}
export function loadInstallMetadata(
extensionDir: string,
): ExtensionInstallMetadata | undefined {
@@ -280,612 +47,3 @@ export function loadInstallMetadata(
return undefined;
}
}
function getContextFileNames(config: ExtensionConfig): string[] {
if (!config.contextFileName) {
return ['GEMINI.md'];
} else if (!Array.isArray(config.contextFileName)) {
return [config.contextFileName];
}
return config.contextFileName;
}
/**
* Requests consent from the user to perform an action, by reading a Y/n
* character from stdin.
*
* This should not be called from interactive mode as it will break the CLI.
*
* @param consentDescription The description of the thing they will be consenting to.
* @returns boolean, whether they consented or not.
*/
export async function requestConsentNonInteractive(
consentDescription: string,
): Promise<boolean> {
debugLogger.log(consentDescription);
const result = await promptForConsentNonInteractive(
'Do you want to continue? [Y/n]: ',
);
return result;
}
/**
* Requests consent from the user to perform an action, in interactive mode.
*
* This should not be called from non-interactive mode as it will not work.
*
* @param consentDescription The description of the thing they will be consenting to.
* @param setExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
* @returns boolean, whether they consented or not.
*/
export async function requestConsentInteractive(
consentDescription: string,
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
): Promise<boolean> {
return await promptForConsentInteractive(
consentDescription + '\n\nDo you want to continue?',
addExtensionUpdateConfirmationRequest,
);
}
/**
* Asks users a prompt and awaits for a y/n response on stdin.
*
* This should not be called from interactive mode as it will break the CLI.
*
* @param prompt A yes/no prompt to ask the user
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
*/
async function promptForConsentNonInteractive(
prompt: string,
): Promise<boolean> {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(['y', ''].includes(answer.trim().toLowerCase()));
});
});
}
/**
* Asks users an interactive yes/no prompt.
*
* This should not be called from non-interactive mode as it will break the CLI.
*
* @param prompt A markdown prompt to ask the user
* @param setExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
* @returns Whether or not the user answers yes.
*/
async function promptForConsentInteractive(
prompt: string,
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
addExtensionUpdateConfirmationRequest({
prompt,
onConfirm: (resolvedConfirmed) => {
resolve(resolvedConfirmed);
},
});
});
}
export function hashValue(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
export async function installOrUpdateExtension(
installMetadata: ExtensionInstallMetadata,
requestConsent: (consent: string) => Promise<boolean>,
cwd: string = process.cwd(),
previousExtensionConfig?: ExtensionConfig,
requestSetting?: (setting: ExtensionSetting) => Promise<string>,
): Promise<string> {
const isUpdate = !!previousExtensionConfig;
const telemetryConfig = getTelemetryConfig(cwd);
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
const extensionEnablementManager = new ExtensionEnablementManager();
// path.join(tempDir, EXTENSION_SETTINGS_FILENAME)
try {
const settings = loadSettings(cwd).merged;
if (!isWorkspaceTrusted(settings).isTrusted) {
throw new Error(
`Could not install extension from untrusted folder at ${installMetadata.source}`,
);
}
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true });
if (
!path.isAbsolute(installMetadata.source) &&
(installMetadata.type === 'local' || installMetadata.type === 'link')
) {
installMetadata.source = path.resolve(cwd, installMetadata.source);
}
let tempDir: string | undefined;
if (
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
tempDir = await ExtensionStorage.createTmpDir();
const parsedGithubParts = tryParseGithubUrl(installMetadata.source);
if (!parsedGithubParts) {
await cloneFromGit(installMetadata, tempDir);
installMetadata.type = 'git';
} else {
const result = await downloadFromGitHubRelease(
installMetadata,
tempDir,
parsedGithubParts,
);
if (result.success) {
installMetadata.type = result.type;
installMetadata.releaseTag = result.tagName;
} else if (
// This repo has no github releases, and wasn't explicitly installed
// from a github release, unconditionally just clone it.
(result.failureReason === 'no release data' &&
installMetadata.type === 'git') ||
// Otherwise ask the user if they would like to try a git clone.
(await requestConsent(
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.\n\nWould you like to attempt to install via "git clone" instead?`,
))
) {
await cloneFromGit(installMetadata, tempDir);
installMetadata.type = 'git';
} else {
throw new Error(
`Failed to install extension ${installMetadata.source}: ${result.errorMessage}`,
);
}
}
localSourcePath = tempDir;
} else if (
installMetadata.type === 'local' ||
installMetadata.type === 'link'
) {
localSourcePath = installMetadata.source;
} else {
throw new Error(`Unsupported install type: ${installMetadata.type}`);
}
try {
newExtensionConfig = loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: cwd,
extensionEnablementManager,
});
if (isUpdate && previousExtensionConfig && installMetadata.autoUpdate) {
const oldSettings = new Set(
previousExtensionConfig.settings?.map((s) => s.name) || [],
);
const newSettings = new Set(
newExtensionConfig.settings?.map((s) => s.name) || [],
);
const settingsAreEqual =
oldSettings.size === newSettings.size &&
[...oldSettings].every((value) => newSettings.has(value));
if (!settingsAreEqual) {
throw new Error(
`Extension "${newExtensionConfig.name}" has settings changes and cannot be auto-updated. Please update manually.`,
);
}
}
const newExtensionName = newExtensionConfig.name;
if (!isUpdate) {
const installedExtensions = loadExtensions(
new ExtensionEnablementManager(),
cwd,
);
if (
installedExtensions.some(
(installed) => installed.name === newExtensionName,
)
) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
}
}
await maybeRequestConsentOrFail(
newExtensionConfig,
requestConsent,
previousExtensionConfig,
);
const extensionStorage = new ExtensionStorage(newExtensionName);
const destinationPath = extensionStorage.getExtensionDir();
let previousSettings: Record<string, string> | undefined;
if (isUpdate) {
previousSettings = getEnvContents(extensionStorage);
await uninstallExtension(newExtensionName, isUpdate, cwd);
}
await fs.promises.mkdir(destinationPath, { recursive: true });
if (requestSetting !== undefined) {
if (isUpdate && previousExtensionConfig) {
await maybePromptForSettings(
newExtensionConfig,
requestSetting,
previousExtensionConfig,
previousSettings,
);
} else if (!isUpdate) {
await maybePromptForSettings(newExtensionConfig, requestSetting);
}
}
if (
installMetadata.type === 'local' ||
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
await copyExtension(localSourcePath, destinationPath);
}
const metadataString = JSON.stringify(installMetadata, null, 2);
const metadataPath = path.join(
destinationPath,
INSTALL_METADATA_FILENAME,
);
await fs.promises.writeFile(metadataPath, metadataString);
} finally {
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}
if (isUpdate) {
logExtensionUpdateEvent(
telemetryConfig,
new ExtensionUpdateEvent(
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
previousExtensionConfig.version,
installMetadata.type,
'success',
),
);
} else {
logExtensionInstallEvent(
telemetryConfig,
new ExtensionInstallEvent(
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
installMetadata.type,
'success',
),
);
enableExtension(
newExtensionConfig.name,
SettingScope.User,
extensionEnablementManager,
);
}
return newExtensionConfig!.name;
} catch (error) {
// Attempt to load config from the source path even if installation fails
// to get the name and version for logging.
if (!newExtensionConfig && localSourcePath) {
try {
newExtensionConfig = loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: cwd,
extensionEnablementManager,
});
} catch {
// Ignore error, this is just for logging.
}
}
const config = newExtensionConfig ?? previousExtensionConfig;
const extensionId = config
? getExtensionId(config, installMetadata)
: undefined;
if (isUpdate) {
logExtensionUpdateEvent(
telemetryConfig,
new ExtensionUpdateEvent(
hashValue(config?.name ?? ''),
extensionId ?? '',
newExtensionConfig?.version ?? '',
previousExtensionConfig.version,
installMetadata.type,
'error',
),
);
} else {
logExtensionInstallEvent(
telemetryConfig,
new ExtensionInstallEvent(
hashValue(newExtensionConfig?.name ?? ''),
extensionId ?? '',
newExtensionConfig?.version ?? '',
installMetadata.type,
'error',
),
);
}
throw error;
}
}
/**
* Builds a consent string for installing an extension based on it's
* extensionConfig.
*/
function extensionConsentString(extensionConfig: ExtensionConfig): string {
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
const output: string[] = [];
const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
output.push(`Installing extension "${sanitizedConfig.name}".`);
output.push(INSTALL_WARNING_MESSAGE);
if (mcpServerEntries.length) {
output.push('This extension will run the following MCP servers:');
for (const [key, mcpServer] of mcpServerEntries) {
const isLocal = !!mcpServer.command;
const source =
mcpServer.httpUrl ??
`${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`;
output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`);
}
}
if (sanitizedConfig.contextFileName) {
output.push(
`This extension will append info to your gemini.md context using ${sanitizedConfig.contextFileName}`,
);
}
if (sanitizedConfig.excludeTools) {
output.push(
`This extension will exclude the following core tools: ${sanitizedConfig.excludeTools}`,
);
}
return output.join('\n');
}
/**
* Requests consent from the user to install an extension (extensionConfig), if
* there is any difference between the consent string for `extensionConfig` and
* `previousExtensionConfig`.
*
* Always requests consent if previousExtensionConfig is null.
*
* Throws if the user does not consent.
*/
async function maybeRequestConsentOrFail(
extensionConfig: ExtensionConfig,
requestConsent: (consent: string) => Promise<boolean>,
previousExtensionConfig?: ExtensionConfig,
) {
const extensionConsent = extensionConsentString(extensionConfig);
if (previousExtensionConfig) {
const previousExtensionConsent = extensionConsentString(
previousExtensionConfig,
);
if (previousExtensionConsent === extensionConsent) {
return;
}
}
if (!(await requestConsent(extensionConsent))) {
throw new Error(`Installation cancelled for "${extensionConfig.name}".`);
}
}
export function validateName(name: string) {
if (!/^[a-zA-Z0-9-]+$/.test(name)) {
throw new Error(
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`,
);
}
}
export function loadExtensionConfig(
context: LoadExtensionContext,
): ExtensionConfig {
const { extensionDir, workspaceDir } = context;
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (!fs.existsSync(configFilePath)) {
throw new Error(`Configuration file not found at ${configFilePath}`);
}
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const rawConfig = JSON.parse(configContent) as ExtensionConfig;
if (!rawConfig.name || !rawConfig.version) {
throw new Error(
`Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`,
);
}
const installDir = new ExtensionStorage(rawConfig.name).getExtensionDir();
const config = recursivelyHydrateStrings(
rawConfig as unknown as JsonObject,
{
extensionPath: installDir,
workspacePath: workspaceDir,
'/': path.sep,
pathSeparator: path.sep,
},
) as unknown as ExtensionConfig;
validateName(config.name);
return config;
} catch (e) {
throw new Error(
`Failed to load extension config from ${configFilePath}: ${getErrorMessage(
e,
)}`,
);
}
}
export async function uninstallExtension(
extensionIdentifier: string,
isUpdate: boolean,
cwd: string = process.cwd(),
): Promise<void> {
const installedExtensions = loadExtensions(
new ExtensionEnablementManager(),
cwd,
);
const extension = installedExtensions.find(
(installed) =>
installed.name.toLowerCase() === extensionIdentifier.toLowerCase() ||
installed.installMetadata?.source.toLowerCase() ===
extensionIdentifier.toLowerCase(),
);
if (!extension) {
throw new Error(`Extension not found.`);
}
const storage = new ExtensionStorage(extension.name);
await fs.promises.rm(storage.getExtensionDir(), {
recursive: true,
force: true,
});
// The rest of the cleanup below here is only for true uninstalls, not
// uninstalls related to updates.
if (isUpdate) return;
const manager = new ExtensionEnablementManager([extension.name]);
manager.remove(extension.name);
const telemetryConfig = getTelemetryConfig(cwd);
logExtensionUninstall(
telemetryConfig,
new ExtensionUninstallEvent(
hashValue(extension.name),
extension.id,
'success',
),
);
}
export function toOutputString(
extension: GeminiCLIExtension,
workspaceDir: string,
): string {
const manager = new ExtensionEnablementManager();
const userEnabled = manager.isEnabled(extension.name, os.homedir());
const workspaceEnabled = manager.isEnabled(extension.name, workspaceDir);
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
let output = `${status} ${extension.name} (${extension.version})`;
output += `\n ID: ${extension.id}`;
output += `\n Path: ${extension.path}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
if (extension.installMetadata.ref) {
output += `\n Ref: ${extension.installMetadata.ref}`;
}
if (extension.installMetadata.releaseTag) {
output += `\n Release tag: ${extension.installMetadata.releaseTag}`;
}
}
output += `\n Enabled (User): ${userEnabled}`;
output += `\n Enabled (Workspace): ${workspaceEnabled}`;
if (extension.contextFiles.length > 0) {
output += `\n Context files:`;
extension.contextFiles.forEach((contextFile) => {
output += `\n ${contextFile}`;
});
}
if (extension.mcpServers) {
output += `\n MCP servers:`;
Object.keys(extension.mcpServers).forEach((key) => {
output += `\n ${key}`;
});
}
if (extension.excludeTools) {
output += `\n Excluded tools:`;
extension.excludeTools.forEach((tool) => {
output += `\n ${tool}`;
});
}
return output;
}
export function disableExtension(
name: string,
scope: SettingScope,
extensionEnablementManager: ExtensionEnablementManager,
cwd: string = process.cwd(),
) {
const config = getTelemetryConfig(cwd);
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = loadExtensionByName(name, extensionEnablementManager, cwd);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
extensionEnablementManager.disable(name, true, scopePath);
logExtensionDisable(
config,
new ExtensionDisableEvent(hashValue(name), extension.id, scope),
);
}
export function enableExtension(
name: string,
scope: SettingScope,
extensionEnablementManager: ExtensionEnablementManager,
cwd: string = process.cwd(),
) {
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = loadExtensionByName(name, extensionEnablementManager, cwd);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
extensionEnablementManager.enable(name, true, scopePath);
const config = getTelemetryConfig(cwd);
logExtensionEnable(
config,
new ExtensionEnableEvent(hashValue(name), extension.id, scope),
);
}
function getExtensionId(
config: ExtensionConfig,
installMetadata?: ExtensionInstallMetadata,
): string {
// IDs are created by hashing details of the installation source in order to
// deduplicate extensions with conflicting names and also obfuscate any
// potentially sensitive information such as private git urls, system paths,
// or project names.
let idValue = config.name;
const githubUrlParts =
installMetadata &&
(installMetadata.type === 'git' ||
installMetadata.type === 'github-release')
? tryParseGithubUrl(installMetadata.source)
: null;
if (githubUrlParts) {
// For github repos, we use the https URI to the repo as the ID.
idValue = `https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`;
} else {
idValue = installMetadata?.source ?? config.name;
}
return hashValue(idValue);
}
@@ -0,0 +1,162 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import type { ConfirmationRequest } from '../../ui/types.js';
import { escapeAnsiCtrlCodes } from '../../ui/utils/textUtils.js';
import type { ExtensionConfig } from '../extension.js';
export const INSTALL_WARNING_MESSAGE =
'**The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.**';
/**
* Requests consent from the user to perform an action, by reading a Y/n
* character from stdin.
*
* This should not be called from interactive mode as it will break the CLI.
*
* @param consentDescription The description of the thing they will be consenting to.
* @returns boolean, whether they consented or not.
*/
export async function requestConsentNonInteractive(
consentDescription: string,
): Promise<boolean> {
debugLogger.log(consentDescription);
const result = await promptForConsentNonInteractive(
'Do you want to continue? [Y/n]: ',
);
return result;
}
/**
* Requests consent from the user to perform an action, in interactive mode.
*
* This should not be called from non-interactive mode as it will not work.
*
* @param consentDescription The description of the thing they will be consenting to.
* @param setExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
* @returns boolean, whether they consented or not.
*/
export async function requestConsentInteractive(
consentDescription: string,
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
): Promise<boolean> {
return await promptForConsentInteractive(
consentDescription + '\n\nDo you want to continue?',
addExtensionUpdateConfirmationRequest,
);
}
/**
* Asks users a prompt and awaits for a y/n response on stdin.
*
* This should not be called from interactive mode as it will break the CLI.
*
* @param prompt A yes/no prompt to ask the user
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
*/
async function promptForConsentNonInteractive(
prompt: string,
): Promise<boolean> {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(['y', ''].includes(answer.trim().toLowerCase()));
});
});
}
/**
* Asks users an interactive yes/no prompt.
*
* This should not be called from non-interactive mode as it will break the CLI.
*
* @param prompt A markdown prompt to ask the user
* @param setExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
* @returns Whether or not the user answers yes.
*/
async function promptForConsentInteractive(
prompt: string,
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
addExtensionUpdateConfirmationRequest({
prompt,
onConfirm: (resolvedConfirmed) => {
resolve(resolvedConfirmed);
},
});
});
}
/**
* Builds a consent string for installing an extension based on it's
* extensionConfig.
*/
function extensionConsentString(extensionConfig: ExtensionConfig): string {
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
const output: string[] = [];
const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
output.push(`Installing extension "${sanitizedConfig.name}".`);
output.push(INSTALL_WARNING_MESSAGE);
if (mcpServerEntries.length) {
output.push('This extension will run the following MCP servers:');
for (const [key, mcpServer] of mcpServerEntries) {
const isLocal = !!mcpServer.command;
const source =
mcpServer.httpUrl ??
`${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`;
output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`);
}
}
if (sanitizedConfig.contextFileName) {
output.push(
`This extension will append info to your gemini.md context using ${sanitizedConfig.contextFileName}`,
);
}
if (sanitizedConfig.excludeTools) {
output.push(
`This extension will exclude the following core tools: ${sanitizedConfig.excludeTools}`,
);
}
return output.join('\n');
}
/**
* Requests consent from the user to install an extension (extensionConfig), if
* there is any difference between the consent string for `extensionConfig` and
* `previousExtensionConfig`.
*
* Always requests consent if previousExtensionConfig is null.
*
* Throws if the user does not consent.
*/
export async function maybeRequestConsentOrFail(
extensionConfig: ExtensionConfig,
requestConsent: (consent: string) => Promise<boolean>,
previousExtensionConfig?: ExtensionConfig,
) {
const extensionConsent = extensionConsentString(extensionConfig);
if (previousExtensionConfig) {
const previousExtensionConsent = extensionConsentString(
previousExtensionConfig,
);
if (previousExtensionConsent === extensionConsent) {
return;
}
}
if (!(await requestConsent(extensionConsent))) {
throw new Error(`Installation cancelled for "${extensionConfig.name}".`);
}
}
@@ -7,7 +7,7 @@
import fs from 'node:fs';
import path from 'node:path';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
import { ExtensionStorage } from '../extension.js';
import { ExtensionStorage } from './storage.js';
export interface ExtensionEnablementConfig {
overrides: string[];
@@ -13,7 +13,7 @@ import {
type ExtensionSetting,
} from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from '../extension.js';
import { ExtensionStorage } from './storage.js';
import prompts from 'prompts';
import * as fsPromises from 'node:fs/promises';
import * as fs from 'node:fs';
@@ -8,7 +8,7 @@ import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as dotenv from 'dotenv';
import { ExtensionStorage } from '../extension.js';
import { ExtensionStorage } from './storage.js';
import type { ExtensionConfig } from '../extension.js';
import prompts from 'prompts';
@@ -4,7 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockedFunction,
} from 'vitest';
import {
checkForExtensionUpdate,
cloneFromGit,
@@ -22,7 +30,9 @@ import * as path from 'node:path';
import * as tar from 'tar';
import * as archiver from 'archiver';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
import { ExtensionEnablementManager } from './extensionEnablement.js';
import { ExtensionManager } from '../extension-manager.js';
import { loadSettings } from '../settings.js';
import type { ExtensionSetting } from './extensionSettings.js';
const mockPlatform = vi.hoisted(() => vi.fn());
const mockArch = vi.hoisted(() => vi.fn());
@@ -134,8 +144,34 @@ describe('git extension helpers', () => {
revparse: vi.fn(),
};
let extensionManager: ExtensionManager;
let mockRequestConsent: MockedFunction<
(consent: string) => Promise<boolean>
>;
let mockPromptForSettings: MockedFunction<
(setting: ExtensionSetting) => Promise<string>
>;
let tempHomeDir: string;
let tempWorkspaceDir: string;
beforeEach(() => {
tempHomeDir = fsSync.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fsSync.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
);
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
mockRequestConsent = vi.fn();
mockRequestConsent.mockResolvedValue(true);
mockPromptForSettings = vi.fn();
mockPromptForSettings.mockResolvedValue('');
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
loadedSettings: loadSettings(tempWorkspaceDir),
});
});
it('should return NOT_UPDATABLE for non-git extensions', async () => {
@@ -151,10 +187,7 @@ describe('git extension helpers', () => {
},
contextFiles: [],
};
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
const result = await checkForExtensionUpdate(extension, extensionManager);
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
});
@@ -172,10 +205,7 @@ describe('git extension helpers', () => {
contextFiles: [],
};
mockGit.getRemotes.mockResolvedValue([]);
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
const result = await checkForExtensionUpdate(extension, extensionManager);
expect(result).toBe(ExtensionUpdateState.ERROR);
});
@@ -198,10 +228,7 @@ describe('git extension helpers', () => {
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.revparse.mockResolvedValue('local-hash');
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
const result = await checkForExtensionUpdate(extension, extensionManager);
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
});
@@ -224,10 +251,7 @@ describe('git extension helpers', () => {
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
mockGit.revparse.mockResolvedValue('same-hash');
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
const result = await checkForExtensionUpdate(extension, extensionManager);
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
});
@@ -246,10 +270,7 @@ describe('git extension helpers', () => {
};
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
const result = await checkForExtensionUpdate(extension, extensionManager);
expect(result).toBe(ExtensionUpdateState.ERROR);
});
});
+4 -9
View File
@@ -16,11 +16,11 @@ 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 { EXTENSIONS_CONFIG_FILENAME, loadExtension } from '../extension.js';
import * as tar from 'tar';
import extract from 'extract-zip';
import { fetchJson, getGitHubToken } from './github_fetch.js';
import { type ExtensionEnablementManager } from './extensionEnablement.js';
import type { ExtensionManager } from '../extension-manager.js';
import { EXTENSIONS_CONFIG_FILENAME } from './variables.js';
/**
* Clones a Git repository to a specified local path.
@@ -153,16 +153,11 @@ export async function fetchReleaseFromGithub(
export async function checkForExtensionUpdate(
extension: GeminiCLIExtension,
extensionEnablementManager: ExtensionEnablementManager,
cwd: string = process.cwd(),
extensionManager: ExtensionManager,
): Promise<ExtensionUpdateState> {
const installMetadata = extension.installMetadata;
if (installMetadata?.type === 'local') {
const newExtension = loadExtension({
extensionDir: installMetadata.source,
workspaceDir: cwd,
extensionEnablementManager,
});
const newExtension = extensionManager.loadExtension(installMetadata.source);
if (!newExtension) {
debugLogger.error(
`Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`,
@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import {
EXTENSION_SETTINGS_FILENAME,
EXTENSIONS_CONFIG_FILENAME,
} from './variables.js';
import { Storage } from '@google/gemini-cli-core';
export class ExtensionStorage {
private readonly extensionName: string;
constructor(extensionName: string) {
this.extensionName = extensionName;
}
getExtensionDir(): string {
return path.join(
ExtensionStorage.getUserExtensionsDir(),
this.extensionName,
);
}
getConfigPath(): string {
return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME);
}
getEnvFilePath(): string {
return path.join(this.getExtensionDir(), EXTENSION_SETTINGS_FILENAME);
}
static getUserExtensionsDir(): string {
return new Storage(os.homedir()).getExtensionsDir();
}
static async createTmpDir(): Promise<string> {
return await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'gemini-extension'),
);
}
}
@@ -4,21 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import { vi, type MockedFunction } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
loadExtension,
} from '../extension.js';
import { checkForAllExtensionUpdates, updateExtension } from './update.js';
import { GEMINI_DIR } from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from '../trustedFolders.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { createExtension } from '../../test-utils/createExtension.js';
import { ExtensionEnablementManager } from './extensionEnablement.js';
import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
} from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
import { loadSettings } from '../settings.js';
import type { ExtensionSetting } from './extensionSettings.js';
const mockGit = {
clone: vi.fn(),
@@ -74,6 +75,11 @@ describe('update tests', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string;
let extensionManager: ExtensionManager;
let mockRequestConsent: MockedFunction<(consent: string) => Promise<boolean>>;
let mockPromptForSettings: MockedFunction<
(setting: ExtensionSetting) => Promise<string>
>;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
@@ -93,6 +99,16 @@ describe('update tests', () => {
});
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
Object.values(mockGit).forEach((fn) => fn.mockReset());
mockRequestConsent = vi.fn();
mockRequestConsent.mockResolvedValue(true);
mockPromptForSettings = vi.fn();
mockPromptForSettings.mockResolvedValue('');
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
loadedSettings: loadSettings(tempWorkspaceDir),
});
});
afterEach(() => {
@@ -127,17 +143,10 @@ describe('update tests', () => {
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir: targetExtDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const extension = extensionManager.loadExtension(targetExtDir)!;
const updateInfo = await updateExtension(
extension,
extensionEnablementManager,
tempHomeDir,
async (_) => true,
extensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
() => {},
);
@@ -181,17 +190,10 @@ describe('update tests', () => {
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const extension = extensionManager.loadExtension(extensionDir)!;
await updateExtension(
extension,
extensionEnablementManager,
tempHomeDir,
async (_) => true,
extensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
dispatch,
);
@@ -228,18 +230,11 @@ describe('update tests', () => {
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const extension = extensionManager.loadExtension(extensionDir)!;
await expect(
updateExtension(
extension,
extensionEnablementManager,
tempHomeDir,
async (_) => true,
extensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
dispatch,
),
@@ -273,12 +268,7 @@ describe('update tests', () => {
type: 'git',
},
});
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const extension = extensionManager.loadExtension(extensionDir)!;
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
@@ -289,9 +279,8 @@ describe('update tests', () => {
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
extensionManager,
dispatch,
tempWorkspaceDir,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
@@ -312,12 +301,7 @@ describe('update tests', () => {
type: 'git',
},
});
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const extension = extensionManager.loadExtension(extensionDir)!;
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
@@ -328,9 +312,8 @@ describe('update tests', () => {
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
extensionManager,
dispatch,
tempWorkspaceDir,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
@@ -355,18 +338,12 @@ describe('update tests', () => {
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir: installedExtensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const extension = extensionManager.loadExtension(installedExtensionDir)!;
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
extensionManager,
dispatch,
tempWorkspaceDir,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
@@ -391,18 +368,12 @@ describe('update tests', () => {
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir: installedExtensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const extension = extensionManager.loadExtension(installedExtensionDir)!;
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
extensionManager,
dispatch,
tempWorkspaceDir,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
@@ -423,21 +394,15 @@ describe('update tests', () => {
type: 'git',
},
});
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const extension = extensionManager.loadExtension(extensionDir)!;
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
extensionManager,
dispatch,
tempWorkspaceDir,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
+19 -42
View File
@@ -9,20 +9,13 @@ import {
ExtensionUpdateState,
type ExtensionUpdateStatus,
} from '../../ui/state/extensions.js';
import {
copyExtension,
installOrUpdateExtension,
loadExtension,
loadInstallMetadata,
ExtensionStorage,
loadExtensionConfig,
} from '../extension.js';
import { loadInstallMetadata } from '../extension.js';
import { checkForExtensionUpdate } from './github.js';
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import { getErrorMessage } from '../../utils/errors.js';
import { type ExtensionEnablementManager } from './extensionEnablement.js';
import { promptForSetting } from './extensionSettings.js';
import { copyExtension, type ExtensionManager } from '../extension-manager.js';
import { ExtensionStorage } from './storage.js';
export interface ExtensionUpdateInfo {
name: string;
@@ -32,9 +25,7 @@ export interface ExtensionUpdateInfo {
export async function updateExtension(
extension: GeminiCLIExtension,
extensionEnablementManager: ExtensionEnablementManager,
cwd: string = process.cwd(),
requestConsent: (consent: string) => Promise<boolean>,
extensionManager: ExtensionManager,
currentState: ExtensionUpdateState,
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void,
): Promise<ExtensionUpdateInfo | undefined> {
@@ -67,25 +58,17 @@ export async function updateExtension(
const tempDir = await ExtensionStorage.createTmpDir();
try {
const previousExtensionConfig = loadExtensionConfig({
extensionDir: extension.path,
workspaceDir: cwd,
extensionEnablementManager,
});
await installOrUpdateExtension(
const previousExtensionConfig = await extensionManager.loadExtensionConfig(
extension.path,
);
await extensionManager.installOrUpdateExtension(
installMetadata,
requestConsent,
cwd,
previousExtensionConfig,
promptForSetting,
);
const updatedExtensionStorage = new ExtensionStorage(extension.name);
const updatedExtension = loadExtension({
extensionDir: updatedExtensionStorage.getExtensionDir(),
workspaceDir: cwd,
extensionEnablementManager,
});
const updatedExtension = extensionManager.loadExtension(
updatedExtensionStorage.getExtensionDir(),
);
if (!updatedExtension) {
dispatchExtensionStateUpdate({
type: 'SET_STATE',
@@ -122,11 +105,9 @@ export async function updateExtension(
}
export async function updateAllUpdatableExtensions(
cwd: string = process.cwd(),
requestConsent: (consent: string) => Promise<boolean>,
extensions: GeminiCLIExtension[],
extensionsState: Map<string, ExtensionUpdateStatus>,
extensionEnablementManager: ExtensionEnablementManager,
extensionManager: ExtensionManager,
dispatch: (action: ExtensionUpdateAction) => void,
): Promise<ExtensionUpdateInfo[]> {
return (
@@ -140,9 +121,7 @@ export async function updateAllUpdatableExtensions(
.map((extension) =>
updateExtension(
extension,
extensionEnablementManager,
cwd,
requestConsent,
extensionManager,
extensionsState.get(extension.name)!.status,
dispatch,
),
@@ -158,9 +137,8 @@ export interface ExtensionUpdateCheckResult {
export async function checkForAllExtensionUpdates(
extensions: GeminiCLIExtension[],
extensionEnablementManager: ExtensionEnablementManager,
extensionManager: ExtensionManager,
dispatch: (action: ExtensionUpdateAction) => void,
cwd: string = process.cwd(),
): Promise<void> {
dispatch({ type: 'BATCH_CHECK_START' });
const promises: Array<Promise<void>> = [];
@@ -183,12 +161,11 @@ export async function checkForAllExtensionUpdates(
},
});
promises.push(
checkForExtensionUpdate(extension, extensionEnablementManager, cwd).then(
(state) =>
dispatch({
type: 'SET_STATE',
payload: { name: extension.name, state },
}),
checkForExtensionUpdate(extension, extensionManager).then((state) =>
dispatch({
type: 'SET_STATE',
payload: { name: extension.name, state },
}),
),
);
}
@@ -4,8 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ExtensionEnablementManager } from './extensionEnablement.js';
export interface VariableDefinition {
type: 'string';
description: string;
@@ -17,12 +15,6 @@ export interface VariableSchema {
[key: string]: VariableDefinition;
}
export interface LoadExtensionContext {
extensionDir: string;
workspaceDir: string;
extensionEnablementManager: ExtensionEnablementManager;
}
const PATH_SEPARATOR_DEFINITION = {
type: 'string',
description: 'The path separator.',
@@ -4,7 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js';
import { GEMINI_DIR } from '@google/gemini-cli-core';
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
export const EXTENSION_SETTINGS_FILENAME = '.env';
export type JsonObject = { [key: string]: JsonValue };
export type JsonArray = JsonValue[];
+28 -20
View File
@@ -50,7 +50,6 @@ import {
import * as fs from 'node:fs'; // fs will be mocked separately
import stripJsonComments from 'strip-json-comments'; // Will be mocked separately
import { isWorkspaceTrusted } from './trustedFolders.js';
import { disableExtension, ExtensionStorage } from './extension.js';
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
import {
@@ -67,8 +66,8 @@ import {
saveSettings,
type SettingsFile,
} from './settings.js';
import { FatalConfigError, GEMINI_DIR, Storage } from '@google/gemini-cli-core';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
import { ExtensionManager } from './extension-manager.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
@@ -2392,18 +2391,13 @@ describe('Settings Loading and Merging', () => {
describe('migrateDeprecatedSettings', () => {
let mockFsExistsSync: Mock;
let mockFsReadFileSync: Mock;
let mockDisableExtension: Mock;
beforeEach(() => {
vi.resetAllMocks();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockFsReadFileSync = vi.mocked(fs.readFileSync);
mockDisableExtension = vi.mocked(disableExtension);
vi.mocked(ExtensionStorage.getUserExtensionsDir).mockReturnValue(
new Storage(osActual.homedir()).getExtensionsDir(),
);
(mockFsExistsSync as Mock).mockReturnValue(true);
mockFsReadFileSync = vi.mocked(fs.readFileSync);
mockFsReadFileSync.mockReturnValue('{}');
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: undefined,
@@ -2438,35 +2432,38 @@ describe('Settings Loading and Merging', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
const extensionManager = new ExtensionManager({
loadedSettings,
workspaceDir: MOCK_WORKSPACE_DIR,
requestConsent: vi.fn(),
requestSetting: vi.fn(),
});
const mockDisableExtension = vi.spyOn(
extensionManager,
'disableExtension',
);
mockDisableExtension.mockImplementation(() => {});
migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR);
migrateDeprecatedSettings(loadedSettings, extensionManager);
// Check user settings migration
expect(mockDisableExtension).toHaveBeenCalledWith(
'user-ext-1',
SettingScope.User,
expect.any(ExtensionEnablementManager),
MOCK_WORKSPACE_DIR,
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'shared-ext',
SettingScope.User,
expect.any(ExtensionEnablementManager),
MOCK_WORKSPACE_DIR,
);
// Check workspace settings migration
expect(mockDisableExtension).toHaveBeenCalledWith(
'workspace-ext-1',
SettingScope.Workspace,
expect.any(ExtensionEnablementManager),
MOCK_WORKSPACE_DIR,
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'shared-ext',
SettingScope.Workspace,
expect.any(ExtensionEnablementManager),
MOCK_WORKSPACE_DIR,
);
// Check that setValue was called to remove the deprecated setting
@@ -2508,8 +2505,19 @@ describe('Settings Loading and Merging', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
const extensionManager = new ExtensionManager({
loadedSettings,
workspaceDir: MOCK_WORKSPACE_DIR,
requestConsent: vi.fn(),
requestSetting: vi.fn(),
});
const mockDisableExtension = vi.spyOn(
extensionManager,
'disableExtension',
);
mockDisableExtension.mockImplementation(() => {});
migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR);
migrateDeprecatedSettings(loadedSettings, extensionManager);
expect(mockDisableExtension).not.toHaveBeenCalled();
expect(setValueSpy).not.toHaveBeenCalled();
+3 -10
View File
@@ -32,8 +32,7 @@ import {
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import { disableExtension } from './extension.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import type { ExtensionManager } from './extension-manager.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
@@ -750,7 +749,7 @@ export function loadSettings(
export function migrateDeprecatedSettings(
loadedSettings: LoadedSettings,
workspaceDir: string = process.cwd(),
extensionManager: ExtensionManager,
): void {
const processScope = (scope: SettingScope) => {
const settings = loadedSettings.forScope(scope).settings;
@@ -758,14 +757,8 @@ export function migrateDeprecatedSettings(
debugLogger.log(
`Migrating deprecated extensions.disabled settings from ${scope} settings...`,
);
const extensionEnablementManager = new ExtensionEnablementManager();
for (const extension of settings.extensions.disabled ?? []) {
disableExtension(
extension,
scope,
extensionEnablementManager,
workspaceDir,
);
extensionManager.disableExtension(extension, scope);
}
const newExtensionsValue = { ...settings.extensions };
+24 -7
View File
@@ -26,7 +26,6 @@ import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { runNonInteractive } from './nonInteractiveCli.js';
import { loadExtensions } from './config/extension.js';
import {
cleanupCheckpoints,
registerCleanup,
@@ -67,8 +66,9 @@ import {
relaunchOnExitCode,
} from './utils/relaunch.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { ExtensionManager } from './config/extension-manager.js';
import { requestConsentNonInteractive } from './config/extensions/consent.js';
import { createPolicyUpdater } from './config/policy.js';
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
export function validateDnsResolutionOrder(
order: string | undefined,
@@ -225,7 +225,17 @@ export async function startInteractiveUI(
export async function main() {
setupUnhandledRejectionHandler();
const settings = loadSettings();
migrateDeprecatedSettings(settings);
migrateDeprecatedSettings(
settings,
// Temporary extension manager only used during this non-interactive UI phase.
new ExtensionManager({
workspaceDir: process.cwd(),
loadedSettings: settings,
enabledExtensionOverrides: [],
requestConsent: requestConsentNonInteractive,
requestSetting: null,
}),
);
await cleanupCheckpoints();
const argv = await parseArguments(settings.merged);
@@ -360,10 +370,17 @@ export async function main() {
// to run Gemini CLI. It is now safe to perform expensive initialization that
// may have side effects.
{
const extensionEnablementManager = new ExtensionEnablementManager(
argv.extensions,
);
const extensions = loadExtensions(extensionEnablementManager);
// Eventually, `extensions` should move off of `config` entirely and into
// the UI state instead.
const extensionManager = new ExtensionManager({
loadedSettings: settings,
workspaceDir: process.cwd(),
// At this stage, we still don't have an interactive UI.
requestConsent: requestConsentNonInteractive,
requestSetting: null,
enabledExtensionOverrides: argv.extensions,
});
const extensions = extensionManager.loadExtensions();
const config = await loadCliConfig(
settings.merged,
extensions,
@@ -6,14 +6,14 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
} from '../config/extension.js';
import {
type MCPServerConfig,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
} from '../config/extensions/variables.js';
import type { ExtensionSetting } from '../config/extensions/extensionSettings.js';
export function createExtension({
+23 -12
View File
@@ -93,9 +93,13 @@ import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
import {
useConfirmUpdateRequests,
useExtensionUpdates,
} from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
import { ExtensionManager } from '../config/extension-manager.js';
import { requestConsentInteractive } from '../config/extensions/consent.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
@@ -165,21 +169,28 @@ export const AppContainer = (props: AppContainerProps) => {
);
const extensions = config.getExtensions();
const [extensionEnablementManager] = useState<ExtensionEnablementManager>(
new ExtensionEnablementManager(config.getEnabledExtensions()),
const [extensionManager] = useState<ExtensionManager>(
new ExtensionManager({
enabledExtensionOverrides: config.getEnabledExtensions(),
workspaceDir: config.getWorkingDir(),
requestConsent: (description) =>
requestConsentInteractive(
description,
addConfirmUpdateExtensionRequest,
),
// TODO: Support requesting settings in the interactive CLI
requestSetting: null,
loadedSettings: settings,
}),
);
const { addConfirmUpdateExtensionRequest, confirmUpdateExtensionRequests } =
useConfirmUpdateRequests();
const {
extensionsUpdateState,
extensionsUpdateStateInternal,
dispatchExtensionStateUpdate,
confirmUpdateExtensionRequests,
addConfirmUpdateExtensionRequest,
} = useExtensionUpdates(
extensions,
extensionEnablementManager,
historyManager.addItem,
config.getWorkingDir(),
);
} = useExtensionUpdates(extensions, extensionManager, historyManager.addItem);
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const openPermissionsDialog = useCallback(
@@ -8,7 +8,6 @@ import { vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { loadExtension } from '../../config/extension.js';
import { createExtension } from '../../test-utils/createExtension.js';
import { useExtensionUpdates } from './useExtensionUpdates.js';
import { GEMINI_DIR, type GeminiCLIExtension } from '@google/gemini-cli-core';
@@ -19,7 +18,8 @@ import {
updateExtension,
} from '../../config/extensions/update.js';
import { ExtensionUpdateState } from '../state/extensions.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
@@ -36,17 +36,29 @@ vi.mock('../../config/extensions/update.js', () => ({
describe('useExtensionUpdates', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string;
let extensionManager: ExtensionManager;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
tempWorkspaceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(checkForAllExtensionUpdates).mockReset();
vi.mocked(updateExtension).mockReset();
extensionManager = new ExtensionManager({
workspaceDir: tempHomeDir,
requestConsent: vi.fn(),
requestSetting: vi.fn(),
loadedSettings: loadSettings(),
});
});
afterEach(() => {
@@ -71,10 +83,9 @@ describe('useExtensionUpdates', () => {
},
];
const addItem = vi.fn();
const cwd = '/test/cwd';
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
async (_extensions, _extensionEnablementManager, dispatch, _cwd) => {
async (_extensions, _extensionManager, dispatch) => {
dispatch({
type: 'SET_STATE',
payload: {
@@ -88,9 +99,8 @@ describe('useExtensionUpdates', () => {
renderHook(() =>
useExtensionUpdates(
extensions as GeminiCLIExtension[],
new ExtensionEnablementManager(),
extensionManager,
addItem,
cwd,
),
);
@@ -116,17 +126,12 @@ describe('useExtensionUpdates', () => {
autoUpdate: true,
},
});
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempHomeDir,
extensionEnablementManager,
})!;
const extension = extensionManager.loadExtension(extensionDir)!;
const addItem = vi.fn();
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
async (_extensions, _extensionEnablementManager, dispatch, _cwd) => {
async (_extensions, _extensionManager, dispatch) => {
dispatch({
type: 'SET_STATE',
payload: {
@@ -144,12 +149,7 @@ describe('useExtensionUpdates', () => {
});
renderHook(() =>
useExtensionUpdates(
[extension],
extensionEnablementManager,
addItem,
tempHomeDir,
),
useExtensionUpdates([extension], extensionManager, addItem),
);
await waitFor(
@@ -188,24 +188,15 @@ describe('useExtensionUpdates', () => {
},
});
const extensionEnablementManager = new ExtensionEnablementManager();
const extensions = [
loadExtension({
extensionDir: extensionDir1,
workspaceDir: tempHomeDir,
extensionEnablementManager,
})!,
loadExtension({
extensionDir: extensionDir2,
workspaceDir: tempHomeDir,
extensionEnablementManager,
})!,
extensionManager.loadExtension(extensionDir1)!,
extensionManager.loadExtension(extensionDir2)!,
];
const addItem = vi.fn();
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
async (_extensions, _extensionEnablementManager, dispatch, _cwd) => {
async (_extensions, _extensionManager, dispatch) => {
dispatch({
type: 'SET_STATE',
payload: {
@@ -236,12 +227,7 @@ describe('useExtensionUpdates', () => {
});
renderHook(() =>
useExtensionUpdates(
extensions,
extensionEnablementManager,
addItem,
tempHomeDir,
),
useExtensionUpdates(extensions, extensionManager, addItem),
);
await waitFor(
@@ -299,10 +285,9 @@ describe('useExtensionUpdates', () => {
},
];
const addItem = vi.fn();
const cwd = '/test/cwd';
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
async (_extensions, _extensionEnablementManager, dispatch, _cwd) => {
async (_extensions, _extensionManager, dispatch) => {
dispatch({ type: 'BATCH_CHECK_START' });
dispatch({
type: 'SET_STATE',
@@ -323,13 +308,11 @@ describe('useExtensionUpdates', () => {
},
);
const extensionEnablementManager = new ExtensionEnablementManager();
renderHook(() =>
useExtensionUpdates(
extensions as GeminiCLIExtension[],
extensionEnablementManager,
extensionManager,
addItem,
cwd,
),
);
@@ -18,12 +18,9 @@ import {
checkForAllExtensionUpdates,
updateExtension,
} from '../../config/extensions/update.js';
import {
requestConsentInteractive,
type ExtensionUpdateInfo,
} from '../../config/extension.js';
import { type ExtensionUpdateInfo } from '../../config/extension.js';
import { checkExhaustive } from '../../utils/checks.js';
import type { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import type { ExtensionManager } from '../../config/extension-manager.js';
type ConfirmationRequestWrapper = {
prompt: React.ReactNode;
@@ -48,16 +45,7 @@ function confirmationRequestsReducer(
}
}
export const useExtensionUpdates = (
extensions: GeminiCLIExtension[],
extensionEnablementManager: ExtensionEnablementManager,
addItem: UseHistoryManagerReturn['addItem'],
cwd: string,
) => {
const [extensionsUpdateState, dispatchExtensionStateUpdate] = useReducer(
extensionUpdatesReducer,
initialExtensionUpdatesState,
);
export const useConfirmUpdateRequests = () => {
const [
confirmUpdateExtensionRequests,
dispatchConfirmUpdateExtensionRequests,
@@ -82,6 +70,22 @@ export const useExtensionUpdates = (
},
[dispatchConfirmUpdateExtensionRequests],
);
return {
addConfirmUpdateExtensionRequest,
confirmUpdateExtensionRequests,
dispatchConfirmUpdateExtensionRequests,
};
};
export const useExtensionUpdates = (
extensions: GeminiCLIExtension[],
extensionManager: ExtensionManager,
addItem: UseHistoryManagerReturn['addItem'],
) => {
const [extensionsUpdateState, dispatchExtensionStateUpdate] = useReducer(
extensionUpdatesReducer,
initialExtensionUpdatesState,
);
useEffect(() => {
const extensionsToCheck = extensions.filter((extension) => {
@@ -95,15 +99,13 @@ export const useExtensionUpdates = (
if (extensionsToCheck.length === 0) return;
checkForAllExtensionUpdates(
extensionsToCheck,
extensionEnablementManager,
extensionManager,
dispatchExtensionStateUpdate,
cwd,
);
}, [
extensions,
extensionEnablementManager,
extensionManager,
extensionsUpdateState.extensionStatuses,
cwd,
dispatchExtensionStateUpdate,
]);
@@ -158,13 +160,7 @@ export const useExtensionUpdates = (
} else {
const updatePromise = updateExtension(
extension,
extensionEnablementManager,
cwd,
(description) =>
requestConsentInteractive(
description,
addConfirmUpdateExtensionRequest,
),
extensionManager,
currentState.status,
dispatchExtensionStateUpdate,
);
@@ -213,14 +209,7 @@ export const useExtensionUpdates = (
});
});
}
}, [
extensions,
extensionEnablementManager,
extensionsUpdateState,
addConfirmUpdateExtensionRequest,
addItem,
cwd,
]);
}, [extensions, extensionManager, extensionsUpdateState, addItem]);
const extensionsUpdateStateComputed = useMemo(() => {
const result = new Map<string, ExtensionUpdateState>();
@@ -237,7 +226,5 @@ export const useExtensionUpdates = (
extensionsUpdateState: extensionsUpdateStateComputed,
extensionsUpdateStateInternal: extensionsUpdateState.extensionStatuses,
dispatchExtensionStateUpdate,
confirmUpdateExtensionRequests,
addConfirmUpdateExtensionRequest,
};
};