mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-09 17:40:44 -07:00
Add support for auto-updating git extensions (#8511)
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, type MockInstance } from 'vitest';
|
import { describe, it, expect, vi, type MockInstance } from 'vitest';
|
||||||
import { handleInstall, installCommand } from './install.js';
|
import { handleInstall, installCommand } from './install.js';
|
||||||
import yargs from 'yargs';
|
import yargs from 'yargs';
|
||||||
|
|
||||||
@@ -32,6 +32,15 @@ describe('extensions install command', () => {
|
|||||||
validationParser.parse('install some-url --path /some/path'),
|
validationParser.parse('install some-url --path /some/path'),
|
||||||
).toThrow('Arguments source and path are mutually exclusive');
|
).toThrow('Arguments source and path are mutually exclusive');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should fail if both auto update and local path are provided', () => {
|
||||||
|
const validationParser = yargs([]).command(installCommand).fail(false);
|
||||||
|
expect(() =>
|
||||||
|
validationParser.parse(
|
||||||
|
'install some-url --path /some/path --auto-update',
|
||||||
|
),
|
||||||
|
).toThrow('Arguments path and auto-update are mutually exclusive');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleInstall', () => {
|
describe('handleInstall', () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface InstallArgs {
|
|||||||
source?: string;
|
source?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
ref?: string;
|
ref?: string;
|
||||||
|
autoUpdate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleInstall(args: InstallArgs) {
|
export async function handleInstall(args: InstallArgs) {
|
||||||
@@ -32,6 +33,7 @@ export async function handleInstall(args: InstallArgs) {
|
|||||||
source,
|
source,
|
||||||
type: 'git',
|
type: 'git',
|
||||||
ref: args.ref,
|
ref: args.ref,
|
||||||
|
autoUpdate: args.autoUpdate,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`The source "${source}" is not a valid URL format.`);
|
throw new Error(`The source "${source}" is not a valid URL format.`);
|
||||||
@@ -40,6 +42,7 @@ export async function handleInstall(args: InstallArgs) {
|
|||||||
installMetadata = {
|
installMetadata = {
|
||||||
source: args.path,
|
source: args.path,
|
||||||
type: 'local',
|
type: 'local',
|
||||||
|
autoUpdate: args.autoUpdate,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// This should not be reached due to the yargs check.
|
// This should not be reached due to the yargs check.
|
||||||
@@ -71,8 +74,13 @@ export const installCommand: CommandModule = {
|
|||||||
describe: 'The git ref to install from.',
|
describe: 'The git ref to install from.',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
})
|
})
|
||||||
|
.option('auto-update', {
|
||||||
|
describe: 'Enable auto-update for this extension.',
|
||||||
|
type: 'boolean',
|
||||||
|
})
|
||||||
.conflicts('source', 'path')
|
.conflicts('source', 'path')
|
||||||
.conflicts('path', 'ref')
|
.conflicts('path', 'ref')
|
||||||
|
.conflicts('path', 'auto-update')
|
||||||
.check((argv) => {
|
.check((argv) => {
|
||||||
if (!argv.source && !argv.path) {
|
if (!argv.source && !argv.path) {
|
||||||
throw new Error('Either source or --path must be provided.');
|
throw new Error('Either source or --path must be provided.');
|
||||||
@@ -84,6 +92,7 @@ export const installCommand: CommandModule = {
|
|||||||
source: argv['source'] as string | undefined,
|
source: argv['source'] as string | undefined,
|
||||||
path: argv['path'] as string | undefined,
|
path: argv['path'] as string | undefined,
|
||||||
ref: argv['ref'] as string | undefined,
|
ref: argv['ref'] as string | undefined,
|
||||||
|
autoUpdate: argv['auto-update'] as boolean | undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,14 +6,18 @@
|
|||||||
|
|
||||||
import type { CommandModule } from 'yargs';
|
import type { CommandModule } from 'yargs';
|
||||||
import {
|
import {
|
||||||
updateExtensionByName,
|
|
||||||
updateAllUpdatableExtensions,
|
|
||||||
type ExtensionUpdateInfo,
|
|
||||||
loadExtensions,
|
loadExtensions,
|
||||||
annotateActiveExtensions,
|
annotateActiveExtensions,
|
||||||
checkForAllExtensionUpdates,
|
|
||||||
} from '../../config/extension.js';
|
} from '../../config/extension.js';
|
||||||
|
import {
|
||||||
|
updateAllUpdatableExtensions,
|
||||||
|
type ExtensionUpdateInfo,
|
||||||
|
checkForAllExtensionUpdates,
|
||||||
|
updateExtension,
|
||||||
|
} from '../../config/extensions/update.js';
|
||||||
|
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
|
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||||
|
|
||||||
interface UpdateArgs {
|
interface UpdateArgs {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -37,7 +41,7 @@ export async function handleUpdate(args: UpdateArgs) {
|
|||||||
let updateInfos = await updateAllUpdatableExtensions(
|
let updateInfos = await updateAllUpdatableExtensions(
|
||||||
workingDir,
|
workingDir,
|
||||||
extensions,
|
extensions,
|
||||||
await checkForAllExtensionUpdates(extensions, (_) => {}),
|
await checkForAllExtensionUpdates(extensions, new Map(), (_) => {}),
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
updateInfos = updateInfos.filter(
|
updateInfos = updateInfos.filter(
|
||||||
@@ -54,13 +58,34 @@ export async function handleUpdate(args: UpdateArgs) {
|
|||||||
}
|
}
|
||||||
if (args.name)
|
if (args.name)
|
||||||
try {
|
try {
|
||||||
// TODO(chrstnb): we should list extensions if the requested extension is not installed.
|
const extension = extensions.find(
|
||||||
const updatedExtensionInfo = await updateExtensionByName(
|
(extension) => extension.name === args.name,
|
||||||
args.name,
|
|
||||||
workingDir,
|
|
||||||
extensions,
|
|
||||||
() => {},
|
|
||||||
);
|
);
|
||||||
|
if (!extension) {
|
||||||
|
console.log(`Extension "${args.name}" not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let updateState: ExtensionUpdateState | undefined;
|
||||||
|
if (!extension.installMetadata) {
|
||||||
|
console.log(
|
||||||
|
`Unable to install extension "${args.name}" due to missing install metadata`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await checkForExtensionUpdate(extension, (newState) => {
|
||||||
|
updateState = newState;
|
||||||
|
});
|
||||||
|
if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) {
|
||||||
|
console.log(`Extension "${args.name}" is already up to date.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TODO(chrstnb): we should list extensions if the requested extension is not installed.
|
||||||
|
const updatedExtensionInfo = (await updateExtension(
|
||||||
|
extension,
|
||||||
|
workingDir,
|
||||||
|
updateState,
|
||||||
|
() => {},
|
||||||
|
))!;
|
||||||
if (
|
if (
|
||||||
updatedExtensionInfo.originalVersion !==
|
updatedExtensionInfo.originalVersion !==
|
||||||
updatedExtensionInfo.updatedVersion
|
updatedExtensionInfo.updatedVersion
|
||||||
@@ -69,7 +94,7 @@ export async function handleUpdate(args: UpdateArgs) {
|
|||||||
`Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`,
|
`Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Extension "${args.name}" already up to date.`);
|
console.log(`Extension "${args.name}" is already up to date.`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(getErrorMessage(error));
|
console.error(getErrorMessage(error));
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
EXTENSIONS_CONFIG_FILENAME,
|
EXTENSIONS_CONFIG_FILENAME,
|
||||||
INSTALL_METADATA_FILENAME,
|
INSTALL_METADATA_FILENAME,
|
||||||
annotateActiveExtensions,
|
annotateActiveExtensions,
|
||||||
checkForAllExtensionUpdates,
|
|
||||||
disableExtension,
|
disableExtension,
|
||||||
enableExtension,
|
enableExtension,
|
||||||
installExtension,
|
installExtension,
|
||||||
@@ -20,22 +19,19 @@ import {
|
|||||||
loadExtensions,
|
loadExtensions,
|
||||||
performWorkspaceExtensionMigration,
|
performWorkspaceExtensionMigration,
|
||||||
uninstallExtension,
|
uninstallExtension,
|
||||||
updateExtension,
|
|
||||||
type Extension,
|
type Extension,
|
||||||
} from './extension.js';
|
} from './extension.js';
|
||||||
import {
|
import {
|
||||||
GEMINI_DIR,
|
GEMINI_DIR,
|
||||||
type GeminiCLIExtension,
|
type GeminiCLIExtension,
|
||||||
type MCPServerConfig,
|
|
||||||
ClearcutLogger,
|
ClearcutLogger,
|
||||||
type Config,
|
type Config,
|
||||||
ExtensionUninstallEvent,
|
ExtensionUninstallEvent,
|
||||||
type ExtensionInstallMetadata,
|
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { SettingScope } from './settings.js';
|
import { SettingScope } from './settings.js';
|
||||||
import { isWorkspaceTrusted } from './trustedFolders.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';
|
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||||
|
|
||||||
const mockGit = {
|
const mockGit = {
|
||||||
@@ -58,9 +54,9 @@ vi.mock('simple-git', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('os', async (importOriginal) => {
|
vi.mock('os', async (importOriginal) => {
|
||||||
const os = await importOriginal<typeof os>();
|
const mockedOs = await importOriginal<typeof os>();
|
||||||
return {
|
return {
|
||||||
...os,
|
...mockedOs,
|
||||||
homedir: vi.fn(),
|
homedir: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -454,6 +450,86 @@ describe('extension tests', () => {
|
|||||||
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
|
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
|
||||||
consoleSpy.mockRestore();
|
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', () => {
|
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 () => {
|
it('should ignore consent flow if not required', async () => {
|
||||||
const sourceExtDir = createExtension({
|
const sourceExtDir = createExtension({
|
||||||
extensionsDir: tempHomeDir,
|
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', () => {
|
describe('disableExtension', () => {
|
||||||
it('should disable an extension at the user scope', () => {
|
it('should disable an extension at the user scope', () => {
|
||||||
disableExtension('my-extension', SettingScope.User);
|
disableExtension('my-extension', SettingScope.User);
|
||||||
@@ -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: {
|
function isEnabled(options: {
|
||||||
name: string;
|
name: string;
|
||||||
configDir: string;
|
configDir: string;
|
||||||
|
|||||||
@@ -26,10 +26,8 @@ import { recursivelyHydrateStrings } from './extensions/variables.js';
|
|||||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { ExtensionUpdateState } from '../ui/state/extensions.js';
|
|
||||||
import {
|
import {
|
||||||
cloneFromGit,
|
cloneFromGit,
|
||||||
checkForExtensionUpdate,
|
|
||||||
downloadFromGitHubRelease,
|
downloadFromGitHubRelease,
|
||||||
} from './extensions/github.js';
|
} from './extensions/github.js';
|
||||||
import type { LoadExtensionContext } from './extensions/variableSchema.js';
|
import type { LoadExtensionContext } from './extensions/variableSchema.js';
|
||||||
@@ -99,7 +97,7 @@ export function getWorkspaceExtensions(workspaceDir: string): Extension[] {
|
|||||||
return loadExtensionsFromDir(workspaceDir);
|
return loadExtensionsFromDir(workspaceDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyExtension(
|
export async function copyExtension(
|
||||||
source: string,
|
source: string,
|
||||||
destination: string,
|
destination: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -263,7 +261,7 @@ export function loadExtension(context: LoadExtensionContext): Extension | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadInstallMetadata(
|
export function loadInstallMetadata(
|
||||||
extensionDir: string,
|
extensionDir: string,
|
||||||
): ExtensionInstallMetadata | undefined {
|
): ExtensionInstallMetadata | undefined {
|
||||||
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
|
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
|
||||||
@@ -633,77 +631,6 @@ export function toOutputString(extension: Extension): string {
|
|||||||
return output;
|
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(
|
export function disableExtension(
|
||||||
name: string,
|
name: string,
|
||||||
scope: SettingScope,
|
scope: SettingScope,
|
||||||
@@ -734,54 +661,3 @@ export function enableExtension(
|
|||||||
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
|
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
|
||||||
manager.enable(name, true, scopePath);
|
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 { simpleGit, type SimpleGit } from 'simple-git';
|
||||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||||
import type * as os from 'node:os';
|
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 mockPlatform = vi.hoisted(() => vi.fn());
|
||||||
const mockArch = 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 () => {
|
it('should return NOT_UPDATABLE for non-git extensions', async () => {
|
||||||
const installMetadata: ExtensionInstallMetadata = {
|
const extension: GeminiCLIExtension = {
|
||||||
type: 'local',
|
name: 'test',
|
||||||
source: '',
|
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);
|
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return ERROR if no remotes found', async () => {
|
it('should return ERROR if no remotes found', async () => {
|
||||||
const installMetadata: ExtensionInstallMetadata = {
|
const extension: GeminiCLIExtension = {
|
||||||
type: 'git',
|
name: 'test',
|
||||||
source: '',
|
path: '/ext',
|
||||||
|
version: '1.0.0',
|
||||||
|
isActive: true,
|
||||||
|
installMetadata: {
|
||||||
|
type: 'git',
|
||||||
|
source: '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
mockGit.getRemotes.mockResolvedValue([]);
|
mockGit.getRemotes.mockResolvedValue([]);
|
||||||
const result = await checkForExtensionUpdate(installMetadata);
|
let result: ExtensionUpdateState | undefined = undefined;
|
||||||
|
await checkForExtensionUpdate(
|
||||||
|
extension,
|
||||||
|
(newState) => (result = newState),
|
||||||
|
);
|
||||||
expect(result).toBe(ExtensionUpdateState.ERROR);
|
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return UPDATE_AVAILABLE when remote hash is different', async () => {
|
it('should return UPDATE_AVAILABLE when remote hash is different', async () => {
|
||||||
const installMetadata: ExtensionInstallMetadata = {
|
const extension: GeminiCLIExtension = {
|
||||||
type: 'git',
|
name: 'test',
|
||||||
source: '/ext',
|
path: '/ext',
|
||||||
|
version: '1.0.0',
|
||||||
|
isActive: true,
|
||||||
|
installMetadata: {
|
||||||
|
type: 'git',
|
||||||
|
source: 'my/ext',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
mockGit.getRemotes.mockResolvedValue([
|
mockGit.getRemotes.mockResolvedValue([
|
||||||
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||||
@@ -151,14 +177,24 @@ describe('git extension helpers', () => {
|
|||||||
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
|
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
|
||||||
mockGit.revparse.mockResolvedValue('local-hash');
|
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);
|
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return UP_TO_DATE when remote and local hashes are the same', async () => {
|
it('should return UP_TO_DATE when remote and local hashes are the same', async () => {
|
||||||
const installMetadata: ExtensionInstallMetadata = {
|
const extension: GeminiCLIExtension = {
|
||||||
type: 'git',
|
name: 'test',
|
||||||
source: '/ext',
|
path: '/ext',
|
||||||
|
version: '1.0.0',
|
||||||
|
isActive: true,
|
||||||
|
installMetadata: {
|
||||||
|
type: 'git',
|
||||||
|
source: 'my/ext',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
mockGit.getRemotes.mockResolvedValue([
|
mockGit.getRemotes.mockResolvedValue([
|
||||||
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||||
@@ -166,17 +202,32 @@ describe('git extension helpers', () => {
|
|||||||
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
|
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
|
||||||
mockGit.revparse.mockResolvedValue('same-hash');
|
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);
|
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return ERROR on git error', async () => {
|
it('should return ERROR on git error', async () => {
|
||||||
const installMetadata: ExtensionInstallMetadata = {
|
const extension: GeminiCLIExtension = {
|
||||||
type: 'git',
|
name: 'test',
|
||||||
source: '/ext',
|
path: '/ext',
|
||||||
|
version: '1.0.0',
|
||||||
|
isActive: true,
|
||||||
|
installMetadata: {
|
||||||
|
type: 'git',
|
||||||
|
source: 'my/ext',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
|
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);
|
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
|
|
||||||
import { simpleGit } from 'simple-git';
|
import { simpleGit } from 'simple-git';
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
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 { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
@@ -110,39 +113,44 @@ async function fetchFromGithub(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function checkForExtensionUpdate(
|
export async function checkForExtensionUpdate(
|
||||||
installMetadata: ExtensionInstallMetadata,
|
extension: GeminiCLIExtension,
|
||||||
): Promise<ExtensionUpdateState> {
|
setExtensionUpdateState: (updateState: ExtensionUpdateState) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
setExtensionUpdateState(ExtensionUpdateState.CHECKING_FOR_UPDATES);
|
||||||
|
const installMetadata = extension.installMetadata;
|
||||||
if (
|
if (
|
||||||
installMetadata.type !== 'git' &&
|
!installMetadata ||
|
||||||
installMetadata.type !== 'github-release'
|
(installMetadata.type !== 'git' &&
|
||||||
|
installMetadata.type !== 'github-release')
|
||||||
) {
|
) {
|
||||||
return ExtensionUpdateState.NOT_UPDATABLE;
|
setExtensionUpdateState(ExtensionUpdateState.NOT_UPDATABLE);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (installMetadata.type === 'git') {
|
if (installMetadata.type === 'git') {
|
||||||
const git = simpleGit(installMetadata.source);
|
const git = simpleGit(extension.path);
|
||||||
const remotes = await git.getRemotes(true);
|
const remotes = await git.getRemotes(true);
|
||||||
if (remotes.length === 0) {
|
if (remotes.length === 0) {
|
||||||
console.error('No git remotes found.');
|
console.error('No git remotes found.');
|
||||||
return ExtensionUpdateState.ERROR;
|
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const remoteUrl = remotes[0].refs.fetch;
|
const remoteUrl = remotes[0].refs.fetch;
|
||||||
if (!remoteUrl) {
|
if (!remoteUrl) {
|
||||||
console.error(`No fetch URL found for git remote ${remotes[0].name}.`);
|
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.
|
// Determine the ref to check on the remote.
|
||||||
const refToCheck = installMetadata.ref || 'HEAD';
|
const refToCheck = installMetadata.ref || 'HEAD';
|
||||||
|
|
||||||
const lsRemoteOutput = await git.listRemote([
|
const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]);
|
||||||
remotes[0].name,
|
|
||||||
refToCheck,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') {
|
if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') {
|
||||||
console.error(`Git ref ${refToCheck} not found.`);
|
console.error(`Git ref ${refToCheck} not found.`);
|
||||||
return ExtensionUpdateState.ERROR;
|
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteHash = lsRemoteOutput.split('\t')[0];
|
const remoteHash = lsRemoteOutput.split('\t')[0];
|
||||||
@@ -152,16 +160,21 @@ export async function checkForExtensionUpdate(
|
|||||||
console.error(
|
console.error(
|
||||||
`Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`,
|
`Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`,
|
||||||
);
|
);
|
||||||
return ExtensionUpdateState.ERROR;
|
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (remoteHash === localHash) {
|
if (remoteHash === localHash) {
|
||||||
return ExtensionUpdateState.UP_TO_DATE;
|
setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return ExtensionUpdateState.UPDATE_AVAILABLE;
|
setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
const { source, ref } = installMetadata;
|
const { source, ref } = installMetadata;
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return ExtensionUpdateState.ERROR;
|
console.error(`No "source" provided for extension.`);
|
||||||
|
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const { owner, repo } = parseGitHubRepoForReleases(source);
|
const { owner, repo } = parseGitHubRepoForReleases(source);
|
||||||
|
|
||||||
@@ -171,15 +184,18 @@ export async function checkForExtensionUpdate(
|
|||||||
installMetadata.ref,
|
installMetadata.ref,
|
||||||
);
|
);
|
||||||
if (releaseData.tag_name !== 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) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import {
|
||||||
|
EXTENSIONS_CONFIG_FILENAME,
|
||||||
|
INSTALL_METADATA_FILENAME,
|
||||||
|
} from '../config/extension.js';
|
||||||
|
import {
|
||||||
|
type MCPServerConfig,
|
||||||
|
type ExtensionInstallMetadata,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
@@ -54,6 +54,8 @@ export const createMockCommandContext = (
|
|||||||
loadHistory: vi.fn(),
|
loadHistory: vi.fn(),
|
||||||
toggleCorgiMode: vi.fn(),
|
toggleCorgiMode: vi.fn(),
|
||||||
toggleVimEnabled: vi.fn(),
|
toggleVimEnabled: vi.fn(),
|
||||||
|
extensionsUpdateState: new Map(),
|
||||||
|
setExtensionsUpdateState: vi.fn(),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any,
|
} as any,
|
||||||
session: {
|
session: {
|
||||||
|
|||||||
@@ -85,9 +85,8 @@ import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
|||||||
import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
|
import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
|
||||||
import { useSessionStats } from './contexts/SessionContext.js';
|
import { useSessionStats } from './contexts/SessionContext.js';
|
||||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||||
|
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
|
||||||
import { FocusContext } from './contexts/FocusContext.js';
|
import { FocusContext } from './contexts/FocusContext.js';
|
||||||
import type { ExtensionUpdateState } from './state/extensions.js';
|
|
||||||
import { checkForAllExtensionUpdates } from '../config/extension.js';
|
|
||||||
|
|
||||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||||
|
|
||||||
@@ -149,9 +148,14 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
||||||
config.isTrustedFolder(),
|
config.isTrustedFolder(),
|
||||||
);
|
);
|
||||||
const [extensionsUpdateState, setExtensionsUpdateState] = useState(
|
|
||||||
new Map<string, ExtensionUpdateState>(),
|
const extensions = config.getExtensions();
|
||||||
);
|
const { extensionsUpdateState, setExtensionsUpdateState } =
|
||||||
|
useExtensionUpdates(
|
||||||
|
extensions,
|
||||||
|
historyManager.addItem,
|
||||||
|
config.getWorkingDir(),
|
||||||
|
);
|
||||||
|
|
||||||
// Helper to determine the effective model, considering the fallback state.
|
// Helper to determine the effective model, considering the fallback state.
|
||||||
const getEffectiveModel = useCallback(() => {
|
const getEffectiveModel = useCallback(() => {
|
||||||
@@ -1196,11 +1200,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const extensions = config.getExtensions();
|
|
||||||
useEffect(() => {
|
|
||||||
checkForAllExtensionUpdates(extensions, setExtensionsUpdateState);
|
|
||||||
}, [extensions, setExtensionsUpdateState]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UIStateContext.Provider value={uiState}>
|
<UIStateContext.Provider value={uiState}>
|
||||||
<UIActionsContext.Provider value={uiActions}>
|
<UIActionsContext.Provider value={uiActions}>
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
updateAllUpdatableExtensions,
|
updateAllUpdatableExtensions,
|
||||||
updateExtensionByName,
|
updateExtension,
|
||||||
} from '../../config/extension.js';
|
} from '../../config/extensions/update.js';
|
||||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import { extensionsCommand } from './extensionsCommand.js';
|
import { extensionsCommand } from './extensionsCommand.js';
|
||||||
@@ -20,14 +21,15 @@ import {
|
|||||||
beforeEach,
|
beforeEach,
|
||||||
type MockedFunction,
|
type MockedFunction,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
import { ExtensionUpdateState } from '../state/extensions.js';
|
||||||
|
|
||||||
vi.mock('../../config/extension.js', () => ({
|
vi.mock('../../config/extensions/update.js', () => ({
|
||||||
updateExtensionByName: vi.fn(),
|
updateExtension: vi.fn(),
|
||||||
updateAllUpdatableExtensions: vi.fn(),
|
updateAllUpdatableExtensions: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockUpdateExtensionByName = updateExtensionByName as MockedFunction<
|
const mockUpdateExtension = updateExtension as MockedFunction<
|
||||||
typeof updateExtensionByName
|
typeof updateExtension
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const mockUpdateAllUpdatableExtensions =
|
const mockUpdateAllUpdatableExtensions =
|
||||||
@@ -35,6 +37,8 @@ const mockUpdateAllUpdatableExtensions =
|
|||||||
typeof updateAllUpdatableExtensions
|
typeof updateAllUpdatableExtensions
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
const mockGetExtensions = vi.fn();
|
||||||
|
|
||||||
describe('extensionsCommand', () => {
|
describe('extensionsCommand', () => {
|
||||||
let mockContext: CommandContext;
|
let mockContext: CommandContext;
|
||||||
|
|
||||||
@@ -43,7 +47,7 @@ describe('extensionsCommand', () => {
|
|||||||
mockContext = createMockCommandContext({
|
mockContext = createMockCommandContext({
|
||||||
services: {
|
services: {
|
||||||
config: {
|
config: {
|
||||||
getExtensions: () => [],
|
getExtensions: mockGetExtensions,
|
||||||
getWorkingDir: () => '/test/dir',
|
getWorkingDir: () => '/test/dir',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -147,36 +151,73 @@ describe('extensionsCommand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should update a single extension by name', async () => {
|
it('should update a single extension by name', async () => {
|
||||||
mockUpdateExtensionByName.mockResolvedValue({
|
const extension: GeminiCLIExtension = {
|
||||||
name: 'ext-one',
|
name: 'ext-one',
|
||||||
originalVersion: '1.0.0',
|
type: 'git',
|
||||||
|
version: '1.0.0',
|
||||||
|
isActive: true,
|
||||||
|
path: '/test/dir/ext-one',
|
||||||
|
autoUpdate: false,
|
||||||
|
};
|
||||||
|
mockUpdateExtension.mockResolvedValue({
|
||||||
|
name: extension.name,
|
||||||
|
originalVersion: extension.version,
|
||||||
updatedVersion: '1.0.1',
|
updatedVersion: '1.0.1',
|
||||||
});
|
});
|
||||||
|
mockGetExtensions.mockReturnValue([extension]);
|
||||||
|
mockContext.ui.extensionsUpdateState.set(
|
||||||
|
extension.name,
|
||||||
|
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||||
|
);
|
||||||
await updateAction(mockContext, 'ext-one');
|
await updateAction(mockContext, 'ext-one');
|
||||||
expect(mockUpdateExtensionByName).toHaveBeenCalledWith(
|
expect(mockUpdateExtension).toHaveBeenCalledWith(
|
||||||
'ext-one',
|
extension,
|
||||||
'/test/dir',
|
'/test/dir',
|
||||||
[],
|
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle errors when updating a single extension', async () => {
|
it('should handle errors when updating a single extension', async () => {
|
||||||
mockUpdateExtensionByName.mockRejectedValue(
|
mockUpdateExtension.mockRejectedValue(new Error('Extension not found'));
|
||||||
new Error('Extension not found'),
|
mockGetExtensions.mockReturnValue([]);
|
||||||
);
|
|
||||||
await updateAction(mockContext, 'ext-one');
|
await updateAction(mockContext, 'ext-one');
|
||||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: 'Extension not found',
|
text: 'Extension ext-one not found.',
|
||||||
},
|
},
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update multiple extensions by name', async () => {
|
it('should update multiple extensions by name', async () => {
|
||||||
mockUpdateExtensionByName
|
const extensionOne: GeminiCLIExtension = {
|
||||||
|
name: 'ext-one',
|
||||||
|
type: 'git',
|
||||||
|
version: '1.0.0',
|
||||||
|
isActive: true,
|
||||||
|
path: '/test/dir/ext-one',
|
||||||
|
autoUpdate: false,
|
||||||
|
};
|
||||||
|
const extensionTwo: GeminiCLIExtension = {
|
||||||
|
name: 'ext-two',
|
||||||
|
type: 'git',
|
||||||
|
version: '1.0.0',
|
||||||
|
isActive: true,
|
||||||
|
path: '/test/dir/ext-two',
|
||||||
|
autoUpdate: false,
|
||||||
|
};
|
||||||
|
mockGetExtensions.mockReturnValue([extensionOne, extensionTwo]);
|
||||||
|
mockContext.ui.extensionsUpdateState.set(
|
||||||
|
extensionOne.name,
|
||||||
|
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||||
|
);
|
||||||
|
mockContext.ui.extensionsUpdateState.set(
|
||||||
|
extensionTwo.name,
|
||||||
|
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||||
|
);
|
||||||
|
mockUpdateExtension
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
name: 'ext-one',
|
name: 'ext-one',
|
||||||
originalVersion: '1.0.0',
|
originalVersion: '1.0.0',
|
||||||
@@ -188,7 +229,7 @@ describe('extensionsCommand', () => {
|
|||||||
updatedVersion: '2.0.1',
|
updatedVersion: '2.0.1',
|
||||||
});
|
});
|
||||||
await updateAction(mockContext, 'ext-one ext-two');
|
await updateAction(mockContext, 'ext-one ext-two');
|
||||||
expect(mockUpdateExtensionByName).toHaveBeenCalledTimes(2);
|
expect(mockUpdateExtension).toHaveBeenCalledTimes(2);
|
||||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
|
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
|
||||||
type: MessageType.EXTENSIONS_LIST,
|
type: MessageType.EXTENSIONS_LIST,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,11 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
updateExtensionByName,
|
|
||||||
updateAllUpdatableExtensions,
|
updateAllUpdatableExtensions,
|
||||||
type ExtensionUpdateInfo,
|
type ExtensionUpdateInfo,
|
||||||
} from '../../config/extension.js';
|
updateExtension,
|
||||||
|
} from '../../config/extensions/update.js';
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
|
import { ExtensionUpdateState } from '../state/extensions.js';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import {
|
import {
|
||||||
type CommandContext,
|
type CommandContext,
|
||||||
@@ -55,19 +56,36 @@ async function updateAction(context: CommandContext, args: string) {
|
|||||||
context.ui.setExtensionsUpdateState,
|
context.ui.setExtensionsUpdateState,
|
||||||
);
|
);
|
||||||
} else if (names?.length) {
|
} else if (names?.length) {
|
||||||
|
const workingDir = context.services.config!.getWorkingDir();
|
||||||
|
const extensions = context.services.config!.getExtensions();
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
updateInfos.push(
|
const extension = extensions.find(
|
||||||
await updateExtensionByName(
|
(extension) => extension.name === name,
|
||||||
name,
|
|
||||||
context.services.config!.getWorkingDir(),
|
|
||||||
context.services.config!.getExtensions(),
|
|
||||||
(updateState) => {
|
|
||||||
const newState = new Map(context.ui.extensionsUpdateState);
|
|
||||||
newState.set(name, updateState);
|
|
||||||
context.ui.setExtensionsUpdateState(newState);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
if (!extension) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Extension ${name} not found.`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updateInfo = await updateExtension(
|
||||||
|
extension,
|
||||||
|
workingDir,
|
||||||
|
context.ui.extensionsUpdateState.get(extension.name) ??
|
||||||
|
ExtensionUpdateState.UNKNOWN,
|
||||||
|
(updateState) => {
|
||||||
|
context.ui.setExtensionsUpdateState((prev) => {
|
||||||
|
const newState = new Map(prev);
|
||||||
|
newState.set(name, updateState);
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (updateInfo) updateInfos.push(updateInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||||
import type { Content, PartListUnion } from '@google/genai';
|
import type { Content, PartListUnion } from '@google/genai';
|
||||||
import type { HistoryItemWithoutId, HistoryItem } from '../types.js';
|
import type { HistoryItemWithoutId, HistoryItem } from '../types.js';
|
||||||
import type { Config, GitService, Logger } from '@google/gemini-cli-core';
|
import type { Config, GitService, Logger } from '@google/gemini-cli-core';
|
||||||
@@ -63,9 +63,9 @@ export interface CommandContext {
|
|||||||
setGeminiMdFileCount: (count: number) => void;
|
setGeminiMdFileCount: (count: number) => void;
|
||||||
reloadCommands: () => void;
|
reloadCommands: () => void;
|
||||||
extensionsUpdateState: Map<string, ExtensionUpdateState>;
|
extensionsUpdateState: Map<string, ExtensionUpdateState>;
|
||||||
setExtensionsUpdateState: (
|
setExtensionsUpdateState: Dispatch<
|
||||||
updateState: Map<string, ExtensionUpdateState>,
|
SetStateAction<Map<string, ExtensionUpdateState>>
|
||||||
) => void;
|
>;
|
||||||
};
|
};
|
||||||
// Session-specific data
|
// Session-specific data
|
||||||
session: {
|
session: {
|
||||||
|
|||||||
@@ -4,7 +4,14 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useMemo, useEffect, useState } from 'react';
|
import {
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type Dispatch,
|
||||||
|
type SetStateAction,
|
||||||
|
} from 'react';
|
||||||
import { type PartListUnion } from '@google/genai';
|
import { type PartListUnion } from '@google/genai';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
@@ -45,9 +52,9 @@ interface SlashCommandProcessorActions {
|
|||||||
quit: (messages: HistoryItem[]) => void;
|
quit: (messages: HistoryItem[]) => void;
|
||||||
setDebugMessage: (message: string) => void;
|
setDebugMessage: (message: string) => void;
|
||||||
toggleCorgiMode: () => void;
|
toggleCorgiMode: () => void;
|
||||||
setExtensionsUpdateState: (
|
setExtensionsUpdateState: Dispatch<
|
||||||
updateState: Map<string, ExtensionUpdateState>,
|
SetStateAction<Map<string, ExtensionUpdateState>>
|
||||||
) => void;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
annotateActiveExtensions,
|
||||||
|
loadExtension,
|
||||||
|
} from '../../config/extension.js';
|
||||||
|
import { createExtension } from '../../test-utils/createExtension.js';
|
||||||
|
import { useExtensionUpdates } from './useExtensionUpdates.js';
|
||||||
|
import { GEMINI_DIR, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||||
|
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { MessageType } from '../types.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('../../config/trustedFolders.js', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('../../config/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(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('child_process', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('child_process')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
execSync: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockQuestion = vi.hoisted(() => vi.fn());
|
||||||
|
const mockClose = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('node:readline', () => ({
|
||||||
|
createInterface: vi.fn(() => ({
|
||||||
|
question: mockQuestion,
|
||||||
|
close: mockClose,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useExtensionUpdates', () => {
|
||||||
|
let tempHomeDir: string;
|
||||||
|
let userExtensionsDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempHomeDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||||
|
);
|
||||||
|
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||||
|
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
|
||||||
|
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||||
|
Object.values(mockGit).forEach((fn) => fn.mockReset());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check for updates and log a message if an update is available', async () => {
|
||||||
|
const extensions = [
|
||||||
|
{
|
||||||
|
name: 'test-extension',
|
||||||
|
type: 'git',
|
||||||
|
version: '1.0.0',
|
||||||
|
path: '/some/path',
|
||||||
|
isActive: true,
|
||||||
|
installMetadata: {
|
||||||
|
type: 'git',
|
||||||
|
source: 'https://some/repo',
|
||||||
|
autoUpdate: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const addItem = vi.fn();
|
||||||
|
const cwd = '/test/cwd';
|
||||||
|
|
||||||
|
mockGit.getRemotes.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: 'origin',
|
||||||
|
refs: {
|
||||||
|
fetch: 'https://github.com/google/gemini-cli.git',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockGit.revparse.mockResolvedValue('local-hash');
|
||||||
|
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
|
||||||
|
|
||||||
|
renderHook(() =>
|
||||||
|
useExtensionUpdates(extensions as GeminiCLIExtension[], addItem, cwd),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Extension test-extension has an update available, run "/extensions update test-extension" to install it.',
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check for updates and automatically update if autoUpdate is true', async () => {
|
||||||
|
const extensionDir = createExtension({
|
||||||
|
extensionsDir: userExtensionsDir,
|
||||||
|
name: 'test-extension',
|
||||||
|
version: '1.0.0',
|
||||||
|
installMetadata: {
|
||||||
|
source: 'https://some.git/repo',
|
||||||
|
type: 'git',
|
||||||
|
autoUpdate: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const extension = annotateActiveExtensions(
|
||||||
|
[loadExtension({ extensionDir, workspaceDir: tempHomeDir })!],
|
||||||
|
[],
|
||||||
|
tempHomeDir,
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const addItem = vi.fn();
|
||||||
|
mockGit.getRemotes.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: 'origin',
|
||||||
|
refs: {
|
||||||
|
fetch: 'https://github.com/google/gemini-cli.git',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockGit.revparse.mockResolvedValue('local-hash');
|
||||||
|
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
|
||||||
|
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: 'test-extension', version: '1.1.0' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||||
|
|
||||||
|
renderHook(() => useExtensionUpdates([extension], addItem, tempHomeDir));
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Extension "test-extension" successfully updated: 1.0.0 → 1.1.0.',
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||||
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
|
import { ExtensionUpdateState } from '../state/extensions.js';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
|
import { MessageType } from '../types.js';
|
||||||
|
import {
|
||||||
|
checkForAllExtensionUpdates,
|
||||||
|
updateExtension,
|
||||||
|
} from '../../config/extensions/update.js';
|
||||||
|
|
||||||
|
export const useExtensionUpdates = (
|
||||||
|
extensions: GeminiCLIExtension[],
|
||||||
|
addItem: UseHistoryManagerReturn['addItem'],
|
||||||
|
cwd: string,
|
||||||
|
) => {
|
||||||
|
const [extensionsUpdateState, setExtensionsUpdateState] = useState(
|
||||||
|
new Map<string, ExtensionUpdateState>(),
|
||||||
|
);
|
||||||
|
useMemo(() => {
|
||||||
|
const checkUpdates = async () => {
|
||||||
|
const updateState = await checkForAllExtensionUpdates(
|
||||||
|
extensions,
|
||||||
|
extensionsUpdateState,
|
||||||
|
setExtensionsUpdateState,
|
||||||
|
);
|
||||||
|
for (const extension of extensions) {
|
||||||
|
const prevState = extensionsUpdateState.get(extension.name);
|
||||||
|
const currentState = updateState.get(extension.name);
|
||||||
|
if (
|
||||||
|
prevState === currentState ||
|
||||||
|
currentState !== ExtensionUpdateState.UPDATE_AVAILABLE
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (extension.installMetadata?.autoUpdate) {
|
||||||
|
updateExtension(extension, cwd, currentState, (newState) => {
|
||||||
|
setExtensionsUpdateState((prev) => {
|
||||||
|
const finalState = new Map(prev);
|
||||||
|
finalState.set(extension.name, newState);
|
||||||
|
return finalState;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (!result) return;
|
||||||
|
addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Extension "${extension.name}" successfully updated: ${result.originalVersion} → ${result.updatedVersion}.`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(
|
||||||
|
`Error updating extension "${extension.name}": ${getErrorMessage(error)}.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Extension ${extension.name} has an update available, run "/extensions update ${extension.name}" to install it.`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkUpdates();
|
||||||
|
}, [
|
||||||
|
extensions,
|
||||||
|
extensionsUpdateState,
|
||||||
|
setExtensionsUpdateState,
|
||||||
|
addItem,
|
||||||
|
cwd,
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
extensionsUpdateState,
|
||||||
|
setExtensionsUpdateState,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -12,4 +12,5 @@ export enum ExtensionUpdateState {
|
|||||||
UP_TO_DATE = 'up to date',
|
UP_TO_DATE = 'up to date',
|
||||||
ERROR = 'error checking for updates',
|
ERROR = 'error checking for updates',
|
||||||
NOT_UPDATABLE = 'not updatable',
|
NOT_UPDATABLE = 'not updatable',
|
||||||
|
UNKNOWN = 'unknown',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export interface ExtensionInstallMetadata {
|
|||||||
source: string;
|
source: string;
|
||||||
type: 'git' | 'local' | 'link' | 'github-release';
|
type: 'git' | 'local' | 'link' | 'github-release';
|
||||||
ref?: string;
|
ref?: string;
|
||||||
|
autoUpdate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileFilteringOptions {
|
export interface FileFilteringOptions {
|
||||||
|
|||||||
Reference in New Issue
Block a user