import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs' import { join } from 'node:path' /** * The app's one log sink: a plain-text file under the OS log dir, written * SYNCHRONOUSLY so a line survives the process dying moments later. * * Why a file at all: `console.*` goes nowhere in real use. Helder runs one * process per project window, and every window past the first is spawned by * `spawnInstance()` with `stdio: 'ignore'` — its output is discarded. Launched * from Finder there's no terminal attached either. Before this, a crash left * literally no trace; that's what made "it crashed sometimes" undebuggable. * * This module deliberately does NOT import electron, so it stays unit-testable. * `initLogger()` is handed the directory by the caller (see diagnostics.ts). */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error' const LEVELS: Record = { debug: 10, info: 20, warn: 30, error: 40 } /** Rotate at 2 MB, keep 3 old files (~8 MB worst case for a dev tool's log). */ const MAX_BYTES = 2 * 1024 * 1024 const KEEP = 3 interface LoggerState { file: string | null dir: string | null min: number mirror: boolean /** Byte size tracked in-process so the common path avoids a stat() per line. */ size: number } const state: LoggerState = { file: null, dir: null, min: LEVELS.debug, mirror: false, size: 0 } /** Absolute path of the active log file, or null before initLogger(). */ export function getLogPath(): string | null { return state.file } export function getLogDir(): string | null { return state.dir } /** * Point the logger at `dir` (created if needed). Safe to call once per process. * `mirror` also echoes to the console, which is useful in `npm run dev` where a * terminal IS attached. `level` gates the floor (default: everything). */ export function initLogger(opts: { dir: string; mirror?: boolean; level?: LogLevel }): void { state.dir = opts.dir state.file = join(opts.dir, 'helder.log') state.mirror = !!opts.mirror state.min = LEVELS[opts.level ?? 'debug'] try { mkdirSync(opts.dir, { recursive: true }) state.size = statSync(state.file).size } catch { // Missing file is the normal first-run case (size stays 0). A genuinely // unwritable dir surfaces on the first write() instead, which no-ops. state.size = 0 } } /** * `helder.log` → `helder.1.log` → … → dropped after KEEP. Called when the live * file crosses MAX_BYTES. Several project processes share one file and could in * principle rotate at the same moment; the renames are best-effort and a lost * race costs at most some log lines, never a crash — hence the blanket catch. */ function rotate(): void { const dir = state.dir const file = state.file if (!dir || !file) return try { const oldest = join(dir, `helder.${KEEP}.log`) try { unlinkSync(oldest) } catch { /* wasn't there */ } for (let i = KEEP - 1; i >= 1; i--) { try { renameSync(join(dir, `helder.${i}.log`), join(dir, `helder.${i + 1}.log`)) } catch { /* gap in the chain */ } } renameSync(file, join(dir, 'helder.1.log')) state.size = 0 } catch { /* another process rotated first; keep appending */ } } /** JSON that can't throw on cycles/BigInt — a logger must never be the crash. */ function safeJson(value: unknown): string { const seen = new WeakSet() try { return JSON.stringify(value, (_k, v) => { if (typeof v === 'bigint') return `${v}n` if (typeof v === 'function') return `[Function ${v.name || 'anonymous'}]` if (typeof v === 'object' && v !== null) { if (seen.has(v as object)) return '[Circular]' seen.add(v as object) } return v }) ?? String(value) } catch { return '[unserializable]' } } /** * Normalise anything thrown into a loggable shape. Non-Errors get stringified * (people throw strings), and `cause` is followed so wrapped errors keep their * root cause — usually the line that actually explains the failure. */ export function formatErr(e: unknown): { message: string; stack?: string; cause?: string } { if (e instanceof Error) { const out: { message: string; stack?: string; cause?: string } = { message: e.message } if (e.stack) out.stack = e.stack if (e.cause !== undefined) out.cause = e.cause instanceof Error ? (e.cause.stack || e.cause.message) : safeJson(e.cause) return out } return { message: typeof e === 'string' ? e : safeJson(e) } } /** * One log line: ISO ts · level · pid · scope · message · context JSON. * * Continuation lines are indented, never bare: a message can carry newlines of * its own (Electron's console warnings do, and so does any stack passed as the * message), and an unindented second line is indistinguishable from a new entry * to both a human skimming the file and to `grep`. */ export function formatLine(level: LogLevel, scope: string, msg: string, ctx: unknown, pid: number, now: string): string { const head = `${now} ${level.toUpperCase().padEnd(5)} ${String(pid).padStart(5)} ${scope.padEnd(9)} ${indent(msg)}` if (ctx === undefined) return head + '\n' return head + ' ' + indent(safeJson(ctx)) + '\n' } function indent(s: string): string { return s.replace(/\r?\n/g, '\n ') } export function log(level: LogLevel, scope: string, msg: string, ctx?: unknown): void { if (LEVELS[level] < state.min) return const line = formatLine(level, scope, msg, ctx, process.pid, new Date().toISOString()) if (state.mirror) { const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log fn(line.trimEnd()) } const file = state.file if (!file) return if (state.size + line.length > MAX_BYTES) rotate() try { // Sync + O_APPEND: the write lands before an imminent crash can eat it, and // concurrent appends from sibling project processes don't interleave. appendFileSync(file, line, { encoding: 'utf8' }) state.size += Buffer.byteLength(line) } catch { /* disk full / no permission — never let logging break the app */ } } export const logger = { debug: (scope: string, msg: string, ctx?: unknown): void => log('debug', scope, msg, ctx), info: (scope: string, msg: string, ctx?: unknown): void => log('info', scope, msg, ctx), warn: (scope: string, msg: string, ctx?: unknown): void => log('warn', scope, msg, ctx), error: (scope: string, msg: string, err?: unknown, ctx?: Record): void => log('error', scope, msg, err === undefined ? ctx : { ...ctx, err: formatErr(err) }), } /** Reset for tests. Not used by the app. */ export function _resetLogger(): void { state.file = null; state.dir = null; state.min = LEVELS.debug; state.mirror = false; state.size = 0 }