From 50e7c88aa4d62d1611b3165990e23ac4f9bea0d0 Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Wed, 10 Sep 2025 08:09:09 -0700 Subject: [PATCH] Extensions update command (#8100) --- .../src/ui/commands/extensionsCommand.test.ts | 239 +++++++++++++++--- .../cli/src/ui/commands/extensionsCommand.ts | 122 +++++++-- 2 files changed, 305 insertions(+), 56 deletions(-) diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 0a69e01c66..191c2a343d 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -4,16 +4,42 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; -import { extensionsCommand } from './extensionsCommand.js'; -import { type CommandContext } from './types.js'; +import { + updateAllUpdatableExtensions, + updateExtensionByName, +} from '../../config/extension.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; +import { extensionsCommand } from './extensionsCommand.js'; +import { type CommandContext } from './types.js'; +import { + describe, + it, + expect, + vi, + beforeEach, + type MockedFunction, +} from 'vitest'; + +vi.mock('../../config/extension.js', () => ({ + updateExtensionByName: vi.fn(), + updateAllUpdatableExtensions: vi.fn(), +})); + +const mockUpdateExtensionByName = updateExtensionByName as MockedFunction< + typeof updateExtensionByName +>; + +const mockUpdateAllUpdatableExtensions = + updateAllUpdatableExtensions as MockedFunction< + typeof updateAllUpdatableExtensions + >; describe('extensionsCommand', () => { let mockContext: CommandContext; - it('should display "No active extensions." when none are found', async () => { + beforeEach(() => { + vi.resetAllMocks(); mockContext = createMockCommandContext({ services: { config: { @@ -21,47 +47,182 @@ describe('extensionsCommand', () => { }, }, }); - - if (!extensionsCommand.action) throw new Error('Action not defined'); - await extensionsCommand.action(mockContext, ''); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'No active extensions.', - }, - expect.any(Number), - ); }); - it('should list active extensions when they are found', async () => { - const mockExtensions = [ - { name: 'ext-one', version: '1.0.0', isActive: true }, - { name: 'ext-two', version: '2.1.0', isActive: true }, - { name: 'ext-three', version: '3.0.0', isActive: false }, - ]; - mockContext = createMockCommandContext({ - services: { - config: { - getExtensions: () => mockExtensions, + describe('list', () => { + it('should display "No active extensions." when none are found', async () => { + if (!extensionsCommand.action) throw new Error('Action not defined'); + await extensionsCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'No active extensions.', }, - }, + expect.any(Number), + ); }); - if (!extensionsCommand.action) throw new Error('Action not defined'); - await extensionsCommand.action(mockContext, ''); + it('should list active extensions when they are found', async () => { + const mockExtensions = [ + { name: 'ext-one', version: '1.0.0', isActive: true }, + { name: 'ext-two', version: '2.1.0', isActive: true }, + { name: 'ext-three', version: '3.0.0', isActive: false }, + ]; + mockContext = createMockCommandContext({ + services: { + config: { + getExtensions: () => mockExtensions, + }, + }, + }); - const expectedMessage = - 'Active extensions:\n\n' + - ` - \u001b[36mext-one (v1.0.0)\u001b[0m\n` + - ` - \u001b[36mext-two (v2.1.0)\u001b[0m\n`; + if (!extensionsCommand.action) throw new Error('Action not defined'); + await extensionsCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: expectedMessage, - }, - expect.any(Number), - ); + const expectedMessage = + 'Active extensions:\n\n' + + ` - \u001b[36mext-one (v1.0.0)\u001b[0m\n` + + ` - \u001b[36mext-two (v2.1.0)\u001b[0m\n`; + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: expectedMessage, + }, + expect.any(Number), + ); + }); + }); + + describe('update', () => { + const updateAction = extensionsCommand.subCommands?.find( + (cmd) => cmd.name === 'update', + )?.action; + + if (!updateAction) { + throw new Error('Update action not found'); + } + + it('should show usage if no args are provided', async () => { + await updateAction(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Usage: /extensions update |--all', + }, + expect.any(Number), + ); + }); + + it('should inform user if there are no extensions to update with --all', async () => { + mockUpdateAllUpdatableExtensions.mockResolvedValue([]); + await updateAction(mockContext, '--all'); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'No extensions to update.', + }, + expect.any(Number), + ); + }); + + it('should update all extensions with --all', async () => { + mockUpdateAllUpdatableExtensions.mockResolvedValue([ + { + name: 'ext-one', + originalVersion: '1.0.0', + updatedVersion: '1.0.1', + }, + { + name: 'ext-two', + originalVersion: '2.0.0', + updatedVersion: '2.0.1', + }, + ]); + await updateAction(mockContext, '--all'); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: + 'Extension "ext-one" successfully updated: 1.0.0 → 1.0.1.\n' + + 'Extension "ext-two" successfully updated: 2.0.0 → 2.0.1.\n' + + 'Restart gemini-cli to see the changes.', + }, + expect.any(Number), + ); + }); + + it('should handle errors when updating all extensions', async () => { + mockUpdateAllUpdatableExtensions.mockRejectedValue( + new Error('Something went wrong'), + ); + await updateAction(mockContext, '--all'); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Something went wrong', + }, + expect.any(Number), + ); + }); + + it('should update a single extension by name', async () => { + mockUpdateExtensionByName.mockResolvedValue({ + name: 'ext-one', + originalVersion: '1.0.0', + updatedVersion: '1.0.1', + }); + await updateAction(mockContext, 'ext-one'); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: + 'Extension "ext-one" successfully updated: 1.0.0 → 1.0.1.\n' + + 'Restart gemini-cli to see the changes.', + }, + expect.any(Number), + ); + }); + + it('should handle errors when updating a single extension', async () => { + mockUpdateExtensionByName.mockRejectedValue( + new Error('Extension not found'), + ); + await updateAction(mockContext, 'ext-one'); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Extension not found', + }, + expect.any(Number), + ); + }); + + it('should update multiple extensions by name', async () => { + mockUpdateExtensionByName + .mockResolvedValueOnce({ + name: 'ext-one', + originalVersion: '1.0.0', + updatedVersion: '1.0.1', + }) + .mockResolvedValueOnce({ + name: 'ext-two', + originalVersion: '2.0.0', + updatedVersion: '2.0.1', + }); + await updateAction(mockContext, 'ext-one ext-two'); + expect(mockUpdateExtensionByName).toHaveBeenCalledTimes(2); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: + 'Extension "ext-one" successfully updated: 1.0.0 → 1.0.1.\n' + + 'Extension "ext-two" successfully updated: 2.0.0 → 2.0.1.\n' + + 'Restart gemini-cli to see the changes.', + }, + expect.any(Number), + ); + }); }); }); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index ea9f9a4f40..d0b81ad2a1 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -4,43 +4,131 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + updateExtensionByName, + updateAllUpdatableExtensions, + type ExtensionUpdateInfo, +} from '../../config/extension.js'; +import { getErrorMessage } from '../../utils/errors.js'; +import { MessageType } from '../types.js'; import { type CommandContext, type SlashCommand, CommandKind, } from './types.js'; -import { MessageType } from '../types.js'; -export const extensionsCommand: SlashCommand = { - name: 'extensions', - description: 'list active extensions', - kind: CommandKind.BUILT_IN, - action: async (context: CommandContext): Promise => { - const activeExtensions = context.services.config - ?.getExtensions() - .filter((ext) => ext.isActive); - if (!activeExtensions || activeExtensions.length === 0) { +async function listAction(context: CommandContext) { + const activeExtensions = context.services.config + ?.getExtensions() + .filter((ext) => ext.isActive); + if (!activeExtensions || activeExtensions.length === 0) { + context.ui.addItem( + { + type: MessageType.INFO, + text: 'No active extensions.', + }, + Date.now(), + ); + return; + } + + const extensionLines = activeExtensions.map( + (ext) => ` - \u001b[36m${ext.name} (v${ext.version})\u001b[0m`, + ); + const message = `Active extensions:\n\n${extensionLines.join('\n')}\n`; + + context.ui.addItem( + { + type: MessageType.INFO, + text: message, + }, + Date.now(), + ); +} + +const updateOutput = (info: ExtensionUpdateInfo) => + `Extension "${info.name}" successfully updated: ${info.originalVersion} → ${info.updatedVersion}.`; + +async function updateAction(context: CommandContext, args: string) { + const updateArgs = args.split(' ').filter((value) => value.length > 0); + const all = updateArgs.length === 1 && updateArgs[0] === '--all'; + const names = all ? undefined : updateArgs; + let updateInfos: ExtensionUpdateInfo[] = []; + try { + if (all) { + updateInfos = await updateAllUpdatableExtensions(); + } else if (names?.length) { + for (const name of names) { + updateInfos.push(await updateExtensionByName(name)); + } + } else { context.ui.addItem( { - type: MessageType.INFO, - text: 'No active extensions.', + type: MessageType.ERROR, + text: 'Usage: /extensions update |--all', }, Date.now(), ); return; } - const extensionLines = activeExtensions.map( - (ext) => ` - \u001b[36m${ext.name} (v${ext.version})\u001b[0m`, + // Filter to the actually updated ones. + updateInfos = updateInfos.filter( + (info) => info.originalVersion !== info.updatedVersion, ); - const message = `Active extensions:\n\n${extensionLines.join('\n')}\n`; + + if (updateInfos.length === 0) { + context.ui.addItem( + { + type: MessageType.INFO, + text: 'No extensions to update.', + }, + Date.now(), + ); + return; + } context.ui.addItem( { type: MessageType.INFO, - text: message, + text: [ + ...updateInfos.map((info) => updateOutput(info)), + 'Restart gemini-cli to see the changes.', + ].join('\n'), }, Date.now(), ); - }, + } catch (error) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: getErrorMessage(error), + }, + Date.now(), + ); + } +} + +const listExtensionsCommand: SlashCommand = { + name: 'list', + description: 'List active extensions', + kind: CommandKind.BUILT_IN, + action: listAction, +}; + +const updateExtensionsCommand: SlashCommand = { + name: 'update', + description: 'Update extensions. Usage: update |--all', + kind: CommandKind.BUILT_IN, + action: updateAction, +}; + +export const extensionsCommand: SlashCommand = { + name: 'extensions', + description: 'Manage extensions', + kind: CommandKind.BUILT_IN, + subCommands: [listExtensionsCommand, updateExtensionsCommand], + action: (context, args) => + // Default to list if no subcommand is provided + listExtensionsCommand.action!(context, args), };