Skip to content

Commit 509d4ae

Browse files
authored
fix(cli): implement --all flag for extensions uninstall (#21319)
1 parent 4d310dd commit 509d4ae

File tree

6 files changed

+178
-41
lines changed

6 files changed

+178
-41
lines changed

packages/cli/src/acp/commands/extensions.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -319,26 +319,43 @@ export class UninstallExtensionCommand implements Command {
319319
};
320320
}
321321

322-
const name = args.join(' ').trim();
323-
if (!name) {
322+
const all = args.includes('--all');
323+
const names = args.filter((a) => !a.startsWith('--')).map((a) => a.trim());
324+
325+
if (!all && names.length === 0) {
324326
return {
325327
name: this.name,
326-
data: `Usage: /extensions uninstall <extension-name>`,
328+
data: `Usage: /extensions uninstall <extension-names...>|--all`,
327329
};
328330
}
329331

330-
try {
331-
await extensionLoader.uninstallExtension(name, false);
332-
return {
333-
name: this.name,
334-
data: `Extension "${name}" uninstalled successfully.`,
335-
};
336-
} catch (error) {
332+
let namesToUninstall: string[] = [];
333+
if (all) {
334+
namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name);
335+
} else {
336+
namesToUninstall = names;
337+
}
338+
339+
if (namesToUninstall.length === 0) {
337340
return {
338341
name: this.name,
339-
data: `Failed to uninstall extension "${name}": ${getErrorMessage(error)}`,
342+
data: all ? 'No extensions installed.' : 'No extension name provided.',
340343
};
341344
}
345+
346+
const output: string[] = [];
347+
for (const extensionName of namesToUninstall) {
348+
try {
349+
await extensionLoader.uninstallExtension(extensionName, false);
350+
output.push(`Extension "${extensionName}" uninstalled successfully.`);
351+
} catch (error) {
352+
output.push(
353+
`Failed to uninstall extension "${extensionName}": ${getErrorMessage(error)}`,
354+
);
355+
}
356+
}
357+
358+
return { name: this.name, data: output.join('\n') };
342359
}
343360
}
344361

packages/cli/src/commands/extensions/uninstall.test.ts

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { getErrorMessage } from '../../utils/errors.js';
2828
// Hoisted mocks - these survive vi.clearAllMocks()
2929
const mockUninstallExtension = vi.hoisted(() => vi.fn());
3030
const mockLoadExtensions = vi.hoisted(() => vi.fn());
31+
const mockGetExtensions = vi.hoisted(() => vi.fn());
3132

