@rightkit/release 0.2.173 → 0.3.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.
Files changed (50) hide show
  1. package/README.md +188 -5
  2. package/bin/darwin/rightkit-swap +0 -0
  3. package/bin/darwin/rightkit-swap.sign.json +1 -1
  4. package/cli/right-release.mjs +34 -6
  5. package/command-spec.mjs +80 -0
  6. package/deps-cargo.mjs +55 -0
  7. package/deps.mjs +7 -0
  8. package/development-signing.mjs +135 -4
  9. package/development.mjs +12 -11
  10. package/dist-scan.mjs +102 -0
  11. package/dmg-build.mjs +53 -4
  12. package/icon-pack.mjs +117 -0
  13. package/in-place-install.mjs +32 -7
  14. package/init-command.mjs +116 -0
  15. package/install-app-windows.mjs +108 -17
  16. package/install-app.mjs +68 -11
  17. package/install-tauri-devid.mjs +106 -47
  18. package/local-publish.mjs +317 -29
  19. package/mac-bundle-signing.mjs +143 -1
  20. package/mac-signing-matrix.mjs +73 -6
  21. package/macho-approval-hash.mjs +181 -0
  22. package/macos-signing-session.mjs +12 -2
  23. package/managed-cargo.mjs +49 -1
  24. package/media-sidecar.mjs +20 -4
  25. package/mirror-root-artifact.mjs +69 -21
  26. package/npm-license-notices.mjs +159 -0
  27. package/ownership-dependency-allowlist.json +1 -0
  28. package/ownership.mjs +37 -4
  29. package/package-recipe-targets.mjs +180 -16
  30. package/package.json +1 -1
  31. package/pe-content.mjs +133 -0
  32. package/portable-development.mjs +9 -9
  33. package/publication-gate.mjs +70 -3
  34. package/publish-cargo.mjs +26 -7
  35. package/qa-build-features.mjs +7 -1
  36. package/recipe-build.mjs +346 -0
  37. package/release-dependencies-command.mjs +45 -0
  38. package/release-dependencies.mjs +60 -0
  39. package/release-switch-scan.mjs +17 -7
  40. package/release.mjs +98 -12
  41. package/rightkit-ownership.json +46 -2
  42. package/rightkit-signing-agent.mjs +10 -4
  43. package/rightkit-versions.json +11 -6
  44. package/runtime-budgets.mjs +28 -12
  45. package/stage-binary.mjs +1 -1
  46. package/stage-sidecar-command.mjs +36 -4
  47. package/stage-sidecar.mjs +926 -74
  48. package/test-command.mjs +190 -0
  49. package/toolchain-swift.mjs +239 -0
  50. package/web-build.mjs +67 -0
package/release.mjs CHANGED
@@ -45,7 +45,9 @@ import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
45
45
  import { DEFAULT_STORAGE_THRESHOLD_BYTES, findInstallerCopyScripts, findLargeRepositorySubdirectories } from "./installer-storage-doctor.mjs";
46
46
  import { macosCodesignEnvironment } from "./codesign-environment.mjs";
47
47
  import { assertNoQaControl, assertNoWebBundleMocks } from "./qa-control-scan.mjs";
48
+ import { assertReleaseDependencies } from "./release-dependencies.mjs";
48
49
  import { assertNoForbiddenBuildFeatures } from "./qa-build-features.mjs";
50
+ import { expandRecipeBuildCommands } from "./recipe-build.mjs";
49
51
  import { assertNoReleaseSwitches, commandTokens, resolveFeatureClosure } from "./release-switch-scan.mjs";
50
52
  import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
51
53
  import { runAndroidRelease, validateAndroidTarget } from "./android-release.mjs";
@@ -356,7 +358,7 @@ try {
356
358
  { label: "target.package", command: target.package },
357
359
  { label: "nativeAssembly.packageHook", command: nativeAssembly?.packageHook },
358
360
  { label: "nativeAssembly.finalizer", command: nativeFinalizer },
359
- { label: "target.postSign", command: target.postSign },
361
+ ...commandList(target.postSign).map((command, index) => ({ label: Array.isArray(target.postSign) ? `target.postSign[${index}]` : "target.postSign", command })),
360
362
  { label: "target.gradle", command: target.gradle },
361
363
  ] });
362
364
  } catch (error) { fail(error.message); }
