From f32a54fefc28347dddff4dd0b7c947d8ac6ba21b Mon Sep 17 00:00:00 2001 From: christine betts Date: Mon, 25 Aug 2025 19:41:15 +0000 Subject: [PATCH] [extensions] Add extensions update command (#6878) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/cli/src/commands/extensions.tsx | 2 + .../cli/src/commands/extensions/update.ts | 46 +++++++++++ packages/cli/src/config/extension.test.ts | 79 ++++++++++++++++++- packages/cli/src/config/extension.ts | 51 +++++++++++- 4 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/commands/extensions/update.ts diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index 2cccde9c49..fe9cdecb8b 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -8,6 +8,7 @@ import { CommandModule } from 'yargs'; import { installCommand } from './extensions/install.js'; import { uninstallCommand } from './extensions/uninstall.js'; import { listCommand } from './extensions/list.js'; +import { updateCommand } from './extensions/update.js'; export const extensionsCommand: CommandModule = { command: 'extensions ', @@ -17,6 +18,7 @@ export const extensionsCommand: CommandModule = { .command(installCommand) .command(uninstallCommand) .command(listCommand) + .command(updateCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts new file mode 100644 index 0000000000..9fdbd8380d --- /dev/null +++ b/packages/cli/src/commands/extensions/update.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandModule } from 'yargs'; +import { updateExtension } from '../../config/extension.js'; + +interface UpdateArgs { + name: string; +} + +export async function handleUpdate(args: UpdateArgs) { + try { + // TODO(chrstnb): we should list extensions if the requested extension is not installed. + const updatedExtensionInfo = await updateExtension(args.name); + if (!updatedExtensionInfo) { + console.log(`Extension "${args.name}" failed to update.`); + return; + } + console.log( + `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, + ); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } +} + +export const updateCommand: CommandModule = { + command: 'update ', + describe: 'Updates an extension.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'The name of the extension to update.', + type: 'string', + }) + .check((_argv) => true), + handler: async (argv) => { + await handleUpdate({ + name: argv['name'] as string, + }); + }, +}; diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 15098c8407..dbe1fd2ede 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -15,6 +15,7 @@ import { installExtension, loadExtensions, uninstallExtension, + updateExtension, } from './extension.js'; import { execSync } from 'child_process'; import { SimpleGit, simpleGit } from 'simple-git'; @@ -231,7 +232,7 @@ describe('installExtension', () => { await expect( installExtension({ source: sourceExtDir, type: 'local' }), ).rejects.toThrow( - 'Error: Extension "my-local-extension" is already installed. Please uninstall it first.', + 'Extension "my-local-extension" is already installed. Please uninstall it first.', ); }); @@ -335,7 +336,7 @@ describe('uninstallExtension', () => { it('should throw an error if the extension does not exist', async () => { await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow( - 'Error: Extension "nonexistent-extension" not found.', + 'Extension "nonexistent-extension" not found.', ); }); }); @@ -363,3 +364,77 @@ function createExtension( } return extDir; } + +describe('updateExtension', () => { + let tempHomeDir: string; + let userExtensionsDir: string; + + beforeEach(() => { + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + userExtensionsDir = path.join(tempHomeDir, '.gemini', 'extensions'); + // Clean up before each test + fs.rmSync(userExtensionsDir, { recursive: true, force: true }); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + + vi.mocked(execSync).mockClear(); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + }); + + it('should update a git-installed extension', async () => { + // 1. "Install" an extension + const gitUrl = 'https://github.com/google/gemini-extensions.git'; + const extensionName = 'gemini-extensions'; + const targetExtDir = path.join(userExtensionsDir, extensionName); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + + // Create the "installed" extension directory and files + fs.mkdirSync(targetExtDir, { recursive: true }); + fs.writeFileSync( + path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.0.0' }), + ); + fs.writeFileSync( + metadataPath, + JSON.stringify({ source: gitUrl, type: 'git' }), + ); + + // 2. Mock the git clone for the update + const clone = vi.fn().mockImplementation(async (_, destination) => { + fs.mkdirSync(destination, { recursive: true }); + // This is the "updated" version + fs.writeFileSync( + path.join(destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.1.0' }), + ); + }); + + const mockedSimpleGit = simpleGit as vi.MockedFunction; + mockedSimpleGit.mockReturnValue({ + clone, + } as unknown as SimpleGit); + + // 3. Call updateExtension + const updateInfo = await updateExtension(extensionName); + + // 4. Assertions + expect(updateInfo).toEqual({ + originalVersion: '1.0.0', + updatedVersion: '1.1.0', + }); + + // Check that the config file reflects the new version + const updatedConfig = JSON.parse( + fs.readFileSync( + path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME), + 'utf-8', + ), + ); + expect(updatedConfig.version).toBe('1.1.0'); + }); +}); diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index a0e50d3510..c6f9ece315 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -39,6 +39,11 @@ export interface ExtensionInstallMetadata { type: 'git' | 'local'; } +export interface ExtensionUpdateInfo { + originalVersion: string; + updatedVersion: string; +} + export class ExtensionStorage { private readonly extensionName: string; @@ -321,7 +326,7 @@ export async function installExtension( ) ) { throw new Error( - `Error: Extension "${newExtensionName}" is already installed. Please uninstall it first.`, + `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, ); } @@ -346,7 +351,7 @@ export async function uninstallExtension(extensionName: string): Promise { (installed) => installed.config.name === extensionName, ) ) { - throw new Error(`Error: Extension "${extensionName}" not found.`); + throw new Error(`Extension "${extensionName}" not found.`); } const storage = new ExtensionStorage(extensionName); return await fs.promises.rm(storage.getExtensionDir(), { @@ -381,3 +386,45 @@ export function toOutputString(extension: Extension): string { } return output; } + +export async function updateExtension( + extensionName: string, +): Promise { + const installedExtensions = loadUserExtensions(); + const extension = installedExtensions.find( + (installed) => installed.config.name === extensionName, + ); + if (!extension) { + throw new Error( + `Extension "${extensionName}" not found. Run gemini extensions list to see available extensions.`, + ); + } + if (!extension.installMetadata) { + throw new Error( + `Extension cannot be updated because it is missing the .gemini-extension.install.json file. To update manually, uninstall and then reinstall the updated version.`, + ); + } + const originalVersion = extension.config.version; + const tempDir = await ExtensionStorage.createTmpDir(); + try { + await copyExtension(extension.path, tempDir); + await uninstallExtension(extensionName); + await installExtension(extension.installMetadata); + + const updatedExtension = loadExtension(extension.path); + if (!updatedExtension) { + throw new Error('Updated extension not found after installation.'); + } + const updatedVersion = updatedExtension.config.version; + return { + originalVersion, + updatedVersion, + }; + } catch (e) { + console.error(`Error updating extension, rolling back. ${e}`); + await copyExtension(tempDir, extension.path); + throw e; + } finally { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } +}