UNPKG

automagik-genie

Version:

Universal AI development companion that can be initialized in any codebase

431 lines (367 loc) 13.1 kB
const fs = require('fs').promises; const path = require('path'); const os = require('os'); /** * BackupManager - Handles backup and restore operations * Provides atomic operations with complete rollback capability */ class BackupManager { constructor(backupDir = null) { this.backupDir = backupDir || path.join(os.homedir(), '.automagik-genie', 'backups'); } /** * Create a complete backup of specified files * @param {Array<string>} files - Array of file paths to backup * @param {Object} metadata - Additional metadata to store with backup * @returns {Object} Backup information */ async createBackup(files, metadata = {}) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupId = `backup-${timestamp}`; const backupPath = path.join(this.backupDir, backupId); await fs.mkdir(backupPath, { recursive: true }); const backupManifest = { id: backupId, timestamp, metadata, files: [], totalSize: 0, fileCount: 0, status: 'in-progress' }; try { // Backup each file preserving directory structure for (const filePath of files) { if (await this.fileExists(filePath)) { const backupInfo = await this.backupSingleFile(filePath, backupPath); backupManifest.files.push(backupInfo); backupManifest.totalSize += backupInfo.size; backupManifest.fileCount++; } } // Create manifest file backupManifest.status = 'completed'; const manifestPath = path.join(backupPath, 'manifest.json'); await fs.writeFile(manifestPath, JSON.stringify(backupManifest, null, 2)); // Validate backup integrity const isValid = await this.validateBackup(backupId); if (!isValid) { throw new Error('Backup validation failed'); } return { backupId, path: backupPath, fileCount: backupManifest.fileCount, totalSize: backupManifest.totalSize, timestamp: backupManifest.timestamp }; } catch (error) { // Cleanup failed backup await this.cleanupFailedBackup(backupPath); throw new Error(`Backup creation failed: ${error.message}`); } } /** * Backup a single file to the backup directory * @param {string} sourcePath - Source file path * @param {string} backupBasePath - Base backup directory * @returns {Object} File backup information */ async backupSingleFile(sourcePath, backupBasePath) { const stats = await fs.stat(sourcePath); const content = await fs.readFile(sourcePath, 'utf-8'); // Preserve directory structure relative to project root const relativePath = path.relative(process.cwd(), sourcePath); const backupFilePath = path.join(backupBasePath, 'files', relativePath); // Ensure backup file directory exists await fs.mkdir(path.dirname(backupFilePath), { recursive: true }); // Copy file to backup location await fs.writeFile(backupFilePath, content, 'utf-8'); return { originalPath: sourcePath, backupPath: backupFilePath, relativePath, size: stats.size, modified: stats.mtime.toISOString(), checksum: this.calculateChecksum(content) }; } /** * Validate backup integrity * @param {string} backupId - Backup ID to validate * @returns {boolean} True if backup is valid */ async validateBackup(backupId) { const backupPath = path.join(this.backupDir, backupId); const manifestPath = path.join(backupPath, 'manifest.json'); try { // Check if manifest exists if (!await this.fileExists(manifestPath)) { return false; } const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')); // Validate each backed up file for (const fileInfo of manifest.files) { const backupFilePath = fileInfo.backupPath; // Check if backup file exists if (!await this.fileExists(backupFilePath)) { return false; } // Verify file size and checksum const stats = await fs.stat(backupFilePath); if (stats.size !== fileInfo.size) { return false; } const content = await fs.readFile(backupFilePath, 'utf-8'); const checksum = this.calculateChecksum(content); if (checksum !== fileInfo.checksum) { return false; } } return true; } catch (error) { return false; } } /** * Restore files from backup * @param {string} backupId - Backup ID to restore from * @param {string} targetPath - Target directory (defaults to original locations) * @param {Object} options - Restoration options */ async restoreFromBackup(backupId, targetPath = null, options = {}) { const { dryRun = false, force = false } = options; const backupPath = path.join(this.backupDir, backupId); const manifestPath = path.join(backupPath, 'manifest.json'); // Validate backup before restoration if (!await this.validateBackup(backupId)) { throw new Error(`Backup ${backupId} is invalid or corrupted`); } const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')); if (dryRun) { return this.generateRestorePreview(manifest, targetPath); } // Create staging area for atomic restore const stagingDir = path.join(this.backupDir, `restore-staging-${Date.now()}`); await fs.mkdir(stagingDir, { recursive: true }); try { const restoredFiles = []; // First, copy all files to staging area for (const fileInfo of manifest.files) { const sourceBackupPath = fileInfo.backupPath; const finalPath = targetPath ? path.join(targetPath, fileInfo.relativePath) : fileInfo.originalPath; const stagingPath = path.join(stagingDir, fileInfo.relativePath); // Create staging directory structure await fs.mkdir(path.dirname(stagingPath), { recursive: true }); // Copy from backup to staging const content = await fs.readFile(sourceBackupPath, 'utf-8'); await fs.writeFile(stagingPath, content, 'utf-8'); restoredFiles.push({ stagingPath, finalPath, relativePath: fileInfo.relativePath }); } // Atomic move from staging to final locations for (const fileInfo of restoredFiles) { // Ensure target directory exists await fs.mkdir(path.dirname(fileInfo.finalPath), { recursive: true }); // Check for existing file conflicts if (!force && await this.fileExists(fileInfo.finalPath)) { // Create backup of existing file before overwriting const existingBackupPath = `${fileInfo.finalPath}.restore-backup.${Date.now()}`; await fs.copyFile(fileInfo.finalPath, existingBackupPath); } // Move from staging to final location await fs.rename(fileInfo.stagingPath, fileInfo.finalPath); } // Cleanup staging directory await fs.rm(stagingDir, { recursive: true, force: true }); return { success: true, restoredFiles: restoredFiles.length, backupId, timestamp: new Date().toISOString() }; } catch (error) { // Cleanup staging directory on failure try { await fs.rm(stagingDir, { recursive: true, force: true }); } catch (cleanupError) { // Ignore cleanup errors } throw new Error(`Restore failed: ${error.message}`); } } /** * Generate restore preview for dry-run * @param {Object} manifest - Backup manifest * @param {string} targetPath - Target path for restoration */ generateRestorePreview(manifest, targetPath) { const preview = { backupId: manifest.id, timestamp: manifest.timestamp, fileCount: manifest.fileCount, totalSize: manifest.totalSize, files: [] }; for (const fileInfo of manifest.files) { const finalPath = targetPath ? path.join(targetPath, fileInfo.relativePath) : fileInfo.originalPath; preview.files.push({ source: fileInfo.backupPath, destination: finalPath, size: fileInfo.size, action: 'restore' }); } return preview; } /** * List available backups * @returns {Array} List of available backups with metadata */ async listAvailableBackups() { try { const backupEntries = await fs.readdir(this.backupDir, { withFileTypes: true }); const backups = []; for (const entry of backupEntries) { if (entry.isDirectory() && entry.name.startsWith('backup-')) { const manifestPath = path.join(this.backupDir, entry.name, 'manifest.json'); if (await this.fileExists(manifestPath)) { try { const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')); const isValid = await this.validateBackup(entry.name); backups.push({ id: entry.name, timestamp: manifest.timestamp, fileCount: manifest.fileCount, totalSize: manifest.totalSize, metadata: manifest.metadata, valid: isValid, path: path.join(this.backupDir, entry.name) }); } catch (error) { // Invalid manifest, mark as corrupted backups.push({ id: entry.name, timestamp: null, fileCount: 0, totalSize: 0, metadata: {}, valid: false, corrupted: true, path: path.join(this.backupDir, entry.name) }); } } } } // Sort by timestamp (newest first) return backups.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0) ); } catch (error) { return []; } } /** * Cleanup old backups * @param {number} maxAge - Maximum age in days * @param {number} keepCount - Minimum number of backups to keep */ async cleanupOldBackups(maxAge = 30, keepCount = 5) { const backups = await this.listAvailableBackups(); const cutoffDate = new Date(Date.now() - (maxAge * 24 * 60 * 60 * 1000)); // Sort by timestamp and identify backups to delete const backupsToDelete = backups .filter((backup, index) => { // Keep at least 'keepCount' most recent backups if (index < keepCount && backup.valid) { return false; } // Delete if older than maxAge if (backup.timestamp && new Date(backup.timestamp) < cutoffDate) { return true; } // Delete corrupted backups return backup.corrupted; }); const deletedBackups = []; for (const backup of backupsToDelete) { try { await fs.rm(backup.path, { recursive: true, force: true }); deletedBackups.push(backup.id); } catch (error) { // Log error but continue cleanup console.warn(`Failed to delete backup ${backup.id}: ${error.message}`); } } return { deleted: deletedBackups.length, remaining: backups.length - deletedBackups.length, deletedBackups }; } /** * Get backup size and file count * @param {string} backupId - Backup ID to analyze */ async getBackupInfo(backupId) { const manifestPath = path.join(this.backupDir, backupId, 'manifest.json'); if (!await this.fileExists(manifestPath)) { return null; } const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')); const isValid = await this.validateBackup(backupId); return { id: backupId, timestamp: manifest.timestamp, fileCount: manifest.fileCount, totalSize: manifest.totalSize, metadata: manifest.metadata, valid: isValid, files: manifest.files.map(f => ({ path: f.originalPath, size: f.size, modified: f.modified })) }; } /** * Calculate SHA-256 checksum * @param {string} content - Content to hash */ calculateChecksum(content) { const crypto = require('crypto'); return crypto.createHash('sha256').update(content, 'utf-8').digest('hex'); } /** * Check if file exists * @param {string} filePath - Path to check */ async fileExists(filePath) { try { await fs.access(filePath); return true; } catch (error) { return false; } } /** * Cleanup failed backup directory * @param {string} backupPath - Path to failed backup */ async cleanupFailedBackup(backupPath) { try { await fs.rm(backupPath, { recursive: true, force: true }); } catch (error) { // Ignore cleanup errors for failed backups } } } module.exports = { BackupManager };