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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FailSnap contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,207 @@
1
+ # FailSnap
2
+
3
+ > Capture a failed dev command. Paste it into any AI. Get a real answer.
4
+
5
+ When something breaks, you waste time copy-pasting terminal output, hunting down your Node version, and re-explaining your project setup — every single time. FailSnap does all of that automatically in one command.
6
+
7
+ ![FailSnap demo](./demo.gif)
8
+
9
+ ---
10
+
11
+ ## What it does
12
+
13
+ Run any command through FailSnap:
14
+
15
+ ```bash
16
+ failsnap npm run dev
17
+ ```
18
+
19
+ If the command fails, FailSnap saves a structured Markdown report to `.failsnap/latest.md` — ready to paste directly into Claude, ChatGPT, Cursor, or any AI.
20
+
21
+ If the command succeeds, nothing happens. Zero noise.
22
+
23
+ The report includes everything an AI needs to actually help you:
24
+
25
+ - The full command output (stdout + stderr)
26
+ - Your OS, shell, Node / npm / Python / Java versions
27
+ - Git branch and status
28
+ - Relevant config files (`package.json`, `tsconfig.json`, `pyproject.toml`, etc.)
29
+ - Project type auto-detected (Node, Python, Java, …)
30
+ - **Secrets automatically masked** before anything is written to disk
31
+
32
+ ---
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ npm install -g failsnap
38
+ ```
39
+
40
+ Requires Node.js 18+. Works on Linux, macOS, and Windows.
41
+
42
+ ---
43
+
44
+ ## Usage
45
+
46
+ ### One-shot capture
47
+
48
+ Prefix any command with `failsnap`:
49
+
50
+ ```bash
51
+ failsnap npm run dev
52
+ failsnap python main.py
53
+ failsnap cargo build
54
+ ```
55
+
56
+ ### Paste to AI immediately
57
+
58
+ ```bash
59
+ failsnap copy # copy the latest report to your clipboard
60
+ ```
61
+
62
+ ### Watch mode
63
+
64
+ For longer sessions, start a persistent shell that captures every failing
65
+ command automatically — no need to prefix each one with `failsnap`:
66
+
67
+ ```bash
68
+ failsnap shell
69
+ ```
70
+
71
+ ```console
72
+ $ failsnap shell
73
+ [failsnap] monitored shell started (/bin/bash)
74
+ [failsnap] failed commands are captured to .failsnap/latest.md automatically
75
+ [failsnap] cd / export / source / aliases all persist across commands
76
+ [failsnap] type "exit" to leave
77
+ [failsnap] ~/project $ npm test
78
+
79
+ ... test output ...
80
+
81
+ [failsnap] command failed (exit code 1)
82
+ [failsnap] report: ~/project/.failsnap/latest.md
83
+ [failsnap] raw log: ~/project/.failsnap/latest.log
84
+ [failsnap] snapshot: ~/project/.failsnap/snapshots/2026-06-12_01-50-09
85
+ [failsnap] ~/project $ export API_BASE=http://localhost:3000
86
+ [failsnap] ~/project $ exit
87
+ [failsnap] bye
88
+ ```
89
+
90
+ The shell keeps your `cd`, `export`, `source`, and `alias` state across
91
+ commands — just like your normal terminal. Successful commands run normally;
92
+ only failures are snapshotted.
93
+
94
+ ### Other commands
95
+
96
+ ```bash
97
+ failsnap last # print the path of the latest report
98
+ failsnap doctor # preview what would be collected, without running anything
99
+ failsnap clean # delete the .failsnap/ directory (reports + snapshots)
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Why not just copy-paste the terminal output?
105
+
106
+ | | Copy-paste | FailSnap |
107
+ |---|---|---|
108
+ | Command output | ✅ | ✅ |
109
+ | Runtime versions | ❌ manual | ✅ auto |
110
+ | Git state | ❌ manual | ✅ auto |
111
+ | Config files | ❌ manual | ✅ auto |
112
+ | Secret masking | ❌ none | ✅ best-effort |
113
+ | Repeatable | ❌ | ✅ |
114
+
115
+ The difference is context. An AI that knows your Node version, your `tsconfig`, and your git branch gives a different answer than one that just sees a stack trace.
116
+
117
+ ---
118
+
119
+ ## Security
120
+
121
+ FailSnap is fully local. No server, no account, no telemetry, no network.
122
+
123
+ Secrets are masked before anything is written to disk using pattern matching for common formats: API keys, tokens, PEM private key blocks, Bearer headers, URL credentials, and more.
124
+
125
+ **This is best-effort masking, not a guarantee.** Pattern-based detection can miss novel or custom secret formats. Before sharing a report outside your machine, review it — `failsnap last` prints its path.
126
+
127
+ `.failsnap/` is automatically added to your `.gitignore`.
128
+
129
+ ---
130
+
131
+ ## How the report looks
132
+
133
+ Generated by `failsnap node app.js` in a small Node project, where `app.js`
134
+ imports a module that doesn't exist (front of the report shown; output and
135
+ later sections elided):
136
+
137
+ ````markdown
138
+ # FailSnap Report
139
+
140
+ Generated: 2026-06-11T16:48:52.015Z
141
+
142
+ ## Failed Command
143
+
144
+ - **Command:** `node app.js`
145
+ - **Exit code:** 1
146
+ - **Duration:** 29ms
147
+ - **Directory:** `/tmp/failsnap-demo`
148
+
149
+ ## Environment
150
+
151
+ | | |
152
+ |---|---|
153
+ | **OS** | Linux 5.15.0-139-generic (x64) |
154
+ | **Shell** | /bin/bash |
155
+ | **Working directory** | /tmp/failsnap-demo |
156
+ | **Node** | v24.16.0 |
157
+ | **npm** | 11.13.0 |
158
+ | **pnpm** | not found |
159
+ | **Python** | Python 3.8.10 |
160
+ | **Java** | not found |
161
+ | **Git branch** | not a git repository |
162
+ | **Git status** | n/a |
163
+
164
+ ## Project
165
+
166
+ Detected project type: **Node.js**
167
+
168
+ ## Command Output
169
+
170
+ ```text
171
+ ...
172
+ Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/failsnap-demo/nope.js' imported from /tmp/failsnap-demo/app.js
173
+ at finalizeResolution (node:internal/modules/esm/resolve:271:11)
174
+ ...
175
+ ```
176
+ ````
177
+
178
+ The full report continues with a **Relevant Files** section (your
179
+ `package.json`, `tsconfig.json`, … redacted and inlined) and an
180
+ **AI Debugging Prompt** that tells the AI exactly what to give back: root
181
+ cause, exact fix, commands to run, files to edit, and verification steps.
182
+
183
+ ---
184
+
185
+ ## Limitations
186
+
187
+ - Secret masking is pattern-based. Review reports before sharing externally.
188
+ - Very large outputs are truncated (first 200 / last 800 lines kept).
189
+ - Shell built-ins (`export`, `source`) persist within a `failsnap shell` session but not across sessions.
190
+ - Windows clipboard uses `clip`; Linux requires `wl-copy`, `xclip`, or `xsel` to be installed.
191
+
192
+ ---
193
+
194
+ ## Contributing
195
+
196
+ Issues and PRs welcome. Run tests with:
197
+
198
+ ```bash
199
+ npm run build
200
+ npx vitest run
201
+ ```
202
+
203
+ ---
204
+
205
+ ## License
206
+
207
+ MIT
package/dist/ansi.js ADDED
@@ -0,0 +1,14 @@
1
+ // ANSI / terminal control sequences. Tools emit these even when stdout isn't a
2
+ // TTY (e.g. FORCE_COLOR), and they hurt readability for humans and LLMs alike.
3
+ // Built with the RegExp constructor + \u escapes so no raw control chars live in
4
+ // source.
5
+ // OSC: ESC ] ... terminated by BEL () or ST (ESC \). Titles / hyperlinks.
6
+ const OSC = new RegExp("\\u001b\\][^\\u0007\\u001b]*(?:\\u0007|\\u001b\\\\)", "g");
7
+ // CSI: ESC [ params intermediates final-byte. Colors, cursor moves, clears.
8
+ const CSI = new RegExp("\\u001b\\[[0-?]*[ -/]*[@-~]", "g");
9
+ // Other two-character escapes: ESC followed by a single Fe/Fp/Fs byte.
10
+ const ESCAPE = new RegExp("\\u001b[@-Z\\\\-_]", "g");
11
+ /** Remove ANSI escape sequences from text. */
12
+ export function stripAnsi(text) {
13
+ return text.replace(OSC, "").replace(CSI, "").replace(ESCAPE, "");
14
+ }
@@ -0,0 +1,46 @@
1
+ import { collectEnvInfo } from "./env.js";
2
+ import { detectProjectTypes } from "./detect.js";
3
+ import { collectRelevantFiles } from "./files.js";
4
+ import { ensureGitignore } from "./gitignore.js";
5
+ import { generateReport } from "./report.js";
6
+ import { runCommand } from "./runner.js";
7
+ import { FAILSNAP_DIR } from "./paths.js";
8
+ /**
9
+ * If `result` failed, snapshot environment + project context for the directory
10
+ * the command ran in and write `.failsnap/latest.md`, `.failsnap/latest.log`, and
11
+ * a timestamped snapshot under `.failsnap/snapshots/`. Returns the report paths,
12
+ * or null on success. Shared by one-shot mode and the monitored shell.
13
+ */
14
+ export function snapshotFailure(result, opts = {}) {
15
+ if (result.exitCode === 0)
16
+ return null;
17
+ const cwd = opts.cwd ?? process.cwd();
18
+ const report = generateReport({
19
+ command: result.command,
20
+ exitCode: result.exitCode,
21
+ durationMs: result.durationMs,
22
+ output: result.output,
23
+ signal: result.signal,
24
+ cwd,
25
+ env: collectEnvInfo(cwd),
26
+ projectTypes: detectProjectTypes(cwd),
27
+ files: collectRelevantFiles(cwd),
28
+ });
29
+ const ignored = ensureGitignore(cwd);
30
+ if (!opts.silent) {
31
+ process.stderr.write(`\n[failsnap] command failed (exit code ${result.exitCode})\n` +
32
+ `[failsnap] report: ${report.reportPath}\n` +
33
+ `[failsnap] raw log: ${report.rawLogPath}\n` +
34
+ `[failsnap] snapshot: ${report.snapshotDir}\n` +
35
+ (ignored ? `[failsnap] added ${FAILSNAP_DIR}/ to .gitignore\n` : ""));
36
+ }
37
+ return report;
38
+ }
39
+ /**
40
+ * Run a command once through the user's shell; snapshot on failure.
41
+ */
42
+ export async function runAndSnap(command, opts = {}) {
43
+ const result = await runCommand(command, opts);
44
+ const report = snapshotFailure(result, { cwd: opts.cwd, silent: opts.silent });
45
+ return { ...result, report };
46
+ }
@@ -0,0 +1,48 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ const LINUX_TOOLS = [
3
+ { cmd: "wl-copy", args: [] },
4
+ { cmd: "xclip", args: ["-selection", "clipboard"] },
5
+ { cmd: "xsel", args: ["--clipboard", "--input"] },
6
+ ];
7
+ function candidatesFor(platform) {
8
+ if (platform === "darwin")
9
+ return [{ cmd: "pbcopy", args: [] }];
10
+ if (platform === "win32")
11
+ return [{ cmd: "clip", args: [] }];
12
+ return LINUX_TOOLS;
13
+ }
14
+ function commandExists(cmd, platform) {
15
+ const checker = platform === "win32" ? "where" : "which";
16
+ try {
17
+ const res = spawnSync(checker, [cmd], { stdio: "ignore", timeout: 5000 });
18
+ return res.status === 0;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ /** First available clipboard tool for this platform, or null if none. */
25
+ export function findClipboardTool(platform = process.platform) {
26
+ for (const tool of candidatesFor(platform)) {
27
+ if (commandExists(tool.cmd, platform))
28
+ return tool;
29
+ }
30
+ return null;
31
+ }
32
+ /** Pipe text into a clipboard tool. Resolves false on any failure; never throws. */
33
+ export function copyToClipboard(text, tool) {
34
+ return new Promise((resolve) => {
35
+ let child;
36
+ try {
37
+ child = spawn(tool.cmd, tool.args, { stdio: ["pipe", "ignore", "ignore"] });
38
+ }
39
+ catch {
40
+ return resolve(false);
41
+ }
42
+ child.on("error", () => resolve(false));
43
+ child.on("close", (code) => resolve(code === 0));
44
+ child.stdin.on("error", () => resolve(false));
45
+ child.stdin.write(text);
46
+ child.stdin.end();
47
+ });
48
+ }
package/dist/detect.js ADDED
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const LABELS = {
4
+ node: "Node.js",
5
+ typescript: "TypeScript",
6
+ vite: "Vite",
7
+ nextjs: "Next.js",
8
+ python: "Python",
9
+ "java-maven": "Java (Maven)",
10
+ "java-gradle": "Java (Gradle)",
11
+ };
12
+ export function projectTypeLabel(type) {
13
+ return LABELS[type];
14
+ }
15
+ /** Detect every project type that applies to the given directory. */
16
+ export function detectProjectTypes(dir = process.cwd()) {
17
+ let entries;
18
+ try {
19
+ entries = fs.readdirSync(dir);
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ const has = (name) => entries.includes(name);
25
+ const hasPrefix = (prefix) => entries.some((e) => e.startsWith(prefix) && fs.statSync(path.join(dir, e)).isFile());
26
+ const types = [];
27
+ if (has("package.json"))
28
+ types.push("node");
29
+ if (has("tsconfig.json"))
30
+ types.push("typescript");
31
+ if (hasPrefix("vite.config."))
32
+ types.push("vite");
33
+ if (hasPrefix("next.config."))
34
+ types.push("nextjs");
35
+ if (has("pyproject.toml") || has("requirements.txt"))
36
+ types.push("python");
37
+ if (has("pom.xml"))
38
+ types.push("java-maven");
39
+ if (has("build.gradle") || has("build.gradle.kts"))
40
+ types.push("java-gradle");
41
+ return types;
42
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,79 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { detectProjectTypes, projectTypeLabel } from "./detect.js";
5
+ import { collectRelevantFiles, EXCLUDED_DIRS, MAX_FILE_SIZE } from "./files.js";
6
+ function git(args, cwd) {
7
+ try {
8
+ const res = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 10_000 });
9
+ if (res.error || res.status !== 0)
10
+ return null;
11
+ return res.stdout.trim();
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ function detectPackageManager(dir) {
18
+ const locks = [
19
+ ["pnpm-lock.yaml", "pnpm"],
20
+ ["yarn.lock", "yarn"],
21
+ ["bun.lockb", "bun"],
22
+ ["bun.lock", "bun"],
23
+ ["package-lock.json", "npm"],
24
+ ];
25
+ for (const [lock, manager] of locks) {
26
+ if (fs.existsSync(path.join(dir, lock)))
27
+ return `${manager} (${lock})`;
28
+ }
29
+ try {
30
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
31
+ if (typeof pkg.packageManager === "string") {
32
+ return `${pkg.packageManager} (package.json "packageManager")`;
33
+ }
34
+ }
35
+ catch {
36
+ // no package.json or unparsable: fall through
37
+ }
38
+ return null;
39
+ }
40
+ /**
41
+ * Build the `failsnap doctor` output: what FailSnap detects and what it
42
+ * would collect for this directory, without running anything.
43
+ */
44
+ export function buildDoctorReport(dir = process.cwd()) {
45
+ const types = detectProjectTypes(dir);
46
+ const files = collectRelevantFiles(dir).map((f) => f.path);
47
+ const packageManager = detectPackageManager(dir);
48
+ const envExists = fs.existsSync(path.join(dir, ".env"));
49
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir);
50
+ const dirty = branch === null ? null : git(["status", "--porcelain"], dir);
51
+ const lines = [];
52
+ const section = (title, items) => {
53
+ lines.push("", `${title}:`);
54
+ for (const item of items)
55
+ lines.push(`- ${item}`);
56
+ };
57
+ lines.push("FailSnap Doctor");
58
+ section("Directory", [dir]);
59
+ section("Project", types.length > 0 ? types.map(projectTypeLabel) : ["no known project type detected"]);
60
+ if (packageManager)
61
+ section("Package manager", [packageManager]);
62
+ section("Will collect", files.length > 0 ? files : ["nothing (no relevant config files found)"]);
63
+ section("Will exclude", [
64
+ ".env files (never collected; .env.example is the only exception)",
65
+ ...[...EXCLUDED_DIRS].map((d) => `${d}/`),
66
+ `files larger than ${Math.round(MAX_FILE_SIZE / 1024)}KB`,
67
+ ]);
68
+ section("Git", branch === null
69
+ ? ["not a git repository"]
70
+ : [
71
+ `branch: ${branch}`,
72
+ `status: ${dirty && dirty.length > 0 ? "dirty (uncommitted changes)" : "clean"}`,
73
+ ]);
74
+ const security = ["Secret redaction enabled (output and collected files)"];
75
+ if (envExists)
76
+ security.push(".env detected but will not be collected");
77
+ section("Security", security);
78
+ return lines.join("\n") + "\n";
79
+ }
package/dist/env.js ADDED
@@ -0,0 +1,36 @@
1
+ import os from "node:os";
2
+ import { spawnSync } from "node:child_process";
3
+ function runRaw(cmd, args, cwd) {
4
+ try {
5
+ const res = spawnSync(cmd, args, { cwd, encoding: "utf8", timeout: 10_000 });
6
+ if (res.error || res.status !== 0)
7
+ return null;
8
+ // Some tools (java) print their version to stderr.
9
+ return (res.stdout || res.stderr || "").trim();
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ function run(cmd, args, cwd) {
16
+ const out = runRaw(cmd, args, cwd);
17
+ if (out === null)
18
+ return null;
19
+ return out.split("\n")[0] || null;
20
+ }
21
+ export function collectEnvInfo(cwd = process.cwd()) {
22
+ const gitBranch = run("git", ["rev-parse", "--abbrev-ref", "HEAD"], cwd);
23
+ const gitStatus = gitBranch === null ? null : runRaw("git", ["status", "--porcelain"], cwd);
24
+ return {
25
+ os: `${os.type()} ${os.release()} (${os.arch()})`,
26
+ shell: process.env.SHELL || process.env.ComSpec || "unknown",
27
+ cwd,
28
+ node: process.version,
29
+ npm: run("npm", ["--version"]),
30
+ pnpm: run("pnpm", ["--version"]),
31
+ python: run("python3", ["--version"]) ?? run("python", ["--version"]),
32
+ java: run("java", ["-version"]),
33
+ gitBranch,
34
+ gitDirty: gitStatus === null ? null : gitStatus.length > 0,
35
+ };
36
+ }
package/dist/files.js ADDED
@@ -0,0 +1,70 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { redact } from "./redact.js";
4
+ export const MAX_FILE_SIZE = 100 * 1024; // 100 KB
5
+ export const EXCLUDED_DIRS = new Set([
6
+ "node_modules",
7
+ "dist",
8
+ "build",
9
+ "coverage",
10
+ ".git",
11
+ ]);
12
+ /** Exact file names worth including in a report. */
13
+ const EXACT_CANDIDATES = [
14
+ "package.json",
15
+ "tsconfig.json",
16
+ "pyproject.toml",
17
+ "requirements.txt",
18
+ "pom.xml",
19
+ "build.gradle",
20
+ "build.gradle.kts",
21
+ ".env.example",
22
+ ];
23
+ /** Prefix-matched config files (vite.config.ts, next.config.mjs, ...). */
24
+ const PREFIX_CANDIDATES = ["vite.config.", "next.config."];
25
+ function isCandidate(name) {
26
+ // Never include real env files; only the .env.example template is allowed.
27
+ if (name === ".env" || (name.startsWith(".env.") && name !== ".env.example")) {
28
+ return false;
29
+ }
30
+ if (EXACT_CANDIDATES.includes(name))
31
+ return true;
32
+ return PREFIX_CANDIDATES.some((p) => name.startsWith(p));
33
+ }
34
+ /**
35
+ * Collect (and redact) project config files relevant for debugging.
36
+ * Only looks at the project root; excluded directories are never entered.
37
+ */
38
+ export function collectRelevantFiles(dir = process.cwd()) {
39
+ let entries;
40
+ try {
41
+ entries = fs.readdirSync(dir);
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ const files = [];
47
+ for (const name of entries.sort()) {
48
+ if (EXCLUDED_DIRS.has(name))
49
+ continue;
50
+ if (!isCandidate(name))
51
+ continue;
52
+ const fullPath = path.join(dir, name);
53
+ let stat;
54
+ try {
55
+ stat = fs.statSync(fullPath);
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ if (!stat.isFile() || stat.size > MAX_FILE_SIZE)
61
+ continue;
62
+ try {
63
+ files.push({ path: name, content: redact(fs.readFileSync(fullPath, "utf8")) });
64
+ }
65
+ catch {
66
+ // unreadable file: skip
67
+ }
68
+ }
69
+ return files;
70
+ }
@@ -0,0 +1,45 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { FAILSNAP_DIR } from "./paths.js";
4
+ /** Walk up from `dir` looking for a `.git` entry; return true if inside a repo. */
5
+ function isInGitRepo(dir) {
6
+ let current = path.resolve(dir);
7
+ for (;;) {
8
+ if (fs.existsSync(path.join(current, ".git")))
9
+ return true;
10
+ const parent = path.dirname(current);
11
+ if (parent === current)
12
+ return false;
13
+ current = parent;
14
+ }
15
+ }
16
+ /** True if any line of the .gitignore already ignores the .failsnap directory. */
17
+ function alreadyIgnored(content) {
18
+ return content.split(/\r?\n/).some((line) => {
19
+ const trimmed = line.trim().replace(/^\/+/, "").replace(/\/+$/, "");
20
+ return trimmed === FAILSNAP_DIR;
21
+ });
22
+ }
23
+ /**
24
+ * Ensure `.failsnap/` is git-ignored for the project at `cwd`.
25
+ *
26
+ * Best-effort and conservative: only acts inside a git repository, never
27
+ * duplicates an existing rule, and swallows any I/O error. Returns true only
28
+ * when it actually added the entry.
29
+ */
30
+ export function ensureGitignore(cwd = process.cwd()) {
31
+ try {
32
+ if (!isInGitRepo(cwd))
33
+ return false;
34
+ const file = path.join(cwd, ".gitignore");
35
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
36
+ if (existing && alreadyIgnored(existing))
37
+ return false;
38
+ const prefix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
39
+ fs.appendFileSync(file, `${prefix}${FAILSNAP_DIR}/\n`, "utf8");
40
+ return true;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }