mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 06:31:01 -07:00
Add clearcut logging for extensions install command (#8057)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
This commit is contained in:
@@ -45,10 +45,8 @@ export async function handleInstall(args: InstallArgs) {
|
||||
throw new Error('Either --source or --path must be provided.');
|
||||
}
|
||||
|
||||
const extensionName = await installExtension(installMetadata);
|
||||
console.log(
|
||||
`Extension "${extensionName}" installed successfully and enabled.`,
|
||||
);
|
||||
const name = await installExtension(installMetadata);
|
||||
console.log(`Extension "${name}" installed successfully and enabled.`);
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
GEMINI_DIR,
|
||||
type GeminiCLIExtension,
|
||||
type MCPServerConfig,
|
||||
ClearcutLogger,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { SettingScope, loadSettings } from './settings.js';
|
||||
@@ -52,6 +54,22 @@ vi.mock('./trustedFolders.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mockLogExtensionInstallEvent = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
ClearcutLogger: {
|
||||
getInstance: vi.fn(() => ({
|
||||
logExtensionInstallEvent: mockLogExtensionInstallEvent,
|
||||
})),
|
||||
},
|
||||
Config: vi.fn(),
|
||||
ExtensionInstallEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
@@ -519,6 +537,19 @@ describe('installExtension', () => {
|
||||
});
|
||||
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',
|
||||
});
|
||||
|
||||
await installExtension({ source: sourceExtDir, type: 'local' });
|
||||
|
||||
const logger = ClearcutLogger.getInstance({} as Config);
|
||||
expect(logger?.logExtensionInstallEvent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallExtension', () => {
|
||||
|
||||
@@ -8,7 +8,13 @@ import type {
|
||||
MCPServerConfig,
|
||||
GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { GEMINI_DIR, Storage } from '@google/gemini-cli-core';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
ClearcutLogger,
|
||||
Config,
|
||||
ExtensionInstallEvent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -18,6 +24,7 @@ import { getErrorMessage } from '../utils/errors.js';
|
||||
import { recursivelyHydrateStrings } from './extensions/variables.js';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
||||
|
||||
@@ -346,83 +353,120 @@ export async function installExtension(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<string> {
|
||||
const settings = loadSettings(cwd).merged;
|
||||
if (!isWorkspaceTrusted(settings)) {
|
||||
throw new Error(
|
||||
`Could not install extension from untrusted folder at ${installMetadata.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
||||
|
||||
// Convert relative paths to absolute paths for the metadata file.
|
||||
if (
|
||||
!path.isAbsolute(installMetadata.source) &&
|
||||
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
||||
) {
|
||||
installMetadata.source = path.resolve(cwd, installMetadata.source);
|
||||
}
|
||||
|
||||
let localSourcePath: string;
|
||||
let tempDir: string | undefined;
|
||||
let newExtensionName: string | undefined;
|
||||
|
||||
if (installMetadata.type === 'git') {
|
||||
tempDir = await ExtensionStorage.createTmpDir();
|
||||
await cloneFromGit(installMetadata.source, tempDir);
|
||||
localSourcePath = tempDir;
|
||||
} else if (
|
||||
installMetadata.type === 'local' ||
|
||||
installMetadata.type === 'link'
|
||||
) {
|
||||
localSourcePath = installMetadata.source;
|
||||
} else {
|
||||
throw new Error(`Unsupported install type: ${installMetadata.type}`);
|
||||
}
|
||||
const config = new Config({
|
||||
sessionId: randomUUID(),
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model: '',
|
||||
debugMode: false,
|
||||
});
|
||||
const logger = ClearcutLogger.getInstance(config);
|
||||
let newExtensionConfig: ExtensionConfig | null = null;
|
||||
let localSourcePath: string | undefined;
|
||||
|
||||
try {
|
||||
const newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||
if (!newExtensionConfig) {
|
||||
const settings = loadSettings(cwd).merged;
|
||||
if (!isWorkspaceTrusted(settings)) {
|
||||
throw new Error(
|
||||
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
|
||||
`Could not install extension from untrusted folder at ${installMetadata.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
newExtensionName = newExtensionConfig.name;
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
||||
|
||||
const installedExtensions = loadUserExtensions();
|
||||
if (
|
||||
installedExtensions.some(
|
||||
(installed) => installed.config.name === newExtensionName,
|
||||
)
|
||||
!path.isAbsolute(installMetadata.source) &&
|
||||
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||
installMetadata.source = path.resolve(cwd, installMetadata.source);
|
||||
}
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
if (installMetadata.type === 'git') {
|
||||
tempDir = await ExtensionStorage.createTmpDir();
|
||||
await cloneFromGit(installMetadata.source, tempDir);
|
||||
localSourcePath = tempDir;
|
||||
} else if (
|
||||
installMetadata.type === 'local' ||
|
||||
installMetadata.type === 'link'
|
||||
) {
|
||||
localSourcePath = installMetadata.source;
|
||||
} else {
|
||||
throw new Error(`Unsupported install type: ${installMetadata.type}`);
|
||||
}
|
||||
|
||||
try {
|
||||
newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||
if (!newExtensionConfig) {
|
||||
throw new Error(
|
||||
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
|
||||
);
|
||||
}
|
||||
|
||||
const newExtensionName = newExtensionConfig.name;
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
|
||||
const installedExtensions = loadUserExtensions();
|
||||
if (
|
||||
installedExtensions.some(
|
||||
(installed) => installed.config.name === newExtensionName,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
|
||||
await copyExtension(localSourcePath, destinationPath);
|
||||
}
|
||||
|
||||
const metadataString = JSON.stringify(installMetadata, null, 2);
|
||||
const metadataPath = path.join(
|
||||
destinationPath,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
);
|
||||
await fs.promises.writeFile(metadataPath, metadataString);
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
logger?.logExtensionInstallEvent(
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig!.name,
|
||||
newExtensionConfig!.version,
|
||||
installMetadata.source,
|
||||
'success',
|
||||
),
|
||||
);
|
||||
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
|
||||
await copyExtension(localSourcePath, destinationPath);
|
||||
}
|
||||
|
||||
const metadataString = JSON.stringify(installMetadata, null, 2);
|
||||
const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME);
|
||||
await fs.promises.writeFile(metadataPath, metadataString);
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
return newExtensionConfig!.name;
|
||||
} catch (error) {
|
||||
// Attempt to load config from the source path even if installation fails
|
||||
// to get the name and version for logging.
|
||||
if (!newExtensionConfig && localSourcePath) {
|
||||
newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||
}
|
||||
logger?.logExtensionInstallEvent(
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig?.name ?? '',
|
||||
newExtensionConfig?.version ?? '',
|
||||
installMetadata.source,
|
||||
'error',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return newExtensionName;
|
||||
}
|
||||
|
||||
async function loadExtensionConfig(
|
||||
export async function loadExtensionConfig(
|
||||
extensionDir: string,
|
||||
): Promise<ExtensionConfig | null> {
|
||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
|
||||
@@ -21,7 +21,9 @@ export { logIdeConnection } from './src/telemetry/loggers.js';
|
||||
export {
|
||||
IdeConnectionEvent,
|
||||
IdeConnectionType,
|
||||
ExtensionInstallEvent,
|
||||
} from './src/telemetry/types.js';
|
||||
export { getIdeTrust } from './src/utils/ide-trust.js';
|
||||
export { makeFakeConfig } from './src/test-utils/config.js';
|
||||
export * from './src/utils/pathReader.js';
|
||||
export { ClearcutLogger } from './src/telemetry/clearcut-logger/clearcut-logger.js';
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
InvalidChunkEvent,
|
||||
ContentRetryEvent,
|
||||
ContentRetryFailureEvent,
|
||||
ExtensionInstallEvent,
|
||||
} from '../types.js';
|
||||
import { EventMetadataKey } from './event-metadata-key.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
@@ -56,6 +57,7 @@ export enum EventNames {
|
||||
INVALID_CHUNK = 'invalid_chunk',
|
||||
CONTENT_RETRY = 'content_retry',
|
||||
CONTENT_RETRY_FAILURE = 'content_retry_failure',
|
||||
EXTENSION_INSTALL = 'extension_install',
|
||||
}
|
||||
|
||||
export interface LogResponse {
|
||||
@@ -833,6 +835,32 @@ export class ClearcutLogger {
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logExtensionInstallEvent(event: ExtensionInstallEvent): 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_SOURCE,
|
||||
value: event.extension_source,
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_INSTALL_STATUS,
|
||||
value: event.status,
|
||||
},
|
||||
];
|
||||
|
||||
this.enqueueLogEvent(
|
||||
this.createLogEvent(EventNames.EXTENSION_INSTALL, data),
|
||||
);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds default fields to data, and returns a new data array. This fields
|
||||
* should exist on all log events.
|
||||
|
||||
@@ -331,4 +331,20 @@ export enum EventMetadataKey {
|
||||
|
||||
// Logs the current nodejs version
|
||||
GEMINI_CLI_NODE_VERSION = 83,
|
||||
|
||||
// ==========================================================================
|
||||
// Extension Install Event Keys
|
||||
// ===========================================================================
|
||||
|
||||
// Logs the name of the extension.
|
||||
GEMINI_CLI_EXTENSION_NAME = 85,
|
||||
|
||||
// Logs the version of the extension.
|
||||
GEMINI_CLI_EXTENSION_VERSION = 86,
|
||||
|
||||
// Logs the source of the extension.
|
||||
GEMINI_CLI_EXTENSION_SOURCE = 87,
|
||||
|
||||
// Logs the status of the extension install.
|
||||
GEMINI_CLI_EXTENSION_INSTALL_STATUS = 88,
|
||||
}
|
||||
|
||||
@@ -531,4 +531,28 @@ export type TelemetryEvent =
|
||||
| FileOperationEvent
|
||||
| InvalidChunkEvent
|
||||
| ContentRetryEvent
|
||||
| ContentRetryFailureEvent;
|
||||
| ContentRetryFailureEvent
|
||||
| ExtensionInstallEvent;
|
||||
|
||||
export class ExtensionInstallEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'extension_install';
|
||||
'event.timestamp': string;
|
||||
extension_name: string;
|
||||
extension_version: string;
|
||||
extension_source: string;
|
||||
status: 'success' | 'error';
|
||||
|
||||
constructor(
|
||||
extension_name: string,
|
||||
extension_version: string,
|
||||
extension_source: string,
|
||||
status: 'success' | 'error',
|
||||
) {
|
||||
this['event.name'] = 'extension_install';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.extension_name = extension_name;
|
||||
this.extension_version = extension_version;
|
||||
this.extension_source = extension_source;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user