feat(02-01): implement deepMerge and overlay-aware loadConfig with tests
- Add deepMerge utility for recursive object merging (arrays replace, not concat) - Extend loadConfig with optional overlayPath parameter - Merge happens before env var expansion and Zod validation - Add 6 deepMerge unit tests and 4 overlay integration tests - Re-export deepMerge from config/index.ts - All 1087 existing tests still pass
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
||||
export { loadConfig } from './loader.js';
|
||||
export { loadConfig, deepMerge } from './loader.js';
|
||||
export { configSchema, type Config, type TelegramConfig, type ModelConfig, type CronJobConfig, type AgentsConfig, type CompactionConfig, type ToolProfile, type ToolOverrideConfig, type ToolsConfig, type SandboxConfig, type AgentConfigEntry, type RoutingConfig, type ServerConfig } from './schema.js';
|
||||
|
||||
+140
-2
@@ -1,9 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { loadConfig } from './loader.js';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { loadConfig, deepMerge } from './loader.js';
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
describe('deepMerge', () => {
|
||||
it('merges nested objects', () => {
|
||||
const result = deepMerge({ a: { b: 1, c: 2 } }, { a: { c: 3 } });
|
||||
expect(result).toEqual({ a: { b: 1, c: 3 } });
|
||||
});
|
||||
|
||||
it('overlay arrays replace base arrays', () => {
|
||||
const result = deepMerge({ a: [1, 2] }, { a: [3] });
|
||||
expect(result).toEqual({ a: [3] });
|
||||
});
|
||||
|
||||
it('adds new keys from overlay', () => {
|
||||
const result = deepMerge({ a: 1 }, { b: 2 });
|
||||
expect(result).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it('returns base when overlay is empty', () => {
|
||||
const result = deepMerge({ a: 1 }, {});
|
||||
expect(result).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('handles deeply nested objects (3+ levels)', () => {
|
||||
const base = { a: { b: { c: { d: 1, e: 2 }, f: 3 } } };
|
||||
const overlay = { a: { b: { c: { e: 99 } } } };
|
||||
const result = deepMerge(base, overlay);
|
||||
expect(result).toEqual({ a: { b: { c: { d: 1, e: 99 }, f: 3 } } });
|
||||
});
|
||||
|
||||
it('null in overlay sets key to null', () => {
|
||||
const result = deepMerge({ a: { b: 1 } }, { a: null as unknown as Record<string, unknown> });
|
||||
expect(result).toEqual({ a: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig', () => {
|
||||
const testDir = join(tmpdir(), 'flynn-test-config');
|
||||
|
||||
@@ -69,3 +103,107 @@ telegram:
|
||||
rmSync(testDir, { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig with overlay', () => {
|
||||
const testDir = join(tmpdir(), 'flynn-test-overlay');
|
||||
|
||||
afterEach(() => {
|
||||
try { rmSync(testDir, { recursive: true }); } catch {}
|
||||
delete process.env.TEST_OVERLAY_TOKEN;
|
||||
delete process.env.TEST_OVERLAY_MODEL;
|
||||
});
|
||||
|
||||
it('merges overlay on top of base config', () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const basePath = join(testDir, 'config.yaml');
|
||||
const overlayPath = join(testDir, 'docker.yaml');
|
||||
|
||||
writeFileSync(basePath, `
|
||||
telegram:
|
||||
bot_token: "base-token"
|
||||
allowed_chat_ids: [123]
|
||||
server:
|
||||
port: 18800
|
||||
models:
|
||||
default:
|
||||
provider: anthropic
|
||||
model: claude-sonnet
|
||||
`);
|
||||
|
||||
writeFileSync(overlayPath, `
|
||||
server:
|
||||
port: 9999
|
||||
`);
|
||||
|
||||
const config = loadConfig(basePath, overlayPath);
|
||||
expect(config.server.port).toBe(9999);
|
||||
expect(config.telegram.bot_token).toBe('base-token');
|
||||
});
|
||||
|
||||
it('loads base-only when no overlay provided', () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const basePath = join(testDir, 'config.yaml');
|
||||
|
||||
writeFileSync(basePath, `
|
||||
telegram:
|
||||
bot_token: "base-only-token"
|
||||
allowed_chat_ids: [456]
|
||||
server:
|
||||
port: 18800
|
||||
models:
|
||||
default:
|
||||
provider: anthropic
|
||||
model: claude-sonnet
|
||||
`);
|
||||
|
||||
const config = loadConfig(basePath);
|
||||
expect(config.telegram.bot_token).toBe('base-only-token');
|
||||
expect(config.server.port).toBe(18800);
|
||||
});
|
||||
|
||||
it('expands env vars in both base and overlay', () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const basePath = join(testDir, 'config.yaml');
|
||||
const overlayPath = join(testDir, 'docker.yaml');
|
||||
|
||||
process.env.TEST_OVERLAY_TOKEN = 'expanded-token';
|
||||
process.env.TEST_OVERLAY_MODEL = 'gpt-4o';
|
||||
|
||||
writeFileSync(basePath, `
|
||||
telegram:
|
||||
bot_token: \${TEST_OVERLAY_TOKEN}
|
||||
allowed_chat_ids: [123]
|
||||
models:
|
||||
default:
|
||||
provider: anthropic
|
||||
model: claude-sonnet
|
||||
`);
|
||||
|
||||
writeFileSync(overlayPath, `
|
||||
models:
|
||||
default:
|
||||
model: \${TEST_OVERLAY_MODEL}
|
||||
`);
|
||||
|
||||
const config = loadConfig(basePath, overlayPath);
|
||||
expect(config.telegram.bot_token).toBe('expanded-token');
|
||||
expect(config.models.default.model).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('throws when overlay path provided but file does not exist', () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const basePath = join(testDir, 'config.yaml');
|
||||
|
||||
writeFileSync(basePath, `
|
||||
telegram:
|
||||
bot_token: "token"
|
||||
allowed_chat_ids: [123]
|
||||
models:
|
||||
default:
|
||||
provider: anthropic
|
||||
model: claude-sonnet
|
||||
`);
|
||||
|
||||
expect(() => loadConfig(basePath, '/nonexistent/overlay.yaml')).toThrow('/nonexistent/overlay.yaml');
|
||||
});
|
||||
});
|
||||
|
||||
+50
-2
@@ -29,9 +29,57 @@ function expandEnvVarsInObject(obj: unknown): unknown {
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function loadConfig(configPath: string): Config {
|
||||
/**
|
||||
* Deep merge two plain objects. Overlay values override base values.
|
||||
* Arrays in overlay replace base arrays (not concatenated).
|
||||
* null in overlay explicitly sets key to null.
|
||||
* Only plain objects recurse; arrays, primitives, and null are overwritten.
|
||||
*/
|
||||
export function deepMerge(
|
||||
base: Record<string, unknown>,
|
||||
overlay: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const result = { ...base };
|
||||
for (const [key, value] of Object.entries(overlay)) {
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
typeof result[key] === 'object' &&
|
||||
result[key] !== null &&
|
||||
!Array.isArray(result[key])
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
result[key] as Record<string, unknown>,
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate a Flynn config from YAML file(s).
|
||||
* If overlayPath is provided, the overlay YAML is deep-merged on top of
|
||||
* the base config BEFORE env var expansion and Zod validation.
|
||||
*/
|
||||
export function loadConfig(configPath: string, overlayPath?: string): Config {
|
||||
const rawContent = readFileSync(configPath, 'utf-8');
|
||||
const rawConfig = parse(rawContent);
|
||||
let rawConfig = parse(rawContent);
|
||||
|
||||
if (overlayPath) {
|
||||
const overlayContent = readFileSync(overlayPath, 'utf-8');
|
||||
const overlayConfig = parse(overlayContent);
|
||||
if (overlayConfig && typeof overlayConfig === 'object') {
|
||||
rawConfig = deepMerge(
|
||||
rawConfig as Record<string, unknown>,
|
||||
overlayConfig as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const expandedConfig = expandEnvVarsInObject(rawConfig);
|
||||
return configSchema.parse(expandedConfig);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user