import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, renameSync, readdirSync, unlinkSync, statSync, } from 'node:fs'; import { dirname, join, relative } from 'node:path'; const MAX_BACKUPS = 3; /** * Atomic write: write to temp file, then rename. * Creates parent directories as needed. */ export function atomicWrite(filePath: string, content: string): void { mkdirSync(dirname(filePath), { recursive: true }); const tmpPath = `${filePath}.tmp-${process.pid.toString()}`; writeFileSync(tmpPath, content, 'utf-8'); renameSync(tmpPath, filePath); } /** * Create a backup of a file before overwriting. * Rotates backups to keep at most MAX_BACKUPS. */ export function backupFile(filePath: string): string | null { if (!existsSync(filePath)) return null; const timestamp = new Date().toISOString().replace(/[:.]/g, '').replace('T', '-').slice(0, 19); const backupPath = `${filePath}.bak-${timestamp}`; copyFileSync(filePath, backupPath); rotateBackups(filePath); return backupPath; } function rotateBackups(filePath: string): void { const dir = dirname(filePath); const baseName = filePath.split('/').pop() ?? ''; const prefix = `${baseName}.bak-`; try { const backups = readdirSync(dir) .filter((f: string) => f.startsWith(prefix)) .sort() .reverse(); for (let i = MAX_BACKUPS; i < backups.length; i++) { const backup = backups[i]; if (backup !== undefined) { unlinkSync(join(dir, backup)); } } } catch { // Non-fatal: backup rotation failure doesn't block writes } } /** * Sync a source directory to a target, with optional preserve paths. * Replaces the rsync/cp logic from install.sh. */ export function syncDirectory( source: string, target: string, options: { preserve?: string[]; excludeGit?: boolean } = {}, ): void { const preserveSet = new Set(options.preserve ?? []); // Collect files from source function copyRecursive(src: string, dest: string, relBase: string): void { if (!existsSync(src)) return; const stat = statSync(src); if (stat.isDirectory()) { const relPath = relative(relBase, src); // Skip .git if (options.excludeGit && relPath === '.git') return; // Skip preserved paths at top level if (preserveSet.has(relPath) && existsSync(dest)) return; mkdirSync(dest, { recursive: true }); for (const entry of readdirSync(src)) { copyRecursive(join(src, entry), join(dest, entry), relBase); } } else { const relPath = relative(relBase, src); // Skip preserved files at top level if (preserveSet.has(relPath) && existsSync(dest)) return; mkdirSync(dirname(dest), { recursive: true }); copyFileSync(src, dest); } } copyRecursive(source, target, source); } /** * Safely read a file, returning null if it doesn't exist. */ export function safeReadFile(filePath: string): string | null { try { return readFileSync(filePath, 'utf-8'); } catch { return null; } }