diff --git a/docs/extension-releasing.md b/docs/extension-releasing.md new file mode 100644 index 0000000000..86e212c653 --- /dev/null +++ b/docs/extension-releasing.md @@ -0,0 +1,121 @@ +# Extension Releasing + +There are two primary ways of releasing extensions to users: + +- [Git repository](#releasing-through-a-git-repository) +- [Github Releases](#releasing-through-github-releases) + +Git repository releases tend to be the simplest and most flexible approach, while GitHub releases can be more efficient on initial install as they are shipped as single archives instead of requiring a git clone which downloads each file individually. Github releases may also contain platform specific archives if you need to ship platform specific binary files. + +## Releasing through a git repository + +This is the most flexible and simple option. All you need to do us create a publicly accessible git repo (such as a public github repository) and then users can install your extension using `gemini extensions install `, or for a GitHub repository they can use the simplified `gemini extensions install /` format. They can optionally depend on a specific ref (branch/tag/commit) using the `--ref=` argument, this defaults to the default branch. + +Whenever commits are pushed to the ref that a user depends on, they will be prompted to update the extension. Note that this also allows for easy rollbacks, the HEAD commit is always treated as the latest version regardless of the actual version in the `gemini-extension.json` file. + +### Managing release channels using a git repository + +Users can depend on any ref from your git repo, such as a branch or tag, which allows you to manage multiple release channels. + +For instance, you can maintain a `stable` branch, which users can install this way `gemini extensions install --ref=stable`. Or, you could make this the default by treating your default branch as your stable release branch, and doing development in a different branch (for instance called `dev`). You can maintain as many branches or tags as you like, providing maximum flexibility for you and your users. + +Note that these `ref` arguments can be tags, branches, or even specific commits, which allows users to depend on a specific version of your extension. It is up to you how you want to manage your tags and branches. + +### Example releasing flow using a git repo + +While there are many options for how you want to manage releases using a git flow, we recommend treating your default branch as your "stable" release branch. This means that the default behavior for `gemini extensions install ` is to be on the stable release branch. + +Lets say you want to maintain three standard release channels, `stable`, `preview`, and `dev`. You would do all your standard development in the `dev` branch. When you are ready to do a preview release, you merge that branch into your `preview` branch. When you are ready to promote your preview branch to stable, you merge `preview` into your stable branch (which might be your default branch or a different branch). + +You can also cherry pick changes from one branch into another using `git cherry-pick`, but do note that this will result in your branches having a slightly divergent history from each other, unless you force push changes to your branches on each release to restore the history to a clean slate (which may not be possible for the default branch depending on your repository settings). If you plan on doing cherry picks, you may want to avoid having your default branch be the stable branch to avoid force-pushing to the default branch which should generally be avoided. + +## Releasing through Github releases + +Gemini CLI extensions can be distributed through [GitHub Releases](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases). This provides a faster and more reliable initial installation experience for users, as it avoids the need to clone the repository. + +Each release includes at least one archive file, which contains the full contents of the repo at the tag that it was linked to. Releases may also include [pre-built archives](#custom-pre-built-archives) if your extension requires some build step or has platform specific binaries attached to it. + +When checking for updates, gemini will just look for the latest release on github (you must mark it as such when creating the release), unless the user installed a specific release by passing `--ref=`. We do not at this time support opting in to pre-release releases or semver. + +### Custom pre-built archives + +Custom archives must be attached directly to the github release as assets and must be fully self-contained. This means they should include the entire extension, see [archive structure](#archive-structure). + +If your extension is platform-independent, you can provide a single generic asset. In this case, there should be only one asset attached to the release. + +Custom archives may also be used if you want to develop your extension within a larger repository, you can build an archive which has a different layout from the repo itself (for instance it might just be an archive of a subdirectory containing the extension). + +#### Platform specific archives + +To ensure Gemini CLI can automatically find the correct release asset for each platform, you must follow this naming convention. The CLI will search for assets in the following order: + +1. **Platform and Architecture-Specific:** `{platform}.{arch}.{name}.{extension}` +2. **Platform-Specific:** `{platform}.{name}.{extension}` +3. **Generic:** If only one asset is provided, it will be used as a generic fallback. + +- `{name}`: The name of your extension. +- `{platform}`: The operating system. Supported values are: + - `darwin` (macOS) + - `linux` + - `win32` (Windows) +- `{arch}`: The architecture. Supported values are: + - `x64` + - `arm64` +- `{extension}`: The file extension of the archive (e.g., `.tar.gz` or `.zip`). + +**Examples:** + +- `darwin.arm64.my-tool.tar.gz` (specific to Apple Silicon Macs) +- `darwin.my-tool.tar.gz` (for all Macs) +- `linux.x64.my-tool.tar.gz` +- `win32.my-tool.zip` + +#### Archive structure + +Archives must be fully contained extensions and have all the standard requirements - specifically the `gemini-extension.json` file must be at the root of the archive. + +The rest of the layout should look exactly the same as a typical extension, see [extensions.md](extension.md). + +#### Example GitHub Actions workflow + +Here is an example of a GitHub Actions workflow that builds and releases a Gemini CLI extension for multiple platforms: + +```yaml +name: Release Extension + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci + + - name: Build extension + run: npm run build + + - name: Create release assets + run: | + npm run package -- --platform=darwin --arch=arm64 + npm run package -- --platform=linux --arch=x64 + npm run package -- --platform=win32 --arch=x64 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + files: | + release/darwin.arm64.my-tool.tar.gz + release/linux.arm64.my-tool.tar.gz + release/win32.arm64.my-tool.zip +``` diff --git a/docs/extension.md b/docs/extension.md index 34595c027a..c00e76a514 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -36,11 +36,13 @@ gemini extensions uninstall gemini-cli-security Extensions are, by default, enabled across all workspaces. You can disable an extension entirely or for specific workspace. -For example, `gemini extensions disable extension-name` will disable the extension at the user level, so it will be disabled everywhere. `gemini extensions disable extension-name --scope=Workspace` will only disable the extension in the current workspace. +For example, `gemini extensions disable extension-name` will disable the extension at the user level, so it will be disabled everywhere. `gemini extensions disable extension-name --scope=workspace` will only disable the extension in the current workspace. ### Enabling an extension -You can re-enable extensions using `gemini extensions enable extension-name`. Note that if an extension is disabled at the user-level, enabling it at the workspace level will not do anything. +You can enable extensions using `gemini extensions enable extension-name`. You can also enable an extension for a specific workspace using `gemini extensions enable extension-name --scope=workspace` from within that workspace. + +This is useful if you have an extension disabled at the top-level and only enabled in specific places. ### Updating an extension @@ -105,8 +107,9 @@ The `gemini-extension.json` file contains the configuration for the extension. T - `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands. The name should be lowercase and use dashes instead of underscores or spaces. This is how users will refer to your extension in the CLI. Note that we expect this name to match the extension directory name. - `version`: The version of the extension. - `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence. + - Note that all MCP server configuration options are supported except for `trust`. - `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded. -- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. +- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence. diff --git a/docs/telemetry.md b/docs/telemetry.md index 59524fec1b..3a427ffcb7 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -307,6 +307,15 @@ for Gemini CLI: - `command` (string) - `subcommand` (string, if applicable) +- `gemini_cli.extension_enable`: This event occurs when an extension is enabled +- `gemini_cli.extension_install`: This event occurs when an extension is installed + - **Attributes**: + - `extension_name` (string) + - `extension_version` (string) + - `extension_source` (string) + - `status` (string) +- `gemini_cli.extension_uninstall`: This event occurs when an extension is uninstalled + ### Metrics Metrics are numerical measurements of behavior over time. The following metrics are collected for Gemini CLI: diff --git a/package-lock.json b/package-lock.json index 2737e4d97d..d0264a4f18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6281,6 +6281,23 @@ "node": ">=8" } }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/cockatiel": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", diff --git a/package.json b/package.json index 543c3502fb..58175db568 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,9 @@ "node-pty": "^1.0.0" }, "overrides": { - "wrap-ansi": "9.0.2" + "wrap-ansi": "9.0.2", + "cliui": { + "wrap-ansi": "7.0.0" + } } } diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts index 19727233c4..0a88ce08f2 100644 --- a/packages/cli/src/commands/extensions/disable.ts +++ b/packages/cli/src/commands/extensions/disable.ts @@ -11,12 +11,16 @@ import { getErrorMessage } from '../../utils/errors.js'; interface DisableArgs { name: string; - scope: SettingScope; + scope?: string; } export function handleDisable(args: DisableArgs) { try { - disableExtension(args.name, args.scope); + if (args.scope?.toLowerCase() === 'workspace') { + disableExtension(args.name, SettingScope.Workspace); + } else { + disableExtension(args.name, SettingScope.User); + } console.log( `Extension "${args.name}" successfully disabled for scope "${args.scope}".`, ); @@ -39,13 +43,28 @@ export const disableCommand: CommandModule = { describe: 'The scope to disable the extenison in.', type: 'string', default: SettingScope.User, - choices: [SettingScope.User, SettingScope.Workspace], }) - .check((_argv) => true), + .check((argv) => { + if ( + argv.scope && + !Object.values(SettingScope) + .map((s) => s.toLowerCase()) + .includes((argv.scope as string).toLowerCase()) + ) { + throw new Error( + `Invalid scope: ${argv.scope}. Please use one of ${Object.values( + SettingScope, + ) + .map((s) => s.toLowerCase()) + .join(', ')}.`, + ); + } + return true; + }), handler: (argv) => { handleDisable({ name: argv['name'] as string, - scope: argv['scope'] as SettingScope, + scope: argv['scope'] as string, }); }, }; diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts index 6bf3b71ff4..0691b86a60 100644 --- a/packages/cli/src/commands/extensions/enable.ts +++ b/packages/cli/src/commands/extensions/enable.ts @@ -11,13 +11,16 @@ import { SettingScope } from '../../config/settings.js'; interface EnableArgs { name: string; - scope?: SettingScope; + scope?: string; } export function handleEnable(args: EnableArgs) { try { - const scope = args.scope ? args.scope : SettingScope.User; - enableExtension(args.name, scope); + if (args.scope?.toLowerCase() === 'workspace') { + enableExtension(args.name, SettingScope.Workspace); + } else { + enableExtension(args.name, SettingScope.User); + } if (args.scope) { console.log( `Extension "${args.name}" successfully enabled for scope "${args.scope}".`, @@ -45,13 +48,28 @@ export const enableCommand: CommandModule = { describe: 'The scope to enable the extenison in. If not set, will be enabled in all scopes.', type: 'string', - choices: [SettingScope.User, SettingScope.Workspace], }) - .check((_argv) => true), + .check((argv) => { + if ( + argv.scope && + !Object.values(SettingScope) + .map((s) => s.toLowerCase()) + .includes((argv.scope as string).toLowerCase()) + ) { + throw new Error( + `Invalid scope: ${argv.scope}. Please use one of ${Object.values( + SettingScope, + ) + .map((s) => s.toLowerCase()) + .join(', ')}.`, + ); + } + return true; + }), handler: (argv) => { handleEnable({ name: argv['name'] as string, - scope: argv['scope'] as SettingScope, + scope: argv['scope'] as string, }); }, }; 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 aae1897974..30c658c3d4 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -5,10 +5,8 @@ */ import type { CommandModule } from 'yargs'; -import { - installExtension, - type ExtensionInstallMetadata, -} from '../../config/extension.js'; +import { installExtension } from '../../config/extension.js'; +import type { ExtensionInstallMetadata } from '@google/gemini-cli-core'; import { getErrorMessage } from '../../utils/errors.js'; @@ -16,12 +14,12 @@ interface InstallArgs { source?: string; path?: string; ref?: string; + autoUpdate?: boolean; } export async function handleInstall(args: InstallArgs) { try { let installMetadata: ExtensionInstallMetadata; - if (args.source) { const { source } = args; if ( @@ -34,6 +32,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.`); @@ -42,6 +41,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. @@ -57,7 +57,7 @@ export async function handleInstall(args: InstallArgs) { } export const installCommand: CommandModule = { - command: 'install [source]', + command: 'install [] [--path] [--ref] [--auto-update]', describe: 'Installs an extension from a git repository URL or a local path.', builder: (yargs) => yargs @@ -73,8 +73,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.'); @@ -86,6 +91,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/link.ts b/packages/cli/src/commands/extensions/link.ts index 034e94d1c8..42ef4d33ce 100644 --- a/packages/cli/src/commands/extensions/link.ts +++ b/packages/cli/src/commands/extensions/link.ts @@ -5,10 +5,8 @@ */ import type { CommandModule } from 'yargs'; -import { - installExtension, - type ExtensionInstallMetadata, -} from '../../config/extension.js'; +import { installExtension } from '../../config/extension.js'; +import type { ExtensionInstallMetadata } from '@google/gemini-cli-core'; import { getErrorMessage } from '../../utils/errors.js'; diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index ff93b79723..d7c131962b 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -9,7 +9,7 @@ import { uninstallExtension } from '../../config/extension.js'; import { getErrorMessage } from '../../utils/errors.js'; interface UninstallArgs { - name: string; + name: string; // can be extension name or source URL. } export async function handleUninstall(args: UninstallArgs) { @@ -28,7 +28,7 @@ export const uninstallCommand: CommandModule = { builder: (yargs) => yargs .positional('name', { - describe: 'The name of the extension to uninstall.', + describe: 'The name or source path of the extension to uninstall.', type: 'string', }) .check((argv) => { diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 03fa4aa7f3..7a39f78add 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; @@ -31,13 +35,56 @@ export async function handleUpdate(args: UpdateArgs) { allExtensions.map((e) => e.config.name), workingDir, ); - + if (args.name) { + try { + 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 + ) { + console.log( + `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, + ); + } else { + console.log(`Extension "${args.name}" is already up to date.`); + } + } catch (error) { + console.error(getErrorMessage(error)); + } + } if (args.all) { try { let updateInfos = await updateAllUpdatableExtensions( workingDir, extensions, - await checkForAllExtensionUpdates(extensions, (_) => {}), + await checkForAllExtensionUpdates(extensions, new Map(), (_) => {}), () => {}, ); updateInfos = updateInfos.filter( @@ -52,32 +99,10 @@ export async function handleUpdate(args: UpdateArgs) { console.error(getErrorMessage(error)); } } - 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, - () => {}, - ); - if ( - updatedExtensionInfo.originalVersion !== - updatedExtensionInfo.updatedVersion - ) { - console.log( - `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, - ); - } else { - console.log(`Extension "${args.name}" already up to date.`); - } - } catch (error) { - console.error(getErrorMessage(error)); - } } export const updateCommand: CommandModule = { - command: 'update [--all] [name]', + command: 'update [] [--all]', describe: 'Updates all extensions or a named extension to the latest version.', builder: (yargs) => diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 0453d4319e..895f4d2769 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -12,8 +12,6 @@ import { EXTENSIONS_CONFIG_FILENAME, INSTALL_METADATA_FILENAME, annotateActiveExtensions, - checkForAllExtensionUpdates, - checkForExtensionUpdate, disableExtension, enableExtension, installExtension, @@ -21,22 +19,18 @@ import { loadExtensions, performWorkspaceExtensionMigration, uninstallExtension, - updateExtension, type Extension, - type ExtensionInstallMetadata, } from './extension.js'; import { GEMINI_DIR, type GeminiCLIExtension, - type MCPServerConfig, - ClearcutLogger, - type Config, ExtensionUninstallEvent, + ExtensionEnableEvent, } 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 = { @@ -59,9 +53,9 @@ vi.mock('simple-git', () => ({ })); vi.mock('os', async (importOriginal) => { - const os = await importOriginal(); + const mockedOs = await importOriginal(); return { - ...os, + ...mockedOs, homedir: vi.fn(), }; }); @@ -74,20 +68,18 @@ vi.mock('./trustedFolders.js', async (importOriginal) => { }; }); +const mockLogExtensionEnable = vi.hoisted(() => vi.fn()); +const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn()); +const mockLogExtensionUninstall = vi.hoisted(() => 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(), + logExtensionEnable: mockLogExtensionEnable, + logExtensionInstallEvent: mockLogExtensionInstallEvent, + logExtensionUninstall: mockLogExtensionUninstall, + ExtensionEnableEvent: vi.fn(), ExtensionInstallEvent: vi.fn(), ExtensionUninstallEvent: vi.fn(), }; @@ -377,6 +369,90 @@ describe('extension tests', () => { expect(serverConfig.env!.MISSING_VAR).toBe('$UNDEFINED_ENV_VAR'); expect(serverConfig.env!.MISSING_VAR_BRACES).toBe('${ALSO_UNDEFINED}'); }); + + it('should skip extensions with invalid JSON and log a warning', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Good extension + createExtension({ + extensionsDir: userExtensionsDir, + name: 'good-ext', + version: '1.0.0', + }); + + // Bad extension + const badExtDir = path.join(userExtensionsDir, 'bad-ext'); + fs.mkdirSync(badExtDir); + const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); + fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed + + const extensions = loadExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].config.name).toBe('good-ext'); + expect(consoleSpy).toHaveBeenCalledOnce(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`, + ), + ); + + consoleSpy.mockRestore(); + }); + + it('should skip extensions with missing name and log a warning', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Good extension + createExtension({ + extensionsDir: userExtensionsDir, + name: 'good-ext', + version: '1.0.0', + }); + + // Bad extension + const badExtDir = path.join(userExtensionsDir, 'bad-ext-no-name'); + fs.mkdirSync(badExtDir); + const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); + fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' })); + + const extensions = loadExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].config.name).toBe('good-ext'); + expect(consoleSpy).toHaveBeenCalledOnce(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, + ), + ); + + consoleSpy.mockRestore(); + }); + + it('should filter trust out of mcp servers', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + trust: true, + }, + }, + }); + + const extensions = loadExtensions(); + expect(extensions).toHaveLength(1); + const loadedConfig = extensions[0].config; + expect(loadedConfig.mcpServers?.['test-server'].trust).toBeUndefined(); + }); }); describe('annotateActiveExtensions', () => { @@ -455,6 +531,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', () => { @@ -496,15 +652,49 @@ describe('extension tests', () => { it('should throw an error and cleanup if gemini-extension.json is missing', async () => { const sourceExtDir = path.join(tempHomeDir, 'bad-extension'); fs.mkdirSync(sourceExtDir, { recursive: true }); + const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); + + await expect( + installExtension({ source: sourceExtDir, type: 'local' }), + ).rejects.toThrow(`Configuration file not found at ${configPath}`); + + const targetExtDir = path.join(userExtensionsDir, 'bad-extension'); + expect(fs.existsSync(targetExtDir)).toBe(false); + }); + + it('should throw an error for invalid JSON in gemini-extension.json', async () => { + const sourceExtDir = path.join(tempHomeDir, 'bad-json-ext'); + fs.mkdirSync(sourceExtDir, { recursive: true }); + const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); + fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON await expect( installExtension({ source: sourceExtDir, type: 'local' }), ).rejects.toThrow( - `Invalid extension at ${sourceExtDir}. Please make sure it has a valid gemini-extension.json file.`, + new RegExp( + `^Failed to load extension config from ${configPath.replace( + /\\/g, + '\\\\', + )}`, + ), ); + }); - const targetExtDir = path.join(userExtensionsDir, 'bad-extension'); - expect(fs.existsSync(targetExtDir)).toBe(false); + it('should throw an error for missing name in gemini-extension.json', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'missing-name-ext', + version: '1.0.0', + }); + const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); + // Overwrite with invalid config + fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' })); + + await expect( + installExtension({ source: sourceExtDir, type: 'local' }), + ).rejects.toThrow( + `Invalid configuration in ${configPath}: missing "name"`, + ); }); it('should install an extension from a git URL', async () => { @@ -570,8 +760,7 @@ describe('extension tests', () => { await installExtension({ source: sourceExtDir, type: 'local' }); - const logger = ClearcutLogger.getInstance({} as Config); - expect(logger?.logExtensionInstallEvent).toHaveBeenCalled(); + expect(mockLogExtensionInstallEvent).toHaveBeenCalled(); }); it('should show users information on their mcp server when installing', async () => { @@ -600,16 +789,11 @@ describe('extension tests', () => { ).resolves.toBe('my-local-extension'); expect(consoleInfoSpy).toHaveBeenCalledWith( - 'This extension will run the following MCP servers: ', - ); - expect(consoleInfoSpy).toHaveBeenCalledWith( - ' * test-server (local): a local mcp server', - ); - expect(consoleInfoSpy).toHaveBeenCalledWith( - ' * test-server-2 (remote): a remote mcp server', - ); - expect(consoleInfoSpy).toHaveBeenCalledWith( - 'The extension will append info to your gemini.md context', + `Extensions may introduce unexpected behavior. +Ensure you have investigated the extension source and trust the author. +This extension will run the following MCP servers: + * test-server (local): node server.js + * test-server-2 (remote): https://google.com`, ); }); @@ -633,7 +817,7 @@ describe('extension tests', () => { ).resolves.toBe('my-local-extension'); expect(mockQuestion).toHaveBeenCalledWith( - expect.stringContaining('Do you want to continue? (y/n)'), + expect.stringContaining('Do you want to continue? [Y/n]: '), expect.any(Function), ); }); @@ -658,11 +842,37 @@ describe('extension tests', () => { ).rejects.toThrow('Installation cancelled by user.'); expect(mockQuestion).toHaveBeenCalledWith( - expect.stringContaining('Do you want to continue? (y/n)'), + expect.stringContaining('Do you want to continue? [Y/n]: '), expect.any(Function), ); }); + 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, @@ -716,7 +926,7 @@ describe('extension tests', () => { it('should throw an error if the extension does not exist', async () => { await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow( - 'Extension "nonexistent-extension" not found.', + 'Extension not found.', ); }); @@ -729,11 +939,47 @@ describe('extension tests', () => { await uninstallExtension('my-local-extension'); - const logger = ClearcutLogger.getInstance({} as Config); - expect(logger?.logExtensionUninstallEvent).toHaveBeenCalledWith( - new ExtensionUninstallEvent('my-local-extension', 'success'), + expect(mockLogExtensionUninstall).toHaveBeenCalled(); + expect(ExtensionUninstallEvent).toHaveBeenCalledWith( + 'my-local-extension', + 'success', ); }); + + it('should uninstall an extension by its source URL', async () => { + const gitUrl = 'https://github.com/google/gemini-sql-extension.git'; + const sourceExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'gemini-sql-extension', + version: '1.0.0', + installMetadata: { + source: gitUrl, + type: 'git', + }, + }); + + await uninstallExtension(gitUrl); + + expect(fs.existsSync(sourceExtDir)).toBe(false); + expect(mockLogExtensionUninstall).toHaveBeenCalled(); + expect(ExtensionUninstallEvent).toHaveBeenCalledWith( + 'gemini-sql-extension', + 'success', + ); + }); + + it('should fail to uninstall by URL if an extension has no install metadata', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'no-metadata-extension', + version: '1.0.0', + // No installMetadata provided + }); + + await expect( + uninstallExtension('https://github.com/google/no-metadata-extension'), + ).rejects.toThrow('Extension not found.'); + }); }); describe('performWorkspaceExtensionMigration', () => { @@ -881,377 +1127,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('checkForExtensionUpdate', () => { - 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 result = await checkForExtensionUpdate(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 result = await checkForExtensionUpdate(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', - }); - const extension = annotateActiveExtensions( - [ - loadExtension({ - extensionDir, - workspaceDir: tempWorkspaceDir, - })!, - ], - [], - process.cwd(), - )[0]; - - const result = await checkForExtensionUpdate(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 result = await checkForExtensionUpdate(extension); - expect(result).toBe(ExtensionUpdateState.ERROR); - }); - }); - describe('disableExtension', () => { it('should disable an extension at the user scope', () => { disableExtension('my-extension', SettingScope.User); @@ -1351,42 +1226,25 @@ describe('extension tests', () => { expect(activeExtensions).toHaveLength(1); expect(activeExtensions[0].name).toBe('ext1'); }); + + it('should log an enable event', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + disableExtension('ext1', SettingScope.Workspace); + enableExtension('ext1', SettingScope.Workspace); + + expect(mockLogExtensionEnable).toHaveBeenCalled(); + expect(ExtensionEnableEvent).toHaveBeenCalledWith( + 'ext1', + SettingScope.Workspace, + ); + }); }); }); -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 89dce696f2..523adf8e81 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -7,26 +7,32 @@ import type { MCPServerConfig, GeminiCLIExtension, + ExtensionInstallMetadata, } from '@google/gemini-cli-core'; import { GEMINI_DIR, Storage, - ClearcutLogger, Config, ExtensionInstallEvent, ExtensionUninstallEvent, + ExtensionEnableEvent, + logExtensionEnable, + logExtensionInstallEvent, + logExtensionUninstall, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { simpleGit } from 'simple-git'; import { SettingScope, loadSettings } from '../config/settings.js'; import { getErrorMessage } from '../utils/errors.js'; 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, + downloadFromGitHubRelease, +} from './extensions/github.js'; import type { LoadExtensionContext } from './extensions/variableSchema.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; @@ -50,12 +56,6 @@ export interface ExtensionConfig { excludeTools?: string[]; } -export interface ExtensionInstallMetadata { - source: string; - type: 'git' | 'local' | 'link'; - ref?: string; -} - export interface ExtensionUpdateInfo { name: string; originalVersion: string; @@ -100,7 +100,7 @@ export function getWorkspaceExtensions(workspaceDir: string): Extension[] { return loadExtensionsFromDir(workspaceDir); } -async function copyExtension( +export async function copyExtension( source: string, destination: string, ): Promise { @@ -126,16 +126,18 @@ export async function performWorkspaceExtensionMigration( return failedInstallNames; } -function getClearcutLogger(cwd: string) { +function getTelemetryConfig(cwd: string) { + const settings = loadSettings(cwd); const config = new Config({ + telemetry: settings.merged.telemetry, + interactive: false, sessionId: randomUUID(), targetDir: cwd, cwd, model: '', debugMode: false, }); - const logger = ClearcutLogger.getInstance(config); - return logger; + return config; } export function loadExtensions( @@ -214,34 +216,23 @@ export function loadExtension(context: LoadExtensionContext): Extension | null { effectiveExtensionPath = installMetadata.source; } - const configFilePath = path.join( - effectiveExtensionPath, - EXTENSIONS_CONFIG_FILENAME, - ); - if (!fs.existsSync(configFilePath)) { - console.error( - `Warning: extension directory ${effectiveExtensionPath} does not contain a config file ${configFilePath}.`, - ); - return null; - } - try { - const configContent = fs.readFileSync(configFilePath, 'utf-8'); - let config = recursivelyHydrateStrings(JSON.parse(configContent), { - extensionPath: extensionDir, - workspacePath: workspaceDir, - '/': path.sep, - pathSeparator: path.sep, - }) as unknown as ExtensionConfig; - if (!config.name || !config.version) { - console.error( - `Invalid extension config in ${configFilePath}: missing name or version.`, - ); - return null; - } + let config = loadExtensionConfig({ + extensionDir: effectiveExtensionPath, + workspaceDir, + }); config = resolveEnvVarsInObject(config); + if (config.mcpServers) { + config.mcpServers = Object.fromEntries( + Object.entries(config.mcpServers).map(([key, value]) => [ + key, + filterMcpConfig(value), + ]), + ); + } + const contextFiles = getContextFileNames(config) .map((contextFileName) => path.join(effectiveExtensionPath, contextFileName), @@ -256,7 +247,7 @@ export function loadExtension(context: LoadExtensionContext): Extension | null { }; } catch (e) { console.error( - `Warning: error parsing extension config in ${configFilePath}: ${getErrorMessage( + `Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage( e, )}`, ); @@ -264,7 +255,13 @@ export function loadExtension(context: LoadExtensionContext): Extension | null { } } -function loadInstallMetadata( +function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { trust, ...rest } = original; + return Object.freeze(rest); +} + +export function loadInstallMetadata( extensionDir: string, ): ExtensionInstallMetadata | undefined { const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME); @@ -301,7 +298,6 @@ export function annotateActiveExtensions( const manager = new ExtensionEnablementManager( ExtensionStorage.getUserExtensionsDir(), ); - const annotatedExtensions: GeminiCLIExtension[] = []; if (enabledExtensionNames.length === 0) { return extensions.map((extension) => ({ @@ -309,9 +305,7 @@ export function annotateActiveExtensions( version: extension.config.version, isActive: manager.isEnabled(extension.config.name, workspaceDir), path: extension.path, - source: extension.installMetadata?.source, - type: extension.installMetadata?.type, - ref: extension.installMetadata?.ref, + installMetadata: extension.installMetadata, })); } @@ -328,9 +322,7 @@ export function annotateActiveExtensions( version: extension.config.version, isActive: false, path: extension.path, - source: extension.installMetadata?.source, - type: extension.installMetadata?.type, - ref: extension.installMetadata?.ref, + installMetadata: extension.installMetadata, })); } @@ -349,6 +341,7 @@ export function annotateActiveExtensions( version: extension.config.version, isActive, path: extension.path, + installMetadata: extension.installMetadata, }); } @@ -359,47 +352,10 @@ export function annotateActiveExtensions( return annotatedExtensions; } -/** - * Clones a Git repository to a specified local path. - * @param installMetadata The metadata for the extension to install. - * @param destination The destination path to clone the repository to. - */ -async function cloneFromGit( - installMetadata: ExtensionInstallMetadata, - destination: string, -): Promise { - try { - const git = simpleGit(destination); - await git.clone(installMetadata.source, './', ['--depth', '1']); - - const remotes = await git.getRemotes(true); - if (remotes.length === 0) { - throw new Error( - `Unable to find any remotes for repo ${installMetadata.source}`, - ); - } - - const refToFetch = installMetadata.ref || 'HEAD'; - - await git.fetch(remotes[0].name, refToFetch); - - // After fetching, checkout FETCH_HEAD to get the content of the fetched ref. - // This results in a detached HEAD state, which is fine for this purpose. - await git.checkout('FETCH_HEAD'); - } catch (error) { - throw new Error( - `Failed to clone Git repository from ${installMetadata.source}`, - { - cause: error, - }, - ); - } -} - /** * Asks users a prompt and awaits for a y/n response * @param prompt A yes/no prompt to ask the user - * @returns Whether or not the user answers 'y' (yes) + * @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter. */ async function promptForContinuation(prompt: string): Promise { const readline = await import('node:readline'); @@ -411,7 +367,7 @@ async function promptForContinuation(prompt: string): Promise { return new Promise((resolve) => { rl.question(prompt, (answer) => { rl.close(); - resolve(answer.toLowerCase() === 'y'); + resolve(['y', ''].includes(answer.trim().toLowerCase())); }); }); } @@ -421,7 +377,7 @@ export async function installExtension( askConsent: boolean = false, cwd: string = process.cwd(), ): Promise { - const logger = getClearcutLogger(cwd); + const telemetryConfig = getTelemetryConfig(cwd); let newExtensionConfig: ExtensionConfig | null = null; let localSourcePath: string | undefined; @@ -445,9 +401,22 @@ export async function installExtension( let tempDir: string | undefined; - if (installMetadata.type === 'git') { + if ( + installMetadata.type === 'git' || + installMetadata.type === 'github-release' + ) { tempDir = await ExtensionStorage.createTmpDir(); - await cloneFromGit(installMetadata, tempDir); + try { + const result = await downloadFromGitHubRelease( + installMetadata, + tempDir, + ); + installMetadata.type = result.type; + installMetadata.releaseTag = result.tagName; + } catch (_error) { + await cloneFromGit(installMetadata, tempDir); + installMetadata.type = 'git'; + } localSourcePath = tempDir; } else if ( installMetadata.type === 'local' || @@ -459,15 +428,10 @@ export async function installExtension( } try { - newExtensionConfig = await loadExtensionConfig({ + newExtensionConfig = loadExtensionConfig({ extensionDir: localSourcePath, workspaceDir: cwd, }); - if (!newExtensionConfig) { - throw new Error( - `Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`, - ); - } const newExtensionName = newExtensionConfig.name; const extensionStorage = new ExtensionStorage(newExtensionName); @@ -488,7 +452,11 @@ export async function installExtension( } await fs.promises.mkdir(destinationPath, { recursive: true }); - if (installMetadata.type === 'local' || installMetadata.type === 'git') { + if ( + installMetadata.type === 'local' || + installMetadata.type === 'git' || + installMetadata.type === 'github-release' + ) { await copyExtension(localSourcePath, destinationPath); } @@ -504,7 +472,8 @@ export async function installExtension( } } - logger?.logExtensionInstallEvent( + logExtensionInstallEvent( + telemetryConfig, new ExtensionInstallEvent( newExtensionConfig!.name, newExtensionConfig!.version, @@ -519,12 +488,17 @@ export async function installExtension( // Attempt to load config from the source path even if installation fails // to get the name and version for logging. if (!newExtensionConfig && localSourcePath) { - newExtensionConfig = await loadExtensionConfig({ - extensionDir: localSourcePath, - workspaceDir: cwd, - }); + try { + newExtensionConfig = loadExtensionConfig({ + extensionDir: localSourcePath, + workspaceDir: cwd, + }); + } catch { + // Ignore error, this is just for logging. + } } - logger?.logExtensionInstallEvent( + logExtensionInstallEvent( + telemetryConfig, new ExtensionInstallEvent( newExtensionConfig?.name ?? '', newExtensionConfig?.version ?? '', @@ -537,33 +511,49 @@ export async function installExtension( } async function requestConsent(extensionConfig: ExtensionConfig) { + const output: string[] = []; const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {}); + output.push('Extensions may introduce unexpected behavior.'); + output.push( + 'Ensure you have investigated the extension source and trust the author.', + ); + if (mcpServerEntries.length) { - console.info('This extension will run the following MCP servers: '); + output.push('This extension will run the following MCP servers:'); for (const [key, mcpServer] of mcpServerEntries) { const isLocal = !!mcpServer.command; - console.info( - ` * ${key} (${isLocal ? 'local' : 'remote'}): ${mcpServer.description}`, - ); + const source = + mcpServer.httpUrl ?? + `${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`; + output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`); } - console.info('The extension will append info to your gemini.md context'); - - const shouldContinue = await promptForContinuation( - 'Do you want to continue? (y/n): ', + } + if (extensionConfig.contextFileName) { + output.push( + `This extension will append info to your gemini.md context using ${extensionConfig.contextFileName}`, ); - if (!shouldContinue) { - throw new Error('Installation cancelled by user.'); - } + } + if (extensionConfig.excludeTools) { + output.push( + `This extension will exclude the following core tools: ${extensionConfig.excludeTools}`, + ); + } + console.info(output.join('\n')); + const shouldContinue = await promptForContinuation( + 'Do you want to continue? [Y/n]: ', + ); + if (!shouldContinue) { + throw new Error('Installation cancelled by user.'); } } -export async function loadExtensionConfig( +export function loadExtensionConfig( context: LoadExtensionContext, -): Promise { +): ExtensionConfig { const { extensionDir, workspaceDir } = context; const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); if (!fs.existsSync(configFilePath)) { - return null; + throw new Error(`Configuration file not found at ${configFilePath}`); } try { const configContent = fs.readFileSync(configFilePath, 'utf-8'); @@ -574,26 +564,35 @@ export async function loadExtensionConfig( pathSeparator: path.sep, }) as unknown as ExtensionConfig; if (!config.name || !config.version) { - return null; + throw new Error( + `Invalid configuration in ${configFilePath}: missing ${!config.name ? '"name"' : '"version"'}`, + ); } return config; - } catch (_) { - return null; + } catch (e) { + throw new Error( + `Failed to load extension config from ${configFilePath}: ${getErrorMessage( + e, + )}`, + ); } } export async function uninstallExtension( - extensionName: string, + extensionIdentifier: string, cwd: string = process.cwd(), ): Promise { - const logger = getClearcutLogger(cwd); + const telemetryConfig = getTelemetryConfig(cwd); const installedExtensions = loadUserExtensions(); - if ( - !installedExtensions.some( - (installed) => installed.config.name === extensionName, - ) - ) { - throw new Error(`Extension "${extensionName}" not found.`); + const extensionName = installedExtensions.find( + (installed) => + installed.config.name.toLowerCase() === + extensionIdentifier.toLowerCase() || + installed.installMetadata?.source.toLowerCase() === + extensionIdentifier.toLowerCase(), + )?.config.name; + if (!extensionName) { + throw new Error(`Extension not found.`); } const manager = new ExtensionEnablementManager( ExtensionStorage.getUserExtensionsDir(), @@ -605,7 +604,8 @@ export async function uninstallExtension( recursive: true, force: true, }); - logger?.logExtensionUninstallEvent( + logExtensionUninstall( + telemetryConfig, new ExtensionUninstallEvent(extensionName, 'success'), ); } @@ -618,6 +618,9 @@ export function toOutputString(extension: Extension): string { if (extension.installMetadata.ref) { output += `\n Ref: ${extension.installMetadata.ref}`; } + if (extension.installMetadata.releaseTag) { + output += `\n Release tag: ${extension.installMetadata.releaseTag}`; + } } if (extension.contextFiles.length > 0) { output += `\n Context files:`; @@ -640,83 +643,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 { - if (!extension.type) { - setExtensionUpdateState(ExtensionUpdateState.ERROR); - throw new Error( - `Extension ${extension.name} cannot be updated, type is unknown.`, - ); - } - if (extension.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( - { - source: extension.source!, - type: extension.type, - ref: extension.ref, - }, - 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, @@ -746,101 +672,6 @@ 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) { - finalState.set(extension.name, await checkForExtensionUpdate(extension)); - } - setExtensionsUpdateState(finalState); - return finalState; -} - -export async function checkForExtensionUpdate( - extension: GeminiCLIExtension, -): Promise { - if (extension.type !== 'git') { - return ExtensionUpdateState.NOT_UPDATABLE; - } - - try { - const git = simpleGit(extension.path); - const remotes = await git.getRemotes(true); - if (remotes.length === 0) { - console.error('No git remotes found.'); - return ExtensionUpdateState.ERROR; - } - const remoteUrl = remotes[0].refs.fetch; - if (!remoteUrl) { - console.error(`No fetch URL found for git remote ${remotes[0].name}.`); - return ExtensionUpdateState.ERROR; - } - - // Determine the ref to check on the remote. - const refToCheck = extension.ref || 'HEAD'; - - const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); - - if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { - console.error(`Git ref ${refToCheck} not found.`); - return ExtensionUpdateState.ERROR; - } - - const remoteHash = lsRemoteOutput.split('\t')[0]; - const localHash = await git.revparse(['HEAD']); - - if (!remoteHash) { - console.error( - `Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`, - ); - return ExtensionUpdateState.ERROR; - } else if (remoteHash === localHash) { - return ExtensionUpdateState.UP_TO_DATE; - } else { - return ExtensionUpdateState.UPDATE_AVAILABLE; - } - } catch (error) { - console.error( - `Failed to check for updates for extension "${ - extension.name - }": ${getErrorMessage(error)}`, - ); - return ExtensionUpdateState.ERROR; - } + const config = getTelemetryConfig(cwd); + logExtensionEnable(config, new ExtensionEnableEvent(name, scope)); } diff --git a/packages/cli/src/config/extensions/extensionEnablement.test.ts b/packages/cli/src/config/extensions/extensionEnablement.test.ts index 874b68b8c9..8923f6e023 100644 --- a/packages/cli/src/config/extensions/extensionEnablement.test.ts +++ b/packages/cli/src/config/extensions/extensionEnablement.test.ts @@ -4,11 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; -import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ExtensionEnablementManager } from './extensionEnablement.js'; +import { ExtensionEnablementManager, Override } from './extensionEnablement.js'; // Helper to create a temporary directory for testing function createTestDir() { @@ -48,7 +48,7 @@ describe('ExtensionEnablementManager', () => { }); it('should enable a path based on an override rule', () => { - manager.disable('ext-test', true, '*'); // Disable globally + manager.disable('ext-test', true, '/'); manager.enable('ext-test', true, '/home/user/projects/'); expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( true, @@ -56,7 +56,7 @@ describe('ExtensionEnablementManager', () => { }); it('should disable a path based on a disable override rule', () => { - manager.enable('ext-test', true, '*'); // Enable globally + manager.enable('ext-test', true, '/'); manager.disable('ext-test', true, '/home/user/projects/'); expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( false, @@ -78,65 +78,253 @@ describe('ExtensionEnablementManager', () => { false, ); }); + + it('should handle', () => { + manager.enable('ext-test', true, '/home/user/projects'); + manager.disable('ext-test', false, '/home/user/projects/my-app'); + expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( + false, + ); + expect( + manager.isEnabled('ext-test', '/home/user/projects/something-else'), + ).toBe(true); + }); }); describe('includeSubdirs', () => { it('should add a glob when enabling with includeSubdirs', () => { manager.enable('ext-test', true, '/path/to/dir'); const config = manager.readConfig(); - expect(config['ext-test'].overrides).toContain('/path/to/dir*'); + expect(config['ext-test'].overrides).toContain('/path/to/dir/*'); }); it('should not add a glob when enabling without includeSubdirs', () => { manager.enable('ext-test', false, '/path/to/dir'); const config = manager.readConfig(); - expect(config['ext-test'].overrides).toContain('/path/to/dir'); - expect(config['ext-test'].overrides).not.toContain('/path/to/dir*'); + expect(config['ext-test'].overrides).toContain('/path/to/dir/'); + expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*'); }); it('should add a glob when disabling with includeSubdirs', () => { manager.disable('ext-test', true, '/path/to/dir'); const config = manager.readConfig(); - expect(config['ext-test'].overrides).toContain('!/path/to/dir*'); + expect(config['ext-test'].overrides).toContain('!/path/to/dir/*'); }); it('should remove conflicting glob rule when enabling without subdirs', () => { manager.enable('ext-test', true, '/path/to/dir'); // Adds /path/to/dir* manager.enable('ext-test', false, '/path/to/dir'); // Should remove the glob const config = manager.readConfig(); - expect(config['ext-test'].overrides).toContain('/path/to/dir'); - expect(config['ext-test'].overrides).not.toContain('/path/to/dir*'); + expect(config['ext-test'].overrides).toContain('/path/to/dir/'); + expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*'); }); it('should remove conflicting non-glob rule when enabling with subdirs', () => { manager.enable('ext-test', false, '/path/to/dir'); // Adds /path/to/dir manager.enable('ext-test', true, '/path/to/dir'); // Should remove the non-glob const config = manager.readConfig(); - expect(config['ext-test'].overrides).toContain('/path/to/dir*'); - expect(config['ext-test'].overrides).not.toContain('/path/to/dir'); + expect(config['ext-test'].overrides).toContain('/path/to/dir/*'); + expect(config['ext-test'].overrides).not.toContain('/path/to/dir/'); }); it('should remove conflicting rules when disabling', () => { manager.enable('ext-test', true, '/path/to/dir'); // enabled with glob manager.disable('ext-test', false, '/path/to/dir'); // disabled without const config = manager.readConfig(); - expect(config['ext-test'].overrides).toContain('!/path/to/dir'); - expect(config['ext-test'].overrides).not.toContain('/path/to/dir*'); + expect(config['ext-test'].overrides).toContain('!/path/to/dir/'); + expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*'); }); it('should correctly evaluate isEnabled with subdirs', () => { - manager.disable('ext-test', true, '*'); + manager.disable('ext-test', true, '/'); manager.enable('ext-test', true, '/path/to/dir'); - expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(true); - expect(manager.isEnabled('ext-test', '/path/to/dir/sub')).toBe(true); - expect(manager.isEnabled('ext-test', '/path/to/another')).toBe(false); + expect(manager.isEnabled('ext-test', '/path/to/dir/')).toBe(true); + expect(manager.isEnabled('ext-test', '/path/to/dir/sub/')).toBe(true); + expect(manager.isEnabled('ext-test', '/path/to/another/')).toBe(false); }); it('should correctly evaluate isEnabled without subdirs', () => { - manager.disable('ext-test', true, '*'); + manager.disable('ext-test', true, '/*'); manager.enable('ext-test', false, '/path/to/dir'); expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(true); expect(manager.isEnabled('ext-test', '/path/to/dir/sub')).toBe(false); }); }); + + describe('pruning child rules', () => { + it('should remove child rules when enabling a parent with subdirs', () => { + // Pre-existing rules for children + manager.enable('ext-test', false, '/path/to/dir/subdir1'); + manager.disable('ext-test', true, '/path/to/dir/subdir2'); + manager.enable('ext-test', false, '/path/to/another/dir'); + + // Enable the parent directory + manager.enable('ext-test', true, '/path/to/dir'); + + const config = manager.readConfig(); + const overrides = config['ext-test'].overrides; + + // The new parent rule should be present + expect(overrides).toContain(`/path/to/dir/*`); + + // Child rules should be removed + expect(overrides).not.toContain('/path/to/dir/subdir1/'); + expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`); + + // Unrelated rules should remain + expect(overrides).toContain('/path/to/another/dir/'); + }); + + it('should remove child rules when disabling a parent with subdirs', () => { + // Pre-existing rules for children + manager.enable('ext-test', false, '/path/to/dir/subdir1'); + manager.disable('ext-test', true, '/path/to/dir/subdir2'); + manager.enable('ext-test', false, '/path/to/another/dir'); + + // Disable the parent directory + manager.disable('ext-test', true, '/path/to/dir'); + + const config = manager.readConfig(); + const overrides = config['ext-test'].overrides; + + // The new parent rule should be present + expect(overrides).toContain(`!/path/to/dir/*`); + + // Child rules should be removed + expect(overrides).not.toContain('/path/to/dir/subdir1/'); + expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`); + + // Unrelated rules should remain + expect(overrides).toContain('/path/to/another/dir/'); + }); + + it('should not remove child rules if includeSubdirs is false', () => { + manager.enable('ext-test', false, '/path/to/dir/subdir1'); + manager.enable('ext-test', false, '/path/to/dir'); // Not including subdirs + + const config = manager.readConfig(); + const overrides = config['ext-test'].overrides; + + expect(overrides).toContain('/path/to/dir/subdir1/'); + expect(overrides).toContain('/path/to/dir/'); + }); + }); + + it('should enable a path based on an enable override', () => { + manager.disable('ext-test', true, '/Users/chrstn'); + manager.enable('ext-test', true, '/Users/chrstn/gemini-cli'); + + expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe( + true, + ); + }); + + it('should ignore subdirs', () => { + manager.disable('ext-test', false, '/Users/chrstn'); + expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe( + true, + ); + }); +}); + +describe('Override', () => { + it('should create an override from input', () => { + const override = Override.fromInput('/path/to/dir', true); + expect(override.baseRule).toBe(`/path/to/dir/`); + expect(override.isDisable).toBe(false); + expect(override.includeSubdirs).toBe(true); + }); + + it('should create a disable override from input', () => { + const override = Override.fromInput('!/path/to/dir', false); + expect(override.baseRule).toBe(`/path/to/dir/`); + expect(override.isDisable).toBe(true); + expect(override.includeSubdirs).toBe(false); + }); + + it('should create an override from a file rule', () => { + const override = Override.fromFileRule('/path/to/dir'); + expect(override.baseRule).toBe('/path/to/dir'); + expect(override.isDisable).toBe(false); + expect(override.includeSubdirs).toBe(false); + }); + + it('should create a disable override from a file rule', () => { + const override = Override.fromFileRule('!/path/to/dir/'); + expect(override.isDisable).toBe(true); + expect(override.baseRule).toBe('/path/to/dir/'); + expect(override.includeSubdirs).toBe(false); + }); + + it('should create an override with subdirs from a file rule', () => { + const override = Override.fromFileRule('/path/to/dir/*'); + expect(override.baseRule).toBe('/path/to/dir/'); + expect(override.isDisable).toBe(false); + expect(override.includeSubdirs).toBe(true); + }); + + it('should correctly identify conflicting overrides', () => { + const override1 = Override.fromInput('/path/to/dir', true); + const override2 = Override.fromInput('/path/to/dir', false); + expect(override1.conflictsWith(override2)).toBe(true); + }); + + it('should correctly identify non-conflicting overrides', () => { + const override1 = Override.fromInput('/path/to/dir', true); + const override2 = Override.fromInput('/path/to/another/dir', true); + expect(override1.conflictsWith(override2)).toBe(false); + }); + + it('should correctly identify equal overrides', () => { + const override1 = Override.fromInput('/path/to/dir', true); + const override2 = Override.fromInput('/path/to/dir', true); + expect(override1.isEqualTo(override2)).toBe(true); + }); + + it('should correctly identify unequal overrides', () => { + const override1 = Override.fromInput('/path/to/dir', true); + const override2 = Override.fromInput('!/path/to/dir', true); + expect(override1.isEqualTo(override2)).toBe(false); + }); + + it('should generate the correct regex', () => { + const override = Override.fromInput('/path/to/dir', true); + const regex = override.asRegex(); + expect(regex.test('/path/to/dir/')).toBe(true); + expect(regex.test('/path/to/dir/subdir')).toBe(true); + expect(regex.test('/path/to/another/dir')).toBe(false); + }); + + it('should correctly identify child overrides', () => { + const parent = Override.fromInput('/path/to/dir', true); + const child = Override.fromInput('/path/to/dir/subdir', false); + expect(child.isChildOf(parent)).toBe(true); + }); + + it('should correctly identify child overrides with glob', () => { + const parent = Override.fromInput('/path/to/dir/*', true); + const child = Override.fromInput('/path/to/dir/subdir', false); + expect(child.isChildOf(parent)).toBe(true); + }); + + it('should correctly identify non-child overrides', () => { + const parent = Override.fromInput('/path/to/dir', true); + const other = Override.fromInput('/path/to/another/dir', false); + expect(other.isChildOf(parent)).toBe(false); + }); + + it('should generate the correct output string', () => { + const override = Override.fromInput('/path/to/dir', true); + expect(override.output()).toBe(`/path/to/dir/*`); + }); + + it('should generate the correct output string for a disable override', () => { + const override = Override.fromInput('!/path/to/dir', false); + expect(override.output()).toBe(`!/path/to/dir/`); + }); + + it('should disable a path based on a disable override rule', () => { + const override = Override.fromInput('!/path/to/dir', false); + expect(override.output()).toBe(`!/path/to/dir/`); + }); }); diff --git a/packages/cli/src/config/extensions/extensionEnablement.ts b/packages/cli/src/config/extensions/extensionEnablement.ts index b32fece9c1..967b6381e9 100644 --- a/packages/cli/src/config/extensions/extensionEnablement.ts +++ b/packages/cli/src/config/extensions/extensionEnablement.ts @@ -15,6 +15,80 @@ export interface AllExtensionsEnablementConfig { [extensionName: string]: ExtensionEnablementConfig; } +export class Override { + constructor( + public baseRule: string, + public isDisable: boolean, + public includeSubdirs: boolean, + ) {} + + static fromInput(inputRule: string, includeSubdirs: boolean): Override { + const isDisable = inputRule.startsWith('!'); + let baseRule = isDisable ? inputRule.substring(1) : inputRule; + baseRule = ensureLeadingAndTrailingSlash(baseRule); + return new Override(baseRule, isDisable, includeSubdirs); + } + + static fromFileRule(fileRule: string): Override { + const isDisable = fileRule.startsWith('!'); + let baseRule = isDisable ? fileRule.substring(1) : fileRule; + const includeSubdirs = baseRule.endsWith('*'); + baseRule = includeSubdirs + ? baseRule.substring(0, baseRule.length - 1) + : baseRule; + return new Override(baseRule, isDisable, includeSubdirs); + } + + conflictsWith(other: Override): boolean { + if (this.baseRule === other.baseRule) { + return ( + this.includeSubdirs !== other.includeSubdirs || + this.isDisable !== other.isDisable + ); + } + return false; + } + + isEqualTo(other: Override): boolean { + return ( + this.baseRule === other.baseRule && + this.includeSubdirs === other.includeSubdirs && + this.isDisable === other.isDisable + ); + } + + asRegex(): RegExp { + return globToRegex(`${this.baseRule}${this.includeSubdirs ? '*' : ''}`); + } + + isChildOf(parent: Override) { + if (!parent.includeSubdirs) { + return false; + } + return parent.asRegex().test(this.baseRule); + } + + output(): string { + return `${this.isDisable ? '!' : ''}${this.baseRule}${this.includeSubdirs ? '*' : ''}`; + } + + matchesPath(path: string) { + return this.asRegex().test(path); + } +} + +const ensureLeadingAndTrailingSlash = function (dirPath: string): string { + // Normalize separators to forward slashes for consistent matching across platforms. + let result = dirPath.replace(/\\/g, '/'); + if (result.charAt(0) !== '/') { + result = '/' + result; + } + if (result.charAt(result.length - 1) !== '/') { + result = result + '/'; + } + return result; +}; + /** * Converts a glob pattern to a RegExp object. * This is a simplified implementation that supports `*`. @@ -25,7 +99,7 @@ export interface AllExtensionsEnablementConfig { function globToRegex(glob: string): RegExp { const regexString = glob .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex characters - .replace(/\*/g, '.*'); // Convert * to .* + .replace(/(\/?)\*/g, '($1.*)?'); // Convert * to optional group return new RegExp(`^${regexString}$`); } @@ -52,16 +126,13 @@ export class ExtensionEnablementManager { const extensionConfig = config[extensionName]; // Extensions are enabled by default. let enabled = true; - - for (const rule of extensionConfig?.overrides ?? []) { - const isDisableRule = rule.startsWith('!'); - const globPattern = isDisableRule ? rule.substring(1) : rule; - const regex = globToRegex(globPattern); - if (regex.test(currentPath)) { - enabled = !isDisableRule; + const allOverrides = extensionConfig?.overrides ?? []; + for (const rule of allOverrides) { + const override = Override.fromFileRule(rule); + if (override.matchesPath(ensureLeadingAndTrailingSlash(currentPath))) { + enabled = !override.isDisable; } } - return enabled; } @@ -96,24 +167,19 @@ export class ExtensionEnablementManager { if (!config[extensionName]) { config[extensionName] = { overrides: [] }; } - - const pathWithGlob = `${scopePath}*`; - const pathWithoutGlob = scopePath; - - const newPath = includeSubdirs ? pathWithGlob : pathWithoutGlob; - const conflictingPath = includeSubdirs ? pathWithoutGlob : pathWithGlob; - - config[extensionName].overrides = config[extensionName].overrides.filter( - (rule) => - rule !== conflictingPath && - rule !== `!${conflictingPath}` && - rule !== `!${newPath}`, - ); - - if (!config[extensionName].overrides.includes(newPath)) { - config[extensionName].overrides.push(newPath); - } - + const override = Override.fromInput(scopePath, includeSubdirs); + const overrides = config[extensionName].overrides.filter((rule) => { + const fileOverride = Override.fromFileRule(rule); + if ( + fileOverride.conflictsWith(override) || + fileOverride.isEqualTo(override) + ) { + return false; // Remove conflicts and equivalent values. + } + return !fileOverride.isChildOf(override); + }); + overrides.push(override.output()); + config[extensionName].overrides = overrides; this.writeConfig(config); } @@ -122,30 +188,7 @@ export class ExtensionEnablementManager { includeSubdirs: boolean, scopePath: string, ): void { - const config = this.readConfig(); - if (!config[extensionName]) { - config[extensionName] = { overrides: [] }; - } - - const pathWithGlob = `${scopePath}*`; - const pathWithoutGlob = scopePath; - - const targetPath = includeSubdirs ? pathWithGlob : pathWithoutGlob; - const newRule = `!${targetPath}`; - const conflictingPath = includeSubdirs ? pathWithoutGlob : pathWithGlob; - - config[extensionName].overrides = config[extensionName].overrides.filter( - (rule) => - rule !== conflictingPath && - rule !== `!${conflictingPath}` && - rule !== targetPath, - ); - - if (!config[extensionName].overrides.includes(newRule)) { - config[extensionName].overrides.push(newRule); - } - - this.writeConfig(config); + this.enable(extensionName, includeSubdirs, `!${scopePath}`); } remove(extensionName: string): void { diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts new file mode 100644 index 0000000000..c33d5e84a2 --- /dev/null +++ b/packages/cli/src/config/extensions/github.test.ts @@ -0,0 +1,337 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + checkForExtensionUpdate, + cloneFromGit, + findReleaseAsset, + parseGitHubRepoForReleases, +} from './github.js'; +import { simpleGit, type SimpleGit } from 'simple-git'; +import { ExtensionUpdateState } from '../../ui/state/extensions.js'; +import type * as os from 'node:os'; +import type { GeminiCLIExtension } from '@google/gemini-cli-core'; + +const mockPlatform = vi.hoisted(() => vi.fn()); +const mockArch = vi.hoisted(() => vi.fn()); +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + platform: mockPlatform, + arch: mockArch, + }; +}); + +vi.mock('simple-git'); + +describe('git extension helpers', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('cloneFromGit', () => { + const mockGit = { + clone: vi.fn(), + getRemotes: vi.fn(), + fetch: vi.fn(), + checkout: vi.fn(), + }; + + beforeEach(() => { + vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); + }); + + it('should clone, fetch and checkout a repo', async () => { + const installMetadata = { + source: 'http://my-repo.com', + ref: 'my-ref', + type: 'git' as const, + }; + const destination = '/dest'; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, + ]); + + await cloneFromGit(installMetadata, destination); + + expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [ + '--depth', + '1', + ]); + expect(mockGit.getRemotes).toHaveBeenCalledWith(true); + expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'my-ref'); + expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD'); + }); + + it('should use HEAD if ref is not provided', async () => { + const installMetadata = { + source: 'http://my-repo.com', + type: 'git' as const, + }; + const destination = '/dest'; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, + ]); + + await cloneFromGit(installMetadata, destination); + + expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'HEAD'); + }); + + it('should throw if no remotes are found', async () => { + const installMetadata = { + source: 'http://my-repo.com', + type: 'git' as const, + }; + const destination = '/dest'; + mockGit.getRemotes.mockResolvedValue([]); + + await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow( + 'Failed to clone Git repository from http://my-repo.com', + ); + }); + + it('should throw on clone error', async () => { + const installMetadata = { + source: 'http://my-repo.com', + type: 'git' as const, + }; + const destination = '/dest'; + mockGit.clone.mockRejectedValue(new Error('clone failed')); + + await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow( + 'Failed to clone Git repository from http://my-repo.com', + ); + }); + }); + + describe('checkForExtensionUpdate', () => { + const mockGit = { + getRemotes: vi.fn(), + listRemote: vi.fn(), + revparse: vi.fn(), + }; + + beforeEach(() => { + vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); + }); + + it('should return NOT_UPDATABLE for non-git extensions', async () => { + const extension: GeminiCLIExtension = { + name: 'test', + path: '/ext', + version: '1.0.0', + isActive: true, + installMetadata: { + type: 'link', + source: '', + }, + }; + 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 extension: GeminiCLIExtension = { + name: 'test', + path: '/ext', + version: '1.0.0', + isActive: true, + installMetadata: { + type: 'git', + source: '', + }, + }; + mockGit.getRemotes.mockResolvedValue([]); + 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 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' } }, + ]); + mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); + mockGit.revparse.mockResolvedValue('local-hash'); + + 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 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' } }, + ]); + mockGit.listRemote.mockResolvedValue('same-hash\tHEAD'); + mockGit.revparse.mockResolvedValue('same-hash'); + + 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 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')); + + let result: ExtensionUpdateState | undefined = undefined; + await checkForExtensionUpdate( + extension, + (newState) => (result = newState), + ); + expect(result).toBe(ExtensionUpdateState.ERROR); + }); + }); + + describe('findReleaseAsset', () => { + const assets = [ + { name: 'darwin.arm64.extension.tar.gz', browser_download_url: 'url1' }, + { name: 'darwin.x64.extension.tar.gz', browser_download_url: 'url2' }, + { name: 'linux.x64.extension.tar.gz', browser_download_url: 'url3' }, + { name: 'win32.x64.extension.tar.gz', browser_download_url: 'url4' }, + { name: 'extension-generic.tar.gz', browser_download_url: 'url5' }, + ]; + + it('should find asset matching platform and architecture', () => { + mockPlatform.mockReturnValue('darwin'); + mockArch.mockReturnValue('arm64'); + const result = findReleaseAsset(assets); + expect(result).toEqual(assets[0]); + }); + + it('should find asset matching platform if arch does not match', () => { + mockPlatform.mockReturnValue('linux'); + mockArch.mockReturnValue('arm64'); + const result = findReleaseAsset(assets); + expect(result).toEqual(assets[2]); + }); + + it('should return undefined if no matching asset is found', () => { + mockPlatform.mockReturnValue('sunos'); + mockArch.mockReturnValue('x64'); + const result = findReleaseAsset(assets); + expect(result).toBeUndefined(); + }); + + it('should find generic asset if it is the only one', () => { + const singleAsset = [ + { name: 'extension.tar.gz', browser_download_url: 'url' }, + ]; + mockPlatform.mockReturnValue('darwin'); + mockArch.mockReturnValue('arm64'); + const result = findReleaseAsset(singleAsset); + expect(result).toEqual(singleAsset[0]); + }); + + it('should return undefined if multiple generic assets exist', () => { + const multipleGenericAssets = [ + { name: 'extension-1.tar.gz', browser_download_url: 'url1' }, + { name: 'extension-2.tar.gz', browser_download_url: 'url2' }, + ]; + mockPlatform.mockReturnValue('darwin'); + mockArch.mockReturnValue('arm64'); + const result = findReleaseAsset(multipleGenericAssets); + expect(result).toBeUndefined(); + }); + }); + + describe('parseGitHubRepoForReleases', () => { + it('should parse owner and repo from a full GitHub URL', () => { + const source = 'https://github.com/owner/repo.git'; + const { owner, repo } = parseGitHubRepoForReleases(source); + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); + }); + + it('should parse owner and repo from a full GitHub UR without .git', () => { + const source = 'https://github.com/owner/repo'; + const { owner, repo } = parseGitHubRepoForReleases(source); + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); + }); + + it('should fail on a GitHub SSH URL', () => { + const source = 'git@github.com:owner/repo.git'; + expect(() => parseGitHubRepoForReleases(source)).toThrow( + 'GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.', + ); + }); + + it('should parse owner and repo from a shorthand string', () => { + const source = 'owner/repo'; + const { owner, repo } = parseGitHubRepoForReleases(source); + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); + }); + + it('should handle .git suffix in repo name', () => { + const source = 'owner/repo.git'; + const { owner, repo } = parseGitHubRepoForReleases(source); + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); + }); + + it('should throw error for invalid source format', () => { + const source = 'invalid-format'; + expect(() => parseGitHubRepoForReleases(source)).toThrow( + 'Invalid GitHub repository source: invalid-format. Expected "owner/repo" or a github repo uri.', + ); + }); + + it('should throw error for source with too many parts', () => { + const source = 'https://github.com/owner/repo/extra'; + expect(() => parseGitHubRepoForReleases(source)).toThrow( + 'Invalid GitHub repository source: https://github.com/owner/repo/extra. Expected "owner/repo" or a github repo uri.', + ); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts new file mode 100644 index 0000000000..ead855cb98 --- /dev/null +++ b/packages/cli/src/config/extensions/github.ts @@ -0,0 +1,420 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { simpleGit } from 'simple-git'; +import { getErrorMessage } from '../../utils/errors.js'; +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'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; +import { loadExtension } from '../extension.js'; +import { quote } from 'shell-quote'; + +function getGitHubToken(): string | undefined { + return process.env['GITHUB_TOKEN']; +} + +/** + * Clones a Git repository to a specified local path. + * @param installMetadata The metadata for the extension to install. + * @param destination The destination path to clone the repository to. + */ +export async function cloneFromGit( + installMetadata: ExtensionInstallMetadata, + destination: string, +): Promise { + try { + const git = simpleGit(destination); + let sourceUrl = installMetadata.source; + const token = getGitHubToken(); + if (token) { + try { + const parsedUrl = new URL(sourceUrl); + if ( + parsedUrl.protocol === 'https:' && + parsedUrl.hostname === 'github.com' + ) { + if (!parsedUrl.username) { + parsedUrl.username = token; + } + sourceUrl = parsedUrl.toString(); + } + } catch { + // If source is not a valid URL, we don't inject the token. + // We let git handle the source as is. + } + } + await git.clone(sourceUrl, './', ['--depth', '1']); + + const remotes = await git.getRemotes(true); + if (remotes.length === 0) { + throw new Error( + `Unable to find any remotes for repo ${installMetadata.source}`, + ); + } + + const refToFetch = installMetadata.ref || 'HEAD'; + + await git.fetch(remotes[0].name, refToFetch); + + // After fetching, checkout FETCH_HEAD to get the content of the fetched ref. + // This results in a detached HEAD state, which is fine for this purpose. + await git.checkout('FETCH_HEAD'); + } catch (error) { + throw new Error( + `Failed to clone Git repository from ${installMetadata.source}`, + { + cause: error, + }, + ); + } +} + +export function parseGitHubRepoForReleases(source: string): { + owner: string; + repo: string; +} { + // Default to a github repo path, so `source` can be just an org/repo + const parsedUrl = URL.parse(source, 'https://github.com'); + // The pathname should be "/owner/repo". + const parts = parsedUrl?.pathname.substring(1).split('/'); + if (parts?.length !== 2) { + throw new Error( + `Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`, + ); + } + const owner = parts[0]; + const repo = parts[1].replace('.git', ''); + + if (owner.startsWith('git@github.com')) { + throw new Error( + `GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.`, + ); + } + + return { owner, repo }; +} + +async function fetchReleaseFromGithub( + owner: string, + repo: string, + ref?: string, +): Promise { + const endpoint = ref ? `releases/tags/${ref}` : 'releases/latest'; + const url = `https://api.github.com/repos/${owner}/${repo}/${endpoint}`; + return await fetchJson(url); +} + +export async function checkForExtensionUpdate( + extension: GeminiCLIExtension, + setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, + cwd: string = process.cwd(), +): Promise { + setExtensionUpdateState(ExtensionUpdateState.CHECKING_FOR_UPDATES); + const installMetadata = extension.installMetadata; + if (installMetadata?.type === 'local') { + const newExtension = loadExtension({ + extensionDir: installMetadata.source, + workspaceDir: cwd, + }); + if (!newExtension) { + console.error( + `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`, + ); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } + if (newExtension.config.version !== extension.version) { + setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE); + return; + } + setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); + return; + } + if ( + !installMetadata || + (installMetadata.type !== 'git' && + installMetadata.type !== 'github-release') + ) { + setExtensionUpdateState(ExtensionUpdateState.NOT_UPDATABLE); + return; + } + try { + if (installMetadata.type === 'git') { + const git = simpleGit(extension.path); + const remotes = await git.getRemotes(true); + if (remotes.length === 0) { + console.error('No git remotes found.'); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } + const remoteUrl = remotes[0].refs.fetch; + if (!remoteUrl) { + console.error(`No fetch URL found for git remote ${remotes[0].name}.`); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } + + // Determine the ref to check on the remote. + const refToCheck = installMetadata.ref || 'HEAD'; + + const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); + + if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { + console.error(`Git ref ${refToCheck} not found.`); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } + + const remoteHash = lsRemoteOutput.split('\t')[0]; + const localHash = await git.revparse(['HEAD']); + + if (!remoteHash) { + console.error( + `Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`, + ); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } + if (remoteHash === localHash) { + setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); + return; + } + setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE); + return; + } else { + const { source, releaseTag } = installMetadata; + if (!source) { + console.error(`No "source" provided for extension.`); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } + const { owner, repo } = parseGitHubRepoForReleases(source); + + const releaseData = await fetchReleaseFromGithub( + owner, + repo, + installMetadata.ref, + ); + if (releaseData.tag_name !== releaseTag) { + setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE); + return; + } + setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); + return; + } + } catch (error) { + console.error( + `Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`, + ); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } +} +export interface GitHubDownloadResult { + tagName: string; + type: 'git' | 'github-release'; +} +export async function downloadFromGitHubRelease( + installMetadata: ExtensionInstallMetadata, + destination: string, +): Promise { + const { source, ref } = installMetadata; + const { owner, repo } = parseGitHubRepoForReleases(source); + + try { + const releaseData = await fetchReleaseFromGithub(owner, repo, ref); + if (!releaseData) { + throw new Error( + `No release data found for ${owner}/${repo} at tag ${ref}`, + ); + } + + const asset = findReleaseAsset(releaseData.assets); + let archiveUrl: string | undefined; + let isTar = false; + let isZip = false; + if (asset) { + archiveUrl = asset.browser_download_url; + } else { + if (releaseData.tarball_url) { + archiveUrl = releaseData.tarball_url; + isTar = true; + } else if (releaseData.zipball_url) { + archiveUrl = releaseData.zipball_url; + isZip = true; + } + } + if (!archiveUrl) { + throw new Error( + `No assets found for release with tag ${releaseData.tag_name}`, + ); + } + let downloadedAssetPath = path.join( + destination, + path.basename(new URL(archiveUrl).pathname), + ); + if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) { + downloadedAssetPath += '.tar.gz'; + } else if (isZip && !downloadedAssetPath.endsWith('.zip')) { + downloadedAssetPath += '.zip'; + } + + await downloadFile(archiveUrl, downloadedAssetPath); + + extractFile(downloadedAssetPath, destination); + + const files = await fs.promises.readdir(destination); + const extractedDirName = files.find((file) => { + const filePath = path.join(destination, file); + return fs.statSync(filePath).isDirectory(); + }); + + if (extractedDirName) { + const extractedDirPath = path.join(destination, extractedDirName); + const extractedDirFiles = await fs.promises.readdir(extractedDirPath); + for (const file of extractedDirFiles) { + await fs.promises.rename( + path.join(extractedDirPath, file), + path.join(destination, file), + ); + } + await fs.promises.rmdir(extractedDirPath); + } + + await fs.promises.unlink(downloadedAssetPath); + return { + tagName: releaseData.tag_name, + type: 'github-release', + }; + } catch (error) { + throw new Error( + `Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`, + ); + } +} + +interface GithubReleaseData { + assets: Asset[]; + tag_name: string; + tarball_url?: string; + zipball_url?: string; +} + +interface Asset { + name: string; + browser_download_url: string; +} + +export function findReleaseAsset(assets: Asset[]): Asset | undefined { + const platform = os.platform(); + const arch = os.arch(); + + const platformArchPrefix = `${platform}.${arch}.`; + const platformPrefix = `${platform}.`; + + // Check for platform + architecture specific asset + const platformArchAsset = assets.find((asset) => + asset.name.toLowerCase().startsWith(platformArchPrefix), + ); + if (platformArchAsset) { + return platformArchAsset; + } + + // Check for platform specific asset + const platformAsset = assets.find((asset) => + asset.name.toLowerCase().startsWith(platformPrefix), + ); + if (platformAsset) { + return platformAsset; + } + + // Check for generic asset if only one is available + const genericAsset = assets.find( + (asset) => + !asset.name.toLowerCase().includes('darwin') && + !asset.name.toLowerCase().includes('linux') && + !asset.name.toLowerCase().includes('win32'), + ); + if (assets.length === 1) { + return genericAsset; + } + + return undefined; +} + +async function fetchJson(url: string): Promise { + const headers: { 'User-Agent': string; Authorization?: string } = { + 'User-Agent': 'gemini-cli', + }; + const token = getGitHubToken(); + if (token) { + headers.Authorization = `token ${token}`; + } + return new Promise((resolve, reject) => { + https + .get(url, { headers }, (res) => { + if (res.statusCode !== 200) { + return reject( + new Error(`Request failed with status code ${res.statusCode}`), + ); + } + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const data = Buffer.concat(chunks).toString(); + resolve(JSON.parse(data) as T); + }); + }) + .on('error', reject); + }); +} + +async function downloadFile(url: string, dest: string): Promise { + const headers: { 'User-agent': string; Authorization?: string } = { + 'User-agent': 'gemini-cli', + }; + const token = getGitHubToken(); + if (token) { + headers.Authorization = `token ${token}`; + } + return new Promise((resolve, reject) => { + https + .get(url, { headers }, (res) => { + if (res.statusCode === 302 || res.statusCode === 301) { + downloadFile(res.headers.location!, dest).then(resolve).catch(reject); + return; + } + if (res.statusCode !== 200) { + return reject( + new Error(`Request failed with status code ${res.statusCode}`), + ); + } + const file = fs.createWriteStream(dest); + res.pipe(file); + file.on('finish', () => file.close(resolve as () => void)); + }) + .on('error', reject); + }); +} + +function extractFile(file: string, dest: string) { + const safeFile = quote([file]); + const safeDest = quote([dest]); + if (file.endsWith('.tar.gz')) { + execSync(`tar -xzf ${safeFile} -C ${safeDest}`); + } else if (file.endsWith('.zip')) { + execSync(`unzip ${safeFile} -d ${safeDest}`); + } else { + throw new Error(`Unsupported file extension for extraction: ${file}`); + } +} 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..ac69b56500 --- /dev/null +++ b/packages/cli/src/config/extensions/update.test.ts @@ -0,0 +1,461 @@ +/** + * @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(), + }; +}); + +const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn()); +const mockLogExtensionUninstall = vi.hoisted(() => vi.fn()); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + logExtensionInstallEvent: mockLogExtensionInstallEvent, + logExtensionUninstall: mockLogExtensionUninstall, + 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 UpToDate for a local extension with no updates', async () => { + const localExtensionSourcePath = path.join(tempHomeDir, 'local-source'); + const sourceExtensionDir = createExtension({ + extensionsDir: localExtensionSourcePath, + name: 'my-local-ext', + version: '1.0.0', + }); + + const installedExtensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'local-extension', + version: '1.0.0', + installMetadata: { source: sourceExtensionDir, type: 'local' }, + }); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir: installedExtensionDir, + 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; + } + }, + tempWorkspaceDir, + ); + const result = results.get('local-extension'); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + }); + + it('should return UpdateAvailable for a local extension with updates', async () => { + const localExtensionSourcePath = path.join(tempHomeDir, 'local-source'); + const sourceExtensionDir = createExtension({ + extensionsDir: localExtensionSourcePath, + name: 'my-local-ext', + version: '1.1.0', + }); + + const installedExtensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'local-extension', + version: '1.0.0', + installMetadata: { source: sourceExtensionDir, type: 'local' }, + }); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir: installedExtensionDir, + 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; + } + }, + tempWorkspaceDir, + ); + const result = results.get('local-extension'); + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + }); + + 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..17d6771d35 --- /dev/null +++ b/packages/cli/src/config/extensions/update.ts @@ -0,0 +1,161 @@ +/** + * @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> + >, + cwd: string = process.cwd(), +): 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; + }); + }, + cwd, + ); + } + } + 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 75b8b20db5..eb01fcfd28 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -83,8 +83,7 @@ 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 type { ExtensionUpdateState } from './state/extensions.js'; -import { checkForAllExtensionUpdates } from '../config/extension.js'; +import { useExtensionUpdates } from './hooks/useExtensionUpdates.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -145,9 +144,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(() => { @@ -1192,11 +1196,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..c9e04cab86 --- /dev/null +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts @@ -0,0 +1,207 @@ +/** + * @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(), + }; +}); + +const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn()); +const mockLogExtensionUninstall = vi.hoisted(() => vi.fn()); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + logExtensionInstallEvent: mockLogExtensionInstallEvent, + logExtensionUninstall: mockLogExtensionUninstall, + 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/index.ts b/packages/core/index.ts index daa3771150..f269ef50e8 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -24,12 +24,16 @@ export { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, } from './src/config/config.js'; export { detectIdeFromEnv, getIdeInfo } from './src/ide/detect-ide.js'; -export { logIdeConnection } from './src/telemetry/loggers.js'; +export { + logExtensionEnable, + logIdeConnection, +} from './src/telemetry/loggers.js'; export { IdeConnectionEvent, IdeConnectionType, ExtensionInstallEvent, + ExtensionEnableEvent, ExtensionUninstallEvent, } from './src/telemetry/types.js'; export { makeFakeConfig } from './src/test-utils/config.js'; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index a8a21d7b00..9202572c78 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -117,9 +117,15 @@ export interface GeminiCLIExtension { version: string; isActive: boolean; path: string; - source?: string; - type?: 'git' | 'local' | 'link'; + installMetadata?: ExtensionInstallMetadata; +} + +export interface ExtensionInstallMetadata { + source: string; + type: 'git' | 'local' | 'link' | 'github-release'; + releaseTag?: string; // Only present for github-release installs. ref?: string; + autoUpdate?: boolean; } export interface FileFilteringOptions { diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 7ab156430d..9ada67c2a4 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -28,6 +28,7 @@ import type { ToolOutputTruncatedEvent, ExtensionUninstallEvent, ModelRoutingEvent, + ExtensionEnableEvent, } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import type { Config } from '../../config/config.js'; @@ -61,6 +62,7 @@ export enum EventNames { INVALID_CHUNK = 'invalid_chunk', CONTENT_RETRY = 'content_retry', CONTENT_RETRY_FAILURE = 'content_retry_failure', + EXTENSION_ENABLE = 'extension_enable', EXTENSION_INSTALL = 'extension_install', EXTENSION_UNINSTALL = 'extension_uninstall', TOOL_OUTPUT_TRUNCATED = 'tool_output_truncated', @@ -959,6 +961,25 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logExtensionEnableEvent(event: ExtensionEnableEvent): void { + const data: EventValue[] = [ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, + value: event.extension_name, + }, + { + gemini_cli_key: + EventMetadataKey.GEMINI_CLI_EXTENSION_ENABLE_SETTING_SCOPE, + value: event.setting_scope, + }, + ]; + + this.enqueueLogEvent( + this.createLogEvent(EventNames.EXTENSION_ENABLE, data), + ); + this.flushIfNeeded(); + } + /** * Adds default fields to data, and returns a new data array. This fields * should exist on all log events. diff --git a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts index 6a783098b3..f700c259d5 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -353,6 +353,9 @@ export enum EventMetadataKey { // Logs the status of the extension uninstall GEMINI_CLI_EXTENSION_UNINSTALL_STATUS = 96, + // Logs the setting scope for an extension enablement. + GEMINI_CLI_EXTENSION_ENABLE_SETTING_SCOPE = 102, + // ========================================================================== // Tool Output Truncated Event Keys // =========================================================================== diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index c10dfdc9a5..fcff8d0334 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -12,6 +12,9 @@ export const EVENT_API_REQUEST = 'gemini_cli.api_request'; export const EVENT_API_ERROR = 'gemini_cli.api_error'; export const EVENT_API_RESPONSE = 'gemini_cli.api_response'; export const EVENT_CLI_CONFIG = 'gemini_cli.config'; +export const EVENT_EXTENSION_ENABLE = 'gemini_cli.extension_enable'; +export const EVENT_EXTENSION_INSTALL = 'gemini_cli.extension_install'; +export const EVENT_EXTENSION_UNINSTALL = 'gemini_cli.extension_uninstall'; export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback'; export const EVENT_RIPGREP_FALLBACK = 'gemini_cli.ripgrep_fallback'; export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check'; diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index e713593cf8..09268c2200 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -36,6 +36,9 @@ export { logKittySequenceOverflow, logChatCompression, logToolOutputTruncated, + logExtensionEnable, + logExtensionInstallEvent, + logExtensionUninstall, } from './loggers.js'; export type { SlashCommandEvent, ChatCompressionEvent } from './types.js'; export { diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 3432391447..4086a448da 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -33,6 +33,9 @@ import { EVENT_FILE_OPERATION, EVENT_RIPGREP_FALLBACK, EVENT_MODEL_ROUTING, + EVENT_EXTENSION_ENABLE, + EVENT_EXTENSION_INSTALL, + EVENT_EXTENSION_UNINSTALL, } from './constants.js'; import { logApiRequest, @@ -47,6 +50,9 @@ import { logRipgrepFallback, logToolOutputTruncated, logModelRouting, + logExtensionEnable, + logExtensionInstallEvent, + logExtensionUninstall, } from './loggers.js'; import { ToolCallDecision } from './tool-call-decision.js'; import { @@ -62,11 +68,14 @@ import { FileOperationEvent, ToolOutputTruncatedEvent, ModelRoutingEvent, + ExtensionEnableEvent, + ExtensionInstallEvent, + ExtensionUninstallEvent, } from './types.js'; import * as metrics from './metrics.js'; import { FileOperation } from './metrics.js'; import * as sdk from './sdk.js'; -import { vi, describe, beforeEach, it, expect } from 'vitest'; +import { vi, describe, beforeEach, it, expect, afterEach } from 'vitest'; import type { GenerateContentResponseUsageMetadata } from '@google/genai'; import * as uiTelemetry from './uiTelemetry.js'; import { makeFakeConfig } from '../test-utils/config.js'; @@ -1108,4 +1117,122 @@ describe('loggers', () => { expect(metrics.recordModelRoutingMetrics).not.toHaveBeenCalled(); }); }); + + describe('logExtensionInstall', () => { + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + } as unknown as Config; + + beforeEach(() => { + vi.spyOn(ClearcutLogger.prototype, 'logExtensionInstallEvent'); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should log extension install event', () => { + const event = new ExtensionInstallEvent( + 'vscode', + '0.1.0', + 'git', + 'success', + ); + + logExtensionInstallEvent(mockConfig, event); + + expect( + ClearcutLogger.prototype.logExtensionInstallEvent, + ).toHaveBeenCalledWith(event); + + expect(mockLogger.emit).toHaveBeenCalledWith({ + body: 'Installed extension vscode', + attributes: { + 'session.id': 'test-session-id', + 'user.email': 'test-user@example.com', + 'event.name': EVENT_EXTENSION_INSTALL, + 'event.timestamp': '2025-01-01T00:00:00.000Z', + extension_name: 'vscode', + extension_version: '0.1.0', + extension_source: 'git', + status: 'success', + }, + }); + }); + }); + + describe('logExtensionUninstall', () => { + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + } as unknown as Config; + + beforeEach(() => { + vi.spyOn(ClearcutLogger.prototype, 'logExtensionUninstallEvent'); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should log extension uninstall event', () => { + const event = new ExtensionUninstallEvent('vscode', 'success'); + + logExtensionUninstall(mockConfig, event); + + expect( + ClearcutLogger.prototype.logExtensionUninstallEvent, + ).toHaveBeenCalledWith(event); + + expect(mockLogger.emit).toHaveBeenCalledWith({ + body: 'Uninstalled extension vscode', + attributes: { + 'session.id': 'test-session-id', + 'user.email': 'test-user@example.com', + 'event.name': EVENT_EXTENSION_UNINSTALL, + 'event.timestamp': '2025-01-01T00:00:00.000Z', + extension_name: 'vscode', + status: 'success', + }, + }); + }); + }); + + describe('logExtensionEnable', () => { + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + } as unknown as Config; + + beforeEach(() => { + vi.spyOn(ClearcutLogger.prototype, 'logExtensionEnableEvent'); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should log extension enable event', () => { + const event = new ExtensionEnableEvent('vscode', 'user'); + + logExtensionEnable(mockConfig, event); + + expect( + ClearcutLogger.prototype.logExtensionEnableEvent, + ).toHaveBeenCalledWith(event); + + expect(mockLogger.emit).toHaveBeenCalledWith({ + body: 'Enabled extension vscode', + attributes: { + 'session.id': 'test-session-id', + 'user.email': 'test-user@example.com', + 'event.name': EVENT_EXTENSION_ENABLE, + 'event.timestamp': '2025-01-01T00:00:00.000Z', + extension_name: 'vscode', + setting_scope: 'user', + }, + }); + }); + }); }); diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index cbe706df08..153b4477b0 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -13,6 +13,8 @@ import { EVENT_API_REQUEST, EVENT_API_RESPONSE, EVENT_CLI_CONFIG, + EVENT_EXTENSION_UNINSTALL, + EVENT_EXTENSION_ENABLE, EVENT_IDE_CONNECTION, EVENT_TOOL_CALL, EVENT_USER_PROMPT, @@ -29,6 +31,7 @@ import { EVENT_FILE_OPERATION, EVENT_RIPGREP_FALLBACK, EVENT_MODEL_ROUTING, + EVENT_EXTENSION_INSTALL, } from './constants.js'; import type { ApiErrorEvent, @@ -54,6 +57,9 @@ import type { RipgrepFallbackEvent, ToolOutputTruncatedEvent, ModelRoutingEvent, + ExtensionEnableEvent, + ExtensionUninstallEvent, + ExtensionInstallEvent, } from './types.js'; import { recordApiErrorMetrics, @@ -691,3 +697,73 @@ export function logModelRouting( logger.emit(logRecord); recordModelRoutingMetrics(config, event); } + +export function logExtensionInstallEvent( + config: Config, + event: ExtensionInstallEvent, +): void { + ClearcutLogger.getInstance(config)?.logExtensionInstallEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_EXTENSION_INSTALL, + 'event.timestamp': new Date().toISOString(), + extension_name: event.extension_name, + extension_version: event.extension_version, + extension_source: event.extension_source, + status: event.status, + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Installed extension ${event.extension_name}`, + attributes, + }; + logger.emit(logRecord); +} + +export function logExtensionUninstall( + config: Config, + event: ExtensionUninstallEvent, +): void { + ClearcutLogger.getInstance(config)?.logExtensionUninstallEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_EXTENSION_UNINSTALL, + 'event.timestamp': new Date().toISOString(), + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Uninstalled extension ${event.extension_name}`, + attributes, + }; + logger.emit(logRecord); +} + +export function logExtensionEnable( + config: Config, + event: ExtensionEnableEvent, +): void { + ClearcutLogger.getInstance(config)?.logExtensionEnableEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_EXTENSION_ENABLE, + 'event.timestamp': new Date().toISOString(), + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Enabled extension ${event.extension_name}`, + attributes, + }; + logger.emit(logRecord); +} diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index 78a9c453bf..bd26be485f 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -198,6 +198,9 @@ export function initializeTelemetry(config: Config): void { process.on('SIGINT', () => { shutdownTelemetry(config); }); + process.on('exit', () => { + shutdownTelemetry(config); + }); } export async function shutdownTelemetry(config: Config): Promise { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index b6a7b932f2..dda0d8f7f8 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -576,6 +576,7 @@ export type TelemetryEvent = | InvalidChunkEvent | ContentRetryEvent | ContentRetryFailureEvent + | ExtensionEnableEvent | ExtensionInstallEvent | ExtensionUninstallEvent | ModelRoutingEvent @@ -648,3 +649,17 @@ export class ExtensionUninstallEvent implements BaseTelemetryEvent { this.status = status; } } + +export class ExtensionEnableEvent implements BaseTelemetryEvent { + 'event.name': 'extension_enable'; + 'event.timestamp': string; + extension_name: string; + setting_scope: string; + + constructor(extension_name: string, settingScope: string) { + this['event.name'] = 'extension_enable'; + this['event.timestamp'] = new Date().toISOString(); + this.extension_name = extension_name; + this.setting_scope = settingScope; + } +}