#!/usr/bin/env node /** * emit-retention-policy.mjs — Collection retention policy emitter. * * Walks packages/*\/src/integrations/cms/collections/*.ts, validates that each * collection declares custom.retention.purgeSchedule, and emits a deterministic * YAML retention schedule at compliance/retention-policy.yml. * * Usage: * node scripts/compliance/emit-retention-policy.mjs # write compliance/retention-policy.yml * node scripts/compliance/emit-retention-policy.mjs --print # write to stdout * node scripts/compliance/emit-retention-policy.mjs --check # diff vs committed file; exit 1 on mismatch */ import fs from "node:fs"; import path from "node:path"; import { findCollectionFiles, parseCollectionFile, unifiedDiff, REPO_ROOT, } from "./emit-data-map.mjs"; export { findCollectionFiles, parseCollectionFile, unifiedDiff, REPO_ROOT }; export const OUTPUT_PATH = "compliance/retention-policy.yml"; // ---- Validation ---- /** * Validate that every collection has custom.retention.purgeSchedule. * Returns an array of error messages (empty array = valid). */ export function validateRetentionPolicy(rawCollections) { const errors = []; for (const coll of rawCollections) { if (!coll || typeof coll.slug !== "string") continue; const retention = coll.custom && coll.custom.retention; if (!retention || typeof retention.purgeSchedule !== "string") { errors.push( `Collection "${coll.slug}" is missing custom.retention.purgeSchedule.\n` + ` Hint: add to your collection config:\n` + ` custom: { retention: { purgeSchedule: "daily" | "weekly" | "monthly" } }`, ); } } return errors; } // ---- Retention policy builder ---- /** * Build the retention policy map from parsed collections. * Returns Record */ export function buildRetentionPolicy(rawCollections) { const map = {}; for (const coll of rawCollections) { if (!coll || typeof coll.slug !== "string") continue; const retention = (coll.custom && coll.custom.retention) || {}; const entry = { slug: coll.slug, purgeSchedule: retention.purgeSchedule }; if (retention.activeRetention) { entry.activeRetention = { ...retention.activeRetention }; } if (retention.coldArchive) { entry.coldArchive = { ...retention.coldArchive }; } if (retention.postDeletion) { entry.postDeletion = { ...retention.postDeletion }; } map[coll.slug] = entry; } return map; } // ---- YAML serialization ---- const YAML_HEADER = [ "# compliance/retention-policy.yml — Collection retention schedules", "# Generated by scripts/compliance/emit-retention-policy.mjs — do not edit manually.", "# Run `pnpm compliance:retention-policy` to regenerate.", ].join("\n"); /** Quote a YAML string scalar only when necessary. */ function yamlStr(s) { if (typeof s !== "string") return String(s); if ( s === "" || ["true", "false", "null", "yes", "no", "on", "off"].includes(s) || /[[{},:#&*!|>'"%@`\]]/u.test(s) || /^\s|\s$/.test(s) ) { return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } return s; } /** * Render an optional retention sub-object (activeRetention, postDeletion, coldArchive). * Keys are sorted alphabetically for determinism. */ function renderRetentionBlock(label, obj, indent) { let out = `${indent}${label}:\n`; for (const key of Object.keys(obj).sort()) { out += `${indent} ${key}: ${yamlStr(obj[key])}\n`; } return out; } /** * Render the retention policy as deterministic YAML. * Collections sorted by slug; optional sub-objects rendered alphabetically by key. */ export function renderRetentionPolicyYaml(policyMap) { let yaml = YAML_HEADER + "\n"; yaml += "collections:\n"; for (const slug of Object.keys(policyMap).sort()) { const entry = policyMap[slug]; yaml += ` ${yamlStr(slug)}:\n`; if (entry.activeRetention) { yaml += renderRetentionBlock( "activeRetention", entry.activeRetention, " ", ); } if (entry.coldArchive) { yaml += renderRetentionBlock("coldArchive", entry.coldArchive, " "); } if (entry.postDeletion) { yaml += renderRetentionBlock("postDeletion", entry.postDeletion, " "); } yaml += ` purgeSchedule: ${yamlStr(entry.purgeSchedule)}\n`; yaml += ` slug: ${yamlStr(slug)}\n`; } return yaml; } // ---- CLI ---- function parseArgs(argv) { const out = { mode: "write" }; // 'write' | 'print' | 'check' for (let i = 2; i < argv.length; i++) { if (argv[i] === "--print") out.mode = "print"; else if (argv[i] === "--check") out.mode = "check"; else if (argv[i] === "--help" || argv[i] === "-h") { console.log( [ "Usage: node scripts/compliance/emit-retention-policy.mjs [--print | --check]", " (default): write compliance/retention-policy.yml", " --print: write YAML to stdout", " --check: diff vs committed file; exit 1 on mismatch", ].join("\n"), ); process.exit(0); } } return out; } function main() { const args = parseArgs(process.argv); const repoRoot = process.cwd(); const files = findCollectionFiles(repoRoot); const rawCollections = files .map((f) => parseCollectionFile(f)) .filter(Boolean); const errors = validateRetentionPolicy(rawCollections); if (errors.length > 0) { for (const err of errors) { process.stderr.write(`[compliance:retention-policy] ${err}\n`); } process.exit(1); } const policyMap = buildRetentionPolicy(rawCollections); const yaml = renderRetentionPolicyYaml(policyMap); const outPath = path.resolve(repoRoot, OUTPUT_PATH); if (args.mode === "print") { process.stdout.write(yaml); return; } if (args.mode === "check") { if (!fs.existsSync(outPath)) { process.stderr.write( `[compliance:retention-policy] --check: no committed file at ${OUTPUT_PATH}\n` + `Run \`pnpm compliance:retention-policy\` to generate it first.\n`, ); process.exit(1); } const committed = fs.readFileSync(outPath, "utf8"); const diff = unifiedDiff(committed, yaml, OUTPUT_PATH); if (diff === null) { console.log( `✓ compliance:retention-policy — ${OUTPUT_PATH} is up to date`, ); process.exit(0); } process.stderr.write( `✗ compliance:retention-policy — ${OUTPUT_PATH} is out of date:\n${diff}\n`, ); process.exit(1); } // Default: write to file const dir = path.dirname(outPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(outPath, yaml, "utf8"); console.log(`✓ compliance:retention-policy — wrote ${OUTPUT_PATH}`); } if (import.meta.url === `file://${process.argv[1]}`) { main(); }