334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const REQUIRED_DOCS = [
|
|
"README.md",
|
|
"docs/quickstart.md",
|
|
"docs/new-project.md",
|
|
"docs/local-dev.md",
|
|
"docs/env-vars.md",
|
|
"docs/database.md",
|
|
"docs/mail.md",
|
|
"docs/worker.md",
|
|
"docs/api.md",
|
|
"docs/deployment.md",
|
|
"docs/deployments/boiler-saas-ctrl-com.md",
|
|
"docs/security.md",
|
|
"docs/observability.md",
|
|
"docs/plugins.md",
|
|
"docs/upgrades.md",
|
|
"docs/troubleshooting.md",
|
|
];
|
|
|
|
export const REQUIRED_TERM_GROUPS = [
|
|
{
|
|
id: "pinned-pnpm",
|
|
description: "Corepack and the pinned pnpm version",
|
|
terms: [/\bcorepack\b/i, /\bpnpm@10\.33\.0\b/],
|
|
},
|
|
{
|
|
id: "local-env",
|
|
description: "local .env setup without production secrets",
|
|
terms: [/\.env\b/i, /\bplaceholder/i, /production secrets?/i],
|
|
},
|
|
{
|
|
id: "postgres-prisma",
|
|
description: "PostgreSQL and Prisma database setup",
|
|
terms: [/\bDATABASE_URL\b/, /\bPostgreSQL\b|\bPostgres\b/i, /\bPrisma\b/i],
|
|
},
|
|
{
|
|
id: "seed-web-runtime",
|
|
description: "seed command and local web runtime",
|
|
terms: [/\bpnpm seed\b/, /\bpnpm dev\b/, /localhost:3000/i],
|
|
},
|
|
{
|
|
id: "optional-worker-redis",
|
|
description: "optional worker Redis boundary",
|
|
terms: [/\bWORKER_ENABLED\b/, /\bBULLMQ_REDIS_URL\b/, /\bRedis\b/i],
|
|
},
|
|
{
|
|
id: "manual-e2e-boundary",
|
|
description: "manual browser/E2E verification boundary",
|
|
terms: [/\bmanual(?:-only)?\b/i, /\bE2E\b|\bPlaywright\b|\bCypress\b|\bbrowser\b/i, /explicitly approved|explicit approval/i],
|
|
},
|
|
{
|
|
id: "docs-entrypoints",
|
|
description: "README and onboarding links to docs entrypoints",
|
|
terms: [/docs\/quickstart\.md/, /docs\/new-project\.md/, /docs\/local-dev\.md/],
|
|
},
|
|
{
|
|
id: "verification-troubleshooting",
|
|
description: "focused non-E2E verification and troubleshooting guidance",
|
|
terms: [/\bnon-E2E\b/i, /\bverification\b/i, /\btroubleshooting\b/i],
|
|
},
|
|
{
|
|
id: "s05-plugin-candidates",
|
|
description: "plugin candidates documented as extraction contracts, not packaged plugins",
|
|
terms: [/\bplugin-candidate\b|\bplugin candidates?\b/i, /\bextraction contracts?\b/i, /not (?:an )?(?:already-)?packaged plugin|not already-packaged plugins?/i],
|
|
},
|
|
{
|
|
id: "s05-upgrade-guidance",
|
|
description: "upgrade runbook covers pinned pnpm, database/runtime/provider upgrades, and non-E2E checks",
|
|
terms: [/\bupgrade(?:s|d)?\b/i, /\bpnpm@10\.33\.0\b/, /\bPrisma\b|\bdatabase\b/i, /\bnon-E2E\b/i],
|
|
},
|
|
{
|
|
id: "s05-troubleshooting-runbook",
|
|
description: "production troubleshooting runbook covers safe evidence collection and redaction",
|
|
terms: [/\btroubleshooting runbook\b/i, /\btriage\b/i, /\bsafe evidence\b/i, /\bredact(?:ed|ion)?\b|\braw secrets?\b/i],
|
|
},
|
|
{
|
|
id: "s05-docs-link-check-ci",
|
|
description: "documentation names the local docs link checker and CI-safe non-E2E gate",
|
|
terms: [/\bverify:docs-links\b|\bdocs link check\b/i, /\bCI\b/i, /\bnon-E2E\b/i],
|
|
},
|
|
{
|
|
id: "s03-env-secrets-redaction",
|
|
description: "environment secret key handling and redacted diagnostics",
|
|
terms: [/\benv(?:ironment)?\b|\.env\b/i, /\bsecret(?:s)?\b|\bAPI_KEY\b|\bTOKEN\b/i, /\bredact(?:ed|ion)?\b|\bmask(?:ed|ing)?\b/i],
|
|
},
|
|
{
|
|
id: "s03-database-operations",
|
|
description: "database migration, status, and backup operations",
|
|
terms: [/\bdatabase\b|\bPostgreSQL\b|\bPostgres\b/i, /\bmigrat(?:e|ion|ions)\b|\bdeploy\b/i, /\bstatus\b|\bhealth\b/i, /\bbackup(?:s)?\b|\brestore\b/i],
|
|
},
|
|
{
|
|
id: "s03-mailersend-boundaries",
|
|
description: "MailerSend outbound mail and webhook signing boundaries",
|
|
terms: [/\bMailerSend\b/i, /\boutbound\b|\bemail\b|\bmail\b/i, /\bwebhook(?:s)?\b/i, /\bsign(?:ing|ature|ed)\b/i],
|
|
},
|
|
{
|
|
id: "s03-worker-runtime",
|
|
description: "worker enablement, Redis, queue health, and runtime boundary",
|
|
terms: [/\bWORKER_ENABLED\b|\benable(?:d|ment)?\b/i, /\bRedis\b|\bBULLMQ_REDIS_URL\b/i, /\bqueue\b|\bjob(?:s)?\b/i, /\bhealth\b|\bdiagnostic(?:s)?\b/i],
|
|
},
|
|
{
|
|
id: "s03-production-troubleshooting-redacted",
|
|
description: "production troubleshooting with redacted output and no raw secrets",
|
|
terms: [/\bproduction\b/i, /\btroubleshoot(?:ing)?\b|\bfailure(?:s)?\b|\bdiagnostic(?:s)?\b/i, /\bredact(?:ed|ion)?\b|\bmask(?:ed|ing)?\b/i, /\braw secret(?:s)?\b|\bsecret value(?:s)?\b/i],
|
|
},
|
|
{
|
|
id: "s04-api-auth-openapi-request-id",
|
|
description: "API authentication, OpenAPI surface, and request ID correlation",
|
|
terms: [/\bAPI\b/i, /\bauth(?:entication|orization)?\b|\bBearer\b|\bAPI key\b/i, /\bOpenAPI\b|\bswagger\b/i, /\brequest[- ]id\b|\bX-Request-ID\b|\bcorrelation id\b/i],
|
|
},
|
|
{
|
|
id: "s04-deployment-image-coolify-runtime",
|
|
description: "deployment image, Coolify, and manual runtime boundaries",
|
|
terms: [/\bdeploy(?:ment)?\b|\brelease\b/i, /\bimage\b|\bcontainer\b|\bDocker\b/i, /\bCoolify\b/i, /\bmanual(?:-only)?\b/i, /\bruntime\b|\bstart command\b|\bservice boundary\b/i],
|
|
},
|
|
{
|
|
id: "s04-separate-worker-groups",
|
|
description: "separate Coolify worker applications and the permanent mail worker group",
|
|
terms: [
|
|
/\bworker-general\b/i,
|
|
/\bworker-mail\b/i,
|
|
/\bworker-plugin\b/i,
|
|
/\bWORKER_QUEUE_GROUP=mail\b/,
|
|
/docs\/ops\/worker-queues\.md/i,
|
|
],
|
|
},
|
|
{
|
|
id: "s04-security-headers-cors-secrets",
|
|
description: "security headers, CORS, and secret handling",
|
|
terms: [/\bsecurity\b/i, /\bheaders?\b|\bCSP\b|\bHSTS\b/i, /\bCORS\b|\ballowed origins?\b/i, /\bsecret(?:s)?\b|\bTOKEN\b|\bAPI_KEY\b/i, /\bredact(?:ed|ion)?\b|\bmask(?:ed|ing)?\b/i],
|
|
},
|
|
{
|
|
id: "s04-observability-diagnostics-health-queue-triage",
|
|
description: "redacted diagnostics, health/readiness, queue inspection, and failure triage",
|
|
terms: [/\bobservability\b|\bdiagnostic(?:s)?\b/i, /\bredact(?:ed|ion)?\b|\bmask(?:ed|ing)?\b/i, /\bhealth\b|\breadiness\b|\bready\b/i, /\bqueue\b|\bjob(?:s)?\b/i, /\bfailure triage\b|\btriage\b|\bincident\b/i],
|
|
},
|
|
];
|
|
|
|
export const FORBIDDEN_PLACEHOLDER_PATTERN = /\b(?:TODO|TBD|FIXME)\b/;
|
|
|
|
export const FORBIDDEN_AUTOMATIC_E2E_PATTERNS = [
|
|
{
|
|
id: "playwright-test-command",
|
|
pattern: /(?:^|[`\n>\s])(?:pnpm\s+(?:exec\s+)?|npx\s+)?playwright\s+test\b/i,
|
|
},
|
|
{
|
|
id: "cypress-command",
|
|
pattern: /(?:^|[`\n>\s])(?:pnpm\s+(?:exec\s+)?|npx\s+)?cypress\s+(?:run|open)\b/i,
|
|
},
|
|
{
|
|
id: "browser-e2e-script-command",
|
|
pattern: /(?:^|[`\n>\s])(?:pnpm|npm|yarn)\s+(?:run\s+)?(?:test:e2e|e2e|browser)\b/i,
|
|
},
|
|
];
|
|
|
|
export const FORBIDDEN_LIVE_STATUS_DRIFT_PATTERNS = [
|
|
{
|
|
id: "superseded-persistent-mail-worker-gap",
|
|
pattern: /Remaining gap:\s*persistent Coolify mail-worker service/i,
|
|
message: "superseded B-014 mail-worker blocker wording must not appear in live production docs",
|
|
},
|
|
{
|
|
id: "plugin-production-enabled-before-signoff",
|
|
pattern: /third-party plugin execution (?:is|as) production-enabled/i,
|
|
message: "third-party plugin execution must stay gated by external WASM signoff",
|
|
},
|
|
];
|
|
|
|
function normalizeRelativePath(relativePath) {
|
|
return relativePath.split(path.sep).join("/");
|
|
}
|
|
|
|
function lineAndColumnForIndex(content, index) {
|
|
const prefix = content.slice(0, index);
|
|
const lines = prefix.split(/\r?\n/);
|
|
return { line: lines.length, column: lines.at(-1).length + 1 };
|
|
}
|
|
|
|
function readDocs(rootDir, requiredDocs = REQUIRED_DOCS) {
|
|
const docs = new Map();
|
|
const failures = [];
|
|
|
|
for (const relativePath of requiredDocs) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
if (!fs.existsSync(absolutePath)) {
|
|
failures.push(`${relativePath}: missing required documentation file`);
|
|
continue;
|
|
}
|
|
|
|
const content = fs.readFileSync(absolutePath, "utf8");
|
|
if (content.trim().length === 0) {
|
|
failures.push(`${relativePath}: documentation file is empty`);
|
|
continue;
|
|
}
|
|
|
|
docs.set(normalizeRelativePath(relativePath), content);
|
|
}
|
|
|
|
return { docs, failures };
|
|
}
|
|
|
|
function checkForbiddenPlaceholders(docs) {
|
|
const failures = [];
|
|
|
|
for (const [relativePath, content] of docs.entries()) {
|
|
const match = FORBIDDEN_PLACEHOLDER_PATTERN.exec(content);
|
|
if (!match) continue;
|
|
|
|
const { line, column } = lineAndColumnForIndex(content, match.index);
|
|
failures.push(`${relativePath}:${line}:${column}: forbidden placeholder marker "${match[0]}"`);
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function checkForbiddenAutomaticE2e(docs) {
|
|
const failures = [];
|
|
|
|
for (const [relativePath, content] of docs.entries()) {
|
|
for (const { id, pattern } of FORBIDDEN_AUTOMATIC_E2E_PATTERNS) {
|
|
const match = pattern.exec(content);
|
|
if (!match) continue;
|
|
|
|
const { line, column } = lineAndColumnForIndex(content, match.index + match[0].search(/\S/));
|
|
failures.push(`${relativePath}:${line}:${column}: forbidden automatic browser/E2E command (${id})`);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function checkForbiddenLiveStatusDrift(docs) {
|
|
const failures = [];
|
|
|
|
for (const [relativePath, content] of docs.entries()) {
|
|
for (const { id, pattern, message } of FORBIDDEN_LIVE_STATUS_DRIFT_PATTERNS) {
|
|
const match = pattern.exec(content);
|
|
if (!match) continue;
|
|
|
|
const { line, column } = lineAndColumnForIndex(content, match.index);
|
|
failures.push(`${relativePath}:${line}:${column}: ${message} (${id})`);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function checkRequiredTermGroups(docs, termGroups = REQUIRED_TERM_GROUPS) {
|
|
const failures = [];
|
|
const combined = [...docs.entries()].map(([relativePath, content]) => `\n--- ${relativePath} ---\n${content}`).join("\n");
|
|
|
|
for (const group of termGroups) {
|
|
const missingTerms = group.terms.filter((term) => !term.test(combined));
|
|
if (missingTerms.length > 0) {
|
|
failures.push(`required term group "${group.id}" missing ${missingTerms.length} term(s): ${group.description}`);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function checkDeploymentCommandSlotDocs(docs) {
|
|
const failures = [];
|
|
const requiredSlotDocs = ["docs/deployment.md", "docs/deployments/boiler-saas-ctrl-com.md"];
|
|
|
|
for (const relativePath of requiredSlotDocs) {
|
|
const content = docs.get(relativePath);
|
|
if (!content) continue;
|
|
|
|
if (!/\bpre_deployment_command\b/.test(content) || !/\bprisma\s+migrate\s+deploy\b/.test(content)) {
|
|
failures.push(`${relativePath}: must document Coolify pre_deployment_command as the Prisma migration slot`);
|
|
}
|
|
if (!/\bpost_deployment_command\b/.test(content) || !/production-seed\.server\.js/.test(content)) {
|
|
failures.push(`${relativePath}: must document Coolify post_deployment_command as the guarded seed slot`);
|
|
}
|
|
if (/Pre-deploy command:[^\n]*(?:&&|then)[^\n]*production-seed\.server\.js/i.test(content)) {
|
|
failures.push(`${relativePath}: must not describe the guarded seed as combined into the pre-deploy command`);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
export function verifyProductionDocsDocs(rootDir = process.cwd(), options = {}) {
|
|
const requiredDocs = options.requiredDocs ?? REQUIRED_DOCS;
|
|
const termGroups = options.termGroups ?? REQUIRED_TERM_GROUPS;
|
|
const { docs, failures: readFailures } = readDocs(rootDir, requiredDocs);
|
|
const failures = [
|
|
...readFailures,
|
|
...checkForbiddenPlaceholders(docs),
|
|
...checkForbiddenAutomaticE2e(docs),
|
|
...checkForbiddenLiveStatusDrift(docs),
|
|
...checkRequiredTermGroups(docs, termGroups),
|
|
...checkDeploymentCommandSlotDocs(docs),
|
|
];
|
|
|
|
return {
|
|
passes: failures.length === 0 ? [`${requiredDocs.length} required documentation files satisfy the Production Docs production documentation contract`] : [],
|
|
failures,
|
|
observations: [`checked files: ${requiredDocs.join(", ")}`],
|
|
};
|
|
}
|
|
|
|
export function formatProductionDocsDocsResult(result) {
|
|
const status = result.failures.length > 0 ? "FAIL" : "PASS";
|
|
return [
|
|
`${status} Production Docs production documentation 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 = verifyProductionDocsDocs(process.cwd());
|
|
const output = formatProductionDocsDocsResult(result);
|
|
|
|
if (result.failures.length > 0) {
|
|
process.stderr.write(output);
|
|
process.exitCode = 1;
|
|
} else {
|
|
process.stdout.write(output);
|
|
}
|
|
}
|