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
text/typescript
/**
* 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);
};