@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,245 @@
1
+ // Declarative package targets (bogusyogi/rightkit#345). `targets.win.winPackage` and `targets.mac.macPackage` replace
2
+ // the per-app NSIS / DMG hook scripts. They expand to the SAME hook shape release.mjs already runs (prePackage,
3
+ // package, sign, notarize, dmg), so every existing gate still applies unchanged: QA-control scans, the bundle
4
+ // marker patch before signing, raw-EXE and installer Authenticode, the NSIS embedding verification, Developer ID
5
+ // signing of the .app and its nested code, app notarization before packing, the signed DMG and its notarization.
6
+ // The standalone recipes (win-package-recipe.mjs, mac-package-recipe.mjs) hold the same steps for direct callers.
7
+ import { existsSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { resolveTargetRoot as cargoResolveTargetRoot } from "./cargo-target.mjs";
11
+ import { validateWinPackage, WinPackageConfigError } from "./win-package-recipe.mjs";
12
+
13
+ export class PackageRecipeTargetError extends Error {
14
+ constructor(message) { super(message); this.name = "PackageRecipeTargetError"; this.code = "RK_PACKAGE_RECIPE_TARGET"; }
15
+ }
16
+
17
+ const fail = (message) => { throw new PackageRecipeTargetError(message); };
18
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
19
+ const rel = (appRoot, file) => path.relative(appRoot, file).split(path.sep).join("/");
20
+ // Tauri never signs or creates updater artifacts here: release.mjs signs, and sign-updater signs the signed bytes.
21
+ const withoutRecipe = (target) => {
22
+ if (!isObject(target) || !("recipe" in target)) return target;
23
+ const { recipe: _ignored, ...rest } = target;
24
+ return rest;
25
+ };
26
+ const QUIET_TAURI_ENV = { TAURI_SIGNING_PRIVATE_KEY: "", TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" };
27
+ const RECIPE_BUILD = fileURLToPath(new URL("./recipe-build.mjs", import.meta.url));
28
+ const PROFILE = /^[a-z][a-z0-9_-]*$/;
29
+
30
+ function relativeInside(value, label) {
31
+ if (typeof value !== "string" || !value.trim()) fail(`${label} must be a non-empty relative path`);
32
+ const normalized = value.replaceAll("\\", "/");
33
+ if (path.isAbsolute(value) || /^[A-Za-z]:/.test(value) || normalized.split("/").includes("..")) fail(`${label} must be relative and stay inside the app: ${value}`);
34
+ return normalized;
35
+ }
36
+
37
+ /** `prepare` / `postBuild`: a list of { cmd, args?, cwd? } argv commands (no shell). */
38
+ function commandList(value, label) {
39
+ if (!Array.isArray(value)) fail(`${label} must be an array of { cmd, args?, cwd? }`);
40
+ return value.map((item, index) => {
41
+ const where = `${label}[${index}]`;
42
+ if (!isObject(item)) fail(`${where} must be an object`);
43
+ const unknown = Object.keys(item).filter((key) => !["cmd", "args", "cwd"].includes(key));
44
+ if (unknown.length) fail(`${where} has unknown key(s): ${unknown.join(", ")} (allowed: cmd, args, cwd)`);
45
+ if (typeof item.cmd !== "string" || !item.cmd.trim()) fail(`${where}.cmd must be a non-empty string`);
46
+ if (item.args !== undefined && (!Array.isArray(item.args) || item.args.some((arg) => typeof arg !== "string"))) fail(`${where}.args must be a list of strings`);
47
+ return { cmd: item.cmd, args: [...(item.args ?? [])], ...(item.cwd === undefined ? {} : { cwd: relativeInside(item.cwd, `${where}.cwd`) }) };
48
+ });
49
+ }
50
+
51
+ /** The keys shared by winPackage and macPackage: profile, tauriConfig, prepare (and postBuild where allowed). */
52
+ function buildExtras(spec, label, { postBuild }) {
53
+ if (spec.profile !== undefined && (typeof spec.profile !== "string" || !PROFILE.test(spec.profile))) fail(`${label}.profile must be a Cargo profile name`);
54
+ const tauriConfig = spec.tauriConfig === undefined ? undefined : relativeInside(spec.tauriConfig, `${label}.tauriConfig`);
55
+ if (tauriConfig && !/\.json$/i.test(tauriConfig)) fail(`${label}.tauriConfig must be a .json file`);
56
+ return {
57
+ profile: spec.profile,
58
+ tauriConfig,
59
+ prepare: spec.prepare === undefined ? [] : commandList(spec.prepare, `${label}.prepare`),
60
+ postBuild: !postBuild || spec.postBuild === undefined ? [] : commandList(spec.postBuild, `${label}.postBuild`),
61
+ };
62
+ }
63
+
64
+ /** Tauri v2 passes Cargo flags after `--`; the profile also decides where Tauri looks for the built binary. */
65
+ const profileArgs = (extras) => (extras.profile ? ["--", "--profile", extras.profile] : []);
66
+ /** The build command also takes `--jobs N` (Cargo parallelism), after the same `--`. */
67
+ const buildCargoArgs = (extras, jobs) => {
68
+ const flags = [...(extras.profile ? ["--profile", extras.profile] : []), ...(jobs ? ["--jobs", String(jobs)] : [])];
69
+ return flags.length ? ["--", ...flags] : [];
70
+ };
71
+ const configArgs = (extras, appRoot, cwd) => (extras.tauriConfig ? ["--config", path.relative(path.resolve(appRoot, cwd), path.resolve(appRoot, extras.tauriConfig)).split(path.sep).join("/")] : []);
72
+
73
+ /**
74
+ * release.mjs runs ONE prePackage command: node recipe-build.mjs runs the built-in cleanup, prepare, the Tauri build,
75
+ * the artifact stability poll and postBuild in order. `cleanup` and `stability` are recipe-build plan sections.
76
+ */
77
+ function recipeBuildCommand({ extras, build, env, cleanup, stability, timeoutMs }) {
78
+ const steps = [
79
+ ...extras.prepare.map((step) => ({ phase: "prepare", ...step })),
80
+ { phase: "build", cmd: build.cmd, args: build.args, cwd: build.cwd },
81
+ ...extras.postBuild.map((step) => ({ phase: "postBuild", ...step })),
82
+ ];
83
+ const plan = { version: 1, steps, ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}) };
84
+ return { cmd: process.execPath, args: [RECIPE_BUILD, "--plan", JSON.stringify(plan)], env, ...(timeoutMs ? { timeoutMs } : {}) };
85
+ }
86
+
87
+ const DEFAULT_WIN_TIMEOUT_MS = 20 * 60 * 1000;
88
+ const positiveInt = (value) => Number.isInteger(value) && value > 0;
89
+
90
+ /**
91
+ * Managed Cargo target roots. Artifact paths in a target are declared (and default) under `<cargo dir>/target/...`.
92
+ * With `cargoTargetRoot` (true: <tauriCwd>/src-tauri/Cargo.toml; or an app-relative Cargo.toml path) or CARGO_TARGET_DIR set
93
+ * (default manifest), `cargo metadata`'s target_directory is resolved and every declared path under the default
94
+ * `<cargo dir>/target/` is moved to the real directory. release.mjs needs app-relative paths with no `..`, so a target
95
+ * directory outside the app fails closed (use RIGHT_RELEASE_ARTIFACT_DIR for copies, see the docs).
96
+ */
97
+ function targetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label }) {
98
+ const explicit = spec.cargoTargetRoot;
99
+ if (explicit !== undefined && explicit !== true && explicit !== false && typeof explicit !== "string") fail(`${label}.cargoTargetRoot must be true or an app-relative Cargo.toml path`);
100
+ const envDir = typeof env.CARGO_TARGET_DIR === "string" && env.CARGO_TARGET_DIR.trim() !== "";
101
+ if (explicit === false || (!explicit && !envDir)) return (declared) => declared;
102
+ const manifestRel = typeof explicit === "string" ? relativeInside(explicit, `${label}.cargoTargetRoot`) : path.posix.join(cwd === "." ? "" : cwd, "src-tauri", "Cargo.toml");
103
+ const manifest = path.resolve(appRoot, manifestRel);
104
+ if (typeof explicit !== "string" && explicit !== true && !existsSync(manifest)) return (declared) => declared;
105
+ let targetDir;
106
+ try { targetDir = resolveTargetRoot(manifest); } catch (error) { fail(`${label}: cannot resolve the Cargo target directory (${error.message})`); }
107
+ const prefix = path.posix.join(path.posix.dirname(manifestRel).replace(/^\.$/, ""), "target");
108
+ return (declared) => {
109
+ const normalized = String(declared).replaceAll("\\", "/");
110
+ if (!normalized.startsWith(`${prefix}/`)) return declared;
111
+ const moved = path.resolve(targetDir, normalized.slice(prefix.length + 1));
112
+ const relative = path.relative(appRoot, moved).split(path.sep).join("/");
113
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) || /^[A-Za-z]:/.test(relative)) {
114
+ fail(`${label}: the Cargo target directory ${targetDir} is outside the app, so ${declared} cannot be addressed by an app-relative path; point CARGO_TARGET_DIR inside the app or declare the artifact paths yourself`);
115
+ }
116
+ return relative;
117
+ };
118
+ }
119
+
120
+ function winPrePackage({ extras, cwd, appRoot, triple, jobs, timeoutMs, cleanup, stability }) {
121
+ const args = ["exec", "tauri", "build", "--no-bundle", "--target", triple, ...configArgs(extras, appRoot, cwd), ...buildCargoArgs(extras, jobs)];
122
+ return recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup, stability, timeoutMs });
123
+ }
124
+
125
+ /** `targets.win.winPackage` → prePackage (`tauri build --no-bundle`) and package (`tauri bundle --bundles nsis`). */
126
+ export function expandWinPackageTarget(target, { appRoot, config = {}, env = process.env, resolveTargetRoot = cargoResolveTargetRoot }) {
127
+ if (!target?.winPackage) return withoutRecipe(target);
128
+ if (target.prePackage !== undefined) fail("targets.win.winPackage replaces prePackage; remove the prePackage script");
129
+ if (target.winPackage.sidecars !== undefined) fail("targets.win.winPackage.sidecars is not supported in a right-release target: declare sidecars through externalBin or mediaSidecar, which release.mjs signs with the raw EXE");
130
+ if (!isObject(target.winPackage)) fail("targets.win.winPackage must be an object");
131
+ const { profile: _p, tauriConfig: _t, prepare: _pr, postBuild, jobs, timeoutMs, cleanStale, cargoTargetRoot: _c, ...coreSpec } = target.winPackage;
132
+ if (postBuild !== undefined) fail("targets.win.winPackage.postBuild is not supported: release.mjs signs the raw EXE between the build and the bundle; use prepare");
133
+ if (jobs !== undefined && !positiveInt(jobs)) fail("targets.win.winPackage.jobs must be a positive integer (Cargo --jobs)");
134
+ if (timeoutMs !== undefined && !positiveInt(timeoutMs)) fail("targets.win.winPackage.timeoutMs must be a positive integer (milliseconds)");
135
+ if (cleanStale !== undefined && typeof cleanStale !== "boolean") fail("targets.win.winPackage.cleanStale must be a boolean");
136
+ const extras = buildExtras(target.winPackage, "targets.win.winPackage", { postBuild: false });
137
+ let plan;
138
+ try { plan = validateWinPackage({ appRoot, config, target: { ...target, winPackage: coreSpec } }); } catch (error) {
139
+ if (error instanceof WinPackageConfigError) fail(`targets.win.winPackage: ${(error.issues ?? [error.message]).join("; ")}`);
140
+ throw error;
141
+ }
142
+ if (target.signingContract !== undefined && target.signingContract !== "windows-raw-exe-authenticode-before-nsis-v1") fail(`targets.win.winPackage builds an NSIS installer; signingContract must be windows-raw-exe-authenticode-before-nsis-v1, got ${target.signingContract}`);
143
+ const cwd = rel(plan.appRoot, plan.tauriCwd) || ".";
144
+ const override = { bundle: { createUpdaterArtifacts: false, windows: { nsis: { installMode: plan.perMachine ? "perMachine" : "currentUser" } } } };
145
+ const mapPath = targetRootMapper({ spec: target.winPackage, appRoot: plan.appRoot, cwd, env, resolveTargetRoot, label: "targets.win.winPackage" });
146
+ const rawExe = mapPath(rel(plan.appRoot, plan.rawExe));
147
+ const installerFile = mapPath(rel(plan.appRoot, plan.installerFile));
148
+ // Stale outputs are removed before the build, and the raw EXE is polled until it stops changing after it.
149
+ const cleanup = cleanStale === false ? undefined : { remove: [rawExe, installerFile] };
150
+ const timeout = timeoutMs ?? DEFAULT_WIN_TIMEOUT_MS;
151
+ // `recipe` is set only here: a hand-written one is dropped so it can never select a recipe path.
152
+ const { winPackage, recipe: _ignoredWin, ...rest } = target;
153
+ return {
154
+ ...rest,
155
+ signingContract: "windows-raw-exe-authenticode-before-nsis-v1",
156
+ prePackage: winPrePackage({ extras, cwd, appRoot: plan.appRoot, triple: plan.triple, jobs, timeoutMs: timeout, cleanup, stability: { files: [rawExe] } }),
157
+ package: { cmd: "pnpm", args: ["exec", "tauri", "bundle", "--bundles", "nsis", "--target", plan.triple, ...configArgs(extras, plan.appRoot, cwd), "--config", JSON.stringify(override), ...profileArgs(extras)], cwd, env: QUIET_TAURI_ENV, timeoutMs: timeout },
158
+ sign: { ...(target.sign ?? {}), prePackageFiles: [rawExe], files: [installerFile] },
159
+ recipe: { kind: "winPackage", productName: plan.productName, updater: plan.updater },
160
+ };
161
+ }
162
+
163
+ const MAC_KEYS = new Set(["universal", "triple", "tauriCwd", "features", "profile", "tauriConfig", "prepare", "postBuild", "cleanup", "cargoTargetRoot"]);
164
+ const MAC_TRIPLES = ["aarch64-apple-darwin", "x86_64-apple-darwin"];
165
+ const MAC_CLEANUP_KEYS = new Set(["staleApps", "staleDmgVolumes", "lsclean"]);
166
+
167
+ function macCleanupSpec(value) {
168
+ if (value === undefined) return {};
169
+ if (!isObject(value)) fail("targets.mac.macPackage.cleanup must be an object");
170
+ const unknown = Object.keys(value).filter((key) => !MAC_CLEANUP_KEYS.has(key));
171
+ if (unknown.length) fail(`targets.mac.macPackage.cleanup has unknown key(s): ${unknown.join(", ")} (allowed: ${[...MAC_CLEANUP_KEYS].join(", ")})`);
172
+ for (const key of MAC_CLEANUP_KEYS) if (value[key] !== undefined && typeof value[key] !== "boolean") fail(`targets.mac.macPackage.cleanup.${key} must be a boolean`);
173
+ return value;
174
+ }
175
+
176
+ /**
177
+ * `targets.mac.macPackage` → prePackage (`tauri build --bundles app`, universal by default), the .app as the
178
+ * Developer ID signing set, app notarization before packing, and right-release's own signed DMG from the stapled app.
179
+ * The app path and DMG path are `notarize.app` and `notarize.file`; DMG branding is `targets.mac.dmg`.
180
+ * Architecture: `triple` (an explicit arch triple) beats `universal`; `universal: false` means this host's arch.
181
+ * `cleanup` runs built-in steps in recipe-build before the build (stale DMG volumes, stale .app bundles, lsregister).
182
+ */
183
+ export function expandMacPackageTarget(target, { appRoot, env = process.env, resolveTargetRoot = cargoResolveTargetRoot }) {
184
+ if (!target?.macPackage) return withoutRecipe(target);
185
+ const spec = target.macPackage;
186
+ if (!isObject(spec)) fail("targets.mac.macPackage must be an object");
187
+ const unknown = Object.keys(spec).filter((key) => !MAC_KEYS.has(key));
188
+ if (unknown.length) fail(`targets.mac.macPackage has unknown key(s): ${unknown.join(", ")} (allowed: ${[...MAC_KEYS].join(", ")})`);
189
+ if (target.prePackage !== undefined || target.package !== undefined) fail("targets.mac.macPackage replaces prePackage and package; remove both scripts");
190
+ if (typeof target.notarize?.app !== "string" || !target.notarize.app.endsWith(".app")) fail("targets.mac.macPackage needs notarize.app: the built .app path");
191
+ if (typeof target.notarize?.file !== "string" || !target.notarize.file.endsWith(".dmg")) fail("targets.mac.macPackage needs notarize.file: the .dmg path right-release builds");
192
+ if (target.dmg === false) fail("targets.mac.macPackage always builds a DMG; remove dmg: false");
193
+ if (spec.universal !== undefined && typeof spec.universal !== "boolean") fail("targets.mac.macPackage.universal must be a boolean");
194
+ if (spec.features !== undefined && (!Array.isArray(spec.features) || spec.features.some((f) => typeof f !== "string" || !/^[A-Za-z0-9_,:-]+$/.test(f)))) fail("targets.mac.macPackage.features must be Cargo feature names");
195
+ if (spec.triple !== undefined && !MAC_TRIPLES.includes(spec.triple)) fail(`targets.mac.macPackage.triple must be one of ${MAC_TRIPLES.join(", ")}`);
196
+ const cwd = spec.tauriCwd ?? ".";
197
+ if (typeof cwd !== "string" || path.isAbsolute(cwd) || cwd.split(/[\\/]/).includes("..")) fail("targets.mac.macPackage.tauriCwd must be a relative path inside the app");
198
+ const cleanupSpec = macCleanupSpec(spec.cleanup);
199
+ // Tauri's universal-apple-darwin target builds both slices and lipos them itself.
200
+ const triple = spec.triple ?? (spec.universal === false ? (process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin") : "universal-apple-darwin");
201
+ if (spec.triple !== undefined) {
202
+ const wrong = [...MAC_TRIPLES, "universal-apple-darwin"].find((other) => other !== triple && target.notarize.app.replaceAll("\\", "/").split("/").includes(other));
203
+ if (wrong) fail(`targets.mac.macPackage.triple is ${triple} but notarize.app is under ${wrong}; the built .app lives in the ${triple} directory`);
204
+ }
205
+ const override = { bundle: { createUpdaterArtifacts: false, macOS: { signingIdentity: null } } };
206
+ const extras = buildExtras(spec, "targets.mac.macPackage", { postBuild: true });
207
+ const args = ["exec", "tauri", "build", "--target", triple, "--bundles", "app", "--no-sign", ...configArgs(extras, appRoot, cwd), "--config", JSON.stringify(override)];
208
+ if (spec.features?.length) args.push("--features", spec.features.join(","));
209
+ args.push(...profileArgs(extras));
210
+ const mapPath = targetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label: "targets.mac.macPackage" });
211
+ const app = mapPath(target.notarize.app);
212
+ const entryFile = (entry) => (typeof entry === "string" ? entry : entry?.file);
213
+ const declaredFiles = target.sign?.prePackageFiles ?? [target.notarize.app];
214
+ if (!declaredFiles.some((entry) => entryFile(entry) === target.notarize.app)) fail("targets.mac.macPackage: sign.prePackageFiles must include notarize.app");
215
+ const prePackageFiles = declaredFiles.map((entry) => (typeof entry === "string" ? mapPath(entry) : { ...entry, file: mapPath(entry.file) }));
216
+ // Built-in cleanup before the build: the bundle dir is where a stale .app lives; DMG volumes are named after the product.
217
+ const appPosix = app.replaceAll("\\", "/");
218
+ const productName = path.posix.basename(appPosix, ".app");
219
+ const volumes = [...new Set([productName, ...(isObject(target.dmg) && typeof target.dmg.volname === "string" ? [target.dmg.volname] : [])])];
220
+ const cleanup = cleanupSpec.staleApps || cleanupSpec.staleDmgVolumes || cleanupSpec.lsclean
221
+ ? {
222
+ ...(cleanupSpec.staleDmgVolumes ? { dmgVolumes: volumes } : {}),
223
+ ...(cleanupSpec.staleApps || cleanupSpec.lsclean ? { apps: { dir: path.posix.dirname(appPosix), remove: cleanupSpec.staleApps === true, unregister: cleanupSpec.lsclean === true } } : {}),
224
+ }
225
+ : undefined;
226
+ const wrapped = extras.prepare.length || extras.postBuild.length || cleanup;
227
+ const { macPackage, recipe: _ignoredMac, ...rest } = target;
228
+ return {
229
+ ...rest,
230
+ prePackage: wrapped ? recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup }) : { cmd: "pnpm", args, cwd, env: QUIET_TAURI_ENV },
231
+ sign: { ...(target.sign ?? {}), prePackageFiles },
232
+ notarize: { ...target.notarize, app, appBeforePackage: true },
233
+ dmg: isObject(target.dmg) ? target.dmg : {},
234
+ recipe: { kind: "macPackage", universal: triple === "universal-apple-darwin" },
235
+ };
236
+ }
237
+
238
+ /** Expand whichever declarative package key the target carries. */
239
+ export function expandPackageRecipeTarget(target, { platform, appRoot, config, env, resolveTargetRoot }) {
240
+ const injected = { ...(env ? { env } : {}), ...(resolveTargetRoot ? { resolveTargetRoot } : {}) };
241
+ if (platform === "win") return expandWinPackageTarget(target, { appRoot, config, ...injected });
242
+ if (platform === "mac") return expandMacPackageTarget(target, { appRoot, config, ...injected });
243
+ if (target?.winPackage || target?.macPackage) fail(`winPackage / macPackage are not valid on ${platform}`);
244
+ return target;
245
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.172",
3
+ "version": "0.2.174",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -1,15 +1,16 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
- import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.mjs";
4
+ import { stagingPathOf, swapInPlace } from "./in-place-install.mjs";
5
5
 
6
6
  const CHUNK_BYTES = 1024 * 1024;
7
7
 
8
8
  /**
9
9
  * Install an unsigned development payload in place at the fixed path `<installRoot>/current` (a real directory).
10
- * No versions/ folder, no digest folder, no symlink or junction. The previous copy is kept once, as
11
- * `<installRoot>/current.previous` (rename it back to roll back); nothing ever runs from it. A legacy `current`
12
- * symlink or junction from the old layout is moved aside to `.previous` the same way.
10
+ * No versions/ folder, no digest folder, no symlink or junction. NO old copy is kept (Orthic-Labs/rightkit#348): the
11
+ * old `current` is removed and the verified staging copy is renamed over it, and a stale `current.previous` left by an
12
+ * older RightKit is removed. A failed install leaves `current` absent or partial; reinstall from an older build. A
13
+ * legacy `current` symlink or junction from the old layout is removed the same way.
13
14
  */
14
15
  export function installPortableDevelopment(descriptor, { root, platform = process.platform, env = process.env } = {}) {
15
16
  const emit = (event, fields = {}) => process.stdout.write(`${JSON.stringify({ event: `DEV_INSTALL_${event}`, ...fields })}
@@ -33,7 +34,6 @@ export function installPortableDevelopment(descriptor, { root, platform = proces
33
34
  digest = digestTree(source);
34
35
  current = path.join(installRoot, "current");
35
36
  const staging = stagingPathOf(current);
36
- const previous = previousPathOf(current);
37
37
  emit("STAGE", { sourceDirectory: source, current, staging, digest });
38
38
  if (exists(current) && !fs.lstatSync(current).isDirectory() && !fs.lstatSync(current).isSymbolicLink()) throw new Error(`development current is not a directory; refusing to replace: ${current}`);
39
39
 
@@ -49,15 +49,15 @@ export function installPortableDevelopment(descriptor, { root, platform = proces
49
49
  const sourceEntryDigest = hashFile(sourceEntrypoint);
50
50
  let entryDigest;
51
51
  swapInPlace({
52
- staging, destination: current, previous,
52
+ staging, destination: current, previous: null,
53
53
  verify: (destination) => {
54
54
  entryDigest = hashFile(resolveContained(destination, descriptor.entrypoint, "entrypoint"));
55
55
  if (entryDigest !== sourceEntryDigest) throw new Error("development entrypoint readback did not match source");
56
56
  },
57
57
  });
58
- emit("ACTIVATE", { current, previous });
59
- emit("SUCCEEDED", { installRoot, current, previous, digest, entrypoint: descriptor.entrypoint, entrypointDigest: entryDigest });
60
- return { installRoot, current, previous, versionRoot: current, digest, entrypoint: path.join(current, descriptor.entrypoint), entrypointDigest: entryDigest };
58
+ emit("ACTIVATE", { current });
59
+ emit("SUCCEEDED", { installRoot, current, digest, entrypoint: descriptor.entrypoint, entrypointDigest: entryDigest });
60
+ return { installRoot, current, versionRoot: current, digest, entrypoint: path.join(current, descriptor.entrypoint), entrypointDigest: entryDigest };
61
61
  } catch (error) {
62
62
  emit("FAILED", { installRoot: descriptor?.installRoot, current, digest, message: error?.message ?? String(error) });
63
63
  throw error;
package/project-type.mjs CHANGED
@@ -40,4 +40,4 @@ export function targetUsesMsix(target) {
40
40
  }
41
41
 
42
42
  /** SwiftPM targets build and package inside prePackage (mac DMG) or the MSIX step; no separate `package` command is required. */
43
- export const packageCommandOptional = (target, projectType) => targetUsesMsix(target) || (projectType === "swiftpm" && Boolean(target?.prePackage?.cmd));
43
+ export const packageCommandOptional = (target, projectType) => targetUsesMsix(target) || Boolean(target?.winPackage || target?.macPackage) || (projectType === "swiftpm" && Boolean(target?.prePackage?.cmd));
@@ -10,6 +10,7 @@
10
10
  // Dev builds are unaffected: `development.mjs` never calls this.
11
11
  import { existsSync, readFileSync } from "node:fs";
12
12
  import path from "node:path";
13
+ import { expandRecipeBuildCommands } from "./recipe-build.mjs";
13
14
 
14
15
  export const FORBIDDEN_FEATURES = Object.freeze(["devtools", "content-devtools", "qa-native", "qa-control", "dev-native", "dev-inspect",
15
16
  // Per-app test-automation features (M20 owner gap): HeardRight `qa-shot`/`qa-tools`, ViewRight `dev-control`/`bench-native`.
@@ -206,7 +207,12 @@ export function buildFeatureIssues({ root, commands, devtools, cargoManifest, ma
206
207
  let scripts = {};
207
208
  try { scripts = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).scripts ?? {}; } catch { /* no package.json */ }
208
209
  const issues = [];
209
- for (const { label, command } of commands) if (command) issues.push(...commandFeatureIssues(command, { label, scripts, allowDevtools }));
210
+ for (const { label, command } of commands) {
211
+ if (!command) continue;
212
+ // A `node recipe-build.mjs --plan <JSON>` wrapper hides every step's flags in one argument: scan each step too.
213
+ const expanded = expandRecipeBuildCommands(command, { root });
214
+ expanded.forEach((part, index) => issues.push(...commandFeatureIssues(part, { label: index === 0 ? label : `${label} -> recipe-build step ${index}`, scripts, allowDevtools })));
215
+ }
210
216
  const { rel } = resolveCargoManifestPath({ root, cargoManifest, manifest });
211
217
  if (rel) issues.push(...cargoManifestIssues(readFileSync(path.join(root, rel), "utf8"), rel, { allowDevtools }));
212
218
  return issues;