@rightkit/release 0.2.174 → 0.4.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 (82) hide show
  1. package/README.md +200 -2
  2. package/addon-command.mjs +3 -4
  3. package/android-play-upload.mjs +2 -1
  4. package/apple-profile-renew.mjs +11 -3
  5. package/artifact-storage.mjs +35 -2
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +1 -1
  8. package/build-release.mjs +138 -47
  9. package/cli/right-release.mjs +40 -5
  10. package/codesign-environment.mjs +2 -2
  11. package/command-spec.mjs +80 -0
  12. package/content-digest.mjs +209 -0
  13. package/deps-cargo.mjs +55 -0
  14. package/deps.mjs +7 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/dist-scan.mjs +102 -0
  21. package/dmg-build.mjs +53 -4
  22. package/github-updater-release.mjs +70 -7
  23. package/icon-pack.mjs +117 -0
  24. package/in-place-install.mjs +17 -14
  25. package/init-command.mjs +116 -0
  26. package/install-app-windows.mjs +24 -7
  27. package/install-app.mjs +56 -42
  28. package/ios-asc-credentials.mjs +163 -0
  29. package/ios-build-context.mjs +129 -0
  30. package/ios-build-upload.mjs +282 -0
  31. package/ios-credentials-command.mjs +70 -0
  32. package/ios-dev.mjs +248 -0
  33. package/ios-host.mjs +76 -0
  34. package/ios-lock.mjs +75 -0
  35. package/ios-package-recipe.mjs +707 -0
  36. package/ios-testflight.mjs +266 -0
  37. package/ios-upload.mjs +313 -0
  38. package/local-publish.mjs +390 -47
  39. package/macho-approval-hash.mjs +181 -0
  40. package/managed-cargo.mjs +57 -1
  41. package/media-sidecar.mjs +20 -4
  42. package/mirror-root-artifact.mjs +74 -22
  43. package/native-cargo-layout.mjs +47 -4
  44. package/npm-license-notices.mjs +159 -0
  45. package/ownership-command.mjs +2 -1
  46. package/ownership-dependency-allowlist.json +2 -1
  47. package/ownership-evidence.mjs +30 -18
  48. package/ownership-key-command.mjs +69 -2
  49. package/ownership-mandatory-set.mjs +66 -16
  50. package/ownership-repositories.json +1 -1
  51. package/ownership-scope.mjs +94 -0
  52. package/ownership.mjs +86 -11
  53. package/package-recipe-targets.mjs +56 -2
  54. package/package.json +1 -1
  55. package/pe-content.mjs +133 -0
  56. package/preflight.mjs +14 -7
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +83 -5
  61. package/publish-cargo.mjs +124 -18
  62. package/publish-update.mjs +15 -2
  63. package/qa-build-features.mjs +16 -13
  64. package/qa-contract.mjs +30 -2
  65. package/qa-control-scan.mjs +4 -0
  66. package/release-dependencies-command.mjs +45 -0
  67. package/release-dependencies.mjs +60 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +62 -24
  71. package/release.mjs +144 -21
  72. package/rightkit-ownership.json +46 -2
  73. package/rightkit-versions.json +380 -189
  74. package/runtime-budgets.mjs +134 -23
  75. package/sign-updater.mjs +56 -59
  76. package/source-gate.mjs +8 -1
  77. package/stage-sidecar.mjs +634 -69
  78. package/target-distribution.mjs +5 -0
  79. package/test-command.mjs +190 -0
  80. package/toolchain-swift.mjs +239 -0
  81. package/upload-release.mjs +7 -2
  82. package/web-build.mjs +67 -0
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?, platformGenerators?, architecture?, cmakeBin?, smoke?, sha256?, dest?, clean?, platforms? },
15
+ // { kind: "prebuilt", name, from? (a path, or { aarch64, x86_64 } for universal-apple-darwin), 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,11 @@
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". A `prebuilt` entry does it from per-arch host-built inputs (`from: { aarch64, x86_64 }`, or the
28
+ // staged `<name>-<slice triple>` files), each verified against its own manifest entry and then lipo-merged. An `ffmpeg` source may be a
29
+ // local path (`sources.ffmpeg: { path: "../vendor/ffmpeg-lgpl" }`) instead of a pinned https archive.
24
30
  //
25
31
  // It never signs (release.mjs signs staged files) and never calls bare cargo. Every effect goes through `deps`.
26
32
  import { createHash } from "node:crypto";
@@ -30,9 +36,11 @@ import path from "node:path";
30
36
  import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
31
37
  import { resolveCommand } from "./development.mjs";
32
38
  import { managedHost } from "./managed-cargo.mjs";
39
+ import { APPROVAL_MACHO_STRIP_LINKEDIT_V1, APPROVAL_RAW_SHA256, approvalDigest } from "./macho-approval-hash.mjs";
33
40
  import {
34
- planMediaSidecar, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
41
+ planMediaSidecar, sourceOfferText, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
35
42
  } from "./media-sidecar.mjs";
43
+ import { ContentDigestError, contentIdentity, digestFor } from "./content-digest.mjs";
36
44
 
37
45
  export const STAGE_SIDECAR_CONTRACT = "rightkit-stage-sidecar-v1";
38
46
  export const STAGE_SIDECAR_RECEIPT_NAME = "stage-sidecar.json";
@@ -52,9 +60,13 @@ export class StageSidecarError extends Error {
52
60
  const fail = (code, message, details) => { throw new StageSidecarError(code, message, details); };
53
61
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
54
62
  const SHA256 = /^[0-9a-f]{64}$/;
55
- const KINDS = new Set(["cargo-bin", "ffmpeg", "onnxruntime", "resource", "command", "remote"]);
63
+ const KINDS = new Set(["cargo-bin", "ffmpeg", "onnxruntime", "resource", "command", "remote", "cmake", "prebuilt"]);
56
64
  export const NESTED_BROKER_CODE = "STAGE_SIDECAR_NESTED_BROKER";
57
65
  export const REMOTE_CACHE_DIR = ".right-release/cache/remote";
66
+ /** Per-arch staging for a universal ffmpeg or onnxruntime entry: `<SLICE_ROOT>/<triple>/...`, merged with lipo afterwards. */
67
+ export const SLICE_ROOT = ".right-release/slices";
68
+ /** Pinned-commit source checkouts and build trees of `cmake` entries: `<CMAKE_ROOT>/<name>/{src,build}` (fixed paths, in place). */
69
+ export const CMAKE_ROOT = ".right-release/cmake";
58
70
  const UNIVERSAL = "universal-apple-darwin";
59
71
  const SLICES = Object.freeze(["aarch64-apple-darwin", "x86_64-apple-darwin"]);
60
72
  const MODES = new Set(["dev", "release"]);
@@ -63,11 +75,13 @@ const PLATFORMS = new Set(["mac", "win", "linux"]);
63
75
  const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
64
76
  const KEYS = Object.freeze({
65
77
  "cargo-bin": ["kind", "package", "bin", "name", "profile", "features", "manifestPath", "sha256", "dest", "clean", "platforms"],
66
- ffmpeg: ["kind", "variant", "tools", "notices", "catalog", "stage", "bundleDir", "platforms"],
78
+ ffmpeg: ["kind", "variant", "variants", "tools", "notices", "catalog", "stage", "bundleDir", "sources", "probe", "platforms"],
67
79
  onnxruntime: ["kind", "version", "catalog", "resourceDir", "as", "licenseDir", "cacheDir", "platforms"],
68
80
  resource: ["kind", "from", "to", "sha256", "machO", "clean", "platforms"],
69
81
  command: ["kind", "name", "cmd", "args", "cwd", "output", "externalBin", "dest", "clean", "platforms"],
70
- remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "clean", "platforms"],
82
+ remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "extract", "memberSha256", "executable", "clean", "platforms"],
83
+ cmake: ["kind", "name", "repo", "commit", "target", "output", "cmakeArgs", "platformArgs", "generator", "platformGenerators", "architecture", "cmakeBin", "smoke", "sha256", "dest", "clean", "platforms"],
84
+ prebuilt: ["kind", "name", "from", "manifest", "dest", "platforms"],
71
85
  });
