diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 8f861246e6..d7cbaa1799 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -4,11 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, type MockInstance, type Mock } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance, + type Mock, +} from 'vitest'; import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core'; -import type { ExtensionManager } from '../../config/extension-manager.js'; +import type { + ExtensionManager, + inferInstallMetadata, +} from '../../config/extension-manager.js'; import type { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import type * as fs from 'node:fs/promises'; import type { Stats } from 'node:fs'; @@ -20,12 +32,15 @@ const mockRequestConsentNonInteractive: Mock< typeof requestConsentNonInteractive > = vi.hoisted(() => vi.fn()); const mockStat: Mock = vi.hoisted(() => vi.fn()); +const mockInferInstallMetadata: Mock = vi.hoisted( + () => vi.fn(), +); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: mockRequestConsentNonInteractive, })); -vi.mock('../../config/extension-manager.ts', async (importOriginal) => { +vi.mock('../../config/extension-manager.js', async (importOriginal) => { const actual = await importOriginal(); return { @@ -34,6 +49,7 @@ vi.mock('../../config/extension-manager.ts', async (importOriginal) => { installOrUpdateExtension: mockInstallOrUpdateExtension, loadExtensions: vi.fn(), })), + inferInstallMetadata: mockInferInstallMetadata, }; }); @@ -72,12 +88,31 @@ describe('handleInstall', () => { processSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); + + mockInferInstallMetadata.mockImplementation(async (source, args) => { + if ( + source.startsWith('http://') || + source.startsWith('https://') || + source.startsWith('git@') || + source.startsWith('sso://') + ) { + return { + source, + type: 'git', + ref: args?.ref, + autoUpdate: args?.autoUpdate, + allowPreRelease: args?.allowPreRelease, + }; + } + return { source, type: 'local' }; + }); }); afterEach(() => { mockInstallOrUpdateExtension.mockClear(); mockRequestConsentNonInteractive.mockClear(); mockStat.mockClear(); + mockInferInstallMetadata.mockClear(); vi.clearAllMocks(); }); @@ -124,7 +159,9 @@ describe('handleInstall', () => { }); it('throws an error from an unknown source', async () => { - mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory')); + mockInferInstallMetadata.mockRejectedValue( + new Error('Install source not found.'), + ); await handleInstall({ source: 'test://google.com', }); diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 0420bd14f3..5830055024 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -5,17 +5,16 @@ */ import type { CommandModule } from 'yargs'; -import { - debugLogger, - type ExtensionInstallMetadata, -} from '@google/gemini-cli-core'; +import { debugLogger } from '@google/gemini-cli-core'; import { getErrorMessage } from '../../utils/errors.js'; -import { stat } from 'node:fs/promises'; import { INSTALL_WARNING_MESSAGE, requestConsentNonInteractive, } from '../../config/extensions/consent.js'; -import { ExtensionManager } from '../../config/extension-manager.js'; +import { + ExtensionManager, + inferInstallMetadata, +} from '../../config/extension-manager.js'; import { loadSettings } from '../../config/settings.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; @@ -30,37 +29,12 @@ interface InstallArgs { export async function handleInstall(args: InstallArgs) { try { - let installMetadata: ExtensionInstallMetadata; const { source } = args; - if ( - source.startsWith('http://') || - source.startsWith('https://') || - source.startsWith('git@') || - source.startsWith('sso://') - ) { - installMetadata = { - source, - type: 'git', - ref: args.ref, - autoUpdate: args.autoUpdate, - allowPreRelease: args.allowPreRelease, - }; - } else { - if (args.ref || args.autoUpdate) { - throw new Error( - '--ref and --auto-update are not applicable for local extensions.', - ); - } - try { - await stat(source); - installMetadata = { - source, - type: 'local', - }; - } catch { - throw new Error('Install source not found.'); - } - } + const installMetadata = await inferInstallMetadata(source, { + ref: args.ref, + autoUpdate: args.autoUpdate, + allowPreRelease: args.allowPreRelease, + }); const requestConsent = args.consent ? () => Promise.resolve(true) diff --git a/packages/cli/src/commands/extensions/validate.test.ts b/packages/cli/src/commands/extensions/validate.test.ts index 3d5aed0df5..d574095eb6 100644 --- a/packages/cli/src/commands/extensions/validate.test.ts +++ b/packages/cli/src/commands/extensions/validate.test.ts @@ -5,7 +5,15 @@ */ import * as fs from 'node:fs'; -import { describe, it, expect, vi, type MockInstance } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; import { handleValidate, validateCommand } from './validate.js'; import yargs from 'yargs'; import { createExtension } from '../../test-utils/createExtension.js'; diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 4547565df5..48cd17b873 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -7,6 +7,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import { stat } from 'node:fs/promises'; import chalk from 'chalk'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import { type Settings, SettingScope } from './settings.js'; @@ -198,7 +199,9 @@ export class ExtensionManager extends ExtensionLoader { installMetadata.type === 'git') || // Otherwise ask the user if they would like to try a git clone. (await this.requestConsent( - `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.\n\nWould you like to attempt to install via "git clone" instead?`, + `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}. + +Would you like to attempt to install via "git clone" instead?`, )) ) { await cloneFromGit(installMetadata, tempDir); @@ -797,7 +800,46 @@ function validateName(name: string) { } } -function getExtensionId( +export async function inferInstallMetadata( + source: string, + args: { + ref?: string; + autoUpdate?: boolean; + allowPreRelease?: boolean; + } = {}, +): Promise { + if ( + source.startsWith('http://') || + source.startsWith('https://') || + source.startsWith('git@') || + source.startsWith('sso://') + ) { + return { + source, + type: 'git', + ref: args.ref, + autoUpdate: args.autoUpdate, + allowPreRelease: args.allowPreRelease, + }; + } else { + if (args.ref || args.autoUpdate) { + throw new Error( + '--ref and --auto-update are not applicable for local extensions.', + ); + } + try { + await stat(source); + return { + source, + type: 'local', + }; + } catch { + throw new Error('Install source not found.'); + } + } +} + +export function getExtensionId( config: ExtensionConfig, installMetadata?: ExtensionInstallMetadata, ): string { diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index fbbd5d900d..4af145b631 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -26,9 +26,21 @@ import { type MockedFunction, } from 'vitest'; import { type ExtensionUpdateAction } from '../state/extensions.js'; -import { ExtensionManager } from '../../config/extension-manager.js'; +import { + ExtensionManager, + inferInstallMetadata, +} from '../../config/extension-manager.js'; import { SettingScope } from '../../config/settings.js'; +vi.mock('../../config/extension-manager.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + inferInstallMetadata: vi.fn(), + }; +}); + import open from 'open'; vi.mock('open', () => ({ @@ -42,6 +54,8 @@ vi.mock('../../config/extensions/update.js', () => ({ const mockDisableExtension = vi.fn(); const mockEnableExtension = vi.fn(); +const mockInstallExtension = vi.fn(); +const mockUninstallExtension = vi.fn(); const mockGetExtensions = vi.fn(); const inactiveExt: GeminiCLIExtension = { @@ -102,6 +116,8 @@ describe('extensionsCommand', () => { Object.assign(actual, { enableExtension: mockEnableExtension, disableExtension: mockDisableExtension, + installOrUpdateExtension: mockInstallExtension, + uninstallExtension: mockUninstallExtension, getExtensions: mockGetExtensions, }); return actual; @@ -477,29 +493,189 @@ describe('extensionsCommand', () => { }); describe('when enableExtensionReloading is true', () => { - it('should include enable and disable subcommands', () => { + it('should include enable, disable, install, and uninstall subcommands', () => { const command = extensionsCommand(true); const subCommandNames = command.subCommands?.map((cmd) => cmd.name); expect(subCommandNames).toContain('enable'); expect(subCommandNames).toContain('disable'); + expect(subCommandNames).toContain('install'); + expect(subCommandNames).toContain('uninstall'); }); }); describe('when enableExtensionReloading is false', () => { - it('should not include enable and disable subcommands', () => { + it('should not include enable, disable, install, and uninstall subcommands', () => { const command = extensionsCommand(false); const subCommandNames = command.subCommands?.map((cmd) => cmd.name); expect(subCommandNames).not.toContain('enable'); expect(subCommandNames).not.toContain('disable'); + expect(subCommandNames).not.toContain('install'); + expect(subCommandNames).not.toContain('uninstall'); }); }); describe('when enableExtensionReloading is not provided', () => { - it('should not include enable and disable subcommands by default', () => { + it('should not include enable, disable, install, and uninstall subcommands by default', () => { const command = extensionsCommand(); const subCommandNames = command.subCommands?.map((cmd) => cmd.name); expect(subCommandNames).not.toContain('enable'); expect(subCommandNames).not.toContain('disable'); + expect(subCommandNames).not.toContain('install'); + expect(subCommandNames).not.toContain('uninstall'); + }); + }); + + describe('install', () => { + let installAction: SlashCommand['action']; + + beforeEach(() => { + installAction = extensionsCommand(true).subCommands?.find( + (cmd) => cmd.name === 'install', + )?.action; + + expect(installAction).not.toBeNull(); + + mockContext.invocation!.name = 'install'; + }); + + it('should show usage if no extension name is provided', async () => { + await installAction!(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Usage: /extensions install ', + }, + expect.any(Number), + ); + expect(mockInstallExtension).not.toHaveBeenCalled(); + }); + + it('should call installExtension and show success message', async () => { + const packageName = 'test-extension-package'; + vi.mocked(inferInstallMetadata).mockResolvedValue({ + source: packageName, + type: 'git', + }); + mockInstallExtension.mockResolvedValue({ name: packageName }); + await installAction!(mockContext, packageName); + expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); + expect(mockInstallExtension).toHaveBeenCalledWith({ + source: packageName, + type: 'git', + }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Installing extension from "${packageName}"...`, + }, + expect.any(Number), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Extension "${packageName}" installed successfully.`, + }, + expect.any(Number), + ); + }); + + it('should show error message on installation failure', async () => { + const packageName = 'failed-extension'; + const errorMessage = 'install failed'; + vi.mocked(inferInstallMetadata).mockResolvedValue({ + source: packageName, + type: 'git', + }); + mockInstallExtension.mockRejectedValue(new Error(errorMessage)); + + await installAction!(mockContext, packageName); + expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); + expect(mockInstallExtension).toHaveBeenCalledWith({ + source: packageName, + type: 'git', + }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: `Failed to install extension from "${packageName}": ${errorMessage}`, + }, + expect.any(Number), + ); + }); + + it('should show error message for invalid source', async () => { + const invalidSource = 'a;b'; + await installAction!(mockContext, invalidSource); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: `Invalid source: ${invalidSource}`, + }, + expect.any(Number), + ); + expect(mockInstallExtension).not.toHaveBeenCalled(); + }); + }); + + describe('uninstall', () => { + let uninstallAction: SlashCommand['action']; + + beforeEach(() => { + uninstallAction = extensionsCommand(true).subCommands?.find( + (cmd) => cmd.name === 'uninstall', + )?.action; + + expect(uninstallAction).not.toBeNull(); + + mockContext.invocation!.name = 'uninstall'; + }); + + it('should show usage if no extension name is provided', async () => { + await uninstallAction!(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Usage: /extensions uninstall ', + }, + expect.any(Number), + ); + expect(mockUninstallExtension).not.toHaveBeenCalled(); + }); + + it('should call uninstallExtension and show success message', async () => { + const extensionName = 'test-extension'; + await uninstallAction!(mockContext, extensionName); + expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Uninstalling extension "${extensionName}"...`, + }, + expect.any(Number), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Extension "${extensionName}" uninstalled successfully.`, + }, + expect.any(Number), + ); + }); + + it('should show error message on uninstallation failure', async () => { + const extensionName = 'failed-extension'; + const errorMessage = 'uninstall failed'; + mockUninstallExtension.mockRejectedValue(new Error(errorMessage)); + + await uninstallAction!(mockContext, extensionName); + expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: `Failed to uninstall extension "${extensionName}": ${errorMessage}`, + }, + expect.any(Number), + ); }); }); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 932eb55b2a..99ea05bccf 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -20,7 +20,10 @@ import { } from './types.js'; import open from 'open'; import process from 'node:process'; -import { ExtensionManager } from '../../config/extension-manager.js'; +import { + ExtensionManager, + inferInstallMetadata, +} from '../../config/extension-manager.js'; import { SettingScope } from '../../config/settings.js'; import { theme } from '../semantic-colors.js'; @@ -429,6 +432,135 @@ async function enableAction(context: CommandContext, args: string) { } } +async function installAction(context: CommandContext, args: string) { + const extensionLoader = context.services.config?.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + debugLogger.error( + `Cannot ${context.invocation?.name} extensions in this environment`, + ); + return; + } + + const source = args.trim(); + if (!source) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Usage: /extensions install `, + }, + Date.now(), + ); + return; + } + + // Validate that the source is either a valid URL or a valid file path. + let isValid = false; + try { + // Check if it's a valid URL. + new URL(source); + isValid = true; + } catch { + // If not a URL, check for characters that are disallowed in file paths + // and could be used for command injection. + if (!/[;&|`'"]/.test(source)) { + isValid = true; + } + } + + if (!isValid) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Invalid source: ${source}`, + }, + Date.now(), + ); + return; + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: `Installing extension from "${source}"...`, + }, + Date.now(), + ); + + try { + const installMetadata = await inferInstallMetadata(source); + const extension = + await extensionLoader.installOrUpdateExtension(installMetadata); + context.ui.addItem( + { + type: MessageType.INFO, + text: `Extension "${extension.name}" installed successfully.`, + }, + Date.now(), + ); + } catch (error) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to install extension from "${source}": ${getErrorMessage( + error, + )}`, + }, + Date.now(), + ); + } +} + +async function uninstallAction(context: CommandContext, args: string) { + const extensionLoader = context.services.config?.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + debugLogger.error( + `Cannot ${context.invocation?.name} extensions in this environment`, + ); + return; + } + + const name = args.trim(); + if (!name) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Usage: /extensions uninstall `, + }, + Date.now(), + ); + return; + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: `Uninstalling extension "${name}"...`, + }, + Date.now(), + ); + + try { + await extensionLoader.uninstallExtension(name, false); + context.ui.addItem( + { + type: MessageType.INFO, + text: `Extension "${name}" uninstalled successfully.`, + }, + Date.now(), + ); + } catch (error) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to uninstall extension "${name}": ${getErrorMessage( + error, + )}`, + }, + Date.now(), + ); + } +} + /** * Exported for testing. */ @@ -505,6 +637,23 @@ const enableCommand: SlashCommand = { completion: completeExtensionsAndScopes, }; +const installCommand: SlashCommand = { + name: 'install', + description: 'Install an extension from a git repo or local path', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: installAction, +}; + +const uninstallCommand: SlashCommand = { + name: 'uninstall', + description: 'Uninstall an extension', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: uninstallAction, + completion: completeExtensions, +}; + const exploreExtensionsCommand: SlashCommand = { name: 'explore', description: 'Open extensions page in your browser', @@ -526,7 +675,7 @@ export function extensionsCommand( enableExtensionReloading?: boolean, ): SlashCommand { const conditionalCommands = enableExtensionReloading - ? [disableCommand, enableCommand] + ? [disableCommand, enableCommand, installCommand, uninstallCommand] : []; return { name: 'extensions',