/** * Backup Manager - Resilient backup execution system * Node.js implementation */ const fs = require('fs-extra'); const path = require('path'); const { spawn } = require('child_process'); class BackupManager { constructor(projectRoot = '.') { this.projectRoot = path.resolve(projectRoot); this.backupDir = path.join(this.projectRoot, '.claude_hooks', 'backups'); fs.ensureDirSync(this.backupDir); // Backup settings this.maxBackups = 10; this.logFile = path.join(this.backupDir, 'backup.log'); } /** * Execute backup with comprehensive error handling */ async executeBackup(decision, sessionState) { const backupId = this._generateBackupId(); const backupPath = path.join(this.backupDir, backupId); try { // Create backup structure const backupInfo = await this._createBackupStructure(backupPath, sessionState); // Git backup (if possible) const gitResult = await this._attemptGitBackup(backupId, decision.reason); // File system backup const fsResult = await this._createFilesystemBackup(backupPath, sessionState); // Session state backup const stateResult = await this._backupSessionState(backupPath, sessionState); // Clean up old backups await this._cleanupOldBackups(); // Log successful backup await this._logBackup(backupId, decision, true); return { success: true, backupId, backupPath, gitSuccess: gitResult.success, components: { git: gitResult, filesystem: fsResult, sessionState: stateResult } }; } catch (error) { // Backup failures should never break the session const fallbackResult = await this._createMinimalBackup(sessionState); await this._logBackup(backupId, decision, false, error.message); return { success: false, backupId, error: error.message, fallbackPerformed: fallbackResult }; } } /** * Create backup from project path (external API) */ async createBackup(projectPath, context = {}, force = false) { const decision = { reason: context.trigger || 'manual', urgency: 'medium', force }; const sessionState = { modifiedFiles: context.modified_files || [], toolUsage: context.tool_usage || {}, timestamp: new Date().toISOString(), ...context }; const result = await this.executeBackup(decision, sessionState); return result.success ? result.backupId : null; } /** * Get backup information */ async getBackupInfo(backupId) { try { const backupPath = path.join(this.backupDir, backupId); const metadataFile = path.join(backupPath, 'metadata.json'); if (await fs.pathExists(metadataFile)) { const metadata = await fs.readJson(metadataFile); // Add file list if available const filesDir = path.join(backupPath, 'files'); if (await fs.pathExists(filesDir)) { const files = await this._getBackupFiles(filesDir); metadata.files_backed_up = files; } return metadata; } } catch (error) { console.error('Error reading backup info:', error.message); } return null; } /** * Generate unique backup identifier */ _generateBackupId() { const timestamp = new Date().toISOString() .replace(/[:-]/g, '') .replace(/\.\d{3}Z$/, '') .replace('T', '_'); return `backup_${timestamp}`; } /** * Create basic backup directory structure */ async _createBackupStructure(backupPath, sessionState) { await fs.ensureDir(backupPath); // Create subdirectories await fs.ensureDir(path.join(backupPath, 'files')); await fs.ensureDir(path.join(backupPath, 'logs')); await fs.ensureDir(path.join(backupPath, 'state')); // Create backup metadata const metadata = { backup_id: path.basename(backupPath), timestamp: new Date().toISOString(), session_state: sessionState, project_root: this.projectRoot }; await fs.writeJson(path.join(backupPath, 'metadata.json'), metadata, { spaces: 2 }); return metadata; } /** * Attempt git backup with proper error handling */ async _attemptGitBackup(backupId, reason) { try { // Check if git repo exists if (!(await fs.pathExists(path.join(this.projectRoot, '.git')))) { // Initialize repo if none exists const initResult = await this._runGitCommand(['init']); if (!initResult.success) { return { success: false, error: `Git init failed: ${initResult.error}` }; } } // Add all changes const addResult = await this._runGitCommand(['add', '-A']); if (!addResult.success) { return { success: false, error: `Git add failed: ${addResult.error}` }; } // Check if there are changes to commit const statusResult = await this._runGitCommand(['status', '--porcelain']); if (statusResult.success && !statusResult.stdout.trim()) { return { success: true, message: 'No changes to commit' }; } // Create commit const commitMsg = `Claude hooks auto-backup: ${reason} (${backupId})`; const commitResult = await this._runGitCommand(['commit', '-m', commitMsg]); if (!commitResult.success) { return { success: false, error: `Git commit failed: ${commitResult.error}` }; } // Get commit ID const commitId = await this._getLatestCommit(); return { success: true, commitId, message: `Committed as ${commitId.substring(0, 8)}` }; } catch (error) { return { success: false, error: `Unexpected git error: ${error.message}` }; } } /** * Run git command with timeout and error handling */ _runGitCommand(args, timeoutMs = 60000) { return new Promise((resolve) => { const child = spawn('git', args, { cwd: this.projectRoot, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (data) => { stdout += data.toString(); }); child.stderr.on('data', (data) => { stderr += data.toString(); }); const timeout = setTimeout(() => { child.kill(); resolve({ success: false, error: 'Git operation timed out' }); }, timeoutMs); child.on('close', (code) => { clearTimeout(timeout); resolve({ success: code === 0, stdout: stdout.trim(), stderr: stderr.trim(), error: code !== 0 ? stderr.trim() : null }); }); child.on('error', (error) => { clearTimeout(timeout); resolve({ success: false, error: error.message }); }); }); } /** * Get the latest commit ID */ async _getLatestCommit() { try { const result = await this._runGitCommand(['rev-parse', 'HEAD'], 10000); return result.success ? result.stdout : 'unknown'; } catch (error) { return 'unknown'; } } /** * Create filesystem backup of important files */ async _createFilesystemBackup(backupPath, sessionState) { try { const filesDir = path.join(backupPath, 'files'); await fs.ensureDir(filesDir); // Backup modified files mentioned in session const modifiedFiles = sessionState.modifiedFiles || sessionState.modified_files || []; const filesBackedUp = []; for (const filePath of modifiedFiles) { try { const src = path.resolve(filePath); if (await fs.pathExists(src) && (await fs.stat(src)).isFile()) { // Create relative path structure const relativePath = path.relative(this.projectRoot, src); const dst = path.join(filesDir, relativePath); await fs.ensureDir(path.dirname(dst)); await fs.copy(src, dst, { preserveTimestamps: true }); filesBackedUp.push(src); } } catch (error) { // Log error but continue with other files await this._logFileBackupError(filePath, error); } } // Backup important project files const importantFiles = [ 'package.json', 'requirements.txt', 'Cargo.toml', 'pyproject.toml', 'setup.py', '.gitignore', 'README.md', 'CLAUDE.md' ]; for (const fileName of importantFiles) { const filePath = path.join(this.projectRoot, fileName); if (await fs.pathExists(filePath)) { try { const dst = path.join(filesDir, fileName); await fs.copy(filePath, dst, { preserveTimestamps: true }); filesBackedUp.push(filePath); } catch (error) { // Not critical } } } return { success: true, message: `Backed up ${filesBackedUp.length} files`, metadata: { files: filesBackedUp } }; } catch (error) { return { success: false, error: error.message }; } } /** * Backup session state and context */ async _backupSessionState(backupPath, sessionState) { try { const stateDir = path.join(backupPath, 'state'); // Save session state await fs.writeJson(path.join(stateDir, 'session.json'), sessionState, { spaces: 2 }); // Copy hook logs if they exist const logsSource = path.join(this.projectRoot, '.claude_hooks', 'logs'); if (await fs.pathExists(logsSource)) { const logsDest = path.join(backupPath, 'logs'); await fs.copy(logsSource, logsDest); } // Copy patterns database const patternsSource = path.join(this.projectRoot, '.claude_hooks', 'patterns'); if (await fs.pathExists(patternsSource)) { const patternsDest = path.join(stateDir, 'patterns'); await fs.copy(patternsSource, patternsDest); } return { success: true, message: 'Session state backed up' }; } catch (error) { return { success: false, error: error.message }; } } /** * Create minimal backup when full backup fails */ async _createMinimalBackup(sessionState) { try { // At minimum, save session state to a simple file const emergencyFile = path.join(this.backupDir, 'emergency_backup.json'); const emergencyData = { timestamp: new Date().toISOString(), session_state: sessionState, type: 'emergency_backup' }; await fs.writeJson(emergencyFile, emergencyData, { spaces: 2 }); return true; } catch (error) { return false; } } /** * Remove old backups to save space */ async _cleanupOldBackups() { try { // Get all backup directories const entries = await fs.readdir(this.backupDir, { withFileTypes: true }); const backupDirs = entries .filter(entry => entry.isDirectory() && entry.name.startsWith('backup_')) .map(entry => ({ name: entry.name, path: path.join(this.backupDir, entry.name) })); // Sort by creation time (newest first) const backupsWithStats = await Promise.all( backupDirs.map(async (backup) => { const stats = await fs.stat(backup.path); return { ...backup, mtime: stats.mtime }; }) ); backupsWithStats.sort((a, b) => b.mtime - a.mtime); // Remove old backups beyond maxBackups const oldBackups = backupsWithStats.slice(this.maxBackups); for (const oldBackup of oldBackups) { await fs.remove(oldBackup.path); } } catch (error) { // Cleanup failures shouldn't break backup } } /** * Log backup operation */ async _logBackup(backupId, decision, success, error = '') { try { const logEntry = { timestamp: new Date().toISOString(), backup_id: backupId, reason: decision.reason, urgency: decision.urgency, success, error }; // Append to log file await fs.appendFile(this.logFile, JSON.stringify(logEntry) + '\n'); } catch (error) { // Logging failures shouldn't break backup } } /** * Log file backup errors */ async _logFileBackupError(filePath, error) { try { const errorEntry = { timestamp: new Date().toISOString(), type: 'file_backup_error', file_path: filePath, error: error.message }; await fs.appendFile(this.logFile, JSON.stringify(errorEntry) + '\n'); } catch (error) { // Ignore logging errors } } /** * List available backups */ async listBackups() { const backups = []; try { const entries = await fs.readdir(this.backupDir, { withFileTypes: true }); const backupDirs = entries .filter(entry => entry.isDirectory() && entry.name.startsWith('backup_')) .map(entry => path.join(this.backupDir, entry.name)); for (const backupDir of backupDirs) { const metadataFile = path.join(backupDir, 'metadata.json'); if (await fs.pathExists(metadataFile)) { try { const metadata = await fs.readJson(metadataFile); backups.push(metadata); } catch (error) { // Skip corrupted metadata } } } } catch (error) { // Return empty list on error } return backups.sort((a, b) => { const timeA = a.timestamp || ''; const timeB = b.timestamp || ''; return timeB.localeCompare(timeA); }); } /** * Get list of files in backup */ async _getBackupFiles(filesDir, relativeTo = filesDir) { const files = []; try { const entries = await fs.readdir(filesDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(filesDir, entry.name); const relativePath = path.relative(relativeTo, fullPath); if (entry.isDirectory()) { const subFiles = await this._getBackupFiles(fullPath, relativeTo); files.push(...subFiles); } else { files.push(relativePath); } } } catch (error) { // Return what we have } return files; } } module.exports = { BackupManager };