72
86
  const TRIPLES = Object.freeze({
73
87
  "aarch64-apple-darwin": { platform: "mac", os: "macos", arch: "aarch64" },
@@ -179,6 +193,76 @@ function parseCatalogSpec(value, label, errors, kind) {
179
193
  return undefined;
180
194
  }
181
195
 
196
+ const CMAKE_GENERATOR = /^[A-Za-z0-9][A-Za-z0-9 .+-]{0,60}$/;
197
+ const CMAKE_ARCHITECTURE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
198
+ const CMAKE_DEFINE = /^-D[A-Za-z0-9_]+(?::[A-Z]+)?=[^\s]*$/;
199
+ const SOURCE_ARCHES = ["aarch64", "x86_64"];
200
+
201
+ /** One pinned https archive: { url, sha256, member, size? }. Problems are pushed to `errors`. */
202
+ function parseArchiveSource(value, label, errors) {
203
+ if (!isObject(value)) { errors.push(`${label} must be { url, sha256, member, size? }`); return undefined; }
204
+ for (const key of Object.keys(value)) if (!["url", "sha256", "member", "size"].includes(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)}`);
205
+ 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)`);
206
+ 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`);
207
+ 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)`);
208
+ if (value.size !== undefined && (!Number.isSafeInteger(value.size) || value.size <= 0)) errors.push(`${label}.size must be a positive integer (bytes)`);
209
+ return { url: value.url, sha256: value.sha256, member: value.member, ...(value.size ? { size: value.size } : {}) };
210
+ }
211
+
212
+ /**
213
+ * A local ffmpeg source: `{ path, member? }`. `path` is relative to the app root and MAY step out of it (a vendored build such as
214
+ * ../vendor/ffmpeg-lgpl); it names the binary itself, or a directory holding `<member>` (or `bin/<member>`). Nothing is downloaded and no
215
+ * archive digest exists: the file's digest must still equal the APPROVED catalog row, exactly like a fetched member.
216
+ */
217
+ function parseLocalSource(value, label, errors) {
218
+ for (const key of Object.keys(value)) if (!["path", "member"].includes(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)} for a local { path, member? } source`);
219
+ const raw = value.path;
220
+ const normalized = typeof raw === "string" ? raw.replaceAll("\\", "/") : "";
221
+ if (typeof raw !== "string" || !raw.trim() || path.isAbsolute(raw) || /^[A-Za-z]:/.test(raw) || /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized)) { errors.push(`${label}.path must be a relative path (it may start with ../); a URL belongs in { url, sha256, member }`); return undefined; }
222
+ if (!normalized.split("/").some((segment) => segment !== "" && segment !== "." && segment !== "..")) { errors.push(`${label}.path must name a file or directory, not the app root or a bare parent: ${raw}`); return undefined; }
223
+ if (value.member !== undefined && (typeof value.member !== "string" || !NAME.test(value.member))) errors.push(`${label}.member must be the bare file name of the binary inside the directory`);
224
+ return { path: normalized.replace(/\/+$/, ""), ...(typeof value.member === "string" ? { member: value.member } : {}) };
225
+ }
226
+
227
+ const parseFfmpegSource = (value, label, errors) => (isObject(value) && "path" in value ? parseLocalSource(value, label, errors) : parseArchiveSource(value, label, errors));
228
+
229
+ /**
230
+ * `ffmpeg.sources`: where the bytes of a catalog row come from (the catalog pins digests, not locations). A tool maps to one
231
+ * source (a pinned https archive, or a local path), or to `{ aarch64: source, x86_64: source }`; a universal target needs the per-arch form for every tool.
232
+ */
233
+ function parseFfmpegSources(raw, where, errors, { universal }) {
234
+ if (raw === undefined) return undefined;
235
+ 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; }
236
+ const out = {};
237
+ for (const [tool, value] of Object.entries(raw)) {
238
+ const label = `${where}: sources.${tool}`;
239
+ if (!["ffmpeg", "ffprobe"].includes(tool)) { errors.push(`${label}: not ffmpeg or ffprobe`); continue; }
240
+ const perArch = isObject(value) && SOURCE_ARCHES.some((arch) => arch in value) && !("url" in value) && !("path" in value);
241
+ if (universal && !perArch) { errors.push(`${label} needs { aarch64, x86_64 } sources for universal-apple-darwin (each slice is a thin file)`); continue; }
242
+ if (perArch) {
243
+ for (const key of Object.keys(value)) if (!SOURCE_ARCHES.includes(key)) errors.push(`${label}: unknown arch ${JSON.stringify(key)}`);
244
+ out[tool] = Object.fromEntries(SOURCE_ARCHES.filter((arch) => arch in value).map((arch) => [arch, parseFfmpegSource(value[arch], `${label}.${arch}`, errors)]));
245
+ } else out[tool] = parseFfmpegSource(value, label, errors);
246
+ }
247
+ return out;
248
+ }
249
+
250
+ /**
251
+ * `prebuilt.manifest`: { file, entry, sha256?, approvalSha256?, approvalAlgorithm? }. `file` is a JSON file inside the app, `entry`
252
+ * a dot path to the object for this target (`{platform}` becomes macos-aarch64, windows-x86_64 and so on), and the three keys name
253
+ * the fields inside it (defaults: sha256 -> "sha256"; approval fields only when named).
254
+ */
255
+ function parsePrebuiltManifest(raw, where, errors) {
256
+ if (!isObject(raw)) { errors.push(`${where}: manifest must be { file, entry, sha256?, approvalSha256?, approvalAlgorithm? }`); return undefined; }
257
+ for (const key of Object.keys(raw)) if (!["file", "entry", "sha256", "approvalSha256", "approvalAlgorithm"].includes(key)) errors.push(`${where}: manifest: unknown key ${JSON.stringify(key)}`);
258
+ const file = relativePath(raw.file, `${where}: manifest.file`, errors);
259
+ 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}`);
260
+ 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`);
261
+ if (raw.approvalAlgorithm !== undefined && raw.approvalSha256 === undefined) errors.push(`${where}: manifest.approvalAlgorithm needs approvalSha256`);
262
+ if (!file) return undefined;
263
+ return { file, entry: raw.entry, sha256: raw.sha256 ?? "sha256", ...(raw.approvalSha256 ? { approvalSha256: raw.approvalSha256 } : {}), ...(raw.approvalAlgorithm ? { approvalAlgorithm: raw.approvalAlgorithm } : {}) };
264
+ }
265
+
182
266
  /**
183
267
  * Validate and normalise the manifest. `raw` is the `sidecars` array, or { sidecars, stageDir?, receiptPath?, profiles? }.
184
268
  * `mode` (dev | release, default release) selects the profile of a { dev, release } pair.
@@ -247,17 +331,33 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
247
331
  if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
248
332
  Object.assign(entry, { package: item.package, bin, name, profile, features: [...features], ...(manifestPath ? { manifestPath } : {}), ...(item.sha256 ? { sha256: item.sha256 } : {}), ...(dest ? { dest } : {}), ...(clean ? { clean } : {}) });
249
333
  } else if (item.kind === "ffmpeg") {
250
- const { kind: _kind, platforms: _platforms, catalog: rawCatalog, ...rest } = item;
334
+ const { kind: _kind, platforms: _platforms, catalog: rawCatalog, sources: rawSources, probe: rawProbe, ...rest } = item;
251
335
  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
336
  const catalogSpec = parseCatalogSpec(rawCatalog, `${where}: catalog`, errors, "ffmpeg");
253
337
  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}`])); }
338
+ if (rawProbe !== undefined && typeof rawProbe !== "boolean") errors.push(`${where}: probe must be a boolean`);
339
+ if (rawProbe === true) entry.probe = true;
340
+ const sources = parseFfmpegSources(rawSources, where, errors, { universal: t.arch === "universal" });
341
+ if (sources) entry.sources = sources;
342
+ if (!skipped) {
343
+ 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}`]));
344
+ if (t.arch === "universal") {
345
+ // One staging pass per arch slice (under SLICE_ROOT), then a lipo merge into <stageDir>/<tool>-universal-apple-darwin.
346
+ try {
347
+ entry.slices = SLICES.map((triple) => {
348
+ const slice = parseTargetTriple(triple);
349
+ return { triple, arch: slice.arch, media: validateMediaSidecarConfig({ ...rest, arch: slice.arch, stageDir: `${SLICE_ROOT}/${triple}` }, { platform: t.platform, hostArch: slice.arch }) };
350
+ });
351
+ entry.media = entry.slices[0].media;
352
+ for (const tool of entry.media.tools) claim(stagedClaim(tool), where);
353
+ } catch (error) { mediaErrors(error); }
354
+ } else {
355
+ try {
356
+ const config = validateMediaSidecarConfig({ ...rest, arch: t.arch, stageDir }, { platform: t.platform, hostArch: t.arch });
357
+ for (const tool of config.tools) claim(`${stageDir}/${stagedFileName(tool, t.os, t.arch)}`, where);
358
+ entry.media = config;
359
+ } catch (error) { mediaErrors(error); }
360
+ }
261
361
  }
262
362
  } else if (item.kind === "onnxruntime") {
263
363
  if (typeof item.version !== "string" || !/^\d+\.\d+\.\d+$/.test(item.version)) errors.push(`${where}: version must be x.y.z`);
@@ -266,7 +366,6 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
266
366
  const cacheDir = relativePath(item.cacheDir ?? ".cache/onnxruntime", `${where}: cacheDir`, errors);
267
367
  const catalogSpec = parseCatalogSpec(item.catalog, `${where}: catalog`, errors, "onnxruntime");
268
368
  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
369
  Object.assign(entry, { version: item.version, resourceDir, licenseDir, cacheDir, ...(catalogSpec ? { catalogSpec } : {}), ...(item.as ? { as: item.as } : {}) });
271
370
  } else if (item.kind === "command") {
272
371
  const name = item.name;
@@ -291,9 +390,72 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
291
390
  if (item.dest === undefined && item.to === undefined) errors.push(`${where}: dest (or to) is required`);
292
391
  const dest = destOf(item.dest ?? item.to, item.dest === undefined ? "to" : "dest");
293
392
  if (item.size !== undefined && (!Number.isSafeInteger(item.size) || item.size <= 0)) errors.push(`${where}: size must be a positive integer (bytes)`);
393
+ 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)`);
394
+ 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`);
395
+ if (item.executable !== undefined && typeof item.executable !== "boolean") errors.push(`${where}: executable must be a boolean`);
294
396
  const clean = cleanOf();
295
397
  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 } : {}) });
398
+ 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 } : {}) });
399
+ } else if (item.kind === "cmake") {
400
+ const name = item.name;
401
+ 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)`);
402
+ 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)`);
403
+ 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)`);
404
+ const target = item.target ?? name;
405
+ if (typeof target !== "string" || !NAME.test(target)) errors.push(`${where}: target must be a build target name`);
406
+ let output;
407
+ if (item.output !== undefined) output = relativePath(item.output, `${where}: output`, errors);
408
+ const flagList = (value, label) => {
409
+ if (value === undefined) return [];
410
+ 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 []; }
411
+ return [...value];
412
+ };
413
+ const cmakeArgs = flagList(item.cmakeArgs, "cmakeArgs");
414
+ const platformArgs = {};
415
+ if (item.platformArgs !== undefined) {
416
+ if (!isObject(item.platformArgs)) errors.push(`${where}: platformArgs must be an object keyed by mac, win or linux`);
417
+ else for (const [key, list] of Object.entries(item.platformArgs)) {
418
+ if (!PLATFORMS.has(key)) errors.push(`${where}: platformArgs.${key}: platforms are ${[...PLATFORMS].join(", ")}`);
419
+ else platformArgs[key] = flagList(list, `platformArgs.${key}`);
420
+ }
421
+ }
422
+ if (item.generator !== undefined && (typeof item.generator !== "string" || !CMAKE_GENERATOR.test(item.generator))) errors.push(`${where}: generator must be a generator name such as "Ninja"`);
423
+ const platformGenerators = {};
424
+ if (item.platformGenerators !== undefined) {
425
+ if (!isObject(item.platformGenerators)) errors.push(`${where}: platformGenerators must be an object keyed by mac, win or linux`);
426
+ else for (const [key, generator] of Object.entries(item.platformGenerators)) {
427
+ if (!PLATFORMS.has(key)) errors.push(`${where}: platformGenerators.${key}: platforms are ${[...PLATFORMS].join(", ")}`);
428
+ else if (typeof generator !== "string" || !CMAKE_GENERATOR.test(generator)) errors.push(`${where}: platformGenerators.${key} must be a generator name such as "Visual Studio 17 2022"`);
429
+ else platformGenerators[key] = generator;
430
+ }
431
+ }
432
+ if (item.architecture !== undefined && (typeof item.architecture !== "string" || !CMAKE_ARCHITECTURE.test(item.architecture))) errors.push(`${where}: architecture must be a cmake -A platform such as x64, Win32 or ARM64`);
433
+ if (item.cmakeBin !== undefined && (typeof item.cmakeBin !== "string" || !item.cmakeBin.trim() || /[\0\r\n]/.test(item.cmakeBin))) errors.push(`${where}: cmakeBin must be a cmake executable name or path`);
434
+ 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`);
435
+ sha(item.sha256, "sha256");
436
+ const dest = destOf(item.dest);
437
+ const clean = cleanOf();
438
+ if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
439
+ Object.assign(entry, { name, repo: item.repo, commit: item.commit, target, ...(output ? { output } : {}), cmakeArgs, platformArgs, ...(item.generator ? { generator: item.generator } : {}), ...(Object.keys(platformGenerators).length ? { platformGenerators } : {}), ...(item.architecture ? { architecture: item.architecture } : {}), ...(item.cmakeBin ? { cmakeBin: item.cmakeBin.trim() } : {}), ...(item.smoke ? { smoke: [...item.smoke] } : {}), ...(item.sha256 ? { sha256: item.sha256 } : {}), ...(dest ? { dest } : {}), ...(clean ? { clean } : {}) });
440
+ } else if (item.kind === "prebuilt") {
441
+ const name = item.name;
442
+ 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)`);
443
+ let from;
444
+ if (isObject(item.from)) {
445
+ // Per-arch inputs: each slice is verified against its own manifest entry; universal-apple-darwin lipo-merges them.
446
+ from = {};
447
+ for (const [arch, value] of Object.entries(item.from)) {
448
+ if (!SOURCE_ARCHES.includes(arch)) errors.push(`${where}: from.${arch}: arches are ${SOURCE_ARCHES.join(", ")}`);
449
+ else { const file = relativePath(value, `${where}: from.${arch}`, errors); if (file) from[arch] = file; }
450
+ }
451
+ if (!Object.keys(item.from).length) errors.push(`${where}: from must name at least one of ${SOURCE_ARCHES.join(", ")}`);
452
+ if (!skipped && t.arch === "universal" && !SOURCE_ARCHES.every((arch) => arch in item.from)) errors.push(`${where}: from needs both aarch64 and x86_64 for universal-apple-darwin (or omit from to use the staged <name>-<slice triple> files)`);
453
+ } else from = item.from === undefined ? undefined : relativePath(item.from, `${where}: from`, errors);
454
+ const manifest = parsePrebuiltManifest(item.manifest, where, errors);
455
+ if (!skipped && t.arch === "universal" && typeof item.from === "string") errors.push(`${where}: from must be { aarch64, x86_64 } for universal-apple-darwin (each slice is verified against its own manifest entry, then lipo-merged)`);
456
+ const dest = destOf(item.dest);
457
+ if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
458
+ Object.assign(entry, { name, ...(from ? { from } : {}), ...(manifest ? { manifest } : {}), ...(dest ? { dest } : {}) });
297
459
  } else {
298
460
  const from = relativePath(item.from, `${where}: from`, errors);
299
461
  const to = relativePath(item.to, `${where}: to`, errors);
@@ -315,6 +477,17 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
315
477
  }
316
478
 
317
479
  const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
480
+ /**
481
+ * Signature-independent catalog digest of `bytes` for `arch` (content-digest.mjs, port of
482
+ * rightkit-ffmpeg content_digest.rs). Catalog `contentSha256` rows pin THIS digest, never a
483
+ * whole-file SHA-256: signed & unsigned copies of one build agree. A malformed recognized
484
+ * format throws ContentDigestError (never a plain-hash fallback). Whole-file hashes elsewhere
485
+ * (archive/download pins, verifyStagedSidecars preSignSha256 signing evidence) stay separate.
486
+ */
487
+ function catalogContentDigest(bytes, arch) {
488
+ const identity = contentIdentity(bytes);
489
+ return digestFor(identity, arch) ?? (identity.slices.length === 1 ? identity.slices[0].sha256 : null);
490
+ }
318
491
  const profileDirectory = (profile) => (profile === "dev" || profile === "test" ? "debug" : profile === "bench" ? "release" : profile);
