mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-03 06:00:49 +00:00
refactor(settings): sequential settings migration
This commit is contained in:
parent
ac5a0c68e5
commit
ae8c0d3d4e
18 changed files with 3527 additions and 944 deletions
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
299
packages/cli/src/config/migration/versions/v1-to-v2.ts
Normal file
299
packages/cli/src/config/migration/versions/v1-to-v2.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
/**
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Best-effort nested setter with collision protection.
|
||||
*
|
||||
* Strategy:
|
||||
* - Create missing intermediate objects while traversing the path.
|
||||
* - If traversal hits a non-object parent, abort this write.
|
||||
*
|
||||
* Rationale:
|
||||
* - Migration should never coerce scalars into objects implicitly because that can
|
||||
* destroy user data shape. Skipping the write is safer; later carry-over logic
|
||||
* preserves original values.
|
||||
*/
|
||||
function setNestedProperty(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
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 {
|
||||
// Path collision with non-object, stop here
|
||||
return;
|
||||
}
|
||||
}
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)) {
|
||||
setNestedProperty(result, `${v2Path}.${nestedKey}`, nestedValue);
|
||||
}
|
||||
} else {
|
||||
setNestedProperty(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.
|
||||
setNestedProperty(result, v2Path, value === true);
|
||||
} else if (typeof value === 'boolean') {
|
||||
// Non-consolidated disable* keys only migrate when explicitly boolean.
|
||||
setNestedProperty(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) {
|
||||
setNestedProperty(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();
|
||||
549
packages/cli/src/config/migration/versions/v2-to-v3.test.ts
Normal file
549
packages/cli/src/config/migration/versions/v2-to-v3.test.ts
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
/**
|
||||
* @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 preserve string "true" without creating enable key', () => {
|
||||
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>)['disableAutoUpdate'],
|
||||
).toBe('true');
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve string "false" without creating enable key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: '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>)['disableAutoUpdate'],
|
||||
).toBe('false');
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve string "TRUE" without creating enable key', () => {
|
||||
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>)['disableAutoUpdate'],
|
||||
).toBe('TRUE');
|
||||
});
|
||||
|
||||
it('should preserve string "FALSE" without creating enable key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: '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>)['disableAutoUpdate'],
|
||||
).toBe('FALSE');
|
||||
});
|
||||
|
||||
it('should preserve number value and not create enable key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 123 },
|
||||
};
|
||||
|
||||
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>)['disableAutoUpdate'],
|
||||
).toBe(123);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve invalid string value and not create enable key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 'invalid-string' },
|
||||
};
|
||||
|
||||
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>)['disableAutoUpdate'],
|
||||
).toBe('invalid-string');
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve disableCacheControl string "true"', () => {
|
||||
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({
|
||||
disableCacheControl: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve disableCacheControl string "false"', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: 'false' } },
|
||||
};
|
||||
|
||||
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({
|
||||
disableCacheControl: 'false',
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve disableCacheControl number value and not create enable key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: 456 } },
|
||||
};
|
||||
|
||||
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({ disableCacheControl: 456 });
|
||||
expect(
|
||||
(
|
||||
(result['model'] as Record<string, unknown>)[
|
||||
'generationConfig'
|
||||
] as Record<string, unknown>
|
||||
)['enableCacheControl'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle mixed valid and invalid disableAutoUpdate and disableUpdateNag', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: true,
|
||||
disableUpdateNag: 'invalid',
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
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'],
|
||||
).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should preserve object value for disable key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: { nested: 'value' } },
|
||||
};
|
||||
|
||||
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>)['disableAutoUpdate'],
|
||||
).toEqual({ nested: 'value' });
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve array value for disable key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: [1, 2, 3] },
|
||||
};
|
||||
|
||||
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>)['disableAutoUpdate'],
|
||||
).toEqual([1, 2, 3]);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve null value for disable key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: null },
|
||||
};
|
||||
|
||||
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>)['disableAutoUpdate'],
|
||||
).toBeNull();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('version properties', () => {
|
||||
it('should have correct fromVersion', () => {
|
||||
expect(migration.fromVersion).toBe(2);
|
||||
});
|
||||
|
||||
it('should have correct toVersion', () => {
|
||||
expect(migration.toVersion).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
241
packages/cli/src/config/migration/versions/v2-to-v3.ts
Normal file
241
packages/cli/src/config/migration/versions/v2-to-v3.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SettingsMigration } from '../types.js';
|
||||
|
||||
/**
|
||||
* Path mapping for boolean polarity migration (V2 disable* -> V3 enable*).
|
||||
*
|
||||
* Strategy:
|
||||
* - For each mapped path, only boolean inputs are transformed.
|
||||
* - Transformation is inversion-based: disable=true -> enable=false, disable=false -> enable=true.
|
||||
* - Non-boolean values are intentionally ignored here to preserve stable compatibility.
|
||||
*/
|
||||
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 observed boolean source is true, target becomes false.
|
||||
* - If no boolean source exists, consolidation does not emit a target value.
|
||||
*/
|
||||
const CONSOLIDATED_V2_PATHS: Record<string, string[]> = {
|
||||
'general.enableAutoUpdate': [
|
||||
'general.disableAutoUpdate',
|
||||
'general.disableUpdateNag',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Safe nested getter used during migration path inspection.
|
||||
*
|
||||
* Returns `undefined` when traversal cannot continue (missing key or non-object parent).
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe nested setter used for emitting migrated paths.
|
||||
*
|
||||
* Behavior:
|
||||
* - Creates intermediate objects when absent.
|
||||
* - Aborts write if a parent segment is a non-object (collision protection).
|
||||
*/
|
||||
function setNestedProperty(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
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 {
|
||||
// Path collision with non-object, stop here
|
||||
return;
|
||||
}
|
||||
}
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort nested delete for removing deprecated disable* keys.
|
||||
*
|
||||
* If traversal hits a non-object parent, deletion is skipped.
|
||||
*/
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-based deep clone used to keep `migrate()` input immutable.
|
||||
*/
|
||||
function deepClone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 -> V3 migration (boolean polarity normalization stage).
|
||||
*
|
||||
* Migration contract:
|
||||
* - Input: V2 settings object (`$version: 2`).
|
||||
* - Output: `$version: 3` with boolean disable* fields migrated to enable* equivalents.
|
||||
*
|
||||
* Compatibility strategy:
|
||||
* - Transform only boolean-valued deprecated fields.
|
||||
* - Preserve non-boolean deprecated values untouched.
|
||||
* - Always bump version to 3 so future loads are idempotent and skip repeated checks.
|
||||
*
|
||||
* This implementation mirrors stable behavior and prioritizes parity over aggressive cleanup.
|
||||
*/
|
||||
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 stable-compatible rules.
|
||||
*
|
||||
* Detailed strategy:
|
||||
* 1) Clone input.
|
||||
* 2) Process consolidated paths first:
|
||||
* - Inspect each source path.
|
||||
* - If value is boolean, consume it (delete old key) and contribute to aggregate.
|
||||
* - Emit consolidated target when at least one boolean source was consumed.
|
||||
* 3) Process remaining one-to-one mappings:
|
||||
* - For each unmapped source, if boolean -> delete old key and write inverted target.
|
||||
* - If non-boolean -> keep old value unchanged.
|
||||
* 4) Set `$version = 3`.
|
||||
*
|
||||
* Guarantees:
|
||||
* - Input object is not mutated.
|
||||
* - Boolean migration is deterministic.
|
||||
* - Non-boolean legacy values are preserved for compatibility.
|
||||
*/
|
||||
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 = deepClone(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);
|
||||
if (typeof oldValue === 'boolean') {
|
||||
hasAnyBooleanValue = true;
|
||||
if (oldValue === true) {
|
||||
hasAnyDisable = true;
|
||||
}
|
||||
deleteNestedProperty(result, oldPath);
|
||||
processedPaths.add(oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAnyBooleanValue) {
|
||||
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
|
||||
setNestedProperty(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);
|
||||
if (typeof oldValue === 'boolean') {
|
||||
deleteNestedProperty(result, oldPath);
|
||||
// Set new property with inverted value
|
||||
setNestedProperty(result, newPath, !oldValue);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue