UNPKG

@xec-sh/cli

Version:

Xec: The Universal Shell for TypeScript

883 lines 37.2 kB
import { z } from 'zod'; import chalk from 'chalk'; import { $ } from '@xec-sh/core'; import * as readline from 'readline'; import { validateOptions } from '../utils/validation.js'; import { InteractiveHelpers } from '../utils/interactive-helpers.js'; import { ConfigAwareCommand } from '../utils/command-base.js'; export class LogsCommand extends ConfigAwareCommand { constructor() { super({ name: 'logs', aliases: ['l'], description: 'View and stream logs from targets (interactive mode if no target specified)', arguments: '[target] [path]', options: [ { flags: '-p, --profile <profile>', description: 'Configuration profile to use', }, { flags: '-f, --follow', description: 'Follow log output (stream new logs)', }, { flags: '-n, --tail <lines>', description: 'Number of lines to show from the end', defaultValue: '50', }, { flags: '--since <time>', description: 'Show logs since timestamp (e.g., 10m, 1h, 2d)', }, { flags: '--until <time>', description: 'Show logs until timestamp', }, { flags: '-t, --timestamps', description: 'Show timestamps with log lines', }, { flags: '--container <name>', description: 'Container name (for pods with multiple containers)', }, { flags: '--previous', description: 'Show previous container logs (Kubernetes)', }, { flags: '-g, --grep <pattern>', description: 'Filter logs by pattern (regex)', }, { flags: '-v, --invert', description: 'Invert grep match (exclude matching lines)', }, { flags: '-A, --after <lines>', description: 'Show N lines after grep match', }, { flags: '-B, --before <lines>', description: 'Show N lines before grep match', }, { flags: '-C, --context <lines>', description: 'Show N lines before and after grep match', }, { flags: '--no-color', description: 'Disable colored output', }, { flags: '--json', description: 'Output logs as JSON', }, { flags: '--parallel', description: 'View logs from multiple targets in parallel', }, { flags: '--aggregate', description: 'Aggregate logs from multiple sources', }, { flags: '--prefix', description: 'Prefix each line with target name', }, { flags: '--task <task>', description: 'Run a log analysis task', }, ], examples: [ { command: 'xec logs', description: 'Start interactive mode to select targets and options', }, { command: 'xec logs containers.app', description: 'View last 50 lines from Docker container', }, { command: 'xec logs hosts.web-1 /var/log/nginx/access.log -f', description: 'Stream nginx access logs from SSH host', }, { command: 'xec logs pods.api --tail 100 --since 1h', description: 'View last 100 lines from past hour', }, { command: 'xec logs containers.* --parallel --prefix', description: 'View logs from all containers with prefixes', }, { command: 'xec logs hosts.web-* /var/log/app.log --grep ERROR -C 3', description: 'Find errors with 3 lines of context', }, { command: 'xec logs pods.worker --container sidecar -f', description: 'Stream logs from specific container in pod', }, { command: 'xec logs local /var/log/system.log --since "2h ago"', description: 'View local system logs from 2 hours ago', }, { command: 'xec logs hosts.db-* --task analyze-slow-queries', description: 'Run analysis task on database logs', }, { command: 'xec logs --interactive', description: 'Interactive mode for selecting targets and log options', }, ], validateOptions: (options) => { const schema = z.object({ profile: z.string().optional(), interactive: z.boolean().optional(), follow: z.boolean().optional(), tail: z.string().optional(), since: z.string().optional(), until: z.string().optional(), timestamps: z.boolean().optional(), container: z.string().optional(), previous: z.boolean().optional(), grep: z.string().optional(), invert: z.boolean().optional(), after: z.string().optional(), before: z.string().optional(), context: z.string().optional(), color: z.boolean().optional(), json: z.boolean().optional(), parallel: z.boolean().optional(), aggregate: z.boolean().optional(), prefix: z.boolean().optional(), task: z.string().optional(), verbose: z.boolean().optional(), quiet: z.boolean().optional(), dryRun: z.boolean().optional(), }); validateOptions(options, schema); }, }); this.streams = new Map(); this.running = true; } getCommandConfigKey() { return 'logs'; } async execute(args) { const lastArg = args[args.length - 1]; const isCommand = lastArg && typeof lastArg === 'object' && lastArg.constructor && lastArg.constructor.name === 'Command'; const options = isCommand ? args[args.length - 2] : lastArg; const positionalArgs = isCommand ? args.slice(0, -2) : args.slice(0, -1); let targetPattern = positionalArgs[0]; let logPath = positionalArgs[1]; if (!targetPattern) { const interactiveResult = await this.runInteractiveMode(options); if (!interactiveResult) return; targetPattern = interactiveResult.targetPattern; logPath = interactiveResult.logPath; Object.assign(options, interactiveResult.options); } if (!targetPattern) { throw new Error('Target specification is required'); } await this.initializeConfig(options); const defaults = this.getCommandDefaults(); const mergedOptions = this.applyDefaults({ ...options, verbose: options.verbose ?? this.options?.verbose, quiet: options.quiet ?? this.options?.quiet }, defaults); let targets; if (targetPattern.includes('*') || targetPattern.includes('{')) { targets = await this.findTargets(targetPattern); if (targets.length === 0) { throw new Error(`No targets found matching pattern: ${targetPattern}`); } } else { const target = await this.resolveTarget(targetPattern); targets = [target]; } if (mergedOptions.task) { await this.executeTask(targets, logPath, mergedOptions.task, mergedOptions); } else if (targets.length > 1 && (mergedOptions.parallel || mergedOptions.aggregate)) { await this.viewMultipleLogs(targets, logPath, mergedOptions); } else { for (const target of targets) { await this.viewSingleLog(target, logPath, mergedOptions); } } } async viewSingleLog(target, logPath, options) { const targetDisplay = this.formatTargetDisplay(target); if (options.dryRun) { console.log(`[DRY RUN] Would view logs from ${targetDisplay}${logPath ? `:${logPath}` : ''}`); return; } if (options.follow) { this.setupCleanupHandlers(); } try { if (options.follow) { console.log(`Streaming logs from ${targetDisplay}${logPath ? `:${logPath}` : ''}...`); if (options.grep) { console.log(`Filter: ${options.grep}${options.invert ? ' (inverted)' : ''}`); } console.log('Press Ctrl+C to stop\n'); } else if (!options.quiet) { this.startSpinner(`Fetching logs from ${targetDisplay}...`); } const logCommand = await this.buildLogCommand(target, logPath, options); if (options.verbose) { console.log(`[DEBUG] Log command: ${logCommand}`); } const useLocalEngine = target.type === 'docker' || target.type === 'k8s'; const engine = useLocalEngine ? $ : await this.createTargetEngine(target); if (options.follow) { await this.streamLogs(target, engine, logCommand, options); } else { let result; result = await engine.raw `${logCommand}`; if (!options.quiet) { this.stopSpinner(); } if (result.stdout && result.stdout.trim()) { this.displayLogs(result.stdout, target, options); } else { console.log('No logs found matching criteria.'); } } } catch (error) { if (!options.quiet) { this.stopSpinner(); } const errorMessage = error instanceof Error ? error.message : String(error); this.log(`${chalk.red('✗')} Failed to view logs: ${errorMessage}`, 'error'); throw error; } } async viewMultipleLogs(targets, logPath, options) { if (options.aggregate) { this.log('Aggregating logs from multiple targets...', 'info'); throw new Error('Log aggregation is not yet implemented'); } this.log(`Viewing logs from ${targets.length} targets in parallel...`, 'info'); if (options.follow) { this.setupCleanupHandlers(); const promises = targets.map(async (target) => { try { await this.streamLogsWithPrefix(target, logPath, options); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.log(`${chalk.red('✗')} ${this.formatTargetDisplay(target)}: ${errorMessage}`, 'error'); } }); try { await Promise.all(promises); } catch (error) { console.error('Unexpected error in parallel log streaming:', error); } } else { const promises = targets.map(async (target) => { try { const logCommand = await this.buildLogCommand(target, logPath, options); const useLocalEngine = target.type === 'docker' || target.type === 'k8s'; const engine = useLocalEngine ? $ : await this.createTargetEngine(target); const result = await engine.raw `${logCommand}`; return { target, logs: result.stdout, error: null }; } catch (error) { return { target, logs: null, error }; } }); const results = await Promise.all(promises); for (const { target, logs, error } of results) { if (error) { this.log(`${chalk.red('✗')} ${this.formatTargetDisplay(target)}: ${error}`, 'error'); } else if (logs) { this.displayLogs(logs, target, { ...options, prefix: true }); } } } } async buildLogCommand(target, logPath, options) { const parts = []; switch (target.type) { case 'docker': { const config = target.config; const container = config.container || target.name; parts.push('docker', 'logs'); if (options.follow) parts.push('--follow'); if (options.tail) parts.push('--tail', options.tail); if (options.since) parts.push('--since', this.convertTimeSpec(options.since)); if (options.until) parts.push('--until', this.convertTimeSpec(options.until)); if (options.timestamps) parts.push('--timestamps'); parts.push(container); if (options.grep) { parts.push('2>&1', '|', 'grep', '-E'); if (options.invert) parts.push('-v'); if (options.before) parts.push('-B', options.before); if (options.after) parts.push('-A', options.after); if (options.context) parts.push('-C', options.context); if (options.color !== false && !options.json) parts.push('--color=always'); parts.push(`'${options.grep.replace(/'/g, "'\\''")}'`); } break; } case 'k8s': { const config = target.config; const namespace = config.namespace || 'default'; const pod = config.pod || target.name; parts.push('kubectl', 'logs'); parts.push('-n', namespace); if (options.follow) parts.push('--follow'); if (options.tail) parts.push('--tail', options.tail); if (options.since) parts.push('--since', this.convertK8sTimeSpec(options.since)); if (options.timestamps) parts.push('--timestamps'); if (options.previous) parts.push('--previous'); if (options.container || config.container) { parts.push('--container', options.container || config.container); } parts.push(pod); if (options.grep) { parts.push('2>&1', '|', 'grep', '-E'); if (options.invert) parts.push('-v'); if (options.before) parts.push('-B', options.before); if (options.after) parts.push('-A', options.after); if (options.context) parts.push('-C', options.context); if (options.color !== false && !options.json) parts.push('--color=always'); parts.push(`'${options.grep.replace(/'/g, "'\\''")}'`); } break; } case 'ssh': case 'local': { const path = logPath || this.getDefaultLogPath(target); if (options.follow) { parts.push('tail', '-f'); if (options.tail) parts.push('-n', options.tail); } else { if (options.since || options.until) { parts.push('tail', '-n', '+1'); } else { parts.push('tail', '-n', options.tail || '50'); } } parts.push(path); if (options.grep) { parts.push('|', 'grep', '-E'); if (options.invert) parts.push('-v'); if (options.before) parts.push('-B', options.before); if (options.after) parts.push('-A', options.after); if (options.context) parts.push('-C', options.context); if (options.color !== false && !options.json) parts.push('--color=always'); parts.push(`'${options.grep.replace(/'/g, "'\\''")}'`); } break; } default: throw new Error(`Log viewing not supported for target type: ${target.type}`); } return parts.join(' '); } async streamLogs(target, engine, logCommand, options) { const sessionId = `${target.id}:logs`; let logProcess; logProcess = engine.raw `${logCommand}`.nothrow(); if (logProcess.child) { logProcess.child.on('error', (error) => { console.error(`Child process error for ${sessionId}:`, error); this.streams.delete(sessionId); }); } this.streams.set(sessionId, { target, process: logProcess, cleanup: async () => { try { if (logProcess && typeof logProcess.kill === 'function') { logProcess.kill('SIGTERM'); } } catch (error) { } }, }); if (logProcess.child?.stdout) { const rl = readline.createInterface({ input: logProcess.child.stdout, crlfDelay: Infinity, }); rl.on('line', (line) => { try { this.displayLogLine(line, target, options); } catch (error) { console.error('Error displaying log line:', error); } }); rl.on('close', () => { this.streams.delete(sessionId); }); rl.on('error', (error) => { console.error('Readline error:', error); this.streams.delete(sessionId); }); } if (logProcess.child?.stderr) { logProcess.child.stderr.on('data', (data) => { try { if (options.verbose) { console.error(chalk.yellow(data.toString().trim())); } } catch (error) { } }); logProcess.child.stderr.on('error', (error) => { if (options.verbose) { console.error('Stderr stream error:', error); } }); } try { await logProcess; } catch (error) { if (!this.running) { return; } throw error; } } async streamLogsWithPrefix(target, logPath, options) { const logCommand = await this.buildLogCommand(target, logPath, options); const useLocalEngine = target.type === 'docker' || target.type === 'k8s'; const engine = useLocalEngine ? $ : await this.createTargetEngine(target); await this.streamLogs(target, engine, logCommand, { ...options, prefix: true }); } displayLogs(logs, target, options) { const lines = logs.split('\n').filter(line => line.trim()); if (lines.length === 0) { return; } for (const line of lines) { this.displayLogLine(line, target, options); } if (!options.quiet && !options.follow) { this.log(chalk.gray(`\nDisplayed ${lines.length} log lines`), 'info'); } } displayLogLine(line, target, options) { if (!line.trim()) return; let output = line; if (options.prefix) { const prefix = chalk.cyan(`[${this.formatTargetDisplay(target)}]`); output = `${prefix} ${output}`; } if (options.timestamps && !this.hasTimestamp(line)) { const timestamp = chalk.gray(new Date().toISOString()); output = `${timestamp} ${output}`; } if (options.json) { try { const parsed = JSON.parse(line); console.log(JSON.stringify({ target: target.id, timestamp: new Date().toISOString(), data: parsed, }, null, 2)); return; } catch { console.log(JSON.stringify({ target: target.id, timestamp: new Date().toISOString(), message: line.trim(), })); return; } } if (options.color !== false) { output = this.colorizeLogLine(output); } console.log(output); } colorizeLogLine(line) { return line .replace(/\b(ERROR|ERR|FAIL|FAILURE|FATAL)\b/gi, chalk.red('$1')) .replace(/\b(WARN|WARNING)\b/gi, chalk.yellow('$1')) .replace(/\b(INFO|INFORMATION)\b/gi, chalk.blue('$1')) .replace(/\b(DEBUG|TRACE)\b/gi, chalk.gray('$1')) .replace(/\b(SUCCESS|OK|DONE)\b/gi, chalk.green('$1')); } hasTimestamp(line) { const timestampPatterns = [ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, /^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}/, /^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]/, /^\w{3} \d{1,2} \d{2}:\d{2}:\d{2}/, ]; return timestampPatterns.some(pattern => pattern.test(line)); } convertTimeSpec(timeSpec) { const match = timeSpec.match(/^(\d+)([smhd])(?:\s+ago)?$/); if (match && match[1] && match[2]) { const value = match[1]; const unit = match[2]; const seconds = this.getSeconds(parseInt(value, 10), unit); return `${seconds}s`; } return timeSpec; } convertK8sTimeSpec(timeSpec) { const match = timeSpec.match(/^(\d+)([smhd])(?:\s+ago)?$/); if (match) { const [, value, unit] = match; return `${value}${unit}`; } return timeSpec; } getSeconds(value, unit) { switch (unit) { case 's': return value; case 'm': return value * 60; case 'h': return value * 3600; case 'd': return value * 86400; default: return value; } } getDefaultLogPath(target) { const config = target.config; if (config.logPath) { return config.logPath; } switch (target.name) { case 'nginx': return '/var/log/nginx/access.log'; case 'apache': return '/var/log/apache2/access.log'; case 'mysql': return '/var/log/mysql/error.log'; case 'postgres': return '/var/log/postgresql/postgresql.log'; default: return '/var/log/syslog'; } } async executeTask(targets, logPath, taskName, options) { if (!this.taskManager) { throw new Error('Task manager not initialized'); } for (const target of targets) { const targetDisplay = this.formatTargetDisplay(target); this.log(`Running log analysis task '${taskName}' on ${targetDisplay}...`, 'info'); try { const result = await this.taskManager.run(taskName, { LOG_PATH: logPath || this.getDefaultLogPath(target) }, { target: target.id }); if (result.success) { this.log(`${chalk.green('✓')} Task completed on ${targetDisplay}`, 'success'); } else { throw new Error(result.error?.message || 'Task failed'); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.log(`${chalk.red('✗')} Task failed on ${targetDisplay}: ${errorMessage}`, 'error'); throw error; } } } async runInteractiveMode(options) { InteractiveHelpers.startInteractiveMode('Interactive Logs Mode'); try { const logSourceType = await InteractiveHelpers.selectFromList('What type of logs do you want to view?', [ { value: 'container', label: '🐳 Container logs (Docker)' }, { value: 'pod', label: '☸️ Pod logs (Kubernetes)' }, { value: 'file', label: '📄 Log file (SSH/Local)' }, { value: 'syslog', label: '🖥️ System logs' }, ], (item) => item.label); if (!logSourceType) return null; let targetType = 'all'; switch (logSourceType.value) { case 'container': targetType = 'docker'; break; case 'pod': targetType = 'k8s'; break; case 'file': case 'syslog': targetType = 'all'; break; } const target = await InteractiveHelpers.selectTarget({ message: 'Select target:', type: targetType, allowCustom: true, }); if (!target || Array.isArray(target)) return null; const targetPattern = target.id; let logPath; if (logSourceType.value === 'file') { const logPathInput = await InteractiveHelpers.inputText('Enter log file path:', { placeholder: '/var/log/app.log', validate: (value) => { if (!value || value.trim().length === 0) { return 'Log path cannot be empty'; } return undefined; }, }); if (!logPathInput) return null; logPath = logPathInput; } else if (logSourceType.value === 'syslog') { const syslogType = await InteractiveHelpers.selectFromList('Select system log type:', [ { value: '/var/log/syslog', label: 'System log' }, { value: '/var/log/messages', label: 'Messages' }, { value: '/var/log/auth.log', label: 'Authentication log' }, { value: '/var/log/kern.log', label: 'Kernel log' }, { value: 'custom', label: 'Custom path...' }, ], (item) => item.label); if (!syslogType) return null; if (syslogType.value === 'custom') { const customLogPath = await InteractiveHelpers.inputText('Enter custom log path:', { placeholder: '/var/log/custom.log', validate: (value) => { if (!value || value.trim().length === 0) { return 'Log path cannot be empty'; } return undefined; }, }); if (!customLogPath) return null; logPath = customLogPath; } else { logPath = syslogType.value; } } const viewingMode = await InteractiveHelpers.selectFromList('How do you want to view the logs?', [ { value: 'tail', label: '📖 View recent logs (tail)' }, { value: 'follow', label: '🔄 Stream live logs (follow)' }, { value: 'search', label: '🔍 Search and filter logs' }, ], (item) => item.label); if (!viewingMode) return null; const interactiveOptions = {}; if (viewingMode.value === 'follow') { interactiveOptions.follow = true; } else if (viewingMode.value === 'search') { const searchPattern = await InteractiveHelpers.inputText('Enter search pattern (regex):', { placeholder: 'ERROR|WARN', }); if (searchPattern) { interactiveOptions.grep = searchPattern; } const contextLines = await InteractiveHelpers.selectFromList('Show context around matches?', [ { value: 'none', label: 'No context' }, { value: '3', label: '3 lines before and after' }, { value: '5', label: '5 lines before and after' }, { value: '10', label: '10 lines before and after' }, ], (item) => item.label); if (contextLines && contextLines.value !== 'none') { interactiveOptions.context = contextLines.value; } const invertMatch = await InteractiveHelpers.confirmAction('Invert match (exclude matching lines)?', false); if (invertMatch) { interactiveOptions.invert = true; } } if (!interactiveOptions.follow) { const tailCount = await InteractiveHelpers.selectFromList('How many recent lines to show?', [ { value: '50', label: '50 lines' }, { value: '100', label: '100 lines' }, { value: '200', label: '200 lines' }, { value: '500', label: '500 lines' }, { value: 'custom', label: 'Custom amount...' }, ], (item) => item.label); if (!tailCount) return null; if (tailCount.value === 'custom') { const customCount = await InteractiveHelpers.inputText('Enter number of lines:', { placeholder: '100', validate: (value) => { const num = parseInt(value, 10); if (isNaN(num) || num <= 0) { return 'Please enter a positive number'; } return undefined; }, }); if (!customCount) return null; interactiveOptions.tail = customCount; } else { interactiveOptions.tail = tailCount.value; } } const useTimeRange = await InteractiveHelpers.confirmAction('Filter by time range?', false); if (useTimeRange) { const timeRange = await InteractiveHelpers.selectFromList('Select time range:', [ { value: '5m', label: 'Last 5 minutes' }, { value: '15m', label: 'Last 15 minutes' }, { value: '1h', label: 'Last hour' }, { value: '6h', label: 'Last 6 hours' }, { value: '1d', label: 'Last day' }, { value: 'custom', label: 'Custom time...' }, ], (item) => item.label); if (timeRange) { if (timeRange.value === 'custom') { const customTime = await InteractiveHelpers.inputText('Enter time specification:', { placeholder: '2h (2 hours ago) or 2023-12-01T10:00:00', }); if (customTime) { interactiveOptions.since = customTime; } } else { interactiveOptions.since = timeRange.value; } } } if (target.type === 'k8s') { const showPrevious = await InteractiveHelpers.confirmAction('Show logs from previous container instance?', false); if (showPrevious) { interactiveOptions.previous = true; } const containerName = await InteractiveHelpers.inputText('Container name (leave empty for default):', { placeholder: 'sidecar, main, etc.', }); if (containerName) { interactiveOptions.container = containerName; } } const outputOptions = await InteractiveHelpers.selectFromList('Select output format:', [ { value: 'default', label: '📝 Standard output' }, { value: 'timestamps', label: '🕐 Include timestamps' }, { value: 'json', label: '📋 JSON format' }, ], (item) => item.label); if (outputOptions) { switch (outputOptions.value) { case 'timestamps': interactiveOptions.timestamps = true; break; case 'json': interactiveOptions.json = true; break; } } const showColors = await InteractiveHelpers.confirmAction('Enable colored output for log levels?', true); if (!showColors) { interactiveOptions.color = false; } InteractiveHelpers.endInteractiveMode('Logs configuration complete!'); return { targetPattern, logPath, options: interactiveOptions, }; } catch (error) { InteractiveHelpers.showError(`Interactive mode failed: ${error}`); return null; } } setupCleanupHandlers() { const cleanup = async () => { try { this.running = false; this.log('\nStopping log streams...', 'info'); for (const [sessionId, stream] of this.streams) { try { if (stream.cleanup) { await stream.cleanup(); } this.log(`Stopped stream for ${sessionId}`, 'info'); } catch (error) { this.log(`Failed to cleanup ${sessionId}: ${error}`, 'error'); } } this.streams.clear(); if (process.env['NODE_ENV'] !== 'test') { process.exit(0); } } catch (error) { console.error('Error during cleanup:', error); if (process.env['NODE_ENV'] !== 'test') { process.exit(1); } } }; const safeCleanup = (...args) => { cleanup().catch((error) => { console.error('Unhandled error in cleanup:', error); if (process.env['NODE_ENV'] !== 'test') { process.exit(1); } }); }; process.once('SIGINT', safeCleanup); process.once('SIGTERM', safeCleanup); } } export default function command(program) { const cmd = new LogsCommand(); program.addCommand(cmd.create()); } //# sourceMappingURL=logs.js.map