319
492
  const tail = (text, lines = 12) => String(text ?? "").trim().split("\n").slice(-lines).join("\n");
320
493
 
@@ -465,6 +638,37 @@ function requireUniversalFile(ctx, file, label) {
465
638
  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
639
  }
467
640
 
641
+ /**
642
+ * lipo-create `slices` into `dest` (a universal Mach-O): temp file, both archs required, written only when the bytes
643
+ * changed (an identical file is not rewritten, so Tauri's rerun-if-changed is not triggered needlessly).
644
+ */
645
+ function lipoMergeInto(ctx, slices, dest, label, { executable = false } = {}) {
646
+ const { fs, run, appRoot, env } = ctx;
647
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
648
+ const temporary = `${dest}.tmp-${ctx.pid}`;
649
+ const args = ["-create", "-output", temporary, ...slices];
650
+ ctx.log(`stage-sidecar: lipo ${args.join(" ")}`);
651
+ const merged = run("lipo", args, { cwd: appRoot, env });
652
+ 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) });
653
+ requireUniversalFile(ctx, temporary, `${label} after lipo`);
654
+ const sha256 = sha256Of(fs.readFileSync(temporary));
655
+ const changed = !(fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === sha256);
656
+ if (executable) fs.chmodSync(temporary, 0o755);
657
+ if (changed) fs.renameSync(temporary, dest);
658
+ else {
659
+ fs.rmSync(temporary, { force: true });
660
+ if (executable) fs.chmodSync(dest, 0o755);
661
+ }
662
+ return { sha256, sizeBytes: fs.statSync(dest).size, changed };
663
+ }
664
+
665
+ /** Can a binary built for `t` run on this host (so a probe or smoke run is meaningful)? Cross-target builds are not run. */
666
+ function canRunTarget(ctx, t) {
667
+ const host = ctx.host;
668
+ if (t.arch === "universal") return host.os === "macos";
669
+ return t.os === host.os && t.arch === host.arch;
670
+ }
671
+
468
672
  const destFor = (entry, ctx) => (entry.dest ? path.resolve(ctx.appRoot, entry.dest) : path.resolve(ctx.appRoot, ctx.stageDir, stagedName(entry.name, ctx.target)));
469
673
 
