@rightkit/release 0.2.173 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +188 -5
  2. package/bin/darwin/rightkit-swap +0 -0
  3. package/bin/darwin/rightkit-swap.sign.json +1 -1
  4. package/cli/right-release.mjs +34 -6
  5. package/command-spec.mjs +80 -0
  6. package/deps-cargo.mjs +55 -0
  7. package/deps.mjs +7 -0
  8. package/development-signing.mjs +135 -4
  9. package/development.mjs +12 -11
  10. package/dist-scan.mjs +102 -0
  11. package/dmg-build.mjs +53 -4
  12. package/icon-pack.mjs +117 -0
  13. package/in-place-install.mjs +32 -7
  14. package/init-command.mjs +116 -0
  15. package/install-app-windows.mjs +108 -17
  16. package/install-app.mjs +68 -11
  17. package/install-tauri-devid.mjs +106 -47
  18. package/local-publish.mjs +317 -29
  19. package/mac-bundle-signing.mjs +143 -1
  20. package/mac-signing-matrix.mjs +73 -6
  21. package/macho-approval-hash.mjs +181 -0
  22. package/macos-signing-session.mjs +12 -2
  23. package/managed-cargo.mjs +49 -1
  24. package/media-sidecar.mjs +20 -4
  25. package/mirror-root-artifact.mjs +69 -21
  26. package/npm-license-notices.mjs +159 -0
  27. package/ownership-dependency-allowlist.json +1 -0
  28. package/ownership.mjs +37 -4
  29. package/package-recipe-targets.mjs +180 -16
  30. package/package.json +1 -1
  31. package/pe-content.mjs +133 -0
  32. package/portable-development.mjs +9 -9
  33. package/publication-gate.mjs +70 -3
  34. package/publish-cargo.mjs +26 -7
  35. package/qa-build-features.mjs +7 -1
  36. package/recipe-build.mjs +346 -0
  37. package/release-dependencies-command.mjs +45 -0
  38. package/release-dependencies.mjs +60 -0
  39. package/release-switch-scan.mjs +17 -7
  40. package/release.mjs +98 -12
  41. package/rightkit-ownership.json +46 -2
  42. package/rightkit-signing-agent.mjs +10 -4
  43. package/rightkit-versions.json +11 -6
  44. package/runtime-budgets.mjs +28 -12
  45. package/stage-binary.mjs +1 -1
  46. package/stage-sidecar-command.mjs +36 -4
  47. package/stage-sidecar.mjs +926 -74
  48. package/test-command.mjs +190 -0
  49. package/toolchain-swift.mjs +239 -0
  50. package/web-build.mjs +67 -0
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env node
2
+ // `right-release recipe-build --plan <json>` (bogusyogi/rightkit#345): the single prePackage command that a declarative
3
+ // winPackage / macPackage target expands to. release.mjs runs one prePackage command; a real Tauri app needs several
4
+ // steps before and after `tauri build` (stage ffmpeg / pdfium / sidecars, then the build, then for macOS embed a dylib
5
+ // before release.mjs signs). This runs them in order from a validated plan, with argv arrays and no shell, and fails
6
+ // closed on the first step that does not exit 0. It never signs; release.mjs signs what it leaves behind.
7
+ //
8
+ // Plan: { version: 1, steps: [{ phase: "prepare" | "build" | "postBuild", cmd, args?, cwd?, env? }], cleanup?, stability? }
9
+ // `cwd` is relative to the app root (default the app root). Phases must appear in order prepare, build, postBuild, and
10
+ // exactly one build step is required.
11
+ //
12
+ // Built-in steps (no app script needed), all paths relative to the app root:
13
+ // cleanup: { remove?: [file or dir], apps?: { dir, remove?: bool, unregister?: bool }, dmgVolumes?: [name prefix] }
14
+ // runs BEFORE the first step. `remove` deletes stale artifacts (a stale raw EXE or installer must never be
15
+ // mistaken for this build's output). `apps` finds stale `*.app` bundles in `dir`, unregisters them with
16
+ // `lsregister -u` and/or deletes them. `dmgVolumes` detaches /Volumes/<name> and /Volumes/<name> <N> (the macOS
17
+ // duplicate suffix) only when `hdiutil info -plist` shows the volume is a mounted disk image; `-force` is used
18
+ // only on retry. The macOS-only parts (apps unregister, dmgVolumes) are skipped off darwin.
19
+ // stability: { files: [file], intervalMs?, checks?, timeoutMs? } runs right AFTER the build step: each file must exist
20
+ // and keep the same size and mtime over `checks` consecutive polls `intervalMs` apart (defaults 2 and 1000 ms,
21
+ // timeout 120 s), so a hung or still-writing Tauri process cannot hand a half-written EXE to signing.
22
+ import { existsSync as nodeExistsSync, readFileSync, readdirSync as nodeReaddirSync, rmSync as nodeRmSync, statSync as nodeStatSync } from "node:fs";
23
+ import path from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+ import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
26
+
27
+ export const RECIPE_BUILD_PLAN_VERSION = 1;
28
+ export const RECIPE_PHASES = Object.freeze(["prepare", "build", "postBuild"]);
29
+
30
+ export class RecipeBuildError extends Error {
31
+ constructor(code, message, details = {}) {
32
+ super(message);
33
+ this.name = "RecipeBuildError";
34
+ this.code = code;
35
+ this.details = details;
36
+ }
37
+ }
38
+
39
+ const fail = (code, message, details) => { throw new RecipeBuildError(code, message, details); };
40
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
41
+ const STEP_KEYS = new Set(["phase", "cmd", "args", "cwd", "env"]);
42
+ const PLAN_KEYS = new Set(["version", "steps", "cleanup", "stability"]);
43
+ const CLEANUP_KEYS = new Set(["remove", "apps", "dmgVolumes"]);
44
+ const APPS_KEYS = new Set(["dir", "remove", "unregister"]);
45
+ const STABILITY_KEYS = new Set(["files", "intervalMs", "checks", "timeoutMs"]);
46
+ const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
47
+ const unknownKeys = (value, allowed, label, errors) => { for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)} (allowed: ${[...allowed].join(", ")})`); };
48
+ const positiveInt = (value) => Number.isInteger(value) && value > 0;
49
+ const badVolumePrefix = (item) => typeof item !== "string" || !item.trim() || item === "." || item === ".." || item.includes("/") || item.includes("\\") || item.includes(":") || item.includes("\0");
50
+
51
+ /** A relative path that stays inside the app root; returns the normalized form or undefined and pushes an error. */
52
+ function insideApp(value, label, errors, { strict = false } = {}) {
53
+ if (typeof value !== "string" || !value.trim()) { errors.push(`${label} must be a non-empty relative path`); return undefined; }
54
+ const normalized = value.replaceAll("\\", "/");
55
+ 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; }
56
+ const resolved = path.posix.normalize(normalized);
57
+ if (strict && (resolved === "." || resolved === "./" || resolved === "")) { errors.push(`${label} must name a path strictly inside the app, not the app root: ${JSON.stringify(value)}`); return undefined; }
58
+ if (resolved === "/" || resolved.startsWith("../") || resolved === "..") { errors.push(`${label} must name a path strictly inside the app, not the app root or an ancestor: ${JSON.stringify(value)}`); return undefined; }
59
+ return normalized;
60
+ }
61
+
62
+ function validateCleanup(value, errors) {
63
+ if (value === undefined) return undefined;
64
+ if (!isObject(value)) { errors.push("cleanup must be an object"); return undefined; }
65
+ unknownKeys(value, CLEANUP_KEYS, "cleanup", errors);
66
+ const out = {};
67
+ if (value.remove !== undefined) {
68
+ if (!Array.isArray(value.remove)) errors.push("cleanup.remove must be a list of relative paths");
69
+ else out.remove = value.remove.map((item, index) => insideApp(item, `cleanup.remove[${index}]`, errors, { strict: true })).filter(Boolean);
70
+ }
71
+ if (value.apps !== undefined) {
72
+ if (!isObject(value.apps)) errors.push("cleanup.apps must be an object");
73
+ else {
74
+ unknownKeys(value.apps, APPS_KEYS, "cleanup.apps", errors);
75
+ for (const key of ["remove", "unregister"]) if (value.apps[key] !== undefined && typeof value.apps[key] !== "boolean") errors.push(`cleanup.apps.${key} must be a boolean`);
76
+ const dir = insideApp(value.apps.dir, "cleanup.apps.dir", errors, { strict: true });
77
+ if (dir) out.apps = { dir, remove: value.apps.remove === true, unregister: value.apps.unregister === true };
78
+ }
79
+ }
80
+ if (value.dmgVolumes !== undefined) {
81
+ if (!Array.isArray(value.dmgVolumes) || value.dmgVolumes.some(badVolumePrefix)) errors.push("cleanup.dmgVolumes must be a list of non-empty volume name prefixes (no path separators)");
82
+ else out.dmgVolumes = [...value.dmgVolumes];
83
+ }
84
+ return out;
85
+ }
86
+
87
+ function validateStability(value, errors) {
88
+ if (value === undefined) return undefined;
89
+ if (!isObject(value)) { errors.push("stability must be an object"); return undefined; }
90
+ unknownKeys(value, STABILITY_KEYS, "stability", errors);
91
+ for (const key of ["intervalMs", "checks", "timeoutMs"]) if (value[key] !== undefined && !positiveInt(value[key])) errors.push(`stability.${key} must be a positive integer`);
92
+ if (!Array.isArray(value.files) || !value.files.length) { errors.push("stability.files must be a non-empty list of relative paths"); return undefined; }
93
+ const files = value.files.map((item, index) => insideApp(item, `stability.files[${index}]`, errors, { strict: true })).filter(Boolean);
94
+ return { files, ...(value.intervalMs === undefined ? {} : { intervalMs: value.intervalMs }), ...(value.checks === undefined ? {} : { checks: value.checks }), ...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }) };
95
+ }
96
+
97
+ /** Validate a plan strictly; every problem is reported at once as RECIPE_BUILD_PLAN_INVALID. Returns the normalized plan. */
98
+ export function validateRecipePlan(plan) {
99
+ const errors = [];
100
+ if (!isObject(plan)) fail("RECIPE_BUILD_PLAN_INVALID", "recipe plan must be an object");
101
+ for (const key of Object.keys(plan)) if (!PLAN_KEYS.has(key)) errors.push(`unknown key ${JSON.stringify(key)}`);
102
+ if (plan.version !== RECIPE_BUILD_PLAN_VERSION) errors.push(`version must be ${RECIPE_BUILD_PLAN_VERSION}`);
103
+ if (!Array.isArray(plan.steps) || !plan.steps.length) errors.push("steps must be a non-empty array");
104
+ const steps = [];
105
+ let lastPhase = -1;
106
+ let builds = 0;
107
+ for (const [index, step] of (Array.isArray(plan.steps) ? plan.steps : []).entries()) {
108
+ const label = `steps[${index}]`;
109
+ if (!isObject(step)) { errors.push(`${label} must be an object`); continue; }
110
+ for (const key of Object.keys(step)) if (!STEP_KEYS.has(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)} (allowed: ${[...STEP_KEYS].join(", ")})`);
111
+ const phase = RECIPE_PHASES.indexOf(step.phase);
112
+ if (phase < 0) errors.push(`${label}.phase must be one of ${RECIPE_PHASES.join(", ")}`);
113
+ else if (phase < lastPhase) errors.push(`${label}: phase ${step.phase} comes after a later phase (order: ${RECIPE_PHASES.join(", ")})`);
114
+ else lastPhase = phase;
115
+ if (step.phase === "build") builds += 1;
116
+ if (typeof step.cmd !== "string" || !step.cmd.trim()) errors.push(`${label}.cmd must be a non-empty string`);
117
+ const args = step.args ?? [];
118
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) errors.push(`${label}.args must be a list of strings`);
119
+ const cwd = step.cwd === undefined ? undefined : insideApp(step.cwd, `${label}.cwd`, errors);
120
+ if (step.env !== undefined && (!isObject(step.env) || Object.values(step.env).some((value) => typeof value !== "string"))) errors.push(`${label}.env must map names to strings`);
121
+ steps.push({ phase: step.phase, cmd: step.cmd, args: Array.isArray(args) ? [...args] : [], ...(cwd ? { cwd } : {}), ...(isObject(step.env) ? { env: { ...step.env } } : {}) });
122
+ }
123
+ if (builds !== 1) errors.push(`exactly one build step is required, found ${builds}`);
124
+ const cleanup = validateCleanup(plan.cleanup, errors);
125
+ const stability = validateStability(plan.stability, errors);
126
+ if (errors.length) fail("RECIPE_BUILD_PLAN_INVALID", `invalid recipe plan:\n${errors.map((line) => `- ${line}`).join("\n")}`, { errors });
127
+ return { version: RECIPE_BUILD_PLAN_VERSION, steps, ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}) };
128
+ }
129
+
130
+ // cmd.exe would reinterpret these in a shell launch; refuse rather than escape.
131
+ const SHELL_UNSAFE = /[&|<>^%"!\r\n]/;
132
+
133
+ /**
134
+ * How to launch one step without a shell. `pnpm` runs through the pinned pnpm CLI (npm_execpath, set whenever the
135
+ * release runs under pnpm). On Windows a non-.exe command is a .cmd shim that only a shell can start, so it is allowed
136
+ * only when no argument could be reinterpreted by that shell.
137
+ */
138
+ export function resolveLaunch({ cmd, args = [], platform = process.platform, env = process.env, execPath = process.execPath }) {
139
+ if (cmd === "pnpm" && env.npm_execpath) return { command: execPath, args: [env.npm_execpath, ...args], shell: false };
140
+ if (platform === "win32" && !/\.(?:exe|com)$/i.test(cmd)) {
141
+ if ([cmd, ...args].some((value) => SHELL_UNSAFE.test(value))) fail("RECIPE_BUILD_LAUNCH_UNSAFE", `${cmd} needs a shell on Windows and its arguments contain characters a shell would reinterpret; run the release under pnpm (npm_execpath) or name an .exe`, { cmd });
142
+ return { command: cmd, args, shell: true };
143
+ }
144
+ return { command: cmd, args, shell: false };
145
+ }
146
+
147
+ const defaultSleep = (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
148
+ const defaultFs = { existsSync: nodeExistsSync, readdirSync: nodeReaddirSync, rmSync: nodeRmSync };
149
+
150
+ /**
151
+ * Mount points of mounted disk images in `hdiutil info -plist` output: a mount-point counts only when it follows an
152
+ * `image-path` key (so it belongs to an image). Anything unparsable yields an empty set, so nothing is detached.
153
+ */
154
+ export function diskImageMountPoints(plistText) {
155
+ const mounts = new Set();
156
+ const segments = String(plistText).split("<key>image-path</key>").slice(1);
157
+ for (const segment of segments) {
158
+ for (const match of segment.matchAll(/<key>mount-point<\/key>\s*<string>([^<]*)<\/string>/g)) mounts.add(match[1].replaceAll("&amp;", "&"));
159
+ }
160
+ return mounts;
161
+ }
162
+
163
+ /**
164
+ * Stale-artifact cleanup, run before the build. Returns what it did: { removed, unregistered, detached }.
165
+ * A failing detach or unregister is logged and skipped: the build's own gates decide pass or fail.
166
+ * Options: appRoot, platform, log, spawnSync, fs ({ existsSync, readdirSync, rmSync }), volumesDir.
167
+ */
168
+ export function runRecipeCleanup(cleanup, { appRoot, platform = process.platform, log = () => {}, spawnSync = nodeSpawnSync, fs = defaultFs, volumesDir = "/Volumes" } = {}) {
169
+ const result = { removed: [], unregistered: [], detached: [] };
170
+ if (!cleanup) return result;
171
+ const tool = (cmd, args) => {
172
+ const run = spawnSync(cmd, args, { stdio: "inherit", windowsHide: true });
173
+ const ok = !(run?.error || run?.status !== 0);
174
+ if (!ok) log(`recipe-build: cleanup ${cmd} ${args.join(" ")} did not succeed (${run?.error?.message ?? `exit ${run?.status ?? "unknown"}`}); continuing`);
175
+ return ok;
176
+ };
177
+ if (cleanup.dmgVolumes?.length && platform !== "darwin") log("recipe-build: cleanup dmgVolumes skipped (not macOS)");
178
+ else {
179
+ // Only a volume named exactly `<name>` or `<name> <N>` (the macOS duplicate suffix) that `hdiutil info -plist` reports as
180
+ // a mounted disk image is detached; a real disk, a network share or a similarly named folder is never touched.
181
+ const candidatesFor = (names) => {
182
+ const wanted = cleanup.dmgVolumes ?? [];
183
+ return names.filter((entry) => wanted.some((volName) => entry === volName || (entry.startsWith(`${volName} `) && /^[0-9]+$/.test(entry.slice(volName.length + 1))))).sort();
184
+ };
185
+ let names = [];
186
+ try { names = fs.readdirSync(volumesDir); } catch { names = []; }
187
+ const candidates = candidatesFor(names);
188
+ if (candidates.length) {
189
+ const info = spawnSync("hdiutil", ["info", "-plist"], { encoding: "utf8", windowsHide: true });
190
+ const imageMounts = info?.error || info?.status !== 0 ? null : diskImageMountPoints(String(info.stdout ?? ""));
191
+ if (!imageMounts) log("recipe-build: cleanup dmgVolumes skipped (hdiutil info -plist failed, so no volume is confirmed to be a disk image)");
192
+ else {
193
+ for (const name of candidates) {
194
+ const volume = path.posix.join(volumesDir, name);
195
+ if (!imageMounts.has(volume)) { log(`recipe-build: cleanup leaves ${volume} (not a mounted disk image)`); continue; }
196
+ log(`recipe-build: cleanup detach ${volume}`);
197
+ // No -force first; -force only on retry, and only for a volume confirmed to be an image mount.
198
+ if (tool("hdiutil", ["detach", volume]) || tool("hdiutil", ["detach", "-force", volume])) result.detached.push(name);
199
+ }
200
+ }
201
+ }
202
+ }
203
+ if (cleanup.apps) {
204
+ const dir = path.resolve(appRoot, cleanup.apps.dir);
205
+ let entries = [];
206
+ try { entries = fs.readdirSync(dir).filter((entry) => entry.toLowerCase().endsWith(".app")).sort(); } catch { entries = []; }
207
+ for (const entry of entries) {
208
+ const stale = path.join(dir, entry);
209
+ const root = path.resolve(appRoot);
210
+ const rel = path.relative(stale, root);
211
+ if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) || (dir === root && entry.toLowerCase() === "notarize.app")) {
212
+ fail("RECIPE_BUILD_CLEANUP_UNSAFE", `cleanup refuses ${stale}: it is, or contains, the app root ${root}`);
213
+ }
214
+ if (cleanup.apps.unregister) {
215
+ if (platform !== "darwin") log("recipe-build: cleanup lsregister skipped (not macOS)");
216
+ else if (tool(LSREGISTER, ["-u", stale])) result.unregistered.push(stale);
217
+ }
218
+ if (cleanup.apps.remove) {
219
+ log(`recipe-build: cleanup remove stale app ${stale}`);
220
+ fs.rmSync(stale, { recursive: true, force: true });
221
+ result.removed.push(stale);
222
+ }
223
+ }
224
+ }
225
+ for (const relative of cleanup.remove ?? []) {
226
+ const stale = path.resolve(appRoot, relative);
227
+ if (!fs.existsSync(stale)) continue;
228
+ log(`recipe-build: cleanup remove stale ${stale}`);
229
+ fs.rmSync(stale, { recursive: true, force: true });
230
+ result.removed.push(stale);
231
+ }
232
+ return result;
233
+ }
234
+
235
+ /**
236
+ * Wait until every file exists and keeps the same size and mtime over `checks` consecutive polls `intervalMs` apart.
237
+ * Throws RECIPE_BUILD_ARTIFACT_UNSTABLE when that does not happen within `timeoutMs`. Returns the absolute files.
238
+ * Options: appRoot, log, statSync, sleep(ms), now().
239
+ */
240
+ export function waitForStableArtifacts(stability, { appRoot, log = () => {}, statSync = nodeStatSync, sleep = defaultSleep, now = Date.now } = {}) {
241
+ if (!stability) return [];
242
+ const { intervalMs = 1000, checks = 2, timeoutMs = 120000 } = stability;
243
+ const signature = (file) => { try { const stat = statSync(file); return `${stat.size}:${stat.mtimeMs}`; } catch { return null; } };
244
+ const files = stability.files.map((relative) => path.resolve(appRoot, relative));
245
+ const deadline = now() + timeoutMs;
246
+ let previous = files.map(signature);
247
+ let stable = 0;
248
+ while (stable < checks) {
249
+ if (now() >= deadline) fail("RECIPE_BUILD_ARTIFACT_UNSTABLE", `artifact missing or still changing after ${timeoutMs} ms: ${files.join(", ")}`, { files });
250
+ sleep(intervalMs);
251
+ const current = files.map(signature);
252
+ stable = current.every((value, index) => value !== null && value === previous[index]) ? stable + 1 : 0;
253
+ previous = current;
254
+ }
255
+ log(`recipe-build: artifacts stable (${files.length} file(s), ${checks} check(s) ${intervalMs} ms apart)`);
256
+ return files;
257
+ }
258
+
259
+ /**
260
+ * Run the plan: cleanup, prepare steps, the build step, stability polling, then postBuild steps, strictly in order.
261
+ * Returns { steps: [{ phase, cmd, status }], cleaned, stable }. The first failure throws RECIPE_BUILD_STEP_FAILED and
262
+ * nothing after it runs.
263
+ * Options: appRoot (absolute), env, platform, log, spawnSync, fs, sleep, now, statSync (injected in tests).
264
+ */
265
+ export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform = process.platform, log = () => {}, spawnSync = nodeSpawnSync, fs, sleep, now, statSync } = {}) {
266
+ if (typeof appRoot !== "string" || !path.isAbsolute(appRoot)) fail("RECIPE_BUILD_PLAN_INVALID", "appRoot must be an absolute path");
267
+ const plan = validateRecipePlan(rawPlan);
268
+ const done = [];
269
+ const cleaned = runRecipeCleanup(plan.cleanup, { appRoot, platform, log, spawnSync, ...(fs ? { fs } : {}) });
270
+ let stable = [];
271
+ for (const [index, step] of plan.steps.entries()) {
272
+ const launch = resolveLaunch({ cmd: step.cmd, args: step.args, platform, env });
273
+ const cwd = path.resolve(appRoot, step.cwd ?? ".");
274
+ log(`recipe-build: [${step.phase}] ${step.cmd} ${step.args.join(" ")}`.trimEnd());
275
+ const result = spawnSync(launch.command, launch.args, { cwd, env: { ...env, ...(step.env ?? {}) }, stdio: "inherit", shell: launch.shell, windowsHide: true });
276
+ if (result?.error || result?.status !== 0) {
277
+ fail("RECIPE_BUILD_STEP_FAILED", `recipe-build ${step.phase} step ${index} (${step.cmd}) failed (${result?.error?.message ?? `exit ${result?.status ?? result?.signal ?? "unknown"}`}); later steps were not run`, { phase: step.phase, index, cmd: step.cmd, status: result?.status ?? null });
278
+ }
279
+ done.push({ phase: step.phase, cmd: step.cmd, status: 0 });
280
+ if (step.phase === "build") stable = waitForStableArtifacts(plan.stability, { appRoot, log, ...(sleep ? { sleep } : {}), ...(now ? { now } : {}), ...(statSync ? { statSync } : {}) });
281
+ }
282
+ return { steps: done, cleaned, stable };
283
+ }
284
+
285
+ export function parseRecipeBuildArgs(argv) {
286
+ const opts = { plan: undefined, planFile: undefined, appRoot: undefined };
287
+ for (let i = 0; i < argv.length; i += 1) {
288
+ const arg = argv[i];
289
+ if (arg === "--plan" || arg === "--plan-file" || arg === "--app-root") {
290
+ const value = argv[i + 1];
291
+ if (value === undefined || (arg !== "--plan" && value.startsWith("--"))) throw new Error(`${arg} needs a value`);
292
+ opts[arg === "--plan" ? "plan" : arg === "--plan-file" ? "planFile" : "appRoot"] = value;
293
+ i += 1;
294
+ } else throw new Error(`unknown recipe-build argument: ${arg}`);
295
+ }
296
+ if ((opts.plan === undefined) === (opts.planFile === undefined)) throw new Error("recipe-build needs exactly one of --plan <json> or --plan-file <path>");
297
+ return opts;
298
+ }
299
+
300
+ /**
301
+ * The commands a build command really runs. A declarative winPackage / macPackage wraps the Tauri build as
302
+ * `node recipe-build.mjs --plan <JSON>` (or `--plan-file <path>`), which hides each step's `--features` inside one JSON
303
+ * argument. The forbidden-feature scan and the cargo-tree closure must see every step, so this returns
304
+ * `[command, ...every prepare / build / postBuild step as { cmd, args, env? }]` (nested wrappers are expanded too), and
305
+ * `[command]` for any other command. A wrapper whose plan cannot be read or validated throws (fail closed).
306
+ * Options: root (the app root, for --plan-file and --app-root), readFile (injected in tests).
307
+ */
308
+ export function expandRecipeBuildCommands(command, { root = process.cwd(), readFile = readFileSync, depth = 0 } = {}) {
309
+ if (command === undefined || command === null) return [];
310
+ const parts = typeof command === "string"
311
+ ? (command.match(/"[^"]*"|'[^']*'|[^\s"']+/g) ?? []).map((token) => token.replace(/^["']|["']$/g, ""))
312
+ : [command?.cmd, ...(command?.args ?? [])].filter((value) => value !== undefined).map(String);
313
+ const at = parts.findIndex((part) => path.basename(part.replaceAll("\\", "/")) === "recipe-build.mjs" || part === "recipe-build");
314
+ if (at < 0) return [command];
315
+ const rest = parts.slice(at + 1);
316
+ if (!rest.some((arg) => arg === "--plan" || arg === "--plan-file")) return [command];
317
+ if (depth >= 3) fail("RECIPE_BUILD_PLAN_INVALID", "recipe-build plans are nested too deeply to scan");
318
+ let plan;
319
+ try {
320
+ const opts = parseRecipeBuildArgs(rest);
321
+ const text = opts.plan ?? readFile(path.resolve(path.resolve(root, opts.appRoot ?? "."), opts.planFile), "utf8");
322
+ plan = validateRecipePlan(JSON.parse(text));
323
+ } catch (error) {
324
+ if (error instanceof RecipeBuildError) throw error;
325
+ fail("RECIPE_BUILD_PLAN_INVALID", `cannot scan the recipe-build command (${error.message}); every step must be visible to the forbidden-feature scan`);
326
+ }
327
+ const steps = plan.steps.map(({ cmd, args, env }) => ({ cmd, args, ...(env ? { env } : {}) }));
328
+ return [command, ...steps.flatMap((step) => expandRecipeBuildCommands(step, { root, readFile, depth: depth + 1 }))];
329
+ }
330
+
331
+ async function main() {
332
+ const opts = parseRecipeBuildArgs(process.argv.slice(2));
333
+ const appRoot = path.resolve(opts.appRoot ?? process.cwd());
334
+ let text = opts.plan;
335
+ if (text === undefined) {
336
+ const { readFileSync } = await import("node:fs");
337
+ text = readFileSync(path.resolve(appRoot, opts.planFile), "utf8");
338
+ }
339
+ let plan;
340
+ try { plan = JSON.parse(text); } catch (error) { throw new RecipeBuildError("RECIPE_BUILD_PLAN_INVALID", `the plan is not JSON: ${error.message}`); }
341
+ runRecipeBuild(plan, { appRoot, log: (line) => console.log(line) });
342
+ }
343
+
344
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
345
+ main().catch((error) => { console.error(`right-release recipe-build: ${error.code ? `[${error.code}] ` : ""}${error.message}`); process.exitCode = 1; });
346
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { assertReleaseDependencies } from "./release-dependencies.mjs";
5
+ import { nodeHostTriple } from "./stage-sidecar-command.mjs";
6
+ import { expandPackageRecipeTarget } from "./package-recipe-targets.mjs";
7
+
8
+ /** Standalone policy check: host target by default, all declared release targets on request. */
9
+ export async function main(argv, { cwd = process.cwd(), platform = process.platform, arch = process.arch, run } = {}) {
10
+ let configPath = "right-release.config.mjs", selected, allTargets = false;
11
+ for (let i = 0; i < argv.length; i += 1) {
12
+ const arg = argv[i];
13
+ if (["--config", "--platform"].includes(arg)) {
14
+ const value = argv[++i];
15
+ if (!value || value.startsWith("--")) throw new Error(`${arg} needs a value`);
16
+ if (arg === "--config") configPath = value; else selected = value;
17
+ } else if (arg === "--all-targets") allTargets = true;
18
+ else throw new Error(`unknown check-release-dependencies argument: ${arg}`);
19
+ }
20
+ if (allTargets && selected) throw new Error("use --platform or --all-targets");
21
+ const file = path.resolve(cwd, configPath);
22
+ const root = path.dirname(file);
23
+ const config = (await import(pathToFileURL(file).href)).default;
24
+ if (config?.releaseDependencies === undefined) throw new Error("check-release-dependencies requires releaseDependencies in config");
25
+ const host = { darwin: "mac", win32: "win", linux: "linux" }[platform];
26
+ const platforms = allTargets ? Object.keys(config.targets ?? {}) : [selected ?? host];
27
+ if (!platforms.length) throw new Error("no release targets declared");
28
+ const results = [];
29
+ for (const name of platforms) {
30
+ let target = config.targets?.[name];
31
+ if (!name || !target) throw new Error(`no release target for ${name ?? platform}; use --platform or --all-targets`);
32
+ target = expandPackageRecipeTarget(target, { platform: name, appRoot: root, config });
33
+ if (name !== host && !(target.cargoTarget ?? target.targetTriple ?? target.rustTarget)) throw new Error(`${name} must declare cargoTarget, targetTriple or rustTarget`);
34
+ if (!allTargets && !selected) target = { ...target, cargoTarget: nodeHostTriple(platform, arch) };
35
+ results.push(assertReleaseDependencies({ root, config, target, run }));
36
+ }
37
+ return results;
38
+ }
39
+
40
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
41
+ main(process.argv.slice(2)).then((results) => console.log(JSON.stringify(results, null, 2))).catch((error) => {
42
+ console.error(`right-release check-release-dependencies: ${error.message}`);
43
+ process.exitCode = 1;
44
+ });
45
+ }
@@ -0,0 +1,60 @@
1
+ // App-specific release dependency policy. Development builds never call this gate.
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveCargoTree } from "./release-switch-scan.mjs";
5
+ import { expandRecipeBuildCommands } from "./recipe-build.mjs";
6
+ import { nodeHostTriple } from "./stage-sidecar-command.mjs";
7
+ import { resolveNativeFinalizerConfig } from "./native-release-finalization.mjs";
8
+
9
+ function crateNames(names) {
10
+ if (!Array.isArray(names) || names.some((name) => typeof name !== "string" || !/^[A-Za-z0-9_][A-Za-z0-9_-]*$/.test(name))) {
11
+ throw new TypeError("releaseDependencies.forbiddenCrates must be an array of exact Cargo package names");
12
+ }
13
+ return [...new Set(names)];
14
+ }
15
+
16
+ /** Exact package matches from `cargo tree -e normal --prefix none`, deduplicated across versions. */
17
+ export function forbiddenCrateFindings(treeText, forbiddenCrates) {
18
+ const denied = new Set(crateNames(forbiddenCrates));
19
+ const found = new Set();
20
+ for (const line of String(treeText).split(/\r?\n/)) {
21
+ const match = line.match(/^([A-Za-z0-9_][A-Za-z0-9_-]*) v\d[^\s]*(?:\s|$)/);
22
+ if (match && denied.has(match[1])) found.add(match[1]);
23
+ }
24
+ return [...found].sort();
25
+ }
26
+
27
+ /**
28
+ * Enforce config.releaseDependencies for one release target before packaging.
29
+ * `commands` may supply already-expanded release hooks; `run` injects the Cargo runner for tests.
30
+ * Returns { skipped } without Cargo when no policy is declared, else { manifest, target, trees, forbiddenCrates }.
31
+ * Cargo errors, empty output, invalid policy & forbidden packages throw; dry runs obey this gate too.
32
+ */
33
+ export function assertReleaseDependencies({ root, config = {}, target = {}, commands, run } = {}) {
34
+ const policy = config.releaseDependencies;
35
+ if (policy === undefined) return { skipped: "no releaseDependencies policy" };
36
+ if (!policy || typeof policy !== "object" || Array.isArray(policy)) throw new TypeError("releaseDependencies must be an object");
37
+ const forbiddenCrates = crateNames(policy.forbiddenCrates);
38
+ if (policy.features !== undefined && (!Array.isArray(policy.features) || policy.features.some((feature) => typeof feature !== "string" || !feature.trim() || feature.startsWith("-")))) {
39
+ throw new TypeError("releaseDependencies.features must be an array of Cargo feature names");
40
+ }
41
+ if (policy.noDefaultFeatures !== undefined && typeof policy.noDefaultFeatures !== "boolean") throw new TypeError("releaseDependencies.noDefaultFeatures must be boolean");
42
+ const manifest = policy.manifest ?? target.cargoManifest ?? config.releaseSwitches?.manifest ?? config.nativeAssembly?.cargoManifest ?? config.nativeAssembly?.manifest;
43
+ if (manifest !== undefined && (typeof manifest !== "string" || !manifest || !existsSync(path.resolve(root, manifest)))) {
44
+ throw new Error(`release dependencies: declared Cargo manifest does not exist: ${manifest}`);
45
+ }
46
+ const triple = target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? nodeHostTriple();
47
+ const commandList = (value) => value == null ? [] : Array.isArray(value) ? value : [value];
48
+ const hooks = commands ?? [target.prePackage, target.package, config.nativeAssembly?.packageHook,
49
+ resolveNativeFinalizerConfig(config.nativeAssembly), ...commandList(target.postSign), target.gradle].filter(Boolean);
50
+ const closure = resolveCargoTree({
51
+ root, manifest: manifest === undefined ? undefined : path.relative(root, path.resolve(root, manifest)), features: [...(config.releaseSwitches?.features ?? []), ...(policy.features ?? [])],
52
+ noDefaultFeatures: policy.noDefaultFeatures ?? false,
53
+ triple, commandTexts: hooks.flatMap((command) => expandRecipeBuildCommands(command, { root })),
54
+ edges: "normal", prefix: "none", label: "release dependencies", run,
55
+ });
56
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_-]* v\d[^\s]*(?:\s|$)/m.test(closure.treeText)) throw new Error("release dependencies: cargo tree returned no package records");
57
+ const found = forbiddenCrateFindings(closure.treeText, forbiddenCrates);
58
+ if (found.length) throw new Error(found.map((crate) => `FAIL: ${crate} is in the release dependency tree (${triple})`).join("\n"));
59
+ return { manifest: closure.manifest, target: triple, trees: closure.trees.length, forbiddenCrates };
60
+ }
@@ -120,7 +120,13 @@ function readPackageScripts(root) {
120
120
  // skip is `rust === false` (releaseSwitches.rust: false, an explicit "this app has no Rust") and only when no candidate
121
121
  // manifest exists. Returns { treeText, manifest } or { skipped: <reason> }. Throws when cargo fails: the caller fails closed.
122
122
  // `run` is injectable for tests: (command, args, options) => { status, stdout, stderr, error }.
123
- export function resolveFeatureClosure({ root, manifest, cargoManifest, rust, features = [], scripts, triple, commandTexts = [], run = spawnSync } = {}) {
123
+ export function resolveFeatureClosure(options = {}) {
124
+ return resolveCargoTree(options);
125
+ }
126
+
127
+ /** Shared Cargo tree resolver; keeps each build command's feature selection separate. */
128
+ export function resolveCargoTree({ root, manifest, cargoManifest, rust, features = [], scripts, triple, commandTexts = [],
129
+ edges = "features,no-dev", prefix, noDefaultFeatures = false, label = "feature closure", run = spawnSync } = {}) {
124
130
  const { rel, declared, candidates } = resolveCargoManifestPath({ root, cargoManifest, manifest });
125
131
  if (!rel) {
126
132
  if (rust === false) return { skipped: "releaseSwitches.rust is false: the app declares no Rust" };
@@ -129,7 +135,8 @@ export function resolveFeatureClosure({ root, manifest, cargoManifest, rust, fea
129
135
  if (declared.length && !declared.some((c) => existsSync(path.join(root, c)))) {
130
136
  throw new Error(`release-switch-manifest-missing: declared manifest ${declared.join(", ")} does not exist under ${root}`);
131
137
  }
132
- const baseArgs = ["tree", "-e", "features,no-dev", "--locked", "--manifest-path", path.join(root, rel), "--target", triple || "all"];
138
+ const baseArgs = ["tree", "--color", "never", "-e", edges, "--locked", "--manifest-path", path.join(root, rel), "--target", triple || "all"];
139
+ if (prefix) baseArgs.push("--prefix", prefix);
133
140
  const pkgScripts = scripts ?? readPackageScripts(root);
134
141
  // One cargo tree per distinct (features, no-default-features, manifest) combination, one combination per build command
135
142
  // (flags gathered over the command and the package.json scripts it runs). A `--no-default-features` in one command never
@@ -143,30 +150,33 @@ export function resolveFeatureClosure({ root, manifest, cargoManifest, rust, fea
143
150
  if (script && depth < 3 && Object.hasOwn(pkgScripts, script) && !seen.has(script)) expand(String(pkgScripts[script]), depth + 1, new Set([...seen, script]));
144
151
  };
145
152
  expand(text, 0, new Set());
146
- let noDefault = false;
153
+ let noDefault = noDefaultFeatures;
154
+ let allFeatures = false;
147
155
  const enabled = [...features];
148
156
  for (const t of expanded) {
149
157
  const tokens = commandTokens(t);
150
158
  if (tokens.includes("--no-default-features")) noDefault = true;
159
+ if (tokens.includes("--all-features")) allFeatures = true;
151
160
  enabled.push(...featuresInTokens(tokens).enabled);
152
161
  }
153
162
  const list = [...new Set(enabled)].sort();
154
- combos.set(`${rel}|${noDefault}|${list.join(",")}`, { noDefault, list });
163
+ combos.set(`${rel}|${noDefault}|${allFeatures}|${list.join(",")}`, { noDefault, allFeatures, list });
155
164
  };
156
165
  const texts = commandTexts.filter(Boolean);
157
166
  if (texts.length === 0) comboFor("");
158
167
  for (const text of texts) comboFor(text);
159
168
  const trees = [];
160
- for (const { noDefault, list } of combos.values()) {
169
+ for (const { noDefault, allFeatures, list } of combos.values()) {
161
170
  const args = [...baseArgs];
162
171
  if (noDefault) args.push("--no-default-features");
172
+ if (allFeatures) args.push("--all-features");
163
173
  if (list.length) args.push("--features", list.join(","));
164
174
  const result = run("cargo", args, { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
165
175
  if (result.error || result.status !== 0) {
166
- throw new Error(`feature closure: \`cargo ${args.join(" ")}\` failed (${result.error?.message ?? `exit ${result.status}`}): ${String(result.stderr ?? "").trim().slice(0, 600)}. The resolved feature graph is the authoritative test-mode check, so a release cannot continue without it.`);
176
+ throw new Error(`${label}: \`cargo ${args.join(" ")}\` failed (${result.error?.message ?? `exit ${result.status}`}): ${String(result.stderr ?? "").trim().slice(0, 600)}. The resolved feature graph is the authoritative test-mode check, so a release cannot continue without it.`);
167
177
  }
168
178
  const out = String(result.stdout ?? "");
169
- if (!out.trim()) throw new Error("feature closure: `cargo tree` printed nothing; cannot verify");
179
+ if (!out.trim()) throw new Error(`${label}: \`cargo tree\` printed nothing; cannot verify`);
170
180
  trees.push(out);
171
181
  }
172
182
  const treeText = trees.join("\n");