diff options
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/config/config.ts | 1 | ||||
| -rw-r--r-- | packages/cli/src/config/settings.test.ts | 167 | ||||
| -rw-r--r-- | packages/cli/src/config/settings.ts | 42 | ||||
| -rw-r--r-- | packages/cli/src/gemini.test.tsx | 5 | ||||
| -rw-r--r-- | packages/cli/src/ui/App.test.tsx | 62 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/AuthDialog.test.tsx | 14 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/ThemeDialog.tsx | 20 |
7 files changed, 284 insertions, 27 deletions
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index ca38814a..20a8afcf 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -168,6 +168,7 @@ async function parseArguments(): Promise<CliArgs> { type: 'boolean', description: 'List all available extensions and exit.', }) + .version(await getCliVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') .help() diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index e7565457..44de24fe 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -13,6 +13,7 @@ vi.mock('os', async (importOriginal) => { return { ...actualOs, homedir: vi.fn(() => '/mock/home/user'), + platform: vi.fn(() => 'linux'), }; }); @@ -45,6 +46,7 @@ import stripJsonComments from 'strip-json-comments'; // Will be mocked separatel import { loadSettings, USER_SETTINGS_PATH, // This IS the mocked path. + SYSTEM_SETTINGS_PATH, SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock. SettingScope, } from './settings.js'; @@ -90,12 +92,41 @@ describe('Settings Loading and Merging', () => { describe('loadSettings', () => { it('should load empty settings if no files exist', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.system.settings).toEqual({}); expect(settings.user.settings).toEqual({}); expect(settings.workspace.settings).toEqual({}); expect(settings.merged).toEqual({}); expect(settings.errors.length).toBe(0); }); + it('should load system settings if only system file exists', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === SYSTEM_SETTINGS_PATH, + ); + const systemSettingsContent = { + theme: 'system-default', + sandbox: false, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === SYSTEM_SETTINGS_PATH) + return JSON.stringify(systemSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(fs.readFileSync).toHaveBeenCalledWith( + SYSTEM_SETTINGS_PATH, + 'utf-8', + ); + expect(settings.system.settings).toEqual(systemSettingsContent); + expect(settings.user.settings).toEqual({}); + expect(settings.workspace.settings).toEqual({}); + expect(settings.merged).toEqual(systemSettingsContent); + }); + it('should load user settings if only user file exists', () => { const expectedUserSettingsPath = USER_SETTINGS_PATH; // Use the path actually resolved by the (mocked) module @@ -187,6 +218,50 @@ describe('Settings Loading and Merging', () => { }); }); + it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + theme: 'system-theme', + sandbox: false, + telemetry: { enabled: false }, + }; + const userSettingsContent = { + theme: 'dark', + sandbox: true, + contextFileName: 'USER_CONTEXT.md', + }; + const workspaceSettingsContent = { + sandbox: false, + coreTools: ['tool1'], + contextFileName: 'WORKSPACE_CONTEXT.md', + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === SYSTEM_SETTINGS_PATH) + return JSON.stringify(systemSettingsContent); + 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.system.settings).toEqual(systemSettingsContent); + expect(settings.user.settings).toEqual(userSettingsContent); + expect(settings.workspace.settings).toEqual(workspaceSettingsContent); + expect(settings.merged).toEqual({ + theme: 'system-theme', + sandbox: false, + telemetry: { enabled: false }, + coreTools: ['tool1'], + contextFileName: 'WORKSPACE_CONTEXT.md', + }); + }); + it('should handle contextFileName correctly when only in user settings', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, @@ -409,6 +484,50 @@ describe('Settings Loading and Merging', () => { delete process.env.WORKSPACE_ENDPOINT; }); + it('should prioritize user env variables over workspace env variables if keys clash after resolution', () => { + const userSettingsContent = { configValue: '$SHARED_VAR' }; + const workspaceSettingsContent = { configValue: '$SHARED_VAR' }; + + (mockFsExistsSync as Mock).mockReturnValue(true); + const originalSharedVar = process.env.SHARED_VAR; + // Temporarily delete to ensure a clean slate for the test's specific manipulations + delete process.env.SHARED_VAR; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) { + process.env.SHARED_VAR = 'user_value_for_user_read'; // Set for user settings read + return JSON.stringify(userSettingsContent); + } + if (p === MOCK_WORKSPACE_SETTINGS_PATH) { + process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read + return JSON.stringify(workspaceSettingsContent); + } + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.user.settings.configValue).toBe( + 'user_value_for_user_read', + ); + expect(settings.workspace.settings.configValue).toBe( + 'workspace_value_for_workspace_read', + ); + // Merged should take workspace's resolved value + expect(settings.merged.configValue).toBe( + 'workspace_value_for_workspace_read', + ); + + // Restore original environment variable state + if (originalSharedVar !== undefined) { + process.env.SHARED_VAR = originalSharedVar; + } else { + delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before + } + }); + it('should prioritize workspace env variables over user env variables if keys clash after resolution', () => { const userSettingsContent = { configValue: '$SHARED_VAR' }; const workspaceSettingsContent = { configValue: '$SHARED_VAR' }; @@ -453,6 +572,48 @@ describe('Settings Loading and Merging', () => { } }); + it('should prioritize system env variables over workspace env variables if keys clash after resolution', () => { + const workspaceSettingsContent = { configValue: '$SHARED_VAR' }; + const systemSettingsContent = { configValue: '$SHARED_VAR' }; + + (mockFsExistsSync as Mock).mockReturnValue(true); + const originalSharedVar = process.env.SHARED_VAR; + // Temporarily delete to ensure a clean slate for the test's specific manipulations + delete process.env.SHARED_VAR; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === SYSTEM_SETTINGS_PATH) { + process.env.SHARED_VAR = 'system_value_for_system_read'; // Set for system settings read + return JSON.stringify(systemSettingsContent); + } + if (p === MOCK_WORKSPACE_SETTINGS_PATH) { + process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read + return JSON.stringify(workspaceSettingsContent); + } + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.system.settings.configValue).toBe( + 'system_value_for_system_read', + ); + expect(settings.workspace.settings.configValue).toBe( + 'workspace_value_for_workspace_read', + ); + // Merged should take workspace's resolved value + expect(settings.merged.configValue).toBe('system_value_for_system_read'); + + // Restore original environment variable state + if (originalSharedVar !== undefined) { + process.env.SHARED_VAR = originalSharedVar; + } else { + delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before + } + }); + it('should leave unresolved environment variables as is', () => { const userSettingsContent = { apiKey: '$UNDEFINED_VAR' }; (mockFsExistsSync as Mock).mockImplementation( @@ -624,10 +785,10 @@ describe('Settings Loading and Merging', () => { 'utf-8', ); - // Workspace theme overrides user theme - loadedSettings.setValue(SettingScope.Workspace, 'theme', 'ocean'); + // System theme overrides user and workspace themes + loadedSettings.setValue(SettingScope.System, 'theme', 'ocean'); - expect(loadedSettings.workspace.settings.theme).toBe('ocean'); + expect(loadedSettings.system.settings.theme).toBe('ocean'); expect(loadedSettings.merged.theme).toBe('ocean'); }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index c2d03167..133701f5 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { homedir } from 'os'; +import { homedir, platform } from 'os'; import * as dotenv from 'dotenv'; import { MCPServerConfig, @@ -24,9 +24,22 @@ 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'); +function getSystemSettingsPath(): string { + if (platform() === 'darwin') { + return '/Library/Application Support/GeminiCli/settings.json'; + } else if (platform() === 'win32') { + return 'C:\\ProgramData\\gemini-cli\\settings.json'; + } else { + return '/etc/gemini-cli/settings.json'; + } +} + +export const SYSTEM_SETTINGS_PATH = getSystemSettingsPath(); + export enum SettingScope { User = 'User', Workspace = 'Workspace', + System = 'System', } export interface CheckpointingSettings { @@ -81,16 +94,19 @@ export interface SettingsFile { } export class LoadedSettings { constructor( + system: SettingsFile, user: SettingsFile, workspace: SettingsFile, errors: SettingsError[], ) { + this.system = system; this.user = user; this.workspace = workspace; this.errors = errors; this._merged = this.computeMergedSettings(); } + readonly system: SettingsFile; readonly user: SettingsFile; readonly workspace: SettingsFile; readonly errors: SettingsError[]; @@ -105,6 +121,7 @@ export class LoadedSettings { return { ...this.user.settings, ...this.workspace.settings, + ...this.system.settings, }; } @@ -114,6 +131,8 @@ export class LoadedSettings { return this.user; case SettingScope.Workspace: return this.workspace; + case SettingScope.System: + return this.system; default: throw new Error(`Invalid scope: ${scope}`); } @@ -243,10 +262,27 @@ export function loadEnvironment(): void { */ export function loadSettings(workspaceDir: string): LoadedSettings { loadEnvironment(); + let systemSettings: Settings = {}; let userSettings: Settings = {}; let workspaceSettings: Settings = {}; const settingsErrors: SettingsError[] = []; + // Load system settings + try { + if (fs.existsSync(SYSTEM_SETTINGS_PATH)) { + const systemContent = fs.readFileSync(SYSTEM_SETTINGS_PATH, 'utf-8'); + const parsedSystemSettings = JSON.parse( + stripJsonComments(systemContent), + ) as Settings; + systemSettings = resolveEnvVarsInObject(parsedSystemSettings); + } + } catch (error: unknown) { + settingsErrors.push({ + message: getErrorMessage(error), + path: SYSTEM_SETTINGS_PATH, + }); + } + // Load user settings try { if (fs.existsSync(USER_SETTINGS_PATH)) { @@ -301,6 +337,10 @@ export function loadSettings(workspaceDir: string): LoadedSettings { return new LoadedSettings( { + path: SYSTEM_SETTINGS_PATH, + settings: systemSettings, + }, + { path: USER_SETTINGS_PATH, settings: userSettings, }, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 952b4a77..20350815 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -112,7 +112,12 @@ describe('gemini.tsx main function', () => { path: '/workspace/.gemini/settings.json', settings: {}, }; + const systemSettingsFile: SettingsFile = { + path: '/system/settings.json', + settings: {}, + }; const mockLoadedSettings = new LoadedSettings( + systemSettingsFile, userSettingsFile, workspaceSettingsFile, [settingsError], diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 8390dac1..22547ae1 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -185,19 +185,30 @@ describe('App UI', () => { let currentUnmount: (() => void) | undefined; const createMockSettings = ( - settings: Partial<Settings> = {}, + settings: { + system?: Partial<Settings>; + user?: Partial<Settings>; + workspace?: Partial<Settings>; + } = {}, ): LoadedSettings => { + const systemSettingsFile: SettingsFile = { + path: '/system/settings.json', + settings: settings.system || {}, + }; const userSettingsFile: SettingsFile = { path: '/user/settings.json', - settings: {}, + settings: settings.user || {}, }; const workspaceSettingsFile: SettingsFile = { path: '/workspace/.gemini/settings.json', - settings: { - ...settings, - }, + settings: settings.workspace || {}, }; - return new LoadedSettings(userSettingsFile, workspaceSettingsFile, []); + return new LoadedSettings( + systemSettingsFile, + userSettingsFile, + workspaceSettingsFile, + [], + ); }; beforeEach(() => { @@ -222,7 +233,7 @@ describe('App UI', () => { mockConfig.getShowMemoryUsage.mockReturnValue(false); // Default for most tests // Ensure a theme is set so the theme dialog does not appear. - mockSettings = createMockSettings({ theme: 'Default' }); + mockSettings = createMockSettings({ workspace: { theme: 'Default' } }); }); afterEach(() => { @@ -268,8 +279,7 @@ describe('App UI', () => { it('should display custom contextFileName in footer when set and count is 1', async () => { mockSettings = createMockSettings({ - contextFileName: 'AGENTS.md', - theme: 'Default', + workspace: { contextFileName: 'AGENTS.md', theme: 'Default' }, }); mockConfig.getGeminiMdFileCount.mockReturnValue(1); mockConfig.getDebugMode.mockReturnValue(false); @@ -288,8 +298,10 @@ describe('App UI', () => { it('should display a generic message when multiple context files with different names are provided', async () => { mockSettings = createMockSettings({ - contextFileName: ['AGENTS.md', 'CONTEXT.md'], - theme: 'Default', + workspace: { + contextFileName: ['AGENTS.md', 'CONTEXT.md'], + theme: 'Default', + }, }); mockConfig.getGeminiMdFileCount.mockReturnValue(2); mockConfig.getDebugMode.mockReturnValue(false); @@ -308,8 +320,7 @@ describe('App UI', () => { it('should display custom contextFileName with plural when set and count is > 1', async () => { mockSettings = createMockSettings({ - contextFileName: 'MY_NOTES.TXT', - theme: 'Default', + workspace: { contextFileName: 'MY_NOTES.TXT', theme: 'Default' }, }); mockConfig.getGeminiMdFileCount.mockReturnValue(3); mockConfig.getDebugMode.mockReturnValue(false); @@ -328,8 +339,7 @@ describe('App UI', () => { it('should not display context file message if count is 0, even if contextFileName is set', async () => { mockSettings = createMockSettings({ - contextFileName: 'ANY_FILE.MD', - theme: 'Default', + workspace: { contextFileName: 'ANY_FILE.MD', theme: 'Default' }, }); mockConfig.getGeminiMdFileCount.mockReturnValue(0); mockConfig.getDebugMode.mockReturnValue(false); @@ -399,7 +409,9 @@ describe('App UI', () => { it('should not display Tips component when hideTips is true', async () => { mockSettings = createMockSettings({ - hideTips: true, + workspace: { + hideTips: true, + }, }); const { unmount } = render( @@ -413,6 +425,24 @@ describe('App UI', () => { expect(vi.mocked(Tips)).not.toHaveBeenCalled(); }); + it('should show tips if system says show, but workspace and user settings say hide', async () => { + mockSettings = createMockSettings({ + system: { hideTips: false }, + user: { hideTips: true }, + workspace: { hideTips: true }, + }); + + const { unmount } = render( + <App + config={mockConfig as unknown as ServerConfig} + settings={mockSettings} + />, + ); + currentUnmount = unmount; + await Promise.resolve(); + expect(vi.mocked(Tips)).toHaveBeenCalled(); + }); + describe('when no theme is set', () => { let originalNoColor: string | undefined; diff --git a/packages/cli/src/ui/components/AuthDialog.test.tsx b/packages/cli/src/ui/components/AuthDialog.test.tsx index 60a8a930..d9f1bd2c 100644 --- a/packages/cli/src/ui/components/AuthDialog.test.tsx +++ b/packages/cli/src/ui/components/AuthDialog.test.tsx @@ -30,6 +30,10 @@ describe('AuthDialog', () => { const settings: LoadedSettings = new LoadedSettings( { + settings: {}, + path: '', + }, + { settings: { selectedAuthType: AuthType.USE_GEMINI, }, @@ -87,6 +91,12 @@ describe('AuthDialog', () => { path: '', }, { + settings: { + selectedAuthType: undefined, + }, + path: '', + }, + { settings: {}, path: '', }, @@ -148,6 +158,10 @@ describe('AuthDialog', () => { const onSelect = vi.fn(); const settings: LoadedSettings = new LoadedSettings( { + settings: {}, + path: '', + }, + { settings: { selectedAuthType: AuthType.USE_GEMINI, }, diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 9351d5a1..ba49f8e3 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -57,6 +57,7 @@ export function ThemeDialog({ const scopeItems = [ { label: 'User Settings', value: SettingScope.User }, { label: 'Workspace Settings', value: SettingScope.Workspace }, + { label: 'System Settings', value: SettingScope.System }, ]; const handleThemeSelect = (themeName: string) => { @@ -86,16 +87,21 @@ export function ThemeDialog({ } }); + const otherScopes = Object.values(SettingScope).filter( + (scope) => scope !== selectedScope, + ); + + const modifiedInOtherScopes = otherScopes.filter( + (scope) => settings.forScope(scope).settings.theme !== undefined, + ); + let otherScopeModifiedMessage = ''; - const otherScope = - selectedScope === SettingScope.User - ? SettingScope.Workspace - : SettingScope.User; - if (settings.forScope(otherScope).settings.theme !== undefined) { + if (modifiedInOtherScopes.length > 0) { + const modifiedScopesStr = modifiedInOtherScopes.join(', '); otherScopeModifiedMessage = settings.forScope(selectedScope).settings.theme !== undefined - ? `(Also modified in ${otherScope})` - : `(Modified in ${otherScope})`; + ? `(Also modified in ${modifiedScopesStr})` + : `(Modified in ${modifiedScopesStr})`; } // Constants for calculating preview pane layout. |
