Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions packages/cli/src/commands/extensions/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/src/config/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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', () => {
Expand Down
164 changes: 104 additions & 60 deletions packages/cli/src/config/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');

Expand Down Expand Up @@ -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);
}

await fs.promises.mkdir(destinationPath, { recursive: true });
let tempDir: string | undefined;

if (installMetadata.type === 'local' || installMetadata.type === 'git') {
await copyExtension(localSourcePath, destinationPath);
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 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 });
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 });
}
}
}

return newExtensionName;
logger?.logExtensionInstallEvent(
new ExtensionInstallEvent(
newExtensionConfig!.name,
newExtensionConfig!.version,
installMetadata.source,
'success',
),
);

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;
}
}

async function loadExtensionConfig(
export async function loadExtensionConfig(
extensionDir: string,
): Promise<ExtensionConfig | null> {
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
Expand Down
2 changes: 2 additions & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
28 changes: 28 additions & 0 deletions packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
26 changes: 25 additions & 1 deletion packages/core/src/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading