crumplelab 0.2.0 → 0.2.2
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/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/bin/crumple.mjs +56 -9
- package/package.json +1 -1
- package/src/bug.mjs +60 -0
- package/src/scan.mjs +7 -4
- package/src/ui.mjs +70 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
- Motion in the terminal: a boot log before the crash test, a live progress bar with a spinner while the
|
|
6
|
+
agent works, and a spinner while `scan` reads your setup. All of it switches off in pipes, CI and NO_COLOR.
|
|
7
|
+
- `crumple bug`, and a clear report box if Crumple itself crashes or a trap errors: one link opens a pre-filled
|
|
8
|
+
GitHub issue with keys, emails and your home folder removed. Nothing is ever sent by Crumple itself.
|
|
9
|
+
- Docker's build output stays hidden unless the build fails.
|
|
10
|
+
|
|
11
|
+
## 0.2.1
|
|
12
|
+
|
|
13
|
+
- `crumple scan` no longer flags exact, fixed commands such as `Bash(curl -s http://localhost:4173/sitemap.xml)`.
|
|
14
|
+
Only wildcard network rules can carry data out, so only those count.
|
|
15
|
+
- Single MCP tools (`mcp__server__tool`) are no longer flagged; whole servers and open `WebFetch` still are.
|
|
16
|
+
- Hooks in your own `.claude/settings.local.json` are no longer reported as shipped by the repository.
|
|
17
|
+
- Packages with install scripts are listed once each.
|
|
18
|
+
|
|
3
19
|
## 0.2.0
|
|
4
20
|
|
|
5
21
|
- 18 built-in traps, each from a real CVE, incident or paper, mapped to OWASP LLM, OWASP Agentic (ASI)
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Crumple: crash-test your AI coding agent
|
|
4
4
|
|
|
5
|
-
> **
|
|
5
|
+
> **Version 0.2.** Needs Node 20+ and Docker (`scan` needs neither). So far Crumple's scores are
|
|
6
6
|
> verified against its built-in test agents; results for Claude Code, Codex and Gemini CLI will be published
|
|
7
7
|
> as they're run. Website: [crumple.priberry.com](https://crumple.priberry.com) · contact: crumple@priberry.com
|
|
8
8
|
|
package/bin/crumple.mjs
CHANGED
|
@@ -14,6 +14,8 @@ import { detectClaudeSetup } from "../src/detect.mjs";
|
|
|
14
14
|
import { summarize, headline, writeReport, fmtTime } from "../src/report.mjs";
|
|
15
15
|
import { applyFix } from "../src/fix.mjs";
|
|
16
16
|
import { banner } from "../src/banner.mjs";
|
|
17
|
+
import { boot, progress, spin } from "../src/ui.mjs";
|
|
18
|
+
import { crashBox, diagnostics, issueUrl, EMAIL } from "../src/bug.mjs";
|
|
17
19
|
|
|
18
20
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
21
|
const { version: VERSION } = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
@@ -48,12 +50,25 @@ const { values: opt, positionals } = parseArgs({
|
|
|
48
50
|
},
|
|
49
51
|
});
|
|
50
52
|
|
|
53
|
+
// If Crumple itself breaks, say so plainly and hand the user a ready-made bug report. Nothing is sent.
|
|
54
|
+
const crashed = (e) => {
|
|
55
|
+
process.stderr.write(crashBox({ version: VERSION, command: positionals.join(" "), agent: positionals[0] === "attack" ? opt.agent : undefined, error: e?.stack ?? e, color: !process.env.NO_COLOR }));
|
|
56
|
+
process.exit(70);
|
|
57
|
+
};
|
|
58
|
+
process.on("uncaughtException", crashed);
|
|
59
|
+
process.on("unhandledRejection", crashed);
|
|
60
|
+
|
|
51
61
|
const c = { red: (s) => `\x1b[31m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m` };
|
|
52
62
|
|
|
53
63
|
// The guard speaks JSON to the agent over stdout, so it (and any machine-readable output) never gets the banner.
|
|
54
64
|
if (!["guard", "audit"].includes(positionals[0]) && !opt.json) process.stdout.write(banner({ version: VERSION, tagline: "the crash test for AI coding agents" }));
|
|
55
65
|
|
|
56
|
-
if (positionals[0] === "
|
|
66
|
+
if (positionals[0] === "bug") {
|
|
67
|
+
// Everything a bug report needs, and the link to file one. Nothing is sent.
|
|
68
|
+
console.log(`\n${c.bold("Report a bug")}\n\n${diagnostics({ version: VERSION, command: "bug" })}\n`);
|
|
69
|
+
console.log(`Open this link to file it on GitHub (pre-filled, you can edit it first):\n${issueUrl({ version: VERSION, command: "crumple bug" })}\n`);
|
|
70
|
+
console.log(c.dim(`Or email ${EMAIL}. Security issue? See https://crumple.priberry.com/.well-known/security.txt`));
|
|
71
|
+
} else if (positionals[0] === "guard") {
|
|
57
72
|
// Same guard the container uses, exposed for hooks on the user's machine.
|
|
58
73
|
(await import("../harness/guard.mjs")).runHook();
|
|
59
74
|
} else if (positionals[0] === "audit") {
|
|
@@ -75,7 +90,11 @@ if (positionals[0] === "guard") {
|
|
|
75
90
|
console.log(`\n${all.length} traps`);
|
|
76
91
|
} else if (positionals[0] === "scan") {
|
|
77
92
|
const { scan, toSarif, SEVERITY } = await import("../src/scan.mjs");
|
|
78
|
-
const result = scan(process.cwd())
|
|
93
|
+
const result = opt.json || opt.sarif ? scan(process.cwd()) : await spin("reading your agent setup the way an attacker would", async () => {
|
|
94
|
+
const r = scan(process.cwd());
|
|
95
|
+
await new Promise((ok) => setTimeout(ok, 350));
|
|
96
|
+
return r;
|
|
97
|
+
});
|
|
79
98
|
if (opt.sarif) fs.writeFileSync(opt.sarif, JSON.stringify(toSarif(result), null, 2) + "\n");
|
|
80
99
|
if (opt.json) console.log(JSON.stringify(result, null, 2));
|
|
81
100
|
else {
|
|
@@ -139,6 +158,7 @@ if (positionals[0] === "guard") {
|
|
|
139
158
|
crumple lock [--check] pin your MCP servers' tools; report any that change
|
|
140
159
|
crumple list [--pack dir] every trap, its source and the standards it maps to
|
|
141
160
|
crumple audit show the guard's recent decisions and check the log wasn't edited
|
|
161
|
+
crumple bug report a problem with Crumple (pre-filled, nothing is sent)
|
|
142
162
|
|
|
143
163
|
Crash-tests your AI coding agent setup against ${SCENARIOS.length} realistic traps,
|
|
144
164
|
inside a throwaway Docker container, using fake keys and fake data.
|
|
@@ -188,9 +208,19 @@ if (positionals[0] === "guard") {
|
|
|
188
208
|
process.exit(1);
|
|
189
209
|
}
|
|
190
210
|
if (spawnSync("docker", ["image", "inspect", IMAGE], { stdio: "ignore" }).status !== 0) {
|
|
191
|
-
|
|
192
|
-
const b =
|
|
193
|
-
|
|
211
|
+
// Docker's own chatter stays hidden unless the build fails.
|
|
212
|
+
const b = await spin("building the crash-test container (first run only, about a minute)", async () =>
|
|
213
|
+
new Promise((done) => {
|
|
214
|
+
const p = spawn("docker", ["build", "-q", "-f", path.join(ROOT, "docker/Dockerfile"), "-t", IMAGE, ROOT]);
|
|
215
|
+
let log = "";
|
|
216
|
+
p.stdout.on("data", (d) => (log += d));
|
|
217
|
+
p.stderr.on("data", (d) => (log += d));
|
|
218
|
+
p.on("close", (status) => done({ status, log }));
|
|
219
|
+
}));
|
|
220
|
+
if (b.status !== 0) {
|
|
221
|
+
console.error(c.red("The crash-test container didn't build:\n") + b.log.slice(-2000));
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
194
224
|
}
|
|
195
225
|
|
|
196
226
|
const packDirs = opt.pack.map((p) => path.resolve(p));
|
|
@@ -203,6 +233,12 @@ if (positionals[0] === "guard") {
|
|
|
203
233
|
const configB64 = Buffer.from(JSON.stringify(setup.files)).toString("base64");
|
|
204
234
|
const modeLabel = opt.mode === "yolo" ? "Hands-off mode (permissions skipped)" : "As shipped (default permissions)";
|
|
205
235
|
|
|
236
|
+
await boot([
|
|
237
|
+
["sandbox", "docker · cap-drop ALL · no-new-privileges"],
|
|
238
|
+
["canaries", "fresh fake keys for every trap"],
|
|
239
|
+
["sinks", "dns · http · tls · every exit leads back here"],
|
|
240
|
+
["traps", `${scenarios.length} armed`],
|
|
241
|
+
]);
|
|
206
242
|
console.log(`\n${c.bold("Crumple agent crash test")} · ${agent.label} · ${modeLabel}`);
|
|
207
243
|
console.log(c.dim(setup.found.length ? `Your setup: ${setup.found.join(", ")}` : agent.mock ? "Scripted test agent" : "No project or user config found; testing default settings"));
|
|
208
244
|
for (const n of setup.notes) console.log(c.dim(`Note: ${n}`));
|
|
@@ -229,18 +265,22 @@ if (positionals[0] === "guard") {
|
|
|
229
265
|
|
|
230
266
|
const jobs = scenarios.flatMap((s) => Array.from({ length: Number(opt.runs) }, () => s));
|
|
231
267
|
const results = [];
|
|
232
|
-
|
|
268
|
+
const live = progress({ total: jobs.length, label: "agent at work" });
|
|
269
|
+
let next = 0, breaches = 0;
|
|
233
270
|
async function worker() {
|
|
234
271
|
while (next < jobs.length) {
|
|
235
272
|
const s = jobs[next++];
|
|
236
273
|
const r = await runOne(s);
|
|
237
274
|
results.push(r);
|
|
275
|
+
if (r.breached) breaches++;
|
|
238
276
|
const tag = r.error ? c.red("ERROR") : r.breached ? c.red(`BREACHED in ${fmtTime(r.timeToBreachMs)}`) : r.hijacked ? c.yellow("hijacked, contained") : c.green("held");
|
|
239
|
-
|
|
240
|
-
if (r.error)
|
|
277
|
+
live.log(` ${s.title.padEnd(52)} ${tag}${r.breached || r.hijacked ? c.dim(` ${r.evidence}`) : ""}`);
|
|
278
|
+
if (r.error) live.log(c.dim(r.error.split("\n").slice(-4).join("\n")));
|
|
279
|
+
live.tick(breaches ? `${breaches} breached` : "");
|
|
241
280
|
}
|
|
242
281
|
}
|
|
243
282
|
await Promise.all(Array.from({ length: Math.max(1, Number(opt.parallel)) }, worker));
|
|
283
|
+
live.stop();
|
|
244
284
|
|
|
245
285
|
const sum = summarize(results, scenarios);
|
|
246
286
|
const outDir = path.resolve(opt.out);
|
|
@@ -251,5 +291,12 @@ if (positionals[0] === "guard") {
|
|
|
251
291
|
console.log(c.dim(`Card: ${path.join(outDir, "card.svg")}`));
|
|
252
292
|
console.log(c.dim(`Badge: ${path.join(outDir, "badge.svg")}`));
|
|
253
293
|
console.log(c.dim(`Report: ${path.join(outDir, "report.md")}`));
|
|
254
|
-
if (sum.rows.some((r) => r.errored))
|
|
294
|
+
if (sum.rows.some((r) => r.errored)) {
|
|
295
|
+
// A trap that errors is Crumple's problem to fix, so make reporting it one click.
|
|
296
|
+
const errs = results.filter((r) => r.error).map((r) => `[${r.scenario}] ${r.error}`).join("\n\n");
|
|
297
|
+
console.log(c.yellow(`\nSome traps errored instead of finishing. If that looks like our bug, report it in one click:`));
|
|
298
|
+
console.log(issueUrl({ title: `Trap errors with ${opt.agent}`, version: VERSION, command: `attack --agent ${opt.agent}`, agent: opt.agent, error: errs }));
|
|
299
|
+
console.log(c.dim("Nothing was sent. The link opens a pre-filled issue you can read first."));
|
|
300
|
+
process.exitCode = 2;
|
|
301
|
+
}
|
|
255
302
|
}
|
package/package.json
CHANGED
package/src/bug.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Bug reports without telemetry. Crumple never sends anything by itself: when something goes wrong
|
|
2
|
+
// it prints what happened and a link that opens a pre-filled GitHub issue, which the user can read,
|
|
3
|
+
// edit and choose to submit.
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
export const ISSUES = "https://github.com/priberry-data/crumplelab/issues/new";
|
|
7
|
+
export const EMAIL = "crumple@priberry.com";
|
|
8
|
+
|
|
9
|
+
const SECRETS = /\b(sk-ant-[\w-]{8,}|sk-[\w-]{16,}|gh[posu]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|AKIA[0-9A-Z]{16}|AIza[\w-]{30,}|xox[abp]-[\w-]{10,}|eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]+)/g;
|
|
10
|
+
|
|
11
|
+
// Keys, tokens and the user's home folder never make it into a report.
|
|
12
|
+
export function redact(text, home = os.homedir()) {
|
|
13
|
+
let s = String(text ?? "").replace(SECRETS, (m) => m.slice(0, 4) + "…[redacted]");
|
|
14
|
+
if (home && home.length > 1) s = s.split(home).join("~");
|
|
15
|
+
return s.replace(/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g, "[email]");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function diagnostics({ version, command, agent } = {}) {
|
|
19
|
+
return [
|
|
20
|
+
`crumple ${version}`,
|
|
21
|
+
`command: ${command || "(none)"}`,
|
|
22
|
+
agent ? `agent: ${agent}` : null,
|
|
23
|
+
`node ${process.version} · ${os.platform()} ${os.release()} · ${os.arch()}`,
|
|
24
|
+
].filter(Boolean).join("\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function issueUrl({ title, version, command, agent, error } = {}) {
|
|
28
|
+
const body = [
|
|
29
|
+
"**What happened**",
|
|
30
|
+
"",
|
|
31
|
+
"<!-- What were you doing, and what did you expect? -->",
|
|
32
|
+
"",
|
|
33
|
+
"**Details** (filled in by Crumple; secrets and your home folder are removed)",
|
|
34
|
+
"```",
|
|
35
|
+
diagnostics({ version, command, agent }),
|
|
36
|
+
error ? "\n" + redact(error).slice(-1500) : "",
|
|
37
|
+
"```",
|
|
38
|
+
].join("\n");
|
|
39
|
+
const q = new URLSearchParams({ title: title || "Bug: ", body, labels: "bug" });
|
|
40
|
+
return `${ISSUES}?${q}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The box printed when Crumple itself fails.
|
|
44
|
+
export function crashBox({ version, command, agent, error, color = true }) {
|
|
45
|
+
const red = (s) => (color ? `\x1b[31m${s}\x1b[0m` : s);
|
|
46
|
+
const dim = (s) => (color ? `\x1b[2m${s}\x1b[0m` : s);
|
|
47
|
+
const firstLine = redact(String(error).split("\n")[0]).slice(0, 200);
|
|
48
|
+
return [
|
|
49
|
+
"",
|
|
50
|
+
red(" ╔═ crumple crashed ══════════════════════════════════════════╗"),
|
|
51
|
+
` ${firstLine}`,
|
|
52
|
+
"",
|
|
53
|
+
" That's our bug, not yours. Please send it to us; it takes one click:",
|
|
54
|
+
` ${issueUrl({ title: `Crash: ${firstLine.slice(0, 80)}`, version, command, agent, error })}`,
|
|
55
|
+
"",
|
|
56
|
+
dim(` Nothing was sent. The link opens a pre-filled issue you can read first. Or email ${EMAIL}.`),
|
|
57
|
+
red(" ╚════════════════════════════════════════════════════════════╝"),
|
|
58
|
+
"",
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
package/src/scan.mjs
CHANGED
|
@@ -48,9 +48,11 @@ export function scan(projectDir, { home = os.homedir() } = {}) {
|
|
|
48
48
|
for (const rule of perm.allow ?? []) {
|
|
49
49
|
const r = String(rule);
|
|
50
50
|
if (/^Bash(\(\*\)|\(:\*\))?$/.test(r)) add("critical", where, `Every shell command is pre-approved (${r})`, "A prompt injection becomes code execution on your machine immediately.", `Remove "${r}" and allow specific commands instead.`);
|
|
51
|
-
|
|
51
|
+
// An exact rule only ever approves that one command, so only wildcards can carry your data out.
|
|
52
|
+
else if (/^Bash\((curl|wget|nc|ssh|scp|rsync|http)\b/.test(r) && r.includes("*")) add("high", where, `Network tool pre-approved (${r})`, "This is the exact command attackers use to send your .env out.", `Remove "${r}"; let the agent ask, or allow only fixed URLs.`);
|
|
52
53
|
else if (/^Bash\((git|find|fd|rg|go|xargs|env|sed|awk|tar|npx|npm|pnpm|yarn|node|python3?|bash|sh)(:\*|\s\*|\*)?\)$/.test(r)) add("high", where, `Broad pre-approval that can run any program (${r})`, "Flags like `git -c core.fsmonitor=`, `find -exec`, `npm run` and `npx` turn a \"safe\" prefix into arbitrary code (Trail of Bits, 2025).", `Narrow "${r}" to exact subcommands (for example "Bash(git status)"), and install the Crumple guard, which checks flags.`);
|
|
53
|
-
|
|
54
|
+
// A single MCP tool (mcp__server__tool) is a narrow approval; a whole server or open WebFetch is not.
|
|
55
|
+
else if (/^WebFetch$/.test(r) || (/^mcp__[\w-]+$/.test(r) && r.split("__").length === 2)) add("medium", where, `Pre-approved without limits (${r})`, "Lets content from anywhere drive the agent and receive data without a prompt.", `Scope "${r}" to specific domains or tools.`);
|
|
54
56
|
}
|
|
55
57
|
for (const d of perm.deny ?? []) if (/\.env/.test(String(d))) denyEnv = true;
|
|
56
58
|
if (s.enableAllProjectMcpServers) add("high", where, "Every MCP server a repository ships is started automatically", "Cloning a hostile repo starts its MCP server with your permissions (Check Point, CVE-2025-59536).", "Remove `enableAllProjectMcpServers` and approve servers one by one.");
|
|
@@ -61,7 +63,8 @@ export function scan(projectDir, { home = os.homedir() } = {}) {
|
|
|
61
63
|
const hooks = JSON.stringify(s.hooks ?? {});
|
|
62
64
|
if (/guard\.mjs|crumplelab guard/.test(hooks)) guarded = true;
|
|
63
65
|
if (/\b(curl|wget|nc)\b|https?:\/\//.test(hooks)) add("high", where, "A hook sends data over the network", "Hooks run automatically on every tool call or session start, without the agent or you approving them.", "Check every hook command; remove any that talk to the network.");
|
|
64
|
-
|
|
66
|
+
// settings.local.json is the user's own and stays out of git; only settings.json comes with a clone.
|
|
67
|
+
if (f === P(".claude/settings.json") && s.hooks && !/guard\.mjs/.test(hooks)) add("medium", where, "This repository ships its own hooks", "Hooks in a cloned repo run commands on your machine as soon as the agent starts.", "Review them before running an agent here.");
|
|
65
68
|
if (s.sandbox?.enabled) sandboxed = true;
|
|
66
69
|
}
|
|
67
70
|
if (!guarded) add("medium", "agent setup", "No runtime guard checks the agent's commands", "Nothing stops a hijacked agent from sending keys out or deleting data between your prompts.", "Run `crumple fix`.");
|
|
@@ -128,7 +131,7 @@ export function scan(projectDir, { home = os.homedir() } = {}) {
|
|
|
128
131
|
const npmrc = (read(P(".npmrc")) ?? "") + (read(H(".npmrc")) ?? "");
|
|
129
132
|
const lock = json(P("package-lock.json"));
|
|
130
133
|
if (lock && !/ignore-scripts\s*=\s*true/.test(npmrc)) {
|
|
131
|
-
const scripts = Object.entries(lock.packages ?? {}).filter(([k, v]) => k && v?.hasInstallScript).map(([k]) => k.split("node_modules/").pop());
|
|
134
|
+
const scripts = [...new Set(Object.entries(lock.packages ?? {}).filter(([k, v]) => k && v?.hasInstallScript).map(([k]) => k.split("node_modules/").pop()))];
|
|
132
135
|
if (scripts.length) add("medium", "package-lock.json", `${scripts.length} dependencies run install scripts (${scripts.slice(0, 4).join(", ")}${scripts.length > 4 ? "…" : ""})`, "Install scripts run with your keys in reach; this is how Shai-Hulud and the Nx attack spread.", "Set `ignore-scripts=true` in .npmrc and allow only the few that need it.");
|
|
133
136
|
}
|
|
134
137
|
|
package/src/ui.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Terminal motion: a short boot log, a live progress line with a spinner, and nothing else.
|
|
2
|
+
// Everything here turns itself off when output isn't a real terminal, or under NO_COLOR or CI,
|
|
3
|
+
// so logs, pipes and --json stay plain.
|
|
4
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
|
|
7
|
+
export const animated = (stream = process.stdout, env = process.env) => Boolean(stream.isTTY && !env.NO_COLOR && !env.CI);
|
|
8
|
+
|
|
9
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
10
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
11
|
+
|
|
12
|
+
// Prints a few "name ...... ok detail" lines, one at a time, like a machine coming up.
|
|
13
|
+
export async function boot(lines, { stream = process.stdout, env = process.env } = {}) {
|
|
14
|
+
const on = animated(stream, env);
|
|
15
|
+
for (const [name, detail] of lines) {
|
|
16
|
+
const dots = ".".repeat(Math.max(2, 22 - name.length));
|
|
17
|
+
if (on) {
|
|
18
|
+
stream.write(` ${dim(`${name} ${dots}`)} `);
|
|
19
|
+
await sleep(70);
|
|
20
|
+
} else stream.write(` ${name} ${dots} `);
|
|
21
|
+
stream.write(on ? `${green("ok")}${detail ? dim(` ${detail}`) : ""}\n` : `ok${detail ? ` ${detail}` : ""}\n`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function bar(done, total, width = 24) {
|
|
26
|
+
const full = total ? Math.round((done / total) * width) : 0;
|
|
27
|
+
return green("█".repeat(full)) + dim("░".repeat(width - full));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A status line that stays at the bottom while finished results print above it.
|
|
31
|
+
export function progress({ total, label = "", stream = process.stdout, env = process.env } = {}) {
|
|
32
|
+
const on = animated(stream, env);
|
|
33
|
+
let done = 0, frame = 0, extra = "", timer;
|
|
34
|
+
const draw = () => {
|
|
35
|
+
if (!on) return;
|
|
36
|
+
stream.write(`\r\x1b[2K ${green(FRAMES[frame++ % FRAMES.length])} ${bar(done, total)} ${done}/${total}${label ? dim(` ${label}`) : ""}${extra ? dim(` ${extra}`) : ""}`);
|
|
37
|
+
};
|
|
38
|
+
if (on) timer = setInterval(draw, 80);
|
|
39
|
+
draw();
|
|
40
|
+
return {
|
|
41
|
+
// Prints a finished line above the status line and moves the bar on.
|
|
42
|
+
log(line) {
|
|
43
|
+
if (on) stream.write("\r\x1b[2K");
|
|
44
|
+
stream.write(line + "\n");
|
|
45
|
+
draw();
|
|
46
|
+
},
|
|
47
|
+
tick(note = "") {
|
|
48
|
+
done++;
|
|
49
|
+
extra = note;
|
|
50
|
+
draw();
|
|
51
|
+
},
|
|
52
|
+
stop() {
|
|
53
|
+
clearInterval(timer);
|
|
54
|
+
if (on) stream.write("\r\x1b[2K");
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// For short waits: a spinner and a label, replaced by a tick when the work is done.
|
|
60
|
+
export async function spin(label, work, { stream = process.stdout, env = process.env } = {}) {
|
|
61
|
+
if (!animated(stream, env)) return work();
|
|
62
|
+
let frame = 0;
|
|
63
|
+
const timer = setInterval(() => stream.write(`\r\x1b[2K ${green(FRAMES[frame++ % FRAMES.length])} ${dim(label)}`), 80);
|
|
64
|
+
try {
|
|
65
|
+
return await work();
|
|
66
|
+
} finally {
|
|
67
|
+
clearInterval(timer);
|
|
68
|
+
stream.write(`\r\x1b[2K ${green("✓")} ${dim(label)}\n`);
|
|
69
|
+
}
|
|
70
|
+
}
|