Add clearcut logging for extensions install command (#8057)

Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
This commit is contained in:
christine betts
2025-09-09 12:12:56 -04:00
committed by GitHub
parent ef9469a417
commit 02f67d3c57
7 changed files with 209 additions and 66 deletions

View File

@@ -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);

View File

@@ -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', () => {

View File

@@ -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);