diff --git a/src/config/index.ts b/src/config/index.ts index 7f974d0..c913cec 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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'; diff --git a/src/config/loader.test.ts b/src/config/loader.test.ts index 8f7b039..e2eeeae 100644 --- a/src/config/loader.test.ts +++ b/src/config/loader.test.ts @@ -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 }); + 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'); + }); +}); diff --git a/src/config/loader.ts b/src/config/loader.ts index 30ed0f2..a3e09fa 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -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, + overlay: Record, +): Record { + 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, + value as Record, + ); + } 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, + overlayConfig as Record, + ); + } + } + const expandedConfig = expandEnvVarsInObject(rawConfig); return configSchema.parse(expandedConfig); }