@rightkit/release 0.2.172 → 0.2.174

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
package/release.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { expandPackageRecipeTarget } from "./package-recipe-targets.mjs";
2
3
  import { access, readFile, readdir } from "node:fs/promises";
3
4
  import { createHash } from "node:crypto";
4
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
@@ -45,6 +46,7 @@ import { DEFAULT_STORAGE_THRESHOLD_BYTES, findInstallerCopyScripts, findLargeRep
45
46
  import { macosCodesignEnvironment } from "./codesign-environment.mjs";
46
47
  import { assertNoQaControl, assertNoWebBundleMocks } from "./qa-control-scan.mjs";
47
48
  import { assertNoForbiddenBuildFeatures } from "./qa-build-features.mjs";
49
+ import { expandRecipeBuildCommands } from "./recipe-build.mjs";
48
50
  import { assertNoReleaseSwitches, commandTokens, resolveFeatureClosure } from "./release-switch-scan.mjs";
49
51
  import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
50
52
  import { runAndroidRelease, validateAndroidTarget } from "./android-release.mjs";
@@ -189,7 +191,9 @@ validateRightKitCargoContract(root, RIGHTKIT_CARGO_ALLOWED, config.app ?? path.b
189
191
  assertCargoProfileContract(root, config.app ?? path.basename(root));
190
192
  // RightKit QA is mandatory for every app (also SwiftPM apps without a package.json): no QA lane refuses the release.
191
193
  assertQaAdoption({ appRoot: root, app: config.app });
192
- const target = config.targets?.[opts.platform];
194
+ let target = config.targets?.[opts.platform];
195
+ // Declarative winPackage / macPackage (#345) expand to the same hook shape every gate below already checks.
196
+ try { target = expandPackageRecipeTarget(target, { platform: opts.platform, appRoot: root, config }); } catch (error) { fail(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
193
197
  if (!target) fail(`${config.app ?? "app"} has no ${opts.platform} release target`);
194
198
  const legalContract = config.legal
195
199
  ? assertLegalReleaseContract(root, config.legal, config.app ?? path.basename(root), opts.platform)
@@ -353,7 +357,7 @@ try {
353
357
  { label: "target.package", command: target.package },
354
358
  { label: "nativeAssembly.packageHook", command: nativeAssembly?.packageHook },
355
359
  { label: "nativeAssembly.finalizer", command: nativeFinalizer },
356
- { label: "target.postSign", command: target.postSign },
360
+ ...commandList(target.postSign).map((command, index) => ({ label: Array.isArray(target.postSign) ? `target.postSign[${index}]` : "target.postSign", command })),
357
361
  { label: "target.gradle", command: target.gradle },
358
362
  ] });
359
363
  } catch (error) { fail(error.message); }
@@ -367,6 +371,9 @@ try {
367
371
  const releaseSwitchInventoryPath = path.resolve(root, config.releaseSwitches?.inventory ?? "switches.json");
368
372
  const releaseSwitchInventoryText = existsSync(releaseSwitchInventoryPath) ? readFileSync(releaseSwitchInventoryPath, "utf8") : null;
369
373
  try {
374
+ // A `node recipe-build.mjs --plan <JSON>` prePackage hides each step's `--features` in one argument: the closure and the
375
+ // command token scan see every prepare / build / postBuild step (a plan that cannot be read or validated fails closed).
376
+ const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
370
377
  let treeText;
371
378
  try {
372
379
  const closure = resolveFeatureClosure({
@@ -376,7 +383,7 @@ try {
376
383
  rust: config.releaseSwitches?.rust,
377
384
  features: config.releaseSwitches?.features ?? [],
378
385
  triple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget,
379
- commandTexts: [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, target.postSign, target.gradle],
386
+ commandTexts: closureCommands,
380
387
  });
381
388
  if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
382
389
  else treeText = closure.treeText;
@@ -385,7 +392,7 @@ try {
385
392
  console.log(`right-release: dry run: ${error.message}`);
386
393
  }
387
394
  // remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
388
- const closureInput = { treeText, remoteHost: config.releaseSwitches?.remoteHost, commands: [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, target.postSign, target.gradle].filter(Boolean).map((c) => commandTokens(c)) };
395
+ const closureInput = { treeText, remoteHost: config.releaseSwitches?.remoteHost, commands: closureCommands.map((c) => commandTokens(c)) };
389
396
  if (releaseSwitchInventoryText != null) {
390
397
  assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
391
398
  } else if (treeText != null || closureInput.commands.length) {
@@ -491,7 +498,7 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
491
498
  assertNoQaControlBinaries("macOS app staged for the DMG", [stagedApp], { requireBinary: true, prePack: true });
492
499
  }
493
500
  if (runMacPhase("sign")) {
494
- await signMacos(signingEntries, root, "signing", macSigning.nestedCode);
501
+ await signMacos(signingEntries, root, "signing", macSigning.nestedCode, macSigning.nestedSigning);
495
502
  if (sidecarPlan && !opts.dryRun) {
496
503
  const app = signingEntries.find((entry) => /\.app\/?$/.test(entry.file));
497
504
  if (!app) fail("mediaSidecar on macOS needs an .app entry in sign.prePackageFiles");
@@ -560,14 +567,14 @@ if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_C
560
567
  // and still installs a binary Windows rejects.
561
568
  await verifyWindowsEmbedding({ root, target, files, markers: bundleMarkerReceipts });
562
569
  await signWindows(files, "installer", root);
563
- if (target.postSign) await runCommand(target.postSign, root);
570
+ if (target.postSign) await runCommands(target.postSign, root);
564
571
  const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => resolveReleaseArtifactPath(root, artifact.file)))];
565
572
  if (!updaterFiles.length) fail(`${config.app} win must declare updater.artifacts for post-code-signing minisign`);
566
573
  await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...(target.updater?.keyFile ? ["--key-file", String(target.updater.keyFile)] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
567
574
  }
568
575
 
569
576
  if (target.postSign && opts.platform !== "win" && (opts.platform !== "mac" || runMacPhase("post-sign"))) {
570
- await runCommand(target.postSign, root);
577
+ await runCommands(target.postSign, root);
571
578
  }
572
579
 
573
580
  if (opts.platform === "mac") {
@@ -930,6 +937,17 @@ async function runPackageScript(pm, script, cwd) {
930
937
  await run(pm, ["run", script], cwd);
931
938
  }
932
939
 
940
+ // A hook may be one command object or an ordered array of them (e.g. create-mac-updater then lsclean).
941
+ function commandList(value) {
942
+ if (value == null) return [];
943
+ return Array.isArray(value) ? value : [value];
944
+ }
945
+
946
+ // Runs in order; run() exits the process on the first failure, so the chain fails closed.
947
+ async function runCommands(value, root, options) {
948
+ for (const command of commandList(value)) await runCommand(command, root, options);
949
+ }
950
+
933
951
  async function runCommand(command, root, { env = {}, captureStdout = false } = {}) {
934
952
  const cwd = path.resolve(root, command.cwd ?? ".");
935
953
  return run(command.cmd, command.args ?? [], cwd, { ...(await commandEnv(command, root)), ...env }, {
@@ -1459,12 +1477,12 @@ function macSignInvocation(items, receiptPath, extra = []) {
1459
1477
  return [SIGN_MACOS, ...extra, "--receipt", receiptPath, "--manifest", manifest];
1460
1478
  }
1461
1479
 
1462
- async function signMacos(entries, root, phase = "signing", nestedCode = []) {
1480
+ async function signMacos(entries, root, phase = "signing", nestedCode = [], nestedSigning) {
1463
1481
  const files = entries.map((entry) => entry.file);
1464
1482
  const receipt = phase === "signing" ? macosReceiptPath(root, "signing") : path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts"), `macos-${phase}.json`);
1465
1483
  // Directory entries (.app bundles): nested code first (default settings unless sign.nestedCode names it), then the bundle itself.
1466
1484
  let nested = [];
1467
- try { ({ nested } = opts.dryRun ? { nested: [] } : planMacSigning({ entries, nestedCode })); } catch (error) { fail(error.message); }
1485
+ try { ({ nested } = opts.dryRun ? { nested: [] } : planMacSigning({ entries, nestedCode, nestedSigning, root })); } catch (error) { fail(error.message); }
1468
1486
  if (opts.dryRun && (nestedCode.length || entries.some(hasNonDefaultSettings))) {
1469
1487
  console.log(`right-release: dry-run: per-file signing matrix (${nestedCode.length} nestedCode rule(s), ${entries.filter(hasNonDefaultSettings).length} entry setting(s)); nested code is resolved against the built bundle at sign time`);
1470
1488
  }
@@ -11,6 +11,11 @@ import { parseCodesignArgv } from "./codesign-argv.mjs";
11
11
 
12
12
  export const AGENT_LABEL = "dev.rightkit.signing-agent";
13
13
  export const MAX_FRAME = 1024 * 1024;
14
+ // A typed codesign `--identifier` (reverse-DNS characters only; never starts with "-", so it cannot read as an option).
15
+ export const AGENT_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$/;
16
+ // Request schema 2 is codesign-only and is the sole schema that may carry per-item `identifier`. Schema 1 stays valid and
17
+ // byte-compatible for everything else; an agent that predates schema 2 refuses it (and any unknown item field) outright.
18
+ export const CODESIGN_IDENTIFIER_SCHEMA = 2;
14
19
  export const NOTARIZATION_TIMEOUT_MS = 1800000;
15
20
  export const MAX_NOTARIZATION_TIMEOUT_MS = 7200000;
16
21
  export class SigningAgentError extends Error {
@@ -72,7 +77,7 @@ export function validateSigningRequest(request, { roots, stagingRoot, io = fs }
72
77
  const notary = ["notarize", "staple"].includes(request?.type);
73
78
  if (!["codesign", "codesign-argv", "updater", "notarize", "staple"].includes(request?.type)) fail("MAC_SIGNING_AGENT_PROTOCOL", "Unsupported signing request type");
74
79
  shape(request, [...common, ...(notary ? ["path", "profile", "staple", "app", "validateOnly"] : request.type === "codesign-argv" ? ["argv", "targets"] : [request.type === "codesign" ? "items" : "files"])]);
75
- if (request.schema !== 1 || !Number.isInteger(request.timeoutMs) || request.timeoutMs < 1 || request.timeoutMs > (notary ? MAX_NOTARIZATION_TIMEOUT_MS : 180000)) {
80
+ if (!(request.schema === 1 || (request.type === "codesign" && request.schema === CODESIGN_IDENTIFIER_SCHEMA)) || !Number.isInteger(request.timeoutMs) || request.timeoutMs < 1 || request.timeoutMs > (notary ? MAX_NOTARIZATION_TIMEOUT_MS : 180000)) {
76
81
  fail("MAC_SIGNING_AGENT_PROTOCOL", "Invalid schema or signing deadline");
77
82
  }
78
83
  if (!Array.isArray(request.allowedRoots) || !request.allowedRoots.length || request.allowedRoots.length > 32) fail("MAC_SIGNING_AGENT_PATH_DENIED", "Allowed roots required");
@@ -142,7 +147,8 @@ export function validateSigningRequest(request, { roots, stagingRoot, io = fs }
142
147
  }
143
148
  if (!Array.isArray(request.items) || !request.items.length || request.items.length > 4096) fail("MAC_SIGNING_AGENT_PROTOCOL", "Codesign items required");
144
149
  const items = request.items.map((item) => {
145
- shape(item, ["path", "identity", "entitlements", "runtime", "timestamp", "force", "preserveEntitlements"]);
150
+ shape(item, ["path", "identity", "entitlements", "runtime", "timestamp", "force", "preserveEntitlements", ...(request.schema === CODESIGN_IDENTIFIER_SCHEMA ? ["identifier"] : [])]);
151
+ if (item.identifier !== undefined && (typeof item.identifier !== "string" || !AGENT_IDENTIFIER_PATTERN.test(item.identifier))) fail("MAC_SIGNING_AGENT_PROTOCOL", "Invalid codesign identifier");
146
152
  if (typeof item.identity !== "string" || !item.identity.trim() || item.identity === "-" || item.identity.includes("\0") || item.identity.length > 512) fail("MAC_SIGNING_AGENT_PROTOCOL", "Real signing identity required");
147
153
  for (const field of ["runtime", "timestamp", "force", "preserveEntitlements"]) if (item[field] !== undefined && typeof item[field] !== "boolean") fail("MAC_SIGNING_AGENT_PROTOCOL", "Signing options must be boolean");
148
154
  return { ...item, path: checked(item.path), ...(item.entitlements !== undefined ? { entitlements: checked(item.entitlements, { entitlements: true }) } : {}) };
@@ -156,7 +162,7 @@ export function authenticateSigningToken(candidate, token) {
156
162
  }
157
163
  export function executeSigningRequest(request, { run = spawnSync, home = os.userInfo().homedir,
158
164
  username = os.userInfo().username, pnpm, environmentPath, now = Date.now } = {}) {
159
- if (request.type === "health") return { schema: 1, ok: true, service: AGENT_LABEL, pid: process.pid, capabilities: ["codesign", "codesign-argv", "updater", "notarize", "staple"] };
165
+ if (request.type === "health") return { schema: 1, ok: true, service: AGENT_LABEL, pid: process.pid, capabilities: ["codesign", "codesign-argv", "updater", "notarize", "staple", "codesign-identifier"] };
160
166
  const deadline = now() + request.timeoutMs;
161
167
  const env = { HOME: home, PATH: environmentPath, TMPDIR: path.join(signingAgentPaths(home).directory, "tmp") };
162
168
  if (request.type === "codesign-argv") {
@@ -191,7 +197,7 @@ export function executeSigningRequest(request, { run = spawnSync, home = os.user
191
197
  // Preserve caller's inside-out ordering. No arbitrary argv reaches spawn.
192
198
  for (const item of request.items) {
193
199
  const args = [...(item.force ? ["--force"] : []), ...(item.runtime ? ["--options", "runtime"] : []),
194
- ...(item.timestamp ? ["--timestamp"] : []), "--sign", item.identity,
200
+ ...(item.timestamp ? ["--timestamp"] : []), ...(item.identifier !== undefined ? ["--identifier", item.identifier] : []), "--sign", item.identity,
195
201
  ...(item.entitlements ? ["--entitlements", item.entitlements] : []),
196
202
  ...(item.preserveEntitlements ? ["--preserve-metadata=entitlements"] : []), item.path];
197
203
  results.push(invoke("/usr/bin/codesign", args).result);
package/rightkit-sync.mjs CHANGED
@@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";
10
10
  const packageRoot = path.dirname(fileURLToPath(import.meta.url));
11
11
  const rightkitRoot = path.resolve(packageRoot, "..", "..");
12
12
  const VERSIONS_FILE = path.join(packageRoot, "rightkit-versions.json");
13
- const USER_AGENT = "rightkit-sync/1 (https://github.com/Orthic-Labs/rightkit)";
13
+ const USER_AGENT = "rightkit-sync/1 (https://github.com/bogusyogi/rightkit)";
14
14
 
15
15
  const field = (toml, key) => toml.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, "m"))?.[1] ?? null;
16
16
 
@@ -31,7 +31,7 @@
31
31
  "@rightkit/qa": "0.4.9",
32
32
  "@rightkit/qa-chrome": "0.1.8",
33
33
  "@rightkit/react-shell": "0.6.8",
34
- "@rightkit/release": "0.2.172",
34
+ "@rightkit/release": "0.2.174",
35
35
  "@rightkit/settings-ui": "0.5.0",
36
36
  "@rightkit/tauri": "0.2.1",
37
37
  "@rightkit/tauri-shell": "0.1.1",
@@ -209,7 +209,9 @@
209
209
  "0.2.162",
210
210
  "0.2.163",
211
211
  "0.2.164",
212
- "0.2.165"
212
+ "0.2.165",
213
+ "0.2.172",
214
+ "0.2.173"
213
215
  ],
214
216
  "@rightkit/qa": [
215
217
  "0.1.0",
@@ -383,7 +385,7 @@
383
385
  "rightkit-automation": "0.4.1",
384
386
  "rightkit-browser": "0.3.1",
385
387
  "rightkit-cache": "0.2.0",
386
- "rightkit-channel-memory": "0.1.0",
388
+ "rightkit-channel-memory": "0.2.1",
387
389
  "rightkit-child-process": "0.4.3",
388
390
  "rightkit-compose": "0.1.0",
389
391
  "rightkit-contracts": "0.1.5",
@@ -455,7 +457,7 @@
455
457
  "rightkit-automation": "0.4.1",
456
458
  "rightkit-browser": "0.3.1",
457
459
  "rightkit-cache": "0.2.0",
458
- "rightkit-channel-memory": "0.1.0",
460
+ "rightkit-channel-memory": "0.2.1",
459
461
  "rightkit-child-process": "0.4.1",
460
462
  "rightkit-compose": "0.1.0",
461
463
  "rightkit-contracts": "0.1.5",
@@ -537,10 +539,10 @@
537
539
  "@rightkit/outdated": "0.2.0",
538
540
  "@rightkit/pdf-ui": "0.1.8",
539
541
  "@rightkit/platform-ui": "0.2.8",
540
- "@rightkit/qa": "0.4.8",
542
+ "@rightkit/qa": "0.4.9",
541
543
  "@rightkit/qa-chrome": "0.1.8",
542
544
  "@rightkit/react-shell": "0.6.8",
543
- "@rightkit/release": "0.2.165",
545
+ "@rightkit/release": "0.2.173",
544
546
  "@rightkit/settings-ui": "0.5.0",
545
547
  "@rightkit/shell": "0.1.1",
546
548
  "@rightkit/tauri": "0.2.1",
@@ -811,6 +813,9 @@
811
813
  ],
812
814
  "rightkit-hotkeys": [
813
815
  "0.1.0"
816
+ ],
817
+ "rightkit-channel-memory": [
818
+ "0.1.0"
814
819
  ]
815
820
  }
816
821
  }