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:
```
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
Extensions can provide [custom commands](../cli/custom-commands.md) by placing

View File

@@ -6,9 +6,10 @@
import type { CommandModule } from 'yargs';
import {
getEnvContents,
updateSetting,
promptForSetting,
ExtensionSettingScope,
getScopedEnvContents,
} from '../../config/extensions/extensionSettings.js';
import { getExtensionAndManager } from './utils.js';
import { debugLogger } from '@google/gemini-cli-core';
@@ -18,10 +19,11 @@ import { exitCli } from '../utils.js';
interface SetArgs {
name: string;
setting: string;
scope: string;
}
const setCommand: CommandModule<object, SetArgs> = {
command: 'set <name> <setting>',
command: 'set [--scope] <name> <setting>',
describe: 'Set a specific setting for an extension.',
builder: (yargs) =>
yargs
@@ -34,9 +36,15 @@ const setCommand: CommandModule<object, SetArgs> = {
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 } = args;
const { name, setting, scope } = args;
const { extension, extensionManager } = await getExtensionAndManager(name);
if (!extension || !extensionManager) {
return;
@@ -55,6 +63,7 @@ const setCommand: CommandModule<object, SetArgs> = {
extension.id,
setting,
promptForSetting,
scope as ExtensionSettingScope,
);
await exitCli();
},
@@ -92,12 +101,30 @@ const listCommand: CommandModule<object, ListArgs> = {
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}":`);
for (const setting of extensionConfig.settings) {
const value = currentSettings[setting.envVar];
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) {
@@ -108,7 +135,7 @@ const listCommand: CommandModule<object, ListArgs> = {
debugLogger.log(`
- ${setting.name} (${setting.envVar})`);
debugLogger.log(` Description: ${setting.description}`);
debugLogger.log(` Value: ${displayValue}`);
debugLogger.log(` Value: ${displayValue}${scopeInfo}`);
}
await exitCli();
},

View File

