From 509d4ae0a9f35f127b420c084c4fb60db1fafe98 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Thu, 5 Mar 2026 22:02:01 -0500 Subject: [PATCH] fix(cli): implement --all flag for extensions uninstall (#21319) --- packages/cli/src/acp/commands/extensions.ts | 39 +++++++--- .../src/commands/extensions/uninstall.test.ts | 76 +++++++++++++++++-- .../cli/src/commands/extensions/uninstall.ts | 36 +++++++-- packages/cli/src/ui/AppContainer.test.tsx | 16 ++++ .../src/ui/commands/extensionsCommand.test.ts | 2 +- .../cli/src/ui/commands/extensionsCommand.ts | 50 ++++++++---- 6 files changed, 178 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/acp/commands/extensions.ts b/packages/cli/src/acp/commands/extensions.ts index b9a3ad81ab..d2946e64a6 100644 --- a/packages/cli/src/acp/commands/extensions.ts +++ b/packages/cli/src/acp/commands/extensions.ts @@ -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 `, + data: `Usage: /extensions uninstall |--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') }; } } diff --git a/packages/cli/src/commands/extensions/uninstall.test.ts b/packages/cli/src/commands/extensions/uninstall.test.ts index 8ae9f6d376..65aed446c5 100644 --- a/packages/cli/src/commands/extensions/uninstall.test.ts +++ b/packages/cli/src/commands/extensions/uninstall.test.ts @@ -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 '); + 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)( argv, ); diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index a67a4d3abe..b78b9510df 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -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 ', + 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(); }, diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0326aee766..cfa81f7c2a 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -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(); diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index c873050490..f1a9e13416 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -755,7 +755,7 @@ describe('extensionsCommand', () => { await uninstallAction!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, - text: 'Usage: /extensions uninstall ', + text: 'Usage: /extensions uninstall |--all', }); expect(mockUninstallExtension).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 842a680a14..3a48b9e173 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -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 `, + text: `Usage: /extensions uninstall |--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, + )}`, + }); + } } }