#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { envVariableDefinitions } from "../packages/config/src/catalog.ts"; const ENV_FILES = [".env.example", ".env.local.example", ".env.production.example", ".env.fly.example", ".env.test"]; const SHARED_REQUIRED_FILES = [".env.example", ".env.test"]; const PRODUCTION_EXAMPLE_FILES = [".env.production.example", ".env.fly.example"]; const PRODUCTION_PLACEHOLDER_PATTERN = /(^|[.:/@-])(?:app\.|admin\.|api\.)?example\.(?:com|org|net)(?:$|[/:?&.#-])/i; const KEY_PATTERN = /(?:^|\s)([A-Z][A-Z0-9_]*)(?:\s*=|=)/g; function readTextFile(rootDir, relativePath) { return fs.readFileSync(path.join(rootDir, relativePath), "utf8"); } function normalizeLine(rawLine) { return rawLine.trim().replace(/\\$/, "").trim(); } function isCommentedAssignment(line) { return /^#\s*[A-Z][A-Z0-9_]*\s*=/.test(line); } function stripInlineComment(value) { let quote = null; for (let index = 0; index < value.length; index += 1) { const character = value[index]; const previous = value[index - 1]; if ((character === '"' || character === "'") && previous !== "\\") { quote = quote === character ? null : quote ?? character; continue; } if (character === "#" && quote === null && /\s/.test(previous ?? " ")) { return value.slice(0, index).trim(); } } return value.trim(); } function unquoteValue(value) { const trimmed = value.trim(); if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { return trimmed.slice(1, -1); } return trimmed; } function parseEnvFile(rootDir, relativePath) { const entries = []; const duplicateKeys = []; const seen = new Set(); const content = readTextFile(rootDir, relativePath); const lines = content.split(/\r?\n/); lines.forEach((rawLine, index) => { let line = normalizeLine(rawLine); if (line.startsWith("flyctl secrets set ")) { line = line.replace(/^flyctl secrets set\s+/, ""); } if (line.length === 0 || line.startsWith("#") || line === "--app YOUR_APP_NAME") { return; } KEY_PATTERN.lastIndex = 0; for (const match of line.matchAll(KEY_PATTERN)) { const key = match[1]; const valueStart = match.index + match[0].lastIndexOf("=") + 1; const value = unquoteValue(stripInlineComment(line.slice(valueStart))); const entry = { key, value, line: index + 1 }; entries.push(entry); if (relativePath !== ".env.fly.example") { if (seen.has(key)) { duplicateKeys.push(entry); } else { seen.add(key); } } } }); return { entries, duplicateKeys, commentedKeys: parseCommentedKeys(content) }; } function parseCommentedKeys(content) { const keys = new Set(); content.split(/\r?\n/).forEach((rawLine) => { const line = rawLine.trim(); if (!isCommentedAssignment(line)) { return; } const match = /^#\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line); if (match) { keys.add(match[1]); } }); return keys; } function buildCatalog() { const staticNames = new Set(); const dynamicPrefixes = []; const requiredNames = new Set(); for (const definition of envVariableDefinitions) { if (definition.dynamicPrefix) { dynamicPrefixes.push(definition.dynamicPrefix); } else { staticNames.add(definition.name); } if (definition.requiredInProduction) { requiredNames.add(definition.name); } } return { staticNames, dynamicPrefixes, requiredNames }; } function isCatalogedKey(key, catalog) { return catalog.staticNames.has(key) || catalog.dynamicPrefixes.some((prefix) => key.startsWith(prefix)); } function checkEnvFile(rootDir, relativePath, catalog) { const passes = []; const failures = []; const { entries, duplicateKeys, commentedKeys } = parseEnvFile(rootDir, relativePath); const activeKeys = new Set(entries.map((entry) => entry.key)); for (const entry of entries) { if (!isCatalogedKey(entry.key, catalog)) { failures.push(`${relativePath}:${entry.line}: unknown-key ${entry.key}`); } } for (const duplicate of duplicateKeys) { failures.push(`${relativePath}:${duplicate.line}: duplicate-key ${duplicate.key}`); } if ((relativePath === ".env.production.example" || relativePath === ".env.fly.example") && activeKeys.has("REDIS_URL")) { const redisEntry = entries.find((entry) => entry.key === "REDIS_URL"); failures.push(`${relativePath}:${redisEntry?.line ?? 1}: redis-url-must-remain-commented REDIS_URL`); } if (relativePath === ".env.test" || PRODUCTION_EXAMPLE_FILES.includes(relativePath)) { for (const entry of entries) { if (PRODUCTION_PLACEHOLDER_PATTERN.test(entry.value) && !entry.value.endsWith("@example.com")) { failures.push(`${relativePath}:${entry.line}: production-domain-placeholder ${entry.key}`); } } } for (const requiredName of catalog.requiredNames) { if ((SHARED_REQUIRED_FILES.includes(relativePath) || PRODUCTION_EXAMPLE_FILES.includes(relativePath)) && !activeKeys.has(requiredName) && !commentedKeys.has(requiredName)) { failures.push(`${relativePath}: missing-required-key ${requiredName}`); } } if (failures.length === 0) { passes.push(`${relativePath}: ${activeKeys.size} active keys match catalog`); } return { passes, failures, observations: [] }; } export function checkEnvDrift(rootDir = process.cwd()) { const catalog = buildCatalog(); const result = { passes: [], failures: [], observations: [] }; for (const relativePath of ENV_FILES) { if (!fs.existsSync(path.join(rootDir, relativePath))) { result.failures.push(`${relativePath}: missing-env-file`); continue; } const check = checkEnvFile(rootDir, relativePath, catalog); result.passes.push(...check.passes); result.failures.push(...check.failures); result.observations.push(...check.observations); } return result; } export function formatEnvDriftResult(result) { const status = result.failures.length > 0 ? "FAIL" : "PASS"; return [ `${status} env drift verification`, ...result.passes.map((pass) => `PASS ${pass}`), ...result.observations.map((observation) => `OBSERVE ${observation}`), ...result.failures.map((failure) => `FAIL ${failure}`), `Summary: ${result.passes.length} passed, ${result.observations.length} observed, ${result.failures.length} failed`, "", ].join("\n"); } const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); if (isCli) { const result = checkEnvDrift(process.cwd()); const output = formatEnvDriftResult(result); if (result.failures.length > 0) { process.stderr.write(output); process.exitCode = 1; } else { process.stdout.write(output); } }