mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 04:00:36 +00:00
Merge branch 'main' into feat/shell-pty-default-and-enhancements
This commit is contained in:
commit
ca3a2be2ec
130 changed files with 16089 additions and 2249 deletions
|
|
@ -548,6 +548,43 @@ describe('loadCliConfig', () => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should reset context file names to QWEN.md and AGENTS.md by default', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const setGeminiMdFilenameSpy = vi.spyOn(
|
||||
ServerConfig,
|
||||
'setGeminiMdFilename',
|
||||
);
|
||||
|
||||
await loadCliConfig(settings, argv);
|
||||
|
||||
expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith([
|
||||
ServerConfig.DEFAULT_CONTEXT_FILENAME,
|
||||
ServerConfig.AGENT_CONTEXT_FILENAME,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use configured context file name when settings.context.fileName is set', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
context: {
|
||||
fileName: 'CUSTOM_AGENTS.md',
|
||||
},
|
||||
};
|
||||
const setGeminiMdFilenameSpy = vi.spyOn(
|
||||
ServerConfig,
|
||||
'setGeminiMdFilename',
|
||||
);
|
||||
|
||||
await loadCliConfig(settings, argv);
|
||||
|
||||
expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith('CUSTOM_AGENTS.md');
|
||||
});
|
||||
|
||||
it('should propagate stream-json formats to config', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
|
|
@ -567,6 +604,35 @@ describe('loadCliConfig', () => {
|
|||
expect(config.getIncludePartialMessages()).toBe(true);
|
||||
});
|
||||
|
||||
it('should reset context filenames to defaults when context.fileName is not configured', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const defaultContextFiles = ['QWEN.md', 'AGENTS.md'];
|
||||
const getAllSpy = vi
|
||||
.spyOn(ServerConfig, 'getAllGeminiMdFilenames')
|
||||
.mockReturnValue(defaultContextFiles);
|
||||
const setFilenameSpy = vi.spyOn(ServerConfig, 'setGeminiMdFilename');
|
||||
|
||||
await loadCliConfig(settings, argv);
|
||||
|
||||
expect(getAllSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setFilenameSpy).toHaveBeenCalledWith(defaultContextFiles);
|
||||
});
|
||||
|
||||
it('should use context.fileName from settings when provided', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { context: { fileName: 'CUSTOM_CONTEXT.md' } };
|
||||
const getAllSpy = vi.spyOn(ServerConfig, 'getAllGeminiMdFilenames');
|
||||
const setFilenameSpy = vi.spyOn(ServerConfig, 'setGeminiMdFilename');
|
||||
|
||||
await loadCliConfig(settings, argv);
|
||||
|
||||
expect(setFilenameSpy).toHaveBeenCalledWith('CUSTOM_CONTEXT.md');
|
||||
expect(getAllSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initialize native LSP service when enabled', async () => {
|
||||
process.argv = ['node', 'script.js', '--experimental-lsp'];
|
||||
const argv = await parseArguments();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
FileDiscoveryService,
|
||||
FileEncoding,
|
||||
getCurrentGeminiMdFilename,
|
||||
getAllGeminiMdFilenames,
|
||||
loadServerHierarchicalMemory,
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
resolveTelemetrySettings,
|
||||
|
|
@ -33,6 +33,7 @@ import {
|
|||
NativeLspService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { hooksCommand } from '../commands/hooks.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import {
|
||||
resolveCliGenerationConfig,
|
||||
|
|
@ -124,6 +125,7 @@ export interface CliArgs {
|
|||
acp: boolean | undefined;
|
||||
experimentalAcp: boolean | undefined;
|
||||
experimentalLsp: boolean | undefined;
|
||||
experimentalHooks: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
openaiLogging: boolean | undefined;
|
||||
|
|
@ -337,6 +339,12 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
'Enable experimental LSP (Language Server Protocol) feature for code intelligence',
|
||||
default: false,
|
||||
})
|
||||
.option('experimental-hooks', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable experimental hooks feature for lifecycle event customization',
|
||||
default: false,
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
|
||||
|
|
@ -561,7 +569,9 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
// Register MCP subcommands
|
||||
.command(mcpCommand)
|
||||
// Register Extension subcommands
|
||||
.command(extensionsCommand);
|
||||
.command(extensionsCommand)
|
||||
// Register Hooks subcommands
|
||||
.command(hooksCommand);
|
||||
|
||||
yargsInstance
|
||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||
|
|
@ -580,9 +590,11 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
// and not return to main CLI logic
|
||||
if (
|
||||
result._.length > 0 &&
|
||||
(result._[0] === 'mcp' || result._[0] === 'extensions')
|
||||
(result._[0] === 'mcp' ||
|
||||
result._[0] === 'extensions' ||
|
||||
result._[0] === 'hooks')
|
||||
) {
|
||||
// MCP commands handle their own execution and process exit
|
||||
// MCP/Extensions/Hooks commands handle their own execution and process exit
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
@ -688,8 +700,8 @@ export async function loadCliConfig(
|
|||
if (settings.context?.fileName) {
|
||||
setServerGeminiMdFilename(settings.context.fileName);
|
||||
} else {
|
||||
// Reset to default if not provided in settings.
|
||||
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
|
||||
// Reset to default context filenames if not provided in settings.
|
||||
setServerGeminiMdFilename(getAllGeminiMdFilenames());
|
||||
}
|
||||
|
||||
// Automatically load output-language.md if it exists
|
||||
|
|
@ -1011,7 +1023,7 @@ export async function loadCliConfig(
|
|||
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
|
||||
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
skipLoopDetection: settings.model?.skipLoopDetection ?? false,
|
||||
skipLoopDetection: settings.model?.skipLoopDetection ?? true,
|
||||
skipStartupContext: settings.model?.skipStartupContext ?? false,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
|
|
@ -1021,6 +1033,10 @@ export async function loadCliConfig(
|
|||
output: {
|
||||
format: outputSettingsFormat,
|
||||
},
|
||||
hooks: settings.hooks,
|
||||
hooksConfig: settings.hooksConfig,
|
||||
enableHooks:
|
||||
argv.experimentalHooks === true || settings.hooksConfig?.enabled === true,
|
||||
channel: argv.channel,
|
||||
// Precedence: explicit CLI flag > settings file > default(true).
|
||||
// NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will
|
||||
|
|
|
|||
383
packages/cli/src/config/migration/index.test.ts
Normal file
383
packages/cli/src/config/migration/index.test.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
runMigrations,
|
||||
needsMigration,
|
||||
ALL_MIGRATIONS,
|
||||
MigrationScheduler,
|
||||
} from './index.js';
|
||||
import { SETTINGS_VERSION } from '../settings.js';
|
||||
|
||||
describe('Migration Framework Integration', () => {
|
||||
describe('runMigrations', () => {
|
||||
it('should migrate V1 settings to V3', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
disableAutoUpdate: true,
|
||||
disableLoadingPhrases: false,
|
||||
};
|
||||
|
||||
const result = runMigrations(v1Settings, 'user');
|
||||
|
||||
expect(result.finalVersion).toBe(3);
|
||||
expect(result.executedMigrations).toHaveLength(2);
|
||||
expect(result.executedMigrations[0]).toEqual({
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
});
|
||||
expect(result.executedMigrations[1]).toEqual({
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
});
|
||||
|
||||
// Check V2 structure was created
|
||||
const settings = result.settings as Record<string, unknown>;
|
||||
expect(settings['$version']).toBe(3);
|
||||
expect(settings['ui']).toEqual({
|
||||
theme: 'dark',
|
||||
accessibility: { enableLoadingPhrases: true },
|
||||
});
|
||||
expect(settings['model']).toEqual({ name: 'gemini' });
|
||||
|
||||
// Check disableAutoUpdate was inverted to enableAutoUpdate: false
|
||||
expect(
|
||||
(settings['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should migrate V2 settings to V3', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'light' },
|
||||
general: { disableAutoUpdate: false },
|
||||
};
|
||||
|
||||
const result = runMigrations(v2Settings, 'user');
|
||||
|
||||
expect(result.finalVersion).toBe(3);
|
||||
expect(result.executedMigrations).toHaveLength(1);
|
||||
expect(result.executedMigrations[0]).toEqual({
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
});
|
||||
|
||||
const settings = result.settings as Record<string, unknown>;
|
||||
expect(settings['$version']).toBe(3);
|
||||
expect(
|
||||
(settings['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(settings['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not modify V3 settings', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
ui: { theme: 'dark' },
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
const result = runMigrations(v3Settings, 'user');
|
||||
|
||||
expect(result.finalVersion).toBe(3);
|
||||
expect(result.executedMigrations).toHaveLength(0);
|
||||
expect(result.settings).toEqual(v3Settings);
|
||||
});
|
||||
|
||||
it('should be idempotent', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
};
|
||||
|
||||
const result1 = runMigrations(v1Settings, 'user');
|
||||
const result2 = runMigrations(result1.settings, 'user');
|
||||
|
||||
expect(result1.executedMigrations).toHaveLength(2);
|
||||
expect(result2.executedMigrations).toHaveLength(0);
|
||||
expect(result1.finalVersion).toBe(result2.finalVersion);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsMigration', () => {
|
||||
it('should return true for V1 settings', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
};
|
||||
|
||||
expect(needsMigration(v1Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for V2 settings with deprecated keys', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(needsMigration(v2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for V2 settings without deprecated keys', () => {
|
||||
const cleanV2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'dark' },
|
||||
};
|
||||
|
||||
// V2 settings should be migrated to V3 to update the version number
|
||||
expect(needsMigration(cleanV2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for V3 settings', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(needsMigration(v3Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for legacy numeric version when no migration can execute', () => {
|
||||
const legacyButUnknownSettings = {
|
||||
$version: 1,
|
||||
customOnlyKey: 'value',
|
||||
};
|
||||
|
||||
expect(needsMigration(legacyButUnknownSettings)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ALL_MIGRATIONS', () => {
|
||||
it('should contain all migrations in order', () => {
|
||||
expect(ALL_MIGRATIONS).toHaveLength(2);
|
||||
|
||||
expect(ALL_MIGRATIONS[0].fromVersion).toBe(1);
|
||||
expect(ALL_MIGRATIONS[0].toVersion).toBe(2);
|
||||
|
||||
expect(ALL_MIGRATIONS[1].fromVersion).toBe(2);
|
||||
expect(ALL_MIGRATIONS[1].toVersion).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MigrationScheduler with all migrations', () => {
|
||||
it('should execute full migration chain', () => {
|
||||
const scheduler = new MigrationScheduler([...ALL_MIGRATIONS], 'user');
|
||||
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
disableLoadingPhrases: true,
|
||||
};
|
||||
|
||||
const result = scheduler.migrate(v1Settings);
|
||||
|
||||
expect(result.executedMigrations).toHaveLength(2);
|
||||
|
||||
const settings = result.settings as Record<string, unknown>;
|
||||
expect(settings['$version']).toBe(3);
|
||||
expect((settings['ui'] as Record<string, unknown>)['theme']).toBe('dark');
|
||||
expect(
|
||||
(settings['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
(settings['ui'] as Record<string, unknown>)[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)['enableLoadingPhrases'],
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsMigration and runMigrations consistency', () => {
|
||||
it('needsMigration should return true when runMigrations would execute migrations', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
};
|
||||
|
||||
// needsMigration should report that migration is needed
|
||||
expect(needsMigration(v1Settings)).toBe(true);
|
||||
|
||||
// runMigrations should actually execute migrations
|
||||
const result = runMigrations(v1Settings, 'user');
|
||||
expect(result.executedMigrations.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('needsMigration should return false when runMigrations would execute no migrations', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// needsMigration should report that no migration is needed
|
||||
expect(needsMigration(v3Settings)).toBe(false);
|
||||
|
||||
// runMigrations should execute no migrations
|
||||
const result = runMigrations(v3Settings, 'user');
|
||||
expect(result.executedMigrations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle V2 settings without deprecated keys consistently', () => {
|
||||
const cleanV2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'dark' },
|
||||
};
|
||||
|
||||
// needsMigration should report that migration is needed
|
||||
expect(needsMigration(cleanV2Settings)).toBe(true);
|
||||
|
||||
// runMigrations should execute the V2->V3 migration
|
||||
const result = runMigrations(cleanV2Settings, 'user');
|
||||
expect(result.executedMigrations.length).toBeGreaterThan(0);
|
||||
expect(result.finalVersion).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration chain integrity', () => {
|
||||
it('should have strictly increasing versions (toVersion > fromVersion)', () => {
|
||||
for (const migration of ALL_MIGRATIONS) {
|
||||
expect(migration.toVersion).toBeGreaterThan(migration.fromVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have no gaps in the chain (adjacent versions)', () => {
|
||||
for (let i = 1; i < ALL_MIGRATIONS.length; i++) {
|
||||
const prevMigration = ALL_MIGRATIONS[i - 1];
|
||||
const currMigration = ALL_MIGRATIONS[i];
|
||||
expect(currMigration.fromVersion).toBe(prevMigration.toVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have no duplicate fromVersions', () => {
|
||||
const fromVersions = ALL_MIGRATIONS.map((m) => m.fromVersion);
|
||||
const uniqueFromVersions = new Set(fromVersions);
|
||||
expect(uniqueFromVersions.size).toBe(fromVersions.length);
|
||||
});
|
||||
|
||||
it('should have no duplicate toVersions', () => {
|
||||
const toVersions = ALL_MIGRATIONS.map((m) => m.toVersion);
|
||||
const uniqueToVersions = new Set(toVersions);
|
||||
expect(uniqueToVersions.size).toBe(toVersions.length);
|
||||
});
|
||||
|
||||
it('should be acyclic (no version appears as fromVersion more than once)', () => {
|
||||
const fromVersionCounts = new Map<number, number>();
|
||||
for (const migration of ALL_MIGRATIONS) {
|
||||
const count = fromVersionCounts.get(migration.fromVersion) || 0;
|
||||
fromVersionCounts.set(migration.fromVersion, count + 1);
|
||||
}
|
||||
|
||||
for (const count of fromVersionCounts.values()) {
|
||||
expect(count).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should chain from version 1 to SETTINGS_VERSION', () => {
|
||||
if (ALL_MIGRATIONS.length > 0) {
|
||||
expect(ALL_MIGRATIONS[0].fromVersion).toBe(1);
|
||||
const lastMigration = ALL_MIGRATIONS[ALL_MIGRATIONS.length - 1];
|
||||
expect(lastMigration.toVersion).toBe(SETTINGS_VERSION);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('single source of truth for version constant', () => {
|
||||
it('should use SETTINGS_VERSION from settings module', () => {
|
||||
// The last migration's toVersion should match SETTINGS_VERSION
|
||||
const lastMigration = ALL_MIGRATIONS[ALL_MIGRATIONS.length - 1];
|
||||
expect(lastMigration.toVersion).toBe(SETTINGS_VERSION);
|
||||
});
|
||||
|
||||
it('needsMigration should use SETTINGS_VERSION for version comparison', () => {
|
||||
// Create settings with version equal to SETTINGS_VERSION
|
||||
const currentVersionSettings = {
|
||||
$version: SETTINGS_VERSION,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// needsMigration should return false for current version
|
||||
expect(needsMigration(currentVersionSettings)).toBe(false);
|
||||
|
||||
// Create settings with version less than SETTINGS_VERSION
|
||||
const oldVersionSettings = {
|
||||
$version: SETTINGS_VERSION - 1,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// needsMigration should return true for old version
|
||||
expect(needsMigration(oldVersionSettings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have SETTINGS_VERSION defined exactly once in codebase', () => {
|
||||
// SETTINGS_VERSION is imported from settings.js
|
||||
// This test verifies the wiring is correct
|
||||
expect(SETTINGS_VERSION).toBeDefined();
|
||||
expect(typeof SETTINGS_VERSION).toBe('number');
|
||||
expect(SETTINGS_VERSION).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid version handling', () => {
|
||||
it('should treat non-numeric version with V1 shape as needing migration', () => {
|
||||
const settingsWithInvalidVersion = {
|
||||
$version: 'invalid',
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
};
|
||||
|
||||
// Should detect migration needed based on V1 shape
|
||||
expect(needsMigration(settingsWithInvalidVersion)).toBe(true);
|
||||
|
||||
// Should run migrations
|
||||
const result = runMigrations(settingsWithInvalidVersion, 'user');
|
||||
expect(result.executedMigrations.length).toBeGreaterThan(0);
|
||||
expect(result.finalVersion).toBe(SETTINGS_VERSION);
|
||||
});
|
||||
|
||||
it('should not migrate non-numeric version with already-migrated shape (normalized by loader)', () => {
|
||||
const settingsWithInvalidVersionButMigratedShape = {
|
||||
$version: 'invalid',
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// needsMigration returns false because no migration applies to this shape
|
||||
// The settings loader will handle version normalization separately
|
||||
expect(needsMigration(settingsWithInvalidVersionButMigratedShape)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// No migrations should execute
|
||||
const result = runMigrations(
|
||||
settingsWithInvalidVersionButMigratedShape,
|
||||
'user',
|
||||
);
|
||||
expect(result.executedMigrations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should avoid repeated no-op migration loops', () => {
|
||||
// Settings that might cause repeated migrations
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// First check
|
||||
expect(needsMigration(v3Settings)).toBe(false);
|
||||
const result1 = runMigrations(v3Settings, 'user');
|
||||
expect(result1.executedMigrations).toHaveLength(0);
|
||||
|
||||
// Second check should be consistent
|
||||
expect(needsMigration(result1.settings)).toBe(false);
|
||||
const result2 = runMigrations(result1.settings, 'user');
|
||||
expect(result2.executedMigrations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/cli/src/config/migration/index.ts
Normal file
106
packages/cli/src/config/migration/index.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Export types
|
||||
export type { SettingsMigration, MigrationResult } from './types.js';
|
||||
|
||||
// Export scheduler
|
||||
export { MigrationScheduler } from './scheduler.js';
|
||||
|
||||
// Export migrations
|
||||
export { v1ToV2Migration, V1ToV2Migration } from './versions/v1-to-v2.js';
|
||||
export { v2ToV3Migration, V2ToV3Migration } from './versions/v2-to-v3.js';
|
||||
|
||||
// Import settings version from single source of truth
|
||||
import { SETTINGS_VERSION } from '../settings.js';
|
||||
|
||||
// Ordered array of all migrations for use with MigrationScheduler
|
||||
// Each migration handles one version transition (N → N+1)
|
||||
// Order matters: migrations must be sorted by ascending version
|
||||
import { v1ToV2Migration } from './versions/v1-to-v2.js';
|
||||
import { v2ToV3Migration } from './versions/v2-to-v3.js';
|
||||
import { MigrationScheduler } from './scheduler.js';
|
||||
import type { MigrationResult } from './types.js';
|
||||
|
||||
/**
|
||||
* Ordered array of all settings migrations.
|
||||
* Use this with MigrationScheduler to run the full migration chain.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const scheduler = new MigrationScheduler(ALL_MIGRATIONS);
|
||||
* const result = scheduler.migrate(settings);
|
||||
* ```
|
||||
*/
|
||||
export const ALL_MIGRATIONS = [v1ToV2Migration, v2ToV3Migration] as const;
|
||||
|
||||
/**
|
||||
* Convenience function that runs all migrations on the given settings.
|
||||
* This is the primary entry point for settings migration.
|
||||
*
|
||||
* @param settings - The settings object to migrate
|
||||
* @param scope - The scope of settings being migrated
|
||||
* @returns MigrationResult containing the final settings, version, and execution log
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = runMigrations(settings, 'User');
|
||||
* if (result.executedMigrations.length > 0) {
|
||||
* console.log(`Migrated from version ${result.executedMigrations[0].fromVersion} to ${result.finalVersion}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function runMigrations(
|
||||
settings: unknown,
|
||||
scope: string,
|
||||
): MigrationResult {
|
||||
const scheduler = new MigrationScheduler([...ALL_MIGRATIONS], scope);
|
||||
return scheduler.migrate(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given settings need migration.
|
||||
* Returns true only if at least one registered migration would be applied.
|
||||
*
|
||||
* This function checks:
|
||||
* 1. If $version field exists and is a number:
|
||||
* - Returns false if $version >= SETTINGS_VERSION
|
||||
* - Returns true only when $version < SETTINGS_VERSION AND at least one
|
||||
* migration can execute for the current settings shape
|
||||
* 2. If $version field is missing or invalid:
|
||||
* - Uses fallback logic by checking individual migrations
|
||||
*
|
||||
* Note:
|
||||
* - Legacy numeric versions that have no executable migrations are handled by
|
||||
* the settings loader via version normalization (bump metadata to current).
|
||||
*
|
||||
* @param settings - The settings object to check
|
||||
* @returns true if migration is needed, false otherwise
|
||||
*/
|
||||
export function needsMigration(settings: unknown): boolean {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = settings as Record<string, unknown>;
|
||||
const version = s['$version'];
|
||||
const hasApplicableMigration = ALL_MIGRATIONS.some((migration) =>
|
||||
migration.shouldMigrate(settings),
|
||||
);
|
||||
|
||||
// If $version is a valid number, use version comparison
|
||||
if (typeof version === 'number') {
|
||||
if (version >= SETTINGS_VERSION) {
|
||||
return false;
|
||||
}
|
||||
// Guardrail: only report migration-needed if at least one migration can execute.
|
||||
return hasApplicableMigration;
|
||||
}
|
||||
|
||||
// If $version exists but is not a number (invalid), or is missing:
|
||||
// Use fallback logic - check if any migration would be applied
|
||||
return hasApplicableMigration;
|
||||
}
|
||||
164
packages/cli/src/config/migration/scheduler.test.ts
Normal file
164
packages/cli/src/config/migration/scheduler.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { MigrationScheduler } from './scheduler.js';
|
||||
|
||||
import type { SettingsMigration } from './types.js';
|
||||
|
||||
describe('MigrationScheduler', () => {
|
||||
// Mock migration for testing
|
||||
const createMockMigration = (
|
||||
fromVersion: number,
|
||||
toVersion: number,
|
||||
shouldMigrateResult: boolean,
|
||||
): SettingsMigration => ({
|
||||
fromVersion,
|
||||
toVersion,
|
||||
shouldMigrate: vi.fn().mockReturnValue(shouldMigrateResult),
|
||||
migrate: vi.fn((settings) => ({
|
||||
settings: {
|
||||
...(settings as Record<string, unknown>),
|
||||
$version: toVersion,
|
||||
},
|
||||
warnings: [],
|
||||
})),
|
||||
});
|
||||
|
||||
it('should execute migrations in order when shouldMigrate returns true', () => {
|
||||
const migration1 = createMockMigration(1, 2, true);
|
||||
const migration2 = createMockMigration(2, 3, true);
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1, migration2], 'user');
|
||||
const result = scheduler.migrate({ $version: 1, someKey: 'value' });
|
||||
|
||||
expect(migration1.shouldMigrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration1.migrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration2.shouldMigrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration2.migrate).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(result.executedMigrations).toHaveLength(2);
|
||||
expect(result.executedMigrations[0]).toEqual({
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
});
|
||||
expect(result.executedMigrations[1]).toEqual({
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
});
|
||||
expect(result.finalVersion).toBe(3);
|
||||
});
|
||||
|
||||
it('should skip migrations when shouldMigrate returns false', () => {
|
||||
const migration1 = createMockMigration(1, 2, false);
|
||||
const migration2 = createMockMigration(2, 3, true);
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1, migration2], 'user');
|
||||
const result = scheduler.migrate({ $version: 2, someKey: 'value' });
|
||||
|
||||
expect(migration1.shouldMigrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration1.migrate).not.toHaveBeenCalled();
|
||||
expect(migration2.shouldMigrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration2.migrate).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(result.executedMigrations).toHaveLength(1);
|
||||
expect(result.executedMigrations[0]).toEqual({
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should be idempotent - running migrations twice produces same result', () => {
|
||||
// Create a migration that checks the version to determine if migration is needed
|
||||
const migration1: SettingsMigration = {
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
shouldMigrate: vi.fn((settings) => {
|
||||
const s = settings as Record<string, unknown>;
|
||||
return s['$version'] !== 2;
|
||||
}),
|
||||
migrate: vi.fn((settings) => ({
|
||||
settings: {
|
||||
...(settings as Record<string, unknown>),
|
||||
$version: 2,
|
||||
},
|
||||
warnings: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1], 'user');
|
||||
const input = { theme: 'dark' };
|
||||
|
||||
const result1 = scheduler.migrate(input);
|
||||
const result2 = scheduler.migrate(result1.settings);
|
||||
|
||||
expect(result1.executedMigrations).toHaveLength(1);
|
||||
expect(result2.executedMigrations).toHaveLength(0);
|
||||
expect(result1.finalVersion).toBe(result2.finalVersion);
|
||||
});
|
||||
|
||||
it('should pass updated settings to each migration', () => {
|
||||
const migration1: SettingsMigration = {
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
shouldMigrate: vi.fn().mockReturnValue(true),
|
||||
migrate: vi.fn(() => ({
|
||||
settings: { $version: 2, transformed: true },
|
||||
warnings: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const migration2: SettingsMigration = {
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
shouldMigrate: vi.fn().mockReturnValue(true),
|
||||
migrate: vi.fn((s) => ({ settings: s, warnings: [] })),
|
||||
};
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1, migration2], 'user');
|
||||
scheduler.migrate({ $version: 1 });
|
||||
|
||||
expect(migration2.shouldMigrate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ $version: 2, transformed: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty migrations array', () => {
|
||||
const scheduler = new MigrationScheduler([], 'user');
|
||||
const result = scheduler.migrate({ $version: 1, key: 'value' });
|
||||
|
||||
expect(result.executedMigrations).toHaveLength(0);
|
||||
expect(result.finalVersion).toBe(1);
|
||||
expect(result.settings).toEqual({ $version: 1, key: 'value' });
|
||||
});
|
||||
|
||||
it('should throw error when migration fails', () => {
|
||||
const migration1: SettingsMigration = {
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
shouldMigrate: vi.fn().mockReturnValue(true),
|
||||
migrate: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Migration failed');
|
||||
}),
|
||||
};
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1], 'user');
|
||||
|
||||
expect(() => scheduler.migrate({ $version: 1 })).toThrow(
|
||||
'Migration failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle settings without version field', () => {
|
||||
const migration1 = createMockMigration(1, 2, true);
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1], 'user');
|
||||
const result = scheduler.migrate({ theme: 'dark' });
|
||||
|
||||
expect(result.finalVersion).toBe(2);
|
||||
expect(result.executedMigrations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
115
packages/cli/src/config/migration/scheduler.ts
Normal file
115
packages/cli/src/config/migration/scheduler.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import type { SettingsMigration, MigrationResult } from './types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SETTINGS_MIGRATION');
|
||||
|
||||
/**
|
||||
* Formats a SettingScope enum value to a human-readable string.
|
||||
* - Converts to lowercase
|
||||
* - Special case: 'SystemDefaults' -> 'system default'
|
||||
*/
|
||||
export function formatScope(scope: string): string {
|
||||
if (scope === 'SystemDefaults') {
|
||||
return 'system default';
|
||||
}
|
||||
return scope.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain scheduler for settings migrations.
|
||||
*
|
||||
* The MigrationScheduler orchestrates multiple migrations in sequence,
|
||||
* delegating version detection to each individual migration via `shouldMigrate`.
|
||||
* It has no centralized version logic - migrations self-determine applicability.
|
||||
*
|
||||
* Key characteristics:
|
||||
* - Linear chain execution: migrations are applied in registration order
|
||||
* - Idempotent: already-migrated versions return false from shouldMigrate
|
||||
* - Adjacent versions only: each migration handles N → N+1
|
||||
* - Pure functions: migrations don't modify input objects
|
||||
*/
|
||||
export class MigrationScheduler {
|
||||
/**
|
||||
* Creates a new MigrationScheduler with the given migrations.
|
||||
*
|
||||
* @param migrations - Array of migrations in execution order (typically ascending version)
|
||||
* @param scope - The scope of settings being migrated
|
||||
*/
|
||||
constructor(
|
||||
private readonly migrations: SettingsMigration[],
|
||||
private readonly scope: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Executes the migration chain on the given settings.
|
||||
*
|
||||
* Iterates through all registered migrations in order. For each migration:
|
||||
* 1. Calls `shouldMigrate` with the current settings
|
||||
* 2. If true, calls `migrate` to transform the settings
|
||||
* 3. Records the execution
|
||||
*
|
||||
* The scheduler itself has no version awareness - all version detection
|
||||
* is delegated to the individual migrations.
|
||||
*
|
||||
* @param settings - The settings object to migrate
|
||||
* @returns MigrationResult containing the final settings, version, and execution log
|
||||
*/
|
||||
migrate(settings: unknown): MigrationResult {
|
||||
debugLogger.debug('MigrationScheduler: Starting migration chain');
|
||||
|
||||
let current = settings;
|
||||
const executed: Array<{ fromVersion: number; toVersion: number }> = [];
|
||||
const allWarnings: string[] = [];
|
||||
|
||||
for (const migration of this.migrations) {
|
||||
try {
|
||||
if (migration.shouldMigrate(current)) {
|
||||
debugLogger.debug(
|
||||
`MigrationScheduler: Executing migration ${migration.fromVersion} → ${migration.toVersion}`,
|
||||
);
|
||||
|
||||
const formattedScope = formatScope(this.scope);
|
||||
const result = migration.migrate(current, formattedScope);
|
||||
current = result.settings;
|
||||
allWarnings.push(...result.warnings);
|
||||
|
||||
executed.push({
|
||||
fromVersion: migration.fromVersion,
|
||||
toVersion: migration.toVersion,
|
||||
});
|
||||
|
||||
debugLogger.debug(
|
||||
`MigrationScheduler: Migration ${migration.fromVersion} → ${migration.toVersion} completed successfully`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`MigrationScheduler: Migration ${migration.fromVersion} → ${migration.toVersion} failed:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final version from the settings object
|
||||
const finalVersion =
|
||||
((current as Record<string, unknown>)['$version'] as number) ?? 1;
|
||||
|
||||
debugLogger.debug(
|
||||
`MigrationScheduler: Migration chain complete. Final version: ${finalVersion}, Executed: ${executed.length} migrations`,
|
||||
);
|
||||
|
||||
return {
|
||||
settings: current,
|
||||
finalVersion,
|
||||
executedMigrations: executed,
|
||||
warnings: allWarnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
58
packages/cli/src/config/migration/types.ts
Normal file
58
packages/cli/src/config/migration/types.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interface that all settings migrations must implement.
|
||||
* Each migration handles a single version transition (N → N+1).
|
||||
*/
|
||||
export interface SettingsMigration {
|
||||
/** Source version number */
|
||||
readonly fromVersion: number;
|
||||
|
||||
/** Target version number */
|
||||
readonly toVersion: number;
|
||||
|
||||
/**
|
||||
* Determines whether this migration should be applied to the given settings.
|
||||
* The migration inspects the settings object to detect its current version
|
||||
* and returns true if this migration is applicable.
|
||||
*
|
||||
* @param settings - The current settings object
|
||||
* @returns true if this migration should be applied, false otherwise
|
||||
*/
|
||||
shouldMigrate(settings: unknown): boolean;
|
||||
|
||||
/**
|
||||
* Executes the migration transformation.
|
||||
* This should be a pure function that does not modify the input object.
|
||||
*
|
||||
* @param settings - The current settings object of version N
|
||||
* @param scope - The scope of settings being migrated
|
||||
* @returns The migrated settings object of version N+1 with optional warnings
|
||||
* @throws Error if the migration fails
|
||||
*/
|
||||
migrate(
|
||||
settings: unknown,
|
||||
scope: string,
|
||||
): { settings: unknown; warnings: string[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a migration execution by MigrationScheduler.
|
||||
*/
|
||||
export interface MigrationResult {
|
||||
/** The final settings object after all applicable migrations */
|
||||
settings: unknown;
|
||||
|
||||
/** The final version number after migrations */
|
||||
finalVersion: number;
|
||||
|
||||
/** List of migrations that were executed */
|
||||
executedMigrations: Array<{ fromVersion: number; toVersion: number }>;
|
||||
|
||||
/** List of warning messages generated during migration */
|
||||
warnings: string[];
|
||||
}
|
||||
180
packages/cli/src/config/migration/versions/v1-to-v2-shared.ts
Normal file
180
packages/cli/src/config/migration/versions/v1-to-v2-shared.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Structural mapping table for V1 -> V2.
|
||||
*
|
||||
* Used by:
|
||||
* - v1->v2 migration execution
|
||||
* - warnings for residual legacy keys in latest-version settings files
|
||||
*/
|
||||
export const V1_TO_V2_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.chatCompression',
|
||||
checkpointing: 'general.checkpointing',
|
||||
coreTools: 'tools.core',
|
||||
contextFileName: 'context.fileName',
|
||||
customThemes: 'ui.customThemes',
|
||||
customWittyPhrases: 'ui.customWittyPhrases',
|
||||
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
|
||||
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
|
||||
enforcedAuthType: 'security.auth.enforcedType',
|
||||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
excludedProjectEnvVars: 'advanced.excludedEnvVars',
|
||||
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',
|
||||
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',
|
||||
model: 'model.name',
|
||||
preferredEditor: 'general.preferredEditor',
|
||||
sandbox: 'tools.sandbox',
|
||||
selectedAuthType: 'security.auth.selectedType',
|
||||
shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell',
|
||||
shellPager: 'tools.shell.pager',
|
||||
shellShowColor: 'tools.shell.showColor',
|
||||
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',
|
||||
enableWelcomeBack: 'ui.enableWelcomeBack',
|
||||
approvalMode: 'tools.approvalMode',
|
||||
sessionTokenLimit: 'model.sessionTokenLimit',
|
||||
contentGenerator: 'model.generationConfig',
|
||||
skipLoopDetection: 'model.skipLoopDetection',
|
||||
skipStartupContext: 'model.skipStartupContext',
|
||||
enableOpenAILogging: 'model.enableOpenAILogging',
|
||||
tavilyApiKey: 'advanced.tavilyApiKey',
|
||||
};
|
||||
|
||||
/**
|
||||
* Top-level keys that are V2/V3 containers.
|
||||
* If one of these keys already has object value, treat it as latest-format data.
|
||||
*/
|
||||
export const V2_CONTAINER_KEYS = new Set([
|
||||
'ui',
|
||||
'tools',
|
||||
'mcp',
|
||||
'advanced',
|
||||
'model',
|
||||
'general',
|
||||
'context',
|
||||
'security',
|
||||
'ide',
|
||||
'privacy',
|
||||
'telemetry',
|
||||
'extensions',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Legacy disable* keys that remain in disable* form for V2.
|
||||
*/
|
||||
export const V1_TO_V2_PRESERVE_DISABLE_MAP: Record<string, string> = {
|
||||
disableAutoUpdate: 'general.disableAutoUpdate',
|
||||
disableUpdateNag: 'general.disableUpdateNag',
|
||||
disableLoadingPhrases: 'ui.accessibility.disableLoadingPhrases',
|
||||
disableFuzzySearch: 'context.fileFiltering.disableFuzzySearch',
|
||||
disableCacheControl: 'model.generationConfig.disableCacheControl',
|
||||
};
|
||||
|
||||
export const CONSOLIDATED_DISABLE_KEYS = new Set([
|
||||
'disableAutoUpdate',
|
||||
'disableUpdateNag',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Keys that indicate V1-like top-level structure when holding primitive values.
|
||||
*/
|
||||
export const V1_INDICATOR_KEYS = [
|
||||
// From V1_TO_V2_MIGRATION_MAP - keys that map to different paths in V2
|
||||
'theme',
|
||||
'model',
|
||||
'autoAccept',
|
||||
'hideTips',
|
||||
'vimMode',
|
||||
'checkpointing',
|
||||
'accessibility',
|
||||
'allowedTools',
|
||||
'allowMCPServers',
|
||||
'autoConfigureMaxOldSpaceSize',
|
||||
'bugCommand',
|
||||
'chatCompression',
|
||||
'coreTools',
|
||||
'contextFileName',
|
||||
'customThemes',
|
||||
'customWittyPhrases',
|
||||
'debugKeystrokeLogging',
|
||||
'dnsResolutionOrder',
|
||||
'enforcedAuthType',
|
||||
'excludeTools',
|
||||
'excludeMCPServers',
|
||||
'excludedProjectEnvVars',
|
||||
'fileFiltering',
|
||||
'folderTrustFeature',
|
||||
'folderTrust',
|
||||
'hasSeenIdeIntegrationNudge',
|
||||
'hideWindowTitle',
|
||||
'showStatusInTitle',
|
||||
'showLineNumbers',
|
||||
'showCitations',
|
||||
'ideMode',
|
||||
'includeDirectories',
|
||||
'loadMemoryFromIncludeDirectories',
|
||||
'maxSessionTurns',
|
||||
'mcpServerCommand',
|
||||
'memoryImportFormat',
|
||||
'preferredEditor',
|
||||
'sandbox',
|
||||
'selectedAuthType',
|
||||
'shouldUseNodePtyShell',
|
||||
'shellPager',
|
||||
'shellShowColor',
|
||||
'skipNextSpeakerCheck',
|
||||
'summarizeToolOutput',
|
||||
'toolDiscoveryCommand',
|
||||
'toolCallCommand',
|
||||
'usageStatisticsEnabled',
|
||||
'useExternalAuth',
|
||||
'useRipgrep',
|
||||
'enableWelcomeBack',
|
||||
'approvalMode',
|
||||
'sessionTokenLimit',
|
||||
'contentGenerator',
|
||||
'skipLoopDetection',
|
||||
'skipStartupContext',
|
||||
'enableOpenAILogging',
|
||||
'tavilyApiKey',
|
||||
// From V1_TO_V2_PRESERVE_DISABLE_MAP - disable* keys that get nested in V2
|
||||
'disableAutoUpdate',
|
||||
'disableUpdateNag',
|
||||
'disableLoadingPhrases',
|
||||
'disableFuzzySearch',
|
||||
'disableCacheControl',
|
||||
];
|
||||
277
packages/cli/src/config/migration/versions/v1-to-v2.test.ts
Normal file
277
packages/cli/src/config/migration/versions/v1-to-v2.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { V1ToV2Migration } from './v1-to-v2.js';
|
||||
|
||||
describe('V1ToV2Migration', () => {
|
||||
const migration = new V1ToV2Migration();
|
||||
|
||||
describe('shouldMigrate', () => {
|
||||
it('should return true for V1 settings without version and with V1 keys', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v1Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for V1 settings with disable* keys', () => {
|
||||
const v1Settings = {
|
||||
disableAutoUpdate: true,
|
||||
disableLoadingPhrases: false,
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v1Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for settings with $version field', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'dark' },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v2Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for V3 settings', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v3Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for settings without V1 indicator keys', () => {
|
||||
const unknownSettings = {
|
||||
customKey: 'value',
|
||||
anotherKey: 123,
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(unknownSettings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null input', () => {
|
||||
expect(migration.shouldMigrate(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-object input', () => {
|
||||
expect(migration.shouldMigrate('string')).toBe(false);
|
||||
expect(migration.shouldMigrate(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrate', () => {
|
||||
it('should migrate flat V1 keys to nested V2 structure', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
autoAccept: true,
|
||||
hideTips: false,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['ui']).toEqual({ theme: 'dark', hideTips: false });
|
||||
expect(result['model']).toEqual({ name: 'gemini' });
|
||||
expect(result['tools']).toEqual({ autoAccept: true });
|
||||
});
|
||||
|
||||
it('should migrate disable* keys to nested V2 paths without inversion', () => {
|
||||
const v1Settings = {
|
||||
theme: 'light',
|
||||
disableAutoUpdate: true,
|
||||
disableLoadingPhrases: false,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['general']).toEqual({ disableAutoUpdate: true });
|
||||
expect(result['ui']).toEqual({
|
||||
theme: 'light',
|
||||
accessibility: { disableLoadingPhrases: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize consolidated disable* non-boolean values to false', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: 'false',
|
||||
disableUpdateNag: null,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['general']).toEqual({
|
||||
disableAutoUpdate: false,
|
||||
disableUpdateNag: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should drop non-boolean non-consolidated disable* values', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableLoadingPhrases: 'TRUE',
|
||||
disableFuzzySearch: 1,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(
|
||||
(result['ui'] as Record<string, unknown>)?.['accessibility'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(result['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableFuzzySearch'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve mcpServers at top level', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
mcpServers: {
|
||||
myServer: { command: 'node', args: ['server.js'] },
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['mcpServers']).toEqual({
|
||||
myServer: { command: 'node', args: ['server.js'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve unrecognized keys', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
myCustomSetting: 'value',
|
||||
anotherCustom: 123,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['myCustomSetting']).toBe('value');
|
||||
expect(result['anotherCustom']).toBe(123);
|
||||
});
|
||||
|
||||
it('should preserve non-object parent path values on collision', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
ui: 'legacy-ui-string',
|
||||
general: 'legacy-general-string',
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['ui']).toBe('legacy-ui-string');
|
||||
expect(result['general']).toBe('legacy-general-string');
|
||||
});
|
||||
|
||||
it('should not modify the input object', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(v1Settings).toEqual({ theme: 'dark', model: 'gemini' });
|
||||
expect(result).not.toBe(v1Settings);
|
||||
});
|
||||
|
||||
it('should throw error for non-object input', () => {
|
||||
expect(() => migration.migrate(null, 'user')).toThrow(
|
||||
'Settings must be an object',
|
||||
);
|
||||
expect(() => migration.migrate('string', 'user')).toThrow(
|
||||
'Settings must be an object',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty V1 settings', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['ui']).toEqual({ theme: 'dark' });
|
||||
});
|
||||
|
||||
it('should correctly handle all V1 indicator keys', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
autoAccept: true,
|
||||
hideTips: false,
|
||||
vimMode: true,
|
||||
checkpointing: false,
|
||||
telemetry: {},
|
||||
accessibility: {},
|
||||
extensions: [],
|
||||
mcpServers: {},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('version properties', () => {
|
||||
it('should have correct fromVersion', () => {
|
||||
expect(migration.fromVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('should have correct toVersion', () => {
|
||||
expect(migration.toVersion).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
267
packages/cli/src/config/migration/versions/v1-to-v2.ts
Normal file
267
packages/cli/src/config/migration/versions/v1-to-v2.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SettingsMigration } from '../types.js';
|
||||
import {
|
||||
CONSOLIDATED_DISABLE_KEYS,
|
||||
V1_INDICATOR_KEYS,
|
||||
V1_TO_V2_MIGRATION_MAP,
|
||||
V1_TO_V2_PRESERVE_DISABLE_MAP,
|
||||
V2_CONTAINER_KEYS,
|
||||
} from './v1-to-v2-shared.js';
|
||||
import { setNestedPropertySafe } from '../../../utils/settingsUtils.js';
|
||||
|
||||
/**
|
||||
* Heuristic indicators for deciding whether an object is "V1-like".
|
||||
*
|
||||
* Detection strategy:
|
||||
* - A file is considered migratable as V1 when:
|
||||
* 1) It is not explicitly versioned as V2+ (`$version` is missing or invalid), and
|
||||
* 2) At least one indicator key appears in a legacy-compatible top-level shape.
|
||||
* - Indicator list intentionally excludes keys that are valid top-level entries in
|
||||
* both old and new structures to reduce false positives.
|
||||
*
|
||||
* Shape rule:
|
||||
* - Object values for indicator keys are treated as already-nested V2-like content
|
||||
* and do not alone trigger migration.
|
||||
* - Primitive/array/null values on indicator keys are treated as legacy V1 signals.
|
||||
*/
|
||||
|
||||
/**
|
||||
* V1 -> V2 migration (structural normalization stage).
|
||||
*
|
||||
* Migration contract:
|
||||
* - Input: settings in legacy V1-like shape (mostly flat, may contain mixed partial V2).
|
||||
* - Output: V2-compatible nested structure with `$version: 2`.
|
||||
* - No semantic inversion of disable* naming in this stage.
|
||||
*
|
||||
* Data-preservation strategy:
|
||||
* - Prefer transforming known keys into canonical V2 locations.
|
||||
* - Preserve unrecognized keys verbatim.
|
||||
* - Preserve parent-path scalar values when nested writes would collide with them.
|
||||
* - Preserve/merge existing partial V2 objects where safe.
|
||||
*
|
||||
* This class intentionally optimizes for backward compatibility and non-destructive
|
||||
* behavior over aggressive normalization.
|
||||
*/
|
||||
export class V1ToV2Migration implements SettingsMigration {
|
||||
readonly fromVersion = 1;
|
||||
readonly toVersion = 2;
|
||||
|
||||
/**
|
||||
* Determines whether this migration should execute.
|
||||
*
|
||||
* Decision strategy:
|
||||
* - Hard-stop when `$version` is a number >= 2 (already V2+).
|
||||
* - Otherwise, scan indicator keys and trigger only when at least one indicator is
|
||||
* still in legacy top-level shape (primitive/array/null).
|
||||
*
|
||||
* Mixed-shape tolerance:
|
||||
* - Files that are partially migrated are supported; V2-like object-valued indicators
|
||||
* are ignored while legacy-shaped indicators can still trigger migration.
|
||||
*/
|
||||
shouldMigrate(settings: unknown): boolean {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = settings as Record<string, unknown>;
|
||||
|
||||
// If $version exists and is a number >= 2, it's not V1
|
||||
const version = s['$version'];
|
||||
if (typeof version === 'number' && version >= 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for V1 indicator keys with primitive values
|
||||
// A setting is considered V1 if ANY indicator key has a primitive value
|
||||
// (string, number, boolean, null, or array) at the top level.
|
||||
// Keys with object values are skipped as they may already be in V2 format.
|
||||
return V1_INDICATOR_KEYS.some((key) => {
|
||||
if (!(key in s)) {
|
||||
return false;
|
||||
}
|
||||
const value = s[key];
|
||||
// Skip keys with object values - they may already be in V2 nested format
|
||||
// But don't let them block migration of other keys
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
// This key appears to be in V2 format, skip it but continue
|
||||
// checking other keys
|
||||
return false;
|
||||
}
|
||||
// Found a key with primitive value - this is V1 format
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs non-destructive V1 -> V2 transformation.
|
||||
*
|
||||
* Detailed strategy:
|
||||
* 1) Relocate known V1 keys using `V1_TO_V2_MIGRATION_MAP`.
|
||||
* - If a source value is already an object and maps to a child path of itself
|
||||
* (partial V2 shape), merge child properties into target path.
|
||||
* 2) Relocate disable* keys into V2 disable* locations.
|
||||
* - Consolidated keys (`disableAutoUpdate`, `disableUpdateNag`): normalize to
|
||||
* boolean with stable-compatible presence semantics (`value === true`).
|
||||
* - Other disable* keys: migrate only boolean values.
|
||||
* 3) Preserve `mcpServers` top-level placement.
|
||||
* 4) Carry over remaining keys:
|
||||
* - If a key is parent of migrated nested paths, merge unprocessed object children.
|
||||
* - If parent value is non-object, preserve that scalar/array/null as-is.
|
||||
* - Otherwise copy untouched key/value.
|
||||
* 5) Stamp `$version = 2`.
|
||||
*
|
||||
* The method is pure with respect to input mutation.
|
||||
*/
|
||||
migrate(
|
||||
settings: unknown,
|
||||
_scope: string,
|
||||
): { settings: unknown; warnings: string[] } {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
throw new Error('Settings must be an object');
|
||||
}
|
||||
|
||||
const source = settings as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
const processedKeys = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Step 1: Map known V1 keys to V2 nested paths
|
||||
for (const [v1Key, v2Path] of Object.entries(V1_TO_V2_MIGRATION_MAP)) {
|
||||
if (v1Key in source) {
|
||||
const value = source[v1Key];
|
||||
|
||||
// Safety check: If this key is a V2 container (like 'model') and it's
|
||||
// already an object, it's likely already in V2 format. Skip migration
|
||||
// to prevent double-nesting (e.g., model.name.name).
|
||||
if (
|
||||
V2_CONTAINER_KEYS.has(v1Key) &&
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
// This is already a V2 container, carry it over as-is
|
||||
result[v1Key] = value;
|
||||
processedKeys.add(v1Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If value is already an object and the path matches the key,
|
||||
// it might be a partial V2 structure. Merge its contents.
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
v2Path.startsWith(v1Key + '.')
|
||||
) {
|
||||
// Merge nested properties from this partial V2 structure
|
||||
for (const [nestedKey, nestedValue] of Object.entries(value)) {
|
||||
setNestedPropertySafe(
|
||||
result,
|
||||
`${v2Path}.${nestedKey}`,
|
||||
nestedValue,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setNestedPropertySafe(result, v2Path, value);
|
||||
}
|
||||
processedKeys.add(v1Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Map V1 disable* keys to V2 nested disable* paths
|
||||
for (const [v1Key, v2Path] of Object.entries(
|
||||
V1_TO_V2_PRESERVE_DISABLE_MAP,
|
||||
)) {
|
||||
if (v1Key in source) {
|
||||
const value = source[v1Key];
|
||||
if (CONSOLIDATED_DISABLE_KEYS.has(v1Key)) {
|
||||
// Preserve stable behavior: consolidated keys use presence semantics.
|
||||
// Only literal true remains true; all other present values become false.
|
||||
setNestedPropertySafe(result, v2Path, value === true);
|
||||
} else if (typeof value === 'boolean') {
|
||||
// Non-consolidated disable* keys only migrate when explicitly boolean.
|
||||
setNestedPropertySafe(result, v2Path, value);
|
||||
}
|
||||
processedKeys.add(v1Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Preserve mcpServers at the top level
|
||||
if ('mcpServers' in source) {
|
||||
result['mcpServers'] = source['mcpServers'];
|
||||
processedKeys.add('mcpServers');
|
||||
}
|
||||
|
||||
// Step 4: Carry over any unrecognized keys (including unknown nested objects)
|
||||
// Important: Skip keys that are parent paths of already-migrated properties
|
||||
// to avoid overwriting merged structures (e.g., 'ui' should not overwrite 'ui.theme')
|
||||
for (const key of Object.keys(source)) {
|
||||
if (!processedKeys.has(key)) {
|
||||
// Check if this key is a parent of any already-migrated path
|
||||
const isParentOfMigratedPath = Array.from(processedKeys).some(
|
||||
(processedKey) => {
|
||||
// Get the v2 path for this processed key
|
||||
const v2Path =
|
||||
V1_TO_V2_MIGRATION_MAP[processedKey] ||
|
||||
V1_TO_V2_PRESERVE_DISABLE_MAP[processedKey];
|
||||
if (!v2Path) return false;
|
||||
// Check if the v2 path starts with this key + '.'
|
||||
return v2Path.startsWith(key + '.');
|
||||
},
|
||||
);
|
||||
|
||||
if (isParentOfMigratedPath) {
|
||||
// This key is a parent of an already-migrated path
|
||||
// Merge its unprocessed children instead of overwriting
|
||||
const existingValue = source[key];
|
||||
if (
|
||||
typeof existingValue === 'object' &&
|
||||
existingValue !== null &&
|
||||
!Array.isArray(existingValue)
|
||||
) {
|
||||
for (const [nestedKey, nestedValue] of Object.entries(
|
||||
existingValue,
|
||||
)) {
|
||||
// Only merge if this nested key wasn't already processed
|
||||
const fullNestedPath = `${key}.${nestedKey}`;
|
||||
const wasProcessed = Array.from(processedKeys).some(
|
||||
(processedKey) => {
|
||||
const v2Path =
|
||||
V1_TO_V2_MIGRATION_MAP[processedKey] ||
|
||||
V1_TO_V2_PRESERVE_DISABLE_MAP[processedKey];
|
||||
return v2Path === fullNestedPath;
|
||||
},
|
||||
);
|
||||
if (!wasProcessed) {
|
||||
setNestedPropertySafe(result, fullNestedPath, nestedValue);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Preserve non-object parent values to match legacy overwrite semantics.
|
||||
result[key] = source[key];
|
||||
}
|
||||
} else {
|
||||
// Not a parent path, safe to copy as-is
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Set version to 2
|
||||
result['$version'] = 2;
|
||||
|
||||
return { settings: result, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance of V1→V2 migration */
|
||||
export const v1ToV2Migration = new V1ToV2Migration();
|
||||
598
packages/cli/src/config/migration/versions/v2-to-v3.test.ts
Normal file
598
packages/cli/src/config/migration/versions/v2-to-v3.test.ts
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { V2ToV3Migration } from './v2-to-v3.js';
|
||||
|
||||
describe('V2ToV3Migration', () => {
|
||||
const migration = new V2ToV3Migration();
|
||||
|
||||
describe('shouldMigrate', () => {
|
||||
it('should return true for V2 settings with deprecated disable* keys', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for V2 settings with ui.accessibility.disableLoadingPhrases', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: { accessibility: { disableLoadingPhrases: false } },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for V3 settings', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v3Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for V1 settings without version', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v1Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for V2 settings without deprecated keys', () => {
|
||||
const cleanV2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'dark' },
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// V2 settings should always be migrated to V3 to update the version number
|
||||
expect(migration.shouldMigrate(cleanV2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for null input', () => {
|
||||
expect(migration.shouldMigrate(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-object input', () => {
|
||||
expect(migration.shouldMigrate('string')).toBe(false);
|
||||
expect(migration.shouldMigrate(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrate', () => {
|
||||
it('should migrate disableAutoUpdate to enableAutoUpdate with inverted value', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should migrate disableLoadingPhrases to enableLoadingPhrases', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: { accessibility: { disableLoadingPhrases: true } },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['ui'] as Record<string, unknown>)['accessibility'],
|
||||
).toEqual({
|
||||
enableLoadingPhrases: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should migrate disableFuzzySearch to enableFuzzySearch', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
context: { fileFiltering: { disableFuzzySearch: false } },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['context'] as Record<string, unknown>)['fileFiltering'],
|
||||
).toEqual({
|
||||
enableFuzzySearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should migrate disableCacheControl to enableCacheControl', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: true } },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['model'] as Record<string, unknown>)['generationConfig'],
|
||||
).toEqual({
|
||||
enableCacheControl: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle consolidated disableAutoUpdate and disableUpdateNag', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: true,
|
||||
disableUpdateNag: false,
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
// If ANY disable* is true, enable should be false
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableUpdateNag'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set enableAutoUpdate to true when both disable* are false', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: false,
|
||||
disableUpdateNag: false,
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve other settings during migration', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
accessibility: { disableLoadingPhrases: true },
|
||||
},
|
||||
model: {
|
||||
name: 'gemini',
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect((result['ui'] as Record<string, unknown>)['theme']).toBe('dark');
|
||||
expect((result['model'] as Record<string, unknown>)['name']).toBe(
|
||||
'gemini',
|
||||
);
|
||||
expect(
|
||||
(result['ui'] as Record<string, unknown>)['accessibility'],
|
||||
).toEqual({
|
||||
enableLoadingPhrases: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not modify the input object', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
const result = migration.migrate(v2Settings, 'user');
|
||||
|
||||
expect(v2Settings.general).toEqual({ disableAutoUpdate: true });
|
||||
expect(result).not.toBe(v2Settings);
|
||||
});
|
||||
|
||||
it('should throw error for non-object input', () => {
|
||||
expect(() => migration.migrate(null, 'user')).toThrow(
|
||||
'Settings must be an object',
|
||||
);
|
||||
expect(() => migration.migrate('string', 'user')).toThrow(
|
||||
'Settings must be an object',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple deprecated keys in one migration', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: false },
|
||||
ui: { accessibility: { disableLoadingPhrases: false } },
|
||||
context: { fileFiltering: { disableFuzzySearch: false } },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(result['ui'] as Record<string, unknown>)['accessibility'],
|
||||
).toEqual({
|
||||
enableLoadingPhrases: true,
|
||||
});
|
||||
expect(
|
||||
(result['context'] as Record<string, unknown>)['fileFiltering'],
|
||||
).toEqual({
|
||||
enableFuzzySearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should coerce string "true" and remove deprecated key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 'true' },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should coerce string "false" and remove deprecated key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 'false' },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(true);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should coerce case-insensitive strings for consolidated keys', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: 'TRUE',
|
||||
disableUpdateNag: 'FALSE',
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableUpdateNag'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should remove number value and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 123 },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
|
||||
it('should remove invalid string value and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 'invalid-string' },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
|
||||
it('should coerce disableCacheControl string "true"', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: 'true' } },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['model'] as Record<string, unknown>)['generationConfig'],
|
||||
).toEqual({
|
||||
enableCacheControl: false,
|
||||
});
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should coerce disableCacheControl string "false"', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: 'false' } },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['model'] as Record<string, unknown>)['generationConfig'],
|
||||
).toEqual({
|
||||
enableCacheControl: true,
|
||||
});
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should remove disableCacheControl number value and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: 456 } },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['model'] as Record<string, unknown>)['generationConfig'],
|
||||
).toEqual({});
|
||||
expect(
|
||||
(
|
||||
(result['model'] as Record<string, unknown>)[
|
||||
'generationConfig'
|
||||
] as Record<string, unknown>
|
||||
)['enableCacheControl'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain(
|
||||
'model.generationConfig.disableCacheControl',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed valid and invalid disableAutoUpdate and disableUpdateNag', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: true,
|
||||
disableUpdateNag: 'invalid',
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
// Only valid values should contribute to the consolidated result
|
||||
// Since disableAutoUpdate is true, enableAutoUpdate should be false
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableUpdateNag'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableUpdateNag');
|
||||
});
|
||||
|
||||
it('should remove object value for disable key and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: { nested: 'value' } },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
|
||||
it('should remove array value for disable key and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: [1, 2, 3] },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
|
||||
it('should remove null value for disable key and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: null },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('version properties', () => {
|
||||
it('should have correct fromVersion', () => {
|
||||
expect(migration.fromVersion).toBe(2);
|
||||
});
|
||||
|
||||
it('should have correct toVersion', () => {
|
||||
expect(migration.toVersion).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
222
packages/cli/src/config/migration/versions/v2-to-v3.ts
Normal file
222
packages/cli/src/config/migration/versions/v2-to-v3.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SettingsMigration } from '../types.js';
|
||||
import {
|
||||
deleteNestedPropertySafe,
|
||||
getNestedProperty,
|
||||
setNestedPropertySafe,
|
||||
} from '../../../utils/settingsUtils.js';
|
||||
|
||||
/**
|
||||
* Path mapping for boolean polarity migration (V2 disable* -> V3 enable*).
|
||||
*
|
||||
* Strategy:
|
||||
* - For each mapped path, values are normalized before migration:
|
||||
* - boolean values are accepted directly
|
||||
* - string values "true"/"false" (case-insensitive, trim-aware) are coerced
|
||||
* - all other present values are treated as invalid
|
||||
* - Transformation is inversion-based: disable=true -> enable=false, disable=false -> enable=true.
|
||||
* - Deprecated disable* keys are removed whenever present (valid or invalid).
|
||||
* - Invalid values do not create enable* keys and produce warnings.
|
||||
*/
|
||||
const V2_TO_V3_BOOLEAN_MAP: Record<string, string> = {
|
||||
'general.disableAutoUpdate': 'general.enableAutoUpdate',
|
||||
'general.disableUpdateNag': 'general.enableAutoUpdate',
|
||||
'ui.accessibility.disableLoadingPhrases':
|
||||
'ui.accessibility.enableLoadingPhrases',
|
||||
'context.fileFiltering.disableFuzzySearch':
|
||||
'context.fileFiltering.enableFuzzySearch',
|
||||
'model.generationConfig.disableCacheControl':
|
||||
'model.generationConfig.enableCacheControl',
|
||||
};
|
||||
|
||||
/**
|
||||
* Consolidated old paths that collapse into one V3 field.
|
||||
*
|
||||
* Current policy:
|
||||
* - `general.disableAutoUpdate` and `general.disableUpdateNag` both drive
|
||||
* `general.enableAutoUpdate`.
|
||||
* - If any valid normalized source is true, target becomes false.
|
||||
* - If at least one valid normalized source exists, consolidated target is emitted.
|
||||
* - Invalid present values are removed and warned, and do not contribute to target calculation.
|
||||
*/
|
||||
const CONSOLIDATED_V2_PATHS: Record<string, string[]> = {
|
||||
'general.enableAutoUpdate': [
|
||||
'general.disableAutoUpdate',
|
||||
'general.disableUpdateNag',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes deprecated disable* values for migration.
|
||||
*
|
||||
* Returns:
|
||||
* - `isPresent=false` when the path does not exist
|
||||
* - `isPresent=true, isValid=true` when value is boolean or coercible string
|
||||
* - `isPresent=true, isValid=false` for invalid values (number/object/array/null/other strings)
|
||||
*/
|
||||
function normalizeDisableValue(value: unknown): {
|
||||
isPresent: boolean;
|
||||
isValid: boolean;
|
||||
booleanValue?: boolean;
|
||||
} {
|
||||
if (value === undefined) {
|
||||
return { isPresent: false, isValid: false };
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return { isPresent: true, isValid: true, booleanValue: value };
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true') {
|
||||
return { isPresent: true, isValid: true, booleanValue: true };
|
||||
}
|
||||
if (normalized === 'false') {
|
||||
return { isPresent: true, isValid: true, booleanValue: false };
|
||||
}
|
||||
}
|
||||
return { isPresent: true, isValid: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 -> V3 migration (boolean polarity normalization stage).
|
||||
*
|
||||
* Migration contract:
|
||||
* - Input: V2 settings object (`$version: 2`).
|
||||
* - Output: `$version: 3` with deprecated disable* fields removed and
|
||||
* valid values migrated to enable* equivalents.
|
||||
*
|
||||
* Compatibility strategy:
|
||||
* - Accept boolean values and coercible strings "true"/"false".
|
||||
* - Remove invalid deprecated values (rather than preserving them).
|
||||
* - Emit warnings for each removed invalid deprecated key.
|
||||
* - Always bump version to 3 so future loads are idempotent and skip repeated checks.
|
||||
*/
|
||||
export class V2ToV3Migration implements SettingsMigration {
|
||||
readonly fromVersion = 2;
|
||||
readonly toVersion = 3;
|
||||
|
||||
/**
|
||||
* Migration trigger rule.
|
||||
*
|
||||
* Execute only when `$version === 2`.
|
||||
* This includes V2 files with no migratable disable* booleans so that version
|
||||
* metadata still advances to 3.
|
||||
*/
|
||||
shouldMigrate(settings: unknown): boolean {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = settings as Record<string, unknown>;
|
||||
|
||||
// Migrate if $version is 2
|
||||
return s['$version'] === 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies V2 -> V3 transformation with deterministic deprecated-key cleanup.
|
||||
*
|
||||
* Detailed strategy:
|
||||
* 1) Clone input.
|
||||
* 2) Process consolidated paths first:
|
||||
* - Inspect each source path.
|
||||
* - Normalize each present value (boolean / coercible string / invalid).
|
||||
* - Always delete present deprecated source key.
|
||||
* - Valid normalized values contribute to aggregate.
|
||||
* - Invalid values emit warnings.
|
||||
* - Emit consolidated target when at least one valid source was consumed.
|
||||
* 3) Process remaining one-to-one mappings:
|
||||
* - For each unmapped source, normalize value.
|
||||
* - If valid -> delete old key and write inverted target.
|
||||
* - If invalid -> delete old key and emit warning.
|
||||
* 4) Set `$version = 3`.
|
||||
*
|
||||
* Guarantees:
|
||||
* - Input object is not mutated.
|
||||
* - Valid migration and invalid cleanup are deterministic.
|
||||
* - Deprecated disable* keys are not retained after migration.
|
||||
*/
|
||||
migrate(
|
||||
settings: unknown,
|
||||
scope: string,
|
||||
): { settings: unknown; warnings: string[] } {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
throw new Error('Settings must be an object');
|
||||
}
|
||||
|
||||
// Deep clone to avoid mutating input
|
||||
const result = structuredClone(settings) as Record<string, unknown>;
|
||||
const processedPaths = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Step 1: Handle consolidated paths (multiple old paths → single new path)
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
|
||||
for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) {
|
||||
let hasAnyDisable = false;
|
||||
let hasAnyBooleanValue = false;
|
||||
|
||||
for (const oldPath of oldPaths) {
|
||||
const oldValue = getNestedProperty(result, oldPath);
|
||||
const normalized = normalizeDisableValue(oldValue);
|
||||
if (!normalized.isPresent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
deleteNestedPropertySafe(result, oldPath);
|
||||
processedPaths.add(oldPath);
|
||||
|
||||
if (normalized.isValid) {
|
||||
hasAnyBooleanValue = true;
|
||||
if (normalized.booleanValue === true) {
|
||||
hasAnyDisable = true;
|
||||
}
|
||||
} else {
|
||||
warnings.push(
|
||||
`Removed deprecated setting '${oldPath}' from ${scope} settings because the value is invalid. Expected boolean.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAnyBooleanValue) {
|
||||
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
|
||||
setNestedPropertySafe(result, newPath, !hasAnyDisable);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Handle remaining individual disable* → enable* mappings
|
||||
for (const [oldPath, newPath] of Object.entries(V2_TO_V3_BOOLEAN_MAP)) {
|
||||
if (processedPaths.has(oldPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldValue = getNestedProperty(result, oldPath);
|
||||
const normalized = normalizeDisableValue(oldValue);
|
||||
if (!normalized.isPresent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
deleteNestedPropertySafe(result, oldPath);
|
||||
if (normalized.isValid) {
|
||||
// Set new property with inverted value
|
||||
setNestedPropertySafe(result, newPath, !normalized.booleanValue);
|
||||
} else {
|
||||
warnings.push(
|
||||
`Removed deprecated setting '${oldPath}' from ${scope} settings because the value is invalid. Expected boolean or string "true"/"false".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Always update version to 3
|
||||
result['$version'] = 3;
|
||||
|
||||
return { settings: result, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance of V2→V3 migration */
|
||||
export const v2ToV3Migration = new V2ToV3Migration();
|
||||
|
|
@ -18,16 +18,6 @@ vi.mock('os', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
// Mock './settings.js' to ensure it uses the mocked 'os.homedir()' for its internal constants.
|
||||
vi.mock('./settings.js', async (importActual) => {
|
||||
const originalModule = await importActual<typeof import('./settings.js')>();
|
||||
return {
|
||||
__esModule: true, // Ensure correct module shape
|
||||
...originalModule, // Re-export all original members
|
||||
// We are relying on originalModule's USER_SETTINGS_PATH being constructed with mocked os.homedir()
|
||||
};
|
||||
});
|
||||
|
||||
// Mock trustedFolders
|
||||
vi.mock('./trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi
|
||||
|
|
@ -46,7 +36,6 @@ import {
|
|||
afterEach,
|
||||
type Mocked,
|
||||
type Mock,
|
||||
fail,
|
||||
} from 'vitest';
|
||||
import * as fs from 'node:fs'; // fs will be mocked separately
|
||||
import stripJsonComments from 'strip-json-comments'; // Will be mocked separately
|
||||
|
|
@ -60,13 +49,12 @@ import {
|
|||
getSystemSettingsPath,
|
||||
getSystemDefaultsPath,
|
||||
SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock.
|
||||
migrateSettingsToV1,
|
||||
needsMigration,
|
||||
type Settings,
|
||||
loadEnvironment,
|
||||
SETTINGS_VERSION,
|
||||
SETTINGS_VERSION_KEY,
|
||||
} from './settings.js';
|
||||
import { needsMigration } from './migration/index.js';
|
||||
import { FatalConfigError, QWEN_DIR } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const MOCK_WORKSPACE_DIR = '/mock/workspace';
|
||||
|
|
@ -84,6 +72,23 @@ type TestSettings = Settings & {
|
|||
nestedObj?: { [key: string]: unknown };
|
||||
};
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
// Get all the functions from the real 'fs' module
|
||||
const actualFs = await importOriginal<typeof fs>();
|
||||
|
||||
return {
|
||||
...actualFs, // Keep all the real functions
|
||||
// Now, just override the ones we need for the test
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
realpathSync: (p: string) => p,
|
||||
};
|
||||
});
|
||||
|
||||
// Also mock 'fs' for compatibility
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
// Get all the functions from the real 'fs' module
|
||||
const actualFs = await importOriginal<typeof fs>();
|
||||
|
|
@ -588,19 +593,22 @@ describe('Settings Loading and Merging', () => {
|
|||
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify that fs.writeFileSync was called (to add version)
|
||||
// but NOT fs.renameSync (no backup needed, just adding version)
|
||||
expect(fs.renameSync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls[0];
|
||||
const writtenPath = writeCall[0];
|
||||
// Version normalization now uses writeWithBackupSync (temp write + rename)
|
||||
// Verify that writeFileSync was called with the temp file path
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`,
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
if (!writeCall) {
|
||||
throw new Error('Expected temp write call for version normalization');
|
||||
}
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
|
||||
expect(writtenPath).toBe(USER_SETTINGS_PATH);
|
||||
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
|
||||
expect(writtenContent.ui?.theme).toBe('dark');
|
||||
expect(writtenContent.model?.name).toBe('qwen-coder');
|
||||
// Verify writeWithBackupSync was called by checking temp file write
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should correctly handle partially migrated settings without version field', () => {
|
||||
|
|
@ -728,14 +736,85 @@ describe('Settings Loading and Merging', () => {
|
|||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Version should be bumped to 3 even though no keys needed migration
|
||||
// writeWithBackupSync writes to a temp file first, then renames
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === USER_SETTINGS_PATH,
|
||||
(call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`,
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
if (!writeCall) {
|
||||
throw new Error('Expected temp write call for V2->V3 version bump');
|
||||
}
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
expect(writtenContent.$version).toBe(SETTINGS_VERSION);
|
||||
});
|
||||
|
||||
it('should normalize invalid version metadata when no migration is applicable', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const invalidVersionSettings = {
|
||||
$version: 'invalid-version',
|
||||
general: {
|
||||
enableAutoUpdate: true,
|
||||
},
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(invalidVersionSettings);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`,
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
if (!writeCall) {
|
||||
throw new Error(
|
||||
'Expected temp write call for invalid version normalization',
|
||||
);
|
||||
}
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
expect(writtenContent.$version).toBe(SETTINGS_VERSION);
|
||||
expect(writtenContent.general?.enableAutoUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('should normalize legacy numeric version when no migration can execute', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const staleVersionSettings = {
|
||||
$version: 1,
|
||||
// No V1/V2 indicators recognized by migrations
|
||||
customOnlyKey: 'value',
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(staleVersionSettings);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`,
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
if (!writeCall) {
|
||||
throw new Error(
|
||||
'Expected temp write call for stale version normalization',
|
||||
);
|
||||
}
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
expect(writtenContent.$version).toBe(SETTINGS_VERSION);
|
||||
expect(writtenContent.customOnlyKey).toBe('value');
|
||||
});
|
||||
|
||||
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const legacyUserSettings = {
|
||||
|
|
@ -1613,7 +1692,7 @@ describe('Settings Loading and Merging', () => {
|
|||
|
||||
try {
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
fail('loadSettings should have thrown a FatalConfigError');
|
||||
throw new Error('loadSettings should have thrown a FatalConfigError');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(FatalConfigError);
|
||||
const error = e as FatalConfigError;
|
||||
|
|
@ -2255,385 +2334,6 @@ describe('Settings Loading and Merging', () => {
|
|||
});
|
||||
});
|
||||
|
||||
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: {
|
||||
hideTips: true,
|
||||
customThemes: {
|
||||
myTheme: {},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
name: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.5,
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'server-1': {
|
||||
command: 'node server.js',
|
||||
},
|
||||
},
|
||||
unrecognized: {
|
||||
should: 'be-preserved',
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
disableAutoUpdate: true,
|
||||
hideTips: true,
|
||||
customThemes: {
|
||||
myTheme: {},
|
||||
},
|
||||
model: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.5,
|
||||
},
|
||||
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-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.8,
|
||||
},
|
||||
},
|
||||
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-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.8,
|
||||
},
|
||||
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-1.5-pro',
|
||||
},
|
||||
unrecognized: 'value',
|
||||
};
|
||||
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
expect(v1Settings).toEqual({
|
||||
vimMode: false,
|
||||
model: 'gemini-1.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: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
expect(v1Settings).toEqual({
|
||||
vimMode: false,
|
||||
maxSessionTurns: -1,
|
||||
includeDirectories: [],
|
||||
folderTrust: false,
|
||||
});
|
||||
});
|
||||
|
||||
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'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove version field when migrating to V1', () => {
|
||||
const v2Settings = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
},
|
||||
model: {
|
||||
name: 'qwen-coder',
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
// Version field should not be present in V1 settings
|
||||
expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined();
|
||||
// Other fields should be properly migrated
|
||||
expect(v1Settings).toEqual({
|
||||
theme: 'dark',
|
||||
model: 'qwen-coder',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle version field in unrecognized properties', () => {
|
||||
const v2Settings = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
general: {
|
||||
vimMode: true,
|
||||
},
|
||||
someUnrecognizedKey: 'value',
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
// Version field should be filtered out
|
||||
expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined();
|
||||
// Unrecognized keys should be preserved
|
||||
expect(v1Settings['someUnrecognizedKey']).toBe('value');
|
||||
expect(v1Settings['vimMode']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadEnvironment', () => {
|
||||
function setup({
|
||||
isFolderTrustEnabled = true,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
QWEN_DIR,
|
||||
getErrorMessage,
|
||||
Storage,
|
||||
setDebugLogSession,
|
||||
sanitizeCwd,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
|
@ -29,9 +31,16 @@ import {
|
|||
getSettingsSchema,
|
||||
} from './settingsSchema.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
|
||||
import { setNestedPropertySafe } from '../utils/settingsUtils.js';
|
||||
import { customDeepMerge } from '../utils/deepMerge.js';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
import { writeStderrLine } from '../utils/stdioHelpers.js';
|
||||
const debugLogger = createDebugLogger('SETTINGS');
|
||||
import { runMigrations, needsMigration } from './migration/index.js';
|
||||
import {
|
||||
V1_TO_V2_MIGRATION_MAP,
|
||||
V2_CONTAINER_KEYS,
|
||||
} from './migration/versions/v1-to-v2-shared.js';
|
||||
import { writeWithBackupSync } from '../utils/writeWithBackup.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SETTINGS');
|
||||
|
||||
|
|
@ -57,113 +66,10 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
|
|||
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
|
||||
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
||||
|
||||
const MIGRATE_V2_OVERWRITE = true;
|
||||
|
||||
// Settings version to track migration state
|
||||
export const SETTINGS_VERSION = 3;
|
||||
export const SETTINGS_VERSION_KEY = '$version';
|
||||
|
||||
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.chatCompression',
|
||||
checkpointing: 'general.checkpointing',
|
||||
coreTools: 'tools.core',
|
||||
contextFileName: 'context.fileName',
|
||||
customThemes: 'ui.customThemes',
|
||||
customWittyPhrases: 'ui.customWittyPhrases',
|
||||
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
|
||||
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
|
||||
enforcedAuthType: 'security.auth.enforcedType',
|
||||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
excludedProjectEnvVars: 'advanced.excludedEnvVars',
|
||||
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',
|
||||
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',
|
||||
model: 'model.name',
|
||||
preferredEditor: 'general.preferredEditor',
|
||||
sandbox: 'tools.sandbox',
|
||||
selectedAuthType: 'security.auth.selectedType',
|
||||
shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell',
|
||||
shellPager: 'tools.shell.pager',
|
||||
shellShowColor: 'tools.shell.showColor',
|
||||
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',
|
||||
|
||||
enableWelcomeBack: 'ui.enableWelcomeBack',
|
||||
approvalMode: 'tools.approvalMode',
|
||||
sessionTokenLimit: 'model.sessionTokenLimit',
|
||||
contentGenerator: 'model.generationConfig',
|
||||
skipLoopDetection: 'model.skipLoopDetection',
|
||||
skipStartupContext: 'model.skipStartupContext',
|
||||
enableOpenAILogging: 'model.enableOpenAILogging',
|
||||
tavilyApiKey: 'advanced.tavilyApiKey',
|
||||
};
|
||||
|
||||
// Settings that need boolean inversion during migration (V1 -> V3)
|
||||
// Old negative naming -> new positive naming with inverted value
|
||||
const INVERTED_BOOLEAN_MIGRATIONS: Record<string, string> = {
|
||||
disableAutoUpdate: 'general.enableAutoUpdate',
|
||||
disableUpdateNag: 'general.enableAutoUpdate',
|
||||
disableLoadingPhrases: 'ui.accessibility.enableLoadingPhrases',
|
||||
disableFuzzySearch: 'context.fileFiltering.enableFuzzySearch',
|
||||
disableCacheControl: 'model.generationConfig.enableCacheControl',
|
||||
};
|
||||
|
||||
// Consolidated settings: multiple old V1 keys that map to a single new key.
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false.
|
||||
const CONSOLIDATED_SETTINGS: Record<string, string[]> = {
|
||||
'general.enableAutoUpdate': ['disableAutoUpdate', 'disableUpdateNag'],
|
||||
};
|
||||
|
||||
// V2 nested paths that need inversion when migrating to V3
|
||||
const INVERTED_V2_PATHS: Record<string, string> = {
|
||||
'general.disableAutoUpdate': 'general.enableAutoUpdate',
|
||||
'general.disableUpdateNag': 'general.enableAutoUpdate',
|
||||
'ui.accessibility.disableLoadingPhrases':
|
||||
'ui.accessibility.enableLoadingPhrases',
|
||||
'context.fileFiltering.disableFuzzySearch':
|
||||
'context.fileFiltering.enableFuzzySearch',
|
||||
'model.generationConfig.disableCacheControl':
|
||||
'model.generationConfig.enableCacheControl',
|
||||
};
|
||||
|
||||
// Consolidated V2 paths: multiple old paths that map to a single new path.
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false.
|
||||
const CONSOLIDATED_V2_PATHS: Record<string, string[]> = {
|
||||
'general.enableAutoUpdate': [
|
||||
'general.disableAutoUpdate',
|
||||
'general.disableUpdateNag',
|
||||
],
|
||||
};
|
||||
|
||||
export function getSystemSettingsPath(): string {
|
||||
if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) {
|
||||
return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
|
||||
|
|
@ -221,312 +127,6 @@ export interface SettingsFile {
|
|||
rawJson?: string;
|
||||
}
|
||||
|
||||
function setNestedProperty(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown,
|
||||
) {
|
||||
const keys = path.split('.');
|
||||
const lastKey = keys.pop();
|
||||
if (!lastKey) return;
|
||||
|
||||
let current: Record<string, unknown> = obj;
|
||||
for (const key of keys) {
|
||||
if (current[key] === undefined) {
|
||||
current[key] = {};
|
||||
}
|
||||
const next = current[key];
|
||||
if (typeof next === 'object' && next !== null) {
|
||||
current = next as Record<string, unknown>;
|
||||
} else {
|
||||
// This path is invalid, so we stop.
|
||||
return;
|
||||
}
|
||||
}
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
// 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]),
|
||||
...Object.values(INVERTED_BOOLEAN_MIGRATIONS).map(
|
||||
(path) => path.split('.')[0],
|
||||
),
|
||||
]);
|
||||
|
||||
export function needsMigration(settings: Record<string, unknown>): boolean {
|
||||
// Check version field first - if present and matches current version, no migration needed
|
||||
if (SETTINGS_VERSION_KEY in settings) {
|
||||
const version = settings[SETTINGS_VERSION_KEY];
|
||||
if (typeof version === 'number' && version >= SETTINGS_VERSION) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy detection: 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 both 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;
|
||||
});
|
||||
|
||||
// Also check for old inverted boolean keys (disable* -> enable*)
|
||||
const hasInvertedBooleanKeys = Object.keys(INVERTED_BOOLEAN_MIGRATIONS).some(
|
||||
(v1Key) => v1Key in settings,
|
||||
);
|
||||
|
||||
return hasV1Keys || hasInvertedBooleanKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates V1 (flat) settings directly to V3.
|
||||
* This includes both structural migration (flat -> nested) and boolean
|
||||
* inversion (disable* -> enable*), so migrateV2ToV3 will be skipped.
|
||||
*/
|
||||
function migrateV1ToV3(
|
||||
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)) {
|
||||
// Safety check: If this key is a V2 container (like 'model') and it's
|
||||
// already an object, it's likely already in V2 format. Skip migration
|
||||
// to prevent double-nesting (e.g., model.name.name).
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
||||
typeof flatSettings[oldKey] === 'object' &&
|
||||
flatSettings[oldKey] !== null &&
|
||||
!Array.isArray(flatSettings[oldKey])
|
||||
) {
|
||||
// This is already a V2 container, carry it over as-is
|
||||
v2Settings[oldKey] = flatSettings[oldKey];
|
||||
flatKeys.delete(oldKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
|
||||
flatKeys.delete(oldKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle consolidated settings first (multiple old keys -> single new key)
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
|
||||
for (const [newPath, oldKeys] of Object.entries(CONSOLIDATED_SETTINGS)) {
|
||||
let hasAnyDisable = false;
|
||||
let hasAnyValue = false;
|
||||
for (const oldKey of oldKeys) {
|
||||
if (flatKeys.has(oldKey)) {
|
||||
hasAnyValue = true;
|
||||
const oldValue = flatSettings[oldKey];
|
||||
if (typeof oldValue === 'boolean' && oldValue === true) {
|
||||
hasAnyDisable = true;
|
||||
}
|
||||
flatKeys.delete(oldKey);
|
||||
}
|
||||
}
|
||||
if (hasAnyValue) {
|
||||
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
|
||||
setNestedProperty(v2Settings, newPath, !hasAnyDisable);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remaining V1 settings that need boolean inversion (disable* -> enable*)
|
||||
// Skip keys that were already handled by consolidated settings
|
||||
const consolidatedKeys = new Set(Object.values(CONSOLIDATED_SETTINGS).flat());
|
||||
for (const [oldKey, newPath] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) {
|
||||
if (consolidatedKeys.has(oldKey)) {
|
||||
continue;
|
||||
}
|
||||
if (flatKeys.has(oldKey)) {
|
||||
const oldValue = flatSettings[oldKey];
|
||||
if (typeof oldValue === 'boolean') {
|
||||
setNestedProperty(v2Settings, newPath, !oldValue);
|
||||
}
|
||||
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,
|
||||
{},
|
||||
newValue as MergeableObject,
|
||||
existingValue as MergeableObject,
|
||||
);
|
||||
} else {
|
||||
v2Settings[remainingKey] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Set version field to indicate this is a V2 settings file
|
||||
v2Settings[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
|
||||
return v2Settings;
|
||||
}
|
||||
|
||||
// Migrate V2 settings to V3 (invert disable* -> enable* booleans)
|
||||
function migrateV2ToV3(
|
||||
settings: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const version = settings[SETTINGS_VERSION_KEY];
|
||||
if (typeof version === 'number' && version >= 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const result = structuredClone(settings);
|
||||
const processedPaths = new Set<string>();
|
||||
|
||||
// Handle consolidated V2 paths first (multiple old paths -> single new path)
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
|
||||
for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) {
|
||||
let hasAnyDisable = false;
|
||||
let hasAnyValue = false;
|
||||
for (const oldPath of oldPaths) {
|
||||
const oldValue = getNestedProperty(result, oldPath);
|
||||
if (typeof oldValue === 'boolean') {
|
||||
hasAnyValue = true;
|
||||
if (oldValue === true) {
|
||||
hasAnyDisable = true;
|
||||
}
|
||||
deleteNestedProperty(result, oldPath);
|
||||
processedPaths.add(oldPath);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (hasAnyValue) {
|
||||
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
|
||||
setNestedProperty(result, newPath, !hasAnyDisable);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remaining V2 paths that need inversion
|
||||
for (const [oldPath, newPath] of Object.entries(INVERTED_V2_PATHS)) {
|
||||
if (processedPaths.has(oldPath)) {
|
||||
continue;
|
||||
}
|
||||
const oldValue = getNestedProperty(result, oldPath);
|
||||
if (typeof oldValue === 'boolean') {
|
||||
// Remove old property
|
||||
deleteNestedProperty(result, oldPath);
|
||||
// Set new property with inverted value
|
||||
setNestedProperty(result, newPath, !oldValue);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Even if no changes, bump version to 3 to skip future migration checks
|
||||
if (typeof version === 'number' && version < SETTINGS_VERSION) {
|
||||
result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function deleteNestedProperty(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
): void {
|
||||
const keys = path.split('.');
|
||||
const lastKey = keys.pop();
|
||||
if (!lastKey) return;
|
||||
|
||||
let current: Record<string, unknown> = obj;
|
||||
for (const key of keys) {
|
||||
const next = current[key];
|
||||
if (typeof next !== 'object' || next === null) {
|
||||
return;
|
||||
}
|
||||
current = next as Record<string, unknown>;
|
||||
}
|
||||
delete current[lastKey];
|
||||
}
|
||||
|
||||
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]),
|
||||
);
|
||||
|
||||
// Reverse map for old V2 paths (before rename) to V1 keys.
|
||||
// Used when migrating settings that still have old V2 naming (e.g., general.disableAutoUpdate).
|
||||
const OLD_V2_TO_V1_MAP: Record<string, string> = {};
|
||||
for (const [oldV2Path, newV3Path] of Object.entries(INVERTED_V2_PATHS)) {
|
||||
// Find the V1 key that maps to this V3 path
|
||||
for (const [v1Key, v3Path] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) {
|
||||
if (v3Path === newV3Path) {
|
||||
OLD_V2_TO_V1_MAP[oldV2Path] = v1Key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse map for new V3 paths to V1 keys (with boolean inversion).
|
||||
// Used when migrating settings that have new V3 naming (e.g., general.enableAutoUpdate).
|
||||
const V3_TO_V1_INVERTED_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(INVERTED_BOOLEAN_MIGRATIONS).map(([v1Key, v3Path]) => [
|
||||
v3Path,
|
||||
v1Key,
|
||||
]),
|
||||
);
|
||||
|
||||
function getSettingsFileKeyWarnings(
|
||||
settings: Record<string, unknown>,
|
||||
settingsFilePath: string,
|
||||
|
|
@ -540,7 +140,7 @@ function getSettingsFileKeyWarnings(
|
|||
const ignoredLegacyKeys = new Set<string>();
|
||||
|
||||
// Ignored legacy keys (V1 top-level keys that moved to a nested V2 path).
|
||||
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
||||
for (const [oldKey, newPath] of Object.entries(V1_TO_V2_MIGRATION_MAP)) {
|
||||
if (oldKey === newPath) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -553,7 +153,7 @@ function getSettingsFileKeyWarnings(
|
|||
// If this key is a V2 container (like 'model') and it's already an object,
|
||||
// it's likely already in V2 format. Don't warn.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
||||
V2_CONTAINER_KEYS.has(oldKey) &&
|
||||
typeof oldValue === 'object' &&
|
||||
oldValue !== null &&
|
||||
!Array.isArray(oldValue)
|
||||
|
|
@ -589,7 +189,8 @@ function getSettingsFileKeyWarnings(
|
|||
}
|
||||
|
||||
/**
|
||||
* Collects warnings for ignored legacy and unknown settings keys.
|
||||
* Collects warnings for ignored legacy and unknown settings keys,
|
||||
* as well as migration warnings.
|
||||
*
|
||||
* For `$version: 2` settings files, we do not apply implicit migrations.
|
||||
* Instead, we surface actionable, de-duplicated warnings in the terminal UI.
|
||||
|
|
@ -597,6 +198,11 @@ function getSettingsFileKeyWarnings(
|
|||
export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
|
||||
const warningSet = new Set<string>();
|
||||
|
||||
// Add migration warnings first
|
||||
for (const warning of loadedSettings.migrationWarnings) {
|
||||
warningSet.add(`Warning: ${warning}`);
|
||||
}
|
||||
|
||||
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||
const settingsFile = loadedSettings.forScope(scope);
|
||||
if (settingsFile.rawJson === undefined) {
|
||||
|
|
@ -619,75 +225,6 @@ export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
|
|||
return [...warningSet];
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle old V2 inverted paths (no value inversion needed)
|
||||
// e.g., general.disableAutoUpdate -> disableAutoUpdate
|
||||
for (const [oldV2Path, v1Key] of Object.entries(OLD_V2_TO_V1_MAP)) {
|
||||
const value = getNestedProperty(v2Settings, oldV2Path);
|
||||
if (value !== undefined) {
|
||||
v1Settings[v1Key] = value;
|
||||
v2Keys.delete(oldV2Path.split('.')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle new V3 inverted paths (WITH value inversion)
|
||||
// e.g., general.enableAutoUpdate -> disableAutoUpdate (inverted)
|
||||
for (const [v3Path, v1Key] of Object.entries(V3_TO_V1_INVERTED_MAP)) {
|
||||
const value = getNestedProperty(v2Settings, v3Path);
|
||||
if (value !== undefined && typeof value === 'boolean') {
|
||||
v1Settings[v1Key] = !value;
|
||||
v2Keys.delete(v3Path.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) {
|
||||
// Skip the version field - it's only for V2 format
|
||||
if (remainingKey === SETTINGS_VERSION_KEY) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function mergeSettings(
|
||||
system: Settings,
|
||||
systemDefaults: Settings,
|
||||
|
|
@ -721,6 +258,7 @@ export class LoadedSettings {
|
|||
workspace: SettingsFile,
|
||||
isTrusted: boolean,
|
||||
migratedInMemorScopes: Set<SettingScope>,
|
||||
migrationWarnings: string[] = [],
|
||||
) {
|
||||
this.system = system;
|
||||
this.systemDefaults = systemDefaults;
|
||||
|
|
@ -728,6 +266,7 @@ export class LoadedSettings {
|
|||
this.workspace = workspace;
|
||||
this.isTrusted = isTrusted;
|
||||
this.migratedInMemorScopes = migratedInMemorScopes;
|
||||
this.migrationWarnings = migrationWarnings;
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
|
||||
|
|
@ -737,6 +276,7 @@ export class LoadedSettings {
|
|||
readonly workspace: SettingsFile;
|
||||
readonly isTrusted: boolean;
|
||||
readonly migratedInMemorScopes: Set<SettingScope>;
|
||||
readonly migrationWarnings: string[];
|
||||
|
||||
private _merged: Settings;
|
||||
|
||||
|
|
@ -771,8 +311,8 @@ export class LoadedSettings {
|
|||
|
||||
setValue(scope: SettingScope, key: string, value: unknown): void {
|
||||
const settingsFile = this.forScope(scope);
|
||||
setNestedProperty(settingsFile.settings, key, value);
|
||||
setNestedProperty(settingsFile.originalSettings, key, value);
|
||||
setNestedPropertySafe(settingsFile.settings, key, value);
|
||||
setNestedPropertySafe(settingsFile.originalSettings, key, value);
|
||||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile);
|
||||
}
|
||||
|
|
@ -796,6 +336,7 @@ export function createMinimalSettings(): LoadedSettings {
|
|||
emptySettingsFile,
|
||||
false,
|
||||
new Set(),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -936,6 +477,16 @@ export function loadEnvironment(settings: Settings): void {
|
|||
export function loadSettings(
|
||||
workspaceDir: string = process.cwd(),
|
||||
): LoadedSettings {
|
||||
// Set up a temporary debug log session for the startup phase.
|
||||
// This allows migration errors to be logged to file instead of being
|
||||
// exposed to users via stderr. The Config class will override this
|
||||
// with the actual session once initialized.
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
const sanitizedProjectId = sanitizeCwd(resolvedWorkspaceDir);
|
||||
setDebugLogSession({
|
||||
getSessionId: () => `startup-${sanitizedProjectId}`,
|
||||
});
|
||||
|
||||
let systemSettings: Settings = {};
|
||||
let systemDefaultSettings: Settings = {};
|
||||
let userSettings: Settings = {};
|
||||
|
|
@ -946,7 +497,7 @@ export function loadSettings(
|
|||
const migratedInMemorScopes = new Set<SettingScope>();
|
||||
|
||||
// Resolve paths to their canonical representation to handle symlinks
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
// Note: resolvedWorkspaceDir is already defined at the top of the function
|
||||
const resolvedHomeDir = path.resolve(homedir());
|
||||
|
||||
let realWorkspaceDir = resolvedWorkspaceDir;
|
||||
|
|
@ -967,7 +518,7 @@ export function loadSettings(
|
|||
const loadAndMigrate = (
|
||||
filePath: string,
|
||||
scope: SettingScope,
|
||||
): { settings: Settings; rawJson?: string } => {
|
||||
): { settings: Settings; rawJson?: string; migrationWarnings?: string[] } => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
|
@ -986,74 +537,59 @@ export function loadSettings(
|
|||
}
|
||||
|
||||
let settingsObject = rawSettings as Record<string, unknown>;
|
||||
const hasVersionKey = SETTINGS_VERSION_KEY in settingsObject;
|
||||
const versionValue = settingsObject[SETTINGS_VERSION_KEY];
|
||||
const hasInvalidVersion =
|
||||
hasVersionKey && typeof versionValue !== 'number';
|
||||
const hasLegacyNumericVersion =
|
||||
typeof versionValue === 'number' && versionValue < SETTINGS_VERSION;
|
||||
let migrationWarnings: string[] | undefined;
|
||||
|
||||
const persistSettingsObject = (warningPrefix: string) => {
|
||||
try {
|
||||
writeWithBackupSync(
|
||||
filePath,
|
||||
JSON.stringify(settingsObject, null, 2),
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error(`${warningPrefix}: ${getErrorMessage(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (needsMigration(settingsObject)) {
|
||||
const migratedSettings = migrateV1ToV3(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) {
|
||||
writeStderrLine(
|
||||
`Error migrating settings file on disk: ${getErrorMessage(
|
||||
e,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
migratedInMemorScopes.add(scope);
|
||||
}
|
||||
settingsObject = migratedSettings;
|
||||
const migrationResult = runMigrations(settingsObject, scope);
|
||||
if (migrationResult.executedMigrations.length > 0) {
|
||||
settingsObject = migrationResult.settings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
migrationWarnings = migrationResult.warnings;
|
||||
persistSettingsObject('Error migrating settings file on disk');
|
||||
} else if (hasLegacyNumericVersion || hasInvalidVersion) {
|
||||
// Migration was deemed needed but nothing executed. Normalize version metadata
|
||||
// to avoid repeated no-op checks on startup.
|
||||
settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
debugLogger.warn(
|
||||
`Settings version metadata in ${filePath} could not be migrated by any registered migration. Normalizing ${SETTINGS_VERSION_KEY} to ${SETTINGS_VERSION}.`,
|
||||
);
|
||||
persistSettingsObject('Error normalizing settings version on disk');
|
||||
}
|
||||
} else if (!(SETTINGS_VERSION_KEY in settingsObject)) {
|
||||
// No migration needed, but version field is missing - add it for future optimizations
|
||||
} else if (
|
||||
!hasVersionKey ||
|
||||
hasInvalidVersion ||
|
||||
hasLegacyNumericVersion
|
||||
) {
|
||||
// No migration needed/executable, but version metadata is missing or invalid.
|
||||
// Normalize it to current version to avoid repeated startup work.
|
||||
settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
if (MIGRATE_V2_OVERWRITE) {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify(settingsObject, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
} catch (e) {
|
||||
writeStderrLine(
|
||||
`Error adding version to settings file: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
persistSettingsObject('Error normalizing settings version on disk');
|
||||
}
|
||||
|
||||
// V2 to V3 migration (invert disable* -> enable* booleans)
|
||||
const v3Migrated = migrateV2ToV3(settingsObject);
|
||||
if (v3Migrated) {
|
||||
if (MIGRATE_V2_OVERWRITE) {
|
||||
try {
|
||||
// Only backup if not already backed up by V1->V2 migration
|
||||
const backupPath = `${filePath}.orig`;
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
fs.renameSync(filePath, backupPath);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify(v3Migrated, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
} catch (e) {
|
||||
writeStderrLine(
|
||||
`Error migrating settings file to V3: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
migratedInMemorScopes.add(scope);
|
||||
}
|
||||
settingsObject = v3Migrated;
|
||||
}
|
||||
|
||||
return { settings: settingsObject as Settings, rawJson: content };
|
||||
return {
|
||||
settings: settingsObject as Settings,
|
||||
rawJson: content,
|
||||
migrationWarnings,
|
||||
};
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
|
|
@ -1071,7 +607,11 @@ export function loadSettings(
|
|||
);
|
||||
const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User);
|
||||
|
||||
let workspaceResult: { settings: Settings; rawJson?: string } = {
|
||||
let workspaceResult: {
|
||||
settings: Settings;
|
||||
rawJson?: string;
|
||||
migrationWarnings?: string[];
|
||||
} = {
|
||||
settings: {} as Settings,
|
||||
rawJson: undefined,
|
||||
};
|
||||
|
|
@ -1141,6 +681,14 @@ export function loadSettings(
|
|||
);
|
||||
}
|
||||
|
||||
// Collect all migration warnings from all scopes
|
||||
const allMigrationWarnings: string[] = [
|
||||
...(systemResult.migrationWarnings ?? []),
|
||||
...(systemDefaultsResult.migrationWarnings ?? []),
|
||||
...(userResult.migrationWarnings ?? []),
|
||||
...(workspaceResult.migrationWarnings ?? []),
|
||||
];
|
||||
|
||||
return new LoadedSettings(
|
||||
{
|
||||
path: systemSettingsPath,
|
||||
|
|
@ -1168,6 +716,7 @@ export function loadSettings(
|
|||
},
|
||||
isTrusted,
|
||||
migratedInMemorScopes,
|
||||
allMigrationWarnings,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1179,21 +728,14 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
|||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
let settingsToSave = settingsFile.originalSettings;
|
||||
if (!MIGRATE_V2_OVERWRITE) {
|
||||
settingsToSave = migrateSettingsToV1(
|
||||
settingsToSave as Record<string, unknown>,
|
||||
) as Settings;
|
||||
}
|
||||
|
||||
// Use the format-preserving update function
|
||||
updateSettingsFilePreservingFormat(
|
||||
settingsFile.path,
|
||||
settingsToSave as Record<string, unknown>,
|
||||
settingsFile.originalSettings as Record<string, unknown>,
|
||||
);
|
||||
} catch (error) {
|
||||
writeStderrLine('Error saving user settings file.');
|
||||
writeStderrLine(error instanceof Error ? error.message : String(error));
|
||||
debugLogger.error('Error saving user settings file.');
|
||||
debugLogger.error(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -589,7 +589,7 @@ const SETTINGS_SCHEMA = {
|
|||
label: 'Skip Loop Detection',
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Disable all loop detection checks (streaming and LLM).',
|
||||
showInDialog: false,
|
||||
},
|
||||
|
|
@ -1176,6 +1176,75 @@ const SETTINGS_SCHEMA = {
|
|||
description: 'Configuration for web search providers.',
|
||||
showInDialog: false,
|
||||
},
|
||||
|
||||
hooksConfig: {
|
||||
type: 'object',
|
||||
label: 'Hooks Config',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description:
|
||||
'Hook configurations for intercepting and customizing agent behavior.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
|
||||
showInDialog: false,
|
||||
},
|
||||
disabled: {
|
||||
type: 'array',
|
||||
label: 'Disabled Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
type: 'object',
|
||||
label: 'Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description:
|
||||
'Hook event configurations for extending CLI behavior at various lifecycle points.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
UserPromptSubmit: {
|
||||
type: 'array',
|
||||
label: 'Before Agent Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute before agent processing. Can modify prompts or inject context.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
Stop: {
|
||||
type: 'array',
|
||||
label: 'After Agent Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute after agent processing. Can post-process responses or log interactions.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies SettingsSchema;
|
||||
|
||||
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue