mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 20:14:44 -07:00
Add support for auto-updating git extensions (#8511)
This commit is contained in:
@@ -12,7 +12,6 @@ import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
annotateActiveExtensions,
|
||||
checkForAllExtensionUpdates,
|
||||
disableExtension,
|
||||
enableExtension,
|
||||
installExtension,
|
||||
@@ -20,22 +19,19 @@ import {
|
||||
loadExtensions,
|
||||
performWorkspaceExtensionMigration,
|
||||
uninstallExtension,
|
||||
updateExtension,
|
||||
type Extension,
|
||||
} from './extension.js';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
type GeminiCLIExtension,
|
||||
type MCPServerConfig,
|
||||
ClearcutLogger,
|
||||
type Config,
|
||||
ExtensionUninstallEvent,
|
||||
type ExtensionInstallMetadata,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { SettingScope } from './settings.js';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { ExtensionUpdateState } from '../ui/state/extensions.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
|
||||
const mockGit = {
|
||||
@@ -58,9 +54,9 @@ vi.mock('simple-git', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const os = await importOriginal<typeof os>();
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
...os,
|
||||
...mockedOs,
|
||||
homedir: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -454,6 +450,86 @@ describe('extension tests', () => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('autoUpdate', () => {
|
||||
it('should be false if autoUpdate is not set in install metadata', () => {
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
[],
|
||||
tempHomeDir,
|
||||
);
|
||||
expect(
|
||||
activeExtensions.every(
|
||||
(e) => e.installMetadata?.autoUpdate === false,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true if autoUpdate is true in install metadata', () => {
|
||||
const extensionsWithAutoUpdate: Extension[] = extensions.map((e) => ({
|
||||
...e,
|
||||
installMetadata: {
|
||||
...e.installMetadata!,
|
||||
autoUpdate: true,
|
||||
},
|
||||
}));
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensionsWithAutoUpdate,
|
||||
[],
|
||||
tempHomeDir,
|
||||
);
|
||||
expect(
|
||||
activeExtensions.every((e) => e.installMetadata?.autoUpdate === true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect the per-extension settings from install metadata', () => {
|
||||
const extensionsWithAutoUpdate: Extension[] = [
|
||||
{
|
||||
path: '/path/to/ext1',
|
||||
config: { name: 'ext1', version: '1.0.0' },
|
||||
contextFiles: [],
|
||||
installMetadata: {
|
||||
source: 'test',
|
||||
type: 'local',
|
||||
autoUpdate: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/path/to/ext2',
|
||||
config: { name: 'ext2', version: '1.0.0' },
|
||||
contextFiles: [],
|
||||
installMetadata: {
|
||||
source: 'test',
|
||||
type: 'local',
|
||||
autoUpdate: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/path/to/ext3',
|
||||
config: { name: 'ext3', version: '1.0.0' },
|
||||
contextFiles: [],
|
||||
},
|
||||
];
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensionsWithAutoUpdate,
|
||||
[],
|
||||
tempHomeDir,
|
||||
);
|
||||
expect(
|
||||
activeExtensions.find((e) => e.name === 'ext1')?.installMetadata
|
||||
?.autoUpdate,
|
||||
).toBe(true);
|
||||
expect(
|
||||
activeExtensions.find((e) => e.name === 'ext2')?.installMetadata
|
||||
?.autoUpdate,
|
||||
).toBe(false);
|
||||
expect(
|
||||
activeExtensions.find((e) => e.name === 'ext3')?.installMetadata
|
||||
?.autoUpdate,
|
||||
).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('installExtension', () => {
|
||||
@@ -662,6 +738,32 @@ describe('extension tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should save the autoUpdate flag to the install metadata', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
await installExtension({
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
autoUpdate: true,
|
||||
});
|
||||
|
||||
expect(fs.existsSync(targetExtDir)).toBe(true);
|
||||
expect(fs.existsSync(metadataPath)).toBe(true);
|
||||
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
||||
expect(metadata).toEqual({
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
autoUpdate: true,
|
||||
});
|
||||
fs.rmSync(targetExtDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should ignore consent flow if not required', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
@@ -914,265 +1016,6 @@ describe('extension tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateExtension', () => {
|
||||
it('should update a git-installed extension', async () => {
|
||||
const gitUrl = 'https://github.com/google/gemini-extensions.git';
|
||||
const extensionName = 'gemini-extensions';
|
||||
const targetExtDir = path.join(userExtensionsDir, extensionName);
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
fs.mkdirSync(targetExtDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.0.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
metadataPath,
|
||||
JSON.stringify({ source: gitUrl, type: 'git' }),
|
||||
);
|
||||
|
||||
mockGit.clone.mockImplementation(async (_, destination) => {
|
||||
fs.mkdirSync(path.join(mockGit.path(), destination), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.1.0' }),
|
||||
);
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir: targetExtDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
const updateInfo = await updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(updateInfo).toEqual({
|
||||
name: 'gemini-extensions',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.1.0',
|
||||
});
|
||||
|
||||
const updatedConfig = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
expect(updatedConfig.version).toBe('1.1.0');
|
||||
});
|
||||
|
||||
it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => {
|
||||
const extensionName = 'test-extension';
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
|
||||
mockGit.clone.mockImplementation(async (_, destination) => {
|
||||
fs.mkdirSync(path.join(mockGit.path(), destination), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.1.0' }),
|
||||
);
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const setExtensionUpdateState = vi.fn();
|
||||
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
await updateExtension(extension, tempHomeDir, setExtensionUpdateState);
|
||||
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.UPDATING,
|
||||
);
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call setExtensionUpdateState with ERROR on failure', async () => {
|
||||
const extensionName = 'test-extension';
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
|
||||
mockGit.clone.mockRejectedValue(new Error('Git clone failed'));
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const setExtensionUpdateState = vi.fn();
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
await expect(
|
||||
updateExtension(extension, tempHomeDir, setExtensionUpdateState),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.UPDATING,
|
||||
);
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.ERROR,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForAllExtensionUpdates', () => {
|
||||
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 results = await checkForAllExtensionUpdates([extension], () => {});
|
||||
const result = results.get('test-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 results = await checkForAllExtensionUpdates([extension], () => {});
|
||||
const result = results.get('test-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',
|
||||
installMetadata: { source: '/local/path', type: 'local' },
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
|
||||
const results = await checkForAllExtensionUpdates([extension], () => {});
|
||||
const result = results.get('local-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 results = await checkForAllExtensionUpdates([extension], () => {});
|
||||
const result = results.get('error-extension');
|
||||
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableExtension', () => {
|
||||
it('should disable an extension at the user scope', () => {
|
||||
disableExtension('my-extension', SettingScope.User);
|
||||
@@ -1275,39 +1118,6 @@ describe('extension tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createExtension({
|
||||
extensionsDir = 'extensions-dir',
|
||||
name = 'my-extension',
|
||||
version = '1.0.0',
|
||||
addContextFile = false,
|
||||
contextFileName = undefined as string | undefined,
|
||||
mcpServers = {} as Record<string, MCPServerConfig>,
|
||||
installMetadata = undefined as ExtensionInstallMetadata | undefined,
|
||||
} = {}): string {
|
||||
const extDir = path.join(extensionsDir, name);
|
||||
fs.mkdirSync(extDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name, version, contextFileName, mcpServers }),
|
||||
);
|
||||
|
||||
if (addContextFile) {
|
||||
fs.writeFileSync(path.join(extDir, 'GEMINI.md'), 'context');
|
||||
}
|
||||
|
||||
if (contextFileName) {
|
||||
fs.writeFileSync(path.join(extDir, contextFileName), 'context');
|
||||
}
|
||||
|
||||
if (installMetadata) {
|
||||
fs.writeFileSync(
|
||||
path.join(extDir, INSTALL_METADATA_FILENAME),
|
||||
JSON.stringify(installMetadata),
|
||||
);
|
||||
}
|
||||
return extDir;
|
||||
}
|
||||
|
||||
function isEnabled(options: {
|
||||
name: string;
|
||||
configDir: string;
|
||||
|
||||
@@ -26,10 +26,8 @@ import { recursivelyHydrateStrings } from './extensions/variables.js';
|
||||
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';
|
||||
@@ -99,7 +97,7 @@ export function getWorkspaceExtensions(workspaceDir: string): Extension[] {
|
||||
return loadExtensionsFromDir(workspaceDir);
|
||||
}
|
||||
|
||||
async function copyExtension(
|
||||
export async function copyExtension(
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
@@ -263,7 +261,7 @@ export function loadExtension(context: LoadExtensionContext): Extension | null {
|
||||
}
|
||||
}
|
||||
|
||||
function loadInstallMetadata(
|
||||
export function loadInstallMetadata(
|
||||
extensionDir: string,
|
||||
): ExtensionInstallMetadata | undefined {
|
||||
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
|
||||
@@ -633,77 +631,6 @@ export function toOutputString(extension: Extension): string {
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function updateExtensionByName(
|
||||
extensionName: string,
|
||||
cwd: string = process.cwd(),
|
||||
extensions: GeminiCLIExtension[],
|
||||
setExtensionUpdateState: (updateState: ExtensionUpdateState) => void,
|
||||
): Promise<ExtensionUpdateInfo> {
|
||||
const extension = extensions.find(
|
||||
(installed) => installed.name === extensionName,
|
||||
);
|
||||
if (!extension) {
|
||||
throw new Error(
|
||||
`Extension "${extensionName}" not found. Run gemini extensions list to see available extensions.`,
|
||||
);
|
||||
}
|
||||
return await updateExtension(extension, cwd, setExtensionUpdateState);
|
||||
}
|
||||
|
||||
export async function updateExtension(
|
||||
extension: GeminiCLIExtension,
|
||||
cwd: string = process.cwd(),
|
||||
setExtensionUpdateState: (updateState: ExtensionUpdateState) => void,
|
||||
): Promise<ExtensionUpdateInfo> {
|
||||
const installMetadata = loadInstallMetadata(extension.path);
|
||||
|
||||
if (!installMetadata?.type) {
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
throw new Error(
|
||||
`Extension ${extension.name} cannot be updated, type is unknown.`,
|
||||
);
|
||||
}
|
||||
if (installMetadata?.type === 'link') {
|
||||
setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE);
|
||||
throw new Error(`Extension is linked so does not need to be updated`);
|
||||
}
|
||||
setExtensionUpdateState(ExtensionUpdateState.UPDATING);
|
||||
const originalVersion = extension.version;
|
||||
|
||||
const tempDir = await ExtensionStorage.createTmpDir();
|
||||
try {
|
||||
await copyExtension(extension.path, tempDir);
|
||||
await uninstallExtension(extension.name, cwd);
|
||||
await installExtension(installMetadata, false, cwd);
|
||||
|
||||
const updatedExtensionStorage = new ExtensionStorage(extension.name);
|
||||
const updatedExtension = loadExtension({
|
||||
extensionDir: updatedExtensionStorage.getExtensionDir(),
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
if (!updatedExtension) {
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
throw new Error('Updated extension not found after installation.');
|
||||
}
|
||||
const updatedVersion = updatedExtension.config.version;
|
||||
setExtensionUpdateState(ExtensionUpdateState.UPDATED_NEEDS_RESTART);
|
||||
return {
|
||||
name: extension.name,
|
||||
originalVersion,
|
||||
updatedVersion,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error updating extension, rolling back. ${getErrorMessage(e)}`,
|
||||
);
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
await copyExtension(tempDir, extension.path);
|
||||
throw e;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function disableExtension(
|
||||
name: string,
|
||||
scope: SettingScope,
|
||||
@@ -734,54 +661,3 @@ export function enableExtension(
|
||||
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
|
||||
manager.enable(name, true, scopePath);
|
||||
}
|
||||
|
||||
export async function updateAllUpdatableExtensions(
|
||||
cwd: string = process.cwd(),
|
||||
extensions: GeminiCLIExtension[],
|
||||
extensionsState: Map<string, ExtensionUpdateState>,
|
||||
setExtensionsUpdateState: (
|
||||
updateState: Map<string, ExtensionUpdateState>,
|
||||
) => void,
|
||||
): Promise<ExtensionUpdateInfo[]> {
|
||||
return await Promise.all(
|
||||
extensions
|
||||
.filter(
|
||||
(extension) =>
|
||||
extensionsState.get(extension.name) ===
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
)
|
||||
.map((extension) =>
|
||||
updateExtension(extension, cwd, (updateState) => {
|
||||
const newState = new Map(extensionsState);
|
||||
newState.set(extension.name, updateState);
|
||||
setExtensionsUpdateState(newState);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateCheckResult {
|
||||
state: ExtensionUpdateState;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function checkForAllExtensionUpdates(
|
||||
extensions: GeminiCLIExtension[],
|
||||
setExtensionsUpdateState: (
|
||||
updateState: Map<string, ExtensionUpdateState>,
|
||||
) => void,
|
||||
): Promise<Map<string, ExtensionUpdateState>> {
|
||||
const finalState = new Map<string, ExtensionUpdateState>();
|
||||
for (const extension of extensions) {
|
||||
if (!extension.installMetadata) {
|
||||
finalState.set(extension.name, ExtensionUpdateState.NOT_UPDATABLE);
|
||||
continue;
|
||||
}
|
||||
finalState.set(
|
||||
extension.name,
|
||||
await checkForExtensionUpdate(extension.installMetadata),
|
||||
);
|
||||
}
|
||||
setExtensionsUpdateState(finalState);
|
||||
return finalState;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
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';
|
||||
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
|
||||
const mockPlatform = vi.hoisted(() => vi.fn());
|
||||
const mockArch = vi.hoisted(() => vi.fn());
|
||||
@@ -122,28 +122,54 @@ describe('git extension helpers', () => {
|
||||
});
|
||||
|
||||
it('should return NOT_UPDATABLE for non-git extensions', async () => {
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
type: 'local',
|
||||
source: '',
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: '',
|
||||
},
|
||||
};
|
||||
const result = await checkForExtensionUpdate(installMetadata);
|
||||
let result: ExtensionUpdateState | undefined = undefined;
|
||||
await checkForExtensionUpdate(
|
||||
extension,
|
||||
(newState) => (result = newState),
|
||||
);
|
||||
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
|
||||
});
|
||||
|
||||
it('should return ERROR if no remotes found', async () => {
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
type: 'git',
|
||||
source: '',
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: '',
|
||||
},
|
||||
};
|
||||
mockGit.getRemotes.mockResolvedValue([]);
|
||||
const result = await checkForExtensionUpdate(installMetadata);
|
||||
let result: ExtensionUpdateState | undefined = undefined;
|
||||
await checkForExtensionUpdate(
|
||||
extension,
|
||||
(newState) => (result = newState),
|
||||
);
|
||||
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||
});
|
||||
|
||||
it('should return UPDATE_AVAILABLE when remote hash is different', async () => {
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
type: 'git',
|
||||
source: '/ext',
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'my/ext',
|
||||
},
|
||||
};
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||
@@ -151,14 +177,24 @@ describe('git extension helpers', () => {
|
||||
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
|
||||
mockGit.revparse.mockResolvedValue('local-hash');
|
||||
|
||||
const result = await checkForExtensionUpdate(installMetadata);
|
||||
let result: ExtensionUpdateState | undefined = undefined;
|
||||
await checkForExtensionUpdate(
|
||||
extension,
|
||||
(newState) => (result = newState),
|
||||
);
|
||||
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',
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'my/ext',
|
||||
},
|
||||
};
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||
@@ -166,17 +202,32 @@ describe('git extension helpers', () => {
|
||||
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
|
||||
mockGit.revparse.mockResolvedValue('same-hash');
|
||||
|
||||
const result = await checkForExtensionUpdate(installMetadata);
|
||||
let result: ExtensionUpdateState | undefined = undefined;
|
||||
await checkForExtensionUpdate(
|
||||
extension,
|
||||
(newState) => (result = newState),
|
||||
);
|
||||
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
|
||||
});
|
||||
|
||||
it('should return ERROR on git error', async () => {
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
type: 'git',
|
||||
source: '/ext',
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'my/ext',
|
||||
},
|
||||
};
|
||||
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
|
||||
const result = await checkForExtensionUpdate(installMetadata);
|
||||
|
||||
let result: ExtensionUpdateState | undefined = undefined;
|
||||
await checkForExtensionUpdate(
|
||||
extension,
|
||||
(newState) => (result = newState),
|
||||
);
|
||||
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
|
||||
import { simpleGit } from 'simple-git';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import type { ExtensionInstallMetadata } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
ExtensionInstallMetadata,
|
||||
GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import * as os from 'node:os';
|
||||
import * as https from 'node:https';
|
||||
@@ -110,39 +113,44 @@ async function fetchFromGithub(
|
||||
}
|
||||
|
||||
export async function checkForExtensionUpdate(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
): Promise<ExtensionUpdateState> {
|
||||
extension: GeminiCLIExtension,
|
||||
setExtensionUpdateState: (updateState: ExtensionUpdateState) => void,
|
||||
): Promise<void> {
|
||||
setExtensionUpdateState(ExtensionUpdateState.CHECKING_FOR_UPDATES);
|
||||
const installMetadata = extension.installMetadata;
|
||||
if (
|
||||
installMetadata.type !== 'git' &&
|
||||
installMetadata.type !== 'github-release'
|
||||
!installMetadata ||
|
||||
(installMetadata.type !== 'git' &&
|
||||
installMetadata.type !== 'github-release')
|
||||
) {
|
||||
return ExtensionUpdateState.NOT_UPDATABLE;
|
||||
setExtensionUpdateState(ExtensionUpdateState.NOT_UPDATABLE);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (installMetadata.type === 'git') {
|
||||
const git = simpleGit(installMetadata.source);
|
||||
const git = simpleGit(extension.path);
|
||||
const remotes = await git.getRemotes(true);
|
||||
if (remotes.length === 0) {
|
||||
console.error('No git remotes found.');
|
||||
return ExtensionUpdateState.ERROR;
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
const remoteUrl = remotes[0].refs.fetch;
|
||||
if (!remoteUrl) {
|
||||
console.error(`No fetch URL found for git remote ${remotes[0].name}.`);
|
||||
return ExtensionUpdateState.ERROR;
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the ref to check on the remote.
|
||||
const refToCheck = installMetadata.ref || 'HEAD';
|
||||
|
||||
const lsRemoteOutput = await git.listRemote([
|
||||
remotes[0].name,
|
||||
refToCheck,
|
||||
]);
|
||||
const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]);
|
||||
|
||||
if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') {
|
||||
console.error(`Git ref ${refToCheck} not found.`);
|
||||
return ExtensionUpdateState.ERROR;
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteHash = lsRemoteOutput.split('\t')[0];
|
||||
@@ -152,16 +160,21 @@ export async function checkForExtensionUpdate(
|
||||
console.error(
|
||||
`Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`,
|
||||
);
|
||||
return ExtensionUpdateState.ERROR;
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
if (remoteHash === localHash) {
|
||||
return ExtensionUpdateState.UP_TO_DATE;
|
||||
setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE);
|
||||
return;
|
||||
}
|
||||
return ExtensionUpdateState.UPDATE_AVAILABLE;
|
||||
setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||
return;
|
||||
} else {
|
||||
const { source, ref } = installMetadata;
|
||||
if (!source) {
|
||||
return ExtensionUpdateState.ERROR;
|
||||
console.error(`No "source" provided for extension.`);
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
const { owner, repo } = parseGitHubRepoForReleases(source);
|
||||
|
||||
@@ -171,15 +184,18 @@ export async function checkForExtensionUpdate(
|
||||
installMetadata.ref,
|
||||
);
|
||||
if (releaseData.tag_name !== ref) {
|
||||
return ExtensionUpdateState.UPDATE_AVAILABLE;
|
||||
setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||
return;
|
||||
}
|
||||
return ExtensionUpdateState.UP_TO_DATE;
|
||||
setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`,
|
||||
);
|
||||
return ExtensionUpdateState.ERROR;
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
annotateActiveExtensions,
|
||||
loadExtension,
|
||||
} from '../extension.js';
|
||||
import { checkForAllExtensionUpdates, updateExtension } from './update.js';
|
||||
import { GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
import { isWorkspaceTrusted } from '../trustedFolders.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import { createExtension } from '../../test-utils/createExtension.js';
|
||||
|
||||
const mockGit = {
|
||||
clone: vi.fn(),
|
||||
getRemotes: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
listRemote: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
// Not a part of the actual API, but we need to use this to do the correct
|
||||
// file system interactions.
|
||||
path: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
simpleGit: vi.fn((path: string) => {
|
||||
mockGit.path.mockReturnValue(path);
|
||||
return mockGit;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
...mockedOs,
|
||||
homedir: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../trustedFolders.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../trustedFolders.js')>();
|
||||
return {
|
||||
...actual,
|
||||
isWorkspaceTrusted: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mockLogExtensionInstallEvent = vi.fn();
|
||||
const mockLogExtensionUninstallEvent = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
ClearcutLogger: {
|
||||
getInstance: vi.fn(() => ({
|
||||
logExtensionInstallEvent: mockLogExtensionInstallEvent,
|
||||
logExtensionUninstallEvent: mockLogExtensionUninstallEvent,
|
||||
})),
|
||||
},
|
||||
Config: vi.fn(),
|
||||
ExtensionInstallEvent: vi.fn(),
|
||||
ExtensionUninstallEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('update tests', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
let userExtensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
|
||||
// Clean up before each test
|
||||
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
Object.values(mockGit).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('updateExtension', () => {
|
||||
it('should update a git-installed extension', async () => {
|
||||
const gitUrl = 'https://github.com/google/gemini-extensions.git';
|
||||
const extensionName = 'gemini-extensions';
|
||||
const targetExtDir = path.join(userExtensionsDir, extensionName);
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
fs.mkdirSync(targetExtDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.0.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
metadataPath,
|
||||
JSON.stringify({ source: gitUrl, type: 'git' }),
|
||||
);
|
||||
|
||||
mockGit.clone.mockImplementation(async (_, destination) => {
|
||||
fs.mkdirSync(path.join(mockGit.path(), destination), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.1.0' }),
|
||||
);
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir: targetExtDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
const updateInfo = await updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(updateInfo).toEqual({
|
||||
name: 'gemini-extensions',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.1.0',
|
||||
});
|
||||
|
||||
const updatedConfig = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
expect(updatedConfig.version).toBe('1.1.0');
|
||||
});
|
||||
|
||||
it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => {
|
||||
const extensionName = 'test-extension';
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
|
||||
mockGit.clone.mockImplementation(async (_, destination) => {
|
||||
fs.mkdirSync(path.join(mockGit.path(), destination), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.1.0' }),
|
||||
);
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const setExtensionUpdateState = vi.fn();
|
||||
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
await updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
setExtensionUpdateState,
|
||||
);
|
||||
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.UPDATING,
|
||||
);
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call setExtensionUpdateState with ERROR on failure', async () => {
|
||||
const extensionName = 'test-extension';
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
|
||||
mockGit.clone.mockRejectedValue(new Error('Git clone failed'));
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const setExtensionUpdateState = vi.fn();
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
await expect(
|
||||
updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
setExtensionUpdateState,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.UPDATING,
|
||||
);
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.ERROR,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForAllExtensionUpdates', () => {
|
||||
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');
|
||||
|
||||
let extensionState = new Map();
|
||||
const results = await checkForAllExtensionUpdates(
|
||||
[extension],
|
||||
extensionState,
|
||||
(newState) => {
|
||||
if (typeof newState === 'function') {
|
||||
newState(extensionState);
|
||||
} else {
|
||||
extensionState = newState;
|
||||
}
|
||||
},
|
||||
);
|
||||
const result = results.get('test-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');
|
||||
|
||||
let extensionState = new Map();
|
||||
const results = await checkForAllExtensionUpdates(
|
||||
[extension],
|
||||
extensionState,
|
||||
(newState) => {
|
||||
if (typeof newState === 'function') {
|
||||
newState(extensionState);
|
||||
} else {
|
||||
extensionState = newState;
|
||||
}
|
||||
},
|
||||
);
|
||||
const result = results.get('test-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',
|
||||
installMetadata: { source: '/local/path', type: 'local' },
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
let extensionState = new Map();
|
||||
const results = await checkForAllExtensionUpdates(
|
||||
[extension],
|
||||
extensionState,
|
||||
(newState) => {
|
||||
if (typeof newState === 'function') {
|
||||
newState(extensionState);
|
||||
} else {
|
||||
extensionState = newState;
|
||||
}
|
||||
},
|
||||
);
|
||||
const result = results.get('local-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'));
|
||||
|
||||
let extensionState = new Map();
|
||||
const results = await checkForAllExtensionUpdates(
|
||||
[extension],
|
||||
extensionState,
|
||||
(newState) => {
|
||||
if (typeof newState === 'function') {
|
||||
newState(extensionState);
|
||||
} else {
|
||||
extensionState = newState;
|
||||
}
|
||||
},
|
||||
);
|
||||
const result = results.get('error-extension');
|
||||
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import { type Dispatch, type SetStateAction } from 'react';
|
||||
import {
|
||||
copyExtension,
|
||||
installExtension,
|
||||
uninstallExtension,
|
||||
loadExtension,
|
||||
loadInstallMetadata,
|
||||
ExtensionStorage,
|
||||
} from '../extension.js';
|
||||
import { checkForExtensionUpdate } from './github.js';
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
name: string;
|
||||
originalVersion: string;
|
||||
updatedVersion: string;
|
||||
}
|
||||
|
||||
export async function updateExtension(
|
||||
extension: GeminiCLIExtension,
|
||||
cwd: string = process.cwd(),
|
||||
currentState: ExtensionUpdateState,
|
||||
setExtensionUpdateState: (updateState: ExtensionUpdateState) => void,
|
||||
): Promise<ExtensionUpdateInfo | undefined> {
|
||||
if (currentState === ExtensionUpdateState.UPDATING) {
|
||||
return undefined;
|
||||
}
|
||||
setExtensionUpdateState(ExtensionUpdateState.UPDATING);
|
||||
const installMetadata = loadInstallMetadata(extension.path);
|
||||
|
||||
if (!installMetadata?.type) {
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
throw new Error(
|
||||
`Extension ${extension.name} cannot be updated, type is unknown.`,
|
||||
);
|
||||
}
|
||||
if (installMetadata?.type === 'link') {
|
||||
setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE);
|
||||
throw new Error(`Extension is linked so does not need to be updated`);
|
||||
}
|
||||
const originalVersion = extension.version;
|
||||
|
||||
const tempDir = await ExtensionStorage.createTmpDir();
|
||||
try {
|
||||
await copyExtension(extension.path, tempDir);
|
||||
await uninstallExtension(extension.name, cwd);
|
||||
await installExtension(installMetadata, false, cwd);
|
||||
|
||||
const updatedExtensionStorage = new ExtensionStorage(extension.name);
|
||||
const updatedExtension = loadExtension({
|
||||
extensionDir: updatedExtensionStorage.getExtensionDir(),
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
if (!updatedExtension) {
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
throw new Error('Updated extension not found after installation.');
|
||||
}
|
||||
const updatedVersion = updatedExtension.config.version;
|
||||
setExtensionUpdateState(ExtensionUpdateState.UPDATED_NEEDS_RESTART);
|
||||
return {
|
||||
name: extension.name,
|
||||
originalVersion,
|
||||
updatedVersion,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error updating extension, rolling back. ${getErrorMessage(e)}`,
|
||||
);
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
await copyExtension(tempDir, extension.path);
|
||||
throw e;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAllUpdatableExtensions(
|
||||
cwd: string = process.cwd(),
|
||||
extensions: GeminiCLIExtension[],
|
||||
extensionsState: Map<string, ExtensionUpdateState>,
|
||||
setExtensionsUpdateState: Dispatch<
|
||||
SetStateAction<Map<string, ExtensionUpdateState>>
|
||||
>,
|
||||
): Promise<ExtensionUpdateInfo[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
extensions
|
||||
.filter(
|
||||
(extension) =>
|
||||
extensionsState.get(extension.name) ===
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
)
|
||||
.map((extension) =>
|
||||
updateExtension(
|
||||
extension,
|
||||
cwd,
|
||||
extensionsState.get(extension.name)!,
|
||||
(updateState) => {
|
||||
setExtensionsUpdateState((prev) => {
|
||||
const finalState = new Map(prev);
|
||||
finalState.set(extension.name, updateState);
|
||||
return finalState;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
).filter((updateInfo) => !!updateInfo);
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateCheckResult {
|
||||
state: ExtensionUpdateState;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function checkForAllExtensionUpdates(
|
||||
extensions: GeminiCLIExtension[],
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateState>,
|
||||
setExtensionsUpdateState: Dispatch<
|
||||
SetStateAction<Map<string, ExtensionUpdateState>>
|
||||
>,
|
||||
): Promise<Map<string, ExtensionUpdateState>> {
|
||||
for (const extension of extensions) {
|
||||
const initialState = extensionsUpdateState.get(extension.name);
|
||||
if (initialState === undefined) {
|
||||
if (!extension.installMetadata) {
|
||||
setExtensionsUpdateState((prev) => {
|
||||
extensionsUpdateState = new Map(prev);
|
||||
extensionsUpdateState.set(
|
||||
extension.name,
|
||||
ExtensionUpdateState.NOT_UPDATABLE,
|
||||
);
|
||||
return extensionsUpdateState;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await checkForExtensionUpdate(extension, (updatedState) => {
|
||||
setExtensionsUpdateState((prev) => {
|
||||
extensionsUpdateState = new Map(prev);
|
||||
extensionsUpdateState.set(extension.name, updatedState);
|
||||
return extensionsUpdateState;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return extensionsUpdateState;
|
||||
}
|
||||
Reference in New Issue
Block a user