470
674
  function stageCargoBin(entry, ctx) {
@@ -522,8 +726,142 @@ function stageCargoBuild(entry, ctx, triple) {
522
726
  return { source, sourceSha256 };
523
727
  }
524
728
 
729
+ /** The archive source for one tool at one arch: a single source, or the per-arch form. */
730
+ const ffmpegSourceFor = (sources, tool, arch) => { const spec = sources?.[tool]; return spec?.url || spec?.path ? spec : spec?.[arch]; };
731
+
732
+ /** The binary a local ffmpeg source names: the file itself, or `<dir>/<member>` / `<dir>/bin/<member>` (`.exe` added for a Windows target). */
733
+ function resolveLocalFfmpegSource(ctx, spec, tool) {
734
+ const { fs } = ctx;
735
+ const base = path.resolve(ctx.appRoot, spec.path);
736
+ if (fs.existsSync(base) && fs.statSync(base).isFile()) return base;
737
+ const member = spec.member ?? `${tool}${ctx.windows ? ".exe" : ""}`;
738
+ const candidates = [path.join(base, member), path.join(base, "bin", member)];
739
+ const found = candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
740
+ if (!found) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `the local ${tool} source ${spec.path} has no ${member} (looked in ${candidates.join(", ")}); vendor the build first, nothing is downloaded for a path source`, { tool, candidates });
741
+ return found;
742
+ }
743
+
744
+ /**
745
+ * Fetch the bytes of the catalog rows that `ffmpeg.sources` names (a local `path` is copied, never downloaded): an archive pinned by digest, one member unpacked, the
746
+ * member's digest checked against the APPROVED catalog row (so a swapped archive can never stage). A staged file that already
747
+ * matches its row is left alone.
748
+ */
749
+ async function fetchFfmpegSources(entry, ctx, plan, arch) {
750
+ if (!entry.sources) return;
751
+ const { fs } = ctx;
752
+ for (const file of plan.files) {
753
+ const spec = ffmpegSourceFor(entry.sources, file.tool, arch);
754
+ if (!spec) continue;
755
+ if (fs.existsSync(file.stagedPath) && catalogContentDigest(fs.readFileSync(file.stagedPath), arch) === file.contentSha256) { ctx.log(`stage-sidecar: ${rel(ctx, file.stagedPath)} already staged`); continue; }
756
+ if (spec.path) {
757
+ const local = resolveLocalFfmpegSource(ctx, spec, file.tool);
758
+ const actual = catalogContentDigest(fs.readFileSync(local), arch);
759
+ if (actual !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${local} is ${actual}, the approved catalog row ${file.artifactId} says ${file.contentSha256}; nothing staged`, { expected: file.contentSha256, actual });
760
+ copyInto(ctx, local, file.stagedPath, { executable: true });
761
+ continue;
762
+ }
763
+ const { cached } = await ensureCachedDownload(ctx, spec);
764
+ const scratch = ctx.makeTempDir();
765
+ try {
766
+ const member = await ctx.extractMember({ archive: cached, fileName: spec.member, destDir: scratch });
767
+ const actual = catalogContentDigest(fs.readFileSync(member), arch);
768
+ 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 });
769
+ copyInto(ctx, member, file.stagedPath, { executable: true });
770
+ } finally {
771
+ ctx.removeDir(scratch);
772
+ }
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Optional (`probe: true`) LGPL and encoder probes of a staged ffmpeg or ffprobe, restored from the apps' own stage scripts: the
778
+ * `configuration:` line of `-version` must say --disable-gpl and --disable-nonfree and must not enable GPL, nonfree or the GPL
779
+ * encoders; ffmpeg's `-encoders` list must not carry libx264, libx265, libxvid or libfdk_aac. A binary built for another
780
+ * architecture or OS than this host is not run: the row says `skipped-cross-target`. Returns "passed" or "skipped-cross-target".
781
+ */
782
+ function probeFfmpegBinary(ctx, { file, tool, target }) {
783
+ if (!canRunTarget(ctx, target)) return "skipped-cross-target";
784
+ const text = (result, what) => {
785
+ if (result?.error || result?.status !== 0) fail("STAGE_SIDECAR_FFMPEG_PROBE_FAILED", `${tool} ${what} failed (${result?.error?.message ?? `exit ${result?.status}`})`, { file });
786
+ return String(result.stdout ?? "");
787
+ };
788
+ const version = text(ctx.run(file, ["-version"], { cwd: ctx.appRoot, env: ctx.env }), "-version");
789
+ const configuration = /^configuration:(.*)$/m.exec(version)?.[1] ?? "";
790
+ 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 });
791
+ const enabled = /--enable-(gpl|nonfree|libx264|libx265|libxvid|libfdk-aac)\b/.exec(configuration);
792
+ if (enabled) fail("STAGE_SIDECAR_FFMPEG_PROBE_FAILED", `${tool} was built with --enable-${enabled[1]}; it cannot ship under the LGPL`, { file });
793
+ if (tool === "ffmpeg") {
794
+ const encoders = text(ctx.run(file, ["-hide_banner", "-encoders"], { cwd: ctx.appRoot, env: ctx.env }), "-encoders");
795
+ const gpl = /\b(libx264|libx264rgb|libx265|libxvid|libfdk_aac)\b/.exec(encoders);
796
+ if (gpl) fail("STAGE_SIDECAR_FFMPEG_PROBE_FAILED", `ffmpeg lists the ${gpl[1]} encoder; it cannot ship under the LGPL`, { file });
797
+ }
798
+ return "passed";
799
+ }
800
+
801
+ /**
802
+ * Stage one architecture of an ffmpeg entry: plan the catalog rows, fetch declared sources, run the optional stage command,
803
+ * write the notices (unless a universal merge writes them once for every slice), verify each staged file against its row.
804
+ * Returns { plan, rows }; each row is a receipt row.
805
+ */
806
+ async function stageFfmpegSlice(entry, ctx, resolved, { config, t, writeNotices }) {
807
+ const { appRoot, run, env, fs } = ctx;
808
+ const plan = planMediaSidecar({ config, catalog: resolved.catalog, crateDir: resolved.crateDir, crateVersion: resolved.crateVersion, catalogBytes: resolved.catalogBytes, root: appRoot });
809
+ await fetchFfmpegSources(entry, ctx, plan, t.arch);
810
+ if (config.stage) {
811
+ const universal = ctx.target.arch === "universal";
812
+ // A universal entry stages once per slice: `{arch}`, `{triple}` and `{stageDir}` in the stage command's arguments name the slice.
813
+ const args = (config.stage.args ?? []).map((arg) => (universal ? String(arg).replaceAll("{arch}", t.arch).replaceAll("{triple}", t.triple).replaceAll("{stageDir}", config.stageDir) : arg));
814
+ ctx.log(`stage-sidecar: ${config.stage.cmd} ${args.join(" ")}`);
815
+ const staged = run(config.stage.cmd, args, { cwd: appRoot, env });
816
+ if (staged?.error || staged?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `ffmpeg stage command failed (${staged?.error?.message ?? `exit ${staged?.status}`})`);
817
+ }
818
+ const noticeFiles = writeNotices ? writeMediaSidecarNotices(plan, { exists: fs.existsSync, mkdir: fs.mkdirSync, copy: fs.copyFileSync, write: fs.writeFileSync }) : [];
819
+ const verified = verifyStagedSidecars(plan, { exists: fs.existsSync, stat: (file) => fs.statSync(file), hashFile: (file) => sha256Of(fs.readFileSync(file)) });
820
+ const notices = noticeFiles.map((file) => ({ name: path.basename(file), path: rel(ctx, file), sha256: sha256Of(fs.readFileSync(file)) }));
821
+ const rows = verified.map((file) => {
822
+ // Catalog rows pin the signature-independent content digest; preSignSha256 stays whole-file signing evidence.
823
+ const contentDigest = catalogContentDigest(fs.readFileSync(file.stagedPath), t.arch);
824
+ if (contentDigest !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${file.tool} staged with content digest ${contentDigest}, the approved catalog row ${file.artifactId} says ${file.contentSha256}`, { expected: file.contentSha256, actual: contentDigest });
825
+ const probe = entry.probe ? probeFfmpegBinary(ctx, { file: file.stagedPath, tool: file.tool, target: t }) : undefined;
826
+ 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 } : {}) };
827
+ });
828
+ return { plan, rows };
829
+ }
830
+
831
+ /** universal-apple-darwin: stage each arch slice under SLICE_ROOT, then lipo-merge every tool into <stageDir>/<tool>-universal-apple-darwin. */
832
+ async function stageFfmpegUniversal(entry, ctx, resolved) {
833
+ const { appRoot, fs } = ctx;
834
+ const results = [];
835
+ for (const slice of entry.slices) {
836
+ results.push({ slice, ...(await stageFfmpegSlice(entry, ctx, resolved, { config: slice.media, t: parseTargetTriple(slice.triple), writeNotices: false })) });
837
+ }
838
+ // One notice set and one source offer that names the rows of every slice (a per-slice offer would overwrite the other).
839
+ const noticeByDest = new Map();
840
+ for (const { plan } of results) for (const notice of plan.notices) noticeByDest.set(notice.dest, notice);
841
+ const rowsById = new Map();
842
+ for (const { plan } of results) for (const row of plan.rows) rowsById.set(row.id, row);
843
+ const combined = {
844
+ notices: [...noticeByDest.values()],
845
+ offerPath: results[0].plan.offerPath,
846
+ offer: sourceOfferText({ rows: [...rowsById.values()], contact: entry.media.notices.sourceOfferContact, crateVersion: resolved.crateVersion }),
847
+ };
848
+ const noticeFiles = writeMediaSidecarNotices(combined, { exists: fs.existsSync, mkdir: fs.mkdirSync, copy: fs.copyFileSync, write: fs.writeFileSync });
849
+ const notices = noticeFiles.map((file) => ({ name: path.basename(file), path: rel(ctx, file), sha256: sha256Of(fs.readFileSync(file)) }));
850
+ return entry.media.tools.map((tool) => {
851
+ const parts = results.map(({ slice, rows }) => ({ slice, row: rows.find((row) => row.name === tool) }));
852
+ const dest = path.resolve(appRoot, ctx.stageDir, stagedName(tool, ctx.target));
853
+ const merged = lipoMergeInto(ctx, parts.map(({ row }) => path.resolve(appRoot, row.path)), dest, `ffmpeg sidecar ${tool}`, { executable: true });
854
+ const probe = entry.probe ? probeFfmpegBinary(ctx, { file: dest, tool, target: ctx.target }) : undefined;
855
+ return {
856
+ kind: "ffmpeg", name: tool, path: rel(ctx, dest), ...merged, sourceSha256: merged.sha256, catalogRow: parts.map(({ row }) => row.catalogRow).join("+"), version: parts[0].row.version,
857
+ externalBin: true, license: "LGPL-2.1-or-later", notices, ...(probe ? { probe } : {}),
858
+ slices: parts.map(({ slice, row }) => ({ triple: slice.triple, sha256: row.sha256, catalogRow: row.catalogRow, catalogContentSha256: row.catalogContentSha256 })),
859
+ };
860
+ });
861
+ }
862
+
525
863
  async function stageFfmpeg(entry, ctx) {
526
- const { appRoot, run, env, fs, target } = ctx;
864
+ const { appRoot, fs, target } = ctx;
527
865
  const config = entry.media;
528
866
  try {
529
867
  let resolved = await ctx.loadMediaCatalog({ config, appRoot, entry });
@@ -531,31 +869,22 @@ async function stageFfmpeg(entry, ctx) {
531
869
  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
870
  resolved = readJsonCatalog(resolveCatalogFile(entry.catalogSpec, ctx, "ffmpeg"), appRoot, fs, "rightkit-ffmpeg");
533
871
  }
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
- });
872
+ if (target.arch === "universal") return await stageFfmpegUniversal(entry, ctx, resolved);
873
+ return (await stageFfmpegSlice(entry, ctx, resolved, { config, t: target, writeNotices: true })).rows;
547
874
  } catch (error) {
548
- if (error instanceof StageSidecarError) throw error;
875
+ if (error instanceof StageSidecarError || error instanceof ContentDigestError) throw error;
549
876
  return fail("STAGE_SIDECAR_FFMPEG_FAILED", `ffmpeg sidecar (${target.triple}): ${error.message}`, { cause: error, causeCode: error.code });
550
877
  }
551
878
  }
552
879
 
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}`;
880
+ /**
881
+ * Stage one architecture of onnxruntime: the approved or pinned catalog row for `t`, its archive (digest-checked), the library
882
+ * (digest-checked) into `destDir` (default the entry's resourceDir), and the notices. Returns { row, dest, outName, notices }.
883
+ */
884
+ async function stageOnnxRuntimeSlice(entry, ctx, loaded, t, { destDir } = {}) {
885
+ const { appRoot, fs } = ctx;
886
+ const rows = (loaded.catalog?.libraries ?? []).filter((row) => row.library === "onnxruntime" && row.version === entry.version && row.os === t.os && row.arch === t.arch);
887
+ const where = `onnxruntime ${entry.version} ${t.os}/${t.arch}`;
559
888
  if (rows.length === 0) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has no ${where} row`);
