Show settings source in extensions lists (#16207)

This commit is contained in:
christine betts
2026-01-09 12:04:53 -05:00
committed by GitHub
parent b9f8858bfb
commit 77e226c55f
8 changed files with 329 additions and 17 deletions
@@ -0,0 +1,196 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import type { Settings } from './settings.js';
let currentTempHome = '';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
homedir: () => currentTempHome,
debugLogger: {
log: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
};
});
describe('ExtensionManager Settings Scope', () => {
const extensionName = 'test-extension';
let tempWorkspace: string;
let extensionsDir: string;
let extensionDir: string;
beforeEach(async () => {
currentTempHome = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
extensionsDir = path.join(currentTempHome, '.gemini', 'extensions');
extensionDir = path.join(extensionsDir, extensionName);
fs.mkdirSync(extensionDir, { recursive: true });
// Create gemini-extension.json
const extensionConfig = {
name: extensionName,
version: '1.0.0',
settings: [
{
name: 'Test Setting',
envVar: 'TEST_SETTING',
description: 'A test setting',
},
],
};
fs.writeFileSync(
path.join(extensionDir, 'gemini-extension.json'),
JSON.stringify(extensionConfig),
);
// Create install metadata
const installMetadata = {
source: extensionDir,
type: 'local',
};
fs.writeFileSync(
path.join(extensionDir, 'install-metadata.json'),
JSON.stringify(installMetadata),
);
});
afterEach(() => {
// Clean up files if needed, or rely on temp dir cleanup
vi.clearAllMocks();
});
it('should prioritize workspace settings over user settings and report correct scope', async () => {
// 1. Set User Setting
const userSettingsPath = path.join(extensionDir, '.env');
fs.writeFileSync(userSettingsPath, 'TEST_SETTING=user-value');
// 2. Set Workspace Setting
const workspaceSettingsPath = path.join(tempWorkspace, '.env');
fs.writeFileSync(workspaceSettingsPath, 'TEST_SETTING=workspace-value');
const extensionManager = new ExtensionManager({
workspaceDir: tempWorkspace,
requestConsent: async () => true,
requestSetting: async () => '',
settings: {
telemetry: {
enabled: false,
},
} as Settings,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === extensionName);
expect(extension).toBeDefined();
// Verify resolved settings
const setting = extension?.resolvedSettings?.find(
(s) => s.envVar === 'TEST_SETTING',
);
expect(setting).toBeDefined();
expect(setting?.value).toBe('workspace-value');
expect(setting?.scope).toBe('workspace');
expect(setting?.source).toBe(workspaceSettingsPath);
// Verify output string contains (Workspace - <path>)
const output = extensionManager.toOutputString(extension!);
expect(output).toContain(
`Test Setting: workspace-value (Workspace - ${workspaceSettingsPath})`,
);
});
it('should fallback to user settings if workspace setting is missing', async () => {
// 1. Set User Setting
const userSettingsPath = path.join(extensionDir, '.env');
fs.writeFileSync(userSettingsPath, 'TEST_SETTING=user-value');
// 2. No Workspace Setting
const extensionManager = new ExtensionManager({
workspaceDir: tempWorkspace,
requestConsent: async () => true,
requestSetting: async () => '',
settings: {
telemetry: {
enabled: false,
},
} as Settings,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === extensionName);
expect(extension).toBeDefined();
// Verify resolved settings
const setting = extension?.resolvedSettings?.find(
(s) => s.envVar === 'TEST_SETTING',
);
expect(setting).toBeDefined();
expect(setting?.value).toBe('user-value');
expect(setting?.scope).toBe('user');
expect(setting?.source?.endsWith(path.join(extensionName, '.env'))).toBe(
true,
);
// Verify output string contains (User - <path>)
const output = extensionManager.toOutputString(extension!);
expect(output).toContain(
`Test Setting: user-value (User - ${userSettingsPath})`,
);
});
it('should report unset if neither is present', async () => {
// No settings files
const extensionManager = new ExtensionManager({
workspaceDir: tempWorkspace,
requestConsent: async () => true,
requestSetting: async () => '',
settings: {
telemetry: {
enabled: false,
},
} as Settings,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === extensionName);
expect(extension).toBeDefined();
// Verify resolved settings
const setting = extension?.resolvedSettings?.find(
(s) => s.envVar === 'TEST_SETTING',
);
expect(setting).toBeDefined();
expect(setting?.value).toBe('[not set]');
expect(setting?.scope).toBeUndefined();
// Verify output string does not contain scope
const output = extensionManager.toOutputString(extension!);
expect(output).toContain('Test Setting: [not set]');
expect(output).not.toContain('Test Setting: [not set] (User)');
expect(output).not.toContain('Test Setting: [not set] (Workspace)');
});
});
+53 -3
View File
@@ -59,9 +59,12 @@ import {
} from './extensions/variables.js'; } from './extensions/variables.js';
import { import {
getEnvContents, getEnvContents,
getEnvFilePath,
maybePromptForSettings, maybePromptForSettings,
getMissingSettings, getMissingSettings,
type ExtensionSetting, type ExtensionSetting,
getScopedEnvContents,
ExtensionSettingScope,
} from './extensions/extensionSettings.js'; } from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream'; import type { EventEmitter } from 'node:stream';
import { getEnableHooks } from './settingsSchema.js'; import { getEnableHooks } from './settingsSchema.js';
@@ -277,6 +280,7 @@ Would you like to attempt to install via "git clone" instead?`,
previousSettings = await getEnvContents( previousSettings = await getEnvContents(
previousExtensionConfig, previousExtensionConfig,
extensionId, extensionId,
this.workspaceDir,
); );
await this.uninstallExtension(newExtensionName, isUpdate); await this.uninstallExtension(newExtensionName, isUpdate);
} }
@@ -303,6 +307,7 @@ Would you like to attempt to install via "git clone" instead?`,
const missingSettings = await getMissingSettings( const missingSettings = await getMissingSettings(
newExtensionConfig, newExtensionConfig,
extensionId, extensionId,
this.workspaceDir,
); );
if (missingSettings.length > 0) { if (missingSettings.length > 0) {
const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings
@@ -518,16 +523,51 @@ Would you like to attempt to install via "git clone" instead?`,
); );
} }
const customEnv = await getEnvContents( const extensionId = getExtensionId(config, installMetadata);
const userSettings = await getScopedEnvContents(
config, config,
getExtensionId(config, installMetadata), extensionId,
ExtensionSettingScope.USER,
); );
const workspaceSettings = await getScopedEnvContents(
config,
extensionId,
ExtensionSettingScope.WORKSPACE,
this.workspaceDir,
);
const customEnv = { ...userSettings, ...workspaceSettings };
config = resolveEnvVarsInObject(config, customEnv); config = resolveEnvVarsInObject(config, customEnv);
const resolvedSettings: ResolvedExtensionSetting[] = []; const resolvedSettings: ResolvedExtensionSetting[] = [];
if (config.settings) { if (config.settings) {
for (const setting of config.settings) { for (const setting of config.settings) {
const value = customEnv[setting.envVar]; const value = customEnv[setting.envVar];
let scope: 'user' | 'workspace' | undefined;
let source: string | undefined;
// Note: strict check for undefined, as empty string is a valid value
if (workspaceSettings[setting.envVar] !== undefined) {
scope = 'workspace';
if (setting.sensitive) {
source = 'Keychain';
} else {
source = getEnvFilePath(
config.name,
ExtensionSettingScope.WORKSPACE,
this.workspaceDir,
);
}
} else if (userSettings[setting.envVar] !== undefined) {
scope = 'user';
if (setting.sensitive) {
source = 'Keychain';
} else {
source = getEnvFilePath(config.name, ExtensionSettingScope.USER);
}
}
resolvedSettings.push({ resolvedSettings.push({
name: setting.name, name: setting.name,
envVar: setting.envVar, envVar: setting.envVar,
@@ -538,6 +578,8 @@ Would you like to attempt to install via "git clone" instead?`,
? '***' ? '***'
: value, : value,
sensitive: setting.sensitive ?? false, sensitive: setting.sensitive ?? false,
scope,
source,
}); });
} }
} }
@@ -754,7 +796,15 @@ Would you like to attempt to install via "git clone" instead?`,
if (resolvedSettings && resolvedSettings.length > 0) { if (resolvedSettings && resolvedSettings.length > 0) {
output += `\n Settings:`; output += `\n Settings:`;
resolvedSettings.forEach((setting) => { resolvedSettings.forEach((setting) => {
output += `\n ${setting.name}: ${setting.value}`; let scope = '';
if (setting.scope) {
scope = setting.scope === 'workspace' ? '(Workspace' : '(User';
if (setting.source) {
scope += ` - ${setting.source}`;
}
scope += ')';
}
output += `\n ${setting.name}: ${setting.value} ${scope}`;
}); });
} }
return output; return output;
@@ -529,6 +529,7 @@ describe('extensionSettings', () => {
config, config,
extensionId, extensionId,
ExtensionSettingScope.USER, ExtensionSettingScope.USER,
tempWorkspaceDir,
); );
expect(contents).toEqual({ expect(contents).toEqual({
@@ -552,6 +553,7 @@ describe('extensionSettings', () => {
config, config,
extensionId, extensionId,
ExtensionSettingScope.WORKSPACE, ExtensionSettingScope.WORKSPACE,
tempWorkspaceDir,
); );
expect(contents).toEqual({ expect(contents).toEqual({
@@ -596,7 +598,11 @@ describe('extensionSettings', () => {
); );
await workspaceKeychain.setSecret('VAR2', 'workspace-secret2'); await workspaceKeychain.setSecret('VAR2', 'workspace-secret2');
const contents = await getEnvContents(config, extensionId); const contents = await getEnvContents(
config,
extensionId,
tempWorkspaceDir,
);
expect(contents).toEqual({ expect(contents).toEqual({
VAR1: 'workspace-value1', VAR1: 'workspace-value1',
@@ -636,6 +642,7 @@ describe('extensionSettings', () => {
'VAR1', 'VAR1',
mockRequestSetting, mockRequestSetting,
ExtensionSettingScope.USER, ExtensionSettingScope.USER,
tempWorkspaceDir,
); );
const expectedEnvPath = path.join(extensionDir, '.env'); const expectedEnvPath = path.join(extensionDir, '.env');
@@ -652,6 +659,7 @@ describe('extensionSettings', () => {
'VAR1', 'VAR1',
mockRequestSetting, mockRequestSetting,
ExtensionSettingScope.WORKSPACE, ExtensionSettingScope.WORKSPACE,
tempWorkspaceDir,
); );
const expectedEnvPath = path.join(tempWorkspaceDir, '.env'); const expectedEnvPath = path.join(tempWorkspaceDir, '.env');
@@ -668,6 +676,7 @@ describe('extensionSettings', () => {
'VAR2', 'VAR2',
mockRequestSetting, mockRequestSetting,
ExtensionSettingScope.USER, ExtensionSettingScope.USER,
tempWorkspaceDir,
); );
const userKeychain = new KeychainTokenStorage( const userKeychain = new KeychainTokenStorage(
@@ -685,6 +694,7 @@ describe('extensionSettings', () => {
'VAR2', 'VAR2',
mockRequestSetting, mockRequestSetting,
ExtensionSettingScope.WORKSPACE, ExtensionSettingScope.WORKSPACE,
tempWorkspaceDir,
); );
const workspaceKeychain = new KeychainTokenStorage( const workspaceKeychain = new KeychainTokenStorage(
@@ -710,6 +720,7 @@ describe('extensionSettings', () => {
'VAR1', 'VAR1',
mockRequestSetting, mockRequestSetting,
ExtensionSettingScope.WORKSPACE, ExtensionSettingScope.WORKSPACE,
tempWorkspaceDir,
); );
// Read the .env file after update // Read the .env file after update
@@ -33,20 +33,28 @@ const getKeychainStorageName = (
extensionName: string, extensionName: string,
extensionId: string, extensionId: string,
scope: ExtensionSettingScope, scope: ExtensionSettingScope,
workspaceDir?: string,
): string => { ): string => {
const base = `Gemini CLI Extensions ${extensionName} ${extensionId}`; const base = `Gemini CLI Extensions ${extensionName} ${extensionId}`;
if (scope === ExtensionSettingScope.WORKSPACE) { if (scope === ExtensionSettingScope.WORKSPACE) {
return `${base} ${process.cwd()}`; if (!workspaceDir) {
throw new Error('Workspace directory is required for workspace scope');
}
return `${base} ${workspaceDir}`;
} }
return base; return base;
}; };
const getEnvFilePath = ( export const getEnvFilePath = (
extensionName: string, extensionName: string,
scope: ExtensionSettingScope, scope: ExtensionSettingScope,
workspaceDir?: string,
): string => { ): string => {
if (scope === ExtensionSettingScope.WORKSPACE) { if (scope === ExtensionSettingScope.WORKSPACE) {
return path.join(process.cwd(), EXTENSION_SETTINGS_FILENAME); if (!workspaceDir) {
throw new Error('Workspace directory is required for workspace scope');
}
return path.join(workspaceDir, EXTENSION_SETTINGS_FILENAME);
} }
return new ExtensionStorage(extensionName).getEnvFilePath(); return new ExtensionStorage(extensionName).getEnvFilePath();
}; };
@@ -143,12 +151,13 @@ export async function getScopedEnvContents(
extensionConfig: ExtensionConfig, extensionConfig: ExtensionConfig,
extensionId: string, extensionId: string,
scope: ExtensionSettingScope, scope: ExtensionSettingScope,
workspaceDir?: string,
): Promise<Record<string, string>> { ): Promise<Record<string, string>> {
const { name: extensionName } = extensionConfig; const { name: extensionName } = extensionConfig;
const keychain = new KeychainTokenStorage( const keychain = new KeychainTokenStorage(
getKeychainStorageName(extensionName, extensionId, scope), getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
); );
const envFilePath = getEnvFilePath(extensionName, scope); const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
let customEnv: Record<string, string> = {}; let customEnv: Record<string, string> = {};
if (fsSync.existsSync(envFilePath)) { if (fsSync.existsSync(envFilePath)) {
const envFile = fsSync.readFileSync(envFilePath, 'utf-8'); const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
@@ -171,6 +180,7 @@ export async function getScopedEnvContents(
export async function getEnvContents( export async function getEnvContents(
extensionConfig: ExtensionConfig, extensionConfig: ExtensionConfig,
extensionId: string, extensionId: string,
workspaceDir: string,
): Promise<Record<string, string>> { ): Promise<Record<string, string>> {
if (!extensionConfig.settings || extensionConfig.settings.length === 0) { if (!extensionConfig.settings || extensionConfig.settings.length === 0) {
return Promise.resolve({}); return Promise.resolve({});
@@ -185,6 +195,7 @@ export async function getEnvContents(
extensionConfig, extensionConfig,
extensionId, extensionId,
ExtensionSettingScope.WORKSPACE, ExtensionSettingScope.WORKSPACE,
workspaceDir,
); );
return { ...userSettings, ...workspaceSettings }; return { ...userSettings, ...workspaceSettings };
@@ -196,6 +207,7 @@ export async function updateSetting(
settingKey: string, settingKey: string,
requestSetting: (setting: ExtensionSetting) => Promise<string>, requestSetting: (setting: ExtensionSetting) => Promise<string>,
scope: ExtensionSettingScope, scope: ExtensionSettingScope,
workspaceDir?: string,
): Promise<void> { ): Promise<void> {
const { name: extensionName, settings } = extensionConfig; const { name: extensionName, settings } = extensionConfig;
if (!settings || settings.length === 0) { if (!settings || settings.length === 0) {
@@ -214,7 +226,7 @@ export async function updateSetting(
const newValue = await requestSetting(settingToUpdate); const newValue = await requestSetting(settingToUpdate);
const keychain = new KeychainTokenStorage( const keychain = new KeychainTokenStorage(
getKeychainStorageName(extensionName, extensionId, scope), getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
); );
if (settingToUpdate.sensitive) { if (settingToUpdate.sensitive) {
@@ -224,7 +236,7 @@ export async function updateSetting(
// For non-sensitive settings, we need to read the existing .env file, // For non-sensitive settings, we need to read the existing .env file,
// update the value, and write it back, preserving any other values. // update the value, and write it back, preserving any other values.
const envFilePath = getEnvFilePath(extensionName, scope); const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
let envContent = ''; let envContent = '';
if (fsSync.existsSync(envFilePath)) { if (fsSync.existsSync(envFilePath)) {
envContent = await fs.readFile(envFilePath, 'utf-8'); envContent = await fs.readFile(envFilePath, 'utf-8');
@@ -302,13 +314,18 @@ async function clearSettings(
export async function getMissingSettings( export async function getMissingSettings(
extensionConfig: ExtensionConfig, extensionConfig: ExtensionConfig,
extensionId: string, extensionId: string,
workspaceDir: string,
): Promise<ExtensionSetting[]> { ): Promise<ExtensionSetting[]> {
const { settings } = extensionConfig; const { settings } = extensionConfig;
if (!settings || settings.length === 0) { if (!settings || settings.length === 0) {
return []; return [];
} }
const existingSettings = await getEnvContents(extensionConfig, extensionId); const existingSettings = await getEnvContents(
extensionConfig,
extensionId,
workspaceDir,
);
const missingSettings: ExtensionSetting[] = []; const missingSettings: ExtensionSetting[] = [];
for (const setting of settings) { for (const setting of settings) {
@@ -154,7 +154,11 @@ describe('extensionUpdates', () => {
); );
await userKeychain.setSecret('VAR2', 'val2'); await userKeychain.setSecret('VAR2', 'val2');
const missing = await getMissingSettings(config, extensionId); const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toEqual([]); expect(missing).toEqual([]);
}); });
@@ -166,7 +170,11 @@ describe('extensionUpdates', () => {
}; };
const extensionId = '12345'; const extensionId = '12345';
const missing = await getMissingSettings(config, extensionId); const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toHaveLength(1); expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s1'); expect(missing[0].name).toBe('s1');
}); });
@@ -181,7 +189,11 @@ describe('extensionUpdates', () => {
}; };
const extensionId = '12345'; const extensionId = '12345';
const missing = await getMissingSettings(config, extensionId); const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toHaveLength(1); expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s2'); expect(missing[0].name).toBe('s2');
}); });
@@ -201,7 +213,11 @@ describe('extensionUpdates', () => {
); );
fs.writeFileSync(workspaceEnvPath, 'VAR1=val1'); fs.writeFileSync(workspaceEnvPath, 'VAR1=val1');
const missing = await getMissingSettings(config, extensionId); const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toEqual([]); expect(missing).toEqual([]);
}); });
}); });
@@ -142,6 +142,16 @@ describe('<ExtensionsList />', () => {
value: '1000', value: '1000',
envVar: 'MAX_TOKENS', envVar: 'MAX_TOKENS',
sensitive: false, sensitive: false,
scope: 'user' as const,
source: '/path/to/.env',
},
{
name: 'model',
value: 'gemini-pro',
envVar: 'MODEL',
sensitive: false,
scope: 'workspace' as const,
source: 'Keychain',
}, },
], ],
}; };
@@ -151,7 +161,8 @@ describe('<ExtensionsList />', () => {
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('settings:'); expect(output).toContain('settings:');
expect(output).toContain('- sensitiveApiKey: ***'); expect(output).toContain('- sensitiveApiKey: ***');
expect(output).toContain('- maxTokens: 1000'); expect(output).toContain('- maxTokens: 1000 (User - /path/to/.env)');
expect(output).toContain('- model: gemini-pro (Workspace - Keychain)');
unmount(); unmount();
}); });
}); });
@@ -71,6 +71,15 @@ export const ExtensionsList: React.FC<ExtensionsList> = ({ extensions }) => {
{ext.resolvedSettings.map((setting) => ( {ext.resolvedSettings.map((setting) => (
<Text key={setting.name}> <Text key={setting.name}>
- {setting.name}: {setting.value} - {setting.name}: {setting.value}
{setting.scope && (
<Text color="gray">
{' '}
(
{setting.scope.charAt(0).toUpperCase() +
setting.scope.slice(1)}
{setting.source ? ` - ${setting.source}` : ''})
</Text>
)}
</Text> </Text>
))} ))}
</Box> </Box>
+2
View File
@@ -149,6 +149,8 @@ export interface ResolvedExtensionSetting {
envVar: string; envVar: string;
value: string; value: string;
sensitive: boolean; sensitive: boolean;
scope?: 'user' | 'workspace';
source?: string;
} }
export interface CliHelpAgentSettings { export interface CliHelpAgentSettings {