@rightkit/release 0.2.173 → 0.2.174
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 +10 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/cli/right-release.mjs +5 -3
- package/development-signing.mjs +135 -4
- package/development.mjs +12 -11
- package/in-place-install.mjs +32 -7
- package/install-app-windows.mjs +108 -17
- package/install-app.mjs +32 -8
- package/install-tauri-devid.mjs +106 -47
- package/mac-bundle-signing.mjs +143 -1
- package/mac-signing-matrix.mjs +73 -6
- package/macos-signing-session.mjs +12 -2
- package/package-recipe-targets.mjs +166 -16
- package/package.json +1 -1
- package/portable-development.mjs +9 -9
- package/qa-build-features.mjs +7 -1
- package/recipe-build.mjs +346 -0
- package/release.mjs +23 -8
- package/rightkit-signing-agent.mjs +10 -4
- package/rightkit-versions.json +9 -5
- package/runtime-budgets.mjs +14 -5
- package/stage-binary.mjs +1 -1
- package/stage-sidecar-command.mjs +36 -4
- package/stage-sidecar.mjs +463 -48
package/stage-sidecar.mjs
CHANGED
|
@@ -5,12 +5,23 @@
|
|
|
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?, clean?, platforms? },
|
|
12
14
|
// ]
|
|
13
15
|
//
|
|
16
|
+
// `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
|
|
17
|
+
// then not an externalBin. `clean: true` removes a stale file at the destination before staging. The wrapper key
|
|
18
|
+
// `wipeStageDir: true` removes stale files named exactly `<name>-<current triple>[.exe]` for a declared name this run no longer produces.
|
|
19
|
+
//
|
|
20
|
+
// `catalog` is a relative path, an absolute path, { crate: "rightkit-ffmpeg", file? } (resolved through `rightkit cargo
|
|
21
|
+
// metadata`) or { npm: "<pkg>/<file>" }. The wrapper form { sidecars, profiles: { dev, release } } sets the default cargo
|
|
22
|
+
// profile pair; RIGHTKIT_BUILD_MODE (dev|release, default release) selects. The target `universal-apple-darwin` builds
|
|
23
|
+
// both slices of a cargo-bin and lipo-creates `<name>-universal-apple-darwin`.
|
|
24
|
+
//
|
|
14
25
|
// It never signs (release.mjs signs staged files) and never calls bare cargo. Every effect goes through `deps`.
|
|
15
26
|
import { createHash } from "node:crypto";
|
|
16
27
|
import nodeFs from "node:fs";
|
|
@@ -18,6 +29,7 @@ import os from "node:os";
|
|
|
18
29
|
import path from "node:path";
|
|
19
30
|
import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
|
|
20
31
|
import { resolveCommand } from "./development.mjs";
|
|
32
|
+
import { managedHost } from "./managed-cargo.mjs";
|
|
21
33
|
import {
|
|
22
34
|
planMediaSidecar, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
|
|
23
35
|
} from "./media-sidecar.mjs";
|
|
@@ -40,14 +52,22 @@ export class StageSidecarError extends Error {
|
|
|
40
52
|
const fail = (code, message, details) => { throw new StageSidecarError(code, message, details); };
|
|
41
53
|
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
42
54
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
43
|
-
const KINDS = new Set(["cargo-bin", "ffmpeg", "onnxruntime", "resource"]);
|
|
55
|
+
const KINDS = new Set(["cargo-bin", "ffmpeg", "onnxruntime", "resource", "command", "remote"]);
|
|
56
|
+
export const NESTED_BROKER_CODE = "STAGE_SIDECAR_NESTED_BROKER";
|
|
57
|
+
export const REMOTE_CACHE_DIR = ".right-release/cache/remote";
|
|
58
|
+
const UNIVERSAL = "universal-apple-darwin";
|
|
59
|
+
const SLICES = Object.freeze(["aarch64-apple-darwin", "x86_64-apple-darwin"]);
|
|
60
|
+
const MODES = new Set(["dev", "release"]);
|
|
61
|
+
const PROFILE = /^[a-z][a-z0-9_-]*$/;
|
|
44
62
|
const PLATFORMS = new Set(["mac", "win", "linux"]);
|
|
45
63
|
const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
46
64
|
const KEYS = Object.freeze({
|
|
47
|
-
"cargo-bin": ["kind", "package", "bin", "name", "profile", "features", "manifestPath", "sha256", "platforms"],
|
|
65
|
+
"cargo-bin": ["kind", "package", "bin", "name", "profile", "features", "manifestPath", "sha256", "dest", "clean", "platforms"],
|
|
48
66
|
ffmpeg: ["kind", "variant", "tools", "notices", "catalog", "stage", "bundleDir", "platforms"],
|
|
49
67
|
onnxruntime: ["kind", "version", "catalog", "resourceDir", "as", "licenseDir", "cacheDir", "platforms"],
|
|
50
|
-
resource: ["kind", "from", "to", "sha256", "platforms"],
|
|
68
|
+
resource: ["kind", "from", "to", "sha256", "machO", "clean", "platforms"],
|
|
69
|
+
command: ["kind", "name", "cmd", "args", "cwd", "output", "externalBin", "dest", "clean", "platforms"],
|
|
70
|
+
remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "clean", "platforms"],
|
|
51
71
|
});
|
|
52
72
|
const TRIPLES = Object.freeze({
|
|
53
73
|
"aarch64-apple-darwin": { platform: "mac", os: "macos", arch: "aarch64" },
|
|
@@ -58,9 +78,9 @@ const TRIPLES = Object.freeze({
|
|
|
58
78
|
"aarch64-unknown-linux-gnu": { platform: "linux", os: "linux", arch: "aarch64" },
|
|
59
79
|
});
|
|
60
80
|
|
|
61
|
-
/** Parse a Rust target triple into { triple, platform, os, arch }. universal-apple-darwin
|
|
81
|
+
/** Parse a Rust target triple into { triple, platform, os, arch }. universal-apple-darwin has arch "universal" (cargo-bin lipos both slices). */
|
|
62
82
|
export function parseTargetTriple(target) {
|
|
63
|
-
if (target ===
|
|
83
|
+
if (target === UNIVERSAL) return { triple: UNIVERSAL, platform: "mac", os: "macos", arch: "universal" };
|
|
64
84
|
const row = typeof target === "string" ? TRIPLES[target] : undefined;
|
|
65
85
|
if (!row) fail("STAGE_SIDECAR_TARGET_UNSUPPORTED", `unsupported target triple ${JSON.stringify(target)} (${Object.keys(TRIPLES).join(", ")})`);
|
|
66
86
|
return { triple: target, ...row };
|
|
@@ -76,35 +96,118 @@ export function resolveTargetTriple({ target, env = process.env, run = defaultRu
|
|
|
76
96
|
return host;
|
|
77
97
|
}
|
|
78
98
|
|
|
79
|
-
function relativePath(value, label, errors) {
|
|
99
|
+
function relativePath(value, label, errors, { allowRoot = false } = {}) {
|
|
80
100
|
if (typeof value !== "string" || !value.trim()) { errors.push(`${label} must be a non-empty relative path`); return undefined; }
|
|
81
101
|
const normalized = value.replaceAll("\\", "/");
|
|
82
102
|
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; }
|
|
103
|
+
// "." and "./" name the app root itself: only a working directory may do that, never a destination or a directory to clean.
|
|
104
|
+
if (!normalized.split("/").some((segment) => segment !== "" && segment !== ".")) {
|
|
105
|
+
if (allowRoot) return ".";
|
|
106
|
+
errors.push(`${label} must name a path inside the app (at least one normal segment), not the app root: ${value}`);
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
83
109
|
return normalized.replace(/\/+$/, "");
|
|
84
110
|
}
|
|
85
111
|
|
|
112
|
+
/** A relative output path in comparison form: no "." or empty segments, lower case (Windows and macOS are case-insensitive). */
|
|
113
|
+
const canonicalOutput = (value) => String(value).replaceAll("\\", "/").split("/").filter((segment) => segment !== "" && segment !== ".").join("/").toLowerCase();
|
|
114
|
+
/** `output` equals `protectedPath` or is an ancestor of it (so writing or cleaning `output` would reach it). */
|
|
115
|
+
const reaches = (output, protectedPath) => output === protectedPath || protectedPath.startsWith(`${output}/`);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Paths stage-sidecar never writes to or cleans, app-relative: the stage directory, the receipt, the remote cache and the
|
|
119
|
+
* `.right-release` state, onnxruntime cache directories, and the app's source and dependency roots. A destination that equals
|
|
120
|
+
* or contains one of these is refused (validation, and again just before any delete).
|
|
121
|
+
*/
|
|
122
|
+
function protectedOutputPaths({ stageDir, receiptPath, entries = [] }) {
|
|
123
|
+
const cacheDirs = entries.filter((entry) => entry.kind === "onnxruntime" && entry.cacheDir).map((entry) => entry.cacheDir);
|
|
124
|
+
return [stageDir, receiptPath, ".right-release", REMOTE_CACHE_DIR, ".git", "src", "src-tauri", "node_modules", "package.json", ...cacheDirs].filter(Boolean).map(canonicalOutput);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `<name>-<triple>[.exe]`, with `<name>-universal-apple-darwin` for the universal target. */
|
|
128
|
+
const stagedName = (name, t) => (t.arch === "universal" ? `${name}-${UNIVERSAL}` : stagedFileName(name, t.os, t.arch));
|
|
129
|
+
|
|
130
|
+
/** RIGHTKIT_BUILD_MODE: dev | release (default release). Anything else is refused. */
|
|
131
|
+
export function resolveBuildMode(env = process.env) {
|
|
132
|
+
const value = env?.RIGHTKIT_BUILD_MODE;
|
|
133
|
+
if (value === undefined || value === "") return "release";
|
|
134
|
+
if (!MODES.has(value)) fail("STAGE_SIDECAR_CONFIG_INVALID", `RIGHTKIT_BUILD_MODE must be dev or release, got ${JSON.stringify(value)}`);
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A profile is a Cargo profile name or exactly { dev, release } of names. Returns the pair or the single name. */
|
|
139
|
+
function parseProfile(value, label, errors) {
|
|
140
|
+
if (typeof value === "string") {
|
|
141
|
+
if (!PROFILE.test(value)) errors.push(`${label} must be a Cargo profile name`);
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
if (!isObject(value)) { errors.push(`${label} must be a Cargo profile name or { dev, release }`); return undefined; }
|
|
145
|
+
for (const key of Object.keys(value)) if (!MODES.has(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)} (allowed: dev, release)`);
|
|
146
|
+
for (const mode of MODES) if (typeof value[mode] !== "string" || !PROFILE.test(value[mode])) errors.push(`${label}.${mode} must be a Cargo profile name`);
|
|
147
|
+
return { dev: value.dev, release: value.release };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const CATALOG_DEFAULT_FILE = Object.freeze({ ffmpeg: "catalog/artifacts.json", onnxruntime: "catalog/native-libraries.json" });
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A catalog reference: a relative path (inside the app), an absolute path, { crate, file? } or { npm: "<pkg>/<file>" }.
|
|
154
|
+
* Problems are pushed to `errors` against this entry only.
|
|
155
|
+
*/
|
|
156
|
+
function parseCatalogSpec(value, label, errors, kind) {
|
|
157
|
+
if (value === undefined) return undefined;
|
|
158
|
+
if (typeof value === "string") {
|
|
159
|
+
if (path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)) return { via: "absolute", file: value };
|
|
160
|
+
const file = relativePath(value, label, errors);
|
|
161
|
+
return file ? { via: "path", file } : undefined;
|
|
162
|
+
}
|
|
163
|
+
if (!isObject(value)) { errors.push(`${label} must be a path, { crate }, or { npm }`); return undefined; }
|
|
164
|
+
const keys = Object.keys(value);
|
|
165
|
+
if ("crate" in value) {
|
|
166
|
+
for (const key of keys) if (!["crate", "file"].includes(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)} for { crate, file? }`);
|
|
167
|
+
if (typeof value.crate !== "string" || !NAME.test(value.crate)) { errors.push(`${label}.crate must be a Cargo package name`); return undefined; }
|
|
168
|
+
const file = relativePath(value.file ?? CATALOG_DEFAULT_FILE[kind] ?? "catalog.json", `${label}.file`, errors);
|
|
169
|
+
return file ? { via: "crate", crate: value.crate, file } : undefined;
|
|
170
|
+
}
|
|
171
|
+
if ("npm" in value) {
|
|
172
|
+
for (const key of keys) if (key !== "npm") errors.push(`${label}: unknown key ${JSON.stringify(key)} for { npm }`);
|
|
173
|
+
const match = typeof value.npm === "string" ? /^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)\/(.+)$/.exec(value.npm) : null;
|
|
174
|
+
if (!match) { errors.push(`${label}.npm must be "<package>/<file>" (scoped packages allowed)`); return undefined; }
|
|
175
|
+
const file = relativePath(match[2], `${label}.npm file`, errors);
|
|
176
|
+
return file ? { via: "npm", pkg: match[1], file } : undefined;
|
|
177
|
+
}
|
|
178
|
+
errors.push(`${label} must be a path, { crate }, or { npm }`);
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
86
182
|
/**
|
|
87
|
-
* Validate and normalise the manifest. `raw` is the `sidecars` array, or { sidecars, stageDir?, receiptPath? }.
|
|
183
|
+
* Validate and normalise the manifest. `raw` is the `sidecars` array, or { sidecars, stageDir?, receiptPath?, profiles? }.
|
|
184
|
+
* `mode` (dev | release, default release) selects the profile of a { dev, release } pair.
|
|
88
185
|
* Every problem is reported at once as STAGE_SIDECAR_CONFIG_INVALID. Entries whose `platforms` excludes the target are
|
|
89
186
|
* kept (shape-checked) with skipped: true.
|
|
90
187
|
*/
|
|
91
|
-
export function validateSidecarManifest(raw, { target } = {}) {
|
|
188
|
+
export function validateSidecarManifest(raw, { target, mode = "release" } = {}) {
|
|
92
189
|
const t = parseTargetTriple(target);
|
|
93
190
|
const errors = [];
|
|
191
|
+
if (!MODES.has(mode)) fail("STAGE_SIDECAR_CONFIG_INVALID", `build mode must be dev or release, got ${JSON.stringify(mode)}`);
|
|
94
192
|
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)}`);
|
|
193
|
+
if (!isObject(wrapper)) fail("STAGE_SIDECAR_CONFIG_INVALID", "sidecars must be an array (or { sidecars, stageDir?, receiptPath?, profiles? })");
|
|
194
|
+
for (const key of Object.keys(wrapper)) if (!["sidecars", "stageDir", "receiptPath", "profiles", "wipeStageDir"].includes(key)) errors.push(`unknown key ${JSON.stringify(key)}`);
|
|
195
|
+
if (wrapper.wipeStageDir !== undefined && typeof wrapper.wipeStageDir !== "boolean") errors.push("wipeStageDir must be a boolean");
|
|
196
|
+
const defaultProfile = wrapper.profiles === undefined ? undefined : parseProfile(wrapper.profiles, "profiles", errors);
|
|
197
|
+
if (typeof defaultProfile === "string") errors.push("profiles must be { dev, release }");
|
|
97
198
|
if (!Array.isArray(wrapper.sidecars) || !wrapper.sidecars.length) errors.push("sidecars must be a non-empty array");
|
|
98
199
|
const stageDir = relativePath(wrapper.stageDir ?? DEFAULT_STAGE_DIR, "stageDir", errors);
|
|
99
200
|
const receiptPath = wrapper.receiptPath === undefined
|
|
100
201
|
? (stageDir ? `${stageDir}/${STAGE_SIDECAR_RECEIPT_NAME}` : undefined)
|
|
101
202
|
: relativePath(wrapper.receiptPath, "receiptPath", errors);
|
|
102
203
|
const entries = [];
|
|
103
|
-
const
|
|
204
|
+
const claimed = [];
|
|
104
205
|
const claim = (output, label) => {
|
|
105
|
-
const key = output
|
|
106
|
-
|
|
107
|
-
|
|
206
|
+
const key = canonicalOutput(output);
|
|
207
|
+
const clash = claimed.find((other) => other.key === key || other.key.startsWith(`${key}/`) || key.startsWith(`${other.key}/`));
|
|
208
|
+
if (clash?.key === key) errors.push(`${label} writes ${output}, which ${clash.label} already writes`);
|
|
209
|
+
else if (clash) errors.push(`${label} writes ${output}, which overlaps ${clash.output} that ${clash.label} writes (outputs may not be nested inside each other)`);
|
|
210
|
+
else claimed.push({ key, output, label });
|
|
108
211
|
};
|
|
109
212
|
for (const [index, item] of (Array.isArray(wrapper.sidecars) ? wrapper.sidecars : []).entries()) {
|
|
110
213
|
const label = `sidecars[${index}]`;
|
|
@@ -121,27 +224,38 @@ export function validateSidecarManifest(raw, { target } = {}) {
|
|
|
121
224
|
if (value !== undefined && !SHA256.test(value ?? "")) errors.push(`${where}: ${name} must be a lowercase 64-hex SHA-256`);
|
|
122
225
|
};
|
|
123
226
|
const entry = { kind: item.kind, index, skipped, ...(platforms ? { platforms } : {}) };
|
|
227
|
+
const destOf = (value, field = "dest") => (value === undefined ? undefined : relativePath(value, `${where}: ${field}`, errors));
|
|
228
|
+
const cleanOf = () => {
|
|
229
|
+
if (item.clean !== undefined && typeof item.clean !== "boolean") errors.push(`${where}: clean must be a boolean`);
|
|
230
|
+
return item.clean === true;
|
|
231
|
+
};
|
|
232
|
+
const stagedClaim = (name) => `${stageDir}/${stagedName(name, t)}`;
|
|
124
233
|
if (item.kind === "cargo-bin") {
|
|
125
234
|
if (typeof item.package !== "string" || !NAME.test(item.package)) errors.push(`${where}: package must be a Cargo package name`);
|
|
126
235
|
const bin = item.bin ?? item.package;
|
|
127
236
|
if (typeof bin !== "string" || !NAME.test(bin)) errors.push(`${where}: bin must be a binary target name`);
|
|
128
237
|
const name = item.name ?? bin;
|
|
129
238
|
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
|
-
|
|
239
|
+
const declared = item.profile !== undefined ? parseProfile(item.profile, `${where}: profile`, errors) : (defaultProfile ?? "release");
|
|
240
|
+
const profile = isObject(declared) ? declared[mode] : declared;
|
|
132
241
|
const features = item.features ?? [];
|
|
133
242
|
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
243
|
const manifestPath = item.manifestPath === undefined ? undefined : relativePath(item.manifestPath, `${where}: manifestPath`, errors);
|
|
135
244
|
sha(item.sha256, "sha256");
|
|
136
|
-
|
|
137
|
-
|
|
245
|
+
const dest = destOf(item.dest);
|
|
246
|
+
const clean = cleanOf();
|
|
247
|
+
if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
|
|
248
|
+
Object.assign(entry, { package: item.package, bin, name, profile, features: [...features], ...(manifestPath ? { manifestPath } : {}), ...(item.sha256 ? { sha256: item.sha256 } : {}), ...(dest ? { dest } : {}), ...(clean ? { clean } : {}) });
|
|
138
249
|
} else if (item.kind === "ffmpeg") {
|
|
139
|
-
const { kind: _kind, platforms: _platforms, ...rest } = item;
|
|
250
|
+
const { kind: _kind, platforms: _platforms, catalog: rawCatalog, ...rest } = item;
|
|
140
251
|
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", ...] }`);
|
|
141
|
-
|
|
252
|
+
const catalogSpec = parseCatalogSpec(rawCatalog, `${where}: catalog`, errors, "ffmpeg");
|
|
253
|
+
if (catalogSpec) entry.catalogSpec = catalogSpec;
|
|
254
|
+
if (!skipped && t.arch === "universal") errors.push(`${where}: the catalog has no universal row; stage a lipo-merged file with a "command" or "resource" entry (machO: true) instead`);
|
|
255
|
+
else if (!skipped) {
|
|
142
256
|
try {
|
|
143
257
|
const config = validateMediaSidecarConfig({ ...rest, arch: t.arch, stageDir }, { platform: t.platform, hostArch: t.arch });
|
|
144
|
-
for (const tool of config.tools) claim(stagedFileName(tool, t.os, t.arch)
|
|
258
|
+
for (const tool of config.tools) claim(`${stageDir}/${stagedFileName(tool, t.os, t.arch)}`, where);
|
|
145
259
|
entry.media = config;
|
|
146
260
|
} catch (error) { errors.push(...String(error.message).split("\n").filter((line) => line.startsWith("- ")).map((line) => `${where}: ${line.slice(2)}`).concat(error.message.startsWith("invalid") ? [] : [`${where}: ${error.message}`])); }
|
|
147
261
|
}
|
|
@@ -150,20 +264,54 @@ export function validateSidecarManifest(raw, { target } = {}) {
|
|
|
150
264
|
const resourceDir = relativePath(item.resourceDir ?? DEFAULT_ORT_RESOURCE_DIR, `${where}: resourceDir`, errors);
|
|
151
265
|
const licenseDir = relativePath(item.licenseDir ?? (resourceDir ? `${resourceDir}/LICENSES` : "x"), `${where}: licenseDir`, errors);
|
|
152
266
|
const cacheDir = relativePath(item.cacheDir ?? ".cache/onnxruntime", `${where}: cacheDir`, errors);
|
|
153
|
-
const
|
|
267
|
+
const catalogSpec = parseCatalogSpec(item.catalog, `${where}: catalog`, errors, "onnxruntime");
|
|
154
268
|
if (item.as !== undefined && (typeof item.as !== "string" || !NAME.test(item.as))) errors.push(`${where}: as must be a bare file name`);
|
|
155
|
-
|
|
269
|
+
if (!skipped && t.arch === "universal") errors.push(`${where}: the catalog has no universal row; stage a lipo-merged file with a "command" or "resource" entry (machO: true) instead`);
|
|
270
|
+
Object.assign(entry, { version: item.version, resourceDir, licenseDir, cacheDir, ...(catalogSpec ? { catalogSpec } : {}), ...(item.as ? { as: item.as } : {}) });
|
|
271
|
+
} else if (item.kind === "command") {
|
|
272
|
+
const name = item.name;
|
|
273
|
+
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)`);
|
|
274
|
+
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)`);
|
|
275
|
+
const args = item.args ?? [];
|
|
276
|
+
if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) errors.push(`${where}: args must be a list of strings`);
|
|
277
|
+
const cwd = item.cwd === undefined ? undefined : relativePath(item.cwd, `${where}: cwd`, errors, { allowRoot: true });
|
|
278
|
+
const output = relativePath(item.output, `${where}: output`, errors);
|
|
279
|
+
const dest = destOf(item.dest);
|
|
280
|
+
const clean = cleanOf();
|
|
281
|
+
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>)`); }
|
|
282
|
+
else if (item.externalBin !== true) errors.push(`${where}: externalBin must be true (the output is staged as <name>-<triple>)`);
|
|
283
|
+
if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
|
|
284
|
+
Object.assign(entry, { name, cmd: item.cmd, args: Array.isArray(args) ? [...args] : [], ...(cwd ? { cwd } : {}), output, ...(dest ? { dest } : {}), ...(clean ? { clean } : {}) });
|
|
285
|
+
} else if (item.kind === "remote") {
|
|
286
|
+
const name = item.name;
|
|
287
|
+
if (typeof name !== "string" || !NAME.test(name)) errors.push(`${where}: name must be a bare label`);
|
|
288
|
+
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)`);
|
|
289
|
+
if (typeof item.sha256 !== "string" || !SHA256.test(item.sha256)) errors.push(`${where}: sha256 is required and must be a lowercase 64-hex SHA-256`);
|
|
290
|
+
if (item.dest !== undefined && item.to !== undefined) errors.push(`${where}: give dest or to, not both`);
|
|
291
|
+
if (item.dest === undefined && item.to === undefined) errors.push(`${where}: dest (or to) is required`);
|
|
292
|
+
const dest = destOf(item.dest ?? item.to, item.dest === undefined ? "to" : "dest");
|
|
293
|
+
if (item.size !== undefined && (!Number.isSafeInteger(item.size) || item.size <= 0)) errors.push(`${where}: size must be a positive integer (bytes)`);
|
|
294
|
+
const clean = cleanOf();
|
|
295
|
+
if (dest && !skipped) claim(dest, where);
|
|
296
|
+
Object.assign(entry, { name, url: item.url, sha256: item.sha256, dest, ...(item.size ? { size: item.size } : {}), ...(clean ? { clean } : {}) });
|
|
156
297
|
} else {
|
|
157
298
|
const from = relativePath(item.from, `${where}: from`, errors);
|
|
158
299
|
const to = relativePath(item.to, `${where}: to`, errors);
|
|
159
300
|
sha(item.sha256, "sha256");
|
|
301
|
+
if (item.machO !== undefined && typeof item.machO !== "boolean") errors.push(`${where}: machO must be a boolean`);
|
|
302
|
+
const clean = cleanOf();
|
|
160
303
|
if (to && !skipped) claim(to, where);
|
|
161
|
-
Object.assign(entry, { from, to, ...(item.sha256 ? { sha256: item.sha256 } : {}) });
|
|
304
|
+
Object.assign(entry, { from, to, ...(clean ? { clean } : {}),...(item.sha256 ? { sha256: item.sha256 } : {}), ...(item.machO ? { machO: true } : {}) });
|
|
162
305
|
}
|
|
163
306
|
entries.push(entry);
|
|
164
307
|
}
|
|
308
|
+
const protectedPaths = protectedOutputPaths({ stageDir, receiptPath, entries });
|
|
309
|
+
for (const { key, output, label } of claimed) {
|
|
310
|
+
const hit = protectedPaths.find((protectedPath) => reaches(key, protectedPath));
|
|
311
|
+
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`);
|
|
312
|
+
}
|
|
165
313
|
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 };
|
|
314
|
+
return { target: t, stageDir, receiptPath, wipeStageDir: wrapper.wipeStageDir === true, entries };
|
|
167
315
|
}
|
|
168
316
|
|
|
169
317
|
const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
@@ -196,6 +344,29 @@ async function defaultFetchBytes(url) {
|
|
|
196
344
|
return Buffer.from(await response.arrayBuffer());
|
|
197
345
|
}
|
|
198
346
|
|
|
347
|
+
/** Absolute catalog file for a validated spec. Crate resolution reads the rightkit metadata listing; npm walks up node_modules. */
|
|
348
|
+
function resolveCatalogFile(spec, ctx, kind) {
|
|
349
|
+
const label = kind === "ffmpeg" ? "rightkit-ffmpeg" : "native-libraries";
|
|
350
|
+
if (spec.via === "absolute") return spec.file;
|
|
351
|
+
if (spec.via === "path") return path.resolve(ctx.appRoot, spec.file);
|
|
352
|
+
if (spec.via === "crate") {
|
|
353
|
+
const meta = ctx.run("rightkit", ["cargo", "metadata", "--format-version", "1"], { cwd: ctx.appRoot, env: ctx.env });
|
|
354
|
+
let packages;
|
|
355
|
+
try { packages = JSON.parse(meta.stdout).packages; } catch { /* refused below */ }
|
|
356
|
+
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 });
|
|
357
|
+
const found = packages.filter((pkg) => pkg.name === spec.crate && typeof pkg.manifest_path === "string");
|
|
358
|
+
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 });
|
|
359
|
+
return path.join(path.dirname(found[0].manifest_path), ...spec.file.split("/"));
|
|
360
|
+
}
|
|
361
|
+
const parts = spec.file.split("/");
|
|
362
|
+
for (let dir = ctx.appRoot; ; dir = path.dirname(dir)) {
|
|
363
|
+
const candidate = path.join(dir, "node_modules", ...spec.pkg.split("/"), ...parts);
|
|
364
|
+
if (ctx.fs.existsSync(candidate)) return candidate;
|
|
365
|
+
if (path.dirname(dir) === dir) break;
|
|
366
|
+
}
|
|
367
|
+
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 });
|
|
368
|
+
}
|
|
369
|
+
|
|
199
370
|
function readJsonCatalog(entryCatalog, appRoot, fs, what) {
|
|
200
371
|
const file = path.resolve(appRoot, entryCatalog);
|
|
201
372
|
let bytes;
|
|
@@ -216,10 +387,50 @@ function artifactFromStream(stdout, bin) {
|
|
|
216
387
|
}
|
|
217
388
|
|
|
218
389
|
/** 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 }) {
|
|
390
|
+
export function builtBinaryCandidates({ targetDir, targetDirs, triple, profile, bin, windows }) {
|
|
220
391
|
const file = windows ? `${bin}.exe` : bin;
|
|
221
392
|
const dir = profileDirectory(profile);
|
|
222
|
-
|
|
393
|
+
const roots = [...new Set([...(targetDirs ?? []), ...(targetDir ? [targetDir] : [])])];
|
|
394
|
+
return roots.flatMap((root) => [path.join(root, triple, dir, file), path.join(root, dir, file)]);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* `clean: true` removes a stale file at the destination before staging. A directory is removed recursively only when the
|
|
399
|
+
* entry is a directory resource (`directory: true`); any other destination is removed non-recursively. Refused (typed,
|
|
400
|
+
* nothing deleted): the app root or anything outside it, a path equal to or containing a protected path (the stage
|
|
401
|
+
* directory, receipt, cache, .right-release, .git, src, src-tauri, node_modules, package.json), and a destination whose
|
|
402
|
+
* parent resolves (through a symlink) outside the app root or onto a protected path.
|
|
403
|
+
*/
|
|
404
|
+
function cleanDest(ctx, entry, dest, { directory = false } = {}) {
|
|
405
|
+
if (!entry.clean) return;
|
|
406
|
+
const refuse = (reason) => fail("STAGE_SIDECAR_CLEAN_REFUSED", `refusing to clean ${dest}: ${reason}`, { dest });
|
|
407
|
+
const protectedPaths = ctx.protectedPaths ?? [];
|
|
408
|
+
const insideApp = (root, file) => { const relative = path.relative(root, file); return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? relative : undefined; };
|
|
409
|
+
const check = (relative, what) => {
|
|
410
|
+
const key = canonicalOutput(relative.split(path.sep).join("/"));
|
|
411
|
+
const hit = protectedPaths.find((protectedPath) => reaches(key, protectedPath));
|
|
412
|
+
if (hit) refuse(`${what} is or contains the protected path ${hit}`);
|
|
413
|
+
};
|
|
414
|
+
const relative = insideApp(ctx.appRoot, dest);
|
|
415
|
+
if (!relative) refuse("it is the app root or outside the app");
|
|
416
|
+
check(relative, "it");
|
|
417
|
+
const realpath = ctx.fs.realpathSync;
|
|
418
|
+
if (typeof realpath === "function") {
|
|
419
|
+
const resolveExisting = (start) => {
|
|
420
|
+
for (let current = start; ; current = path.dirname(current)) {
|
|
421
|
+
try { return realpath(current); } catch { if (path.dirname(current) === current) return undefined; }
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
const realRoot = resolveExisting(ctx.appRoot);
|
|
425
|
+
const realParent = resolveExisting(path.dirname(dest));
|
|
426
|
+
if (!realRoot || !realParent) refuse("its parent directory cannot be resolved");
|
|
427
|
+
if (realParent !== realRoot && !insideApp(realRoot, realParent)) refuse("its parent directory resolves outside the app (a symlink)");
|
|
428
|
+
const realRelative = insideApp(realRoot, path.join(realParent, path.basename(dest)));
|
|
429
|
+
if (!realRelative) refuse("it resolves to the app root");
|
|
430
|
+
check(realRelative, "its resolved location");
|
|
431
|
+
}
|
|
432
|
+
ctx.log(`stage-sidecar: clean ${rel(ctx, dest)}`);
|
|
433
|
+
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
434
|
}
|
|
224
435
|
|
|
225
436
|
function copyInto(ctx, source, dest, { executable }) {
|
|
@@ -233,7 +444,7 @@ function copyInto(ctx, source, dest, { executable }) {
|
|
|
233
444
|
fs.copyFileSync(source, temporary);
|
|
234
445
|
if (executable && !windows) fs.chmodSync(temporary, 0o755);
|
|
235
446
|
fs.renameSync(temporary, dest);
|
|
236
|
-
}
|
|
447
|
+
} else if (executable && !windows) fs.chmodSync(dest, 0o755);
|
|
237
448
|
const sha256 = sha256Of(fs.readFileSync(dest));
|
|
238
449
|
if (sha256 !== sourceSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `staged ${dest} differs from its source ${source}`, { expected: sourceSha256, actual: sha256 });
|
|
239
450
|
return { sha256, sizeBytes: fs.statSync(dest).size, changed };
|
|
@@ -241,10 +452,54 @@ function copyInto(ctx, source, dest, { executable }) {
|
|
|
241
452
|
|
|
242
453
|
const rel = (ctx, file) => path.relative(ctx.appRoot, file).replaceAll("\\", "/");
|
|
243
454
|
|
|
455
|
+
/** `lipo -archs <file>` as a list; a failing lipo is a typed error. */
|
|
456
|
+
function lipoArchs(ctx, file) {
|
|
457
|
+
const result = ctx.run("lipo", ["-archs", file], { cwd: ctx.appRoot, env: ctx.env });
|
|
458
|
+
if (result?.error || result?.status !== 0) fail("STAGE_SIDECAR_NOT_UNIVERSAL", `lipo -archs ${file} failed (${result?.error?.message ?? `exit ${result?.status}`})`, { file });
|
|
459
|
+
return String(result.stdout ?? "").trim().split(/\s+/).filter(Boolean);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** A universal file must carry both slices; anything thinner is refused. */
|
|
463
|
+
function requireUniversalFile(ctx, file, label) {
|
|
464
|
+
const archs = lipoArchs(ctx, file);
|
|
465
|
+
if (!(archs.includes("arm64") && archs.includes("x86_64"))) fail("STAGE_SIDECAR_NOT_UNIVERSAL", `${label} must contain arm64 and x86_64 for universal-apple-darwin, lipo -archs reports ${JSON.stringify(archs.join(" "))}`, { file, archs });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const destFor = (entry, ctx) => (entry.dest ? path.resolve(ctx.appRoot, entry.dest) : path.resolve(ctx.appRoot, ctx.stageDir, stagedName(entry.name, ctx.target)));
|
|
469
|
+
|
|
244
470
|
function stageCargoBin(entry, ctx) {
|
|
245
|
-
const
|
|
471
|
+
const dest = destFor(entry, ctx);
|
|
472
|
+
cleanDest(ctx, entry, dest);
|
|
473
|
+
if (ctx.target.arch !== "universal") return stageCargoSlice(entry, ctx, ctx.target.triple, { dest });
|
|
474
|
+
const slices = SLICES.map((triple) => ({ triple, ...stageCargoBuild(entry, ctx, triple) }));
|
|
475
|
+
const { fs, run, appRoot, env } = ctx;
|
|
476
|
+
const temporary = `${dest}.tmp-${ctx.pid}`;
|
|
477
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
478
|
+
const args = ["-create", "-output", temporary, ...slices.map((slice) => slice.source)];
|
|
479
|
+
ctx.log(`stage-sidecar: lipo ${args.join(" ")}`);
|
|
480
|
+
const merged = run("lipo", args, { cwd: appRoot, env });
|
|
481
|
+
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) });
|
|
482
|
+
requireUniversalFile(ctx, temporary, `${entry.name} after lipo`);
|
|
483
|
+
const sha256 = sha256Of(fs.readFileSync(temporary));
|
|
484
|
+
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 });
|
|
485
|
+
const changed = !(fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === sha256);
|
|
486
|
+
fs.chmodSync(temporary, 0o755);
|
|
487
|
+
fs.renameSync(temporary, dest);
|
|
488
|
+
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 })) }];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function stageCargoSlice(entry, ctx, triple, { dest }) {
|
|
492
|
+
const { source, sourceSha256 } = stageCargoBuild(entry, ctx, triple);
|
|
493
|
+
if (entry.sha256 && entry.sha256 !== sourceSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.bin} built to ${sourceSha256}, expected ${entry.sha256}`, { expected: entry.sha256, actual: sourceSha256 });
|
|
494
|
+
const copied = copyInto(ctx, source, dest, { executable: true });
|
|
495
|
+
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 }];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Build one slice through `rightkit cargo` and locate the executable. Returns { source, sourceSha256 }. */
|
|
499
|
+
function stageCargoBuild(entry, ctx, triple) {
|
|
500
|
+
const { run, appRoot, env, fs } = ctx;
|
|
246
501
|
const args = ["cargo", "build", "--locked", ...(entry.manifestPath ? ["--manifest-path", path.resolve(appRoot, entry.manifestPath)] : []),
|
|
247
|
-
"-p", entry.package, "--bin", entry.bin, "--target",
|
|
502
|
+
"-p", entry.package, "--bin", entry.bin, "--target", triple, "--profile", entry.profile,
|
|
248
503
|
...(entry.features.length ? ["--features", entry.features.join(",")] : []), "--message-format=json-render-diagnostics"];
|
|
249
504
|
ctx.log(`stage-sidecar: rightkit ${args.join(" ")}`);
|
|
250
505
|
const built = run("rightkit", args, { cwd: appRoot, env });
|
|
@@ -254,16 +509,17 @@ function stageCargoBin(entry, ctx) {
|
|
|
254
509
|
const meta = run("rightkit", ["cargo", "metadata", "--format-version", "1", "--no-deps", ...(entry.manifestPath ? ["--manifest-path", path.resolve(appRoot, entry.manifestPath)] : [])], { cwd: appRoot, env });
|
|
255
510
|
let targetDir;
|
|
256
511
|
try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
|
|
257
|
-
if (meta
|
|
258
|
-
|
|
512
|
+
if (meta?.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) targetDir = undefined;
|
|
513
|
+
// A managed target root: the broker/cargo metadata target_directory (which cargo derives from CARGO_TARGET_DIR) is
|
|
514
|
+
// authoritative; an explicit CARGO_TARGET_DIR in this environment is tried too, resolved against the app root.
|
|
515
|
+
const envDir = typeof env.CARGO_TARGET_DIR === "string" && env.CARGO_TARGET_DIR.trim() ? path.resolve(appRoot, env.CARGO_TARGET_DIR) : undefined;
|
|
516
|
+
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 });
|
|
517
|
+
const candidates = builtBinaryCandidates({ targetDir, targetDirs: envDir ? [envDir] : [], triple, profile: entry.profile, bin: entry.bin, windows: ctx.windows });
|
|
259
518
|
source = candidates.find((file) => fs.existsSync(file));
|
|
260
519
|
if (!source) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `the build did not produce ${entry.bin} (looked in ${candidates.join(", ")})`, { bin: entry.bin, candidates });
|
|
261
520
|
}
|
|
262
521
|
const sourceSha256 = sha256Of(fs.readFileSync(source));
|
|
263
|
-
|
|
264
|
-
const dest = path.resolve(appRoot, ctx.stageDir, stagedFileName(entry.name, target.os, target.arch));
|
|
265
|
-
const copied = copyInto(ctx, source, dest, { executable: true });
|
|
266
|
-
return [{ kind: "cargo-bin", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256, package: entry.package, bin: entry.bin, profile: entry.profile, features: entry.features, externalBin: true }];
|
|
522
|
+
return { source, sourceSha256 };
|
|
267
523
|
}
|
|
268
524
|
|
|
269
525
|
async function stageFfmpeg(entry, ctx) {
|
|
@@ -272,8 +528,8 @@ async function stageFfmpeg(entry, ctx) {
|
|
|
272
528
|
try {
|
|
273
529
|
let resolved = await ctx.loadMediaCatalog({ config, appRoot, entry });
|
|
274
530
|
if (!resolved) {
|
|
275
|
-
if (!
|
|
276
|
-
resolved = readJsonCatalog(
|
|
531
|
+
if (!entry.catalogSpec) fail("STAGE_SIDECAR_CATALOG_UNREADABLE", "ffmpeg needs a catalog (the rightkit-ffmpeg artifacts.json: a path, { crate } or { npm }) or deps.loadMediaCatalog");
|
|
532
|
+
resolved = readJsonCatalog(resolveCatalogFile(entry.catalogSpec, ctx, "ffmpeg"), appRoot, fs, "rightkit-ffmpeg");
|
|
277
533
|
}
|
|
278
534
|
const plan = planMediaSidecar({ config, catalog: resolved.catalog, crateDir: resolved.crateDir, crateVersion: resolved.crateVersion, catalogBytes: resolved.catalogBytes, root: appRoot });
|
|
279
535
|
if (config.stage) {
|
|
@@ -296,7 +552,7 @@ async function stageFfmpeg(entry, ctx) {
|
|
|
296
552
|
|
|
297
553
|
async function stageOnnxRuntime(entry, ctx) {
|
|
298
554
|
const { appRoot, fs, target } = ctx;
|
|
299
|
-
const loaded = await (ctx.loadNativeCatalog({ entry, appRoot }) ?? (entry.
|
|
555
|
+
const loaded = await (ctx.loadNativeCatalog({ entry, appRoot }) ?? (entry.catalogSpec ? readJsonCatalog(resolveCatalogFile(entry.catalogSpec, ctx, "onnxruntime"), appRoot, fs, "native-libraries") : undefined));
|
|
300
556
|
if (!loaded) fail("STAGE_SIDECAR_CATALOG_UNREADABLE", "onnxruntime needs a catalog path (the rightkit-ffmpeg native-libraries.json) or deps.loadNativeCatalog");
|
|
301
557
|
const rows = (loaded.catalog?.libraries ?? []).filter((row) => row.library === "onnxruntime" && row.version === entry.version && row.os === target.os && row.arch === target.arch);
|
|
302
558
|
const where = `onnxruntime ${entry.version} ${target.os}/${target.arch}`;
|
|
@@ -346,34 +602,189 @@ async function stageOnnxRuntime(entry, ctx) {
|
|
|
346
602
|
return [{ kind: "onnxruntime", name: outName, path: rel(ctx, dest), sha256: row.sha256, sizeBytes: fs.statSync(dest).size, changed: null, catalogRow: row.id, version: row.version, archiveSha256: row.archive.sha256, license: row.licenseSpdx ?? null, notices: noticeOut, externalBin: false }];
|
|
347
603
|
}
|
|
348
604
|
|
|
605
|
+
/** Every file under `dir`, sorted, as paths relative to it (forward slashes). */
|
|
606
|
+
function listFilesRecursive(fs, dir, prefix = "") {
|
|
607
|
+
const out = [];
|
|
608
|
+
for (const name of [...fs.readdirSync(path.join(dir, prefix))].sort()) {
|
|
609
|
+
const relative = prefix ? `${prefix}/${name}` : name;
|
|
610
|
+
const stat = fs.statSync(path.join(dir, relative));
|
|
611
|
+
if (stat.isDirectory()) out.push(...listFilesRecursive(fs, dir, relative));
|
|
612
|
+
else if (stat.isFile()) out.push(relative);
|
|
613
|
+
}
|
|
614
|
+
return out;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** A directory resource: copy recursively, hash and verify every file, one receipt row per file. */
|
|
618
|
+
function stageResourceDirectory(entry, ctx, source) {
|
|
619
|
+
const { appRoot, fs } = ctx;
|
|
620
|
+
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 });
|
|
621
|
+
if (entry.machO) fail("STAGE_SIDECAR_CONFIG_INVALID", `resource ${entry.from} is a directory; machO applies to one file`, { from: entry.from });
|
|
622
|
+
const members = listFilesRecursive(fs, source);
|
|
623
|
+
if (!members.length) fail("STAGE_SIDECAR_RESOURCE_MISSING", `resource directory ${entry.from} contains no files`, { from: entry.from });
|
|
624
|
+
const destRoot = path.resolve(appRoot, entry.to);
|
|
625
|
+
cleanDest(ctx, entry, destRoot, { directory: true });
|
|
626
|
+
return members.map((member) => {
|
|
627
|
+
const from = path.join(source, ...member.split("/"));
|
|
628
|
+
const dest = path.join(destRoot, ...member.split("/"));
|
|
629
|
+
const sourceSha256 = sha256Of(fs.readFileSync(from));
|
|
630
|
+
const copied = copyInto(ctx, from, dest, { executable: false });
|
|
631
|
+
return { kind: "resource", name: path.posix.basename(member), path: rel(ctx, dest), ...copied, sourceSha256, from: `${entry.from}/${member}`, externalBin: false };
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
349
635
|
function stageResource(entry, ctx) {
|
|
350
636
|
const { appRoot, fs } = ctx;
|
|
351
637
|
const source = path.resolve(appRoot, entry.from);
|
|
352
|
-
if (
|
|
638
|
+
if (fs.existsSync(source) && fs.statSync(source).isDirectory()) return stageResourceDirectory(entry, ctx, source);
|
|
639
|
+
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
640
|
const actual = sha256Of(fs.readFileSync(source));
|
|
354
641
|
if (entry.sha256 && entry.sha256 !== actual) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.from} is ${actual}, expected ${entry.sha256}`, { expected: entry.sha256, actual });
|
|
642
|
+
if (entry.machO && ctx.target.arch === "universal") requireUniversalFile(ctx, source, `resource ${entry.from}`);
|
|
355
643
|
const dest = path.resolve(appRoot, entry.to);
|
|
644
|
+
cleanDest(ctx, entry, dest);
|
|
356
645
|
const copied = copyInto(ctx, source, dest, { executable: false });
|
|
357
646
|
return [{ kind: "resource", name: path.basename(dest), path: rel(ctx, dest), ...copied, sourceSha256: actual, from: entry.from, externalBin: false }];
|
|
358
647
|
}
|
|
359
648
|
|
|
649
|
+
/** Run the entry's command (argv, no shell), then stage its output as <name>-<triple>. */
|
|
650
|
+
function stageCommand(entry, ctx) {
|
|
651
|
+
const { appRoot, fs, run, env } = ctx;
|
|
652
|
+
const cwd = entry.cwd ? path.resolve(appRoot, entry.cwd) : appRoot;
|
|
653
|
+
cleanDest(ctx, entry, destFor(entry, ctx));
|
|
654
|
+
ctx.log(`stage-sidecar: ${entry.cmd} ${entry.args.join(" ")}`);
|
|
655
|
+
const built = run(entry.cmd, entry.args, { cwd, env });
|
|
656
|
+
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) });
|
|
657
|
+
const source = path.resolve(appRoot, entry.output);
|
|
658
|
+
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 });
|
|
659
|
+
if (ctx.target.arch === "universal") requireUniversalFile(ctx, source, `command ${entry.name} output ${entry.output}`);
|
|
660
|
+
const sourceSha256 = sha256Of(fs.readFileSync(source));
|
|
661
|
+
const dest = destFor(entry, ctx);
|
|
662
|
+
const copied = copyInto(ctx, source, dest, { executable: true });
|
|
663
|
+
return [{ kind: "command", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256, output: entry.output, externalBin: !entry.dest }];
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* A pinned https download. The bytes are hashed before anything is moved into place: the content-addressed cache entry
|
|
668
|
+
* (`.right-release/cache/remote/<sha256>`) is written only from verified bytes, and `dest` is copied only from that
|
|
669
|
+
* cache entry. A mismatch (or a non-https redirect) fails closed with nothing staged.
|
|
670
|
+
*/
|
|
671
|
+
async function stageRemote(entry, ctx) {
|
|
672
|
+
const { appRoot, fs } = ctx;
|
|
673
|
+
const dest = path.resolve(appRoot, entry.dest);
|
|
674
|
+
const cached = path.resolve(appRoot, REMOTE_CACHE_DIR, entry.sha256);
|
|
675
|
+
let fromCache = false;
|
|
676
|
+
if (fs.existsSync(cached) && sha256Of(fs.readFileSync(cached)) === entry.sha256) fromCache = true;
|
|
677
|
+
else {
|
|
678
|
+
ctx.log(`stage-sidecar: GET ${entry.url}`);
|
|
679
|
+
let response;
|
|
680
|
+
try { response = await ctx.fetch(entry.url, { redirect: "follow" }); } catch (error) { fail("STAGE_SIDECAR_DOWNLOAD_FAILED", `GET ${entry.url} failed: ${error.message}`, { cause: error, url: entry.url }); }
|
|
681
|
+
if (!response?.ok) fail("STAGE_SIDECAR_DOWNLOAD_FAILED", `GET ${entry.url} failed with ${response?.status}`, { url: entry.url });
|
|
682
|
+
if (typeof response.url === "string" && response.url && !response.url.startsWith("https://")) fail("STAGE_SIDECAR_DOWNLOAD_FAILED", `${entry.url} redirected to a non-https URL: ${response.url}`, { url: entry.url });
|
|
683
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
684
|
+
if (entry.size !== undefined && bytes.length !== entry.size) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.url} is ${bytes.length} bytes, expected ${entry.size}; nothing staged`, { expectedSize: entry.size, actualSize: bytes.length });
|
|
685
|
+
const actual = sha256Of(bytes);
|
|
686
|
+
if (actual !== entry.sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.url} downloaded as ${actual}, expected ${entry.sha256}; nothing staged`, { expected: entry.sha256, actual });
|
|
687
|
+
fs.mkdirSync(path.dirname(cached), { recursive: true });
|
|
688
|
+
const temporary = `${cached}.tmp-${ctx.pid}`;
|
|
689
|
+
fs.writeFileSync(temporary, bytes);
|
|
690
|
+
fs.renameSync(temporary, cached);
|
|
691
|
+
}
|
|
692
|
+
// Download and verify first: a failed fetch or a hash mismatch must never leave a cleaned destination behind.
|
|
693
|
+
cleanDest(ctx, entry, dest);
|
|
694
|
+
const copied = copyInto(ctx, cached, dest, { executable: false });
|
|
695
|
+
if (copied.sha256 !== entry.sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `staged ${entry.dest} is ${copied.sha256}, expected ${entry.sha256}`, { expected: entry.sha256, actual: copied.sha256 });
|
|
696
|
+
return [{ kind: "remote", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256: entry.sha256, url: entry.url, cached: fromCache, externalBin: false }];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* `wipeStageDir`: remove top-level files in stageDir named exactly `<name>-<current triple>[.exe]` for a name this manifest
|
|
701
|
+
* declares (a cargo-bin, command or ffmpeg tool) that this run did not produce, such as an entry now skipped on this platform
|
|
702
|
+
* or now placed at `dest`. Never recursive; other triples, notices, directories, temporaries and unrelated files are left alone.
|
|
703
|
+
*/
|
|
704
|
+
function wipeStaleStageDir(ctx, plan, produced) {
|
|
705
|
+
const dir = path.resolve(ctx.appRoot, ctx.stageDir);
|
|
706
|
+
let listing;
|
|
707
|
+
try { listing = [...ctx.fs.readdirSync(dir)]; } catch { return []; }
|
|
708
|
+
const keep = new Set([...produced, path.resolve(ctx.appRoot, plan.receiptPath)].map((file) => path.resolve(file).toLowerCase()));
|
|
709
|
+
const names = plan.entries.flatMap((entry) => (entry.kind === "ffmpeg" ? (entry.media?.tools ?? []) : entry.kind === "cargo-bin" || entry.kind === "command" ? [entry.name] : []));
|
|
710
|
+
const exact = new Set(names.flatMap((name) => [`${name}-${ctx.target.triple}`, `${name}-${ctx.target.triple}.exe`]));
|
|
711
|
+
const removed = [];
|
|
712
|
+
for (const name of listing.sort()) {
|
|
713
|
+
const file = path.join(dir, name);
|
|
714
|
+
if (keep.has(file.toLowerCase()) || !exact.has(name)) continue;
|
|
715
|
+
let regular = false;
|
|
716
|
+
try { regular = ctx.fs.statSync(file).isFile(); } catch { regular = false; }
|
|
717
|
+
if (!regular) continue;
|
|
718
|
+
ctx.log(`stage-sidecar: wipe stale ${rel(ctx, file)}`);
|
|
719
|
+
ctx.fs.rmSync(file, { recursive: false, force: true });
|
|
720
|
+
removed.push(rel(ctx, file));
|
|
721
|
+
}
|
|
722
|
+
return removed;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Nested broker context: Tauri exports TAURI_ENV_* to beforeBuildCommand/beforeDevCommand, and on a managed host that
|
|
727
|
+
* command runs inside the outer broker build. A broker call from there exits 125. There is no lease or run-id a nested
|
|
728
|
+
* call can reuse (acquireManagedRunLease records a per-process lease file; no RK_* variable carries it to a child), so
|
|
729
|
+
* the only safe behaviour is to refuse early. RIGHTKIT_STAGE_SIDECAR_ALLOW_NESTED=1 opts out.
|
|
730
|
+
*/
|
|
731
|
+
export function isNestedBrokerContext(env = process.env) {
|
|
732
|
+
if (env?.RIGHTKIT_STAGE_SIDECAR_ALLOW_NESTED === "1") return false;
|
|
733
|
+
const insideTauri = Boolean(env?.TAURI_ENV_PLATFORM || env?.TAURI_ENV_TARGET_TRIPLE);
|
|
734
|
+
return insideTauri && (managedHost(env ?? {}) || env?.RIGHTSUITE_HEAVY_WORK_OWNER === "1");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** True when staging would call `rightkit cargo` for a non-skipped entry. */
|
|
738
|
+
const needsBroker = (plan) => plan.entries.some((entry) => !entry.skipped && (entry.kind === "cargo-bin" || entry.catalogSpec?.via === "crate"));
|
|
739
|
+
|
|
740
|
+
function assertNotNested(plan, env) {
|
|
741
|
+
if (needsBroker(plan) && isNestedBrokerContext(env)) {
|
|
742
|
+
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.");
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Dry plan for `stage-sidecar --check`: validates the manifest and describes what staging would write. No effects.
|
|
748
|
+
*/
|
|
749
|
+
export function planSidecarStaging({ manifest, target, env = process.env } = {}) {
|
|
750
|
+
const plan = validateSidecarManifest(manifest, { target, mode: resolveBuildMode(env) });
|
|
751
|
+
const t = plan.target;
|
|
752
|
+
const outputsOf = (entry) => {
|
|
753
|
+
if (entry.kind === "cargo-bin" || entry.kind === "command") return [entry.dest ?? `${plan.stageDir}/${stagedName(entry.name, t)}`];
|
|
754
|
+
if (entry.kind === "remote") return [entry.dest];
|
|
755
|
+
if (entry.kind === "resource") return [entry.to];
|
|
756
|
+
if (entry.kind === "onnxruntime") return [`${entry.resourceDir}/${entry.as ?? "<catalog artifactFileName>"}`];
|
|
757
|
+
return (entry.media?.tools ?? []).map((tool) => `${plan.stageDir}/${stagedFileName(tool, t.os, t.arch)}`);
|
|
758
|
+
};
|
|
759
|
+
const entries = plan.entries.map((entry) => ({
|
|
760
|
+
index: entry.index, kind: entry.kind, skipped: entry.skipped, ...(entry.platforms ? { platforms: entry.platforms } : {}),
|
|
761
|
+
...(entry.skipped ? {} : { outputs: outputsOf(entry), externalBin: (entry.kind === "cargo-bin" || entry.kind === "command") ? !entry.dest : entry.kind === "ffmpeg" }),
|
|
762
|
+
...(entry.kind === "cargo-bin" ? { package: entry.package, bin: entry.bin, profile: entry.profile } : {}),
|
|
763
|
+
...(entry.kind === "remote" ? { url: entry.url } : {}),
|
|
764
|
+
...(entry.clean ? { clean: true } : {}),
|
|
765
|
+
}));
|
|
766
|
+
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 };
|
|
767
|
+
}
|
|
768
|
+
|
|
360
769
|
/**
|
|
361
770
|
* Stage every configured sidecar for `target` (a Rust triple) and write the staging receipt.
|
|
362
771
|
* Returns { receipt, receiptPath, files, externalBinNames }. `deps` (all optional) injects every effect:
|
|
363
|
-
* fs, run, fetchBytes, extractMember, loadMediaCatalog, loadNativeCatalog, makeTempDir, removeDir, now, pid, log, env.
|
|
772
|
+
* 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
773
|
*/
|
|
365
774
|
export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {}) {
|
|
366
775
|
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
776
|
const env = deps.env ?? process.env;
|
|
777
|
+
const plan = validateSidecarManifest(manifest, { target, mode: resolveBuildMode(env) });
|
|
778
|
+
assertNotNested(plan, env);
|
|
779
|
+
const fs = deps.fs ?? nodeFs;
|
|
370
780
|
const run = deps.run ?? defaultRun;
|
|
371
781
|
const ctx = {
|
|
372
|
-
appRoot, target: plan.target, stageDir: plan.stageDir, fs, env, run,
|
|
782
|
+
appRoot, target: plan.target, stageDir: plan.stageDir, fs, env, run, protectedPaths: protectedOutputPaths(plan),
|
|
373
783
|
windows: plan.target.os === "windows",
|
|
374
784
|
pid: deps.pid ?? process.pid,
|
|
375
785
|
log: deps.log ?? (() => {}),
|
|
376
786
|
fetchBytes: deps.fetchBytes ?? defaultFetchBytes,
|
|
787
|
+
fetch: deps.fetch ?? ((...args) => globalThis.fetch(...args)),
|
|
377
788
|
extractMember: deps.extractMember ?? ((args) => defaultExtractMember({ ...args, run })),
|
|
378
789
|
loadMediaCatalog: deps.loadMediaCatalog ?? (() => undefined),
|
|
379
790
|
loadNativeCatalog: deps.loadNativeCatalog ?? (() => undefined),
|
|
@@ -387,9 +798,12 @@ export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {
|
|
|
387
798
|
const staged = entry.kind === "cargo-bin" ? stageCargoBin(entry, ctx)
|
|
388
799
|
: entry.kind === "ffmpeg" ? await stageFfmpeg(entry, ctx)
|
|
389
800
|
: entry.kind === "onnxruntime" ? await stageOnnxRuntime(entry, ctx)
|
|
390
|
-
:
|
|
801
|
+
: entry.kind === "command" ? stageCommand(entry, ctx)
|
|
802
|
+
: entry.kind === "remote" ? await stageRemote(entry, ctx)
|
|
803
|
+
: stageResource(entry, ctx);
|
|
391
804
|
files.push(...staged);
|
|
392
805
|
}
|
|
806
|
+
const wiped = plan.wipeStageDir ? wipeStaleStageDir(ctx, plan, files.map((file) => path.resolve(appRoot, file.path))) : [];
|
|
393
807
|
const receipt = {
|
|
394
808
|
schema: 1,
|
|
395
809
|
contract: STAGE_SIDECAR_CONTRACT,
|
|
@@ -399,6 +813,7 @@ export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {
|
|
|
399
813
|
stagedAt: (deps.now ?? (() => new Date().toISOString()))(),
|
|
400
814
|
files,
|
|
401
815
|
skipped,
|
|
816
|
+
...(plan.wipeStageDir ? { wiped } : {}),
|
|
402
817
|
};
|
|
403
818
|
validateStageSidecarReceipt(receipt, { target: plan.target.triple });
|
|
404
819
|
const receiptFile = path.resolve(appRoot, plan.receiptPath);
|
|
@@ -415,7 +830,7 @@ export function validateStageSidecarReceipt(receipt, expected = {}) {
|
|
|
415
830
|
for (const file of receipt.files) {
|
|
416
831
|
if (typeof file.name !== "string" || !file.name || typeof file.path !== "string" || !file.path) bad("file without a name or path");
|
|
417
832
|
if (!SHA256.test(file.sha256 ?? "")) bad(`${file.name}.sha256 must be a SHA-256`);
|
|
418
|
-
if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes
|
|
833
|
+
if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes < (file.kind === "resource" ? 0 : 1)) bad(`${file.name}.sizeBytes must be positive`);
|
|
419
834
|
}
|
|
420
835
|
if (new Set(receipt.files.map((file) => file.path)).size !== receipt.files.length) bad("duplicate staged path");
|
|
421
836
|
if (expected.target && receipt.target !== expected.target) bad(`target ${receipt.target} is not ${expected.target}`);
|