// backup.mjs — Backup + sentinel-guarded, SCOPED rollback for skills/-tree. // Zero dependencies. Uses fs.cpSync (recursive + preserveTimestamps) without // dereference (Node 22.17.x regression) and without filter (Windows symlink- // type bug). restore(relPaths) rolls back ONLY the relative paths the calling // run wrote — never the whole tree — so a parallel session's writes to sibling // files survive and there is no destructive rm→cp window. It writes a // .rollback-in-progress sentinel at backupRoot BEFORE the first per-file restore // and removes it on success — a crash mid-restore leaves the sentinel behind so // detectStaleRollback() can flag it. import { cpSync, rmSync, statSync, readdirSync, readFileSync, existsSync, unlinkSync, mkdirSync, } from 'node:fs'; import { join, basename, dirname } from 'node:path'; import { atomicWriteJson, atomicWriteSync } from './atomic-write.mjs'; const META_FILENAME = '.backup-meta.json'; const SENTINEL_FILENAME = '.rollback-in-progress'; const DEFAULT_RETENTION_DAYS = 7; /** * Produce a filesystem-safe ISO-ish timestamp: YYYY-MM-DDTHH-MM-SS. * No colons, no fractional seconds, no Z. * @returns {string} */ export function backupTimestamp(now = new Date()) { return now.toISOString().slice(0, 19).replace(/:/g, '-'); } function readMetaCreatedAt(dir) { try { const text = readFileSync(join(dir, META_FILENAME), 'utf8'); const obj = JSON.parse(text); if (obj && typeof obj.created_at === 'string') { const t = Date.parse(obj.created_at); return Number.isFinite(t) ? t : null; } return null; } catch { return null; } } /** * Back up srcDir into backupRoot//. Writes a meta sentinel inside * the new backup dir as the first post-copy action. * * @param {string} srcDir — directory to back up (must exist) * @param {string} backupRoot — parent dir for backup-id subdirs * @param {object} [opts] * @param {number} [opts.retentionDays] — default 7 * @param {Date} [opts.now] — override clock for testing * @returns {{backupPath: string, retentionDays: number, restore: () => void}} */ export function backupDir(srcDir, backupRoot, opts = {}) { if (!srcDir || typeof srcDir !== 'string') { throw new Error('backupDir: srcDir is required'); } if (!backupRoot || typeof backupRoot !== 'string') { throw new Error('backupDir: backupRoot is required'); } if (!existsSync(srcDir)) { throw new Error(`backupDir: srcDir does not exist: ${srcDir}`); } const retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS; const now = opts.now ?? new Date(); mkdirSync(backupRoot, { recursive: true }); const backupPath = join(backupRoot, backupTimestamp(now)); cpSync(srcDir, backupPath, { recursive: true, force: true, preserveTimestamps: true, }); // First action inside backupPath after cpSync — write meta sentinel. atomicWriteJson(join(backupPath, META_FILENAME), { created_at: now.toISOString(), src_dir: srcDir, schema_version: 1, }); // Scoped rollback: restore ONLY the relative paths (relative to srcDir) that the calling run // wrote this session. A file present in the backup is restored to its prior bytes atomically; a // file ABSENT from the backup was created this run, so rolling back means deleting it. Sibling // files a parallel session touched after this backup are never read or written, and there is no // window where srcDir is deleted. The sentinel is written before the first mutation and cleared // only on the success path — a throw mid-restore leaves it for detectStaleRollback(). const restore = (relPaths) => { if (!Array.isArray(relPaths)) { throw new Error( "backupDir.restore: relPaths must be an array — restore is scoped to the run's own writes", ); } const sentinelPath = join(backupRoot, SENTINEL_FILENAME); atomicWriteJson(sentinelPath, { backup_path: backupPath, src_dir: srcDir, rel_paths: relPaths, started_at: new Date().toISOString(), schema_version: 2, }); for (const rel of relPaths) { const from = join(backupPath, rel); const to = join(srcDir, rel); if (existsSync(from)) { mkdirSync(dirname(to), { recursive: true }); atomicWriteSync(to, readFileSync(from)); // prior bytes, crash-safe (Buffer → byte-exact) } else if (existsSync(to)) { try { unlinkSync(to); // created this run — must not exist in the rolled-back state } catch { // best-effort } } } // Success — clear the sentinel. On a throw above it is left behind for detectStaleRollback(). try { unlinkSync(sentinelPath); } catch { // best-effort } }; return { backupPath, retentionDays, restore }; } /** * Back up a SINGLE file into backupRoot//. No-op (returns * null) when the source file does not exist — a first-time write has nothing to * preserve. Used to guard the C2.3 scheduler-config write: atomic-write handles * partial-write safety; this keeps the prior version recoverable. Precise by * design (does not copy the surrounding dir, so onboarding's org/ data is not * dragged into a backup on every cadence change). * * @param {string} filePath — file to back up * @param {string} backupRoot — parent dir for the timestamped backup subdir * @param {object} [opts] * @param {Date} [opts.now] — override clock for testing * @returns {{backupPath: string}|null} */ export function backupFile(filePath, backupRoot, opts = {}) { if (!filePath || typeof filePath !== 'string') { throw new Error('backupFile: filePath is required'); } if (!backupRoot || typeof backupRoot !== 'string') { throw new Error('backupFile: backupRoot is required'); } if (!existsSync(filePath)) return null; const now = opts.now ?? new Date(); const destDir = join(backupRoot, backupTimestamp(now)); mkdirSync(destDir, { recursive: true }); const backupPath = join(destDir, basename(filePath)); cpSync(filePath, backupPath, { force: true, preserveTimestamps: true }); return { backupPath }; } /** * True if a stale rollback sentinel exists at backupRoot. * @param {string} backupRoot * @returns {boolean} */ export function detectStaleRollback(backupRoot) { if (!backupRoot || typeof backupRoot !== 'string') return false; return existsSync(join(backupRoot, SENTINEL_FILENAME)); } /** * Resolve the effective creation time of a backup dir. * Order: meta.created_at → dir mtime → null (skip with warning upstream). */ function resolveBackupAge(dir) { const fromMeta = readMetaCreatedAt(dir); if (fromMeta != null) return fromMeta; try { return statSync(dir).mtimeMs; } catch { return null; } } /** * Delete backup directories under backupRoot older than retentionDays. * Skips dirs with unresolvable age (logs a warning) rather than deleting them. * * @param {string} backupRoot * @param {number} [retentionDays] — default 7 * @param {object} [opts] * @param {(msg: string) => void} [opts.warn] — default console.warn * @param {Date} [opts.now] — override clock for testing * @returns {{kept: string[], deleted: string[], skipped: string[]}} */ export function cleanupOldBackups(backupRoot, retentionDays = DEFAULT_RETENTION_DAYS, opts = {}) { const result = { kept: [], deleted: [], skipped: [] }; if (!backupRoot || !existsSync(backupRoot)) return result; const warn = opts.warn ?? ((m) => console.warn(m)); const now = opts.now ?? new Date(); const cutoffMs = now.getTime() - retentionDays * 24 * 60 * 60 * 1000; let entries; try { entries = readdirSync(backupRoot, { withFileTypes: true }); } catch (err) { warn(`cleanupOldBackups: cannot read ${backupRoot}: ${err.message}`); return result; } for (const entry of entries) { if (!entry.isDirectory()) continue; const full = join(backupRoot, entry.name); const ageMs = resolveBackupAge(full); if (ageMs == null) { warn(`cleanupOldBackups: skipping ${full} — cannot resolve age`); result.skipped.push(full); continue; } if (ageMs < cutoffMs) { try { rmSync(full, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); result.deleted.push(full); } catch (err) { warn(`cleanupOldBackups: failed to delete ${full}: ${err.message}`); result.skipped.push(full); } } else { result.kept.push(full); } } return result; }