From f8269a1cb7b7b3b2d18627d6ef262ebd15ebe978 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 19 Oct 2025 15:08:30 +0000 Subject: [PATCH] feat(cli): add beautiful colored output and fix daemon exit bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major improvements: - Created color theme system (ts/colors.ts) with ANSI colors - Enhanced logger with colors, table formatting, and styled boxes - Fixed daemon exit bug - now stays running when no UPS configured - Added config hot-reload with file watcher for live updates - Beautified CLI help output with color-coded commands - Added showcase test demonstrating all output features - Fixed ANSI code handling for perfect table/box alignment Features: - Color-coded messages (success=green, error=red, warning=yellow, info=cyan) - Status symbols (●○◐◯ for running/stopped/starting/unknown) - Battery level colors (green>60%, yellow 30-60%, red<30%) - Table formatting with auto-sizing and column alignment - Styled boxes (success, error, warning, info styles) - Hot-reload: daemon watches config file and reloads automatically - Idle mode: daemon stays alive when no devices, checks periodically Daemon improvements: - No longer exits when no UPS devices configured - Enters idle monitoring loop waiting for config - File watcher detects config changes in real-time - Auto-reloads and starts monitoring when devices added - Logs warnings instead of errors for missing devices Technical fixes: - Strip ANSI codes when calculating text width for alignment - Use visible length for padding calculations in tables and boxes - Properly handle colored text in table cells and box lines Breaking changes: None (backward compatible) --- deno.json | 2 +- test/showcase.ts | 233 +++++++++++++++++++++++++++++++++++++++++++++++ ts/cli.ts | 114 ++++++++++++++--------- ts/colors.ts | 89 ++++++++++++++++++ ts/daemon.ts | 132 ++++++++++++++++++++++++++- ts/logger.ts | 220 ++++++++++++++++++++++++++++++++++++++++---- 6 files changed, 726 insertions(+), 64 deletions(-) create mode 100644 test/showcase.ts create mode 100644 ts/colors.ts diff --git a/deno.json b/deno.json index a0efa93..626de62 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@serve.zone/nupst", - "version": "4.0.1", + "version": "4.0.2", "exports": "./mod.ts", "tasks": { "dev": "deno run --allow-all mod.ts", diff --git a/test/showcase.ts b/test/showcase.ts new file mode 100644 index 0000000..a28bb43 --- /dev/null +++ b/test/showcase.ts @@ -0,0 +1,233 @@ +/** + * Showcase test for NUPST CLI outputs + * Demonstrates all the beautiful colored output features + * + * Run with: deno run --allow-all test/showcase.ts + */ + +import { logger, type ITableColumn } from '../ts/logger.ts'; +import { theme, symbols, getBatteryColor, formatPowerStatus } from '../ts/colors.ts'; + +console.log(''); +console.log('═'.repeat(80)); +logger.highlight('NUPST CLI OUTPUT SHOWCASE'); +logger.dim('Demonstrating beautiful, colored terminal output'); +console.log('═'.repeat(80)); +console.log(''); + +// === 1. Basic Logging Methods === +logger.logBoxTitle('Basic Logging Methods', 60, 'info'); +logger.logBoxLine(''); +logger.log('Normal log message (default color)'); +logger.success('Success message with ✓ symbol'); +logger.error('Error message with ✗ symbol'); +logger.warn('Warning message with ⚠ symbol'); +logger.info('Info message with ℹ symbol'); +logger.dim('Dim/secondary text for less important info'); +logger.highlight('Highlighted/bold text for emphasis'); +logger.logBoxLine(''); +logger.logBoxEnd(); + +console.log(''); + +// === 2. Colored Boxes === +logger.logBoxTitle('Colored Box Styles', 60); +logger.logBoxLine(''); +logger.logBoxLine('Boxes can be styled with different colors:'); +logger.logBoxEnd(); + +console.log(''); + +logger.logBox('Success Box (Green)', [ + 'Used for successful operations', + 'Installation complete, service started, etc.', +], 60, 'success'); + +console.log(''); + +logger.logBox('Error Box (Red)', [ + 'Used for critical errors and failures', + 'Configuration errors, connection failures, etc.', +], 60, 'error'); + +console.log(''); + +logger.logBox('Warning Box (Yellow)', [ + 'Used for warnings and deprecations', + 'Old command format, missing config, etc.', +], 60, 'warning'); + +console.log(''); + +logger.logBox('Info Box (Cyan)', [ + 'Used for informational messages', + 'Version info, update available, etc.', +], 60, 'info'); + +console.log(''); + +// === 3. Status Symbols === +logger.logBoxTitle('Status Symbols', 60, 'info'); +logger.logBoxLine(''); +logger.logBoxLine(`${symbols.running} Service Running`); +logger.logBoxLine(`${symbols.stopped} Service Stopped`); +logger.logBoxLine(`${symbols.starting} Service Starting`); +logger.logBoxLine(`${symbols.unknown} Status Unknown`); +logger.logBoxLine(''); +logger.logBoxLine(`${symbols.success} Operation Successful`); +logger.logBoxLine(`${symbols.error} Operation Failed`); +logger.logBoxLine(`${symbols.warning} Warning Condition`); +logger.logBoxLine(`${symbols.info} Information`); +logger.logBoxLine(''); +logger.logBoxEnd(); + +console.log(''); + +// === 4. Battery Level Colors === +logger.logBoxTitle('Battery Level Color Coding', 60, 'info'); +logger.logBoxLine(''); +logger.logBoxLine('Battery levels are color-coded:'); +logger.logBoxLine(''); +logger.logBoxLine(` ${getBatteryColor(85)('85%')} - Good (green, ≥60%)`); +logger.logBoxLine(` ${getBatteryColor(45)('45%')} - Medium (yellow, 30-60%)`); +logger.logBoxLine(` ${getBatteryColor(15)('15%')} - Critical (red, <30%)`); +logger.logBoxLine(''); +logger.logBoxEnd(); + +console.log(''); + +// === 5. Power Status Formatting === +logger.logBoxTitle('Power Status Formatting', 60, 'info'); +logger.logBoxLine(''); +logger.logBoxLine(`Status: ${formatPowerStatus('online')}`); +logger.logBoxLine(`Status: ${formatPowerStatus('onBattery')}`); +logger.logBoxLine(`Status: ${formatPowerStatus('unknown')}`); +logger.logBoxLine(''); +logger.logBoxEnd(); + +console.log(''); + +// === 6. Table Formatting === +const upsColumns: ITableColumn[] = [ + { header: 'ID', key: 'id' }, + { header: 'Name', key: 'name' }, + { header: 'Host', key: 'host' }, + { header: 'Status', key: 'status', color: (v) => { + if (v.includes('Online')) return theme.success(v); + if (v.includes('Battery')) return theme.warning(v); + return theme.dim(v); + }}, + { header: 'Battery', key: 'battery', align: 'right', color: (v) => { + const pct = parseInt(v); + return getBatteryColor(pct)(v); + }}, + { header: 'Runtime', key: 'runtime', align: 'right' }, +]; + +const upsData = [ + { + id: 'ups-1', + name: 'Main UPS', + host: '192.168.1.10', + status: 'Online', + battery: '95%', + runtime: '45 min', + }, + { + id: 'ups-2', + name: 'Backup UPS', + host: '192.168.1.11', + status: 'On Battery', + battery: '42%', + runtime: '12 min', + }, + { + id: 'ups-3', + name: 'Critical UPS', + host: '192.168.1.12', + status: 'On Battery', + battery: '18%', + runtime: '5 min', + }, +]; + +logger.logTable(upsColumns, upsData, 'UPS Devices'); + +console.log(''); + +// === 7. Group Table === +const groupColumns: ITableColumn[] = [ + { header: 'ID', key: 'id' }, + { header: 'Name', key: 'name' }, + { header: 'Mode', key: 'mode' }, + { header: 'UPS Count', key: 'count', align: 'right' }, +]; + +const groupData = [ + { id: 'dc-1', name: 'Data Center 1', mode: 'redundant', count: '3' }, + { id: 'office', name: 'Office Servers', mode: 'nonRedundant', count: '2' }, +]; + +logger.logTable(groupColumns, groupData, 'UPS Groups'); + +console.log(''); + +// === 8. Service Status Example === +logger.logBoxTitle('Service Status', 70, 'success'); +logger.logBoxLine(''); +logger.logBoxLine(`Status: ${symbols.running} ${theme.statusActive('Active (Running)')}`); +logger.logBoxLine(`Enabled: ${symbols.success} ${theme.success('Yes')}`); +logger.logBoxLine(`Uptime: 2 days, 5 hours, 23 minutes`); +logger.logBoxLine(`PID: ${theme.dim('12345')}`); +logger.logBoxLine(`Memory: ${theme.dim('45.2 MB')}`); +logger.logBoxLine(''); +logger.logBoxEnd(); + +console.log(''); + +// === 9. Configuration Example === +logger.logBoxTitle('Configuration', 70); +logger.logBoxLine(''); +logger.logBoxLine(`UPS Devices: ${theme.highlight('3')}`); +logger.logBoxLine(`Groups: ${theme.highlight('2')}`); +logger.logBoxLine(`Check Interval: ${theme.dim('30 seconds')}`); +logger.logBoxLine(`Config File: ${theme.path('/etc/nupst/config.json')}`); +logger.logBoxLine(''); +logger.logBoxEnd(); + +console.log(''); + +// === 10. Update Available Example === +logger.logBoxTitle('Update Available', 70, 'warning'); +logger.logBoxLine(''); +logger.logBoxLine(`Current Version: ${theme.dim('4.0.1')}`); +logger.logBoxLine(`Latest Version: ${theme.highlight('4.0.2')}`); +logger.logBoxLine(''); +logger.logBoxLine(`Run ${theme.command('sudo nupst update')} to update`); +logger.logBoxLine(''); +logger.logBoxEnd(); + +console.log(''); + +// === 11. Error Example === +logger.logBoxTitle('Error Example', 70, 'error'); +logger.logBoxLine(''); +logger.logBoxLine(`${symbols.error} Failed to connect to UPS at 192.168.1.10`); +logger.logBoxLine(''); +logger.logBoxLine('Possible causes:'); +logger.logBoxLine(` ${theme.dim('• UPS is offline or unreachable')}`); +logger.logBoxLine(` ${theme.dim('• Incorrect SNMP community string')}`); +logger.logBoxLine(` ${theme.dim('• Firewall blocking port 161')}`); +logger.logBoxLine(''); +logger.logBoxLine(`Try: ${theme.command('nupst ups test --debug')}`); +logger.logBoxLine(''); +logger.logBoxEnd(); + +console.log(''); + +// === Final Summary === +console.log('═'.repeat(80)); +logger.success('CLI Output Showcase Complete!'); +logger.dim('All color and formatting features demonstrated'); +console.log('═'.repeat(80)); +console.log(''); diff --git a/ts/cli.ts b/ts/cli.ts index 5e0cac3..e3c3d91 100644 --- a/ts/cli.ts +++ b/ts/cli.ts @@ -1,6 +1,7 @@ import { execSync } from 'node:child_process'; import { Nupst } from './nupst.ts'; import { logger } from './logger.ts'; +import { theme, symbols } from './colors.ts'; /** * Class for handling CLI commands @@ -475,58 +476,83 @@ export class NupstCli { * Display help message */ private showHelp(): void { - logger.log(` -NUPST - UPS Shutdown Tool + console.log(''); + logger.highlight('NUPST - UPS Shutdown Tool'); + logger.dim('Deno-powered UPS monitoring and shutdown automation'); + console.log(''); -Usage: - nupst [options] + // Usage section + logger.log(theme.info('Usage:')); + logger.log(` ${theme.command('nupst')} ${theme.dim(' [options]')}`); + console.log(''); -Commands: - service - Manage systemd service - ups - Manage UPS devices - group - Manage UPS groups - config [show] - Display current configuration - update - Update NUPST from repository (requires root) - uninstall - Completely remove NUPST from system (requires root) - help, --help, -h - Show this help message - --version, -v - Show version information + // Main commands section + logger.log(theme.info('Commands:')); + this.printCommand('service ', 'Manage systemd service'); + this.printCommand('ups ', 'Manage UPS devices'); + this.printCommand('group ', 'Manage UPS groups'); + this.printCommand('config [show]', 'Display current configuration'); + this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)')); + this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)')); + this.printCommand('help, --help, -h', 'Show this help message'); + this.printCommand('--version, -v', 'Show version information'); + console.log(''); -Service Subcommands: - nupst service enable - Install and enable systemd service (requires root) - nupst service disable - Stop and disable systemd service (requires root) - nupst service start - Start the systemd service - nupst service stop - Stop the systemd service - nupst service restart - Restart the systemd service - nupst service status - Show service and UPS status - nupst service logs - Show service logs in real-time - nupst service start-daemon - Start daemon process directly + // Service subcommands + logger.log(theme.info('Service Subcommands:')); + this.printCommand('nupst service enable', 'Install and enable systemd service', theme.dim('(requires root)')); + this.printCommand('nupst service disable', 'Stop and disable systemd service', theme.dim('(requires root)')); + this.printCommand('nupst service start', 'Start the systemd service'); + this.printCommand('nupst service stop', 'Stop the systemd service'); + this.printCommand('nupst service restart', 'Restart the systemd service'); + this.printCommand('nupst service status', 'Show service and UPS status'); + this.printCommand('nupst service logs', 'Show service logs in real-time'); + this.printCommand('nupst service start-daemon', 'Start daemon process directly'); + console.log(''); -UPS Subcommands: - nupst ups add - Add a new UPS device - nupst ups edit [id] - Edit a UPS device (default if no ID) - nupst ups remove - Remove a UPS device by ID - nupst ups list (or ls) - List all configured UPS devices - nupst ups test - Test UPS connections + // UPS subcommands + logger.log(theme.info('UPS Subcommands:')); + this.printCommand('nupst ups add', 'Add a new UPS device'); + this.printCommand('nupst ups edit [id]', 'Edit a UPS device (default if no ID)'); + this.printCommand('nupst ups remove ', 'Remove a UPS device by ID'); + this.printCommand('nupst ups list (or ls)', 'List all configured UPS devices'); + this.printCommand('nupst ups test', 'Test UPS connections'); + console.log(''); -Group Subcommands: - nupst group add - Add a new UPS group - nupst group edit - Edit an existing UPS group - nupst group remove - Remove a UPS group by ID - nupst group list (or ls) - List all UPS groups + // Group subcommands + logger.log(theme.info('Group Subcommands:')); + this.printCommand('nupst group add', 'Add a new UPS group'); + this.printCommand('nupst group edit ', 'Edit an existing UPS group'); + this.printCommand('nupst group remove ', 'Remove a UPS group by ID'); + this.printCommand('nupst group list (or ls)', 'List all UPS groups'); + console.log(''); -Options: - --debug, -d - Enable debug mode for detailed SNMP logging - (Example: nupst ups test --debug) + // Options + logger.log(theme.info('Options:')); + this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging'); + logger.dim(' (Example: nupst ups test --debug)'); + console.log(''); -Examples: - nupst service enable - Install and start the service - nupst ups add - Add a new UPS interactively - nupst group list - Show all configured groups - nupst config - Display current configuration + // Examples + logger.log(theme.info('Examples:')); + logger.dim(' nupst service enable # Install and start the service'); + logger.dim(' nupst ups add # Add a new UPS interactively'); + logger.dim(' nupst group list # Show all configured groups'); + logger.dim(' nupst config # Display current configuration'); + console.log(''); -Note: Old command format (e.g., 'nupst add') still works but is deprecated. - Use the new format (e.g., 'nupst ups add') going forward. -`); + // Note about deprecated commands + logger.warn('Note: Old command format (e.g., \'nupst add\') still works but is deprecated.'); + logger.dim(' Use the new format (e.g., \'nupst ups add\') going forward.'); + console.log(''); + } + + /** + * Helper to print a command with description + */ + private printCommand(command: string, description: string, extra?: string): void { + const paddedCommand = command.padEnd(30); + logger.log(` ${theme.command(paddedCommand)} ${description}${extra ? ' ' + extra : ''}`); } /** diff --git a/ts/colors.ts b/ts/colors.ts new file mode 100644 index 0000000..75703c8 --- /dev/null +++ b/ts/colors.ts @@ -0,0 +1,89 @@ +/** + * Color theme and styling utilities for NUPST CLI + * Uses Deno standard library colors module + */ +import * as colors from '@std/fmt/colors'; + +/** + * Color theme for consistent CLI styling + */ +export const theme = { + // Message types + error: colors.red, + warning: colors.yellow, + success: colors.green, + info: colors.cyan, + dim: colors.dim, + highlight: colors.bold, + bright: colors.bright, + + // Status indicators + statusActive: (text: string) => colors.green(colors.bold(text)), + statusInactive: (text: string) => colors.red(text), + statusWarning: (text: string) => colors.yellow(text), + statusUnknown: (text: string) => colors.dim(text), + + // Battery level colors + batteryGood: colors.green, // > 60% + batteryMedium: colors.yellow, // 30-60% + batteryCritical: colors.red, // < 30% + + // Box borders + borderSuccess: colors.green, + borderError: colors.red, + borderWarning: colors.yellow, + borderInfo: colors.cyan, + borderDefault: (text: string) => text, // No color + + // Command/code highlighting + command: colors.cyan, + code: colors.dim, + path: colors.blue, +}; + +/** + * Status symbols with colors + */ +export const symbols = { + success: colors.green('✓'), + error: colors.red('✗'), + warning: colors.yellow('⚠'), + info: colors.cyan('ℹ'), + running: colors.green('●'), + stopped: colors.red('○'), + starting: colors.yellow('◐'), + unknown: colors.dim('◯'), +}; + +/** + * Get color for battery level + */ +export function getBatteryColor(percentage: number): (text: string) => string { + if (percentage >= 60) return theme.batteryGood; + if (percentage >= 30) return theme.batteryMedium; + return theme.batteryCritical; +} + +/** + * Get color for runtime remaining + */ +export function getRuntimeColor(minutes: number): (text: string) => string { + if (minutes >= 20) return theme.batteryGood; + if (minutes >= 10) return theme.batteryMedium; + return theme.batteryCritical; +} + +/** + * Format UPS power status with color + */ +export function formatPowerStatus(status: 'online' | 'onBattery' | 'unknown'): string { + switch (status) { + case 'online': + return theme.success('Online'); + case 'onBattery': + return theme.warning('On Battery'); + case 'unknown': + default: + return theme.dim('Unknown'); + } +} diff --git a/ts/daemon.ts b/ts/daemon.ts index 885bd20..f864c63 100644 --- a/ts/daemon.ts +++ b/ts/daemon.ts @@ -353,8 +353,9 @@ export class NupstDaemon { logger.log('Starting UPS monitoring...'); if (!this.config.upsDevices || this.config.upsDevices.length === 0) { - logger.error('No UPS devices found in configuration. Monitoring stopped.'); - this.isRunning = false; + logger.warn('No UPS devices found in configuration. Daemon will remain idle...'); + // Don't exit - enter idle monitoring mode instead + await this.idleMonitoring(); return; } @@ -890,6 +891,133 @@ export class NupstDaemon { } } + /** + * Idle monitoring loop when no UPS devices are configured + * Watches for config changes and reloads when detected + */ + private async idleMonitoring(): Promise { + const IDLE_CHECK_INTERVAL = 60000; // Check every 60 seconds + let lastConfigCheck = Date.now(); + const CONFIG_CHECK_INTERVAL = 60000; // Check config every minute + + logger.log('Entering idle monitoring mode...'); + logger.log('Daemon will check for config changes every 60 seconds'); + + // Start file watcher for hot-reload + this.watchConfigFile(); + + while (this.isRunning) { + try { + const currentTime = Date.now(); + + // Periodically check if config has been updated + if (currentTime - lastConfigCheck >= CONFIG_CHECK_INTERVAL) { + try { + // Try to load config + const newConfig = await this.loadConfig(); + + // Check if we now have UPS devices configured + if (newConfig.upsDevices && newConfig.upsDevices.length > 0) { + logger.success('Configuration updated! UPS devices found. Starting monitoring...'); + this.initializeUpsStatus(); + // Exit idle mode and start monitoring + await this.monitor(); + return; + } + } catch (error) { + // Config still doesn't exist or invalid, continue waiting + } + + lastConfigCheck = currentTime; + } + + await this.sleep(IDLE_CHECK_INTERVAL); + } catch (error) { + logger.error( + `Error during idle monitoring: ${error instanceof Error ? error.message : String(error)}`, + ); + await this.sleep(IDLE_CHECK_INTERVAL); + } + } + + logger.log('Idle monitoring stopped'); + } + + /** + * Watch config file for changes and reload automatically + */ + private watchConfigFile(): void { + try { + // Use Deno's file watcher to monitor config file + const configDir = path.dirname(this.CONFIG_PATH); + + // Spawn a background watcher (non-blocking) + (async () => { + try { + const watcher = Deno.watchFs(configDir); + + logger.log('Config file watcher started'); + + for await (const event of watcher) { + // Only respond to modify events on the config file + if ( + event.kind === 'modify' && + event.paths.some((p) => p.includes('config.json')) + ) { + logger.info('Config file changed, reloading...'); + await this.reloadConfig(); + } + + // Stop watching if daemon stopped + if (!this.isRunning) { + break; + } + } + } catch (error) { + // Watcher error - not critical, just log it + logger.dim( + `Config watcher stopped: ${error instanceof Error ? error.message : String(error)}`, + ); + } + })(); + } catch (error) { + // If we can't start the watcher, just log and continue + // The periodic check will still work + logger.dim('Could not start config file watcher, using periodic checks only'); + } + } + + /** + * Reload configuration and restart monitoring if needed + */ + private async reloadConfig(): Promise { + try { + const oldDeviceCount = this.config.upsDevices?.length || 0; + + // Load the new configuration + await this.loadConfig(); + const newDeviceCount = this.config.upsDevices?.length || 0; + + if (newDeviceCount > 0 && oldDeviceCount === 0) { + logger.success(`Configuration reloaded! Found ${newDeviceCount} UPS device(s)`); + logger.info('Monitoring will start automatically...'); + } else if (newDeviceCount !== oldDeviceCount) { + logger.success( + `Configuration reloaded! UPS devices: ${oldDeviceCount} → ${newDeviceCount}`, + ); + + // Reinitialize UPS status tracking + this.initializeUpsStatus(); + } else { + logger.success('Configuration reloaded successfully'); + } + } catch (error) { + logger.warn( + `Failed to reload config: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + /** * Sleep for the specified milliseconds */ diff --git a/ts/logger.ts b/ts/logger.ts index 116fcec..b727fb2 100644 --- a/ts/logger.ts +++ b/ts/logger.ts @@ -1,9 +1,38 @@ +import { theme, symbols } from './colors.ts'; + +/** + * Table column alignment options + */ +export type TColumnAlign = 'left' | 'right' | 'center'; + +/** + * Table column definition + */ +export interface ITableColumn { + /** Column header text */ + header: string; + /** Column key in data object */ + key: string; + /** Column alignment (default: left) */ + align?: TColumnAlign; + /** Column width (auto-calculated if not specified) */ + width?: number; + /** Color function to apply to cell values */ + color?: (value: string) => string; +} + +/** + * Box style types with colors + */ +export type TBoxStyle = 'default' | 'success' | 'error' | 'warning' | 'info'; + /** * A simple logger class that provides consistent formatting for log messages * including support for logboxes with title, lines, and closing */ export class Logger { private currentBoxWidth: number | null = null; + private currentBoxStyle: TBoxStyle = 'default'; private static instance: Logger; /** Default width to use when no width is specified */ @@ -36,36 +65,83 @@ export class Logger { } /** - * Log an error message + * Log an error message (red with ✗ symbol) * @param message Error message to log */ public error(message: string): void { - console.error(message); + console.error(`${symbols.error} ${theme.error(message)}`); } /** - * Log a warning message with a warning emoji + * Log a warning message (yellow with ⚠ symbol) * @param message Warning message to log */ public warn(message: string): void { - console.warn(`⚠️ ${message}`); + console.warn(`${symbols.warning} ${theme.warning(message)}`); } /** - * Log a success message with a checkmark + * Log a success message (green with ✓ symbol) * @param message Success message to log */ public success(message: string): void { - console.log(`✓ ${message}`); + console.log(`${symbols.success} ${theme.success(message)}`); + } + + /** + * Log an info message (cyan with ℹ symbol) + * @param message Info message to log + */ + public info(message: string): void { + console.log(`${symbols.info} ${theme.info(message)}`); + } + + /** + * Log a dim/secondary message + * @param message Message to log in dim style + */ + public dim(message: string): void { + console.log(theme.dim(message)); + } + + /** + * Log a highlighted/bold message + * @param message Message to highlight + */ + public highlight(message: string): void { + console.log(theme.highlight(message)); + } + + /** + * Get color function for box based on style + */ + private getBoxColor(style: TBoxStyle): (text: string) => string { + switch (style) { + case 'success': + return theme.borderSuccess; + case 'error': + return theme.borderError; + case 'warning': + return theme.borderWarning; + case 'info': + return theme.borderInfo; + case 'default': + default: + return theme.borderDefault; + } } /** * Log a logbox title and set the current box width * @param title Title of the logbox * @param width Width of the logbox (including borders), defaults to DEFAULT_WIDTH + * @param style Box style for coloring (default, success, error, warning, info) */ - public logBoxTitle(title: string, width?: number): void { + public logBoxTitle(title: string, width?: number, style?: TBoxStyle): void { this.currentBoxWidth = width || this.DEFAULT_WIDTH; + this.currentBoxStyle = style || 'default'; + + const colorFn = this.getBoxColor(this.currentBoxStyle); // Create the title line with appropriate padding const paddedTitle = ` ${title} `; @@ -74,7 +150,7 @@ export class Logger { // Title line: ┌─ Title ───┐ const titleLine = `┌─${paddedTitle}${'─'.repeat(Math.max(0, remainingSpace))}┐`; - console.log(titleLine); + console.log(colorFn(titleLine)); } /** @@ -89,17 +165,21 @@ export class Logger { } const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH; + const colorFn = this.getBoxColor(this.currentBoxStyle); - // Calculate the available space for content + // Calculate the available space for content (use visible length) const availableSpace = boxWidth - 2; // Account for left and right borders + const visibleLen = this.visibleLength(content); - if (content.length <= availableSpace - 1) { + if (visibleLen <= availableSpace - 1) { // If content fits with at least one space for the right border stripe - const padding = availableSpace - content.length - 1; - console.log(`│ ${content}${' '.repeat(padding)}│`); + const padding = availableSpace - visibleLen - 1; + const line = `│ ${content}${' '.repeat(padding)}│`; + console.log(colorFn(line)); } else { // Content is too long, let it flow out of boundaries. - console.log(`│ ${content}`); + const line = `│ ${content}`; + console.log(colorFn(line)); } } @@ -109,12 +189,15 @@ export class Logger { */ public logBoxEnd(width?: number): void { const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH; + const colorFn = this.getBoxColor(this.currentBoxStyle); // Create the bottom border: └────────┘ - console.log(`└${'─'.repeat(boxWidth - 2)}┘`); + const bottomLine = `└${'─'.repeat(boxWidth - 2)}┘`; + console.log(colorFn(bottomLine)); - // Reset the current box width + // Reset the current box width and style this.currentBoxWidth = null; + this.currentBoxStyle = 'default'; } /** @@ -122,9 +205,10 @@ export class Logger { * @param title Title of the logbox * @param lines Array of content lines * @param width Width of the logbox, defaults to DEFAULT_WIDTH + * @param style Box style for coloring */ - public logBox(title: string, lines: string[], width?: number): void { - this.logBoxTitle(title, width || this.DEFAULT_WIDTH); + public logBox(title: string, lines: string[], width?: number, style?: TBoxStyle): void { + this.logBoxTitle(title, width || this.DEFAULT_WIDTH, style); for (const line of lines) { this.logBoxLine(line); @@ -141,6 +225,108 @@ export class Logger { public logDivider(width?: number, character: string = '─'): void { console.log(character.repeat(width || this.DEFAULT_WIDTH)); } + + /** + * Strip ANSI color codes from string for accurate length calculation + */ + private stripAnsi(text: string): string { + // Remove ANSI escape codes + return text.replace(/\x1b\[[0-9;]*m/g, ''); + } + + /** + * Get visible length of string (excluding ANSI codes) + */ + private visibleLength(text: string): number { + return this.stripAnsi(text).length; + } + + /** + * Align text within a column (handles ANSI color codes correctly) + */ + private alignText(text: string, width: number, align: TColumnAlign = 'left'): string { + const visibleLen = this.visibleLength(text); + + if (visibleLen >= width) { + // Text is too long, truncate the visible part + const stripped = this.stripAnsi(text); + return stripped.substring(0, width); + } + + const padding = width - visibleLen; + + switch (align) { + case 'right': + return ' '.repeat(padding) + text; + case 'center': { + const leftPad = Math.floor(padding / 2); + const rightPad = padding - leftPad; + return ' '.repeat(leftPad) + text + ' '.repeat(rightPad); + } + case 'left': + default: + return text + ' '.repeat(padding); + } + } + + /** + * Log a formatted table + * @param columns Column definitions + * @param rows Array of data objects + * @param title Optional table title + */ + public logTable(columns: ITableColumn[], rows: Record[], title?: string): void { + if (rows.length === 0) { + this.dim('No data to display'); + return; + } + + // Calculate column widths + const columnWidths = columns.map((col) => { + if (col.width) return col.width; + + // Auto-calculate width based on header and data (use visible length) + let maxWidth = this.visibleLength(col.header); + for (const row of rows) { + const value = String(row[col.key] || ''); + maxWidth = Math.max(maxWidth, this.visibleLength(value)); + } + return maxWidth; + }); + + // Calculate total table width + const totalWidth = columnWidths.reduce((sum, w) => sum + w, 0) + (columns.length * 3) + 1; + + // Print title if provided + if (title) { + this.logBoxTitle(title, totalWidth); + } else { + // Print top border + console.log('┌' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┬') + '┐'); + } + + // Print header row + const headerCells = columns.map((col, i) => + theme.highlight(this.alignText(col.header, columnWidths[i], col.align)) + ); + console.log('│ ' + headerCells.join(' │ ') + ' │'); + + // Print separator + console.log('├' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┼') + '┤'); + + // Print data rows + for (const row of rows) { + const cells = columns.map((col, i) => { + const value = String(row[col.key] || ''); + const aligned = this.alignText(value, columnWidths[i], col.align); + return col.color ? col.color(aligned) : aligned; + }); + console.log('│ ' + cells.join(' │ ') + ' │'); + } + + // Print bottom border + console.log('└' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┴') + '┘'); + } } // Export a singleton instance for easy use