Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/check-env-drift.mjs
T

222 lines
6.8 KiB
JavaScript

#!/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);
}
}