UNPKG

create-roadkit

Version:

Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export

680 lines (608 loc) • 19.8 kB
/** * Comprehensive error handling and validation utilities for RoadKit. * * This module provides robust error handling, validation systems, rollback * capabilities, and logging mechanisms to ensure reliable project generation * with detailed error reporting and recovery options. */ import path from 'path'; import type { Logger } from '../types/config'; /** * Custom error classes for different types of failures * This allows for better error categorization and handling */ export class RoadKitError extends Error { public readonly code: string; public readonly category: ErrorCategory; public readonly recoverable: boolean; public readonly context?: Record<string, unknown>; constructor( message: string, code: string, category: ErrorCategory, recoverable = false, context?: Record<string, unknown> ) { super(message); this.name = 'RoadKitError'; this.code = code; this.category = category; this.recoverable = recoverable; this.context = context; } } /** * Configuration validation error with detailed field information */ export class ConfigValidationError extends RoadKitError { public readonly field: string; constructor(message: string, field: string, context?: Record<string, unknown>) { super(message, 'CONFIG_VALIDATION_ERROR', ErrorCategory.VALIDATION, true, context); this.field = field; } } /** * File operation error with path and operation details */ export class FileOperationError extends RoadKitError { public readonly filePath: string; public readonly operation: string; constructor( message: string, filePath: string, operation: string, context?: Record<string, unknown> ) { super(message, 'FILE_OPERATION_ERROR', ErrorCategory.FILE_SYSTEM, true, context); this.filePath = filePath; this.operation = operation; } } /** * Template processing error with template details */ export class TemplateProcessingError extends RoadKitError { public readonly templatePath: string; public readonly variable?: string; constructor( message: string, templatePath: string, variable?: string, context?: Record<string, unknown> ) { super(message, 'TEMPLATE_PROCESSING_ERROR', ErrorCategory.TEMPLATE, true, context); this.templatePath = templatePath; this.variable = variable; } } /** * Dependency installation error with package details */ export class DependencyInstallationError extends RoadKitError { public readonly packageManager: string; public readonly exitCode?: number; constructor( message: string, packageManager: string, exitCode?: number, context?: Record<string, unknown> ) { super(message, 'DEPENDENCY_INSTALLATION_ERROR', ErrorCategory.EXTERNAL, false, context); this.packageManager = packageManager; this.exitCode = exitCode; } } /** * Network-related error for external resource access */ export class NetworkError extends RoadKitError { public readonly url?: string; public readonly statusCode?: number; constructor( message: string, url?: string, statusCode?: number, context?: Record<string, unknown> ) { super(message, 'NETWORK_ERROR', ErrorCategory.NETWORK, true, context); this.url = url; this.statusCode = statusCode; } } /** * Error categories for better organization and handling */ export enum ErrorCategory { VALIDATION = 'validation', FILE_SYSTEM = 'file_system', TEMPLATE = 'template', CONFIGURATION = 'configuration', EXTERNAL = 'external', NETWORK = 'network', SYSTEM = 'system', USER_INPUT = 'user_input', } /** * Error severity levels for prioritization */ export enum ErrorSeverity { LOW = 'low', MEDIUM = 'medium', HIGH = 'high', CRITICAL = 'critical', } /** * Error context with detailed information for debugging */ export interface ErrorContext { timestamp: Date; operation: string; stage: string; projectName?: string; outputPath?: string; templatePath?: string; themePath?: string; version: string; environment: NodeJS.ProcessEnv; systemInfo: { platform: string; arch: string; nodeVersion: string; bunVersion?: string; }; } /** * Error recovery strategy definition */ export interface RecoveryStrategy { canRecover: boolean; recoverySteps: string[]; automaticRecovery?: () => Promise<void>; userAction?: string; } /** * Error handler result with recovery information */ export interface ErrorHandlerResult { handled: boolean; severity: ErrorSeverity; recovery: RecoveryStrategy; userMessage: string; logMessage: string; context: ErrorContext; } /** * Comprehensive error handler that provides detailed error analysis, * recovery strategies, and user-friendly error messages. */ export class ErrorHandler { private logger: Logger; private context: Partial<ErrorContext>; /** * Initialize the error handler with a logger and context * @param logger - Logger instance for error reporting * @param context - Partial context that will be enhanced with system info */ constructor(logger: Logger, context: Partial<ErrorContext> = {}) { this.logger = logger; this.context = { ...context, timestamp: new Date(), version: '1.0.0', environment: process.env, systemInfo: { platform: process.platform, arch: process.arch, nodeVersion: process.version, bunVersion: process.versions?.bun, }, }; } /** * Handles any error with comprehensive analysis and recovery strategies * * This method provides the main error handling logic, categorizing errors, * determining severity, and providing appropriate recovery strategies. * * @param error - The error to handle * @param operation - The operation that was being performed * @param stage - The current stage of the scaffolding process * @returns Detailed error handler result with recovery information */ public handleError( error: Error | RoadKitError, operation: string, stage: string ): ErrorHandlerResult { // Create full context for this error const fullContext: ErrorContext = { ...this.context, timestamp: new Date(), operation, stage, } as ErrorContext; // Determine error type and category let category: ErrorCategory; let severity: ErrorSeverity; let recovery: RecoveryStrategy; if (error instanceof RoadKitError) { category = error.category; severity = this.determineSeverity(error); recovery = this.determineRecoveryStrategy(error, fullContext); } else { // Handle standard JavaScript errors category = this.categorizeStandardError(error, operation); severity = this.determineSeverityForStandardError(error, category); recovery = this.determineRecoveryForStandardError(error, category, fullContext); } // Generate user-friendly and technical messages const userMessage = this.generateUserMessage(error, category, recovery); const logMessage = this.generateLogMessage(error, fullContext); // Log the error with appropriate level this.logError(error, severity, fullContext); return { handled: true, severity, recovery, userMessage, logMessage, context: fullContext, }; } /** * Validates system requirements before starting scaffolding * @returns Validation result with any requirement failures */ public async validateSystemRequirements(): Promise<{ valid: boolean; errors: string[]; warnings: string[]; }> { const errors: string[] = []; const warnings: string[] = []; try { // Check Node.js version const nodeVersion = process.version; const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0]); if (majorVersion < 18) { errors.push(`Node.js version ${majorVersion} is not supported. Please upgrade to Node.js 18 or higher.`); } else if (majorVersion < 20) { warnings.push(`Node.js version ${majorVersion} is supported but Node.js 20+ is recommended for optimal performance.`); } // Check if Bun is available (preferred) try { const bunVersion = await Bun.$`bun --version`.text(); this.logger.debug(`Bun version detected: ${bunVersion.trim()}`); } catch { warnings.push('Bun is not available. Using Node.js for package operations (slower performance expected).'); } // Check write permissions in current directory try { const testFile = path.join(process.cwd(), '.roadkit-permission-test'); await Bun.write(testFile, 'test'); await Bun.unlink(testFile); } catch (error) { errors.push('No write permissions in current directory. Please check directory permissions.'); } // Check available disk space (basic check) try { const stats = await Bun.file(process.cwd()).stat(); if (stats.size === 0) { warnings.push('Unable to determine available disk space. Ensure sufficient space for project generation.'); } } catch { warnings.push('Unable to check disk space. Ensure sufficient space for project generation.'); } // Check internet connectivity (for dependency installation) try { await fetch('https://registry.npmjs.org/react/latest', { method: 'HEAD', signal: AbortSignal.timeout(5000), }); } catch { warnings.push('Internet connectivity check failed. Dependency installation may fail.'); } } catch (error) { errors.push(`System validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } return { valid: errors.length === 0, errors, warnings, }; } /** * Creates a rollback plan for the current operation * @param operations - List of operations that have been performed * @returns Detailed rollback plan with steps and validation */ public createRollbackPlan(operations: Array<{ type: string; path: string; success: boolean; timestamp: Date; }>): { canRollback: boolean; steps: string[]; riskyOperations: string[]; estimatedTime: number; } { const successfulOperations = operations.filter(op => op.success); const steps: string[] = []; const riskyOperations: string[] = []; let estimatedTime = 0; // Process operations in reverse order for (const operation of successfulOperations.reverse()) { switch (operation.type) { case 'create_directory': steps.push(`Remove directory: ${operation.path}`); estimatedTime += 1; // 1 second per directory break; case 'create_file': steps.push(`Delete file: ${operation.path}`); estimatedTime += 0.5; // 0.5 seconds per file break; case 'copy_file': steps.push(`Remove copied file: ${operation.path}`); estimatedTime += 0.5; break; case 'install_dependencies': steps.push(`Remove node_modules directory`); riskyOperations.push('Dependency removal may affect other projects if using global packages'); estimatedTime += 10; // 10 seconds for dependency cleanup break; case 'git_init': steps.push(`Remove .git directory: ${path.join(operation.path, '.git')}`); riskyOperations.push('Git history will be permanently lost'); estimatedTime += 2; break; default: steps.push(`Undo ${operation.type}: ${operation.path}`); estimatedTime += 1; } } return { canRollback: steps.length > 0, steps, riskyOperations, estimatedTime, }; } // Private helper methods /** * Determines error severity based on error type and context */ private determineSeverity(error: RoadKitError): ErrorSeverity { switch (error.category) { case ErrorCategory.VALIDATION: return ErrorSeverity.MEDIUM; case ErrorCategory.FILE_SYSTEM: return error.recoverable ? ErrorSeverity.MEDIUM : ErrorSeverity.HIGH; case ErrorCategory.EXTERNAL: return ErrorSeverity.HIGH; case ErrorCategory.NETWORK: return ErrorSeverity.MEDIUM; case ErrorCategory.SYSTEM: return ErrorSeverity.CRITICAL; default: return ErrorSeverity.MEDIUM; } } /** * Determines severity for standard JavaScript errors */ private determineSeverityForStandardError(error: Error, category: ErrorCategory): ErrorSeverity { if (error.message.includes('EACCES') || error.message.includes('permission')) { return ErrorSeverity.HIGH; } if (error.message.includes('ENOENT') || error.message.includes('not found')) { return ErrorSeverity.MEDIUM; } if (error.message.includes('EMFILE') || error.message.includes('too many files')) { return ErrorSeverity.CRITICAL; } return ErrorSeverity.MEDIUM; } /** * Categorizes standard JavaScript errors */ private categorizeStandardError(error: Error, operation: string): ErrorCategory { const message = error.message.toLowerCase(); if (message.includes('enoent') || message.includes('not found')) { return ErrorCategory.FILE_SYSTEM; } if (message.includes('eacces') || message.includes('permission')) { return ErrorCategory.FILE_SYSTEM; } if (message.includes('network') || message.includes('fetch')) { return ErrorCategory.NETWORK; } if (operation.includes('template') || operation.includes('process')) { return ErrorCategory.TEMPLATE; } if (operation.includes('config') || operation.includes('validation')) { return ErrorCategory.VALIDATION; } return ErrorCategory.SYSTEM; } /** * Determines recovery strategy for RoadKit errors */ private determineRecoveryStrategy(error: RoadKitError, context: ErrorContext): RecoveryStrategy { switch (error.category) { case ErrorCategory.VALIDATION: return { canRecover: true, recoverySteps: [ 'Review the configuration values', 'Correct any invalid inputs', 'Run the command again', ], userAction: 'Please review and correct your configuration', }; case ErrorCategory.FILE_SYSTEM: if (error instanceof FileOperationError) { return { canRecover: true, recoverySteps: [ `Check permissions for: ${error.filePath}`, 'Ensure the directory exists and is writable', 'Try running with administrator/sudo privileges if needed', ], userAction: 'Please check file system permissions', }; } break; case ErrorCategory.NETWORK: return { canRecover: true, recoverySteps: [ 'Check your internet connection', 'Verify proxy settings if applicable', 'Try again after a few moments', ], userAction: 'Please check your network connection', }; case ErrorCategory.EXTERNAL: return { canRecover: false, recoverySteps: [ 'Check if the external tool is installed correctly', 'Verify the tool is in your PATH', 'Try running the tool manually to test', ], userAction: 'Please check external tool installation', }; } return { canRecover: false, recoverySteps: ['Manual intervention required'], userAction: 'Please review the error and try again', }; } /** * Determines recovery strategy for standard errors */ private determineRecoveryForStandardError( error: Error, category: ErrorCategory, context: ErrorContext ): RecoveryStrategy { const message = error.message.toLowerCase(); if (message.includes('enoent')) { return { canRecover: true, recoverySteps: [ 'Check if the required file or directory exists', 'Verify the path is correct', 'Ensure all parent directories exist', ], userAction: 'Please check if the required files exist', }; } if (message.includes('eacces') || message.includes('permission')) { return { canRecover: true, recoverySteps: [ 'Check file and directory permissions', 'Run with appropriate privileges if needed', 'Ensure you have write access to the target directory', ], userAction: 'Please check file permissions', }; } return { canRecover: true, recoverySteps: ['Review the error message and try again'], userAction: 'Please review the error and retry', }; } /** * Generates user-friendly error message */ private generateUserMessage( error: Error | RoadKitError, category: ErrorCategory, recovery: RecoveryStrategy ): string { let message = `āŒ ${error.message}`; if (recovery.userAction) { message += `\n\nšŸ’” ${recovery.userAction}`; } if (recovery.canRecover && recovery.recoverySteps.length > 0) { message += '\n\nSuggested steps:'; recovery.recoverySteps.forEach((step, index) => { message += `\n ${index + 1}. ${step}`; }); } return message; } /** * Generates technical log message */ private generateLogMessage(error: Error | RoadKitError, context: ErrorContext): string { const parts = [ `Error: ${error.name}: ${error.message}`, `Operation: ${context.operation}`, `Stage: ${context.stage}`, `Timestamp: ${context.timestamp.toISOString()}`, `Platform: ${context.systemInfo.platform} ${context.systemInfo.arch}`, `Node: ${context.systemInfo.nodeVersion}`, ]; if (context.systemInfo.bunVersion) { parts.push(`Bun: ${context.systemInfo.bunVersion}`); } if (context.projectName) { parts.push(`Project: ${context.projectName}`); } if (context.outputPath) { parts.push(`Output: ${context.outputPath}`); } if (error.stack) { parts.push(`Stack: ${error.stack}`); } return parts.join('\n'); } /** * Logs error with appropriate level based on severity */ private logError(error: Error | RoadKitError, severity: ErrorSeverity, context: ErrorContext): void { const logMessage = this.generateLogMessage(error, context); switch (severity) { case ErrorSeverity.LOW: this.logger.info(`Low severity error: ${error.message}`); break; case ErrorSeverity.MEDIUM: this.logger.warn(`Medium severity error: ${error.message}`, { context }); break; case ErrorSeverity.HIGH: this.logger.error(`High severity error: ${error.message}`, error); break; case ErrorSeverity.CRITICAL: this.logger.error(`CRITICAL ERROR: ${error.message}`, error); if (this.logger.debug) { this.logger.debug('Full error context', context); } break; } } /** * Updates the error context with new information * @param updates - Context updates to merge */ public updateContext(updates: Partial<ErrorContext>): void { this.context = { ...this.context, ...updates }; } } /** * Factory function to create an ErrorHandler instance * @param logger - Logger instance for error reporting * @param context - Initial context for error handling * @returns Configured ErrorHandler instance */ export const createErrorHandler = ( logger: Logger, context: Partial<ErrorContext> = {} ): ErrorHandler => { return new ErrorHandler(logger, context); };