import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { stringifyCanonical } from '@dynamic-sites/shared'; import { writeAuditLog } from '../db.js'; import { logger } from '../logger.js'; import { broadcastReload } from '../live-reload.js'; const REPO_ROOT = process.env.REPO_ROOT || '.'; const MAX_BACKUPS = 20; function ensureDir(dir: string) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } function fileHash(content: string): string { return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12); } /** * Atomic write of canonical JSON to a repo-relative path. * Creates a pre-write backup and audit log entry. */ export function writeContentFile( repoRelativePath: string, data: unknown, opts?: { proposalId?: string; source?: string } ): void { const absPath = path.join(REPO_ROOT, repoRelativePath); const canonical = stringifyCanonical(data); // Pre-write backup let beforeHash: string | undefined; if (fs.existsSync(absPath)) { const existing = fs.readFileSync(absPath, 'utf-8'); beforeHash = fileHash(existing); // Derive a clean backup subdirectory name from the repo-relative path. // e.g. "content/sections/hero.json" → "sections/hero" (strip leading content/ and .json) const stripped = repoRelativePath .replace(/^content\//, '') .replace(/\.json$/, ''); const backupDir = path.join(REPO_ROOT, 'content', '.backups', stripped); ensureDir(backupDir); const ts = new Date().toISOString().replace(/[:.]/g, '-'); fs.copyFileSync(absPath, path.join(backupDir, `${ts}.json`)); // Prune old backups const backups = fs.readdirSync(backupDir).sort(); while (backups.length > MAX_BACKUPS) { const oldest = backups.shift()!; fs.unlinkSync(path.join(backupDir, oldest)); } } // Atomic write: temp file + rename ensureDir(path.dirname(absPath)); const tmpPath = absPath + '.tmp.' + process.pid; fs.writeFileSync(tmpPath, canonical, 'utf-8'); fs.renameSync(tmpPath, absPath); const afterHash = fileHash(canonical); // Audit log writeAuditLog({ proposalId: opts?.proposalId, repoRelativePath, beforeHash, afterHash, source: opts?.source || 'http', }); logger.info({ event: 'content.written', path: repoRelativePath, size: canonical.length }, 'Content file written'); // Notify any connected browsers to reload. broadcastReload('content.written', { path: repoRelativePath, proposalId: opts?.proposalId, source: opts?.source, beforeHash, afterHash, }); }