3233
// Mock dependencies with hoisted functions
3334
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
@@ -38,6 +39,7 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => {
3839
ExtensionManager: vi.fn().mockImplementation(() => ({
3940
uninstallExtension: mockUninstallExtension,
4041
loadExtensions: mockLoadExtensions,
42+
getExtensions: mockGetExtensions,
4143
setRequestConsent: vi.fn(),
4244
setRequestSetting: vi.fn(),
4345
})),
@@ -93,6 +95,7 @@ describe('extensions uninstall command', () => {
9395
afterEach(() => {
9496
mockLoadExtensions.mockClear();
9597
mockUninstallExtension.mockClear();
98+
mockGetExtensions.mockClear();
9699
vi.clearAllMocks();
97100
});
98101

@@ -145,6 +148,41 @@ describe('extensions uninstall command', () => {
145148
mockCwd.mockRestore();
146149
});
147150

151+
it('should uninstall all extensions when --all flag is used', async () => {
152+
mockLoadExtensions.mockResolvedValue(undefined);
153+
mockUninstallExtension.mockResolvedValue(undefined);
154+
mockGetExtensions.mockReturnValue([{ name: 'ext1' }, { name: 'ext2' }]);
155+
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
156+
await handleUninstall({ all: true });
157+
158+
expect(mockUninstallExtension).toHaveBeenCalledTimes(2);
159+
expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false);
160+
expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false);
161+
expect(emitConsoleLog).toHaveBeenCalledWith(
162+
'log',
163+
'Extension "ext1" successfully uninstalled.',
164+
);
165+
expect(emitConsoleLog).toHaveBeenCalledWith(
166+
'log',
167+
'Extension "ext2" successfully uninstalled.',
168+
);
169+
mockCwd.mockRestore();
170+
});
171+
172+
it('should log a message if no extensions are installed and --all flag is used', async () => {
173+
mockLoadExtensions.mockResolvedValue(undefined);
174+
mockGetExtensions.mockReturnValue([]);
175+
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
176+
await handleUninstall({ all: true });
177+
178+
expect(mockUninstallExtension).not.toHaveBeenCalled();
179+
expect(emitConsoleLog).toHaveBeenCalledWith(
180+
'log',
181+
'No extensions currently installed.',
182+
);
183+
mockCwd.mockRestore();
184+
});
185+
148186
it('should report errors for failed uninstalls but continue with others', async () => {
149187
mockLoadExtensions.mockResolvedValue(undefined);
150188
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
@@ -236,25 +274,27 @@ describe('extensions uninstall command', () => {
236274
const command = uninstallCommand;
237275

238276
it('should have correct command and describe', () => {
239-
expect(command.command).toBe('uninstall <names..>');
277+
expect(command.command).toBe('uninstall [names..]');
240278
expect(command.describe).toBe('Uninstalls one or more extensions.');
241279
});
242280

243281
describe('builder', () => {
244282
interface MockYargs {
245283
positional: Mock;
284+
option: Mock;
246285
check: Mock;
247286
}
248287

249288
let yargsMock: MockYargs;
250289
beforeEach(() => {
251290
yargsMock = {
252291
positional: vi.fn().mockReturnThis(),
292+
option: vi.fn().mockReturnThis(),
253293
check: vi.fn().mockReturnThis(),
254294
};
255295
});
256296

257-
it('should configure positional argument', () => {
297+
it('should configure arguments and options', () => {
258298
(command.builder as (yargs: Argv) => Argv)(
259299
yargsMock as unknown as Argv,
260300
);
@@ -264,17 +304,30 @@ describe('extensions uninstall command', () => {
264304
type: 'string',
265305
array: true,
266306
});
307+
expect(yargsMock.option).toHaveBeenCalledWith('all', {
308+
type: 'boolean',
309+
describe: 'Uninstall all installed extensions.',
310+
default: false,
311+
});
267312
expect(yargsMock.check).toHaveBeenCalled();
268313
});
269314

270-
it('check function should throw for missing names', () => {
315+
it('check function should throw for missing names and no --all flag', () => {
271316
(command.builder as (yargs: Argv) => Argv)(
272317
yargsMock as unknown as Argv,
273318
);
274319
const checkCallback = yargsMock.check.mock.calls[0][0];
275-
expect(() => checkCallback({ names: [] })).toThrow(
276-
'Please include at least one extension name to uninstall as a positional argument.',
320+
expect(() => checkCallback({ names: [], all: false })).toThrow(
321+
'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.',
322+
);
323+
});
324+
325+
it('check function should pass if --all flag is used even without names', () => {
326+
(command.builder as (yargs: Argv) => Argv)(
327+
yargsMock as unknown as Argv,
277328
);
329+
const checkCallback = yargsMock.check.mock.calls[0][0];
330+
expect(() => checkCallback({ names: [], all: true })).not.toThrow();
278331
});
279332
});
280333

@@ -283,10 +336,17 @@ describe('extensions uninstall command', () => {
283336
mockUninstallExtension.mockResolvedValue(undefined);
284337
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
285338
interface TestArgv {
286-
names: string[];
287-
[key: string]: unknown;
339+
names?: string[];
340+
all?: boolean;
341+
_: string[];
342+
$0: string;
288343
}
289-
const argv: TestArgv = { names: ['my-extension'], _: [], $0: '' };
344+
const argv: TestArgv = {
345+
names: ['my-extension'],
346+
all: false,
347+
_: [],
348+
$0: '',
349+
};
290350
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
291351
argv,
292352
);

packages/cli/src/commands/extensions/uninstall.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import { promptForSetting } from '../../config/extensions/extensionSettings.js';
1414
import { exitCli } from '../utils.js';
1515

1616
interface UninstallArgs {
17-
names: string[]; // can be extension names or source URLs.
17+
names?: string[]; // can be extension names or source URLs.
18+
all?: boolean;
1819
}
1920

2021
export async function handleUninstall(args: UninstallArgs) {
@@ -28,8 +29,24 @@ export async function handleUninstall(args: UninstallArgs) {
2829
});
2930
await extensionManager.loadExtensions();
3031

32+
let namesToUninstall: string[] = [];
33+
if (args.all) {
34+
namesToUninstall = extensionManager
35+
.getExtensions()
36+
.map((ext) => ext.name);
37+
} else if (args.names) {
38+
namesToUninstall = [...new Set(args.names)];
39+
}
40+
41+
if (namesToUninstall.length === 0) {
42+
if (args.all) {
43+
debugLogger.log('No extensions currently installed.');
44+
}
45+
return;
46+
}
47+
3148
const errors: Array<{ name: string; error: string }> = [];
32-
for (const name of [...new Set(args.names)]) {
49+
for (const name of namesToUninstall) {
3350
try {
3451
await extensionManager.uninstallExtension(name, false);
3552
debugLogger.log(`Extension "${name}" successfully uninstalled.`);
@@ -51,7 +68,7 @@ export async function handleUninstall(args: UninstallArgs) {
5168
}
5269

5370
export const uninstallCommand: CommandModule = {
54-
command: 'uninstall <names..>',
71+
command: 'uninstall [names..]',
5572
describe: 'Uninstalls one or more extensions.',
5673
builder: (yargs) =>
5774
yargs
@@ -61,18 +78,25 @@ export const uninstallCommand: CommandModule = {
6178
type: 'string',
6279
array: true,
6380
})
81+
.option('all', {
82+
type: 'boolean',
83+
describe: 'Uninstall all installed extensions.',
84+
default: false,
85+
})
6486
.check((argv) => {
65-
if (!argv.names || argv.names.length === 0) {
87+
if (!argv.all && (!argv.names || argv.names.length === 0)) {
6688
throw new Error(
67-
'Please include at least one extension name to uninstall as a positional argument.',
89+
'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.',
6890
);
6991
}
7092
return true;
7193
}),
7294
handler: async (argv) => {
7395
await handleUninstall({
7496
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
75-
names: argv['names'] as string[],
97+
names: argv['names'] as string[] | undefined,
98+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
99+
all: argv['all'] as boolean,
76100
});
77101
await exitCli();
78102
},

packages/cli/src/ui/AppContainer.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ vi.mock('./hooks/useIdeTrustListener.js');
160160
vi.mock('./hooks/useMessageQueue.js');
161161
vi.mock('./hooks/useApprovalModeIndicator.js');
162162
vi.mock('./hooks/useGitBranchName.js');
163+
vi.mock('./hooks/useExtensionUpdates.js');
163164
vi.mock('./contexts/VimModeContext.js');
164165
vi.mock('./contexts/SessionContext.js');
165166
vi.mock('./components/shared/text-buffer.js');
@@ -218,6 +219,10 @@ import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
218219
import { useMessageQueue } from './hooks/useMessageQueue.js';
219220
import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js';
220221
import { useGitBranchName } from './hooks/useGitBranchName.js';
222+
import {
223+
useConfirmUpdateRequests,
224+
useExtensionUpdates,
225+
} from './hooks/useExtensionUpdates.js';
221226
import { useVimMode } from './contexts/VimModeContext.js';
222227
import { useSessionStats } from './contexts/SessionContext.js';
223228
import { useTextBuffer } from './components/shared/text-buffer.js';
@@ -299,6 +304,8 @@ describe('AppContainer State Management', () => {
299304
const mockedUseMessageQueue = useMessageQueue as Mock;
300305
const mockedUseApprovalModeIndicator = useApprovalModeIndicator as Mock;
301306
const mockedUseGitBranchName = useGitBranchName as Mock;
307+
const mockedUseConfirmUpdateRequests = useConfirmUpdateRequests as Mock;
308+
const mockedUseExtensionUpdates = useExtensionUpdates as Mock;
302309
const mockedUseVimMode = useVimMode as Mock;
303310
const mockedUseSessionStats = useSessionStats as Mock;
304311
const mockedUseTextBuffer = useTextBuffer as Mock;
@@ -451,6 +458,15 @@ describe('AppContainer State Management', () => {
451458
isFocused: true,
452459
hasReceivedFocusEvent: true,
453460
});
461+
mockedUseConfirmUpdateRequests.mockReturnValue({
462+
addConfirmUpdateExtensionRequest: vi.fn(),
463+
confirmUpdateExtensionRequests: [],
464+
});
465+
mockedUseExtensionUpdates.mockReturnValue({
466+
extensionsUpdateState: new Map(),
467+
extensionsUpdateStateInternal: new Map(),
468+
dispatchExtensionStateUpdate: vi.fn(),
469+
});
454470

455471
// Mock Config
456472
mockConfig = makeFakeConfig();

packages/cli/src/ui/commands/extensionsCommand.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,7 @@ describe('extensionsCommand', () => {
755755
await uninstallAction!(mockContext, '');
756756
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
757757
type: MessageType.ERROR,
758-
text: 'Usage: /extensions uninstall <extension-name>',
758+
text: 'Usage: /extensions uninstall <extension-names...>|--all',
759759
});
760760
expect(mockUninstallExtension).not.toHaveBeenCalled();
761761
});

0 commit comments

Comments
 (0)