222 lines
6.8 KiB
JavaScript
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);
|
|
}
|
|
}
|