@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/stage-sidecar.mjs CHANGED
@@ -5,12 +5,27 @@
5
5
  // that script, driven by a `sidecars` list in right-release.config.mjs:
6
6
  //
7
7
  // sidecars: [
8
- // { kind: "cargo-bin", package: "mailright-engine", bin?, name?, profile?: "release", features?, manifestPath?, sha256?, platforms? },
8
+ // { kind: "cargo-bin", package: "mailright-engine", bin?, name?, profile?: "release" | { dev, release }, features?, manifestPath?, sha256?, dest?, clean?, platforms? },
9
9
  // { kind: "ffmpeg", variant?, tools?, notices: { dir, sourceOfferContact }, catalog, stage?, bundleDir? },
10
10
  // { kind: "onnxruntime", version, catalog, resourceDir?: "src-tauri/resources/runtime", as?, licenseDir?, cacheDir? },
11
- // { kind: "resource", from, to, sha256?, platforms? },
11
+ // { kind: "resource", from, to, sha256?, machO?, clean?, platforms? }, // `from` may be a directory (copied recursively, every file hashed)
12
+ // { kind: "command", name, cmd, args?, cwd?, output, externalBin: true | (false with dest), dest?, clean?, platforms? },
13
+ // { kind: "remote", name, url (https), sha256, dest | to, size?, extract?, memberSha256?, executable?, clean?, platforms? },
14
+ // { kind: "cmake", name, repo (https), commit (40 hex), target?, output?, cmakeArgs?, platformArgs?, generator?, smoke?, sha256?, dest?, clean?, platforms? },
15
+ // { kind: "prebuilt", name, from?, manifest: { file, entry, sha256?, approvalSha256?, approvalAlgorithm? }, dest?, platforms? },
12
16
  // ]
13
17
  //
18
+ // `dest` (cargo-bin, command, remote) is a contained app-relative path (never the app root, the stage dir, the receipt, the cache, .git, src, src-tauri, node_modules or package.json; outputs may not overlap) used instead of <stageDir>/<name>-<triple>; the file is
19
+ // then not an externalBin. `clean: true` removes a stale file at the destination before staging. The wrapper key
20
+ // `wipeStageDir: true` removes stale files named exactly `<name>-<current triple>[.exe]` for a declared name this run no longer produces.
21
+ //
22
+ // `catalog` is a relative path, an absolute path, { crate: "rightkit-ffmpeg", file? } (resolved through `rightkit cargo
23
+ // metadata`) or { npm: "<pkg>/<file>" }. The wrapper form { sidecars, profiles: { dev, release } } sets the default cargo
24
+ // profile pair; RIGHTKIT_BUILD_MODE (dev|release, default release) selects. The target `universal-apple-darwin` builds
25
+ // both slices of a cargo-bin and lipo-creates `<name>-universal-apple-darwin`. `ffmpeg` and `onnxruntime` do the same from their
26
+ // per-arch catalog rows (each slice staged under .right-release/slices/<triple>, then lipo-merged), a `cmake` entry builds with
27
+ // CMAKE_OSX_ARCHITECTURES="arm64;x86_64".
28
+ //
14
29
  // It never signs (release.mjs signs staged files) and never calls bare cargo. Every effect goes through `deps`.
15
30
  import { createHash } from "node:crypto";
16
31
  import nodeFs from "node:fs";
@@ -18,8 +33,10 @@ import os from "node:os";
18
33
  import path from "node:path";
19
34
  import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
20
35
  import { resolveCommand } from "./development.mjs";
36
+ import { managedHost } from "./managed-cargo.mjs";
37
+ import { APPROVAL_MACHO_STRIP_LINKEDIT_V1, APPROVAL_RAW_SHA256, approvalDigest } from "./macho-approval-hash.mjs";
21
38
  import {
22
- planMediaSidecar, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
39
+ planMediaSidecar, sourceOfferText, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
23
40
  } from "./media-sidecar.mjs";
24
41
 
25
42
  export const STAGE_SIDECAR_CONTRACT = "rightkit-stage-sidecar-v1";
@@ -40,14 +57,28 @@ export class StageSidecarError extends Error {
40
57
  const fail = (code, message, details) => { throw new StageSidecarError(code, message, details); };
41
58
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
42
59
  const SHA256 = /^[0-9a-f]{64}$/;
43
- const KINDS = new Set(["cargo-bin", "ffmpeg", "onnxruntime", "resource"]);
60
+ const KINDS = new Set(["cargo-bin", "ffmpeg", "onnxruntime", "resource", "command", "remote", "cmake", "prebuilt"]);
61
+ export const NESTED_BROKER_CODE = "STAGE_SIDECAR_NESTED_BROKER";
62
+ export const REMOTE_CACHE_DIR = ".right-release/cache/remote";
63
+ /** Per-arch staging for a universal ffmpeg or onnxruntime entry: `<SLICE_ROOT>/<triple>/...`, merged with lipo afterwards. */
64
+ export const SLICE_ROOT = ".right-release/slices";
65
+ /** Pinned-commit source checkouts and build trees of `cmake` entries: `<CMAKE_ROOT>/<name>/{src,build}` (fixed paths, in place). */
66
+ export const CMAKE_ROOT = ".right-release/cmake";
67
+ const UNIVERSAL = "universal-apple-darwin";
68
+ const SLICES = Object.freeze(["aarch64-apple-darwin", "x86_64-apple-darwin"]);
69
+ const MODES = new Set(["dev", "release"]);
70
+ const PROFILE = /^[a-z][a-z0-9_-]*$/;
44
71
  const PLATFORMS = new Set(["mac", "win", "linux"]);
45
72
  const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
46
73
  const KEYS = Object.freeze({
47
- "cargo-bin": ["kind", "package", "bin", "name", "profile", "features", "manifestPath", "sha256", "platforms"],
48
- ffmpeg: ["kind", "variant", "tools", "notices", "catalog", "stage", "bundleDir", "platforms"],
74
+ "cargo-bin": ["kind", "package", "bin", "name", "profile", "features", "manifestPath", "sha256", "dest", "clean", "platforms"],
75
+ ffmpeg: ["kind", "variant", "variants", "tools", "notices", "catalog", "stage", "bundleDir", "sources", "probe", "platforms"],
49
76
  onnxruntime: ["kind", "version", "catalog", "resourceDir", "as", "licenseDir", "cacheDir", "platforms"],
50
- resource: ["kind", "from", "to", "sha256", "platforms"],
77
+ resource: ["kind", "from", "to", "sha256", "machO", "clean", "platforms"],
78
+ command: ["kind", "name", "cmd", "args", "cwd", "output", "externalBin", "dest", "clean", "platforms"],
79
+ remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "extract", "memberSha256", "executable", "clean", "platforms"],
80
+ cmake: ["kind", "name", "repo", "commit", "target", "output", "cmakeArgs", "platformArgs", "generator", "smoke", "sha256", "dest", "clean", "platforms"],
81
+ prebuilt: ["kind", "name", "from", "manifest", "dest", "platforms"],
51
82
  });
