Pass whole extensions rather than just context files (#10910)

Co-authored-by: Jake Macdonald <jakemac@google.com>
This commit is contained in:
Zack Birkenbuel
2025-10-20 16:15:23 -07:00
committed by GitHub
parent 995ae717cc
commit cc7e1472f9
35 changed files with 487 additions and 1193 deletions
@@ -243,38 +243,6 @@ describe('Configuration Integration Tests', () => {
});
});
describe('Extension Context Files', () => {
it('should have an empty array for extension context files by default', () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
};
const config = new Config(configParams);
expect(config.getExtensionContextFilePaths()).toEqual([]);
});
it('should correctly store and return extension context file paths', () => {
const contextFiles = ['/path/to/file1.txt', '/path/to/file2.js'];
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
extensionContextFilePaths: contextFiles,
};
const config = new Config(configParams);
expect(config.getExtensionContextFilePaths()).toEqual(contextFiles);
});
});
describe('Approval Mode Integration Tests', () => {
let parseArguments: typeof import('./config.js').parseArguments;
File diff suppressed because it is too large Load Diff
+16 -27
View File
@@ -40,7 +40,6 @@ import {
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import { annotateActiveExtensions } from './extension.js';
import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
@@ -48,7 +47,6 @@ import { appEvents } from '../utils/events.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { createPolicyEngineConfig } from './policy.js';
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
export interface CliArgs {
query: string | undefined;
@@ -289,7 +287,7 @@ export async function loadHierarchicalGeminiMemory(
debugMode: boolean,
fileService: FileDiscoveryService,
settings: Settings,
extensionContextFilePaths: string[] = [],
extensions: GeminiCLIExtension[],
folderTrust: boolean,
memoryImportFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
@@ -315,7 +313,7 @@ export async function loadHierarchicalGeminiMemory(
includeDirectoriesToReadGemini,
debugMode,
fileService,
extensionContextFilePaths,
extensions,
folderTrust,
memoryImportFormat,
fileFilteringOptions,
@@ -364,8 +362,7 @@ export function isDebugMode(argv: CliArgs): boolean {
export async function loadCliConfig(
settings: Settings,
extensions: GeminiCLIExtension[],
extensionEnablementManager: ExtensionEnablementManager,
allExtensions: GeminiCLIExtension[],
sessionId: string,
argv: CliArgs,
cwd: string = process.cwd(),
@@ -379,16 +376,6 @@ export async function loadCliConfig(
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true;
const allExtensions = annotateActiveExtensions(
extensions,
cwd,
extensionEnablementManager,
);
const activeExtensions = extensions.filter(
(_, i) => allExtensions[i].isActive,
);
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
@@ -400,10 +387,6 @@ export async function loadCliConfig(
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
}
const extensionContextFilePaths = activeExtensions.flatMap(
(e) => e.contextFiles,
);
const fileService = new FileDiscoveryService(cwd);
const fileFiltering = {
@@ -425,13 +408,13 @@ export async function loadCliConfig(
debugMode,
fileService,
settings,
extensionContextFilePaths,
allExtensions,
trustedFolder,
memoryImportFormat,
fileFiltering,
);
let mcpServers = mergeMcpServers(settings, activeExtensions);
let mcpServers = mergeMcpServers(settings, allExtensions);
const question = argv.promptInteractive || argv.prompt || '';
// Determine approval mode with backward compatibility
@@ -527,7 +510,7 @@ export async function loadCliConfig(
const excludeTools = mergeExcludeTools(
settings,
activeExtensions,
allExtensions,
extraExcludes.length > 0 ? extraExcludes : undefined,
);
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
@@ -618,10 +601,10 @@ export async function loadCliConfig(
fileDiscoveryService: fileService,
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
extensionContextFilePaths,
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
enabledExtensions: argv.extensions,
extensions: allExtensions,
blockedMcpServers,
noBrowser: !!process.env['NO_BROWSER'],
@@ -668,7 +651,7 @@ function allowedMcpServers(
if (!isAllowed) {
blockedMcpServers.push({
name: key,
extensionName: server.extensionName || '',
extensionName: server.extension?.name || '',
});
}
return isAllowed;
@@ -678,7 +661,7 @@ function allowedMcpServers(
blockedMcpServers.push(
...Object.entries(mcpServers).map(([key, server]) => ({
name: key,
extensionName: server.extensionName || '',
extensionName: server.extension?.name || '',
})),
);
mcpServers = {};
@@ -689,6 +672,9 @@ function allowedMcpServers(
function mergeMcpServers(settings: Settings, extensions: GeminiCLIExtension[]) {
const mcpServers = { ...(settings.mcpServers || {}) };
for (const extension of extensions) {
if (!extension.isActive) {
continue;
}
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
if (mcpServers[key]) {
debugLogger.warn(
@@ -698,7 +684,7 @@ function mergeMcpServers(settings: Settings, extensions: GeminiCLIExtension[]) {
}
mcpServers[key] = {
...server,
extensionName: extension.name,
extension,
};
});
}
@@ -715,6 +701,9 @@ function mergeExcludeTools(
...(extraExcludes || []),
]);
for (const extension of extensions) {
if (!extension.isActive) {
continue;
}
for (const tool of extension.excludeTools || []) {
allExcludeTools.add(tool);
}
+69 -203
View File
@@ -14,7 +14,6 @@ import {
ExtensionStorage,
INSTALL_METADATA_FILENAME,
INSTALL_WARNING_MESSAGE,
annotateActiveExtensions,
disableExtension,
enableExtension,
installOrUpdateExtension,
@@ -202,7 +201,7 @@ describe('extension tests', () => {
]);
});
it('should filter out disabled extensions', () => {
it('should annotate disabled extensions', () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'disabled-extension',
@@ -213,20 +212,19 @@ describe('extension tests', () => {
name: 'enabled-extension',
version: '2.0.0',
});
const manager = new ExtensionEnablementManager();
disableExtension(
'disabled-extension',
SettingScope.User,
manager,
tempWorkspaceDir,
);
const manager = new ExtensionEnablementManager();
const extensions = loadExtensions(manager);
const activeExtensions = annotateActiveExtensions(
extensions,
tempWorkspaceDir,
manager,
).filter((e) => e.isActive);
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('enabled-extension');
expect(extensions).toHaveLength(2);
expect(extensions[0].name).toBe('disabled-extension');
expect(extensions[0].isActive).toBe(false);
expect(extensions[1].name).toBe('enabled-extension');
expect(extensions[1].isActive).toBe(true);
});
it('should hydrate variables', () => {
@@ -477,6 +475,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir: badExtDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
expect(extension).toBeNull();
@@ -501,6 +500,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@@ -523,6 +523,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@@ -545,6 +546,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@@ -567,6 +569,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@@ -589,6 +592,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@@ -616,6 +620,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir: new ExtensionStorage(extensionName).getExtensionDir(),
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@@ -634,6 +639,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@@ -644,182 +650,6 @@ describe('extension tests', () => {
});
});
describe('annotateActiveExtensions', () => {
const extensions: GeminiCLIExtension[] = [
{
path: '/path/to/ext1',
name: 'ext1',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
{
path: '/path/to/ext2',
name: 'ext2',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
{
path: '/path/to/ext3',
name: 'ext3',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
];
it('should mark all extensions as active if no enabled extensions are provided', () => {
const activeExtensions = annotateActiveExtensions(
extensions,
'/path/to/workspace',
new ExtensionEnablementManager(),
);
expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.every((e) => e.isActive)).toBe(true);
});
it('should mark only the enabled extensions as active', () => {
const activeExtensions = annotateActiveExtensions(
extensions,
'/path/to/workspace',
new ExtensionEnablementManager(['ext1', 'ext3']),
);
expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
true,
);
expect(activeExtensions.find((e) => e.name === 'ext2')?.isActive).toBe(
false,
);
expect(activeExtensions.find((e) => e.name === 'ext3')?.isActive).toBe(
true,
);
});
it('should mark all extensions as inactive when "none" is provided', () => {
const activeExtensions = annotateActiveExtensions(
extensions,
'/path/to/workspace',
new ExtensionEnablementManager(['none']),
);
expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.every((e) => !e.isActive)).toBe(true);
});
it('should handle case-insensitivity', () => {
const activeExtensions = annotateActiveExtensions(
extensions,
'/path/to/workspace',
new ExtensionEnablementManager(['EXT1']),
);
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
true,
);
});
it('should log an error for unknown extensions', () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
annotateActiveExtensions(
extensions,
'/path/to/workspace',
new ExtensionEnablementManager(['ext4']),
);
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
consoleSpy.mockRestore();
});
describe('autoUpdate', () => {
it('should be false if autoUpdate is not set in install metadata', () => {
const activeExtensions = annotateActiveExtensions(
extensions,
tempHomeDir,
new ExtensionEnablementManager(),
);
expect(
activeExtensions.every(
(e) => e.installMetadata?.autoUpdate === false,
),
).toBe(false);
});
it('should be true if autoUpdate is true in install metadata', () => {
const extensionsWithAutoUpdate: GeminiCLIExtension[] = extensions.map(
(e) => ({
...e,
installMetadata: {
...e.installMetadata!,
autoUpdate: true,
},
}),
);
const activeExtensions = annotateActiveExtensions(
extensionsWithAutoUpdate,
tempHomeDir,
new ExtensionEnablementManager(),
);
expect(
activeExtensions.every((e) => e.installMetadata?.autoUpdate === true),
).toBe(true);
});
it('should respect the per-extension settings from install metadata', () => {
const extensionsWithAutoUpdate: GeminiCLIExtension[] = [
{
path: '/path/to/ext1',
name: 'ext1',
version: '1.0.0',
contextFiles: [],
installMetadata: {
source: 'test',
type: 'local',
autoUpdate: true,
},
isActive: true,
},
{
path: '/path/to/ext2',
name: 'ext2',
version: '1.0.0',
contextFiles: [],
installMetadata: {
source: 'test',
type: 'local',
autoUpdate: false,
},
isActive: true,
},
{
path: '/path/to/ext3',
name: 'ext3',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
];
const activeExtensions = annotateActiveExtensions(
extensionsWithAutoUpdate,
tempHomeDir,
new ExtensionEnablementManager(),
);
expect(
activeExtensions.find((e) => e.name === 'ext1')?.installMetadata
?.autoUpdate,
).toBe(true);
expect(
activeExtensions.find((e) => e.name === 'ext2')?.installMetadata
?.autoUpdate,
).toBe(false);
expect(
activeExtensions.find((e) => e.name === 'ext3')?.installMetadata
?.autoUpdate,
).toBe(undefined);
});
});
});
describe('installExtension', () => {
it('should install an extension from a local path', async () => {
const sourceExtDir = createExtension({
@@ -1194,6 +1024,7 @@ This extension will run the following MCP servers:
await loadExtensionConfig({
extensionDir: sourceExtDir,
workspaceDir: process.cwd(),
extensionEnablementManager: new ExtensionEnablementManager(),
}),
),
).resolves.toBe('my-local-extension');
@@ -1512,7 +1343,11 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
disableExtension('my-extension', SettingScope.User);
disableExtension(
'my-extension',
SettingScope.User,
new ExtensionEnablementManager(),
);
expect(
isEnabled({
name: 'my-extension',
@@ -1531,6 +1366,7 @@ This extension will run the following MCP servers:
disableExtension(
'my-extension',
SettingScope.Workspace,
new ExtensionEnablementManager(),
tempWorkspaceDir,
);
expect(
@@ -1554,8 +1390,16 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
disableExtension('my-extension', SettingScope.User);
disableExtension('my-extension', SettingScope.User);
disableExtension(
'my-extension',
SettingScope.User,
new ExtensionEnablementManager(),
);
disableExtension(
'my-extension',
SettingScope.User,
new ExtensionEnablementManager(),
);
expect(
isEnabled({
name: 'my-extension',
@@ -1566,7 +1410,11 @@ This extension will run the following MCP servers:
it('should throw an error if you request system scope', () => {
expect(() =>
disableExtension('my-extension', SettingScope.System),
disableExtension(
'my-extension',
SettingScope.System,
new ExtensionEnablementManager(),
),
).toThrow('System and SystemDefaults scopes are not supported.');
});
@@ -1577,7 +1425,11 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
disableExtension('ext1', SettingScope.Workspace);
disableExtension(
'ext1',
SettingScope.Workspace,
new ExtensionEnablementManager(),
);
expect(mockLogExtensionDisable).toHaveBeenCalled();
expect(ExtensionDisableEvent).toHaveBeenCalledWith(
@@ -1595,12 +1447,7 @@ This extension will run the following MCP servers:
const getActiveExtensions = (): GeminiCLIExtension[] => {
const manager = new ExtensionEnablementManager();
const extensions = loadExtensions(manager);
const activeExtensions = annotateActiveExtensions(
extensions,
tempWorkspaceDir,
manager,
);
return activeExtensions.filter((e) => e.isActive);
return extensions.filter((e) => e.isActive);
};
it('should enable an extension at the user scope', () => {
@@ -1609,11 +1456,12 @@ This extension will run the following MCP servers:
name: 'ext1',
version: '1.0.0',
});
disableExtension('ext1', SettingScope.User);
const extensionEnablementManager = new ExtensionEnablementManager();
disableExtension('ext1', SettingScope.User, extensionEnablementManager);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
enableExtension('ext1', SettingScope.User);
enableExtension('ext1', SettingScope.User, extensionEnablementManager);
activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext1');
@@ -1625,11 +1473,20 @@ This extension will run the following MCP servers:
name: 'ext1',
version: '1.0.0',
});
disableExtension('ext1', SettingScope.Workspace);
const extensionEnablementManager = new ExtensionEnablementManager();
disableExtension(
'ext1',
SettingScope.Workspace,
extensionEnablementManager,
);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
enableExtension('ext1', SettingScope.Workspace);
enableExtension(
'ext1',
SettingScope.Workspace,
extensionEnablementManager,
);
activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext1');
@@ -1641,8 +1498,17 @@ This extension will run the following MCP servers:
name: 'ext1',
version: '1.0.0',
});
disableExtension('ext1', SettingScope.Workspace);
enableExtension('ext1', SettingScope.Workspace);
const extensionEnablementManager = new ExtensionEnablementManager();
disableExtension(
'ext1',
SettingScope.Workspace,
extensionEnablementManager,
);
enableExtension(
'ext1',
SettingScope.Workspace,
extensionEnablementManager,
);
expect(mockLogExtensionEnable).toHaveBeenCalled();
expect(ExtensionEnableEvent).toHaveBeenCalledWith(
+25 -33
View File
@@ -142,6 +142,7 @@ export function loadExtensions(
const extension = loadExtension({
extensionDir,
workspaceDir,
extensionEnablementManager,
});
if (extension != null) {
extensions.push(extension);
@@ -151,10 +152,7 @@ export function loadExtensions(
const uniqueExtensions = new Map<string, GeminiCLIExtension>();
for (const extension of extensions) {
if (
!uniqueExtensions.has(extension.name) &&
extensionEnablementManager.isEnabled(extension.name, workspaceDir)
) {
if (!uniqueExtensions.has(extension.name)) {
uniqueExtensions.set(extension.name, extension);
}
}
@@ -165,7 +163,7 @@ export function loadExtensions(
export function loadExtension(
context: LoadExtensionContext,
): GeminiCLIExtension | null {
const { extensionDir, workspaceDir } = context;
const { extensionDir, workspaceDir, extensionEnablementManager } = context;
if (!fs.statSync(extensionDir).isDirectory()) {
return null;
}
@@ -181,6 +179,7 @@ export function loadExtension(
let config = loadExtensionConfig({
extensionDir: effectiveExtensionPath,
workspaceDir,
extensionEnablementManager,
});
config = resolveEnvVarsInObject(config);
@@ -230,7 +229,7 @@ export function loadExtension(
installMetadata,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
isActive: true, // Barring any other signals extensions should be considered Active.
isActive: extensionEnablementManager.isEnabled(config.name, workspaceDir),
id,
};
} catch (e) {
@@ -245,6 +244,7 @@ export function loadExtension(
export function loadExtensionByName(
name: string,
extensionEnablementManager: ExtensionEnablementManager,
workspaceDir: string = process.cwd(),
): GeminiCLIExtension | null {
const userExtensionsDir = ExtensionStorage.getUserExtensionsDir();
@@ -257,7 +257,11 @@ export function loadExtensionByName(
if (!fs.statSync(extensionDir).isDirectory()) {
continue;
}
const extension = loadExtension({ extensionDir, workspaceDir });
const extension = loadExtension({
extensionDir,
workspaceDir,
extensionEnablementManager,
});
if (extension && extension.name.toLowerCase() === name.toLowerCase()) {
return extension;
}
@@ -294,25 +298,6 @@ function getContextFileNames(config: ExtensionConfig): string[] {
return config.contextFileName;
}
/**
* Returns an annotated list of extensions. If an extension is listed in enabledExtensionNames, it will be active.
* If enabledExtensionNames is empty, an extension is active unless it is disabled.
* @param extensions The base list of extensions.
* @param enabledExtensionNames The names of explicitly enabled extensions.
* @param workspaceDir The current workspace directory.
*/
export function annotateActiveExtensions(
extensions: GeminiCLIExtension[],
workspaceDir: string,
manager: ExtensionEnablementManager,
): GeminiCLIExtension[] {
manager.validateExtensionOverrides(extensions);
return extensions.map((extension) => ({
...extension,
isActive: manager.isEnabled(extension.name, workspaceDir),
}));
}
/**
* Requests consent from the user to perform an action, by reading a Y/n
* character from stdin.
@@ -409,6 +394,7 @@ export async function installOrUpdateExtension(
const telemetryConfig = getTelemetryConfig(cwd);
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
const extensionEnablementManager = new ExtensionEnablementManager();
try {
const settings = loadSettings(cwd).merged;
@@ -480,6 +466,7 @@ export async function installOrUpdateExtension(
newExtensionConfig = loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: cwd,
extensionEnablementManager,
});
const newExtensionName = newExtensionConfig.name;
@@ -555,7 +542,11 @@ export async function installOrUpdateExtension(
'success',
),
);
enableExtension(newExtensionConfig.name, SettingScope.User);
enableExtension(
newExtensionConfig.name,
SettingScope.User,
extensionEnablementManager,
);
}
return newExtensionConfig!.name;
@@ -567,6 +558,7 @@ export async function installOrUpdateExtension(
newExtensionConfig = loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: cwd,
extensionEnablementManager,
});
} catch {
// Ignore error, this is just for logging.
@@ -791,38 +783,38 @@ export function toOutputString(
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, cwd);
const extension = loadExtensionByName(name, extensionEnablementManager, cwd);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const manager = new ExtensionEnablementManager([name]);
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
manager.disable(name, true, scopePath);
extensionEnablementManager.disable(name, true, scopePath);
logExtensionDisable(config, new ExtensionDisableEvent(name, 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, cwd);
const extension = loadExtensionByName(name, extensionEnablementManager, cwd);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const manager = new ExtensionEnablementManager();
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
manager.enable(name, true, scopePath);
extensionEnablementManager.enable(name, true, scopePath);
const config = getTelemetryConfig(cwd);
logExtensionEnable(config, new ExtensionEnableEvent(name, scope));
}
@@ -22,6 +22,7 @@ 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';
const mockPlatform = vi.hoisted(() => vi.fn());
const mockArch = vi.hoisted(() => vi.fn());
@@ -149,7 +150,10 @@ describe('git extension helpers', () => {
},
contextFiles: [],
};
const result = await checkForExtensionUpdate(extension);
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
});
@@ -166,7 +170,10 @@ describe('git extension helpers', () => {
contextFiles: [],
};
mockGit.getRemotes.mockResolvedValue([]);
const result = await checkForExtensionUpdate(extension);
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.ERROR);
});
@@ -188,7 +195,10 @@ describe('git extension helpers', () => {
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.revparse.mockResolvedValue('local-hash');
const result = await checkForExtensionUpdate(extension);
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
});
@@ -210,7 +220,10 @@ describe('git extension helpers', () => {
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
mockGit.revparse.mockResolvedValue('same-hash');
const result = await checkForExtensionUpdate(extension);
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
});
@@ -228,7 +241,10 @@ describe('git extension helpers', () => {
};
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
const result = await checkForExtensionUpdate(extension);
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.ERROR);
});
});
@@ -20,6 +20,7 @@ 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';
/**
* Clones a Git repository to a specified local path.
@@ -152,6 +153,7 @@ export async function fetchReleaseFromGithub(
export async function checkForExtensionUpdate(
extension: GeminiCLIExtension,
extensionEnablementManager: ExtensionEnablementManager,
cwd: string = process.cwd(),
): Promise<ExtensionUpdateState> {
const installMetadata = extension.installMetadata;
@@ -159,6 +161,7 @@ export async function checkForExtensionUpdate(
const newExtension = loadExtension({
extensionDir: installMetadata.source,
workspaceDir: cwd,
extensionEnablementManager,
});
if (!newExtension) {
debugLogger.error(
@@ -11,7 +11,6 @@ import * as path from 'node:path';
import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
annotateActiveExtensions,
loadExtension,
} from '../extension.js';
import { checkForAllExtensionUpdates, updateExtension } from './update.js';
@@ -128,18 +127,15 @@ describe('update tests', () => {
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir: targetExtDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir: targetExtDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const updateInfo = await updateExtension(
extension,
extensionEnablementManager,
tempHomeDir,
async (_) => true,
ExtensionUpdateState.UPDATE_AVAILABLE,
@@ -185,18 +181,15 @@ describe('update tests', () => {
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
await updateExtension(
extension,
extensionEnablementManager,
tempHomeDir,
async (_) => true,
ExtensionUpdateState.UPDATE_AVAILABLE,
@@ -235,19 +228,16 @@ describe('update tests', () => {
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
await expect(
updateExtension(
extension,
extensionEnablementManager,
tempHomeDir,
async (_) => true,
ExtensionUpdateState.UPDATE_AVAILABLE,
@@ -283,16 +273,12 @@ describe('update tests', () => {
type: 'git',
},
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
@@ -303,6 +289,7 @@ describe('update tests', () => {
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
dispatch,
tempWorkspaceDir,
);
@@ -325,16 +312,12 @@ describe('update tests', () => {
type: 'git',
},
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
@@ -345,6 +328,7 @@ describe('update tests', () => {
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
dispatch,
tempWorkspaceDir,
);
@@ -371,19 +355,16 @@ describe('update tests', () => {
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir: installedExtensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir: installedExtensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
dispatch,
tempWorkspaceDir,
);
@@ -410,19 +391,16 @@ describe('update tests', () => {
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir: installedExtensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir: installedExtensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
dispatch,
tempWorkspaceDir,
);
@@ -445,22 +423,19 @@ describe('update tests', () => {
type: 'git',
},
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
dispatch,
tempWorkspaceDir,
);
+13 -5
View File
@@ -21,6 +21,7 @@ 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';
export interface ExtensionUpdateInfo {
name: string;
@@ -30,6 +31,7 @@ export interface ExtensionUpdateInfo {
export async function updateExtension(
extension: GeminiCLIExtension,
extensionEnablementManager: ExtensionEnablementManager,
cwd: string = process.cwd(),
requestConsent: (consent: string) => Promise<boolean>,
currentState: ExtensionUpdateState,
@@ -67,6 +69,7 @@ export async function updateExtension(
const previousExtensionConfig = await loadExtensionConfig({
extensionDir: extension.path,
workspaceDir: cwd,
extensionEnablementManager,
});
await installOrUpdateExtension(
installMetadata,
@@ -79,6 +82,7 @@ export async function updateExtension(
const updatedExtension = loadExtension({
extensionDir: updatedExtensionStorage.getExtensionDir(),
workspaceDir: cwd,
extensionEnablementManager,
});
if (!updatedExtension) {
dispatchExtensionStateUpdate({
@@ -120,6 +124,7 @@ export async function updateAllUpdatableExtensions(
requestConsent: (consent: string) => Promise<boolean>,
extensions: GeminiCLIExtension[],
extensionsState: Map<string, ExtensionUpdateStatus>,
extensionEnablementManager: ExtensionEnablementManager,
dispatch: (action: ExtensionUpdateAction) => void,
): Promise<ExtensionUpdateInfo[]> {
return (
@@ -133,6 +138,7 @@ export async function updateAllUpdatableExtensions(
.map((extension) =>
updateExtension(
extension,
extensionEnablementManager,
cwd,
requestConsent,
extensionsState.get(extension.name)!.status,
@@ -150,6 +156,7 @@ export interface ExtensionUpdateCheckResult {
export async function checkForAllExtensionUpdates(
extensions: GeminiCLIExtension[],
extensionEnablementManager: ExtensionEnablementManager,
dispatch: (action: ExtensionUpdateAction) => void,
cwd: string = process.cwd(),
): Promise<void> {
@@ -174,11 +181,12 @@ export async function checkForAllExtensionUpdates(
},
});
promises.push(
checkForExtensionUpdate(extension, cwd).then((state) =>
dispatch({
type: 'SET_STATE',
payload: { name: extension.name, state },
}),
checkForExtensionUpdate(extension, extensionEnablementManager, cwd).then(
(state) =>
dispatch({
type: 'SET_STATE',
payload: { name: extension.name, state },
}),
),
);
}
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ExtensionEnablementManager } from './extensionEnablement.js';
export interface VariableDefinition {
type: 'string';
description: string;
@@ -18,6 +20,7 @@ export interface VariableSchema {
export interface LoadExtensionContext {
extensionDir: string;
workspaceDir: string;
extensionEnablementManager: ExtensionEnablementManager;
}
const PATH_SEPARATOR_DEFINITION = {
+11 -6
View File
@@ -50,7 +50,7 @@ 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 } from './extension.js';
import { disableExtension, ExtensionStorage } from './extension.js';
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
import {
@@ -65,7 +65,8 @@ import {
migrateDeprecatedSettings,
SettingScope,
} from './settings.js';
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
import { FatalConfigError, GEMINI_DIR, Storage } from '@google/gemini-cli-core';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
// Use the (mocked) GEMINI_DIR for consistency
@@ -93,9 +94,7 @@ vi.mock('fs', async (importOriginal) => {
};
});
vi.mock('./extension.js', () => ({
disableExtension: vi.fn(),
}));
vi.mock('./extension.js');
vi.mock('strip-json-comments', () => ({
default: vi.fn((content) => content),
@@ -2349,7 +2348,9 @@ describe('Settings Loading and Merging', () => {
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);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
@@ -2392,11 +2393,13 @@ describe('Settings Loading and Merging', () => {
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,
);
@@ -2404,11 +2407,13 @@ describe('Settings Loading and Merging', () => {
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,
);
+8 -1
View File
@@ -31,6 +31,7 @@ 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';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
@@ -755,8 +756,14 @@ export function migrateDeprecatedSettings(
console.log(
`Migrating deprecated extensions.disabled settings from ${scope} settings...`,
);
const extensionEnablementManager = new ExtensionEnablementManager();
for (const extension of settings.extensions.disabled ?? []) {
disableExtension(extension, scope, workspaceDir);
disableExtension(
extension,
scope,
extensionEnablementManager,
workspaceDir,
);
}
const newExtensionsValue = { ...settings.extensions };