diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts index 53f8f145fd..184d11a410 100644 --- a/packages/cli/src/commands/extensions/disable.ts +++ b/packages/cli/src/commands/extensions/disable.ts @@ -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}".`, diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts index f5245088d7..43523af372 100644 --- a/packages/cli/src/commands/extensions/enable.ts +++ b/packages/cli/src/commands/extensions/enable.ts @@ -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( diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 3f85015da1..7348bf89ec 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -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(); + 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 () => { diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 58120f084e..13c59a1855 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -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)); diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts index 6bbfcebd7e..9f0693cd7e 100644 --- a/packages/cli/src/commands/extensions/link.ts +++ b/packages/cli/src/commands/extensions/link.ts @@ -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.`, ); diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts index 532d6b7233..432299c902 100644 --- a/packages/cli/src/commands/extensions/list.ts +++ b/packages/cli/src/commands/extensions/list.ts @@ -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) { diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index 82717f0f00..59dc8c828f 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -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)); diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 39b9e174e3..5523149f18 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -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( diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 4c20d82c3f..ee9cf9395c 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -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' } }, diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index 733f0071f8..3253641894 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -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 > { 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]) => { diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts new file mode 100644 index 0000000000..d175b8382c --- /dev/null +++ b/packages/cli/src/config/extension-manager.ts @@ -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; + requestSetting: ((setting: ExtensionSetting) => Promise) | null; + workspaceDir: string; +} + +export class ExtensionManager { + private extensionEnablementManager: ExtensionEnablementManager; + private loadedSettings: LoadedSettings; + private requestConsent: (consent: string) => Promise; + private requestSetting: + | ((setting: ExtensionSetting) => Promise) + | 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 { + 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 | 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 { + 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(); + + 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 { + 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'); +} diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 7bc6bd116c..9d81a26be2 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -4,37 +4,29 @@ * 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 { createHash } from 'node:crypto'; import { - EXTENSIONS_CONFIG_FILENAME, - ExtensionStorage, - INSTALL_METADATA_FILENAME, - INSTALL_WARNING_MESSAGE, - disableExtension, - enableExtension, - installOrUpdateExtension, - loadExtension, - loadExtensionConfig, - loadExtensions, - uninstallExtension, - hashValue, -} from './extension.js'; -import { - GEMINI_DIR, type GeminiCLIExtension, ExtensionUninstallEvent, ExtensionDisableEvent, ExtensionEnableEvent, } from '@google/gemini-cli-core'; -import { SettingScope } from './settings.js'; +import { loadSettings, SettingScope } from './settings.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { createExtension } from '../test-utils/createExtension.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import { join } from 'node:path'; +import { + EXTENSIONS_CONFIG_FILENAME, + EXTENSIONS_DIRECTORY_NAME, + INSTALL_METADATA_FILENAME, +} from './extensions/variables.js'; +import { hashValue, ExtensionManager } from './extension-manager.js'; +import { ExtensionStorage } from './extensions/storage.js'; +import { INSTALL_WARNING_MESSAGE } from './extensions/consent.js'; import type { ExtensionSetting } from './extensions/extensionSettings.js'; const mockGit = { @@ -113,12 +105,15 @@ vi.mock('child_process', async (importOriginal) => { }; }); -const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); - describe('extension tests', () => { let tempHomeDir: string; let tempWorkspaceDir: string; let userExtensionsDir: string; + let extensionManager: ExtensionManager; + let mockRequestConsent: MockedFunction<(consent: string) => Promise>; + let mockPromptForSettings: MockedFunction< + (setting: ExtensionSetting) => Promise + >; beforeEach(() => { tempHomeDir = fs.mkdtempSync( @@ -128,14 +123,23 @@ describe('extension tests', () => { path.join(tempHomeDir, 'gemini-cli-test-workspace-'), ); userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); + mockRequestConsent = vi.fn(); + mockRequestConsent.mockResolvedValue(true); + mockPromptForSettings = vi.fn(); + mockPromptForSettings.mockResolvedValue(''); fs.mkdirSync(userExtensionsDir, { recursive: true }); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: undefined, }); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + loadedSettings: loadSettings(tempWorkspaceDir), + }); }); afterEach(() => { @@ -155,7 +159,7 @@ describe('extension tests', () => { version: '1.0.0', }); - const extensions = loadExtensions(new ExtensionEnablementManager()); + const extensions = extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].path).toBe(extensionDir); expect(extensions[0].name).toBe('test-extension'); @@ -174,7 +178,7 @@ describe('extension tests', () => { version: '2.0.0', }); - const extensions = loadExtensions(new ExtensionEnablementManager()); + const extensions = extensionManager.loadExtensions(); expect(extensions).toHaveLength(2); const ext1 = extensions.find((e) => e.name === 'ext1'); @@ -194,7 +198,7 @@ describe('extension tests', () => { contextFileName: 'my-context-file.md', }); - const extensions = loadExtensions(new ExtensionEnablementManager()); + const extensions = extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const ext1 = extensions.find((e) => e.name === 'ext1'); @@ -214,14 +218,11 @@ describe('extension tests', () => { name: 'enabled-extension', version: '2.0.0', }); - const manager = new ExtensionEnablementManager(); - disableExtension( + extensionManager.disableExtension( 'disabled-extension', SettingScope.User, - manager, - tempWorkspaceDir, ); - const extensions = loadExtensions(manager); + const extensions = extensionManager.loadExtensions(); expect(extensions).toHaveLength(2); expect(extensions[0].name).toBe('disabled-extension'); expect(extensions[0].isActive).toBe(false); @@ -243,7 +244,7 @@ describe('extension tests', () => { }, }); - const extensions = loadExtensions(new ExtensionEnablementManager()); + const extensions = extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const expectedCwd = path.join( userExtensionsDir, @@ -262,16 +263,13 @@ describe('extension tests', () => { }); fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context'); - const extensionName = await installOrUpdateExtension( - { - source: sourceExtDir, - type: 'link', - }, - async (_) => true, - ); + const extensionName = await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'link', + }); expect(extensionName).toEqual('my-linked-extension'); - const extensions = loadExtensions(new ExtensionEnablementManager()); + const extensions = extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const linkedExt = extensions[0]; @@ -320,7 +318,7 @@ describe('extension tests', () => { }; fs.writeFileSync(configPath, JSON.stringify(extensionConfig)); - const extensions = loadExtensions(new ExtensionEnablementManager()); + const extensions = extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; @@ -361,7 +359,7 @@ describe('extension tests', () => { const envFilePath = path.join(extDir, '.env'); fs.writeFileSync(envFilePath, 'MY_API_KEY=test-key-from-file\n'); - const extensions = loadExtensions(new ExtensionEnablementManager()); + const extensions = extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; @@ -401,7 +399,7 @@ describe('extension tests', () => { JSON.stringify(extensionConfig), ); - const extensions = loadExtensions(new ExtensionEnablementManager()); + const extensions = extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; @@ -429,7 +427,7 @@ describe('extension tests', () => { const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed - const extensions = loadExtensions(new ExtensionEnablementManager()); + const extensions = extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); @@ -461,7 +459,7 @@ describe('extension tests', () => { const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' })); - const extensions = loadExtensions(new ExtensionEnablementManager()); + const extensions = extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); @@ -489,7 +487,7 @@ describe('extension tests', () => { }, }); - const extensions = loadExtensions(new ExtensionEnablementManager()); + const extensions = extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined(); }); @@ -504,11 +502,7 @@ describe('extension tests', () => { version: '1.0.0', }); - const extension = loadExtension({ - extensionDir: badExtDir, - workspaceDir: tempWorkspaceDir, - extensionEnablementManager: new ExtensionEnablementManager(), - }); + const extension = extensionManager.loadExtension(badExtDir); expect(extension).toBeNull(); expect(consoleSpy).toHaveBeenCalledWith( @@ -529,16 +523,8 @@ describe('extension tests', () => { }, }); - const extension = loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - extensionEnablementManager: new ExtensionEnablementManager(), - }); - - const expectedHash = createHash('sha256') - .update('http://somehost.com/foo/bar') - .digest('hex'); - expect(extension?.id).toBe(expectedHash); + const extension = extensionManager.loadExtension(extensionDir); + expect(extension?.id).toBe(hashValue('http://somehost.com/foo/bar')); }); it('should generate id from owner/repo for github http urls', () => { @@ -552,16 +538,8 @@ describe('extension tests', () => { }, }); - const extension = loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - extensionEnablementManager: new ExtensionEnablementManager(), - }); - - const expectedHash = createHash('sha256') - .update('https://github.com/foo/bar') - .digest('hex'); - expect(extension?.id).toBe(expectedHash); + const extension = extensionManager.loadExtension(extensionDir); + expect(extension?.id).toBe(hashValue('https://github.com/foo/bar')); }); it('should generate id from owner/repo for github ssh urls', () => { @@ -575,16 +553,8 @@ describe('extension tests', () => { }, }); - const extension = loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - extensionEnablementManager: new ExtensionEnablementManager(), - }); - - const expectedHash = createHash('sha256') - .update('https://github.com/foo/bar') - .digest('hex'); - expect(extension?.id).toBe(expectedHash); + const extension = extensionManager.loadExtension(extensionDir); + expect(extension?.id).toBe(hashValue('https://github.com/foo/bar')); }); it('should generate id from source for github-release extension', () => { @@ -598,16 +568,8 @@ describe('extension tests', () => { }, }); - const extension = loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - extensionEnablementManager: new ExtensionEnablementManager(), - }); - - const expectedHash = createHash('sha256') - .update('https://github.com/foo/bar') - .digest('hex'); - expect(extension?.id).toBe(expectedHash); + const extension = extensionManager.loadExtension(extensionDir); + expect(extension?.id).toBe(hashValue('https://github.com/foo/bar')); }); it('should generate id from the original source for local extension', () => { @@ -621,16 +583,8 @@ describe('extension tests', () => { }, }); - const extension = loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - extensionEnablementManager: new ExtensionEnablementManager(), - }); - - const expectedHash = createHash('sha256') - .update('/some/path') - .digest('hex'); - expect(extension?.id).toBe(expectedHash); + const extension = extensionManager.loadExtension(extensionDir); + expect(extension?.id).toBe(hashValue('/some/path')); }); it('should generate id from the original source for linked extensions', async () => { @@ -640,25 +594,15 @@ describe('extension tests', () => { name: 'link-ext-name', version: '1.0.0', }); - const extensionName = await installOrUpdateExtension( - { - type: 'link', - source: actualExtensionDir, - }, - async () => true, - tempWorkspaceDir, - ); - - const extension = loadExtension({ - extensionDir: new ExtensionStorage(extensionName).getExtensionDir(), - workspaceDir: tempWorkspaceDir, - extensionEnablementManager: new ExtensionEnablementManager(), + const extensionName = await extensionManager.installOrUpdateExtension({ + type: 'link', + source: actualExtensionDir, }); - const expectedHash = createHash('sha256') - .update(actualExtensionDir) - .digest('hex'); - expect(extension?.id).toBe(expectedHash); + const extension = extensionManager.loadExtension( + new ExtensionStorage(extensionName).getExtensionDir(), + ); + expect(extension?.id).toBe(hashValue(actualExtensionDir)); }); it('should generate id from name for extension with no install metadata', () => { @@ -668,16 +612,8 @@ describe('extension tests', () => { version: '1.0.0', }); - const extension = loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - extensionEnablementManager: new ExtensionEnablementManager(), - }); - - const expectedHash = createHash('sha256') - .update('no-meta-name') - .digest('hex'); - expect(extension?.id).toBe(expectedHash); + const extension = extensionManager.loadExtension(extensionDir); + expect(extension?.id).toBe(hashValue('no-meta-name')); }); }); }); @@ -692,10 +628,10 @@ describe('extension tests', () => { const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - await installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); expect(fs.existsSync(targetExtDir)).toBe(true); expect(fs.existsSync(metadataPath)).toBe(true); @@ -713,15 +649,15 @@ describe('extension tests', () => { name: 'my-local-extension', version: '1.0.0', }); - await installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); await expect( - installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ), + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), ).rejects.toThrow( 'Extension "my-local-extension" is already installed. Please uninstall it first.', ); @@ -733,10 +669,10 @@ describe('extension tests', () => { const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); await expect( - installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ), + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), ).rejects.toThrow(`Configuration file not found at ${configPath}`); const targetExtDir = path.join(userExtensionsDir, 'bad-extension'); @@ -750,10 +686,10 @@ describe('extension tests', () => { fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON await expect( - installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ), + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), ).rejects.toThrow( new RegExp( `^Failed to load extension config from ${configPath.replace( @@ -775,10 +711,10 @@ describe('extension tests', () => { fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' })); await expect( - installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ), + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), ).rejects.toThrow( `Invalid configuration in ${configPath}: missing "name"`, ); @@ -806,10 +742,10 @@ describe('extension tests', () => { type: 'github-release', }); - await installOrUpdateExtension( - { source: gitUrl, type: 'git' }, - async (_) => true, - ); + await extensionManager.installOrUpdateExtension({ + source: gitUrl, + type: 'git', + }); expect(fs.existsSync(targetExtDir)).toBe(true); expect(fs.existsSync(metadataPath)).toBe(true); @@ -830,10 +766,10 @@ describe('extension tests', () => { const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME); - await installOrUpdateExtension( - { source: sourceExtDir, type: 'link' }, - async (_) => true, - ); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'link', + }); expect(fs.existsSync(targetExtDir)).toBe(true); expect(fs.existsSync(metadataPath)).toBe(true); @@ -860,20 +796,18 @@ describe('extension tests', () => { version: '1.1.0', }); if (isUpdate) { - await installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); } // Clears out any calls to mocks from the above function calls. vi.clearAllMocks(); }); it(`should log an ${isUpdate ? 'update' : 'install'} event to clearcut on success`, async () => { - await installOrUpdateExtension( + await extensionManager.installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, - async (_) => true, - undefined, isUpdate ? { name: 'my-local-extension', @@ -895,10 +829,8 @@ describe('extension tests', () => { const enablementManager = new ExtensionEnablementManager(); enablementManager.enable('my-local-extension', true, '/some/scope'); - await installOrUpdateExtension( + await extensionManager.installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, - async (_) => true, - undefined, isUpdate ? { name: 'my-local-extension', @@ -936,14 +868,11 @@ describe('extension tests', () => { }, }); - const mockRequestConsent = vi.fn(); - mockRequestConsent.mockResolvedValue(true); - await expect( - installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - mockRequestConsent, - ), + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), ).resolves.toBe('my-local-extension'); expect(mockRequestConsent).toHaveBeenCalledWith( @@ -969,10 +898,10 @@ This extension will run the following MCP servers: }); await expect( - installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async () => true, - ), + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), ).resolves.toBe('my-local-extension'); }); @@ -988,12 +917,12 @@ This extension will run the following MCP servers: }, }, }); - + mockRequestConsent.mockResolvedValue(false); await expect( - installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async () => false, - ), + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), ).rejects.toThrow('Installation cancelled for "my-local-extension".'); }); @@ -1006,14 +935,11 @@ This extension will run the following MCP servers: const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - await installOrUpdateExtension( - { - source: sourceExtDir, - type: 'local', - autoUpdate: true, - }, - async (_) => true, - ); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + autoUpdate: true, + }); expect(fs.existsSync(targetExtDir)).toBe(true); expect(fs.existsSync(metadataPath)).toBe(true); @@ -1039,29 +965,24 @@ This extension will run the following MCP servers: }, }); - const mockRequestConsent = vi.fn(); - // Install it and force consent first. - await installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async () => true, - ); + // Install it with hard coded consent first. + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + expect(mockRequestConsent).toHaveBeenCalledOnce(); // Now update it without changing anything. await expect( - installOrUpdateExtension( + extensionManager.installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, - mockRequestConsent, - process.cwd(), // Provide its own existing config as the previous config. - await loadExtensionConfig({ - extensionDir: sourceExtDir, - workspaceDir: process.cwd(), - extensionEnablementManager: new ExtensionEnablementManager(), - }), + await extensionManager.loadExtensionConfig(sourceExtDir), ), ).resolves.toBe('my-local-extension'); - expect(mockRequestConsent).not.toHaveBeenCalled(); + // Still only called once + expect(mockRequestConsent).toHaveBeenCalledOnce(); }); it('should prompt for settings if promptForSettings', async () => { @@ -1078,18 +999,12 @@ This extension will run the following MCP servers: ], }); - const promptForSettingsMock = vi.fn( - async (_: ExtensionSetting): Promise => Promise.resolve(''), - ); - await installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - process.cwd(), - undefined, - promptForSettingsMock, - ); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); - expect(promptForSettingsMock).toHaveBeenCalled(); + expect(mockPromptForSettings).toHaveBeenCalled(); }); it('should not prompt for settings if promptForSettings is false', async () => { @@ -1106,10 +1021,17 @@ This extension will run the following MCP servers: ], }); - await installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: null, + loadedSettings: loadSettings(tempWorkspaceDir), + }); + + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); }); it('should only prompt for new settings on update, and preserve old settings', async () => { @@ -1127,14 +1049,12 @@ This extension will run the following MCP servers: ], }); + mockPromptForSettings.mockResolvedValueOnce('old-api-key'); // Install it so it exists in the userExtensionsDir - await installOrUpdateExtension( - { source: oldSourceExtDir, type: 'local' }, - async (_) => true, - process.cwd(), - undefined, - async () => 'old-api-key', - ); + await extensionManager.installOrUpdateExtension({ + source: oldSourceExtDir, + type: 'local', + }); const envPath = new ExtensionStorage( 'my-local-extension', @@ -1142,6 +1062,7 @@ This extension will run the following MCP servers: expect(fs.existsSync(envPath)).toBe(true); let envContent = fs.readFileSync(envPath, 'utf-8'); expect(envContent).toContain('MY_API_KEY=old-api-key'); + expect(mockPromptForSettings).toHaveBeenCalledTimes(1); // 2. Create the "new" version of the extension in a new source directory. const newSourceExtDir = createExtension({ @@ -1162,27 +1083,19 @@ This extension will run the following MCP servers: ], }); - const previousExtensionConfig = loadExtensionConfig({ - extensionDir: path.join(userExtensionsDir, 'my-local-extension'), - workspaceDir: process.cwd(), - extensionEnablementManager: new ExtensionEnablementManager(), - }); - - const promptForSettingsMock = vi.fn( - async (_: ExtensionSetting): Promise => 'new-setting-value', + const previousExtensionConfig = extensionManager.loadExtensionConfig( + path.join(userExtensionsDir, 'my-local-extension'), ); + mockPromptForSettings.mockResolvedValueOnce('new-setting-value'); // 3. Call installOrUpdateExtension to perform the update. - await installOrUpdateExtension( + await extensionManager.installOrUpdateExtension( { source: newSourceExtDir, type: 'local' }, - async (_) => true, - process.cwd(), previousExtensionConfig, - promptForSettingsMock, ); - expect(promptForSettingsMock).toHaveBeenCalledTimes(1); - expect(promptForSettingsMock).toHaveBeenCalledWith( + expect(mockPromptForSettings).toHaveBeenCalledTimes(2); + expect(mockPromptForSettings).toHaveBeenCalledWith( expect.objectContaining({ name: 'New Setting' }), ); @@ -1206,10 +1119,11 @@ This extension will run the following MCP servers: }, ], }); - await installOrUpdateExtension( - { source: oldSourceExtDir, type: 'local', autoUpdate: true }, - async () => true, - ); + await extensionManager.installOrUpdateExtension({ + source: oldSourceExtDir, + type: 'local', + autoUpdate: true, + }); // 2. Create new version with different settings const newSourceExtDir = createExtension({ @@ -1225,18 +1139,14 @@ This extension will run the following MCP servers: ], }); - const previousExtensionConfig = loadExtensionConfig({ - extensionDir: path.join(userExtensionsDir, 'my-auto-update-ext'), - workspaceDir: process.cwd(), - extensionEnablementManager: new ExtensionEnablementManager(), - }); + const previousExtensionConfig = extensionManager.loadExtensionConfig( + path.join(userExtensionsDir, 'my-auto-update-ext'), + ); // 3. Attempt to update and assert it fails await expect( - installOrUpdateExtension( + extensionManager.installOrUpdateExtension( { source: newSourceExtDir, type: 'local', autoUpdate: true }, - async () => true, - process.cwd(), previousExtensionConfig, ), ).rejects.toThrow( @@ -1252,10 +1162,10 @@ This extension will run the following MCP servers: }); await expect( - installOrUpdateExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ), + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), ).rejects.toThrow('Invalid extension name: "bad_name"'); }); @@ -1300,10 +1210,10 @@ This extension will run the following MCP servers: join(tempDir, extensionName), ); - await installOrUpdateExtension( - { source: gitUrl, type: 'github-release' }, - async () => true, - ); + await extensionManager.installOrUpdateExtension({ + source: gitUrl, + type: 'github-release', + }); expect(fs.existsSync(targetExtDir)).toBe(true); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); @@ -1323,17 +1233,15 @@ This extension will run the following MCP servers: errorMessage: 'download failed', type: 'github-release', }); - const requestConsent = vi.fn().mockResolvedValue(true); - await installOrUpdateExtension( + await extensionManager.installOrUpdateExtension( { source: gitUrl, type: 'github-release' }, // Use github-release to force consent - requestConsent, ); // It gets called once to ask for a git clone, and once to consent to // the actual extension features. - expect(requestConsent).toHaveBeenCalledTimes(2); - expect(requestConsent).toHaveBeenCalledWith( + expect(mockRequestConsent).toHaveBeenCalledTimes(2); + expect(mockRequestConsent).toHaveBeenCalledWith( expect.stringContaining( 'Would you like to attempt to install via "git clone" instead?', ), @@ -1354,18 +1262,18 @@ This extension will run the following MCP servers: errorMessage: 'download failed', type: 'github-release', }); - const requestConsent = vi.fn().mockResolvedValue(false); + mockRequestConsent.mockResolvedValue(false); await expect( - installOrUpdateExtension( - { source: gitUrl, type: 'github-release' }, - requestConsent, - ), + extensionManager.installOrUpdateExtension({ + source: gitUrl, + type: 'github-release', + }), ).rejects.toThrow( `Failed to install extension ${gitUrl}: download failed`, ); - expect(requestConsent).toHaveBeenCalledExactlyOnceWith( + expect(mockRequestConsent).toHaveBeenCalledExactlyOnceWith( expect.stringContaining( 'Would you like to attempt to install via "git clone" instead?', ), @@ -1379,16 +1287,15 @@ This extension will run the following MCP servers: failureReason: 'no release data', type: 'github-release', }); - const requestConsent = vi.fn().mockResolvedValue(true); - await installOrUpdateExtension( - { source: gitUrl, type: 'git' }, - requestConsent, - ); + await extensionManager.installOrUpdateExtension({ + source: gitUrl, + type: 'git', + }); // We should not see the request to use git clone, this is a repo that // has no github releases so it is the only install method. - expect(requestConsent).toHaveBeenCalledExactlyOnceWith( + expect(mockRequestConsent).toHaveBeenCalledExactlyOnceWith( expect.stringContaining( 'Installing extension "gemini-test-extension"', ), @@ -1410,14 +1317,12 @@ This extension will run the following MCP servers: errorMessage: 'No release data found', type: 'github-release', }); - const requestConsent = vi.fn().mockResolvedValue(true); - await installOrUpdateExtension( + await extensionManager.installOrUpdateExtension( { source: gitUrl, type: 'github-release' }, // Note the type - requestConsent, ); - expect(requestConsent).toHaveBeenCalledWith( + expect(mockRequestConsent).toHaveBeenCalledWith( expect.stringContaining( 'Would you like to attempt to install via "git clone" instead?', ), @@ -1435,7 +1340,7 @@ This extension will run the following MCP servers: version: '1.0.0', }); - await uninstallExtension('my-local-extension', false); + await extensionManager.uninstallExtension('my-local-extension', false); expect(fs.existsSync(sourceExtDir)).toBe(false); }); @@ -1452,16 +1357,16 @@ This extension will run the following MCP servers: version: '1.0.0', }); - await uninstallExtension('my-local-extension', false); + await extensionManager.uninstallExtension('my-local-extension', false); expect(fs.existsSync(sourceExtDir)).toBe(false); - expect(loadExtensions(new ExtensionEnablementManager())).toHaveLength(1); + expect(extensionManager.loadExtensions()).toHaveLength(1); expect(fs.existsSync(otherExtDir)).toBe(true); }); it('should throw an error if the extension does not exist', async () => { await expect( - uninstallExtension('nonexistent-extension', false), + extensionManager.uninstallExtension('nonexistent-extension', false), ).rejects.toThrow('Extension not found.'); }); @@ -1477,7 +1382,10 @@ This extension will run the following MCP servers: }, }); - await uninstallExtension('my-local-extension', isUpdate); + await extensionManager.uninstallExtension( + 'my-local-extension', + isUpdate, + ); if (isUpdate) { expect(mockLogExtensionUninstall).not.toHaveBeenCalled(); @@ -1501,7 +1409,7 @@ This extension will run the following MCP servers: const enablementManager = new ExtensionEnablementManager(); enablementManager.enable('test-extension', true, '/some/scope'); - await uninstallExtension('test-extension', isUpdate); + await extensionManager.uninstallExtension('test-extension', isUpdate); const config = enablementManager.readConfig()['test-extension']; if (isUpdate) { @@ -1525,7 +1433,7 @@ This extension will run the following MCP servers: }, }); - await uninstallExtension(gitUrl, false); + await extensionManager.uninstallExtension(gitUrl, false); expect(fs.existsSync(sourceExtDir)).toBe(false); expect(mockLogExtensionUninstall).toHaveBeenCalled(); @@ -1545,7 +1453,7 @@ This extension will run the following MCP servers: }); await expect( - uninstallExtension( + extensionManager.uninstallExtension( 'https://github.com/google/no-metadata-extension', false, ), @@ -1561,11 +1469,7 @@ This extension will run the following MCP servers: version: '1.0.0', }); - disableExtension( - 'my-extension', - SettingScope.User, - new ExtensionEnablementManager(), - ); + extensionManager.disableExtension('my-extension', SettingScope.User); expect( isEnabled({ name: 'my-extension', @@ -1581,12 +1485,7 @@ This extension will run the following MCP servers: version: '1.0.0', }); - disableExtension( - 'my-extension', - SettingScope.Workspace, - new ExtensionEnablementManager(), - tempWorkspaceDir, - ); + extensionManager.disableExtension('my-extension', SettingScope.Workspace); expect( isEnabled({ name: 'my-extension', @@ -1608,16 +1507,8 @@ This extension will run the following MCP servers: version: '1.0.0', }); - disableExtension( - 'my-extension', - SettingScope.User, - new ExtensionEnablementManager(), - ); - disableExtension( - 'my-extension', - SettingScope.User, - new ExtensionEnablementManager(), - ); + extensionManager.disableExtension('my-extension', SettingScope.User); + extensionManager.disableExtension('my-extension', SettingScope.User); expect( isEnabled({ name: 'my-extension', @@ -1628,11 +1519,7 @@ This extension will run the following MCP servers: it('should throw an error if you request system scope', () => { expect(() => - disableExtension( - 'my-extension', - SettingScope.System, - new ExtensionEnablementManager(), - ), + extensionManager.disableExtension('my-extension', SettingScope.System), ).toThrow('System and SystemDefaults scopes are not supported.'); }); @@ -1647,11 +1534,7 @@ This extension will run the following MCP servers: }, }); - disableExtension( - 'ext1', - SettingScope.Workspace, - new ExtensionEnablementManager(), - ); + extensionManager.disableExtension('ext1', SettingScope.Workspace); expect(mockLogExtensionDisable).toHaveBeenCalled(); expect(ExtensionDisableEvent).toHaveBeenCalledWith( @@ -1668,8 +1551,7 @@ This extension will run the following MCP servers: }); const getActiveExtensions = (): GeminiCLIExtension[] => { - const manager = new ExtensionEnablementManager(); - const extensions = loadExtensions(manager); + const extensions = extensionManager.loadExtensions(); return extensions.filter((e) => e.isActive); }; @@ -1679,12 +1561,11 @@ This extension will run the following MCP servers: name: 'ext1', version: '1.0.0', }); - const extensionEnablementManager = new ExtensionEnablementManager(); - disableExtension('ext1', SettingScope.User, extensionEnablementManager); + extensionManager.disableExtension('ext1', SettingScope.User); let activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(0); - enableExtension('ext1', SettingScope.User, extensionEnablementManager); + extensionManager.enableExtension('ext1', SettingScope.User); activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(1); expect(activeExtensions[0].name).toBe('ext1'); @@ -1696,20 +1577,11 @@ This extension will run the following MCP servers: name: 'ext1', version: '1.0.0', }); - const extensionEnablementManager = new ExtensionEnablementManager(); - disableExtension( - 'ext1', - SettingScope.Workspace, - extensionEnablementManager, - ); + extensionManager.disableExtension('ext1', SettingScope.Workspace); let activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(0); - enableExtension( - 'ext1', - SettingScope.Workspace, - extensionEnablementManager, - ); + extensionManager.enableExtension('ext1', SettingScope.Workspace); activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(1); expect(activeExtensions[0].name).toBe('ext1'); @@ -1725,17 +1597,8 @@ This extension will run the following MCP servers: type: 'local', }, }); - const extensionEnablementManager = new ExtensionEnablementManager(); - disableExtension( - 'ext1', - SettingScope.Workspace, - extensionEnablementManager, - ); - enableExtension( - 'ext1', - SettingScope.Workspace, - extensionEnablementManager, - ); + extensionManager.disableExtension('ext1', SettingScope.Workspace); + extensionManager.enableExtension('ext1', SettingScope.Workspace); expect(mockLogExtensionEnable).toHaveBeenCalled(); expect(ExtensionEnableEvent).toHaveBeenCalledWith( diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 482f7eabbb..bafaba59a8 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -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 { - return await fs.promises.mkdtemp( - path.join(os.tmpdir(), 'gemini-extension'), - ); - } -} - -export async function copyExtension( - source: string, - destination: string, -): Promise { - 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(); - - 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 { - 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 { - 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 { - 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 { - return await new Promise((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, - cwd: string = process.cwd(), - previousExtensionConfig?: ExtensionConfig, - requestSetting?: (setting: ExtensionSetting) => Promise, -): Promise { - 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 | 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, - 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 { - 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); -} diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts new file mode 100644 index 0000000000..b6db1f647b --- /dev/null +++ b/packages/cli/src/config/extensions/consent.ts @@ -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 { + 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 { + 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 { + 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 { + return await new Promise((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, + 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}".`); + } +} diff --git a/packages/cli/src/config/extensions/extensionEnablement.ts b/packages/cli/src/config/extensions/extensionEnablement.ts index f5018df6a8..9994a4ecff 100644 --- a/packages/cli/src/config/extensions/extensionEnablement.ts +++ b/packages/cli/src/config/extensions/extensionEnablement.ts @@ -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[]; diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts index e05c573f3b..9beb8a4284 100644 --- a/packages/cli/src/config/extensions/extensionSettings.test.ts +++ b/packages/cli/src/config/extensions/extensionSettings.test.ts @@ -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'; diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index dbc28f8e07..55eb70b83a 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -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'; diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index 74f6992286..57eaa3e32e 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -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 + >; + let mockPromptForSettings: MockedFunction< + (setting: ExtensionSetting) => Promise + >; + 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); }); }); diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index 367c80501f..5e5e5cde7d 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -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 { 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}`, diff --git a/packages/cli/src/config/extensions/storage.ts b/packages/cli/src/config/extensions/storage.ts new file mode 100644 index 0000000000..583bdd044b --- /dev/null +++ b/packages/cli/src/config/extensions/storage.ts @@ -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 { + return await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'gemini-extension'), + ); + } +} diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts index f0ba5ba982..176e7ad3fa 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -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>; + let mockPromptForSettings: MockedFunction< + (setting: ExtensionSetting) => Promise + >; 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', diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts index 99d80eac5b..141ace88d8 100644 --- a/packages/cli/src/config/extensions/update.ts +++ b/packages/cli/src/config/extensions/update.ts @@ -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, + extensionManager: ExtensionManager, currentState: ExtensionUpdateState, dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void, ): Promise { @@ -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, extensions: GeminiCLIExtension[], extensionsState: Map, - extensionEnablementManager: ExtensionEnablementManager, + extensionManager: ExtensionManager, dispatch: (action: ExtensionUpdateAction) => void, ): Promise { 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 { dispatch({ type: 'BATCH_CHECK_START' }); const promises: Array> = []; @@ -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 }, + }), ), ); } diff --git a/packages/cli/src/config/extensions/variableSchema.ts b/packages/cli/src/config/extensions/variableSchema.ts index 4e0adf3582..0b8abc5021 100644 --- a/packages/cli/src/config/extensions/variableSchema.ts +++ b/packages/cli/src/config/extensions/variableSchema.ts @@ -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.', diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts index 7c6ef84692..78506a9738 100644 --- a/packages/cli/src/config/extensions/variables.ts +++ b/packages/cli/src/config/extensions/variables.ts @@ -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[]; diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 2f7b358f6e..3c79657b14 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -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(); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 2818c9a214..00cd27a7f7 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -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 }; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index f22a9dda08..05388524f3 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -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, diff --git a/packages/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts index 452138e959..f7ad425f06 100644 --- a/packages/cli/src/test-utils/createExtension.ts +++ b/packages/cli/src/test-utils/createExtension.ts @@ -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({ diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c8de2a27ec..426543d772 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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( - new ExtensionEnablementManager(config.getEnabledExtensions()), + const [extensionManager] = useState( + 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( diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts index 8270bdf789..b0949035d0 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts @@ -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(); @@ -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, ), ); diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.ts index 5103cde875..a4e9e2598e 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.ts @@ -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(); @@ -237,7 +226,5 @@ export const useExtensionUpdates = ( extensionsUpdateState: extensionsUpdateStateComputed, extensionsUpdateStateInternal: extensionsUpdateState.extensionStatuses, dispatchExtensionStateUpdate, - confirmUpdateExtensionRequests, - addConfirmUpdateExtensionRequest, }; };