@rightkit/release 0.2.164 → 0.2.172

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.
Files changed (77) hide show
  1. package/README.md +121 -4
  2. package/addon-command.mjs +6 -13
  3. package/android-aab-scan.mjs +265 -0
  4. package/android-dev.mjs +111 -0
  5. package/android-ndk.mjs +77 -0
  6. package/android-play-upload.mjs +262 -0
  7. package/android-release.mjs +344 -0
  8. package/android-test-fixtures.mjs +79 -0
  9. package/apple-profile-create.mjs +363 -0
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/darwin/rightkit-swap.sign.json +6 -0
  12. package/build-release.mjs +73 -13
  13. package/cargo-guard.mjs +2 -0
  14. package/cargo-profile-contract.mjs +183 -0
  15. package/cargo-target.mjs +8 -0
  16. package/cli/right-release.mjs +14 -4
  17. package/committed-source.mjs +1 -1
  18. package/dev-artifact-refresh.mjs +176 -0
  19. package/dev-artifact-support.mjs +61 -0
  20. package/development-signing.mjs +71 -4
  21. package/development.mjs +78 -46
  22. package/direct-bootstrap.mjs +26 -41
  23. package/disk-command.mjs +315 -13
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +141 -22
  26. package/disk-stable-root.mjs +19 -16
  27. package/engine-locator.mjs +116 -0
  28. package/external-bin.mjs +133 -0
  29. package/github-release.mjs +2 -2
  30. package/github-updater-release.mjs +154 -0
  31. package/hardeningscan.mjs +1 -1
  32. package/in-place-install.mjs +56 -0
  33. package/install-app-windows.mjs +63 -0
  34. package/install-app.mjs +53 -23
  35. package/local-publish.mjs +11 -1
  36. package/mac-signing-matrix.mjs +23 -11
  37. package/managed-cargo.mjs +6 -0
  38. package/media-sidecar-catalog.mjs +45 -0
  39. package/media-sidecar.mjs +306 -0
  40. package/ownership-approval-command.mjs +10 -44
  41. package/ownership-approval.mjs +3 -42
  42. package/ownership-catalog-config.json +2 -1
  43. package/ownership-catalog.mjs +4 -2
  44. package/ownership-command.mjs +7 -9
  45. package/ownership-dependency-allowlist.json +25 -1
  46. package/ownership-evidence.mjs +8 -5
  47. package/ownership-git-inventory.mjs +41 -7
  48. package/ownership-lockfiles.mjs +91 -8
  49. package/ownership-mandatory-set.json +36 -16
  50. package/ownership-report.mjs +5 -6
  51. package/ownership-scan-worker.mjs +2 -2
  52. package/ownership-verdict.mjs +10 -7
  53. package/ownership.mjs +47 -29
  54. package/package.json +7 -4
  55. package/portable-development.mjs +32 -115
  56. package/preflight.mjs +4 -6
  57. package/publication-artifacts.mjs +34 -0
  58. package/publish-update.mjs +72 -11
  59. package/qa-adoption-exemptions.json +5 -0
  60. package/qa-build-features.mjs +7 -0
  61. package/qa-contract.mjs +182 -0
  62. package/qa-control-scan.mjs +3 -3
  63. package/release-qa-command.mjs +38 -12
  64. package/release-qa-installed.mjs +136 -0
  65. package/release-qa-receipt.mjs +23 -2
  66. package/release.mjs +283 -14
  67. package/rightkit-ownership.json +537 -5
  68. package/rightkit-sync.mjs +26 -0
  69. package/rightkit-versions.json +110 -62
  70. package/runtime-budget-classes.json +28 -0
  71. package/runtime-budgets.mjs +4 -1
  72. package/sign-candidate.mjs +161 -0
  73. package/sign-dev-artifact.mjs +198 -0
  74. package/stage-binary.mjs +60 -0
  75. package/swap-helper-bundle.mjs +105 -0
  76. package/templates/cargo-profiles.toml +28 -0
  77. package/upload-release.mjs +11 -1
