mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-20 19:11:23 -07:00
fix(cli): implement --all flag for extensions uninstall (#21319)
This commit is contained in:
@@ -319,26 +319,43 @@ export class UninstallExtensionCommand implements Command {
|
||||
};
|
||||
}
|
||||
|
||||
const name = args.join(' ').trim();
|
||||
if (!name) {
|
||||
const all = args.includes('--all');
|
||||
const names = args.filter((a) => !a.startsWith('--')).map((a) => a.trim());
|
||||
|
||||
if (!all && names.length === 0) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Usage: /extensions uninstall <extension-name>`,
|
||||
data: `Usage: /extensions uninstall <extension-names...>|--all`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await extensionLoader.uninstallExtension(name, false);
|
||||
let namesToUninstall: string[] = [];
|
||||
if (all) {
|
||||
namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name);
|
||||
} else {
|
||||
namesToUninstall = names;
|
||||
}
|
||||
|
||||
if (namesToUninstall.length === 0) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Extension "${name}" uninstalled successfully.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Failed to uninstall extension "${name}": ${getErrorMessage(error)}`,
|
||||
data: all ? 'No extensions installed.' : 'No extension name provided.',
|
||||
};
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
for (const extensionName of namesToUninstall) {
|
||||
try {
|
||||
await extensionLoader.uninstallExtension(extensionName, false);
|
||||
output.push(`Extension "${extensionName}" uninstalled successfully.`);
|
||||
} catch (error) {
|
||||
output.push(
|
||||
`Failed to uninstall extension "${extensionName}": ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { name: this.name, data: output.join('\n') };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getErrorMessage } from '../../utils/errors.js';
|
||||
// Hoisted mocks - these survive vi.clearAllMocks()
|
||||
const mockUninstallExtension = vi.hoisted(() => vi.fn());
|
||||
const mockLoadExtensions = vi.hoisted(() => vi.fn());
|
||||
const mockGetExtensions = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Mock dependencies with hoisted functions
|
||||
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
||||
@@ -38,6 +39,7 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
||||
ExtensionManager: vi.fn().mockImplementation(() => ({
|
||||
uninstallExtension: mockUninstallExtension,
|
||||
loadExtensions: mockLoadExtensions,
|
||||
getExtensions: mockGetExtensions,
|
||||
setRequestConsent: vi.fn(),
|
||||
setRequestSetting: vi.fn(),
|
||||
})),
|
||||
@@ -93,6 +95,7 @@ describe('extensions uninstall command', () => {
|
||||
afterEach(() => {
|
||||
mockLoadExtensions.mockClear();
|
||||
mockUninstallExtension.mockClear();
|
||||
mockGetExtensions.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -145,6 +148,41 @@ describe('extensions uninstall command', () => {
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should uninstall all extensions when --all flag is used', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
mockUninstallExtension.mockResolvedValue(undefined);
|
||||
mockGetExtensions.mockReturnValue([{ name: 'ext1' }, { name: 'ext2' }]);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
await handleUninstall({ all: true });
|
||||
|
||||
expect(mockUninstallExtension).toHaveBeenCalledTimes(2);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Extension "ext1" successfully uninstalled.',
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Extension "ext2" successfully uninstalled.',
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should log a message if no extensions are installed and --all flag is used', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
await handleUninstall({ all: true });
|
||||
|
||||
expect(mockUninstallExtension).not.toHaveBeenCalled();
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'No extensions currently installed.',
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should report errors for failed uninstalls but continue with others', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
@@ -236,13 +274,14 @@ describe('extensions uninstall command', () => {
|
||||
const command = uninstallCommand;
|
||||
|
||||
it('should have correct command and describe', () => {
|
||||
expect(command.command).toBe('uninstall <names..>');
|
||||
expect(command.command).toBe('uninstall [names..]');
|
||||
expect(command.describe).toBe('Uninstalls one or more extensions.');
|
||||
});
|
||||
|
||||
describe('builder', () => {
|
||||
interface MockYargs {
|
||||
positional: Mock;
|
||||
option: Mock;
|
||||
check: Mock;
|
||||
}
|
||||
|
||||
@@ -250,11 +289,12 @@ describe('extensions uninstall command', () => {
|
||||
beforeEach(() => {
|
||||
yargsMock = {
|
||||
positional: vi.fn().mockReturnThis(),
|
||||
option: vi.fn().mockReturnThis(),
|
||||
check: vi.fn().mockReturnThis(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should configure positional argument', () => {
|
||||
it('should configure arguments and options', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
@@ -264,18 +304,31 @@ describe('extensions uninstall command', () => {
|
||||
type: 'string',
|
||||
array: true,
|
||||
});
|
||||
expect(yargsMock.option).toHaveBeenCalledWith('all', {
|
||||
type: 'boolean',
|
||||
describe: 'Uninstall all installed extensions.',
|
||||
default: false,
|
||||
});
|
||||
expect(yargsMock.check).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check function should throw for missing names', () => {
|
||||
it('check function should throw for missing names and no --all flag', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
const checkCallback = yargsMock.check.mock.calls[0][0];
|
||||
expect(() => checkCallback({ names: [] })).toThrow(
|
||||
'Please include at least one extension name to uninstall as a positional argument.',
|
||||
expect(() => checkCallback({ names: [], all: false })).toThrow(
|
||||
'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.',
|
||||
);
|
||||
});
|
||||
|
||||
it('check function should pass if --all flag is used even without names', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
const checkCallback = yargsMock.check.mock.calls[0][0];
|
||||
expect(() => checkCallback({ names: [], all: true })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('handler should call handleUninstall', async () => {
|
||||
@@ -283,10 +336,17 @@ describe('extensions uninstall command', () => {
|
||||
mockUninstallExtension.mockResolvedValue(undefined);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
interface TestArgv {
|
||||
names: string[];
|
||||
[key: string]: unknown;
|
||||
names?: string[];
|
||||
all?: boolean;
|
||||
_: string[];
|
||||
$0: string;
|
||||
}
|
||||
const argv: TestArgv = { names: ['my-extension'], _: [], $0: '' };
|
||||
const argv: TestArgv = {
|
||||
names: ['my-extension'],
|
||||
all: false,
|
||||
_: [],
|
||||
$0: '',
|
||||
};
|
||||
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
|
||||
argv,
|
||||
);
|
||||
|
||||
@@ -14,7 +14,8 @@ import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface UninstallArgs {
|
||||
names: string[]; // can be extension names or source URLs.
|
||||
names?: string[]; // can be extension names or source URLs.
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
export async function handleUninstall(args: UninstallArgs) {
|
||||
@@ -28,8 +29,24 @@ export async function handleUninstall(args: UninstallArgs) {
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
let namesToUninstall: string[] = [];
|
||||
if (args.all) {
|
||||
namesToUninstall = extensionManager
|
||||
.getExtensions()
|
||||
.map((ext) => ext.name);
|
||||
} else if (args.names) {
|
||||
namesToUninstall = [...new Set(args.names)];
|
||||
}
|
||||
|
||||
if (namesToUninstall.length === 0) {
|
||||
if (args.all) {
|
||||
debugLogger.log('No extensions currently installed.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
for (const name of [...new Set(args.names)]) {
|
||||
for (const name of namesToUninstall) {
|
||||
try {
|
||||
await extensionManager.uninstallExtension(name, false);
|
||||
debugLogger.log(`Extension "${name}" successfully uninstalled.`);
|
||||
@@ -51,7 +68,7 @@ export async function handleUninstall(args: UninstallArgs) {
|
||||
}
|
||||
|
||||
export const uninstallCommand: CommandModule = {
|
||||
command: 'uninstall <names..>',
|
||||
command: 'uninstall [names..]',
|
||||
describe: 'Uninstalls one or more extensions.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
@@ -61,10 +78,15 @@ export const uninstallCommand: CommandModule = {
|
||||
type: 'string',
|
||||
array: true,
|
||||
})
|
||||
.option('all', {
|
||||
type: 'boolean',
|
||||
describe: 'Uninstall all installed extensions.',
|
||||
default: false,
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.names || argv.names.length === 0) {
|
||||
if (!argv.all && (!argv.names || argv.names.length === 0)) {
|
||||
throw new Error(
|
||||
'Please include at least one extension name to uninstall as a positional argument.',
|
||||
'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -72,7 +94,9 @@ export const uninstallCommand: CommandModule = {
|
||||
handler: async (argv) => {
|
||||
await handleUninstall({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
names: argv['names'] as string[],
|
||||
names: argv['names'] as string[] | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
all: argv['all'] as boolean,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
|
||||
@@ -160,6 +160,7 @@ vi.mock('./hooks/useIdeTrustListener.js');
|
||||
vi.mock('./hooks/useMessageQueue.js');
|
||||
vi.mock('./hooks/useApprovalModeIndicator.js');
|
||||
vi.mock('./hooks/useGitBranchName.js');
|
||||
vi.mock('./hooks/useExtensionUpdates.js');
|
||||
vi.mock('./contexts/VimModeContext.js');
|
||||
vi.mock('./contexts/SessionContext.js');
|
||||
vi.mock('./components/shared/text-buffer.js');
|
||||
@@ -218,6 +219,10 @@ import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
|
||||
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
||||
import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js';
|
||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||
import {
|
||||
useConfirmUpdateRequests,
|
||||
useExtensionUpdates,
|
||||
} from './hooks/useExtensionUpdates.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import { useSessionStats } from './contexts/SessionContext.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
@@ -299,6 +304,8 @@ describe('AppContainer State Management', () => {
|
||||
const mockedUseMessageQueue = useMessageQueue as Mock;
|
||||
const mockedUseApprovalModeIndicator = useApprovalModeIndicator as Mock;
|
||||
const mockedUseGitBranchName = useGitBranchName as Mock;
|
||||
const mockedUseConfirmUpdateRequests = useConfirmUpdateRequests as Mock;
|
||||
const mockedUseExtensionUpdates = useExtensionUpdates as Mock;
|
||||
const mockedUseVimMode = useVimMode as Mock;
|
||||
const mockedUseSessionStats = useSessionStats as Mock;
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
@@ -451,6 +458,15 @@ describe('AppContainer State Management', () => {
|
||||
isFocused: true,
|
||||
hasReceivedFocusEvent: true,
|
||||
});
|
||||
mockedUseConfirmUpdateRequests.mockReturnValue({
|
||||
addConfirmUpdateExtensionRequest: vi.fn(),
|
||||
confirmUpdateExtensionRequests: [],
|
||||
});
|
||||
mockedUseExtensionUpdates.mockReturnValue({
|
||||
extensionsUpdateState: new Map(),
|
||||
extensionsUpdateStateInternal: new Map(),
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock Config
|
||||
mockConfig = makeFakeConfig();
|
||||
|
||||
@@ -755,7 +755,7 @@ describe('extensionsCommand', () => {
|
||||
await uninstallAction!(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions uninstall <extension-name>',
|
||||
text: 'Usage: /extensions uninstall <extension-names...>|--all',
|
||||
});
|
||||
expect(mockUninstallExtension).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -594,33 +594,53 @@ async function uninstallAction(context: CommandContext, args: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = args.trim();
|
||||
if (!name) {
|
||||
const uninstallArgs = args.split(' ').filter((value) => value.length > 0);
|
||||
const all = uninstallArgs.includes('--all');
|
||||
const names = uninstallArgs.filter((a) => !a.startsWith('--'));
|
||||
|
||||
if (!all && names.length === 0) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Usage: /extensions uninstall <extension-name>`,
|
||||
text: `Usage: /extensions uninstall <extension-names...>|--all`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Uninstalling extension "${name}"...`,
|
||||
});
|
||||
let namesToUninstall: string[] = [];
|
||||
if (all) {
|
||||
namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name);
|
||||
} else {
|
||||
namesToUninstall = names;
|
||||
}
|
||||
|
||||
try {
|
||||
await extensionLoader.uninstallExtension(name, false);
|
||||
if (namesToUninstall.length === 0) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Extension "${name}" uninstalled successfully.`,
|
||||
text: all ? 'No extensions installed.' : 'No extension name provided.',
|
||||
});
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extensionName of namesToUninstall) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to uninstall extension "${name}": ${getErrorMessage(
|
||||
error,
|
||||
)}`,
|
||||
type: MessageType.INFO,
|
||||
text: `Uninstalling extension "${extensionName}"...`,
|
||||
});
|
||||
|
||||
try {
|
||||
await extensionLoader.uninstallExtension(extensionName, false);
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Extension "${extensionName}" uninstalled successfully.`,
|
||||
});
|
||||
} catch (error) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to uninstall extension "${extensionName}": ${getErrorMessage(
|
||||
error,
|
||||
)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user