9.8 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-config-overlays | 01 | tdd | 1 |
|
true |
|
Purpose: Enable operators to manage environment-specific configs (dev/docker/production) with a base file plus lightweight overlay files, selected by FLYNN_ENV.
Output: Working overlay merge in loader.ts, FLYNN_ENV resolution in shared.ts, comprehensive tests proving merge behavior and backward compatibility.
<execution_context> @/home/will/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/will/.config/opencode/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md@src/config/loader.ts @src/config/loader.test.ts @src/config/schema.ts @src/config/index.ts @src/cli/shared.ts @src/cli/start.ts
Task 1: Implement deepMerge and overlay-aware loadConfig with tests src/config/loader.ts, src/config/loader.test.ts, src/config/index.ts **RED phase — write failing tests first in `src/config/loader.test.ts`:**Add a new describe('deepMerge') block testing the exported deepMerge function:
- "merges nested objects" —
deepMerge({a: {b: 1, c: 2}}, {a: {c: 3}})→{a: {b: 1, c: 3}} - "overlay arrays replace base arrays" —
deepMerge({a: [1,2]}, {a: [3]})→{a: [3]} - "adds new keys from overlay" —
deepMerge({a: 1}, {b: 2})→{a: 1, b: 2} - "returns base when overlay is empty" —
deepMerge({a: 1}, {})→{a: 1} - "handles deeply nested objects" — 3+ levels deep
Add a new describe('loadConfig with overlay') block:
- "merges overlay on top of base config" — create base config.yaml with full valid config (telegram + models), create overlay.yaml with only
server: { port: 9999 }, callloadConfig(basePath, overlayPath), assertconfig.server.port === 9999ANDconfig.telegram.bot_tokenstill has the base value. - "loads base-only when no overlay provided" — call
loadConfig(basePath)(no second arg), assert it works identically to current behavior. This is the backward-compatibility test. - "expands env vars in both base and overlay" — set an env var, reference it in both base and overlay, verify both expand.
- "throws when overlay path provided but file doesn't exist" — call
loadConfig(basePath, '/nonexistent/overlay.yaml'), expect it to throw with a message containing the path.
Run tests — they should FAIL (deepMerge and overlay parameter don't exist yet).
GREEN phase — implement in src/config/loader.ts:
Add deepMerge function (export it):
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;
}
Key design decisions:
- Arrays REPLACE (not concat) — overlay
allowed_chat_ids: [999]replaces base[123] nullin overlay sets key to null (explicit nullification)- Only plain objects recurse; arrays, primitives, and null are overwritten
Modify loadConfig signature to accept optional overlay:
export function loadConfig(configPath: string, overlayPath?: string): Config {
const rawContent = readFileSync(configPath, 'utf-8');
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);
}
IMPORTANT: The merge happens BEFORE env var expansion and BEFORE Zod validation. This means:
- Overlay files don't need required fields (telegram, models) — those come from base
- Env vars in overlay get expanded too
- The final merged+expanded object is what Zod validates
Update src/config/index.ts to re-export deepMerge:
export { loadConfig, deepMerge } from './loader.js';
Run pnpm test:run src/config/loader.test.ts — all tests should PASS.
Run pnpm test:run src/config/loader.test.ts — all existing tests still pass, all new deepMerge and overlay tests pass.
deepMerge is exported and tested with 5+ cases. loadConfig accepts optional overlayPath, merges before validation. Backward compatibility confirmed (no overlay = same behavior). Existing 3 tests still pass.
import { dirname, join } from 'path';
import { existsSync } from 'fs';
/**
* Resolve overlay config path from FLYNN_ENV.
* If FLYNN_ENV is set, looks for {configDir}/{FLYNN_ENV}.yaml relative to the base config file.
* Returns undefined if FLYNN_ENV is not set.
* Returns the overlay path if FLYNN_ENV is set (even if file doesn't exist — caller decides error handling).
*/
export function resolveOverlayPath(basePath: string): string | undefined {
const env = process.env.FLYNN_ENV;
if (!env) return undefined;
const configDir = dirname(basePath);
return join(configDir, `${env}.yaml`);
}
Note: This function does NOT check if the file exists. Different callers handle missing overlays differently:
loadConfigSafe/loadConfig→ throw (file was expected)doctor→ report as check failure (diagnostic, not crash)
Update loadConfigSafe to pass overlay path to loadConfig:
export function loadConfigSafe(configPath?: string): { config?: Config; error?: string } {
const path = configPath ?? getConfigPath();
try {
const overlayPath = resolveOverlayPath(path);
const config = loadConfig(path, overlayPath);
return { config };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { error: `Failed to load config from ${path}: ${message}` };
}
}
This means ALL CLI commands that use loadConfigSafe() (start, doctor, etc.) automatically get overlay support. The daemon itself (startDaemon(config)) receives the already-merged Config — no changes needed downstream.
Update the import at the top of shared.ts to include dirname and join from 'path' (resolve is already imported — add dirname and join). Note: existsSync is NOT needed in shared.ts since resolveOverlayPath doesn't check existence.
Run the full test suite: pnpm test:run to verify nothing is broken.
Run pnpm test:run — full suite passes (1077+ tests). No regressions.
resolveOverlayPath exported from shared.ts. loadConfigSafe passes overlay path to loadConfig. All CLI commands using loadConfigSafe() automatically get overlay support. Full test suite passes.
<success_criteria>
- deepMerge function exported from config/loader.ts with 5+ test cases
- loadConfig accepts optional overlayPath, merges before Zod parse
- resolveOverlayPath in shared.ts resolves FLYNN_ENV to overlay path
- loadConfigSafe passes overlay path through to loadConfig
- No FLYNN_ENV set = exact same behavior as before (backward compatible)
- Full test suite passes with zero regressions </success_criteria>