@rightkit/release 0.2.174 → 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.
package/stage-sidecar.mjs CHANGED
@@ -10,7 +10,9 @@
10
10
  // { kind: "onnxruntime", version, catalog, resourceDir?: "src-tauri/resources/runtime", as?, licenseDir?, cacheDir? },
11
11
  // { kind: "resource", from, to, sha256?, machO?, clean?, platforms? }, // `from` may be a directory (copied recursively, every file hashed)
12
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?, 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? },
14
16
  // ]
15
17
  //
16
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
@@ -20,7 +22,9 @@
20
22
  // `catalog` is a relative path, an absolute path, { crate: "rightkit-ffmpeg", file? } (resolved through `rightkit cargo
21
23
  // metadata`) or { npm: "<pkg>/<file>" }. The wrapper form { sidecars, profiles: { dev, release } } sets the default cargo
22
24
  // profile pair; RIGHTKIT_BUILD_MODE (dev|release, default release) selects. The target `universal-apple-darwin` builds
23
- // both slices of a cargo-bin and lipo-creates `<name>-universal-apple-darwin`.
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".
24
28
  //
25
29
  // It never signs (release.mjs signs staged files) and never calls bare cargo. Every effect goes through `deps`.
26
30
  import { createHash } from "node:crypto";
@@ -30,8 +34,9 @@ import path from "node:path";
30
34
  import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
31
35
  import { resolveCommand } from "./development.mjs";
32
36
  import { managedHost } from "./managed-cargo.mjs";
37
+ import { APPROVAL_MACHO_STRIP_LINKEDIT_V1, APPROVAL_RAW_SHA256, approvalDigest } from "./macho-approval-hash.mjs";
33
38
  import {
34
- planMediaSidecar, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
39
+ planMediaSidecar, sourceOfferText, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
35
40
  } from "./media-sidecar.mjs";
36
41
 
37
42
  export const STAGE_SIDECAR_CONTRACT = "rightkit-stage-sidecar-v1";
@@ -52,9 +57,13 @@ export class StageSidecarError extends Error {
52
57
  const fail = (code, message, details) => { throw new StageSidecarError(code, message, details); };
53
58
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
54
59
  const SHA256 = /^[0-9a-f]{64}$/;
55
- const KINDS = new Set(["cargo-bin", "ffmpeg", "onnxruntime", "resource", "command", "remote"]);
60
+ const KINDS = new Set(["cargo-bin", "ffmpeg", "onnxruntime", "resource", "command", "remote", "cmake", "prebuilt"]);
56
61
  export const NESTED_BROKER_CODE = "STAGE_SIDECAR_NESTED_BROKER";
57
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";
58
67
  const UNIVERSAL = "universal-apple-darwin";
59
68
  const SLICES = Object.freeze(["aarch64-apple-darwin", "x86_64-apple-darwin"]);
60
69
  const MODES = new Set(["dev", "release"]);
@@ -63,11 +72,13 @@ const PLATFORMS = new Set(["mac", "win", "linux"]);
63
72
  const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
64
73
  const KEYS = Object.freeze({
65
74
  "cargo-bin": ["kind", "package", "bin", "name", "profile", "features", "manifestPath", "sha256", "dest", "clean", "platforms"],
66
- ffmpeg: ["kind", "variant", "tools", "notices", "catalog", "stage", "bundleDir", "platforms"],
75
+ ffmpeg: ["kind", "variant", "variants", "tools", "notices", "catalog", "stage", "bundleDir", "sources", "probe", "platforms"],
67
76
  onnxruntime: ["kind", "version", "catalog", "resourceDir", "as", "licenseDir", "cacheDir", "platforms"],
68
77
  resource: ["kind", "from", "to", "sha256", "machO", "clean", "platforms"],
69
78
  command: ["kind", "name", "cmd", "args", "cwd", "output", "externalBin", "dest", "clean", "platforms"],
70
- remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "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"],
71
82
  });
