UNPKG

claude-buddy

Version:

Claude Buddy installation and setup script

613 lines (528 loc) 15.7 kB
/** * Logger Module for Claude Buddy Installation Script * Provides comprehensive logging functionality with multiple output modes, * color support, progress indicators, and file logging capabilities. */ const chalk = require('chalk'); const fs = require('fs').promises; const path = require('path'); // const os = require('os'); // Reserved for future use /** * Logger configuration and state */ const loggerState = { level: 'normal', // 'normal', 'verbose', 'quiet', 'json' colorEnabled: true, logFile: null, logFileStream: null, spinner: null, spinnerInterval: null, spinnerFrames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], spinnerIndex: 0, currentSection: null }; /** * Create a logger instance with specified configuration * @param {Object} options - Logger options * @param {string} options.level - Log level ('normal', 'verbose', 'quiet', 'json') * @param {boolean} options.color - Enable/disable color output * @param {string} options.logFile - Path to log file (optional) * @returns {Object} Logger instance */ function createLogger(options = {}) { loggerState.level = options.level || 'normal'; loggerState.logFile = options.logFile || null; // Check environment variables first (they take precedence) if (process.env.NO_COLOR || process.env.CLAUDE_BUDDY_NO_COLOR) { loggerState.colorEnabled = false; } else if (options.color !== undefined) { // Explicit color option loggerState.colorEnabled = options.color; } else { // Auto-detect loggerState.colorEnabled = supportsColor(); } // Initialize file logging if specified if (loggerState.logFile) { initializeFileLogging(loggerState.logFile); } return { log, info, warn, error, success, debug, verbose, section, progress, startSpinner, stopSpinner, logInstallationSummary, logUpdateSummary, logUninstallSummary, logErrorWithSuggestions, setLevel, close }; } /** * Detect if the terminal supports color output * @returns {boolean} True if colors are supported */ function supportsColor() { // Check if stdout is a TTY if (!process.stdout.isTTY) { return false; } // Check for NO_COLOR or FORCE_COLOR environment variables if (process.env.NO_COLOR || process.env.CLAUDE_BUDDY_NO_COLOR) { return false; } if (process.env.FORCE_COLOR || process.env.CLAUDE_BUDDY_FORCE_COLOR) { return true; } // Check TERM environment variable const term = process.env.TERM || ''; if (term === 'dumb') { return false; } // Most modern terminals support color return true; } /** * Initialize file logging with rotation * @param {string} filePath - Path to log file */ async function initializeFileLogging(filePath) { try { const logDir = path.dirname(filePath); await fs.mkdir(logDir, { recursive: true }); // Rotate old log files await rotateLogFiles(filePath); // Create write stream for new log file loggerState.logFileStream = await fs.open(filePath, 'a'); // Write log header const header = `\n${'='.repeat(80)}\n` + `Installation Log Started: ${new Date().toISOString()}\n` + `${'='.repeat(80)}\n`; await writeToFile(header); } catch (err) { console.error(`Warning: Failed to initialize log file: ${err.message}`); } } /** * Rotate log files (keep last 3, delete older than 7 days) * @param {string} filePath - Base log file path */ async function rotateLogFiles(filePath) { try { const dir = path.dirname(filePath); const basename = path.basename(filePath, '.log'); const files = await fs.readdir(dir); // Find all log files with timestamp suffix const logFiles = files .filter(f => f.startsWith(basename) && f.endsWith('.log')) .map(f => ({ name: f, path: path.join(dir, f), time: 0 })); // Get file stats and sort by modification time for (const file of logFiles) { try { const stats = await fs.stat(file.path); file.time = stats.mtime.getTime(); } catch (err) { // Skip files that can't be accessed } } logFiles.sort((a, b) => b.time - a.time); // Keep only the last 3 files, delete the rest const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); for (let i = 3; i < logFiles.length; i++) { try { if (logFiles[i].time < sevenDaysAgo) { await fs.unlink(logFiles[i].path); } } catch (err) { // Ignore deletion errors } } // Rename current log file if it exists try { await fs.access(filePath); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const newName = path.join(dir, `${basename}-${timestamp}.log`); await fs.rename(filePath, newName); } catch (err) { // File doesn't exist, no need to rename } } catch (err) { // Rotation is optional, don't fail if it doesn't work } } /** * Write message to log file * @param {string} message - Message to write */ async function writeToFile(message) { if (loggerState.logFileStream) { try { await loggerState.logFileStream.write(message + '\n', 'utf-8'); } catch (err) { // Ignore file write errors } } } /** * Format message with color (if enabled) * @param {string} message - Message to format * @param {string} color - Color name * @returns {string} Formatted message */ function colorize(message, color) { if (!loggerState.colorEnabled || !chalk[color]) { return message; } return chalk[color](message); } /** * Log a generic message * @param {string} message - Message to log */ function log(message) { if (loggerState.level === 'quiet' || loggerState.level === 'json') { return; } console.log(message); writeToFile(message); } /** * Log an informational message * @param {string} message - Message to log */ function info(message) { if (loggerState.level === 'quiet' || loggerState.level === 'json') { return; } const formatted = colorize(`ℹ ${message}`, 'blue'); console.log(formatted); writeToFile(`INFO: ${message}`); } /** * Log a warning message * @param {string} message - Message to log */ function warn(message) { if (loggerState.level === 'quiet' || loggerState.level === 'json') { return; } const formatted = colorize(`⚠ ${message}`, 'yellow'); console.log(formatted); writeToFile(`WARN: ${message}`); } /** * Log an error message * @param {string} message - Message to log */ function error(message) { // Always log errors, even in quiet mode if (loggerState.level === 'json') { console.error(JSON.stringify({ level: 'error', message })); } else { const formatted = colorize(`✗ ${message}`, 'red'); console.error(formatted); } writeToFile(`ERROR: ${message}`); } /** * Log a success message * @param {string} message - Message to log */ function success(message) { if (loggerState.level === 'quiet' || loggerState.level === 'json') { return; } const formatted = colorize(`✓ ${message}`, 'green'); console.log(formatted); writeToFile(`SUCCESS: ${message}`); } /** * Log a debug message (only in verbose mode) * @param {string} message - Message to log */ function debug(message) { if (loggerState.level !== 'verbose') { return; } const formatted = colorize(`🔍 ${message}`, 'gray'); console.log(formatted); writeToFile(`DEBUG: ${message}`); } /** * Log a verbose message (only in verbose mode) * @param {string} message - Message to log */ function verbose(message) { if (loggerState.level !== 'verbose') { return; } const formatted = colorize(message, 'gray'); console.log(formatted); writeToFile(`VERBOSE: ${message}`); } /** * Log a section header * @param {string} title - Section title */ function section(title) { if (loggerState.level === 'quiet' || loggerState.level === 'json') { return; } loggerState.currentSection = title; const separator = '─'.repeat(Math.min(title.length + 4, 80)); const formatted = colorize(`\n${separator}\n ${title}\n${separator}`, 'cyan'); console.log(formatted); writeToFile(`\n=== ${title} ===`); } /** * Display progress indicator or status message * @param {string} message - Progress message * @param {number|boolean} current - Current progress value (or verbose flag for simple status) * @param {number} total - Total progress value */ function progress(message, current, total) { if (loggerState.level === 'quiet' || loggerState.level === 'json') { return; } // If current and total are valid numbers, show progress bar if (typeof current === 'number' && typeof total === 'number' && total > 0) { const percentage = Math.floor((current / total) * 100); const barLength = 30; const filledLength = Math.floor((barLength * current) / total); const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength); const formatted = `${message} [${bar}] ${percentage}% (${current}/${total})`; process.stdout.write('\r' + formatted); if (current === total) { process.stdout.write('\n'); writeToFile(`PROGRESS: ${message} - Complete (${current}/${total})`); } } else { // Simple status message without progress bar (when called with verbose flag) // Don't output anything - the success/info message will be shown instead writeToFile(`STATUS: ${message}`); } } /** * Start a spinner with a message * @param {string} message - Spinner message */ function startSpinner(message) { if (loggerState.level === 'quiet' || loggerState.level === 'json' || !process.stdout.isTTY) { // Just log the message without spinner log(message); return; } // Stop existing spinner if any stopSpinner(); loggerState.spinner = message; loggerState.spinnerIndex = 0; loggerState.spinnerInterval = setInterval(() => { const frame = loggerState.spinnerFrames[loggerState.spinnerIndex]; const formatted = colorize(`${frame} ${loggerState.spinner}`, 'cyan'); process.stdout.write('\r' + formatted + ' '.repeat(10)); loggerState.spinnerIndex = (loggerState.spinnerIndex + 1) % loggerState.spinnerFrames.length; }, 80); writeToFile(`SPINNER: ${message}`); } /** * Stop the current spinner * @param {string} finalMessage - Optional final message to display * @param {boolean} success - Whether the operation succeeded */ function stopSpinner(finalMessage, success = true) { if (loggerState.spinnerInterval) { clearInterval(loggerState.spinnerInterval); loggerState.spinnerInterval = null; if (process.stdout.isTTY) { process.stdout.write('\r' + ' '.repeat(100) + '\r'); } if (finalMessage) { if (success) { this.success(finalMessage); } else { this.error(finalMessage); } } loggerState.spinner = null; } } /** * Log installation summary * @param {Object} summary - Installation summary data */ function logInstallationSummary(summary) { if (loggerState.level === 'json') { console.log(JSON.stringify({ type: 'installation-summary', ...summary })); return; } section('Installation Summary'); success(`Claude Buddy v${summary.version} installed successfully`); info(`Installation mode: ${summary.mode}`); info(`Target directory: ${summary.targetDirectory}`); if (summary.components && summary.components.length > 0) { log('\nInstalled components:'); for (const component of summary.components) { if (component.enabled) { success(` ${component.name} - ${component.description}`); } else { warn(` ${component.name} - Skipped (${component.reason})`); } } } if (summary.warnings && summary.warnings.length > 0) { log('\nWarnings:'); for (const warning of summary.warnings) { warn(` ${warning}`); } } if (summary.nextSteps && summary.nextSteps.length > 0) { log('\nNext steps:'); for (const step of summary.nextSteps) { info(` ${step}`); } } writeToFile(JSON.stringify(summary, null, 2)); } /** * Log update summary * @param {Object} summary - Update summary data */ function logUpdateSummary(summary) { if (loggerState.level === 'json') { console.log(JSON.stringify({ type: 'update-summary', ...summary })); return; } section('Update Summary'); success(`Updated from v${summary.fromVersion} to v${summary.toVersion}`); if (summary.updatedFiles && summary.updatedFiles.length > 0) { log('\nUpdated files:'); for (const file of summary.updatedFiles) { info(` ${file}`); } } if (summary.preservedFiles && summary.preservedFiles.length > 0) { log('\nPreserved customizations:'); for (const file of summary.preservedFiles) { success(` ${file}`); } } if (summary.migrations && summary.migrations.length > 0) { log('\nMigrations applied:'); for (const migration of summary.migrations) { info(` ${migration}`); } } writeToFile(JSON.stringify(summary, null, 2)); } /** * Log uninstallation summary * @param {Object} summary - Uninstallation summary data */ function logUninstallSummary(summary) { if (loggerState.level === 'json') { console.log(JSON.stringify({ type: 'uninstall-summary', ...summary })); return; } section('Uninstallation Summary'); success('Claude Buddy uninstalled successfully'); if (summary.removedFiles && summary.removedFiles.length > 0) { log('\nRemoved files:'); for (const file of summary.removedFiles) { info(` ${file}`); } } if (summary.preservedFiles && summary.preservedFiles.length > 0) { log('\nPreserved files:'); for (const file of summary.preservedFiles) { success(` ${file}`); } } writeToFile(JSON.stringify(summary, null, 2)); } /** * Log error with actionable suggestions * @param {Object} errorInfo - Error information */ function logErrorWithSuggestions(errorInfo) { if (loggerState.level === 'json') { console.error(JSON.stringify({ type: 'error', ...errorInfo })); return; } section('Error Occurred'); error(errorInfo.message); if (errorInfo.details) { log('\nDetails:'); log(colorize(errorInfo.details, 'gray')); } if (errorInfo.suggestions && errorInfo.suggestions.length > 0) { log('\nSuggestions:'); for (const suggestion of errorInfo.suggestions) { info(` • ${suggestion}`); } } if (errorInfo.technicalDetails) { verbose('\nTechnical details:'); verbose(errorInfo.technicalDetails); } writeToFile(JSON.stringify(errorInfo, null, 2)); } /** * Set the logger level * @param {string} level - New log level */ function setLevel(level) { loggerState.level = level; } /** * Close the logger and cleanup resources */ async function close() { if (loggerState.spinnerInterval) { stopSpinner(); } if (loggerState.logFileStream) { try { const footer = `\n${'='.repeat(80)}\n` + `Installation Log Ended: ${new Date().toISOString()}\n` + `${'='.repeat(80)}\n`; await writeToFile(footer); await loggerState.logFileStream.close(); loggerState.logFileStream = null; } catch (err) { // Ignore close errors } } } // Export the logger factory and individual functions module.exports = { createLogger, log, info, warn, error, success, debug, verbose, section, progress, startSpinner, stopSpinner, logInstallationSummary, logUpdateSummary, logUninstallSummary, logErrorWithSuggestions, setLevel, close };