// backup.mjs — Backup + sentinel-guarded 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). Rollback writes a .rollback-in-progress sentinel at backupRoot // BEFORE destructive operations 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 } from 'node:path'; import { atomicWriteJson } 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, }); const restore = () => { const sentinelPath = join(backupRoot, SENTINEL_FILENAME); atomicWriteJson(sentinelPath, { backup_path: backupPath, src_dir: srcDir, started_at: new Date().toISOString(), schema_version: 1, }); try { rmSync(srcDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200, }); cpSync(backupPath, srcDir, { recursive: true, force: true, preserveTimestamps: true, }); // Remove the meta file we copied back into srcDir so srcDir is clean. const restoredMeta = join(srcDir, META_FILENAME); if (existsSync(restoredMeta)) { try { unlinkSync(restoredMeta); } catch { // best-effort } } } finally { // Sentinel removed only on success path — leave it on throw so the // post-mortem detector can see the orphan. } try { unlinkSync(sentinelPath); } catch { // best-effort } }; return { backupPath, retentionDays, restore }; } /** * 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; }