diff --git a/docs/extensions/index.md b/docs/extensions/index.md index 62b05d3aad4..9946ea7f4d2 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -163,11 +163,11 @@ The file has the following structure: your extension in the CLI. Note that we expect this name to match the extension directory name. - `version`: The version of the extension. -- `mcpServers`: A map of MCP servers to configure. The key is the name of the +- `mcpServers`: A map of MCP servers to settings. The key is the name of the server, and the value is the server configuration. These servers will be - loaded on startup just like MCP servers configured in a + loaded on startup just like MCP servers settingsd in a [`settings.json` file](../get-started/configuration.md). If both an extension - and a `settings.json` file configure an MCP server with the same name, the + and a `settings.json` file settings an MCP server with the same name, the server defined in the `settings.json` file takes precedence. - Note that all MCP server configuration options are supported except for `trust`. @@ -223,6 +223,21 @@ When a user installs this extension, they will be prompted to enter their API key. The value will be saved to a `.env` file in the extension's directory (e.g., `/.gemini/extensions/my-api-extension/.env`). +You can view a list of an extension's settings by running: + +``` +gemini extensions settings list +``` + +and you can update a given setting using: + +``` +gemini extensions settings set [--scope ] +``` + +- `--scope`: The scope to set the setting in (`user` or `workspace`). This is + optional and will default to `user`. + ### Custom commands Extensions can provide [custom commands](../cli/custom-commands.md) by placing diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index fe4c48059cf..b2cf160e900 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -14,6 +14,7 @@ import { enableCommand } from './extensions/enable.js'; import { linkCommand } from './extensions/link.js'; import { newCommand } from './extensions/new.js'; import { validateCommand } from './extensions/validate.js'; +import { settingsCommand } from './extensions/settings.js'; import { initializeOutputListenersAndFlush } from '../gemini.js'; export const extensionsCommand: CommandModule = { @@ -32,6 +33,7 @@ export const extensionsCommand: CommandModule = { .command(linkCommand) .command(newCommand) .command(validateCommand) + .command(settingsCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/packages/cli/src/commands/extensions/settings.ts b/packages/cli/src/commands/extensions/settings.ts new file mode 100644 index 00000000000..e695054c11d --- /dev/null +++ b/packages/cli/src/commands/extensions/settings.ts @@ -0,0 +1,158 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { + updateSetting, + promptForSetting, + ExtensionSettingScope, + getScopedEnvContents, +} from '../../config/extensions/extensionSettings.js'; +import { getExtensionAndManager } from './utils.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; + +// --- SET COMMAND --- +interface SetArgs { + name: string; + setting: string; + scope: string; +} + +const setCommand: CommandModule = { + command: 'set [--scope] ', + describe: 'Set a specific setting for an extension.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'Name of the extension to configure.', + type: 'string', + demandOption: true, + }) + .positional('setting', { + describe: 'The setting to configure (name or env var).', + type: 'string', + demandOption: true, + }) + .option('scope', { + describe: 'The scope to set the setting in.', + type: 'string', + choices: ['user', 'workspace'], + default: 'user', + }), + handler: async (args) => { + const { name, setting, scope } = args; + const { extension, extensionManager } = await getExtensionAndManager(name); + if (!extension || !extensionManager) { + return; + } + const extensionConfig = extensionManager.loadExtensionConfig( + extension.path, + ); + if (!extensionConfig) { + debugLogger.error( + `Could not find configuration for extension "${name}".`, + ); + return; + } + await updateSetting( + extensionConfig, + extension.id, + setting, + promptForSetting, + scope as ExtensionSettingScope, + ); + await exitCli(); + }, +}; + +// --- LIST COMMAND --- +interface ListArgs { + name: string; +} + +const listCommand: CommandModule = { + command: 'list ', + describe: 'List all settings for an extension.', + builder: (yargs) => + yargs.positional('name', { + describe: 'Name of the extension.', + type: 'string', + demandOption: true, + }), + handler: async (args) => { + const { name } = args; + const { extension, extensionManager } = await getExtensionAndManager(name); + if (!extension || !extensionManager) { + return; + } + const extensionConfig = extensionManager.loadExtensionConfig( + extension.path, + ); + if ( + !extensionConfig || + !extensionConfig.settings || + extensionConfig.settings.length === 0 + ) { + debugLogger.log(`Extension "${name}" has no settings to configure.`); + return; + } + + 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}":`); + for (const setting of extensionConfig.settings) { + const value = mergedSettings[setting.envVar]; + let displayValue: string; + let scopeInfo = ''; + + if (workspaceSettings[setting.envVar] !== undefined) { + scopeInfo = ' (workspace)'; + } else if (userSettings[setting.envVar] !== undefined) { + scopeInfo = ' (user)'; + } + + if (value === undefined) { + displayValue = '[not set]'; + } else if (setting.sensitive) { + displayValue = '[value stored in keychain]'; + } else { + displayValue = value; + } + debugLogger.log(` +- ${setting.name} (${setting.envVar})`); + debugLogger.log(` Description: ${setting.description}`); + debugLogger.log(` Value: ${displayValue}${scopeInfo}`); + } + await exitCli(); + }, +}; + +// --- SETTINGS COMMAND --- +export const settingsCommand: CommandModule = { + command: 'settings ', + describe: 'Manage extension settings.', + builder: (yargs) => + yargs + .command(setCommand) + .command(listCommand) + .demandCommand(1, 'You need to specify a command (set or list).') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/extensions/utils.ts b/packages/cli/src/commands/extensions/utils.ts new file mode 100644 index 00000000000..9e0ee97f40f --- /dev/null +++ b/packages/cli/src/commands/extensions/utils.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExtensionManager } from '../../config/extension-manager.js'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { loadSettings } from '../../config/settings.js'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; +import { debugLogger } from '@google/gemini-cli-core'; + +export async function getExtensionAndManager(name: string) { + const workspaceDir = process.cwd(); + const extensionManager = new ExtensionManager({ + workspaceDir, + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + settings: loadSettings(workspaceDir).merged, + }); + await extensionManager.loadExtensions(); + const extension = extensionManager + .getExtensions() + .find((ext) => ext.name === name); + + if (!extension) { + debugLogger.error(`Extension "${name}" is not installed.`); + return { extension: null, extensionManager: null }; + } + + return { extension, extensionManager }; +} diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts index 2c140c85c94..8081b2bb144 100644 --- a/packages/cli/src/config/extensions/extensionSettings.test.ts +++ b/packages/cli/src/config/extensions/extensionSettings.test.ts @@ -12,6 +12,9 @@ import { maybePromptForSettings, promptForSetting, type ExtensionSetting, + updateSetting, + ExtensionSettingScope, + getScopedEnvContents, } from './extensionSettings.js'; import type { ExtensionConfig } from '../extension.js'; import { ExtensionStorage } from './storage.js'; @@ -19,6 +22,7 @@ import prompts from 'prompts'; import * as fsPromises from 'node:fs/promises'; import * as fs from 'node:fs'; import { KeychainTokenStorage } from '@google/gemini-cli-core'; +import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; vi.mock('prompts'); vi.mock('os', async (importOriginal) => { @@ -54,6 +58,7 @@ interface MockKeychainStorage { describe('extensionSettings', () => { let tempHomeDir: string; + let tempWorkspaceDir: string; let extensionDir: string; let mockKeychainStorage: MockKeychainStorage; let keychainData: Record; @@ -83,18 +88,25 @@ describe('extensionSettings', () => { ).mockImplementation(() => mockKeychainStorage); 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'); // Spy and mock the method, but also create the directory so we can write to it. vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue( extensionDir, ); fs.mkdirSync(extensionDir, { recursive: true }); + fs.mkdirSync(tempWorkspaceDir, { recursive: true }); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); vi.mocked(prompts).mockClear(); }); afterEach(() => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); @@ -371,6 +383,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', () => { @@ -433,7 +466,7 @@ describe('extensionSettings', () => { }); }); - describe('getEnvContents', () => { + describe('getScopedEnvContents', () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', @@ -447,39 +480,211 @@ describe('extensionSettings', () => { }, ], }; + const extensionId = '12345'; - it('should return combined contents from .env and keychain', async () => { - const envPath = path.join(extensionDir, '.env'); - await fsPromises.writeFile(envPath, 'VAR1=value1'); - keychainData['SENSITIVE_VAR'] = 'secret'; + it('should return combined contents from user .env and keychain for USER scope', async () => { + const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME); + await fsPromises.writeFile(userEnvPath, 'VAR1=user-value1'); + await mockKeychainStorage.setSecret('SENSITIVE_VAR', 'user-secret'); - const contents = await getEnvContents(config, '12345'); + const contents = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.USER, + ); expect(contents).toEqual({ - VAR1: 'value1', - SENSITIVE_VAR: 'secret', + VAR1: 'user-value1', + SENSITIVE_VAR: 'user-secret', + }); + }); + + it('should return combined contents from workspace .env and keychain for WORKSPACE scope', async () => { + const workspaceEnvPath = path.join( + tempWorkspaceDir, + EXTENSION_SETTINGS_FILENAME, + ); + await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1'); + await mockKeychainStorage.setSecret('SENSITIVE_VAR', 'workspace-secret'); + + const contents = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.WORKSPACE, + ); + + expect(contents).toEqual({ + VAR1: 'workspace-value1', + SENSITIVE_VAR: 'workspace-secret', }); }); + }); + + 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', + }); + }); + }); + + 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 () => { + const userEnvPath = path.join(extensionDir, '.env'); + await fsPromises.writeFile(userEnvPath, 'VAR1=value1\n'); + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + await userKeychain.setSecret('VAR2', 'value2'); + mockRequestSetting.mockClear(); + }); - it('should return an empty object if no settings are defined', async () => { - const contents = await getEnvContents( - { name: 'test-ext', version: '1.0.0' }, + it('should update a non-sensitive setting in USER scope', async () => { + mockRequestSetting.mockResolvedValue('new-value1'); + + await updateSetting( + config, '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.USER, ); - expect(contents).toEqual({}); + + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + expect(actualContent).toContain('VAR1=new-value1'); }); - it('should return only keychain contents if .env file does not exist', async () => { - keychainData['SENSITIVE_VAR'] = 'secret'; - const contents = await getEnvContents(config, '12345'); - expect(contents).toEqual({ SENSITIVE_VAR: 'secret' }); + it('should update a non-sensitive setting in WORKSPACE scope', async () => { + mockRequestSetting.mockResolvedValue('new-workspace-value'); + + await updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.WORKSPACE, + ); + + const expectedEnvPath = path.join(tempWorkspaceDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + expect(actualContent).toContain('VAR1=new-workspace-value'); }); - it('should return only .env contents if keychain is empty', async () => { - const envPath = path.join(extensionDir, '.env'); - await fsPromises.writeFile(envPath, 'VAR1=value1'); - const contents = await getEnvContents(config, '12345'); - expect(contents).toEqual({ VAR1: 'value1' }); + it('should update a sensitive setting in USER scope', async () => { + mockRequestSetting.mockResolvedValue('new-value2'); + + await updateSetting( + config, + '12345', + 'VAR2', + mockRequestSetting, + ExtensionSettingScope.USER, + ); + + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + expect(await userKeychain.getSecret('VAR2')).toBe('new-value2'); + }); + + it('should update a sensitive setting in WORKSPACE scope', async () => { + mockRequestSetting.mockResolvedValue('new-workspace-secret'); + + await updateSetting( + config, + '12345', + 'VAR2', + 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 leave existing, unmanaged .env variables intact when updating in WORKSPACE scope', async () => { + // Setup a pre-existing .env file in the workspace with unmanaged variables + 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); + + // Simulate updating an extension-managed non-sensitive setting + mockRequestSetting.mockResolvedValue('updated-value'); + await updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.WORKSPACE, + ); + + // Read the .env file after update + const actualContent = await fsPromises.readFile( + workspaceEnvPath, + 'utf-8', + ); + + // 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'); + + // Ensure no other unexpected changes or deletions + const lines = actualContent.split('\n').filter((line) => line.length > 0); + expect(lines).toHaveLength(3); // Should only have the three variables }); }); }); diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index 89209e58ec8..0aa12d66fab 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -7,12 +7,19 @@ import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; import * as dotenv from 'dotenv'; +import * as path from 'node:path'; 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'; +import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; + +export enum ExtensionSettingScope { + USER = 'user', + WORKSPACE = 'workspace', +} export interface ExtensionSetting { name: string; @@ -25,7 +32,24 @@ export interface ExtensionSetting { const getKeychainStorageName = ( extensionName: 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( extensionConfig: ExtensionConfig, @@ -42,9 +66,12 @@ export async function maybePromptForSettings( ) { 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( - getKeychainStorageName(extensionName, extensionId), + getKeychainStorageName(extensionName, extensionId, scope), ); if (!settings || settings.length === 0) { @@ -57,7 +84,7 @@ export async function maybePromptForSettings( previousExtensionConfig?.settings ?? [], ); - const allSettings: Record = { ...(previousSettings ?? {}) }; + const allSettings: Record = { ...previousSettings }; for (const removedEnvSetting of settingsChanges.removeEnv) { delete allSettings[removedEnvSetting.envVar]; @@ -87,14 +114,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 { + 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 { @@ -106,23 +139,19 @@ export async function promptForSetting( return response.value; } -export async function getEnvContents( +export async function getScopedEnvContents( extensionConfig: ExtensionConfig, extensionId: string, + scope: ExtensionSettingScope, ): Promise> { - if (!extensionConfig.settings || extensionConfig.settings.length === 0) { - return Promise.resolve({}); - } - const extensionStorage = new ExtensionStorage(extensionConfig.name); + const { name: extensionName } = extensionConfig; const keychain = new KeychainTokenStorage( - getKeychainStorageName(extensionConfig.name, extensionId), + getKeychainStorageName(extensionName, extensionId, scope), ); + const envFilePath = getEnvFilePath(extensionName, scope); let customEnv: Record = {}; - if (fsSync.existsSync(extensionStorage.getEnvFilePath())) { - const envFile = fsSync.readFileSync( - extensionStorage.getEnvFilePath(), - 'utf-8', - ); + if (fsSync.existsSync(envFilePath)) { + const envFile = fsSync.readFileSync(envFilePath, 'utf-8'); customEnv = dotenv.parse(envFile); } @@ -139,6 +168,88 @@ export async function getEnvContents( return customEnv; } +export async function getEnvContents( + extensionConfig: ExtensionConfig, + extensionId: string, +): Promise> { + 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( + extensionConfig: ExtensionConfig, + extensionId: string, + settingKey: string, + requestSetting: (setting: ExtensionSetting) => Promise, + scope: ExtensionSettingScope, +): Promise { + 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, scope), + ); + + if (settingToUpdate.sensitive) { + await keychain.setSecret(settingToUpdate.envVar, newValue); + return; + } + + // For non-sensitive settings, we need to read the existing .env file for the given scope, + // update the value, and write it back. + const allSettings = await getScopedEnvContents( + extensionConfig, + extensionId, + scope, + ); + allSettings[settingToUpdate.envVar] = newValue; + + const envFilePath = getEnvFilePath(extensionName, scope); + + const nonSensitiveSettings: Record = {}; + 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[];