@rightkit/release 0.6.8 → 0.6.11

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 (96) hide show
  1. package/README.md +30 -12
  2. package/admission-files.mjs +4 -1
  3. package/admission.mjs +2 -2
  4. package/advisory-exceptions.mjs +41 -5
  5. package/android-play-upload.mjs +16 -15
  6. package/app-check.mjs +105 -27
  7. package/assemble-app-command.mjs +151 -0
  8. package/assembled-app.mjs +445 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/darwin/rightkit-swap.sign.json +6 -0
  11. package/bin/rightkit-gate.mjs +1608 -345
  12. package/broker-pins.mjs +152 -0
  13. package/build-launch.mjs +88 -0
  14. package/build-ownership.mjs +1231 -0
  15. package/build-release.mjs +82 -28
  16. package/cargo-contract.mjs +3 -1
  17. package/cargo-ledger.mjs +62 -0
  18. package/cargo-target-shim.mjs +238 -0
  19. package/class-a-launch-guard.mjs +126 -0
  20. package/cli/right-release.mjs +31 -5
  21. package/clippy-equivalence.mjs +202 -0
  22. package/clippy-route.mjs +543 -0
  23. package/codesign-shim.mjs +11 -1
  24. package/committed-source.mjs +39 -5
  25. package/crate-workspaces.mjs +87 -0
  26. package/create-mac-updater.mjs +14 -2
  27. package/deterministic-scans.mjs +997 -71
  28. package/development-signing.mjs +4 -1
  29. package/development.mjs +120 -21
  30. package/direct-receipt.mjs +135 -0
  31. package/door-policy.mjs +71 -6
  32. package/door.mjs +6 -6
  33. package/freshness-census.mjs +257 -0
  34. package/gate-executor.mjs +14 -2
  35. package/gate-stages.mjs +91 -15
  36. package/heavy-command.mjs +3 -2
  37. package/hook-retirement.mjs +65 -0
  38. package/init-command.mjs +1 -1
  39. package/install-app.mjs +11 -2
  40. package/install-assembled-app.mjs +423 -0
  41. package/inventory-check.mjs +11 -8
  42. package/ios-dev.mjs +70 -3
  43. package/ios-upload.mjs +20 -15
  44. package/linux-artifacts.mjs +93 -0
  45. package/local-publish.mjs +224 -53
  46. package/local-qualify.mjs +5 -3
  47. package/local-runtime.mjs +5 -4
  48. package/mac-package-recipe.mjs +4 -1
  49. package/managed-cargo.mjs +47 -5
  50. package/managed-root-guard.mjs +34 -0
  51. package/mirror-root-artifact.mjs +18 -5
  52. package/native-helpers.mjs +355 -0
  53. package/ownership-class-rules.mjs +1 -1
  54. package/ownership-evidence.mjs +37 -13
  55. package/ownership-hooks.mjs +20 -59
  56. package/ownership-lockfiles.mjs +14 -1
  57. package/ownership-policy-bundle.mjs +3 -2
  58. package/ownership-staged.mjs +2 -1
  59. package/ownership.mjs +24 -0
  60. package/package-recipe-targets.mjs +84 -10
  61. package/package.json +4 -3
  62. package/pipeline-admission.mjs +4 -4
  63. package/pipeline-receipts.mjs +302 -302
  64. package/preflight.mjs +27 -4
  65. package/project-type.mjs +2 -2
  66. package/provenance-verify.mjs +115 -0
  67. package/publish-cargo.mjs +12 -9
  68. package/qa-admission-policy.json +10 -1
  69. package/recipe-build.mjs +106 -14
  70. package/release-artifact-path.mjs +341 -0
  71. package/release-config.mjs +1 -1
  72. package/release-invocation.mjs +8 -1
  73. package/release-qa-command.mjs +25 -9
  74. package/release-source-brand.mjs +17 -0
  75. package/release-source-identity.mjs +171 -0
  76. package/release.mjs +59 -22
  77. package/rightkit-versions.json +73 -47
  78. package/runtime-budgets.mjs +6 -2
  79. package/scan-digest.mjs +71 -5
  80. package/sign-updater.mjs +6 -3
  81. package/snapshot-broker.mjs +172 -0
  82. package/snapshot-check.mjs +312 -0
  83. package/snapshot-git.mjs +166 -0
  84. package/snapshot-request.mjs +363 -0
  85. package/snapshot-root-config.mjs +76 -0
  86. package/snapshot.mjs +969 -0
  87. package/source-split.mjs +73 -0
  88. package/stage-commands.mjs +60 -0
  89. package/stage-ledger.mjs +204 -0
  90. package/stage-sidecar-command.mjs +5 -1
  91. package/stage-sidecar.mjs +157 -27
  92. package/swap-helper-bundle.mjs +5 -2
  93. package/test-command.mjs +17 -4
  94. package/unit-gate.mjs +301 -0
  95. package/web-build.mjs +40 -6
  96. package/win-package-recipe.mjs +20 -7
package/stage-sidecar.mjs CHANGED
@@ -38,7 +38,7 @@ import os from "node:os";
38
38
  import path from "node:path";
39
39
  import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
40
40
  import { resolveCommand } from "./development.mjs";
41
- import { managedHost } from "./managed-cargo.mjs";
41
+ import { directPlatform, managedHost } from "./managed-cargo.mjs";
42
42
  import { APPROVAL_MACHO_STRIP_LINKEDIT_V1, APPROVAL_RAW_SHA256, approvalDigest } from "./macho-approval-hash.mjs";
43
43
  import {
44
44
  bundledFileName, planMediaSidecar, sourceOfferText, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
@@ -111,7 +111,7 @@ const KEYS = Object.freeze({
111
111
  "native-library": ["kind", "library", "version", "catalog", "resourceDir", "licenseDir", "cacheDir", "as", "files", "companions", "source", "platforms"],
112
112
  resource: ["kind", "from", "to", "sha256", "machO", "clean", "platforms"],
113
113
  command: ["kind", "name", "cmd", "args", "cwd", "output", "externalBin", "dest", "clean", "platforms"],
114
- remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "extract", "memberSha256", "executable", "clean", "platforms"],
114
+ remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "extract", "extractKind", "memberSha256", "executable", "clean", "platforms"],
115
115
  cmake: ["kind", "name", "repo", "commit", "target", "output", "cmakeArgs", "platformArgs", "generator", "platformGenerators", "architecture", "cmakeBin", "smoke", "sha256", "dest", "clean", "platforms"],
116
116
  prebuilt: ["kind", "name", "from", "manifest", "dest", "platforms"],
117
117
  });