560
889
  if (rows.length > 1) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has ${rows.length} ${where} rows`);
561
890
  const [row] = rows;
@@ -572,7 +901,7 @@ async function stageOnnxRuntime(entry, ctx) {
572
901
  for (const { source } of notices) if (!fs.existsSync(source)) fail("STAGE_SIDECAR_NOTICE_MISSING", `notice file not found: ${source}`);
573
902
 
574
903
  const outName = entry.as ?? row.artifactFileName;
575
- const dest = path.resolve(appRoot, entry.resourceDir, outName);
904
+ const dest = path.resolve(appRoot, destDir ?? entry.resourceDir, outName);
576
905
  if (fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === row.sha256) {
577
906
  ctx.log(`stage-sidecar: ${rel(ctx, dest)} already staged`);
578
907
  } else {
@@ -599,7 +928,30 @@ async function stageOnnxRuntime(entry, ctx) {
599
928
  fs.copyFileSync(source, out);
600
929
  return { name: path.basename(out), path: rel(ctx, out), sha256: sha256Of(fs.readFileSync(out)) };
601
930
  });
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 }];
931
+ return { row, dest, outName, notices: noticeOut };
932
+ }
933
+
934
+ async function stageOnnxRuntime(entry, ctx) {
935
+ const { appRoot, fs, target } = ctx;
936
+ const loaded = await (ctx.loadNativeCatalog({ entry, appRoot }) ?? (entry.catalogSpec ? readJsonCatalog(resolveCatalogFile(entry.catalogSpec, ctx, "onnxruntime"), appRoot, fs, "native-libraries") : undefined));
937
+ if (!loaded) fail("STAGE_SIDECAR_CATALOG_UNREADABLE", "onnxruntime needs a catalog path (the rightkit-ffmpeg native-libraries.json) or deps.loadNativeCatalog");
938
+ if (target.arch !== "universal") {
939
+ const { row, dest, outName, notices } = await stageOnnxRuntimeSlice(entry, ctx, loaded, target);
940
+ 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 }];
941
+ }
942
+ // universal-apple-darwin: each arch row is staged thin under SLICE_ROOT, then merged into the entry's resource file.
943
+ const slices = [];
944
+ for (const triple of SLICES) slices.push({ triple, ...(await stageOnnxRuntimeSlice(entry, ctx, loaded, parseTargetTriple(triple), { destDir: `${SLICE_ROOT}/${triple}` })) });
945
+ const names = [...new Set(slices.map((slice) => slice.outName))];
946
+ 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`);
947
+ const dest = path.resolve(appRoot, entry.resourceDir, names[0]);
948
+ const merged = lipoMergeInto(ctx, slices.map((slice) => slice.dest), dest, `onnxruntime ${entry.version}`);
949
+ const notices = [...new Map(slices.flatMap((slice) => slice.notices).map((notice) => [notice.path, notice])).values()];
950
+ return [{
951
+ kind: "onnxruntime", name: names[0], path: rel(ctx, dest), ...merged, sourceSha256: merged.sha256, catalogRow: slices.map((slice) => slice.row.id).join("+"), version: entry.version,
952
+ license: slices[0].row.licenseSpdx ?? null, notices, externalBin: false,
953
+ slices: slices.map((slice) => ({ triple: slice.triple, sha256: slice.row.sha256, catalogRow: slice.row.id, archiveSha256: slice.row.archive.sha256 })),
954
+ }];
603
955
  }
604
956
 
605
957
  /** Every file under `dir`, sorted, as paths relative to it (forward slashes). */
@@ -664,36 +1016,244 @@ function stageCommand(entry, ctx) {
664
1016
  }
665
1017
 
666
1018
  /**
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.
1019
+ * Download `{ url, sha256, size? }` into the content-addressed cache (`.right-release/cache/remote/<sha256>`). The bytes are
1020
+ * hashed before anything is written: the cache entry exists only from verified bytes. A mismatch (or a non-https redirect)
1021
+ * fails closed with nothing staged. Returns { cached, fromCache }.
1022
+ */
1023
+ async function ensureCachedDownload(ctx, { url, sha256, size }) {
1024
+ const { appRoot, fs } = ctx;
1025
+ const cached = path.resolve(appRoot, REMOTE_CACHE_DIR, sha256);
1026
+ if (fs.existsSync(cached) && sha256Of(fs.readFileSync(cached)) === sha256) return { cached, fromCache: true };
1027
+ ctx.log(`stage-sidecar: GET ${url}`);
1028
+ let response;
1029
+ try { response = await ctx.fetch(url, { redirect: "follow" }); } catch (error) { fail("STAGE_SIDECAR_DOWNLOAD_FAILED", `GET ${url} failed: ${error.message}`, { cause: error, url }); }
1030
+ if (!response?.ok) fail("STAGE_SIDECAR_DOWNLOAD_FAILED", `GET ${url} failed with ${response?.status}`, { url });
1031
+ 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 });
1032
+ const bytes = Buffer.from(await response.arrayBuffer());
1033
+ 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 });
1034
+ const actual = sha256Of(bytes);
1035
+ if (actual !== sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${url} downloaded as ${actual}, expected ${sha256}; nothing staged`, { expected: sha256, actual });
1036
+ fs.mkdirSync(path.dirname(cached), { recursive: true });
1037
+ const temporary = `${cached}.tmp-${ctx.pid}`;
1038
+ fs.writeFileSync(temporary, bytes);
1039
+ fs.renameSync(temporary, cached);
1040
+ return { cached, fromCache: false };
1041
+ }
1042
+
1043
+ /**
1044
+ * A pinned https download. `dest` is copied only from the verified cache entry. With `extract` the download is an archive
1045
+ * (zip, tgz): the one member whose base name is `extract` is unpacked (digest-checked against `memberSha256` when given) and
1046
+ * only that member is staged, so no `command` entry or shell is needed to unpack it.
670
1047
  */
671
1048
  async function stageRemote(entry, ctx) {
672
1049
  const { appRoot, fs } = ctx;
673
1050
  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.
1051
+ const { cached, fromCache } = await ensureCachedDownload(ctx, entry);
1052
+ let source = cached;
1053
+ let scratch;
1054
+ try {
1055
+ let memberSha256;
1056
+ if (entry.extract) {
1057
+ scratch = ctx.makeTempDir();
1058
+ source = await ctx.extractMember({ archive: cached, fileName: entry.extract, destDir: scratch });
1059
+ memberSha256 = sha256Of(fs.readFileSync(source));
1060
+ 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 });
1061
+ }
1062
+ // Download and verify first: a failed fetch or a hash mismatch must never leave a cleaned destination behind.
1063
+ cleanDest(ctx, entry, dest);
1064
+ const copied = copyInto(ctx, source, dest, { executable: entry.executable === true });
1065
+ const expected = entry.extract ? entry.memberSha256 : entry.sha256;
1066
+ if (expected && copied.sha256 !== expected) fail("STAGE_SIDECAR_HASH_MISMATCH", `staged ${entry.dest} is ${copied.sha256}, expected ${expected}`, { expected, actual: copied.sha256 });
1067
+ 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 } : {}) }];
1068
+ } finally {
1069
+ if (scratch) ctx.removeDir(scratch);
1070
+ }
1071
+ }
1072
+
1073
+ /**
1074
+ * Bring the fixed source checkout `srcDir` to the pinned commit: `git init` once, then fetch exactly that commit (git verifies the
1075
+ * object hash, so the tree is pinned with no repackable tarball) and detach onto it. A checkout already at the commit is reused;
1076
+ * one with local edits is refused rather than built.
1077
+ */
1078
+ function checkoutPinnedSource(ctx, entry, srcDir) {
1079
+ const { fs, run, env } = ctx;
1080
+ const git = (args) => {
1081
+ const result = run("git", args, { cwd: srcDir, env });
1082
+ 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) });
1083
+ return result;
1084
+ };
1085
+ const head = () => { const result = run("git", ["rev-parse", "HEAD"], { cwd: srcDir, env }); return result?.status === 0 ? String(result.stdout ?? "").trim() : ""; };
1086
+ if (!fs.existsSync(path.join(srcDir, ".git"))) {
1087
+ fs.mkdirSync(srcDir, { recursive: true });
1088
+ git(["init", "-q"]);
1089
+ git(["remote", "add", "origin", entry.repo]);
1090
+ } else git(["remote", "set-url", "origin", entry.repo]);
1091
+ // Cleanliness is judged BEFORE any destructive source operation: tracked edits, untracked files and ignored files are all
1092
+ // build inputs (CMake globbing), so any of them refuses the build and nothing is checked out over them.
1093
+ const assertClean = (when) => {
1094
+ const dirty = run("git", ["status", "--porcelain", "--ignored"], { cwd: srcDir, env });
1095
+ 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) });
1096
+ };
1097
+ assertClean("before checkout");
1098
+ if (head() !== entry.commit) {
1099
+ ctx.log(`stage-sidecar: git fetch ${entry.repo} ${entry.commit}`);
1100
+ git(["fetch", "--depth", "1", "origin", entry.commit]);
1101
+ git(["checkout", "-q", "--force", "--detach", "FETCH_HEAD"]);
1102
+ const actual = head();
1103
+ if (actual !== entry.commit) fail("STAGE_SIDECAR_SOURCE_MISMATCH", `${entry.name} checkout is ${actual || "unreadable"}, the pin is ${entry.commit}`, { expected: entry.commit, actual });
1104
+ }
1105
+ assertClean("after checkout");
1106
+ if (fs.existsSync(path.join(srcDir, ".gitmodules"))) {
1107
+ // Submodule commits are pinned by the superproject tree: bring them to exactly those commits, then require every one to match
1108
+ // (no `+` moved, `-` missing or `U` conflicted prefix) and be clean, so no unpinned submodule source reaches the build.
1109
+ git(["submodule", "update", "--init", "--recursive", "--checkout"]);
1110
+ const modules = git(["submodule", "status", "--recursive"]);
1111
+ const bad = String(modules.stdout ?? "").split("\n").filter((line) => /^[-+U]/.test(line));
1112
+ if (bad.length) fail("STAGE_SIDECAR_SOURCE_MISMATCH", `${entry.name} submodules are not at their pinned commits: ${bad.join("; ")}`, { name: entry.name });
1113
+ const inner = git(["submodule", "foreach", "--recursive", "git status --porcelain --ignored"]);
1114
+ 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 });
1115
+ assertClean("after submodule update");
1116
+ }
1117
+ }
1118
+
1119
+ /** Fixed Visual Studio Installer (vswhere) location under %ProgramFiles(x86)%, and the cmake Visual Studio bundles under an installationPath. */
1120
+ const VSWHERE_PARTS = ["Microsoft Visual Studio", "Installer", "vswhere.exe"];
1121
+ const VS_CMAKE_PARTS = ["Common7", "IDE", "CommonExtensions", "Microsoft", "CMake", "CMake", "bin", "cmake.exe"];
1122
+
1123
+ /**
1124
+ * Which cmake to run. Order: the entry's `cmakeBin` (a bare name is looked up on PATH; a path with separators is resolved against the
1125
+ * app root); off a Windows host plain `cmake`; on a Windows host `cmake` when `where cmake` finds one, else the Visual Studio-bundled
1126
+ * cmake that vswhere locates (installationPath\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe), else plain `cmake`
1127
+ * (which then fails with the usual spawn error naming it).
1128
+ */
1129
+ function resolveCmakeBinary(ctx, entry) {
1130
+ if (entry.cmakeBin) return /[\\/]/.test(entry.cmakeBin) ? path.resolve(ctx.appRoot, entry.cmakeBin) : entry.cmakeBin;
1131
+ if (ctx.host.os !== "windows") return "cmake";
1132
+ const probe = ctx.run("where", ["cmake"], { cwd: ctx.appRoot, env: ctx.env });
1133
+ if (!probe?.error && probe?.status === 0 && String(probe.stdout ?? "").trim()) return "cmake";
1134
+ const programFiles = ctx.env["ProgramFiles(x86)"];
1135
+ if (typeof programFiles !== "string" || !programFiles) return "cmake";
1136
+ const vswhere = path.win32.join(programFiles, ...VSWHERE_PARTS);
1137
+ if (!ctx.fs.existsSync(vswhere)) return "cmake";
1138
+ const found = ctx.run(vswhere, ["-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.CMake.Project", "-property", "installationPath"], { cwd: ctx.appRoot, env: ctx.env });
1139
+ const installation = found?.error || found?.status !== 0 ? "" : String(found.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
1140
+ if (!installation) return "cmake";
1141
+ const bundled = path.win32.join(installation, ...VS_CMAKE_PARTS);
1142
+ return ctx.fs.existsSync(bundled) ? bundled : "cmake";
1143
+ }
1144
+
1145
+ /**
1146
+ * Build a pinned git commit with the build tool (no shell) and stage the result as <name>-<triple>[.exe]: fetch the commit, configure,
1147
+ * build `target`, locate the executable, check it carries both archs for universal-apple-darwin, optionally pin its digest and
1148
+ * smoke-run it. Per-platform flags (for example Metal on macOS) come from `platformArgs`; the source and build trees live at the
1149
+ * fixed `.right-release/cmake/<name>/{src,build}`.
1150
+ */
1151
+ function stageCmake(entry, ctx) {
1152
+ const { appRoot, fs, run, env, target } = ctx;
1153
+ const cmake = resolveCmakeBinary(ctx, entry);
1154
+ const generator = entry.platformGenerators?.[target.platform] ?? entry.generator;
1155
+ const root = path.resolve(appRoot, CMAKE_ROOT, entry.name);
1156
+ const srcDir = path.join(root, "src");
1157
+ const buildDir = path.join(root, "build");
1158
+ const dest = destFor(entry, ctx);
693
1159
  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 }];
1160
+ checkoutPinnedSource(ctx, entry, srcDir);
1161
+ const archFlag = target.platform !== "mac" ? undefined
1162
+ : target.arch === "universal" ? "-DCMAKE_OSX_ARCHITECTURES=arm64;x86_64"
1163
+ : `-DCMAKE_OSX_ARCHITECTURES=${target.arch === "aarch64" ? "arm64" : "x86_64"}`;
1164
+ const configure = ["-S", srcDir, "-B", buildDir, "-DCMAKE_BUILD_TYPE=Release", ...(generator ? ["-G", generator] : []), ...(entry.architecture ? ["-A", entry.architecture] : []), ...(archFlag ? [archFlag] : []), ...(entry.platformArgs[target.platform] ?? []), ...entry.cmakeArgs];
1165
+ const step = (args, label) => {
1166
+ ctx.log(`stage-sidecar: ${cmake} ${args.join(" ")}`);
1167
+ const result = run(cmake, args, { cwd: appRoot, env });
1168
+ 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) });
1169
+ };
1170
+ step(configure, "configure");
1171
+ step(["--build", buildDir, "--config", "Release", "--target", entry.target, "--parallel"], "build");
1172
+ const exe = ctx.windows ? ".exe" : "";
1173
+ const output = entry.output ?? `bin/${entry.target}`;
1174
+ const dir = path.posix.dirname(output);
1175
+ const file = `${path.posix.basename(output)}${exe}`;
1176
+ const candidates = [...new Set([path.join(buildDir, dir, file), path.join(buildDir, dir, "Release", file), path.join(buildDir, "Release", file)])];
1177
+ const source = candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
1178
+ 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 });
1179
+ if (target.arch === "universal") requireUniversalFile(ctx, source, `cmake build ${entry.name}`);
1180
+ const sourceSha256 = sha256Of(fs.readFileSync(source));
1181
+ if (entry.sha256 && entry.sha256 !== sourceSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.name} built to ${sourceSha256}, expected ${entry.sha256}`, { expected: entry.sha256, actual: sourceSha256 });
1182
+ const copied = copyInto(ctx, source, dest, { executable: true });
1183
+ if (entry.smoke && canRunTarget(ctx, target)) {
1184
+ const smoked = run(dest, entry.smoke, { cwd: appRoot, env });
1185
+ 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 });
1186
+ }
1187
+ return [{ kind: "cmake", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256, repo: entry.repo, commit: entry.commit, cmakeTarget: entry.target, ...(cmake !== "cmake" ? { cmakeBin: cmake } : {}), ...(generator ? { generator } : {}), flags: configure.filter((arg) => arg.startsWith("-D")), externalBin: !entry.dest }];
1188
+ }
1189
+
1190
+ /** `{platform}` in a prebuilt manifest entry: macos-aarch64, macos-x86_64, windows-x86_64, linux-x86_64, ... */
1191
+ const prebuiltPlatformKey = (t) => `${t.os}-${t.arch}`;
1192
+
1193
+ /**
1194
+ * Verify one host-built binary `source` for `slice` (a parsed target: its os and arch name the manifest entry) against the digest in
1195
+ * an app manifest file: its raw sha256, and when the manifest names an approval hash, the signing-independent approval digest
1196
+ * (`macho-codesign-strip-linkedit-v1` on macOS, `raw-sha256` elsewhere). Nothing is built or downloaded: a missing file or
1197
+ * a mismatch fails closed. Returns { platformKey, actual, approval? }.
1198
+ */
1199
+ async function verifyPrebuilt(entry, ctx, source, slice) {
1200
+ const { appRoot, fs } = ctx;
1201
+ const platformKey = prebuiltPlatformKey(slice);
1202
+ const manifestFile = path.resolve(appRoot, entry.manifest.file);
1203
+ let manifest;
1204
+ 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 }); }
1205
+ let node = manifest;
1206
+ for (const part of entry.manifest.entry.replaceAll("{platform}", platformKey).split(".")) node = isObject(node) ? node[part] : undefined;
1207
+ 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}`);
1208
+ const pinned = node[entry.manifest.sha256];
1209
+ 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`);
1210
+ 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 });
1211
+ const actual = sha256Of(fs.readFileSync(source));
1212
+ if (actual !== pinned) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.name} integrity failure: ${source} is ${actual}, ${entry.manifest.file} pins ${pinned}`, { expected: pinned, actual });
1213
+ let approval;
1214
+ if (entry.manifest.approvalSha256) {
1215
+ const expected = node[entry.manifest.approvalSha256];
1216
+ 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`);
1217
+ const algorithm = entry.manifest.approvalAlgorithm ? node[entry.manifest.approvalAlgorithm] : (slice.os === "macos" ? APPROVAL_MACHO_STRIP_LINKEDIT_V1 : APPROVAL_RAW_SHA256);
1218
+ if (typeof algorithm !== "string") fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} ${platformKey}.${entry.manifest.approvalAlgorithm} does not name an approval hash algorithm`);
1219
+ let digest;
1220
+ 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 }); }
1221
+ 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 });
1222
+ approval = { algorithm, sha256: digest };
1223
+ }
1224
+ return { platformKey, actual, ...(approval ? { approval } : {}) };
1225
+ }
1226
+
1227
+ /**
1228
+ * Verify a host-built binary (already staged at <stageDir>/<name>-<triple>, or copied from `from`) against the app manifest and stage it.
1229
+ * `from` is a path, or `{ aarch64, x86_64 }` (the entry for this arch is used). `universal-apple-darwin`: each slice (its `from.<arch>`, else the
1230
+ * staged `<stageDir>/<name>-<slice triple>`) is verified against its own manifest entry (macos-aarch64, macos-x86_64), then the two are
1231
+ * lipo-merged by the same helper the ffmpeg, onnxruntime and cmake kinds use into `<name>-universal-apple-darwin`; a thin merge result is refused.
1232
+ */
1233
+ async function stagePrebuilt(entry, ctx) {
1234
+ const { fs, target, appRoot } = ctx;
1235
+ const dest = destFor(entry, ctx);
1236
+ if (target.arch === "universal") {
1237
+ const parts = [];
1238
+ for (const triple of SLICES) {
1239
+ const slice = parseTargetTriple(triple);
1240
+ const from = entry.from?.[slice.arch];
1241
+ const source = from ? path.resolve(appRoot, from) : path.resolve(appRoot, ctx.stageDir, stagedName(entry.name, slice));
1242
+ parts.push({ triple, source, ...(await verifyPrebuilt(entry, ctx, source, slice)) });
1243
+ }
1244
+ const merged = lipoMergeInto(ctx, parts.map((part) => part.source), dest, `prebuilt ${entry.name}`, { executable: true });
1245
+ return [{ kind: "prebuilt", name: entry.name, path: rel(ctx, dest), ...merged, sourceSha256: merged.sha256, manifest: entry.manifest.file, platform: "macos-universal", externalBin: !entry.dest, slices: parts.map((part) => ({ triple: part.triple, sha256: part.actual, platform: part.platformKey, ...(part.approval ? { approvalAlgorithm: part.approval.algorithm, approvalSha256: part.approval.sha256 } : {}) })) }];
1246
+ }
1247
+ const declared = isObject(entry.from) ? entry.from[target.arch] : entry.from;
1248
+ if (isObject(entry.from) && !declared) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `prebuilt ${entry.name} declares from for ${Object.keys(entry.from).join(", ")}, not ${target.arch}`, { name: entry.name });
1249
+ const source = declared ? path.resolve(appRoot, declared) : dest;
1250
+ const { platformKey, actual, approval } = await verifyPrebuilt(entry, ctx, source, target);
1251
+ let copied;
1252
+ if (path.resolve(source) === path.resolve(dest)) {
1253
+ if (!ctx.windows) fs.chmodSync(dest, 0o755);
1254
+ copied = { sha256: actual, sizeBytes: fs.statSync(dest).size, changed: false };
1255
+ } else copied = copyInto(ctx, source, dest, { executable: true });
1256
+ 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
1257
  }
698
1258
 
699
1259
  /**
@@ -706,7 +1266,7 @@ function wipeStaleStageDir(ctx, plan, produced) {
706
1266
  let listing;
707
1267
  try { listing = [...ctx.fs.readdirSync(dir)]; } catch { return []; }
708
1268
  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] : []));
1269
+ 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
1270
  const exact = new Set(names.flatMap((name) => [`${name}-${ctx.target.triple}`, `${name}-${ctx.target.triple}.exe`]));
711
1271
  const removed = [];
712
1272
  for (const name of listing.sort()) {
@@ -750,17 +1310,18 @@ export function planSidecarStaging({ manifest, target, env = process.env } = {})
750
1310
  const plan = validateSidecarManifest(manifest, { target, mode: resolveBuildMode(env) });
751
1311
  const t = plan.target;
752
1312
  const outputsOf = (entry) => {
753
- if (entry.kind === "cargo-bin" || entry.kind === "command") return [entry.dest ?? `${plan.stageDir}/${stagedName(entry.name, t)}`];
1313
+ if (["cargo-bin", "command", "cmake", "prebuilt"].includes(entry.kind)) return [entry.dest ?? `${plan.stageDir}/${stagedName(entry.name, t)}`];
754
1314
  if (entry.kind === "remote") return [entry.dest];
755
1315
  if (entry.kind === "resource") return [entry.to];
756
1316
  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)}`);
