mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-21 19:40:40 -07:00
Fix rough edges around extension updates (#10926)
This commit is contained in:
@@ -8,12 +8,12 @@ import { describe, it, expect, vi, type MockInstance } from 'vitest';
|
||||
import { handleInstall, installCommand } from './install.js';
|
||||
import yargs from 'yargs';
|
||||
|
||||
const mockInstallExtension = vi.hoisted(() => vi.fn());
|
||||
const mockInstallOrUpdateExtension = vi.hoisted(() => vi.fn());
|
||||
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
|
||||
const mockStat = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../config/extension.js', () => ({
|
||||
installExtension: mockInstallExtension,
|
||||
installOrUpdateExtension: mockInstallOrUpdateExtension,
|
||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||
}));
|
||||
|
||||
@@ -51,14 +51,14 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockInstallExtension.mockClear();
|
||||
mockInstallOrUpdateExtension.mockClear();
|
||||
mockRequestConsentNonInteractive.mockClear();
|
||||
mockStat.mockClear();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should install an extension from a http source', async () => {
|
||||
mockInstallExtension.mockResolvedValue('http-extension');
|
||||
mockInstallOrUpdateExtension.mockResolvedValue('http-extension');
|
||||
|
||||
await handleInstall({
|
||||
source: 'http://google.com',
|
||||
@@ -70,7 +70,7 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a https source', async () => {
|
||||
mockInstallExtension.mockResolvedValue('https-extension');
|
||||
mockInstallOrUpdateExtension.mockResolvedValue('https-extension');
|
||||
|
||||
await handleInstall({
|
||||
source: 'https://google.com',
|
||||
@@ -82,7 +82,7 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a git source', async () => {
|
||||
mockInstallExtension.mockResolvedValue('git-extension');
|
||||
mockInstallOrUpdateExtension.mockResolvedValue('git-extension');
|
||||
|
||||
await handleInstall({
|
||||
source: 'git@some-url',
|
||||
@@ -104,7 +104,7 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a sso source', async () => {
|
||||
mockInstallExtension.mockResolvedValue('sso-extension');
|
||||
mockInstallOrUpdateExtension.mockResolvedValue('sso-extension');
|
||||
|
||||
await handleInstall({
|
||||
source: 'sso://google.com',
|
||||
@@ -116,7 +116,7 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a local path', async () => {
|
||||
mockInstallExtension.mockResolvedValue('local-extension');
|
||||
mockInstallOrUpdateExtension.mockResolvedValue('local-extension');
|
||||
mockStat.mockResolvedValue({});
|
||||
await handleInstall({
|
||||
source: '/some/path',
|
||||
@@ -128,7 +128,7 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should throw an error if install extension fails', async () => {
|
||||
mockInstallExtension.mockRejectedValue(
|
||||
mockInstallOrUpdateExtension.mockRejectedValue(
|
||||
new Error('Install extension failed'),
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
installExtension,
|
||||
installOrUpdateExtension,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extension.js';
|
||||
import type { ExtensionInstallMetadata } from '@google/gemini-cli-core';
|
||||
@@ -54,7 +54,7 @@ export async function handleInstall(args: InstallArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
const name = await installExtension(
|
||||
const name = await installOrUpdateExtension(
|
||||
installMetadata,
|
||||
requestConsentNonInteractive,
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
installExtension,
|
||||
installOrUpdateExtension,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extension.js';
|
||||
import type { ExtensionInstallMetadata } from '@google/gemini-cli-core';
|
||||
@@ -23,7 +23,7 @@ export async function handleLink(args: InstallArgs) {
|
||||
source: args.path,
|
||||
type: 'link',
|
||||
};
|
||||
const extensionName = await installExtension(
|
||||
const extensionName = await installOrUpdateExtension(
|
||||
installMetadata,
|
||||
requestConsentNonInteractive,
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ interface UninstallArgs {
|
||||
|
||||
export async function handleUninstall(args: UninstallArgs) {
|
||||
try {
|
||||
await uninstallExtension(args.name);
|
||||
await uninstallExtension(args.name, false);
|
||||
console.log(`Extension "${args.name}" successfully uninstalled.`);
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
annotateActiveExtensions,
|
||||
disableExtension,
|
||||
enableExtension,
|
||||
installExtension,
|
||||
installOrUpdateExtension,
|
||||
loadExtension,
|
||||
loadExtensionConfig,
|
||||
loadExtensions,
|
||||
@@ -73,6 +73,7 @@ vi.mock('./trustedFolders.js', async (importOriginal) => {
|
||||
const mockLogExtensionEnable = vi.hoisted(() => vi.fn());
|
||||
const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn());
|
||||
const mockLogExtensionUninstall = vi.hoisted(() => vi.fn());
|
||||
const mockLogExtensionUpdateEvent = vi.hoisted(() => vi.fn());
|
||||
const mockLogExtensionDisable = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -82,6 +83,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
logExtensionEnable: mockLogExtensionEnable,
|
||||
logExtensionInstallEvent: mockLogExtensionInstallEvent,
|
||||
logExtensionUninstall: mockLogExtensionUninstall,
|
||||
logExtensionUpdateEvent: mockLogExtensionUpdateEvent,
|
||||
logExtensionDisable: mockLogExtensionDisable,
|
||||
ExtensionEnableEvent: vi.fn(),
|
||||
ExtensionInstallEvent: vi.fn(),
|
||||
@@ -260,7 +262,7 @@ describe('extension tests', () => {
|
||||
});
|
||||
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
|
||||
|
||||
const extensionName = await installExtension(
|
||||
const extensionName = await installOrUpdateExtension(
|
||||
{
|
||||
source: sourceExtDir,
|
||||
type: 'link',
|
||||
@@ -703,7 +705,7 @@ describe('extension tests', () => {
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
await installExtension(
|
||||
await installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
);
|
||||
@@ -724,12 +726,12 @@ describe('extension tests', () => {
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
await installExtension(
|
||||
await installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
);
|
||||
await expect(
|
||||
installExtension(
|
||||
installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
),
|
||||
@@ -744,7 +746,7 @@ describe('extension tests', () => {
|
||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
|
||||
await expect(
|
||||
installExtension(
|
||||
installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
),
|
||||
@@ -761,7 +763,7 @@ describe('extension tests', () => {
|
||||
fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON
|
||||
|
||||
await expect(
|
||||
installExtension(
|
||||
installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
),
|
||||
@@ -786,7 +788,7 @@ describe('extension tests', () => {
|
||||
fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' }));
|
||||
|
||||
await expect(
|
||||
installExtension(
|
||||
installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
),
|
||||
@@ -812,7 +814,7 @@ describe('extension tests', () => {
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
await installExtension(
|
||||
await installOrUpdateExtension(
|
||||
{ source: gitUrl, type: 'git' },
|
||||
async (_) => true,
|
||||
);
|
||||
@@ -836,7 +838,7 @@ describe('extension tests', () => {
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
|
||||
await installExtension(
|
||||
await installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'link' },
|
||||
async (_) => true,
|
||||
);
|
||||
@@ -854,20 +856,77 @@ describe('extension tests', () => {
|
||||
fs.rmSync(targetExtDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should log to clearcut on successful install', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
describe.each([true, false])(
|
||||
'with previous extension config: %s',
|
||||
(isUpdate: boolean) => {
|
||||
let sourceExtDir: string;
|
||||
|
||||
await installExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
);
|
||||
beforeEach(async () => {
|
||||
sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.1.0',
|
||||
});
|
||||
if (isUpdate) {
|
||||
await installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
);
|
||||
}
|
||||
// Clears out any calls to mocks from the above function calls.
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
expect(mockLogExtensionInstallEvent).toHaveBeenCalled();
|
||||
});
|
||||
it(`should log an ${isUpdate ? 'update' : 'install'} event to clearcut on success`, async () => {
|
||||
await installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
undefined,
|
||||
isUpdate
|
||||
? {
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (isUpdate) {
|
||||
expect(mockLogExtensionUpdateEvent).toHaveBeenCalled();
|
||||
expect(mockLogExtensionInstallEvent).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mockLogExtensionInstallEvent).toHaveBeenCalled();
|
||||
expect(mockLogExtensionUpdateEvent).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it(`should ${isUpdate ? 'not ' : ''} alter the extension enablement configuration`, async () => {
|
||||
const enablementManager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
);
|
||||
enablementManager.enable('my-local-extension', true, '/some/scope');
|
||||
|
||||
await installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
undefined,
|
||||
isUpdate
|
||||
? {
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const config = enablementManager.readConfig()['my-local-extension'];
|
||||
if (isUpdate) {
|
||||
expect(config).not.toBeUndefined();
|
||||
expect(config.overrides).toContain('/some/scope/*');
|
||||
} else {
|
||||
expect(config).not.toContain('/some/scope/*');
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should show users information on their ansi escaped mcp servers when installing', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
@@ -891,7 +950,7 @@ describe('extension tests', () => {
|
||||
mockRequestConsent.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
installExtension(
|
||||
installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
mockRequestConsent,
|
||||
),
|
||||
@@ -920,7 +979,7 @@ This extension will run the following MCP servers:
|
||||
});
|
||||
|
||||
await expect(
|
||||
installExtension(
|
||||
installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async () => true,
|
||||
),
|
||||
@@ -941,7 +1000,7 @@ This extension will run the following MCP servers:
|
||||
});
|
||||
|
||||
await expect(
|
||||
installExtension(
|
||||
installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async () => false,
|
||||
),
|
||||
@@ -957,7 +1016,7 @@ This extension will run the following MCP servers:
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
await installExtension(
|
||||
await installOrUpdateExtension(
|
||||
{
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
@@ -991,9 +1050,15 @@ This extension will run the following MCP servers:
|
||||
});
|
||||
|
||||
const mockRequestConsent = vi.fn();
|
||||
// Install it and force consent first.
|
||||
await installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async () => true,
|
||||
);
|
||||
|
||||
// Now update it without changing anything.
|
||||
await expect(
|
||||
installExtension(
|
||||
installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
mockRequestConsent,
|
||||
process.cwd(),
|
||||
@@ -1016,7 +1081,7 @@ This extension will run the following MCP servers:
|
||||
});
|
||||
|
||||
await expect(
|
||||
installExtension(
|
||||
installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
),
|
||||
@@ -1032,7 +1097,7 @@ This extension will run the following MCP servers:
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await uninstallExtension('my-local-extension');
|
||||
await uninstallExtension('my-local-extension', false);
|
||||
|
||||
expect(fs.existsSync(sourceExtDir)).toBe(false);
|
||||
});
|
||||
@@ -1049,7 +1114,7 @@ This extension will run the following MCP servers:
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await uninstallExtension('my-local-extension');
|
||||
await uninstallExtension('my-local-extension', false);
|
||||
|
||||
expect(fs.existsSync(sourceExtDir)).toBe(false);
|
||||
expect(
|
||||
@@ -1063,25 +1128,54 @@ This extension will run the following MCP servers:
|
||||
});
|
||||
|
||||
it('should throw an error if the extension does not exist', async () => {
|
||||
await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow(
|
||||
'Extension not found.',
|
||||
);
|
||||
await expect(
|
||||
uninstallExtension('nonexistent-extension', false),
|
||||
).rejects.toThrow('Extension not found.');
|
||||
});
|
||||
|
||||
it('should log uninstall event', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
describe.each([true, false])('with isUpdate: %s', (isUpdate: boolean) => {
|
||||
it(`should ${isUpdate ? 'not ' : ''}log uninstall event`, async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await uninstallExtension('my-local-extension', isUpdate);
|
||||
|
||||
if (isUpdate) {
|
||||
expect(mockLogExtensionUninstall).not.toHaveBeenCalled();
|
||||
expect(ExtensionUninstallEvent).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mockLogExtensionUninstall).toHaveBeenCalled();
|
||||
expect(ExtensionUninstallEvent).toHaveBeenCalledWith(
|
||||
'my-local-extension',
|
||||
'success',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await uninstallExtension('my-local-extension');
|
||||
it(`should ${isUpdate ? 'not ' : ''} alter the extension enablement configuration`, async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const enablementManager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
);
|
||||
enablementManager.enable('test-extension', true, '/some/scope');
|
||||
|
||||
expect(mockLogExtensionUninstall).toHaveBeenCalled();
|
||||
expect(ExtensionUninstallEvent).toHaveBeenCalledWith(
|
||||
'my-local-extension',
|
||||
'success',
|
||||
);
|
||||
await uninstallExtension('test-extension', isUpdate);
|
||||
|
||||
const config = enablementManager.readConfig()['test-extension'];
|
||||
if (isUpdate) {
|
||||
expect(config).not.toBeUndefined();
|
||||
expect(config.overrides).toEqual(['/some/scope/*']);
|
||||
} else {
|
||||
expect(config).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should uninstall an extension by its source URL', async () => {
|
||||
@@ -1096,7 +1190,7 @@ This extension will run the following MCP servers:
|
||||
},
|
||||
});
|
||||
|
||||
await uninstallExtension(gitUrl);
|
||||
await uninstallExtension(gitUrl, false);
|
||||
|
||||
expect(fs.existsSync(sourceExtDir)).toBe(false);
|
||||
expect(mockLogExtensionUninstall).toHaveBeenCalled();
|
||||
@@ -1115,7 +1209,10 @@ This extension will run the following MCP servers:
|
||||
});
|
||||
|
||||
await expect(
|
||||
uninstallExtension('https://github.com/google/no-metadata-extension'),
|
||||
uninstallExtension(
|
||||
'https://github.com/google/no-metadata-extension',
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow('Extension not found.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
Config,
|
||||
ExtensionInstallEvent,
|
||||
ExtensionUninstallEvent,
|
||||
ExtensionUpdateEvent,
|
||||
ExtensionDisableEvent,
|
||||
ExtensionEnableEvent,
|
||||
logExtensionEnable,
|
||||
logExtensionInstallEvent,
|
||||
logExtensionUninstall,
|
||||
logExtensionUpdateEvent,
|
||||
logExtensionDisable,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
@@ -129,7 +131,7 @@ export async function performWorkspaceExtensionMigration(
|
||||
source: extension.path,
|
||||
type: 'local',
|
||||
};
|
||||
await installExtension(installMetadata, requestConsent);
|
||||
await installOrUpdateExtension(installMetadata, requestConsent);
|
||||
} catch (_) {
|
||||
failedInstallNames.push(extension.name);
|
||||
}
|
||||
@@ -426,12 +428,13 @@ async function promptForConsentInteractive(
|
||||
});
|
||||
}
|
||||
|
||||
export async function installExtension(
|
||||
export async function installOrUpdateExtension(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
cwd: string = process.cwd(),
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
): Promise<string> {
|
||||
const isUpdate = !!previousExtensionConfig;
|
||||
const telemetryConfig = getTelemetryConfig(cwd);
|
||||
let newExtensionConfig: ExtensionConfig | null = null;
|
||||
let localSourcePath: string | undefined;
|
||||
@@ -489,24 +492,32 @@ export async function installExtension(
|
||||
});
|
||||
|
||||
const newExtensionName = newExtensionConfig.name;
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
|
||||
const installedExtensions = loadUserExtensions();
|
||||
if (
|
||||
installedExtensions.some(
|
||||
(installed) => installed.name === newExtensionName,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||
);
|
||||
if (!isUpdate) {
|
||||
const installedExtensions = loadUserExtensions();
|
||||
if (
|
||||
installedExtensions.some(
|
||||
(installed) => installed.name === newExtensionName,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await maybeRequestConsentOrFail(
|
||||
newExtensionConfig,
|
||||
requestConsent,
|
||||
previousExtensionConfig,
|
||||
);
|
||||
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
|
||||
if (isUpdate) {
|
||||
await uninstallExtension(newExtensionName, isUpdate, cwd);
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
|
||||
if (
|
||||
@@ -529,17 +540,30 @@ export async function installExtension(
|
||||
}
|
||||
}
|
||||
|
||||
logExtensionInstallEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig!.name,
|
||||
newExtensionConfig!.version,
|
||||
installMetadata.source,
|
||||
'success',
|
||||
),
|
||||
);
|
||||
if (isUpdate) {
|
||||
logExtensionUpdateEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionUpdateEvent(
|
||||
newExtensionConfig.name,
|
||||
newExtensionConfig.version,
|
||||
previousExtensionConfig.version,
|
||||
installMetadata.source,
|
||||
'success',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
logExtensionInstallEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig.name,
|
||||
newExtensionConfig.version,
|
||||
installMetadata.source,
|
||||
'success',
|
||||
),
|
||||
);
|
||||
enableExtension(newExtensionConfig.name, SettingScope.User);
|
||||
}
|
||||
|
||||
enableExtension(newExtensionConfig!.name, SettingScope.User);
|
||||
return newExtensionConfig!.name;
|
||||
} catch (error) {
|
||||
// Attempt to load config from the source path even if installation fails
|
||||
@@ -554,15 +578,28 @@ export async function installExtension(
|
||||
// Ignore error, this is just for logging.
|
||||
}
|
||||
}
|
||||
logExtensionInstallEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig?.name ?? '',
|
||||
newExtensionConfig?.version ?? '',
|
||||
installMetadata.source,
|
||||
'error',
|
||||
),
|
||||
);
|
||||
if (isUpdate) {
|
||||
logExtensionUpdateEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionUpdateEvent(
|
||||
newExtensionConfig?.name ?? previousExtensionConfig.name,
|
||||
newExtensionConfig?.version ?? '',
|
||||
previousExtensionConfig.version,
|
||||
installMetadata.source,
|
||||
'error',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
logExtensionInstallEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig?.name ?? '',
|
||||
newExtensionConfig?.version ?? '',
|
||||
installMetadata.source,
|
||||
'error',
|
||||
),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -679,9 +716,9 @@ export function loadExtensionConfig(
|
||||
|
||||
export async function uninstallExtension(
|
||||
extensionIdentifier: string,
|
||||
isUpdate: boolean,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<void> {
|
||||
const telemetryConfig = getTelemetryConfig(cwd);
|
||||
const installedExtensions = loadUserExtensions();
|
||||
const extensionName = installedExtensions.find(
|
||||
(installed) =>
|
||||
@@ -692,17 +729,24 @@ export async function uninstallExtension(
|
||||
if (!extensionName) {
|
||||
throw new Error(`Extension not found.`);
|
||||
}
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
[extensionName],
|
||||
);
|
||||
manager.remove(extensionName);
|
||||
const storage = new ExtensionStorage(extensionName);
|
||||
|
||||
await fs.promises.rm(storage.getExtensionDir(), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
// The rest of the cleanup below here is only for true uninstalls, not
|
||||
// uninstalls related to updates.
|
||||
if (isUpdate) return;
|
||||
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
[extensionName],
|
||||
);
|
||||
manager.remove(extensionName);
|
||||
|
||||
const telemetryConfig = getTelemetryConfig(cwd);
|
||||
logExtensionUninstall(
|
||||
telemetryConfig,
|
||||
new ExtensionUninstallEvent(extensionName, 'success'),
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
} from '../../ui/state/extensions.js';
|
||||
import {
|
||||
copyExtension,
|
||||
installExtension,
|
||||
uninstallExtension,
|
||||
installOrUpdateExtension,
|
||||
loadExtension,
|
||||
loadInstallMetadata,
|
||||
ExtensionStorage,
|
||||
@@ -65,13 +64,11 @@ export async function updateExtension(
|
||||
|
||||
const tempDir = await ExtensionStorage.createTmpDir();
|
||||
try {
|
||||
await copyExtension(extension.path, tempDir);
|
||||
const previousExtensionConfig = await loadExtensionConfig({
|
||||
extensionDir: extension.path,
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
await uninstallExtension(extension.name, cwd);
|
||||
await installExtension(
|
||||
await installOrUpdateExtension(
|
||||
installMetadata,
|
||||
requestConsent,
|
||||
cwd,
|
||||
|
||||
@@ -37,6 +37,7 @@ export {
|
||||
ExtensionDisableEvent,
|
||||
ExtensionEnableEvent,
|
||||
ExtensionUninstallEvent,
|
||||
ExtensionUpdateEvent,
|
||||
ModelSlashCommandEvent,
|
||||
} from './src/telemetry/types.js';
|
||||
export { makeFakeConfig } from './src/test-utils/config.js';
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
AgentStartEvent,
|
||||
AgentFinishEvent,
|
||||
WebFetchFallbackAttemptEvent,
|
||||
ExtensionUpdateEvent,
|
||||
} from '../types.js';
|
||||
import { EventMetadataKey } from './event-metadata-key.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
@@ -77,6 +78,7 @@ export enum EventNames {
|
||||
EXTENSION_DISABLE = 'extension_disable',
|
||||
EXTENSION_INSTALL = 'extension_install',
|
||||
EXTENSION_UNINSTALL = 'extension_uninstall',
|
||||
EXTENSION_UPDATE = 'extension_update',
|
||||
TOOL_OUTPUT_TRUNCATED = 'tool_output_truncated',
|
||||
MODEL_ROUTING = 'model_routing',
|
||||
MODEL_SLASH_COMMAND = 'model_slash_command',
|
||||
@@ -929,6 +931,38 @@ export class ClearcutLogger {
|
||||
});
|
||||
}
|
||||
|
||||
logExtensionUpdateEvent(event: ExtensionUpdateEvent): void {
|
||||
const data: EventValue[] = [
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME,
|
||||
value: event.extension_name,
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_VERSION,
|
||||
value: event.extension_version,
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_PREVIOUS_VERSION,
|
||||
value: event.extension_previous_version,
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_SOURCE,
|
||||
value: event.extension_source,
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_UPDATE_STATUS,
|
||||
value: event.status,
|
||||
},
|
||||
];
|
||||
|
||||
this.enqueueLogEvent(
|
||||
this.createLogEvent(EventNames.EXTENSION_UPDATE, data),
|
||||
);
|
||||
this.flushToClearcut().catch((error) => {
|
||||
console.debug('Error flushing to Clearcut:', error);
|
||||
});
|
||||
}
|
||||
|
||||
logToolOutputTruncatedEvent(event: ToolOutputTruncatedEvent): void {
|
||||
const data: EventValue[] = [
|
||||
{
|
||||
|
||||
@@ -367,7 +367,7 @@ export enum EventMetadataKey {
|
||||
GEMINI_CLI_NODE_VERSION = 83,
|
||||
|
||||
// ==========================================================================
|
||||
// Extension Install Event Keys
|
||||
// Extension Event Keys
|
||||
// ===========================================================================
|
||||
|
||||
// Logs the name of the extension.
|
||||
@@ -376,6 +376,9 @@ export enum EventMetadataKey {
|
||||
// Logs the version of the extension.
|
||||
GEMINI_CLI_EXTENSION_VERSION = 86,
|
||||
|
||||
// Logs the previous version of the extension.
|
||||
GEMINI_CLI_EXTENSION_PREVIOUS_VERSION = 117,
|
||||
|
||||
// Logs the source of the extension.
|
||||
GEMINI_CLI_EXTENSION_SOURCE = 87,
|
||||
|
||||
@@ -385,6 +388,9 @@ export enum EventMetadataKey {
|
||||
// Logs the status of the extension uninstall
|
||||
GEMINI_CLI_EXTENSION_UNINSTALL_STATUS = 96,
|
||||
|
||||
// Logs the status of the extension uninstall
|
||||
GEMINI_CLI_EXTENSION_UPDATE_STATUS = 118,
|
||||
|
||||
// Logs the setting scope for an extension enablement.
|
||||
GEMINI_CLI_EXTENSION_ENABLE_SETTING_SCOPE = 102,
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export {
|
||||
logExtensionEnable,
|
||||
logExtensionInstallEvent,
|
||||
logExtensionUninstall,
|
||||
logExtensionUpdateEvent,
|
||||
logWebFetchFallbackAttempt,
|
||||
} from './loggers.js';
|
||||
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
logAgentStart,
|
||||
logAgentFinish,
|
||||
logWebFetchFallbackAttempt,
|
||||
logExtensionUpdateEvent,
|
||||
} from './loggers.js';
|
||||
import { ToolCallDecision } from './tool-call-decision.js';
|
||||
import {
|
||||
@@ -82,6 +83,8 @@ import {
|
||||
AgentStartEvent,
|
||||
AgentFinishEvent,
|
||||
WebFetchFallbackAttemptEvent,
|
||||
ExtensionUpdateEvent,
|
||||
EVENT_EXTENSION_UPDATE,
|
||||
} from './types.js';
|
||||
import * as metrics from './metrics.js';
|
||||
import {
|
||||
@@ -1292,6 +1295,9 @@ describe('loggers', () => {
|
||||
const mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getContentGeneratorConfig: () => null,
|
||||
getUseSmartEdit: () => null,
|
||||
getUseModelRouter: () => null,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1333,10 +1339,63 @@ describe('loggers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('logExtensionUpdate', () => {
|
||||
const mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getContentGeneratorConfig: () => null,
|
||||
getUseSmartEdit: () => null,
|
||||
getUseModelRouter: () => null,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(ClearcutLogger.prototype, 'logExtensionUpdateEvent');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should log extension update event', () => {
|
||||
const event = new ExtensionUpdateEvent(
|
||||
'vscode',
|
||||
'0.1.0',
|
||||
'0.1.1',
|
||||
'git',
|
||||
'success',
|
||||
);
|
||||
|
||||
logExtensionUpdateEvent(mockConfig, event);
|
||||
|
||||
expect(
|
||||
ClearcutLogger.prototype.logExtensionUpdateEvent,
|
||||
).toHaveBeenCalledWith(event);
|
||||
|
||||
expect(mockLogger.emit).toHaveBeenCalledWith({
|
||||
body: 'Updated extension vscode',
|
||||
attributes: {
|
||||
'session.id': 'test-session-id',
|
||||
'user.email': 'test-user@example.com',
|
||||
'installation.id': 'test-installation-id',
|
||||
'event.name': EVENT_EXTENSION_UPDATE,
|
||||
'event.timestamp': '2025-01-01T00:00:00.000Z',
|
||||
extension_name: 'vscode',
|
||||
extension_version: '0.1.0',
|
||||
extension_previous_version: '0.1.1',
|
||||
extension_source: 'git',
|
||||
status: 'success',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('logExtensionUninstall', () => {
|
||||
const mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getContentGeneratorConfig: () => null,
|
||||
getUseSmartEdit: () => null,
|
||||
getUseModelRouter: () => null,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -47,6 +47,7 @@ import type {
|
||||
AgentStartEvent,
|
||||
AgentFinishEvent,
|
||||
WebFetchFallbackAttemptEvent,
|
||||
ExtensionUpdateEvent,
|
||||
} from './types.js';
|
||||
import {
|
||||
recordApiErrorMetrics,
|
||||
@@ -531,6 +532,21 @@ export function logExtensionUninstall(
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
export function logExtensionUpdateEvent(
|
||||
config: Config,
|
||||
event: ExtensionUpdateEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logExtensionUpdateEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
const logRecord: LogRecord = {
|
||||
body: event.toLogBody(),
|
||||
attributes: event.toOpenTelemetryAttributes(config),
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
export function logExtensionEnable(
|
||||
config: Config,
|
||||
event: ExtensionEnableEvent,
|
||||
|
||||
@@ -1156,6 +1156,50 @@ export class ExtensionUninstallEvent implements BaseTelemetryEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export const EVENT_EXTENSION_UPDATE = 'gemini_cli.extension_update';
|
||||
export class ExtensionUpdateEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'extension_update';
|
||||
'event.timestamp': string;
|
||||
extension_name: string;
|
||||
extension_previous_version: string;
|
||||
extension_version: string;
|
||||
extension_source: string;
|
||||
status: 'success' | 'error';
|
||||
|
||||
constructor(
|
||||
extension_name: string,
|
||||
extension_version: string,
|
||||
extension_previous_version: string,
|
||||
extension_source: string,
|
||||
status: 'success' | 'error',
|
||||
) {
|
||||
this['event.name'] = 'extension_update';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.extension_name = extension_name;
|
||||
this.extension_version = extension_version;
|
||||
this.extension_previous_version = extension_previous_version;
|
||||
this.extension_source = extension_source;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
||||
return {
|
||||
...getCommonAttributes(config),
|
||||
'event.name': EVENT_EXTENSION_UPDATE,
|
||||
'event.timestamp': this['event.timestamp'],
|
||||
extension_name: this.extension_name,
|
||||
extension_version: this.extension_version,
|
||||
extension_previous_version: this.extension_previous_version,
|
||||
extension_source: this.extension_source,
|
||||
status: this.status,
|
||||
};
|
||||
}
|
||||
|
||||
toLogBody(): string {
|
||||
return `Updated extension ${this.extension_name}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const EVENT_EXTENSION_ENABLE = 'gemini_cli.extension_enable';
|
||||
export class ExtensionEnableEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'extension_enable';
|
||||
|
||||
Reference in New Issue
Block a user