@@ -476,10 +476,15 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
476
476
  if (item.size !== undefined && (!Number.isSafeInteger(item.size) || item.size <= 0)) errors.push(`${where}: size must be a positive integer (bytes)`);
477
477
  if (item.extract !== undefined) { const member = normalizeArchiveMember(item.extract); if (member.error || (!member.byPath && !NAME.test(member.value))) errors.push(`${where}: extract must be the bare file name of one archive member or its archive path (posix, no leading /, no .. segment): ${member.error ?? "invalid file name"}`); }
478
478
  if (item.memberSha256 !== undefined && (item.extract === undefined || !SHA256.test(item.memberSha256 ?? ""))) errors.push(`${where}: memberSha256 must be a lowercase 64-hex SHA-256 and needs extract`);
479
+ if (item.extractKind !== undefined && item.extractKind !== "nsis") errors.push(`${where}: extractKind must be "nsis" (omit it for tar-readable zip/tgz/nupkg archives)`);
480
+ if (item.extractKind === "nsis") {
481
+ if (item.extract === undefined) errors.push(`${where}: extractKind "nsis" needs extract (the installer member path, e.g. bin/smartctl.exe)`);
482
+ if (typeof item.memberSha256 !== "string" || !SHA256.test(item.memberSha256)) errors.push(`${where}: extractKind "nsis" requires memberSha256 (the extracted member's lowercase 64-hex SHA-256); an NSIS member is never staged unpinned`);
483
+ }
479
484
  if (item.executable !== undefined && typeof item.executable !== "boolean") errors.push(`${where}: executable must be a boolean`);
480
485
  const clean = cleanOf();
481
486
  if (dest && !skipped) claim(dest, where);
