UNPKG

create-roadkit

Version:

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

604 lines (525 loc) 16 kB
/** * Comprehensive logging framework for RoadKit CLI * * This module provides structured logging with security audit capabilities, * error tracking, and proper log formatting for CLI operations. */ import chalk from 'chalk'; import { writeFile, appendFile, mkdir } from 'fs/promises'; import { resolve, dirname } from 'path'; import { existsSync } from 'fs'; /** * Log levels in order of severity */ export enum LogLevel { DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3, SECURITY = 4 } /** * Log entry structure */ interface LogEntry { timestamp: string; level: LogLevel; message: string; context?: string; metadata?: Record<string, any>; stack?: string; } /** * Logger configuration options */ interface LoggerConfig { /** Minimum log level to display */ level: LogLevel; /** Whether to enable console output */ enableConsole: boolean; /** Whether to enable file logging */ enableFile: boolean; /** Directory for log files */ logDir?: string; /** Whether to enable verbose output */ verbose: boolean; /** Whether to enable colored output */ colored: boolean; } /** * Security audit log entry */ interface SecurityLogEntry extends LogEntry { securityEvent: string; userInput?: string; sanitizedInput?: string; risk: 'low' | 'medium' | 'high' | 'critical'; } /** * Comprehensive logger class with security audit capabilities */ export class Logger { private config: LoggerConfig; private logBuffer: LogEntry[] = []; private securityLogBuffer: SecurityLogEntry[] = []; constructor(config: Partial<LoggerConfig> = {}) { this.config = { level: LogLevel.INFO, enableConsole: true, enableFile: false, verbose: false, colored: true, ...config }; // Create log directory if file logging is enabled if (this.config.enableFile && this.config.logDir) { this.ensureLogDirectory(); } } /** * Logs a debug message */ debug(message: string, context?: string, metadata?: Record<string, any>): void { this.log(LogLevel.DEBUG, message, context, metadata); } /** * Logs an info message */ info(message: string, context?: string, metadata?: Record<string, any>): void { this.log(LogLevel.INFO, message, context, metadata); } /** * Logs a warning message */ warn(message: string, context?: string, metadata?: Record<string, any>): void { this.log(LogLevel.WARN, message, context, metadata); } /** * Logs an error message */ error(message: string, error?: Error, context?: string, metadata?: Record<string, any>): void { const entry: LogEntry = { timestamp: new Date().toISOString(), level: LogLevel.ERROR, message, context, metadata, stack: error?.stack }; this.processLogEntry(entry); } /** * Logs a security-related event for audit purposes */ security( message: string, securityEvent: string, risk: 'low' | 'medium' | 'high' | 'critical', userInput?: string, sanitizedInput?: string, metadata?: Record<string, any> ): void { const entry: SecurityLogEntry = { timestamp: new Date().toISOString(), level: LogLevel.SECURITY, message, securityEvent, userInput, sanitizedInput, risk, metadata }; this.securityLogBuffer.push(entry); this.processLogEntry(entry); // Immediately flush security logs to file if enabled if (this.config.enableFile) { this.flushSecurityLogs().catch(() => { // Silently handle file write errors to avoid infinite loops }); } } /** * Logs a CLI operation start */ startOperation(operation: string, metadata?: Record<string, any>): void { this.info(`🚀 Starting ${operation}`, 'CLI', { operation: 'start', ...metadata }); } /** * Logs a CLI operation completion */ completeOperation(operation: string, duration?: number, metadata?: Record<string, any>): void { const durationText = duration ? ` in ${duration}ms` : ''; this.info(`✅ Completed ${operation}${durationText}`, 'CLI', { operation: 'complete', duration, ...metadata }); } /** * Logs a CLI operation failure */ failOperation(operation: string, error: Error, metadata?: Record<string, any>): void { this.error(`❌ Failed ${operation}: ${error.message}`, error, 'CLI', { operation: 'failed', ...metadata }); } /** * Logs file system operations for audit trail */ fileOperation( operation: 'read' | 'write' | 'create' | 'delete' | 'copy', filePath: string, success: boolean, error?: string ): void { const message = success ? `File ${operation}: ${filePath}` : `File ${operation} failed: ${filePath} - ${error}`; this.log(success ? LogLevel.INFO : LogLevel.ERROR, message, 'FileSystem', { operation, filePath, success, error }); } /** * Logs template processing operations */ templateOperation( operation: 'validate' | 'copy' | 'process' | 'replace_vars', templatePath: string, details?: Record<string, any> ): void { this.debug(`Template ${operation}: ${templatePath}`, 'Template', { operation, templatePath, ...details }); } /** * Creates a progress logger that updates the same line */ progress(message: string, current: number, total: number): void { if (this.config.enableConsole) { const percentage = Math.round((current / total) * 100); const progressBar = this.createProgressBar(current, total, 20); const text = `${message} ${progressBar} ${percentage}%`; if (this.config.colored) { console.log(chalk.blue(text)); } else { console.log(text); } } } /** * Displays a success message with checkmark */ success(message: string, context?: string): void { const successMessage = `✅ ${message}`; this.info(successMessage, context); } /** * Displays a failure message with X mark */ failure(message: string, error?: Error, context?: string): void { const failureMessage = `❌ ${message}`; this.error(failureMessage, error, context); } /** * Sets the logger configuration */ configure(config: Partial<LoggerConfig>): void { this.config = { ...this.config, ...config }; if (this.config.enableFile && this.config.logDir) { this.ensureLogDirectory(); } } /** * Flushes all buffered logs to file */ async flush(): Promise<void> { if (!this.config.enableFile || !this.config.logDir) { return; } try { await this.flushRegularLogs(); await this.flushSecurityLogs(); } catch (error) { // Silently handle flush errors to avoid infinite loops console.error('Failed to flush logs:', error); } } /** * Core logging method */ private log( level: LogLevel, message: string, context?: string, metadata?: Record<string, any> ): void { if (level < this.config.level) { return; } const entry: LogEntry = { timestamp: new Date().toISOString(), level, message, context, metadata }; this.processLogEntry(entry); } /** * Processes a log entry (console output and buffering) */ private processLogEntry(entry: LogEntry | SecurityLogEntry): void { // Add to buffer for file logging if ('securityEvent' in entry) { // Don't add security entries to regular log buffer } else { this.logBuffer.push(entry); } // Console output if (this.config.enableConsole && entry.level >= this.config.level) { this.outputToConsole(entry); } } /** * Outputs a log entry to console with appropriate formatting */ private outputToConsole(entry: LogEntry | SecurityLogEntry): void { const timestamp = new Date(entry.timestamp).toLocaleTimeString(); const levelText = this.getLevelText(entry.level); const contextText = entry.context ? `[${entry.context}] ` : ''; let message = `${timestamp} ${levelText} ${contextText}${entry.message}`; // Add metadata if verbose mode is enabled if (this.config.verbose && entry.metadata) { message += `\n ${JSON.stringify(entry.metadata, null, 2)}`; } // Add stack trace for errors if (entry.level === LogLevel.ERROR && entry.stack) { message += `\n${entry.stack}`; } // Add security event details if ('securityEvent' in entry) { message += `\n Security Event: ${entry.securityEvent}`; message += `\n Risk Level: ${entry.risk.toUpperCase()}`; if (this.config.verbose) { if (entry.userInput) { message += `\n User Input: ${entry.userInput}`; } if (entry.sanitizedInput) { message += `\n Sanitized: ${entry.sanitizedInput}`; } } } if (this.config.colored) { console.log(this.colorizeMessage(message, entry.level)); } else { console.log(message); } } /** * Gets the text representation of a log level */ private getLevelText(level: LogLevel): string { switch (level) { case LogLevel.DEBUG: return '[DEBUG]'; case LogLevel.INFO: return '[INFO] '; case LogLevel.WARN: return '[WARN] '; case LogLevel.ERROR: return '[ERROR]'; case LogLevel.SECURITY: return '[SECURITY]'; default: return '[UNKNOWN]'; } } /** * Colorizes a message based on log level */ private colorizeMessage(message: string, level: LogLevel): string { switch (level) { case LogLevel.DEBUG: return chalk.gray(message); case LogLevel.INFO: return chalk.white(message); case LogLevel.WARN: return chalk.yellow(message); case LogLevel.ERROR: return chalk.red(message); case LogLevel.SECURITY: return chalk.magenta(message); default: return message; } } /** * Creates a visual progress bar */ private createProgressBar(current: number, total: number, width: number): string { const completed = Math.round((current / total) * width); const remaining = width - completed; return `[${'█'.repeat(completed)}${' '.repeat(remaining)}]`; } /** * Ensures the log directory exists */ private async ensureLogDirectory(): Promise<void> { if (!this.config.logDir) { return; } try { if (!existsSync(this.config.logDir)) { await mkdir(this.config.logDir, { recursive: true }); } } catch (error) { // Disable file logging if directory creation fails this.config.enableFile = false; console.error(`Failed to create log directory ${this.config.logDir}:`, error); } } /** * Flushes regular logs to file */ private async flushRegularLogs(): Promise<void> { if (!this.config.logDir || this.logBuffer.length === 0) { return; } const logFile = resolve(this.config.logDir, `roadkit-${new Date().toISOString().split('T')[0]}.log`); const logLines = this.logBuffer.map(entry => this.formatLogEntryForFile(entry)); await appendFile(logFile, logLines.join('\n') + '\n'); this.logBuffer = []; } /** * Flushes security logs to separate audit file */ private async flushSecurityLogs(): Promise<void> { if (!this.config.logDir || this.securityLogBuffer.length === 0) { return; } const securityLogFile = resolve(this.config.logDir, `roadkit-security-${new Date().toISOString().split('T')[0]}.log`); const logLines = this.securityLogBuffer.map(entry => this.formatSecurityLogEntryForFile(entry)); await appendFile(securityLogFile, logLines.join('\n') + '\n'); this.securityLogBuffer = []; } /** * Formats a regular log entry for file output */ private formatLogEntryForFile(entry: LogEntry): string { const levelText = this.getLevelText(entry.level); const contextText = entry.context ? `[${entry.context}] ` : ''; let line = `${entry.timestamp} ${levelText} ${contextText}${entry.message}`; if (entry.metadata) { line += ` | Metadata: ${JSON.stringify(entry.metadata)}`; } if (entry.stack) { line += ` | Stack: ${entry.stack.replace(/\n/g, ' | ')}`; } return line; } /** * Formats a security log entry for file output */ private formatSecurityLogEntryForFile(entry: SecurityLogEntry): string { let line = `${entry.timestamp} [SECURITY] ${entry.message}`; line += ` | Event: ${entry.securityEvent}`; line += ` | Risk: ${entry.risk.toUpperCase()}`; if (entry.userInput) { line += ` | UserInput: ${JSON.stringify(entry.userInput)}`; } if (entry.sanitizedInput) { line += ` | Sanitized: ${JSON.stringify(entry.sanitizedInput)}`; } if (entry.metadata) { line += ` | Metadata: ${JSON.stringify(entry.metadata)}`; } return line; } } /** * Default logger instance */ export const logger = new Logger(); /** * Creates a new logger instance with custom configuration */ export function createLogger(config?: Partial<LoggerConfig>): Logger { return new Logger(config); } /** * Error recovery utilities */ export class ErrorRecovery { private logger: Logger; constructor(logger: Logger) { this.logger = logger; } /** * Attempts to recover from a file operation error */ async recoverFromFileError(error: Error, operation: string, filePath: string): Promise<boolean> { this.logger.warn(`Attempting recovery from file error: ${error.message}`, 'ErrorRecovery', { operation, filePath, errorType: error.constructor.name }); // Specific recovery strategies based on error type if (error.message.includes('ENOENT')) { this.logger.info('File not found - continuing without this file', 'ErrorRecovery'); return true; } if (error.message.includes('EACCES') || error.message.includes('EPERM')) { this.logger.warn('Permission denied - skipping this operation', 'ErrorRecovery'); return true; } if (error.message.includes('EEXIST')) { this.logger.info('File already exists - continuing', 'ErrorRecovery'); return true; } // For other errors, we cannot recover this.logger.error('Cannot recover from this error', error, 'ErrorRecovery'); return false; } /** * Recovers from template processing errors */ async recoverFromTemplateError(error: Error, templatePath: string): Promise<boolean> { this.logger.warn(`Attempting recovery from template error: ${error.message}`, 'ErrorRecovery', { templatePath, errorType: error.constructor.name }); // If it's a validation error, we can skip this template if (error.message.includes('validation') || error.message.includes('invalid')) { this.logger.info('Skipping invalid template file', 'ErrorRecovery'); return true; } return false; } /** * Provides user-friendly error messages and recovery suggestions */ getErrorRecoverySuggestion(error: Error): string { if (error.message.includes('EACCES') || error.message.includes('EPERM')) { return 'Try running the command with elevated permissions or check file/directory permissions.'; } if (error.message.includes('ENOSPC')) { return 'Insufficient disk space. Free up some space and try again.'; } if (error.message.includes('EMFILE') || error.message.includes('ENFILE')) { return 'Too many open files. Close unnecessary applications and try again.'; } if (error.message.includes('network') || error.message.includes('fetch')) { return 'Network error. Check your internet connection and try again.'; } return 'An unexpected error occurred. Please check the logs for more details.'; } }