Add support for user-scoped extension settings (#13748)

This commit is contained in:
christine betts
2025-12-08 17:51:26 -05:00
committed by GitHub
parent 91c46311c8
commit ec9a8c7a72
4 changed files with 322 additions and 158 deletions

View File

@@ -232,9 +232,12 @@ gemini extensions settings list <extension name>
and you can update a given setting using: and you can update a given setting using:
``` ```
gemini extensions settings set <extension name> <setting name> gemini extensions settings set <extension name> <setting name> [--scope <scope>]
``` ```
- `--scope`: The scope to set the setting in (`user` or `workspace`). This is
optional and will default to `user`.
### Custom commands ### Custom commands
Extensions can provide [custom commands](../cli/custom-commands.md) by placing Extensions can provide [custom commands](../cli/custom-commands.md) by placing

View File

@@ -6,9 +6,10 @@
import type { CommandModule } from 'yargs'; import type { CommandModule } from 'yargs';
import { import {
getEnvContents,
updateSetting, updateSetting,
promptForSetting, promptForSetting,
ExtensionSettingScope,
getScopedEnvContents,
} from '../../config/extensions/extensionSettings.js'; } from '../../config/extensions/extensionSettings.js';
import { getExtensionAndManager } from './utils.js'; import { getExtensionAndManager } from './utils.js';
import { debugLogger } from '@google/gemini-cli-core'; import { debugLogger } from '@google/gemini-cli-core';
@@ -18,10 +19,11 @@ import { exitCli } from '../utils.js';
interface SetArgs { interface SetArgs {
name: string; name: string;
setting: string; setting: string;
scope: string;
} }
const setCommand: CommandModule<object, SetArgs> = { const setCommand: CommandModule<object, SetArgs> = {
command: 'set <name> <setting>', command: 'set [--scope] <name> <setting>',
describe: 'Set a specific setting for an extension.', describe: 'Set a specific setting for an extension.',
builder: (yargs) => builder: (yargs) =>
yargs yargs
@@ -34,9 +36,15 @@ const setCommand: CommandModule<object, SetArgs> = {
describe: 'The setting to configure (name or env var).', describe: 'The setting to configure (name or env var).',
type: 'string', type: 'string',
demandOption: true, demandOption: true,
})
.option('scope', {
describe: 'The scope to set the setting in.',
type: 'string',
choices: ['user', 'workspace'],
default: 'user',
}), }),
handler: async (args) => { handler: async (args) => {
const { name, setting } = args; const { name, setting, scope } = args;
const { extension, extensionManager } = await getExtensionAndManager(name); const { extension, extensionManager } = await getExtensionAndManager(name);
if (!extension || !extensionManager) { if (!extension || !extensionManager) {
return; return;
@@ -55,6 +63,7 @@ const setCommand: CommandModule<object, SetArgs> = {
extension.id, extension.id,
setting, setting,
promptForSetting, promptForSetting,
scope as ExtensionSettingScope,
); );
await exitCli(); await exitCli();
}, },
@@ -92,12 +101,30 @@ const listCommand: CommandModule<object, ListArgs> = {
return; return;
} }
const currentSettings = await getEnvContents(extensionConfig, extension.id); const userSettings = await getScopedEnvContents(
extensionConfig,
extension.id,
ExtensionSettingScope.USER,
);
const workspaceSettings = await getScopedEnvContents(
extensionConfig,
extension.id,
ExtensionSettingScope.WORKSPACE,
);
const mergedSettings = { ...userSettings, ...workspaceSettings };
debugLogger.log(`Settings for "${name}":`); debugLogger.log(`Settings for "${name}":`);
for (const setting of extensionConfig.settings) { for (const setting of extensionConfig.settings) {
const value = currentSettings[setting.envVar]; const value = mergedSettings[setting.envVar];
let displayValue: string; let displayValue: string;
let scopeInfo = '';
if (workspaceSettings[setting.envVar] !== undefined) {
scopeInfo = ' (workspace)';
} else if (userSettings[setting.envVar] !== undefined) {
scopeInfo = ' (user)';
}
if (value === undefined) { if (value === undefined) {
displayValue = '[not set]'; displayValue = '[not set]';
} else if (setting.sensitive) { } else if (setting.sensitive) {
@@ -108,7 +135,7 @@ const listCommand: CommandModule<object, ListArgs> = {
debugLogger.log(` debugLogger.log(`
- ${setting.name} (${setting.envVar})`); - ${setting.name} (${setting.envVar})`);
debugLogger.log(` Description: ${setting.description}`); debugLogger.log(` Description: ${setting.description}`);
debugLogger.log(` Value: ${displayValue}`); debugLogger.log(` Value: ${displayValue}${scopeInfo}`);
} }
await exitCli(); await exitCli();
}, },

View File

@@ -13,6 +13,8 @@ import {
promptForSetting, promptForSetting,
type ExtensionSetting, type ExtensionSetting,
updateSetting, updateSetting,
ExtensionSettingScope,
getScopedEnvContents,
} from './extensionSettings.js'; } from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js'; import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js'; import { ExtensionStorage } from './storage.js';
@@ -20,6 +22,7 @@ import prompts from 'prompts';
import * as fsPromises from 'node:fs/promises'; import * as fsPromises from 'node:fs/promises';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { KeychainTokenStorage } from '@google/gemini-cli-core'; import { KeychainTokenStorage } from '@google/gemini-cli-core';
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
vi.mock('prompts'); vi.mock('prompts');
vi.mock('os', async (importOriginal) => { vi.mock('os', async (importOriginal) => {
@@ -35,67 +38,66 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>(); await importOriginal<typeof import('@google/gemini-cli-core')>();
return { return {
...actual, ...actual,
KeychainTokenStorage: vi.fn().mockImplementation(() => ({ KeychainTokenStorage: vi.fn(),
getSecret: vi.fn(),
setSecret: vi.fn(),
deleteSecret: vi.fn(),
listSecrets: vi.fn(),
isAvailable: vi.fn().mockResolvedValue(true),
})),
}; };
}); });
interface MockKeychainStorage {
getSecret: ReturnType<typeof vi.fn>;
setSecret: ReturnType<typeof vi.fn>;
deleteSecret: ReturnType<typeof vi.fn>;
listSecrets: ReturnType<typeof vi.fn>;
isAvailable: ReturnType<typeof vi.fn>;
}
describe('extensionSettings', () => { describe('extensionSettings', () => {
let tempHomeDir: string; let tempHomeDir: string;
let tempWorkspaceDir: string;
let extensionDir: string; let extensionDir: string;
let mockKeychainStorage: MockKeychainStorage; let mockKeychainData: Record<string, Record<string, string>>;
let keychainData: Record<string, string>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
keychainData = {}; mockKeychainData = {};
mockKeychainStorage = { vi.mocked(KeychainTokenStorage).mockImplementation(
getSecret: vi (serviceName: string) => {
.fn() if (!mockKeychainData[serviceName]) {
.mockImplementation(async (key: string) => keychainData[key] || null), mockKeychainData[serviceName] = {};
setSecret: vi }
.fn() const keychainData = mockKeychainData[serviceName];
.mockImplementation(async (key: string, value: string) => { return {
keychainData[key] = value; getSecret: vi
}), .fn()
deleteSecret: vi.fn().mockImplementation(async (key: string) => { .mockImplementation(
delete keychainData[key]; async (key: string) => keychainData[key] || null,
}), ),
listSecrets: vi setSecret: vi
.fn() .fn()
.mockImplementation(async () => Object.keys(keychainData)), .mockImplementation(async (key: string, value: string) => {
isAvailable: vi.fn().mockResolvedValue(true), keychainData[key] = value;
}; }),
( deleteSecret: vi.fn().mockImplementation(async (key: string) => {
KeychainTokenStorage as unknown as ReturnType<typeof vi.fn> delete keychainData[key];
).mockImplementation(() => mockKeychainStorage); }),
listSecrets: vi
.fn()
.mockImplementation(async () => Object.keys(keychainData)),
isAvailable: vi.fn().mockResolvedValue(true),
} as unknown as KeychainTokenStorage;
},
);
tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`; tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`;
tempWorkspaceDir = path.join(
os.tmpdir(),
`gemini-cli-test-workspace-${Date.now()}`,
);
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext'); extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
// Spy and mock the method, but also create the directory so we can write to it. // Spy and mock the method, but also create the directory so we can write to it.
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue( vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
extensionDir, extensionDir,
); );
fs.mkdirSync(extensionDir, { recursive: true }); fs.mkdirSync(extensionDir, { recursive: true });
fs.mkdirSync(tempWorkspaceDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(tempHomeDir); vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
vi.mocked(prompts).mockClear(); vi.mocked(prompts).mockClear();
}); });
afterEach(() => { afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true }); fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -213,7 +215,10 @@ describe('extensionSettings', () => {
VAR1: 'previous-VAR1', VAR1: 'previous-VAR1',
SENSITIVE_VAR: 'secret', SENSITIVE_VAR: 'secret',
}; };
keychainData['SENSITIVE_VAR'] = 'secret'; const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
await userKeychain.setSecret('SENSITIVE_VAR', 'secret');
const envPath = path.join(extensionDir, '.env'); const envPath = path.join(extensionDir, '.env');
await fsPromises.writeFile(envPath, 'VAR1=previous-VAR1'); await fsPromises.writeFile(envPath, 'VAR1=previous-VAR1');
@@ -228,9 +233,7 @@ describe('extensionSettings', () => {
expect(mockRequestSetting).not.toHaveBeenCalled(); expect(mockRequestSetting).not.toHaveBeenCalled();
const actualContent = await fsPromises.readFile(envPath, 'utf-8'); const actualContent = await fsPromises.readFile(envPath, 'utf-8');
expect(actualContent).toBe(''); expect(actualContent).toBe('');
expect(mockKeychainStorage.deleteSecret).toHaveBeenCalledWith( expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
'SENSITIVE_VAR',
);
}); });
it('should remove sensitive settings from keychain', async () => { it('should remove sensitive settings from keychain', async () => {
@@ -252,7 +255,10 @@ describe('extensionSettings', () => {
settings: [], settings: [],
}; };
const previousSettings = { SENSITIVE_VAR: 'secret' }; const previousSettings = { SENSITIVE_VAR: 'secret' };
keychainData['SENSITIVE_VAR'] = 'secret'; const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
await userKeychain.setSecret('SENSITIVE_VAR', 'secret');
await maybePromptForSettings( await maybePromptForSettings(
newConfig, newConfig,
@@ -262,9 +268,7 @@ describe('extensionSettings', () => {
previousSettings, previousSettings,
); );
expect(mockKeychainStorage.deleteSecret).toHaveBeenCalledWith( expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
'SENSITIVE_VAR',
);
}); });
it('should remove settings that are no longer in the config', async () => { it('should remove settings that are no longer in the config', async () => {
@@ -455,7 +459,7 @@ describe('extensionSettings', () => {
}); });
}); });
describe('getEnvContents', () => { describe('getScopedEnvContents', () => {
const config: ExtensionConfig = { const config: ExtensionConfig = {
name: 'test-ext', name: 'test-ext',
version: '1.0.0', version: '1.0.0',
@@ -469,39 +473,94 @@ describe('extensionSettings', () => {
}, },
], ],
}; };
const extensionId = '12345';
it('should return combined contents from .env and keychain', async () => { it('should return combined contents from user .env and keychain for USER scope', async () => {
const envPath = path.join(extensionDir, '.env'); const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
await fsPromises.writeFile(envPath, 'VAR1=value1'); await fsPromises.writeFile(userEnvPath, 'VAR1=user-value1');
keychainData['SENSITIVE_VAR'] = 'secret'; const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
await userKeychain.setSecret('SENSITIVE_VAR', 'user-secret');
const contents = await getEnvContents(config, '12345'); const contents = await getScopedEnvContents(
config,
extensionId,
ExtensionSettingScope.USER,
);
expect(contents).toEqual({ expect(contents).toEqual({
VAR1: 'value1', VAR1: 'user-value1',
SENSITIVE_VAR: 'secret', SENSITIVE_VAR: 'user-secret',
}); });
}); });
it('should return an empty object if no settings are defined', async () => { it('should return combined contents from workspace .env and keychain for WORKSPACE scope', async () => {
const contents = await getEnvContents( const workspaceEnvPath = path.join(
{ name: 'test-ext', version: '1.0.0' }, tempWorkspaceDir,
'12345', EXTENSION_SETTINGS_FILENAME,
); );
expect(contents).toEqual({}); await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
}); const workspaceKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
);
await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
it('should return only keychain contents if .env file does not exist', async () => { const contents = await getScopedEnvContents(
keychainData['SENSITIVE_VAR'] = 'secret'; config,
const contents = await getEnvContents(config, '12345'); extensionId,
expect(contents).toEqual({ SENSITIVE_VAR: 'secret' }); ExtensionSettingScope.WORKSPACE,
}); );
it('should return only .env contents if keychain is empty', async () => { expect(contents).toEqual({
const envPath = path.join(extensionDir, '.env'); VAR1: 'workspace-value1',
await fsPromises.writeFile(envPath, 'VAR1=value1'); SENSITIVE_VAR: 'workspace-secret',
const contents = await getEnvContents(config, '12345'); });
expect(contents).toEqual({ VAR1: 'value1' }); });
});
describe('getEnvContents (merged)', () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
{ name: 's3', description: 'd3', envVar: 'VAR3' },
],
};
const extensionId = '12345';
it('should merge user and workspace settings, with workspace taking precedence', async () => {
// User settings
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
await fsPromises.writeFile(
userEnvPath,
'VAR1=user-value1\nVAR3=user-value3',
);
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext ${extensionId}`,
);
await userKeychain.setSecret('VAR2', 'user-secret2');
// Workspace settings
const workspaceEnvPath = path.join(
tempWorkspaceDir,
EXTENSION_SETTINGS_FILENAME,
);
await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
const workspaceKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext ${extensionId} ${tempWorkspaceDir}`,
);
await workspaceKeychain.setSecret('VAR2', 'workspace-secret2');
const contents = await getEnvContents(config, extensionId);
expect(contents).toEqual({
VAR1: 'workspace-value1',
VAR2: 'workspace-secret2',
VAR3: 'user-value3',
});
}); });
}); });
@@ -517,87 +576,114 @@ describe('extensionSettings', () => {
const mockRequestSetting = vi.fn(); const mockRequestSetting = vi.fn();
beforeEach(async () => { beforeEach(async () => {
// Pre-populate settings const userEnvPath = path.join(extensionDir, '.env');
const envContent = 'VAR1=value1\n'; await fsPromises.writeFile(userEnvPath, 'VAR1=value1\n');
const envPath = path.join(extensionDir, '.env'); const userKeychain = new KeychainTokenStorage(
await fsPromises.writeFile(envPath, envContent); `Gemini CLI Extensions test-ext 12345`,
keychainData['VAR2'] = 'value2'; );
await userKeychain.setSecret('VAR2', 'value2');
mockRequestSetting.mockClear(); mockRequestSetting.mockClear();
}); });
it('should update a non-sensitive setting', async () => { it('should update a non-sensitive setting in USER scope', async () => {
mockRequestSetting.mockResolvedValue('new-value1'); mockRequestSetting.mockResolvedValue('new-value1');
await updateSetting(config, '12345', 'VAR1', mockRequestSetting); await updateSetting(
config,
'12345',
'VAR1',
mockRequestSetting,
ExtensionSettingScope.USER,
);
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]);
const expectedEnvPath = path.join(extensionDir, '.env'); const expectedEnvPath = path.join(extensionDir, '.env');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).toContain('VAR1=new-value1'); expect(actualContent).toContain('VAR1=new-value1');
expect(mockKeychainStorage.setSecret).not.toHaveBeenCalled();
}); });
it('should update a non-sensitive setting by name', async () => { it('should update a non-sensitive setting in WORKSPACE scope', async () => {
mockRequestSetting.mockResolvedValue('new-value-by-name'); mockRequestSetting.mockResolvedValue('new-workspace-value');
await updateSetting(config, '12345', 's1', mockRequestSetting); await updateSetting(
config,
'12345',
'VAR1',
mockRequestSetting,
ExtensionSettingScope.WORKSPACE,
);
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]); const expectedEnvPath = path.join(tempWorkspaceDir, '.env');
const expectedEnvPath = path.join(extensionDir, '.env');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).toContain('VAR1=new-value-by-name'); expect(actualContent).toContain('VAR1=new-workspace-value');
expect(mockKeychainStorage.setSecret).not.toHaveBeenCalled();
}); });
it('should update a sensitive setting', async () => { it('should update a sensitive setting in USER scope', async () => {
mockRequestSetting.mockResolvedValue('new-value2'); mockRequestSetting.mockResolvedValue('new-value2');
await updateSetting(config, '12345', 'VAR2', mockRequestSetting); await updateSetting(
config,
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]); '12345',
expect(mockKeychainStorage.setSecret).toHaveBeenCalledWith(
'VAR2', 'VAR2',
'new-value2', mockRequestSetting,
ExtensionSettingScope.USER,
); );
const expectedEnvPath = path.join(extensionDir, '.env');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); const userKeychain = new KeychainTokenStorage(
expect(actualContent).not.toContain('VAR2=new-value2'); `Gemini CLI Extensions test-ext 12345`,
);
expect(await userKeychain.getSecret('VAR2')).toBe('new-value2');
}); });
it('should update a sensitive setting by name', async () => { it('should update a sensitive setting in WORKSPACE scope', async () => {
mockRequestSetting.mockResolvedValue('new-sensitive-by-name'); mockRequestSetting.mockResolvedValue('new-workspace-secret');
await updateSetting(config, '12345', 's2', mockRequestSetting); await updateSetting(
config,
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]); '12345',
expect(mockKeychainStorage.setSecret).toHaveBeenCalledWith(
'VAR2', 'VAR2',
'new-sensitive-by-name', mockRequestSetting,
ExtensionSettingScope.WORKSPACE,
);
const workspaceKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
);
expect(await workspaceKeychain.getSecret('VAR2')).toBe(
'new-workspace-secret',
); );
}); });
it('should do nothing if the setting does not exist', async () => { it('should leave existing, unmanaged .env variables intact when updating in WORKSPACE scope', async () => {
await updateSetting(config, '12345', 'VAR3', mockRequestSetting); // Setup a pre-existing .env file in the workspace with unmanaged variables
expect(mockRequestSetting).not.toHaveBeenCalled(); const workspaceEnvPath = path.join(tempWorkspaceDir, '.env');
}); const originalEnvContent =
'PROJECT_VAR_1=value_1\nPROJECT_VAR_2=value_2\nVAR1=original-value'; // VAR1 is managed by extension
await fsPromises.writeFile(workspaceEnvPath, originalEnvContent);
it('should do nothing if there are no settings', async () => { // Simulate updating an extension-managed non-sensitive setting
const emptyConfig: ExtensionConfig = { mockRequestSetting.mockResolvedValue('updated-value');
name: 'test-ext', await updateSetting(
version: '1.0.0', config,
}; '12345',
await updateSetting(emptyConfig, '12345', 'VAR1', mockRequestSetting); 'VAR1',
expect(mockRequestSetting).not.toHaveBeenCalled(); mockRequestSetting,
}); ExtensionSettingScope.WORKSPACE,
);
it('should wrap values with spaces in quotes', async () => { // Read the .env file after update
mockRequestSetting.mockResolvedValue('a value with spaces'); const actualContent = await fsPromises.readFile(
workspaceEnvPath,
'utf-8',
);
await updateSetting(config, '12345', 'VAR1', mockRequestSetting); // Assert that original variables are intact and extension variable is updated
expect(actualContent).toContain('PROJECT_VAR_1=value_1');
expect(actualContent).toContain('PROJECT_VAR_2=value_2');
expect(actualContent).toContain('VAR1=updated-value');
const expectedEnvPath = path.join(extensionDir, '.env'); // Ensure no other unexpected changes or deletions
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); const lines = actualContent.split('\n').filter((line) => line.length > 0);
expect(actualContent).toContain('VAR1="a value with spaces"'); expect(lines).toHaveLength(3); // Should only have the three variables
}); });
}); });
}); });