52
83
  const TRIPLES = Object.freeze({
53
84
  "aarch64-apple-darwin": { platform: "mac", os: "macos", arch: "aarch64" },
@@ -58,9 +89,9 @@ const TRIPLES = Object.freeze({
58
89
  "aarch64-unknown-linux-gnu": { platform: "linux", os: "linux", arch: "aarch64" },
59
90
  });
60
91
 
61
- /** Parse a Rust target triple into { triple, platform, os, arch }. universal-apple-darwin is refused (stage per arch, lipo is the app's). */
92
+ /** Parse a Rust target triple into { triple, platform, os, arch }. universal-apple-darwin has arch "universal" (cargo-bin lipos both slices). */
62
93
  export function parseTargetTriple(target) {
63
- if (target === "universal-apple-darwin") fail("STAGE_SIDECAR_TARGET_UNSUPPORTED", "universal-apple-darwin is not staged here: stage aarch64-apple-darwin and x86_64-apple-darwin separately and merge with lipo");
94
+ if (target === UNIVERSAL) return { triple: UNIVERSAL, platform: "mac", os: "macos", arch: "universal" };
64
95
  const row = typeof target === "string" ? TRIPLES[target] : undefined;
65
96
  if (!row) fail("STAGE_SIDECAR_TARGET_UNSUPPORTED", `unsupported target triple ${JSON.stringify(target)} (${Object.keys(TRIPLES).join(", ")})`);
66
97
  return { triple: target, ...row };
@@ -76,35 +107,169 @@ export function resolveTargetTriple({ target, env = process.env, run = defaultRu
76
107
  return host;
77
108
  }
78
109
 
79
- function relativePath(value, label, errors) {
110
+ function relativePath(value, label, errors, { allowRoot = false } = {}) {
80
111
  if (typeof value !== "string" || !value.trim()) { errors.push(`${label} must be a non-empty relative path`); return undefined; }
81
112
  const normalized = value.replaceAll("\\", "/");
82
113
  if (path.isAbsolute(value) || /^[A-Za-z]:/.test(value) || normalized.split("/").includes("..")) { errors.push(`${label} must be relative and stay inside the app: ${value}`); return undefined; }
114
+ // "." and "./" name the app root itself: only a working directory may do that, never a destination or a directory to clean.
115
+ if (!normalized.split("/").some((segment) => segment !== "" && segment !== ".")) {
116
+ if (allowRoot) return ".";
117
+ errors.push(`${label} must name a path inside the app (at least one normal segment), not the app root: ${value}`);
118
+ return undefined;
119
+ }
83
120
  return normalized.replace(/\/+$/, "");
84
121
  }
85
122
 
123
+ /** A relative output path in comparison form: no "." or empty segments, lower case (Windows and macOS are case-insensitive). */
124
+ const canonicalOutput = (value) => String(value).replaceAll("\\", "/").split("/").filter((segment) => segment !== "" && segment !== ".").join("/").toLowerCase();
125
+ /** `output` equals `protectedPath` or is an ancestor of it (so writing or cleaning `output` would reach it). */
126
+ const reaches = (output, protectedPath) => output === protectedPath || protectedPath.startsWith(`${output}/`);
127
+
128
+ /**
129
+ * Paths stage-sidecar never writes to or cleans, app-relative: the stage directory, the receipt, the remote cache and the
130
+ * `.right-release` state, onnxruntime cache directories, and the app's source and dependency roots. A destination that equals
131
+ * or contains one of these is refused (validation, and again just before any delete).
132
+ */
133
+ function protectedOutputPaths({ stageDir, receiptPath, entries = [] }) {
134
+ const cacheDirs = entries.filter((entry) => entry.kind === "onnxruntime" && entry.cacheDir).map((entry) => entry.cacheDir);
135
+ return [stageDir, receiptPath, ".right-release", REMOTE_CACHE_DIR, ".git", "src", "src-tauri", "node_modules", "package.json", ...cacheDirs].filter(Boolean).map(canonicalOutput);
136
+ }
137
+
138
+ /** `<name>-<triple>[.exe]`, with `<name>-universal-apple-darwin` for the universal target. */
139
+ const stagedName = (name, t) => (t.arch === "universal" ? `${name}-${UNIVERSAL}` : stagedFileName(name, t.os, t.arch));
140
+
141
+ /** RIGHTKIT_BUILD_MODE: dev | release (default release). Anything else is refused. */
142
+ export function resolveBuildMode(env = process.env) {
143
+ const value = env?.RIGHTKIT_BUILD_MODE;
144
+ if (value === undefined || value === "") return "release";
145
+ if (!MODES.has(value)) fail("STAGE_SIDECAR_CONFIG_INVALID", `RIGHTKIT_BUILD_MODE must be dev or release, got ${JSON.stringify(value)}`);
146
+ return value;
147
+ }
148
+
149
+ /** A profile is a Cargo profile name or exactly { dev, release } of names. Returns the pair or the single name. */
150
+ function parseProfile(value, label, errors) {
151
+ if (typeof value === "string") {
152
+ if (!PROFILE.test(value)) errors.push(`${label} must be a Cargo profile name`);
153
+ return value;
154
+ }
155
+ if (!isObject(value)) { errors.push(`${label} must be a Cargo profile name or { dev, release }`); return undefined; }
156
+ for (const key of Object.keys(value)) if (!MODES.has(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)} (allowed: dev, release)`);
157
+ for (const mode of MODES) if (typeof value[mode] !== "string" || !PROFILE.test(value[mode])) errors.push(`${label}.${mode} must be a Cargo profile name`);
158
+ return { dev: value.dev, release: value.release };
159
+ }
160
+
161
+ const CATALOG_DEFAULT_FILE = Object.freeze({ ffmpeg: "catalog/artifacts.json", onnxruntime: "catalog/native-libraries.json" });
162
+
86
163
  /**
87
- * Validate and normalise the manifest. `raw` is the `sidecars` array, or { sidecars, stageDir?, receiptPath? }.
164
+ * A catalog reference: a relative path (inside the app), an absolute path, { crate, file? } or { npm: "<pkg>/<file>" }.
165
+ * Problems are pushed to `errors` against this entry only.
166
+ */
167
+ function parseCatalogSpec(value, label, errors, kind) {
168
+ if (value === undefined) return undefined;
169
+ if (typeof value === "string") {
170
+ if (path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)) return { via: "absolute", file: value };
171
+ const file = relativePath(value, label, errors);
172
+ return file ? { via: "path", file } : undefined;
173
+ }
174
+ if (!isObject(value)) { errors.push(`${label} must be a path, { crate }, or { npm }`); return undefined; }
175
+ const keys = Object.keys(value);
176
+ if ("crate" in value) {
177
+ for (const key of keys) if (!["crate", "file"].includes(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)} for { crate, file? }`);
178
+ if (typeof value.crate !== "string" || !NAME.test(value.crate)) { errors.push(`${label}.crate must be a Cargo package name`); return undefined; }
179
+ const file = relativePath(value.file ?? CATALOG_DEFAULT_FILE[kind] ?? "catalog.json", `${label}.file`, errors);
180
+ return file ? { via: "crate", crate: value.crate, file } : undefined;
181
+ }
182
+ if ("npm" in value) {
183
+ for (const key of keys) if (key !== "npm") errors.push(`${label}: unknown key ${JSON.stringify(key)} for { npm }`);
184
+ const match = typeof value.npm === "string" ? /^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)\/(.+)$/.exec(value.npm) : null;
185
+ if (!match) { errors.push(`${label}.npm must be "<package>/<file>" (scoped packages allowed)`); return undefined; }
186
+ const file = relativePath(match[2], `${label}.npm file`, errors);
187
+ return file ? { via: "npm", pkg: match[1], file } : undefined;
188
+ }
189
+ errors.push(`${label} must be a path, { crate }, or { npm }`);
190
+ return undefined;
191
+ }
192
+
193
+ const CMAKE_DEFINE = /^-D[A-Za-z0-9_]+(?::[A-Z]+)?=[^\s]*$/;
194
+ const SOURCE_ARCHES = ["aarch64", "x86_64"];
195
+
196
+ /** One pinned https archive: { url, sha256, member, size? }. Problems are pushed to `errors`. */
197
+ function parseArchiveSource(value, label, errors) {
198
+ if (!isObject(value)) { errors.push(`${label} must be { url, sha256, member, size? }`); return undefined; }
199
+ for (const key of Object.keys(value)) if (!["url", "sha256", "member", "size"].includes(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)}`);
200
+ if (typeof value.url !== "string" || !/^https:\/\/[^\s/]+\/\S*$/.test(value.url)) errors.push(`${label}.url must be an https:// URL (no other scheme is fetched)`);
201
+ if (typeof value.sha256 !== "string" || !SHA256.test(value.sha256)) errors.push(`${label}.sha256 is required and must be a lowercase 64-hex SHA-256 of the archive`);
202
+ if (typeof value.member !== "string" || !NAME.test(value.member)) errors.push(`${label}.member must be the bare file name of one archive member (for example ffmpeg or ffmpeg.exe)`);
203
+ if (value.size !== undefined && (!Number.isSafeInteger(value.size) || value.size <= 0)) errors.push(`${label}.size must be a positive integer (bytes)`);
204
+ return { url: value.url, sha256: value.sha256, member: value.member, ...(value.size ? { size: value.size } : {}) };
205
+ }
206
+
207
+ /**
208
+ * `ffmpeg.sources`: where to download the bytes of a catalog row (the catalog pins digests, not URLs). A tool maps to one
209
+ * archive source, or to `{ aarch64: source, x86_64: source }`; a universal target needs the per-arch form for every tool.
210
+ */
211
+ function parseFfmpegSources(raw, where, errors, { universal }) {
212
+ if (raw === undefined) return undefined;
213
+ if (!isObject(raw) || !Object.keys(raw).length) { errors.push(`${where}: sources must be an object mapping ffmpeg or ffprobe to an archive source`); return undefined; }
214
+ const out = {};
215
+ for (const [tool, value] of Object.entries(raw)) {
216
+ const label = `${where}: sources.${tool}`;
217
+ if (!["ffmpeg", "ffprobe"].includes(tool)) { errors.push(`${label}: not ffmpeg or ffprobe`); continue; }
218
+ const perArch = isObject(value) && SOURCE_ARCHES.some((arch) => arch in value) && !("url" in value);
219
+ if (universal && !perArch) { errors.push(`${label} needs { aarch64, x86_64 } sources for universal-apple-darwin (each slice is a thin file)`); continue; }
220
+ if (perArch) {
221
+ for (const key of Object.keys(value)) if (!SOURCE_ARCHES.includes(key)) errors.push(`${label}: unknown arch ${JSON.stringify(key)}`);
222
+ out[tool] = Object.fromEntries(SOURCE_ARCHES.filter((arch) => arch in value).map((arch) => [arch, parseArchiveSource(value[arch], `${label}.${arch}`, errors)]));
223
+ } else out[tool] = parseArchiveSource(value, label, errors);
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /**
229
+ * `prebuilt.manifest`: { file, entry, sha256?, approvalSha256?, approvalAlgorithm? }. `file` is a JSON file inside the app, `entry`
230
+ * a dot path to the object for this target (`{platform}` becomes macos-aarch64, windows-x86_64 and so on), and the three keys name
231
+ * the fields inside it (defaults: sha256 -> "sha256"; approval fields only when named).
232
+ */
233
+ function parsePrebuiltManifest(raw, where, errors) {
234
+ if (!isObject(raw)) { errors.push(`${where}: manifest must be { file, entry, sha256?, approvalSha256?, approvalAlgorithm? }`); return undefined; }
235
+ for (const key of Object.keys(raw)) if (!["file", "entry", "sha256", "approvalSha256", "approvalAlgorithm"].includes(key)) errors.push(`${where}: manifest: unknown key ${JSON.stringify(key)}`);
236
+ const file = relativePath(raw.file, `${where}: manifest.file`, errors);
237
+ if (typeof raw.entry !== "string" || !/^[A-Za-z0-9_{}.-]+$/.test(raw.entry) || raw.entry.split(".").some((part) => part === "")) errors.push(`${where}: manifest.entry must be a dot path such as binaries.{platform}`);
238
+ for (const key of ["sha256", "approvalSha256", "approvalAlgorithm"]) if (raw[key] !== undefined && (typeof raw[key] !== "string" || !/^[A-Za-z0-9_-]+$/.test(raw[key]))) errors.push(`${where}: manifest.${key} must be a field name`);
239
+ if (raw.approvalAlgorithm !== undefined && raw.approvalSha256 === undefined) errors.push(`${where}: manifest.approvalAlgorithm needs approvalSha256`);
240
+ if (!file) return undefined;
241
+ return { file, entry: raw.entry, sha256: raw.sha256 ?? "sha256", ...(raw.approvalSha256 ? { approvalSha256: raw.approvalSha256 } : {}), ...(raw.approvalAlgorithm ? { approvalAlgorithm: raw.approvalAlgorithm } : {}) };
242
+ }
243
+
244
+ /**
245
+ * Validate and normalise the manifest. `raw` is the `sidecars` array, or { sidecars, stageDir?, receiptPath?, profiles? }.
246
+ * `mode` (dev | release, default release) selects the profile of a { dev, release } pair.
88
247
  * Every problem is reported at once as STAGE_SIDECAR_CONFIG_INVALID. Entries whose `platforms` excludes the target are
89
248
  * kept (shape-checked) with skipped: true.
90
249
  */
91
- export function validateSidecarManifest(raw, { target } = {}) {
250
+ export function validateSidecarManifest(raw, { target, mode = "release" } = {}) {
92
251
  const t = parseTargetTriple(target);
93
252
  const errors = [];
253
+ if (!MODES.has(mode)) fail("STAGE_SIDECAR_CONFIG_INVALID", `build mode must be dev or release, got ${JSON.stringify(mode)}`);
94
254
  const wrapper = Array.isArray(raw) ? { sidecars: raw } : raw;
95
- if (!isObject(wrapper)) fail("STAGE_SIDECAR_CONFIG_INVALID", "sidecars must be an array (or { sidecars, stageDir?, receiptPath? })");
96
- for (const key of Object.keys(wrapper)) if (!["sidecars", "stageDir", "receiptPath"].includes(key)) errors.push(`unknown key ${JSON.stringify(key)}`);
255
+ if (!isObject(wrapper)) fail("STAGE_SIDECAR_CONFIG_INVALID", "sidecars must be an array (or { sidecars, stageDir?, receiptPath?, profiles? })");
256
+ for (const key of Object.keys(wrapper)) if (!["sidecars", "stageDir", "receiptPath", "profiles", "wipeStageDir"].includes(key)) errors.push(`unknown key ${JSON.stringify(key)}`);
257
+ if (wrapper.wipeStageDir !== undefined && typeof wrapper.wipeStageDir !== "boolean") errors.push("wipeStageDir must be a boolean");
258
+ const defaultProfile = wrapper.profiles === undefined ? undefined : parseProfile(wrapper.profiles, "profiles", errors);
259
+ if (typeof defaultProfile === "string") errors.push("profiles must be { dev, release }");
97
260
  if (!Array.isArray(wrapper.sidecars) || !wrapper.sidecars.length) errors.push("sidecars must be a non-empty array");
98
261
  const stageDir = relativePath(wrapper.stageDir ?? DEFAULT_STAGE_DIR, "stageDir", errors);
99
262
  const receiptPath = wrapper.receiptPath === undefined
100
263
  ? (stageDir ? `${stageDir}/${STAGE_SIDECAR_RECEIPT_NAME}` : undefined)
101
264
  : relativePath(wrapper.receiptPath, "receiptPath", errors);
102
265
  const entries = [];
103
- const outputs = new Map();
266
+ const claimed = [];
104
267
  const claim = (output, label) => {
105
- const key = output.toLowerCase();
106
- if (outputs.has(key)) errors.push(`${label} writes ${output}, which ${outputs.get(key)} already writes`);
107
- else outputs.set(key, label);
268
+ const key = canonicalOutput(output);
269
+ const clash = claimed.find((other) => other.key === key || other.key.startsWith(`${key}/`) || key.startsWith(`${other.key}/`));
270
+ if (clash?.key === key) errors.push(`${label} writes ${output}, which ${clash.label} already writes`);
271
+ else if (clash) errors.push(`${label} writes ${output}, which overlaps ${clash.output} that ${clash.label} writes (outputs may not be nested inside each other)`);
272
+ else claimed.push({ key, output, label });
108
273
  };
109
274
  for (const [index, item] of (Array.isArray(wrapper.sidecars) ? wrapper.sidecars : []).entries()) {
110
275
  const label = `sidecars[${index}]`;
@@ -121,49 +286,151 @@ export function validateSidecarManifest(raw, { target } = {}) {
121
286
  if (value !== undefined && !SHA256.test(value ?? "")) errors.push(`${where}: ${name} must be a lowercase 64-hex SHA-256`);
122
287
  };
123
288
  const entry = { kind: item.kind, index, skipped, ...(platforms ? { platforms } : {}) };
289
+ const destOf = (value, field = "dest") => (value === undefined ? undefined : relativePath(value, `${where}: ${field}`, errors));
290
+ const cleanOf = () => {
291
+ if (item.clean !== undefined && typeof item.clean !== "boolean") errors.push(`${where}: clean must be a boolean`);
292
+ return item.clean === true;
293
+ };
294
+ const stagedClaim = (name) => `${stageDir}/${stagedName(name, t)}`;
124
295
  if (item.kind === "cargo-bin") {
125
296
  if (typeof item.package !== "string" || !NAME.test(item.package)) errors.push(`${where}: package must be a Cargo package name`);
126
297
  const bin = item.bin ?? item.package;
127
298
  if (typeof bin !== "string" || !NAME.test(bin)) errors.push(`${where}: bin must be a binary target name`);
128
299
  const name = item.name ?? bin;
129
300
  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)`);
130
- const profile = item.profile ?? "release";
131
- if (typeof profile !== "string" || !/^[a-z][a-z0-9_-]*$/.test(profile)) errors.push(`${where}: profile must be a Cargo profile name`);
301
+ const declared = item.profile !== undefined ? parseProfile(item.profile, `${where}: profile`, errors) : (defaultProfile ?? "release");
302
+ const profile = isObject(declared) ? declared[mode] : declared;
132
303
  const features = item.features ?? [];
133
304
  if (!Array.isArray(features) || features.some((f) => typeof f !== "string" || !/^[A-Za-z0-9_/-]+$/.test(f))) errors.push(`${where}: features must be a list of feature names (package/feature allowed)`);
134
305
  const manifestPath = item.manifestPath === undefined ? undefined : relativePath(item.manifestPath, `${where}: manifestPath`, errors);
135
306
  sha(item.sha256, "sha256");
136
- if (typeof name === "string" && stageDir && !skipped) claim(stagedFileName(name, t.os, t.arch), where);
137
- Object.assign(entry, { package: item.package, bin, name, profile, features: [...features], ...(manifestPath ? { manifestPath } : {}), ...(item.sha256 ? { sha256: item.sha256 } : {}) });
307
+ const dest = destOf(item.dest);
308
+ const clean = cleanOf();
309
+ if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
310
+ Object.assign(entry, { package: item.package, bin, name, profile, features: [...features], ...(manifestPath ? { manifestPath } : {}), ...(item.sha256 ? { sha256: item.sha256 } : {}), ...(dest ? { dest } : {}), ...(clean ? { clean } : {}) });
138
311
  } else if (item.kind === "ffmpeg") {
139
- const { kind: _kind, platforms: _platforms, ...rest } = item;
312
+ const { kind: _kind, platforms: _platforms, catalog: rawCatalog, sources: rawSources, probe: rawProbe, ...rest } = item;
140
313
  if (rest.stage !== undefined && isObject(rest.stage) && rest.stage.cmd === "cargo") errors.push(`${where}: stage.cmd must not be bare cargo; use { cmd: "rightkit", args: ["cargo", ...] }`);
314
+ const catalogSpec = parseCatalogSpec(rawCatalog, `${where}: catalog`, errors, "ffmpeg");
315
+ if (catalogSpec) entry.catalogSpec = catalogSpec;
316
+ if (rawProbe !== undefined && typeof rawProbe !== "boolean") errors.push(`${where}: probe must be a boolean`);
317
+ if (rawProbe === true) entry.probe = true;
318
+ const sources = parseFfmpegSources(rawSources, where, errors, { universal: t.arch === "universal" });
319
+ if (sources) entry.sources = sources;
141
320
  if (!skipped) {
142
- try {
143
- const config = validateMediaSidecarConfig({ ...rest, arch: t.arch, stageDir }, { platform: t.platform, hostArch: t.arch });
144
- for (const tool of config.tools) claim(stagedFileName(tool, t.os, t.arch), where);
145
- entry.media = config;
146
- } catch (error) { errors.push(...String(error.message).split("\n").filter((line) => line.startsWith("- ")).map((line) => `${where}: ${line.slice(2)}`).concat(error.message.startsWith("invalid") ? [] : [`${where}: ${error.message}`])); }
321
+ const mediaErrors = (error) => errors.push(...String(error.message).split("\n").filter((line) => line.startsWith("- ")).map((line) => `${where}: ${line.slice(2)}`).concat(error.message.startsWith("invalid") ? [] : [`${where}: ${error.message}`]));
322
+ if (t.arch === "universal") {
323
+ // One staging pass per arch slice (under SLICE_ROOT), then a lipo merge into <stageDir>/<tool>-universal-apple-darwin.
324
+ try {
325
+ entry.slices = SLICES.map((triple) => {
326
+ const slice = parseTargetTriple(triple);
327
+ return { triple, arch: slice.arch, media: validateMediaSidecarConfig({ ...rest, arch: slice.arch, stageDir: `${SLICE_ROOT}/${triple}` }, { platform: t.platform, hostArch: slice.arch }) };
328
+ });
329
+ entry.media = entry.slices[0].media;
330
+ for (const tool of entry.media.tools) claim(stagedClaim(tool), where);
331
+ } catch (error) { mediaErrors(error); }
332
+ } else {
333
+ try {
334
+ const config = validateMediaSidecarConfig({ ...rest, arch: t.arch, stageDir }, { platform: t.platform, hostArch: t.arch });
335
+ for (const tool of config.tools) claim(`${stageDir}/${stagedFileName(tool, t.os, t.arch)}`, where);
336
+ entry.media = config;
337
+ } catch (error) { mediaErrors(error); }
338
+ }
147
339
  }
148
340
  } else if (item.kind === "onnxruntime") {
149
341
  if (typeof item.version !== "string" || !/^\d+\.\d+\.\d+$/.test(item.version)) errors.push(`${where}: version must be x.y.z`);
150
342
  const resourceDir = relativePath(item.resourceDir ?? DEFAULT_ORT_RESOURCE_DIR, `${where}: resourceDir`, errors);
151
343
  const licenseDir = relativePath(item.licenseDir ?? (resourceDir ? `${resourceDir}/LICENSES` : "x"), `${where}: licenseDir`, errors);
152
344
  const cacheDir = relativePath(item.cacheDir ?? ".cache/onnxruntime", `${where}: cacheDir`, errors);
153
- const catalog = item.catalog === undefined ? undefined : relativePath(item.catalog, `${where}: catalog`, errors);
345
+ const catalogSpec = parseCatalogSpec(item.catalog, `${where}: catalog`, errors, "onnxruntime");
154
346
  if (item.as !== undefined && (typeof item.as !== "string" || !NAME.test(item.as))) errors.push(`${where}: as must be a bare file name`);
155
- Object.assign(entry, { version: item.version, resourceDir, licenseDir, cacheDir, ...(catalog ? { catalog } : {}), ...(item.as ? { as: item.as } : {}) });
347
+ Object.assign(entry, { version: item.version, resourceDir, licenseDir, cacheDir, ...(catalogSpec ? { catalogSpec } : {}), ...(item.as ? { as: item.as } : {}) });
348
+ } else if (item.kind === "command") {
349
+ const name = item.name;
350
+ 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)`);
351
+ if (typeof item.cmd !== "string" || !item.cmd.trim() || /\s/.test(item.cmd)) errors.push(`${where}: cmd must be one executable name or path with no spaces (arguments go in args; no shell is used)`);
352
+ const args = item.args ?? [];
353
+ if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) errors.push(`${where}: args must be a list of strings`);
354
+ const cwd = item.cwd === undefined ? undefined : relativePath(item.cwd, `${where}: cwd`, errors, { allowRoot: true });
355
+ const output = relativePath(item.output, `${where}: output`, errors);
356
+ const dest = destOf(item.dest);
357
+ const clean = cleanOf();
358
+ if (dest) { if (item.externalBin === true) errors.push(`${where}: externalBin must be false or omitted with dest (a dest file is not staged as <name>-<triple>)`); }
359
+ else if (item.externalBin !== true) errors.push(`${where}: externalBin must be true (the output is staged as <name>-<triple>)`);
360
+ if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
361
+ Object.assign(entry, { name, cmd: item.cmd, args: Array.isArray(args) ? [...args] : [], ...(cwd ? { cwd } : {}), output, ...(dest ? { dest } : {}), ...(clean ? { clean } : {}) });
362
+ } else if (item.kind === "remote") {
363
+ const name = item.name;
364
+ if (typeof name !== "string" || !NAME.test(name)) errors.push(`${where}: name must be a bare label`);
365
+ if (typeof item.url !== "string" || !/^https:\/\/[^\s/]+\/\S*$/.test(item.url)) errors.push(`${where}: url must be an https:// URL (no other scheme is fetched)`);
366
+ if (typeof item.sha256 !== "string" || !SHA256.test(item.sha256)) errors.push(`${where}: sha256 is required and must be a lowercase 64-hex SHA-256`);
367
+ if (item.dest !== undefined && item.to !== undefined) errors.push(`${where}: give dest or to, not both`);
368
+ if (item.dest === undefined && item.to === undefined) errors.push(`${where}: dest (or to) is required`);
369
+ const dest = destOf(item.dest ?? item.to, item.dest === undefined ? "to" : "dest");
370
+ if (item.size !== undefined && (!Number.isSafeInteger(item.size) || item.size <= 0)) errors.push(`${where}: size must be a positive integer (bytes)`);
371
+ if (item.extract !== undefined && (typeof item.extract !== "string" || !NAME.test(item.extract))) errors.push(`${where}: extract must be the bare file name of one archive member (no path)`);
372
+ 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`);
373
+ if (item.executable !== undefined && typeof item.executable !== "boolean") errors.push(`${where}: executable must be a boolean`);
374
+ const clean = cleanOf();
375
+ if (dest && !skipped) claim(dest, where);
376
+ 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 } : {}) });
377
+ } else if (item.kind === "cmake") {
378
+ const name = item.name;
379
+ 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)`);
380
+ if (typeof item.repo !== "string" || !/^https:\/\/[^\s/]+\/\S*$/.test(item.repo)) errors.push(`${where}: repo must be an https:// URL (no other scheme is fetched)`);
381
+ if (typeof item.commit !== "string" || !/^[0-9a-f]{40}$/.test(item.commit)) errors.push(`${where}: commit must be a full lowercase 40-hex commit id (a branch or tag is not a pin)`);
382
+ const target = item.target ?? name;
383
+ if (typeof target !== "string" || !NAME.test(target)) errors.push(`${where}: target must be a build target name`);
384
+ let output;
385
+ if (item.output !== undefined) output = relativePath(item.output, `${where}: output`, errors);
386
+ const flagList = (value, label) => {
387
+ if (value === undefined) return [];
388
+ if (!Array.isArray(value) || value.some((arg) => typeof arg !== "string" || !CMAKE_DEFINE.test(arg))) { errors.push(`${where}: ${label} must be a list of -DNAME=value definitions (no shell, no other flags)`); return []; }
389
+ return [...value];
390
+ };
391
+ const cmakeArgs = flagList(item.cmakeArgs, "cmakeArgs");
392
+ const platformArgs = {};
393
+ if (item.platformArgs !== undefined) {
394
+ if (!isObject(item.platformArgs)) errors.push(`${where}: platformArgs must be an object keyed by mac, win or linux`);
395
+ else for (const [key, list] of Object.entries(item.platformArgs)) {
396
+ if (!PLATFORMS.has(key)) errors.push(`${where}: platformArgs.${key}: platforms are ${[...PLATFORMS].join(", ")}`);
397
+ else platformArgs[key] = flagList(list, `platformArgs.${key}`);
398
+ }
399
+ }
400
+ if (item.generator !== undefined && (typeof item.generator !== "string" || !/^[A-Za-z0-9][A-Za-z0-9 .+-]{0,60}$/.test(item.generator))) errors.push(`${where}: generator must be a generator name such as "Ninja"`);
401
+ if (item.smoke !== undefined && (!Array.isArray(item.smoke) || item.smoke.some((arg) => typeof arg !== "string"))) errors.push(`${where}: smoke must be a list of argument strings`);
402
+ sha(item.sha256, "sha256");
403
+ const dest = destOf(item.dest);
404
+ const clean = cleanOf();
405
+ if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
406
+ Object.assign(entry, { name, repo: item.repo, commit: item.commit, target, ...(output ? { output } : {}), cmakeArgs, platformArgs, ...(item.generator ? { generator: item.generator } : {}), ...(item.smoke ? { smoke: [...item.smoke] } : {}), ...(item.sha256 ? { sha256: item.sha256 } : {}), ...(dest ? { dest } : {}), ...(clean ? { clean } : {}) });
407
+ } else if (item.kind === "prebuilt") {
408
+ const name = item.name;
409
+ 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)`);
410
+ const from = item.from === undefined ? undefined : relativePath(item.from, `${where}: from`, errors);
411
+ const manifest = parsePrebuiltManifest(item.manifest, where, errors);
412
+ if (!skipped && t.arch === "universal") errors.push(`${where}: a prebuilt binary is verified per architecture; there is no universal manifest entry`);
413
+ const dest = destOf(item.dest);
414
+ if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
415
+ Object.assign(entry, { name, ...(from ? { from } : {}), ...(manifest ? { manifest } : {}), ...(dest ? { dest } : {}) });
156
416
  } else {
157
417
  const from = relativePath(item.from, `${where}: from`, errors);
158
418
  const to = relativePath(item.to, `${where}: to`, errors);
159
419
  sha(item.sha256, "sha256");
420
+ if (item.machO !== undefined && typeof item.machO !== "boolean") errors.push(`${where}: machO must be a boolean`);
421
+ const clean = cleanOf();
160
422
  if (to && !skipped) claim(to, where);
161
- Object.assign(entry, { from, to, ...(item.sha256 ? { sha256: item.sha256 } : {}) });
423
+ Object.assign(entry, { from, to, ...(clean ? { clean } : {}),...(item.sha256 ? { sha256: item.sha256 } : {}), ...(item.machO ? { machO: true } : {}) });
162
424
  }
163
425
  entries.push(entry);
164
426
  }
427
+ const protectedPaths = protectedOutputPaths({ stageDir, receiptPath, entries });
428
+ for (const { key, output, label } of claimed) {
429
+ const hit = protectedPaths.find((protectedPath) => reaches(key, protectedPath));
430
+ if (hit) errors.push(`${label} writes ${output}, which is or contains ${hit}; stage-sidecar never overwrites or cleans the app's stage directory, receipt, cache, .git, src, src-tauri, node_modules or package.json`);
431
+ }
165
432
  if (errors.length) fail("STAGE_SIDECAR_CONFIG_INVALID", `invalid sidecars configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`, { errors });
166
- return { target: t, stageDir, receiptPath, entries };
433
+ return { target: t, stageDir, receiptPath, wipeStageDir: wrapper.wipeStageDir === true, entries };
167
434
  }
168
435
 
169
436
  const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
@@ -196,6 +463,29 @@ async function defaultFetchBytes(url) {
196
463
  return Buffer.from(await response.arrayBuffer());
197
464
  }
198
465
 
466
+ /** Absolute catalog file for a validated spec. Crate resolution reads the rightkit metadata listing; npm walks up node_modules. */
467
+ function resolveCatalogFile(spec, ctx, kind) {
468
+ const label = kind === "ffmpeg" ? "rightkit-ffmpeg" : "native-libraries";
469
+ if (spec.via === "absolute") return spec.file;
470
+ if (spec.via === "path") return path.resolve(ctx.appRoot, spec.file);
471
+ if (spec.via === "crate") {
472
+ const meta = ctx.run("rightkit", ["cargo", "metadata", "--format-version", "1"], { cwd: ctx.appRoot, env: ctx.env });
473
+ let packages;
474
+ try { packages = JSON.parse(meta.stdout).packages; } catch { /* refused below */ }
475
+ if (meta?.status !== 0 || !Array.isArray(packages)) fail("STAGE_SIDECAR_CATALOG_UNREADABLE", `the ${label} catalog crate ${spec.crate} cannot be located: the workspace metadata query failed`, { crate: spec.crate });
476
+ const found = packages.filter((pkg) => pkg.name === spec.crate && typeof pkg.manifest_path === "string");
477
+ if (found.length !== 1) fail("STAGE_SIDECAR_CATALOG_UNREADABLE", `the ${label} catalog crate ${spec.crate} matches ${found.length} packages in this workspace's dependency graph, expected exactly one`, { crate: spec.crate });
478
+ return path.join(path.dirname(found[0].manifest_path), ...spec.file.split("/"));
479
+ }
480
+ const parts = spec.file.split("/");
481
+ for (let dir = ctx.appRoot; ; dir = path.dirname(dir)) {
482
+ const candidate = path.join(dir, "node_modules", ...spec.pkg.split("/"), ...parts);
483
+ if (ctx.fs.existsSync(candidate)) return candidate;
484
+ if (path.dirname(dir) === dir) break;
485
+ }
486
+ return fail("STAGE_SIDECAR_CATALOG_UNREADABLE", `the ${label} catalog ${spec.pkg}/${spec.file} was not found in any node_modules above ${ctx.appRoot}`, { pkg: spec.pkg });
487
+ }
488
+
199
489
  function readJsonCatalog(entryCatalog, appRoot, fs, what) {
200
490
  const file = path.resolve(appRoot, entryCatalog);
201
491
  let bytes;
@@ -216,10 +506,50 @@ function artifactFromStream(stdout, bin) {
216
506
  }
217
507
 
218
508
  /** Candidate output paths, in lookup order: the explicit-target layout (broker) first, then the plain layout. */
219
- export function builtBinaryCandidates({ targetDir, triple, profile, bin, windows }) {
509
+ export function builtBinaryCandidates({ targetDir, targetDirs, triple, profile, bin, windows }) {
220
510
  const file = windows ? `${bin}.exe` : bin;
221
511
  const dir = profileDirectory(profile);
222
- return [path.join(targetDir, triple, dir, file), path.join(targetDir, dir, file)];
512
+ const roots = [...new Set([...(targetDirs ?? []), ...(targetDir ? [targetDir] : [])])];
513
+ return roots.flatMap((root) => [path.join(root, triple, dir, file), path.join(root, dir, file)]);
514
+ }
515
+
516
+ /**
517
+ * `clean: true` removes a stale file at the destination before staging. A directory is removed recursively only when the
518
+ * entry is a directory resource (`directory: true`); any other destination is removed non-recursively. Refused (typed,
519
+ * nothing deleted): the app root or anything outside it, a path equal to or containing a protected path (the stage
520
+ * directory, receipt, cache, .right-release, .git, src, src-tauri, node_modules, package.json), and a destination whose
521
+ * parent resolves (through a symlink) outside the app root or onto a protected path.
522
+ */
523
+ function cleanDest(ctx, entry, dest, { directory = false } = {}) {
524
+ if (!entry.clean) return;
525
+ const refuse = (reason) => fail("STAGE_SIDECAR_CLEAN_REFUSED", `refusing to clean ${dest}: ${reason}`, { dest });
526
+ const protectedPaths = ctx.protectedPaths ?? [];
527
+ const insideApp = (root, file) => { const relative = path.relative(root, file); return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? relative : undefined; };
528
+ const check = (relative, what) => {
529
+ const key = canonicalOutput(relative.split(path.sep).join("/"));
530
+ const hit = protectedPaths.find((protectedPath) => reaches(key, protectedPath));
531
+ if (hit) refuse(`${what} is or contains the protected path ${hit}`);
532
+ };
533
+ const relative = insideApp(ctx.appRoot, dest);
534
+ if (!relative) refuse("it is the app root or outside the app");
535
+ check(relative, "it");
536
+ const realpath = ctx.fs.realpathSync;
537
+ if (typeof realpath === "function") {
538
+ const resolveExisting = (start) => {
539
+ for (let current = start; ; current = path.dirname(current)) {
540
+ try { return realpath(current); } catch { if (path.dirname(current) === current) return undefined; }
541
+ }
542
+ };
543
+ const realRoot = resolveExisting(ctx.appRoot);
544
+ const realParent = resolveExisting(path.dirname(dest));
545
+ if (!realRoot || !realParent) refuse("its parent directory cannot be resolved");
546
+ if (realParent !== realRoot && !insideApp(realRoot, realParent)) refuse("its parent directory resolves outside the app (a symlink)");
547
+ const realRelative = insideApp(realRoot, path.join(realParent, path.basename(dest)));
548
+ if (!realRelative) refuse("it resolves to the app root");
549
+ check(realRelative, "its resolved location");
550
+ }
551
+ ctx.log(`stage-sidecar: clean ${rel(ctx, dest)}`);
552
+ 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 }); }
223
553
  }
