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

@@ -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';

View File

@@ -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';

View File

@@ -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);

View File

@@ -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<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
* @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<ExtensionUpdateInfo> {
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<Map<string, ExtensionUpdateState>> {
const finalState = new Map<string, ExtensionUpdateState>();
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<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}`);
}
}