Add simple extensions release flow support (#8498)

This commit is contained in:
christine betts
2025-09-17 18:14:01 -04:00
committed by GitHub
parent 13a65ad94f
commit eddd13d70e
8 changed files with 726 additions and 243 deletions

View File

@@ -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
```

View File

@@ -5,10 +5,8 @@
*/ */
import type { CommandModule } from 'yargs'; import type { CommandModule } from 'yargs';
import { import { installExtension } from '../../config/extension.js';
installExtension, import type { ExtensionInstallMetadata } from '@google/gemini-cli-core';
type ExtensionInstallMetadata,
} from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js'; import { getErrorMessage } from '../../utils/errors.js';

View File

@@ -5,10 +5,8 @@
*/ */
import type { CommandModule } from 'yargs'; import type { CommandModule } from 'yargs';
import { import { installExtension } from '../../config/extension.js';
installExtension, import type { ExtensionInstallMetadata } from '@google/gemini-cli-core';
type ExtensionInstallMetadata,
} from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js'; import { getErrorMessage } from '../../utils/errors.js';

View File

@@ -13,7 +13,6 @@ import {
INSTALL_METADATA_FILENAME, INSTALL_METADATA_FILENAME,
annotateActiveExtensions, annotateActiveExtensions,
checkForAllExtensionUpdates, checkForAllExtensionUpdates,
checkForExtensionUpdate,
disableExtension, disableExtension,
enableExtension, enableExtension,
installExtension, installExtension,
@@ -23,7 +22,6 @@ import {
uninstallExtension, uninstallExtension,
updateExtension, updateExtension,
type Extension, type Extension,
type ExtensionInstallMetadata,
} from './extension.js'; } from './extension.js';
import { import {
GEMINI_DIR, GEMINI_DIR,
@@ -32,6 +30,7 @@ import {
ClearcutLogger, ClearcutLogger,
type Config, type Config,
ExtensionUninstallEvent, ExtensionUninstallEvent,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { SettingScope } from './settings.js'; 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', () => { describe('disableExtension', () => {
it('should disable an extension at the user scope', () => { it('should disable an extension at the user scope', () => {
disableExtension('my-extension', SettingScope.User); disableExtension('my-extension', SettingScope.User);

View File

@@ -7,6 +7,7 @@
import type { import type {
MCPServerConfig, MCPServerConfig,
GeminiCLIExtension, GeminiCLIExtension,
ExtensionInstallMetadata,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { import {
GEMINI_DIR, GEMINI_DIR,
@@ -19,7 +20,6 @@ import {
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import * as os from 'node:os'; import * as os from 'node:os';
import { simpleGit } from 'simple-git';
import { SettingScope, loadSettings } from '../config/settings.js'; import { SettingScope, loadSettings } from '../config/settings.js';
import { getErrorMessage } from '../utils/errors.js'; import { getErrorMessage } from '../utils/errors.js';
import { recursivelyHydrateStrings } from './extensions/variables.js'; import { recursivelyHydrateStrings } from './extensions/variables.js';
@@ -27,6 +27,11 @@ import { isWorkspaceTrusted } from './trustedFolders.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { ExtensionUpdateState } from '../ui/state/extensions.js'; import { ExtensionUpdateState } from '../ui/state/extensions.js';
import {
cloneFromGit,
checkForExtensionUpdate,
downloadFromGitHubRelease,
} from './extensions/github.js';
import type { LoadExtensionContext } from './extensions/variableSchema.js'; import type { LoadExtensionContext } from './extensions/variableSchema.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
@@ -50,12 +55,6 @@ export interface ExtensionConfig {
excludeTools?: string[]; excludeTools?: string[];
} }
export interface ExtensionInstallMetadata {
source: string;
type: 'git' | 'local' | 'link';
ref?: string;
}
export interface ExtensionUpdateInfo { export interface ExtensionUpdateInfo {
name: string; name: string;
originalVersion: string; originalVersion: string;
@@ -301,7 +300,6 @@ export function annotateActiveExtensions(
const manager = new ExtensionEnablementManager( const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(), ExtensionStorage.getUserExtensionsDir(),
); );
const annotatedExtensions: GeminiCLIExtension[] = []; const annotatedExtensions: GeminiCLIExtension[] = [];
if (enabledExtensionNames.length === 0) { if (enabledExtensionNames.length === 0) {
return extensions.map((extension) => ({ return extensions.map((extension) => ({
@@ -309,9 +307,7 @@ export function annotateActiveExtensions(
version: extension.config.version, version: extension.config.version,
isActive: manager.isEnabled(extension.config.name, workspaceDir), isActive: manager.isEnabled(extension.config.name, workspaceDir),
path: extension.path, path: extension.path,
source: extension.installMetadata?.source, installMetadata: extension.installMetadata,
type: extension.installMetadata?.type,
ref: extension.installMetadata?.ref,
})); }));
} }
@@ -328,9 +324,7 @@ export function annotateActiveExtensions(
version: extension.config.version, version: extension.config.version,
isActive: false, isActive: false,
path: extension.path, path: extension.path,
source: extension.installMetadata?.source, installMetadata: extension.installMetadata,
type: extension.installMetadata?.type,
ref: extension.installMetadata?.ref,
})); }));
} }
@@ -349,6 +343,7 @@ export function annotateActiveExtensions(
version: extension.config.version, version: extension.config.version,
isActive, isActive,
path: extension.path, path: extension.path,
installMetadata: extension.installMetadata,
}); });
} }
@@ -359,43 +354,6 @@ export function annotateActiveExtensions(
return annotatedExtensions; 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<void> {
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 * Asks users a prompt and awaits for a y/n response
* @param prompt A yes/no prompt to ask the user * @param prompt A yes/no prompt to ask the user
@@ -445,9 +403,22 @@ export async function installExtension(
let tempDir: string | undefined; let tempDir: string | undefined;
if (installMetadata.type === 'git') { if (
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
tempDir = await ExtensionStorage.createTmpDir(); 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; localSourcePath = tempDir;
} else if ( } else if (
installMetadata.type === 'local' || installMetadata.type === 'local' ||
@@ -488,7 +459,11 @@ export async function installExtension(
} }
await fs.promises.mkdir(destinationPath, { recursive: true }); 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); 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) { async function requestConsent(extensionConfig: ExtensionConfig) {
const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {}); const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {});
if (mcpServerEntries.length) { if (mcpServerEntries.length) {
@@ -662,13 +652,15 @@ export async function updateExtension(
cwd: string = process.cwd(), cwd: string = process.cwd(),
setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, setExtensionUpdateState: (updateState: ExtensionUpdateState) => void,
): Promise<ExtensionUpdateInfo> { ): Promise<ExtensionUpdateInfo> {
if (!extension.type) { const installMetadata = loadInstallMetadata(extension.path);
if (!installMetadata?.type) {
setExtensionUpdateState(ExtensionUpdateState.ERROR); setExtensionUpdateState(ExtensionUpdateState.ERROR);
throw new Error( throw new Error(
`Extension ${extension.name} cannot be updated, type is unknown.`, `Extension ${extension.name} cannot be updated, type is unknown.`,
); );
} }
if (extension.type === 'link') { if (installMetadata?.type === 'link') {
setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE);
throw new Error(`Extension is linked so does not need to be updated`); throw new Error(`Extension is linked so does not need to be updated`);
} }
@@ -679,15 +671,7 @@ export async function updateExtension(
try { try {
await copyExtension(extension.path, tempDir); await copyExtension(extension.path, tempDir);
await uninstallExtension(extension.name, cwd); await uninstallExtension(extension.name, cwd);
await installExtension( await installExtension(installMetadata, false, cwd);
{
source: extension.source!,
type: extension.type,
ref: extension.ref,
},
false,
cwd,
);
const updatedExtensionStorage = new ExtensionStorage(extension.name); const updatedExtensionStorage = new ExtensionStorage(extension.name);
const updatedExtension = loadExtension({ const updatedExtension = loadExtension({
@@ -786,61 +770,15 @@ export async function checkForAllExtensionUpdates(
): Promise<Map<string, ExtensionUpdateState>> { ): Promise<Map<string, ExtensionUpdateState>> {
const finalState = new Map<string, ExtensionUpdateState>(); const finalState = new Map<string, ExtensionUpdateState>();
for (const extension of extensions) { 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); setExtensionsUpdateState(finalState);
return finalState; return finalState;
} }
export async function checkForExtensionUpdate(
extension: GeminiCLIExtension,
): Promise<ExtensionUpdateState> {
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;
}
}

View File

@@ -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<typeof os>();
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();
});
});
});

View File

@@ -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<void> {
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<ExtensionUpdateState> {
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<string> {
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<void> {
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}`);
}
}

View File

@@ -117,8 +117,12 @@ export interface GeminiCLIExtension {
version: string; version: string;
isActive: boolean; isActive: boolean;
path: string; path: string;
source?: string; installMetadata?: ExtensionInstallMetadata;
type?: 'git' | 'local' | 'link'; }
export interface ExtensionInstallMetadata {
source: string;
type: 'git' | 'local' | 'link' | 'github-release';
ref?: string; ref?: string;
} }