/** * Logs command — application logs with follow mode (polling). * Ctrl+C stops the follow loop without killing the process. * * @module */ import * as p from "@clack/prompts"; import { isErr } from "@mks2508/no-throw"; import chalk from "chalk"; import { getCoolifyService } from "../../coolify/index.js"; import { resolveUuid, loadMultiAppState } from "../coolify-state.js"; import { resolveAppNameOrUuid } from "../name-resolver.js"; const POLL_INTERVAL = 2000; /** * Logs command handler (CLI entry point). */ export async function logsCommand( uuid: string | undefined, options: { lines?: number; follow?: boolean; errors?: boolean; since?: string; }, ) { if (!uuid) { uuid = await resolveOrPromptApp(); if (!uuid) return; } else { let resolvedUuid = resolveUuid(uuid); if (!resolvedUuid) resolvedUuid = await resolveAppNameOrUuid(uuid); if (!resolvedUuid) { console.error(chalk.red("Error: Could not resolve app UUID/name")); return; } uuid = resolvedUuid; } const coolify = getCoolifyService(); const initResult = await coolify.init(); if (isErr(initResult)) { console.error(chalk.red(`Error: ${initResult.error.message}`)); return; } if (options.follow) { await followLogs(coolify, uuid, options); } else { await showLogs(coolify, uuid, options); } } /** * Show logs once (non-follow). */ async function showLogs( coolify: ReturnType, uuid: string, options: { lines?: number; errors?: boolean; since?: string }, ): Promise { const result = await coolify.getApplicationLogs(uuid, { tail: options.lines || 100, }); if (isErr(result)) { console.error(chalk.red(`Error: ${result.error.message}`)); return; } let logs = result.value.logs; logs = filterLogs(logs, options); if (logs.length === 0) { console.log(chalk.yellow("No logs available")); return; } console.log(chalk.gray(`Logs (${logs.length} lines):\n`)); for (const line of logs) { console.log(colorizeLogLine(line)); } } /** * Follow logs with polling. * Ctrl+C stops the follow and returns control to caller (does NOT exit process). */ async function followLogs( coolify: ReturnType, uuid: string, options: { lines?: number; errors?: boolean; since?: string }, ): Promise { console.log( chalk.gray( `Following logs for ${chalk.cyan(uuid.slice(0, 12))} — ${chalk.bold("Ctrl+C")} to stop\n`, ), ); let seenLines = new Set(); let tail = options.lines || 20; // Start small — only recent lines let isFirstFetch = true; let running = true; // Ctrl+C handler — stops the loop, does NOT exit process const abort = () => { running = false; }; process.on("SIGINT", abort); try { while (running) { const result = await coolify.getApplicationLogs(uuid, { tail }); if (isErr(result)) { if (!running) break; // Aborted during fetch console.error(chalk.red(`Error: ${result.error.message}`)); await sleep(POLL_INTERVAL); continue; } let logs = result.value.logs; logs = filterLogs(logs, options); for (const line of logs) { const lineHash = line.trim(); if (!seenLines.has(lineHash) && lineHash.length > 0) { seenLines.add(lineHash); console.log(colorizeLogLine(line)); } } if (seenLines.size > 5000) { const entries = [...seenLines]; seenLines = new Set(entries.slice(-2000)); } // Keep tail small — we rely on dedup to only show new lines tail = 30; // Interruptible sleep await new Promise((resolve) => { const timer = setTimeout(resolve, POLL_INTERVAL); const checkAbort = () => { if (!running) { clearTimeout(timer); resolve(); } }; // Check abort every 100ms during sleep const interval = setInterval(checkAbort, 100); setTimeout(() => clearInterval(interval), POLL_INTERVAL + 50); }); } } finally { process.removeListener("SIGINT", abort); } console.log(chalk.gray("\nStopped following logs.")); } /** * Fetch latest N log lines for a preview panel (no follow, just snapshot). * Returns colorized string lines. * * @param uuid - Application UUID (already resolved) * @param lines - Number of lines to fetch * @returns Array of colorized log lines */ export async function fetchLogPreview( uuid: string, lines: number = 8, ): Promise { const coolify = getCoolifyService(); const result = await coolify.getApplicationLogs(uuid, { tail: lines }); if (isErr(result)) return [chalk.gray("(logs unavailable)")]; const logs = result.value.logs; if (logs.length === 0) return [chalk.gray("(no logs)")]; return logs.slice(-lines).map(colorizeLogLine); } // ─── Filters & helpers ─────────────────────────────────────────────────────── function filterLogs( logs: string[], options: { errors?: boolean; since?: string }, ): string[] { let filtered = logs; if (options.errors) { filtered = filtered.filter( (line) => /\b(error|err|fatal|panic|exception|fail)\b/i.test(line) || /stderr/i.test(line), ); } if (options.since) { const sinceMs = parseDuration(options.since); if (sinceMs > 0) { const cutoff = Date.now() - sinceMs; filtered = filtered.filter((line) => { const ts = extractTimestamp(line); return ts === null || ts >= cutoff; }); } } return filtered; } export function colorizeLogLine(line: string): string { if (/\b(error|fatal|panic)\b/i.test(line)) return chalk.red(line); if (/\b(warn|warning)\b/i.test(line)) return chalk.yellow(line); if (/\b(info)\b/i.test(line)) return chalk.white(line); if (/\b(debug|trace)\b/i.test(line)) return chalk.gray(line); return line; } function parseDuration(str: string): number { const match = str.match(/^(\d+)(s|m|h|d)$/); if (!match) return 0; const value = parseInt(match[1], 10); const multipliers: Record = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, }; return value * (multipliers[match[2]] || 0); } function extractTimestamp(line: string): number | null { const isoMatch = line.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); if (isoMatch) return new Date(isoMatch[0]).getTime(); return null; } async function resolveOrPromptApp(): Promise { const resolved = resolveUuid(undefined); if (resolved) return resolved; const multiState = loadMultiAppState(); if (multiState && multiState.apps.length > 0) { const response = await p.select({ message: "Select an application:", options: multiState.apps.map((app) => ({ label: app.name, value: app.uuid, hint: app.domain || app.service, })), }); if (p.isCancel(response)) return null; return response as string; } console.error(chalk.red("Error: No UUID/name and no .coolify.json found")); return null; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }