Add ExtensionLoader interface, use that on Config object (#12116)

This commit is contained in:
Jacob MacDonald
2025-10-28 09:04:30 -07:00
committed by GitHub
parent 25f27509c0
commit 1b302deeff
35 changed files with 619 additions and 505 deletions
File diff suppressed because it is too large Load Diff
+19 -7
View File
@@ -48,6 +48,10 @@ import { appEvents } from '../utils/events.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { createPolicyEngineConfig } from './policy.js';
import { ExtensionManager } from './extension-manager.js';
import type { ExtensionLoader } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
import { requestConsentNonInteractive } from './extensions/consent.js';
import { promptForSetting } from './extensions/extensionSettings.js';
export interface CliArgs {
query: string | undefined;
@@ -293,7 +297,7 @@ export async function loadHierarchicalGeminiMemory(
debugMode: boolean,
fileService: FileDiscoveryService,
settings: Settings,
extensions: GeminiCLIExtension[],
extensionLoader: ExtensionLoader,
folderTrust: boolean,
memoryImportFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
@@ -319,7 +323,7 @@ export async function loadHierarchicalGeminiMemory(
includeDirectoriesToReadGemini,
debugMode,
fileService,
extensions,
extensionLoader,
folderTrust,
memoryImportFormat,
fileFilteringOptions,
@@ -368,7 +372,6 @@ export function isDebugMode(argv: CliArgs): boolean {
export async function loadCliConfig(
settings: Settings,
allExtensions: GeminiCLIExtension[],
sessionId: string,
argv: CliArgs,
cwd: string = process.cwd(),
@@ -413,6 +416,15 @@ export async function loadCliConfig(
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
const extensionManager = new ExtensionManager({
settings,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
});
extensionManager.loadExtensions();
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount, filePaths } =
await loadHierarchicalGeminiMemory(
@@ -423,13 +435,13 @@ export async function loadCliConfig(
debugMode,
fileService,
settings,
allExtensions,
extensionManager,
trustedFolder,
memoryImportFormat,
memoryFileFiltering,
);
let mcpServers = mergeMcpServers(settings, allExtensions);
let mcpServers = mergeMcpServers(settings, extensionManager.getExtensions());
const question = argv.promptInteractive || argv.prompt || '';
// Determine approval mode with backward compatibility
@@ -540,7 +552,7 @@ export async function loadCliConfig(
const excludeTools = mergeExcludeTools(
settings,
allExtensions,
extensionManager.getExtensions(),
extraExcludes.length > 0 ? extraExcludes : undefined,
);
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
@@ -636,7 +648,7 @@ export async function loadCliConfig(
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
enabledExtensions: argv.extensions,
extensions: allExtensions,
extensionLoader: extensionManager,
blockedMcpServers,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
+132 -92
View File
@@ -9,7 +9,7 @@ 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 { type Settings, SettingScope } from './settings.js';
import { createHash, randomUUID } from 'node:crypto';
import { loadInstallMetadata, type ExtensionConfig } from './extension.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
@@ -50,33 +50,45 @@ import {
maybePromptForSettings,
type ExtensionSetting,
} from './extensions/extensionSettings.js';
import type {
ExtensionEvents,
ExtensionLoader,
} from '@google/gemini-cli-core/src/utils/extensionLoader.js';
import { EventEmitter } from 'node:events';
interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
loadedSettings: LoadedSettings;
settings: Settings;
requestConsent: (consent: string) => Promise<boolean>;
requestSetting: ((setting: ExtensionSetting) => Promise<string>) | null;
workspaceDir: string;
}
export class ExtensionManager {
/**
* Actual implementation of an ExtensionLoader.
*
* You must call `loadExtensions` prior to calling other methods on this class.
*/
export class ExtensionManager implements ExtensionLoader {
private extensionEnablementManager: ExtensionEnablementManager;
private loadedSettings: LoadedSettings;
private settings: Settings;
private requestConsent: (consent: string) => Promise<boolean>;
private requestSetting:
| ((setting: ExtensionSetting) => Promise<string>)
| null;
| undefined;
private telemetryConfig: Config;
private workspaceDir: string;
private loadedExtensions: GeminiCLIExtension[] | undefined;
private eventEmitter: EventEmitter<ExtensionEvents>;
constructor(options: ExtensionManagerParams) {
this.workspaceDir = options.workspaceDir;
this.extensionEnablementManager = new ExtensionEnablementManager(
options.enabledExtensionOverrides,
);
this.loadedSettings = options.loadedSettings;
this.settings = options.settings;
this.telemetryConfig = new Config({
telemetry: options.loadedSettings.merged.telemetry,
telemetry: options.settings.telemetry,
interactive: false,
sessionId: randomUUID(),
targetDir: options.workspaceDir,
@@ -85,19 +97,45 @@ export class ExtensionManager {
debugMode: false,
});
this.requestConsent = options.requestConsent;
this.requestSetting = options.requestSetting;
this.requestSetting = options.requestSetting ?? undefined;
this.eventEmitter = new EventEmitter();
}
setRequestConsent(
requestConsent: (consent: string) => Promise<boolean>,
): void {
this.requestConsent = requestConsent;
}
setRequestSetting(
requestSetting?: (setting: ExtensionSetting) => Promise<string>,
): void {
this.requestSetting = requestSetting;
}
getExtensions(): GeminiCLIExtension[] {
if (!this.loadedExtensions) {
throw new Error(
'Extensions not yet loaded, must call `loadExtensions` first',
);
}
return this.loadedExtensions!;
}
extensionEvents(): EventEmitter<ExtensionEvents> {
return this.eventEmitter;
}
async installOrUpdateExtension(
installMetadata: ExtensionInstallMetadata,
previousExtensionConfig?: ExtensionConfig,
): Promise<string> {
): Promise<GeminiCLIExtension> {
const isUpdate = !!previousExtensionConfig;
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
let extension: GeminiCLIExtension;
try {
const settings = this.loadedSettings.merged;
if (!isWorkspaceTrusted(settings).isTrusted) {
if (!isWorkspaceTrusted(this.settings).isTrusted) {
throw new Error(
`Could not install extension from untrusted folder at ${installMetadata.source}`,
);
@@ -187,17 +225,17 @@ export class ExtensionManager {
}
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.`,
);
}
const previous = this.getExtensions().find(
(installed) => installed.name === newExtensionName,
);
if (isUpdate && !previous) {
throw new Error(
`Extension "${newExtensionName}" was not already installed, cannot update it.`,
);
} else if (!isUpdate && previous) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
}
await maybeRequestConsentOrFail(
@@ -245,39 +283,43 @@ export class ExtensionManager {
INSTALL_METADATA_FILENAME,
);
await fs.promises.writeFile(metadataPath, metadataString);
// TODO: Gracefully handle this call failing, we should back up the old
// extension prior to overwriting it and then restore it.
extension = this.loadExtension(destinationPath)!;
if (isUpdate) {
logExtensionUpdateEvent(
this.telemetryConfig,
new ExtensionUpdateEvent(
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
previousExtensionConfig.version,
installMetadata.type,
'success',
),
);
this.eventEmitter.emit('extensionUpdated', { extension });
} else {
logExtensionInstallEvent(
this.telemetryConfig,
new ExtensionInstallEvent(
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
installMetadata.type,
'success',
),
);
this.eventEmitter.emit('extensionInstalled', { extension });
this.enableExtension(newExtensionConfig.name, SettingScope.User);
}
} 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;
return extension;
} catch (error) {
// Attempt to load config from the source path even if installation fails
// to get the name and version for logging.
@@ -324,7 +366,7 @@ export class ExtensionManager {
extensionIdentifier: string,
isUpdate: boolean,
): Promise<void> {
const installedExtensions = this.loadExtensions();
const installedExtensions = this.getExtensions();
const extension = installedExtensions.find(
(installed) =>
installed.name.toLowerCase() === extensionIdentifier.toLowerCase() ||
@@ -334,6 +376,7 @@ export class ExtensionManager {
if (!extension) {
throw new Error(`Extension not found.`);
}
this.unloadExtension(extension);
const storage = new ExtensionStorage(extension.name);
await fs.promises.rm(storage.getExtensionDir(), {
@@ -355,36 +398,28 @@ export class ExtensionManager {
'success',
),
);
this.eventEmitter.emit('extensionUninstalled', { extension });
}
loadExtensions(): GeminiCLIExtension[] {
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
if (!fs.existsSync(extensionsDir)) {
return [];
if (this.loadedExtensions) {
throw new Error('Extensions already loaded, only load extensions once.');
}
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
this.loadedExtensions = [];
if (!fs.existsSync(extensionsDir)) {
return this.loadedExtensions;
}
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);
}
this.loadExtension(extensionDir);
}
const uniqueExtensions = new Map<string, GeminiCLIExtension>();
for (const extension of extensions) {
if (!uniqueExtensions.has(extension.name)) {
uniqueExtensions.set(extension.name, extension);
}
}
return Array.from(uniqueExtensions.values());
return this.loadedExtensions;
}
loadExtension(extensionDir: string): GeminiCLIExtension | null {
private loadExtension(extensionDir: string): GeminiCLIExtension | null {
this.loadedExtensions ??= [];
if (!fs.statSync(extensionDir).isDirectory()) {
return null;
}
@@ -398,6 +433,13 @@ export class ExtensionManager {
try {
let config = this.loadExtensionConfig(effectiveExtensionPath);
if (
this.getExtensions().find((extension) => extension.name === config.name)
) {
throw new Error(
`Extension with name ${config.name} already was loaded.`,
);
}
const customEnv = getEnvContents(new ExtensionStorage(config.name));
config = resolveEnvVarsInObject(config, customEnv);
@@ -417,7 +459,7 @@ export class ExtensionManager {
)
.filter((contextFilePath) => fs.existsSync(contextFilePath));
return {
const extension = {
name: config.name,
version: config.version,
path: effectiveExtensionPath,
@@ -431,6 +473,9 @@ export class ExtensionManager {
),
id: getExtensionId(config, installMetadata),
};
this.eventEmitter.emit('extensionLoaded', { extension });
this.getExtensions().push(extension);
return extension;
} catch (e) {
debugLogger.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
@@ -441,24 +486,11 @@ export class ExtensionManager {
}
}
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;
private unloadExtension(extension: GeminiCLIExtension) {
this.loadedExtensions = this.getExtensions().filter(
(entry) => extension !== entry,
);
this.eventEmitter.emit('extensionUnloaded', { extension });
}
loadExtensionConfig(extensionDir: string): ExtensionConfig {
@@ -548,7 +580,9 @@ export class ExtensionManager {
) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = this.loadExtensionByName(name);
const extension = this.getExtensions().find(
(extension) => extension.name === name,
);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
@@ -560,6 +594,8 @@ export class ExtensionManager {
this.telemetryConfig,
new ExtensionDisableEvent(hashValue(name), extension.id, scope),
);
extension.isActive = false;
this.eventEmitter.emit('extensionDisabled', { extension });
}
enableExtension(name: string, scope: SettingScope) {
@@ -569,7 +605,9 @@ export class ExtensionManager {
) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = this.loadExtensionByName(name);
const extension = this.getExtensions().find(
(extension) => extension.name === name,
);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
@@ -580,6 +618,8 @@ export class ExtensionManager {
this.telemetryConfig,
new ExtensionEnableEvent(hashValue(name), extension.id, scope),
);
extension.isActive = true;
this.eventEmitter.emit('extensionEnabled', { extension });
}
}
+84 -31
View File
@@ -140,7 +140,7 @@ describe('extension tests', () => {
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
loadedSettings: loadSettings(tempWorkspaceDir),
settings: loadSettings(tempWorkspaceDir).merged,
});
});
@@ -220,11 +220,12 @@ describe('extension tests', () => {
name: 'enabled-extension',
version: '2.0.0',
});
extensionManager.loadExtensions();
extensionManager.disableExtension(
'disabled-extension',
SettingScope.User,
);
const extensions = extensionManager.loadExtensions();
const extensions = extensionManager.getExtensions();
expect(extensions).toHaveLength(2);
expect(extensions[0].name).toBe('disabled-extension');
expect(extensions[0].isActive).toBe(false);
@@ -265,13 +266,14 @@ describe('extension tests', () => {
});
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
const extensionName = await extensionManager.installOrUpdateExtension({
extensionManager.loadExtensions();
const extension = await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'link',
});
expect(extensionName).toEqual('my-linked-extension');
const extensions = extensionManager.loadExtensions();
expect(extension.name).toEqual('my-linked-extension');
const extensions = extensionManager.getExtensions();
expect(extensions).toHaveLength(1);
const linkedExt = extensions[0];
@@ -301,12 +303,13 @@ describe('extension tests', () => {
},
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'link',
});
const extensions = extensionManager.loadExtensions();
const extensions = extensionManager.getExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].mcpServers?.['test-server'].cwd).toBe(
path.join(sourceExtDir, 'server'),
@@ -525,15 +528,17 @@ describe('extension tests', () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const badExtDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'bad_name',
version: '1.0.0',
});
const extension = extensionManager.loadExtension(badExtDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'bad_name');
expect(extension).toBeNull();
expect(extension).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid extension name: "bad_name"'),
);
@@ -542,7 +547,7 @@ describe('extension tests', () => {
describe('id generation', () => {
it('should generate id from source for non-github git urls', () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
@@ -552,12 +557,14 @@ describe('extension tests', () => {
},
});
const extension = extensionManager.loadExtension(extensionDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('http://somehost.com/foo/bar'));
});
it('should generate id from owner/repo for github http urls', () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
@@ -567,12 +574,14 @@ describe('extension tests', () => {
},
});
const extension = extensionManager.loadExtension(extensionDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from owner/repo for github ssh urls', () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
@@ -582,12 +591,14 @@ describe('extension tests', () => {
},
});
const extension = extensionManager.loadExtension(extensionDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from source for github-release extension', () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
@@ -597,12 +608,14 @@ describe('extension tests', () => {
},
});
const extension = extensionManager.loadExtension(extensionDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from the original source for local extension', () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-ext-name',
version: '1.0.0',
@@ -612,7 +625,9 @@ describe('extension tests', () => {
},
});
const extension = extensionManager.loadExtension(extensionDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'local-ext-name');
expect(extension?.id).toBe(hashValue('/some/path'));
});
@@ -623,25 +638,28 @@ describe('extension tests', () => {
name: 'link-ext-name',
version: '1.0.0',
});
const extensionName = await extensionManager.installOrUpdateExtension({
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
type: 'link',
source: actualExtensionDir,
});
const extension = extensionManager.loadExtension(
new ExtensionStorage(extensionName).getExtensionDir(),
);
const extension = extensionManager
.getExtensions()
.find((e) => e.name === 'link-ext-name');
expect(extension?.id).toBe(hashValue(actualExtensionDir));
});
it('should generate id from name for extension with no install metadata', () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'no-meta-name',
version: '1.0.0',
});
const extension = extensionManager.loadExtension(extensionDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'no-meta-name');
expect(extension?.id).toBe(hashValue('no-meta-name'));
});
});
@@ -657,6 +675,7 @@ describe('extension tests', () => {
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
@@ -678,6 +697,7 @@ describe('extension tests', () => {
name: 'my-local-extension',
version: '1.0.0',
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
@@ -771,6 +791,7 @@ describe('extension tests', () => {
type: 'github-release',
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'git',
@@ -795,6 +816,7 @@ describe('extension tests', () => {
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'link',
@@ -824,6 +846,7 @@ describe('extension tests', () => {
name: 'my-local-extension',
version: '1.1.0',
});
extensionManager.loadExtensions();
if (isUpdate) {
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
@@ -897,12 +920,15 @@ describe('extension tests', () => {
},
});
extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).resolves.toBe('my-local-extension');
).resolves.toMatchObject({
name: 'my-local-extension',
});
expect(mockRequestConsent).toHaveBeenCalledWith(
`Installing extension "my-local-extension".
@@ -926,12 +952,13 @@ This extension will run the following MCP servers:
},
});
extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).resolves.toBe('my-local-extension');
).resolves.toMatchObject({ name: 'my-local-extension' });
});
it('should cancel installation if user declines prompt for local extension with mcp servers', async () => {
@@ -947,6 +974,7 @@ This extension will run the following MCP servers:
},
});
mockRequestConsent.mockResolvedValue(false);
extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
@@ -964,6 +992,7 @@ This extension will run the following MCP servers:
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
@@ -994,6 +1023,7 @@ This extension will run the following MCP servers:
},
});
extensionManager.loadExtensions();
// Install it with hard coded consent first.
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
@@ -1008,7 +1038,7 @@ This extension will run the following MCP servers:
// Provide its own existing config as the previous config.
await extensionManager.loadExtensionConfig(sourceExtDir),
),
).resolves.toBe('my-local-extension');
).resolves.toMatchObject({ name: 'my-local-extension' });
// Still only called once
expect(mockRequestConsent).toHaveBeenCalledOnce();
@@ -1028,6 +1058,7 @@ This extension will run the following MCP servers:
],
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
@@ -1054,9 +1085,10 @@ This extension will run the following MCP servers:
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: null,
loadedSettings: loadSettings(tempWorkspaceDir),
settings: loadSettings(tempWorkspaceDir).merged,
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
@@ -1079,6 +1111,7 @@ This extension will run the following MCP servers:
});
mockPromptForSettings.mockResolvedValueOnce('old-api-key');
extensionManager.loadExtensions();
// Install it so it exists in the userExtensionsDir
await extensionManager.installOrUpdateExtension({
source: oldSourceExtDir,
@@ -1148,6 +1181,7 @@ This extension will run the following MCP servers:
},
],
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: oldSourceExtDir,
type: 'local',
@@ -1239,6 +1273,7 @@ This extension will run the following MCP servers:
join(tempDir, extensionName),
);
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'github-release',
@@ -1263,6 +1298,7 @@ This extension will run the following MCP servers:
type: 'github-release',
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension(
{ source: gitUrl, type: 'github-release' }, // Use github-release to force consent
);
@@ -1293,6 +1329,7 @@ This extension will run the following MCP servers:
});
mockRequestConsent.mockResolvedValue(false);
extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: gitUrl,
@@ -1317,6 +1354,7 @@ This extension will run the following MCP servers:
type: 'github-release',
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'git',
@@ -1347,6 +1385,7 @@ This extension will run the following MCP servers:
type: 'github-release',
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension(
{ source: gitUrl, type: 'github-release' }, // Note the type
);
@@ -1369,6 +1408,7 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
extensionManager.loadExtensions();
await extensionManager.uninstallExtension('my-local-extension', false);
expect(fs.existsSync(sourceExtDir)).toBe(false);
@@ -1386,14 +1426,16 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
extensionManager.loadExtensions();
await extensionManager.uninstallExtension('my-local-extension', false);
expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(extensionManager.loadExtensions()).toHaveLength(1);
expect(extensionManager.getExtensions()).toHaveLength(1);
expect(fs.existsSync(otherExtDir)).toBe(true);
});
it('should throw an error if the extension does not exist', async () => {
extensionManager.loadExtensions();
await expect(
extensionManager.uninstallExtension('nonexistent-extension', false),
).rejects.toThrow('Extension not found.');
@@ -1411,6 +1453,7 @@ This extension will run the following MCP servers:
},
});
extensionManager.loadExtensions();
await extensionManager.uninstallExtension(
'my-local-extension',
isUpdate,
@@ -1438,6 +1481,7 @@ This extension will run the following MCP servers:
const enablementManager = new ExtensionEnablementManager();
enablementManager.enable('test-extension', true, '/some/scope');
extensionManager.loadExtensions();
await extensionManager.uninstallExtension('test-extension', isUpdate);
const config = enablementManager.readConfig()['test-extension'];
@@ -1462,6 +1506,7 @@ This extension will run the following MCP servers:
},
});
extensionManager.loadExtensions();
await extensionManager.uninstallExtension(gitUrl, false);
expect(fs.existsSync(sourceExtDir)).toBe(false);
@@ -1481,6 +1526,7 @@ This extension will run the following MCP servers:
// No installMetadata provided
});
extensionManager.loadExtensions();
await expect(
extensionManager.uninstallExtension(
'https://github.com/google/no-metadata-extension',
@@ -1498,6 +1544,7 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
extensionManager.loadExtensions();
extensionManager.disableExtension('my-extension', SettingScope.User);
expect(
isEnabled({
@@ -1514,6 +1561,7 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
extensionManager.loadExtensions();
extensionManager.disableExtension('my-extension', SettingScope.Workspace);
expect(
isEnabled({
@@ -1536,6 +1584,7 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
extensionManager.loadExtensions();
extensionManager.disableExtension('my-extension', SettingScope.User);
extensionManager.disableExtension('my-extension', SettingScope.User);
expect(
@@ -1563,6 +1612,7 @@ This extension will run the following MCP servers:
},
});
extensionManager.loadExtensions();
extensionManager.disableExtension('ext1', SettingScope.Workspace);
expect(mockLogExtensionDisable).toHaveBeenCalled();
@@ -1580,7 +1630,7 @@ This extension will run the following MCP servers:
});
const getActiveExtensions = (): GeminiCLIExtension[] => {
const extensions = extensionManager.loadExtensions();
const extensions = extensionManager.getExtensions();
return extensions.filter((e) => e.isActive);
};
@@ -1590,6 +1640,7 @@ This extension will run the following MCP servers:
name: 'ext1',
version: '1.0.0',
});
extensionManager.loadExtensions();
extensionManager.disableExtension('ext1', SettingScope.User);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
@@ -1606,6 +1657,7 @@ This extension will run the following MCP servers:
name: 'ext1',
version: '1.0.0',
});
extensionManager.loadExtensions();
extensionManager.disableExtension('ext1', SettingScope.Workspace);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
@@ -1626,6 +1678,7 @@ This extension will run the following MCP servers:
type: 'local',
},
});
extensionManager.loadExtensions();
extensionManager.disableExtension('ext1', SettingScope.Workspace);
extensionManager.enableExtension('ext1', SettingScope.Workspace);
@@ -170,7 +170,7 @@ describe('git extension helpers', () => {
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
loadedSettings: loadSettings(tempWorkspaceDir),
settings: loadSettings(tempWorkspaceDir).merged,
});
});
+5 -3
View File
@@ -157,14 +157,16 @@ export async function checkForExtensionUpdate(
): Promise<ExtensionUpdateState> {
const installMetadata = extension.installMetadata;
if (installMetadata?.type === 'local') {
const newExtension = extensionManager.loadExtension(installMetadata.source);
if (!newExtension) {
const latestConfig = extensionManager.loadExtensionConfig(
installMetadata.source,
);
if (!latestConfig) {
debugLogger.error(
`Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`,
);
return ExtensionUpdateState.ERROR;
}
if (newExtension.version !== extension.version) {
if (latestConfig.version !== extension.version) {
return ExtensionUpdateState.UPDATE_AVAILABLE;
}
return ExtensionUpdateState.UP_TO_DATE;
@@ -109,7 +109,7 @@ describe('update tests', () => {
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
loadedSettings: loadSettings(tempWorkspaceDir),
settings: loadSettings(tempWorkspaceDir).merged,
});
});
@@ -145,7 +145,9 @@ describe('update tests', () => {
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const extension = extensionManager.loadExtension(targetExtDir)!;
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === extensionName)!;
const updateInfo = await updateExtension(
extension,
extensionManager,
@@ -170,7 +172,7 @@ describe('update tests', () => {
it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => {
const extensionName = 'test-extension';
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
@@ -192,7 +194,10 @@ describe('update tests', () => {
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extension = extensionManager.loadExtension(extensionDir)!;
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === extensionName)!;
await updateExtension(
extension,
extensionManager,
@@ -218,7 +223,7 @@ describe('update tests', () => {
it('should call setExtensionUpdateState with ERROR on failure', async () => {
const extensionName = 'test-extension';
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
@@ -232,7 +237,9 @@ describe('update tests', () => {
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extension = extensionManager.loadExtension(extensionDir)!;
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === extensionName)!;
await expect(
updateExtension(
extension,
@@ -261,7 +268,7 @@ describe('update tests', () => {
describe('checkForAllExtensionUpdates', () => {
it('should return UpdateAvailable for a git extension with updates', async () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
@@ -270,7 +277,6 @@ describe('update tests', () => {
type: 'git',
},
});
const extension = extensionManager.loadExtension(extensionDir)!;
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
@@ -280,7 +286,7 @@ describe('update tests', () => {
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
@@ -294,7 +300,7 @@ describe('update tests', () => {
});
it('should return UpToDate for a git extension with no updates', async () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
@@ -303,7 +309,6 @@ describe('update tests', () => {
type: 'git',
},
});
const extension = extensionManager.loadExtension(extensionDir)!;
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
@@ -313,7 +318,7 @@ describe('update tests', () => {
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
@@ -334,16 +339,15 @@ describe('update tests', () => {
version: '1.0.0',
});
const installedExtensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const extension = extensionManager.loadExtension(installedExtensionDir)!;
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
@@ -360,20 +364,19 @@ describe('update tests', () => {
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
const sourceExtensionDir = createExtension({
extensionsDir: localExtensionSourcePath,
name: 'my-local-ext',
name: 'local-extension',
version: '1.1.0',
});
const installedExtensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const extension = extensionManager.loadExtension(installedExtensionDir)!;
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
@@ -387,7 +390,7 @@ describe('update tests', () => {
});
it('should return Error when git check fails', async () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'error-extension',
version: '1.0.0',
@@ -396,13 +399,12 @@ describe('update tests', () => {
type: 'git',
},
});
const extension = extensionManager.loadExtension(extensionDir)!;
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
+10 -10
View File
@@ -61,20 +61,20 @@ export async function updateExtension(
const previousExtensionConfig = await extensionManager.loadExtensionConfig(
extension.path,
);
await extensionManager.installOrUpdateExtension(
installMetadata,
previousExtensionConfig,
);
const updatedExtensionStorage = new ExtensionStorage(extension.name);
const updatedExtension = extensionManager.loadExtension(
updatedExtensionStorage.getExtensionDir(),
);
if (!updatedExtension) {
let updatedExtension: GeminiCLIExtension;
try {
updatedExtension = await extensionManager.installOrUpdateExtension(
installMetadata,
previousExtensionConfig,
);
} catch (e) {
dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
});
throw new Error('Updated extension not found after installation.');
throw new Error(
`Updated extension not found after installation, got error:\n${e}`,
);
}
const updatedVersion = updatedExtension.version;
dispatchExtensionStateUpdate({
+2 -2
View File
@@ -2433,7 +2433,7 @@ describe('Settings Loading and Merging', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
const extensionManager = new ExtensionManager({
loadedSettings,
settings: loadedSettings.merged,
workspaceDir: MOCK_WORKSPACE_DIR,
requestConsent: vi.fn(),
requestSetting: vi.fn(),
@@ -2506,7 +2506,7 @@ describe('Settings Loading and Merging', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
const extensionManager = new ExtensionManager({
loadedSettings,
settings: loadedSettings.merged,
workspaceDir: MOCK_WORKSPACE_DIR,
requestConsent: vi.fn(),
requestSetting: vi.fn(),