Loading files
import { readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs'
import { join } from 'node:path'
import { writeZip, type ZipEntry } from './zipWriter.js'
/**
* The file a person sends when the dial got stuck: the last week of every log this product writes, in
* one zip, secrets blanked.
*
* app-YYYYMMDD.log, cli-YYYYMMDD.log the desktop app's own log and its CLI transcript
* dial-YYYYMMDD.log the dial's console + the daemon's cable events (dialLog.ts)
* harness.log (tail) the daemon's console — the last few MB, which is where today is
* README.txt when, which version, what was included
*
* Shared by `harness logs export` and the desktop app's Settings ▸ Debug button, so the two produce
* the same bundle; the app runs this through the CLI rather than carrying a second copy.
*/
export interface LogBundleOptions {
/** `~/.harness/logs` — the dated files. */
logsDir: string
/** `~/.harness/cli/data` — where `harness.log` is. */
dataDir: string
/** Days of dated logs to include, counting today. Default 7. */
days?: number
/** How much of the end of `harness.log` to take. Default 5 MB. */
harnessLogTailBytes?: number
now?: Date
version: string
/** Free text for the README — `harness status` output, the app's version, anything else known. */
notes?: string[]
/** Runs over every text file before it is written. */
redact: (text: string) => string
}
const DATED = /^(app|cli|dial)-(\d{8})\.log$/
function ymd(d: Date): string {
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`
}
/** `harness-logs-YYYYMMDD-HHMM.zip` — sortable, and says what it is in Finder. */
export function bundleFileName(now: Date): string {
return `harness-logs-${ymd(now)}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}.zip`
}
function readTail(path: string, bytes: number): Buffer {
const size = statSync(path).size
const from = Math.max(0, size - bytes)
const fd = openSync(path, 'r')
try {
const out = Buffer.alloc(size - from)
readSync(fd, out, 0, out.length, from)
return out
} finally {
closeSync(fd)
}
}
/** The bundle as bytes, plus what went into it — for the README and for the caller's own message. */
export function buildLogBundle(opts: LogBundleOptions): { zip: Buffer; included: string[] } {
const now = opts.now ?? new Date()
const days = opts.days ?? 7
const cutoff = ymd(new Date(now.getTime() - (days - 1) * 86_400_000))
const entries: ZipEntry[] = []
const included: string[] = []
let names: string[] = []
try { names = readdirSync(opts.logsDir).sort() } catch { /* no logs directory yet */ }
for (const name of names) {
const m = DATED.exec(name)
if (!m || m[2] < cutoff) continue
const path = join(opts.logsDir, name)
try {
const text = readFileSync(path, 'utf8')
entries.push({ name: `logs/${name}`, data: Buffer.from(opts.redact(text)), mtime: statSync(path).mtime })
included.push(name)
} catch { /* unreadable: skipped, said in the README below */ }
}
const harnessLog = join(opts.dataDir, 'harness.log')
try {
const tail = readTail(harnessLog, opts.harnessLogTailBytes ?? 5 * 1024 * 1024)
const text = tail.toString('utf8')
entries.push({ name: 'harness.log', data: Buffer.from(opts.redact(text)), mtime: statSync(harnessLog).mtime })
included.push(`harness.log (last ${Math.round(tail.length / 1024)} KB)`)
} catch { /* no daemon log */ }
const readme = [
`Harness log bundle`,
`exported: ${now.toISOString()}`,
`cli: v${opts.version}`,
`window: last ${days} day${days === 1 ? '' : 's'} (from ${cutoff})`,
...(opts.notes ?? []),
``,
`included:`,
...(included.length ? included.map((n) => ` ${n}`) : [' (nothing — no logs were found)']),
``,
`Secrets (tokens, keys, passwords, signed URLs) were blanked before writing.`,
`dial-*.log: the dial's own console (I/W/E lines, ticks since boot) interleaved with the daemon's`,
`[daemon] cable events. 'alive up=' once a minute is the UI task's heartbeat; a gap in it while`,
`[daemon] lines continue is a wedged UI task. 'boot: reset reason' after a session-up says why the`,
`dial last rebooted; 'last:' lines are its log from just before a crash.`,
].join('\n')
entries.unshift({ name: 'README.txt', data: Buffer.from(readme + '\n'), mtime: now })
return { zip: writeZip(entries), included }
}
/** The same denylist the desktop app's `redactSecretsInText` applies — keep the two in step. */
export function redactSecretsInText(input: string): string {
return input
.replace(/(Bearer\s+)[A-Za-z0-9._-]{8,}/gi, '$1<redacted>')
.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, 'sk-<redacted>')
.replace(/(["']?\b\w*(?:token|secret|password|passphrase|credential|api[_-]?key)\w*\b["']?\s*[:=]\s*["']?)([^\s"',}]{6,})/gi, '$1<redacted>')
.replace(/([?&][^=\s&]*(?:token|key|secret|sig|signature)[^=\s&]*=)[^\s&]+/gi, '$1<redacted>')
}
264df2ec6This file is pinned to the selected release. Download the original to open it in your design tool.