@rightkit/release 0.2.165 → 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 +298 -5
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +105 -16
  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 +281 -13
  67. package/rightkit-ownership.json +537 -5
  68. package/rightkit-sync.mjs +26 -0
  69. package/rightkit-versions.json +109 -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/install-app.mjs CHANGED
@@ -4,6 +4,9 @@
4
4
  // Never uses a shell, osascript, AppleEvents, or foreground activation.
5
5
  import { execFile as nodeExecFile, execFileSync as nodeExecFileSync } from "./background-process.mjs";
6
6
  import { detachWithRetry } from "./hdiutil-detach.mjs";
7
+ import { previousPathOf } from "./in-place-install.mjs";
8
+ import { assertShippedSwapHelperSigned } from "./swap-helper-bundle.mjs";
9
+ import { installAppWindows, InstallAppWindowsError } from "./install-app-windows.mjs";
7
10
  import fs from "node:fs";
8
11
  import os from "node:os";
9
12
  import path from "node:path";
@@ -19,11 +22,11 @@ export class InstallAppError extends Error {
19
22
  }
20
23
  }
21
24
 
22
- const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest"]);
25
+ const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest", "--installer", "--exe"]);
23
26
  const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json", "--dev", "--recover"]);
24
27
 
25
28
  export function parseArgs(argv) {
26
- const opts = { dmg: null, zip: null, app: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false, recover: false };
29
+ const opts = { dmg: null, zip: null, app: null, installer: null, exe: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false, recover: false };
27
30
  for (let i = 0; i < argv.length; i += 1) {
28
31
  const flag = argv[i];
29
32
  if (VALUE_FLAGS.has(flag)) {
@@ -35,6 +38,8 @@ export function parseArgs(argv) {
35
38
  else if (flag === "--app") opts.app = value;
36
39
  else if (flag === "--app-name") opts.appName = value;
37
40
  else if (flag === "--bundle-id") opts.bundleId = value;
41
+ else if (flag === "--installer") opts.installer = value;
42
+ else if (flag === "--exe") opts.exe = value;
38
43
  else opts.dest = value;
39
44
  } else if (BOOL_FLAGS.has(flag)) {
40
45
  if (flag === "--json") opts.json = true;
@@ -45,6 +50,12 @@ export function parseArgs(argv) {
45
50
  throw new InstallAppError("usage", `unknown argument: ${flag}`);
46
51
  }
47
52
  }
53
+ if (opts.installer !== null) {
54
+ // Windows: run the sealed NSIS installer in place at one fixed folder, keeping <dest>.previous.
55
+ if (opts.dmg !== null || opts.zip !== null || opts.app !== null || opts.recover || opts.dev) throw new InstallAppError("usage", "--installer cannot be combined with --dmg, --zip, --app, --dev or --recover");
56
+ if (!argv.includes("--dest") || opts.exe === null) throw new InstallAppError("usage", "--installer requires --dest <fixed install folder> and --exe <App.exe>");
57
+ return opts;
58
+ }
48
59
  if (argv.includes("--relaunch") && argv.includes("--no-relaunch")) throw new InstallAppError("usage", "--relaunch and --no-relaunch are mutually exclusive");
49
60
  const inputs = [opts.dmg, opts.zip, opts.app].filter((v) => v !== null).length;
50
61
  if (opts.recover) {
@@ -220,10 +231,15 @@ function coded(code, message) {
220
231
 
221
232
  // Sync on purpose: signal-handler rollback must be able to swap back. Throws an error whose `code`
222
233
  // is in SWAP_FALLBACK_CODES when no atomic swap is possible here (no helper, or exit 3 / ENOTSUP / EINVAL).
223
- export function createSwapPaths({ env = process.env, resolve = resolveSwapHelper, execFileSync = nodeExecFileSync } = {}) {
234
+ // A helper that exists must be code-signed (#258): an unsigned one is a typed SWAP_HELPER_UNSIGNED error naming the fix,
235
+ // never a silent downgrade. No helper at all keeps the documented journaled two-rename fallback, which is logged
236
+ // (`swap-fallback`, then `replaced {swap}`).
237
+ export function createSwapPaths({ env = process.env, resolve = resolveSwapHelper, execFileSync = nodeExecFileSync, assertSigned = assertShippedSwapHelperSigned } = {}) {
238
+ const checked = new Set();
224
239
  return (a, b) => {
225
240
  const helper = resolve({ env });
226
241
  if (!helper) throw coded("ENOHELPER", `${SWAP_HELPER_NAME} helper not found`);
242
+ if (!checked.has(helper)) { assertSigned({ file: helper, execFileSync }); checked.add(helper); }
227
243
  try { execFileSync(helper, [a, b], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
228
244
  catch (error) {
229
245
  const text = `${error?.stderr || ""}`.trim();
@@ -251,12 +267,15 @@ const defaultIsAlive = (pid) => {
251
267
  try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
252
268
  };
253
269
 
254
- async function trashBundle(run, fsImpl, home, now, bundle, appName) {
255
- const trashDir = path.join(home, ".Trash");
256
- fsImpl.mkdirSync(trashDir, { recursive: true });
257
- const trashed = path.join(trashDir, trashName(appName, now()));
258
- await moveAside(run, fsImpl, bundle, trashed);
259
- return trashed;
270
+ // Keep the bundle it replaced as `<installed>.previous` beside the fixed path (replacing any older one), by rename.
271
+ // Rollback is renaming it back; nothing ever runs from it. It is never sent to the Trash: a Trash copy is a second
272
+ // path macOS can resolve for the same bundle id.
273
+ async function keepPreviousBundle(run, fsImpl, bundle, installed) {
274
+ const previous = previousPathOf(installed);
275
+ if (bundle === previous) return previous;
276
+ fsImpl.rmSync(previous, { recursive: true, force: true });
277
+ await moveAside(run, fsImpl, bundle, previous);
278
+ return previous;
260
279
  }
261
280
 
262
281
  // Deterministic roll-forward/back of any interrupted install journal in `dest`. Idempotent.
@@ -292,8 +311,8 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
292
311
  let ok = j.phase === "verified";
293
312
  if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
294
313
  if (ok) {
295
- try { await trashBundle(run, fsImpl, home, now, j.staged, path.basename(j.installed)); action = "rolled-forward"; }
296
- catch { action = "rolled-forward-trash-pending"; keepJournal = true; keepStaged = true; }
314
+ try { await keepPreviousBundle(run, fsImpl, j.staged, j.installed); action = "rolled-forward"; }
315
+ catch { action = "rolled-forward-previous-pending"; keepJournal = true; keepStaged = true; }
297
316
  } else {
298
317
  swapBack(swapPaths, fsImpl, j.staged, j.installed); // staged now holds the bad new bundle
299
318
  action = "rolled-back";
@@ -306,8 +325,8 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
306
325
  let ok = j.phase === "verified";
307
326
  if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
308
327
  if (ok) {
309
- try { await trashBundle(run, fsImpl, home, now, j.backup, path.basename(j.installed)); action = "rolled-forward"; }
310
- catch { action = "rolled-forward-trash-pending"; keepJournal = true; }
328
+ try { await keepPreviousBundle(run, fsImpl, j.backup, j.installed); action = "rolled-forward"; }
329
+ catch { action = "rolled-forward-previous-pending"; keepJournal = true; }
311
330
  } else {
312
331
  rm(j.installed);
313
332
  fsImpl.renameSync(j.backup, j.installed);
@@ -376,7 +395,7 @@ export async function installApp(opts, deps = {}) {
376
395
  if (s.mode === "atomic") {
377
396
  // installed was never absent. Before the exchange only the staged copy needs removing; after it,
378
397
  // exchange back (installed = old again, staged = the new copy) and drop the new copy. Once final,
379
- // staged holds the verified-old bundle awaiting the Trash, so it must not be touched.
398
+ // staged holds the verified-old bundle awaiting retirement to .previous, so it must not be touched.
380
399
  if (!s.final) {
381
400
  if (s.swapped) swapBack(swapPaths, fsImpl, s.staged, s.installed);
382
401
  removeQuiet(s.staged);
@@ -486,8 +505,9 @@ export async function installApp(opts, deps = {}) {
486
505
  // Stage a verified copy on the destination volume so the swap is rename(2) only.
487
506
  failedStage = "stage";
488
507
  const base = appName.replace(/\.app$/, "");
508
+ // Staging is never run from: it is verified, then renamed into place. The rollback copy is the fixed `<installed>.previous`.
489
509
  const staged = path.join(opts.dest, `.${base}.rightkit-install-${pid}.app`);
490
- const backup = hadInstalled ? path.join(opts.dest, `.${base}.rightkit-backup-${pid}.app`) : null;
510
+ const backup = hadInstalled ? previousPathOf(installed) : null;
491
511
  const journal = journalPathFor(opts.dest, appName);
492
512
  const journalData = { schema: 1, appName, pid, phase: "staging", dev, installed, backup, staged, mount: mountPoint, mountRoot };
493
513
  removeQuiet(staged);
@@ -512,7 +532,7 @@ export async function installApp(opts, deps = {}) {
512
532
 
513
533
  // Swap. Preferred: one atomic exchange (renamex_np RENAME_SWAP), so `installed` is never absent even
514
534
  // under SIGKILL; the old bundle then sits at the staged path. Fallback (no helper, or a volume without
515
- // RENAME_SWAP): old -> backup, staged -> installed, journaled. The old bundle is never trashed before
535
+ // RENAME_SWAP): old -> backup, staged -> installed, journaled. The old bundle is never retired before
516
536
  // the new one is verified.
517
537
  failedStage = "replace";
518
538
  let jd = journalData;
@@ -535,6 +555,7 @@ export async function installApp(opts, deps = {}) {
535
555
  swap.mode = "two-rename";
536
556
  jd = { ...journalData, swap: "two-rename" };
537
557
  writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
558
+ removeQuiet(backup); // an older .previous; the one kept copy is the bundle moving aside now
538
559
  fsImpl.renameSync(installed, backup); swap.backedUp = true;
539
560
  fsImpl.renameSync(staged, installed);
540
561
  swap.stagedIn = true;
@@ -555,16 +576,18 @@ export async function installApp(opts, deps = {}) {
555
576
  writeJournal(fsImpl, journal, { ...jd, phase: "verified", mount: null, mountRoot: null });
556
577
  swap.final = true;
557
578
 
558
- // Only now does the old bundle go to the Trash.
559
- let trashed = null;
579
+ // Only now is the old bundle retired, to <installed>.previous (never the Trash).
580
+ let kept = null;
581
+ let keepFailed = false;
560
582
  const oldBundle = swapMode === "atomic" ? staged : backup; // atomic: the exchange left the old bundle at the staged path
561
583
  if (hadInstalled && oldBundle) {
562
- try { trashed = await trashBundle(run, fsImpl, home, now, oldBundle, appName); }
563
- catch (error) { event("backup-trash-failed", { backup: oldBundle, reason: errText(error) }); trashed = oldBundle; }
584
+ try { kept = await keepPreviousBundle(run, fsImpl, oldBundle, installed); }
585
+ catch (error) { event("previous-keep-failed", { backup: oldBundle, reason: errText(error) }); kept = oldBundle; }
564
586
  }
565
- if (!oldBundle || trashed !== oldBundle) removeQuiet(journal);
587
+ if (!oldBundle || kept !== previousPathOf(installed)) { if (!oldBundle) removeQuiet(journal); }
588
+ else removeQuiet(journal);
566
589
  swap = null;
567
- event("replaced", { installed, previous: trashed, swap: swapMode });
590
+ event("replaced", { installed, previous: kept, swap: swapMode });
568
591
 
569
592
  // Relaunch hidden and in the background only.
570
593
  const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
@@ -573,7 +596,7 @@ export async function installApp(opts, deps = {}) {
573
596
  await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
574
597
  event("relaunched", { bundleId });
575
598
  }
576
- const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, swap: swapMode, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
599
+ const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: kept, swap: swapMode, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
577
600
  event("done", result);
578
601
  return result;
579
602
  } catch (error) {
@@ -685,6 +708,7 @@ export async function main(argv = process.argv.slice(2), io = {}) {
685
708
  stderr(`right-release install-app: ${error.message}`);
686
709
  stderr("usage: right-release install-app (--dmg <path.dmg> | --dev (--zip <path.zip> | --app <path.app> | --dmg <path.dmg>)) [--app-name Pulse.app] [--bundle-id dev.orthic.pulse] [--dest /Applications] [--relaunch|--no-relaunch] [--json]");
687
710
  stderr(" right-release install-app --recover [--dest /Applications] [--app-name Pulse.app] [--json]");
711
+ stderr(" right-release install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--json] (Windows: in place, keeps <dest>.previous)");
688
712
  stderr(" --dev installs a Developer ID-signed, NOT notarized build (skips spctl/stapler, clears quarantine on the new bundle).");
689
713
  return 2;
690
714
  }
@@ -696,10 +720,16 @@ export async function main(argv = process.argv.slice(2), io = {}) {
696
720
  else stdout(`install-app: ${event}${Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ""}`);
697
721
  };
698
722
  try {
723
+ if (opts.installer !== null) {
724
+ installAppWindows(opts, { ...io.deps, event: (name, data = {}) => emit({ event: name, ...data }) });
725
+ emit({ event: "done", dest: opts.dest });
726
+ return 0;
727
+ }
699
728
  if (opts.recover) await recoverApp(opts, { ...io.deps, emit });
700
729
  else await installApp(opts, { ...io.deps, emit });
701
730
  return 0;
702
731
  } catch (error) {
732
+ if (error instanceof InstallAppWindowsError) { stderr(`install-app: ${error.code}: ${error.message}`); return error.code === "unsupported" ? 3 : 1; }
703
733
  return error.code === "unsupported" ? 3 : 1;
704
734
  }
705
735
  }
package/local-publish.mjs CHANGED
@@ -17,7 +17,8 @@ import { GATE_SCHEMA, defaultEvidencePath, enforcePublicationGate, publishGatedT
17
17
  import { runCargoPublish } from "./publish-cargo.mjs";
18
18
  import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
19
19
  import { readCargoPackage } from "./crate-graph.mjs";
20
- import { recordPublished } from "./rightkit-sync.mjs";
20
+ import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
21
+ import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
21
22
 
22
23
  export const LOCAL_OPERATOR = "agent (local-build grant)";
23
24
  export const LOCAL_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); Windows covered by the next CI run";
@@ -194,7 +195,16 @@ export async function runLocalPublish(kind, argv, deps = {}) {
194
195
  const cleanup = () => fs.rmSync(scratch, { recursive: true, force: true });
195
196
  try {
196
197
  let artifactFile; const packCommands = [];
198
+ if (kind === "npm" && unit === SWAP_HELPER_PACKAGE) {
199
+ // #258: the package ships a Developer-ID-signed bin/darwin/rightkit-swap; build, sign and verify it before packing (fails closed).
200
+ try { (deps.prepareSwapHelper ?? prepareSwapHelper)({ workspaceRoot, packageDir: unitPath, platform, run, env, log }); }
201
+ catch (error) { refuse(`${error.code ? `[${error.code}] ` : ""}${error.message}`); }
202
+ packCommands.push(`rightkit cargo ${SWAP_HELPER_CARGO_ARGS.join(" ")} && codesign --verify --strict bin/darwin/rightkit-swap`);
203
+ }
197
204
  if (kind === "npm") {
205
+ // The feed a tarball ships must list the version it ships with (#319): stage it before packing.
206
+ const feedFile = path.join(workspaceRoot, "packages", "release", "rightkit-versions.json");
207
+ if (fs.existsSync(feedFile)) (deps.stageSourceVersions ?? stageSourceVersions)([{ registry: "npm", name: unit, version }], { file: feedFile, root: workspaceRoot, log });
198
208
  const args = ["--dir", dir, "pack", "--pack-destination", scratch];
199
209
  packCommands.push(`pnpm ${args.join(" ")}`);
200
210
  const packed = run("pnpm", args, { cwd: workspaceRoot, env });
@@ -1,8 +1,9 @@
1
1
  // Per-file macOS signing matrix for `sign.prePackageFiles[]` object entries and `sign.nestedCode`.
2
2
  //
3
3
  // sign: {
4
- // prePackageFiles: ["dist/tool", { file: "dist/App.app", entitlements: "release/app.entitlements.plist" }],
5
- // nestedCode: { "Contents/Helpers/Helper": { options: ["library"] },
4
+ // prePackageFiles: ["dist/tool", { file: "dist/App.app", entitlements: "release/app.entitlements.plist" },
5
+ // { file: "dist/legion-hook", identifier: "com.example.legion-hook" }],
6
+ // nestedCode: { "Contents/Helpers/Helper": { options: ["library"], identifier: "com.example.helper" },
6
7
  // "Contents/PlugIns/Ext.appex": { entitlements: "release/ext.entitlements.plist" } },
7
8
  // }
8
9
  //
@@ -16,8 +17,12 @@ const invalid = (message) => Object.assign(new Error(message), { code: "MAC_SIGN
16
17
 
17
18
  /** codesign `--options` names accepted in `options[]` (man codesign, OPTION FLAGS). `runtime` is always on. */
18
19
  export const CODESIGN_OPTION_FLAGS = Object.freeze(["expires", "hard", "host", "kill", "library", "runtime"]);
19
- const ENTRY_KEYS = new Set(["file", "entitlements", "hardenedRuntime", "options"]);
20
- const SETTING_KEYS = new Set(["entitlements", "hardenedRuntime", "options"]);
20
+ const ENTRY_KEYS = new Set(["file", "entitlements", "hardenedRuntime", "options", "identifier"]);
21
+ const SETTING_KEYS = new Set(["entitlements", "hardenedRuntime", "options", "identifier"]);
22
+ // A stable per-file code identity: codesign `--identifier`. A loose tool otherwise gets an identifier derived from its file
23
+ // name or cdhash, and macOS keys privacy grants (TCC) to the code identity, so it must not change between builds.
24
+ // Same pattern as mac-sign-core.mjs (not imported: that module imports this one).
25
+ const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$/;
21
26
 
22
27
  const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
23
28
 
@@ -33,7 +38,11 @@ function looksLikePlist(file) {
33
38
  }
34
39
 
35
40
  function validateSettings(settings, label, { root, errors, isFile }) {
36
- const out = { entitlements: undefined, hardenedRuntime: true, options: [] };
41
+ const out = { entitlements: undefined, hardenedRuntime: true, options: [], identifier: undefined };
42
+ if (settings.identifier !== undefined) {
43
+ if (typeof settings.identifier !== "string" || !IDENTIFIER_PATTERN.test(settings.identifier)) errors.push(`${label}.identifier must be 1-255 characters of letters, digits, '.' and '-', starting with a letter or digit`);
44
+ else out.identifier = settings.identifier;
45
+ }
37
46
  if (settings.entitlements !== undefined) {
38
47
  const value = settings.entitlements;
39
48
  if (typeof value !== "string" || !value.trim()) errors.push(`${label}.entitlements must be a non-empty path string`);
@@ -79,8 +88,8 @@ function validateNestedKey(key) {
79
88
  /**
80
89
  * Validate and normalize `sign` for a macOS target. Collects every problem, then throws one
81
90
  * MAC_SIGN_CONFIG_INVALID error. Returns { entries, nestedCode } where
82
- * entries: [{ file, entitlements?: absolute, options: [] }] (file stays as declared)
83
- * nestedCode: [{ key, entitlements?: absolute, options: [] }]
91
+ * entries: [{ file, identifier?, entitlements?: absolute, options: [] }] (file stays as declared)
92
+ * nestedCode: [{ key, identifier?, entitlements?: absolute, options: [] }]
84
93
  * Injectable: isFile.
85
94
  */
86
95
  export function validateMacSigningConfig(sign, { root, isFile = (file) => existsSync(file) && statSync(file).isFile() } = {}) {
@@ -106,7 +115,7 @@ export function validateMacSigningConfig(sign, { root, isFile = (file) => exists
106
115
  if (seen.has(file)) errors.push(`${label}: duplicate signing entry ${file}`);
107
116
  seen.add(file);
108
117
  const normalized = validateSettings(settings, label, context);
109
- entries.push({ file, ...(normalized.entitlements ? { entitlements: normalized.entitlements } : {}), options: normalized.options });
118
+ entries.push({ file, ...(normalized.identifier ? { identifier: normalized.identifier } : {}), ...(normalized.entitlements ? { entitlements: normalized.entitlements } : {}), options: normalized.options });
110
119
  });
111
120
  }
112
121
  if (sign.nestedCode !== undefined) {
@@ -119,7 +128,7 @@ export function validateMacSigningConfig(sign, { root, isFile = (file) => exists
119
128
  if (!isPlainObject(settings)) { errors.push(`${label} must be an object { entitlements?, hardenedRuntime?, options? }`); continue; }
120
129
  for (const name of Object.keys(settings)) if (!SETTING_KEYS.has(name)) errors.push(`${label}: unknown key ${JSON.stringify(name)} (allowed: ${[...SETTING_KEYS].join(", ")})`);
121
130
  const normalized = validateSettings(settings, label, context);
122
- nestedCode.push({ key, ...(normalized.entitlements ? { entitlements: normalized.entitlements } : {}), options: normalized.options });
131
+ nestedCode.push({ key, ...(normalized.identifier ? { identifier: normalized.identifier } : {}), ...(normalized.entitlements ? { entitlements: normalized.entitlements } : {}), options: normalized.options });
123
132
  }
124
133
  if (nestedCode.length) {
125
134
  const apps = entries.filter((entry) => /\.app\/?$/.test(entry.file));
@@ -132,7 +141,7 @@ export function validateMacSigningConfig(sign, { root, isFile = (file) => exists
132
141
  }
133
142
 
134
143
  /** True when an entry or nested item carries anything beyond the default (hardened runtime, no entitlements). */
135
- export const hasNonDefaultSettings = (item) => Boolean(item.entitlements) || (item.options?.length ?? 0) > 0;
144
+ export const hasNonDefaultSettings = (item) => Boolean(item.entitlements) || Boolean(item.identifier) || (item.options?.length ?? 0) > 0;
136
145
 
137
146
  const depth = (file) => file.split(path.sep).length;
138
147
 
@@ -162,7 +171,7 @@ export function planMacSigning({ entries, nestedCode = [], expand = expandMacSig
162
171
  const real = realpath(file);
163
172
  if (real !== appReal && !real.startsWith(appReal + path.sep)) throw invalid(`${label}: resolves outside the bundle`);
164
173
  if (real === mainReal) throw invalid(`${label}: the main executable is signed with the bundle; set entitlements on its sign.prePackageFiles entry`);
165
- byPath.set(file, { file, ...(item.entitlements ? { entitlements: item.entitlements } : {}), options: item.options });
174
+ byPath.set(file, { file, ...(item.identifier ? { identifier: item.identifier } : {}), ...(item.entitlements ? { entitlements: item.entitlements } : {}), options: item.options });
166
175
  }
167
176
  }
168
177
  const nested = [...byPath.values()].sort((a, b) => depth(b.file) - depth(a.file) || a.file.localeCompare(b.file));
@@ -178,6 +187,9 @@ export function matrixReceiptProblems(evidenceFiles, entries, hashFile) {
178
187
  if (!item) { problems.push(`missing receipt entry for ${entry.file}`); continue; }
179
188
  const wantOptions = ["runtime", ...(entry.options ?? [])].join(",");
180
189
  if ((item.codesignOptions ?? "runtime") !== wantOptions) problems.push(`${entry.file}: signed with options ${item.codesignOptions ?? "runtime"}, expected ${wantOptions}`);
190
+ if (entry.identifier) {
191
+ if (item.identifier !== entry.identifier) problems.push(`${entry.file}: signed with identifier ${item.identifier ?? "(none recorded)"}, expected ${entry.identifier}`);
192
+ } else if (item.identifier) problems.push(`${entry.file}: signed with identifier ${item.identifier} the plan did not declare`);
181
193
  if (entry.entitlements) {
182
194
  if (item.entitlements?.sha256 !== hashFile(entry.entitlements)) problems.push(`${entry.file}: receipt entitlements digest does not match ${entry.entitlements}`);
183
195
  } else if (item.entitlements) problems.push(`${entry.file}: signed with entitlements the plan did not declare`);
package/managed-cargo.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { discoverBrokerSocket, managedControlCandidates, resolveManagedRoots } from './managed-roots.mjs';
2
2
  import { randomUUID } from "node:crypto";
3
+ import { homedir } from "node:os";
3
4
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
5
  import path from "node:path";
5
6
  import { spawnSync } from "./background-process.mjs";
@@ -12,6 +13,11 @@ export function managedRuntimeClient(env = process.env, platform = process.platf
12
13
  const client = paths.join(root, "runtime-current", platform === 'win32' ? "rustright.exe" : "rustright");
13
14
  if (exists(client)) return client;
14
15
  }
16
+ // RustRight's own install (2026-10-17 cutover): the Mac agent-bin shims exec this client directly.
17
+ if (platform === 'darwin') {
18
+ const client = path.posix.join(env.HOME || homedir(), 'Library', 'Application Support', 'RustRight', 'runtime', 'rustright');
19
+ if (exists(client)) return client;
20
+ }
15
21
  return undefined;
16
22
  }
17
23
 
@@ -0,0 +1,45 @@
1
+ // Find the rightkit-ffmpeg crate (its catalog and notice files) for a release.
2
+ //
3
+ // Order: `mediaSidecar.catalog` in the release config (the crate's catalog/artifacts.json; the crate directory is the
4
+ // catalog's grandparent), otherwise the crate Cargo resolves for the app's native manifest (`cargo metadata --offline`,
5
+ // so the catalog is exactly the one the app was compiled against, at the version in its Cargo.lock).
6
+ import { readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { runCargoSync } from "./managed-cargo.mjs";
9
+
10
+ const invalid = (code, message) => Object.assign(new Error(message), { code });
11
+
12
+ export function cargoFfmpegCrate(manifestPath, { run = runCargoSync } = {}) {
13
+ const result = run(["metadata", "--offline", "--format-version", "1", "--manifest-path", manifestPath], {
14
+ cwd: path.dirname(manifestPath),
15
+ encoding: "utf8",
16
+ maxBuffer: 256 * 1024 * 1024,
17
+ windowsHide: true,
18
+ });
19
+ if (result.error) throw invalid("MEDIA_SIDECAR_CRATE_NOT_FOUND", `could not run cargo metadata: ${result.error.message}`);
20
+ if (result.status !== 0) throw invalid("MEDIA_SIDECAR_CRATE_NOT_FOUND", `cargo metadata failed: ${String(result.stderr ?? "").trim()}`);
21
+ const metadata = JSON.parse(result.stdout);
22
+ const crates = metadata.packages.filter((pkg) => pkg.name === "rightkit-ffmpeg");
23
+ if (crates.length !== 1) throw invalid("MEDIA_SIDECAR_CRATE_NOT_FOUND", `the app's dependency graph has ${crates.length} rightkit-ffmpeg packages (expected exactly one)`);
24
+ return { crateDir: path.dirname(crates[0].manifest_path), crateVersion: crates[0].version };
25
+ }
26
+
27
+ /** Returns { catalog, catalogBytes, crateDir, crateVersion }. Injectable: read, crateFor. */
28
+ export function loadSidecarCatalog({ config, root, manifestPath, read = readFileSync, crateFor = cargoFfmpegCrate }) {
29
+ let crateDir;
30
+ let crateVersion;
31
+ let catalogPath;
32
+ if (config.catalog) {
33
+ catalogPath = path.resolve(root, config.catalog);
34
+ crateDir = path.dirname(path.dirname(catalogPath));
35
+ } else {
36
+ if (!manifestPath) throw invalid("MEDIA_SIDECAR_CRATE_NOT_FOUND", "no mediaSidecar.catalog and no native Cargo manifest to resolve rightkit-ffmpeg from");
37
+ ({ crateDir, crateVersion } = crateFor(manifestPath));
38
+ catalogPath = path.join(crateDir, "catalog", "artifacts.json");
39
+ }
40
+ let catalogBytes;
41
+ try { catalogBytes = read(catalogPath); } catch (error) { throw invalid("MEDIA_SIDECAR_CATALOG_INVALID", `cannot read ${catalogPath}: ${error.message}`); }
42
+ let catalog;
43
+ try { catalog = JSON.parse(String(catalogBytes)); } catch { throw invalid("MEDIA_SIDECAR_CATALOG_INVALID", `${catalogPath} is not JSON`); }
44
+ return { catalog, catalogBytes, crateDir, crateVersion };
45
+ }