import * as plugins from '../../plugins.js'; import { tspmIpcClient } from '../../../client/tspm.ipcclient.js'; import type { CliArguments } from '../../types.js'; import { registerIpcCommand } from '../../registration/index.js'; import { getBool, getNumber, getString } from '../../helpers/argv.js'; import { formatLog } from '../../helpers/formatting.js'; import { withStreamingLifecycle } from '../../helpers/lifecycle.js'; export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) { registerIpcCommand( smartcli, 'logs', async (argvArg: CliArguments) => { const target = argvArg._[1]; if (!target) { console.error('Error: Please provide a process target'); console.log('Usage: tspm logs [options]'); console.log('\nOptions:'); console.log(' --lines Number of lines to show (default: 50)'); console.log(' --since Only show logs since duration (e.g., 10m, 2h, 1d)'); console.log(' --stderr-only Only show stderr logs'); console.log(' --stdout-only Only show stdout logs'); console.log(' --ndjson Output each log as JSON line'); console.log(' --follow Stream logs in real-time (like tail -f)'); return; } const lines = getNumber(argvArg, 'lines', 50); const follow = getBool(argvArg, 'follow', 'f'); const sinceSpec = getString(argvArg, 'since'); const stderrOnly = getBool(argvArg, 'stderr-only'); const stdoutOnly = getBool(argvArg, 'stdout-only'); const ndjson = getBool(argvArg, 'ndjson'); const parseDuration = (spec?: string): number | undefined => { if (!spec) return undefined; const m = spec.trim().match(/^(\d+)(ms|s|m|h|d)?$/i); if (!m) return undefined; const val = Number(m[1]); const unit = (m[2] || 'm').toLowerCase(); const mult = unit === 'ms' ? 1 : unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000; return Date.now() - val * mult; }; const sinceTime = parseDuration(sinceSpec); const typesFilter: Array<'stdout' | 'stderr' | 'system'> | undefined = stderrOnly && !stdoutOnly ? ['stderr'] : stdoutOnly && !stderrOnly ? ['stdout'] : undefined; // all const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) }); const id = resolved.id; const response = await tspmIpcClient.request('getLogs', { id, lines: sinceTime ? 0 : lines }); if (!follow) { // One-shot mode - auto-disconnect handled by registerIpcCommand const filtered = response.logs.filter((l) => { if (typesFilter && !typesFilter.includes(l.type)) return false; if (sinceTime && new Date(l.timestamp).getTime() < sinceTime) return false; return true; }); console.log(`Logs for process: ${id} (${sinceTime ? 'since ' + new Date(sinceTime).toLocaleString() : 'last ' + lines + ' lines'})`); console.log('─'.repeat(60)); for (const log of filtered) { if (ndjson) { console.log( JSON.stringify({ ...log, timestamp: new Date(log.timestamp).getTime(), }), ); } else { const timestamp = new Date(log.timestamp).toLocaleTimeString(); const prefix = log.type === 'stdout' ? '[OUT]' : log.type === 'stderr' ? '[ERR]' : '[SYS]'; console.log(`${timestamp} ${prefix} ${log.message}`); } } return; } // Streaming mode console.log(`Logs for process: ${resolved.name || id} (streaming...)`); console.log('─'.repeat(60)); // Prepare backlog printing state and stream handler let lastSeq = 0; let lastRunId: string | undefined = undefined; const printLog = (log: any) => { if (typesFilter && !typesFilter.includes(log.type)) return; if (sinceTime && new Date(log.timestamp).getTime() < sinceTime) return; if (ndjson) { console.log( JSON.stringify({ ...log, timestamp: new Date(log.timestamp).getTime(), }), ); } else { const timestamp = new Date(log.timestamp).toLocaleTimeString(); const prefix = log.type === 'stdout' ? '[OUT]' : log.type === 'stderr' ? '[ERR]' : '[SYS]'; console.log(`${timestamp} ${prefix} ${log.message}`); } }; // Print initial backlog (already fetched via getLogs) for (const log of response.logs) { printLog(log); if (log.seq !== undefined) lastSeq = Math.max(lastSeq, log.seq); if ((log as any).runId) lastRunId = (log as any).runId; } // Request additional backlog delivered as incremental messages to avoid large payloads try { const disposeBacklog = tspmIpcClient.onBacklogTopic(id, (log: any) => { if (log.runId && log.runId !== lastRunId) { console.log(`[INFO] Detected process restart (runId changed).`); lastSeq = -1; lastRunId = log.runId; } if (log.seq !== undefined && log.seq <= lastSeq) return; if (log.seq !== undefined && log.seq > lastSeq + 1) { console.log( `[WARNING] Log gap detected: expected seq ${lastSeq + 1}, got ${log.seq}`, ); } printLog({ ...log, timestamp: new Date(log.timestamp) }); if (log.seq !== undefined) lastSeq = log.seq; }); await tspmIpcClient.requestLogsBacklogStream(id, { lines: sinceTime ? undefined : lines, sinceTime, types: typesFilter }); // Dispose backlog handler after a short grace (backlog is finite) setTimeout(() => disposeBacklog(), 10000); } catch {} await withStreamingLifecycle( async () => { await tspmIpcClient.subscribe(id, (log: any) => { // Reset sequence if runId changed (e.g., process restarted) if (log.runId && log.runId !== lastRunId) { console.log(`[INFO] Detected process restart (runId changed).`); lastSeq = -1; lastRunId = log.runId; } if (log.seq !== undefined && log.seq <= lastSeq) return; if (log.seq !== undefined && log.seq > lastSeq + 1) { console.log( `[WARNING] Log gap detected: expected seq ${lastSeq + 1}, got ${log.seq}`, ); } printLog(log); if (log.seq !== undefined) lastSeq = log.seq; }); }, async () => { console.log('\n\nStopping log stream...'); try { await tspmIpcClient.unsubscribe(id); } catch {} try { await tspmIpcClient.disconnect(); } catch {} }, ); }, { actionLabel: 'get logs', keepAlive: (argv) => getBool(argv, 'follow', 'f'), }, ); }