@@ -370,6 +372,10 @@ try {
370
372
  const releaseSwitchInventoryPath = path.resolve(root, config.releaseSwitches?.inventory ?? "switches.json");
371
373
  const releaseSwitchInventoryText = existsSync(releaseSwitchInventoryPath) ? readFileSync(releaseSwitchInventoryPath, "utf8") : null;
372
374
  try {
375
+ // A `node recipe-build.mjs --plan <JSON>` prePackage hides each step's `--features` in one argument: the closure and the
376
+ // command token scan see every prepare / build / postBuild step (a plan that cannot be read or validated fails closed).
377
+ const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
378
+ assertReleaseDependencies({ root, config, target, commands: closureCommands });
373
379
  let treeText;
374
380
  try {
375
381
  const closure = resolveFeatureClosure({
@@ -379,7 +385,7 @@ try {
379
385
  rust: config.releaseSwitches?.rust,
380
386
  features: config.releaseSwitches?.features ?? [],
381
387
  triple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget,
382
- commandTexts: [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, target.postSign, target.gradle],
388
+ commandTexts: closureCommands,
383
389
  });
384
390
  if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
385
391
  else treeText = closure.treeText;
@@ -388,7 +394,7 @@ try {
388
394
  console.log(`right-release: dry run: ${error.message}`);
389
395
  }
390
396
  // remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
391
- const closureInput = { treeText, remoteHost: config.releaseSwitches?.remoteHost, commands: [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, target.postSign, target.gradle].filter(Boolean).map((c) => commandTokens(c)) };
397
+ const closureInput = { treeText, remoteHost: config.releaseSwitches?.remoteHost, commands: closureCommands.map((c) => commandTokens(c)) };
392
398
  if (releaseSwitchInventoryText != null) {
393
399
  assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
394
400
  } else if (treeText != null || closureInput.commands.length) {
@@ -494,7 +500,7 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
494
500
  assertNoQaControlBinaries("macOS app staged for the DMG", [stagedApp], { requireBinary: true, prePack: true });
495
501
  }
496
502
  if (runMacPhase("sign")) {
497
- await signMacos(signingEntries, root, "signing", macSigning.nestedCode);
503
+ await signMacos(signingEntries, root, "signing", macSigning.nestedCode, macSigning.nestedSigning);
498
504
  if (sidecarPlan && !opts.dryRun) {
499
505
  const app = signingEntries.find((entry) => /\.app\/?$/.test(entry.file));
500
506
  if (!app) fail("mediaSidecar on macOS needs an .app entry in sign.prePackageFiles");
@@ -563,14 +569,14 @@ if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_C
563
569
  // and still installs a binary Windows rejects.
564
570
  await verifyWindowsEmbedding({ root, target, files, markers: bundleMarkerReceipts });
565
571
  await signWindows(files, "installer", root);
566
- if (target.postSign) await runCommand(target.postSign, root);
572
+ if (target.postSign) await runCommands(target.postSign, root);
567
573
  const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => resolveReleaseArtifactPath(root, artifact.file)))];
568
574
  if (!updaterFiles.length) fail(`${config.app} win must declare updater.artifacts for post-code-signing minisign`);
569
575
  await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...(target.updater?.keyFile ? ["--key-file", String(target.updater.keyFile)] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
570
576
  }
571
577
 
572
578
  if (target.postSign && opts.platform !== "win" && (opts.platform !== "mac" || runMacPhase("post-sign"))) {
573
- await runCommand(target.postSign, root);
579
+ await runCommands(target.postSign, root);
574
580
  }
575
581
 
576
582
  if (opts.platform === "mac") {
@@ -933,6 +939,17 @@ async function runPackageScript(pm, script, cwd) {
933
939
  await run(pm, ["run", script], cwd);
934
940
  }
935
941
 
942
+ // A hook may be one command object or an ordered array of them (e.g. create-mac-updater then lsclean).
943
+ function commandList(value) {
944
+ if (value == null) return [];
945
+ return Array.isArray(value) ? value : [value];
946
+ }
947
+
948
+ // Runs in order; run() exits the process on the first failure, so the chain fails closed.
949
+ async function runCommands(value, root, options) {
950
+ for (const command of commandList(value)) await runCommand(command, root, options);
951
+ }
952
+
936
953
  async function runCommand(command, root, { env = {}, captureStdout = false } = {}) {
937
954
  const cwd = path.resolve(root, command.cwd ?? ".");
938
955
  return run(command.cmd, command.args ?? [], cwd, { ...(await commandEnv(command, root)), ...env }, {
@@ -1117,9 +1134,13 @@ function signExistingRefusal(source) {
1117
1134
  // The renderer comes from this monorepo's own packages/ci when present, else from the repository's installed @rightkit/ci; with
1118
1135
  // neither the file is refused, because an unverifiable sign-existing.yml must not be accepted.
1119
1136
  async function loadRightKitCiRenderer(root) {
1120
- const candidates = [pathToFileURL(fileURLToPath(new URL("../ci/sync.mjs", import.meta.url))).href];
1137
+ return (await loadRightKitCiModule(root, "sync.mjs", "renderLanes"))?.renderLanes ?? null;
1138
+ }
1139
+
1140
+ async function loadRightKitCiModule(root, file, exportName) {
1141
+ const candidates = [pathToFileURL(fileURLToPath(new URL(`../ci/${file}`, import.meta.url))).href];
1121
1142
  try {
1122
- candidates.push(pathToFileURL(createRequire(path.join(root, "package.json")).resolve("@rightkit/ci/sync.mjs")).href);
1143
+ candidates.push(pathToFileURL(createRequire(path.join(root, "package.json")).resolve(`@rightkit/ci/${file}`)).href);
1123
1144
  } catch {
1124
1145
  // not installed in the repository; the sibling candidate may still apply
1125
1146
  }
@@ -1127,7 +1148,7 @@ async function loadRightKitCiRenderer(root) {
1127
1148
  try {
1128
1149
  if (href.startsWith("file:") && !existsSync(fileURLToPath(href))) continue;
1129
1150
  const loaded = await import(href);
1130
- if (typeof loaded.renderLanes === "function") return loaded.renderLanes;
1151
+ if (typeof loaded[exportName] === "function") return loaded;
1131
1152
  } catch {
1132
1153
  // try the next candidate
1133
1154
  }
@@ -1150,6 +1171,57 @@ async function signExistingRenderRefusal(root, manifest, source) {
1150
1171
  }
1151
1172
 
1152
1173
 
1174
+ // `workflows` block entries render from code, so they are admitted only when the block passes @rightkit/ci's own
1175
+ // validateWorkflows and each file is byte-for-byte the @rightkit/ci render. With no resolvable @rightkit/ci they are refused.
1176
+ async function validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles) {
1177
+ const validator = (await loadRightKitCiModule(root, "workflows.mjs", "validateWorkflows"))?.validateWorkflows;
1178
+ const renderLanes = await loadRightKitCiRenderer(root);
1179
+ if (!validator || !renderLanes) fail(`${appName} right-git-ci-only policy cannot verify .rightgit.json workflows: @rightkit/ci is not available (install @rightkit/ci as a devDependency)`);
1180
+ const errors = [];
1181
+ validator(manifest, errors);
1182
+ if (errors.length > 0) fail(`${appName} right-git-ci-only policy refuses the .rightgit.json workflows block: ${errors.join("; ")}`);
1183
+ let rendered;
1184
+ try {
1185
+ rendered = renderLanes(manifest, root);
1186
+ } catch (error) {
1187
+ fail(`${appName} right-git-ci-only policy cannot render the .rightgit.json workflows block: ${error.message}`);
1188
+ }
1189
+ for (const file of declaredFiles) {
1190
+ const expected = rendered.find((entry) => entry.filename === file)?.content;
1191
+ const actual = await readFile(path.join(root, ".github", "workflows", file), "utf8");
1192
+ if (typeof expected !== "string" || actual.replace(/\r\n?/g, "\n") !== expected.replace(/\r\n?/g, "\n")) {
1193
+ fail(`${appName} ${file} is not byte-for-byte the @rightkit/ci render of its .rightgit.json workflows entry (run right-git sync)`);
1194
+ }
1195
+ }
1196
+ }
1197
+
1198
+ // Structural backstop for declared workflows (after render equality): the one top-level permissions block is exactly
1199
+ // `contents: read`, no other permissions key exists, no secrets reference, every `uses:` is commit-pinned, and every
1200
+ // checkout step sets persist-credentials: false. Steps are split on step-list indentation, not matched as free text.
1201
+ function declaredWorkflowPolicyRefusal(appName, workflow, source) {
1202
+ const refuse = (why) => fail(`${appName} right-git managed ${workflow} violates the declared-workflow policy: ${why}`);
1203
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
1204
+ const permissionsAt = lines.flatMap((line, i) => (/^\s*permissions\s*:/.test(line) ? [i] : []));
1205
+ if (permissionsAt.length !== 1 || lines[permissionsAt[0]] !== "permissions:" || lines[permissionsAt[0] + 1] !== " contents: read" || /^ {2,}\S/.test(lines[permissionsAt[0] + 2] ?? "")) refuse("permissions must be exactly one top-level `contents: read`");
1206
+ if (/\bsecrets\b/i.test(source)) refuse("it references the secrets context");
1207
+ let step = null;
1208
+ const steps = [];
1209
+ for (const line of lines) {
1210
+ if (/^ {6}- /.test(line)) { step = [line]; steps.push(step); }
1211
+ else if (step && (/^ {7,}\S/.test(line) || line === "")) step.push(line);
1212
+ else step = null;
1213
+ }
1214
+ for (const body of steps) {
1215
+ const uses = /^ {6}- uses: (\S+)|^ {8}uses: (\S+)/m.exec(body.join("\n"));
1216
+ if (!uses) continue;
1217
+ const ref = uses[1] ?? uses[2];
1218
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+@[0-9a-f]{40}$/.test(ref)) refuse(`action ${ref} is not commit-pinned`);
1219
+ if (/^actions\/checkout@/.test(ref) && !body.some((line) => /^ {10}persist-credentials: false\s*$/.test(line))) refuse("a checkout step does not set persist-credentials: false");
1220
+ if (body.some((line) => /persist-credentials:\s*true/.test(line))) refuse("persist-credentials: true");
1221
+ }
1222
+ if (/^\s*(?:- )?uses:/m.test(lines.filter((l) => !steps.some((b) => b.includes(l))).join("\n"))) refuse("a uses: sits outside a step list");
1223
+ }
1224
+
1153
1225
  async function validateHostedWorkflows(root, appName, workflows, policy) {
1154
1226
  if (policy !== "right-git-ci-only") {
1155
1227
  fail(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")}`);
@@ -1173,15 +1245,29 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
1173
1245
  || lanes.some((lane) => !safeLanes.has(lane))) {
1174
1246
  fail(`${appName} right-git-ci-only policy requires compile=github-actions-only plus unique .rightgit.json lanes drawn from [${[...safeLanes].map((lane) => `"${lane}"`).join(", ")}] and including "ci" or "swift-check"`);
1175
1247
  }
1176
- const expectedWorkflows = lanes.map((lane) => `${lane}.yml`).sort();
1248
+ // Workflows declared under the manifest's `workflows` block (@rightkit/ci workflows.mjs: explicit `file`,
1249
+ // else `<name>.yml`) are managed files too, in addition to the lane files.
1250
+ const declaredWorkflows = manifest?.workflows;
1251
+ if (declaredWorkflows !== undefined && (declaredWorkflows === null || typeof declaredWorkflows !== "object" || Array.isArray(declaredWorkflows))) {
1252
+ fail(`${appName} right-git-ci-only policy requires .rightgit.json workflows to be an object`);
1253
+ }
1254
+ const declaredFiles = Object.entries(declaredWorkflows ?? {}).map(([name, config]) => {
1255
+ if (config === null || typeof config !== "object" || Array.isArray(config)) fail(`${appName} right-git-ci-only policy requires .rightgit.json workflows.${name} to be an object`);
1256
+ const file = typeof config?.file === "string" ? config.file : `${name}.yml`;
1257
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,60}\.yml$/.test(file)) fail(`${appName} right-git-ci-only policy refuses workflow file name ${JSON.stringify(file)} declared by .rightgit.json`);
1258
+ return file;
1259
+ });
1260
+ const expectedWorkflows = [...new Set([...lanes.map((lane) => `${lane}.yml`), ...declaredFiles])].sort();
1177
1261
  if (JSON.stringify(workflows) !== JSON.stringify(expectedWorkflows)) {
1178
1262
  fail(`${appName} right-git-ci-only policy requires workflows declared by .rightgit.json; expected ${expectedWorkflows.join(", ")}, got: ${workflows.join(", ")}`);
1179
1263
  }
1264
+ if (declaredFiles.length > 0) await validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles);
1180
1265
  for (const workflow of expectedWorkflows) {
1181
1266
  const source = await readFile(path.join(root, ".github", "workflows", workflow), "utf8");
1182
1267
  if (!source.startsWith("# Managed by right-git")) {
1183
1268
  fail(`${appName} ${workflow} is not marked as right-git managed`);
1184
1269
  }
1270
+ if (declaredFiles.includes(workflow)) declaredWorkflowPolicyRefusal(appName, workflow, source);
1185
1271
  const jobSpan = (jobName) => {
1186
1272
  const marker = `\n ${jobName}:\n`;
1187
1273
  const start = source.indexOf(marker);
@@ -1462,12 +1548,12 @@ function macSignInvocation(items, receiptPath, extra = []) {
1462
1548
  return [SIGN_MACOS, ...extra, "--receipt", receiptPath, "--manifest", manifest];
1463
1549
  }
1464
1550
 
1465
- async function signMacos(entries, root, phase = "signing", nestedCode = []) {
1551
+ async function signMacos(entries, root, phase = "signing", nestedCode = [], nestedSigning) {
1466
1552
  const files = entries.map((entry) => entry.file);
1467
1553
  const receipt = phase === "signing" ? macosReceiptPath(root, "signing") : path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts"), `macos-${phase}.json`);
1468
1554
  // Directory entries (.app bundles): nested code first (default settings unless sign.nestedCode names it), then the bundle itself.
1469
1555
  let nested = [];
1470
- try { ({ nested } = opts.dryRun ? { nested: [] } : planMacSigning({ entries, nestedCode })); } catch (error) { fail(error.message); }
1556
+ try { ({ nested } = opts.dryRun ? { nested: [] } : planMacSigning({ entries, nestedCode, nestedSigning, root })); } catch (error) { fail(error.message); }
1471
1557
  if (opts.dryRun && (nestedCode.length || entries.some(hasNonDefaultSettings))) {
1472
1558
  console.log(`right-release: dry-run: per-file signing matrix (${nestedCode.length} nestedCode rule(s), ${entries.filter(hasNonDefaultSettings).length} entry setting(s)); nested code is resolved against the built bundle at sign time`);
1473
1559
  }
@@ -671,6 +671,17 @@
671
671
  "Fingerprints identify review candidates; published two-host replacement qualification is not recorded."
672
672
  ]
673
673
  },
674
+ {
675
+ "package": "rightkit-crypto",
676
+ "capability": "crypto-primitives",
677
+ "summary": "Neutral SHA-256, HMAC-SHA256, constant-time compare, base64/base64url, hex, BLAKE3 text hash, canonical JSON and HMAC approval tokens",
678
+ "fingerprints": [],
679
+ "detection": "declaration-only",
680
+ "policyNotes": [
681
+ "Declaration-only: apps call this crate instead of hand-rolling HMAC, constant-time compare, base64 or approval tokens.",
682
+ "Not a general cryptography suite: no encryption, signatures or key derivation."
683
+ ]
684
+ },
674
685
  {
675
686
  "package": "rightkit-daemon",
676
687
  "capability": "local-services",
@@ -1320,6 +1331,17 @@
1320
1331
  "Fingerprints identify review candidates (competing dependencies and distinctive implementation code); published two-host replacement qualification is not recorded."
1321
1332
  ]
1322
1333
  },
1334
+ {
1335
+ "package": "rightkit-google-identity",
1336
+ "capability": "google-identity",
1337
+ "summary": "Google sign-in building blocks: PKCE, redirect, token request body, ID-token claim validation and refresh-token store; Android Credential Manager seam",
1338
+ "fingerprints": [],
1339
+ "detection": "declaration-only",
1340
+ "policyNotes": [
1341
+ "Declaration-only: apps call this crate instead of hand-rolling Google OAuth request shapes.",
1342
+ "ID-token signatures are not verified here; callers must not grant access on claims alone."
1343
+ ]
1344
+ },
1323
1345
  {
1324
1346
  "package": "rightkit-hooks",
1325
1347
  "capability": "lifecycle-hooks",
@@ -1416,6 +1438,17 @@
1416
1438
  "Fingerprints identify review candidates; published two-host replacement qualification is not recorded."
1417
1439
  ]
1418
1440
  },
1441
+ {
1442
+ "package": "rightkit-identity",
1443
+ "capability": "installation-identity",
1444
+ "summary": "Installation identity with clone detection and startup claim, build identity (git rev, dirty flag, profile, channel, UTC stamp), device pairing with signed transcript, owner approval and resume tickets, bearer-token device auth with per-IP rate limit",
1445
+ "fingerprints": [],
1446
+ "detection": "declaration-only",
1447
+ "policyNotes": [
1448
+ "Declaration-only: apps call this crate instead of writing their own install ids, pairing transcripts or bearer-token checks.",
1449
+ "Key agreement is out of scope: the caller supplies the shared secret."
1450
+ ]
1451
+ },
1419
1452
  {
1420
1453
  "package": "rightkit-job-queue",
1421
1454
  "capability": "persistent-jobs",
@@ -2946,12 +2979,12 @@
2946
2979
  {
2947
2980
  "package": "rightkit-spend",
2948
2981
  "capability": "spend-ledger",
2949
- "summary": "Spend reservation, allowance and compare-and-swap ledger on SQLite: integer micro-units, idempotent reserve on request id, in-flight and overrun handling, opaque allowance periods",
2982
+ "summary": "Spend reservation, allowance and compare-and-swap ledger on SQLite: integer micro-units, idempotent reserve on request id, in-flight and overrun handling, opaque allowance periods, optional account key and per-reservation currency, billed-unknown commit, caller-transaction (`_in`) variants, strict transition mode",
2950
2983
  "fingerprints": [],
2951
2984
  "detection": "declaration-only",
2952
2985
  "policyNotes": [
2953
2986
  "Declaration-only: the ledger tables and reservation state machine are not an ownership fingerprint.",
2954
- "Price rows live in rightkit-llm; the ledger stores opaque route and price-version text and no currency."
2987
+ "Price rows live in rightkit-llm; the ledger stores opaque route and price-version text; an allowance holds no currency and a reservation holds only the currency text the caller passes."
2955
2988
  ]
2956
2989
  },
2957
2990
  {
@@ -3078,6 +3111,17 @@
3078
3111
  "Fingerprints identify review candidates; published two-host replacement qualification is not recorded."
3079
3112
  ]
3080
3113
  },
3114
+ {
3115
+ "package": "rightkit-time",
3116
+ "capability": "time-civil",
3117
+ "summary": "RFC 3339 parse and format, fixed UTC offsets, civil date math and HTTP-date formatting",
3118
+ "fingerprints": [],
3119
+ "detection": "declaration-only",
3120
+ "policyNotes": [
3121
+ "Declaration-only: apps call this crate instead of hand-rolling RFC 3339, civil date math or HTTP-date formatting.",
3122
+ "No time zone database: named zones and DST rules are not owned here; callers pass a fixed offset."
3123
+ ]
3124
+ },
3081
3125
  {
3082
3126
  "package": "rightkit-transcript",
3083
3127
  "capability": "timed-transcripts",
@@ -11,6 +11,11 @@ import { parseCodesignArgv } from "./codesign-argv.mjs";
11
11
 
12
12
  export const AGENT_LABEL = "dev.rightkit.signing-agent";
13
13
  export const MAX_FRAME = 1024 * 1024;
14
+ // A typed codesign `--identifier` (reverse-DNS characters only; never starts with "-", so it cannot read as an option).
15
+ export const AGENT_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$/;
16
+ // Request schema 2 is codesign-only and is the sole schema that may carry per-item `identifier`. Schema 1 stays valid and
17
+ // byte-compatible for everything else; an agent that predates schema 2 refuses it (and any unknown item field) outright.
18
+ export const CODESIGN_IDENTIFIER_SCHEMA = 2;
14
19
  export const NOTARIZATION_TIMEOUT_MS = 1800000;
15
20
  export const MAX_NOTARIZATION_TIMEOUT_MS = 7200000;
16
21
  export class SigningAgentError extends Error {
@@ -72,7 +77,7 @@ export function validateSigningRequest(request, { roots, stagingRoot, io = fs }
72
77
  const notary = ["notarize", "staple"].includes(request?.type);
73
78
  if (!["codesign", "codesign-argv", "updater", "notarize", "staple"].includes(request?.type)) fail("MAC_SIGNING_AGENT_PROTOCOL", "Unsupported signing request type");
74
79
  shape(request, [...common, ...(notary ? ["path", "profile", "staple", "app", "validateOnly"] : request.type === "codesign-argv" ? ["argv", "targets"] : [request.type === "codesign" ? "items" : "files"])]);
75
- if (request.schema !== 1 || !Number.isInteger(request.timeoutMs) || request.timeoutMs < 1 || request.timeoutMs > (notary ? MAX_NOTARIZATION_TIMEOUT_MS : 180000)) {
80
+ if (!(request.schema === 1 || (request.type === "codesign" && request.schema === CODESIGN_IDENTIFIER_SCHEMA)) || !Number.isInteger(request.timeoutMs) || request.timeoutMs < 1 || request.timeoutMs > (notary ? MAX_NOTARIZATION_TIMEOUT_MS : 180000)) {
76
81
  fail("MAC_SIGNING_AGENT_PROTOCOL", "Invalid schema or signing deadline");
77
82
  }
78
83
  if (!Array.isArray(request.allowedRoots) || !request.allowedRoots.length || request.allowedRoots.length > 32) fail("MAC_SIGNING_AGENT_PATH_DENIED", "Allowed roots required");
@@ -142,7 +147,8 @@ export function validateSigningRequest(request, { roots, stagingRoot, io = fs }
142
147
  }
143
148
  if (!Array.isArray(request.items) || !request.items.length || request.items.length > 4096) fail("MAC_SIGNING_AGENT_PROTOCOL", "Codesign items required");
144
149
  const items = request.items.map((item) => {
145
- shape(item, ["path", "identity", "entitlements", "runtime", "timestamp", "force", "preserveEntitlements"]);
150
+ shape(item, ["path", "identity", "entitlements", "runtime", "timestamp", "force", "preserveEntitlements", ...(request.schema === CODESIGN_IDENTIFIER_SCHEMA ? ["identifier"] : [])]);
151
+ if (item.identifier !== undefined && (typeof item.identifier !== "string" || !AGENT_IDENTIFIER_PATTERN.test(item.identifier))) fail("MAC_SIGNING_AGENT_PROTOCOL", "Invalid codesign identifier");
146
152
  if (typeof item.identity !== "string" || !item.identity.trim() || item.identity === "-" || item.identity.includes("\0") || item.identity.length > 512) fail("MAC_SIGNING_AGENT_PROTOCOL", "Real signing identity required");
147
153
  for (const field of ["runtime", "timestamp", "force", "preserveEntitlements"]) if (item[field] !== undefined && typeof item[field] !== "boolean") fail("MAC_SIGNING_AGENT_PROTOCOL", "Signing options must be boolean");
148
154
  return { ...item, path: checked(item.path), ...(item.entitlements !== undefined ? { entitlements: checked(item.entitlements, { entitlements: true }) } : {}) };
@@ -156,7 +162,7 @@ export function authenticateSigningToken(candidate, token) {
156
162
  }
157
163
  export function executeSigningRequest(request, { run = spawnSync, home = os.userInfo().homedir,
158
164
  username = os.userInfo().username, pnpm, environmentPath, now = Date.now } = {}) {
159
- if (request.type === "health") return { schema: 1, ok: true, service: AGENT_LABEL, pid: process.pid, capabilities: ["codesign", "codesign-argv", "updater", "notarize", "staple"] };
165
+ if (request.type === "health") return { schema: 1, ok: true, service: AGENT_LABEL, pid: process.pid, capabilities: ["codesign", "codesign-argv", "updater", "notarize", "staple", "codesign-identifier"] };
160
166
  const deadline = now() + request.timeoutMs;
161
167
  const env = { HOME: home, PATH: environmentPath, TMPDIR: path.join(signingAgentPaths(home).directory, "tmp") };
162
168
  if (request.type === "codesign-argv") {
@@ -191,7 +197,7 @@ export function executeSigningRequest(request, { run = spawnSync, home = os.user
191
197
  // Preserve caller's inside-out ordering. No arbitrary argv reaches spawn.
192
198
  for (const item of request.items) {
193
199
  const args = [...(item.force ? ["--force"] : []), ...(item.runtime ? ["--options", "runtime"] : []),
194
- ...(item.timestamp ? ["--timestamp"] : []), "--sign", item.identity,
200
+ ...(item.timestamp ? ["--timestamp"] : []), ...(item.identifier !== undefined ? ["--identifier", item.identifier] : []), "--sign", item.identity,
195
201
  ...(item.entitlements ? ["--entitlements", item.entitlements] : []),
196
202
  ...(item.preserveEntitlements ? ["--preserve-metadata=entitlements"] : []), item.path];
197
203
  results.push(invoke("/usr/bin/codesign", args).result);
@@ -31,7 +31,7 @@
31
31
  "@rightkit/qa": "0.4.9",
32
32
  "@rightkit/qa-chrome": "0.1.8",
33
33
  "@rightkit/react-shell": "0.6.8",
34
- "@rightkit/release": "0.2.173",
34
+ "@rightkit/release": "0.3.0",
35
35
  "@rightkit/settings-ui": "0.5.0",
36
36
  "@rightkit/tauri": "0.2.1",
37
37
  "@rightkit/tauri-shell": "0.1.1",
@@ -210,7 +210,9 @@
210
210
  "0.2.163",
211
211
  "0.2.164",
212
212
  "0.2.165",
213
- "0.2.172"
213
+ "0.2.172",
214
+ "0.2.173",
215
+ "0.2.174"
214
216
  ],
215
217
  "@rightkit/qa": [
216
218
  "0.1.0",
@@ -384,7 +386,7 @@
384
386
  "rightkit-automation": "0.4.1",
385
387
  "rightkit-browser": "0.3.1",
386
388
  "rightkit-cache": "0.2.0",
387
- "rightkit-channel-memory": "0.1.0",
389
+ "rightkit-channel-memory": "0.2.1",
388
390
  "rightkit-child-process": "0.4.3",
389
391
  "rightkit-compose": "0.1.0",
390
392
  "rightkit-contracts": "0.1.5",
@@ -456,8 +458,8 @@
456
458
  "rightkit-automation": "0.4.1",
457
459
  "rightkit-browser": "0.3.1",
458
460
  "rightkit-cache": "0.2.0",
459
- "rightkit-channel-memory": "0.1.0",
460
- "rightkit-child-process": "0.4.1",
461
+ "rightkit-channel-memory": "0.2.1",
462
+ "rightkit-child-process": "0.4.3",
461
463
  "rightkit-compose": "0.1.0",
462
464
  "rightkit-contracts": "0.1.5",
463
465
  "rightkit-control": "0.3.0",
@@ -541,7 +543,7 @@
541
543
  "@rightkit/qa": "0.4.9",
542
544
  "@rightkit/qa-chrome": "0.1.8",
543
545
  "@rightkit/react-shell": "0.6.8",
544
- "@rightkit/release": "0.2.172",
546
+ "@rightkit/release": "0.2.174",
545
547
  "@rightkit/settings-ui": "0.5.0",
546
548
  "@rightkit/shell": "0.1.1",
547
549
  "@rightkit/tauri": "0.2.1",
@@ -812,6 +814,9 @@
812
814
  ],
813
815
  "rightkit-hotkeys": [
814
816
  "0.1.0"
817
+ ],
818
+ "rightkit-channel-memory": [
819
+ "0.1.0"
815
820
  ]
816
821
  }
817
822
  }
@@ -199,7 +199,7 @@ export function renderRuntimeBudgetBody(budget) {
199
199
  const host = `${m.host?.os}/${m.host?.arch}${m.host?.model ? ` (${m.host.model})` : ""}`;
200
200
  const scale = `${count(m.dataScale?.count)} ${m.dataScale?.unit} (${m.dataScale?.description})`;
201
201
  const app = m.scope === "installed" && m.app ? `, app ${m.app.name} ${m.app.version}` : "";
202
- const observed = BUDGET_FIGURES.map((k) => `${FIGURE_LABEL[k]} ${m.observed?.[k] === undefined ? "n/a" : FORMAT[k](m.observed[k])}`).join(", ");
202
+ const observed = BUDGET_FIGURES.map((k) => `${FIGURE_LABEL[k]} ${m.observed?.[k] === undefined || m.observed?.[k] === null ? "n/a (not measurable on this host)" : FORMAT[k](m.observed[k])}`).join(", ");
203
203
  out.push(`- Measured: ${host}, ${budget.crate} ${m.crateVersion} at ${String(m.commit).slice(0, 12)}, ${scale}, ${m.scope}${app}, \`${m.command}\`, ${m.date}, evidence ${String(m.evidence?.sha256 ?? "none").slice(0, 12)}: ${observed}.`);
204
204
  }
205
205
  return out;
@@ -291,6 +291,10 @@ export function measurementStaleness(commit, crateDir, git = defaultGit, deps =
291
291
 
292
292
  // ---------------------------------------------------------------- bench evidence
293
293
 
294
+ // Figures a host cannot measure by design. macOS has no per-process disk-operation count (rightkit-child-process
295
+ // usage.rs: ru_inblock/ru_oublock stay 0 there), so disk_ops_per_s may be "unavailable" on darwin only.
296
+ export const HOST_UNAVAILABLE_FIGURES = Object.freeze({ darwin: Object.freeze(["diskOpsPerSecond"]) });
297
+ export const figureUnavailableOn = (os, key) => (HOST_UNAVAILABLE_FIGURES[os] ?? []).includes(key);
294
298
  const RESULT_NUMBERS = Object.freeze({ resident_bytes: "residentMemoryBytes", idle_cpu_percent: "idleCpuPercent", wakeups_per_s: "wakeupsPerSecond", disk_ops_per_s: "diskOpsPerSecond", scale_count: "scaleCount" });
295
299
  const RESULT_WORDS = Object.freeze(["crate", "version", "scale_unit"]);
296
300
 
@@ -312,7 +316,8 @@ export function parseBudgetResult(text) {
312
316
  for (const key of RESULT_WORDS) { if (!nonEmpty(fields[key])) problems.push(`missing field ${key}`); else value[key] = fields[key]; }
313
317
  for (const [key, name] of Object.entries(RESULT_NUMBERS)) {
314
318
  const n = Number(fields[key]);
315
- if (fields[key] === undefined || fields[key] === "" || !Number.isFinite(n) || n < 0) problems.push(`field ${key} must be a non-negative number`);
319
+ if (fields[key] === "unavailable" && name !== "residentMemoryBytes" && name !== "scaleCount") value[name] = null;
320
+ else if (fields[key] === undefined || fields[key] === "" || !Number.isFinite(n) || n < 0) problems.push(`field ${key} must be a non-negative number`);
316
321
  else value[name] = n;
317
322
  }
318
323
  return problems.length ? { ok: false, problems } : { ok: true, problems: [], value };
@@ -412,6 +417,7 @@ function validateMeasurement(m, index) {
412
417
  }
413
418
  if (!nonEmpty(m.command)) problems.push(`${at} has no command`);
414
419
  for (const key of BUDGET_FIGURES) {
420
+ if (m.observed?.[key] === null && figureUnavailableOn(m.host?.os, key)) continue;
415
421
  if (!isNumber(m.observed?.[key]) || m.observed[key] < 0) problems.push(`${at} observed.${key} must be a non-negative number`);
416
422
  }
417
423
  if (isNumber(m.observed?.residentMemoryBytes) && m.observed.residentMemoryBytes === 0) {
@@ -507,12 +513,12 @@ export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requ
507
513
  }
508
514
  for (const { m, index } of counted) {
509
515
  for (const key of BUDGET_FIGURES) {
510
- if (ceilings[key] !== undefined && m.observed[key] > ceilings[key]) {
516
+ if (ceilings[key] !== undefined && m.observed[key] !== null && m.observed[key] > ceilings[key]) {
511
517
  problems.push(`measurement[${index}] on ${m.host.os}/${m.host.arch} exceeds the ${FIGURE_LABEL[key]} ceiling: observed ${m.observed[key]} > ${ceilings[key]}`);
512
518
  }
513
519
  }
514
520
  }
515
- const withinCeilings = (m) => BUDGET_FIGURES.every((key) => ceilings[key] !== undefined && m.observed[key] <= ceilings[key]);
521
+ const withinCeilings = (m) => BUDGET_FIGURES.every((key) => ceilings[key] !== undefined && (m.observed[key] === null ? figureUnavailableOn(m.host?.os, key) : m.observed[key] <= ceilings[key]));
516
522
  const atScale = (m) => requiredScale !== null && m.dataScale.unit === requiredScale.unit && m.dataScale.count >= requiredScale.minCount;
517
523
  const staleness = new Map();
518
524
  const stale = (m) => {
@@ -749,16 +755,18 @@ export function appRuntimeBudgetAdoptionCheck({ appRoot, app, version, cargoLock
749
755
  const used = Object.keys(classes).filter((name) => locked.has(name));
750
756
  if (used.length === 0) return null;
751
757
  const problems = [];
758
+ // A crate whose ceilings or scale RightKit has not declared yet cannot be measured against anything, so it
759
+ // never blocks an app: it is reported as pending (warn) until the crate declares its budget.
760
+ const pending = used.filter((name) => !classes[name].ceilings || !classes[name].requiredScale);
761
+ const measurable = used.filter((name) => !pending.includes(name));
752
762
  const adoptionPath = path.join(appRoot, APP_ADOPTION_FILE);
753
763
  let records = [];
754
- if (!exists(adoptionPath)) problems.push(`${APP_ADOPTION_FILE} is missing`);
764
+ if (!exists(adoptionPath)) { if (measurable.length) problems.push(`${APP_ADOPTION_FILE} is missing`); }
755
765
  else {
756
766
  try { records = JSON.parse(readFile(adoptionPath, "utf8")).measurements ?? []; } catch (error) { problems.push(`${APP_ADOPTION_FILE} is not valid JSON: ${error.message}`); }
757
767
  }
758
- for (const crate of used) {
768
+ for (const crate of measurable) {
759
769
  const { ceilings, requiredScale } = classes[crate];
760
- if (!ceilings) { problems.push(`${crate}: ceilings undeclared (declare them in its runtime-budget.json and regenerate ${CLASSES_FILE})`); continue; }
761
- if (!requiredScale) { problems.push(`${crate}: requiredScale undeclared`); continue; }
762
770
  const mine = records.map((m, index) => ({ m, index })).filter(({ m }) => m?.crate === crate && m.scope === "installed" && locked.get(crate).has(m.crateVersion));
763
771
  if (mine.length === 0) { problems.push(`${crate} ${[...locked.get(crate)].join("/")}: no installed-scope measurement of ${app}`); continue; }
764
772
  const good = mine.filter(({ m, index }) => {
@@ -768,16 +776,21 @@ export function appRuntimeBudgetAdoptionCheck({ appRoot, app, version, cargoLock
768
776
  if (evidenceProblems.length) { problems.push(`${crate}: ${evidenceProblems.join("; ")}`); return false; }
769
777
  if (m.app.name !== app) { problems.push(`${crate}: measurement[${index}] is for app ${m.app.name}, not ${app}`); return false; }
770
778
  if (version && m.app.version !== version) { problems.push(`${crate}: measurement[${index}] was taken at ${app} ${m.app.version}, not the current ${version}`); return false; }
771
- const over = BUDGET_FIGURES.filter((k) => m.observed[k] > ceilings[k]);
779
+ const over = BUDGET_FIGURES.filter((k) => m.observed[k] !== null && m.observed[k] > ceilings[k]);
772
780
  if (over.length) { problems.push(`${crate}: measurement[${index}] exceeds ${over.join(", ")}`); return false; }
773
781
  if (m.dataScale.unit !== requiredScale.unit || m.dataScale.count < requiredScale.minCount) { problems.push(`${crate}: measurement[${index}] is below the required scale ${requiredScale.minCount} ${requiredScale.unit}`); return false; }
774
782
  return true;
775
783
  });
776
784
  if (good.length === 0 && !problems.some((p) => p.startsWith(`${crate}`))) problems.push(`${crate}: no qualifying measurement`);
777
785
  }
778
- return problems.length
779
- ? { name: RUNTIME_BUDGET_CHECK, status: "fail", detail: `${app} uses held-resource crate(s) ${used.join(", ")}: ${problems.join("; ")}`, fix: `record an installed measurement of ${app} for each crate in ${APP_ADOPTION_FILE} (docs/RUNTIME-BUDGETS.md)` }
780
- : { name: RUNTIME_BUDGET_CHECK, status: "ok", detail: `${app} has installed measurements for ${used.join(", ")}` };
786
+ const pendingNote = pending.length ? `; pending RightKit budget declaration (not blocking): ${pending.join(", ")}` : "";
787
+ if (problems.length) {
788
+ return { name: RUNTIME_BUDGET_CHECK, status: "fail", detail: `${app} uses held-resource crate(s) ${measurable.join(", ")}: ${problems.join("; ")}${pendingNote}`, fix: `record an installed measurement of ${app} for each crate in ${APP_ADOPTION_FILE} (docs/RUNTIME-BUDGETS.md)` };
789
+ }
790
+ if (pending.length) {
791
+ return { name: RUNTIME_BUDGET_CHECK, status: "warn", detail: `${app} uses held-resource crate(s) whose RightKit ceilings are not declared yet: ${pending.join(", ")}${measurable.length ? `; measured: ${measurable.join(", ")}` : ""}`, fix: "RightKit declares the crate ceilings; then record installed measurements (docs/RUNTIME-BUDGETS.md)" };
792
+ }
793
+ return { name: RUNTIME_BUDGET_CHECK, status: "ok", detail: `${app} has installed measurements for ${used.join(", ")}` };
781
794
  }
782
795
 
783
796
  // ---------------------------------------------------------------- record (measurement provenance)
@@ -813,6 +826,9 @@ export function recordMeasurement({ crateDir, resultFile, scope, command, descri
813
826
  const v = parsed.value;
814
827
  if (v.crate !== pkg.name) throw new Error(`bench output is for ${v.crate}, crate is ${pkg.name}`);
815
828
  if (v.version !== pkg.version) throw new Error(`bench output is for version ${v.version}, crate is ${pkg.version}`);
829
+ for (const key of BUDGET_FIGURES) {
830
+ if (v[key] === null && !figureUnavailableOn(host.os, key)) throw new Error(`bench output rejected: ${key} is unavailable, which is allowed only where the host cannot measure it (${JSON.stringify(HOST_UNAVAILABLE_FIGURES)})`);
831
+ }
816
832
  if (!SCOPES.includes(scope)) throw new Error(`--scope must be one of ${SCOPES.join(", ")}`);
817
833
  if (scope === "installed" && !(nonEmpty(app?.name) && nonEmpty(app?.version))) throw new Error("--scope installed needs --app and --app-version");
818
834
  if (!nonEmpty(command)) throw new Error("--command (the exact bench command) is required");
package/stage-binary.mjs CHANGED
@@ -9,7 +9,7 @@ import { resolveTargetRoot } from "./cargo-target.mjs";
9
9
  * The build broker writes Cargo output to per-generation folders (`<managed>/rightkit-build-control/ws/<hash>/g/<gen>/...`)
10
10
  * and `cargo-guard` uses a hashed `dev-targets/<project>-<hash>` folder. Those are build output only: their path is not
11
11
  * stable, so NOTHING may run from them. macOS ties privacy grants to the path an app runs from, so a dev or QA binary is
12
- * copied in place to `<root>/.rightkit-dev-bin/<triple-or-host>/<name>` (keeping `<name>.previous`) and run from there.
12
+ * copied in place to `<root>/.rightkit-dev-bin/<triple-or-host>/<name>` (replaced in place, no `.previous` kept) and run from there.
13
13
  */
14
14
  export const DEV_STAGE_DIRECTORY = ".rightkit-dev-bin";
15
15
 
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
- // `right-release stage-sidecar [--config right-release.config.mjs] [--target <triple>]` (bogusyogi/rightkit#345):
3
- // stages the config's declarative `sidecars` into src-tauri/binaries with Tauri externalBin naming.
2
+ // `right-release stage-sidecar [--config right-release.config.mjs] [--target <triple>] [--check] [--json]` (bogusyogi/rightkit#345):
3
+ // stages the config's declarative `sidecars` into src-tauri/binaries with Tauri externalBin naming. `--check` validates
4
+ // the manifest and prints the plan without building, downloading, copying or deleting anything (for template gates).
4
5
  import path from "node:path";
5
6
  import { pathToFileURL } from "node:url";
6
- import { resolveTargetTriple, stageSidecars } from "./stage-sidecar.mjs";
7
+ import { planSidecarStaging, resolveTargetTriple, stageSidecars } from "./stage-sidecar.mjs";
7
8
 
8
9
  export function parseStageSidecarArgs(argv) {
9
- const opts = { config: "right-release.config.mjs", target: undefined, json: false };
10
+ const opts = { config: "right-release.config.mjs", target: undefined, json: false, check: false };
10
11
  for (let i = 0; i < argv.length; i += 1) {
11
12
  const arg = argv[i];
12
13
  if (arg === "--config" || arg === "--target") {
@@ -15,11 +16,35 @@ export function parseStageSidecarArgs(argv) {
15
16
  opts[arg.slice(2)] = value;
16
17
  i += 1;
17
18
  } else if (arg === "--json") opts.json = true;
19
+ else if (arg === "--check") opts.check = true;
18
20
  else throw new Error(`unknown stage-sidecar argument: ${arg}`);
19
21
  }
20
22
  return opts;
21
23
  }
22
24
 
25
+ const NODE_ARCH = { x64: "x86_64", arm64: "aarch64" };
26
+ const NODE_OS = { darwin: "apple-darwin", win32: "pc-windows-msvc", linux: "unknown-linux-gnu" };
27
+
28
+ /** Host triple from Node alone (no subprocess), so `--check` stays free of effects. */
29
+ export function nodeHostTriple(platform = process.platform, arch = process.arch) {
30
+ const cpu = NODE_ARCH[arch];
31
+ const system = NODE_OS[platform];
32
+ if (!cpu || !system) throw new Error(`cannot derive a target triple from ${platform}/${arch}; pass --target`);
33
+ return `${cpu}-${system}`;
34
+ }
35
+
36
+ /** Human-readable plan lines for `--check`. */
37
+ export function formatSidecarPlan(plan) {
38
+ const lines = [`right-release stage-sidecar --check: target ${plan.target}, mode ${plan.mode}, stageDir ${plan.stageDir}${plan.wipeStageDir ? " (wipeStageDir)" : ""}`];
39
+ for (const entry of plan.entries) {
40
+ if (entry.skipped) lines.push(` [${entry.index}] ${entry.kind}: skipped (platforms ${entry.platforms.join(",")})`);
41
+ else lines.push(` [${entry.index}] ${entry.kind}: ${entry.outputs.join(", ")}${entry.externalBin ? " (externalBin)" : ""}${entry.clean ? " (clean)" : ""}${entry.url ? ` <- ${entry.url}` : ""}`);
42
+ }
43
+ if (plan.nestedBrokerRisk) lines.push(" warning: running inside an outer broker build; staging would fail with STAGE_SIDECAR_NESTED_BROKER (run stage-sidecar in recipe prepare, before the Tauri build)");
44
+ lines.push("right-release stage-sidecar --check: manifest is valid; nothing was staged");
45
+ return lines;
46
+ }
47
+
23
48
  async function main() {
24
49
  const opts = parseStageSidecarArgs(process.argv.slice(2));
25
50
  const configPath = path.resolve(opts.config);
@@ -27,6 +52,13 @@ async function main() {
27
52
  const loaded = await import(pathToFileURL(configPath).href);
28
53
  const config = loaded.default ?? loaded.config ?? loaded;
29
54
  if (config?.sidecars === undefined) throw new Error(`${opts.config} declares no sidecars`);
55
+ if (opts.check) {
56
+ const target = opts.target ?? process.env.TAURI_ENV_TARGET_TRIPLE ?? nodeHostTriple();
57
+ const plan = planSidecarStaging({ manifest: config.sidecars, target });
58
+ if (opts.json) console.log(JSON.stringify(plan, null, 2));
59
+ else for (const line of formatSidecarPlan(plan)) console.log(line);
60
+ return;
61
+ }
30
62
  const target = resolveTargetTriple({ target: opts.target, cwd: appRoot });
31
63
  const result = await stageSidecars({ appRoot, manifest: config.sidecars, target });
32
64
  if (opts.json) console.log(JSON.stringify(result, null, 2));