@@ -13,6 +13,8 @@ import {
promptForSetting,
type ExtensionSetting,
updateSetting,
ExtensionSettingScope,
getScopedEnvContents,
} from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js';
@@ -20,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) => {
@@ -35,67 +38,66 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
KeychainTokenStorage: vi.fn().mockImplementation(() => ({
getSecret: vi.fn(),
setSecret: vi.fn(),
deleteSecret: vi.fn(),
listSecrets: vi.fn(),
isAvailable: vi.fn().mockResolvedValue(true),
})),
KeychainTokenStorage: vi.fn(),
};
});
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', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let extensionDir: string;
let mockKeychainStorage: MockKeychainStorage;
let keychainData: Record<string, string>;
let mockKeychainData: Record<string, Record<string, string>>;
beforeEach(() => {
vi.clearAllMocks();
keychainData = {};
mockKeychainStorage = {
getSecret: vi
.fn()
.mockImplementation(async (key: string) => keychainData[key] || null),
setSecret: vi
.fn()
.mockImplementation(async (key: string, value: string) => {
keychainData[key] = value;
}),
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
delete keychainData[key];
}),
listSecrets: vi
.fn()
.mockImplementation(async () => Object.keys(keychainData)),
isAvailable: vi.fn().mockResolvedValue(true),
};
(
KeychainTokenStorage as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockKeychainStorage);
mockKeychainData = {};
vi.mocked(KeychainTokenStorage).mockImplementation(
(serviceName: string) => {
if (!mockKeychainData[serviceName]) {
mockKeychainData[serviceName] = {};
}
const keychainData = mockKeychainData[serviceName];
return {
getSecret: vi
.fn()
.mockImplementation(
async (key: string) => keychainData[key] || null,
),
setSecret: vi
.fn()
.mockImplementation(async (key: string, value: string) => {
keychainData[key] = value;
}),
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
delete keychainData[key];
}),
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()}`;
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();
});
@@ -213,7 +215,10 @@ describe('extensionSettings', () => {
VAR1: 'previous-VAR1',
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');
await fsPromises.writeFile(envPath, 'VAR1=previous-VAR1');
@@ -228,9 +233,7 @@ describe('extensionSettings', () => {
expect(mockRequestSetting).not.toHaveBeenCalled();
const actualContent = await fsPromises.readFile(envPath, 'utf-8');
expect(actualContent).toBe('');
expect(mockKeychainStorage.deleteSecret).toHaveBeenCalledWith(
'SENSITIVE_VAR',
);
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
});
it('should remove sensitive settings from keychain', async () => {
@@ -252,7 +255,10 @@ describe('extensionSettings', () => {
settings: [],
};
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(
newConfig,
@@ -262,9 +268,7 @@ describe('extensionSettings', () => {
previousSettings,
);
expect(mockKeychainStorage.deleteSecret).toHaveBeenCalledWith(
'SENSITIVE_VAR',
);
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
});
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 = {
name: 'test-ext',
version: '1.0.0',
@@ -469,39 +473,94 @@ 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');
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({
VAR1: 'value1',
SENSITIVE_VAR: 'secret',
VAR1: 'user-value1',
SENSITIVE_VAR: 'user-secret',
});
});
it('should return an empty object if no settings are defined', async () => {
const contents = await getEnvContents(
{ name: 'test-ext', version: '1.0.0' },
'12345',
it('should return combined contents from workspace .env and keychain for WORKSPACE scope', async () => {
const workspaceEnvPath = path.join(
tempWorkspaceDir,
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 () => {
keychainData['SENSITIVE_VAR'] = 'secret';
const contents = await getEnvContents(config, '12345');
expect(contents).toEqual({ SENSITIVE_VAR: 'secret' });
});
const contents = await getScopedEnvContents(
config,
extensionId,
ExtensionSettingScope.WORKSPACE,
);
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' });
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',
});
});
});
@@ -517,87 +576,114 @@ describe('extensionSettings', () => {
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';
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 update a non-sensitive setting', async () => {
it('should update a non-sensitive setting in USER scope', async () => {
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 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');
it('should update a non-sensitive setting in WORKSPACE scope', async () => {
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(extensionDir, '.env');
const expectedEnvPath = path.join(tempWorkspaceDir, '.env');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).toContain('VAR1=new-value-by-name');
expect(mockKeychainStorage.setSecret).not.toHaveBeenCalled();
expect(actualContent).toContain('VAR1=new-workspace-value');
});
it('should update a sensitive setting', async () => {
it('should update a sensitive setting in USER scope', async () => {
mockRequestSetting.mockResolvedValue('new-value2');
await updateSetting(config, '12345', 'VAR2', mockRequestSetting);
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]);
expect(mockKeychainStorage.setSecret).toHaveBeenCalledWith(
await updateSetting(
config,
'12345',
'VAR2',
'new-value2',
mockRequestSetting,
ExtensionSettingScope.USER,
);
const expectedEnvPath = path.join(extensionDir, '.env');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).not.toContain('VAR2=new-value2');
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
expect(await userKeychain.getSecret('VAR2')).toBe('new-value2');
});
it('should update a sensitive setting by name', async () => {
mockRequestSetting.mockResolvedValue('new-sensitive-by-name');
it('should update a sensitive setting in WORKSPACE scope', async () => {
mockRequestSetting.mockResolvedValue('new-workspace-secret');
await updateSetting(config, '12345', 's2', mockRequestSetting);
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]);
expect(mockKeychainStorage.setSecret).toHaveBeenCalledWith(
await updateSetting(
config,
'12345',
'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 () => {
await updateSetting(config, '12345', 'VAR3', mockRequestSetting);
expect(mockRequestSetting).not.toHaveBeenCalled();
});
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);
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();
});
// Simulate updating an extension-managed non-sensitive setting
mockRequestSetting.mockResolvedValue('updated-value');
await updateSetting(
config,
'12345',
'VAR1',
mockRequestSetting,
ExtensionSettingScope.WORKSPACE,
);
it('should wrap values with spaces in quotes', async () => {
mockRequestSetting.mockResolvedValue('a value with spaces');
// Read the .env file after update
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');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).toContain('VAR1="a value with spaces"');
// 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
});
});
});

View File

@@ -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 { 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) {
@@ -112,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<Record<string, string>> {
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<string, string> = {};
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);
}
@@ -145,11 +168,34 @@ export async function getEnvContents(
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(
extensionConfig: ExtensionConfig,
extensionId: string,
settingKey: string,
requestSetting: (setting: ExtensionSetting) => Promise<string>,
scope: ExtensionSettingScope,
): Promise<void> {
const { name: extensionName, settings } = extensionConfig;
if (!settings || settings.length === 0) {
@@ -168,7 +214,7 @@ export async function updateSetting(
const newValue = await requestSetting(settingToUpdate);
const keychain = new KeychainTokenStorage(
getKeychainStorageName(extensionName, extensionId),
getKeychainStorageName(extensionName, extensionId, scope),
);
if (settingToUpdate.sensitive) {
@@ -177,27 +223,29 @@ export async function updateSetting(
}
// 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;
// update the value, and write it back, preserving any other values.
const envFilePath = getEnvFilePath(extensionName, scope);
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> = {};
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 sensitiveEnvVars = new Set(
settings.filter((s) => s.sensitive).map((s) => s.envVar),
);
for (const [key, value] of Object.entries(parsedEnv)) {
if (!sensitiveEnvVars.has(key)) {
nonSensitiveSettings[key] = value;
}
}
const envContent = formatEnvContent(nonSensitiveSettings);
await fs.writeFile(envFilePath, envContent);
const newEnvContent = formatEnvContent(nonSensitiveSettings);
await fs.writeFile(envFilePath, newEnvContent);
}
interface settingsChanges {