From eddd13d70e85f4a4080715213e9b8bc5953b8dc6 Mon Sep 17 00:00:00 2001 From: christine betts Date: Wed, 17 Sep 2025 18:14:01 -0400 Subject: [PATCH] Add simple extensions release flow support (#8498) --- docs/extension-releasing.md | 78 ++++ .../cli/src/commands/extensions/install.ts | 6 +- packages/cli/src/commands/extensions/link.ts | 6 +- packages/cli/src/config/extension.test.ts | 115 +----- packages/cli/src/config/extension.ts | 176 +++------ .../cli/src/config/extensions/github.test.ts | 234 ++++++++++++ packages/cli/src/config/extensions/github.ts | 346 ++++++++++++++++++ packages/core/src/config/config.ts | 8 +- 8 files changed, 726 insertions(+), 243 deletions(-) create mode 100644 docs/extension-releasing.md create mode 100644 packages/cli/src/config/extensions/github.test.ts create mode 100644 packages/cli/src/config/extensions/github.ts diff --git a/docs/extension-releasing.md b/docs/extension-releasing.md new file mode 100644 index 0000000000..a623a4bfc8 --- /dev/null +++ b/docs/extension-releasing.md @@ -0,0 +1,78 @@ +# Extension Releasing + +Gemini CLI extensions can be distributed as pre-built binaries through GitHub Releases. This provides a faster and more reliable installation experience for users, as it avoids the need to clone the repository and build the extension from source. + +## Asset naming convention + +To ensure Gemini CLI can automatically find the correct release asset for each platform, you should 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` + +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. + +## Archive structure + +The `gemini-extension.json` file must be at the root of the archive. + +## 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/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index aae1897974..e82864d34e 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'; 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/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 0453d4319e..fda8e58279 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -13,7 +13,6 @@ import { INSTALL_METADATA_FILENAME, annotateActiveExtensions, checkForAllExtensionUpdates, - checkForExtensionUpdate, disableExtension, enableExtension, installExtension, @@ -23,7 +22,6 @@ import { uninstallExtension, updateExtension, type Extension, - type ExtensionInstallMetadata, } from './extension.js'; import { GEMINI_DIR, @@ -32,6 +30,7 @@ import { ClearcutLogger, type Config, ExtensionUninstallEvent, + type ExtensionInstallMetadata, } from '@google/gemini-cli-core'; import { execSync } from 'node:child_process'; import { SettingScope } from './settings.js'; @@ -1140,118 +1139,6 @@ describe('extension tests', () => { }); }); - 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); diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 89dce696f2..c187bcb38e 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -7,6 +7,7 @@ import type { MCPServerConfig, GeminiCLIExtension, + ExtensionInstallMetadata, } from '@google/gemini-cli-core'; import { GEMINI_DIR, @@ -19,7 +20,6 @@ import { 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'; @@ -27,6 +27,11 @@ import { isWorkspaceTrusted } from './trustedFolders.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { randomUUID } from 'node:crypto'; import { ExtensionUpdateState } from '../ui/state/extensions.js'; +import { + cloneFromGit, + checkForExtensionUpdate, + downloadFromGitHubRelease, +} from './extensions/github.js'; import type { LoadExtensionContext } from './extensions/variableSchema.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; @@ -50,12 +55,6 @@ export interface ExtensionConfig { excludeTools?: string[]; } -export interface ExtensionInstallMetadata { - source: string; - type: 'git' | 'local' | 'link'; - ref?: string; -} - export interface ExtensionUpdateInfo { name: string; originalVersion: string; @@ -301,7 +300,6 @@ export function annotateActiveExtensions( const manager = new ExtensionEnablementManager( ExtensionStorage.getUserExtensionsDir(), ); - const annotatedExtensions: GeminiCLIExtension[] = []; if (enabledExtensionNames.length === 0) { return extensions.map((extension) => ({ @@ -309,9 +307,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 +324,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 +343,7 @@ export function annotateActiveExtensions( version: extension.config.version, isActive, path: extension.path, + installMetadata: extension.installMetadata, }); } @@ -359,43 +354,6 @@ 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 @@ -445,9 +403,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 tagName = await downloadFromGitHubRelease( + installMetadata, + tempDir, + ); + updateExtensionVersion(tempDir, tagName); + installMetadata.type = 'github-release'; + } catch (_error) { + await cloneFromGit(installMetadata, tempDir); + installMetadata.type = 'git'; + } localSourcePath = tempDir; } else if ( installMetadata.type === 'local' || @@ -488,7 +459,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); } @@ -536,6 +511,21 @@ export async function installExtension( } } +async function updateExtensionVersion( + extensionDir: string, + extensionVersion: string, +) { + const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); + if (fs.existsSync(configFilePath)) { + const configContent = await fs.promises.readFile(configFilePath, 'utf-8'); + const config = JSON.parse(configContent); + config.version = extensionVersion; + await fs.promises.writeFile( + configFilePath, + JSON.stringify(config, null, 2), + ); + } +} async function requestConsent(extensionConfig: ExtensionConfig) { const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {}); if (mcpServerEntries.length) { @@ -662,13 +652,15 @@ export async function updateExtension( cwd: string = process.cwd(), setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, ): Promise { - if (!extension.type) { + const installMetadata = loadInstallMetadata(extension.path); + + if (!installMetadata?.type) { setExtensionUpdateState(ExtensionUpdateState.ERROR); throw new Error( `Extension ${extension.name} cannot be updated, type is unknown.`, ); } - if (extension.type === 'link') { + if (installMetadata?.type === 'link') { setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); throw new Error(`Extension is linked so does not need to be updated`); } @@ -679,15 +671,7 @@ export async function updateExtension( try { await copyExtension(extension.path, tempDir); await uninstallExtension(extension.name, cwd); - await installExtension( - { - source: extension.source!, - type: extension.type, - ref: extension.ref, - }, - false, - cwd, - ); + await installExtension(installMetadata, false, cwd); const updatedExtensionStorage = new ExtensionStorage(extension.name); const updatedExtension = loadExtension({ @@ -786,61 +770,15 @@ export async function checkForAllExtensionUpdates( ): Promise> { const finalState = new Map(); for (const extension of extensions) { - finalState.set(extension.name, await checkForExtensionUpdate(extension)); + if (!extension.installMetadata) { + finalState.set(extension.name, ExtensionUpdateState.NOT_UPDATABLE); + continue; + } + finalState.set( + extension.name, + await checkForExtensionUpdate(extension.installMetadata), + ); } 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; - } -} 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..fae95dc7f2 --- /dev/null +++ b/packages/cli/src/config/extensions/github.test.ts @@ -0,0 +1,234 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + checkForExtensionUpdate, + cloneFromGit, + findReleaseAsset, +} 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 { ExtensionInstallMetadata } 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 installMetadata: ExtensionInstallMetadata = { + type: 'local', + source: '', + }; + const result = await checkForExtensionUpdate(installMetadata); + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + }); + + it('should return ERROR if no remotes found', async () => { + const installMetadata: ExtensionInstallMetadata = { + type: 'git', + source: '', + }; + mockGit.getRemotes.mockResolvedValue([]); + const result = await checkForExtensionUpdate(installMetadata); + expect(result).toBe(ExtensionUpdateState.ERROR); + }); + + it('should return UPDATE_AVAILABLE when remote hash is different', async () => { + const installMetadata: ExtensionInstallMetadata = { + type: 'git', + source: '/ext', + }; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, + ]); + mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); + mockGit.revparse.mockResolvedValue('local-hash'); + + const result = await checkForExtensionUpdate(installMetadata); + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + }); + + it('should return UP_TO_DATE when remote and local hashes are the same', async () => { + const installMetadata: ExtensionInstallMetadata = { + type: 'git', + source: '/ext', + }; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, + ]); + mockGit.listRemote.mockResolvedValue('same-hash\tHEAD'); + mockGit.revparse.mockResolvedValue('same-hash'); + + const result = await checkForExtensionUpdate(installMetadata); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + }); + + it('should return ERROR on git error', async () => { + const installMetadata: ExtensionInstallMetadata = { + type: 'git', + source: '/ext', + }; + mockGit.getRemotes.mockRejectedValue(new Error('git error')); + const result = await checkForExtensionUpdate(installMetadata); + 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(); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts new file mode 100644 index 0000000000..c1ac7365dd --- /dev/null +++ b/packages/cli/src/config/extensions/github.ts @@ -0,0 +1,346 @@ +/** + * @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 } 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'; + +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, + }, + ); + } +} + +function parseGitHubRepo(source: string): { owner: string; repo: string } { + // The source should be "owner/repo" or a full GitHub URL. + const parts = source.split('/'); + if (!source.includes('://') && parts.length !== 2) { + throw new Error( + `Invalid GitHub repository source: ${source}. Expected "owner/repo".`, + ); + } + const owner = parts.at(-2); + const repo = parts.at(-1)?.replace('.git', ''); + + if (!owner || !repo) { + throw new Error(`Invalid GitHub repository source: ${source}`); + } + return { owner, repo }; +} + +async function fetchFromGithub( + owner: string, + repo: string, + ref?: string, +): Promise<{ assets: Asset[]; tag_name: string }> { + 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( + installMetadata: ExtensionInstallMetadata, +): Promise { + if ( + installMetadata.type !== 'git' && + installMetadata.type !== 'github-release' + ) { + return ExtensionUpdateState.NOT_UPDATABLE; + } + try { + if (installMetadata.type === 'git') { + const git = simpleGit(installMetadata.source); + 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 = installMetadata.ref || 'HEAD'; + + const lsRemoteOutput = await git.listRemote([ + remotes[0].name, + 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; + } + if (remoteHash === localHash) { + return ExtensionUpdateState.UP_TO_DATE; + } + return ExtensionUpdateState.UPDATE_AVAILABLE; + } else { + const { source, ref } = installMetadata; + if (!source) { + return ExtensionUpdateState.ERROR; + } + const { owner, repo } = parseGitHubRepo(source); + + const releaseData = await fetchFromGithub( + owner, + repo, + installMetadata.ref, + ); + if (releaseData.tag_name !== ref) { + return ExtensionUpdateState.UPDATE_AVAILABLE; + } + return ExtensionUpdateState.UP_TO_DATE; + } + } catch (error) { + console.error( + `Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`, + ); + return ExtensionUpdateState.ERROR; + } +} + +export async function downloadFromGitHubRelease( + installMetadata: ExtensionInstallMetadata, + destination: string, +): Promise { + const { source, ref } = installMetadata; + const { owner, repo } = parseGitHubRepo(source); + + try { + const releaseData = await fetchFromGithub(owner, repo, ref); + if ( + !releaseData || + !releaseData.assets || + releaseData.assets.length === 0 + ) { + throw new Error( + `No release assets found for ${owner}/${repo} at tag ${ref}`, + ); + } + + const asset = findReleaseAsset(releaseData.assets); + if (!asset) { + throw new Error( + `No suitable release asset found for platform ${os.platform()}-${os.arch()}`, + ); + } + + const downloadedAssetPath = path.join( + destination, + path.basename(asset.browser_download_url), + ); + await downloadFile(asset.browser_download_url, 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 releaseData.tag_name; + } catch (error) { + throw new Error( + `Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`, + ); + } +} + +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<{ assets: Asset[]; tag_name: string }> { + 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 { assets: Asset[]; tag_name: string }); + }); + }) + .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) { + if (file.endsWith('.tar.gz')) { + execSync(`tar -xzf ${file} -C ${dest}`); + } else if (file.endsWith('.zip')) { + execSync(`unzip ${file} -d ${dest}`); + } else { + throw new Error(`Unsupported file extension for extraction: ${file}`); + } +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index a8a21d7b00..9ba06e3e06 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -117,8 +117,12 @@ 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'; ref?: string; }