Skip to content

Commit bf6e336

Browse files
committed
fix(cli): implement --all flag for extensions uninstall
1 parent 22d962e commit bf6e336

File tree

5 files changed

+186
-41
lines changed

5 files changed

+186
-41
lines changed

package-lock.json

Lines changed: 25 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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 name = args.find((a) => !a.startsWith('--'))?.trim();
324+
325+
if (!all && !name) {
324326
return {
325327
name: this.name,
326-
data: `Usage: /extensions uninstall <extension-name>`,
328+
data: `Usage: /extensions uninstall <extension-name>|--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 if (name) {
336+
namesToUninstall = [name];
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
);

0 commit comments

Comments
 (0)