diff options
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/config/settings.test.ts | 216 | ||||
| -rw-r--r-- | packages/cli/src/config/settings.ts | 75 |
2 files changed, 281 insertions, 10 deletions
diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index b8ecbb62..4099e778 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -334,6 +334,86 @@ describe('Settings Loading and Merging', () => { expect(settings.merged.contextFileName).toBe('PROJECT_SPECIFIC.md'); }); + it('should handle excludedProjectEnvVars correctly when only in user settings', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const userSettingsContent = { + excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'], + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return ''; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'CUSTOM_VAR', + ]); + }); + + it('should handle excludedProjectEnvVars correctly when only in workspace settings', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH, + ); + const workspaceSettingsContent = { + excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return ''; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + }); + + it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'], + }; + const workspaceSettingsContent = { + excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return ''; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.user.settings.excludedProjectEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'USER_VAR', + ]); + expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + }); + it('should default contextFileName to undefined if not in any settings file', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { theme: 'dark' }; @@ -1055,4 +1135,140 @@ describe('Settings Loading and Merging', () => { expect(loadedSettings.merged.theme).toBe('ocean'); }); }); + + describe('excludedProjectEnvVars integration', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should exclude DEBUG and DEBUG_MODE from project .env files by default', () => { + // Create a workspace settings file with excludedProjectEnvVars + const workspaceSettingsContent = { + excludedProjectEnvVars: ['DEBUG', 'DEBUG_MODE'], + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH, + ); + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + // Mock findEnvFile to return a project .env file + const originalFindEnvFile = ( + loadSettings as unknown as { findEnvFile: () => string } + ).findEnvFile; + (loadSettings as unknown as { findEnvFile: () => string }).findEnvFile = + () => '/mock/project/.env'; + + // Mock fs.readFileSync for .env file content + const originalReadFileSync = fs.readFileSync; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === '/mock/project/.env') { + return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key'; + } + if (p === MOCK_WORKSPACE_SETTINGS_PATH) { + return JSON.stringify(workspaceSettingsContent); + } + return '{}'; + }, + ); + + try { + // This will call loadEnvironment internally with the merged settings + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify the settings were loaded correctly + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'DEBUG', + 'DEBUG_MODE', + ]); + + // Note: We can't directly test process.env changes here because the mocking + // prevents the actual file system operations, but we can verify the settings + // are correctly merged and passed to loadEnvironment + } finally { + (loadSettings as unknown as { findEnvFile: () => string }).findEnvFile = + originalFindEnvFile; + (fs.readFileSync as Mock).mockImplementation(originalReadFileSync); + } + }); + + it('should respect custom excludedProjectEnvVars from user settings', () => { + const userSettingsContent = { + excludedProjectEnvVars: ['NODE_ENV', 'DEBUG'], + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.user.settings.excludedProjectEnvVars).toEqual([ + 'NODE_ENV', + 'DEBUG', + ]); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'NODE_ENV', + 'DEBUG', + ]); + }); + + it('should merge excludedProjectEnvVars with workspace taking precedence', () => { + const userSettingsContent = { + excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'], + }; + const workspaceSettingsContent = { + excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + }; + + (mockFsExistsSync as Mock).mockReturnValue(true); + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.user.settings.excludedProjectEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'USER_VAR', + ]); + expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + }); + }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 84f996ba..05d4313f 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -24,6 +24,7 @@ import { CustomTheme } from '../ui/themes/theme.js'; export const SETTINGS_DIRECTORY_NAME = '.gemini'; export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME); export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json'); +export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; export function getSystemSettingsPath(): string { if (process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH) { @@ -38,6 +39,10 @@ export function getSystemSettingsPath(): string { } } +export function getWorkspaceSettingsPath(workspaceDir: string): string { + return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json'); +} + export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; export enum SettingScope { @@ -115,6 +120,9 @@ export interface Settings { disableUpdateNag?: boolean; memoryDiscoveryMaxDirs?: number; + + // Environment variables to exclude from project .env files + excludedProjectEnvVars?: string[]; dnsResolutionOrder?: DnsResolutionOrder; } @@ -292,15 +300,61 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void { } } -export function loadEnvironment(): void { +export function loadEnvironment(settings?: Settings): void { const envFilePath = findEnvFile(process.cwd()); + // Cloud Shell environment variable handling if (process.env.CLOUD_SHELL === 'true') { setUpCloudShellEnvironment(envFilePath); } + // If no settings provided, try to load workspace settings for exclusions + let resolvedSettings = settings; + if (!resolvedSettings) { + const workspaceSettingsPath = getWorkspaceSettingsPath(process.cwd()); + try { + if (fs.existsSync(workspaceSettingsPath)) { + const workspaceContent = fs.readFileSync( + workspaceSettingsPath, + 'utf-8', + ); + const parsedWorkspaceSettings = JSON.parse( + stripJsonComments(workspaceContent), + ) as Settings; + resolvedSettings = resolveEnvVarsInObject(parsedWorkspaceSettings); + } + } catch (_e) { + // Ignore errors loading workspace settings + } + } + if (envFilePath) { - dotenv.config({ path: envFilePath, quiet: true }); + // Manually parse and load environment variables to handle exclusions correctly. + // This avoids modifying environment variables that were already set from the shell. + try { + const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); + const parsedEnv = dotenv.parse(envFileContent); + + const excludedVars = + resolvedSettings?.excludedProjectEnvVars || DEFAULT_EXCLUDED_ENV_VARS; + const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR); + + for (const key in parsedEnv) { + if (Object.hasOwn(parsedEnv, key)) { + // If it's a project .env file, skip loading excluded variables. + if (isProjectEnvFile && excludedVars.includes(key)) { + continue; + } + + // Load variable only if it's not already set in the environment. + if (!Object.hasOwn(process.env, key)) { + process.env[key] = parsedEnv[key]; + } + } + } + } catch (_e) { + // Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`. + } } } @@ -309,7 +363,6 @@ export function loadEnvironment(): void { * Project settings override user settings. */ export function loadSettings(workspaceDir: string): LoadedSettings { - loadEnvironment(); let systemSettings: Settings = {}; let userSettings: Settings = {}; let workspaceSettings: Settings = {}; @@ -331,6 +384,8 @@ export function loadSettings(workspaceDir: string): LoadedSettings { // We expect homedir to always exist and be resolvable. const realHomeDir = fs.realpathSync(resolvedHomeDir); + const workspaceSettingsPath = getWorkspaceSettingsPath(workspaceDir); + // Load system settings try { if (fs.existsSync(systemSettingsPath)) { @@ -369,12 +424,6 @@ export function loadSettings(workspaceDir: string): LoadedSettings { }); } - const workspaceSettingsPath = path.join( - workspaceDir, - SETTINGS_DIRECTORY_NAME, - 'settings.json', - ); - // This comparison is now much more reliable. if (realWorkspaceDir !== realHomeDir) { // Load workspace settings @@ -402,7 +451,8 @@ export function loadSettings(workspaceDir: string): LoadedSettings { } } - return new LoadedSettings( + // Create LoadedSettings first + const loadedSettings = new LoadedSettings( { path: systemSettingsPath, settings: systemSettings, @@ -417,6 +467,11 @@ export function loadSettings(workspaceDir: string): LoadedSettings { }, settingsErrors, ); + + // Load environment with merged settings + loadEnvironment(loadedSettings.merged); + + return loadedSettings; } export function saveSettings(settingsFile: SettingsFile): void { |
