@authtrack/secura 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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * `secura scan` — drive a static-analysis run against the backend and render it.
3
+ *
4
+ * Transport is chosen by target:
5
+ * • repo URL / owner/repo → sent verbatim as target_path; the backend clones it.
6
+ * • local path + local backend (default) → the absolute path is scanned in place.
7
+ * • local path + remote backend, or --upload → the dir is tar.gz'd and uploaded.
8
+ *
9
+ * Exit codes match the Python CLI: 0 clean · 1 --fail-on gate breached ·
10
+ * 2 bad target / scan failure · 130 interrupted.
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ import { resolveConfig, isLocalBackend } from "../config.mjs";
17
+ import { classifyTarget } from "../target.mjs";
18
+ import { streamStatic, streamStaticUpload } from "../client.mjs";
19
+ import { packDir, MAX_UPLOAD_BYTES } from "../pack.mjs";
20
+ import { ScanMonitor, SEVERITY_RANK } from "../monitor.mjs";
21
+ import { LiveView, renderLive, printReport } from "../render.mjs";
22
+ import { bold, dim, red, green, yellow, cyan } from "../ansi.mjs";
23
+
24
+ const errline = (s) => process.stderr.write(s + "\n");
25
+ const outline = (s = "") => process.stdout.write(s + "\n");
26
+
27
+ export async function runScan(values, positionals) {
28
+ const rawTarget = String(positionals[0] ?? ".").trim() || ".";
29
+ const includeCodeql = !values.fast;
30
+ const language = values.language || "";
31
+ const maxTriage = values["max-triage"] ?? 25;
32
+ const maxFindings = values["max-findings"] ?? 20;
33
+ const failOn = String(values["fail-on"] || "").toLowerCase();
34
+ const jsonOut = values.json || "";
35
+ const showFixes = Boolean(values["show-fixes"]);
36
+ const quiet = Boolean(values.quiet);
37
+ const forceUpload = Boolean(values.upload);
38
+
39
+ const { api, token } = resolveConfig(values);
40
+ const target = classifyTarget(rawTarget);
41
+
42
+ // Validate a local path up front (mirrors the Python CLI's exit-2 checks).
43
+ if (target.kind === "path") {
44
+ if (!fs.existsSync(target.value)) {
45
+ errline(`${red("Not found:")} '${rawTarget}' is neither an existing path nor a Git repo URL.`);
46
+ return 2;
47
+ }
48
+ if (!fs.statSync(target.value).isDirectory()) {
49
+ errline(`${red("Not a directory:")} ${rawTarget}`);
50
+ return 2;
51
+ }
52
+ }
53
+
54
+ const remote = !isLocalBackend(api);
55
+ const transport = target.kind === "url" ? "url" : forceUpload || remote ? "upload" : "path";
56
+
57
+ if (!quiet) {
58
+ outline();
59
+ outline(`${bold("secura")} scanning ${cyan(target.display)}`);
60
+ outline(dim(`backend ${api}`));
61
+ if (target.kind === "url") outline(dim("remote repo — the backend shallow-clones it, scans, then removes it"));
62
+ else if (transport === "upload") outline(dim("packaging local code and uploading it to the backend"));
63
+ else outline(dim("scanning the path in place on the backend host"));
64
+ if (values.fast) outline(dim("--fast: CodeQL skipped, no taint paths in this run"));
65
+ outline();
66
+ }
67
+
68
+ const monitor = new ScanMonitor(target.display, includeCodeql);
69
+ let cleanup = () => {};
70
+
71
+ try {
72
+ let events;
73
+ if (transport === "upload") {
74
+ const packed = packDir(target.value);
75
+ cleanup = packed.cleanup;
76
+ const { size } = fs.statSync(packed.path);
77
+ if (size > MAX_UPLOAD_BYTES) {
78
+ cleanup();
79
+ errline(
80
+ `${red("Too large:")} packaged code is ${(size / 1e6).toFixed(0)} MB ` +
81
+ `(limit ${(MAX_UPLOAD_BYTES / 1e6).toFixed(0)} MB). ` +
82
+ `Scan a Git URL, or run the backend locally and drop --upload.`
83
+ );
84
+ return 2;
85
+ }
86
+ if (!quiet) outline(dim(`packaged ${packed.fileCount} file(s) → ${(size / 1e6).toFixed(1)} MB`));
87
+
88
+ const buf = fs.readFileSync(packed.path);
89
+ const form = new FormData();
90
+ form.append("include_codeql", String(includeCodeql));
91
+ form.append("codeql_language", language);
92
+ form.append("max_triage", String(maxTriage));
93
+ form.append("file", new Blob([buf], { type: "application/gzip" }), "code.tar.gz");
94
+ events = streamStaticUpload(api, token, form);
95
+ } else {
96
+ events = streamStatic(api, token, {
97
+ target_path: target.value,
98
+ include_codeql: includeCodeql,
99
+ codeql_language: language,
100
+ max_triage: maxTriage,
101
+ });
102
+ }
103
+
104
+ const streamCode = await consume(events, monitor, quiet);
105
+ if (streamCode !== 0) return streamCode;
106
+ } catch (err) {
107
+ errline(`\n${red("Scan failed:")} ${err && err.message ? err.message : String(err)}`);
108
+ return 2;
109
+ } finally {
110
+ cleanup();
111
+ }
112
+
113
+ printReport(monitor.report, { showFixes, maxFindings });
114
+
115
+ if (jsonOut) {
116
+ try {
117
+ const dest = path.resolve(jsonOut);
118
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
119
+ fs.writeFileSync(dest, JSON.stringify(monitor.report, null, 2), "utf8");
120
+ outline(`\n${green("Report written to")} ${cyan(jsonOut)}`);
121
+ } catch (err) {
122
+ outline(`\n${yellow(`Could not write ${jsonOut}: ${err.message}`)}`);
123
+ }
124
+ }
125
+
126
+ // ── CI gate ──
127
+ if (failOn) {
128
+ const threshold = SEVERITY_RANK[failOn];
129
+ if (threshold === undefined) {
130
+ outline(yellow(`Unknown --fail-on severity '${failOn}' — ignored.`));
131
+ } else {
132
+ const breaching = (monitor.report.confirmed_findings || []).filter(
133
+ (f) => (SEVERITY_RANK[String(f.final_severity || f.severity || "info").toLowerCase()] ?? 0) >= threshold
134
+ );
135
+ if (breaching.length) {
136
+ outline(`\n${red(`${breaching.length} confirmed finding(s) at or above ${failOn.toUpperCase()}.`)}`);
137
+ return 1;
138
+ }
139
+ outline(`\n${green(`No confirmed findings at or above ${failOn.toUpperCase()}.`)}`);
140
+ }
141
+ }
142
+
143
+ return 0;
144
+ }
145
+
146
+ /**
147
+ * Consume the event stream, driving the live view. Returns 0 on a clean run, or
148
+ * 2 if the backend emitted an error event. A spinner timer advances the live
149
+ * frame even while the backend is quiet (e.g. CodeQL building its database).
150
+ */
151
+ async function consume(events, monitor, quiet) {
152
+ const live = new LiveView();
153
+ const useLive = !quiet && live.active;
154
+ let frame = 0;
155
+ let timer = null;
156
+
157
+ if (useLive) {
158
+ live.start();
159
+ timer = setInterval(() => {
160
+ frame += 1;
161
+ live.render(renderLive(monitor, frame));
162
+ }, 120);
163
+ }
164
+
165
+ const onSigint = () => {
166
+ if (timer) clearInterval(timer);
167
+ live.stop();
168
+ errline(`\n${yellow("Interrupted.")}`);
169
+ process.exit(130);
170
+ };
171
+ process.once("SIGINT", onSigint);
172
+
173
+ try {
174
+ for await (const evt of events) {
175
+ if (!evt || typeof evt !== "object") continue;
176
+ switch (evt.event) {
177
+ case "node_update":
178
+ monitor.ingest(evt.node, evt.state);
179
+ if (useLive) live.render(renderLive(monitor, frame));
180
+ break;
181
+ case "complete":
182
+ monitor.stage = "done";
183
+ if (useLive) live.render(renderLive(monitor, frame));
184
+ break;
185
+ case "error":
186
+ if (timer) clearInterval(timer);
187
+ if (useLive) {
188
+ live.render(renderLive(monitor, frame));
189
+ live.stop();
190
+ }
191
+ errline(`\n${red("Backend error:")} ${evt.message || "unknown error"}`);
192
+ return 2;
193
+ default:
194
+ break; // "start" and any future events — ignore
195
+ }
196
+ }
197
+ return 0;
198
+ } finally {
199
+ if (timer) clearInterval(timer);
200
+ live.stop();
201
+ process.removeListener("SIGINT", onSigint);
202
+ }
203
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Resolve where the CLI talks to and how.
3
+ *
4
+ * The backend's scan router has no auth, so the token is optional; it is sent
5
+ * as a bearer header only when present, so this works unchanged if auth is
6
+ * added later.
7
+ */
8
+
9
+ // Matches frontend/src/lib/api.ts (API_BASE) so the CLI and the web app default
10
+ // to the same backend.
11
+ const DEFAULT_API = "http://localhost:8000/api/v1";
12
+
13
+ export function resolveConfig(values) {
14
+ const raw = values.api || process.env.SECURA_API || DEFAULT_API;
15
+ const api = raw.replace(/\/+$/, ""); // trim trailing slashes
16
+ const token = values.token || process.env.SECURA_TOKEN || "";
17
+ return { api, token };
18
+ }
19
+
20
+ /**
21
+ * True when the API base points at this machine. A local path target can then
22
+ * be scanned in place (sent as `target_path`); otherwise it must be uploaded.
23
+ */
24
+ export function isLocalBackend(api) {
25
+ try {
26
+ const { hostname } = new URL(api);
27
+ return ["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"].includes(hostname);
28
+ } catch {
29
+ // An unparseable base is treated as local — the request will fail loudly
30
+ // either way, and this keeps the default (no upload) for odd inputs.
31
+ return true;
32
+ }
33
+ }
package/src/help.mjs ADDED
@@ -0,0 +1,66 @@
1
+ /** Help / usage text for the `secura` CLI. */
2
+
3
+ import { bold, dim, cyan } from "./ansi.mjs";
4
+
5
+ export function printHelp() {
6
+ const p = (s = "") => process.stdout.write(s + "\n");
7
+ p();
8
+ p(`${bold("secura")} — AuthTrack static analysis (SAST) from the command line`);
9
+ p();
10
+ p("Streams a five-scanner scan (Semgrep, Bearer, OSV-Scanner, Gitleaks, CodeQL)");
11
+ p("from the AuthTrack backend and renders it live in your terminal.");
12
+ p();
13
+ p(bold("USAGE"));
14
+ p(` secura scan [target] [options]`);
15
+ p(` secura version`);
16
+ p(` secura --help`);
17
+ p();
18
+ p(bold("TARGETS"));
19
+ p(` ${cyan(".")} the current directory (default)`);
20
+ p(` ${cyan("./path/to/repo")} a local folder`);
21
+ p(` ${cyan("owner/repo")} a public GitHub repo (shorthand)`);
22
+ p(` ${cyan("https://github.com/o/r")} any public Git URL (cloned by the backend)`);
23
+ p();
24
+ p(bold("COMMON"));
25
+ p(` secura scan .`);
26
+ p(` secura scan owner/repo --fast`);
27
+ p(` secura scan . --fail-on high ${dim("# CI gate")}`);
28
+ p(` secura scan . --json report.json`);
29
+ p();
30
+ p(`Run ${cyan("secura scan --help")} for every flag.`);
31
+ p();
32
+ }
33
+
34
+ export function printScanHelp() {
35
+ const p = (s = "") => process.stdout.write(s + "\n");
36
+ p();
37
+ p(`${bold("secura scan")} [target] [options]`);
38
+ p();
39
+ p("Fans out to five scanners at once, merges and deduplicates their findings,");
40
+ p("retrieves similar known-vulnerable patterns, then has an LLM confirm or rule");
41
+ p("out each finding and write a patch for the confirmed ones.");
42
+ p();
43
+ p(bold("OPTIONS"));
44
+ p(` -f, --fast Skip CodeQL (its DB build dominates the run).`);
45
+ p(` -l, --language <lang> CodeQL language (javascript, python, java, go,`);
46
+ p(` ruby, csharp, cpp). Auto-detected when omitted.`);
47
+ p(` -t, --max-triage <n> Cap findings sent to LLM triage (default 25).`);
48
+ p(` --fail-on <sev> Exit 1 if a confirmed finding is ≥ this severity`);
49
+ p(` (critical|high|medium|low). For CI gates.`);
50
+ p(` -o, --json <path> Write the full report as JSON to <path>.`);
51
+ p(` --max-findings <n> How many confirmed findings to print (default 20).`);
52
+ p(` --show-fixes Print the generated fix diffs in full.`);
53
+ p(` -q, --quiet Suppress the live view; print only the report.`);
54
+ p();
55
+ p(bold("BACKEND"));
56
+ p(` --api <url> Backend base URL`);
57
+ p(` (default $SECURA_API or http://localhost:8000/api/v1).`);
58
+ p(` --token <token> Bearer token, if the backend requires one`);
59
+ p(` (default $SECURA_TOKEN).`);
60
+ p(` --upload Package the local dir and upload it, even for a`);
61
+ p(` local backend. Automatic for a remote --api.`);
62
+ p();
63
+ p(bold("EXIT CODES"));
64
+ p(` 0 clean 1 --fail-on breached 2 bad target / failure 130 interrupted`);
65
+ p();
66
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Port of ScanMonitor (backend/app/cli/main.py): fold each `node_update` event
3
+ * into the running totals the terminal view needs.
4
+ *
5
+ * The graph streams one update per completed node, and the triage loop streams
6
+ * one per finding, so state is accumulated here rather than re-derived from any
7
+ * single partial snapshot — identical semantics to the Python monitor.
8
+ */
9
+
10
+ // Mirrors frontend/src/lib/static-analysis.ts and main.py so the CLI and the
11
+ // web UI describe the same five engines in the same words.
12
+ export const TOOL_ORDER = ["semgrep", "bearer", "osv-scanner", "gitleaks", "codeql"];
13
+
14
+ export const TOOL_KIND = {
15
+ semgrep: "SAST",
16
+ bearer: "SAST + privacy",
17
+ "osv-scanner": "SCA",
18
+ gitleaks: "Secrets",
19
+ codeql: "Taint analysis",
20
+ };
21
+
22
+ // Backend graph node name → the tool whose row it fills in.
23
+ const NODE_TO_TOOL = {
24
+ run_semgrep: "semgrep",
25
+ run_bearer: "bearer",
26
+ run_osv_scanner: "osv-scanner",
27
+ run_gitleaks: "gitleaks",
28
+ run_codeql: "codeql",
29
+ };
30
+
31
+ export const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
32
+
33
+ export const STAGE_LABELS = {
34
+ dispatch: "Resolving target",
35
+ scan: "Running 5 scanners in parallel",
36
+ merge: "Merging and deduplicating",
37
+ rag: "Retrieving similar known-vulnerable patterns",
38
+ triage: "AI triage",
39
+ fixes: "Generating fixes",
40
+ report: "Building report",
41
+ done: "Complete",
42
+ };
43
+
44
+ export class ScanMonitor {
45
+ constructor(target, includeCodeql) {
46
+ this.target = target;
47
+ this.includeCodeql = includeCodeql;
48
+ this.started = Date.now();
49
+ this.stage = "dispatch";
50
+ this.language = "detecting…";
51
+
52
+ this.tools = {};
53
+ for (const name of TOOL_ORDER) {
54
+ this.tools[name] = { state: "pending", count: 0, duration: 0, note: "", sample: false };
55
+ }
56
+ if (!includeCodeql) {
57
+ this.tools.codeql = {
58
+ state: "skipped", count: 0, duration: 0, note: "skipped (--fast)", sample: false,
59
+ };
60
+ }
61
+
62
+ this.mergeStats = {};
63
+ this.safePatterns = 0;
64
+ this.ragMatched = null;
65
+ this.ragTotal = 0;
66
+
67
+ this.triageDone = 0;
68
+ this.triageTotal = 0;
69
+ this.confirmed = 0;
70
+ this.discarded = 0;
71
+
72
+ this.fixes = 0;
73
+ this.report = {};
74
+ this.errors = [];
75
+ }
76
+
77
+ startScanners() {
78
+ for (const row of Object.values(this.tools)) {
79
+ if (row.state === "pending") row.state = "running";
80
+ }
81
+ }
82
+
83
+ elapsedSeconds() {
84
+ return (Date.now() - this.started) / 1000;
85
+ }
86
+
87
+ /** Fold one `node_update` into the running totals. */
88
+ ingest(node, state) {
89
+ const s = state || {};
90
+
91
+ if (node === "dispatch") {
92
+ this.language = s.codeql_language || "auto";
93
+ this.stage = "scan";
94
+ this.startScanners();
95
+ return;
96
+ }
97
+
98
+ if (node in NODE_TO_TOOL) {
99
+ const tool = NODE_TO_TOOL[node];
100
+ const status = (s.tool_status || {})[tool] || {};
101
+ const skipped = String(status.note || "").toLowerCase().includes("skipped");
102
+ this.tools[tool] = {
103
+ state: skipped ? "skipped" : status.ok ? "ok" : "failed",
104
+ count: status.finding_count || 0,
105
+ duration: status.duration_s || 0,
106
+ note: status.note || "",
107
+ sample: Boolean(status.used_sample),
108
+ };
109
+ return;
110
+ }
111
+
112
+ if (node === "merge_findings") {
113
+ this.stage = "merge";
114
+ this.mergeStats = s.merge_stats || {};
115
+ this.safePatterns = (s.safe_patterns || []).length;
116
+ return;
117
+ }
118
+
119
+ if (node === "retrieve_context") {
120
+ this.stage = "rag";
121
+ const findings = s.merged_findings || [];
122
+ this.ragTotal = findings.length;
123
+ this.ragMatched = findings.filter((f) => (f.context || []).length).length;
124
+ return;
125
+ }
126
+
127
+ if (node === "triage") {
128
+ this.stage = "triage";
129
+ const progress = s.triage_progress || {};
130
+ if (progress.done !== undefined && progress.done !== null) this.triageDone = progress.done;
131
+ if (progress.total !== undefined && progress.total !== null) this.triageTotal = progress.total;
132
+ return;
133
+ }
134
+
135
+ if (node === "confirm_finding") {
136
+ this.confirmed += (s.triaged_findings || []).length || 1;
137
+ return;
138
+ }
139
+
140
+ if (node === "discard_finding") {
141
+ this.discarded += (s.false_positives || []).length || 1;
142
+ return;
143
+ }
144
+
145
+ if (node === "fix_generator") {
146
+ this.stage = "fixes";
147
+ this.fixes = (s.fixes || []).length;
148
+ this.errors.push(...(s.errors || []));
149
+ return;
150
+ }
151
+
152
+ if (node === "report_builder") {
153
+ this.stage = "report";
154
+ this.report = s.static_report || {};
155
+ }
156
+ }
157
+ }
package/src/pack.mjs ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Package a local directory as a gzipped tarball for upload to a remote backend.
3
+ *
4
+ * The tree is walked in Node — skipping the same directories/files the scanners
5
+ * ignore, and never following symlinks — and the resulting relative file list is
6
+ * handed to the system `tar` via `-T`. Walking ourselves (rather than leaning on
7
+ * tar's `--exclude` globbing, which differs between GNU tar and bsdtar) keeps the
8
+ * archive contents identical on Windows, macOS and Linux, and guarantees only
9
+ * regular files under the root make it in.
10
+ */
11
+
12
+ import { spawnSync } from "node:child_process";
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+
17
+ // CLI-side cap on the packaged archive; the backend enforces its own uncompressed
18
+ // limits on extraction. A repo bigger than this should be scanned as a Git URL.
19
+ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100 MB
20
+
21
+ // Mirrors EXCLUDE_DIRS / EXCLUDE_GLOBS in
22
+ // backend/app/agents/static_analysis/tools.py.
23
+ const EXCLUDE_DIRS = new Set([
24
+ "node_modules", ".git", ".venv", "venv", "__pycache__", "dist", "build",
25
+ ".next", "out", "coverage", "vendor", "site-packages", "target",
26
+ ".mypy_cache", ".pytest_cache", ".ruff_cache", ".turbo", ".cache",
27
+ ]);
28
+ const EXCLUDE_SUFFIXES = [".min.js", ".min.css", ".map", ".lock", ".bundle.js"];
29
+
30
+ const isExcludedFile = (name) => EXCLUDE_SUFFIXES.some((s) => name.endsWith(s));
31
+
32
+ export function hasTar() {
33
+ try {
34
+ const r = spawnSync("tar", ["--version"], { stdio: "ignore" });
35
+ return !r.error && r.status === 0;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ function walk(root) {
42
+ const files = [];
43
+ const stack = [""]; // relative paths, "" = root
44
+ while (stack.length) {
45
+ const rel = stack.pop();
46
+ const abs = rel ? path.join(root, rel) : root;
47
+ let entries;
48
+ try {
49
+ entries = fs.readdirSync(abs, { withFileTypes: true });
50
+ } catch {
51
+ continue; // unreadable directory — skip it
52
+ }
53
+ for (const ent of entries) {
54
+ if (ent.isSymbolicLink()) continue; // never archive links
55
+ const childRel = rel ? `${rel}/${ent.name}` : ent.name;
56
+ if (ent.isDirectory()) {
57
+ if (!EXCLUDE_DIRS.has(ent.name)) stack.push(childRel);
58
+ } else if (ent.isFile() && !isExcludedFile(ent.name)) {
59
+ files.push(childRel); // forward-slashed; tar accepts these on Windows too
60
+ }
61
+ }
62
+ }
63
+ return files;
64
+ }
65
+
66
+ function safeUnlink(p) {
67
+ try {
68
+ fs.unlinkSync(p);
69
+ } catch {
70
+ /* already gone */
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Build the tarball. Returns { path, fileCount, cleanup }. `cleanup()` removes
76
+ * the archive and must be called by the caller once the upload finishes.
77
+ */
78
+ export function packDir(dir) {
79
+ if (!hasTar()) {
80
+ throw new Error(
81
+ "`tar` was not found on your PATH, so local code can't be packaged for a remote backend.\n" +
82
+ " Fixes: install tar (bundled with Windows 10 1803+, macOS and Linux),\n" +
83
+ " scan a public Git repo URL instead (the backend clones it),\n" +
84
+ " or run the backend on this machine and drop --upload."
85
+ );
86
+ }
87
+
88
+ const files = walk(dir);
89
+ if (files.length === 0) {
90
+ throw new Error(`Nothing to scan under ${dir} (it's empty, or every file is excluded).`);
91
+ }
92
+
93
+ const stamp = `${process.pid}-${Date.now()}`;
94
+ const listPath = path.join(os.tmpdir(), `secura-files-${stamp}.txt`);
95
+ const archivePath = path.join(os.tmpdir(), `secura-upload-${stamp}.tar.gz`);
96
+
97
+ try {
98
+ fs.writeFileSync(listPath, files.join("\n") + "\n", "utf8");
99
+ // -C dir + relative names → the paths archive at the tarball root.
100
+ const r = spawnSync("tar", ["-czf", archivePath, "-C", dir, "-T", listPath], {
101
+ stdio: ["ignore", "ignore", "pipe"],
102
+ });
103
+ if (r.error) throw r.error;
104
+ if (r.status !== 0) {
105
+ const stderr = r.stderr ? r.stderr.toString().trim() : "";
106
+ throw new Error(`tar exited ${r.status}${stderr ? `: ${stderr}` : ""}`);
107
+ }
108
+ return {
109
+ path: archivePath,
110
+ fileCount: files.length,
111
+ cleanup: () => safeUnlink(archivePath),
112
+ };
113
+ } catch (err) {
114
+ safeUnlink(archivePath);
115
+ throw new Error(`Failed to package ${dir}: ${err.message}`);
116
+ } finally {
117
+ safeUnlink(listPath); // the list is consumed by tar; drop it either way
118
+ }
119
+ }