1317
+ return (entry.media?.tools ?? []).map((tool) => `${plan.stageDir}/${stagedName(tool, t)}`);
758
1318
  };
759
1319
  const entries = plan.entries.map((entry) => ({
760
1320
  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" }),
1321
+ ...(entry.skipped ? {} : { outputs: outputsOf(entry), externalBin: ["cargo-bin", "command", "cmake", "prebuilt"].includes(entry.kind) ? !entry.dest : entry.kind === "ffmpeg" }),
762
1322
  ...(entry.kind === "cargo-bin" ? { package: entry.package, bin: entry.bin, profile: entry.profile } : {}),
763
1323
  ...(entry.kind === "remote" ? { url: entry.url } : {}),
1324
+ ...(entry.kind === "cmake" ? { repo: entry.repo, commit: entry.commit } : {}),
764
1325
  ...(entry.clean ? { clean: true } : {}),
765
1326
  }));
766
1327
  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 +1343,8 @@ export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {
782
1343
  appRoot, target: plan.target, stageDir: plan.stageDir, fs, env, run, protectedPaths: protectedOutputPaths(plan),
783
1344
  windows: plan.target.os === "windows",
784
1345
  pid: deps.pid ?? process.pid,
1346
+ host: deps.host ?? { os: { darwin: "macos", win32: "windows", linux: "linux" }[process.platform] ?? process.platform, arch: { arm64: "aarch64", x64: "x86_64" }[process.arch] ?? process.arch },
1347
+ approvalDigest: deps.approvalDigest ?? ((file, algorithm) => approvalDigest(file, algorithm)),
785
1348
  log: deps.log ?? (() => {}),
786
1349
  fetchBytes: deps.fetchBytes ?? defaultFetchBytes,
787
1350
  fetch: deps.fetch ?? ((...args) => globalThis.fetch(...args)),
@@ -800,7 +1363,9 @@ export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {
800
1363
  : entry.kind === "onnxruntime" ? await stageOnnxRuntime(entry, ctx)
801
1364
  : entry.kind === "command" ? stageCommand(entry, ctx)
802
1365
  : entry.kind === "remote" ? await stageRemote(entry, ctx)
803
- : stageResource(entry, ctx);
1366
+ : entry.kind === "cmake" ? stageCmake(entry, ctx)
1367
+ : entry.kind === "prebuilt" ? await stagePrebuilt(entry, ctx)
1368
+ : stageResource(entry, ctx);
804
1369
  files.push(...staged);
805
1370
  }
806
1371
  const wiped = plan.wipeStageDir ? wipeStaleStageDir(ctx, plan, files.map((file) => path.resolve(appRoot, file.path))) : [];