224
554
 
225
555
  function copyInto(ctx, source, dest, { executable }) {
@@ -233,7 +563,7 @@ function copyInto(ctx, source, dest, { executable }) {
233
563
  fs.copyFileSync(source, temporary);
234
564
  if (executable && !windows) fs.chmodSync(temporary, 0o755);
235
565
  fs.renameSync(temporary, dest);
236
- }
566
+ } else if (executable && !windows) fs.chmodSync(dest, 0o755);
237
567
  const sha256 = sha256Of(fs.readFileSync(dest));
238
568
  if (sha256 !== sourceSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `staged ${dest} differs from its source ${source}`, { expected: sourceSha256, actual: sha256 });
239
569
  return { sha256, sizeBytes: fs.statSync(dest).size, changed };
@@ -241,10 +571,85 @@ function copyInto(ctx, source, dest, { executable }) {
241
571
 
242
572
  const rel = (ctx, file) => path.relative(ctx.appRoot, file).replaceAll("\\", "/");
243
573
 
574
+ /** `lipo -archs <file>` as a list; a failing lipo is a typed error. */
575
+ function lipoArchs(ctx, file) {
576
+ const result = ctx.run("lipo", ["-archs", file], { cwd: ctx.appRoot, env: ctx.env });
577
+ if (result?.error || result?.status !== 0) fail("STAGE_SIDECAR_NOT_UNIVERSAL", `lipo -archs ${file} failed (${result?.error?.message ?? `exit ${result?.status}`})`, { file });
578
+ return String(result.stdout ?? "").trim().split(/\s+/).filter(Boolean);
579
+ }
580
+
581
+ /** A universal file must carry both slices; anything thinner is refused. */
582
+ function requireUniversalFile(ctx, file, label) {
583
+ const archs = lipoArchs(ctx, file);
584
+ if (!(archs.includes("arm64") && archs.includes("x86_64"))) fail("STAGE_SIDECAR_NOT_UNIVERSAL", `${label} must contain arm64 and x86_64 for universal-apple-darwin, lipo -archs reports ${JSON.stringify(archs.join(" "))}`, { file, archs });
585
+ }
586
+
587
+ /**
588
+ * lipo-create `slices` into `dest` (a universal Mach-O): temp file, both archs required, written only when the bytes
589
+ * changed (an identical file is not rewritten, so Tauri's rerun-if-changed is not triggered needlessly).
590
+ */
591
+ function lipoMergeInto(ctx, slices, dest, label, { executable = false } = {}) {
592
+ const { fs, run, appRoot, env } = ctx;
593
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
594
+ const temporary = `${dest}.tmp-${ctx.pid}`;
595
+ const args = ["-create", "-output", temporary, ...slices];
596
+ ctx.log(`stage-sidecar: lipo ${args.join(" ")}`);
597
+ const merged = run("lipo", args, { cwd: appRoot, env });
598
+ if (merged?.error || merged?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `lipo -create of ${label} failed (${merged?.error?.message ?? `exit ${merged?.status}`})`, { stderr: tail(merged?.stderr) });
599
+ requireUniversalFile(ctx, temporary, `${label} after lipo`);
600
+ const sha256 = sha256Of(fs.readFileSync(temporary));
601
+ const changed = !(fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === sha256);
602
+ if (executable) fs.chmodSync(temporary, 0o755);
603
+ if (changed) fs.renameSync(temporary, dest);
604
+ else {
605
+ fs.rmSync(temporary, { force: true });
606
+ if (executable) fs.chmodSync(dest, 0o755);
607
+ }
608
+ return { sha256, sizeBytes: fs.statSync(dest).size, changed };
609
+ }
610
+
611
+ /** Can a binary built for `t` run on this host (so a probe or smoke run is meaningful)? Cross-target builds are not run. */
612
+ function canRunTarget(ctx, t) {
613
+ const host = ctx.host;
614
+ if (t.arch === "universal") return host.os === "macos";
615
+ return t.os === host.os && t.arch === host.arch;
616
+ }
617
+
618
+ const destFor = (entry, ctx) => (entry.dest ? path.resolve(ctx.appRoot, entry.dest) : path.resolve(ctx.appRoot, ctx.stageDir, stagedName(entry.name, ctx.target)));
619
+
244
620
  function stageCargoBin(entry, ctx) {
245
- const { target, run, appRoot, env, fs } = ctx;
621
+ const dest = destFor(entry, ctx);
622
+ cleanDest(ctx, entry, dest);
623
+ if (ctx.target.arch !== "universal") return stageCargoSlice(entry, ctx, ctx.target.triple, { dest });
624
+ const slices = SLICES.map((triple) => ({ triple, ...stageCargoBuild(entry, ctx, triple) }));
625
+ const { fs, run, appRoot, env } = ctx;
626
+ const temporary = `${dest}.tmp-${ctx.pid}`;
627
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
628
+ const args = ["-create", "-output", temporary, ...slices.map((slice) => slice.source)];
629
+ ctx.log(`stage-sidecar: lipo ${args.join(" ")}`);
630
+ const merged = run("lipo", args, { cwd: appRoot, env });
631
+ if (merged?.error || merged?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `lipo -create of ${entry.name} failed (${merged?.error?.message ?? `exit ${merged?.status}`})`, { package: entry.package, stderr: tail(merged?.stderr) });
632
+ requireUniversalFile(ctx, temporary, `${entry.name} after lipo`);
633
+ const sha256 = sha256Of(fs.readFileSync(temporary));
634
+ if (entry.sha256 && entry.sha256 !== sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.bin} lipo-merged to ${sha256}, expected ${entry.sha256}`, { expected: entry.sha256, actual: sha256 });
635
+ const changed = !(fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === sha256);
636
+ fs.chmodSync(temporary, 0o755);
637
+ fs.renameSync(temporary, dest);
638
+ 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 })) }];
639
+ }
640
+
641
+ function stageCargoSlice(entry, ctx, triple, { dest }) {
642
+ const { source, sourceSha256 } = stageCargoBuild(entry, ctx, triple);
643
+ if (entry.sha256 && entry.sha256 !== sourceSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.bin} built to ${sourceSha256}, expected ${entry.sha256}`, { expected: entry.sha256, actual: sourceSha256 });
644
+ const copied = copyInto(ctx, source, dest, { executable: true });
645
+ return [{ kind: "cargo-bin", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256, package: entry.package, bin: entry.bin, profile: entry.profile, features: entry.features, externalBin: !entry.dest }];
646
+ }
647
+
648
+ /** Build one slice through `rightkit cargo` and locate the executable. Returns { source, sourceSha256 }. */
649
+ function stageCargoBuild(entry, ctx, triple) {
650
+ const { run, appRoot, env, fs } = ctx;
246
651
  const args = ["cargo", "build", "--locked", ...(entry.manifestPath ? ["--manifest-path", path.resolve(appRoot, entry.manifestPath)] : []),
247
- "-p", entry.package, "--bin", entry.bin, "--target", target.triple, "--profile", entry.profile,
652
+ "-p", entry.package, "--bin", entry.bin, "--target", triple, "--profile", entry.profile,
248
653
  ...(entry.features.length ? ["--features", entry.features.join(",")] : []), "--message-format=json-render-diagnostics"];
249
654
  ctx.log(`stage-sidecar: rightkit ${args.join(" ")}`);
250
655
  const built = run("rightkit", args, { cwd: appRoot, env });
@@ -254,52 +659,157 @@ function stageCargoBin(entry, ctx) {
254
659
  const meta = run("rightkit", ["cargo", "metadata", "--format-version", "1", "--no-deps", ...(entry.manifestPath ? ["--manifest-path", path.resolve(appRoot, entry.manifestPath)] : [])], { cwd: appRoot, env });
255
660
  let targetDir;
256
661
  try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
257
- if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `cargo reported no ${entry.bin} executable and its target directory cannot be read`, { bin: entry.bin });
258
- const candidates = builtBinaryCandidates({ targetDir, triple: target.triple, profile: entry.profile, bin: entry.bin, windows: ctx.windows });
662
+ if (meta?.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) targetDir = undefined;
663
+ // A managed target root: the broker/cargo metadata target_directory (which cargo derives from CARGO_TARGET_DIR) is
664
+ // authoritative; an explicit CARGO_TARGET_DIR in this environment is tried too, resolved against the app root.
665
+ const envDir = typeof env.CARGO_TARGET_DIR === "string" && env.CARGO_TARGET_DIR.trim() ? path.resolve(appRoot, env.CARGO_TARGET_DIR) : undefined;
666
+ 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 });
667
+ const candidates = builtBinaryCandidates({ targetDir, targetDirs: envDir ? [envDir] : [], triple, profile: entry.profile, bin: entry.bin, windows: ctx.windows });
259
668
  source = candidates.find((file) => fs.existsSync(file));
