mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 10:34:35 -07:00
refactor(config): remove legacy V1 settings migration logic (#16252)
This commit is contained in:
@@ -341,7 +341,9 @@ export class TestRig {
|
|||||||
ui: {
|
ui: {
|
||||||
useAlternateBuffer: true,
|
useAlternateBuffer: true,
|
||||||
},
|
},
|
||||||
model: DEFAULT_GEMINI_MODEL,
|
model: {
|
||||||
|
name: DEFAULT_GEMINI_MODEL,
|
||||||
|
},
|
||||||
sandbox:
|
sandbox:
|
||||||
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
|
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
|
||||||
// Don't show the IDE connection dialog when running from VsCode
|
// Don't show the IDE connection dialog when running from VsCode
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ vi.mock('./settingsSchema.js', async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// NOW import everything else, including the (now effectively re-exported) settings.js
|
// NOW import everything else, including the (now effectively re-exported) settings.js
|
||||||
import path, * as pathActual from 'node:path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH
|
import * as pathActual from 'node:path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
it,
|
it,
|
||||||
@@ -65,18 +65,12 @@ import {
|
|||||||
USER_SETTINGS_PATH, // This IS the mocked path.
|
USER_SETTINGS_PATH, // This IS the mocked path.
|
||||||
getSystemSettingsPath,
|
getSystemSettingsPath,
|
||||||
getSystemDefaultsPath,
|
getSystemDefaultsPath,
|
||||||
migrateSettingsToV1,
|
|
||||||
needsMigration,
|
|
||||||
type Settings,
|
type Settings,
|
||||||
loadEnvironment,
|
|
||||||
migrateDeprecatedSettings,
|
|
||||||
SettingScope,
|
|
||||||
saveSettings,
|
saveSettings,
|
||||||
type SettingsFile,
|
type SettingsFile,
|
||||||
getDefaultsFromSchema,
|
getDefaultsFromSchema,
|
||||||
} from './settings.js';
|
} from './settings.js';
|
||||||
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
|
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
|
||||||
import { ExtensionManager } from './extension-manager.js';
|
|
||||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||||
import {
|
import {
|
||||||
getSettingsSchema,
|
getSettingsSchema,
|
||||||
@@ -290,169 +284,6 @@ describe('Settings Loading and Merging', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly migrate a complex legacy (v1) settings file', () => {
|
|
||||||
(mockFsExistsSync as Mock).mockImplementation(
|
|
||||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
|
||||||
);
|
|
||||||
const legacySettingsContent = {
|
|
||||||
theme: 'legacy-dark',
|
|
||||||
vimMode: true,
|
|
||||||
contextFileName: 'LEGACY_CONTEXT.md',
|
|
||||||
model: 'gemini-2.5-pro',
|
|
||||||
mcpServers: {
|
|
||||||
'legacy-server-1': {
|
|
||||||
command: 'npm',
|
|
||||||
args: ['run', 'start:server1'],
|
|
||||||
description: 'Legacy Server 1',
|
|
||||||
},
|
|
||||||
'legacy-server-2': {
|
|
||||||
command: 'node',
|
|
||||||
args: ['server2.js'],
|
|
||||||
description: 'Legacy Server 2',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
allowMCPServers: ['legacy-server-1'],
|
|
||||||
someUnrecognizedSetting: 'should-be-preserved',
|
|
||||||
};
|
|
||||||
|
|
||||||
(fs.readFileSync as Mock).mockImplementation(
|
|
||||||
(p: fs.PathOrFileDescriptor) => {
|
|
||||||
if (p === USER_SETTINGS_PATH)
|
|
||||||
return JSON.stringify(legacySettingsContent);
|
|
||||||
return '{}';
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
||||||
|
|
||||||
expect(settings.merged).toMatchObject({
|
|
||||||
ui: {
|
|
||||||
theme: 'legacy-dark',
|
|
||||||
},
|
|
||||||
general: {
|
|
||||||
vimMode: true,
|
|
||||||
},
|
|
||||||
context: {
|
|
||||||
fileName: 'LEGACY_CONTEXT.md',
|
|
||||||
},
|
|
||||||
model: {
|
|
||||||
name: 'gemini-2.5-pro',
|
|
||||||
},
|
|
||||||
mcpServers: {
|
|
||||||
'legacy-server-1': {
|
|
||||||
command: 'npm',
|
|
||||||
args: ['run', 'start:server1'],
|
|
||||||
description: 'Legacy Server 1',
|
|
||||||
},
|
|
||||||
'legacy-server-2': {
|
|
||||||
command: 'node',
|
|
||||||
args: ['server2.js'],
|
|
||||||
description: 'Legacy Server 2',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mcp: {
|
|
||||||
allowed: ['legacy-server-1'],
|
|
||||||
},
|
|
||||||
someUnrecognizedSetting: 'should-be-preserved',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should rewrite allowedTools to tools.allowed during migration', () => {
|
|
||||||
(mockFsExistsSync as Mock).mockImplementation(
|
|
||||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
|
||||||
);
|
|
||||||
const legacySettingsContent = {
|
|
||||||
allowedTools: ['fs', 'shell'],
|
|
||||||
};
|
|
||||||
(fs.readFileSync as Mock).mockImplementation(
|
|
||||||
(p: fs.PathOrFileDescriptor) => {
|
|
||||||
if (p === USER_SETTINGS_PATH)
|
|
||||||
return JSON.stringify(legacySettingsContent);
|
|
||||||
return '{}';
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
||||||
|
|
||||||
expect(settings.merged.tools?.allowed).toEqual(['fs', 'shell']);
|
|
||||||
expect((settings.merged as TestSettings)['allowedTools']).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow V2 settings to override V1 settings when both are present (zombie setting fix)', () => {
|
|
||||||
(mockFsExistsSync as Mock).mockImplementation(
|
|
||||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
|
||||||
);
|
|
||||||
const mixedSettingsContent = {
|
|
||||||
// V1 setting (migrates to ui.accessibility.screenReader = true)
|
|
||||||
accessibility: {
|
|
||||||
screenReader: true,
|
|
||||||
},
|
|
||||||
// V2 setting (explicitly set to false)
|
|
||||||
ui: {
|
|
||||||
accessibility: {
|
|
||||||
screenReader: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
(fs.readFileSync as Mock).mockImplementation(
|
|
||||||
(p: fs.PathOrFileDescriptor) => {
|
|
||||||
if (p === USER_SETTINGS_PATH)
|
|
||||||
return JSON.stringify(mixedSettingsContent);
|
|
||||||
return '{}';
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
||||||
|
|
||||||
// We expect the V2 setting (false) to win, NOT the migrated V1 setting (true)
|
|
||||||
expect(settings.merged.ui?.accessibility?.screenReader).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
|
|
||||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
|
||||||
const legacyUserSettings = {
|
|
||||||
includeDirectories: ['/user/dir'],
|
|
||||||
excludeTools: ['user-tool'],
|
|
||||||
excludedProjectEnvVars: ['USER_VAR'],
|
|
||||||
};
|
|
||||||
const legacyWorkspaceSettings = {
|
|
||||||
includeDirectories: ['/workspace/dir'],
|
|
||||||
excludeTools: ['workspace-tool'],
|
|
||||||
excludedProjectEnvVars: ['WORKSPACE_VAR', 'USER_VAR'],
|
|
||||||
};
|
|
||||||
|
|
||||||
(fs.readFileSync as Mock).mockImplementation(
|
|
||||||
(p: fs.PathOrFileDescriptor) => {
|
|
||||||
if (p === USER_SETTINGS_PATH)
|
|
||||||
return JSON.stringify(legacyUserSettings);
|
|
||||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
|
||||||
return JSON.stringify(legacyWorkspaceSettings);
|
|
||||||
return '{}';
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
||||||
|
|
||||||
// Verify includeDirectories are concatenated
|
|
||||||
expect(settings.merged.context?.includeDirectories).toEqual([
|
|
||||||
'/user/dir',
|
|
||||||
'/workspace/dir',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Verify excludeTools are concatenated and de-duped
|
|
||||||
expect(settings.merged.tools?.exclude).toEqual([
|
|
||||||
'user-tool',
|
|
||||||
'workspace-tool',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Verify excludedProjectEnvVars are concatenated and de-duped
|
|
||||||
expect(settings.merged.advanced?.excludedEnvVars).toEqual(
|
|
||||||
expect.arrayContaining(['USER_VAR', 'WORKSPACE_VAR']),
|
|
||||||
);
|
|
||||||
expect(settings.merged.advanced?.excludedEnvVars).toHaveLength(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should merge all settings files with the correct precedence', () => {
|
it('should merge all settings files with the correct precedence', () => {
|
||||||
// Mock schema to test defaults application
|
// Mock schema to test defaults application
|
||||||
const mockSchema = {
|
const mockSchema = {
|
||||||
@@ -1771,653 +1602,6 @@ describe('Settings Loading and Merging', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('with workspace trust', () => {
|
|
||||||
it('should merge workspace settings when workspace is trusted', () => {
|
|
||||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
|
||||||
const userSettingsContent = {
|
|
||||||
ui: { theme: 'dark' },
|
|
||||||
tools: { sandbox: false },
|
|
||||||
};
|
|
||||||
const workspaceSettingsContent = {
|
|
||||||
tools: { sandbox: true },
|
|
||||||
context: { fileName: 'WORKSPACE.md' },
|
|
||||||
};
|
|
||||||
|
|
||||||
(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.merged.tools?.sandbox).toBe(true);
|
|
||||||
expect(settings.merged.context?.fileName).toBe('WORKSPACE.md');
|
|
||||||
expect(settings.merged.ui?.theme).toBe('dark');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should NOT merge workspace settings when workspace is not trusted', () => {
|
|
||||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
|
||||||
isTrusted: false,
|
|
||||||
source: 'file',
|
|
||||||
});
|
|
||||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
|
||||||
const userSettingsContent = {
|
|
||||||
ui: { theme: 'dark' },
|
|
||||||
tools: { sandbox: false },
|
|
||||||
context: { fileName: 'USER.md' },
|
|
||||||
};
|
|
||||||
const workspaceSettingsContent = {
|
|
||||||
tools: { sandbox: true },
|
|
||||||
context: { fileName: 'WORKSPACE.md' },
|
|
||||||
};
|
|
||||||
|
|
||||||
(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.merged.tools?.sandbox).toBe(false); // User setting
|
|
||||||
expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting
|
|
||||||
expect(settings.merged.ui?.theme).toBe('dark'); // User setting
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('migrateSettingsToV1', () => {
|
|
||||||
it('should handle an empty object', () => {
|
|
||||||
const v2Settings = {};
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
|
||||||
expect(v1Settings).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should migrate a simple v2 settings object to v1', () => {
|
|
||||||
const v2Settings = {
|
|
||||||
general: {
|
|
||||||
preferredEditor: 'vscode',
|
|
||||||
vimMode: true,
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
theme: 'dark',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
|
||||||
expect(v1Settings).toEqual({
|
|
||||||
preferredEditor: 'vscode',
|
|
||||||
vimMode: true,
|
|
||||||
theme: 'dark',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle nested properties correctly', () => {
|
|
||||||
const v2Settings = {
|
|
||||||
security: {
|
|
||||||
folderTrust: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
selectedType: 'oauth',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
advanced: {
|
|
||||||
autoConfigureMemory: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
|
||||||
expect(v1Settings).toEqual({
|
|
||||||
folderTrust: true,
|
|
||||||
selectedAuthType: 'oauth',
|
|
||||||
autoConfigureMaxOldSpaceSize: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve mcpServers at the top level', () => {
|
|
||||||
const v2Settings = {
|
|
||||||
general: {
|
|
||||||
preferredEditor: 'vscode',
|
|
||||||
},
|
|
||||||
mcpServers: {
|
|
||||||
'my-server': {
|
|
||||||
command: 'npm start',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
|
||||||
expect(v1Settings).toEqual({
|
|
||||||
preferredEditor: 'vscode',
|
|
||||||
mcpServers: {
|
|
||||||
'my-server': {
|
|
||||||
command: 'npm start',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should carry over unrecognized top-level properties', () => {
|
|
||||||
const v2Settings = {
|
|
||||||
general: {
|
|
||||||
vimMode: false,
|
|
||||||
},
|
|
||||||
unrecognized: 'value',
|
|
||||||
another: {
|
|
||||||
nested: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
|
||||||
expect(v1Settings).toEqual({
|
|
||||||
vimMode: false,
|
|
||||||
unrecognized: 'value',
|
|
||||||
another: {
|
|
||||||
nested: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle a complex object with mixed properties', () => {
|
|
||||||
const v2Settings = {
|
|
||||||
general: {
|
|
||||||
disableAutoUpdate: true,
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
hideBanner: true,
|
|
||||||
customThemes: {
|
|
||||||
myTheme: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
model: {
|
|
||||||
name: 'gemini-pro',
|
|
||||||
},
|
|
||||||
mcpServers: {
|
|
||||||
'server-1': {
|
|
||||||
command: 'node server.js',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unrecognized: {
|
|
||||||
should: 'be-preserved',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
|
||||||
expect(v1Settings).toEqual({
|
|
||||||
disableAutoUpdate: true,
|
|
||||||
hideBanner: true,
|
|
||||||
customThemes: {
|
|
||||||
myTheme: {},
|
|
||||||
},
|
|
||||||
model: 'gemini-pro',
|
|
||||||
mcpServers: {
|
|
||||||
'server-1': {
|
|
||||||
command: 'node server.js',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unrecognized: {
|
|
||||||
should: 'be-preserved',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not migrate a v1 settings object', () => {
|
|
||||||
const v1Settings = {
|
|
||||||
preferredEditor: 'vscode',
|
|
||||||
vimMode: true,
|
|
||||||
theme: 'dark',
|
|
||||||
};
|
|
||||||
const migratedSettings = migrateSettingsToV1(v1Settings);
|
|
||||||
expect(migratedSettings).toEqual({
|
|
||||||
preferredEditor: 'vscode',
|
|
||||||
vimMode: true,
|
|
||||||
theme: 'dark',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should migrate a full v2 settings object to v1', () => {
|
|
||||||
const v2Settings: TestSettings = {
|
|
||||||
general: {
|
|
||||||
preferredEditor: 'code',
|
|
||||||
vimMode: true,
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
theme: 'dark',
|
|
||||||
},
|
|
||||||
privacy: {
|
|
||||||
usageStatisticsEnabled: false,
|
|
||||||
},
|
|
||||||
model: {
|
|
||||||
name: 'gemini-2.5-pro',
|
|
||||||
},
|
|
||||||
context: {
|
|
||||||
fileName: 'CONTEXT.md',
|
|
||||||
includeDirectories: ['/src'],
|
|
||||||
},
|
|
||||||
tools: {
|
|
||||||
sandbox: true,
|
|
||||||
exclude: ['toolA'],
|
|
||||||
},
|
|
||||||
mcp: {
|
|
||||||
allowed: ['server1'],
|
|
||||||
},
|
|
||||||
security: {
|
|
||||||
folderTrust: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
advanced: {
|
|
||||||
dnsResolutionOrder: 'ipv4first',
|
|
||||||
excludedEnvVars: ['SECRET'],
|
|
||||||
},
|
|
||||||
mcpServers: {
|
|
||||||
'my-server': {
|
|
||||||
command: 'npm start',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unrecognizedTopLevel: {
|
|
||||||
value: 'should be preserved',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
|
||||||
|
|
||||||
expect(v1Settings).toEqual({
|
|
||||||
preferredEditor: 'code',
|
|
||||||
vimMode: true,
|
|
||||||
theme: 'dark',
|
|
||||||
usageStatisticsEnabled: false,
|
|
||||||
model: 'gemini-2.5-pro',
|
|
||||||
contextFileName: 'CONTEXT.md',
|
|
||||||
includeDirectories: ['/src'],
|
|
||||||
sandbox: true,
|
|
||||||
excludeTools: ['toolA'],
|
|
||||||
allowMCPServers: ['server1'],
|
|
||||||
folderTrust: true,
|
|
||||||
dnsResolutionOrder: 'ipv4first',
|
|
||||||
excludedProjectEnvVars: ['SECRET'],
|
|
||||||
mcpServers: {
|
|
||||||
'my-server': {
|
|
||||||
command: 'npm start',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unrecognizedTopLevel: {
|
|
||||||
value: 'should be preserved',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle partial v2 settings', () => {
|
|
||||||
const v2Settings: TestSettings = {
|
|
||||||
general: {
|
|
||||||
vimMode: false,
|
|
||||||
},
|
|
||||||
ui: {},
|
|
||||||
model: {
|
|
||||||
name: 'gemini-2.5-pro',
|
|
||||||
},
|
|
||||||
unrecognized: 'value',
|
|
||||||
};
|
|
||||||
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
|
||||||
|
|
||||||
expect(v1Settings).toEqual({
|
|
||||||
vimMode: false,
|
|
||||||
model: 'gemini-2.5-pro',
|
|
||||||
unrecognized: 'value',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle settings with different data types', () => {
|
|
||||||
const v2Settings: TestSettings = {
|
|
||||||
general: {
|
|
||||||
vimMode: false,
|
|
||||||
},
|
|
||||||
model: {
|
|
||||||
maxSessionTurns: -1,
|
|
||||||
},
|
|
||||||
context: {
|
|
||||||
includeDirectories: [],
|
|
||||||
},
|
|
||||||
security: {
|
|
||||||
folderTrust: {
|
|
||||||
enabled: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
|
||||||
|
|
||||||
expect(v1Settings).toEqual({
|
|
||||||
vimMode: false,
|
|
||||||
maxSessionTurns: -1,
|
|
||||||
includeDirectories: [],
|
|
||||||
security: {
|
|
||||||
folderTrust: {
|
|
||||||
enabled: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve unrecognized top-level keys', () => {
|
|
||||||
const v2Settings: TestSettings = {
|
|
||||||
general: {
|
|
||||||
vimMode: true,
|
|
||||||
},
|
|
||||||
customTopLevel: {
|
|
||||||
a: 1,
|
|
||||||
b: [2],
|
|
||||||
},
|
|
||||||
anotherOne: 'hello',
|
|
||||||
};
|
|
||||||
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
|
||||||
|
|
||||||
expect(v1Settings).toEqual({
|
|
||||||
vimMode: true,
|
|
||||||
customTopLevel: {
|
|
||||||
a: 1,
|
|
||||||
b: [2],
|
|
||||||
},
|
|
||||||
anotherOne: 'hello',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle an empty v2 settings object', () => {
|
|
||||||
const v2Settings = {};
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
|
||||||
expect(v1Settings).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correctly handle mcpServers at the top level', () => {
|
|
||||||
const v2Settings: TestSettings = {
|
|
||||||
mcpServers: {
|
|
||||||
serverA: { command: 'a' },
|
|
||||||
},
|
|
||||||
mcp: {
|
|
||||||
allowed: ['serverA'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
|
||||||
|
|
||||||
expect(v1Settings).toEqual({
|
|
||||||
mcpServers: {
|
|
||||||
serverA: { command: 'a' },
|
|
||||||
},
|
|
||||||
allowMCPServers: ['serverA'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correctly migrate customWittyPhrases', () => {
|
|
||||||
const v2Settings: Partial<Settings> = {
|
|
||||||
ui: {
|
|
||||||
customWittyPhrases: ['test phrase'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const v1Settings = migrateSettingsToV1(v2Settings as Settings);
|
|
||||||
expect(v1Settings).toEqual({
|
|
||||||
customWittyPhrases: ['test phrase'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loadEnvironment', () => {
|
|
||||||
function setup({
|
|
||||||
isFolderTrustEnabled = true,
|
|
||||||
isWorkspaceTrustedValue = true,
|
|
||||||
}) {
|
|
||||||
delete process.env['TESTTEST']; // reset
|
|
||||||
const geminiEnvPath = path.resolve(path.join(GEMINI_DIR, '.env'));
|
|
||||||
|
|
||||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
|
||||||
isTrusted: isWorkspaceTrustedValue,
|
|
||||||
source: 'file',
|
|
||||||
});
|
|
||||||
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
|
|
||||||
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
|
|
||||||
);
|
|
||||||
const userSettingsContent: Settings = {
|
|
||||||
ui: {
|
|
||||||
theme: 'dark',
|
|
||||||
},
|
|
||||||
security: {
|
|
||||||
folderTrust: {
|
|
||||||
enabled: isFolderTrustEnabled,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
context: {
|
|
||||||
fileName: 'USER_CONTEXT.md',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
(fs.readFileSync as Mock).mockImplementation(
|
|
||||||
(p: fs.PathOrFileDescriptor) => {
|
|
||||||
if (p === USER_SETTINGS_PATH)
|
|
||||||
return JSON.stringify(userSettingsContent);
|
|
||||||
if (p === geminiEnvPath) return 'TESTTEST=1234';
|
|
||||||
return '{}';
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('sets environment variables from .env files', () => {
|
|
||||||
setup({ isFolderTrustEnabled: false, isWorkspaceTrustedValue: true });
|
|
||||||
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
|
|
||||||
|
|
||||||
expect(process.env['TESTTEST']).toEqual('1234');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not load env files from untrusted spaces', () => {
|
|
||||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
|
||||||
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
|
|
||||||
|
|
||||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('needsMigration', () => {
|
|
||||||
it('should return false for an empty object', () => {
|
|
||||||
expect(needsMigration({})).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for settings that are already in V2 format', () => {
|
|
||||||
const v2Settings: Partial<Settings> = {
|
|
||||||
ui: {
|
|
||||||
theme: 'dark',
|
|
||||||
},
|
|
||||||
tools: {
|
|
||||||
sandbox: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
expect(needsMigration(v2Settings)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for settings with a V1 key that needs to be moved', () => {
|
|
||||||
const v1Settings = {
|
|
||||||
theme: 'dark', // v1 key
|
|
||||||
};
|
|
||||||
expect(needsMigration(v1Settings)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for settings with a mix of V1 and V2 keys', () => {
|
|
||||||
const mixedSettings = {
|
|
||||||
theme: 'dark', // v1 key
|
|
||||||
tools: {
|
|
||||||
sandbox: true, // v2 key
|
|
||||||
},
|
|
||||||
};
|
|
||||||
expect(needsMigration(mixedSettings)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for settings with only V1 keys that are the same in V2', () => {
|
|
||||||
const v1Settings = {
|
|
||||||
mcpServers: {},
|
|
||||||
telemetry: {},
|
|
||||||
extensions: [],
|
|
||||||
};
|
|
||||||
expect(needsMigration(v1Settings)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for settings with a mix of V1 keys that are the same in V2 and V1 keys that need moving', () => {
|
|
||||||
const v1Settings = {
|
|
||||||
mcpServers: {}, // same in v2
|
|
||||||
theme: 'dark', // needs moving
|
|
||||||
};
|
|
||||||
expect(needsMigration(v1Settings)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for settings with unrecognized keys', () => {
|
|
||||||
const settings = {
|
|
||||||
someUnrecognizedKey: 'value',
|
|
||||||
};
|
|
||||||
expect(needsMigration(settings)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for settings with v2 keys and unrecognized keys', () => {
|
|
||||||
const settings = {
|
|
||||||
ui: { theme: 'dark' },
|
|
||||||
someUnrecognizedKey: 'value',
|
|
||||||
};
|
|
||||||
expect(needsMigration(settings)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('migrateDeprecatedSettings', () => {
|
|
||||||
let mockFsExistsSync: Mock;
|
|
||||||
let mockFsReadFileSync: Mock;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
mockFsExistsSync = vi.mocked(fs.existsSync);
|
|
||||||
mockFsExistsSync.mockReturnValue(true);
|
|
||||||
mockFsReadFileSync = vi.mocked(fs.readFileSync);
|
|
||||||
mockFsReadFileSync.mockReturnValue('{}');
|
|
||||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
|
||||||
isTrusted: true,
|
|
||||||
source: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should migrate disabled extensions from user and workspace settings', () => {
|
|
||||||
const userSettingsContent = {
|
|
||||||
extensions: {
|
|
||||||
disabled: ['user-ext-1', 'shared-ext'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const workspaceSettingsContent = {
|
|
||||||
extensions: {
|
|
||||||
disabled: ['workspace-ext-1', 'shared-ext'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFsReadFileSync.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 loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
||||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
|
||||||
const extensionManager = new ExtensionManager({
|
|
||||||
settings: loadedSettings.merged,
|
|
||||||
workspaceDir: MOCK_WORKSPACE_DIR,
|
|
||||||
requestConsent: vi.fn(),
|
|
||||||
requestSetting: vi.fn(),
|
|
||||||
});
|
|
||||||
const mockDisableExtension = vi.spyOn(
|
|
||||||
extensionManager,
|
|
||||||
'disableExtension',
|
|
||||||
);
|
|
||||||
mockDisableExtension.mockImplementation(async () => {});
|
|
||||||
|
|
||||||
migrateDeprecatedSettings(loadedSettings, extensionManager);
|
|
||||||
|
|
||||||
// Check user settings migration
|
|
||||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
|
||||||
'user-ext-1',
|
|
||||||
SettingScope.User,
|
|
||||||
);
|
|
||||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
|
||||||
'shared-ext',
|
|
||||||
SettingScope.User,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check workspace settings migration
|
|
||||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
|
||||||
'workspace-ext-1',
|
|
||||||
SettingScope.Workspace,
|
|
||||||
);
|
|
||||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
|
||||||
'shared-ext',
|
|
||||||
SettingScope.Workspace,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check that setValue was called to remove the deprecated setting
|
|
||||||
expect(setValueSpy).toHaveBeenCalledWith(
|
|
||||||
SettingScope.User,
|
|
||||||
'extensions',
|
|
||||||
{
|
|
||||||
disabled: undefined,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(setValueSpy).toHaveBeenCalledWith(
|
|
||||||
SettingScope.Workspace,
|
|
||||||
'extensions',
|
|
||||||
{
|
|
||||||
disabled: undefined,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not do anything if there are no deprecated settings', () => {
|
|
||||||
const userSettingsContent = {
|
|
||||||
extensions: {
|
|
||||||
enabled: ['user-ext-1'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const workspaceSettingsContent = {
|
|
||||||
someOtherSetting: 'value',
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFsReadFileSync.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 loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
||||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
|
||||||
const extensionManager = new ExtensionManager({
|
|
||||||
settings: loadedSettings.merged,
|
|
||||||
workspaceDir: MOCK_WORKSPACE_DIR,
|
|
||||||
requestConsent: vi.fn(),
|
|
||||||
requestSetting: vi.fn(),
|
|
||||||
});
|
|
||||||
const mockDisableExtension = vi.spyOn(
|
|
||||||
extensionManager,
|
|
||||||
'disableExtension',
|
|
||||||
);
|
|
||||||
mockDisableExtension.mockImplementation(async () => {});
|
|
||||||
|
|
||||||
migrateDeprecatedSettings(loadedSettings, extensionManager);
|
|
||||||
|
|
||||||
expect(mockDisableExtension).not.toHaveBeenCalled();
|
|
||||||
expect(setValueSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('saveSettings', () => {
|
describe('saveSettings', () => {
|
||||||
it('should save settings using updateSettingsFilePreservingFormat', () => {
|
it('should save settings using updateSettingsFilePreservingFormat', () => {
|
||||||
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
|
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { platform } from 'node:os';
|
|||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import {
|
import {
|
||||||
debugLogger,
|
|
||||||
FatalConfigError,
|
FatalConfigError,
|
||||||
GEMINI_DIR,
|
GEMINI_DIR,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
@@ -32,14 +31,12 @@ import {
|
|||||||
getSettingsSchema,
|
getSettingsSchema,
|
||||||
} from './settingsSchema.js';
|
} from './settingsSchema.js';
|
||||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||||
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
|
import { customDeepMerge } from '../utils/deepMerge.js';
|
||||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||||
import type { ExtensionManager } from './extension-manager.js';
|
|
||||||
import {
|
import {
|
||||||
validateSettings,
|
validateSettings,
|
||||||
formatValidationError,
|
formatValidationError,
|
||||||
} from './settings-validation.js';
|
} from './settings-validation.js';
|
||||||
import { SettingPaths } from './settingPaths.js';
|
|
||||||
|
|
||||||
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||||
let current: SettingDefinition | undefined = undefined;
|
let current: SettingDefinition | undefined = undefined;
|
||||||
@@ -68,79 +65,6 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
|
|||||||
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
|
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
|
||||||
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
||||||
|
|
||||||
const MIGRATE_V2_OVERWRITE = true;
|
|
||||||
|
|
||||||
const MIGRATION_MAP: Record<string, string> = {
|
|
||||||
accessibility: 'ui.accessibility',
|
|
||||||
allowedTools: 'tools.allowed',
|
|
||||||
allowMCPServers: 'mcp.allowed',
|
|
||||||
autoAccept: 'tools.autoAccept',
|
|
||||||
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
|
|
||||||
bugCommand: 'advanced.bugCommand',
|
|
||||||
chatCompression: 'model.compressionThreshold',
|
|
||||||
checkpointing: 'general.checkpointing',
|
|
||||||
coreTools: 'tools.core',
|
|
||||||
contextFileName: 'context.fileName',
|
|
||||||
customThemes: 'ui.customThemes',
|
|
||||||
customWittyPhrases: 'ui.customWittyPhrases',
|
|
||||||
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
|
|
||||||
disableAutoUpdate: 'general.disableAutoUpdate',
|
|
||||||
disableUpdateNag: 'general.disableUpdateNag',
|
|
||||||
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
|
|
||||||
enableHooks: 'tools.enableHooks',
|
|
||||||
enablePromptCompletion: 'general.enablePromptCompletion',
|
|
||||||
enforcedAuthType: 'security.auth.enforcedType',
|
|
||||||
excludeTools: 'tools.exclude',
|
|
||||||
excludeMCPServers: 'mcp.excluded',
|
|
||||||
excludedProjectEnvVars: 'advanced.excludedEnvVars',
|
|
||||||
experimentalSkills: 'experimental.skills',
|
|
||||||
extensionManagement: 'experimental.extensionManagement',
|
|
||||||
extensions: 'extensions',
|
|
||||||
fileFiltering: 'context.fileFiltering',
|
|
||||||
folderTrustFeature: 'security.folderTrust.featureEnabled',
|
|
||||||
folderTrust: 'security.folderTrust.enabled',
|
|
||||||
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
|
|
||||||
hideWindowTitle: 'ui.hideWindowTitle',
|
|
||||||
showStatusInTitle: 'ui.showStatusInTitle',
|
|
||||||
hideTips: 'ui.hideTips',
|
|
||||||
hideBanner: 'ui.hideBanner',
|
|
||||||
hideFooter: 'ui.hideFooter',
|
|
||||||
hideCWD: 'ui.footer.hideCWD',
|
|
||||||
hideSandboxStatus: 'ui.footer.hideSandboxStatus',
|
|
||||||
hideModelInfo: 'ui.footer.hideModelInfo',
|
|
||||||
hideContextSummary: 'ui.hideContextSummary',
|
|
||||||
showMemoryUsage: 'ui.showMemoryUsage',
|
|
||||||
showLineNumbers: 'ui.showLineNumbers',
|
|
||||||
showCitations: 'ui.showCitations',
|
|
||||||
ideMode: 'ide.enabled',
|
|
||||||
includeDirectories: 'context.includeDirectories',
|
|
||||||
loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories',
|
|
||||||
maxSessionTurns: 'model.maxSessionTurns',
|
|
||||||
mcpServers: 'mcpServers',
|
|
||||||
mcpServerCommand: 'mcp.serverCommand',
|
|
||||||
memoryImportFormat: 'context.importFormat',
|
|
||||||
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
|
|
||||||
model: 'model.name',
|
|
||||||
preferredEditor: SettingPaths.General.PreferredEditor,
|
|
||||||
retryFetchErrors: 'general.retryFetchErrors',
|
|
||||||
sandbox: 'tools.sandbox',
|
|
||||||
selectedAuthType: 'security.auth.selectedType',
|
|
||||||
enableInteractiveShell: 'tools.shell.enableInteractiveShell',
|
|
||||||
shellPager: 'tools.shell.pager',
|
|
||||||
shellShowColor: 'tools.shell.showColor',
|
|
||||||
shellInactivityTimeout: 'tools.shell.inactivityTimeout',
|
|
||||||
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
|
|
||||||
summarizeToolOutput: 'model.summarizeToolOutput',
|
|
||||||
telemetry: 'telemetry',
|
|
||||||
theme: 'ui.theme',
|
|
||||||
toolDiscoveryCommand: 'tools.discoveryCommand',
|
|
||||||
toolCallCommand: 'tools.callCommand',
|
|
||||||
usageStatisticsEnabled: 'privacy.usageStatisticsEnabled',
|
|
||||||
useExternalAuth: 'security.auth.useExternal',
|
|
||||||
useRipgrep: 'tools.useRipgrep',
|
|
||||||
vimMode: 'general.vimMode',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getSystemSettingsPath(): string {
|
export function getSystemSettingsPath(): string {
|
||||||
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
|
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
|
||||||
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
|
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
|
||||||
@@ -270,162 +194,6 @@ function setNestedProperty(
|
|||||||
current[lastKey] = value;
|
current[lastKey] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function needsMigration(settings: Record<string, unknown>): boolean {
|
|
||||||
// A file needs migration if it contains any top-level key that is moved to a
|
|
||||||
// nested location in V2.
|
|
||||||
const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => {
|
|
||||||
if (v1Key === v2Path || !(v1Key in settings)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// If a key exists that is a V1 key and a V2 container (like 'model'),
|
|
||||||
// we need to check the type. If it's an object, it's a V2 container and not
|
|
||||||
// a V1 key that needs migration.
|
|
||||||
if (
|
|
||||||
KNOWN_V2_CONTAINERS.has(v1Key) &&
|
|
||||||
typeof settings[v1Key] === 'object' &&
|
|
||||||
settings[v1Key] !== null
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return hasV1Keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateSettingsToV2(
|
|
||||||
flatSettings: Record<string, unknown>,
|
|
||||||
): Record<string, unknown> | null {
|
|
||||||
if (!needsMigration(flatSettings)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const v2Settings: Record<string, unknown> = {};
|
|
||||||
const flatKeys = new Set(Object.keys(flatSettings));
|
|
||||||
|
|
||||||
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
|
||||||
if (flatKeys.has(oldKey)) {
|
|
||||||
// If the key exists and is a V2 container (like 'model'), and the value is an object,
|
|
||||||
// it is likely already migrated or partially migrated. We should not move it
|
|
||||||
// to the mapped V2 path (e.g. 'model' -> 'model.name').
|
|
||||||
// Instead, let it fall through to the "Carry over" section to be merged.
|
|
||||||
if (
|
|
||||||
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
|
||||||
typeof flatSettings[oldKey] === 'object' &&
|
|
||||||
flatSettings[oldKey] !== null &&
|
|
||||||
!Array.isArray(flatSettings[oldKey])
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
|
|
||||||
flatKeys.delete(oldKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve mcpServers at the top level
|
|
||||||
if (flatSettings['mcpServers']) {
|
|
||||||
v2Settings['mcpServers'] = flatSettings['mcpServers'];
|
|
||||||
flatKeys.delete('mcpServers');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carry over any unrecognized keys
|
|
||||||
for (const remainingKey of flatKeys) {
|
|
||||||
const existingValue = v2Settings[remainingKey];
|
|
||||||
const newValue = flatSettings[remainingKey];
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof existingValue === 'object' &&
|
|
||||||
existingValue !== null &&
|
|
||||||
!Array.isArray(existingValue) &&
|
|
||||||
typeof newValue === 'object' &&
|
|
||||||
newValue !== null &&
|
|
||||||
!Array.isArray(newValue)
|
|
||||||
) {
|
|
||||||
const pathAwareGetStrategy = (path: string[]) =>
|
|
||||||
getMergeStrategyForPath([remainingKey, ...path]);
|
|
||||||
v2Settings[remainingKey] = customDeepMerge(
|
|
||||||
pathAwareGetStrategy,
|
|
||||||
{},
|
|
||||||
existingValue as MergeableObject,
|
|
||||||
newValue as MergeableObject,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
v2Settings[remainingKey] = newValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return v2Settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNestedProperty(
|
|
||||||
obj: Record<string, unknown>,
|
|
||||||
path: string,
|
|
||||||
): unknown {
|
|
||||||
const keys = path.split('.');
|
|
||||||
let current: unknown = obj;
|
|
||||||
for (const key of keys) {
|
|
||||||
if (typeof current !== 'object' || current === null || !(key in current)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
current = (current as Record<string, unknown>)[key];
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
const REVERSE_MIGRATION_MAP: Record<string, string> = Object.fromEntries(
|
|
||||||
Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Dynamically determine the top-level keys from the V2 settings structure.
|
|
||||||
const KNOWN_V2_CONTAINERS = new Set(
|
|
||||||
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
|
|
||||||
);
|
|
||||||
|
|
||||||
export function migrateSettingsToV1(
|
|
||||||
v2Settings: Record<string, unknown>,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const v1Settings: Record<string, unknown> = {};
|
|
||||||
const v2Keys = new Set(Object.keys(v2Settings));
|
|
||||||
|
|
||||||
for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) {
|
|
||||||
const value = getNestedProperty(v2Settings, newPath);
|
|
||||||
if (value !== undefined) {
|
|
||||||
v1Settings[oldKey] = value;
|
|
||||||
v2Keys.delete(newPath.split('.')[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve mcpServers at the top level
|
|
||||||
if (v2Settings['mcpServers']) {
|
|
||||||
v1Settings['mcpServers'] = v2Settings['mcpServers'];
|
|
||||||
v2Keys.delete('mcpServers');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carry over any unrecognized keys
|
|
||||||
for (const remainingKey of v2Keys) {
|
|
||||||
const value = v2Settings[remainingKey];
|
|
||||||
if (value === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't carry over empty objects that were just containers for migrated settings.
|
|
||||||
if (
|
|
||||||
KNOWN_V2_CONTAINERS.has(remainingKey) &&
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
Object.keys(value).length === 0
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
v1Settings[remainingKey] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return v1Settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultsFromSchema(
|
export function getDefaultsFromSchema(
|
||||||
schema: SettingsSchema = getSettingsSchema(),
|
schema: SettingsSchema = getSettingsSchema(),
|
||||||
): Settings {
|
): Settings {
|
||||||
@@ -478,7 +246,6 @@ export class LoadedSettings {
|
|||||||
user: SettingsFile,
|
user: SettingsFile,
|
||||||
workspace: SettingsFile,
|
workspace: SettingsFile,
|
||||||
isTrusted: boolean,
|
isTrusted: boolean,
|
||||||
migratedInMemoryScopes: Set<SettingScope>,
|
|
||||||
errors: SettingsError[] = [],
|
errors: SettingsError[] = [],
|
||||||
) {
|
) {
|
||||||
this.system = system;
|
this.system = system;
|
||||||
@@ -486,7 +253,6 @@ export class LoadedSettings {
|
|||||||
this.user = user;
|
this.user = user;
|
||||||
this.workspace = workspace;
|
this.workspace = workspace;
|
||||||
this.isTrusted = isTrusted;
|
this.isTrusted = isTrusted;
|
||||||
this.migratedInMemoryScopes = migratedInMemoryScopes;
|
|
||||||
this.errors = errors;
|
this.errors = errors;
|
||||||
this._merged = this.computeMergedSettings();
|
this._merged = this.computeMergedSettings();
|
||||||
}
|
}
|
||||||
@@ -496,7 +262,6 @@ export class LoadedSettings {
|
|||||||
readonly user: SettingsFile;
|
readonly user: SettingsFile;
|
||||||
readonly workspace: SettingsFile;
|
readonly workspace: SettingsFile;
|
||||||
readonly isTrusted: boolean;
|
readonly isTrusted: boolean;
|
||||||
readonly migratedInMemoryScopes: Set<SettingScope>;
|
|
||||||
readonly errors: SettingsError[];
|
readonly errors: SettingsError[];
|
||||||
|
|
||||||
private _merged: Settings;
|
private _merged: Settings;
|
||||||
@@ -690,7 +455,6 @@ export function loadSettings(
|
|||||||
const settingsErrors: SettingsError[] = [];
|
const settingsErrors: SettingsError[] = [];
|
||||||
const systemSettingsPath = getSystemSettingsPath();
|
const systemSettingsPath = getSystemSettingsPath();
|
||||||
const systemDefaultsPath = getSystemDefaultsPath();
|
const systemDefaultsPath = getSystemDefaultsPath();
|
||||||
const migratedInMemoryScopes = new Set<SettingScope>();
|
|
||||||
|
|
||||||
// Resolve paths to their canonical representation to handle symlinks
|
// Resolve paths to their canonical representation to handle symlinks
|
||||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||||
@@ -711,10 +475,7 @@ export function loadSettings(
|
|||||||
workspaceDir,
|
workspaceDir,
|
||||||
).getWorkspaceSettingsPath();
|
).getWorkspaceSettingsPath();
|
||||||
|
|
||||||
const loadAndMigrate = (
|
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
|
||||||
filePath: string,
|
|
||||||
scope: SettingScope,
|
|
||||||
): { settings: Settings; rawJson?: string } => {
|
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
@@ -733,33 +494,9 @@ export function loadSettings(
|
|||||||
return { settings: {} };
|
return { settings: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
let settingsObject = rawSettings as Record<string, unknown>;
|
const settingsObject = rawSettings as Record<string, unknown>;
|
||||||
if (needsMigration(settingsObject)) {
|
|
||||||
const migratedSettings = migrateSettingsToV2(settingsObject);
|
|
||||||
if (migratedSettings) {
|
|
||||||
if (MIGRATE_V2_OVERWRITE) {
|
|
||||||
try {
|
|
||||||
fs.renameSync(filePath, `${filePath}.orig`);
|
|
||||||
fs.writeFileSync(
|
|
||||||
filePath,
|
|
||||||
JSON.stringify(migratedSettings, null, 2),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
coreEvents.emitFeedback(
|
|
||||||
'error',
|
|
||||||
'Failed to migrate settings file.',
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
migratedInMemoryScopes.add(scope);
|
|
||||||
}
|
|
||||||
settingsObject = migratedSettings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate settings structure with Zod after migration
|
// Validate settings structure with Zod
|
||||||
const validationResult = validateSettings(settingsObject);
|
const validationResult = validateSettings(settingsObject);
|
||||||
if (!validationResult.success && validationResult.error) {
|
if (!validationResult.success && validationResult.error) {
|
||||||
const errorMessage = formatValidationError(
|
const errorMessage = formatValidationError(
|
||||||
@@ -785,22 +522,16 @@ export function loadSettings(
|
|||||||
return { settings: {} };
|
return { settings: {} };
|
||||||
};
|
};
|
||||||
|
|
||||||
const systemResult = loadAndMigrate(systemSettingsPath, SettingScope.System);
|
const systemResult = load(systemSettingsPath);
|
||||||
const systemDefaultsResult = loadAndMigrate(
|
const systemDefaultsResult = load(systemDefaultsPath);
|
||||||
systemDefaultsPath,
|
const userResult = load(USER_SETTINGS_PATH);
|
||||||
SettingScope.SystemDefaults,
|
|
||||||
);
|
|
||||||
const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User);
|
|
||||||
|
|
||||||
let workspaceResult: { settings: Settings; rawJson?: string } = {
|
let workspaceResult: { settings: Settings; rawJson?: string } = {
|
||||||
settings: {} as Settings,
|
settings: {} as Settings,
|
||||||
rawJson: undefined,
|
rawJson: undefined,
|
||||||
};
|
};
|
||||||
if (realWorkspaceDir !== realHomeDir) {
|
if (realWorkspaceDir !== realHomeDir) {
|
||||||
workspaceResult = loadAndMigrate(
|
workspaceResult = load(workspaceSettingsPath);
|
||||||
workspaceSettingsPath,
|
|
||||||
SettingScope.Workspace,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemOriginalSettings = structuredClone(systemResult.settings);
|
const systemOriginalSettings = structuredClone(systemResult.settings);
|
||||||
@@ -888,37 +619,10 @@ export function loadSettings(
|
|||||||
rawJson: workspaceResult.rawJson,
|
rawJson: workspaceResult.rawJson,
|
||||||
},
|
},
|
||||||
isTrusted,
|
isTrusted,
|
||||||
migratedInMemoryScopes,
|
|
||||||
settingsErrors,
|
settingsErrors,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrateDeprecatedSettings(
|
|
||||||
loadedSettings: LoadedSettings,
|
|
||||||
extensionManager: ExtensionManager,
|
|
||||||
): void {
|
|
||||||
const processScope = (scope: LoadableSettingScope) => {
|
|
||||||
const settings = loadedSettings.forScope(scope).settings;
|
|
||||||
if (settings.extensions?.disabled) {
|
|
||||||
debugLogger.log(
|
|
||||||
`Migrating deprecated extensions.disabled settings from ${scope} settings...`,
|
|
||||||
);
|
|
||||||
for (const extension of settings.extensions.disabled ?? []) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
extensionManager.disableExtension(extension, scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newExtensionsValue = { ...settings.extensions };
|
|
||||||
newExtensionsValue.disabled = undefined;
|
|
||||||
|
|
||||||
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
processScope(SettingScope.User);
|
|
||||||
processScope(SettingScope.Workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveSettings(settingsFile: SettingsFile): void {
|
export function saveSettings(settingsFile: SettingsFile): void {
|
||||||
try {
|
try {
|
||||||
// Ensure the directory exists
|
// Ensure the directory exists
|
||||||
@@ -927,12 +631,7 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
|||||||
fs.mkdirSync(dirPath, { recursive: true });
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
let settingsToSave = settingsFile.originalSettings;
|
const settingsToSave = settingsFile.originalSettings;
|
||||||
if (!MIGRATE_V2_OVERWRITE) {
|
|
||||||
settingsToSave = migrateSettingsToV1(
|
|
||||||
settingsToSave as Record<string, unknown>,
|
|
||||||
) as Settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the format-preserving update function
|
// Use the format-preserving update function
|
||||||
updateSettingsFilePreservingFormat(
|
updateSettingsFilePreservingFormat(
|
||||||
|
|||||||
@@ -20,11 +20,7 @@ import {
|
|||||||
loadTrustedFolders,
|
loadTrustedFolders,
|
||||||
type TrustedFoldersError,
|
type TrustedFoldersError,
|
||||||
} from './config/trustedFolders.js';
|
} from './config/trustedFolders.js';
|
||||||
import {
|
import { loadSettings, SettingScope } from './config/settings.js';
|
||||||
loadSettings,
|
|
||||||
migrateDeprecatedSettings,
|
|
||||||
SettingScope,
|
|
||||||
} from './config/settings.js';
|
|
||||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||||
@@ -93,9 +89,7 @@ import {
|
|||||||
} from './utils/relaunch.js';
|
} from './utils/relaunch.js';
|
||||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||||
import { deleteSession, listSessions } from './utils/sessions.js';
|
import { deleteSession, listSessions } from './utils/sessions.js';
|
||||||
import { ExtensionManager } from './config/extension-manager.js';
|
|
||||||
import { createPolicyUpdater } from './config/policy.js';
|
import { createPolicyUpdater } from './config/policy.js';
|
||||||
import { requestConsentNonInteractive } from './config/extensions/consent.js';
|
|
||||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||||
|
|
||||||
@@ -315,19 +309,6 @@ export async function main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const migrateHandle = startupProfiler.start('migrate_settings');
|
|
||||||
migrateDeprecatedSettings(
|
|
||||||
settings,
|
|
||||||
// Temporary extension manager only used during this non-interactive UI phase.
|
|
||||||
new ExtensionManager({
|
|
||||||
workspaceDir: process.cwd(),
|
|
||||||
settings: settings.merged,
|
|
||||||
enabledExtensionOverrides: [],
|
|
||||||
requestConsent: requestConsentNonInteractive,
|
|
||||||
requestSetting: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
migrateHandle?.end();
|
|
||||||
await cleanupCheckpoints();
|
await cleanupCheckpoints();
|
||||||
|
|
||||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const mockSettings = new LoadedSettings(
|
|||||||
{ path: '', settings: {}, originalSettings: {} },
|
{ path: '', settings: {}, originalSettings: {} },
|
||||||
{ path: '', settings: {}, originalSettings: {} },
|
{ path: '', settings: {}, originalSettings: {} },
|
||||||
true,
|
true,
|
||||||
new Set(),
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const createMockSettings = (
|
export const createMockSettings = (
|
||||||
@@ -122,7 +122,7 @@ export const createMockSettings = (
|
|||||||
{ path: '', settings, originalSettings: settings },
|
{ path: '', settings, originalSettings: settings },
|
||||||
{ path: '', settings: {}, originalSettings: {} },
|
{ path: '', settings: {}, originalSettings: {} },
|
||||||
true,
|
true,
|
||||||
new Set(),
|
[],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ import { StreamingState } from './types.js';
|
|||||||
import { ConfigContext } from './contexts/ConfigContext.js';
|
import { ConfigContext } from './contexts/ConfigContext.js';
|
||||||
import { AppContext, type AppState } from './contexts/AppContext.js';
|
import { AppContext, type AppState } from './contexts/AppContext.js';
|
||||||
import { SettingsContext } from './contexts/SettingsContext.js';
|
import { SettingsContext } from './contexts/SettingsContext.js';
|
||||||
import {
|
import { LoadedSettings, type SettingsFile } from '../config/settings.js';
|
||||||
type SettingScope,
|
|
||||||
LoadedSettings,
|
|
||||||
type SettingsFile,
|
|
||||||
} from '../config/settings.js';
|
|
||||||
|
|
||||||
vi.mock('ink', async (importOriginal) => {
|
vi.mock('ink', async (importOriginal) => {
|
||||||
const original = await importOriginal<typeof import('ink')>();
|
const original = await importOriginal<typeof import('ink')>();
|
||||||
@@ -92,7 +88,7 @@ describe('App', () => {
|
|||||||
mockSettingsFile,
|
mockSettingsFile,
|
||||||
mockSettingsFile,
|
mockSettingsFile,
|
||||||
true,
|
true,
|
||||||
new Set<SettingScope>(),
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const mockAppState: AppState = {
|
const mockAppState: AppState = {
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ const createMockSettings = (
|
|||||||
path: '/workspace/settings.json',
|
path: '/workspace/settings.json',
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
new Set(),
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
|
vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const createMockSettings = (
|
|||||||
path: '/workspace/settings.json',
|
path: '/workspace/settings.json',
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
new Set(),
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('ThemeDialog Snapshots', () => {
|
describe('ThemeDialog Snapshots', () => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe('colorizeCode', () => {
|
|||||||
},
|
},
|
||||||
{ path: '', settings: {}, originalSettings: {} },
|
{ path: '', settings: {}, originalSettings: {} },
|
||||||
true,
|
true,
|
||||||
new Set(),
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = colorizeCode({
|
const result = colorizeCode({
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ Another paragraph.
|
|||||||
},
|
},
|
||||||
{ path: '', settings: {}, originalSettings: {} },
|
{ path: '', settings: {}, originalSettings: {} },
|
||||||
true,
|
true,
|
||||||
new Set(),
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { lastFrame } = renderWithProviders(
|
const { lastFrame } = renderWithProviders(
|
||||||
|
|||||||
Reference in New Issue
Block a user