482
- Object.assign(entry, { name, url: item.url, sha256: item.sha256, dest, ...(item.size ? { size: item.size } : {}), ...(item.extract ? { extract: item.extract } : {}), ...(item.memberSha256 ? { memberSha256: item.memberSha256 } : {}), ...(item.executable ? { executable: true } : {}), ...(clean ? { clean } : {}) });
487
+ Object.assign(entry, { name, url: item.url, sha256: item.sha256, dest, ...(item.extractKind ? { extractKind: item.extractKind } : {}), ...(item.size ? { size: item.size } : {}), ...(item.extract ? { extract: item.extract } : {}), ...(item.memberSha256 ? { memberSha256: item.memberSha256 } : {}), ...(item.executable ? { executable: true } : {}), ...(clean ? { clean } : {}) });
483
488
  } else if (item.kind === "cmake") {
484
489
  const name = item.name;
485
490
  if (typeof name !== "string" || !NAME.test(name) || /\.exe$/i.test(name) || name !== path.basename(name)) errors.push(`${where}: name must be a bare sidecar name (no path, no .exe, no target triple)`);
@@ -611,6 +616,45 @@ export function defaultExtractMember({ archive, fileName, destDir, run = default
611
616
  return out;
612
617
  }
613
618
 
619
+ /** 7-Zip candidates: an explicit path (deps.sevenZip or RIGHTKIT_SEVEN_ZIP), then `7z` on PATH, then 7z.exe under Program Files on win32. */
620
+ export function sevenZipCandidates({ explicit, env = process.env, platform = process.platform } = {}) {
621
+ const list = [];
622
+ if (explicit) list.push(explicit);
623
+ else if (env.RIGHTKIT_SEVEN_ZIP) list.push(env.RIGHTKIT_SEVEN_ZIP);
624
+ list.push("7z");
625
+ if (platform === "win32") for (const base of [env.ProgramFiles ?? "C:\\Program Files", env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"]) list.push(path.win32.join(base, "7-Zip", "7z.exe"));
626
+ return [...new Set(list)];
627
+ }
628
+
629
+ /**
630
+ * Extract one member of an NSIS installer with 7-Zip (`x -y -o<dir> <installer> <member>`; nothing in the installer is run), then
631
+ * require its SHA-256 to equal the pinned one. Fails closed, naming the installer digest, member, expected and actual digests, on a
632
+ * missing tool, a failed extraction, a missing member or any mismatch. Returns the extracted file path under destDir.
633
+ */
634
+ export function defaultExtractNsisMember({ archive, fileName, destDir, expectedSha256, installerSha256, run = defaultRun, sevenZip, env = process.env, platform = process.platform, readFile = nodeFs.readFileSync, exists = nodeFs.existsSync }) {
635
+ const wanted = normalizeArchiveMember(fileName);
636
+ const who = `NSIS installer ${installerSha256 ?? archive} member ${JSON.stringify(fileName)}`;
637
+ if (wanted.error) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who} ${wanted.error}`);
638
+ if (!SHA256.test(expectedSha256 ?? "")) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who}: a pinned member sha256 is required`);
639
+ let tool;
640
+ let lastError = "not found";
641
+ for (const candidate of sevenZipCandidates({ explicit: sevenZip, env, platform })) {
642
+ const result = run(candidate, ["x", "-y", `-o${destDir}`, archive, wanted.value], { cwd: path.dirname(archive) });
643
+ if (result.error || result.status === null || result.status === undefined) { lastError = result.error?.message ?? "could not start"; continue; }
644
+ tool = candidate;
645
+ if (result.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who}: ${candidate} exited ${result.status}; nothing staged`);
646
+ break;
647
+ }
648
+ if (!tool) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who}: 7-Zip is required and was not found (${lastError}); install 7-Zip or set RIGHTKIT_SEVEN_ZIP; nothing staged`);
649
+ const out = path.join(destDir, ...wanted.value.split("/"));
650
+ const inside = path.relative(path.resolve(destDir), path.resolve(out));
651
+ if (!inside || inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who} resolves outside the extraction directory`);
652
+ if (!exists(out)) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who}: member not found after extraction; nothing staged`);
653
+ const actual = sha256Of(readFile(out));
654
+ if (actual !== expectedSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${who}: sha256 is ${actual}, expected ${expectedSha256}; nothing staged`, { expected: expectedSha256, actual });
655
+ return out;
656
+ }
657
+
614
658
  async function defaultFetchBytes(url) {
615
659
  if (!url.startsWith("https://")) fail("STAGE_SIDECAR_CONFIG_INVALID", `refusing a non-https archive URL: ${url}`);
616
660
  const response = await fetch(url);
@@ -621,8 +665,8 @@ async function defaultFetchBytes(url) {
621
665
  /** Absolute catalog file for a validated spec. Crate resolution reads the rightkit metadata listing; npm walks up node_modules. */
622
666
  function resolveCatalogFile(spec, ctx, kind) {
623
667
  const label = kind === "ffmpeg" ? "rightkit-ffmpeg" : "native-libraries";
624
- if (spec.via === "absolute") return spec.file;
625
- if (spec.via === "path") return path.resolve(ctx.appRoot, spec.file);
668
+ if (spec.via === "absolute") return requireSnapshotSource(ctx, spec.file, `the ${label} catalog`);
669
+ if (spec.via === "path") return requireSnapshotSource(ctx, path.resolve(ctx.appRoot, spec.file), `the ${label} catalog`);
626
670
  if (spec.via === "crate") {
627
671
  const meta = ctx.run("rustright", ["cargo", "metadata", "--format-version", "1"], { cwd: ctx.appRoot, env: ctx.env });
628
672
  let packages;
@@ -707,6 +751,23 @@ function cleanDest(ctx, entry, dest, { directory = false } = {}) {
707
751
  try { ctx.fs.rmSync(dest, { recursive: directory, force: true }); } catch (error) { fail("STAGE_SIDECAR_CLEAN_REFUSED", `cannot clean ${dest} (${error.message}); a directory is only removed for a directory resource`, { dest, cause: error }); }
708
752
  }
709
753
 
754
+ /**
755
+ * Warm-cache rule (GATE-CONTRACT "rebuild only what changed"): a staged file carries the mtime of the cache file it was copied from, a
756
+ * lipo or merge output the newest mtime of its inputs, so a re-stage after the snapshot sync (which removes every ledger entry) does not
757
+ * bump the mtime and re-trigger the app crate (tauri-build emits rerun-if-changed for the staged sidecar). Bytes are bound by the
758
+ * ledger, never by mtime. A file system without `utimesSync` or `statSync().mtime` (a test double) is left as written.
759
+ */
760
+ function carryMtime(ctx, dest, sources) {
761
+ const { fs } = ctx;
762
+ if (typeof fs.utimesSync !== "function") return;
763
+ let newest = null;
764
+ for (const source of sources) {
765
+ let st; try { st = fs.statSync(source); } catch { continue; }
766
+ if (st?.mtime instanceof Date && (!newest || st.mtime > newest)) newest = st.mtime;
767
+ }
768
+ if (newest) { try { fs.utimesSync(dest, newest, newest); } catch { /* the mtime is a cache hint, never a binding */ } }
769
+ }
770
+
710
771
  function copyInto(ctx, source, dest, { executable }) {
711
772
  const { fs, windows } = ctx;
712
773
  const sourceSha256 = sha256Of(fs.readFileSync(source));
@@ -721,11 +782,44 @@ function copyInto(ctx, source, dest, { executable }) {
721
782
  } else if (executable && !windows) fs.chmodSync(dest, 0o755);
722
783
  const sha256 = sha256Of(fs.readFileSync(dest));
723
784
  if (sha256 !== sourceSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `staged ${dest} differs from its source ${source}`, { expected: sourceSha256, actual: sha256 });
785
+ carryMtime(ctx, dest, [source]);
724
786
  return { sha256, sizeBytes: fs.statSync(dest).size, changed };
725
787
  }
726
788
 
727
789
  const rel = (ctx, file) => path.relative(ctx.appRoot, file).replaceAll("\\", "/");
728
790
 
791
+ /**
792
+ * The boundary of a split run (row 7.05a): the only checkout bytes it may read are cache entries whose digest the commit pins or
793
+ * tool-invalidated build caches, both under `ctx.cacheRoot`. A SOURCE the config names (a local `source.dir`, an ffmpeg `path`) must
794
+ * resolve inside the snapshot's app root, never into the checkout or anywhere else.
795
+ */
796
+ function requireSnapshotSource(ctx, file, label) {
797
+ if (!ctx.split) return file;
798
+ const relative = path.relative(ctx.appRoot, path.resolve(file));
799
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail("STAGE_SIDECAR_SOURCE_OUTSIDE_SNAPSHOT", `${label} resolves to ${file}, outside the snapshot's app root ${ctx.appRoot}; a split run reads sources only from the snapshot (checkout bytes enter only as digest-pinned cache entries)`, { file });
800
+ return file;
801
+ }
802
+ /**
803
+ * The environment a split run hands to the commands it starts (a `command` entry, an ffmpeg `stage` command): Cargo's output directory is
804
+ * the checkout's, resolved there once and passed absolute, so a command that reaches Cargo (`rustright cargo run -p ...`) never writes
805
+ * `target/` into the snapshot. Without a split, or when the checkout holds no Cargo workspace, the environment is returned as it is.
806
+ */
807
+ function splitCommandEnv(ctx) {
808
+ if (!ctx.split) return ctx.env;
809
+ if (ctx.splitEnv === undefined) {
810
+ const resolved = ctx.run("rustright", ["cargo", "metadata", "--format-version", "1", "--no-deps"], { cwd: ctx.cacheRoot, env: ctx.env });
811
+ let dir;
812
+ try { dir = JSON.parse(resolved?.stdout).target_directory; } catch { /* no workspace in the checkout: nothing to pin */ }
813
+ ctx.splitEnv = !resolved?.error && resolved?.status === 0 && typeof dir === "string" && path.isAbsolute(dir) ? { ...ctx.env, CARGO_TARGET_DIR: path.resolve(dir) } : ctx.env;
814
+ }
815
+ return ctx.splitEnv;
816
+ }
817
+
818
+ /** A cache directory the config names (`cacheDir`): the checkout's `.right-release` under a split, the app root otherwise. */
819
+ const cacheDirFor = (ctx, dir) => (ctx.split ? path.resolve(ctx.cacheRoot, ".right-release", dir) : path.resolve(ctx.appRoot, dir));
820
+ /** The per-arch slice directory of a universal staging: absolute under the checkout's cache root on a split, app-relative otherwise. */
821
+ const sliceDirFor = (ctx, triple) => (ctx.split ? path.resolve(ctx.cacheRoot, SLICE_ROOT, triple) : `${SLICE_ROOT}/${triple}`);
822
+
729
823
  /** `lipo -archs <file>` as a list; a failing lipo is a typed error. */
730
824
  function lipoArchs(ctx, file) {
731
825
  const result = ctx.run("lipo", ["-archs", file], { cwd: ctx.appRoot, env: ctx.env });
@@ -760,6 +854,7 @@ function lipoMergeInto(ctx, slices, dest, label, { executable = false } = {}) {
760
854
  fs.rmSync(temporary, { force: true });
761
855
  if (executable) fs.chmodSync(dest, 0o755);
762
856
  }
857
+ carryMtime(ctx, dest, slices);
763
858
  return { sha256, sizeBytes: fs.statSync(dest).size, changed };
764
859
  }
765
860
 
@@ -790,6 +885,7 @@ function stageCargoBin(entry, ctx) {
790
885
  const changed = !(fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === sha256);
791
886
  fs.chmodSync(temporary, 0o755);
792
887
  fs.renameSync(temporary, dest);
888
+ carryMtime(ctx, dest, slices.map((slice) => slice.source));
793
889
  return [{ kind: "cargo-bin", name: entry.name, path: rel(ctx, dest), sha256, sizeBytes: fs.statSync(dest).size, changed, sourceSha256: sha256, package: entry.package, bin: entry.bin, profile: entry.profile, features: entry.features, externalBin: !entry.dest, slices: slices.map((slice) => ({ triple: slice.triple, sha256: slice.sourceSha256 })) }];
794
890
  }
795
891
 
@@ -807,17 +903,29 @@ function stageCargoBuild(entry, ctx, triple) {
807
903
  "-p", entry.package, "--bin", entry.bin, "--target", triple, "--profile", entry.profile,
808
904
  ...(entry.features.length ? ["--features", entry.features.join(",")] : []), "--message-format=json-render-diagnostics"];
809
905
  ctx.log(`stage-sidecar: rightkit ${args.join(" ")}`);
810
- const built = run("rustright", args, { cwd: appRoot, env });
906
+ // From a snapshot, Cargo would resolve `target/` (or a relative build.target-dir) against the snapshot: its output directory is
907
+ // resolved in the checkout instead and passed as an absolute CARGO_TARGET_DIR, so the warm outputs stay the checkout's and the
908
+ // snapshot holds no target/ (docs/GATE-CONTRACT.md "Output directory & cache").
909
+ let buildEnv = env;
910
+ if (ctx.split) {
911
+ const checkoutManifest = entry.manifestPath ? ["--manifest-path", path.resolve(ctx.cacheRoot, entry.manifestPath)] : [];
912
+ const resolved = run("rustright", ["cargo", "metadata", "--format-version", "1", "--no-deps", ...checkoutManifest], { cwd: ctx.cacheRoot, env });
913
+ let pinned;
914
+ try { pinned = JSON.parse(resolved?.stdout).target_directory; } catch { /* refused below */ }
915
+ if (resolved?.error || resolved?.status !== 0 || typeof pinned !== "string" || !path.isAbsolute(pinned)) fail("STAGE_SIDECAR_BUILD_FAILED", `the checkout's cargo output directory for ${entry.package} cannot be resolved (${resolved?.error?.message ?? `exit ${resolved?.status}`}); nothing was built`, { package: entry.package });
916
+ buildEnv = { ...env, CARGO_TARGET_DIR: path.resolve(pinned) };
917
+ }
918
+ const built = run("rustright", args, { cwd: appRoot, env: buildEnv });
811
919
  if (built?.error || built?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `rustright cargo build of ${entry.package} failed (${built?.error?.message ?? `exit ${built?.status}`})`, { package: entry.package, stderr: tail(built?.stderr) });
812
920
  let source = artifactFromStream(built.stdout, entry.bin);
813
921
  if (!source || !fs.existsSync(source)) {
814
- const meta = run("rustright", ["cargo", "metadata", "--format-version", "1", "--no-deps", ...(entry.manifestPath ? ["--manifest-path", path.resolve(appRoot, entry.manifestPath)] : [])], { cwd: appRoot, env });
922
+ const meta = run("rustright", ["cargo", "metadata", "--format-version", "1", "--no-deps", ...(entry.manifestPath ? ["--manifest-path", path.resolve(appRoot, entry.manifestPath)] : [])], { cwd: appRoot, env: buildEnv });
815
923
  let targetDir;
816
924
  try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
817
925
  if (meta?.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) targetDir = undefined;
818
926
  // A managed target root: the broker/cargo metadata target_directory (which cargo derives from CARGO_TARGET_DIR) is
819
927
  // authoritative; an explicit CARGO_TARGET_DIR in this environment is tried too, resolved against the app root.
820
- const envDir = typeof env.CARGO_TARGET_DIR === "string" && env.CARGO_TARGET_DIR.trim() ? path.resolve(appRoot, env.CARGO_TARGET_DIR) : undefined;
928
+ const envDir = typeof buildEnv.CARGO_TARGET_DIR === "string" && buildEnv.CARGO_TARGET_DIR.trim() ? path.resolve(appRoot, buildEnv.CARGO_TARGET_DIR) : undefined;
821
929
  if (!targetDir && !envDir) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `cargo reported no ${entry.bin} executable and its target directory cannot be read`, { bin: entry.bin });
822
930
  const candidates = builtBinaryCandidates({ targetDir, targetDirs: envDir ? [envDir] : [], triple, profile: entry.profile, bin: entry.bin, windows: ctx.windows });
823
931
  source = candidates.find((file) => fs.existsSync(file));
@@ -833,7 +941,7 @@ const ffmpegSourceFor = (sources, tool, arch) => { const spec = sources?.[tool];
833
941
  /** The binary a local ffmpeg source names: the file itself, or `<dir>/<member>` / `<dir>/bin/<member>` (`.exe` added for a Windows target). */
834
942
  function resolveLocalFfmpegSource(ctx, spec, tool) {
835
943
  const { fs } = ctx;
836
- const base = path.resolve(ctx.appRoot, spec.path);
944
+ const base = requireSnapshotSource(ctx, path.resolve(ctx.appRoot, spec.path), `the ffmpeg ${tool} source path`);
837
945
  if (fs.existsSync(base) && fs.statSync(base).isFile()) return base;
838
946
  const member = spec.member ?? `${tool}${ctx.windows ? ".exe" : ""}`;
839
947
  const candidates = [path.join(base, member), path.join(base, "bin", member)];
@@ -911,15 +1019,17 @@ function probeFfmpegBinary(ctx, { file, tool, target }) {
911
1019
  */
912
1020
  async function stageFfmpegSlice(entry, ctx, resolved, { config, t, writeNotices, flat = false }) {
913
1021
  const { appRoot, run, env, fs } = ctx;
914
- const planned = planMediaSidecar({ config, catalog: resolved.catalog, crateDir: resolved.crateDir, crateVersion: resolved.crateVersion, catalogBytes: resolved.catalogBytes, root: appRoot });
1022
+ // A universal slice is staged under SLICE_ROOT, which a split keeps in the checkout's cache root, never in the snapshot.
1023
+ const sliceRoot = ctx.split && String(config.stageDir ?? "").startsWith(SLICE_ROOT) ? ctx.cacheRoot : appRoot;
1024
+ const planned = planMediaSidecar({ config, catalog: resolved.catalog, crateDir: resolved.crateDir, crateVersion: resolved.crateVersion, catalogBytes: resolved.catalogBytes, root: sliceRoot });
915
1025
  const plan = flat ? { ...planned, files: planned.files.map((file) => ({ ...file, stagedPath: path.resolve(appRoot, entry.resourceDir, file.bundledName) })) } : planned;
916
1026
  await fetchFfmpegSources(entry, ctx, plan, t.arch);
917
1027
  if (config.stage) {
918
1028
  const universal = ctx.target.arch === "universal";
919
1029
  // A universal entry stages once per slice: `{arch}`, `{triple}` and `{stageDir}` in the stage command's arguments name the slice.
920
- const args = (config.stage.args ?? []).map((arg) => (universal ? String(arg).replaceAll("{arch}", t.arch).replaceAll("{triple}", t.triple).replaceAll("{stageDir}", config.stageDir) : arg));
1030
+ const args = (config.stage.args ?? []).map((arg) => (universal ? String(arg).replaceAll("{arch}", t.arch).replaceAll("{triple}", t.triple).replaceAll("{stageDir}", sliceRoot === appRoot ? config.stageDir : path.resolve(sliceRoot, config.stageDir)) : arg));
921
1031
  ctx.log(`stage-sidecar: ${config.stage.cmd} ${args.join(" ")}`);
922
- const staged = run(config.stage.cmd, args, { cwd: appRoot, env });
1032
+ const staged = run(config.stage.cmd, args, { cwd: appRoot, env: splitCommandEnv(ctx) });
923
1033
  if (staged?.error || staged?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `ffmpeg stage command failed (${staged?.error?.message ?? `exit ${staged?.status}`})`);
924
1034
  }
925
1035
  const noticeFiles = writeNotices ? writeMediaSidecarNotices(plan, { exists: fs.existsSync, mkdir: fs.mkdirSync, copy: fs.copyFileSync, write: fs.writeFileSync }) : [];
@@ -943,13 +1053,20 @@ async function stageFfmpegUniversal(entry, ctx, resolved) {
943
1053
  results.push({ slice, ...(await stageFfmpegSlice(entry, ctx, resolved, { config: slice.media, t: parseTargetTriple(slice.triple), writeNotices: false })) });
944
1054
  }
945
1055
  // One notice set and one source offer that names the rows of every slice (a per-slice offer would overwrite the other).
1056
+ // A split plans each slice under the checkout's cache root (SLICE_ROOT), so the slice plans name their notices there too. The notices are FINAL
1057
+ // outputs and belong in the snapshot beside the merged file: they are rebased onto the app root before anything is written, never into the checkout.
1058
+ const toApp = (file) => {
1059
+ if (!ctx.split) return file;
1060
+ const relative = path.relative(ctx.cacheRoot, file);
1061
+ return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) ? file : path.resolve(appRoot, relative);
1062
+ };
946
1063
  const noticeByDest = new Map();
947
- for (const { plan } of results) for (const notice of plan.notices) noticeByDest.set(notice.dest, notice);
1064
+ for (const { plan } of results) for (const notice of plan.notices) noticeByDest.set(toApp(notice.dest), { ...notice, dest: toApp(notice.dest) });
948
1065
  const rowsById = new Map();
949
1066
  for (const { plan } of results) for (const row of plan.rows) rowsById.set(row.id, row);
950
1067
  const combined = {
951
1068
  notices: [...noticeByDest.values()],
952
- offerPath: results[0].plan.offerPath,
1069
+ offerPath: toApp(results[0].plan.offerPath),
953
1070
  offer: sourceOfferText({ rows: [...rowsById.values()], contact: entry.media.notices.sourceOfferContact, crateVersion: resolved.crateVersion }),
954
1071
  };
955
1072
  const noticeFiles = writeMediaSidecarNotices(combined, { exists: fs.existsSync, mkdir: fs.mkdirSync, copy: fs.copyFileSync, write: fs.writeFileSync });
@@ -1185,14 +1302,14 @@ async function prepareNativeFile(entry, ctx, loaded, item, { destDir, scratchDir
1185
1302
  if (fs.existsSync(dest) && !nativeDigestMismatch(entry, row, fs.readFileSync(dest), { fromSource: Boolean(entry.source) })) return prepared;
1186
1303
 
1187
1304
  if (entry.source) {
1188
- const file = path.resolve(appRoot, entry.source.dir, row.artifactFileName);
1305
+ const file = requireSnapshotSource(ctx, path.resolve(appRoot, entry.source.dir, row.artifactFileName), `source.dir of the ${where} row ${row.id}`);
1189
1306
  if (!(fs.existsSync(file) && fs.statSync(file).isFile())) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `the ${where} row ${row.id} is staged from source.dir ${entry.source.dir}, which has no ${row.artifactFileName} (looked at ${file}); nothing is downloaded for a source dir`, { file });
1190
1307
  const mismatch = nativeDigestMismatch(entry, row, fs.readFileSync(file), { fromSource: true });
1191
1308
  if (mismatch) failNativeMismatch(row, file, mismatch);
1192
1309
  return { ...prepared, from: file };
1193
1310
  }
1194
1311
  if (!row.archive) fail("STAGE_SIDECAR_CONFIG_INVALID", `the ${where} row ${row.id} has no archive, so it is staged from a local build: set source: { dir } on this entry to the directory holding ${row.artifactFileName}`);
1195
- const archive = path.resolve(appRoot, entry.cacheDir, archiveFileName(row.archive.url));
1312
+ const archive = path.join(cacheDirFor(ctx, entry.cacheDir), archiveFileName(row.archive.url));
1196
1313
  if (!(fs.existsSync(archive) && sha256Of(fs.readFileSync(archive)) === row.archive.sha256)) {
1197
1314
  const bytes = await ctx.fetchBytes(row.archive.url);
1198
1315
  const actual = sha256Of(bytes);
@@ -1334,7 +1451,7 @@ async function stageNativeLibrary(entry, ctx) {
1334
1451
  }
1335
1452
  // Otherwise each arch's files are staged thin under SLICE_ROOT, then merged file by file into the entry's resource directory.
1336
1453
  const slices = [];
1337
- for (const plan of plans) slices.push({ triple: plan.triple, files: await stageNativeSet(entry, ctx, loaded, plan.items, { destDir: `${SLICE_ROOT}/${plan.triple}` }) });
1454
+ for (const plan of plans) slices.push({ triple: plan.triple, files: await stageNativeSet(entry, ctx, loaded, plan.items, { destDir: sliceDirFor(ctx, plan.triple) }) });
1338
1455
  const [first, second] = slices.map((slice) => slice.files.map((file) => file.outName));
1339
1456
  if ([...first].sort().join("\n") !== [...second].sort().join("\n")) fail("STAGE_SIDECAR_NOT_APPROVED", `the arch rows name different library files (aarch64: ${first.join(", ")}; x86_64: ${second.join(", ")}); each file needs one name on both arches (a single file can be renamed with "as")`);
1340
1457
  return first.map((name) => {
@@ -1402,7 +1519,7 @@ function stageCommand(entry, ctx) {
1402
1519
  const cwd = entry.cwd ? path.resolve(appRoot, entry.cwd) : appRoot;
1403
1520
  cleanDest(ctx, entry, destFor(entry, ctx));
1404
1521
  ctx.log(`stage-sidecar: ${entry.cmd} ${entry.args.join(" ")}`);
1405
- const built = run(entry.cmd, entry.args, { cwd, env });
1522
+ const built = run(entry.cmd, entry.args, { cwd, env: splitCommandEnv(ctx) });
1406
1523
  if (built?.error || built?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `command ${entry.name} (${entry.cmd}) failed (${built?.error?.message ?? `exit ${built?.status}`})`, { name: entry.name, stderr: tail(built?.stderr) });
1407
1524
  const source = path.resolve(appRoot, entry.output);
1408
1525
  if (!fs.existsSync(source) || !fs.statSync(source).isFile()) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `command ${entry.name} did not produce ${entry.output}`, { name: entry.name, output: entry.output });
@@ -1419,8 +1536,8 @@ function stageCommand(entry, ctx) {
1419
1536
  * fails closed with nothing staged. Returns { cached, fromCache }.
1420
1537
  */
1421
1538
  async function ensureCachedDownload(ctx, { url, sha256, size }) {
1422
- const { appRoot, fs } = ctx;
1423
- const cached = path.resolve(appRoot, REMOTE_CACHE_DIR, sha256);
1539
+ const { fs } = ctx;
1540
+ const cached = path.resolve(ctx.cacheRoot, REMOTE_CACHE_DIR, sha256);
1424
1541
  if (fs.existsSync(cached) && sha256Of(fs.readFileSync(cached)) === sha256) return { cached, fromCache: true };
1425
1542
  ctx.log(`stage-sidecar: GET ${url}`);
1426
1543
  let response;
@@ -1453,7 +1570,9 @@ async function stageRemote(entry, ctx) {
1453
1570
  let memberSha256;
1454
1571
  if (entry.extract) {
1455
1572
  scratch = ctx.makeTempDir();
1456
- source = await ctx.extractMember({ archive: cached, fileName: entry.extract, destDir: scratch });
1573
+ source = entry.extractKind === "nsis"
1574
+ ? await ctx.extractNsisMember({ archive: cached, fileName: entry.extract, destDir: scratch, expectedSha256: entry.memberSha256, installerSha256: entry.sha256 })
1575
+ : await ctx.extractMember({ archive: cached, fileName: entry.extract, destDir: scratch });
1457
1576
  memberSha256 = sha256Of(fs.readFileSync(source));
1458
1577
  if (entry.memberSha256 && memberSha256 !== entry.memberSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.extract} from ${entry.url} is ${memberSha256}, expected ${entry.memberSha256}; nothing staged`, { expected: entry.memberSha256, actual: memberSha256 });
1459
1578
  }
@@ -1462,7 +1581,7 @@ async function stageRemote(entry, ctx) {
1462
1581
  const copied = copyInto(ctx, source, dest, { executable: entry.executable === true });
1463
1582
  const expected = entry.extract ? entry.memberSha256 : entry.sha256;
1464
1583
  if (expected && copied.sha256 !== expected) fail("STAGE_SIDECAR_HASH_MISMATCH", `staged ${entry.dest} is ${copied.sha256}, expected ${expected}`, { expected, actual: copied.sha256 });
1465
- return [{ kind: "remote", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256: memberSha256 ?? entry.sha256, url: entry.url, cached: fromCache, externalBin: false, ...(entry.extract ? { archiveSha256: entry.sha256, member: entry.extract } : {}) }];
1584
+ return [{ kind: "remote", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256: memberSha256 ?? entry.sha256, url: entry.url, cached: fromCache, externalBin: false, ...(entry.extract ? { archiveSha256: entry.sha256, member: entry.extract, memberSha256 } : {}), ...(entry.extractKind ? { extractKind: entry.extractKind } : {}) }];
1466
1585
  } finally {
1467
1586
  if (scratch) ctx.removeDir(scratch);
1468
1587
  }
@@ -1550,7 +1669,7 @@ function stageCmake(entry, ctx) {
1550
1669
  const { appRoot, fs, run, env, target } = ctx;
1551
1670
  const cmake = resolveCmakeBinary(ctx, entry);
1552
1671
  const generator = entry.platformGenerators?.[target.platform] ?? entry.generator;
1553
- const root = path.resolve(appRoot, CMAKE_ROOT, entry.name);
1672
+ const root = path.resolve(ctx.cacheRoot, CMAKE_ROOT, entry.name);
1554
1673
  const srcDir = path.join(root, "src");
1555
1674
  const buildDir = path.join(root, "build");
1556
1675
  const dest = destFor(entry, ctx);
@@ -1687,10 +1806,13 @@ function wipeStaleStageDir(ctx, plan, produced) {
1687
1806
  * call can reuse (acquireManagedRunLease records a per-process lease file; no RK_* variable carries it to a child), so
1688
1807
  * the only safe behaviour is to refuse early. RIGHTKIT_STAGE_SIDECAR_ALLOW_NESTED=1 opts out.
1689
1808
  */
1690
- export function isNestedBrokerContext(env = process.env) {
1809
+ export function isNestedBrokerContext(env = process.env, platform = process.platform) {
1810
+ // A direct (Linux) host has no broker, so no outer broker build to nest in, whatever flag or socket the env carries (heavy-command sets
1811
+ // RIGHTSUITE_HEAVY_WORK_OWNER on every platform).
1812
+ if (directPlatform(platform)) return false;
1691
1813
  if (env?.RIGHTKIT_STAGE_SIDECAR_ALLOW_NESTED === "1") return false;
1692
1814
  const insideTauri = Boolean(env?.TAURI_ENV_PLATFORM || env?.TAURI_ENV_TARGET_TRIPLE);
1693
- return insideTauri && (managedHost(env ?? {}) || env?.RIGHTSUITE_HEAVY_WORK_OWNER === "1");
1815
+ return insideTauri && (managedHost(env ?? {}, platform) || env?.RIGHTSUITE_HEAVY_WORK_OWNER === "1");
1694
1816
  }
1695
1817
 
1696
1818
  /** True when staging would call `rustright cargo` for a non-skipped entry. */
@@ -1734,15 +1856,22 @@ export function planSidecarStaging({ manifest, target, env = process.env } = {})
1734
1856
  * fs (existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, renameSync, chmodSync, statSync, readdirSync, rmSync), run, fetch (https downloads of `remote`), fetchBytes, extractMember, loadMediaCatalog, loadNativeCatalog, makeTempDir, removeDir, now, pid, log (progress), warn (warnings; defaults to log), env.
1735
1857
  * The API is quiet by default; the `right-release stage-sidecar` command passes `warn` so the CLI prints warnings on stderr.
1736
1858
  */
1737
- export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {}) {
1859
+ export async function stageSidecars({ appRoot, manifest, target, cacheRoot, deps = {} } = {}) {
1738
1860
  if (typeof appRoot !== "string" || !path.isAbsolute(appRoot)) fail("STAGE_SIDECAR_CONFIG_INVALID", "appRoot must be an absolute path");
1861
+ if (cacheRoot !== undefined && (typeof cacheRoot !== "string" || !path.isAbsolute(cacheRoot))) fail("STAGE_SIDECAR_CONFIG_INVALID", "cacheRoot must be an absolute path");
1739
1862
  const env = deps.env ?? process.env;
1740
1863
  const plan = validateSidecarManifest(manifest, { target, mode: resolveBuildMode(env) });
1741
1864
  assertNotNested(plan, env);
1865
+ // A source split (source-split.mjs; rightkit-gate's snapshot route): `appRoot` is the snapshot of the commit, where the declared
1866
+ // sources are read and where the staged files and the receipt go (the Tauri build script reads them there). `cacheRoot` is the
1867
+ // checkout's app directory, which keeps every cache, archive, slice and build tree right-release owns (`.right-release/...`) and
1868
+ // where Cargo's target directory is resolved: only final outputs and the receipt are written under the snapshot (row 7.05a). Checkout
1869
+ // bytes enter a split run only as cache entries whose digest the commit pins, or as tool-invalidated build caches.
1870
+ const split = cacheRoot !== undefined && path.resolve(cacheRoot) !== path.resolve(appRoot);
1742
1871
  const fs = deps.fs ?? nodeFs;
1743
1872
  const run = deps.run ?? defaultRun;
1744
1873
  const ctx = {
1745
- appRoot, target: plan.target, stageDir: plan.stageDir, fs, env, run, protectedPaths: protectedOutputPaths(plan),
1874
+ appRoot, cacheRoot: split ? path.resolve(cacheRoot) : appRoot, split, target: plan.target, stageDir: plan.stageDir, fs, env, run, protectedPaths: protectedOutputPaths(plan),
1746
1875
  windows: plan.target.os === "windows",
1747
1876
  pid: deps.pid ?? process.pid,
1748
1877
  host: deps.host ?? { os: { darwin: "macos", win32: "windows", linux: "linux" }[process.platform] ?? process.platform, arch: { arm64: "aarch64", x64: "x86_64" }[process.arch] ?? process.arch },
@@ -1754,6 +1883,7 @@ export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {
1754
1883
  fetchBytes: deps.fetchBytes ?? defaultFetchBytes,
1755
1884
  fetch: deps.fetch ?? ((...args) => globalThis.fetch(...args)),
1756
1885
  extractMember: deps.extractMember ?? ((args) => defaultExtractMember({ ...args, run })),
1886
+ extractNsisMember: deps.extractNsisMember ?? ((args) => defaultExtractNsisMember({ ...args, run, env, sevenZip: deps.sevenZip, readFile: fs.readFileSync, exists: fs.existsSync })),
1757
1887
  loadMediaCatalog: deps.loadMediaCatalog ?? (() => undefined),
1758
1888
  loadNativeCatalog: deps.loadNativeCatalog ?? (() => undefined),
1759
1889
  makeTempDir: deps.makeTempDir ?? (() => nodeFs.mkdtempSync(path.join(os.tmpdir(), "rightkit-stage-"))),
@@ -18,7 +18,10 @@ export const SWAP_HELPER_PACKAGE = "@rightkit/release";
18
18
  export const SWAP_HELPER_BIN = ["bin", "darwin", "rightkit-swap"];
19
19
  export const SWAP_HELPER_RECEIPT = ["bin", "darwin", "rightkit-swap.sign.json"];
20
20
  // rightkit-swap is gated on the crate feature "fs" (crates/rightkit-fs/Cargo.toml [[bin]] required-features).
21
- export const SWAP_HELPER_CARGO_ARGS = ["build", "--release", "-p", "rightkit-fs", "--features", "fs", "--bin", "rightkit-swap"];
21
+ // Every crate is its own Cargo workspace (rightkit#1285): there is no root manifest, so the build and the target-directory read name crates/rightkit-fs.
22
+ export const SWAP_HELPER_MANIFEST = "crates/rightkit-fs/Cargo.toml";
23
+ export const SWAP_HELPER_CARGO_ARGS = ["build", "--manifest-path", SWAP_HELPER_MANIFEST, "--release", "-p", "rightkit-fs", "--features", "fs", "--bin", "rightkit-swap"];
24
+ export const SWAP_HELPER_METADATA_ARGS = ["metadata", "--format-version", "1", "--no-deps", "--manifest-path", SWAP_HELPER_MANIFEST];
22
25
 
23
26
  /** Typed error. Codes: SWAP_HELPER_BUILD_FAILED, SWAP_HELPER_SIGN_FAILED, SWAP_HELPER_MISSING, SWAP_HELPER_UNSIGNED. */
24
27
  export class SwapHelperError extends Error {
@@ -67,7 +70,7 @@ export function prepareSwapHelper({
67
70
  const fail = (code, message) => { throw new SwapHelperError(code, message); };
68
71
  const built = run("cargo", SWAP_HELPER_CARGO_ARGS, { cwd: workspaceRoot, env });
69
72
  if (built.error || built.status !== 0) fail("SWAP_HELPER_BUILD_FAILED", `rustright cargo ${SWAP_HELPER_CARGO_ARGS.join(" ")} failed (${built.error?.message ?? `exit ${built.status}`})\n${tail(`${built.stdout ?? ""}\n${built.stderr ?? ""}`)}`);
70
- const meta = run("cargo", ["metadata", "--format-version", "1", "--no-deps"], { cwd: workspaceRoot, env });
73
+ const meta = run("cargo", SWAP_HELPER_METADATA_ARGS, { cwd: workspaceRoot, env });
71
74
  let targetDir;
72
75
  try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
73
76
  if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) fail("SWAP_HELPER_BUILD_FAILED", "cannot read the Cargo target directory to find rightkit-swap");
package/test-command.mjs CHANGED
@@ -21,7 +21,8 @@
21
21
  // from the app root. `--suite journeys` selects every chunk of that group. `--runner vitest -- run --project unit` runs
22
22
  // the runner once with the arguments after `--` (the watch or one-off case); `-- <args>` after `--suite` is allowed when
23
23
  // exactly one suite is selected. Exit code: 0 when every selected suite passed, else the failing suite's exit code.
24
- import { readFileSync } from "node:fs";
24
+ import { existsSync, readFileSync } from "node:fs";
25
+ import { buildLaunch } from "./build-launch.mjs";
25
26
  import path from "node:path";
26
27
  import { pathToFileURL } from "node:url";
27
28
  import { spawn } from "./background-process.mjs";
@@ -39,10 +40,11 @@ export function parseTestArgs(argv) {
39
40
  for (let i = 0; i < argv.length; i += 1) {
40
41
  const arg = argv[i];
41
42
  if (arg === "--") { opts.passthrough = argv.slice(i + 1); break; }
42
- if (arg === "--config" || arg === "--suite" || arg === "--runner") {
43
+ if (arg === "--config" || arg === "--suite" || arg === "--runner" || arg === "--platform") {
43
44
  const value = argv[i + 1];
44
45
  if (!value || value.startsWith("--")) throw new Error(`${arg} needs a value`);
45
46
  if (arg === "--suite") opts.suites.push(...value.split(",").filter(Boolean));
47
+ else if (arg === "--platform") opts.platform = value;
46
48
  else opts[arg.slice(2)] = value;
47
49
  i += 1;
48
50
  } else if (arg === "--list") opts.list = true;
@@ -147,14 +149,16 @@ export function eventPrefix(config) {
147
149
  * Run the planned suites in order; stop at the first non-zero exit. `spawnFn` and `log` are injectable.
148
150
  * Resolves `{ exitCode, results: [{ suite, exitCode, durationMs }] }`; a spawn error or a signal rejects.
149
151
  */
150
- export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log } = {}) {
152
+ export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log, door = buildLaunch } = {}) {
151
153
  const results = [];
152
154
  for (const item of plan) {
153
155
  const invocation = resolveInvocation(item.step, { appRoot, baseEnv: env, extraArgs: item.extraArgs });
154
156
  log(JSON.stringify({ event: `${prefix}_suite_started`, suite: item.name }));
155
157
  const started = Date.now();
156
158
  const exitCode = await new Promise((resolve, reject) => {
157
- const child = spawnFn(invocation.cmd, invocation.args, { cwd: invocation.cwd, env: invocation.env, stdio: "inherit" });
159
+ // A test suite goes through the one door (build-launch.mjs): a broker job on a managed host, which builds it under the app's claim.
160
+ const target = door(invocation.cmd, invocation.args, { env: invocation.env, cwd: invocation.cwd, exists: existsSync });
161
+ const child = spawnFn(target.command, target.args, { cwd: invocation.cwd, env: invocation.env, stdio: "inherit" });
158
162
  child.once("error", reject);
159
163
  child.once("exit", (code, signal) => {
160
164
  if (signal) reject(new Error(`${item.name} stopped by ${signal}`));
@@ -172,6 +176,15 @@ export async function runSuites(plan, { appRoot, prefix = "test", env = process.
172
176
  export async function main(argv = process.argv.slice(2)) {
173
177
  const opts = parseTestArgs(argv);
174
178
  const { config, appRoot } = await loadAppConfig(opts.config);
179
+ // `--platform ios`: the app's declared XCUITest journeys (development.targets.ios.uiTests) on its dev device or simulator.
180
+ if (opts.platform !== undefined) {
181
+ if (opts.platform !== "ios") throw new Error(`--platform ${opts.platform}: only ios has platform UI test journeys; other suites are declared in tests.suites`);
182
+ if (opts.suites.length || opts.runner || opts.passthrough.length) throw new Error("--platform ios runs the declared journeys; it takes no --suite, --runner or arguments");
183
+ const { runDevelopment } = await import("./development.mjs");
184
+ const result = runDevelopment({ root: appRoot, platform: "ios", config, uiTests: true, dryRun: opts.list });
185
+ if (opts.list) console.log(JSON.stringify(result.plan ?? result, null, 2));
186
+ return 0;
187
+ }
175
188
  const plan = planTests(config, { suites: opts.suites, runner: opts.runner, passthrough: opts.passthrough, appRoot });
176
189
  if (opts.list) {
177
190
  const view = plan.map((item) => ({ suite: item.name, ...resolveInvocation(item.step, { appRoot, baseEnv: {}, extraArgs: item.extraArgs }), env: undefined }));