cinatra 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,538 @@
1
+ // -----------------------------------------------------------------------------
2
+ // Dev-only seed of the on-disk first-party extensions into the LOCAL bundled
3
+ // Verdaccio (cinatra#386).
4
+ //
5
+ // THE GAP: the repo ships ~80 first-party extension packages on disk at
6
+ // `extensions/<vendor>/<pkg>/` (e.g. `@cinatra-ai/blog-content-workflow`), but a
7
+ // fresh/bundled local Verdaccio (docker-compose `verdaccio` service at
8
+ // 127.0.0.1:4873) starts EMPTY — those packages were never published into it.
9
+ // The installer resolves REGISTRY-ONLY (pacote against the registry URL; no
10
+ // on-disk fallback), so a `GET /@cinatra-ai%2fblog-content-workflow` returns 404
11
+ // and the extension is uninstallable out of the box.
12
+ //
13
+ // THE FIX (the dev-side mirror of the production seeding contract): production
14
+ // installs resolve from immutable registry tarballs that a maintainer publishes
15
+ // to the production Verdaccio; here we do the dev equivalent — publish the
16
+ // on-disk first-party packages into the LOCAL registry during `cinatra setup
17
+ // dev`, so dev resolution matches the production "registry is the install
18
+ // backend" model WITHOUT teaching the installer a source-tree fallback (which
19
+ // would widen the install trust surface and let packaging defects hide until
20
+ // production).
21
+ //
22
+ // GUARDRAILS (codex-converged on cinatra#386):
23
+ // - DEV-ONLY: called only from the `mode === "dev"` block of `runSetup`,
24
+ // after the on-disk extension tree is materialized + manifests regenerated.
25
+ // Never on the prod setup path and never on any install path.
26
+ // - LOOPBACK-ONLY: the publish target is HARD-BOUND to a loopback host
27
+ // (127.0.0.1 / ::1 / localhost). A non-loopback registry URL is REJECTED
28
+ // before any publish — this seed must never push at a remote/production
29
+ // registry. Arbitrary env registry values are not honored as a publish
30
+ // target.
31
+ // - REACHABILITY-GATED: if the local registry is down/unreadable, warn and
32
+ // skip the whole step (loud-but-non-fatal — never abort setup).
33
+ // - TEMP AUTH ONLY: self-register a throwaway Verdaccio user, write the auth
34
+ // token only into a temp `--userconfig` file, and delete it in `finally`.
35
+ // The real `~/.npmrc` is never read or mutated.
36
+ // - IDEMPOTENT + NON-CLOBBERING: check the packument first; if `name@version`
37
+ // already exists, SKIP (never force-publish / unpublish / overwrite a
38
+ // dist-tag). Re-running setup is a cheap no-op.
39
+ // - VERSION-SKEW DETECTION: if `name@version` already exists but the local
40
+ // packed bytes differ from the registry tarball integrity, warn LOUDLY and
41
+ // set a non-zero exit code (don't republish the same version) — the operator
42
+ // must purge/reset the local Verdaccio or bump the extension version.
43
+ // - PRIVATE/SHAPE FILTER: only publish packages whose `package.json` has a
44
+ // valid `name` + `version` and is not `"private": true`.
45
+ // - FAILURE DISCIPLINE: a per-package publish failure warns, continues to the
46
+ // remaining packages, and leaves setup loud-but-non-fatal.
47
+ //
48
+ // This module is intentionally self-contained (node builtins + the `npm` binary
49
+ // that ships alongside node): like its `agents-install.mjs` sibling it cannot
50
+ // import the @cinatra-ai/registries TS source from a `.mjs` CLI script.
51
+ // -----------------------------------------------------------------------------
52
+
53
+ import {
54
+ existsSync,
55
+ mkdtempSync,
56
+ readFileSync,
57
+ readdirSync,
58
+ rmSync,
59
+ writeFileSync,
60
+ } from "node:fs";
61
+ import os from "node:os";
62
+ import path from "node:path";
63
+ import process from "node:process";
64
+ import { spawnSync } from "node:child_process";
65
+ import { createHash } from "node:crypto";
66
+
67
+ // The bundled local Verdaccio — the ONLY sanctioned publish target for this
68
+ // dev seed. Matches docker-compose `verdaccio` (host port 4873) and the
69
+ // `DEFAULT_REGISTRY_URL` the agents-install CLI already uses.
70
+ export const LOCAL_REGISTRY_URL = "http://127.0.0.1:4873";
71
+
72
+ // Hostnames that count as loopback. A publish target MUST resolve to one of
73
+ // these or the step refuses to run (never push at a remote/production registry).
74
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
75
+
76
+ // Per-call ceilings so a loopback service that ACCEPTS a connection then stalls
77
+ // can never hang setup. A timeout is always treated as warn-and-continue (the
78
+ // whole step is loud-but-non-fatal). Generous because publish writes a tarball.
79
+ const HTTP_TIMEOUT_MS = 15_000;
80
+ const PUBLISH_TIMEOUT_MS = 60_000;
81
+
82
+ /**
83
+ * Compare two dotted numeric version cores (`major.minor.patch[...]`). Returns
84
+ * 1 if `a > b`, -1 if `a < b`, 0 if equal. Pre-release/build metadata is
85
+ * ignored (split off the first `-`/`+`); a non-numeric segment compares as 0.
86
+ * Deliberately tiny — this only decides "would npm reject a lower publish?",
87
+ * not full semver precedence.
88
+ */
89
+ export function compareVersionCores(a, b) {
90
+ const core = (v) => String(v).split(/[-+]/)[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
91
+ const av = core(a);
92
+ const bv = core(b);
93
+ const len = Math.max(av.length, bv.length);
94
+ for (let i = 0; i < len; i++) {
95
+ const x = av[i] ?? 0;
96
+ const y = bv[i] ?? 0;
97
+ if (x > y) return 1;
98
+ if (x < y) return -1;
99
+ }
100
+ return 0;
101
+ }
102
+
103
+ /**
104
+ * True iff any published version is >= `onDiskVersion`. When this holds, `npm
105
+ * publish` of the on-disk (lower-or-equal) version would be rejected as a lower
106
+ * `latest`, so we skip gracefully instead of forcing a loud failure. When only
107
+ * LOWER versions exist (e.g. after an on-disk version bump), this is false and
108
+ * the caller proceeds to publish the new version.
109
+ */
110
+ export function registryHasAtLeast(packument, onDiskVersion) {
111
+ const versions = packument?.versions ? Object.keys(packument.versions) : [];
112
+ return versions.some((v) => compareVersionCores(v, onDiskVersion) >= 0);
113
+ }
114
+
115
+ /**
116
+ * True iff `registryUrl` is a well-formed http(s) URL whose host is loopback.
117
+ * Defensive: a parse failure (or any non-loopback host) returns false so the
118
+ * caller refuses to publish.
119
+ */
120
+ export function isLoopbackRegistryUrl(registryUrl) {
121
+ if (!registryUrl || typeof registryUrl !== "string") return false;
122
+ let parsed;
123
+ try {
124
+ parsed = new URL(registryUrl);
125
+ } catch {
126
+ return false;
127
+ }
128
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
129
+ return LOOPBACK_HOSTS.has(parsed.hostname);
130
+ }
131
+
132
+ /**
133
+ * Enumerate the on-disk first-party extension package dirs under
134
+ * `<repoRoot>/extensions/<vendor>/<pkg>/` that carry a publishable
135
+ * `package.json` (valid name+version, not private). Returns sorted entries
136
+ * `{ dir, name, version, private }` for deterministic ordering.
137
+ */
138
+ export function enumeratePublishableExtensions(repoRoot) {
139
+ const extensionsRoot = path.join(repoRoot, "extensions");
140
+ const out = [];
141
+ if (!existsSync(extensionsRoot)) return out;
142
+
143
+ let vendors;
144
+ try {
145
+ vendors = readdirSync(extensionsRoot, { withFileTypes: true });
146
+ } catch {
147
+ return out;
148
+ }
149
+
150
+ for (const vendor of vendors) {
151
+ if (!vendor.isDirectory()) continue;
152
+ const vendorDir = path.join(extensionsRoot, vendor.name);
153
+ let pkgs;
154
+ try {
155
+ pkgs = readdirSync(vendorDir, { withFileTypes: true });
156
+ } catch {
157
+ continue;
158
+ }
159
+ for (const pkg of pkgs) {
160
+ if (!pkg.isDirectory()) continue;
161
+ const dir = path.join(vendorDir, pkg.name);
162
+ const manifestPath = path.join(dir, "package.json");
163
+ if (!existsSync(manifestPath)) continue;
164
+ let manifest;
165
+ try {
166
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
167
+ } catch {
168
+ // Unreadable/invalid package.json — skip (a per-package warn happens
169
+ // at the call site only for things we actually try to publish).
170
+ continue;
171
+ }
172
+ if (manifest.private === true) continue;
173
+ if (typeof manifest.name !== "string" || !manifest.name) continue;
174
+ if (typeof manifest.version !== "string" || !manifest.version) continue;
175
+ out.push({
176
+ dir,
177
+ name: manifest.name,
178
+ version: manifest.version,
179
+ private: false,
180
+ });
181
+ }
182
+ }
183
+
184
+ out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * Probe the registry root. Returns true on a 2xx HTTP response, false on any
190
+ * network error / non-2xx. Used to skip the whole step when Verdaccio is down.
191
+ */
192
+ async function isRegistryReachable(registryUrl) {
193
+ try {
194
+ const res = await fetch(new URL("/", registryUrl), {
195
+ method: "GET",
196
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
197
+ });
198
+ return res.ok;
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Read the registry packument for `name`. Returns the parsed JSON on 200, or
206
+ * `null` on 404 / network error / non-200 (treated as "not present yet").
207
+ */
208
+ async function fetchPackument(registryUrl, name) {
209
+ // Scoped names must be URL-escaped (`@scope/name` → `@scope%2fname`).
210
+ const escaped = name.replace("/", "%2f");
211
+ try {
212
+ const res = await fetch(new URL(`/${escaped}`, registryUrl), {
213
+ method: "GET",
214
+ headers: { accept: "application/json" },
215
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
216
+ });
217
+ if (res.status === 200) return await res.json();
218
+ return null;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ // The throwaway seed user. The password is DETERMINISTIC (and local-only): the
225
+ // only credential it ever guards is publish access to a loopback dev Verdaccio.
226
+ // Determinism is REQUIRED for idempotency — on a re-run the user already exists,
227
+ // so the second `cinatra setup dev` must be able to LOG BACK IN (anonymous
228
+ // adduser then returns 409) to get a fresh publish token. A random per-run
229
+ // password would lock the existing user out and break re-seeding.
230
+ const SEED_USER = "cinatra-dev-seed";
231
+ const SEED_PASSWORD = "cinatra-local-dev-seed-v1";
232
+ const SEED_EMAIL = "dev-seed@cinatra.local";
233
+
234
+ /**
235
+ * Provision (or re-authenticate) the throwaway seed user on the LOOPBACK
236
+ * registry and return a fresh publish token. Two-step:
237
+ * 1. Anonymous PUT to the couchdb adduser endpoint — creates the user the
238
+ * first time (Verdaccio enables anonymous registration by default).
239
+ * 2. If the user already exists (409 "already registered"), PUT again WITH
240
+ * Basic auth (the npm-login path) to mint a fresh token.
241
+ * Returns `null` on any failure (the caller then skips the whole step
242
+ * loud-but-non-fatally). NEVER include a response body in surfaced text — it may
243
+ * reflect the password back.
244
+ */
245
+ async function provisionSeedToken(registryUrl) {
246
+ const base = registryUrl.replace(/\/$/, "");
247
+ const url = `${base}/-/user/org.couchdb.user:${encodeURIComponent(SEED_USER)}`;
248
+ const body = {
249
+ _id: `org.couchdb.user:${SEED_USER}`,
250
+ name: SEED_USER,
251
+ password: SEED_PASSWORD,
252
+ email: SEED_EMAIL,
253
+ type: "user",
254
+ roles: [],
255
+ date: new Date().toISOString(),
256
+ };
257
+
258
+ // Step 1 — anonymous create (first-run).
259
+ try {
260
+ const res = await fetch(url, {
261
+ method: "PUT",
262
+ headers: { "content-type": "application/json", accept: "application/json" },
263
+ body: JSON.stringify(body),
264
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
265
+ });
266
+ if (res.status === 201 || res.status === 200) {
267
+ const parsed = await res.json().catch(() => null);
268
+ if (parsed && typeof parsed.token === "string" && parsed.token) return parsed.token;
269
+ return null;
270
+ }
271
+ // Any non-409 failure (e.g. registration disabled) → give up.
272
+ if (res.status !== 409) return null;
273
+ } catch {
274
+ return null;
275
+ }
276
+
277
+ // Step 2 — the user already exists; re-authenticate with Basic auth (npm
278
+ // login) using the deterministic seed password to mint a fresh token.
279
+ try {
280
+ const basic = Buffer.from(`${SEED_USER}:${SEED_PASSWORD}`).toString("base64");
281
+ const res = await fetch(url, {
282
+ method: "PUT",
283
+ headers: {
284
+ authorization: `Basic ${basic}`,
285
+ "content-type": "application/json",
286
+ accept: "application/json",
287
+ },
288
+ body: JSON.stringify({ name: SEED_USER, password: SEED_PASSWORD }),
289
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
290
+ });
291
+ if (res.status === 201 || res.status === 200) {
292
+ const parsed = await res.json().catch(() => null);
293
+ if (parsed && typeof parsed.token === "string" && parsed.token) return parsed.token;
294
+ }
295
+ return null;
296
+ } catch {
297
+ return null;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * `npm pack` the package dir into `outDir` and return the absolute tarball path
303
+ * (no publish). Used for version-skew integrity comparison. Returns null on
304
+ * failure.
305
+ */
306
+ function packTarball(dir, outDir, registryUrl, userconfigPath) {
307
+ const result = spawnSync(
308
+ "npm",
309
+ [
310
+ "pack",
311
+ "--pack-destination",
312
+ outDir,
313
+ "--registry",
314
+ registryUrl,
315
+ // Pure-source first-party packages — no pack lifecycle hooks are needed,
316
+ // and ignoring scripts keeps a malicious/accidental prepack out of setup.
317
+ "--ignore-scripts",
318
+ ],
319
+ {
320
+ cwd: dir,
321
+ encoding: "utf8",
322
+ env: { ...process.env, NPM_CONFIG_USERCONFIG: userconfigPath },
323
+ timeout: PUBLISH_TIMEOUT_MS,
324
+ },
325
+ );
326
+ if (result.status !== 0) return null;
327
+ // npm pack prints the produced filename on the last non-empty stdout line.
328
+ const lines = (result.stdout || "")
329
+ .split("\n")
330
+ .map((l) => l.trim())
331
+ .filter(Boolean);
332
+ const file = lines[lines.length - 1];
333
+ if (!file) return null;
334
+ const abs = path.join(outDir, path.basename(file));
335
+ return existsSync(abs) ? abs : null;
336
+ }
337
+
338
+ /** sha512 base64 integrity string (`sha512-<base64>`) for a tarball file. */
339
+ function tarballIntegrity(tarballPath) {
340
+ const bytes = readFileSync(tarballPath);
341
+ const digest = createHash("sha512").update(bytes).digest("base64");
342
+ return `sha512-${digest}`;
343
+ }
344
+
345
+ /**
346
+ * Seed every on-disk first-party extension into the LOCAL bundled Verdaccio.
347
+ *
348
+ * Loud-but-non-fatal: returns a summary and may set `process.exitCode = 1` on a
349
+ * meaningful failure/skew, but NEVER throws past the caller and NEVER aborts
350
+ * setup. Returns `{ status, registryUrl, published, skipped, failed, skew }`.
351
+ *
352
+ * `status`:
353
+ * - "skipped-not-loopback" registry target is not loopback → refused
354
+ * - "skipped-unreachable" local Verdaccio not up → nothing to do
355
+ * - "skipped-no-auth" could not self-register a publish user
356
+ * - "skipped-empty" no publishable extensions on disk
357
+ * - "ok" ran (see counts)
358
+ */
359
+ export async function seedLocalRegistryExtensions({
360
+ repoRoot,
361
+ registryUrl = LOCAL_REGISTRY_URL,
362
+ } = {}) {
363
+ const summary = {
364
+ status: "ok",
365
+ registryUrl,
366
+ published: [],
367
+ skipped: [],
368
+ failed: [],
369
+ skew: [],
370
+ divergentVersion: [],
371
+ };
372
+
373
+ // GUARDRAIL: loopback-only. Refuse any non-loopback publish target outright.
374
+ if (!isLoopbackRegistryUrl(registryUrl)) {
375
+ summary.status = "skipped-not-loopback";
376
+ console.warn(
377
+ `\n⚠ Local registry seed SKIPPED: publish target '${registryUrl}' is not a loopback ` +
378
+ `address. This dev seed only ever publishes to the local bundled Verdaccio.\n`,
379
+ );
380
+ return summary;
381
+ }
382
+
383
+ // GUARDRAIL: reachability. Verdaccio down → skip the whole step.
384
+ if (!(await isRegistryReachable(registryUrl))) {
385
+ summary.status = "skipped-unreachable";
386
+ console.log(
387
+ `- Local registry seed: skipped (Verdaccio not reachable at ${registryUrl}; ` +
388
+ `start the docker stack and re-run \`cinatra setup dev\` to seed bundled extensions).`,
389
+ );
390
+ return summary;
391
+ }
392
+
393
+ const extensions = enumeratePublishableExtensions(repoRoot);
394
+ if (extensions.length === 0) {
395
+ summary.status = "skipped-empty";
396
+ console.log("- Local registry seed: no publishable on-disk extensions found.");
397
+ return summary;
398
+ }
399
+
400
+ // GUARDRAIL: temp auth only. Self-register and write the token into a temp
401
+ // userconfig, removed in `finally` — the real ~/.npmrc is never touched.
402
+ const token = await provisionSeedToken(registryUrl);
403
+ if (!token) {
404
+ summary.status = "skipped-no-auth";
405
+ console.warn(
406
+ "\n⚠ Local registry seed SKIPPED: could not provision a publish user on the local " +
407
+ "Verdaccio (registration may be disabled). Bundled extensions will not be seeded.\n",
408
+ );
409
+ process.exitCode = 1;
410
+ return summary;
411
+ }
412
+
413
+ let tmpDir;
414
+ try {
415
+ tmpDir = mkdtempSync(path.join(os.tmpdir(), "cinatra-seed-registry-"));
416
+ const userconfigPath = path.join(tmpDir, ".npmrc");
417
+ const host = new URL(registryUrl).host;
418
+ writeFileSync(
419
+ userconfigPath,
420
+ `//${host}/:_authToken=${token}\nregistry=${registryUrl.replace(/\/$/, "")}/\n`,
421
+ { mode: 0o600 },
422
+ );
423
+ for (const ext of extensions) {
424
+ const id = `${ext.name}@${ext.version}`;
425
+ const packument = await fetchPackument(registryUrl, ext.name);
426
+ const existing = packument?.versions?.[ext.version];
427
+ // IDEMPOTENT: already-present exact version → skip (or skew-check).
428
+ if (existing) {
429
+ // VERSION-SKEW DETECTION: compare local packed bytes to the registry
430
+ // tarball integrity. A mismatch means the on-disk source diverged from
431
+ // the already-published version — warn loudly, do NOT republish.
432
+ const registryIntegrity =
433
+ typeof existing.dist?.integrity === "string" ? existing.dist.integrity : null;
434
+ if (registryIntegrity) {
435
+ const packed = packTarball(ext.dir, tmpDir, registryUrl, userconfigPath);
436
+ if (packed) {
437
+ const localIntegrity = tarballIntegrity(packed);
438
+ try {
439
+ rmSync(packed, { force: true });
440
+ } catch {
441
+ /* ignore */
442
+ }
443
+ if (localIntegrity !== registryIntegrity) {
444
+ summary.skew.push(id);
445
+ console.warn(
446
+ `\n⚠ Local registry seed: ${id} is already published but the on-disk source ` +
447
+ `differs from the published tarball. NOT republishing the same version. ` +
448
+ `Purge/reset the local Verdaccio or bump the extension version to refresh it.\n`,
449
+ );
450
+ process.exitCode = 1;
451
+ continue;
452
+ }
453
+ }
454
+ }
455
+ summary.skipped.push(id);
456
+ continue;
457
+ }
458
+
459
+ // DIVERGENT VERSION (dirty-registry tolerance): the package exists on the
460
+ // registry but NOT at the on-disk version, AND a version >= the on-disk
461
+ // version is already published (e.g. a higher version from a past ad-hoc
462
+ // publish). npm would reject the lower `latest` publish; this is not a
463
+ // fresh-instance failure, so record it as an informational skip (NON-fatal)
464
+ // and leave the existing version(s) in place.
465
+ //
466
+ // IMPORTANT: when only LOWER versions exist (e.g. the on-disk version was
467
+ // bumped since the last seed), this is FALSE — we fall through and publish
468
+ // the new version, so a version bump actually lands. On a truly fresh
469
+ // instance the packument is 404 → `packument` is null → we publish.
470
+ if (registryHasAtLeast(packument, ext.version)) {
471
+ const others = Object.keys(packument.versions).join(", ");
472
+ summary.divergentVersion.push(id);
473
+ console.log(
474
+ `- Local registry seed: ${id} not published — the local registry already has ` +
475
+ `${ext.name} at a version >= ${ext.version} (${others}). Leaving the existing version(s) in place.`,
476
+ );
477
+ continue;
478
+ }
479
+
480
+ // PUBLISH: `npm publish <dir>` against the loopback registry with the temp
481
+ // userconfig. Per-package failure warns + continues (loud-but-non-fatal).
482
+ const result = spawnSync(
483
+ "npm",
484
+ [
485
+ "publish",
486
+ ext.dir,
487
+ "--registry",
488
+ registryUrl,
489
+ // default access keeps scoped packages public on Verdaccio.
490
+ "--access",
491
+ "public",
492
+ // Pure-source first-party packages — no publish lifecycle hooks are
493
+ // needed; ignoring scripts keeps a prepublish hook out of setup.
494
+ "--ignore-scripts",
495
+ ],
496
+ {
497
+ encoding: "utf8",
498
+ env: { ...process.env, NPM_CONFIG_USERCONFIG: userconfigPath },
499
+ timeout: PUBLISH_TIMEOUT_MS,
500
+ },
501
+ );
502
+ if (result.status === 0) {
503
+ summary.published.push(id);
504
+ } else {
505
+ summary.failed.push(id);
506
+ const stderr = (result.stderr || "").trim().split("\n").slice(-3).join("\n");
507
+ console.warn(`\n⚠ Local registry seed: failed to publish ${id}\n ${stderr}\n`);
508
+ process.exitCode = 1;
509
+ }
510
+ }
511
+
512
+ console.log(
513
+ `- Local registry seed: ${summary.published.length} published, ` +
514
+ `${summary.skipped.length} already present, ${summary.failed.length} failed` +
515
+ (summary.divergentVersion.length
516
+ ? `, ${summary.divergentVersion.length} different-version (left as-is)`
517
+ : "") +
518
+ (summary.skew.length ? `, ${summary.skew.length} version-skew (NOT republished)` : "") +
519
+ ` (bundled extensions → ${registryUrl}).`,
520
+ );
521
+ } catch (err) {
522
+ // Any unexpected escape is loud-but-non-fatal — setup is not rolled back.
523
+ console.warn(
524
+ `\n⚠ Local registry seed encountered an error:\n ${err && err.message ? err.message : err}\n`,
525
+ );
526
+ process.exitCode = 1;
527
+ } finally {
528
+ if (tmpDir) {
529
+ try {
530
+ rmSync(tmpDir, { recursive: true, force: true });
531
+ } catch {
532
+ /* best-effort cleanup */
533
+ }
534
+ }
535
+ }
536
+
537
+ return summary;
538
+ }