failsnap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { Command } from "commander";
5
+ import { runAndSnap } from "./capture.js";
6
+ import { copyToClipboard, findClipboardTool } from "./clipboard.js";
7
+ import { buildDoctorReport } from "./doctor.js";
8
+ import { joinCommand } from "./runner.js";
9
+ import { latestReportPath } from "./report.js";
10
+ import { FAILSNAP_DIR, LATEST_REPORT_REL } from "./paths.js";
11
+ import { startShell } from "./shell.js";
12
+ const program = new Command();
13
+ program
14
+ .name("failsnap")
15
+ .description("Turn a failed dev command into an AI-ready debugging report.")
16
+ .version("0.1.0")
17
+ .enablePositionalOptions();
18
+ program
19
+ .command("run <command...>", { isDefault: true })
20
+ .description(`run a command; on failure, write ${LATEST_REPORT_REL} (default)`)
21
+ .passThroughOptions()
22
+ .allowUnknownOption()
23
+ .action(async (parts) => {
24
+ const result = await runAndSnap(joinCommand(parts));
25
+ process.exitCode = result.exitCode;
26
+ });
27
+ program
28
+ .command("shell")
29
+ .description("start a monitored interactive shell session")
30
+ .action(async () => {
31
+ await startShell();
32
+ });
33
+ program
34
+ .command("last")
35
+ .description("print the path of the latest report")
36
+ .action(() => {
37
+ const reportPath = latestReportPath();
38
+ if (reportPath) {
39
+ process.stdout.write(reportPath + "\n");
40
+ }
41
+ else {
42
+ process.stderr.write("[failsnap] no report found — run a failing command first\n");
43
+ process.exitCode = 1;
44
+ }
45
+ });
46
+ program
47
+ .command("copy")
48
+ .description("copy the latest report to the clipboard")
49
+ .action(async () => {
50
+ const reportPath = latestReportPath();
51
+ if (!reportPath) {
52
+ process.stderr.write("[failsnap] no report found — run a failing command first\n");
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+ const tool = findClipboardTool();
57
+ if (!tool) {
58
+ process.stdout.write("[failsnap] no clipboard tool found (looked for wl-copy, xclip, xsel)\n" +
59
+ `[failsnap] the report is at: ${reportPath}\n`);
60
+ return;
61
+ }
62
+ const ok = await copyToClipboard(fs.readFileSync(reportPath, "utf8"), tool);
63
+ if (ok) {
64
+ process.stdout.write(`[failsnap] report copied to clipboard (via ${tool.cmd})\n`);
65
+ }
66
+ else {
67
+ process.stdout.write(`[failsnap] could not copy with ${tool.cmd}\n` +
68
+ `[failsnap] the report is at: ${reportPath}\n`);
69
+ }
70
+ });
71
+ program
72
+ .command("doctor")
73
+ .description("show what would be detected and collected, without running anything")
74
+ .action(() => {
75
+ process.stdout.write(buildDoctorReport());
76
+ });
77
+ program
78
+ .command("clean")
79
+ .description(`remove the ${FAILSNAP_DIR} directory`)
80
+ .action(() => {
81
+ const dir = path.join(process.cwd(), FAILSNAP_DIR);
82
+ if (fs.existsSync(dir)) {
83
+ fs.rmSync(dir, { recursive: true, force: true });
84
+ process.stdout.write(`[failsnap] removed ${dir}\n`);
85
+ }
86
+ else {
87
+ process.stdout.write("[failsnap] nothing to clean\n");
88
+ }
89
+ });
90
+ program.parseAsync(process.argv);
@@ -0,0 +1,204 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { StringDecoder } from "node:string_decoder";
4
+ import { BoundedLineBuffer } from "./output-buffer.js";
5
+ /** Pick a real shell: $SHELL, else bash, else sh. */
6
+ export function resolveInteractiveShell() {
7
+ if (process.platform === "win32")
8
+ return process.env.ComSpec || "cmd.exe";
9
+ return process.env.SHELL || "/bin/bash";
10
+ }
11
+ /**
12
+ * A single long-lived shell process driven over stdin. Because every command
13
+ * runs in the *same* shell, `cd`, `export`, `source`, aliases and quoted paths
14
+ * all behave exactly as the user expects — no command-string parsing on our side.
15
+ *
16
+ * Each command is followed by a unique sentinel that echoes `$?` and `$PWD`, so
17
+ * we can detect completion, capture the real exit code, and track the cwd. The
18
+ * sentinel lines are stripped before anything is captured or displayed.
19
+ */
20
+ export class MonitoredShell {
21
+ child;
22
+ shell;
23
+ cwd;
24
+ closed = false;
25
+ alive = true;
26
+ nonce = randomBytes(8).toString("hex");
27
+ outMark = `__FAILSNAP_OUT_${this.nonce}__`;
28
+ errMark = `__FAILSNAP_ERR_${this.nonce}__`;
29
+ outRe;
30
+ constructor(opts = {}) {
31
+ this.shell = opts.shell ?? resolveInteractiveShell();
32
+ this.cwd = opts.cwd ?? process.cwd();
33
+ // outMark<TAB>exitcode<TAB>pwd (tab is delimiter; rare in paths)
34
+ this.outRe = new RegExp(`${this.outMark}\\t(\\d+)\\t([^\\n]*)`);
35
+ this.child = spawn(this.shell, [], {
36
+ cwd: this.cwd,
37
+ env: process.env,
38
+ stdio: ["pipe", "pipe", "pipe"],
39
+ });
40
+ this.child.on("exit", () => {
41
+ this.alive = false;
42
+ });
43
+ // A broken stdin pipe (shell already gone) must not crash failsnap.
44
+ this.child.stdin.on("error", () => { });
45
+ // Best-effort: let non-interactive bash expand aliases like an interactive one.
46
+ this.child.stdin.write("shopt -s expand_aliases 2>/dev/null\n");
47
+ }
48
+ /** False once the underlying shell process has exited. */
49
+ get isAlive() {
50
+ return this.alive;
51
+ }
52
+ /** Run one command to completion, streaming output live and capturing it. */
53
+ run(command, opts = {}) {
54
+ const start = Date.now();
55
+ const stdoutBuf = new BoundedLineBuffer();
56
+ const stderrBuf = new BoundedLineBuffer();
57
+ const outputBuf = new BoundedLineBuffer();
58
+ const outDecoder = new StringDecoder("utf8");
59
+ const errDecoder = new StringDecoder("utf8");
60
+ return new Promise((resolve) => {
61
+ // Shell already dead (e.g. a previous `exit`): don't hang.
62
+ if (!this.alive) {
63
+ resolve({
64
+ command,
65
+ exitCode: 1,
66
+ durationMs: 0,
67
+ stdout: "",
68
+ stderr: "",
69
+ output: "failsnap: shell session has ended\n",
70
+ signal: null,
71
+ cwd: this.cwd,
72
+ });
73
+ return;
74
+ }
75
+ let exitCode = null;
76
+ let signalName = null;
77
+ let newCwd = this.cwd;
78
+ let errDone = false;
79
+ let outCarry = "";
80
+ let errCarry = "";
81
+ let settled = false;
82
+ const tryFinish = () => {
83
+ if (settled || exitCode === null || !errDone)
84
+ return;
85
+ settled = true;
86
+ cleanup();
87
+ this.cwd = newCwd;
88
+ resolve({
89
+ command,
90
+ exitCode,
91
+ durationMs: Date.now() - start,
92
+ stdout: stdoutBuf.toString(),
93
+ stderr: stderrBuf.toString(),
94
+ output: outputBuf.toString(),
95
+ signal: signalName,
96
+ cwd: newCwd,
97
+ });
98
+ };
99
+ // Process complete lines from one stream; return leftover partial line.
100
+ const onStdout = (chunk) => {
101
+ outCarry += outDecoder.write(chunk);
102
+ let nl;
103
+ while ((nl = outCarry.indexOf("\n")) !== -1) {
104
+ const line = outCarry.slice(0, nl);
105
+ outCarry = outCarry.slice(nl + 1);
106
+ const m = this.outRe.exec(line);
107
+ if (m) {
108
+ // Emit any real output that shared the marker's line, then drop the marker.
109
+ const pre = line.slice(0, m.index);
110
+ if (pre) {
111
+ stdoutBuf.push(pre);
112
+ outputBuf.push(pre);
113
+ if (!opts.silent)
114
+ process.stdout.write(pre);
115
+ }
116
+ exitCode = Number(m[1]);
117
+ if (m[2])
118
+ newCwd = m[2];
119
+ tryFinish();
120
+ continue;
121
+ }
122
+ stdoutBuf.push(line + "\n");
123
+ outputBuf.push(line + "\n");
124
+ if (!opts.silent)
125
+ process.stdout.write(line + "\n");
126
+ }
127
+ };
128
+ const onStderr = (chunk) => {
129
+ errCarry += errDecoder.write(chunk);
130
+ let nl;
131
+ while ((nl = errCarry.indexOf("\n")) !== -1) {
132
+ const line = errCarry.slice(0, nl);
133
+ errCarry = errCarry.slice(nl + 1);
134
+ const idx = line.indexOf(this.errMark);
135
+ if (idx !== -1) {
136
+ const pre = line.slice(0, idx);
137
+ if (pre) {
138
+ stderrBuf.push(pre);
139
+ outputBuf.push(pre);
140
+ if (!opts.silent)
141
+ process.stderr.write(pre);
142
+ }
143
+ errDone = true;
144
+ tryFinish();
145
+ continue;
146
+ }
147
+ stderrBuf.push(line + "\n");
148
+ outputBuf.push(line + "\n");
149
+ if (!opts.silent)
150
+ process.stderr.write(line + "\n");
151
+ }
152
+ };
153
+ // If the shell exits before the sentinels (e.g. the command was `exit 7`),
154
+ // resolve with the shell's own exit code instead of hanging forever.
155
+ const onExit = (code, signal) => {
156
+ if (settled)
157
+ return;
158
+ if (exitCode === null)
159
+ exitCode = code ?? (signal ? 128 : 1);
160
+ if (signal)
161
+ signalName = signal;
162
+ errDone = true;
163
+ tryFinish();
164
+ };
165
+ const cleanup = () => {
166
+ this.child.stdout.off("data", onStdout);
167
+ this.child.stderr.off("data", onStderr);
168
+ this.child.off("exit", onExit);
169
+ };
170
+ this.child.stdout.on("data", onStdout);
171
+ this.child.stderr.on("data", onStderr);
172
+ this.child.once("exit", onExit);
173
+ // Run the command, then capture $? and $PWD before the sentinels reset them.
174
+ const driver = `${command}\n` +
175
+ `__rc=$?; printf '%s\\t%s\\t%s\\n' "${this.outMark}" "$__rc" "$PWD"; ` +
176
+ `printf '%s\\n' "${this.errMark}" 1>&2\n`;
177
+ this.child.stdin.write(driver);
178
+ });
179
+ }
180
+ get currentCwd() {
181
+ return this.cwd;
182
+ }
183
+ /** The shell binary backing this session. */
184
+ get shellPath() {
185
+ return this.shell;
186
+ }
187
+ /** Forward an interrupt to the running command. */
188
+ interrupt() {
189
+ if (!this.closed)
190
+ this.child.kill("SIGINT");
191
+ }
192
+ close() {
193
+ if (this.closed)
194
+ return;
195
+ this.closed = true;
196
+ try {
197
+ this.child.stdin.end("exit\n");
198
+ }
199
+ catch {
200
+ /* ignore */
201
+ }
202
+ this.child.kill();
203
+ }
204
+ }
@@ -0,0 +1,63 @@
1
+ // In-memory capture budget: how many lines we *retain* from a command's output
2
+ // (first 200 + last 800). Distinct from report.ts's MAX_REPORT_OUTPUT_LINES,
3
+ // which controls how many lines are *displayed* in the Markdown report.
4
+ export const DEFAULT_HEAD_LINES = 200;
5
+ export const DEFAULT_TAIL_LINES = 800;
6
+ /** Cap a single line so output with no newlines can't grow without bound. */
7
+ const MAX_LINE_CHARS = 64 * 1024;
8
+ /**
9
+ * Bounds captured output in memory: keeps the first `headLines` and the last
10
+ * `tailLines` lines, dropping the middle and recording how many lines were lost.
11
+ * Prevents OOM on commands that emit huge output (and keeps reports within an
12
+ * LLM-friendly size).
13
+ */
14
+ export class BoundedLineBuffer {
15
+ headLines;
16
+ tailLines;
17
+ head = [];
18
+ tail = [];
19
+ pending = "";
20
+ dropped = 0;
21
+ constructor(headLines = DEFAULT_HEAD_LINES, tailLines = DEFAULT_TAIL_LINES) {
22
+ this.headLines = headLines;
23
+ this.tailLines = tailLines;
24
+ }
25
+ push(text) {
26
+ this.pending += text;
27
+ let nl;
28
+ while ((nl = this.pending.indexOf("\n")) !== -1) {
29
+ this.addLine(this.pending.slice(0, nl));
30
+ this.pending = this.pending.slice(nl + 1);
31
+ }
32
+ // A single line longer than the cap is flushed eagerly to bound memory.
33
+ if (this.pending.length > MAX_LINE_CHARS) {
34
+ this.addLine(this.pending);
35
+ this.pending = "";
36
+ }
37
+ }
38
+ addLine(line) {
39
+ if (this.head.length < this.headLines) {
40
+ this.head.push(line);
41
+ return;
42
+ }
43
+ this.tail.push(line);
44
+ if (this.tail.length > this.tailLines) {
45
+ this.tail.shift();
46
+ this.dropped++;
47
+ }
48
+ }
49
+ /** Number of lines dropped from the middle. */
50
+ get droppedLines() {
51
+ return this.dropped;
52
+ }
53
+ toString() {
54
+ const lines = [...this.head];
55
+ if (this.dropped > 0) {
56
+ lines.push(`... (${this.dropped} lines truncated) ...`);
57
+ }
58
+ lines.push(...this.tail);
59
+ if (this.pending.length > 0)
60
+ lines.push(this.pending);
61
+ return lines.join("\n");
62
+ }
63
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,27 @@
1
+ import path from "node:path";
2
+ /**
3
+ * Single source of truth for FailSnap's on-disk layout. Every reference to a
4
+ * report path or filename — including user-facing messages in shell mode —
5
+ * must come from here so they can never drift out of sync.
6
+ *
7
+ * .failsnap/
8
+ * ├─ latest.md (LATEST_REPORT)
9
+ * ├─ latest.log (LATEST_LOG)
10
+ * └─ snapshots/ (SNAPSHOTS_DIR)
11
+ * └─ <timestamp>/{report.md, raw.log} (SNAPSHOT_REPORT / SNAPSHOT_LOG)
12
+ */
13
+ export const FAILSNAP_DIR = ".failsnap";
14
+ export const LATEST_REPORT = "latest.md";
15
+ export const LATEST_LOG = "latest.log";
16
+ export const SNAPSHOTS_DIR = "snapshots";
17
+ export const SNAPSHOT_REPORT = "report.md";
18
+ export const SNAPSHOT_LOG = "raw.log";
19
+ /** Filename of the report written before the latest.md/latest.log layout. */
20
+ export const LEGACY_REPORT = "report.md";
21
+ /** `.failsnap/latest.md` — for display in messages. */
22
+ export const LATEST_REPORT_REL = `${FAILSNAP_DIR}/${LATEST_REPORT}`;
23
+ /** `.failsnap/latest.log` — for display in messages. */
24
+ export const LATEST_LOG_REL = `${FAILSNAP_DIR}/${LATEST_LOG}`;
25
+ export function failsnapDir(cwd) {
26
+ return path.join(cwd, FAILSNAP_DIR);
27
+ }
package/dist/redact.js ADDED
@@ -0,0 +1,91 @@
1
+ const REDACTED = "[REDACTED]";
2
+ /** Matches any redaction placeholder, e.g. [REDACTED] or [REDACTED_PRIVATE_KEY]. */
3
+ const PLACEHOLDER = String.raw `\[REDACTED(?:_[A-Z_]+)?\]`;
4
+ /**
5
+ * Names that mark a key/value pair as sensitive when they appear in the key,
6
+ * e.g. OPENAI_API_KEY=..., "jwtSecret": "...", DATABASE_URL=...
7
+ */
8
+ const SENSITIVE_KEY = "(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?access[_-]?key|secret[_-]?key|client[_-]?secret|auth[_-]?token|secret|token|password|passwd|pwd|database[_-]?url|db[_-]?url|conn(?:ection)?[_-]?string|jwt|credential|private[_-]?key)";
9
+ /**
10
+ * Reject keys where the sensitive word is only a prefix of a benign field name
11
+ * (secretName, tokenCount, keyId, ...). These name/describe a secret rather than
12
+ * holding one, so redacting them is a false positive.
13
+ */
14
+ const BENIGN_SUFFIX = "(?![_-]?(?:name|names|count|id|ids|type|length|len|index|prefix|suffix|format|path|file|field|label|enabled|required)\\b)";
15
+ const KEY = `[A-Za-z0-9_-]*${SENSITIVE_KEY}${BENIGN_SUFFIX}[A-Za-z0-9_-]*`;
16
+ const RULES = [
17
+ // --- Multiline secrets (must run first, before per-line rules) ---
18
+ // PEM blocks: -----BEGIN [RSA|EC|OPENSSH|PGP ...] PRIVATE KEY----- ... -----END ...-----
19
+ {
20
+ pattern: /-----BEGIN [^-\n]*PRIVATE KEY[^-\n]*-----[\s\S]*?-----END [^-\n]*PRIVATE KEY[^-\n]*-----/g,
21
+ replacement: "[REDACTED_PRIVATE_KEY]",
22
+ },
23
+ // --- key/value pairs ---
24
+ // key = "value" / key: 'value' (JSON, YAML, TS/JS config, quoted env).
25
+ // (?!PLACEHOLDER) keeps the pass idempotent and preserves typed labels.
26
+ {
27
+ pattern: new RegExp(`((?:["']?)${KEY}(?:["']?)\\s*[=:]\\s*)(["'])(?!${PLACEHOLDER})[^"'\\n]*\\2`, "gi"),
28
+ replacement: `$1$2${REDACTED}$2`,
29
+ },
30
+ // KEY=value unquoted (env-file / shell style)
31
+ {
32
+ pattern: new RegExp(`(${KEY}\\s*=\\s*)(?!${PLACEHOLDER})([^\\s"']+)`, "gi"),
33
+ replacement: `$1${REDACTED}`,
34
+ },
35
+ // .npmrc auth tokens: //registry.example.com/:_authToken=xxx and _authToken=xxx
36
+ {
37
+ pattern: /((?:\/\/[^\s]*:)?_authToken\s*=\s*)(?!\[REDACTED)\S+/gi,
38
+ replacement: `$1${REDACTED}`,
39
+ },
40
+ // aws_secret_access_key context, 40-char value (covers odd quoting/spacing)
41
+ {
42
+ pattern: /(aws_secret_access_key\s*[=:]\s*)(?!\[REDACTED)["']?[A-Za-z0-9/+]{40}["']?/gi,
43
+ replacement: `$1${REDACTED}`,
44
+ },
45
+ // --- Authorization headers ---
46
+ { pattern: /(bearer\s+)[A-Za-z0-9\-._~+/]+=*/gi, replacement: `$1${REDACTED}` },
47
+ { pattern: /(basic\s+)[A-Za-z0-9+/]{8,}=*/gi, replacement: `$1${REDACTED}` },
48
+ // --- Known token shapes (no surrounding key needed) ---
49
+ // OpenAI / Anthropic style keys (sk-..., sk-ant-...)
50
+ { pattern: /\bsk-[A-Za-z0-9_-]{10,}\b/g, replacement: REDACTED },
51
+ // Stripe live secret/restricted keys (sk_live_, rk_live_)
52
+ { pattern: /\b(?:sk|rk)_live_[0-9A-Za-z]{10,}\b/g, replacement: REDACTED },
53
+ // GitHub tokens
54
+ { pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, replacement: REDACTED },
55
+ { pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: REDACTED },
56
+ // GitLab personal access tokens
57
+ { pattern: /\bglpat-[A-Za-z0-9_-]{10,}\b/g, replacement: REDACTED },
58
+ // npm tokens
59
+ { pattern: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: REDACTED },
60
+ // Slack tokens
61
+ { pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, replacement: REDACTED },
62
+ // Google API keys
63
+ { pattern: /\bAIza[0-9A-Za-z\-_]{35}\b/g, replacement: REDACTED },
64
+ // SendGrid API keys
65
+ { pattern: /\bSG\.[\w-]{16,}\.[\w-]{16,}\b/g, replacement: REDACTED },
66
+ // AWS access key ids
67
+ { pattern: /\bAKIA[0-9A-Z]{16}\b/g, replacement: REDACTED },
68
+ // JWTs (three base64url segments starting with eyJ)
69
+ {
70
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b/g,
71
+ replacement: REDACTED,
72
+ },
73
+ // --- Credentials embedded in URLs: scheme://user:pass@host ---
74
+ {
75
+ pattern: /\b([a-z][a-z0-9+.-]*:\/\/)([^\s:@/]+):([^\s@/]+)@/gi,
76
+ replacement: `$1$2:${REDACTED}@`,
77
+ },
78
+ ];
79
+ /**
80
+ * Redact API keys, tokens, passwords, private keys and connection strings from
81
+ * text. Best-effort and pattern-based: it catches the well-known secret shapes
82
+ * and `sensitiveKey = value` pairs, but cannot guarantee removal of arbitrary
83
+ * high-entropy strings. Running it twice yields the same result (idempotent).
84
+ */
85
+ export function redact(text) {
86
+ let result = text;
87
+ for (const rule of RULES) {
88
+ result = result.replace(rule.pattern, rule.replacement);
89
+ }
90
+ return result;
91
+ }
package/dist/report.js ADDED
@@ -0,0 +1,185 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { projectTypeLabel } from "./detect.js";
4
+ import { redact } from "./redact.js";
5
+ import { stripAnsi } from "./ansi.js";
6
+ import { LATEST_LOG, LATEST_REPORT, LEGACY_REPORT, FAILSNAP_DIR, SNAPSHOT_LOG, SNAPSHOT_REPORT, SNAPSHOTS_DIR, } from "./paths.js";
7
+ // Re-export so existing importers keep working through report.js.
8
+ export { FAILSNAP_DIR, LATEST_REPORT, LATEST_LOG } from "./paths.js";
9
+ /** Strip terminal control codes, then redact secrets, before anything is shown. */
10
+ function sanitize(output) {
11
+ return redact(stripAnsi(output));
12
+ }
13
+ /** Human-readable interpretation of a fatal signal. */
14
+ function signalNote(signal) {
15
+ switch (signal) {
16
+ case "SIGKILL":
17
+ return "Terminated by SIGKILL — force-killed, often by the OS out-of-memory (OOM) killer. Check memory usage.";
18
+ case "SIGSEGV":
19
+ return "Terminated by SIGSEGV — segmentation fault (a native-level crash).";
20
+ case "SIGABRT":
21
+ return "Terminated by SIGABRT — the process aborted itself (assertion/fatal error).";
22
+ case "SIGINT":
23
+ return "Interrupted by SIGINT (Ctrl-C).";
24
+ case "SIGTERM":
25
+ return "Terminated by SIGTERM (a graceful termination request).";
26
+ case "SIGFPE":
27
+ return "Terminated by SIGFPE — arithmetic error (e.g. divide by zero).";
28
+ default:
29
+ return `Terminated by ${signal}.`;
30
+ }
31
+ }
32
+ // How many trailing output lines to *display* in the Markdown report. Distinct
33
+ // from output-buffer.ts's DEFAULT_HEAD_LINES/DEFAULT_TAIL_LINES, which bound how
34
+ // much output is *retained* in memory; the full retained log is saved to disk.
35
+ const MAX_REPORT_OUTPUT_LINES = 200;
36
+ function timestampSlug(date = new Date()) {
37
+ const p = (n) => String(n).padStart(2, "0");
38
+ return (`${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}` +
39
+ `_${p(date.getHours())}-${p(date.getMinutes())}-${p(date.getSeconds())}`);
40
+ }
41
+ /**
42
+ * Path of the most recent report, or null if none exists.
43
+ * Falls back to the legacy `report.md` written by pre-0.1 versions.
44
+ */
45
+ export function latestReportPath(cwd = process.cwd()) {
46
+ const dir = path.join(cwd, FAILSNAP_DIR);
47
+ for (const name of [LATEST_REPORT, LEGACY_REPORT]) {
48
+ const candidate = path.join(dir, name);
49
+ if (fs.existsSync(candidate))
50
+ return candidate;
51
+ }
52
+ return null;
53
+ }
54
+ function formatDuration(ms) {
55
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
56
+ }
57
+ function fenceFor(content) {
58
+ // Grow the fence if the content itself contains backtick fences.
59
+ let fence = "```";
60
+ while (content.includes(fence))
61
+ fence += "`";
62
+ return fence;
63
+ }
64
+ function codeBlock(content, lang = "") {
65
+ const fence = fenceFor(content);
66
+ return `${fence}${lang}\n${content.replace(/\n$/, "")}\n${fence}`;
67
+ }
68
+ function languageFor(file) {
69
+ if (file.endsWith(".json"))
70
+ return "json";
71
+ if (file.endsWith(".toml"))
72
+ return "toml";
73
+ if (file.endsWith(".xml"))
74
+ return "xml";
75
+ if (file.endsWith(".gradle") || file.endsWith(".kts"))
76
+ return "groovy";
77
+ if (/\.(ts|mts|cts)$/.test(file))
78
+ return "ts";
79
+ if (/\.(js|mjs|cjs)$/.test(file))
80
+ return "js";
81
+ return "";
82
+ }
83
+ function tailLines(text, max) {
84
+ const lines = text.replace(/\n$/, "").split("\n");
85
+ if (lines.length <= max)
86
+ return { text: lines.join("\n"), truncated: 0 };
87
+ return {
88
+ text: lines.slice(-max).join("\n"),
89
+ truncated: lines.length - max,
90
+ };
91
+ }
92
+ function envTable(env) {
93
+ const rows = [
94
+ ["OS", env.os],
95
+ ["Shell", env.shell],
96
+ ["Working directory", env.cwd],
97
+ ["Node", env.node ?? "not found"],
98
+ ["npm", env.npm ?? "not found"],
99
+ ["pnpm", env.pnpm ?? "not found"],
100
+ ["Python", env.python ?? "not found"],
101
+ ["Java", env.java ?? "not found"],
102
+ ["Git branch", env.gitBranch ?? "not a git repository"],
103
+ [
104
+ "Git status",
105
+ env.gitDirty === null ? "n/a" : env.gitDirty ? "dirty (uncommitted changes)" : "clean",
106
+ ],
107
+ ];
108
+ return [
109
+ "| | |",
110
+ "|---|---|",
111
+ ...rows.map(([k, v]) => `| **${k}** | ${v.replace(/\|/g, "\\|")} |`),
112
+ ].join("\n");
113
+ }
114
+ export function buildReportMarkdown(input) {
115
+ const safeOutput = sanitize(input.output);
116
+ const { text: shownOutput, truncated } = tailLines(safeOutput || "(no output)", MAX_REPORT_OUTPUT_LINES);
117
+ const projectLine = input.projectTypes.length > 0
118
+ ? input.projectTypes.map(projectTypeLabel).join(", ")
119
+ : "unknown";
120
+ const sections = [];
121
+ sections.push("# FailSnap Report");
122
+ sections.push(`Generated: ${new Date().toISOString()}`);
123
+ sections.push("## Failed Command");
124
+ sections.push([
125
+ `- **Command:** \`${input.command}\``,
126
+ `- **Exit code:** ${input.exitCode}`,
127
+ ...(input.signal ? [`- **Signal:** ${signalNote(input.signal)}`] : []),
128
+ `- **Duration:** ${formatDuration(input.durationMs)}`,
129
+ `- **Directory:** \`${input.cwd}\``,
130
+ ].join("\n"));
131
+ sections.push("## Environment");
132
+ sections.push(envTable(input.env));
133
+ sections.push("## Project");
134
+ sections.push(`Detected project type: **${projectLine}**`);
135
+ sections.push("## Command Output");
136
+ if (truncated > 0) {
137
+ sections.push(`_Showing the last ${MAX_REPORT_OUTPUT_LINES} lines (${truncated} earlier lines omitted; full output in \`${FAILSNAP_DIR}/${LATEST_LOG}\`)._`);
138
+ }
139
+ sections.push(codeBlock(shownOutput, "text"));
140
+ if (input.files.length > 0) {
141
+ sections.push("## Relevant Files");
142
+ for (const file of input.files) {
143
+ sections.push(`### \`${file.path}\``);
144
+ sections.push(codeBlock(file.content, languageFor(file.path)));
145
+ }
146
+ }
147
+ sections.push("## AI Debugging Prompt");
148
+ sections.push([
149
+ "Paste this entire report into ChatGPT, Claude, Codex, or Cursor together with the request below.",
150
+ "",
151
+ "> The command above failed. Using the captured output, environment, and project files in this report, please provide:",
152
+ ">",
153
+ "> 1. **Root cause** — what exactly went wrong and why.",
154
+ "> 2. **Exact fix** — the precise change that resolves it.",
155
+ "> 3. **Commands to run** — every command, in order.",
156
+ "> 4. **Files to edit** — each file and the exact edit to make.",
157
+ "> 5. **Verification steps** — how to confirm the problem is fixed.",
158
+ ].join("\n"));
159
+ return sections.join("\n\n") + "\n";
160
+ }
161
+ /**
162
+ * Write the report for a failed command:
163
+ *
164
+ * .failsnap/latest.md / latest.log — always the most recent failure
165
+ * .failsnap/snapshots/<timestamp>/report.md — kept permanently, never overwritten
166
+ *
167
+ * Everything is redacted before it touches disk.
168
+ */
169
+ export function generateReport(input) {
170
+ const dir = path.join(input.cwd, FAILSNAP_DIR);
171
+ const base = path.join(dir, SNAPSHOTS_DIR, timestampSlug());
172
+ let snapshotDir = base;
173
+ for (let n = 2; fs.existsSync(snapshotDir); n++)
174
+ snapshotDir = `${base}_${n}`;
175
+ fs.mkdirSync(snapshotDir, { recursive: true });
176
+ const markdown = buildReportMarkdown(input);
177
+ const log = sanitize(input.output);
178
+ fs.writeFileSync(path.join(snapshotDir, SNAPSHOT_REPORT), markdown, "utf8");
179
+ fs.writeFileSync(path.join(snapshotDir, SNAPSHOT_LOG), log, "utf8");
180
+ const reportPath = path.join(dir, LATEST_REPORT);
181
+ const rawLogPath = path.join(dir, LATEST_LOG);
182
+ fs.writeFileSync(reportPath, markdown, "utf8");
183
+ fs.writeFileSync(rawLogPath, log, "utf8");
184
+ return { dir, reportPath, rawLogPath, snapshotDir };
185
+ }