@rightkit/release 0.2.172 → 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 +7 -3
- package/development-signing.mjs +135 -4
- package/development.mjs +27 -12
- 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 +231 -0
- package/inventory-check.mjs +1 -1
- package/mac-bundle-signing.mjs +143 -1
- package/mac-package-recipe.mjs +304 -0
- package/mac-signing-matrix.mjs +73 -6
- package/macos-signing-session.mjs +12 -2
- package/media-sidecar.mjs +1 -1
- package/package-recipe-targets.mjs +245 -0
- package/package.json +1 -1
- package/portable-development.mjs +9 -9
- package/project-type.mjs +1 -1
- package/qa-build-features.mjs +7 -1
- package/recipe-build.mjs +346 -0
- package/release.mjs +27 -9
- package/rightkit-signing-agent.mjs +10 -4
- package/rightkit-sync.mjs +1 -1
- package/rightkit-versions.json +11 -6
- package/runtime-budgets.mjs +14 -5
- package/stage-binary.mjs +1 -1
- package/stage-sidecar-command.mjs +73 -0
- package/stage-sidecar.mjs +839 -0
- package/swift-mac-dmg.mjs +2 -1
- package/win-package-recipe.mjs +321 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
// Declarative engine and sidecar staging for Tauri externalBin (bogusyogi/rightkit#345).
|
|
2
|
+
//
|
|
3
|
+
// Eight apps hand-rolled the same script: build an engine through `rightkit cargo`, find the executable, copy it to
|
|
4
|
+
// src-tauri/binaries/<name>-<target-triple>[.exe], stage ffmpeg, stage onnxruntime, copy model resources. This module is
|
|
5
|
+
// that script, driven by a `sidecars` list in right-release.config.mjs:
|
|
6
|
+
//
|
|
7
|
+
// sidecars: [
|
|
8
|
+
// { kind: "cargo-bin", package: "mailright-engine", bin?, name?, profile?: "release" | { dev, release }, features?, manifestPath?, sha256?, dest?, clean?, platforms? },
|
|
9
|
+
// { kind: "ffmpeg", variant?, tools?, notices: { dir, sourceOfferContact }, catalog, stage?, bundleDir? },
|
|
10
|
+
// { kind: "onnxruntime", version, catalog, resourceDir?: "src-tauri/resources/runtime", as?, licenseDir?, cacheDir? },
|
|
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? },
|
|
14
|
+
// ]
|
|
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
|
+
//
|
|
25
|
+
// It never signs (release.mjs signs staged files) and never calls bare cargo. Every effect goes through `deps`.
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import nodeFs from "node:fs";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
|
|
31
|
+
import { resolveCommand } from "./development.mjs";
|
|
32
|
+
import { managedHost } from "./managed-cargo.mjs";
|
|
33
|
+
import {
|
|
34
|
+
planMediaSidecar, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
|
|
35
|
+
} from "./media-sidecar.mjs";
|
|
36
|
+
|
|
37
|
+
export const STAGE_SIDECAR_CONTRACT = "rightkit-stage-sidecar-v1";
|
|
38
|
+
export const STAGE_SIDECAR_RECEIPT_NAME = "stage-sidecar.json";
|
|
39
|
+
export const DEFAULT_STAGE_DIR = "src-tauri/binaries";
|
|
40
|
+
export const DEFAULT_ORT_RESOURCE_DIR = "src-tauri/resources/runtime";
|
|
41
|
+
|
|
42
|
+
export class StageSidecarError extends Error {
|
|
43
|
+
constructor(code, message, details = {}) {
|
|
44
|
+
super(message, details.cause ? { cause: details.cause } : undefined);
|
|
45
|
+
this.name = "StageSidecarError";
|
|
46
|
+
this.code = code;
|
|
47
|
+
const { cause: _cause, ...rest } = details;
|
|
48
|
+
this.details = rest;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fail = (code, message, details) => { throw new StageSidecarError(code, message, details); };
|
|
53
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
54
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
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_-]*$/;
|
|
62
|
+
const PLATFORMS = new Set(["mac", "win", "linux"]);
|
|
63
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
64
|
+
const KEYS = Object.freeze({
|
|
65
|
+
"cargo-bin": ["kind", "package", "bin", "name", "profile", "features", "manifestPath", "sha256", "dest", "clean", "platforms"],
|
|
66
|
+
ffmpeg: ["kind", "variant", "tools", "notices", "catalog", "stage", "bundleDir", "platforms"],
|
|
67
|
+
onnxruntime: ["kind", "version", "catalog", "resourceDir", "as", "licenseDir", "cacheDir", "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"],
|
|
71
|
+
});
|
|
72
|
+
const TRIPLES = Object.freeze({
|
|
73
|
+
"aarch64-apple-darwin": { platform: "mac", os: "macos", arch: "aarch64" },
|
|
74
|
+
"x86_64-apple-darwin": { platform: "mac", os: "macos", arch: "x86_64" },
|
|
75
|
+
"x86_64-pc-windows-msvc": { platform: "win", os: "windows", arch: "x86_64" },
|
|
76
|
+
"aarch64-pc-windows-msvc": { platform: "win", os: "windows", arch: "aarch64" },
|
|
77
|
+
"x86_64-unknown-linux-gnu": { platform: "linux", os: "linux", arch: "x86_64" },
|
|
78
|
+
"aarch64-unknown-linux-gnu": { platform: "linux", os: "linux", arch: "aarch64" },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** Parse a Rust target triple into { triple, platform, os, arch }. universal-apple-darwin has arch "universal" (cargo-bin lipos both slices). */
|
|
82
|
+
export function parseTargetTriple(target) {
|
|
83
|
+
if (target === UNIVERSAL) return { triple: UNIVERSAL, platform: "mac", os: "macos", arch: "universal" };
|
|
84
|
+
const row = typeof target === "string" ? TRIPLES[target] : undefined;
|
|
85
|
+
if (!row) fail("STAGE_SIDECAR_TARGET_UNSUPPORTED", `unsupported target triple ${JSON.stringify(target)} (${Object.keys(TRIPLES).join(", ")})`);
|
|
86
|
+
return { triple: target, ...row };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `--target`, then TAURI_ENV_TARGET_TRIPLE, then the host triple from `rightkit rustc -vV`. */
|
|
90
|
+
export function resolveTargetTriple({ target, env = process.env, run = defaultRun, cwd = process.cwd() } = {}) {
|
|
91
|
+
if (target) return target;
|
|
92
|
+
if (env.TAURI_ENV_TARGET_TRIPLE) return env.TAURI_ENV_TARGET_TRIPLE;
|
|
93
|
+
const result = run("rightkit", ["rustc", "-vV"], { cwd, env });
|
|
94
|
+
const host = result?.stdout?.match(/^host:\s*(.+)$/m)?.[1]?.trim();
|
|
95
|
+
if (!host || result.status !== 0) fail("STAGE_SIDECAR_TARGET_UNKNOWN", "cannot determine the host triple (rightkit rustc -vV failed); pass a target");
|
|
96
|
+
return host;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function relativePath(value, label, errors, { allowRoot = false } = {}) {
|
|
100
|
+
if (typeof value !== "string" || !value.trim()) { errors.push(`${label} must be a non-empty relative path`); return undefined; }
|
|
101
|
+
const normalized = value.replaceAll("\\", "/");
|
|
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
|
+
}
|
|
109
|
+
return normalized.replace(/\/+$/, "");
|
|
110
|
+
}
|
|
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
|
+
|
|
182
|
+
/**
|
|
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.
|
|
185
|
+
* Every problem is reported at once as STAGE_SIDECAR_CONFIG_INVALID. Entries whose `platforms` excludes the target are
|
|
186
|
+
* kept (shape-checked) with skipped: true.
|
|
187
|
+
*/
|
|
188
|
+
export function validateSidecarManifest(raw, { target, mode = "release" } = {}) {
|
|
189
|
+
const t = parseTargetTriple(target);
|
|
190
|
+
const errors = [];
|
|
191
|
+
if (!MODES.has(mode)) fail("STAGE_SIDECAR_CONFIG_INVALID", `build mode must be dev or release, got ${JSON.stringify(mode)}`);
|
|
192
|
+
const wrapper = Array.isArray(raw) ? { sidecars: raw } : raw;
|
|
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 }");
|
|
198
|
+
if (!Array.isArray(wrapper.sidecars) || !wrapper.sidecars.length) errors.push("sidecars must be a non-empty array");
|
|
199
|
+
const stageDir = relativePath(wrapper.stageDir ?? DEFAULT_STAGE_DIR, "stageDir", errors);
|
|
200
|
+
const receiptPath = wrapper.receiptPath === undefined
|
|
201
|
+
? (stageDir ? `${stageDir}/${STAGE_SIDECAR_RECEIPT_NAME}` : undefined)
|
|
202
|
+
: relativePath(wrapper.receiptPath, "receiptPath", errors);
|
|
203
|
+
const entries = [];
|
|
204
|
+
const claimed = [];
|
|
205
|
+
const claim = (output, label) => {
|
|
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 });
|
|
211
|
+
};
|
|
212
|
+
for (const [index, item] of (Array.isArray(wrapper.sidecars) ? wrapper.sidecars : []).entries()) {
|
|
213
|
+
const label = `sidecars[${index}]`;
|
|
214
|
+
if (!isObject(item) || !KINDS.has(item.kind)) { errors.push(`${label}.kind must be one of ${[...KINDS].join(", ")}`); continue; }
|
|
215
|
+
const where = `${label} (${item.kind})`;
|
|
216
|
+
for (const key of Object.keys(item)) if (!KEYS[item.kind].includes(key)) errors.push(`${where}: unknown key ${JSON.stringify(key)} (allowed: ${KEYS[item.kind].join(", ")})`);
|
|
217
|
+
let platforms;
|
|
218
|
+
if (item.platforms !== undefined) {
|
|
219
|
+
if (!Array.isArray(item.platforms) || !item.platforms.length || item.platforms.some((p) => !PLATFORMS.has(p))) errors.push(`${where}: platforms must be a non-empty list drawn from ${[...PLATFORMS].join(", ")}`);
|
|
220
|
+
else platforms = [...item.platforms];
|
|
221
|
+
}
|
|
222
|
+
const skipped = platforms ? !platforms.includes(t.platform) : false;
|
|
223
|
+
const sha = (value, name) => {
|
|
224
|
+
if (value !== undefined && !SHA256.test(value ?? "")) errors.push(`${where}: ${name} must be a lowercase 64-hex SHA-256`);
|
|
225
|
+
};
|
|
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)}`;
|
|
233
|
+
if (item.kind === "cargo-bin") {
|
|
234
|
+
if (typeof item.package !== "string" || !NAME.test(item.package)) errors.push(`${where}: package must be a Cargo package name`);
|
|
235
|
+
const bin = item.bin ?? item.package;
|
|
236
|
+
if (typeof bin !== "string" || !NAME.test(bin)) errors.push(`${where}: bin must be a binary target name`);
|
|
237
|
+
const name = item.name ?? bin;
|
|
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)`);
|
|
239
|
+
const declared = item.profile !== undefined ? parseProfile(item.profile, `${where}: profile`, errors) : (defaultProfile ?? "release");
|
|
240
|
+
const profile = isObject(declared) ? declared[mode] : declared;
|
|
241
|
+
const features = item.features ?? [];
|
|
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)`);
|
|
243
|
+
const manifestPath = item.manifestPath === undefined ? undefined : relativePath(item.manifestPath, `${where}: manifestPath`, errors);
|
|
244
|
+
sha(item.sha256, "sha256");
|
|
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 } : {}) });
|
|
249
|
+
} else if (item.kind === "ffmpeg") {
|
|
250
|
+
const { kind: _kind, platforms: _platforms, catalog: rawCatalog, ...rest } = item;
|
|
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", ...] }`);
|
|
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) {
|
|
256
|
+
try {
|
|
257
|
+
const config = validateMediaSidecarConfig({ ...rest, arch: t.arch, stageDir }, { platform: t.platform, hostArch: t.arch });
|
|
258
|
+
for (const tool of config.tools) claim(`${stageDir}/${stagedFileName(tool, t.os, t.arch)}`, where);
|
|
259
|
+
entry.media = config;
|
|
260
|
+
} catch (error) { errors.push(...String(error.message).split("\n").filter((line) => line.startsWith("- ")).map((line) => `${where}: ${line.slice(2)}`).concat(error.message.startsWith("invalid") ? [] : [`${where}: ${error.message}`])); }
|
|
261
|
+
}
|
|
262
|
+
} else if (item.kind === "onnxruntime") {
|
|
263
|
+
if (typeof item.version !== "string" || !/^\d+\.\d+\.\d+$/.test(item.version)) errors.push(`${where}: version must be x.y.z`);
|
|
264
|
+
const resourceDir = relativePath(item.resourceDir ?? DEFAULT_ORT_RESOURCE_DIR, `${where}: resourceDir`, errors);
|
|
265
|
+
const licenseDir = relativePath(item.licenseDir ?? (resourceDir ? `${resourceDir}/LICENSES` : "x"), `${where}: licenseDir`, errors);
|
|
266
|
+
const cacheDir = relativePath(item.cacheDir ?? ".cache/onnxruntime", `${where}: cacheDir`, errors);
|
|
267
|
+
const catalogSpec = parseCatalogSpec(item.catalog, `${where}: catalog`, errors, "onnxruntime");
|
|
268
|
+
if (item.as !== undefined && (typeof item.as !== "string" || !NAME.test(item.as))) errors.push(`${where}: as must be a bare file name`);
|
|
269
|
+
if (!skipped && t.arch === "universal") errors.push(`${where}: the catalog has no universal row; stage a lipo-merged file with a "command" or "resource" entry (machO: true) instead`);
|
|
270
|
+
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 } : {}) });
|
|
297
|
+
} else {
|
|
298
|
+
const from = relativePath(item.from, `${where}: from`, errors);
|
|
299
|
+
const to = relativePath(item.to, `${where}: to`, errors);
|
|
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();
|
|
303
|
+
if (to && !skipped) claim(to, where);
|
|
304
|
+
Object.assign(entry, { from, to, ...(clean ? { clean } : {}),...(item.sha256 ? { sha256: item.sha256 } : {}), ...(item.machO ? { machO: true } : {}) });
|
|
305
|
+
}
|
|
306
|
+
entries.push(entry);
|
|
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
|
+
}
|
|
313
|
+
if (errors.length) fail("STAGE_SIDECAR_CONFIG_INVALID", `invalid sidecars configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`, { errors });
|
|
314
|
+
return { target: t, stageDir, receiptPath, wipeStageDir: wrapper.wipeStageDir === true, entries };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
318
|
+
const profileDirectory = (profile) => (profile === "dev" || profile === "test" ? "debug" : profile === "bench" ? "release" : profile);
|
|
319
|
+
const tail = (text, lines = 12) => String(text ?? "").trim().split("\n").slice(-lines).join("\n");
|
|
320
|
+
|
|
321
|
+
/** Default process runner: `rightkit` resolves through the installed runtime client (as the apps' scripts did). */
|
|
322
|
+
export function defaultRun(command, args, { cwd, env = process.env } = {}) {
|
|
323
|
+
const launch = command === "rightkit" ? resolveCommand("rightkit", args, { root: cwd, env }) : { command, args };
|
|
324
|
+
return nodeSpawnSync(launch.command, launch.args, {
|
|
325
|
+
cwd, env: { ...env, ...(launch.env ?? {}) }, encoding: "utf8", maxBuffer: 512 * 1024 * 1024, shell: false, windowsHide: true, stdio: ["inherit", "pipe", "inherit"],
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Default archive member extraction (bsdtar reads tgz and zip/nupkg): the one member whose basename is `fileName`. */
|
|
330
|
+
export function defaultExtractMember({ archive, fileName, destDir, run = defaultRun }) {
|
|
331
|
+
const listing = run("tar", ["-tf", archive], { cwd: path.dirname(archive) });
|
|
332
|
+
if (listing.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot list ${archive}`);
|
|
333
|
+
const members = String(listing.stdout).split(/\r?\n/).map((line) => line.trim()).filter((line) => line && path.posix.basename(line) === fileName);
|
|
334
|
+
if (members.length !== 1) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${archive} has ${members.length} members named ${fileName}, expected exactly one`);
|
|
335
|
+
const extracted = run("tar", ["-xf", archive, "-C", destDir, members[0]], { cwd: path.dirname(archive) });
|
|
336
|
+
if (extracted.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot extract ${members[0]} from ${archive}`);
|
|
337
|
+
return path.join(destDir, ...members[0].split("/"));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function defaultFetchBytes(url) {
|
|
341
|
+
if (!url.startsWith("https://")) fail("STAGE_SIDECAR_CONFIG_INVALID", `refusing a non-https archive URL: ${url}`);
|
|
342
|
+
const response = await fetch(url);
|
|
343
|
+
if (!response.ok) fail("STAGE_SIDECAR_DOWNLOAD_FAILED", `GET ${url} failed with ${response.status}`);
|
|
344
|
+
return Buffer.from(await response.arrayBuffer());
|
|
345
|
+
}
|
|
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
|
+
|
|
370
|
+
function readJsonCatalog(entryCatalog, appRoot, fs, what) {
|
|
371
|
+
const file = path.resolve(appRoot, entryCatalog);
|
|
372
|
+
let bytes;
|
|
373
|
+
try { bytes = fs.readFileSync(file); } catch (error) { fail("STAGE_SIDECAR_CATALOG_UNREADABLE", `cannot read the ${what} catalog ${file}`, { cause: error }); }
|
|
374
|
+
try { return { catalog: JSON.parse(String(bytes)), catalogBytes: bytes, crateDir: path.resolve(path.dirname(file), "..") }; } catch (error) { fail("STAGE_SIDECAR_CATALOG_UNREADABLE", `the ${what} catalog ${file} is not JSON`, { cause: error }); }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function artifactFromStream(stdout, bin) {
|
|
378
|
+
let found;
|
|
379
|
+
for (const line of String(stdout ?? "").split("\n")) {
|
|
380
|
+
if (!line.startsWith("{")) continue;
|
|
381
|
+
try {
|
|
382
|
+
const message = JSON.parse(line);
|
|
383
|
+
if (message.reason === "compiler-artifact" && message.executable && message.target?.name === bin && (message.target.kind ?? ["bin"]).includes("bin")) found = message.executable;
|
|
384
|
+
} catch { /* not a cargo message */ }
|
|
385
|
+
}
|
|
386
|
+
return found;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Candidate output paths, in lookup order: the explicit-target layout (broker) first, then the plain layout. */
|
|
390
|
+
export function builtBinaryCandidates({ targetDir, targetDirs, triple, profile, bin, windows }) {
|
|
391
|
+
const file = windows ? `${bin}.exe` : bin;
|
|
392
|
+
const dir = profileDirectory(profile);
|
|
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 }); }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function copyInto(ctx, source, dest, { executable }) {
|
|
437
|
+
const { fs, windows } = ctx;
|
|
438
|
+
const sourceSha256 = sha256Of(fs.readFileSync(source));
|
|
439
|
+
let changed = true;
|
|
440
|
+
if (fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === sourceSha256) changed = false;
|
|
441
|
+
if (changed) {
|
|
442
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
443
|
+
const temporary = `${dest}.tmp-${ctx.pid}`;
|
|
444
|
+
fs.copyFileSync(source, temporary);
|
|
445
|
+
if (executable && !windows) fs.chmodSync(temporary, 0o755);
|
|
446
|
+
fs.renameSync(temporary, dest);
|
|
447
|
+
} else if (executable && !windows) fs.chmodSync(dest, 0o755);
|
|
448
|
+
const sha256 = sha256Of(fs.readFileSync(dest));
|
|
449
|
+
if (sha256 !== sourceSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `staged ${dest} differs from its source ${source}`, { expected: sourceSha256, actual: sha256 });
|
|
450
|
+
return { sha256, sizeBytes: fs.statSync(dest).size, changed };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const rel = (ctx, file) => path.relative(ctx.appRoot, file).replaceAll("\\", "/");
|
|
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
|
+
|
|
470
|
+
function stageCargoBin(entry, ctx) {
|
|
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;
|
|
501
|
+
const args = ["cargo", "build", "--locked", ...(entry.manifestPath ? ["--manifest-path", path.resolve(appRoot, entry.manifestPath)] : []),
|
|
502
|
+
"-p", entry.package, "--bin", entry.bin, "--target", triple, "--profile", entry.profile,
|
|
503
|
+
...(entry.features.length ? ["--features", entry.features.join(",")] : []), "--message-format=json-render-diagnostics"];
|
|
504
|
+
ctx.log(`stage-sidecar: rightkit ${args.join(" ")}`);
|
|
505
|
+
const built = run("rightkit", args, { cwd: appRoot, env });
|
|
506
|
+
if (built?.error || built?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `rightkit cargo build of ${entry.package} failed (${built?.error?.message ?? `exit ${built?.status}`})`, { package: entry.package, stderr: tail(built?.stderr) });
|
|
507
|
+
let source = artifactFromStream(built.stdout, entry.bin);
|
|
508
|
+
if (!source || !fs.existsSync(source)) {
|
|
509
|
+
const meta = run("rightkit", ["cargo", "metadata", "--format-version", "1", "--no-deps", ...(entry.manifestPath ? ["--manifest-path", path.resolve(appRoot, entry.manifestPath)] : [])], { cwd: appRoot, env });
|
|
510
|
+
let targetDir;
|
|
511
|
+
try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
|
|
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 });
|
|
518
|
+
source = candidates.find((file) => fs.existsSync(file));
|
|
519
|
+
if (!source) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `the build did not produce ${entry.bin} (looked in ${candidates.join(", ")})`, { bin: entry.bin, candidates });
|
|
520
|
+
}
|
|
521
|
+
const sourceSha256 = sha256Of(fs.readFileSync(source));
|
|
522
|
+
return { source, sourceSha256 };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function stageFfmpeg(entry, ctx) {
|
|
526
|
+
const { appRoot, run, env, fs, target } = ctx;
|
|
527
|
+
const config = entry.media;
|
|
528
|
+
try {
|
|
529
|
+
let resolved = await ctx.loadMediaCatalog({ config, appRoot, entry });
|
|
530
|
+
if (!resolved) {
|
|
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");
|
|
533
|
+
}
|
|
534
|
+
const plan = planMediaSidecar({ config, catalog: resolved.catalog, crateDir: resolved.crateDir, crateVersion: resolved.crateVersion, catalogBytes: resolved.catalogBytes, root: appRoot });
|
|
535
|
+
if (config.stage) {
|
|
536
|
+
ctx.log(`stage-sidecar: ${config.stage.cmd} ${(config.stage.args ?? []).join(" ")}`);
|
|
537
|
+
const staged = run(config.stage.cmd, config.stage.args ?? [], { cwd: appRoot, env });
|
|
538
|
+
if (staged?.error || staged?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `ffmpeg stage command failed (${staged?.error?.message ?? `exit ${staged?.status}`})`);
|
|
539
|
+
}
|
|
540
|
+
const noticeFiles = writeMediaSidecarNotices(plan, { exists: fs.existsSync, mkdir: fs.mkdirSync, copy: fs.copyFileSync, write: fs.writeFileSync });
|
|
541
|
+
const verified = verifyStagedSidecars(plan, { exists: fs.existsSync, stat: (file) => fs.statSync(file), hashFile: (file) => sha256Of(fs.readFileSync(file)) });
|
|
542
|
+
const notices = noticeFiles.map((file) => ({ name: path.basename(file), path: rel(ctx, file), sha256: sha256Of(fs.readFileSync(file)) }));
|
|
543
|
+
return verified.map((file) => {
|
|
544
|
+
if (file.preSignSha256 !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${file.tool} staged as ${file.preSignSha256}, the approved catalog row ${file.artifactId} says ${file.contentSha256}`, { expected: file.contentSha256, actual: file.preSignSha256 });
|
|
545
|
+
return { kind: "ffmpeg", name: file.tool, path: rel(ctx, file.stagedPath), sha256: file.preSignSha256, sizeBytes: file.preSignSizeBytes, changed: null, catalogRow: file.artifactId, version: file.version, catalogContentSha256: file.contentSha256, externalBin: true, license: "LGPL-2.1-or-later", notices };
|
|
546
|
+
});
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (error instanceof StageSidecarError) throw error;
|
|
549
|
+
return fail("STAGE_SIDECAR_FFMPEG_FAILED", `ffmpeg sidecar (${target.triple}): ${error.message}`, { cause: error, causeCode: error.code });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function stageOnnxRuntime(entry, ctx) {
|
|
554
|
+
const { appRoot, fs, target } = ctx;
|
|
555
|
+
const loaded = await (ctx.loadNativeCatalog({ entry, appRoot }) ?? (entry.catalogSpec ? readJsonCatalog(resolveCatalogFile(entry.catalogSpec, ctx, "onnxruntime"), appRoot, fs, "native-libraries") : undefined));
|
|
556
|
+
if (!loaded) fail("STAGE_SIDECAR_CATALOG_UNREADABLE", "onnxruntime needs a catalog path (the rightkit-ffmpeg native-libraries.json) or deps.loadNativeCatalog");
|
|
557
|
+
const rows = (loaded.catalog?.libraries ?? []).filter((row) => row.library === "onnxruntime" && row.version === entry.version && row.os === target.os && row.arch === target.arch);
|
|
558
|
+
const where = `onnxruntime ${entry.version} ${target.os}/${target.arch}`;
|
|
559
|
+
if (rows.length === 0) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has no ${where} row`);
|
|
560
|
+
if (rows.length > 1) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has ${rows.length} ${where} rows`);
|
|
561
|
+
const [row] = rows;
|
|
562
|
+
if (!["approved", "pinned"].includes(row.status)) fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} is ${row.status}, not approved or pinned`);
|
|
563
|
+
for (const key of ["sha256", "artifactFileName"]) if (typeof row[key] !== "string" || !row[key]) fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} has no ${key}`);
|
|
564
|
+
if (!SHA256.test(row.sha256) || !SHA256.test(row.archive?.sha256 ?? "") || typeof row.archive?.url !== "string") fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} lacks a valid library digest or archive url and digest`);
|
|
565
|
+
if (!Array.isArray(row.noticeFiles) || !row.noticeFiles.length) fail("STAGE_SIDECAR_NOTICE_MISSING", `the ${where} row ${row.id} lists no notice files; the licence must ship with the library`);
|
|
566
|
+
const crateDir = loaded.crateDir;
|
|
567
|
+
const notices = row.noticeFiles.map((file) => {
|
|
568
|
+
const normalized = String(file).replaceAll("\\", "/");
|
|
569
|
+
if (path.isAbsolute(file) || normalized.split("/").includes("..")) fail("STAGE_SIDECAR_NOTICE_MISSING", `notice file path escapes the catalog crate: ${file}`);
|
|
570
|
+
return { source: path.join(crateDir, ...normalized.split("/")), dest: path.resolve(appRoot, entry.licenseDir, path.posix.basename(normalized)) };
|
|
571
|
+
});
|
|
572
|
+
for (const { source } of notices) if (!fs.existsSync(source)) fail("STAGE_SIDECAR_NOTICE_MISSING", `notice file not found: ${source}`);
|
|
573
|
+
|
|
574
|
+
const outName = entry.as ?? row.artifactFileName;
|
|
575
|
+
const dest = path.resolve(appRoot, entry.resourceDir, outName);
|
|
576
|
+
if (fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === row.sha256) {
|
|
577
|
+
ctx.log(`stage-sidecar: ${rel(ctx, dest)} already staged`);
|
|
578
|
+
} else {
|
|
579
|
+
const archive = path.resolve(appRoot, entry.cacheDir, path.posix.basename(new URL(row.archive.url).pathname));
|
|
580
|
+
if (!(fs.existsSync(archive) && sha256Of(fs.readFileSync(archive)) === row.archive.sha256)) {
|
|
581
|
+
const bytes = await ctx.fetchBytes(row.archive.url);
|
|
582
|
+
const actual = sha256Of(bytes);
|
|
583
|
+
if (actual !== row.archive.sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${row.archive.url} downloaded as ${actual}, the catalog pins ${row.archive.sha256}; nothing staged`, { expected: row.archive.sha256, actual });
|
|
584
|
+
fs.mkdirSync(path.dirname(archive), { recursive: true });
|
|
585
|
+
fs.writeFileSync(archive, bytes);
|
|
586
|
+
}
|
|
587
|
+
const scratch = ctx.makeTempDir();
|
|
588
|
+
try {
|
|
589
|
+
const library = await ctx.extractMember({ archive, fileName: row.artifactFileName, destDir: scratch });
|
|
590
|
+
const actual = sha256Of(fs.readFileSync(library));
|
|
591
|
+
if (actual !== row.sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${row.artifactFileName} extracted as ${actual}, the catalog pins ${row.sha256}; nothing staged`, { expected: row.sha256, actual });
|
|
592
|
+
copyInto(ctx, library, dest, { executable: false });
|
|
593
|
+
} finally {
|
|
594
|
+
ctx.removeDir(scratch);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const noticeOut = notices.map(({ source, dest: out }) => {
|
|
598
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
599
|
+
fs.copyFileSync(source, out);
|
|
600
|
+
return { name: path.basename(out), path: rel(ctx, out), sha256: sha256Of(fs.readFileSync(out)) };
|
|
601
|
+
});
|
|
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 }];
|
|
603
|
+
}
|
|
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
|
+
|
|
635
|
+
function stageResource(entry, ctx) {
|
|
636
|
+
const { appRoot, fs } = ctx;
|
|
637
|
+
const source = path.resolve(appRoot, entry.from);
|
|
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 });
|
|
640
|
+
const actual = sha256Of(fs.readFileSync(source));
|
|
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}`);
|
|
643
|
+
const dest = path.resolve(appRoot, entry.to);
|
|
644
|
+
cleanDest(ctx, entry, dest);
|
|
645
|
+
const copied = copyInto(ctx, source, dest, { executable: false });
|
|
646
|
+
return [{ kind: "resource", name: path.basename(dest), path: rel(ctx, dest), ...copied, sourceSha256: actual, from: entry.from, externalBin: false }];
|
|
647
|
+
}
|
|
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
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Stage every configured sidecar for `target` (a Rust triple) and write the staging receipt.
|
|
771
|
+
* Returns { receipt, receiptPath, files, externalBinNames }. `deps` (all optional) injects every effect:
|
|
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.
|
|
773
|
+
*/
|
|
774
|
+
export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {}) {
|
|
775
|
+
if (typeof appRoot !== "string" || !path.isAbsolute(appRoot)) fail("STAGE_SIDECAR_CONFIG_INVALID", "appRoot must be an absolute path");
|
|
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;
|
|
780
|
+
const run = deps.run ?? defaultRun;
|
|
781
|
+
const ctx = {
|
|
782
|
+
appRoot, target: plan.target, stageDir: plan.stageDir, fs, env, run, protectedPaths: protectedOutputPaths(plan),
|
|
783
|
+
windows: plan.target.os === "windows",
|
|
784
|
+
pid: deps.pid ?? process.pid,
|
|
785
|
+
log: deps.log ?? (() => {}),
|
|
786
|
+
fetchBytes: deps.fetchBytes ?? defaultFetchBytes,
|
|
787
|
+
fetch: deps.fetch ?? ((...args) => globalThis.fetch(...args)),
|
|
788
|
+
extractMember: deps.extractMember ?? ((args) => defaultExtractMember({ ...args, run })),
|
|
789
|
+
loadMediaCatalog: deps.loadMediaCatalog ?? (() => undefined),
|
|
790
|
+
loadNativeCatalog: deps.loadNativeCatalog ?? (() => undefined),
|
|
791
|
+
makeTempDir: deps.makeTempDir ?? (() => nodeFs.mkdtempSync(path.join(os.tmpdir(), "rightkit-stage-"))),
|
|
792
|
+
removeDir: deps.removeDir ?? ((dir) => nodeFs.rmSync(dir, { recursive: true, force: true })),
|
|
793
|
+
};
|
|
794
|
+
const files = [];
|
|
795
|
+
const skipped = [];
|
|
796
|
+
for (const entry of plan.entries) {
|
|
797
|
+
if (entry.skipped) { skipped.push({ kind: entry.kind, index: entry.index, platforms: entry.platforms }); continue; }
|
|
798
|
+
const staged = entry.kind === "cargo-bin" ? stageCargoBin(entry, ctx)
|
|
799
|
+
: entry.kind === "ffmpeg" ? await stageFfmpeg(entry, ctx)
|
|
800
|
+
: entry.kind === "onnxruntime" ? await stageOnnxRuntime(entry, ctx)
|
|
801
|
+
: entry.kind === "command" ? stageCommand(entry, ctx)
|
|
802
|
+
: entry.kind === "remote" ? await stageRemote(entry, ctx)
|
|
803
|
+
: stageResource(entry, ctx);
|
|
804
|
+
files.push(...staged);
|
|
805
|
+
}
|
|
806
|
+
const wiped = plan.wipeStageDir ? wipeStaleStageDir(ctx, plan, files.map((file) => path.resolve(appRoot, file.path))) : [];
|
|
807
|
+
const receipt = {
|
|
808
|
+
schema: 1,
|
|
809
|
+
contract: STAGE_SIDECAR_CONTRACT,
|
|
810
|
+
target: plan.target.triple,
|
|
811
|
+
os: plan.target.os,
|
|
812
|
+
arch: plan.target.arch,
|
|
813
|
+
stagedAt: (deps.now ?? (() => new Date().toISOString()))(),
|
|
814
|
+
files,
|
|
815
|
+
skipped,
|
|
816
|
+
...(plan.wipeStageDir ? { wiped } : {}),
|
|
817
|
+
};
|
|
818
|
+
validateStageSidecarReceipt(receipt, { target: plan.target.triple });
|
|
819
|
+
const receiptFile = path.resolve(appRoot, plan.receiptPath);
|
|
820
|
+
fs.mkdirSync(path.dirname(receiptFile), { recursive: true });
|
|
821
|
+
fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
822
|
+
return { receipt, receiptPath: receiptFile, files, externalBinNames: files.filter((file) => file.externalBin).map((file) => file.name) };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Structural check of a staging receipt; `expected` may pin target and names. */
|
|
826
|
+
export function validateStageSidecarReceipt(receipt, expected = {}) {
|
|
827
|
+
const bad = (message) => fail("STAGE_SIDECAR_RECEIPT_INVALID", `stage sidecar receipt: ${message}`);
|
|
828
|
+
if (!isObject(receipt) || receipt.schema !== 1 || receipt.contract !== STAGE_SIDECAR_CONTRACT) bad("unrecognized contract");
|
|
829
|
+
if (!Array.isArray(receipt.files)) bad("no files array");
|
|
830
|
+
for (const file of receipt.files) {
|
|
831
|
+
if (typeof file.name !== "string" || !file.name || typeof file.path !== "string" || !file.path) bad("file without a name or path");
|
|
832
|
+
if (!SHA256.test(file.sha256 ?? "")) bad(`${file.name}.sha256 must be a SHA-256`);
|
|
833
|
+
if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes < (file.kind === "resource" ? 0 : 1)) bad(`${file.name}.sizeBytes must be positive`);
|
|
834
|
+
}
|
|
835
|
+
if (new Set(receipt.files.map((file) => file.path)).size !== receipt.files.length) bad("duplicate staged path");
|
|
836
|
+
if (expected.target && receipt.target !== expected.target) bad(`target ${receipt.target} is not ${expected.target}`);
|
|
837
|
+
if (expected.names && JSON.stringify([...expected.names].sort()) !== JSON.stringify(receipt.files.filter((file) => file.externalBin).map((file) => file.name).sort())) bad("externalBin names differ from the configuration");
|
|
838
|
+
return receipt;
|
|
839
|
+
}
|