mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-03 19:17:03 +00:00
Add simple extensions release flow support (#8498)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user