72
83
  const TRIPLES = Object.freeze({
73
84
  "aarch64-apple-darwin": { platform: "mac", os: "macos", arch: "aarch64" },
@@ -179,6 +190,57 @@ function parseCatalogSpec(value, label, errors, kind) {
179
190
  return undefined;
180
191
  }
181
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
+
182
244
  /**
183
245
  * Validate and normalise the manifest. `raw` is the `sidecars` array, or { sidecars, stageDir?, receiptPath?, profiles? }.
184
246
  * `mode` (dev | release, default release) selects the profile of a { dev, release } pair.
@@ -247,17 +309,33 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
247
309
  if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
248
310
  Object.assign(entry, { package: item.package, bin, name, profile, features: [...features], ...(manifestPath ? { manifestPath } : {}), ...(item.sha256 ? { sha256: item.sha256 } : {}), ...(dest ? { dest } : {}), ...(clean ? { clean } : {}) });
249
311
  } else if (item.kind === "ffmpeg") {
250
- const { kind: _kind, platforms: _platforms, catalog: rawCatalog, ...rest } = item;
312
+ const { kind: _kind, platforms: _platforms, catalog: rawCatalog, sources: rawSources, probe: rawProbe, ...rest } = item;
251
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", ...] }`);
252
314
  const catalogSpec = parseCatalogSpec(rawCatalog, `${where}: catalog`, errors, "ffmpeg");
253
315
  if (catalogSpec) entry.catalogSpec = catalogSpec;
254
- if (!skipped && t.arch === "universal") errors.push(`${where}: the catalog has no universal row; stage a lipo-merged file with a "command" or "resource" entry (machO: true) instead`);
255
- else if (!skipped) {
256
- try {
257
- const config = validateMediaSidecarConfig({ ...rest, arch: t.arch, stageDir }, { platform: t.platform, hostArch: t.arch });
258
- for (const tool of config.tools) claim(`${stageDir}/${stagedFileName(tool, t.os, t.arch)}`, where);
259
- entry.media = config;
260
- } 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}`])); }
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;
320
+ if (!skipped) {
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
+ }
261
339
  }
262
340
  } else if (item.kind === "onnxruntime") {
263
341
  if (typeof item.version !== "string" || !/^\d+\.\d+\.\d+$/.test(item.version)) errors.push(`${where}: version must be x.y.z`);
@@ -266,7 +344,6 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
266
344
  const cacheDir = relativePath(item.cacheDir ?? ".cache/onnxruntime", `${where}: cacheDir`, errors);
267
345
  const catalogSpec = parseCatalogSpec(item.catalog, `${where}: catalog`, errors, "onnxruntime");
268
346
  if (item.as !== undefined && (typeof item.as !== "string" || !NAME.test(item.as))) errors.push(`${where}: as must be a bare file name`);
269
- if (!skipped && t.arch === "universal") errors.push(`${where}: the catalog has no universal row; stage a lipo-merged file with a "command" or "resource" entry (machO: true) instead`);
270
347
  Object.assign(entry, { version: item.version, resourceDir, licenseDir, cacheDir, ...(catalogSpec ? { catalogSpec } : {}), ...(item.as ? { as: item.as } : {}) });
271
348
  } else if (item.kind === "command") {
272
349
  const name = item.name;
@@ -291,9 +368,51 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
291
368
  if (item.dest === undefined && item.to === undefined) errors.push(`${where}: dest (or to) is required`);
292
369
  const dest = destOf(item.dest ?? item.to, item.dest === undefined ? "to" : "dest");
293
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`);
294
374
  const clean = cleanOf();
295
375
  if (dest && !skipped) claim(dest, where);
296
- Object.assign(entry, { name, url: item.url, sha256: item.sha256, dest, ...(item.size ? { size: item.size } : {}), ...(clean ? { clean } : {}) });
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 } : {}) });
297
416
  } else {
298
417
  const from = relativePath(item.from, `${where}: from`, errors);
299
418
  const to = relativePath(item.to, `${where}: to`, errors);
@@ -465,6 +584,37 @@ function requireUniversalFile(ctx, file, label) {
465
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 });
466
585
  }
467
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
+
468
618
  const destFor = (entry, ctx) => (entry.dest ? path.resolve(ctx.appRoot, entry.dest) : path.resolve(ctx.appRoot, ctx.stageDir, stagedName(entry.name, ctx.target)));
469
619
 
470
620
  function stageCargoBin(entry, ctx) {
@@ -522,8 +672,121 @@ function stageCargoBuild(entry, ctx, triple) {
522
672
  return { source, sourceSha256 };
523
673
  }
524
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
+ });
786
+ }
787
+
525
788
  async function stageFfmpeg(entry, ctx) {
526
- const { appRoot, run, env, fs, target } = ctx;
789
+ const { appRoot, fs, target } = ctx;
527
790
  const config = entry.media;
528
791
  try {
529
792
  let resolved = await ctx.loadMediaCatalog({ config, appRoot, entry });
@@ -531,31 +794,22 @@ async function stageFfmpeg(entry, ctx) {
531
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");
532
795
  resolved = readJsonCatalog(resolveCatalogFile(entry.catalogSpec, ctx, "ffmpeg"), appRoot, fs, "rightkit-ffmpeg");
533
796
  }
534
- const plan = planMediaSidecar({ config, catalog: resolved.catalog, crateDir: resolved.crateDir, crateVersion: resolved.crateVersion, catalogBytes: resolved.catalogBytes, root: appRoot });
535
- if (config.stage) {
536
- ctx.log(`stage-sidecar: ${config.stage.cmd} ${(config.stage.args ?? []).join(" ")}`);
537
- const staged = run(config.stage.cmd, config.stage.args ?? [], { cwd: appRoot, env });
538
- if (staged?.error || staged?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `ffmpeg stage command failed (${staged?.error?.message ?? `exit ${staged?.status}`})`);
539
- }
540
- const noticeFiles = writeMediaSidecarNotices(plan, { exists: fs.existsSync, mkdir: fs.mkdirSync, copy: fs.copyFileSync, write: fs.writeFileSync });
541
- const verified = verifyStagedSidecars(plan, { exists: fs.existsSync, stat: (file) => fs.statSync(file), hashFile: (file) => sha256Of(fs.readFileSync(file)) });
542
- const notices = noticeFiles.map((file) => ({ name: path.basename(file), path: rel(ctx, file), sha256: sha256Of(fs.readFileSync(file)) }));
543
- return verified.map((file) => {
544
- 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 });
545
- 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 };
546
- });
797
+ if (target.arch === "universal") return await stageFfmpegUniversal(entry, ctx, resolved);
798
+ return (await stageFfmpegSlice(entry, ctx, resolved, { config, t: target, writeNotices: true })).rows;
547
799
  } catch (error) {
548
800
  if (error instanceof StageSidecarError) throw error;
549
801
  return fail("STAGE_SIDECAR_FFMPEG_FAILED", `ffmpeg sidecar (${target.triple}): ${error.message}`, { cause: error, causeCode: error.code });
550
802
  }
551
803
  }
552
804
 
553
- async function stageOnnxRuntime(entry, ctx) {
554
- const { appRoot, fs, target } = ctx;
555
- const loaded = await (ctx.loadNativeCatalog({ entry, appRoot }) ?? (entry.catalogSpec ? readJsonCatalog(resolveCatalogFile(entry.catalogSpec, ctx, "onnxruntime"), appRoot, fs, "native-libraries") : undefined));
556
- if (!loaded) fail("STAGE_SIDECAR_CATALOG_UNREADABLE", "onnxruntime needs a catalog path (the rightkit-ffmpeg native-libraries.json) or deps.loadNativeCatalog");
557
- const rows = (loaded.catalog?.libraries ?? []).filter((row) => row.library === "onnxruntime" && row.version === entry.version && row.os === target.os && row.arch === target.arch);
558
- 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}`;
559
813
  if (rows.length === 0) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has no ${where} row`);
560
814
  if (rows.length > 1) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has ${rows.length} ${where} rows`);
561
815
  const [row] = rows;
@@ -572,7 +826,7 @@ async function stageOnnxRuntime(entry, ctx) {
572
826
  for (const { source } of notices) if (!fs.existsSync(source)) fail("STAGE_SIDECAR_NOTICE_MISSING", `notice file not found: ${source}`);
573
827
 
574
828
  const outName = entry.as ?? row.artifactFileName;
575
- const dest = path.resolve(appRoot, entry.resourceDir, outName);
829
+ const dest = path.resolve(appRoot, destDir ?? entry.resourceDir, outName);
576
830
  if (fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === row.sha256) {
577
831
  ctx.log(`stage-sidecar: ${rel(ctx, dest)} already staged`);
578
832
  } else {
@@ -599,7 +853,30 @@ async function stageOnnxRuntime(entry, ctx) {
599
853
  fs.copyFileSync(source, out);
600
854
  return { name: path.basename(out), path: rel(ctx, out), sha256: sha256Of(fs.readFileSync(out)) };
601
855
  });
602
- 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
+ }];
603
880
  }
604
881
 
605
882
  /** Every file under `dir`, sorted, as paths relative to it (forward slashes). */
@@ -664,36 +941,191 @@ function stageCommand(entry, ctx) {
664
941
  }
665
942
 
666
943
  /**
667
- * A pinned https download. The bytes are hashed before anything is moved into place: the content-addressed cache entry
668
- * (`.right-release/cache/remote/<sha256>`) is written only from verified bytes, and `dest` is copied only from that
669
- * cache entry. A mismatch (or a non-https redirect) fails closed with nothing staged.
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.
670
972
  */
671
973
  async function stageRemote(entry, ctx) {
672
974
  const { appRoot, fs } = ctx;
673
975
  const dest = path.resolve(appRoot, entry.dest);
674
- const cached = path.resolve(appRoot, REMOTE_CACHE_DIR, entry.sha256);
675
- let fromCache = false;
676
- if (fs.existsSync(cached) && sha256Of(fs.readFileSync(cached)) === entry.sha256) fromCache = true;
677
- else {
678
- ctx.log(`stage-sidecar: GET ${entry.url}`);
679
- let response;
680
- try { response = await ctx.fetch(entry.url, { redirect: "follow" }); } catch (error) { fail("STAGE_SIDECAR_DOWNLOAD_FAILED", `GET ${entry.url} failed: ${error.message}`, { cause: error, url: entry.url }); }
681
- if (!response?.ok) fail("STAGE_SIDECAR_DOWNLOAD_FAILED", `GET ${entry.url} failed with ${response?.status}`, { url: entry.url });
682
- if (typeof response.url === "string" && response.url && !response.url.startsWith("https://")) fail("STAGE_SIDECAR_DOWNLOAD_FAILED", `${entry.url} redirected to a non-https URL: ${response.url}`, { url: entry.url });
683
- const bytes = Buffer.from(await response.arrayBuffer());
684
- if (entry.size !== undefined && bytes.length !== entry.size) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.url} is ${bytes.length} bytes, expected ${entry.size}; nothing staged`, { expectedSize: entry.size, actualSize: bytes.length });
685
- const actual = sha256Of(bytes);
686
- if (actual !== entry.sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.url} downloaded as ${actual}, expected ${entry.sha256}; nothing staged`, { expected: entry.sha256, actual });
687
- fs.mkdirSync(path.dirname(cached), { recursive: true });
688
- const temporary = `${cached}.tmp-${ctx.pid}`;
689
- fs.writeFileSync(temporary, bytes);
690
- fs.renameSync(temporary, cached);
691
- }
692
- // Download and verify first: a failed fetch or a hash mismatch must never leave a cleaned destination behind.
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);
693
1056
  cleanDest(ctx, entry, dest);
