diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index e44cf2375c..c971e3213b 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, type MockInstance } from 'vitest'; +import { describe, it, expect, vi, type MockInstance } from 'vitest'; import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; @@ -32,6 +32,15 @@ describe('extensions install command', () => { validationParser.parse('install some-url --path /some/path'), ).toThrow('Arguments source and path are mutually exclusive'); }); + + it('should fail if both auto update and local path are provided', () => { + const validationParser = yargs([]).command(installCommand).fail(false); + expect(() => + validationParser.parse( + 'install some-url --path /some/path --auto-update', + ), + ).toThrow('Arguments path and auto-update are mutually exclusive'); + }); }); describe('handleInstall', () => { diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index e82864d34e..44fad4dbba 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -14,6 +14,7 @@ interface InstallArgs { source?: string; path?: string; ref?: string; + autoUpdate?: boolean; } export async function handleInstall(args: InstallArgs) { @@ -32,6 +33,7 @@ export async function handleInstall(args: InstallArgs) { source, type: 'git', ref: args.ref, + autoUpdate: args.autoUpdate, }; } else { throw new Error(`The source "${source}" is not a valid URL format.`); @@ -40,6 +42,7 @@ export async function handleInstall(args: InstallArgs) { installMetadata = { source: args.path, type: 'local', + autoUpdate: args.autoUpdate, }; } else { // This should not be reached due to the yargs check. @@ -71,8 +74,13 @@ export const installCommand: CommandModule = { describe: 'The git ref to install from.', type: 'string', }) + .option('auto-update', { + describe: 'Enable auto-update for this extension.', + type: 'boolean', + }) .conflicts('source', 'path') .conflicts('path', 'ref') + .conflicts('path', 'auto-update') .check((argv) => { if (!argv.source && !argv.path) { throw new Error('Either source or --path must be provided.'); @@ -84,6 +92,7 @@ export const installCommand: CommandModule = { source: argv['source'] as string | undefined, path: argv['path'] as string | undefined, ref: argv['ref'] as string | undefined, + autoUpdate: argv['auto-update'] as boolean | undefined, }); }, }; diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 03fa4aa7f3..1cda6c36f3 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -6,14 +6,18 @@ import type { CommandModule } from 'yargs'; import { - updateExtensionByName, - updateAllUpdatableExtensions, - type ExtensionUpdateInfo, loadExtensions, annotateActiveExtensions, - checkForAllExtensionUpdates, } from '../../config/extension.js'; +import { + updateAllUpdatableExtensions, + type ExtensionUpdateInfo, + checkForAllExtensionUpdates, + updateExtension, +} from '../../config/extensions/update.js'; +import { checkForExtensionUpdate } from '../../config/extensions/github.js'; import { getErrorMessage } from '../../utils/errors.js'; +import { ExtensionUpdateState } from '../../ui/state/extensions.js'; interface UpdateArgs { name?: string; @@ -37,7 +41,7 @@ export async function handleUpdate(args: UpdateArgs) { let updateInfos = await updateAllUpdatableExtensions( workingDir, extensions, - await checkForAllExtensionUpdates(extensions, (_) => {}), + await checkForAllExtensionUpdates(extensions, new Map(), (_) => {}), () => {}, ); updateInfos = updateInfos.filter( @@ -54,13 +58,34 @@ export async function handleUpdate(args: UpdateArgs) { } if (args.name) try { - // TODO(chrstnb): we should list extensions if the requested extension is not installed. - const updatedExtensionInfo = await updateExtensionByName( - args.name, - workingDir, - extensions, - () => {}, + const extension = extensions.find( + (extension) => extension.name === args.name, ); + if (!extension) { + console.log(`Extension "${args.name}" not found.`); + return; + } + let updateState: ExtensionUpdateState | undefined; + if (!extension.installMetadata) { + console.log( + `Unable to install extension "${args.name}" due to missing install metadata`, + ); + return; + } + await checkForExtensionUpdate(extension, (newState) => { + updateState = newState; + }); + if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) { + console.log(`Extension "${args.name}" is already up to date.`); + return; + } + // TODO(chrstnb): we should list extensions if the requested extension is not installed. + const updatedExtensionInfo = (await updateExtension( + extension, + workingDir, + updateState, + () => {}, + ))!; if ( updatedExtensionInfo.originalVersion !== updatedExtensionInfo.updatedVersion @@ -69,7 +94,7 @@ export async function handleUpdate(args: UpdateArgs) { `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, ); } else { - console.log(`Extension "${args.name}" already up to date.`); + console.log(`Extension "${args.name}" is already up to date.`); } } catch (error) { console.error(getErrorMessage(error)); diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 28ea16bf1c..a7e32fe8b0 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -12,7 +12,6 @@ import { EXTENSIONS_CONFIG_FILENAME, INSTALL_METADATA_FILENAME, annotateActiveExtensions, - checkForAllExtensionUpdates, disableExtension, enableExtension, installExtension, @@ -20,22 +19,19 @@ import { loadExtensions, performWorkspaceExtensionMigration, uninstallExtension, - updateExtension, type Extension, } from './extension.js'; import { GEMINI_DIR, type GeminiCLIExtension, - type MCPServerConfig, ClearcutLogger, type Config, ExtensionUninstallEvent, - type ExtensionInstallMetadata, } from '@google/gemini-cli-core'; import { execSync } from 'node:child_process'; import { SettingScope } from './settings.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; -import { ExtensionUpdateState } from '../ui/state/extensions.js'; +import { createExtension } from '../test-utils/createExtension.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; const mockGit = { @@ -58,9 +54,9 @@ vi.mock('simple-git', () => ({ })); vi.mock('os', async (importOriginal) => { - const os = await importOriginal(); + const mockedOs = await importOriginal(); return { - ...os, + ...mockedOs, homedir: vi.fn(), }; }); @@ -454,6 +450,86 @@ describe('extension tests', () => { expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4'); consoleSpy.mockRestore(); }); + + describe('autoUpdate', () => { + it('should be false if autoUpdate is not set in install metadata', () => { + const activeExtensions = annotateActiveExtensions( + extensions, + [], + tempHomeDir, + ); + expect( + activeExtensions.every( + (e) => e.installMetadata?.autoUpdate === false, + ), + ).toBe(false); + }); + + it('should be true if autoUpdate is true in install metadata', () => { + const extensionsWithAutoUpdate: Extension[] = extensions.map((e) => ({ + ...e, + installMetadata: { + ...e.installMetadata!, + autoUpdate: true, + }, + })); + const activeExtensions = annotateActiveExtensions( + extensionsWithAutoUpdate, + [], + tempHomeDir, + ); + expect( + activeExtensions.every((e) => e.installMetadata?.autoUpdate === true), + ).toBe(true); + }); + + it('should respect the per-extension settings from install metadata', () => { + const extensionsWithAutoUpdate: Extension[] = [ + { + path: '/path/to/ext1', + config: { name: 'ext1', version: '1.0.0' }, + contextFiles: [], + installMetadata: { + source: 'test', + type: 'local', + autoUpdate: true, + }, + }, + { + path: '/path/to/ext2', + config: { name: 'ext2', version: '1.0.0' }, + contextFiles: [], + installMetadata: { + source: 'test', + type: 'local', + autoUpdate: false, + }, + }, + { + path: '/path/to/ext3', + config: { name: 'ext3', version: '1.0.0' }, + contextFiles: [], + }, + ]; + const activeExtensions = annotateActiveExtensions( + extensionsWithAutoUpdate, + [], + tempHomeDir, + ); + expect( + activeExtensions.find((e) => e.name === 'ext1')?.installMetadata + ?.autoUpdate, + ).toBe(true); + expect( + activeExtensions.find((e) => e.name === 'ext2')?.installMetadata + ?.autoUpdate, + ).toBe(false); + expect( + activeExtensions.find((e) => e.name === 'ext3')?.installMetadata + ?.autoUpdate, + ).toBe(undefined); + }); + }); }); describe('installExtension', () => { @@ -662,6 +738,32 @@ describe('extension tests', () => { ); }); + it('should save the autoUpdate flag to the install metadata', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + + await installExtension({ + source: sourceExtDir, + type: 'local', + autoUpdate: true, + }); + + expect(fs.existsSync(targetExtDir)).toBe(true); + expect(fs.existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: sourceExtDir, + type: 'local', + autoUpdate: true, + }); + fs.rmSync(targetExtDir, { recursive: true, force: true }); + }); + it('should ignore consent flow if not required', async () => { const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, @@ -914,265 +1016,6 @@ describe('extension tests', () => { }); }); - describe('updateExtension', () => { - it('should update a git-installed extension', async () => { - 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); - - 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' }), - ); - - mockGit.clone.mockImplementation(async (_, destination) => { - fs.mkdirSync(path.join(mockGit.path(), destination), { - recursive: true, - }); - fs.writeFileSync( - path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name: extensionName, version: '1.1.0' }), - ); - }); - mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); - const extension = annotateActiveExtensions( - [ - loadExtension({ - extensionDir: targetExtDir, - workspaceDir: tempWorkspaceDir, - })!, - ], - [], - process.cwd(), - )[0]; - const updateInfo = await updateExtension( - extension, - tempHomeDir, - () => {}, - ); - - expect(updateInfo).toEqual({ - name: 'gemini-extensions', - originalVersion: '1.0.0', - updatedVersion: '1.1.0', - }); - - const updatedConfig = JSON.parse( - fs.readFileSync( - path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME), - 'utf-8', - ), - ); - expect(updatedConfig.version).toBe('1.1.0'); - }); - - it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => { - const extensionName = 'test-extension'; - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: extensionName, - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - - mockGit.clone.mockImplementation(async (_, destination) => { - fs.mkdirSync(path.join(mockGit.path(), destination), { - recursive: true, - }); - fs.writeFileSync( - path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name: extensionName, version: '1.1.0' }), - ); - }); - mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); - - const setExtensionUpdateState = vi.fn(); - - const extension = annotateActiveExtensions( - [ - loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - })!, - ], - [], - process.cwd(), - )[0]; - await updateExtension(extension, tempHomeDir, setExtensionUpdateState); - - expect(setExtensionUpdateState).toHaveBeenCalledWith( - ExtensionUpdateState.UPDATING, - ); - expect(setExtensionUpdateState).toHaveBeenCalledWith( - ExtensionUpdateState.UPDATED_NEEDS_RESTART, - ); - }); - - it('should call setExtensionUpdateState with ERROR on failure', async () => { - const extensionName = 'test-extension'; - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: extensionName, - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - - mockGit.clone.mockRejectedValue(new Error('Git clone failed')); - mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); - - const setExtensionUpdateState = vi.fn(); - const extension = annotateActiveExtensions( - [ - loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - })!, - ], - [], - process.cwd(), - )[0]; - await expect( - updateExtension(extension, tempHomeDir, setExtensionUpdateState), - ).rejects.toThrow(); - - expect(setExtensionUpdateState).toHaveBeenCalledWith( - ExtensionUpdateState.UPDATING, - ); - expect(setExtensionUpdateState).toHaveBeenCalledWith( - ExtensionUpdateState.ERROR, - ); - }); - }); - - describe('checkForAllExtensionUpdates', () => { - it('should return UpdateAvailable for a git extension with updates', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'test-extension', - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - const extension = annotateActiveExtensions( - [ - loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - })!, - ], - [], - process.cwd(), - )[0]; - - mockGit.getRemotes.mockResolvedValue([ - { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, - ]); - mockGit.listRemote.mockResolvedValue('remoteHash HEAD'); - mockGit.revparse.mockResolvedValue('localHash'); - - const results = await checkForAllExtensionUpdates([extension], () => {}); - const result = results.get('test-extension'); - expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); - }); - - it('should return UpToDate for a git extension with no updates', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'test-extension', - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - const extension = annotateActiveExtensions( - [ - loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - })!, - ], - [], - process.cwd(), - )[0]; - - mockGit.getRemotes.mockResolvedValue([ - { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, - ]); - mockGit.listRemote.mockResolvedValue('sameHash HEAD'); - mockGit.revparse.mockResolvedValue('sameHash'); - - const results = await checkForAllExtensionUpdates([extension], () => {}); - const result = results.get('test-extension'); - expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); - }); - - it('should return NotUpdatable for a non-git extension', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'local-extension', - version: '1.0.0', - installMetadata: { source: '/local/path', type: 'local' }, - }); - const extension = annotateActiveExtensions( - [ - loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - })!, - ], - [], - process.cwd(), - )[0]; - - const results = await checkForAllExtensionUpdates([extension], () => {}); - const result = results.get('local-extension'); - expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); - }); - - it('should return Error when git check fails', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'error-extension', - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - const extension = annotateActiveExtensions( - [ - loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - })!, - ], - [], - process.cwd(), - )[0]; - - mockGit.getRemotes.mockRejectedValue(new Error('Git error')); - - const results = await checkForAllExtensionUpdates([extension], () => {}); - const result = results.get('error-extension'); - expect(result).toBe(ExtensionUpdateState.ERROR); - }); - }); - describe('disableExtension', () => { it('should disable an extension at the user scope', () => { disableExtension('my-extension', SettingScope.User); @@ -1275,39 +1118,6 @@ describe('extension tests', () => { }); }); -function createExtension({ - extensionsDir = 'extensions-dir', - name = 'my-extension', - version = '1.0.0', - addContextFile = false, - contextFileName = undefined as string | undefined, - mcpServers = {} as Record, - installMetadata = undefined as ExtensionInstallMetadata | undefined, -} = {}): string { - const extDir = path.join(extensionsDir, name); - fs.mkdirSync(extDir, { recursive: true }); - fs.writeFileSync( - path.join(extDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name, version, contextFileName, mcpServers }), - ); - - if (addContextFile) { - fs.writeFileSync(path.join(extDir, 'GEMINI.md'), 'context'); - } - - if (contextFileName) { - fs.writeFileSync(path.join(extDir, contextFileName), 'context'); - } - - if (installMetadata) { - fs.writeFileSync( - path.join(extDir, INSTALL_METADATA_FILENAME), - JSON.stringify(installMetadata), - ); - } - return extDir; -} - function isEnabled(options: { name: string; configDir: string; diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 8205efa011..282cf62b62 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -26,10 +26,8 @@ import { recursivelyHydrateStrings } from './extensions/variables.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { randomUUID } from 'node:crypto'; -import { ExtensionUpdateState } from '../ui/state/extensions.js'; import { cloneFromGit, - checkForExtensionUpdate, downloadFromGitHubRelease, } from './extensions/github.js'; import type { LoadExtensionContext } from './extensions/variableSchema.js'; @@ -99,7 +97,7 @@ export function getWorkspaceExtensions(workspaceDir: string): Extension[] { return loadExtensionsFromDir(workspaceDir); } -async function copyExtension( +export async function copyExtension( source: string, destination: string, ): Promise { @@ -263,7 +261,7 @@ export function loadExtension(context: LoadExtensionContext): Extension | null { } } -function loadInstallMetadata( +export function loadInstallMetadata( extensionDir: string, ): ExtensionInstallMetadata | undefined { const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME); @@ -633,77 +631,6 @@ export function toOutputString(extension: Extension): string { return output; } -export async function updateExtensionByName( - extensionName: string, - cwd: string = process.cwd(), - extensions: GeminiCLIExtension[], - setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, -): Promise { - const extension = extensions.find( - (installed) => installed.name === extensionName, - ); - if (!extension) { - throw new Error( - `Extension "${extensionName}" not found. Run gemini extensions list to see available extensions.`, - ); - } - return await updateExtension(extension, cwd, setExtensionUpdateState); -} - -export async function updateExtension( - extension: GeminiCLIExtension, - cwd: string = process.cwd(), - setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, -): Promise { - const installMetadata = loadInstallMetadata(extension.path); - - if (!installMetadata?.type) { - setExtensionUpdateState(ExtensionUpdateState.ERROR); - throw new Error( - `Extension ${extension.name} cannot be updated, type is unknown.`, - ); - } - if (installMetadata?.type === 'link') { - setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); - throw new Error(`Extension is linked so does not need to be updated`); - } - setExtensionUpdateState(ExtensionUpdateState.UPDATING); - const originalVersion = extension.version; - - const tempDir = await ExtensionStorage.createTmpDir(); - try { - await copyExtension(extension.path, tempDir); - await uninstallExtension(extension.name, cwd); - await installExtension(installMetadata, false, cwd); - - const updatedExtensionStorage = new ExtensionStorage(extension.name); - const updatedExtension = loadExtension({ - extensionDir: updatedExtensionStorage.getExtensionDir(), - workspaceDir: cwd, - }); - if (!updatedExtension) { - setExtensionUpdateState(ExtensionUpdateState.ERROR); - throw new Error('Updated extension not found after installation.'); - } - const updatedVersion = updatedExtension.config.version; - setExtensionUpdateState(ExtensionUpdateState.UPDATED_NEEDS_RESTART); - return { - name: extension.name, - originalVersion, - updatedVersion, - }; - } catch (e) { - console.error( - `Error updating extension, rolling back. ${getErrorMessage(e)}`, - ); - setExtensionUpdateState(ExtensionUpdateState.ERROR); - await copyExtension(tempDir, extension.path); - throw e; - } finally { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - } -} - export function disableExtension( name: string, scope: SettingScope, @@ -734,54 +661,3 @@ export function enableExtension( const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir(); manager.enable(name, true, scopePath); } - -export async function updateAllUpdatableExtensions( - cwd: string = process.cwd(), - extensions: GeminiCLIExtension[], - extensionsState: Map, - setExtensionsUpdateState: ( - updateState: Map, - ) => void, -): Promise { - return await Promise.all( - extensions - .filter( - (extension) => - extensionsState.get(extension.name) === - ExtensionUpdateState.UPDATE_AVAILABLE, - ) - .map((extension) => - updateExtension(extension, cwd, (updateState) => { - const newState = new Map(extensionsState); - newState.set(extension.name, updateState); - setExtensionsUpdateState(newState); - }), - ), - ); -} - -export interface ExtensionUpdateCheckResult { - state: ExtensionUpdateState; - error?: string; -} - -export async function checkForAllExtensionUpdates( - extensions: GeminiCLIExtension[], - setExtensionsUpdateState: ( - updateState: Map, - ) => void, -): Promise> { - const finalState = new Map(); - for (const extension of extensions) { - if (!extension.installMetadata) { - finalState.set(extension.name, ExtensionUpdateState.NOT_UPDATABLE); - continue; - } - finalState.set( - extension.name, - await checkForExtensionUpdate(extension.installMetadata), - ); - } - setExtensionsUpdateState(finalState); - return finalState; -} diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index b5d66d190b..5afacd1551 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -14,7 +14,7 @@ import { import { simpleGit, type SimpleGit } from 'simple-git'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; import type * as os from 'node:os'; -import type { ExtensionInstallMetadata } from '@google/gemini-cli-core'; +import type { GeminiCLIExtension } from '@google/gemini-cli-core'; const mockPlatform = vi.hoisted(() => vi.fn()); const mockArch = vi.hoisted(() => vi.fn()); @@ -122,28 +122,54 @@ describe('git extension helpers', () => { }); it('should return NOT_UPDATABLE for non-git extensions', async () => { - const installMetadata: ExtensionInstallMetadata = { - type: 'local', - source: '', + const extension: GeminiCLIExtension = { + name: 'test', + path: '/ext', + version: '1.0.0', + isActive: true, + installMetadata: { + type: 'local', + source: '', + }, }; - const result = await checkForExtensionUpdate(installMetadata); + let result: ExtensionUpdateState | undefined = undefined; + await checkForExtensionUpdate( + extension, + (newState) => (result = newState), + ); expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); }); it('should return ERROR if no remotes found', async () => { - const installMetadata: ExtensionInstallMetadata = { - type: 'git', - source: '', + const extension: GeminiCLIExtension = { + name: 'test', + path: '/ext', + version: '1.0.0', + isActive: true, + installMetadata: { + type: 'git', + source: '', + }, }; mockGit.getRemotes.mockResolvedValue([]); - const result = await checkForExtensionUpdate(installMetadata); + let result: ExtensionUpdateState | undefined = undefined; + await checkForExtensionUpdate( + extension, + (newState) => (result = newState), + ); expect(result).toBe(ExtensionUpdateState.ERROR); }); it('should return UPDATE_AVAILABLE when remote hash is different', async () => { - const installMetadata: ExtensionInstallMetadata = { - type: 'git', - source: '/ext', + const extension: GeminiCLIExtension = { + name: 'test', + path: '/ext', + version: '1.0.0', + isActive: true, + installMetadata: { + type: 'git', + source: 'my/ext', + }, }; mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, @@ -151,14 +177,24 @@ describe('git extension helpers', () => { mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); mockGit.revparse.mockResolvedValue('local-hash'); - const result = await checkForExtensionUpdate(installMetadata); + let result: ExtensionUpdateState | undefined = undefined; + await checkForExtensionUpdate( + extension, + (newState) => (result = newState), + ); expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); }); it('should return UP_TO_DATE when remote and local hashes are the same', async () => { - const installMetadata: ExtensionInstallMetadata = { - type: 'git', - source: '/ext', + const extension: GeminiCLIExtension = { + name: 'test', + path: '/ext', + version: '1.0.0', + isActive: true, + installMetadata: { + type: 'git', + source: 'my/ext', + }, }; mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, @@ -166,17 +202,32 @@ describe('git extension helpers', () => { mockGit.listRemote.mockResolvedValue('same-hash\tHEAD'); mockGit.revparse.mockResolvedValue('same-hash'); - const result = await checkForExtensionUpdate(installMetadata); + let result: ExtensionUpdateState | undefined = undefined; + await checkForExtensionUpdate( + extension, + (newState) => (result = newState), + ); expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); }); it('should return ERROR on git error', async () => { - const installMetadata: ExtensionInstallMetadata = { - type: 'git', - source: '/ext', + const extension: GeminiCLIExtension = { + name: 'test', + path: '/ext', + version: '1.0.0', + isActive: true, + installMetadata: { + type: 'git', + source: 'my/ext', + }, }; mockGit.getRemotes.mockRejectedValue(new Error('git error')); - const result = await checkForExtensionUpdate(installMetadata); + + let result: ExtensionUpdateState | undefined = undefined; + await checkForExtensionUpdate( + extension, + (newState) => (result = newState), + ); expect(result).toBe(ExtensionUpdateState.ERROR); }); }); diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index f77e54edc9..471945c507 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -6,7 +6,10 @@ import { simpleGit } from 'simple-git'; import { getErrorMessage } from '../../utils/errors.js'; -import type { ExtensionInstallMetadata } from '@google/gemini-cli-core'; +import type { + ExtensionInstallMetadata, + GeminiCLIExtension, +} from '@google/gemini-cli-core'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; import * as os from 'node:os'; import * as https from 'node:https'; @@ -110,39 +113,44 @@ async function fetchFromGithub( } export async function checkForExtensionUpdate( - installMetadata: ExtensionInstallMetadata, -): Promise { + extension: GeminiCLIExtension, + setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, +): Promise { + setExtensionUpdateState(ExtensionUpdateState.CHECKING_FOR_UPDATES); + const installMetadata = extension.installMetadata; if ( - installMetadata.type !== 'git' && - installMetadata.type !== 'github-release' + !installMetadata || + (installMetadata.type !== 'git' && + installMetadata.type !== 'github-release') ) { - return ExtensionUpdateState.NOT_UPDATABLE; + setExtensionUpdateState(ExtensionUpdateState.NOT_UPDATABLE); + return; } try { if (installMetadata.type === 'git') { - const git = simpleGit(installMetadata.source); + const git = simpleGit(extension.path); const remotes = await git.getRemotes(true); if (remotes.length === 0) { console.error('No git remotes found.'); - return ExtensionUpdateState.ERROR; + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; } const remoteUrl = remotes[0].refs.fetch; if (!remoteUrl) { console.error(`No fetch URL found for git remote ${remotes[0].name}.`); - return ExtensionUpdateState.ERROR; + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; } // Determine the ref to check on the remote. const refToCheck = installMetadata.ref || 'HEAD'; - const lsRemoteOutput = await git.listRemote([ - remotes[0].name, - refToCheck, - ]); + const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { console.error(`Git ref ${refToCheck} not found.`); - return ExtensionUpdateState.ERROR; + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; } const remoteHash = lsRemoteOutput.split('\t')[0]; @@ -152,16 +160,21 @@ export async function checkForExtensionUpdate( console.error( `Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`, ); - return ExtensionUpdateState.ERROR; + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; } if (remoteHash === localHash) { - return ExtensionUpdateState.UP_TO_DATE; + setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); + return; } - return ExtensionUpdateState.UPDATE_AVAILABLE; + setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE); + return; } else { const { source, ref } = installMetadata; if (!source) { - return ExtensionUpdateState.ERROR; + console.error(`No "source" provided for extension.`); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; } const { owner, repo } = parseGitHubRepoForReleases(source); @@ -171,15 +184,18 @@ export async function checkForExtensionUpdate( installMetadata.ref, ); if (releaseData.tag_name !== ref) { - return ExtensionUpdateState.UPDATE_AVAILABLE; + setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE); + return; } - return ExtensionUpdateState.UP_TO_DATE; + setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); + return; } } catch (error) { console.error( `Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`, ); - return ExtensionUpdateState.ERROR; + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; } } diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts new file mode 100644 index 0000000000..371e5ae279 --- /dev/null +++ b/packages/cli/src/config/extensions/update.test.ts @@ -0,0 +1,416 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + EXTENSIONS_CONFIG_FILENAME, + INSTALL_METADATA_FILENAME, + annotateActiveExtensions, + loadExtension, +} from '../extension.js'; +import { checkForAllExtensionUpdates, updateExtension } from './update.js'; +import { GEMINI_DIR } from '@google/gemini-cli-core'; +import { isWorkspaceTrusted } from '../trustedFolders.js'; +import { ExtensionUpdateState } from '../../ui/state/extensions.js'; +import { createExtension } from '../../test-utils/createExtension.js'; + +const mockGit = { + clone: vi.fn(), + getRemotes: vi.fn(), + fetch: vi.fn(), + checkout: vi.fn(), + listRemote: vi.fn(), + revparse: vi.fn(), + // Not a part of the actual API, but we need to use this to do the correct + // file system interactions. + path: vi.fn(), +}; + +vi.mock('simple-git', () => ({ + simpleGit: vi.fn((path: string) => { + mockGit.path.mockReturnValue(path); + return mockGit; + }), +})); + +vi.mock('os', async (importOriginal) => { + const mockedOs = await importOriginal(); + return { + ...mockedOs, + homedir: vi.fn(), + }; +}); + +vi.mock('../trustedFolders.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isWorkspaceTrusted: vi.fn(), + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + const mockLogExtensionInstallEvent = vi.fn(); + const mockLogExtensionUninstallEvent = vi.fn(); + return { + ...actual, + ClearcutLogger: { + getInstance: vi.fn(() => ({ + logExtensionInstallEvent: mockLogExtensionInstallEvent, + logExtensionUninstallEvent: mockLogExtensionUninstallEvent, + })), + }, + Config: vi.fn(), + ExtensionInstallEvent: vi.fn(), + ExtensionUninstallEvent: vi.fn(), + }; +}); + +describe('update tests', () => { + let tempHomeDir: string; + let tempWorkspaceDir: string; + let userExtensionsDir: string; + + beforeEach(() => { + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + tempWorkspaceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'gemini-cli-test-workspace-'), + ); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); + // Clean up before each test + fs.rmSync(userExtensionsDir, { recursive: true, force: true }); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + Object.values(mockGit).forEach((fn) => fn.mockReset()); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + }); + + describe('updateExtension', () => { + it('should update a git-installed extension', async () => { + 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); + + 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' }), + ); + + mockGit.clone.mockImplementation(async (_, destination) => { + fs.mkdirSync(path.join(mockGit.path(), destination), { + recursive: true, + }); + fs.writeFileSync( + path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.1.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir: targetExtDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + [], + process.cwd(), + )[0]; + const updateInfo = await updateExtension( + extension, + tempHomeDir, + ExtensionUpdateState.UPDATE_AVAILABLE, + () => {}, + ); + + expect(updateInfo).toEqual({ + name: 'gemini-extensions', + originalVersion: '1.0.0', + updatedVersion: '1.1.0', + }); + + const updatedConfig = JSON.parse( + fs.readFileSync( + path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME), + 'utf-8', + ), + ); + expect(updatedConfig.version).toBe('1.1.0'); + }); + + it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => { + const extensionName = 'test-extension'; + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: extensionName, + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + + mockGit.clone.mockImplementation(async (_, destination) => { + fs.mkdirSync(path.join(mockGit.path(), destination), { + recursive: true, + }); + fs.writeFileSync( + path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.1.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + + const setExtensionUpdateState = vi.fn(); + + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + [], + process.cwd(), + )[0]; + await updateExtension( + extension, + tempHomeDir, + ExtensionUpdateState.UPDATE_AVAILABLE, + setExtensionUpdateState, + ); + + expect(setExtensionUpdateState).toHaveBeenCalledWith( + ExtensionUpdateState.UPDATING, + ); + expect(setExtensionUpdateState).toHaveBeenCalledWith( + ExtensionUpdateState.UPDATED_NEEDS_RESTART, + ); + }); + + it('should call setExtensionUpdateState with ERROR on failure', async () => { + const extensionName = 'test-extension'; + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: extensionName, + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + + mockGit.clone.mockRejectedValue(new Error('Git clone failed')); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + + const setExtensionUpdateState = vi.fn(); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + [], + process.cwd(), + )[0]; + await expect( + updateExtension( + extension, + tempHomeDir, + ExtensionUpdateState.UPDATE_AVAILABLE, + setExtensionUpdateState, + ), + ).rejects.toThrow(); + + expect(setExtensionUpdateState).toHaveBeenCalledWith( + ExtensionUpdateState.UPDATING, + ); + expect(setExtensionUpdateState).toHaveBeenCalledWith( + ExtensionUpdateState.ERROR, + ); + }); + }); + + describe('checkForAllExtensionUpdates', () => { + it('should return UpdateAvailable for a git extension with updates', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + [], + process.cwd(), + )[0]; + + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, + ]); + mockGit.listRemote.mockResolvedValue('remoteHash HEAD'); + mockGit.revparse.mockResolvedValue('localHash'); + + let extensionState = new Map(); + const results = await checkForAllExtensionUpdates( + [extension], + extensionState, + (newState) => { + if (typeof newState === 'function') { + newState(extensionState); + } else { + extensionState = newState; + } + }, + ); + const result = results.get('test-extension'); + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + }); + + it('should return UpToDate for a git extension with no updates', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + [], + process.cwd(), + )[0]; + + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, + ]); + mockGit.listRemote.mockResolvedValue('sameHash HEAD'); + mockGit.revparse.mockResolvedValue('sameHash'); + + let extensionState = new Map(); + const results = await checkForAllExtensionUpdates( + [extension], + extensionState, + (newState) => { + if (typeof newState === 'function') { + newState(extensionState); + } else { + extensionState = newState; + } + }, + ); + const result = results.get('test-extension'); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + }); + + it('should return NotUpdatable for a non-git extension', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'local-extension', + version: '1.0.0', + installMetadata: { source: '/local/path', type: 'local' }, + }); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + [], + process.cwd(), + )[0]; + let extensionState = new Map(); + const results = await checkForAllExtensionUpdates( + [extension], + extensionState, + (newState) => { + if (typeof newState === 'function') { + newState(extensionState); + } else { + extensionState = newState; + } + }, + ); + const result = results.get('local-extension'); + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + }); + + it('should return Error when git check fails', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'error-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + [], + process.cwd(), + )[0]; + + mockGit.getRemotes.mockRejectedValue(new Error('Git error')); + + let extensionState = new Map(); + const results = await checkForAllExtensionUpdates( + [extension], + extensionState, + (newState) => { + if (typeof newState === 'function') { + newState(extensionState); + } else { + extensionState = newState; + } + }, + ); + const result = results.get('error-extension'); + expect(result).toBe(ExtensionUpdateState.ERROR); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts new file mode 100644 index 0000000000..acb930aee0 --- /dev/null +++ b/packages/cli/src/config/extensions/update.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { GeminiCLIExtension } from '@google/gemini-cli-core'; +import * as fs from 'node:fs'; +import { getErrorMessage } from '../../utils/errors.js'; +import { ExtensionUpdateState } from '../../ui/state/extensions.js'; +import { type Dispatch, type SetStateAction } from 'react'; +import { + copyExtension, + installExtension, + uninstallExtension, + loadExtension, + loadInstallMetadata, + ExtensionStorage, +} from '../extension.js'; +import { checkForExtensionUpdate } from './github.js'; + +export interface ExtensionUpdateInfo { + name: string; + originalVersion: string; + updatedVersion: string; +} + +export async function updateExtension( + extension: GeminiCLIExtension, + cwd: string = process.cwd(), + currentState: ExtensionUpdateState, + setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, +): Promise { + if (currentState === ExtensionUpdateState.UPDATING) { + return undefined; + } + setExtensionUpdateState(ExtensionUpdateState.UPDATING); + const installMetadata = loadInstallMetadata(extension.path); + + if (!installMetadata?.type) { + setExtensionUpdateState(ExtensionUpdateState.ERROR); + throw new Error( + `Extension ${extension.name} cannot be updated, type is unknown.`, + ); + } + if (installMetadata?.type === 'link') { + setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); + throw new Error(`Extension is linked so does not need to be updated`); + } + const originalVersion = extension.version; + + const tempDir = await ExtensionStorage.createTmpDir(); + try { + await copyExtension(extension.path, tempDir); + await uninstallExtension(extension.name, cwd); + await installExtension(installMetadata, false, cwd); + + const updatedExtensionStorage = new ExtensionStorage(extension.name); + const updatedExtension = loadExtension({ + extensionDir: updatedExtensionStorage.getExtensionDir(), + workspaceDir: cwd, + }); + if (!updatedExtension) { + setExtensionUpdateState(ExtensionUpdateState.ERROR); + throw new Error('Updated extension not found after installation.'); + } + const updatedVersion = updatedExtension.config.version; + setExtensionUpdateState(ExtensionUpdateState.UPDATED_NEEDS_RESTART); + return { + name: extension.name, + originalVersion, + updatedVersion, + }; + } catch (e) { + console.error( + `Error updating extension, rolling back. ${getErrorMessage(e)}`, + ); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + await copyExtension(tempDir, extension.path); + throw e; + } finally { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } +} + +export async function updateAllUpdatableExtensions( + cwd: string = process.cwd(), + extensions: GeminiCLIExtension[], + extensionsState: Map, + setExtensionsUpdateState: Dispatch< + SetStateAction> + >, +): Promise { + return ( + await Promise.all( + extensions + .filter( + (extension) => + extensionsState.get(extension.name) === + ExtensionUpdateState.UPDATE_AVAILABLE, + ) + .map((extension) => + updateExtension( + extension, + cwd, + extensionsState.get(extension.name)!, + (updateState) => { + setExtensionsUpdateState((prev) => { + const finalState = new Map(prev); + finalState.set(extension.name, updateState); + return finalState; + }); + }, + ), + ), + ) + ).filter((updateInfo) => !!updateInfo); +} + +export interface ExtensionUpdateCheckResult { + state: ExtensionUpdateState; + error?: string; +} + +export async function checkForAllExtensionUpdates( + extensions: GeminiCLIExtension[], + extensionsUpdateState: Map, + setExtensionsUpdateState: Dispatch< + SetStateAction> + >, +): Promise> { + for (const extension of extensions) { + const initialState = extensionsUpdateState.get(extension.name); + if (initialState === undefined) { + if (!extension.installMetadata) { + setExtensionsUpdateState((prev) => { + extensionsUpdateState = new Map(prev); + extensionsUpdateState.set( + extension.name, + ExtensionUpdateState.NOT_UPDATABLE, + ); + return extensionsUpdateState; + }); + continue; + } + await checkForExtensionUpdate(extension, (updatedState) => { + setExtensionsUpdateState((prev) => { + extensionsUpdateState = new Map(prev); + extensionsUpdateState.set(extension.name, updatedState); + return extensionsUpdateState; + }); + }); + } + } + return extensionsUpdateState; +} diff --git a/packages/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts new file mode 100644 index 0000000000..6500a6d1f5 --- /dev/null +++ b/packages/cli/src/test-utils/createExtension.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + EXTENSIONS_CONFIG_FILENAME, + INSTALL_METADATA_FILENAME, +} from '../config/extension.js'; +import { + type MCPServerConfig, + type ExtensionInstallMetadata, +} from '@google/gemini-cli-core'; + +export function createExtension({ + extensionsDir = 'extensions-dir', + name = 'my-extension', + version = '1.0.0', + addContextFile = false, + contextFileName = undefined as string | undefined, + mcpServers = {} as Record, + installMetadata = undefined as ExtensionInstallMetadata | undefined, +} = {}): string { + const extDir = path.join(extensionsDir, name); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name, version, contextFileName, mcpServers }), + ); + + if (addContextFile) { + fs.writeFileSync(path.join(extDir, 'GEMINI.md'), 'context'); + } + + if (contextFileName) { + fs.writeFileSync(path.join(extDir, contextFileName), 'context'); + } + + if (installMetadata) { + fs.writeFileSync( + path.join(extDir, INSTALL_METADATA_FILENAME), + JSON.stringify(installMetadata), + ); + } + return extDir; +} diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 648e3c8fd6..b07bccaacf 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -54,6 +54,8 @@ export const createMockCommandContext = ( loadHistory: vi.fn(), toggleCorgiMode: vi.fn(), toggleVimEnabled: vi.fn(), + extensionsUpdateState: new Map(), + setExtensionsUpdateState: vi.fn(), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, session: { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d637952934..cca13d5dfe 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -85,9 +85,8 @@ import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; +import { useExtensionUpdates } from './hooks/useExtensionUpdates.js'; import { FocusContext } from './contexts/FocusContext.js'; -import type { ExtensionUpdateState } from './state/extensions.js'; -import { checkForAllExtensionUpdates } from '../config/extension.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -149,9 +148,14 @@ export const AppContainer = (props: AppContainerProps) => { const [isTrustedFolder, setIsTrustedFolder] = useState( config.isTrustedFolder(), ); - const [extensionsUpdateState, setExtensionsUpdateState] = useState( - new Map(), - ); + + const extensions = config.getExtensions(); + const { extensionsUpdateState, setExtensionsUpdateState } = + useExtensionUpdates( + extensions, + historyManager.addItem, + config.getWorkingDir(), + ); // Helper to determine the effective model, considering the fallback state. const getEffectiveModel = useCallback(() => { @@ -1196,11 +1200,6 @@ Logging in with Google... Please restart Gemini CLI to continue. ], ); - const extensions = config.getExtensions(); - useEffect(() => { - checkForAllExtensionUpdates(extensions, setExtensionsUpdateState); - }, [extensions, setExtensionsUpdateState]); - return ( diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 85a717eb77..947d77734a 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -4,10 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { GeminiCLIExtension } from '@google/gemini-cli-core'; import { updateAllUpdatableExtensions, - updateExtensionByName, -} from '../../config/extension.js'; + updateExtension, +} from '../../config/extensions/update.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; import { extensionsCommand } from './extensionsCommand.js'; @@ -20,14 +21,15 @@ import { beforeEach, type MockedFunction, } from 'vitest'; +import { ExtensionUpdateState } from '../state/extensions.js'; -vi.mock('../../config/extension.js', () => ({ - updateExtensionByName: vi.fn(), +vi.mock('../../config/extensions/update.js', () => ({ + updateExtension: vi.fn(), updateAllUpdatableExtensions: vi.fn(), })); -const mockUpdateExtensionByName = updateExtensionByName as MockedFunction< - typeof updateExtensionByName +const mockUpdateExtension = updateExtension as MockedFunction< + typeof updateExtension >; const mockUpdateAllUpdatableExtensions = @@ -35,6 +37,8 @@ const mockUpdateAllUpdatableExtensions = typeof updateAllUpdatableExtensions >; +const mockGetExtensions = vi.fn(); + describe('extensionsCommand', () => { let mockContext: CommandContext; @@ -43,7 +47,7 @@ describe('extensionsCommand', () => { mockContext = createMockCommandContext({ services: { config: { - getExtensions: () => [], + getExtensions: mockGetExtensions, getWorkingDir: () => '/test/dir', }, }, @@ -147,36 +151,73 @@ describe('extensionsCommand', () => { }); it('should update a single extension by name', async () => { - mockUpdateExtensionByName.mockResolvedValue({ + const extension: GeminiCLIExtension = { name: 'ext-one', - originalVersion: '1.0.0', + type: 'git', + version: '1.0.0', + isActive: true, + path: '/test/dir/ext-one', + autoUpdate: false, + }; + mockUpdateExtension.mockResolvedValue({ + name: extension.name, + originalVersion: extension.version, updatedVersion: '1.0.1', }); + mockGetExtensions.mockReturnValue([extension]); + mockContext.ui.extensionsUpdateState.set( + extension.name, + ExtensionUpdateState.UPDATE_AVAILABLE, + ); await updateAction(mockContext, 'ext-one'); - expect(mockUpdateExtensionByName).toHaveBeenCalledWith( - 'ext-one', + expect(mockUpdateExtension).toHaveBeenCalledWith( + extension, '/test/dir', - [], + ExtensionUpdateState.UPDATE_AVAILABLE, expect.any(Function), ); }); it('should handle errors when updating a single extension', async () => { - mockUpdateExtensionByName.mockRejectedValue( - new Error('Extension not found'), - ); + mockUpdateExtension.mockRejectedValue(new Error('Extension not found')); + mockGetExtensions.mockReturnValue([]); await updateAction(mockContext, 'ext-one'); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.ERROR, - text: 'Extension not found', + text: 'Extension ext-one not found.', }, expect.any(Number), ); }); it('should update multiple extensions by name', async () => { - mockUpdateExtensionByName + const extensionOne: GeminiCLIExtension = { + name: 'ext-one', + type: 'git', + version: '1.0.0', + isActive: true, + path: '/test/dir/ext-one', + autoUpdate: false, + }; + const extensionTwo: GeminiCLIExtension = { + name: 'ext-two', + type: 'git', + version: '1.0.0', + isActive: true, + path: '/test/dir/ext-two', + autoUpdate: false, + }; + mockGetExtensions.mockReturnValue([extensionOne, extensionTwo]); + mockContext.ui.extensionsUpdateState.set( + extensionOne.name, + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + mockContext.ui.extensionsUpdateState.set( + extensionTwo.name, + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + mockUpdateExtension .mockResolvedValueOnce({ name: 'ext-one', originalVersion: '1.0.0', @@ -188,7 +229,7 @@ describe('extensionsCommand', () => { updatedVersion: '2.0.1', }); await updateAction(mockContext, 'ext-one ext-two'); - expect(mockUpdateExtensionByName).toHaveBeenCalledTimes(2); + expect(mockUpdateExtension).toHaveBeenCalledTimes(2); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, }); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 8552a49237..c50143192a 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -5,11 +5,12 @@ */ import { - updateExtensionByName, updateAllUpdatableExtensions, type ExtensionUpdateInfo, -} from '../../config/extension.js'; + updateExtension, +} from '../../config/extensions/update.js'; import { getErrorMessage } from '../../utils/errors.js'; +import { ExtensionUpdateState } from '../state/extensions.js'; import { MessageType } from '../types.js'; import { type CommandContext, @@ -55,19 +56,36 @@ async function updateAction(context: CommandContext, args: string) { context.ui.setExtensionsUpdateState, ); } else if (names?.length) { + const workingDir = context.services.config!.getWorkingDir(); + const extensions = context.services.config!.getExtensions(); for (const name of names) { - updateInfos.push( - await updateExtensionByName( - name, - context.services.config!.getWorkingDir(), - context.services.config!.getExtensions(), - (updateState) => { - const newState = new Map(context.ui.extensionsUpdateState); - newState.set(name, updateState); - context.ui.setExtensionsUpdateState(newState); - }, - ), + const extension = extensions.find( + (extension) => extension.name === name, ); + if (!extension) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Extension ${name} not found.`, + }, + Date.now(), + ); + continue; + } + const updateInfo = await updateExtension( + extension, + workingDir, + context.ui.extensionsUpdateState.get(extension.name) ?? + ExtensionUpdateState.UNKNOWN, + (updateState) => { + context.ui.setExtensionsUpdateState((prev) => { + const newState = new Map(prev); + newState.set(name, updateState); + return newState; + }); + }, + ); + if (updateInfo) updateInfos.push(updateInfo); } } diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 8a5d35ad54..755b28993a 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ReactNode } from 'react'; +import type { Dispatch, ReactNode, SetStateAction } from 'react'; import type { Content, PartListUnion } from '@google/genai'; import type { HistoryItemWithoutId, HistoryItem } from '../types.js'; import type { Config, GitService, Logger } from '@google/gemini-cli-core'; @@ -63,9 +63,9 @@ export interface CommandContext { setGeminiMdFileCount: (count: number) => void; reloadCommands: () => void; extensionsUpdateState: Map; - setExtensionsUpdateState: ( - updateState: Map, - ) => void; + setExtensionsUpdateState: Dispatch< + SetStateAction> + >; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 5b22f5d198..acb43286cb 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useMemo, useEffect, useState } from 'react'; +import { + useCallback, + useMemo, + useEffect, + useState, + type Dispatch, + type SetStateAction, +} from 'react'; import { type PartListUnion } from '@google/genai'; import process from 'node:process'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -45,9 +52,9 @@ interface SlashCommandProcessorActions { quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; toggleCorgiMode: () => void; - setExtensionsUpdateState: ( - updateState: Map, - ) => void; + setExtensionsUpdateState: Dispatch< + SetStateAction> + >; } /** diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts new file mode 100644 index 0000000000..d06f9038d0 --- /dev/null +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts @@ -0,0 +1,211 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + EXTENSIONS_CONFIG_FILENAME, + annotateActiveExtensions, + loadExtension, +} from '../../config/extension.js'; +import { createExtension } from '../../test-utils/createExtension.js'; +import { useExtensionUpdates } from './useExtensionUpdates.js'; +import { GEMINI_DIR, type GeminiCLIExtension } from '@google/gemini-cli-core'; +import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; +import { renderHook, waitFor } from '@testing-library/react'; +import { MessageType } from '../types.js'; + +const mockGit = { + clone: vi.fn(), + getRemotes: vi.fn(), + fetch: vi.fn(), + checkout: vi.fn(), + listRemote: vi.fn(), + revparse: vi.fn(), + // Not a part of the actual API, but we need to use this to do the correct + // file system interactions. + path: vi.fn(), +}; + +vi.mock('simple-git', () => ({ + simpleGit: vi.fn((path: string) => { + mockGit.path.mockReturnValue(path); + return mockGit; + }), +})); + +vi.mock('os', async (importOriginal) => { + const mockedOs = await importOriginal(); + return { + ...mockedOs, + homedir: vi.fn(), + }; +}); + +vi.mock('../../config/trustedFolders.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isWorkspaceTrusted: vi.fn(), + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + const mockLogExtensionInstallEvent = vi.fn(); + const mockLogExtensionUninstallEvent = vi.fn(); + return { + ...actual, + ClearcutLogger: { + getInstance: vi.fn(() => ({ + logExtensionInstallEvent: mockLogExtensionInstallEvent, + logExtensionUninstallEvent: mockLogExtensionUninstallEvent, + })), + }, + Config: vi.fn(), + ExtensionInstallEvent: vi.fn(), + ExtensionUninstallEvent: vi.fn(), + }; +}); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn(), + }; +}); + +const mockQuestion = vi.hoisted(() => vi.fn()); +const mockClose = vi.hoisted(() => vi.fn()); +vi.mock('node:readline', () => ({ + createInterface: vi.fn(() => ({ + question: mockQuestion, + close: mockClose, + })), +})); + +describe('useExtensionUpdates', () => { + 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_DIR, 'extensions'); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + Object.values(mockGit).forEach((fn) => fn.mockReset()); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + }); + + it('should check for updates and log a message if an update is available', async () => { + const extensions = [ + { + name: 'test-extension', + type: 'git', + version: '1.0.0', + path: '/some/path', + isActive: true, + installMetadata: { + type: 'git', + source: 'https://some/repo', + autoUpdate: false, + }, + }, + ]; + const addItem = vi.fn(); + const cwd = '/test/cwd'; + + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { + fetch: 'https://github.com/google/gemini-cli.git', + }, + }, + ]); + mockGit.revparse.mockResolvedValue('local-hash'); + mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); + + renderHook(() => + useExtensionUpdates(extensions as GeminiCLIExtension[], addItem, cwd), + ); + + await waitFor(() => { + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Extension test-extension has an update available, run "/extensions update test-extension" to install it.', + }, + expect.any(Number), + ); + }); + }); + + it('should check for updates and automatically update if autoUpdate is true', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + autoUpdate: true, + }, + }); + const extension = annotateActiveExtensions( + [loadExtension({ extensionDir, workspaceDir: tempHomeDir })!], + [], + tempHomeDir, + )[0]; + + const addItem = vi.fn(); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { + fetch: 'https://github.com/google/gemini-cli.git', + }, + }, + ]); + mockGit.revparse.mockResolvedValue('local-hash'); + mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); + mockGit.clone.mockImplementation(async (_, destination) => { + fs.mkdirSync(path.join(mockGit.path(), destination), { + recursive: true, + }); + fs.writeFileSync( + path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: 'test-extension', version: '1.1.0' }), + ); + }); + vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + + renderHook(() => useExtensionUpdates([extension], addItem, tempHomeDir)); + + await waitFor( + () => { + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Extension "test-extension" successfully updated: 1.0.0 → 1.1.0.', + }, + expect.any(Number), + ); + }, + { timeout: 2000 }, + ); + }); +}); diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.ts new file mode 100644 index 0000000000..03aa1e10cc --- /dev/null +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { GeminiCLIExtension } from '@google/gemini-cli-core'; +import { getErrorMessage } from '../../utils/errors.js'; +import { ExtensionUpdateState } from '../state/extensions.js'; +import { useMemo, useState } from 'react'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import { MessageType } from '../types.js'; +import { + checkForAllExtensionUpdates, + updateExtension, +} from '../../config/extensions/update.js'; + +export const useExtensionUpdates = ( + extensions: GeminiCLIExtension[], + addItem: UseHistoryManagerReturn['addItem'], + cwd: string, +) => { + const [extensionsUpdateState, setExtensionsUpdateState] = useState( + new Map(), + ); + useMemo(() => { + const checkUpdates = async () => { + const updateState = await checkForAllExtensionUpdates( + extensions, + extensionsUpdateState, + setExtensionsUpdateState, + ); + for (const extension of extensions) { + const prevState = extensionsUpdateState.get(extension.name); + const currentState = updateState.get(extension.name); + if ( + prevState === currentState || + currentState !== ExtensionUpdateState.UPDATE_AVAILABLE + ) { + continue; + } + if (extension.installMetadata?.autoUpdate) { + updateExtension(extension, cwd, currentState, (newState) => { + setExtensionsUpdateState((prev) => { + const finalState = new Map(prev); + finalState.set(extension.name, newState); + return finalState; + }); + }) + .then((result) => { + if (!result) return; + addItem( + { + type: MessageType.INFO, + text: `Extension "${extension.name}" successfully updated: ${result.originalVersion} → ${result.updatedVersion}.`, + }, + Date.now(), + ); + }) + .catch((error) => { + console.error( + `Error updating extension "${extension.name}": ${getErrorMessage(error)}.`, + ); + }); + } else { + addItem( + { + type: MessageType.INFO, + text: `Extension ${extension.name} has an update available, run "/extensions update ${extension.name}" to install it.`, + }, + Date.now(), + ); + } + } + }; + checkUpdates(); + }, [ + extensions, + extensionsUpdateState, + setExtensionsUpdateState, + addItem, + cwd, + ]); + return { + extensionsUpdateState, + setExtensionsUpdateState, + }; +}; diff --git a/packages/cli/src/ui/state/extensions.ts b/packages/cli/src/ui/state/extensions.ts index 3bc625f133..8b02af1919 100644 --- a/packages/cli/src/ui/state/extensions.ts +++ b/packages/cli/src/ui/state/extensions.ts @@ -12,4 +12,5 @@ export enum ExtensionUpdateState { UP_TO_DATE = 'up to date', ERROR = 'error checking for updates', NOT_UPDATABLE = 'not updatable', + UNKNOWN = 'unknown', } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9ba06e3e06..2d1a686016 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -124,6 +124,7 @@ export interface ExtensionInstallMetadata { source: string; type: 'git' | 'local' | 'link' | 'github-release'; ref?: string; + autoUpdate?: boolean; } export interface FileFilteringOptions {