UNPKG

@vizzly-testing/cli

Version:

Visual review platform for UI developers and designers

988 lines (908 loc) 29.4 kB
/** * Unified CLI output module * * Handles all console output with proper stream separation: * - stdout: program output only (things you can pipe) * - stderr: everything else (spinners, progress, errors, debug) * * Replaces both ConsoleUI and Logger with a single, simple API. */ import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { createColors } from './colors.js'; import { getLogLevel as getEnvLogLevel } from './environment-config.js'; /** * Log levels in order of severity (lowest to highest) * debug < info < warn < error */ const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; const VALID_LOG_LEVELS = Object.keys(LOG_LEVELS); // Module state const config = { json: false, logLevel: null, // null = not yet initialized, will check env var on first configure color: undefined, // undefined = auto-detect, true = force on, false = force off silent: false, logFile: null }; let colors = createColors({ useColor: config.color }); // undefined triggers auto-detect let spinnerInterval = null; let spinnerMessage = ''; let lastSpinnerLine = ''; let startTime = Date.now(); // Track if we've shown the header let headerShown = false; /** * Check if a given log level should be displayed based on current config * @param {string} level - The level to check (debug, info, warn, error) * @returns {boolean} Whether the level should be displayed */ function shouldLog(level) { if (config.silent) return false; // If logLevel not yet initialized, default to 'info' let configLevel = config.logLevel || 'info'; let currentLevel = LOG_LEVELS[configLevel]; let targetLevel = LOG_LEVELS[level]; // Default to showing everything if levels are invalid if (currentLevel === undefined) currentLevel = LOG_LEVELS.info; if (targetLevel === undefined) targetLevel = LOG_LEVELS.debug; return targetLevel >= currentLevel; } /** * Normalize and validate log level * @param {string} level - Log level to validate * @returns {string} Valid log level (defaults to 'info' if invalid) */ function normalizeLogLevel(level) { if (!level) return 'info'; let normalized = level.toLowerCase().trim(); return VALID_LOG_LEVELS.includes(normalized) ? normalized : 'info'; } /** * Configure output settings * Call this once at CLI startup with global options * * @param {Object} options - Configuration options * @param {boolean} [options.json] - Enable JSON output mode * @param {string} [options.logLevel] - Log level (debug, info, warn, error) * @param {boolean} [options.verbose] - Shorthand for logLevel='debug' (backwards compatible) * @param {boolean} [options.color] - Enable colored output * @param {boolean} [options.silent] - Suppress all output * @param {string} [options.logFile] - Path to log file * @param {boolean} [options.resetTimer] - Reset the start timer (default: true) */ export function configure(options = {}) { if (options.json !== undefined) config.json = options.json; if (options.color !== undefined) config.color = options.color; if (options.silent !== undefined) config.silent = options.silent; if (options.logFile !== undefined) config.logFile = options.logFile; // Determine log level with priority: // 1. Explicit logLevel option (highest priority) // 2. verbose flag (maps to 'debug') // 3. Keep existing level if already initialized // 4. VIZZLY_LOG_LEVEL env var (checked on first configure when logLevel is null) // 5. Default ('info') if (options.logLevel !== undefined) { config.logLevel = normalizeLogLevel(options.logLevel); } else if (options.verbose) { config.logLevel = 'debug'; } else if (config.logLevel === null) { // First configure call - check env var let envLogLevel = getEnvLogLevel(); config.logLevel = normalizeLogLevel(envLogLevel); } // If logLevel is already set (not null) and no new option was provided, keep it colors = createColors({ useColor: config.color }); // Reset state (optional - commands may want to preserve timer) if (options.resetTimer !== false) { startTime = Date.now(); headerShown = false; } // Initialize log file if specified if (config.logFile) { initLogFile(); } } /** * Get current log level * @returns {string} Current log level (defaults to 'info' if not initialized) */ export function getLogLevel() { return config.logLevel || 'info'; } /** * Check if verbose/debug mode is enabled * @returns {boolean} True if debug level is active */ export function isVerbose() { return config.logLevel === 'debug'; } /** * Show command header with distinctive branding * Uses Observatory's signature amber color for "vizzly" * Only shows once per command execution * @param {string} command - Command name (e.g., 'tdd', 'run') * @param {string} [mode] - Optional mode (e.g., 'local', 'cloud') */ export function header(command, mode = null) { if (config.json || config.silent || headerShown) return; headerShown = true; let parts = []; // Brand "vizzly" with Observatory's signature amber parts.push(colors.brand.amber(colors.bold('vizzly'))); // Command in info blue (processing, active) parts.push(colors.brand.info(command)); // Mode (if provided) in muted text if (mode) { parts.push(colors.brand.textTertiary(mode)); } console.error(''); console.error(parts.join(colors.brand.textMuted(' · '))); console.error(''); } /** * Get current colors instance (for custom formatting) */ export function getColors() { return colors; } // ============================================================================ // User-facing output (what the user asked for) // ============================================================================ /** * Show a success message */ export function success(message, data = {}) { stopSpinner(); if (config.silent) return; if (config.json) { console.log(JSON.stringify({ status: 'success', message, ...data })); } else { console.error(''); console.error(colors.green('✓'), message); } } /** * Show final result summary (e.g., "✓ 5 screenshots · 234ms") */ export function result(message) { stopSpinner(); if (config.silent) return; const elapsed = getElapsedTime(); if (config.json) { console.log(JSON.stringify({ status: 'complete', message, elapsed })); } else { console.error(''); console.error(colors.green('✓'), `${message} ${colors.dim(${elapsed}`)}`); } } /** * Show an info message */ export function info(message, data = {}) { if (!shouldLog('info')) return; if (config.json) { console.log(JSON.stringify({ status: 'info', message, ...data })); } else { console.log(colors.cyan('ℹ'), message); } } /** * Show a warning message (goes to stderr) */ export function warn(message, data = {}) { stopSpinner(); if (!shouldLog('warn')) return; if (config.json) { console.error(JSON.stringify({ status: 'warning', message, ...data })); } else { console.error(colors.yellow('⚠'), message); } } /** * Show an error message (goes to stderr) * Does NOT exit - caller decides whether to exit * Note: Errors are always shown regardless of log level (unless silent mode) */ export function error(message, err = null, data = {}) { stopSpinner(); // Errors always show (unless silent), but we still check shouldLog for consistency if (config.silent) return; if (config.json) { let errorData = { status: 'error', message, ...data }; if (err instanceof Error) { errorData.error = { name: err.name, message: err.getUserMessage ? err.getUserMessage() : err.message, code: err.code }; if (isVerbose()) { errorData.error.stack = err.stack; } } console.error(JSON.stringify(errorData)); } else { console.error(colors.red('✖'), message); // Show error details if (err instanceof Error) { let errMessage = err.getUserMessage ? err.getUserMessage() : err.message; if (errMessage && errMessage !== message) { console.error(colors.dim(errMessage)); } if (isVerbose() && err.stack) { console.error(colors.dim(err.stack)); } } else if (typeof err === 'string' && err) { console.error(colors.dim(err)); } } // Write to log file writeLog('error', message, { error: err?.message, ...data }); } /** * Print a blank line for spacing */ export function blank() { if (!config.json && !config.silent) { console.log(''); } } /** * Print raw text without any formatting */ export function print(text) { if (!config.silent) { console.log(text); } } /** * Print raw text to stderr */ export function printErr(text) { if (!config.silent) { console.error(text); } } /** * Output structured data */ export function data(obj) { if (config.json) { console.log(JSON.stringify({ status: 'data', data: obj })); } else { console.log(JSON.stringify(obj, null, 2)); } } // ============================================================================ // Spinner / Progress (stderr so it doesn't pollute piped output) // ============================================================================ /** * Start a spinner with message * Uses Observatory amber for the spinner animation */ export function startSpinner(message) { if (config.json || config.silent || !process.stderr.isTTY) return; stopSpinner(); spinnerMessage = message; // Braille dots spinner - smooth animation let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; let i = 0; spinnerInterval = setInterval(() => { let frame = frames[i++ % frames.length]; // Use amber brand color for spinner, plain text for message (better readability) let line = ` ${colors.brand.amber(frame)} ${spinnerMessage}`; // Clear previous line and write new one process.stderr.write(`\r${' '.repeat(lastSpinnerLine.length)}\r`); process.stderr.write(line); lastSpinnerLine = line; }, 80); } /** * Update spinner message */ export function updateSpinner(message, current = 0, total = 0) { if (config.json || config.silent || !process.stderr.isTTY) return; let progressText = total > 0 ? ` ${colors.brand.textMuted(`(${current}/${total})`)}` : ''; spinnerMessage = `${message}${progressText}`; if (!spinnerInterval) { startSpinner(spinnerMessage); } } /** * Stop the spinner */ export function stopSpinner() { if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; // Clear the spinner line if (process.stderr.isTTY) { process.stderr.write(`\r${' '.repeat(lastSpinnerLine.length)}\r`); } lastSpinnerLine = ''; spinnerMessage = ''; } } /** * Show progress update */ export function progress(message, current = 0, total = 0) { if (config.silent) return; if (config.json) { console.log(JSON.stringify({ status: 'progress', message, progress: { current, total } })); } else { updateSpinner(message, current, total); } } // ============================================================================ // Debug logging (only when verbose, goes to stderr and/or file) // ============================================================================ /** * Format elapsed time since CLI start */ function getElapsedTime() { const elapsed = Date.now() - startTime; if (elapsed < 1000) { return `${elapsed}ms`; } return `${(elapsed / 1000).toFixed(1)}s`; } /** * Format a data object for human-readable output * Only shows meaningful values, skips nulls/undefined/empty */ function formatData(data) { if (!data || typeof data !== 'object') return ''; const entries = Object.entries(data).filter(([, v]) => { if (v === null || v === undefined) return false; if (typeof v === 'string' && v === '') return false; if (Array.isArray(v) && v.length === 0) return false; return true; }); if (entries.length === 0) return ''; // For simple key-value pairs, show inline if (entries.length <= 4 && entries.every(([, v]) => typeof v !== 'object')) { return entries.map(([k, v]) => `${k}=${v}`).join(' '); } // For complex objects, show on multiple lines return entries.map(([k, v]) => { if (typeof v === 'object') { return `${k}: ${JSON.stringify(v)}`; } return `${k}: ${v}`; }).join('\n'); } /** * Log debug message with component prefix (only shown when log level is 'debug') * * @param {string} component - Component name (e.g., 'server', 'config', 'build') * @param {string} message - Debug message * @param {Object} data - Optional data object to display inline */ /** * Get a distinctive color for a component name * Uses Observatory design system colors for consistent styling * @param {string} component - Component name * @returns {Function} Color function */ function getComponentColor(component) { // Map components to Observatory semantic colors let componentColors = { // Server/infrastructure - success green (active, running) server: colors.brand.success, baseline: colors.brand.success, // TDD/comparison - info blue (processing, informational) tdd: colors.brand.info, compare: colors.brand.info, // Config/auth - warning amber (attention, configuration) config: colors.brand.warning, build: colors.brand.warning, auth: colors.brand.warning, // Upload/API - info blue (processing) upload: colors.brand.info, api: colors.brand.info, // Run - amber (primary action) run: colors.brand.amber }; return componentColors[component] || colors.brand.info; } export function debug(component, message, data = {}) { if (!shouldLog('debug')) return; // Handle legacy calls: debug('message') or debug('message', {data}) if (typeof message === 'object' || message === undefined) { data = message || {}; message = component; component = null; } let elapsed = getElapsedTime(); if (config.json) { console.error(JSON.stringify({ status: 'debug', time: elapsed, component, message, ...data })); } else { let formattedData = formatData(data); let dataStr = formattedData ? ` ${colors.dim(formattedData)}` : ''; if (component) { // Component-based format with distinctive colors // " server ready on :47392" let paddedComponent = component.padEnd(8); let componentColor = getComponentColor(component); // Use plain text for message (better readability on dark backgrounds) console.error(` ${componentColor(paddedComponent)} ${message}${dataStr}`); } else { // Simple format for legacy calls console.error(` ${colors.dim('•')} ${message}${dataStr}`); } } writeLog('debug', message, { component, ...data }); } // ============================================================================ // Log file support // ============================================================================ function initLogFile() { if (!config.logFile) return; try { mkdirSync(dirname(config.logFile), { recursive: true }); const header = { timestamp: new Date().toISOString(), session_start: true, pid: process.pid, node_version: process.version, platform: process.platform }; writeFileSync(config.logFile, `${JSON.stringify(header)}\n`); } catch { // Silently fail - don't crash CLI for logging issues } } function writeLog(level, message, data = {}) { if (!config.logFile) return; try { const entry = { timestamp: new Date().toISOString(), level, message, ...data }; appendFileSync(config.logFile, `${JSON.stringify(entry)}\n`); } catch { // Silently fail } } // ============================================================================ // Visual formatting helpers // ============================================================================ /** * Generate a visual diff bar with color coding * Shows percentage as a filled/empty bar with color based on severity * Uses Observatory semantic colors (success → warning → danger) * @param {number} percentage - Diff percentage (0-100) * @param {number} [width=10] - Bar width in characters * @returns {string} Colored diff bar string * * @example * diffBar(4.2) // Returns "████░░░░░░" in warning amber * diffBar(0.5) // Returns "█░░░░░░░░░" in success green * diffBar(15.0) // Returns "██░░░░░░░░" in danger red */ export function diffBar(percentage, width = 10) { if (config.json || config.silent) return ''; // Calculate filled blocks - ensure at least 1 filled for non-zero percentages let filled = Math.round(percentage / 100 * width); if (percentage > 0 && filled === 0) filled = 1; let empty = width - filled; // Color based on severity using Observatory semantic colors let barColor; if (percentage < 1) { barColor = colors.brand.success; // Green - minimal change } else if (percentage < 5) { barColor = colors.brand.warning; // Amber - attention needed } else { barColor = colors.brand.danger; // Red - significant change } let filledPart = barColor('█'.repeat(filled)); let emptyPart = colors.brand.textMuted('░'.repeat(empty)); return `${filledPart}${emptyPart}`; } /** * Generate a gradient progress bar * Creates a visually appealing progress indicator with color gradient * Default gradient uses Observatory amber → amber-light (signature brand gradient) * @param {number} current - Current progress value * @param {number} total - Total value * @param {number} [width=20] - Bar width in characters * @param {Object} [options] - Gradient options * @param {string} [options.from='#F59E0B'] - Start color (hex) - default: amber * @param {string} [options.to='#FBBF24'] - End color (hex) - default: amber-light * @returns {string} Gradient progress bar string */ export function progressBar(current, total, width = 20, options = {}) { if (config.json || config.silent) return ''; // Default to Observatory's signature amber gradient let { from = '#F59E0B', to = '#FBBF24' } = options; let percent = Math.min(100, Math.max(0, current / total * 100)); let filled = Math.round(percent / 100 * width); let empty = width - filled; // Parse hex colors let fromRgb = hexToRgb(from); let toRgb = hexToRgb(to); // Build gradient let bar = ''; for (let i = 0; i < filled; i++) { let ratio = filled > 1 ? i / (filled - 1) : 0; let r = Math.round(fromRgb.r + (toRgb.r - fromRgb.r) * ratio); let g = Math.round(fromRgb.g + (toRgb.g - fromRgb.g) * ratio); let b = Math.round(fromRgb.b + (toRgb.b - fromRgb.b) * ratio); bar += colors.rgb(r, g, b)('█'); } bar += colors.dim('░'.repeat(empty)); return bar; } /** * Parse hex color to RGB object * @param {string} hex - Hex color string (e.g., '#FF0000' or 'FF0000') * @returns {{r: number, g: number, b: number}} RGB values */ function hexToRgb(hex) { let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : { r: 128, g: 128, b: 128 }; } /** * Create a colored badge/pill for status indicators * Uses Observatory semantic colors for consistent meaning * @param {string} text - Badge text * @param {string} [type='info'] - Badge type: 'success', 'warning', 'error', 'info' * @returns {string} Formatted badge string * * @example * badge('READY', 'success') // Success green background * badge('FAIL', 'error') // Danger red background * badge('SYNC', 'warning') // Warning amber background */ export function badge(text, type = 'info') { if (config.json || config.silent) return text; let bgColor; let fgColor = colors.black; switch (type) { case 'success': bgColor = colors.brand.bgSuccess; break; case 'warning': bgColor = colors.brand.bgWarning; break; case 'error': bgColor = colors.brand.bgDanger; fgColor = colors.white; break; default: bgColor = colors.brand.bgInfo; fgColor = colors.white; break; } return bgColor(fgColor(` ${text} `)); } /** * Create a colored status dot * Uses Observatory semantic colors for consistent meaning * @param {string} [status='info'] - Status type: 'success', 'warning', 'error', 'info' * @returns {string} Colored dot character */ export function statusDot(status = 'info') { if (config.json || config.silent) return '●'; switch (status) { case 'success': return colors.brand.success('●'); case 'warning': return colors.brand.warning('●'); case 'error': return colors.brand.danger('●'); default: return colors.brand.info('●'); } } /** * Format a link with styling * @param {string} label - Link label (not currently used, for future OSC 8 support) * @param {string} url - URL to display * @returns {string} Styled URL string */ export function link(_label, url) { if (config.json) return url; if (config.silent) return ''; // Style the URL with underline and info blue return colors.brand.info(colors.underline(url)); } /** * Print a labeled value with consistent formatting * Useful for displaying key-value pairs in verbose output * @param {string} label - The label (will be styled as tertiary text) * @param {string} value - The value to display * @param {Object} [options] - Display options * @param {number} [options.indent=2] - Number of spaces to indent */ export function labelValue(label, value, options = {}) { if (config.json || config.silent) return; let { indent = 2 } = options; let padding = ' '.repeat(indent); console.log(`${padding}${colors.brand.textTertiary(`${label}:`)} ${value}`); } /** * Print a hint/tip with muted styling * @param {string} text - The hint text * @param {Object} [options] - Display options * @param {number} [options.indent=2] - Number of spaces to indent */ export function hint(text, options = {}) { if (config.json || config.silent) return; let { indent = 2 } = options; let padding = ' '.repeat(indent); console.log(`${padding}${colors.brand.textMuted(text)}`); } /** * Print a list of items with bullet points * @param {string[]} items - Array of items to display * @param {Object} [options] - Display options * @param {number} [options.indent=2] - Number of spaces to indent * @param {string} [options.bullet='•'] - Bullet character * @param {string} [options.style='default'] - Style: 'default', 'success', 'warning', 'error' */ export function list(items, options = {}) { if (config.json || config.silent) return; let { indent = 2, bullet = '•', style = 'default' } = options; let padding = ' '.repeat(indent); let bulletColor; switch (style) { case 'success': bulletColor = colors.brand.success; bullet = '✓'; break; case 'warning': bulletColor = colors.brand.warning; bullet = '!'; break; case 'error': bulletColor = colors.brand.danger; bullet = '✗'; break; default: bulletColor = colors.brand.textMuted; } for (let item of items) { console.log(`${padding}${bulletColor(bullet)} ${item}`); } } /** * Print a success/completion message with checkmark * @param {string} message - The success message * @param {Object} [options] - Display options * @param {string} [options.detail] - Optional detail text (shown dimmed) */ export function complete(message, options = {}) { if (config.silent) return; let { detail } = options; let detailStr = detail ? ` ${colors.brand.textMuted(detail)}` : ''; if (config.json) { console.log(JSON.stringify({ status: 'complete', message, detail })); } else { console.log(` ${colors.brand.success('✓')} ${message}${detailStr}`); } } /** * Print a simple key-value table * @param {Object} data - Object with key-value pairs to display * @param {Object} [options] - Display options * @param {number} [options.indent=2] - Number of spaces to indent * @param {number} [options.keyWidth=12] - Width for key column */ export function keyValue(data, options = {}) { if (config.json || config.silent) return; let { indent = 2, keyWidth = 12 } = options; let padding = ' '.repeat(indent); for (let [key, value] of Object.entries(data)) { if (value === undefined || value === null) continue; let paddedKey = key.padEnd(keyWidth); console.log(`${padding}${colors.brand.textTertiary(paddedKey)} ${value}`); } } /** * Print a divider line * @param {Object} [options] - Display options * @param {number} [options.width=40] - Width of the divider * @param {string} [options.char='─'] - Character to use for divider */ export function divider(options = {}) { if (config.json || config.silent) return; let { width = 40, char = '─' } = options; console.log(colors.brand.textMuted(char.repeat(width))); } /** * Create a styled box around content * Uses Unicode box-drawing characters for clean terminal rendering * Features brand-colored borders and titles * * @param {string|string[]} content - Content to display (string or array of lines) * @param {Object} [options] - Box options * @param {string} [options.title] - Optional title for the box * @param {number} [options.padding=1] - Horizontal padding inside the box * @param {Function} [options.borderColor] - Color function for the border * @param {string} [options.style='default'] - Box style: 'default', 'branded' * @returns {string} Formatted box string * * @example * box('Dashboard: http://localhost:47392') * // ╭───────────────────────────────────────╮ * // │ Dashboard: http://localhost:47392 │ * // ╰───────────────────────────────────────╯ * * @example * box(['Line 1', 'Line 2'], { title: 'Info', style: 'branded' }) */ export function box(content, options = {}) { if (config.json || config.silent) return ''; let { title = null, padding = 1, borderColor = null, style = 'default' } = options; let lines = Array.isArray(content) ? content : [content]; // Strip ANSI codes for width calculation // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape sequence matching let stripAnsi = str => str.replace(/\x1b\[[0-9;]*m/g, ''); // Calculate max width (content + padding on each side) let maxContentWidth = Math.max(...lines.map(line => stripAnsi(line).length)); let innerWidth = maxContentWidth + padding * 2; // If title provided, ensure box is wide enough if (title) { let titleWidth = stripAnsi(title).length + 4; // " title " with spaces innerWidth = Math.max(innerWidth, titleWidth); } // Border styling - use Observatory amber for 'branded' style let border = borderColor || (style === 'branded' ? colors.brand.amber : colors.dim); let titleColor = style === 'branded' ? colors.bold : s => s; // Build the box let result = []; // Top border with optional title if (title) { let titleStr = ` ${titleColor(title)} `; let leftDash = '─'.repeat(1); let rightDash = '─'.repeat(innerWidth - stripAnsi(title).length - 3); result.push(border(`╭${leftDash}`) + titleStr + border(`${rightDash}╮`)); } else { result.push(border(`╭${'─'.repeat(innerWidth)}╮`)); } // Content lines let paddingStr = ' '.repeat(padding); for (let line of lines) { let lineWidth = stripAnsi(line).length; let rightPad = ' '.repeat(innerWidth - lineWidth - padding * 2); result.push(border('│') + paddingStr + line + rightPad + paddingStr + border('│')); } // Bottom border result.push(border(`╰${'─'.repeat(innerWidth)}╯`)); return result.join('\n'); } /** * Print a box to stderr * Convenience wrapper around box() that prints directly * * @param {string|string[]} content - Content to display * @param {Object} [options] - Box options (see box()) */ export function printBox(content, options = {}) { if (config.json || config.silent) return; let boxStr = box(content, options); if (boxStr) { console.error(boxStr); } } // ============================================================================ // Cleanup // ============================================================================ /** * Clean up (stop spinner, flush logs) */ export function cleanup() { stopSpinner(); } /** * Reset module state to defaults (useful for testing) * This resets all configuration to initial state */ export function reset() { stopSpinner(); config.json = false; config.logLevel = null; config.color = undefined; // Reset to auto-detect config.silent = false; config.logFile = null; colors = createColors({ useColor: config.color }); startTime = Date.now(); headerShown = false; }