fix(config): prevent profile loss from strict config validation

Relaxed isUnifiedConfig() type guard to accept version >= 1 and partial
configs. Added mergeWithDefaults() to preserve user data while filling
missing sections. Fixes profile "not found" after terminal restart.

Closes #82
This commit is contained in:
kaitranntt
2025-12-12 05:12:08 -05:00
parent 53499619ac
commit d343abca53
3 changed files with 47 additions and 13 deletions
+28 -1
View File
@@ -90,12 +90,39 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
}
}
/**
* Merge partial config with defaults.
* Preserves existing data while filling in missing sections.
*/
function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
const defaults = createEmptyUnifiedConfig();
return {
version: partial.version ?? defaults.version,
default: partial.default ?? defaults.default,
accounts: partial.accounts ?? defaults.accounts,
profiles: partial.profiles ?? defaults.profiles,
cliproxy: {
oauth_accounts: partial.cliproxy?.oauth_accounts ?? defaults.cliproxy.oauth_accounts,
providers: defaults.cliproxy.providers, // Always use defaults for providers
variants: partial.cliproxy?.variants ?? defaults.cliproxy.variants,
},
preferences: {
...defaults.preferences,
...partial.preferences,
},
};
}
/**
* Load config, preferring YAML if available, falling back to creating empty config.
* Merges with defaults to ensure all sections exist.
*/
export function loadOrCreateUnifiedConfig(): UnifiedConfig {
const existing = loadUnifiedConfig();
if (existing) return existing;
if (existing) {
// Merge with defaults to fill any missing sections
return mergeWithDefaults(existing);
}
// Create empty config
const config = createEmptyUnifiedConfig();
+5 -7
View File
@@ -151,17 +151,15 @@ export function createEmptySecretsConfig(): SecretsConfig {
/**
* Type guard for UnifiedConfig.
* Relaxed validation: accepts configs with version >= 1 and any subset of sections.
* Missing sections will be filled with defaults during merge.
*/
export function isUnifiedConfig(obj: unknown): obj is UnifiedConfig {
if (typeof obj !== 'object' || obj === null) return false;
const config = obj as Record<string, unknown>;
return (
typeof config.version === 'number' &&
config.version === UNIFIED_CONFIG_VERSION &&
typeof config.accounts === 'object' &&
typeof config.profiles === 'object' &&
typeof config.cliproxy === 'object'
);
// Only require version to be a number >= 1 (allow future versions)
// Sections are optional - will be merged with defaults in loadOrCreateUnifiedConfig
return typeof config.version === 'number' && config.version >= 1;
}
/**
+14 -5
View File
@@ -124,14 +124,23 @@ describe('unified-config-types', () => {
expect(isUnifiedConfig(null)).toBe(false);
});
it('should return false for wrong version', () => {
it('should return true for older version (relaxed validation)', () => {
// Fix for issue #82: Relaxed validation accepts version >= 1
// to prevent profile loss when loading partially valid configs
const config = { ...createEmptyUnifiedConfig(), version: 1 };
expect(isUnifiedConfig(config)).toBe(false);
expect(isUnifiedConfig(config)).toBe(true);
});
it('should return false for missing fields', () => {
expect(isUnifiedConfig({ version: 2 })).toBe(false);
expect(isUnifiedConfig({ version: 2, accounts: {} })).toBe(false);
it('should return true for partial configs (relaxed validation)', () => {
// Fix for issue #82: Relaxed validation accepts partial configs
// Missing sections are merged with defaults in loadOrCreateUnifiedConfig
expect(isUnifiedConfig({ version: 2 })).toBe(true);
expect(isUnifiedConfig({ version: 2, accounts: {} })).toBe(true);
});
it('should return false for version < 1', () => {
expect(isUnifiedConfig({ version: 0 })).toBe(false);
expect(isUnifiedConfig({ version: -1 })).toBe(false);
});
});