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/runner.js ADDED
@@ -0,0 +1,99 @@
1
+ import { spawn } from "node:child_process";
2
+ import os from "node:os";
3
+ import { StringDecoder } from "node:string_decoder";
4
+ import { BoundedLineBuffer } from "./output-buffer.js";
5
+ const SIGNALS = os.constants.signals;
6
+ /** Exit code for a process killed by a signal, by the 128+N convention. */
7
+ function signalExitCode(signal) {
8
+ const num = SIGNALS[signal];
9
+ return num ? 128 + num : 1;
10
+ }
11
+ export function defaultShell() {
12
+ if (process.platform === "win32")
13
+ return process.env.ComSpec || "cmd.exe";
14
+ return process.env.SHELL || "/bin/sh";
15
+ }
16
+ /** Quote argv parts back into a single shell command string. */
17
+ export function joinCommand(parts) {
18
+ return parts
19
+ .map((p) => /[^\w@%+=:,./-]/.test(p) ? `'${p.replace(/'/g, `'\\''`)}'` : p)
20
+ .join(" ");
21
+ }
22
+ /**
23
+ * Run a command through the user's shell, streaming stdout/stderr live
24
+ * while capturing everything for the report.
25
+ */
26
+ export function runCommand(command, opts = {}) {
27
+ const shell = opts.shell ?? defaultShell();
28
+ const isWin = process.platform === "win32";
29
+ const args = isWin ? ["/d", "/s", "/c", command] : ["-c", command];
30
+ const start = Date.now();
31
+ return new Promise((resolve) => {
32
+ const child = spawn(shell, args, {
33
+ cwd: opts.cwd ?? process.cwd(),
34
+ env: process.env,
35
+ stdio: ["inherit", "pipe", "pipe"],
36
+ });
37
+ // Bounded buffers keep memory flat on huge output. Separate StringDecoders
38
+ // per stream so a multibyte char split across chunk boundaries isn't
39
+ // corrupted (live streaming still writes raw bytes to the terminal).
40
+ const stdoutBuf = new BoundedLineBuffer();
41
+ const stderrBuf = new BoundedLineBuffer();
42
+ const outputBuf = new BoundedLineBuffer();
43
+ const stdoutDecoder = new StringDecoder("utf8");
44
+ const stderrDecoder = new StringDecoder("utf8");
45
+ child.stdout.on("data", (chunk) => {
46
+ const text = stdoutDecoder.write(chunk);
47
+ stdoutBuf.push(text);
48
+ outputBuf.push(text);
49
+ if (!opts.silent)
50
+ process.stdout.write(chunk);
51
+ });
52
+ child.stderr.on("data", (chunk) => {
53
+ const text = stderrDecoder.write(chunk);
54
+ stderrBuf.push(text);
55
+ outputBuf.push(text);
56
+ if (!opts.silent)
57
+ process.stderr.write(chunk);
58
+ });
59
+ // Forward Ctrl+C to the child instead of killing failsnap itself.
60
+ const onSigint = () => child.kill("SIGINT");
61
+ process.on("SIGINT", onSigint);
62
+ const finish = (exitCode, signal = null) => {
63
+ process.removeListener("SIGINT", onSigint);
64
+ // Flush any bytes held back by the decoders for an incomplete sequence.
65
+ const stdoutTail = stdoutDecoder.end();
66
+ const stderrTail = stderrDecoder.end();
67
+ if (stdoutTail) {
68
+ stdoutBuf.push(stdoutTail);
69
+ outputBuf.push(stdoutTail);
70
+ }
71
+ if (stderrTail) {
72
+ stderrBuf.push(stderrTail);
73
+ outputBuf.push(stderrTail);
74
+ }
75
+ resolve({
76
+ command,
77
+ exitCode,
78
+ durationMs: Date.now() - start,
79
+ stdout: stdoutBuf.toString(),
80
+ stderr: stderrBuf.toString(),
81
+ output: outputBuf.toString(),
82
+ signal,
83
+ });
84
+ };
85
+ child.on("error", (err) => {
86
+ const message = `failsnap: failed to start command: ${err.message}\n`;
87
+ stderrBuf.push(message);
88
+ outputBuf.push(message);
89
+ if (!opts.silent)
90
+ process.stderr.write(message);
91
+ finish(127);
92
+ });
93
+ child.on("close", (code, signal) => {
94
+ if (code !== null)
95
+ return finish(code, null);
96
+ finish(signal ? signalExitCode(signal) : 1, signal ?? null);
97
+ });
98
+ });
99
+ }
package/dist/shell.js ADDED
@@ -0,0 +1,101 @@
1
+ import os from "node:os";
2
+ import readline from "node:readline";
3
+ import { snapshotFailure } from "./capture.js";
4
+ import { MonitoredShell } from "./monitored-shell.js";
5
+ import { LATEST_REPORT_REL } from "./paths.js";
6
+ const PREFIX = "[failsnap]";
7
+ function shortCwd(cwd) {
8
+ const home = os.homedir();
9
+ return cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
10
+ }
11
+ /**
12
+ * Line reader backed by a queue so that lines arriving while a command is
13
+ * running (pasted blocks, piped stdin) are not dropped, unlike rl.question.
14
+ */
15
+ function createLineReader(rl) {
16
+ const queue = [];
17
+ let pending = null;
18
+ let closed = false;
19
+ rl.on("line", (line) => {
20
+ if (pending) {
21
+ const resolve = pending;
22
+ pending = null;
23
+ resolve(line);
24
+ }
25
+ else {
26
+ queue.push(line);
27
+ }
28
+ });
29
+ rl.on("close", () => {
30
+ closed = true;
31
+ if (pending) {
32
+ const resolve = pending;
33
+ pending = null;
34
+ resolve(null);
35
+ }
36
+ });
37
+ return () => {
38
+ if (queue.length > 0)
39
+ return Promise.resolve(queue.shift());
40
+ if (closed)
41
+ return Promise.resolve(null);
42
+ return new Promise((resolve) => {
43
+ pending = resolve;
44
+ });
45
+ };
46
+ }
47
+ /**
48
+ * Monitored shell mode: a single long-lived shell runs every command the user
49
+ * types, so `cd`, `export`, `source` and aliases persist across commands with
50
+ * no command-string parsing on our side. Output is streamed live and captured;
51
+ * failing commands automatically produce a report under `.failsnap/`.
52
+ */
53
+ export async function startShell() {
54
+ const shell = new MonitoredShell();
55
+ process.stdout.write(`${PREFIX} monitored shell started (${shell.shellPath})\n` +
56
+ `${PREFIX} failed commands are captured to ${LATEST_REPORT_REL} automatically\n` +
57
+ `${PREFIX} cd / export / source / aliases all persist across commands\n` +
58
+ `${PREFIX} type "exit" to leave\n`);
59
+ const rl = readline.createInterface({
60
+ input: process.stdin,
61
+ output: process.stdout,
62
+ });
63
+ // Ctrl+C at the prompt clears the current line instead of killing the shell.
64
+ rl.on("SIGINT", () => {
65
+ rl.write(null, { ctrl: true, name: "u" });
66
+ process.stdout.write("^C\n");
67
+ rl.prompt(true);
68
+ });
69
+ const nextLine = createLineReader(rl);
70
+ for (;;) {
71
+ rl.setPrompt(`${PREFIX} ${shortCwd(shell.currentCwd)} $ `);
72
+ rl.prompt(true);
73
+ const line = await nextLine();
74
+ if (line === null)
75
+ break; // stdin closed (Ctrl+D)
76
+ const command = line.trim();
77
+ if (command.length === 0)
78
+ continue;
79
+ if (command === "exit" || command === "quit")
80
+ break;
81
+ // Hand the terminal to the running command; Ctrl+C interrupts it.
82
+ rl.pause();
83
+ const onSigint = () => shell.interrupt();
84
+ process.on("SIGINT", onSigint);
85
+ try {
86
+ const result = await shell.run(command);
87
+ snapshotFailure(result, { cwd: result.cwd });
88
+ }
89
+ finally {
90
+ process.removeListener("SIGINT", onSigint);
91
+ rl.resume();
92
+ }
93
+ if (!shell.isAlive) {
94
+ process.stdout.write(`${PREFIX} shell session ended\n`);
95
+ break;
96
+ }
97
+ }
98
+ shell.close();
99
+ rl.close();
100
+ process.stdout.write(`${PREFIX} bye\n`);
101
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "failsnap",
3
+ "version": "0.1.0",
4
+ "description": "Turn a failed dev command into an AI-ready debugging report.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/MuyajakiMuyaho/failsnap.git"
10
+ },
11
+ "homepage": "https://github.com/MuyajakiMuyaho/failsnap#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/MuyajakiMuyaho/failsnap/issues"
14
+ },
15
+ "bin": {
16
+ "failsnap": "dist/index.js"
17
+ },
18
+ "main": "dist/index.js",
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc",
29
+ "dev": "tsx src/index.ts",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
32
+ "prepublishOnly": "npm run build"
33
+ },
34
+ "keywords": [
35
+ "cli",
36
+ "debugging",
37
+ "ai",
38
+ "error-report",
39
+ "developer-tools"
40
+ ],
41
+ "dependencies": {
42
+ "commander": "^12.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^20.14.0",
46
+ "tsx": "^4.16.0",
47
+ "typescript": "^5.5.0",
48
+ "vitest": "^2.0.0"
49
+ }
50
+ }