Spaces:
Running
Running
| // | |
| // Sync-check: every env var read by `src/` MUST be documented in | |
| // `.env.example`. Runs in CI as a soft guard rail β keeps `.env.example` | |
| // from drifting behind real config-surface additions. | |
| // | |
| // Usage: | |
| // node scripts/check-env-example.mjs | |
| // | |
| // Returns 0 when in sync, 1 with a diff when out of sync. | |
| import { readFileSync, readdirSync, statSync } from "node:fs"; | |
| import { join } from "node:path"; | |
| const ROOT = new URL("..", import.meta.url).pathname; | |
| const SRC = join(ROOT, "src"); | |
| const ENV_FILE = join(ROOT, ".env.example"); | |
| // Env vars read by the runtime but NOT user-facing config β these are | |
| // either process-injected (HOME, PATH, USERPROFILE), set by the build / | |
| // wrapper (NODE_*, npm_*), or set by tests (VITEST, *_TEST_*). Skipping | |
| // them keeps `.env.example` a documented config surface rather than an | |
| // inventory of every getenv anywhere in the codebase. | |
| const RUNTIME_ONLY = new Set([ | |
| "HOME", | |
| "PATH", | |
| "USERPROFILE", | |
| "NODE_ENV", | |
| "AGENTMEMORY_SDK_CHILD", | |
| ]); | |
| // Walk src/ for .ts / .mts / .mjs / .js files (excluding `.d.ts` declarations | |
| // and dotfile dirs / node_modules). test/ lives outside src/ so it never enters. | |
| function walk(dir) { | |
| const out = []; | |
| for (const entry of readdirSync(dir)) { | |
| const full = join(dir, entry); | |
| const s = statSync(full); | |
| if (s.isDirectory()) { | |
| if (entry === "node_modules" || entry.startsWith(".")) continue; | |
| out.push(...walk(full)); | |
| } else if (/\.(ts|mts|mjs|js)$/.test(entry) && !entry.endsWith(".d.ts")) { | |
| out.push(full); | |
| } | |
| } | |
| return out; | |
| } | |
| // Multiple patterns: | |
| // process.env["KEY"] β direct access | |
| // env["KEY"] β local alias inside detectProvider, etc. | |
| // getEnvVar("KEY") β helper from src/config.ts | |
| // env: ProcessEnv β env.KEY β caught as `env["KEY"]` only; if you add | |
| // a dotted-access path, extend the regex. | |
| const PATTERNS = [ | |
| // Direct map index: process.env["KEY"], env["KEY"], getMergedEnv()["KEY"]. | |
| // The trailing `]\s*` form covers `β¦)["KEY"]` and `β¦env["KEY"]`. | |
| /\[\s*"([A-Z][A-Z0-9_]+)"\s*\]/g, | |
| /getEnvVar\(\s*"([A-Z][A-Z0-9_]+)"\s*\)/g, | |
| ]; | |
| const used = new Set(); | |
| for (const file of walk(SRC)) { | |
| const text = readFileSync(file, "utf8"); | |
| for (const pat of PATTERNS) { | |
| pat.lastIndex = 0; | |
| let m; | |
| while ((m = pat.exec(text)) !== null) { | |
| const name = m[1]; | |
| if (!RUNTIME_ONLY.has(name)) used.add(name); | |
| } | |
| } | |
| } | |
| const envText = readFileSync(ENV_FILE, "utf8"); | |
| const documented = new Set(); | |
| for (const line of envText.split("\n")) { | |
| const m = line.match(/^#?\s*([A-Z][A-Z0-9_]+)=/); | |
| if (m) documented.add(m[1]); | |
| } | |
| const missing = [...used].filter((k) => !documented.has(k)).sort(); | |
| const orphan = [...documented].filter((k) => !used.has(k)).sort(); | |
| if (missing.length === 0 && orphan.length === 0) { | |
| console.log(`env-example: in sync (${used.size} keys documented)`); | |
| process.exit(0); | |
| } | |
| if (missing.length > 0) { | |
| console.error( | |
| `env-example: MISSING from .env.example β add documentation for these keys:`, | |
| ); | |
| for (const k of missing) console.error(` - ${k}`); | |
| } | |
| if (orphan.length > 0) { | |
| console.error( | |
| `env-example: ORPHAN in .env.example β no longer read by src/, remove or move to runtime-only allowlist:`, | |
| ); | |
| for (const k of orphan) console.error(` - ${k}`); | |
| } | |
| process.exit(1); | |