package/disk-policy.mjs CHANGED
@@ -10,6 +10,12 @@ export const DEFAULT_POLICY_FILE = path.join(path.dirname(fileURLToPath(import.m
10
10
  export function readDiskPolicy(file = DEFAULT_POLICY_FILE) {
11
11
  const policy = JSON.parse(readFileSync(file, "utf8"));
12
12
  if (policy.schema !== 1 || !Array.isArray(policy.writers)) throw new Error("disk policy: unsupported schema");
13
+ for (const w of policy.writers) {
14
+ // `escalate` lets a report-only cache sweep its idle children once it is over cap by `factor` (see planSweep).
15
+ if (!w.escalate) continue;
16
+ const e = w.escalate;
17
+ if (w.action !== "report" || w.unit !== "children" || !(e.factor >= 1) || !(e.staleDays >= 0)) throw new Error(`disk policy: ${w.id} escalate needs action report, unit children, factor >= 1, staleDays >= 0`);
18
+ }
13
19
  return policy;
14
20
  }
15
21
 
@@ -112,6 +118,20 @@ export function isInsideRoot(candidate, root) {
112
118
  } catch { return false; }
113
119
  }
114
120
 
121
+ // True when a directory strictly between `literal` (the writer's literal root) and `target` is a symlink: a cache reached
122
+ // through a symlinked cache dir (some Mac caches now live on /Volumes/D) is never swept.
123
+ export function symlinkAncestor(target, literal) {
124
+ const rel = path.relative(literal, target);
125
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false;
126
+ const parts = rel.split(path.sep).slice(0, -1);
127
+ let cur = literal;
128
+ for (const part of parts) {
129
+ cur = path.join(cur, part);
130
+ try { if (lstatSync(cur).isSymbolicLink()) return true; } catch { return false; }
131
+ }
132
+ return false;
133
+ }
134
+
115
135
  export function writerMatches(writer, ctx) {
116
136
  const pattern = expandPolicyPath(writer.path, ctx);
117
137
  return expandGlob(pattern);
@@ -160,9 +180,33 @@ export function ensureScratchRoots({ policy, platform = process.platform, env =
160
180
 
161
181
  /** Top-level entries of each workspace root that are neither tracked by the workspace repo, nested git checkouts,
162
182
  * nor on the allowlist. Symlinks are listed (`symlink: true`) but sweep never touches them. */
183
+ // Names that are always known at a workspace root, whatever the policy file says: agent instructions and agent state.
184
+ export const WORKSPACE_ALWAYS_ALLOWED = ["CLAUDE.md", "AGENTS.md", "CLAUDE.local.md", ".claude", ".codex", ".agent", ".agents", ".membrane", "cockpit", "contributions"];
185
+ const PROJECT_MARKERS = [".git", "package.json", "Cargo.toml"];
186
+
187
+ /** Host-local opt-in for moving workspace-unknown entries. Without it the target is report-only on every host. */
188
+ export function workspaceSweepFlagPath({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
189
+ if (platform === "win32" || platform === "win") {
190
+ if (!env.LOCALAPPDATA) return null;
191
+ return path.win32.join(env.LOCALAPPDATA, "Orthic Labs", "rightkit-release", "workspace-sweep-allowed");
192
+ }
193
+ return path.posix.join(home, "Library", "Application Support", "Orthic Labs", "rightkit-release", "workspace-sweep-allowed");
194
+ }
195
+ export function workspaceSweepAllowed(options = {}) {
196
+ const file = workspaceSweepFlagPath(options);
197
+ return Boolean(file) && existsSync(file);
198
+ }
199
+ export function setWorkspaceSweepAllowed(on, options = {}) {
200
+ const file = workspaceSweepFlagPath(options);
201
+ if (!file) throw new Error("disk: cannot place the host-local flag (LOCALAPPDATA is not set)");
202
+ if (on) { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, `workspace-unknown sweep allowed on this host ${new Date().toISOString()}\n`); }
203
+ else renameSync(file, `${file}.revoked`);
204
+ return file;
205
+ }
206
+
163
207
  export function scanWorkspaceRoots({ policy, platform = process.platform, env = process.env, home = os.homedir(), tracked = (r) => workspaceTracked(r, { platform }) } = {}) {
164
208
  const key = platformKey(platform);
165
- const allow = new Set((policy.workspaceAllowlist ?? []).map((n) => fold(n, key)));
209
+ const allow = new Set([...WORKSPACE_ALWAYS_ALLOWED, ...(policy.workspaceAllowlist ?? [])].map((n) => fold(n, key)));
166
210
  const unknown = [];
167
211
  const skipped = [];
168
212
  for (const entry of policy.workspaceRoots?.[key] ?? []) {
@@ -177,10 +221,12 @@ export function scanWorkspaceRoots({ policy, platform = process.platform, env =
177
221
  for (const name of names) {
178
222
  const folded = fold(name, key);
179
223
  if (allow.has(folded) || extra.has(folded) || trackedNames.has(folded)) continue;
224
+ if (/\.md$/i.test(name)) continue; // any markdown file at the root is documentation
180
225
  const full = path.join(root, name);
181
226
  let st;
182
227
  try { st = lstatSync(full); } catch { continue; }
183
- if (!st.isSymbolicLink() && existsSync(path.join(full, ".git"))) continue; // nested git checkout
228
+ // a nested git checkout or any project directory (own package.json / Cargo.toml) is known
229
+ if (!st.isSymbolicLink() && st.isDirectory() && PROJECT_MARKERS.some((m) => existsSync(path.join(full, m)))) continue;
184
230
  unknown.push({ root, name, path: full, symlink: st.isSymbolicLink(), mtimeMs: st.mtimeMs });
185
231
  }
186
232
  }
@@ -206,7 +252,7 @@ export function checkDisk({ policy, platform = process.platform, env = process.e
206
252
  for (const w of writers) if (w.over) warnings.push(`${w.id} holds ${formatBytes(w.bytes)} over its ${formatBytes(w.capBytes)} cap`);
207
253
  const scanned = scan({ policy, platform, env, home });
208
254
  const unknown = scanned.unknown.map((u) => ({ root: u.root, name: u.name, path: u.path, symlink: u.symlink, bytes: u.symlink ? 0 : size(u.path), ageDays: Math.max(0, Math.floor((now - u.mtimeMs) / 86400_000)) }));
209
- for (const u of unknown) warnings.push(`unknown top-level entry ${u.path} (${u.symlink ? "symlink" : formatBytes(u.bytes)}, ${u.ageDays}d old): track it, add it to workspaceAllowlist, or let sweep move it`);
255
+ for (const u of unknown) warnings.push(`unknown top-level entry ${u.path} (${u.symlink ? "symlink" : formatBytes(u.bytes)}, ${u.ageDays}d old): track it or add it to workspaceAllowlist (sweep only moves it on a host that ran \`disk allow-workspace-sweep\`)`);
210
256
  for (const s of scanned.skipped) warnings.push(`workspace root ${s.path}: tracked paths cannot be read from git, so unknown entries are not evaluated or swept`);
211
257
  return { schema: 1, platform: key, floor, writers, unknown, warnings, warn: warnings.length > 0 };
212
258
  }
@@ -272,18 +318,25 @@ export function gitTracksPath(target) {
272
318
  return String(listed.stdout).length > 0;
273
319
  }
274
320
 
275
- export function planSweep({ policy, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), staleDays, openPaths, inUse, tracked = gitTracksPath, size = (p) => pathSize(p, { platform }), scan = scanWorkspaceRoots } = {}) {
321
+ export function planSweep({ policy, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), staleDays, openPaths, inUse, tracked = gitTracksPath, size = (p) => pathSize(p, { platform }), scan = scanWorkspaceRoots, workspaceSweep = workspaceSweepAllowed({ platform, env, home }) } = {}) {
276
322
  const key = platformKey(platform);
277
323
  const effectiveDays = staleDays ?? policy.staleDays;
278
324
  const candidates = [];
279
325
  const skipped = [];
280
326
  const seen = new Set();
281
327
  const lockCheck = inUse ?? (key === "win" ? windowsLockProbe : (p) => inUseByOpenPaths(p, openPaths));
282
- for (const writer of policy.writers.filter((w) => w.platform === key && w.action === "sweep")) {
283
- // `--days N` overrides every writer; otherwise a writer may carry its own window (QA evidence: 3 days).
284
- const cutoff = now - (staleDays ?? writer.staleDays ?? policy.staleDays) * 86400_000;
328
+ // Sweep writers, plus report writers with `escalate` when they are over cap by its factor (pure caches: idle children go).
329
+ for (const writer of policy.writers.filter((w) => w.platform === key && (w.action === "sweep" || w.escalate))) {
285
330
  let roots; let literal;
286
331
  try { roots = writerMatches(writer, { env, home }); literal = literalRoot(expandPolicyPath(writer.path, { env, home })); } catch { continue; }
332
+ // `--days N` overrides every writer; otherwise a writer may carry its own window (QA evidence: 3 days, caches: 2).
333
+ let window = staleDays ?? writer.staleDays ?? policy.staleDays;
334
+ if (writer.action !== "sweep") {
335
+ const total = roots.reduce((sum, r) => sum + size(r), 0);
336
+ if (!(total > writer.capBytes * writer.escalate.factor)) continue;
337
+ window = staleDays ?? writer.escalate.staleDays;
338
+ }
339
+ const cutoff = now - window * 86400_000;
287
340
  const units = [];
288
341
  for (const r of roots) {
289
342
  if (writer.unit !== "children") { units.push({ unit: r, root: literal }); continue; }
@@ -302,6 +355,7 @@ export function planSweep({ policy, platform = process.platform, env = process.e
302
355
  try { st = lstatSync(unit); } catch { continue; }
303
356
  if (st.isSymbolicLink()) { skipped.push({ ...base, reason: "symlink" }); continue; }
304
357
  if (!isInsideRoot(unit, root)) { skipped.push({ ...base, reason: "outside-root" }); continue; }
358
+ if (symlinkAncestor(unit, literal)) { skipped.push({ ...base, reason: "symlink-ancestor" }); continue; }
305
359
  if (isProtectedPath(unit, policy)) { skipped.push({ ...base, reason: "protected" }); continue; }
306
360
  if (writer.skipTracked && tracked(unit)) { skipped.push({ ...base, reason: "tracked" }); continue; }
307
361
  if (hasFreshContent(unit, cutoff)) { skipped.push({ ...base, reason: "fresh" }); continue; }
@@ -309,6 +363,10 @@ export function planSweep({ policy, platform = process.platform, env = process.e
309
363
  candidates.push({ ...base, root, bytes: size(unit), mtime: new Date(st.mtimeMs).toISOString() });
310
364
  }
311
365
  }
366
+ // Overlapping writers (a whole cache dir and one of its children): the outer unit wins, the nested one is not moved twice.
367
+ const nested = candidates.filter((c) => candidates.some((o) => o !== c && c.path.startsWith(o.path + path.sep)));
368
+ for (const c of nested) skipped.push({ writer: c.writer, path: c.path, reason: "nested" });
369
+ if (nested.length) candidates.splice(0, candidates.length, ...candidates.filter((c) => !nested.includes(c)));
312
370
  // Allowlisted workspace roots: unknown top-level entries older than 3 days and idle.
313
371
  const scanned = scan({ policy, platform, env, home });
314
372
  skipped.push(...scanned.skipped);
@@ -326,7 +384,11 @@ export function planSweep({ policy, platform = process.platform, env = process.e
326
384
  if (lockCheck(unit)) { skipped.push({ ...base, reason: "in-use" }); return; }
327
385
  candidates.push({ ...base, root, bytes: size(unit), mtime: new Date(st.mtimeMs).toISOString() });
328
386
  };
329
- for (const u of scanned.unknown) consider("workspace-unknown", u.path, u.root, unknownCutoff);
387
+ // Report-only unless this host opted in (`disk allow-workspace-sweep`): unknown entries are warned about, never moved.
388
+ for (const u of scanned.unknown) {
389
+ if (!workspaceSweep) { skipped.push({ writer: "workspace-unknown", path: u.path, reason: "report-only" }); continue; }
390
+ consider("workspace-unknown", u.path, u.root, unknownCutoff);
391
+ }
330
392
  // Scratch roots: every child older than 3 days and idle (the same window).
331
393
  for (const root of scratchRoots(policy, { platform, env, home })) {
332
394
  let rst;
@@ -373,26 +435,52 @@ const isUnder = (candidate, prefix, win) => {
373
435
  return c === p || c.startsWith(withSep);
374
436
  };
375
437
 
438
+ // True when a volume is mounted at `mount` and is not the item's own volume (`itemDev`).
439
+ export function defaultMounted(mount, itemDev) {
440
+ try {
441
+ const dev = deviceOf(mount);
442
+ return existsSync(mount) && dev !== null && dev !== itemDev;
443
+ } catch { return false; }
444
+ }
445
+
376
446
  // Candidate holding bases for an item, best first: policy per-volume rules (longest prefix), then the item's own volume root.
447
+ // A rule with `mount` is a cross-volume base: it applies only to items on the policy floor's volume (the Mac internal drive),
448
+ // and only while that mount is present (checked at pick time). Entries are `{ path, mount? }`.
377
449
  export function holdingBases(candidate, { policy, platform = process.platform, env = process.env, home = os.homedir() } = {}) {
378
450
  const key = platformKey(platform);
379
451
  const out = [];
452
+ let floorDev;
380
453
  const rules = (policy.holding?.[key] ?? []).flatMap((r) => {
381
- try { return [{ prefix: expandPolicyPath(r.prefix, { env, home }), path: expandPolicyPath(r.path, { env, home }) }]; } catch { return []; }
454
+ try {
455
+ return [{ prefix: expandPolicyPath(r.prefix, { env, home }), path: expandPolicyPath(r.path, { env, home }), mount: r.mount ? expandPolicyPath(r.mount, { env, home }) : undefined }];
456
+ } catch { return []; }
382
457
  }).filter((r) => isUnder(candidate, r.prefix, key === "win")).sort((a, b) => b.prefix.length - a.prefix.length);
383
- for (const r of rules) out.push(r.path);
384
- out.push(path.join(volumeRoot(path.dirname(candidate)), HOLDING_DIR));
458
+ for (const r of rules) {
459
+ if (r.mount) {
460
+ if (floorDev === undefined) {
461
+ const floorPath = policy.floors?.[key]?.path;
462
+ floorDev = floorPath ? deviceOf(expandPolicyPath(floorPath, { env, home })) : null;
463
+ }
464
+ if (floorDev === null || deviceOf(path.dirname(candidate)) !== floorDev) continue;
465
+ }
466
+ out.push({ path: r.path, ...(r.mount ? { mount: r.mount } : {}) });
467
+ }
468
+ out.push({ path: path.join(volumeRoot(path.dirname(candidate)), HOLDING_DIR) });
385
469
  return out;
386
470
  }
387
471
 
388
- // Create (and mark) today's holding dir on the item's own volume; null when no holding dir on that volume is possible.
389
- export function pickHolding(candidate, { policy, platform = process.platform, env = process.env, home = os.homedir(), date = new Date(), bases } = {}) {
472
+ // Create (and mark) today's holding dir; null when no holding dir is possible. A same-volume base must sit on the item's
473
+ // own volume; a mount base must sit on a mounted volume other than the item's (`isMounted`, injectable for tests).
474
+ export function pickHolding(candidate, { policy, platform = process.platform, env = process.env, home = os.homedir(), date = new Date(), bases, isMounted = defaultMounted } = {}) {
390
475
  const dev = deviceOf(path.dirname(candidate));
391
- for (const base of bases ?? holdingBases(candidate, { policy, platform, env, home })) {
476
+ for (const entry of bases ?? holdingBases(candidate, { policy, platform, env, home })) {
477
+ const base = typeof entry === "string" ? entry : entry.path;
478
+ const mount = typeof entry === "string" ? undefined : entry.mount;
392
479
  try {
393
- if (deviceOf(base) !== dev) continue; // checked before creating anything: a base on another volume would free nothing
480
+ // checked before creating anything: a same-volume base on another volume would free nothing
481
+ if (mount ? !isMounted(mount, dev) : deviceOf(base) !== dev) continue;
394
482
  mkdirSync(base, { recursive: true });
395
- if (deviceOf(base) !== dev || lstatSync(base).isSymbolicLink()) continue;
483
+ if (lstatSync(base).isSymbolicLink() || (!mount && deviceOf(base) !== dev)) continue;
396
484
  const marker = path.join(base, HOLDING_MARKER);
397
485
  if (!existsSync(marker)) writeFileSync(marker, "created by right-release disk; date-named children are purged after the retention window\n");
398
486
  const dir = path.join(base, date.toISOString().slice(0, 10));
@@ -403,14 +491,45 @@ export function pickHolding(candidate, { policy, platform = process.platform, en
403
491
  return null;
404
492
  }
405
493
 
494
+ // Entries (lstat-visited, symlinks not followed) and allocated bytes under `root`.
495
+ export function treeStats(root) {
496
+ let entries = 0;
497
+ let bytes = 0;
498
+ const stack = [root];
499
+ while (stack.length) {
500
+ const dir = stack.pop();
501
+ let st;
502
+ try { st = lstatSync(dir); } catch { continue; }
503
+ entries += 1;
504
+ bytes += st.blocks ? st.blocks * 512 : st.size;
505
+ if (!st.isDirectory()) continue;
506
+ let names = [];
507
+ try { names = readdirSync(dir); } catch { continue; }
508
+ for (const name of names) stack.push(path.join(dir, name));
509
+ }
510
+ return { entries, bytes };
511
+ }
512
+
513
+ const pathExists = (p) => { try { lstatSync(p); return true; } catch { return false; } };
514
+
406
515
  export function moveEntry(source, destination) {
407
516
  mkdirSync(path.dirname(destination), { recursive: true });
408
517
  try { renameSync(source, destination); return "rename"; } catch (error) {
409
518
  if (error.code !== "EXDEV") throw error;
410
519
  }
411
- const before = walkSize(source);
412
- cpSync(source, destination, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, errorOnExist: true, force: false });
413
- if (walkSize(destination) < before) throw new Error(`copy verification failed for ${source}; source kept`);
520
+ // Cross-volume: copy, verify the copy against the source, and only then remove the source. Never overwrite.
521
+ if (pathExists(destination)) throw new Error(`destination exists: ${destination}; source kept`);
522
+ const before = treeStats(source);
523
+ try {
524
+ cpSync(source, destination, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, errorOnExist: true, force: false });
525
+ const after = treeStats(destination);
526
+ if (after.entries !== before.entries || after.bytes < before.bytes) {
527
+ throw new Error(`copy verification failed for ${source} (entries ${before.entries} -> ${after.entries}, bytes ${before.bytes} -> ${after.bytes}); source kept`);
528
+ }
529
+ } catch (error) {
530
+ rmSync(destination, { recursive: true, force: true }); // our partial copy only: the source is untouched
531
+ throw error;
532
+ }
414
533
  rmSync(source, { recursive: true, force: true }); // completes the move after verified copy
415
534
  return "copy-verify-remove";
416
535
  }
@@ -422,17 +541,17 @@ export function logEvent(logFile, event) {
422
541
  } catch { /* logging must never fail the sweep */ }
423
542
  }
424
543
 
425
- export function applySweep({ plan, policy, platform = process.platform, deleteMode = false, date = new Date(), env = process.env, home = os.homedir(), move = moveEntry, pick = pickHolding, remove = (p) => rmSync(p, { recursive: true, force: true }) }) {
544
+ export function applySweep({ plan, policy, platform = process.platform, deleteMode = false, date = new Date(), env = process.env, home = os.homedir(), move = moveEntry, pick = pickHolding, isMounted, remove = (p) => rmSync(p, { recursive: true, force: true }) }) {
426
545
  const logFile = path.join(policy.heavyVolume[platformKey(platform)], "disk-hygiene.jsonl");
427
546
  const results = [];
428
547
  for (const c of plan.candidates) {
429
548
  try {
430
549
  // Re-verify at apply time: still the planned unit, not a symlink, still inside its root.
431
550
  if (!c.root || lstatSync(c.path).isSymbolicLink() || !isInsideRoot(c.path, c.root)) throw new Error("refused: path is not a sweepable unit inside its target root");
432
- if (c.writer === "workspace-unknown" && existsSync(path.join(c.path, ".git"))) throw new Error("refused: a nested git checkout is never swept");
551
+ if (c.writer === "workspace-unknown" && PROJECT_MARKERS.some((m) => existsSync(path.join(c.path, m)))) throw new Error("refused: a nested git checkout or project directory is never swept");
433
552
  if (deleteMode) { remove(c.path); results.push({ ...c, action: "delete", ok: true }); logEvent(logFile, { event: "disk.sweep.delete", ...c }); continue; }
434
553
  // An item stays on its own volume: moving it elsewhere frees nothing on the volume that is short of space.
435
- const holding = pick(c.path, { policy, platform, env, home, date });
554
+ const holding = pick(c.path, { policy, platform, env, home, date, isMounted });
436
555
  if (!holding) {
437
556
  remove(c.path);
438
557
  results.push({ ...c, action: "delete", how: "no-holding-on-volume", ok: true });
@@ -1,7 +1,8 @@
1
- import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "./background-process.mjs";
5
+ import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.mjs";
5
6
 
6
7
  // The scheduled disk job must never bind to a pnpm store, a pnpm global hash directory or a checkout: those move or vanish
7
8
  // on upgrade. `disk schedule install` copies the installed package into this per-user root and binds the job to it.
@@ -64,8 +65,8 @@ export function collectDependencies(pkgRoot) {
64
65
  }
65
66
 
66
67
  /** Copy the installed @rightkit/release package (and its dependencies) into the stable root. The new tree is built and
67
- * smoke-tested in a staging directory next to `current`, then swapped in by rename; a failed swap restores the previous
68
- * `current`. Returns `{ stable, cli, version, replaced, unchanged }`. */
68
+ * built in the fixed `current.staging`, swapped in by rename (the old copy stays as `current.previous`), then smoke-tested at
69
+ * `current`; a failed swap or smoke run renames `current.previous` back. Returns `{ stable, cli, version, replaced, unchanged }`. */
69
70
  export function installStableRoot({ cli, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), nodePath = process.execPath, smoke = true } = {}) {
70
71
  const stable = stableRootPath({ platform, env, home });
71
72
  const stableCli = stableCliPath(stable, platform);
@@ -81,10 +82,13 @@ export function installStableRoot({ cli, platform = process.platform, env = proc
81
82
 
82
83
  const parent = path.dirname(stable);
83
84
  mkdirSync(parent, { recursive: true });
85
+ // Legacy per-pid names from earlier versions; the fixed names are `current.staging` and `current.previous`.
84
86
  for (const name of readdirSync(parent)) {
85
87
  if (/^current\.(staging|prev)-/.test(name)) try { rmSync(path.join(parent, name), { recursive: true, force: true }); } catch { /* in use: leave it */ }
86
88
  }
87
- const staging = path.join(parent, `current.staging-${process.pid}-${now}`);
89
+ const staging = stagingPathOf(stable);
90
+ const previous = previousPathOf(stable);
91
+ rmSync(staging, { recursive: true, force: true });
88
92
  const copy = (from, to, filter) => cpSync(from, to, { recursive: true, dereference: true, force: true, filter });
89
93
  try {
90
94
  const target = path.join(staging, "node_modules", "@rightkit", "release");
@@ -98,30 +102,29 @@ export function installStableRoot({ cli, platform = process.platform, env = proc
98
102
  writeFileSync(path.join(staging, "install.json"), `${JSON.stringify({ package: pkg.name, version: pkg.version, source: pkgRoot, installedAt: new Date(now).toISOString(), node: nodePath }, null, 2)}\n`);
99
103
  const stagedCli = stableCliPath(staging, platform);
100
104
  if (!existsSync(stagedCli)) throw new Error("disk: the copied package has no cli/right-release.mjs");
101
- if (smoke) {
102
- const r = spawnSync(nodePath, [stagedCli, "--version"], { encoding: "utf8" });
103
- if (r.status !== 0) throw new Error(`disk: the staged copy does not run: ${r.stderr || r.stdout || r.error?.message}`);
104
- }
105
105
  } catch (error) {
106
106
  rmSync(staging, { recursive: true, force: true });
107
107
  throw error;
108
108
  }
109
+ // Swap first, then smoke-run at the fixed `current` path (never from the staging path). A failed run renames
110
+ // `current.previous` back. Exactly one `.previous` is kept.
109
111
  let replaced = false;
110
- let prev;
111
112
  try {
112
- if (existsSync(stable) || isLink(stable)) { prev = path.join(parent, `current.prev-${process.pid}-${now}`); renameSync(stable, prev); replaced = true; }
113
- renameSync(staging, stable);
113
+ const result = swapInPlace({
114
+ staging, destination: stable, previous,
115
+ verify: () => {
116
+ if (!smoke) return;
117
+ const r = spawnSync(nodePath, [stableCli, "--version"], { encoding: "utf8" });
118
+ if (r.status !== 0) throw new Error(`the installed copy does not run: ${r.stderr || r.stdout || r.error?.message}`);
119
+ },
120
+ });
121
+ replaced = result.previous !== null;
114
122
  } catch (error) {
115
- if (prev && !existsSync(stable)) try { renameSync(prev, stable); } catch { /* reported below */ }
116
- rmSync(staging, { recursive: true, force: true });
117
123
  throw new Error(`disk: could not replace ${stable}: ${error.message}`);
118
124
  }
119
- if (prev) try { rmSync(prev, { recursive: true, force: true }); } catch { /* a later install removes it */ }
120
125
  return { stable, cli: stableCli, version: pkg.version, replaced, unchanged: false };
121
126
  }
122
127
 
123
- function isLink(p) { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } }
124
-
125
128
  /** Registered schedule target: the CLI path the launchd job or Windows task runs, or null when none is registered. */
126
129
  export function readScheduleBinding({ platform = process.platform, home = os.homedir(), file, run = spawnSync, label = "RightKit Disk Hygiene", plistFile } = {}) {
127
130
  if (platform === "darwin") {
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // right-release engine-locate: the verified route to the CutRight and GenRight engines (Orthic-Labs/rightkit#316).
3
+ // Callers such as PostRight get the engine path ONLY from here. The route is fixed per platform (the installed app's stable
4
+ // `current` folder, see in-place-install.mjs) and every candidate is verified before its path is returned: a regular file (no
5
+ // symlink or junction), signed by the Orthic Labs release identity (macOS: Developer ID, hardened runtime, pinned Team ID and
6
+ // Identifier, not ad hoc; Windows: valid Authenticode, pinned signer organisation), and, when the caller pins one, an exact sha256.
7
+ // There is no environment override, no path argument and no PATH search: a path the user or a build supplies is never trusted.
8
+ import { createHash } from "node:crypto";
9
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import { spawnSync } from "./background-process.mjs";
14
+ import { assertPackagedIdentity, parseCodesignDisplay } from "./packaged-app-identity.mjs";
15
+
16
+ export const ENGINE_TEAM_IDENTIFIER = "6KLGD3LLKF";
17
+ export const ENGINE_SIGNER_ORGANIZATION = "Orthic Labs";
18
+
19
+ /** engine id -> installed app folder, executable name (no extension) and macOS codesign Identifier. */
20
+ export const ENGINES = Object.freeze({
21
+ cutright: Object.freeze({ app: "CutRight", exe: "cutright", identifier: "com.orthiclabs.cutright.engine" }),
22
+ genright: Object.freeze({ app: "GenRight", exe: "genright", identifier: "com.orthiclabs.genright.engine" }),
23
+ });
24
+
25
+ export class EngineLocateError extends Error {
26
+ constructor(code, message) {
27
+ super(message);
28
+ this.name = "EngineLocateError";
29
+ this.code = code;
30
+ }
31
+ }
32
+
33
+ const fail = (code, message) => { throw new EngineLocateError(code, message); };
34
+
35
+ /** The single fixed path for an engine on a platform. Reads no override variable: `localAppData` / `home` only name the user's profile roots. */
36
+ export function engineRoute(engine, { platform = process.platform, home = os.homedir(), localAppData = process.env.LOCALAPPDATA } = {}) {
37
+ const spec = ENGINES[engine];
38
+ if (!spec) fail("ENGINE_UNKNOWN", `unknown engine ${JSON.stringify(engine)} (cutright or genright)`);
39
+ if (platform === "win32") {
40
+ if (!localAppData) fail("ENGINE_ROUTE_UNAVAILABLE", "LOCALAPPDATA is not set, so the Windows engine route cannot be formed");
41
+ return path.win32.join(localAppData, "Orthic Labs", spec.app, "current", "bin", `${spec.exe}.exe`);
42
+ }
43
+ if (platform === "darwin") return path.posix.join(home, "Library", "Application Support", "Orthic Labs", spec.app, "current", "bin", spec.exe);
44
+ return fail("ENGINE_PLATFORM_UNSUPPORTED", `no engine route for platform ${platform}`);
45
+ }
46
+
47
+ const capture = (command, args, run = spawnSync) => {
48
+ const result = run(command, args, { encoding: "utf8", windowsHide: true, shell: false });
49
+ return { status: result.status ?? 1, output: `${result.stdout ?? ""}\n${result.stderr ?? ""}` };
50
+ };
51
+
52
+ const defaultVerifyMac = (file, spec, run) => {
53
+ const strict = capture("codesign", ["--verify", "--strict", file], run);
54
+ if (strict.status !== 0) fail("ENGINE_SIGNATURE_INVALID", `codesign --verify --strict failed for ${file}: ${strict.output.trim()}`);
55
+ const display = capture("codesign", ["-dv", "--verbose=4", file], run);
56
+ if (display.status !== 0) fail("ENGINE_SIGNATURE_INVALID", `codesign could not read ${file}: ${display.output.trim()}`);
57
+ const failures = assertPackagedIdentity(parseCodesignDisplay(display.output), [], { identifier: spec.identifier, team: ENGINE_TEAM_IDENTIFIER });
58
+ if (failures.length) fail("ENGINE_IDENTITY_MISMATCH", `${file}: ${failures.join("; ")}`);
59
+ };
60
+
61
+ const defaultVerifyWindows = (file, _spec, run) => {
62
+ const script = "$s = Get-AuthenticodeSignature -LiteralPath $args[0]; [pscustomobject]@{ status = [string]$s.Status; subject = if ($s.SignerCertificate) { $s.SignerCertificate.Subject } else { '' } } | ConvertTo-Json -Compress";
63
+ const result = capture("powershell", ["-NoProfile", "-NonInteractive", "-Command", script, file], run);
64
+ let parsed;
65
+ try { parsed = JSON.parse(result.output.trim().split(/\r?\n/).filter(Boolean).at(-1)); } catch { fail("ENGINE_SIGNATURE_INVALID", `Authenticode could not be read for ${file}: ${result.output.trim()}`); }
66
+ if (parsed.status !== "Valid") fail("ENGINE_SIGNATURE_INVALID", `${file}: Authenticode status is ${parsed.status}, not Valid`);
67
+ if (!new RegExp(`(^|,\\s*)O=${ENGINE_SIGNER_ORGANIZATION}(,|$)`).test(parsed.subject)) fail("ENGINE_IDENTITY_MISMATCH", `${file}: signer ${JSON.stringify(parsed.subject)} is not ${ENGINE_SIGNER_ORGANIZATION}`);
68
+ };
69
+
70
+ /**
71
+ * Locate and verify an engine. Returns { engine, path, sha256, platform }. Throws EngineLocateError (ENGINE_NOT_INSTALLED,
72
+ * ENGINE_NOT_REGULAR_FILE, ENGINE_SIGNATURE_INVALID, ENGINE_IDENTITY_MISMATCH, ENGINE_HASH_MISMATCH, ...). Injectable for tests:
73
+ * `route`, `verifySignature`, `lstat`, `readBytes`. `expectedSha256` is an optional exact pin from the caller's own release record.
74
+ */
75
+ export function locateEngine(engine, {
76
+ platform = process.platform, home, localAppData, expectedSha256, run = spawnSync,
77
+ route = engineRoute(engine, { platform, ...(home ? { home } : {}), ...(localAppData ? { localAppData } : {}) }),
78
+ verifySignature, lstat = lstatSync, readBytes = readFileSync, exists = existsSync,
79
+ } = {}) {
80
+ const spec = ENGINES[engine];
81
+ if (!spec) fail("ENGINE_UNKNOWN", `unknown engine ${JSON.stringify(engine)} (cutright or genright)`);
82
+ if (!exists(route)) fail("ENGINE_NOT_INSTALLED", `${spec.app} engine is not installed at ${route}; install ${spec.app} from RightApps (there is no path override)`);
83
+ const info = lstat(route);
84
+ if (info.isSymbolicLink() || !info.isFile()) fail("ENGINE_NOT_REGULAR_FILE", `${route} must be a regular file, not a symlink, junction or directory`);
85
+ const verify = verifySignature ?? (platform === "win32" ? defaultVerifyWindows : platform === "darwin" ? defaultVerifyMac : () => fail("ENGINE_PLATFORM_UNSUPPORTED", `no engine verification for platform ${platform}`));
86
+ verify(route, spec, run);
87
+ const sha256 = createHash("sha256").update(readBytes(route)).digest("hex");
88
+ if (expectedSha256 !== undefined) {
89
+ if (!/^[0-9a-f]{64}$/.test(expectedSha256)) fail("ENGINE_HASH_MISMATCH", `expectedSha256 must be a lowercase SHA-256: ${JSON.stringify(expectedSha256)}`);
90
+ if (sha256 !== expectedSha256) fail("ENGINE_HASH_MISMATCH", `${route}: sha256 ${sha256} is not the pinned ${expectedSha256}`);
91
+ }
92
+ return { engine, path: route, sha256, platform };
93
+ }
94
+
95
+ export function main(args = process.argv.slice(2), options = {}) {
96
+ try {
97
+ const known = new Set(["--engine", "--sha256", "--json"]);
98
+ for (const arg of args) if (arg.startsWith("--") && !known.has(arg)) fail("ENGINE_USAGE", `unknown option ${arg} (there is deliberately no path option)`);
99
+ const take = (flag) => {
100
+ const index = args.indexOf(flag);
101
+ if (index < 0) return undefined;
102
+ const value = args[index + 1];
103
+ if (!value || value.startsWith("--")) fail("ENGINE_USAGE", `${flag} requires a value`);
104
+ return value;
105
+ };
106
+ const engine = take("--engine");
107
+ if (!engine) fail("ENGINE_USAGE", "usage: right-release engine-locate --engine cutright|genright [--sha256 <hex>] [--json]");
108
+ const result = locateEngine(engine, { expectedSha256: take("--sha256"), ...options });
109
+ console.log(args.includes("--json") ? JSON.stringify(result) : result.path);
110
+ } catch (error) {
111
+ console.error(`right-release engine-locate: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
112
+ process.exitCode = 1;
113
+ }
114
+ }
115
+
116
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) main();
@@ -0,0 +1,133 @@
1
+ // Stage, sign and receipt any number of Tauri externalBin sidecars (Orthic-Labs/rightkit#270).
2
+ //
3
+ // targets.mac.externalBin = {
4
+ // names: ["mailright-engine", "mailright-core"], // bare names, as in tauri.conf externalBin "binaries/<name>"
5
+ // arch: "aarch64", // default: build host; one arch per build
6
+ // stageDir: "src-tauri/binaries", // default; files are <name>-<target triple>[.exe]
7
+ // bundleDir: "Contents/MacOS", // mac only (or "Contents/Resources")
8
+ // stage: { cmd: "node", args: ["scripts/build-engine.mjs"] }, // optional: the app's own build of the files
9
+ // }
10
+ //
11
+ // This is the generic form of the ffmpeg sidecar from #246 and reuses its naming, hashing, staged-file checks and signing
12
+ // evidence checks (media-sidecar.mjs); it adds no catalog, notices or release-manifest seal. It never signs: release.mjs
13
+ // signs the staged files with the app (Windows raw-EXE step, macOS nested code) and writes external-bin.json.
14
+ import path from "node:path";
15
+ import {
16
+ bundledFileName, hostCatalogArch, sidecarTargetTriple, stagedFileName, verifyStagedSidecars,
17
+ } from "./media-sidecar.mjs";
18
+
19
+ export const EXTERNAL_BIN_CONTRACT = "rightkit-external-bin-v1";
20
+ export const EXTERNAL_BIN_RECEIPT_NAME = "external-bin.json";
21
+
22
+ const PLATFORM_OS = Object.freeze({ mac: "macos", win: "windows", linux: "linux" });
23
+ const ARCHES = new Set(["aarch64", "x86_64"]);
24
+ const CONFIG_KEYS = new Set(["names", "arch", "stageDir", "bundleDir", "stage"]);
25
+ const BUNDLE_DIRS = new Set(["Contents/MacOS", "Contents/Resources"]);
26
+ const SHA256 = /^[0-9a-f]{64}$/;
27
+
28
+ const invalid = (code, message) => Object.assign(new Error(message), { code });
29
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
30
+
31
+ /** Validate and normalise `targets.<platform>.externalBin`. Every problem is reported at once. */
32
+ export function validateExternalBinConfig(raw, { platform, hostArch = hostCatalogArch(), reservedNames = [] } = {}) {
33
+ const errors = [];
34
+ const os = PLATFORM_OS[platform];
35
+ if (!os) throw invalid("EXTERNAL_BIN_CONFIG_INVALID", `externalBin is not supported on platform ${JSON.stringify(platform)} (mac, win or linux)`);
36
+ if (!isObject(raw)) throw invalid("EXTERNAL_BIN_CONFIG_INVALID", "externalBin must be an object");
37
+ for (const key of Object.keys(raw)) if (!CONFIG_KEYS.has(key)) errors.push(`unknown key ${JSON.stringify(key)} (allowed: ${[...CONFIG_KEYS].join(", ")})`);
38
+ const names = raw.names;
39
+ if (!Array.isArray(names) || !names.length || new Set(names).size !== names.length
40
+ || names.some((name) => typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name) || name !== path.basename(name) || /\.exe$/i.test(name))) {
41
+ errors.push("names must be a non-empty list of unique bare sidecar names (no path, no .exe, no target triple)");
42
+ } else {
43
+ for (const name of names) if (reservedNames.includes(name)) errors.push(`${name} is already staged by mediaSidecar`);
44
+ }
45
+ const arch = raw.arch ?? hostArch;
46
+ if (!ARCHES.has(arch)) errors.push(`arch must be one of ${[...ARCHES].join(", ")} (one architecture per build): ${JSON.stringify(arch)}`);
47
+ else {
48
+ try { sidecarTargetTriple(os, arch); } catch (error) { errors.push(error.message); }
49
+ }
50
+ const stageDirRaw = raw.stageDir ?? "src-tauri/binaries";
51
+ let stageDir;
52
+ if (typeof stageDirRaw !== "string" || !stageDirRaw.trim()) errors.push("stageDir must be a non-empty relative path");
53
+ else {
54
+ const normalized = stageDirRaw.replaceAll("\\", "/");
55
+ if (path.isAbsolute(stageDirRaw) || /^[A-Za-z]:/.test(stageDirRaw) || normalized.split("/").includes("..")) errors.push(`stageDir must be relative and stay inside the app: ${stageDirRaw}`);
56
+ else stageDir = normalized.replace(/\/+$/, "");
57
+ }
58
+ let bundleDir;
59
+ if (os === "macos") {
60
+ bundleDir = raw.bundleDir ?? "Contents/MacOS";
61
+ if (!BUNDLE_DIRS.has(bundleDir)) errors.push(`bundleDir must be ${[...BUNDLE_DIRS].join(" or ")}`);
62
+ } else if (raw.bundleDir !== undefined) errors.push("bundleDir applies to macOS only");
63
+ let stage;
64
+ if (raw.stage !== undefined) {
65
+ if (!isObject(raw.stage) || typeof raw.stage.cmd !== "string" || !raw.stage.cmd || (raw.stage.args !== undefined && !Array.isArray(raw.stage.args))) errors.push("stage must be { cmd, args? }");
66
+ else stage = raw.stage;
67
+ }
68
+ if (errors.length) throw invalid("EXTERNAL_BIN_CONFIG_INVALID", `invalid externalBin configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
69
+ return { os, arch, names: [...names], stageDir, ...(bundleDir ? { bundleDir } : {}), ...(stage ? { stage } : {}) };
70
+ }
71
+
72
+ /** The staged file (and, on macOS, bundle path) for every configured sidecar. Same shape `verifyStagedSidecars` reads. */
73
+ export function planExternalBin({ config, root }) {
74
+ return {
75
+ config,
76
+ files: config.names.map((name) => ({
77
+ tool: name,
78
+ stagedPath: path.resolve(root, config.stageDir, stagedFileName(name, config.os, config.arch)),
79
+ bundledName: bundledFileName(name, config.os),
80
+ ...(config.bundleDir ? { bundlePath: `${config.bundleDir}/${bundledFileName(name, config.os)}` } : {}),
81
+ })),
82
+ };
83
+ }
84
+
85
+ /** `sign.nestedCode` entries for the macOS bundle: the sidecar executables, hardened runtime, no entitlements. */
86
+ export function externalBinNestedCode(config) {
87
+ if (config.os !== "macos") return {};
88
+ return Object.fromEntries(config.names.map((name) => [`${config.bundleDir}/${bundledFileName(name, "macos")}`, { hardenedRuntime: true }]));
89
+ }
90
+
91
+ /** Before signing: every staged file exists, is non-empty, and its hash is recorded (reuses the ffmpeg check). */
92
+ export function verifyStagedExternalBins(plan, deps) {
93
+ return verifyStagedSidecars(plan, deps).map((file) => ({ ...file }));
94
+ }
95
+
96
+ /** After signing: `signed` maps name to { sha256, sizeBytes }. */
97
+ export function buildExternalBinReceipt(plan, preSign, signed) {
98
+ const { config } = plan;
99
+ return {
100
+ schema: 1,
101
+ contract: EXTERNAL_BIN_CONTRACT,
102
+ os: config.os,
103
+ arch: config.arch,
104
+ signing: config.os === "macos" ? "developer-id-hardened-runtime" : config.os === "windows" ? "authenticode" : "none",
105
+ files: preSign.map((file) => {
106
+ const after = signed[file.tool];
107
+ if (!after || !SHA256.test(after.sha256 ?? "")) throw invalid("EXTERNAL_BIN_RECEIPT_INVALID", `no signed digest for ${file.tool}`);
108
+ return {
109
+ name: file.tool,
110
+ preSignSha256: file.preSignSha256,
111
+ signedSha256: after.sha256,
112
+ sizeBytes: after.sizeBytes,
113
+ ...(file.bundlePath ? { bundlePath: file.bundlePath } : { bundledName: file.bundledName }),
114
+ };
115
+ }),
116
+ };
117
+ }
118
+
119
+ export function validateExternalBinReceipt(receipt, expected = {}) {
120
+ const fail = (message) => { throw invalid("EXTERNAL_BIN_RECEIPT_INVALID", `external bin receipt: ${message}`); };
121
+ if (!isObject(receipt) || receipt.schema !== 1 || receipt.contract !== EXTERNAL_BIN_CONTRACT) fail("unrecognized contract");
122
+ if (!Array.isArray(receipt.files) || !receipt.files.length) fail("no files");
123
+ for (const file of receipt.files) {
124
+ if (typeof file.name !== "string" || !file.name) fail("file without a name");
125
+ for (const key of ["preSignSha256", "signedSha256"]) if (!SHA256.test(file[key] ?? "")) fail(`${file.name}.${key} must be a SHA-256`);
126
+ if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes <= 0) fail(`${file.name}.sizeBytes must be positive`);
127
+ if (file.preSignSha256 === file.signedSha256) fail(`${file.name} was not changed by signing`);
128
+ }
129
+ if (new Set(receipt.files.map((file) => file.name)).size !== receipt.files.length) fail("duplicate name");
130
+ if (expected.os && receipt.os !== expected.os) fail(`os ${receipt.os} is not ${expected.os}`);
131
+ if (expected.names && JSON.stringify([...expected.names].sort()) !== JSON.stringify(receipt.files.map((file) => file.name).sort())) fail("names differ from the configuration");
132
+ return receipt;
133
+ }
@@ -329,7 +329,7 @@ function verifyRemoteAsset(tag, repo, asset, run, evidence) {
329
329
  if (inspectRemoteAsset(tag, repo, asset, run, evidence) !== "match") throw new Error(`GitHub release asset missing; inspect remote asset ${repo}/${tag}/${path.basename(asset)}`);
330
330
  }
331
331
 
332
- function inspectRemoteAsset(tag, repo, asset, run, evidence) {
332
+ export function inspectRemoteAsset(tag, repo, asset, run, evidence) {
333
333
  const verifyDir = `${path.dirname(asset)}/verify-${path.basename(asset)}`;
334
334
  rmSync(verifyDir, { recursive: true, force: true });
335
335
  mkdirSync(verifyDir, { recursive: true });
@@ -350,7 +350,7 @@ function sha256(file) {
350
350
  return createHash("sha256").update(readFileSync(file)).digest("hex");
351
351
  }
352
352
 
353
- function runCommand(command, args, { allowFailure = false, cwd } = {}) {
353
+ export function runCommand(command, args, { allowFailure = false, cwd } = {}) {
354
354
  const result = spawnSync(command, args, { cwd, encoding: "utf8", windowsHide: true });
355
355
  const output = result.stdout?.trim() ?? "";
356
356
  if (result.status !== 0) {