View File

@@ -7,12 +7,19 @@
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs'; import * as fsSync from 'node:fs';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import * as path from 'node:path';
import { ExtensionStorage } from './storage.js'; import { ExtensionStorage } from './storage.js';
import type { ExtensionConfig } from '../extension.js'; import type { ExtensionConfig } from '../extension.js';
import prompts from 'prompts'; import prompts from 'prompts';
import { debugLogger, KeychainTokenStorage } from '@google/gemini-cli-core'; import { debugLogger, KeychainTokenStorage } from '@google/gemini-cli-core';
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
export enum ExtensionSettingScope {
USER = 'user',
WORKSPACE = 'workspace',
}
export interface ExtensionSetting { export interface ExtensionSetting {
name: string; name: string;
@@ -25,7 +32,24 @@ export interface ExtensionSetting {
const getKeychainStorageName = ( const getKeychainStorageName = (
extensionName: string, extensionName: string,
extensionId: string, extensionId: string,
): string => `Gemini CLI Extensions ${extensionName} ${extensionId}`; scope: ExtensionSettingScope,
): string => {
const base = `Gemini CLI Extensions ${extensionName} ${extensionId}`;
if (scope === ExtensionSettingScope.WORKSPACE) {
return `${base} ${process.cwd()}`;
}
return base;
};
const getEnvFilePath = (
extensionName: string,
scope: ExtensionSettingScope,
): string => {
if (scope === ExtensionSettingScope.WORKSPACE) {
return path.join(process.cwd(), EXTENSION_SETTINGS_FILENAME);
}
return new ExtensionStorage(extensionName).getEnvFilePath();
};
export async function maybePromptForSettings( export async function maybePromptForSettings(
extensionConfig: ExtensionConfig, extensionConfig: ExtensionConfig,
@@ -42,9 +66,12 @@ export async function maybePromptForSettings(
) { ) {
return; return;
} }
const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath(); // We assume user scope here because we don't have a way to ask the user for scope during the initial setup.
// The user can change the scope later using the `settings set` command.
const scope = ExtensionSettingScope.USER;
const envFilePath = getEnvFilePath(extensionName, scope);
const keychain = new KeychainTokenStorage( const keychain = new KeychainTokenStorage(
getKeychainStorageName(extensionName, extensionId), getKeychainStorageName(extensionName, extensionId, scope),
); );
if (!settings || settings.length === 0) { if (!settings || settings.length === 0) {
@@ -112,23 +139,19 @@ export async function promptForSetting(
return response.value; return response.value;
} }
export async function getEnvContents( export async function getScopedEnvContents(
extensionConfig: ExtensionConfig, extensionConfig: ExtensionConfig,
extensionId: string, extensionId: string,
scope: ExtensionSettingScope,
): Promise<Record<string, string>> { ): Promise<Record<string, string>> {
if (!extensionConfig.settings || extensionConfig.settings.length === 0) { const { name: extensionName } = extensionConfig;
return Promise.resolve({});
}
const extensionStorage = new ExtensionStorage(extensionConfig.name);
const keychain = new KeychainTokenStorage( const keychain = new KeychainTokenStorage(
getKeychainStorageName(extensionConfig.name, extensionId), getKeychainStorageName(extensionName, extensionId, scope),
); );
const envFilePath = getEnvFilePath(extensionName, scope);
let customEnv: Record<string, string> = {}; let customEnv: Record<string, string> = {};
if (fsSync.existsSync(extensionStorage.getEnvFilePath())) { if (fsSync.existsSync(envFilePath)) {
const envFile = fsSync.readFileSync( const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
extensionStorage.getEnvFilePath(),
'utf-8',
);
customEnv = dotenv.parse(envFile); customEnv = dotenv.parse(envFile);
} }
@@ -145,11 +168,34 @@ export async function getEnvContents(
return customEnv; return customEnv;
} }
export async function getEnvContents(
extensionConfig: ExtensionConfig,
extensionId: string,
): Promise<Record<string, string>> {
if (!extensionConfig.settings || extensionConfig.settings.length === 0) {
return Promise.resolve({});
}
const userSettings = await getScopedEnvContents(
extensionConfig,
extensionId,
ExtensionSettingScope.USER,
);
const workspaceSettings = await getScopedEnvContents(
extensionConfig,
extensionId,
ExtensionSettingScope.WORKSPACE,
);
return { ...userSettings, ...workspaceSettings };
}
export async function updateSetting( export async function updateSetting(
extensionConfig: ExtensionConfig, extensionConfig: ExtensionConfig,
extensionId: string, extensionId: string,
settingKey: string, settingKey: string,
requestSetting: (setting: ExtensionSetting) => Promise<string>, requestSetting: (setting: ExtensionSetting) => Promise<string>,
scope: ExtensionSettingScope,
): Promise<void> { ): Promise<void> {
const { name: extensionName, settings } = extensionConfig; const { name: extensionName, settings } = extensionConfig;
if (!settings || settings.length === 0) { if (!settings || settings.length === 0) {
@@ -168,7 +214,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), getKeychainStorageName(extensionName, extensionId, scope),
); );
if (settingToUpdate.sensitive) { if (settingToUpdate.sensitive) {
@@ -177,27 +223,29 @@ 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. // update the value, and write it back, preserving any other values.
const allSettings = await getEnvContents(extensionConfig, extensionId); const envFilePath = getEnvFilePath(extensionName, scope);
allSettings[settingToUpdate.envVar] = newValue; let envContent = '';
if (fsSync.existsSync(envFilePath)) {
envContent = await fs.readFile(envFilePath, 'utf-8');
}
const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath(); const parsedEnv = dotenv.parse(envContent);
parsedEnv[settingToUpdate.envVar] = newValue;
// We only want to write back the variables that are not sensitive.
const nonSensitiveSettings: Record<string, string> = {}; const nonSensitiveSettings: Record<string, string> = {};
for (const setting of settings) { const sensitiveEnvVars = new Set(
// We only care about non-sensitive settings for the .env file. settings.filter((s) => s.sensitive).map((s) => s.envVar),
if (setting.sensitive) { );
continue; for (const [key, value] of Object.entries(parsedEnv)) {
} if (!sensitiveEnvVars.has(key)) {
const value = allSettings[setting.envVar]; nonSensitiveSettings[key] = value;
if (value !== undefined) {
nonSensitiveSettings[setting.envVar] = value;
} }
} }
const envContent = formatEnvContent(nonSensitiveSettings); const newEnvContent = formatEnvContent(nonSensitiveSettings);
await fs.writeFile(envFilePath, newEnvContent);
await fs.writeFile(envFilePath, envContent);
} }
interface settingsChanges { interface settingsChanges {