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

This commit is contained in:
Jacob MacDonald
2025-10-23 11:39:36 -07:00
committed by GitHub
parent 3a501196f0
commit c4c0c0d182
31 changed files with 1450 additions and 1568 deletions
+14 -14
View File
@@ -5,11 +5,12 @@
*/
import { type CommandModule } from 'yargs';
import { disableExtension } from '../../config/extension.js';
import { SettingScope } from '../../config/settings.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface DisableArgs {
name: string;
@@ -17,20 +18,19 @@ interface DisableArgs {
}
export function handleDisable(args: DisableArgs) {
const extensionEnablementManager = new ExtensionEnablementManager();
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
try {
if (args.scope?.toLowerCase() === 'workspace') {
disableExtension(
args.name,
SettingScope.Workspace,
extensionEnablementManager,
);
extensionManager.disableExtension(args.name, SettingScope.Workspace);
} else {
disableExtension(
args.name,
SettingScope.User,
extensionEnablementManager,
);
extensionManager.disableExtension(args.name, SettingScope.User);
}
debugLogger.log(
`Extension "${args.name}" successfully disabled for scope "${args.scope}".`,
+13 -10
View File
@@ -5,14 +5,15 @@
*/
import { type CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
debugLogger,
FatalConfigError,
getErrorMessage,
} from '@google/gemini-cli-core';
import { enableExtension } from '../../config/extension.js';
import { SettingScope } from '../../config/settings.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface EnableArgs {
name: string;
@@ -20,16 +21,18 @@ interface EnableArgs {
}
export function handleEnable(args: EnableArgs) {
const extensionEnablementManager = new ExtensionEnablementManager();
const workingDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir: workingDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workingDir),
});
try {
if (args.scope?.toLowerCase() === 'workspace') {
enableExtension(
args.name,
SettingScope.Workspace,
extensionEnablementManager,
);
extensionManager.enableExtension(args.name, SettingScope.Workspace);
} else {
enableExtension(args.name, SettingScope.User, extensionEnablementManager);
extensionManager.enableExtension(args.name, SettingScope.User);
}
if (args.scope) {
debugLogger.log(
@@ -12,11 +12,21 @@ const mockInstallOrUpdateExtension = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
const mockStat = vi.hoisted(() => vi.fn());
vi.mock('../../config/extension.js', () => ({
installOrUpdateExtension: mockInstallOrUpdateExtension,
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
}));
vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
...actual,
ExtensionManager: vi.fn().mockImplementation(() => ({
installOrUpdateExtension: mockInstallOrUpdateExtension,
})),
};
});
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
@@ -54,7 +64,7 @@ describe('handleInstall', () => {
mockInstallOrUpdateExtension.mockClear();
mockRequestConsentNonInteractive.mockClear();
mockStat.mockClear();
vi.resetAllMocks();
vi.clearAllMocks();
});
it('should install an extension from a http source', async () => {
+15 -11
View File
@@ -5,17 +5,18 @@
*/
import type { CommandModule } from 'yargs';
import {
INSTALL_WARNING_MESSAGE,
installOrUpdateExtension,
requestConsentNonInteractive,
} from '../../config/extension.js';
import {
debugLogger,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { stat } from 'node:fs/promises';
import {
INSTALL_WARNING_MESSAGE,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface InstallArgs {
@@ -67,13 +68,16 @@ export async function handleInstall(args: InstallArgs) {
debugLogger.log('You have consented to the following:');
debugLogger.log(INSTALL_WARNING_MESSAGE);
}
const name = await installOrUpdateExtension(
installMetadata,
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent,
process.cwd(),
undefined,
promptForSetting,
);
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
const name =
await extensionManager.installOrUpdateExtension(installMetadata);
debugLogger.log(`Extension "${name}" installed successfully and enabled.`);
} catch (error) {
debugLogger.error(getErrorMessage(error));
+13 -8
View File
@@ -5,16 +5,16 @@
*/
import type { CommandModule } from 'yargs';
import {
installOrUpdateExtension,
requestConsentNonInteractive,
} from '../../config/extension.js';
import {
debugLogger,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface InstallArgs {
path: string;
@@ -26,10 +26,15 @@ export async function handleLink(args: InstallArgs) {
source: args.path,
type: 'link',
};
const extensionName = await installOrUpdateExtension(
installMetadata,
requestConsentNonInteractive,
);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
const extensionName =
await extensionManager.installOrUpdateExtension(installMetadata);
debugLogger.log(
`Extension "${extensionName}" linked successfully and enabled.`,
);
+15 -7
View File
@@ -5,24 +5,32 @@
*/
import type { CommandModule } from 'yargs';
import { loadExtensions, toOutputString } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
export async function handleList() {
try {
const extensions = loadExtensions(
new ExtensionEnablementManager(),
process.cwd(),
);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
const extensions = extensionManager.loadExtensions();
if (extensions.length === 0) {
debugLogger.log('No extensions installed.');
return;
}
debugLogger.log(
extensions
.map((extension, _): string => toOutputString(extension, process.cwd()))
.map((extension, _): string =>
extensionManager.toOutputString(extension),
)
.join('\n\n'),
);
} catch (error) {
@@ -5,9 +5,12 @@
*/
import type { CommandModule } from 'yargs';
import { uninstallExtension } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
import { debugLogger } from '@google/gemini-cli-core';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface UninstallArgs {
name: string; // can be extension name or source URL.
@@ -15,7 +18,14 @@ interface UninstallArgs {
export async function handleUninstall(args: UninstallArgs) {
try {
await uninstallExtension(args.name, false);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
await extensionManager.uninstallExtension(args.name, false);
debugLogger.log(`Extension "${args.name}" successfully uninstalled.`);
} catch (error) {
debugLogger.error(getErrorMessage(error));
+17 -21
View File
@@ -5,10 +5,6 @@
*/
import type { CommandModule } from 'yargs';
import {
loadExtensions,
requestConsentNonInteractive,
} from '../../config/extension.js';
import {
updateAllUpdatableExtensions,
type ExtensionUpdateInfo,
@@ -18,8 +14,11 @@ import {
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface UpdateArgs {
name?: string;
@@ -30,13 +29,15 @@ const updateOutput = (info: ExtensionUpdateInfo) =>
`Extension "${info.name}" successfully updated: ${info.originalVersion}${info.updatedVersion}.`;
export async function handleUpdate(args: UpdateArgs) {
const workingDir = process.cwd();
const extensionEnablementManager = new ExtensionEnablementManager(
// Force enable named extensions, otherwise we will only update the enabled
// ones.
args.name ? [args.name] : [],
);
const extensions = loadExtensions(extensionEnablementManager);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
const extensions = extensionManager.loadExtensions();
if (args.name) {
try {
const extension = extensions.find(
@@ -54,7 +55,7 @@ export async function handleUpdate(args: UpdateArgs) {
}
const updateState = await checkForExtensionUpdate(
extension,
extensionEnablementManager,
extensionManager,
);
if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) {
debugLogger.log(`Extension "${args.name}" is already up to date.`);
@@ -63,9 +64,7 @@ export async function handleUpdate(args: UpdateArgs) {
// TODO(chrstnb): we should list extensions if the requested extension is not installed.
const updatedExtensionInfo = (await updateExtension(
extension,
extensionEnablementManager,
workingDir,
requestConsentNonInteractive,
extensionManager,
updateState,
() => {},
))!;
@@ -88,7 +87,7 @@ export async function handleUpdate(args: UpdateArgs) {
const extensionState = new Map();
await checkForAllExtensionUpdates(
extensions,
extensionEnablementManager,
extensionManager,
(action) => {
if (action.type === 'SET_STATE') {
extensionState.set(action.payload.name, {
@@ -96,14 +95,11 @@ export async function handleUpdate(args: UpdateArgs) {
});
}
},
workingDir,
);
let updateInfos = await updateAllUpdatableExtensions(
workingDir,
requestConsentNonInteractive,
extensions,
extensionState,
extensionEnablementManager,
extensionManager,
() => {},
);
updateInfos = updateInfos.filter(