|
| 1 | +import fs from 'fs'; |
| 2 | +import os from 'os'; |
| 3 | +import path from 'path'; |
| 4 | +import { format } from 'util'; |
| 5 | +import { VERSION } from '../../version.js'; |
| 6 | +import { redact } from './redact.js'; |
| 7 | + |
| 8 | +/** |
| 9 | + * The remote device log: ~/.desktop-commander-device/remote-<day>.log, the |
| 10 | + * device's history that `remote --report` packs for support. |
| 11 | + * |
| 12 | + * `remote` passes the device's console output through startDeviceLog(). Every |
| 13 | + * line is written, masked by redact() and timestamped in UTC; only the private |
| 14 | + * kinds below are dropped. Each UTC weekday has its own files: remote-mon.log |
| 15 | + * rotates at 1 MB into remote-mon.1.log and remote-mon.2.log. The first write |
| 16 | + * on a weekday whose file is over a day old (last week's) removes that day's |
| 17 | + * files first, so the log keeps at most 7 days × 3 files, 21 MB. |
| 18 | + */ |
| 19 | + |
| 20 | +export const DEVICE_LOG_MAX_BYTES = 1024 * 1024; |
| 21 | +/** How many files the log keeps per day: remote-<day>.log and its rotated older copies. */ |
| 22 | +export const DEVICE_LOG_FILES = 3; |
| 23 | +/** The UTC weekdays as the file names spell them; Date.getUTCDay() indexes it. */ |
| 24 | +const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; |
| 25 | +const DAY_MS = 24 * 60 * 60 * 1000; |
| 26 | +/** remote-mon.log, then remote-mon.1.log, remote-mon.2.log (older); `day` is the UTC weekday, 0 = Sunday. */ |
| 27 | +export const deviceLogName = (day: number, i: number) => `remote-${DAYS[day]}${i === 0 ? '' : `.${i}`}.log`; |
| 28 | +/** Every name the log can use: 7 days × DEVICE_LOG_FILES. */ |
| 29 | +export const deviceLogNames = (): string[] => |
| 30 | + DAYS.flatMap((_, day) => Array.from({ length: DEVICE_LOG_FILES }, (_, i) => deviceLogName(day, i))); |
| 31 | +const MAX_LINE_CHARS = 1000; |
| 32 | +/** A multi-line print (a stack, a config dump) is cut after this many lines. */ |
| 33 | +const MAX_LINES_PER_CALL = 40; |
| 34 | +/** Consecutive write failures after which the log gives up for this run. */ |
| 35 | +const MAX_WRITE_FAILURES = 5; |
| 36 | + |
| 37 | +export function getDeviceLogDir(): string { |
| 38 | + return path.join(os.homedir(), '.desktop-commander-device'); |
| 39 | +} |
| 40 | + |
| 41 | +// --- the private kinds: the only lines that don't reach the log as printed ------------------- |
| 42 | + |
| 43 | +/** " name" when there is one: an empty name, or one that isn't a name, is left out. */ |
| 44 | +function toolName(name: string | undefined): string { |
| 45 | + const trimmed = (name ?? '').trim(); |
| 46 | + return trimmed ? ` ${trimmed}` : ''; |
| 47 | +} |
| 48 | + |
| 49 | +/** |
| 50 | + * Tool calls keep the tool's name and the outcome; their arguments, metadata, |
| 51 | + * results and error details go. Matched on a print's first line by its fixed |
| 52 | + * words only, so an empty name, or one with a space, can't make a print slip |
| 53 | + * past (device.ts prints whatever name it gets). The whole print becomes this |
| 54 | + * one line, so a result's own lines never reach the log. null: the print goes |
| 55 | + * entirely. |
| 56 | + */ |
| 57 | +const TOOL_CALLS: Array<[RegExp, ((match: RegExpMatchArray) => string) | null]> = [ |
| 58 | + // "Received tool call <id>: <name> <args JSON> metadata: <JSON>": the name is |
| 59 | + // kept only if it looks like one, never the JSON that follows an empty name |
| 60 | + [/^Received tool call (\S*):(?: ([\w.-]+)(?=\s|$))?/, (m) => `Received tool call ${m[1]}:${toolName(m[2])}`], |
| 61 | + // Anchored on the words after the name, so any name (with a colon, too) is taken whole |
| 62 | + [/^Tool call (.*?) ?completed:?$/, (m) => `Tool call${toolName(m[1])} completed`], |
| 63 | + [/^Tool call (.*?) ?failed:/, (m) => `Tool call${toolName(m[1])} failed`], |
| 64 | + [/^Calling MCP tool: ?(.*?) ?args:/, (m) => `Calling MCP tool${m[1] ? `:${toolName(m[1])}` : ''}`], |
| 65 | + [/^Calling MCP tool:/, () => 'Calling MCP tool'], |
| 66 | + [/^Error executing tool ?([^:]*?):/, (m) => `Error executing tool${toolName(m[1])}`], |
| 67 | + [/^Tool call error details/, null], |
| 68 | +]; |
| 69 | + |
| 70 | +/** The ready block's "User: <email>", and the sign-in link in "Please visit: <link>". */ |
| 71 | +const PRIVATE_LINES = [/^User:\s/, /^Please visit:/]; |
| 72 | + |
| 73 | +/** The sign-in link and the code are each printed alone, on the line after these. */ |
| 74 | +const BEFORE_PRIVATE_LINE = [/Verify this device in your browser:$/, /Make sure the code matches:$/]; |
| 75 | + |
| 76 | +/** |
| 77 | + * Error objects are written as their name and message only. Printed whole they |
| 78 | + * carry their properties: a spawn error's `spawnargs` hold the session tokens |
| 79 | + * of the offline update script. |
| 80 | + */ |
| 81 | +function plain(arg: unknown): unknown { |
| 82 | + return arg instanceof Error ? `${arg.name}: ${arg.message}` : arg; |
| 83 | +} |
| 84 | + |
| 85 | +/** Whitespace, bullets, arrows, check marks, emoji and "[DEBUG]" before the text. */ |
| 86 | +const LEADING = /^(?:\s|\[DEBUG\]|[-–•→✓✗]|\p{Extended_Pictographic}|️|)+/u; |
| 87 | + |
| 88 | +function toolCall(text: string): { line: string | null } | null { |
| 89 | + for (const [pattern, keep] of TOOL_CALLS) { |
| 90 | + const match = text.match(pattern); |
| 91 | + if (match) return { line: keep ? redact(keep(match)).slice(0, MAX_LINE_CHARS) : null }; |
| 92 | + } |
| 93 | + return null; |
| 94 | +} |
| 95 | + |
| 96 | +/** |
| 97 | + * One line as the log keeps it: masked, or null for a private one. The report |
| 98 | + * runs every stored line through this again. |
| 99 | + */ |
| 100 | +export function cleanLine(line: string): string | null { |
| 101 | + const text = line.replace(LEADING, '').trimEnd(); |
| 102 | + if (!text) return null; |
| 103 | + const tool = toolCall(text); |
| 104 | + if (tool) return tool.line; |
| 105 | + if (PRIVATE_LINES.some((pattern) => pattern.test(text))) return null; |
| 106 | + return redact(text).slice(0, MAX_LINE_CHARS); |
| 107 | +} |
| 108 | + |
| 109 | +export interface DeviceLogOptions { |
| 110 | + /** The folder for the log files; ~/.desktop-commander-device by default. */ |
| 111 | + dir?: string; |
| 112 | + /** The clock (ms), for the timestamps and the day; Date.now by default. Tests set it. */ |
| 113 | + now?: () => number; |
| 114 | + /** The size at which a file rotates; DEVICE_LOG_MAX_BYTES by default. Tests set it. */ |
| 115 | + maxBytes?: number; |
| 116 | +} |
| 117 | + |
| 118 | +export class DeviceLog { |
| 119 | + private readonly dir: string; |
| 120 | + private readonly now: () => number; |
| 121 | + private readonly maxBytes: number; |
| 122 | + /** The UTC weekday being written; -1 before the first write. */ |
| 123 | + private day = -1; |
| 124 | + private size = -1; |
| 125 | + private failures = 0; |
| 126 | + /** The next printed line is the sign-in link or code. */ |
| 127 | + private skipNextLine = false; |
| 128 | + |
| 129 | + constructor(options: DeviceLogOptions = {}) { |
| 130 | + this.dir = options.dir ?? getDeviceLogDir(); |
| 131 | + this.now = options.now ?? Date.now; |
| 132 | + this.maxBytes = options.maxBytes ?? DEVICE_LOG_MAX_BYTES; |
| 133 | + } |
| 134 | + |
| 135 | + private file(i: number): string { |
| 136 | + return path.join(this.dir, deviceLogName(this.day, i)); |
| 137 | + } |
| 138 | + |
| 139 | + /** One console call's arguments: each of its lines, cleaned. */ |
| 140 | + record(args: unknown[]): void { |
| 141 | + if (this.failures >= MAX_WRITE_FAILURES) return; |
| 142 | + let lines: string[]; |
| 143 | + try { |
| 144 | + const [first, ...rest] = args.map(plain); |
| 145 | + lines = format(first, ...rest).split(/\r?\n/).filter((line) => line.trim() !== ''); |
| 146 | + } catch { |
| 147 | + return; |
| 148 | + } |
| 149 | + if (lines.length === 0) return; |
| 150 | + const tool = toolCall(lines[0].replace(LEADING, '').trimEnd()); |
| 151 | + if (tool) { |
| 152 | + if (tool.line) this.write(tool.line); |
| 153 | + return; |
| 154 | + } |
| 155 | + for (const line of lines.slice(0, MAX_LINES_PER_CALL)) { |
| 156 | + if (this.skipNextLine) { |
| 157 | + this.skipNextLine = false; |
| 158 | + continue; |
| 159 | + } |
| 160 | + const text = cleanLine(line); |
| 161 | + if (text === null) continue; |
| 162 | + if (BEFORE_PRIVATE_LINE.some((pattern) => pattern.test(text))) this.skipNextLine = true; |
| 163 | + this.write(text); |
| 164 | + } |
| 165 | + } |
| 166 | + |
| 167 | + /** Appends one line as is: callers pass text that is already masked. */ |
| 168 | + write(text: string): void { |
| 169 | + if (this.failures >= MAX_WRITE_FAILURES) return; |
| 170 | + const now = this.now(); |
| 171 | + const line = `${new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z')} ${text}\n`; |
| 172 | + const bytes = Buffer.byteLength(line); |
| 173 | + try { |
| 174 | + const day = new Date(now).getUTCDay(); |
| 175 | + if (day !== this.day) this.startDay(day, now); |
| 176 | + if (this.size < 0) { |
| 177 | + fs.mkdirSync(this.dir, { recursive: true }); |
| 178 | + this.size = fs.existsSync(this.file(0)) ? fs.statSync(this.file(0)).size : 0; |
| 179 | + } |
| 180 | + if (this.size > 0 && this.size + bytes > this.maxBytes) { |
| 181 | + this.rotate(); |
| 182 | + this.size = 0; |
| 183 | + } |
| 184 | + fs.appendFileSync(this.file(0), line, { mode: 0o600 }); |
| 185 | + this.size += bytes; |
| 186 | + this.failures = 0; |
| 187 | + } catch { |
| 188 | + // The log must never break the device: skip the line, give up after a few in a row |
| 189 | + this.failures++; |
| 190 | + this.size = -1; |
| 191 | + } |
| 192 | + } |
| 193 | + |
| 194 | + /** |
| 195 | + * The first write of a UTC weekday: if that weekday's file is over a day |
| 196 | + * old, it is last week's, and its files go before today's lines start. |
| 197 | + */ |
| 198 | + private startDay(day: number, now: number): void { |
| 199 | + const today = path.join(this.dir, deviceLogName(day, 0)); |
| 200 | + if (fs.existsSync(today) && now - fs.statSync(today).mtimeMs > DAY_MS) { |
| 201 | + for (let i = 0; i < DEVICE_LOG_FILES; i++) fs.rmSync(path.join(this.dir, deviceLogName(day, i)), { force: true }); |
| 202 | + } |
| 203 | + this.day = day; |
| 204 | + this.size = -1; |
| 205 | + } |
| 206 | + |
| 207 | + /** Drops the day's oldest file and moves each other one a step older: remote-mon.log becomes remote-mon.1.log. */ |
| 208 | + private rotate(): void { |
| 209 | + fs.rmSync(this.file(DEVICE_LOG_FILES - 1), { force: true }); |
| 210 | + for (let i = DEVICE_LOG_FILES - 2; i >= 0; i--) { |
| 211 | + if (fs.existsSync(this.file(i))) fs.renameSync(this.file(i), this.file(i + 1)); |
| 212 | + } |
| 213 | + } |
| 214 | +} |
| 215 | + |
| 216 | +const CONSOLE_METHODS = ['log', 'info', 'warn', 'error', 'debug'] as const; |
| 217 | + |
| 218 | +/** |
| 219 | + * Passes console output through the device log for this `remote` run. Each |
| 220 | + * method still does what it did (so a console.debug that `remote` silenced |
| 221 | + * stays silent), then the log records the call. Returns a function that puts |
| 222 | + * the console back. |
| 223 | + */ |
| 224 | +export function startDeviceLog(options: DeviceLogOptions = {}): () => void { |
| 225 | + const log = new DeviceLog(options); |
| 226 | + const originals = CONSOLE_METHODS.map((method) => [method, console[method]] as const); |
| 227 | + for (const [method, original] of originals) { |
| 228 | + console[method] = (...args: unknown[]) => { |
| 229 | + original.apply(console, args); |
| 230 | + log.record(args); |
| 231 | + }; |
| 232 | + } |
| 233 | + log.write(`Remote started (Desktop Commander ${VERSION}, Node ${process.versions.node}, ${process.platform})`); |
| 234 | + return () => { |
| 235 | + for (const [method, original] of originals) console[method] = original; |
| 236 | + }; |
| 237 | +} |
0 commit comments