260
669
  if (!source) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `the build did not produce ${entry.bin} (looked in ${candidates.join(", ")})`, { bin: entry.bin, candidates });
261
670
  }
262
671
  const sourceSha256 = sha256Of(fs.readFileSync(source));
263
- if (entry.sha256 && entry.sha256 !== sourceSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.bin} built to ${sourceSha256}, expected ${entry.sha256}`, { expected: entry.sha256, actual: sourceSha256 });
264
- const dest = path.resolve(appRoot, ctx.stageDir, stagedFileName(entry.name, target.os, target.arch));
265
- const copied = copyInto(ctx, source, dest, { executable: true });
266
- return [{ kind: "cargo-bin", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256, package: entry.package, bin: entry.bin, profile: entry.profile, features: entry.features, externalBin: true }];
672
+ return { source, sourceSha256 };
673
+ }
674
+
675
+ /** The archive source for one tool at one arch: a single source, or the per-arch form. */
676
+ const ffmpegSourceFor = (sources, tool, arch) => { const spec = sources?.[tool]; return spec?.url ? spec : spec?.[arch]; };
677
+
678
+ /**
679
+ * Download the bytes of the catalog rows that `ffmpeg.sources` names: an archive pinned by digest, one member unpacked, the
680
+ * member's digest checked against the APPROVED catalog row (so a swapped archive can never stage). A staged file that already
681
+ * matches its row is left alone.
682
+ */
683
+ async function fetchFfmpegSources(entry, ctx, plan, arch) {
684
+ if (!entry.sources) return;
685
+ const { fs } = ctx;
686
+ for (const file of plan.files) {
687
+ const spec = ffmpegSourceFor(entry.sources, file.tool, arch);
688
+ if (!spec) continue;
689
+ if (fs.existsSync(file.stagedPath) && sha256Of(fs.readFileSync(file.stagedPath)) === file.contentSha256) { ctx.log(`stage-sidecar: ${rel(ctx, file.stagedPath)} already staged`); continue; }
690
+ const { cached } = await ensureCachedDownload(ctx, spec);
691
+ const scratch = ctx.makeTempDir();
692
+ try {
693
+ const member = await ctx.extractMember({ archive: cached, fileName: spec.member, destDir: scratch });
694
+ const actual = sha256Of(fs.readFileSync(member));
695
+ if (actual !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${spec.member} from ${spec.url} is ${actual}, the approved catalog row ${file.artifactId} says ${file.contentSha256}; nothing staged`, { expected: file.contentSha256, actual });
696
+ copyInto(ctx, member, file.stagedPath, { executable: true });
697
+ } finally {
698
+ ctx.removeDir(scratch);
699
+ }
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Optional (`probe: true`) LGPL and encoder probes of a staged ffmpeg or ffprobe, restored from the apps' own stage scripts: the
705
+ * `configuration:` line of `-version` must say --disable-gpl and --disable-nonfree and must not enable GPL, nonfree or the GPL
706
+ * encoders; ffmpeg's `-encoders` list must not carry libx264, libx265, libxvid or libfdk_aac. A binary built for another
707
+ * architecture or OS than this host is not run: the row says `skipped-cross-target`. Returns "passed" or "skipped-cross-target".
708
+ */
709
+ function probeFfmpegBinary(ctx, { file, tool, target }) {
710
+ if (!canRunTarget(ctx, target)) return "skipped-cross-target";
711
+ const text = (result, what) => {
712
+ if (result?.error || result?.status !== 0) fail("STAGE_SIDECAR_FFMPEG_PROBE_FAILED", `${tool} ${what} failed (${result?.error?.message ?? `exit ${result?.status}`})`, { file });
713
+ return String(result.stdout ?? "");
714
+ };
715
+ const version = text(ctx.run(file, ["-version"], { cwd: ctx.appRoot, env: ctx.env }), "-version");
716
+ const configuration = /^configuration:(.*)$/m.exec(version)?.[1] ?? "";
717
+ for (const flag of ["--disable-gpl", "--disable-nonfree"]) if (!configuration.includes(flag)) fail("STAGE_SIDECAR_FFMPEG_PROBE_FAILED", `${tool} was not built with ${flag}; the LGPL redistribution terms do not hold`, { file });
718
+ const enabled = /--enable-(gpl|nonfree|libx264|libx265|libxvid|libfdk-aac)\b/.exec(configuration);
719
+ if (enabled) fail("STAGE_SIDECAR_FFMPEG_PROBE_FAILED", `${tool} was built with --enable-${enabled[1]}; it cannot ship under the LGPL`, { file });
720
+ if (tool === "ffmpeg") {
721
+ const encoders = text(ctx.run(file, ["-hide_banner", "-encoders"], { cwd: ctx.appRoot, env: ctx.env }), "-encoders");
722
+ const gpl = /\b(libx264|libx264rgb|libx265|libxvid|libfdk_aac)\b/.exec(encoders);
723
+ if (gpl) fail("STAGE_SIDECAR_FFMPEG_PROBE_FAILED", `ffmpeg lists the ${gpl[1]} encoder; it cannot ship under the LGPL`, { file });
724
+ }
725
+ return "passed";
726
+ }
727
+
728
+ /**
729
+ * Stage one architecture of an ffmpeg entry: plan the catalog rows, fetch declared sources, run the optional stage command,
730
+ * write the notices (unless a universal merge writes them once for every slice), verify each staged file against its row.
731
+ * Returns { plan, rows }; each row is a receipt row.
732
+ */
733
+ async function stageFfmpegSlice(entry, ctx, resolved, { config, t, writeNotices }) {
734
+ const { appRoot, run, env, fs } = ctx;
735
+ const plan = planMediaSidecar({ config, catalog: resolved.catalog, crateDir: resolved.crateDir, crateVersion: resolved.crateVersion, catalogBytes: resolved.catalogBytes, root: appRoot });
736
+ await fetchFfmpegSources(entry, ctx, plan, t.arch);
737
+ if (config.stage) {
738
+ const universal = ctx.target.arch === "universal";
739
+ // A universal entry stages once per slice: `{arch}`, `{triple}` and `{stageDir}` in the stage command's arguments name the slice.
740
+ const args = (config.stage.args ?? []).map((arg) => (universal ? String(arg).replaceAll("{arch}", t.arch).replaceAll("{triple}", t.triple).replaceAll("{stageDir}", config.stageDir) : arg));
741
+ ctx.log(`stage-sidecar: ${config.stage.cmd} ${args.join(" ")}`);
742
+ const staged = run(config.stage.cmd, args, { cwd: appRoot, env });
743
+ if (staged?.error || staged?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `ffmpeg stage command failed (${staged?.error?.message ?? `exit ${staged?.status}`})`);
744
+ }
745
+ const noticeFiles = writeNotices ? writeMediaSidecarNotices(plan, { exists: fs.existsSync, mkdir: fs.mkdirSync, copy: fs.copyFileSync, write: fs.writeFileSync }) : [];
746
+ const verified = verifyStagedSidecars(plan, { exists: fs.existsSync, stat: (file) => fs.statSync(file), hashFile: (file) => sha256Of(fs.readFileSync(file)) });
747
+ const notices = noticeFiles.map((file) => ({ name: path.basename(file), path: rel(ctx, file), sha256: sha256Of(fs.readFileSync(file)) }));
748
+ const rows = verified.map((file) => {
749
+ if (file.preSignSha256 !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${file.tool} staged as ${file.preSignSha256}, the approved catalog row ${file.artifactId} says ${file.contentSha256}`, { expected: file.contentSha256, actual: file.preSignSha256 });
750
+ const probe = entry.probe ? probeFfmpegBinary(ctx, { file: file.stagedPath, tool: file.tool, target: t }) : undefined;
751
+ return { kind: "ffmpeg", name: file.tool, path: rel(ctx, file.stagedPath), sha256: file.preSignSha256, sizeBytes: file.preSignSizeBytes, changed: null, catalogRow: file.artifactId, version: file.version, catalogContentSha256: file.contentSha256, ...(file.variant ? { variant: file.variant } : {}), externalBin: true, license: "LGPL-2.1-or-later", notices, ...(probe ? { probe } : {}) };
752
+ });
753
+ return { plan, rows };
754
+ }
755
+
756
+ /** universal-apple-darwin: stage each arch slice under SLICE_ROOT, then lipo-merge every tool into <stageDir>/<tool>-universal-apple-darwin. */
757
+ async function stageFfmpegUniversal(entry, ctx, resolved) {
758
+ const { appRoot, fs } = ctx;
759
+ const results = [];
760
+ for (const slice of entry.slices) {
761
+ results.push({ slice, ...(await stageFfmpegSlice(entry, ctx, resolved, { config: slice.media, t: parseTargetTriple(slice.triple), writeNotices: false })) });
762
+ }
763
+ // One notice set and one source offer that names the rows of every slice (a per-slice offer would overwrite the other).
764
+ const noticeByDest = new Map();
765
+ for (const { plan } of results) for (const notice of plan.notices) noticeByDest.set(notice.dest, notice);
766
+ const rowsById = new Map();
767
+ for (const { plan } of results) for (const row of plan.rows) rowsById.set(row.id, row);
768
+ const combined = {
769
+ notices: [...noticeByDest.values()],
770
+ offerPath: results[0].plan.offerPath,
771
+ offer: sourceOfferText({ rows: [...rowsById.values()], contact: entry.media.notices.sourceOfferContact, crateVersion: resolved.crateVersion }),
772
+ };
773
+ const noticeFiles = writeMediaSidecarNotices(combined, { exists: fs.existsSync, mkdir: fs.mkdirSync, copy: fs.copyFileSync, write: fs.writeFileSync });
774
+ const notices = noticeFiles.map((file) => ({ name: path.basename(file), path: rel(ctx, file), sha256: sha256Of(fs.readFileSync(file)) }));
775
+ return entry.media.tools.map((tool) => {
776
+ const parts = results.map(({ slice, rows }) => ({ slice, row: rows.find((row) => row.name === tool) }));
777
+ const dest = path.resolve(appRoot, ctx.stageDir, stagedName(tool, ctx.target));
778
+ const merged = lipoMergeInto(ctx, parts.map(({ row }) => path.resolve(appRoot, row.path)), dest, `ffmpeg sidecar ${tool}`, { executable: true });
779
+ const probe = entry.probe ? probeFfmpegBinary(ctx, { file: dest, tool, target: ctx.target }) : undefined;
780
+ return {
781
+ kind: "ffmpeg", name: tool, path: rel(ctx, dest), ...merged, sourceSha256: merged.sha256, catalogRow: parts.map(({ row }) => row.catalogRow).join("+"), version: parts[0].row.version,
782
+ externalBin: true, license: "LGPL-2.1-or-later", notices, ...(probe ? { probe } : {}),
783
+ slices: parts.map(({ slice, row }) => ({ triple: slice.triple, sha256: row.sha256, catalogRow: row.catalogRow, catalogContentSha256: row.catalogContentSha256 })),
784
+ };
785
+ });
267
786
  }
268
787
 
269
788
  async function stageFfmpeg(entry, ctx) {
270
- const { appRoot, run, env, fs, target } = ctx;
789
+ const { appRoot, fs, target } = ctx;
271
790
  const config = entry.media;
272
791
  try {
273
792
  let resolved = await ctx.loadMediaCatalog({ config, appRoot, entry });
274
793
  if (!resolved) {
275
- if (!config.catalog) fail("STAGE_SIDECAR_CATALOG_UNREADABLE", "ffmpeg needs a catalog path (the rightkit-ffmpeg artifacts.json) or deps.loadMediaCatalog");
276
- resolved = readJsonCatalog(config.catalog, appRoot, fs, "rightkit-ffmpeg");
794
+ if (!entry.catalogSpec) fail("STAGE_SIDECAR_CATALOG_UNREADABLE", "ffmpeg needs a catalog (the rightkit-ffmpeg artifacts.json: a path, { crate } or { npm }) or deps.loadMediaCatalog");
795
+ resolved = readJsonCatalog(resolveCatalogFile(entry.catalogSpec, ctx, "ffmpeg"), appRoot, fs, "rightkit-ffmpeg");
277
796
  }
278
- const plan = planMediaSidecar({ config, catalog: resolved.catalog, crateDir: resolved.crateDir, crateVersion: resolved.crateVersion, catalogBytes: resolved.catalogBytes, root: appRoot });
279
- if (config.stage) {
280
- ctx.log(`stage-sidecar: ${config.stage.cmd} ${(config.stage.args ?? []).join(" ")}`);
281
- const staged = run(config.stage.cmd, config.stage.args ?? [], { cwd: appRoot, env });
282
- if (staged?.error || staged?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `ffmpeg stage command failed (${staged?.error?.message ?? `exit ${staged?.status}`})`);
283
- }
284
- const noticeFiles = writeMediaSidecarNotices(plan, { exists: fs.existsSync, mkdir: fs.mkdirSync, copy: fs.copyFileSync, write: fs.writeFileSync });
285
- const verified = verifyStagedSidecars(plan, { exists: fs.existsSync, stat: (file) => fs.statSync(file), hashFile: (file) => sha256Of(fs.readFileSync(file)) });
286
- const notices = noticeFiles.map((file) => ({ name: path.basename(file), path: rel(ctx, file), sha256: sha256Of(fs.readFileSync(file)) }));
287
- return verified.map((file) => {
288
- if (file.preSignSha256 !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${file.tool} staged as ${file.preSignSha256}, the approved catalog row ${file.artifactId} says ${file.contentSha256}`, { expected: file.contentSha256, actual: file.preSignSha256 });
289
- return { kind: "ffmpeg", name: file.tool, path: rel(ctx, file.stagedPath), sha256: file.preSignSha256, sizeBytes: file.preSignSizeBytes, changed: null, catalogRow: file.artifactId, version: file.version, catalogContentSha256: file.contentSha256, externalBin: true, license: "LGPL-2.1-or-later", notices };
290
- });
797
+ if (target.arch === "universal") return await stageFfmpegUniversal(entry, ctx, resolved);
798
+ return (await stageFfmpegSlice(entry, ctx, resolved, { config, t: target, writeNotices: true })).rows;
291
799
  } catch (error) {
292
800
  if (error instanceof StageSidecarError) throw error;
293
801
  return fail("STAGE_SIDECAR_FFMPEG_FAILED", `ffmpeg sidecar (${target.triple}): ${error.message}`, { cause: error, causeCode: error.code });
294
802
  }
