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

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