UNPKG

shipdeck

Version:

Ship MVPs in 48 hours. Fix bugs in 30 seconds. The command deck for developers who ship.

669 lines (564 loc) โ€ข 18.1 kB
/** * Checkpoint Manager for Rollback System * Creates and manages atomic checkpoints for safe recovery */ const { execSync } = require('child_process'); const fs = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const EventEmitter = require('events'); class CheckpointManager extends EventEmitter { constructor(options = {}) { super(); this.projectPath = options.projectPath || process.cwd(); this.checkpointDir = options.checkpointDir || path.join(this.projectPath, '.shipdeck', 'checkpoints'); this.maxCheckpoints = options.maxCheckpoints || 50; this.autoCheckpoint = options.autoCheckpoint !== false; // Checkpoint metadata this.checkpoints = new Map(); this.currentCheckpoint = null; // Health check configuration this.healthChecks = { build: options.runBuildCheck !== false, tests: options.runTestCheck !== false, lint: options.runLintCheck !== false, types: options.runTypeCheck !== false }; // Statistics this.stats = { totalCheckpoints: 0, successfulRollbacks: 0, failedRollbacks: 0, averageRollbackTime: 0 }; } /** * Initialize checkpoint system */ async initialize() { // Create checkpoint directory await fs.mkdir(this.checkpointDir, { recursive: true }); // Load existing checkpoints await this.loadCheckpoints(); // Verify Git repository this.verifyGitRepo(); console.log(`๐Ÿ”’ Checkpoint Manager initialized with ${this.checkpoints.size} checkpoints`); return this; } /** * Create a checkpoint */ async createCheckpoint(metadata = {}) { const startTime = Date.now(); try { // Run health checks before creating checkpoint const healthStatus = await this.runHealthChecks(); if (!healthStatus.healthy && !metadata.force) { console.warn('โš ๏ธ Health checks failed. Use force: true to create checkpoint anyway.'); return { success: false, reason: 'Health checks failed', details: healthStatus }; } // Generate checkpoint ID const checkpointId = this.generateCheckpointId(); // Get current Git state const gitStatus = this.getGitStatus(); // Create atomic commit if there are changes if (gitStatus.hasChanges) { const commitHash = await this.createAtomicCommit(metadata); // Create checkpoint metadata const checkpoint = { id: checkpointId, timestamp: Date.now(), commitHash, branch: gitStatus.branch, metadata: { ...metadata, healthStatus, gitStatus }, tags: metadata.tags || [], description: metadata.description || `Checkpoint ${checkpointId}`, type: metadata.type || 'auto', node: metadata.node || null, workflow: metadata.workflow || null }; // Save checkpoint await this.saveCheckpoint(checkpoint); // Update current checkpoint this.currentCheckpoint = checkpoint; // Emit event this.emit('checkpoint:created', checkpoint); const duration = Date.now() - startTime; console.log(`โœ… Checkpoint ${checkpointId} created in ${duration}ms`); return { success: true, checkpoint, duration }; } else { console.log('โ„น๏ธ No changes to checkpoint'); return { success: false, reason: 'No changes to commit' }; } } catch (error) { console.error(`โŒ Failed to create checkpoint: ${error.message}`); return { success: false, error: error.message }; } } /** * Rollback to a checkpoint */ async rollback(checkpointId, options = {}) { const startTime = Date.now(); try { console.log(`๐Ÿ”„ Rolling back to checkpoint ${checkpointId}...`); // Get checkpoint const checkpoint = this.checkpoints.get(checkpointId); if (!checkpoint) { throw new Error(`Checkpoint ${checkpointId} not found`); } // Save current state as recovery point if (!options.skipRecoveryPoint) { await this.createCheckpoint({ type: 'recovery', description: `Recovery point before rollback to ${checkpointId}`, tags: ['recovery', 'pre-rollback'] }); } // Stash any uncommitted changes const hasUncommitted = this.hasUncommittedChanges(); if (hasUncommitted) { console.log('๐Ÿ“ฆ Stashing uncommitted changes...'); execSync('git stash push -m "Rollback stash"', { cwd: this.projectPath }); } // Perform rollback console.log(`๐ŸŽฏ Reverting to commit ${checkpoint.commitHash}...`); if (options.hard) { // Hard reset (destructive) execSync(`git reset --hard ${checkpoint.commitHash}`, { cwd: this.projectPath }); } else { // Soft rollback (preserves history) execSync(`git revert --no-edit ${checkpoint.commitHash}..HEAD`, { cwd: this.projectPath }); } // Run post-rollback hooks if (options.runHooks !== false) { await this.runPostRollbackHooks(checkpoint); } // Verify rollback success const verification = await this.verifyRollback(checkpoint); if (!verification.success) { throw new Error(`Rollback verification failed: ${verification.reason}`); } // Update stats this.stats.successfulRollbacks++; const duration = Date.now() - startTime; this.updateAverageRollbackTime(duration); // Emit event this.emit('rollback:success', { checkpoint, duration, options }); console.log(`โœ… Rollback completed in ${duration}ms`); return { success: true, checkpoint, duration, verification }; } catch (error) { this.stats.failedRollbacks++; console.error(`โŒ Rollback failed: ${error.message}`); // Attempt recovery if (options.attemptRecovery !== false) { await this.attemptRecovery(); } this.emit('rollback:failed', { checkpointId, error: error.message }); return { success: false, error: error.message }; } } /** * Rollback to previous checkpoint */ async rollbackToPrevious(options = {}) { const checkpoints = Array.from(this.checkpoints.values()) .sort((a, b) => b.timestamp - a.timestamp); if (checkpoints.length < 2) { return { success: false, reason: 'No previous checkpoint available' }; } // Skip current checkpoint and rollback to previous const previousCheckpoint = checkpoints[1]; return this.rollback(previousCheckpoint.id, options); } /** * List checkpoints with filtering */ listCheckpoints(filters = {}) { let checkpoints = Array.from(this.checkpoints.values()); // Apply filters if (filters.type) { checkpoints = checkpoints.filter(c => c.type === filters.type); } if (filters.tags && filters.tags.length > 0) { checkpoints = checkpoints.filter(c => filters.tags.some(tag => c.tags.includes(tag)) ); } if (filters.since) { checkpoints = checkpoints.filter(c => c.timestamp >= filters.since); } if (filters.workflow) { checkpoints = checkpoints.filter(c => c.workflow === filters.workflow); } // Sort by timestamp (newest first) checkpoints.sort((a, b) => b.timestamp - a.timestamp); // Apply limit if (filters.limit) { checkpoints = checkpoints.slice(0, filters.limit); } return checkpoints; } /** * Get checkpoint details */ getCheckpoint(checkpointId) { return this.checkpoints.get(checkpointId); } /** * Delete old checkpoints */ async pruneCheckpoints(keepCount = this.maxCheckpoints) { const checkpoints = Array.from(this.checkpoints.values()) .sort((a, b) => b.timestamp - a.timestamp); if (checkpoints.length <= keepCount) { return { pruned: 0 }; } const toPrune = checkpoints.slice(keepCount); let pruned = 0; for (const checkpoint of toPrune) { // Don't prune tagged checkpoints if (checkpoint.tags.includes('keep') || checkpoint.tags.includes('important')) { continue; } await this.deleteCheckpoint(checkpoint.id); pruned++; } console.log(`๐Ÿงน Pruned ${pruned} old checkpoints`); return { pruned }; } /** * Run health checks */ async runHealthChecks() { const results = { healthy: true, checks: {}, errors: [] }; // Build check if (this.healthChecks.build) { try { console.log('๐Ÿ”จ Running build check...'); execSync('npm run build', { cwd: this.projectPath, stdio: 'pipe' }); results.checks.build = 'passed'; } catch (error) { results.checks.build = 'failed'; results.errors.push(`Build failed: ${error.message}`); results.healthy = false; } } // Test check if (this.healthChecks.tests) { try { console.log('๐Ÿงช Running tests...'); execSync('npm test', { cwd: this.projectPath, stdio: 'pipe' }); results.checks.tests = 'passed'; } catch (error) { results.checks.tests = 'failed'; results.errors.push(`Tests failed: ${error.message}`); // Don't mark as unhealthy for test failures (they might be expected) } } // Lint check if (this.healthChecks.lint) { try { console.log('๐Ÿ“ Running lint check...'); execSync('npm run lint', { cwd: this.projectPath, stdio: 'pipe' }); results.checks.lint = 'passed'; } catch (error) { results.checks.lint = 'failed'; results.errors.push(`Lint failed: ${error.message}`); // Don't mark as unhealthy for lint issues } } // Type check if (this.healthChecks.types) { try { console.log('๐Ÿ“ Running type check...'); execSync('npm run type-check', { cwd: this.projectPath, stdio: 'pipe' }); results.checks.types = 'passed'; } catch (error) { results.checks.types = 'failed'; results.errors.push(`Type check failed: ${error.message}`); results.healthy = false; } } return results; } /** * Create atomic commit */ async createAtomicCommit(metadata) { const message = metadata.message || `Checkpoint: ${metadata.description || 'Auto-save'}`; // Stage all changes execSync('git add -A', { cwd: this.projectPath }); // Create commit const commitCmd = `git commit -m "${message}"`; execSync(commitCmd, { cwd: this.projectPath }); // Get commit hash const commitHash = execSync('git rev-parse HEAD', { cwd: this.projectPath }) .toString().trim(); return commitHash; } /** * Verify rollback success */ async verifyRollback(checkpoint) { try { // Verify Git state const currentHash = execSync('git rev-parse HEAD', { cwd: this.projectPath }) .toString().trim(); // Check if we're at the expected commit const correctCommit = currentHash === checkpoint.commitHash || currentHash.startsWith(checkpoint.commitHash); // Run basic health checks const healthStatus = await this.runHealthChecks(); return { success: correctCommit && healthStatus.healthy, correctCommit, healthStatus, currentHash }; } catch (error) { return { success: false, error: error.message }; } } /** * Run post-rollback hooks */ async runPostRollbackHooks(checkpoint) { const hooks = [ 'npm install', // Reinstall dependencies 'npm run build' // Rebuild project ]; for (const hook of hooks) { try { console.log(`๐Ÿ”ง Running: ${hook}`); execSync(hook, { cwd: this.projectPath, stdio: 'pipe' }); } catch (error) { console.warn(`โš ๏ธ Hook failed: ${hook}`); } } } /** * Attempt recovery after failed rollback */ async attemptRecovery() { console.log('๐Ÿš‘ Attempting recovery...'); try { // Try to restore from stash const stashList = execSync('git stash list', { cwd: this.projectPath }) .toString(); if (stashList.includes('Rollback stash')) { execSync('git stash pop', { cwd: this.projectPath }); console.log('โœ… Recovered from stash'); } // Reset to last known good state const lastGoodCheckpoint = this.findLastGoodCheckpoint(); if (lastGoodCheckpoint) { execSync(`git reset --hard ${lastGoodCheckpoint.commitHash}`, { cwd: this.projectPath }); console.log(`โœ… Recovered to checkpoint ${lastGoodCheckpoint.id}`); } } catch (error) { console.error('โŒ Recovery failed:', error.message); } } /** * Find last checkpoint marked as good */ findLastGoodCheckpoint() { const checkpoints = Array.from(this.checkpoints.values()) .sort((a, b) => b.timestamp - a.timestamp); return checkpoints.find(c => c.metadata?.healthStatus?.healthy || c.tags.includes('stable') || c.tags.includes('good') ); } /** * Check if repository has uncommitted changes */ hasUncommittedChanges() { try { const status = execSync('git status --porcelain', { cwd: this.projectPath }) .toString(); return status.length > 0; } catch { return false; } } /** * Get current Git status */ getGitStatus() { try { const branch = execSync('git branch --show-current', { cwd: this.projectPath }) .toString().trim(); const status = execSync('git status --porcelain', { cwd: this.projectPath }) .toString(); const lastCommit = execSync('git rev-parse HEAD', { cwd: this.projectPath }) .toString().trim(); return { branch, hasChanges: status.length > 0, lastCommit, changes: status.split('\n').filter(Boolean) }; } catch (error) { return { branch: 'unknown', hasChanges: false, lastCommit: null, error: error.message }; } } /** * Verify Git repository exists */ verifyGitRepo() { try { execSync('git rev-parse --git-dir', { cwd: this.projectPath, stdio: 'pipe' }); } catch { throw new Error('Not a Git repository. Initialize with: git init'); } } /** * Generate checkpoint ID */ generateCheckpointId() { const timestamp = Date.now(); const random = crypto.randomBytes(4).toString('hex'); return `cp-${timestamp}-${random}`; } /** * Save checkpoint to disk */ async saveCheckpoint(checkpoint) { // Add to memory this.checkpoints.set(checkpoint.id, checkpoint); // Save to disk const checkpointFile = path.join(this.checkpointDir, `${checkpoint.id}.json`); await fs.writeFile(checkpointFile, JSON.stringify(checkpoint, null, 2)); // Update stats this.stats.totalCheckpoints++; // Prune old checkpoints if needed if (this.checkpoints.size > this.maxCheckpoints) { await this.pruneCheckpoints(); } } /** * Load checkpoints from disk */ async loadCheckpoints() { try { const files = await fs.readdir(this.checkpointDir); const checkpointFiles = files.filter(f => f.endsWith('.json')); for (const file of checkpointFiles) { const filePath = path.join(this.checkpointDir, file); const content = await fs.readFile(filePath, 'utf8'); const checkpoint = JSON.parse(content); this.checkpoints.set(checkpoint.id, checkpoint); } } catch (error) { // Directory doesn't exist yet } } /** * Delete a checkpoint */ async deleteCheckpoint(checkpointId) { this.checkpoints.delete(checkpointId); const checkpointFile = path.join(this.checkpointDir, `${checkpointId}.json`); try { await fs.unlink(checkpointFile); } catch { // File doesn't exist } } /** * Update average rollback time */ updateAverageRollbackTime(duration) { const totalRollbacks = this.stats.successfulRollbacks; const currentAvg = this.stats.averageRollbackTime; this.stats.averageRollbackTime = ((currentAvg * (totalRollbacks - 1)) + duration) / totalRollbacks; } /** * Get rollback statistics */ getStats() { return { ...this.stats, checkpointCount: this.checkpoints.size, oldestCheckpoint: this.getOldestCheckpoint(), newestCheckpoint: this.getNewestCheckpoint(), successRate: this.stats.successfulRollbacks / (this.stats.successfulRollbacks + this.stats.failedRollbacks) * 100 }; } /** * Get oldest checkpoint */ getOldestCheckpoint() { const checkpoints = Array.from(this.checkpoints.values()); if (checkpoints.length === 0) return null; return checkpoints.reduce((oldest, current) => current.timestamp < oldest.timestamp ? current : oldest ); } /** * Get newest checkpoint */ getNewestCheckpoint() { const checkpoints = Array.from(this.checkpoints.values()); if (checkpoints.length === 0) return null; return checkpoints.reduce((newest, current) => current.timestamp > newest.timestamp ? current : newest ); } } module.exports = { CheckpointManager };