/** * Backup library for config-audit. * Creates timestamped backups of config files with checksums and manifests. * Zero external dependencies. */ import { readFileSync, writeFileSync, copyFileSync, mkdirSync, readdirSync, existsSync, statSync, rmSync, readFile } from 'node:fs'; import { readFile as readFileAsync } from 'node:fs/promises'; import { join, basename } from 'node:path'; import { createHash } from 'node:crypto'; import { homedir } from 'node:os'; const MAX_BACKUPS = 10; /** * Get the backup root directory path. * * Canonical location is `~/.claude/config-audit/backups` — the path every * command, agent and doc uses. `CONFIG_AUDIT_BACKUP_ROOT` overrides it so tests * never write into the operator's real home. * @returns {string} */ export function getBackupDir() { return process.env.CONFIG_AUDIT_BACKUP_ROOT || join(homedir(), '.claude', 'config-audit', 'backups'); } /** * Get the pre-v2.2.0 backup root. Read-only: nothing writes here any more, but * backups made before the move must stay listable and restorable. * @returns {string} */ export function getLegacyBackupDir() { return process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT || join(homedir(), '.config-audit', 'backups'); } /** * Generate a timestamp-based backup ID. * @returns {string} Format: YYYYMMDD_HHMMSS */ export function generateBackupId() { const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, '0'); const d = String(now.getDate()).padStart(2, '0'); const h = String(now.getHours()).padStart(2, '0'); const min = String(now.getMinutes()).padStart(2, '0'); const s = String(now.getSeconds()).padStart(2, '0'); return `${y}${m}${d}_${h}${min}${s}`; } /** * Create a safe filename from a file path (replace path separators with _). * @param {string} filePath * @returns {string} */ export function safeFileName(filePath) { return filePath.replace(/[\\\/]/g, '_'); } /** * Calculate SHA-256 checksum of a buffer or string. * @param {Buffer|string} content * @returns {string} */ export function checksum(content) { return createHash('sha256').update(content).digest('hex'); } /** * Create a backup of the specified files. * @param {string[]} files - Array of absolute file paths to back up * @param {object} [opts] * @param {string} [opts.backupId] - Override backup ID (for testing) * @returns {{ backupId: string, backupPath: string, manifest: object }} */ export function createBackup(files, opts = {}) { const backupId = opts.backupId || generateBackupId(); const backupPath = join(getBackupDir(), backupId); const filesDir = join(backupPath, 'files'); mkdirSync(filesDir, { recursive: true }); const manifestFiles = []; for (const file of files) { if (!existsSync(file)) continue; const safeName = safeFileName(file); copyFileSync(file, join(filesDir, safeName)); const content = readFileSync(file); const hash = checksum(content); const sizeBytes = statSync(file).size; manifestFiles.push({ originalPath: file, backupPath: `./files/${safeName}`, checksum: hash, sizeBytes, }); } const manifest = { created_at: new Date().toISOString(), backup_id: backupId, files: manifestFiles, }; // Write manifest as YAML-like format const manifestYaml = serializeManifest(manifest); writeFileSync(join(backupPath, 'manifest.yaml'), manifestYaml); // Cleanup old backups cleanupOldBackups(); return { backupId, backupPath, manifest }; } /** * Serialize manifest to YAML-like format. * @param {object} manifest * @returns {string} */ function serializeManifest(manifest) { let yaml = `created_at: "${manifest.created_at}"\n`; yaml += `backup_id: "${manifest.backup_id}"\n`; yaml += `files:\n`; for (const f of manifest.files) { yaml += ` - original_path: "${f.originalPath}"\n`; yaml += ` backup_path: "${f.backupPath}"\n`; yaml += ` checksum: "${f.checksum}"\n`; yaml += ` size_bytes: ${f.sizeBytes}\n`; } return yaml; } /** * Parse a manifest.yaml file content. * @param {string} content * @returns {object} */ export function parseManifest(content) { const result = { created_at: '', backup_id: '', files: [], created: [] }; const createdMatch = content.match(/created_at:\s*"([^"]+)"/); if (createdMatch) result.created_at = createdMatch[1]; const idMatch = content.match(/backup_id:\s*"([^"]+)"/); if (idMatch) result.backup_id = idMatch[1]; // Parse file entries — engine format (quoted `original_path:` …). const fileBlocks = content.split(/\n\s+-\s+original_path:/).slice(1); for (const block of fileBlocks) { const origMatch = block.match(/^\s*"([^"]+)"/); const bpMatch = block.match(/backup_path:\s*"([^"]+)"/); const csMatch = block.match(/checksum:\s*"([^"]+)"/); const szMatch = block.match(/size_bytes:\s*(\d+)/); if (origMatch && bpMatch && csMatch) { result.files.push({ originalPath: origMatch[1], backupPath: bpMatch[1], checksum: csMatch[1], sizeBytes: szMatch ? parseInt(szMatch[1], 10) : 0, }); } } // Parse file entries — implement-flow format. `commands/implement.md` has the // agent hand-build the backup dir, so real manifests on disk use unquoted // `- backup:` / `original:` / `sha256:`. Reading only the engine format made // restoreBackup a success-shaped no-op on every backup implement produced. if (result.files.length === 0) { const implBlocks = content.split(/\n\s+-\s+backup:/).slice(1); for (const block of implBlocks) { const bpMatch = block.match(/^\s*(\S+)/); const origMatch = block.match(/original:\s*(\S+)/); const csMatch = block.match(/sha256:\s*(\S+)/); if (origMatch && bpMatch && csMatch) { result.files.push({ originalPath: origMatch[1], backupPath: bpMatch[1], checksum: csMatch[1], sizeBytes: 0, }); } } if (!result.backup_id) { const implId = content.match(/^created:\s*(\S+)\s*$/m); if (implId) result.backup_id = implId[1]; } } // Files the implement step CREATED. A backup cannot hold a file that did not // exist, so rollback can never restore these — but it must be able to say so. const lines = content.split('\n'); const createdAt = lines.findIndex(l => /^created:[ \t]*$/.test(l)); if (createdAt !== -1) { for (const line of lines.slice(createdAt + 1)) { const item = line.match(/^[ \t]+-[ \t]+(\S+)[ \t]*$/); if (!item) break; result.created.push(item[1]); } } return result; } /** * Remove old backups beyond MAX_BACKUPS. */ function cleanupOldBackups() { const backupRoot = getBackupDir(); if (!existsSync(backupRoot)) return; const dirs = readdirSync(backupRoot, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => d.name) .sort(); if (dirs.length > MAX_BACKUPS) { const toDelete = dirs.slice(0, dirs.length - MAX_BACKUPS); for (const dir of toDelete) { rmSync(join(backupRoot, dir), { recursive: true, force: true }); } } } export { MAX_BACKUPS };