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