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