694
- const copied = copyInto(ctx, cached, dest, { executable: false });
695
- if (copied.sha256 !== entry.sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `staged ${entry.dest} is ${copied.sha256}, expected ${entry.sha256}`, { expected: entry.sha256, actual: copied.sha256 });
696
- return [{ kind: "remote", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256: entry.sha256, url: entry.url, cached: fromCache, externalBin: false }];
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 }];
697
1129
  }
698
1130
 
699
1131
  /**
@@ -706,7 +1138,7 @@ function wipeStaleStageDir(ctx, plan, produced) {
706
1138
  let listing;
707
1139
  try { listing = [...ctx.fs.readdirSync(dir)]; } catch { return []; }
708
1140
  const keep = new Set([...produced, path.resolve(ctx.appRoot, plan.receiptPath)].map((file) => path.resolve(file).toLowerCase()));
709
- const names = plan.entries.flatMap((entry) => (entry.kind === "ffmpeg" ? (entry.media?.tools ?? []) : entry.kind === "cargo-bin" || entry.kind === "command" ? [entry.name] : []));
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] : []));
710
1142
  const exact = new Set(names.flatMap((name) => [`${name}-${ctx.target.triple}`, `${name}-${ctx.target.triple}.exe`]));
711
1143
  const removed = [];
712
1144
  for (const name of listing.sort()) {
@@ -750,17 +1182,18 @@ export function planSidecarStaging({ manifest, target, env = process.env } = {})
750
1182
  const plan = validateSidecarManifest(manifest, { target, mode: resolveBuildMode(env) });
751
1183
  const t = plan.target;
752
1184
  const outputsOf = (entry) => {
753
- if (entry.kind === "cargo-bin" || entry.kind === "command") return [entry.dest ?? `${plan.stageDir}/${stagedName(entry.name, t)}`];
1185
+ if (["cargo-bin", "command", "cmake", "prebuilt"].includes(entry.kind)) return [entry.dest ?? `${plan.stageDir}/${stagedName(entry.name, t)}`];
754
1186
  if (entry.kind === "remote") return [entry.dest];
755
1187
  if (entry.kind === "resource") return [entry.to];
756
1188
  if (entry.kind === "onnxruntime") return [`${entry.resourceDir}/${entry.as ?? "<catalog artifactFileName>"}`];
757
- return (entry.media?.tools ?? []).map((tool) => `${plan.stageDir}/${stagedFileName(tool, t.os, t.arch)}`);
1189
+ return (entry.media?.tools ?? []).map((tool) => `${plan.stageDir}/${stagedName(tool, t)}`);
758
1190
  };
759
1191
  const entries = plan.entries.map((entry) => ({
760
1192
  index: entry.index, kind: entry.kind, skipped: entry.skipped, ...(entry.platforms ? { platforms: entry.platforms } : {}),
761
- ...(entry.skipped ? {} : { outputs: outputsOf(entry), externalBin: (entry.kind === "cargo-bin" || entry.kind === "command") ? !entry.dest : entry.kind === "ffmpeg" }),
1193
+ ...(entry.skipped ? {} : { outputs: outputsOf(entry), externalBin: ["cargo-bin", "command", "cmake", "prebuilt"].includes(entry.kind) ? !entry.dest : entry.kind === "ffmpeg" }),
762
1194
  ...(entry.kind === "cargo-bin" ? { package: entry.package, bin: entry.bin, profile: entry.profile } : {}),
763
1195
  ...(entry.kind === "remote" ? { url: entry.url } : {}),
1196
+ ...(entry.kind === "cmake" ? { repo: entry.repo, commit: entry.commit } : {}),
764
1197
  ...(entry.clean ? { clean: true } : {}),
765
1198
  }));
766
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 };
@@ -782,6 +1215,8 @@ export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {
782
1215
  appRoot, target: plan.target, stageDir: plan.stageDir, fs, env, run, protectedPaths: protectedOutputPaths(plan),
783
1216
  windows: plan.target.os === "windows",
784
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)),
785
1220
  log: deps.log ?? (() => {}),
786
1221
  fetchBytes: deps.fetchBytes ?? defaultFetchBytes,
787
1222
  fetch: deps.fetch ?? ((...args) => globalThis.fetch(...args)),
@@ -800,7 +1235,9 @@ export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {
800
1235
  : entry.kind === "onnxruntime" ? await stageOnnxRuntime(entry, ctx)
801
1236
  : entry.kind === "command" ? stageCommand(entry, ctx)
802
1237
  : entry.kind === "remote" ? await stageRemote(entry, ctx)
803
- : stageResource(entry, ctx);
1238
+ : entry.kind === "cmake" ? stageCmake(entry, ctx)
1239
+ : entry.kind === "prebuilt" ? await stagePrebuilt(entry, ctx)
1240
+ : stageResource(entry, ctx);
804
1241
  files.push(...staged);
805
1242
  }
806
1243
  const wiped = plan.wipeStageDir ? wipeStaleStageDir(ctx, plan, files.map((file) => path.resolve(appRoot, file.path))) : [];