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 +21 -0
- package/README.md +207 -0
- package/dist/ansi.js +14 -0
- package/dist/capture.js +46 -0
- package/dist/clipboard.js +48 -0
- package/dist/detect.js +42 -0
- package/dist/doctor.js +79 -0
- package/dist/env.js +36 -0
- package/dist/files.js +70 -0
- package/dist/gitignore.js +45 -0
- package/dist/index.js +90 -0
- package/dist/monitored-shell.js +204 -0
- package/dist/output-buffer.js +63 -0
- package/dist/paths.js +27 -0
- package/dist/redact.js +91 -0
- package/dist/report.js +185 -0
- package/dist/runner.js +99 -0
- package/dist/shell.js +101 -0
- package/package.json +50 -0
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
|
+

|
|
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
|
+
}
|
package/dist/capture.js
ADDED
|
@@ -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
|
+
}
|