|
| 1 | +import { appendFileSync, mkdirSync } from "node:fs"; |
| 2 | +import { homedir } from "node:os"; |
| 3 | +import { dirname, join } from "node:path"; |
| 4 | + |
| 5 | +import { configureSync } from "@intx/log"; |
| 6 | + |
| 7 | +import { SETTINGS_DIR_NAME } from "../branding.js"; |
| 8 | + |
| 9 | +// Matches LogTape's Sink shape structurally (see @logtape/logtape's |
| 10 | +// sink.d.ts); not imported directly since only @intx/log is a declared |
| 11 | +// dependency here. The real type is strictly wider than this — if LogTape |
| 12 | +// ever renames or narrows one of these fields, nothing here will catch the |
| 13 | +// drift, so keep this in sync by hand if @intx/log's pinned version moves. |
| 14 | +type LogRecord = { |
| 15 | + readonly category: readonly string[]; |
| 16 | + readonly level: string; |
| 17 | + readonly message: readonly unknown[]; |
| 18 | + readonly timestamp: number; |
| 19 | + readonly properties: Record<string, unknown>; |
| 20 | +}; |
| 21 | + |
| 22 | +export function corbitsLogFilePath(home: string = homedir()): string { |
| 23 | + return join(home, SETTINGS_DIR_NAME, "logs", "corbits.log"); |
| 24 | +} |
| 25 | + |
| 26 | +function formatRecord(record: LogRecord): string { |
| 27 | + return ( |
| 28 | + JSON.stringify({ |
| 29 | + timestamp: new Date(record.timestamp).toISOString(), |
| 30 | + level: record.level, |
| 31 | + category: record.category.join("."), |
| 32 | + message: record.message.join(""), |
| 33 | + properties: record.properties, |
| 34 | + }) + "\n" |
| 35 | + ); |
| 36 | +} |
| 37 | + |
| 38 | +/** |
| 39 | + * Routes every logger — including ones inside vendored dependencies, which |
| 40 | + * Corbits cannot edit — to a file instead of the console. |
| 41 | + * |
| 42 | + * `@intx/log` installs a console sink as a side effect of its first import |
| 43 | + * (see its `default-sink` module), so a bare `getLogger` import is enough |
| 44 | + * for a log call to reach stdout/stderr before Corbits does anything. This |
| 45 | + * must run before any other Corbits code executes — first statement in |
| 46 | + * `mainWithRunners` — so that race is never live: the TUI holds the |
| 47 | + * alternate screen for the rest of the process, and anything landing on |
| 48 | + * the real terminal mid-frame corrupts it. |
| 49 | + */ |
| 50 | +export function installFileLogSink(path: string = corbitsLogFilePath()): void { |
| 51 | + mkdirSync(dirname(path), { recursive: true }); |
| 52 | + configureSync({ |
| 53 | + reset: true, |
| 54 | + sinks: { |
| 55 | + file: (record: LogRecord) => { |
| 56 | + appendFileSync(path, formatRecord(record)); |
| 57 | + }, |
| 58 | + }, |
| 59 | + // "debug" (not "warning"): a file has no screen to corrupt, and several |
| 60 | + // teardown-race diagnostics (e.g. src/tui/runner.ts, src/exec/runner.ts) |
| 61 | + // are logger.debug calls that exist specifically to be readable here |
| 62 | + // after the fact. Filtering them out at the sink would silently disable |
| 63 | + // the diagnostics the file exists to capture. |
| 64 | + loggers: [ |
| 65 | + { category: ["logtape", "meta"], lowestLevel: "warning", sinks: ["file"] }, |
| 66 | + { category: [], lowestLevel: "debug", sinks: ["file"] }, |
| 67 | + ], |
| 68 | + }); |
| 69 | +} |
0 commit comments