From 4037fa68f9e1b7af870c847483d62a7d31445336 Mon Sep 17 00:00:00 2001 From: joelteply Date: Mon, 8 Jun 2026 21:08:42 -0500 Subject: [PATCH] =?UTF-8?q?fix(tools):=20inline=20dotenv.parse=20in=20gene?= =?UTF-8?q?rate-config=20=E2=80=94=20kill=20canary=20CI=20breakage=20from?= =?UTF-8?q?=20layout=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout sweep (PR #1557, task #214) moved `generate-config.ts` from `src/generator/` to `tools/generator/`. Node's module resolution walks ancestors of the SCRIPT location, not `process.cwd()`, so the `import * as dotenv from 'dotenv'` at the top now traverses `tools/generator/` -> `tools/` -> `/` looking for `node_modules/dotenv` and finds nothing — `dotenv` only exists at `src/node_modules/dotenv` (a SIBLING of `tools/`, not an ancestor). Every TS-validation CI job that invokes `npx tsx ../tools/generator/generate-config.ts` has been failing on canary since the layout sweep merged: Error: Cannot find module 'dotenv' Require stack: - /home/runner/work/continuum/continuum/tools/generator/generate-config.ts Surfaced via PR #1561's validate / ts-eslint-baseline-ratchet / verify-architectures / verify-after-rebuild checks. NOT caused by that PR's diff — pre-existing canary infra regression that has been silently breaking every TS PR check since the layout sweep landed. Fix: replace `dotenv.parse()` with a 15-line inline `parseEnvText`. The script only ever called `.parse()` (the pure string-to-KV transform), never the `.config()` side-effect path that mutates process.env. Inline parser handles the same shape (KEY=value, optional surrounding quotes, # comments, blank lines). Like-for-like behavioral replacement at zero dep cost. Doctrinal bonus: generator scripts now have a node-stdlib-only footprint, matching `generate-version.ts`'s shape. No upward-walk module-resolution surprises possible. Aligns with task #209 ("npm start IS the headless Rust binary, period") — fewer Node deps in the build path is unambiguously good. Verified locally: cd src && npx tsx ../tools/generator/generate-config.ts -> "shared/config.ts unchanged" (idempotent on re-run) -> HTTP_PORT/WS_PORT defaults pick up correctly -> ACTIVE_EXAMPLE resolved from main package.json Net diff: -1 import + 6 lines removed, +33 lines added (parseEnvText + doc block explaining WHY this exists). No behavior change on the happy path. Co-Authored-By: Claude Opus 4.7 --- tools/generator/generate-config.ts | 36 ++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/tools/generator/generate-config.ts b/tools/generator/generate-config.ts index 18512c41c7..a5c14765e0 100644 --- a/tools/generator/generate-config.ts +++ b/tools/generator/generate-config.ts @@ -10,10 +10,42 @@ import { readFileSync } from 'fs'; import { writeIfChanged } from './core/writeIfChanged'; import { join } from 'path'; -import * as dotenv from 'dotenv'; const rootDir = process.cwd(); +// Inline `KEY=value` parser — replaces the previous `import * as dotenv +// from 'dotenv'` dependency. The layout sweep (PR #1557, task #214) +// moved this script from `src/generator/` to `tools/generator/`, which +// put it OUTSIDE the upward `node_modules` walk that resolves +// `src/node_modules/dotenv`. Node's resolution walks ancestors of the +// SCRIPT location, not `process.cwd()`, so `dotenv` was unfindable +// from `tools/generator/`. We only ever called `dotenv.parse()` (the +// pure string→KV transform), not the `.config()` side-effect path, so +// the inline parser is a like-for-like replacement at zero +// architectural cost. Bonus: generator scripts now have a node-stdlib- +// only footprint, matching `generate-version.ts`'s shape. +function parseEnvText(text: string): Record { + const result: Record = {}; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + // Strip a single matched pair of surrounding quotes — matches + // dotenv's behavior for `KEY="value"` and `KEY='value'`. + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + result[key] = value; + } + return result; +} + // Read config.env (follow SecretManager pattern for file locations) function loadConfigEnv(): Record { const configPaths = [ @@ -25,7 +57,7 @@ function loadConfigEnv(): Record { for (const configPath of configPaths) { try { - const parsed = dotenv.parse(readFileSync(configPath, 'utf-8')); + const parsed = parseEnvText(readFileSync(configPath, 'utf-8')); config = { ...config, ...parsed }; } catch { // File doesn't exist, continue