mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-28 05:55:17 -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.');
|
throw new Error('Either --source or --path must be provided.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const extensionName = await installExtension(installMetadata);
|
const name = await installExtension(installMetadata);
|
||||||
console.log(
|
console.log(`Extension "${name}" installed successfully and enabled.`);
|
||||||
`Extension "${extensionName}" installed successfully and enabled.`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(getErrorMessage(error));
|
console.error(getErrorMessage(error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
GEMINI_DIR,
|
GEMINI_DIR,
|
||||||
type GeminiCLIExtension,
|
type GeminiCLIExtension,
|
||||||
type MCPServerConfig,
|
type MCPServerConfig,
|
||||||
|
ClearcutLogger,
|
||||||
|
type Config,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { SettingScope, loadSettings } from './settings.js';
|
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) => {
|
vi.mock('child_process', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('child_process')>();
|
const actual = await importOriginal<typeof import('child_process')>();
|
||||||
return {
|
return {
|
||||||
@@ -519,6 +537,19 @@ describe('installExtension', () => {
|
|||||||
});
|
});
|
||||||
fs.rmSync(targetExtDir, { recursive: true, force: true });
|
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', () => {
|
describe('uninstallExtension', () => {
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import type {
|
|||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
GeminiCLIExtension,
|
GeminiCLIExtension,
|
||||||
} from '@google/gemini-cli-core';
|
} 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 fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
@@ -18,6 +24,7 @@ import { getErrorMessage } from '../utils/errors.js';
|
|||||||
import { recursivelyHydrateStrings } from './extensions/variables.js';
|
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';
|
||||||
|
|
||||||
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
||||||
|
|
||||||
@@ -346,83 +353,120 @@ export async function installExtension(
|
|||||||
installMetadata: ExtensionInstallMetadata,
|
installMetadata: ExtensionInstallMetadata,
|
||||||
cwd: string = process.cwd(),
|
cwd: string = process.cwd(),
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const settings = loadSettings(cwd).merged;
|
const config = new Config({
|
||||||
if (!isWorkspaceTrusted(settings)) {
|
sessionId: randomUUID(),
|
||||||
throw new Error(
|
targetDir: process.cwd(),
|
||||||
`Could not install extension from untrusted folder at ${installMetadata.source}`,
|
cwd: process.cwd(),
|
||||||
);
|
model: '',
|
||||||
}
|
debugMode: false,
|
||||||
|
});
|
||||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
const logger = ClearcutLogger.getInstance(config);
|
||||||
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
let newExtensionConfig: ExtensionConfig | null = null;
|
||||||
|
let localSourcePath: string | undefined;
|
||||||
// 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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
const settings = loadSettings(cwd).merged;
|
||||||
if (!newExtensionConfig) {
|
if (!isWorkspaceTrusted(settings)) {
|
||||||
throw new Error(
|
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 extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
||||||
const destinationPath = extensionStorage.getExtensionDir();
|
|
||||||
|
|
||||||
const installedExtensions = loadUserExtensions();
|
|
||||||
if (
|
if (
|
||||||
installedExtensions.some(
|
!path.isAbsolute(installMetadata.source) &&
|
||||||
(installed) => installed.config.name === newExtensionName,
|
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
installMetadata.source = path.resolve(cwd, installMetadata.source);
|
||||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
}
|
||||||
|
|
||||||
|
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') {
|
return newExtensionConfig!.name;
|
||||||
await copyExtension(localSourcePath, destinationPath);
|
} catch (error) {
|
||||||
}
|
// Attempt to load config from the source path even if installation fails
|
||||||
|
// to get the name and version for logging.
|
||||||
const metadataString = JSON.stringify(installMetadata, null, 2);
|
if (!newExtensionConfig && localSourcePath) {
|
||||||
const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME);
|
newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||||
await fs.promises.writeFile(metadataPath, metadataString);
|
|
||||||
} finally {
|
|
||||||
if (tempDir) {
|
|
||||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
||||||
}
|
}
|
||||||
|
logger?.logExtensionInstallEvent(
|
||||||
|
new ExtensionInstallEvent(
|
||||||
|
newExtensionConfig?.name ?? '',
|
||||||
|
newExtensionConfig?.version ?? '',
|
||||||
|
installMetadata.source,
|
||||||
|
'error',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newExtensionName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadExtensionConfig(
|
export async function loadExtensionConfig(
|
||||||
extensionDir: string,
|
extensionDir: string,
|
||||||
): Promise<ExtensionConfig | null> {
|
): Promise<ExtensionConfig | null> {
|
||||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export { logIdeConnection } from './src/telemetry/loggers.js';
|
|||||||
export {
|
export {
|
||||||
IdeConnectionEvent,
|
IdeConnectionEvent,
|
||||||
IdeConnectionType,
|
IdeConnectionType,
|
||||||
|
ExtensionInstallEvent,
|
||||||
} from './src/telemetry/types.js';
|
} from './src/telemetry/types.js';
|
||||||
export { getIdeTrust } from './src/utils/ide-trust.js';
|
export { getIdeTrust } from './src/utils/ide-trust.js';
|
||||||
export { makeFakeConfig } from './src/test-utils/config.js';
|
export { makeFakeConfig } from './src/test-utils/config.js';
|
||||||
export * from './src/utils/pathReader.js';
|
export * from './src/utils/pathReader.js';
|
||||||
|
export { ClearcutLogger } from './src/telemetry/clearcut-logger/clearcut-logger.js';
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
InvalidChunkEvent,
|
InvalidChunkEvent,
|
||||||
ContentRetryEvent,
|
ContentRetryEvent,
|
||||||
ContentRetryFailureEvent,
|
ContentRetryFailureEvent,
|
||||||
|
ExtensionInstallEvent,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { EventMetadataKey } from './event-metadata-key.js';
|
import { EventMetadataKey } from './event-metadata-key.js';
|
||||||
import type { Config } from '../../config/config.js';
|
import type { Config } from '../../config/config.js';
|
||||||
@@ -56,6 +57,7 @@ export enum EventNames {
|
|||||||
INVALID_CHUNK = 'invalid_chunk',
|
INVALID_CHUNK = 'invalid_chunk',
|
||||||
CONTENT_RETRY = 'content_retry',
|
CONTENT_RETRY = 'content_retry',
|
||||||
CONTENT_RETRY_FAILURE = 'content_retry_failure',
|
CONTENT_RETRY_FAILURE = 'content_retry_failure',
|
||||||
|
EXTENSION_INSTALL = 'extension_install',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogResponse {
|
export interface LogResponse {
|
||||||
@@ -833,6 +835,32 @@ export class ClearcutLogger {
|
|||||||
this.flushIfNeeded();
|
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
|
* Adds default fields to data, and returns a new data array. This fields
|
||||||
* should exist on all log events.
|
* should exist on all log events.
|
||||||
|
|||||||
@@ -331,4 +331,20 @@ export enum EventMetadataKey {
|
|||||||
|
|
||||||
// Logs the current nodejs version
|
// Logs the current nodejs version
|
||||||
GEMINI_CLI_NODE_VERSION = 83,
|
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
|
| FileOperationEvent
|
||||||
| InvalidChunkEvent
|
| InvalidChunkEvent
|
||||||
| ContentRetryEvent
|
| 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