@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/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 is refused (stage per arch, lipo is the app's). */
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 === "universal-apple-darwin") fail("STAGE_SIDECAR_TARGET_UNSUPPORTED", "universal-apple-darwin is not staged here: stage aarch64-apple-darwin and x86_64-apple-darwin separately and merge with lipo");
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 outputs = new Map();
204
+ const claimed = [];
104
205
  const claim = (output, label) => {
105
- const key = output.toLowerCase();
106
- if (outputs.has(key)) errors.push(`${label} writes ${output}, which ${outputs.get(key)} already writes`);
107
- else outputs.set(key, 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 });
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 profile = item.profile ?? "release";
131
- if (typeof profile !== "string" || !/^[a-z][a-z0-9_-]*$/.test(profile)) errors.push(`${where}: profile must be a Cargo profile name`);
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
- if (typeof name === "string" && stageDir && !skipped) claim(stagedFileName(name, t.os, t.arch), where);
137
- Object.assign(entry, { package: item.package, bin, name, profile, features: [...features], ...(manifestPath ? { manifestPath } : {}), ...(item.sha256 ? { sha256: item.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 } : {}) });
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
- if (!skipped) {
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), where);
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 catalog = item.catalog === undefined ? undefined : relativePath(item.catalog, `${where}: catalog`, errors);
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
- Object.assign(entry, { version: item.version, resourceDir, licenseDir, cacheDir, ...(catalog ? { catalog } : {}), ...(item.as ? { as: item.as } : {}) });
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
- return [path.join(targetDir, triple, dir, file), path.join(targetDir, dir, file)];
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 { target, run, appRoot, env, fs } = 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;
246
501
  const args = ["cargo", "build", "--locked", ...(entry.manifestPath ? ["--manifest-path", path.resolve(appRoot, entry.manifestPath)] : []),
247
- "-p", entry.package, "--bin", entry.bin, "--target", target.triple, "--profile", entry.profile,
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.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `cargo reported no ${entry.bin} executable and its target directory cannot be read`, { bin: entry.bin });
258
- const candidates = builtBinaryCandidates({ targetDir, triple: target.triple, profile: entry.profile, bin: entry.bin, windows: ctx.windows });
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
- if (entry.sha256 && entry.sha256 !== sourceSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.bin} built to ${sourceSha256}, expected ${entry.sha256}`, { expected: entry.sha256, actual: sourceSha256 });
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 (!config.catalog) fail("STAGE_SIDECAR_CATALOG_UNREADABLE", "ffmpeg needs a catalog path (the rightkit-ffmpeg artifacts.json) or deps.loadMediaCatalog");
276
- resolved = readJsonCatalog(config.catalog, appRoot, fs, "rightkit-ffmpeg");
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.catalog ? readJsonCatalog(entry.catalog, appRoot, fs, "native-libraries") : undefined));
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 (!fs.existsSync(source) || !fs.statSync(source).isFile()) fail("STAGE_SIDECAR_RESOURCE_MISSING", `resource ${entry.from} is not a file (directories are not staged; list each file)`, { from: 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 });
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
- : stageResource(entry, ctx);
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 <= 0) bad(`${file.name}.sizeBytes must be positive`);
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}`);