mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 16:41:11 -07:00
Add extensions logging (#11261)
This commit is contained in:
@@ -603,6 +603,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
{
|
||||
path: '/path/to/ext1',
|
||||
name: 'ext1',
|
||||
id: 'ext1-id',
|
||||
version: '1.0.0',
|
||||
contextFiles: ['/path/to/ext1/GEMINI.md'],
|
||||
isActive: true,
|
||||
@@ -610,6 +611,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
{
|
||||
path: '/path/to/ext2',
|
||||
name: 'ext2',
|
||||
id: 'ext2-id',
|
||||
version: '1.0.0',
|
||||
contextFiles: [],
|
||||
isActive: true,
|
||||
@@ -617,6 +619,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
{
|
||||
path: '/path/to/ext3',
|
||||
name: 'ext3',
|
||||
id: 'ext3-id',
|
||||
version: '1.0.0',
|
||||
contextFiles: [
|
||||
'/path/to/ext3/context1.md',
|
||||
@@ -690,6 +693,8 @@ describe('mergeMcpServers', () => {
|
||||
{
|
||||
path: '/path/to/ext1',
|
||||
name: 'ext1',
|
||||
id: 'ext1-id',
|
||||
|
||||
version: '1.0.0',
|
||||
mcpServers: {
|
||||
'ext1-server': {
|
||||
@@ -730,6 +735,7 @@ describe('mergeExcludeTools', () => {
|
||||
{
|
||||
path: '/path/to/ext1',
|
||||
name: 'ext1',
|
||||
id: 'ext1-id',
|
||||
version: '1.0.0',
|
||||
excludeTools: ['tool3', 'tool4'],
|
||||
contextFiles: [],
|
||||
@@ -738,6 +744,7 @@ describe('mergeExcludeTools', () => {
|
||||
{
|
||||
path: '/path/to/ext2',
|
||||
name: 'ext2',
|
||||
id: 'ext2-id',
|
||||
version: '1.0.0',
|
||||
excludeTools: ['tool5'],
|
||||
contextFiles: [],
|
||||
@@ -764,6 +771,7 @@ describe('mergeExcludeTools', () => {
|
||||
{
|
||||
path: '/path/to/ext1',
|
||||
name: 'ext1',
|
||||
id: 'ext1-id',
|
||||
version: '1.0.0',
|
||||
excludeTools: ['tool2', 'tool3'],
|
||||
contextFiles: [],
|
||||
@@ -790,6 +798,7 @@ describe('mergeExcludeTools', () => {
|
||||
{
|
||||
path: '/path/to/ext1',
|
||||
name: 'ext1',
|
||||
id: 'ext1-id',
|
||||
version: '1.0.0',
|
||||
excludeTools: ['tool2', 'tool3'],
|
||||
contextFiles: [],
|
||||
@@ -798,6 +807,7 @@ describe('mergeExcludeTools', () => {
|
||||
{
|
||||
path: '/path/to/ext2',
|
||||
name: 'ext2',
|
||||
id: 'ext2-id',
|
||||
version: '1.0.0',
|
||||
excludeTools: ['tool3', 'tool4'],
|
||||
contextFiles: [],
|
||||
@@ -871,6 +881,7 @@ describe('mergeExcludeTools', () => {
|
||||
{
|
||||
path: '/path/to/ext',
|
||||
name: 'ext1',
|
||||
id: 'ext1-id',
|
||||
version: '1.0.0',
|
||||
excludeTools: ['tool1', 'tool2'],
|
||||
contextFiles: [],
|
||||
@@ -897,6 +908,7 @@ describe('mergeExcludeTools', () => {
|
||||
{
|
||||
path: '/path/to/ext',
|
||||
name: 'ext1',
|
||||
id: 'ext1-id',
|
||||
version: '1.0.0',
|
||||
excludeTools: ['tool2'],
|
||||
contextFiles: [],
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
loadExtensionConfig,
|
||||
loadExtensions,
|
||||
uninstallExtension,
|
||||
hashValue,
|
||||
} from './extension.js';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
@@ -1259,6 +1260,10 @@ This extension will run the following MCP servers:
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: userExtensionsDir,
|
||||
type: 'local',
|
||||
},
|
||||
});
|
||||
|
||||
await uninstallExtension('my-local-extension', isUpdate);
|
||||
@@ -1269,7 +1274,8 @@ This extension will run the following MCP servers:
|
||||
} else {
|
||||
expect(mockLogExtensionUninstall).toHaveBeenCalled();
|
||||
expect(ExtensionUninstallEvent).toHaveBeenCalledWith(
|
||||
'my-local-extension',
|
||||
hashValue('my-local-extension'),
|
||||
hashValue(userExtensionsDir),
|
||||
'success',
|
||||
);
|
||||
}
|
||||
@@ -1313,7 +1319,8 @@ This extension will run the following MCP servers:
|
||||
expect(fs.existsSync(sourceExtDir)).toBe(false);
|
||||
expect(mockLogExtensionUninstall).toHaveBeenCalled();
|
||||
expect(ExtensionUninstallEvent).toHaveBeenCalledWith(
|
||||
'gemini-sql-extension',
|
||||
hashValue('gemini-sql-extension'),
|
||||
hashValue('https://github.com/google/gemini-sql-extension'),
|
||||
'success',
|
||||
);
|
||||
});
|
||||
@@ -1423,6 +1430,10 @@ This extension will run the following MCP servers:
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: userExtensionsDir,
|
||||
type: 'local',
|
||||
},
|
||||
});
|
||||
|
||||
disableExtension(
|
||||
@@ -1433,7 +1444,8 @@ This extension will run the following MCP servers:
|
||||
|
||||
expect(mockLogExtensionDisable).toHaveBeenCalled();
|
||||
expect(ExtensionDisableEvent).toHaveBeenCalledWith(
|
||||
'ext1',
|
||||
hashValue('ext1'),
|
||||
hashValue(userExtensionsDir),
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
});
|
||||
@@ -1497,6 +1509,10 @@ This extension will run the following MCP servers:
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: userExtensionsDir,
|
||||
type: 'local',
|
||||
},
|
||||
});
|
||||
const extensionEnablementManager = new ExtensionEnablementManager();
|
||||
disableExtension(
|
||||
@@ -1512,7 +1528,8 @@ This extension will run the following MCP servers:
|
||||
|
||||
expect(mockLogExtensionEnable).toHaveBeenCalled();
|
||||
expect(ExtensionEnableEvent).toHaveBeenCalledWith(
|
||||
'ext1',
|
||||
hashValue('ext1'),
|
||||
hashValue(userExtensionsDir),
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -199,28 +199,6 @@ export function loadExtension(
|
||||
)
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
// IDs are created by hashing details of the installation source in order to
|
||||
// deduplicate extensions with conflicting names and also obfuscate any
|
||||
// potentially sensitive information such as private git urls, system paths,
|
||||
// or project names.
|
||||
const hash = createHash('sha256');
|
||||
const githubUrlParts =
|
||||
installMetadata &&
|
||||
(installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release')
|
||||
? tryParseGithubUrl(installMetadata.source)
|
||||
: null;
|
||||
if (githubUrlParts) {
|
||||
// For github repos, we use the https URI to the repo as the ID.
|
||||
hash.update(
|
||||
`https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`,
|
||||
);
|
||||
} else {
|
||||
hash.update(installMetadata?.source ?? config.name);
|
||||
}
|
||||
|
||||
const id = hash.digest('hex');
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
@@ -230,7 +208,7 @@ export function loadExtension(
|
||||
mcpServers: config.mcpServers,
|
||||
excludeTools: config.excludeTools,
|
||||
isActive: extensionEnablementManager.isEnabled(config.name, workspaceDir),
|
||||
id,
|
||||
id: getExtensionId(config, installMetadata),
|
||||
};
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
@@ -384,6 +362,10 @@ async function promptForConsentInteractive(
|
||||
});
|
||||
}
|
||||
|
||||
export function hashValue(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
export async function installOrUpdateExtension(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
@@ -520,15 +502,15 @@ export async function installOrUpdateExtension(
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (isUpdate) {
|
||||
logExtensionUpdateEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionUpdateEvent(
|
||||
newExtensionConfig.name,
|
||||
hashValue(newExtensionConfig.name),
|
||||
getExtensionId(newExtensionConfig, installMetadata),
|
||||
newExtensionConfig.version,
|
||||
previousExtensionConfig.version,
|
||||
installMetadata.source,
|
||||
installMetadata.type,
|
||||
'success',
|
||||
),
|
||||
);
|
||||
@@ -536,9 +518,10 @@ export async function installOrUpdateExtension(
|
||||
logExtensionInstallEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig.name,
|
||||
hashValue(newExtensionConfig.name),
|
||||
getExtensionId(newExtensionConfig, installMetadata),
|
||||
newExtensionConfig.version,
|
||||
installMetadata.source,
|
||||
installMetadata.type,
|
||||
'success',
|
||||
),
|
||||
);
|
||||
@@ -564,14 +547,19 @@ export async function installOrUpdateExtension(
|
||||
// Ignore error, this is just for logging.
|
||||
}
|
||||
}
|
||||
const config = newExtensionConfig ?? previousExtensionConfig;
|
||||
const extensionId = config
|
||||
? getExtensionId(config, installMetadata)
|
||||
: undefined;
|
||||
if (isUpdate) {
|
||||
logExtensionUpdateEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionUpdateEvent(
|
||||
newExtensionConfig?.name ?? previousExtensionConfig.name,
|
||||
hashValue(config?.name ?? ''),
|
||||
extensionId ?? '',
|
||||
newExtensionConfig?.version ?? '',
|
||||
previousExtensionConfig.version,
|
||||
installMetadata.source,
|
||||
installMetadata.type,
|
||||
'error',
|
||||
),
|
||||
);
|
||||
@@ -579,9 +567,10 @@ export async function installOrUpdateExtension(
|
||||
logExtensionInstallEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig?.name ?? '',
|
||||
hashValue(newExtensionConfig?.name ?? ''),
|
||||
extensionId ?? '',
|
||||
newExtensionConfig?.version ?? '',
|
||||
installMetadata.source,
|
||||
installMetadata.type,
|
||||
'error',
|
||||
),
|
||||
);
|
||||
@@ -707,16 +696,16 @@ export async function uninstallExtension(
|
||||
new ExtensionEnablementManager(),
|
||||
cwd,
|
||||
);
|
||||
const extensionName = installedExtensions.find(
|
||||
const extension = installedExtensions.find(
|
||||
(installed) =>
|
||||
installed.name.toLowerCase() === extensionIdentifier.toLowerCase() ||
|
||||
installed.installMetadata?.source.toLowerCase() ===
|
||||
extensionIdentifier.toLowerCase(),
|
||||
)?.name;
|
||||
if (!extensionName) {
|
||||
);
|
||||
if (!extension) {
|
||||
throw new Error(`Extension not found.`);
|
||||
}
|
||||
const storage = new ExtensionStorage(extensionName);
|
||||
const storage = new ExtensionStorage(extension.name);
|
||||
|
||||
await fs.promises.rm(storage.getExtensionDir(), {
|
||||
recursive: true,
|
||||
@@ -727,13 +716,17 @@ export async function uninstallExtension(
|
||||
// uninstalls related to updates.
|
||||
if (isUpdate) return;
|
||||
|
||||
const manager = new ExtensionEnablementManager([extensionName]);
|
||||
manager.remove(extensionName);
|
||||
const manager = new ExtensionEnablementManager([extension.name]);
|
||||
manager.remove(extension.name);
|
||||
|
||||
const telemetryConfig = getTelemetryConfig(cwd);
|
||||
logExtensionUninstall(
|
||||
telemetryConfig,
|
||||
new ExtensionUninstallEvent(extensionName, 'success'),
|
||||
new ExtensionUninstallEvent(
|
||||
hashValue(extension.name),
|
||||
extension.id,
|
||||
'success',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -747,6 +740,7 @@ export function toOutputString(
|
||||
|
||||
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
|
||||
let output = `${status} ${extension.name} (${extension.version})`;
|
||||
output += `\n ID: ${extension.id}`;
|
||||
output += `\n Path: ${extension.path}`;
|
||||
if (extension.installMetadata) {
|
||||
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
|
||||
@@ -797,7 +791,10 @@ export function disableExtension(
|
||||
|
||||
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
|
||||
extensionEnablementManager.disable(name, true, scopePath);
|
||||
logExtensionDisable(config, new ExtensionDisableEvent(name, scope));
|
||||
logExtensionDisable(
|
||||
config,
|
||||
new ExtensionDisableEvent(hashValue(name), extension.id, scope),
|
||||
);
|
||||
}
|
||||
|
||||
export function enableExtension(
|
||||
@@ -816,5 +813,32 @@ export function enableExtension(
|
||||
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
|
||||
extensionEnablementManager.enable(name, true, scopePath);
|
||||
const config = getTelemetryConfig(cwd);
|
||||
logExtensionEnable(config, new ExtensionEnableEvent(name, scope));
|
||||
logExtensionEnable(
|
||||
config,
|
||||
new ExtensionEnableEvent(hashValue(name), extension.id, scope),
|
||||
);
|
||||
}
|
||||
|
||||
function getExtensionId(
|
||||
config: ExtensionConfig,
|
||||
installMetadata?: ExtensionInstallMetadata,
|
||||
): string {
|
||||
// IDs are created by hashing details of the installation source in order to
|
||||
// deduplicate extensions with conflicting names and also obfuscate any
|
||||
// potentially sensitive information such as private git urls, system paths,
|
||||
// or project names.
|
||||
let idValue = config.name;
|
||||
const githubUrlParts =
|
||||
installMetadata &&
|
||||
(installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release')
|
||||
? tryParseGithubUrl(installMetadata.source)
|
||||
: null;
|
||||
if (githubUrlParts) {
|
||||
// For github repos, we use the https URI to the repo as the ID.
|
||||
idValue = `https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`;
|
||||
} else {
|
||||
idValue = installMetadata?.source ?? config.name;
|
||||
}
|
||||
return hashValue(idValue);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ describe('git extension helpers', () => {
|
||||
it('should return NOT_UPDATABLE for non-git extensions', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
id: 'test-id',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
@@ -160,6 +161,7 @@ describe('git extension helpers', () => {
|
||||
it('should return ERROR if no remotes found', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
id: 'test-id',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
@@ -180,6 +182,7 @@ describe('git extension helpers', () => {
|
||||
it('should return UPDATE_AVAILABLE when remote hash is different', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
id: 'test-id',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
@@ -205,6 +208,7 @@ describe('git extension helpers', () => {
|
||||
it('should return UP_TO_DATE when remote and local hashes are the same', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
id: 'test-id',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
@@ -230,6 +234,7 @@ describe('git extension helpers', () => {
|
||||
it('should return ERROR on git error', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
id: 'test-id',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
|
||||
@@ -223,6 +223,7 @@ describe('extensionsCommand', () => {
|
||||
|
||||
const extensionOne: GeminiCLIExtension = {
|
||||
name: 'ext-one',
|
||||
id: 'ext-one-id',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-one',
|
||||
@@ -235,6 +236,7 @@ describe('extensionsCommand', () => {
|
||||
};
|
||||
const extensionTwo: GeminiCLIExtension = {
|
||||
name: 'another-ext',
|
||||
id: 'another-ext-id',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/another-ext',
|
||||
@@ -247,6 +249,7 @@ describe('extensionsCommand', () => {
|
||||
};
|
||||
const allExt: GeminiCLIExtension = {
|
||||
name: 'all-ext',
|
||||
id: 'all-ext-id',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/all-ext',
|
||||
|
||||
@@ -196,6 +196,7 @@ export interface SlashCommand {
|
||||
|
||||
// Optional metadata for extension commands
|
||||
extensionName?: string;
|
||||
extensionId?: string;
|
||||
|
||||
// The action to run. Optional for parent commands that only group sub-commands.
|
||||
action?: (
|
||||
|
||||
@@ -518,6 +518,7 @@ export const useSlashCommandProcessor = (
|
||||
command: resolvedCommandPath[0],
|
||||
subcommand,
|
||||
status: SlashCommandStatus.ERROR,
|
||||
extension_id: commandToExecute?.extensionId,
|
||||
});
|
||||
logSlashCommand(config, event);
|
||||
}
|
||||
@@ -535,6 +536,7 @@ export const useSlashCommandProcessor = (
|
||||
command: resolvedCommandPath[0],
|
||||
subcommand,
|
||||
status: SlashCommandStatus.SUCCESS,
|
||||
extension_id: commandToExecute?.extensionId,
|
||||
});
|
||||
logSlashCommand(config, event);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ describe('useExtensionUpdates', () => {
|
||||
const extensions = [
|
||||
{
|
||||
name: 'test-extension',
|
||||
id: 'test-extension-id',
|
||||
type: 'git',
|
||||
version: '1.0.0',
|
||||
path: '/some/path',
|
||||
@@ -269,6 +270,7 @@ describe('useExtensionUpdates', () => {
|
||||
const extensions = [
|
||||
{
|
||||
name: 'test-extension-1',
|
||||
id: 'test-extension-1-id',
|
||||
type: 'git',
|
||||
version: '1.0.0',
|
||||
path: '/some/path1',
|
||||
@@ -282,6 +284,8 @@ describe('useExtensionUpdates', () => {
|
||||
},
|
||||
{
|
||||
name: 'test-extension-2',
|
||||
id: 'test-extension-2-id',
|
||||
|
||||
type: 'git',
|
||||
version: '2.0.0',
|
||||
path: '/some/path2',
|
||||
|
||||
Reference in New Issue
Block a user