From af6a792caa26b210001ef8a95aeb5587591eff70 Mon Sep 17 00:00:00 2001 From: christine betts Date: Fri, 29 Aug 2025 17:24:17 +0000 Subject: [PATCH] Add flag to update all extensions (#7321) --- .../cli/src/commands/extensions/update.ts | 65 ++++++++++++++----- packages/cli/src/config/extension.test.ts | 10 +-- packages/cli/src/config/extension.ts | 30 +++++++-- 3 files changed, 73 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 43ac6de8b1..f235304957 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -5,43 +5,72 @@ */ import type { CommandModule } from 'yargs'; -import { updateExtension } from '../../config/extension.js'; +import { + updateExtensionByName, + updateAllUpdatableExtensions, + type ExtensionUpdateInfo, +} from '../../config/extension.js'; import { getErrorMessage } from '../../utils/errors.js'; interface UpdateArgs { - name: string; + name?: string; + all?: boolean; } +const updateOutput = (info: ExtensionUpdateInfo) => + `Extension "${info.name}" successfully updated: ${info.originalVersion} → ${info.updatedVersion}.`; + 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; + if (args.all) { + try { + const updateInfos = await updateAllUpdatableExtensions(); + if (updateInfos.length === 0) { + console.log('No extensions to update.'); + return; + } + console.log(updateInfos.map((info) => updateOutput(info)).join('\n')); + } catch (error) { + console.error(getErrorMessage(error)); } - console.log( - `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, - ); - } catch (error) { - console.error(getErrorMessage(error)); - process.exit(1); + return; } + if (args.name) + try { + // TODO(chrstnb): we should list extensions if the requested extension is not installed. + const updatedExtensionInfo = await updateExtensionByName(args.name); + console.log( + `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, + ); + } catch (error) { + console.error(getErrorMessage(error)); + } } export const updateCommand: CommandModule = { - command: 'update ', - describe: 'Updates an extension.', + command: 'update [--all] [name]', + describe: + 'Updates all extensions or a named extension to the latest version.', builder: (yargs) => yargs .positional('name', { describe: 'The name of the extension to update.', type: 'string', }) - .check((_argv) => true), + .option('all', { + describe: 'Update all extensions.', + type: 'boolean', + }) + .conflicts('name', 'all') + .check((argv) => { + if (!argv.all && !argv.name) { + throw new Error('Either an extension name or --all must be provided'); + } + return true; + }), handler: async (argv) => { await handleUpdate({ - name: argv['name'] as string, + name: argv['name'] as string | undefined, + all: argv['all'] as boolean | undefined, }); }, }; diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index e0ec10491d..707cd23543 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -635,13 +635,11 @@ describe('updateExtension', () => { }); 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), @@ -652,10 +650,8 @@ describe('updateExtension', () => { 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' }), @@ -667,16 +663,14 @@ describe('updateExtension', () => { clone, } as unknown as SimpleGit); - // 3. Call updateExtension - const updateInfo = await updateExtension(extensionName); + const updateInfo = await updateExtension(loadExtension(targetExtDir)); - // 4. Assertions expect(updateInfo).toEqual({ + name: 'gemini-extensions', 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), diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 6816af4eba..e87eda4c68 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -44,6 +44,7 @@ export interface ExtensionInstallMetadata { } export interface ExtensionUpdateInfo { + name: string; originalVersion: string; updatedVersion: string; } @@ -444,10 +445,10 @@ export function toOutputString(extension: Extension): string { return output; } -export async function updateExtension( +export async function updateExtensionByName( extensionName: string, cwd: string = process.cwd(), -): Promise { +): Promise { const installedExtensions = loadUserExtensions(); const extension = installedExtensions.find( (installed) => installed.config.name === extensionName, @@ -457,16 +458,21 @@ export async function updateExtension( `Extension "${extensionName}" not found. Run gemini extensions list to see available extensions.`, ); } + return await updateExtension(extension, cwd); +} + +export async function updateExtension( + extension: Extension, + cwd: string = process.cwd(), +): Promise { 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.`, - ); + throw new Error(`Extension ${extension.config.name} cannot be updated.`); } const originalVersion = extension.config.version; const tempDir = await ExtensionStorage.createTmpDir(); try { await copyExtension(extension.path, tempDir); - await uninstallExtension(extensionName, cwd); + await uninstallExtension(extension.config.name, cwd); await installExtension(extension.installMetadata, cwd); const updatedExtension = loadExtension(extension.path); @@ -475,6 +481,7 @@ export async function updateExtension( } const updatedVersion = updatedExtension.config.version; return { + name: extension.config.name, originalVersion, updatedVersion, }; @@ -537,3 +544,14 @@ function removeFromDisabledExtensions( settings.setValue(scope, 'extensions', extensionSettings); } } + +export async function updateAllUpdatableExtensions( + cwd: string = process.cwd(), +): Promise { + const extensions = loadExtensions(cwd).filter( + (extension) => !!extension.installMetadata, + ); + return await Promise.all( + extensions.map((extension) => updateExtension(extension, cwd)), + ); +}