/** * KHAELOR * File: src/cli/logger.ts * Description: Tiny redacting file logger for ~/.khaelor/logs — developer logs never pollute the TUI, secrets never reach disk. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; /** Default developer log directory (ARCHITECTURE.md §5.1, CLAUDE.md §19). */ export function defaultLogDir(): string { return join(homedir(), ".khaelor", "logs"); } /** Secret-shaped substrings that must never reach a log file. */ const REDACTIONS: readonly RegExp[] = [ /sk-ant-[A-Za-z0-9_-]+/g, // Anthropic API keys /(bearer\s+)[A-Za-z0-9._-]{8,}/gi, // bearer tokens (before the header rules eat "Bearer") /(authorization\s*[:=]\s*)\S+/gi, // Authorization headers /(x-api-key\s*[:=]\s*)\S+/gi, // raw header form /(ANTHROPIC_API_KEY\s*[:=]\s*)\S+/g, // env echoes ]; /** Redact API keys and authorization material from arbitrary text. */ export function redactForLog(text: string): string { let out = text; for (const pattern of REDACTIONS) { out = out.replace(pattern, (_match, prefix: unknown) => typeof prefix === "string" ? `${prefix}[redacted]` : "[redacted]", ); } return out; } export type LogLevel = "debug" | "info" | "warn" | "error"; /** * Append-only file logger. No dependencies, synchronous appends (log volume * is low — lifecycle events and error paths only). When `enabled` is false * only `warn`/`error` lines are written; `--debug` enables everything. */ export class FileLogger { readonly filePath: string; #debug: boolean; #ready = false; #broken = false; constructor(filePath: string, options: { debug?: boolean } = {}) { this.filePath = filePath; this.#debug = options.debug ?? false; } get debugEnabled(): boolean { return this.#debug; } log(level: LogLevel, message: string, details?: Record): void { if (this.#broken) return; if (!this.#debug && (level === "debug" || level === "info")) return; const line = JSON.stringify({ ts: new Date().toISOString(), level, message: redactForLog(message), ...(details !== undefined ? { details: JSON.parse(redactForLog(JSON.stringify(details))) as unknown } : {}), }) + "\n"; try { this.#ensureDir(); appendFileSync(this.filePath, line, "utf8"); } catch { // A broken log file must never break the app (CLAUDE.md §19). this.#broken = true; } } debug(message: string, details?: Record): void { this.log("debug", message, details); } error(message: string, details?: Record): void { this.log("error", message, details); } #ensureDir(): void { if (this.#ready) return; mkdirSync(dirname(this.filePath), { recursive: true }); this.#ready = true; } } /** * Write a crash report file (never the screen — the terminal guard restores * the shell; details live under ~/.khaelor/logs/). Returns the file path, * or null when even that failed. */ export function writeCrashFile(logDir: string, error: unknown): string | null { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const file = join(logDir, `crash-${stamp}.log`); const body = error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error); try { mkdirSync(logDir, { recursive: true }); writeFileSync(file, redactForLog(body) + "\n", "utf8"); return file; } catch { return null; } }