Add commands for listing and updating per-extension settings (#12664)

This commit is contained in:
christine betts
2025-12-03 19:16:16 -05:00
committed by GitHub
parent 470f3b057f
commit e0a2227faf
6 changed files with 365 additions and 9 deletions

View File

@@ -12,6 +12,7 @@ import {
maybePromptForSettings,
promptForSetting,
type ExtensionSetting,
updateSetting,
} from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js';
@@ -371,6 +372,27 @@ describe('extensionSettings', () => {
const expectedContent = 'VAR1=previous-VAR1\nVAR2=previous-VAR2\n';
expect(actualContent).toBe(expectedContent);
});
it('should wrap values with spaces in quotes', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
mockRequestSetting.mockResolvedValue('a value with spaces');
await maybePromptForSettings(
config,
'12345',
mockRequestSetting,
undefined,
undefined,
);
const expectedEnvPath = path.join(extensionDir, '.env');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).toBe('VAR1="a value with spaces"\n');
});
});
describe('promptForSetting', () => {
@@ -482,4 +504,100 @@ describe('extensionSettings', () => {
expect(contents).toEqual({ VAR1: 'value1' });
});
});
describe('updateSetting', () => {
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 },
],
};
const mockRequestSetting = vi.fn();
beforeEach(async () => {
// Pre-populate settings
const envContent = 'VAR1=value1\n';
const envPath = path.join(extensionDir, '.env');
await fsPromises.writeFile(envPath, envContent);
keychainData['VAR2'] = 'value2';
mockRequestSetting.mockClear();
});
it('should update a non-sensitive setting', async () => {
mockRequestSetting.mockResolvedValue('new-value1');
await updateSetting(config, '12345', 'VAR1', mockRequestSetting);
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]);
const expectedEnvPath = path.join(extensionDir, '.env');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).toContain('VAR1=new-value1');
expect(mockKeychainStorage.setSecret).not.toHaveBeenCalled();
});
it('should update a non-sensitive setting by name', async () => {
mockRequestSetting.mockResolvedValue('new-value-by-name');
await updateSetting(config, '12345', 's1', mockRequestSetting);
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]);
const expectedEnvPath = path.join(extensionDir, '.env');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).toContain('VAR1=new-value-by-name');
expect(mockKeychainStorage.setSecret).not.toHaveBeenCalled();
});
it('should update a sensitive setting', async () => {
mockRequestSetting.mockResolvedValue('new-value2');
await updateSetting(config, '12345', 'VAR2', mockRequestSetting);
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]);
expect(mockKeychainStorage.setSecret).toHaveBeenCalledWith(
'VAR2',
'new-value2',
);
const expectedEnvPath = path.join(extensionDir, '.env');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).not.toContain('VAR2=new-value2');
});
it('should update a sensitive setting by name', async () => {
mockRequestSetting.mockResolvedValue('new-sensitive-by-name');
await updateSetting(config, '12345', 's2', mockRequestSetting);
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]);
expect(mockKeychainStorage.setSecret).toHaveBeenCalledWith(
'VAR2',
'new-sensitive-by-name',
);
});
it('should do nothing if the setting does not exist', async () => {
await updateSetting(config, '12345', 'VAR3', mockRequestSetting);
expect(mockRequestSetting).not.toHaveBeenCalled();
});
it('should do nothing if there are no settings', async () => {
const emptyConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
};
await updateSetting(emptyConfig, '12345', 'VAR1', mockRequestSetting);
expect(mockRequestSetting).not.toHaveBeenCalled();
});
it('should wrap values with spaces in quotes', async () => {
mockRequestSetting.mockResolvedValue('a value with spaces');
await updateSetting(config, '12345', 'VAR1', mockRequestSetting);
const expectedEnvPath = path.join(extensionDir, '.env');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).toContain('VAR1="a value with spaces"');
});
});
});

View File

@@ -12,7 +12,7 @@ import { ExtensionStorage } from './storage.js';
import type { ExtensionConfig } from '../extension.js';
import prompts from 'prompts';
import { KeychainTokenStorage } from '@google/gemini-cli-core';
import { debugLogger, KeychainTokenStorage } from '@google/gemini-cli-core';
export interface ExtensionSetting {
name: string;
@@ -57,7 +57,7 @@ export async function maybePromptForSettings(
previousExtensionConfig?.settings ?? [],
);
const allSettings: Record<string, string> = { ...(previousSettings ?? {}) };
const allSettings: Record<string, string> = { ...previousSettings };
for (const removedEnvSetting of settingsChanges.removeEnv) {
delete allSettings[removedEnvSetting.envVar];
@@ -87,14 +87,20 @@ export async function maybePromptForSettings(
}
}
let envContent = '';
for (const [key, value] of Object.entries(nonSensitiveSettings)) {
envContent += `${key}=${value}\n`;
}
const envContent = formatEnvContent(nonSensitiveSettings);
await fs.writeFile(envFilePath, envContent);
}
function formatEnvContent(settings: Record<string, string>): string {
let envContent = '';
for (const [key, value] of Object.entries(settings)) {
const formattedValue = value.includes(' ') ? `"${value}"` : value;
envContent += `${key}=${formattedValue}\n`;
}
return envContent;
}
export async function promptForSetting(
setting: ExtensionSetting,
): Promise<string> {
@@ -139,6 +145,61 @@ export async function getEnvContents(
return customEnv;
}
export async function updateSetting(
extensionConfig: ExtensionConfig,
extensionId: string,
settingKey: string,
requestSetting: (setting: ExtensionSetting) => Promise<string>,
): Promise<void> {
const { name: extensionName, settings } = extensionConfig;
if (!settings || settings.length === 0) {
debugLogger.log('This extension does not have any settings.');
return;
}
const settingToUpdate = settings.find(
(s) => s.name === settingKey || s.envVar === settingKey,
);
if (!settingToUpdate) {
debugLogger.log(`Setting ${settingKey} not found.`);
return;
}
const newValue = await requestSetting(settingToUpdate);
const keychain = new KeychainTokenStorage(
getKeychainStorageName(extensionName, extensionId),
);
if (settingToUpdate.sensitive) {
await keychain.setSecret(settingToUpdate.envVar, newValue);
return;
}
// For non-sensitive settings, we need to read the existing .env file,
// update the value, and write it back.
const allSettings = await getEnvContents(extensionConfig, extensionId);
allSettings[settingToUpdate.envVar] = newValue;
const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath();
const nonSensitiveSettings: Record<string, string> = {};
for (const setting of settings) {
// We only care about non-sensitive settings for the .env file.
if (setting.sensitive) {
continue;
}
const value = allSettings[setting.envVar];
if (value !== undefined) {
nonSensitiveSettings[setting.envVar] = value;
}
}
const envContent = formatEnvContent(nonSensitiveSettings);
await fs.writeFile(envFilePath, envContent);
}
interface settingsChanges {
promptForSensitive: ExtensionSetting[];
removeSensitive: ExtensionSetting[];