Files
flynn/.planning/phases/02-config-overlays/02-01-PLAN.md
T
2026-02-09 20:44:03 -08:00

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
src/config/loader.ts
src/config/loader.test.ts
src/config/index.ts
src/cli/shared.ts
true
truths artifacts key_links
Setting FLYNN_ENV=docker loads docker.yaml overlay merged on top of base config
Starting Flynn without FLYNN_ENV works exactly as before — zero breakage
Overlay values override base values at any nesting depth
Overlay files do not need to repeat required base fields — merge happens before Zod validation
Arrays in overlays replace base arrays (not concatenated)
path provides exports contains
src/config/loader.ts deepMerge utility + overlay-aware loadConfig
loadConfig
deepMerge
deepMerge
path provides min_lines
src/config/loader.test.ts Tests for deep merge, overlay loading, env-only loading, missing overlay 100
path provides exports contains
src/cli/shared.ts resolveOverlayPath utility + overlay-aware loadConfigSafe
resolveOverlayPath
FLYNN_ENV
path provides contains
src/config/index.ts Re-exports deepMerge deepMerge
from to via pattern
src/cli/shared.ts src/config/loader.ts loadConfigSafe calls loadConfig with overlayPath loadConfig(.*overlay
from to via pattern
src/config/loader.ts src/config/loader.ts loadConfig calls deepMerge before configSchema.parse deepMerge.*parse|parse.*deepMerge
Implement config overlay merge system: deep merge utility, overlay-aware config loader, and FLYNN_ENV resolution.

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:

  1. "merges nested objects" — deepMerge({a: {b: 1, c: 2}}, {a: {c: 3}}){a: {b: 1, c: 3}}
  2. "overlay arrays replace base arrays" — deepMerge({a: [1,2]}, {a: [3]}){a: [3]}
  3. "adds new keys from overlay" — deepMerge({a: 1}, {b: 2}){a: 1, b: 2}
  4. "returns base when overlay is empty" — deepMerge({a: 1}, {}){a: 1}
  5. "handles deeply nested objects" — 3+ levels deep

Add a new describe('loadConfig with overlay') block:

  1. "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 }, call loadConfig(basePath, overlayPath), assert config.server.port === 9999 AND config.telegram.bot_token still has the base value.
  2. "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.
  3. "expands env vars in both base and overlay" — set an env var, reference it in both base and overlay, verify both expand.
  4. "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]
  • null in 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.

Task 2: Wire FLYNN_ENV resolution into shared.ts and update loadConfigSafe src/cli/shared.ts Add `resolveOverlayPath` function to `src/cli/shared.ts`:
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.

1. `pnpm test:run src/config/loader.test.ts` — all deepMerge and overlay tests pass 2. `pnpm test:run` — full suite passes (1077+ tests, no regressions) 3. `pnpm typecheck` — no type errors 4. Manual verification: the merge happens BEFORE Zod validation (overlay files don't need required fields)

<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>
After completion, create `.planning/phases/02-config-overlays/02-01-SUMMARY.md`