295
803
  }
296
804
 
297
- async function stageOnnxRuntime(entry, ctx) {
298
- const { appRoot, fs, target } = ctx;
299
- const loaded = await (ctx.loadNativeCatalog({ entry, appRoot }) ?? (entry.catalog ? readJsonCatalog(entry.catalog, appRoot, fs, "native-libraries") : undefined));
300
- if (!loaded) fail("STAGE_SIDECAR_CATALOG_UNREADABLE", "onnxruntime needs a catalog path (the rightkit-ffmpeg native-libraries.json) or deps.loadNativeCatalog");
301
- const rows = (loaded.catalog?.libraries ?? []).filter((row) => row.library === "onnxruntime" && row.version === entry.version && row.os === target.os && row.arch === target.arch);
302
- const where = `onnxruntime ${entry.version} ${target.os}/${target.arch}`;
805
+ /**
806
+ * Stage one architecture of onnxruntime: the approved or pinned catalog row for `t`, its archive (digest-checked), the library
807
+ * (digest-checked) into `destDir` (default the entry's resourceDir), and the notices. Returns { row, dest, outName, notices }.
808
+ */
809
+ async function stageOnnxRuntimeSlice(entry, ctx, loaded, t, { destDir } = {}) {
810
+ const { appRoot, fs } = ctx;
811
+ const rows = (loaded.catalog?.libraries ?? []).filter((row) => row.library === "onnxruntime" && row.version === entry.version && row.os === t.os && row.arch === t.arch);
812
+ const where = `onnxruntime ${entry.version} ${t.os}/${t.arch}`;
303
813
  if (rows.length === 0) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has no ${where} row`);
304
814
  if (rows.length > 1) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has ${rows.length} ${where} rows`);
305
815
  const [row] = rows;
@@ -316,7 +826,7 @@ async function stageOnnxRuntime(entry, ctx) {
316
826
  for (const { source } of notices) if (!fs.existsSync(source)) fail("STAGE_SIDECAR_NOTICE_MISSING", `notice file not found: ${source}`);
317
827
 
318
828
  const outName = entry.as ?? row.artifactFileName;
319
- const dest = path.resolve(appRoot, entry.resourceDir, outName);
829
+ const dest = path.resolve(appRoot, destDir ?? entry.resourceDir, outName);
320
830
  if (fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === row.sha256) {
321
831
  ctx.log(`stage-sidecar: ${rel(ctx, dest)} already staged`);
322
832
  } else {
@@ -343,37 +853,373 @@ async function stageOnnxRuntime(entry, ctx) {
343
853
  fs.copyFileSync(source, out);
344
854
  return { name: path.basename(out), path: rel(ctx, out), sha256: sha256Of(fs.readFileSync(out)) };
345
855
  });
346
- return [{ kind: "onnxruntime", name: outName, path: rel(ctx, dest), sha256: row.sha256, sizeBytes: fs.statSync(dest).size, changed: null, catalogRow: row.id, version: row.version, archiveSha256: row.archive.sha256, license: row.licenseSpdx ?? null, notices: noticeOut, externalBin: false }];
856
+ return { row, dest, outName, notices: noticeOut };
857
+ }
858
+
859
+ async function stageOnnxRuntime(entry, ctx) {
860
+ const { appRoot, fs, target } = ctx;
861
+ const loaded = await (ctx.loadNativeCatalog({ entry, appRoot }) ?? (entry.catalogSpec ? readJsonCatalog(resolveCatalogFile(entry.catalogSpec, ctx, "onnxruntime"), appRoot, fs, "native-libraries") : undefined));
862
+ if (!loaded) fail("STAGE_SIDECAR_CATALOG_UNREADABLE", "onnxruntime needs a catalog path (the rightkit-ffmpeg native-libraries.json) or deps.loadNativeCatalog");
863
+ if (target.arch !== "universal") {
864
+ const { row, dest, outName, notices } = await stageOnnxRuntimeSlice(entry, ctx, loaded, target);
865
+ return [{ kind: "onnxruntime", name: outName, path: rel(ctx, dest), sha256: row.sha256, sizeBytes: fs.statSync(dest).size, changed: null, catalogRow: row.id, version: row.version, archiveSha256: row.archive.sha256, license: row.licenseSpdx ?? null, notices, externalBin: false }];
866
+ }
867
+ // universal-apple-darwin: each arch row is staged thin under SLICE_ROOT, then merged into the entry's resource file.
868
+ const slices = [];
869
+ for (const triple of SLICES) slices.push({ triple, ...(await stageOnnxRuntimeSlice(entry, ctx, loaded, parseTargetTriple(triple), { destDir: `${SLICE_ROOT}/${triple}` })) });
870
+ const names = [...new Set(slices.map((slice) => slice.outName))];
871
+ if (names.length !== 1) fail("STAGE_SIDECAR_NOT_APPROVED", `the arch rows name different library files (${names.join(", ")}); set "as" so one universal file name is staged`);
872
+ const dest = path.resolve(appRoot, entry.resourceDir, names[0]);
873
+ const merged = lipoMergeInto(ctx, slices.map((slice) => slice.dest), dest, `onnxruntime ${entry.version}`);
874
+ const notices = [...new Map(slices.flatMap((slice) => slice.notices).map((notice) => [notice.path, notice])).values()];
875
+ return [{
876
+ kind: "onnxruntime", name: names[0], path: rel(ctx, dest), ...merged, sourceSha256: merged.sha256, catalogRow: slices.map((slice) => slice.row.id).join("+"), version: entry.version,
877
+ license: slices[0].row.licenseSpdx ?? null, notices, externalBin: false,
878
+ slices: slices.map((slice) => ({ triple: slice.triple, sha256: slice.row.sha256, catalogRow: slice.row.id, archiveSha256: slice.row.archive.sha256 })),
879
+ }];
880
+ }
881
+
882
+ /** Every file under `dir`, sorted, as paths relative to it (forward slashes). */
883
+ function listFilesRecursive(fs, dir, prefix = "") {
884
+ const out = [];
885
+ for (const name of [...fs.readdirSync(path.join(dir, prefix))].sort()) {
886
+ const relative = prefix ? `${prefix}/${name}` : name;
887
+ const stat = fs.statSync(path.join(dir, relative));
888
+ if (stat.isDirectory()) out.push(...listFilesRecursive(fs, dir, relative));
889
+ else if (stat.isFile()) out.push(relative);
890
+ }
891
+ return out;
892
+ }
893
+
894
+ /** A directory resource: copy recursively, hash and verify every file, one receipt row per file. */
895
+ function stageResourceDirectory(entry, ctx, source) {
896
+ const { appRoot, fs } = ctx;
897
+ if (entry.sha256) fail("STAGE_SIDECAR_CONFIG_INVALID", `resource ${entry.from} is a directory; sha256 pins one file, so drop it (every file is hashed into the receipt)`, { from: entry.from });
898
+ if (entry.machO) fail("STAGE_SIDECAR_CONFIG_INVALID", `resource ${entry.from} is a directory; machO applies to one file`, { from: entry.from });
899
+ const members = listFilesRecursive(fs, source);
900
+ if (!members.length) fail("STAGE_SIDECAR_RESOURCE_MISSING", `resource directory ${entry.from} contains no files`, { from: entry.from });
901
+ const destRoot = path.resolve(appRoot, entry.to);
902
+ cleanDest(ctx, entry, destRoot, { directory: true });
903
+ return members.map((member) => {
904
+ const from = path.join(source, ...member.split("/"));
905
+ const dest = path.join(destRoot, ...member.split("/"));
906
+ const sourceSha256 = sha256Of(fs.readFileSync(from));
907
+ const copied = copyInto(ctx, from, dest, { executable: false });
908
+ return { kind: "resource", name: path.posix.basename(member), path: rel(ctx, dest), ...copied, sourceSha256, from: `${entry.from}/${member}`, externalBin: false };
909
+ });
347
910
  }
348
911
 
349
912
  function stageResource(entry, ctx) {
350
913
  const { appRoot, fs } = ctx;
351
914
  const source = path.resolve(appRoot, entry.from);
352
- if (!fs.existsSync(source) || !fs.statSync(source).isFile()) fail("STAGE_SIDECAR_RESOURCE_MISSING", `resource ${entry.from} is not a file (directories are not staged; list each file)`, { from: entry.from });
915
+ if (fs.existsSync(source) && fs.statSync(source).isDirectory()) return stageResourceDirectory(entry, ctx, source);
916
+ if (!fs.existsSync(source) || !fs.statSync(source).isFile()) fail("STAGE_SIDECAR_RESOURCE_MISSING", `resource ${entry.from} is neither a file nor a directory`, { from: entry.from });
353
917
  const actual = sha256Of(fs.readFileSync(source));
354
918
  if (entry.sha256 && entry.sha256 !== actual) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.from} is ${actual}, expected ${entry.sha256}`, { expected: entry.sha256, actual });
919
+ if (entry.machO && ctx.target.arch === "universal") requireUniversalFile(ctx, source, `resource ${entry.from}`);
355
920
  const dest = path.resolve(appRoot, entry.to);
921
+ cleanDest(ctx, entry, dest);
356
922
  const copied = copyInto(ctx, source, dest, { executable: false });
357
923
  return [{ kind: "resource", name: path.basename(dest), path: rel(ctx, dest), ...copied, sourceSha256: actual, from: entry.from, externalBin: false }];
358
924
  }
359
925
 
926
+ /** Run the entry's command (argv, no shell), then stage its output as <name>-<triple>. */
927
+ function stageCommand(entry, ctx) {
928
+ const { appRoot, fs, run, env } = ctx;
929
+ const cwd = entry.cwd ? path.resolve(appRoot, entry.cwd) : appRoot;
930
+ cleanDest(ctx, entry, destFor(entry, ctx));
931
+ ctx.log(`stage-sidecar: ${entry.cmd} ${entry.args.join(" ")}`);
932
+ const built = run(entry.cmd, entry.args, { cwd, env });
933
+ 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) });
934
+ const source = path.resolve(appRoot, entry.output);
935
+ 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 });
936
+ if (ctx.target.arch === "universal") requireUniversalFile(ctx, source, `command ${entry.name} output ${entry.output}`);
937
+ const sourceSha256 = sha256Of(fs.readFileSync(source));
938
+ const dest = destFor(entry, ctx);
939
+ const copied = copyInto(ctx, source, dest, { executable: true });
940
+ return [{ kind: "command", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256, output: entry.output, externalBin: !entry.dest }];
941
+ }
942
+
943
+ /**
944
+ * Download `{ url, sha256, size? }` into the content-addressed cache (`.right-release/cache/remote/<sha256>`). The bytes are
945
+ * hashed before anything is written: the cache entry exists only from verified bytes. A mismatch (or a non-https redirect)
946
+ * fails closed with nothing staged. Returns { cached, fromCache }.
947
+ */
948
+ async function ensureCachedDownload(ctx, { url, sha256, size }) {
949
+ const { appRoot, fs } = ctx;
950
+ const cached = path.resolve(appRoot, REMOTE_CACHE_DIR, sha256);
951
+ if (fs.existsSync(cached) && sha256Of(fs.readFileSync(cached)) === sha256) return { cached, fromCache: true };
952
+ ctx.log(`stage-sidecar: GET ${url}`);
953
+ let response;
954
+ try { response = await ctx.fetch(url, { redirect: "follow" }); } catch (error) { fail("STAGE_SIDECAR_DOWNLOAD_FAILED", `GET ${url} failed: ${error.message}`, { cause: error, url }); }
955
+ if (!response?.ok) fail("STAGE_SIDECAR_DOWNLOAD_FAILED", `GET ${url} failed with ${response?.status}`, { url });
956
+ if (typeof response.url === "string" && response.url && !response.url.startsWith("https://")) fail("STAGE_SIDECAR_DOWNLOAD_FAILED", `${url} redirected to a non-https URL: ${response.url}`, { url });
957
+ const bytes = Buffer.from(await response.arrayBuffer());
958
+ if (size !== undefined && bytes.length !== size) fail("STAGE_SIDECAR_HASH_MISMATCH", `${url} is ${bytes.length} bytes, expected ${size}; nothing staged`, { expectedSize: size, actualSize: bytes.length });
959
+ const actual = sha256Of(bytes);
960
+ if (actual !== sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${url} downloaded as ${actual}, expected ${sha256}; nothing staged`, { expected: sha256, actual });
961
+ fs.mkdirSync(path.dirname(cached), { recursive: true });
962
+ const temporary = `${cached}.tmp-${ctx.pid}`;
963
+ fs.writeFileSync(temporary, bytes);
964
+ fs.renameSync(temporary, cached);
965
+ return { cached, fromCache: false };
966
+ }
967
+
968
+ /**
969
+ * A pinned https download. `dest` is copied only from the verified cache entry. With `extract` the download is an archive
970
+ * (zip, tgz): the one member whose base name is `extract` is unpacked (digest-checked against `memberSha256` when given) and
971
+ * only that member is staged, so no `command` entry or shell is needed to unpack it.
972
+ */
973
+ async function stageRemote(entry, ctx) {
974
+ const { appRoot, fs } = ctx;
975
+ const dest = path.resolve(appRoot, entry.dest);
976
+ const { cached, fromCache } = await ensureCachedDownload(ctx, entry);
977
+ let source = cached;
978
+ let scratch;
979
+ try {
980
+ let memberSha256;
981
+ if (entry.extract) {
982
+ scratch = ctx.makeTempDir();
983
+ source = await ctx.extractMember({ archive: cached, fileName: entry.extract, destDir: scratch });
984
+ memberSha256 = sha256Of(fs.readFileSync(source));
985
+ 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 });
986
+ }
987
+ // Download and verify first: a failed fetch or a hash mismatch must never leave a cleaned destination behind.
988
+ cleanDest(ctx, entry, dest);
989
+ const copied = copyInto(ctx, source, dest, { executable: entry.executable === true });
990
+ const expected = entry.extract ? entry.memberSha256 : entry.sha256;
991
+ if (expected && copied.sha256 !== expected) fail("STAGE_SIDECAR_HASH_MISMATCH", `staged ${entry.dest} is ${copied.sha256}, expected ${expected}`, { expected, actual: copied.sha256 });
992
+ 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 } : {}) }];
993
+ } finally {
994
+ if (scratch) ctx.removeDir(scratch);
995
+ }
996
+ }
997
+
998
+ /**
999
+ * Bring the fixed source checkout `srcDir` to the pinned commit: `git init` once, then fetch exactly that commit (git verifies the
1000
+ * object hash, so the tree is pinned with no repackable tarball) and detach onto it. A checkout already at the commit is reused;
1001
+ * one with local edits is refused rather than built.
1002
+ */
1003
+ function checkoutPinnedSource(ctx, entry, srcDir) {
1004
+ const { fs, run, env } = ctx;
1005
+ const git = (args) => {
1006
+ const result = run("git", args, { cwd: srcDir, env });
1007
+ if (result?.error || result?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `git ${args[0]} for ${entry.name} failed (${result?.error?.message ?? `exit ${result?.status}`})`, { name: entry.name, stderr: tail(result?.stderr) });
1008
+ return result;
1009
+ };
1010
+ const head = () => { const result = run("git", ["rev-parse", "HEAD"], { cwd: srcDir, env }); return result?.status === 0 ? String(result.stdout ?? "").trim() : ""; };
1011
+ if (!fs.existsSync(path.join(srcDir, ".git"))) {
1012
+ fs.mkdirSync(srcDir, { recursive: true });
1013
+ git(["init", "-q"]);
1014
+ git(["remote", "add", "origin", entry.repo]);
1015
+ } else git(["remote", "set-url", "origin", entry.repo]);
1016
+ // Cleanliness is judged BEFORE any destructive source operation: tracked edits, untracked files and ignored files are all
1017
+ // build inputs (CMake globbing), so any of them refuses the build and nothing is checked out over them.
1018
+ const assertClean = (when) => {
1019
+ const dirty = run("git", ["status", "--porcelain", "--ignored"], { cwd: srcDir, env });
1020
+ if (dirty?.status !== 0 || String(dirty.stdout ?? "").trim()) fail("STAGE_SIDECAR_SOURCE_MISMATCH", `${entry.name} source checkout ${srcDir} has local, untracked or ignored files ${when}; refusing to build a tree that is not the pinned commit`, { name: entry.name, status: tail(dirty?.stdout) });
1021
+ };
1022
+ assertClean("before checkout");
1023
+ if (head() !== entry.commit) {
1024
+ ctx.log(`stage-sidecar: git fetch ${entry.repo} ${entry.commit}`);
1025
+ git(["fetch", "--depth", "1", "origin", entry.commit]);
1026
+ git(["checkout", "-q", "--force", "--detach", "FETCH_HEAD"]);
1027
+ const actual = head();
1028
+ if (actual !== entry.commit) fail("STAGE_SIDECAR_SOURCE_MISMATCH", `${entry.name} checkout is ${actual || "unreadable"}, the pin is ${entry.commit}`, { expected: entry.commit, actual });
1029
+ }
1030
+ assertClean("after checkout");
1031
+ if (fs.existsSync(path.join(srcDir, ".gitmodules"))) {
1032
+ // Submodule commits are pinned by the superproject tree: bring them to exactly those commits, then require every one to match
1033
+ // (no `+` moved, `-` missing or `U` conflicted prefix) and be clean, so no unpinned submodule source reaches the build.
1034
+ git(["submodule", "update", "--init", "--recursive", "--checkout"]);
1035
+ const modules = git(["submodule", "status", "--recursive"]);
1036
+ const bad = String(modules.stdout ?? "").split("\n").filter((line) => /^[-+U]/.test(line));
1037
+ if (bad.length) fail("STAGE_SIDECAR_SOURCE_MISMATCH", `${entry.name} submodules are not at their pinned commits: ${bad.join("; ")}`, { name: entry.name });
1038
+ const inner = git(["submodule", "foreach", "--recursive", "git status --porcelain --ignored"]);
1039
+ if (String(inner.stdout ?? "").split("\n").some((line) => line.trim() && !/^Entering /.test(line))) fail("STAGE_SIDECAR_SOURCE_MISMATCH", `${entry.name} submodule checkout has local, untracked or ignored files; refusing to build`, { name: entry.name });
1040
+ assertClean("after submodule update");
1041
+ }
1042
+ }
1043
+
1044
+ /**
1045
+ * Build a pinned git commit with the build tool (no shell) and stage the result as <name>-<triple>[.exe]: fetch the commit, configure,
1046
+ * build `target`, locate the executable, check it carries both archs for universal-apple-darwin, optionally pin its digest and
1047
+ * smoke-run it. Per-platform flags (for example Metal on macOS) come from `platformArgs`; the source and build trees live at the
1048
+ * fixed `.right-release/cmake/<name>/{src,build}`.
1049
+ */
1050
+ function stageCmake(entry, ctx) {
1051
+ const { appRoot, fs, run, env, target } = ctx;
1052
+ const root = path.resolve(appRoot, CMAKE_ROOT, entry.name);
1053
+ const srcDir = path.join(root, "src");
1054
+ const buildDir = path.join(root, "build");
1055
+ const dest = destFor(entry, ctx);
1056
+ cleanDest(ctx, entry, dest);
1057
+ checkoutPinnedSource(ctx, entry, srcDir);
1058
+ const archFlag = target.platform !== "mac" ? undefined
1059
+ : target.arch === "universal" ? "-DCMAKE_OSX_ARCHITECTURES=arm64;x86_64"
1060
+ : `-DCMAKE_OSX_ARCHITECTURES=${target.arch === "aarch64" ? "arm64" : "x86_64"}`;
1061
+ const configure = ["-S", srcDir, "-B", buildDir, "-DCMAKE_BUILD_TYPE=Release", ...(entry.generator ? ["-G", entry.generator] : []), ...(archFlag ? [archFlag] : []), ...(entry.platformArgs[target.platform] ?? []), ...entry.cmakeArgs];
1062
+ const step = (args, label) => {
1063
+ ctx.log(`stage-sidecar: cmake ${args.join(" ")}`);
1064
+ const result = run("cmake", args, { cwd: appRoot, env });
1065
+ if (result?.error || result?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `cmake ${label} of ${entry.name} failed (${result?.error?.message ?? `exit ${result?.status}`})`, { name: entry.name, stderr: tail(result?.stderr) });
1066
+ };
1067
+ step(configure, "configure");
1068
+ step(["--build", buildDir, "--config", "Release", "--target", entry.target, "--parallel"], "build");
1069
+ const exe = ctx.windows ? ".exe" : "";
1070
+ const output = entry.output ?? `bin/${entry.target}`;
1071
+ const dir = path.posix.dirname(output);
1072
+ const file = `${path.posix.basename(output)}${exe}`;
1073
+ const candidates = [...new Set([path.join(buildDir, dir, file), path.join(buildDir, dir, "Release", file), path.join(buildDir, "Release", file)])];
1074
+ const source = candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
1075
+ if (!source) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `the build of ${entry.name} did not produce ${output} (looked in ${candidates.join(", ")})`, { name: entry.name, candidates });
1076
+ if (target.arch === "universal") requireUniversalFile(ctx, source, `cmake build ${entry.name}`);
1077
+ const sourceSha256 = sha256Of(fs.readFileSync(source));
1078
+ if (entry.sha256 && entry.sha256 !== sourceSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.name} built to ${sourceSha256}, expected ${entry.sha256}`, { expected: entry.sha256, actual: sourceSha256 });
1079
+ const copied = copyInto(ctx, source, dest, { executable: true });
1080
+ if (entry.smoke && canRunTarget(ctx, target)) {
1081
+ const smoked = run(dest, entry.smoke, { cwd: appRoot, env });
1082
+ if (smoked?.error || smoked?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `${entry.name} does not run (${entry.smoke.join(" ")}): ${smoked?.error?.message ?? `exit ${smoked?.status}`}`, { name: entry.name });
1083
+ }
1084
+ return [{ kind: "cmake", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256, repo: entry.repo, commit: entry.commit, cmakeTarget: entry.target, flags: configure.filter((arg) => arg.startsWith("-D")), externalBin: !entry.dest }];
1085
+ }
1086
+
1087
+ /** `{platform}` in a prebuilt manifest entry: macos-aarch64, macos-x86_64, windows-x86_64, linux-x86_64, ... */
1088
+ const prebuiltPlatformKey = (t) => `${t.os}-${t.arch}`;
1089
+
1090
+ /**
1091
+ * Verify a host-built binary (already staged at <stageDir>/<name>-<triple>, or copied from `from`) against the digest in an app
1092
+ * manifest file: its raw sha256, and when the manifest names an approval hash, the signing-independent approval digest
1093
+ * (`macho-codesign-strip-linkedit-v1` on macOS, `raw-sha256` elsewhere). Nothing is built or downloaded: a missing file or
1094
+ * a mismatch fails closed.
1095
+ */
1096
+ async function stagePrebuilt(entry, ctx) {
1097
+ const { appRoot, fs, target } = ctx;
1098
+ const dest = destFor(entry, ctx);
1099
+ const platformKey = prebuiltPlatformKey(target);
1100
+ const manifestFile = path.resolve(appRoot, entry.manifest.file);
1101
+ let manifest;
1102
+ try { manifest = JSON.parse(String(fs.readFileSync(manifestFile))); } catch (error) { fail("STAGE_SIDECAR_CATALOG_UNREADABLE", `cannot read the prebuilt manifest ${manifestFile} as JSON`, { cause: error }); }
1103
+ let node = manifest;
1104
+ for (const part of entry.manifest.entry.replaceAll("{platform}", platformKey).split(".")) node = isObject(node) ? node[part] : undefined;
1105
+ if (!isObject(node)) fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} has no ${entry.manifest.entry.replaceAll("{platform}", platformKey)} entry for ${entry.name} on ${platformKey}`);
1106
+ const pinned = node[entry.manifest.sha256];
1107
+ if (typeof pinned !== "string" || !SHA256.test(pinned)) fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} ${platformKey}.${entry.manifest.sha256} is not a lowercase 64-hex SHA-256`);
1108
+ const source = entry.from ? path.resolve(appRoot, entry.from) : dest;
1109
+ if (!fs.existsSync(source) || !fs.statSync(source).isFile()) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `prebuilt ${entry.name} for ${platformKey} is missing: ${source}. Build it on its native host first; packaging never substitutes a stock binary.`, { name: entry.name, source });
1110
+ const actual = sha256Of(fs.readFileSync(source));
1111
+ if (actual !== pinned) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.name} integrity failure: ${source} is ${actual}, ${entry.manifest.file} pins ${pinned}`, { expected: pinned, actual });
1112
+ let approval;
1113
+ if (entry.manifest.approvalSha256) {
1114
+ const expected = node[entry.manifest.approvalSha256];
1115
+ if (typeof expected !== "string" || !SHA256.test(expected)) fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} ${platformKey}.${entry.manifest.approvalSha256} is not a lowercase 64-hex SHA-256`);
1116
+ const algorithm = entry.manifest.approvalAlgorithm ? node[entry.manifest.approvalAlgorithm] : (target.os === "macos" ? APPROVAL_MACHO_STRIP_LINKEDIT_V1 : APPROVAL_RAW_SHA256);
1117
+ if (typeof algorithm !== "string") fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} ${platformKey}.${entry.manifest.approvalAlgorithm} does not name an approval hash algorithm`);
1118
+ let digest;
1119
+ try { digest = await ctx.approvalDigest(source, algorithm); } catch (error) { fail("STAGE_SIDECAR_APPROVAL_FAILED", `${entry.name} approval hash (${algorithm}): ${error.message}`, { cause: error, causeCode: error.code }); }
1120
+ if (digest !== expected) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.name} signing-independent integrity failure: ${algorithm} is ${digest}, ${entry.manifest.file} pins ${expected}`, { expected, actual: digest });
1121
+ approval = { algorithm, sha256: digest };
1122
+ }
1123
+ let copied;
1124
+ if (path.resolve(source) === path.resolve(dest)) {
1125
+ if (!ctx.windows) fs.chmodSync(dest, 0o755);
1126
+ copied = { sha256: actual, sizeBytes: fs.statSync(dest).size, changed: false };
1127
+ } else copied = copyInto(ctx, source, dest, { executable: true });
1128
+ return [{ kind: "prebuilt", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256: actual, manifest: entry.manifest.file, platform: platformKey, ...(approval ? { approvalAlgorithm: approval.algorithm, approvalSha256: approval.sha256 } : {}), externalBin: !entry.dest }];
1129
+ }
1130
+
1131
+ /**
1132
+ * `wipeStageDir`: remove top-level files in stageDir named exactly `<name>-<current triple>[.exe]` for a name this manifest
1133
+ * declares (a cargo-bin, command or ffmpeg tool) that this run did not produce, such as an entry now skipped on this platform
1134
+ * or now placed at `dest`. Never recursive; other triples, notices, directories, temporaries and unrelated files are left alone.
1135
+ */
1136
+ function wipeStaleStageDir(ctx, plan, produced) {
1137
+ const dir = path.resolve(ctx.appRoot, ctx.stageDir);
1138
+ let listing;
1139
+ try { listing = [...ctx.fs.readdirSync(dir)]; } catch { return []; }
1140
+ const keep = new Set([...produced, path.resolve(ctx.appRoot, plan.receiptPath)].map((file) => path.resolve(file).toLowerCase()));
1141
+ const names = plan.entries.flatMap((entry) => (entry.kind === "ffmpeg" ? (entry.media?.tools ?? []) : entry.kind === "cargo-bin" || entry.kind === "command" || entry.kind === "cmake" || entry.kind === "prebuilt" ? [entry.name] : []));
1142
+ const exact = new Set(names.flatMap((name) => [`${name}-${ctx.target.triple}`, `${name}-${ctx.target.triple}.exe`]));
1143
+ const removed = [];
1144
+ for (const name of listing.sort()) {
1145
+ const file = path.join(dir, name);
1146
+ if (keep.has(file.toLowerCase()) || !exact.has(name)) continue;
1147
+ let regular = false;
1148
+ try { regular = ctx.fs.statSync(file).isFile(); } catch { regular = false; }
1149
+ if (!regular) continue;
1150
+ ctx.log(`stage-sidecar: wipe stale ${rel(ctx, file)}`);
1151
+ ctx.fs.rmSync(file, { recursive: false, force: true });
1152
+ removed.push(rel(ctx, file));
1153
+ }
1154
+ return removed;
1155
+ }
1156
+
1157
+ /**
1158
+ * Nested broker context: Tauri exports TAURI_ENV_* to beforeBuildCommand/beforeDevCommand, and on a managed host that
1159
+ * command runs inside the outer broker build. A broker call from there exits 125. There is no lease or run-id a nested
1160
+ * call can reuse (acquireManagedRunLease records a per-process lease file; no RK_* variable carries it to a child), so
1161
+ * the only safe behaviour is to refuse early. RIGHTKIT_STAGE_SIDECAR_ALLOW_NESTED=1 opts out.
1162
+ */
1163
+ export function isNestedBrokerContext(env = process.env) {
1164
+ if (env?.RIGHTKIT_STAGE_SIDECAR_ALLOW_NESTED === "1") return false;
1165
+ const insideTauri = Boolean(env?.TAURI_ENV_PLATFORM || env?.TAURI_ENV_TARGET_TRIPLE);
1166
+ return insideTauri && (managedHost(env ?? {}) || env?.RIGHTSUITE_HEAVY_WORK_OWNER === "1");
1167
+ }
1168
+
1169
+ /** True when staging would call `rightkit cargo` for a non-skipped entry. */
1170
+ const needsBroker = (plan) => plan.entries.some((entry) => !entry.skipped && (entry.kind === "cargo-bin" || entry.catalogSpec?.via === "crate"));
1171
+
1172
+ function assertNotNested(plan, env) {
1173
+ if (needsBroker(plan) && isNestedBrokerContext(env)) {
1174
+ fail(NESTED_BROKER_CODE, "stage-sidecar needs `rightkit cargo`, but it is running inside an outer broker build (a Tauri beforeBuildCommand/beforeDevCommand on a managed host), where a nested broker call exits 125. Run stage-sidecar in the recipe `prepare` step, before the Tauri build (or call it from your own script before `tauri build`), not from beforeBuildCommand.");
1175
+ }
1176
+ }
1177
+
1178
+ /**
1179
+ * Dry plan for `stage-sidecar --check`: validates the manifest and describes what staging would write. No effects.
1180
+ */
1181
+ export function planSidecarStaging({ manifest, target, env = process.env } = {}) {
1182
+ const plan = validateSidecarManifest(manifest, { target, mode: resolveBuildMode(env) });
1183
+ const t = plan.target;
1184
+ const outputsOf = (entry) => {
1185
+ if (["cargo-bin", "command", "cmake", "prebuilt"].includes(entry.kind)) return [entry.dest ?? `${plan.stageDir}/${stagedName(entry.name, t)}`];
1186
+ if (entry.kind === "remote") return [entry.dest];
1187
+ if (entry.kind === "resource") return [entry.to];
1188
+ if (entry.kind === "onnxruntime") return [`${entry.resourceDir}/${entry.as ?? "<catalog artifactFileName>"}`];
1189
+ return (entry.media?.tools ?? []).map((tool) => `${plan.stageDir}/${stagedName(tool, t)}`);
1190
+ };
1191
+ const entries = plan.entries.map((entry) => ({
1192
+ index: entry.index, kind: entry.kind, skipped: entry.skipped, ...(entry.platforms ? { platforms: entry.platforms } : {}),
1193
+ ...(entry.skipped ? {} : { outputs: outputsOf(entry), externalBin: ["cargo-bin", "command", "cmake", "prebuilt"].includes(entry.kind) ? !entry.dest : entry.kind === "ffmpeg" }),
1194
+ ...(entry.kind === "cargo-bin" ? { package: entry.package, bin: entry.bin, profile: entry.profile } : {}),
1195
+ ...(entry.kind === "remote" ? { url: entry.url } : {}),
1196
+ ...(entry.kind === "cmake" ? { repo: entry.repo, commit: entry.commit } : {}),
1197
+ ...(entry.clean ? { clean: true } : {}),
1198
+ }));
1199
+ return { target: t.triple, platform: t.platform, mode: resolveBuildMode(env), stageDir: plan.stageDir, receiptPath: plan.receiptPath, wipeStageDir: plan.wipeStageDir, nestedBrokerRisk: needsBroker(plan) && isNestedBrokerContext(env), entries };
1200
+ }
1201
+
360
1202
  /**
361
1203
  * Stage every configured sidecar for `target` (a Rust triple) and write the staging receipt.
362
1204
  * Returns { receipt, receiptPath, files, externalBinNames }. `deps` (all optional) injects every effect:
363
- * fs, run, fetchBytes, extractMember, loadMediaCatalog, loadNativeCatalog, makeTempDir, removeDir, now, pid, log, env.
1205
+ * 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, env.
364
1206
  */
365
1207
  export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {}) {
366
1208
  if (typeof appRoot !== "string" || !path.isAbsolute(appRoot)) fail("STAGE_SIDECAR_CONFIG_INVALID", "appRoot must be an absolute path");
367
- const plan = validateSidecarManifest(manifest, { target });
368
- const fs = deps.fs ?? nodeFs;
369
1209
  const env = deps.env ?? process.env;
1210
+ const plan = validateSidecarManifest(manifest, { target, mode: resolveBuildMode(env) });
1211
+ assertNotNested(plan, env);
1212
+ const fs = deps.fs ?? nodeFs;
370
1213
  const run = deps.run ?? defaultRun;
371
1214
  const ctx = {
372
- appRoot, target: plan.target, stageDir: plan.stageDir, fs, env, run,
1215
+ appRoot, target: plan.target, stageDir: plan.stageDir, fs, env, run, protectedPaths: protectedOutputPaths(plan),
373
1216
  windows: plan.target.os === "windows",
374
1217
  pid: deps.pid ?? process.pid,
1218
+ host: deps.host ?? { os: { darwin: "macos", win32: "windows", linux: "linux" }[process.platform] ?? process.platform, arch: { arm64: "aarch64", x64: "x86_64" }[process.arch] ?? process.arch },
1219
+ approvalDigest: deps.approvalDigest ?? ((file, algorithm) => approvalDigest(file, algorithm)),
375
1220
  log: deps.log ?? (() => {}),
376
1221
  fetchBytes: deps.fetchBytes ?? defaultFetchBytes,
1222
+ fetch: deps.fetch ?? ((...args) => globalThis.fetch(...args)),
377
1223
  extractMember: deps.extractMember ?? ((args) => defaultExtractMember({ ...args, run })),
378
1224
  loadMediaCatalog: deps.loadMediaCatalog ?? (() => undefined),
379
1225
  loadNativeCatalog: deps.loadNativeCatalog ?? (() => undefined),
@@ -387,9 +1233,14 @@ export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {
387
1233
  const staged = entry.kind === "cargo-bin" ? stageCargoBin(entry, ctx)
388
1234
  : entry.kind === "ffmpeg" ? await stageFfmpeg(entry, ctx)
389
1235
  : entry.kind === "onnxruntime" ? await stageOnnxRuntime(entry, ctx)
390
- : stageResource(entry, ctx);
1236
+ : entry.kind === "command" ? stageCommand(entry, ctx)
1237
+ : entry.kind === "remote" ? await stageRemote(entry, ctx)
1238
+ : entry.kind === "cmake" ? stageCmake(entry, ctx)
1239
+ : entry.kind === "prebuilt" ? await stagePrebuilt(entry, ctx)
1240
+ : stageResource(entry, ctx);
391
1241
  files.push(...staged);
392
1242
  }
1243
+ const wiped = plan.wipeStageDir ? wipeStaleStageDir(ctx, plan, files.map((file) => path.resolve(appRoot, file.path))) : [];
393
1244
  const receipt = {
394
1245
  schema: 1,
395
1246
  contract: STAGE_SIDECAR_CONTRACT,
@@ -399,6 +1250,7 @@ export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {
399
1250
  stagedAt: (deps.now ?? (() => new Date().toISOString()))(),
400
1251
  files,
401
1252
  skipped,
1253
+ ...(plan.wipeStageDir ? { wiped } : {}),
402
1254
  };
403
1255
  validateStageSidecarReceipt(receipt, { target: plan.target.triple });
404
1256
  const receiptFile = path.resolve(appRoot, plan.receiptPath);
@@ -415,7 +1267,7 @@ export function validateStageSidecarReceipt(receipt, expected = {}) {
415
1267
  for (const file of receipt.files) {
416
1268
  if (typeof file.name !== "string" || !file.name || typeof file.path !== "string" || !file.path) bad("file without a name or path");
417
1269
  if (!SHA256.test(file.sha256 ?? "")) bad(`${file.name}.sha256 must be a SHA-256`);
418
- if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes <= 0) bad(`${file.name}.sizeBytes must be positive`);
1270
+ if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes < (file.kind === "resource" ? 0 : 1)) bad(`${file.name}.sizeBytes must be positive`);
419
1271
  }
420
1272
  if (new Set(receipt.files.map((file) => file.path)).size !== receipt.files.length) bad("duplicate staged path");
421
1273
  if (expected.target && receipt.target !== expected.target) bad(`target ${receipt.target} is not ${expected.target}`);