@rightkit/release 0.2.173 → 0.2.174

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@
11
11
  // inside-out plan. It never runs codesign: sign-macos.mjs (via mac-sign-core.mjs) executes the plan.
12
12
  import { existsSync, lstatSync, openSync, closeSync, readSync, realpathSync, statSync } from "node:fs";
13
13
  import path from "node:path";
14
- import { expandMacSigningEntries, mainExecutableName } from "./mac-bundle-signing.mjs";
14
+ import { expandMacSigningEntries, mainExecutableName, resolveNestedSigningSettings } from "./mac-bundle-signing.mjs";
15
15
 
16
16
  const invalid = (message) => Object.assign(new Error(message), { code: "MAC_SIGN_CONFIG_INVALID" });
17
17
 
@@ -85,11 +85,57 @@ function validateNestedKey(key) {
85
85
  return null;
86
86
  }
87
87
 
88
+ // Per-path nested signing maps: `sign.identifiers`, `sign.identifierTemplate` and an object-valued `sign.entitlements`.
89
+ // Same strictness as the per-item settings above: reverse-DNS identifiers, contained relative plist paths that exist.
90
+ function validateNestedSigningMaps(sign, { root, errors, isFile }) {
91
+ const declared = {};
92
+ const check = (map, label, value) => {
93
+ if (!isPlainObject(map)) { errors.push(`${label} must be an object mapping bundle-relative paths or globs to values`); return; }
94
+ for (const [key, raw] of Object.entries(map)) {
95
+ const entry = `${label}[${JSON.stringify(key)}]`;
96
+ if (!key.trim() || key !== key.trim() || key.includes("\0") || key.includes("\\") || path.posix.isAbsolute(key) || /^[A-Za-z]:/.test(key) || key.split("/").includes("..")) {
97
+ errors.push(`${entry}: key must be a bundle-relative path (no "..", no absolute path, "/" separators)`);
98
+ }
99
+ value(raw, entry);
100
+ }
101
+ };
102
+ if (sign.identifiers !== undefined) {
103
+ check(sign.identifiers, "sign.identifiers", (raw, entry) => {
104
+ if (typeof raw !== "string" || !IDENTIFIER_PATTERN.test(raw)) errors.push(`${entry} must be 1-255 characters of letters, digits, '.' and '-', starting with a letter or digit`);
105
+ });
106
+ declared.identifiers = sign.identifiers;
107
+ }
108
+ if (sign.identifierTemplate !== undefined) {
109
+ const template = sign.identifierTemplate;
110
+ if (typeof template !== "string" || !template) errors.push("sign.identifierTemplate must be a non-empty string such as \"<bundleId>.runtime.<relpath>\"");
111
+ else if (!/^[A-Za-z0-9.-]*$/.test(template.replace(/<[^<>]*>/g, ""))) errors.push("sign.identifierTemplate literal text may only contain letters, digits, '.' and '-' around <bundleId>, <relpath> and <basename>");
112
+ else declared.identifierTemplate = template;
113
+ }
114
+ if (isPlainObject(sign.entitlements)) {
115
+ check(sign.entitlements, "sign.entitlements", (raw, entry) => {
116
+ if (typeof raw !== "string" || !raw.trim()) { errors.push(`${entry} must be a non-empty path string`); return; }
117
+ if (path.isAbsolute(raw) || /^[A-Za-z]:/.test(raw) || raw.includes("\0")) { errors.push(`${entry} must be relative to the release config directory: ${raw}`); return; }
118
+ const resolved = path.resolve(root, raw);
119
+ const relative = path.relative(root, resolved);
120
+ if (relative.startsWith("..") || path.isAbsolute(relative)) errors.push(`${entry} must stay inside the release config directory: ${raw}`);
121
+ else if (!isFile(resolved)) errors.push(`${entry} entitlements file not found: ${raw}`);
122
+ else if (!looksLikePlist(resolved)) errors.push(`${entry} is not a property list (expected an XML or binary plist): ${raw}`);
123
+ });
124
+ declared.entitlements = sign.entitlements;
125
+ }
126
+ if (errors.length || !Object.keys(declared).length) return undefined;
127
+ // Delegate key globs and template tokens to the shared resolver so release and development read them identically.
128
+ try { resolveNestedSigningSettings([], { app: "x.app", sign: declared, bundleId: "a.b", root, exists: () => true }); }
129
+ catch (error) { errors.push(error.message); return undefined; }
130
+ return declared;
131
+ }
132
+
88
133
  /**
89
134
  * Validate and normalize `sign` for a macOS target. Collects every problem, then throws one
90
- * MAC_SIGN_CONFIG_INVALID error. Returns { entries, nestedCode } where
91
- * entries: [{ file, identifier?, entitlements?: absolute, options: [] }] (file stays as declared)
92
- * nestedCode: [{ key, identifier?, entitlements?: absolute, options: [] }]
135
+ * MAC_SIGN_CONFIG_INVALID error. Returns { entries, nestedCode, nestedSigning? } where
136
+ * entries: [{ file, identifier?, entitlements?: absolute, options: [] }] (file stays as declared)
137
+ * nestedCode: [{ key, identifier?, entitlements?: absolute, options: [] }]
138
+ * nestedSigning: { identifiers?, identifierTemplate?, entitlements? } as declared; present only when one is declared
93
139
  * Injectable: isFile.
94
140
  */
95
141
  export function validateMacSigningConfig(sign, { root, isFile = (file) => existsSync(file) && statSync(file).isFile() } = {}) {
@@ -136,8 +182,10 @@ export function validateMacSigningConfig(sign, { root, isFile = (file) => exists
136
182
  }
137
183
  }
138
184
  }
185
+ const nestedSigning = validateNestedSigningMaps(sign, context);
186
+ if (nestedSigning && !entries.some((entry) => /\.app\/?$/.test(entry.file))) errors.push("sign.identifiers, sign.identifierTemplate and per-path sign.entitlements need a .app entry in sign.prePackageFiles to resolve their paths against");
139
187
  if (errors.length) throw invalid(`invalid macOS signing configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
140
- return { entries, nestedCode };
188
+ return { entries, nestedCode, ...(nestedSigning ? { nestedSigning } : {}) };
141
189
  }
142
190
 
143
191
  /** True when an entry or nested item carries anything beyond the default (hardened runtime, no entitlements). */
@@ -154,7 +202,7 @@ const depth = (file) => file.split(path.sep).length;
154
202
  * a nestedCode path is missing, is a symlink, escapes the bundle, or names the bundle's main executable.
155
203
  * Injectable: expand, lstat, realpath, mainName.
156
204
  */
157
- export function planMacSigning({ entries, nestedCode = [], expand = expandMacSigningEntries, lstat = lstatSync, realpath = realpathSync, mainName = mainExecutableName }) {
205
+ export function planMacSigning({ entries, nestedCode = [], nestedSigning, root, resolveSettings = resolveNestedSigningSettings, expand = expandMacSigningEntries, lstat = lstatSync, realpath = realpathSync, mainName = mainExecutableName }) {
158
206
  const { nested: discovered } = expand(entries.map((entry) => entry.file));
159
207
  const byPath = new Map(discovered.map((file) => [file, { file, options: [] }]));
160
208
  if (nestedCode.length) {
@@ -174,6 +222,25 @@ export function planMacSigning({ entries, nestedCode = [], expand = expandMacSig
174
222
  byPath.set(file, { file, ...(item.identifier ? { identifier: item.identifier } : {}), ...(item.entitlements ? { entitlements: item.entitlements } : {}), options: item.options });
175
223
  }
176
224
  }
225
+ // Per-path maps (sign.identifiers / identifierTemplate / per-path sign.entitlements) fill in only what a nestedCode item left
226
+ // unset. Nothing declared: this block is skipped and the items stay exactly as discovered.
227
+ if (nestedSigning && Object.keys(nestedSigning).length) {
228
+ const apps = entries.map((entry) => entry.file).filter((file) => /\.app\/?$/.test(file)).map((file) => file.replace(/\/+$/, ""));
229
+ for (const app of apps) {
230
+ const own = [...byPath.values()].filter((item) => item.file.startsWith(app + path.sep) || item.file.startsWith(`${app}/`));
231
+ let settings;
232
+ try { settings = resolveSettings(own.map((item) => item.file), { app, sign: nestedSigning, root: root ?? process.cwd() }); }
233
+ catch (error) { throw invalid(error.message); }
234
+ for (const item of own) {
235
+ const found = settings.get(item.file);
236
+ if (!found) continue;
237
+ const identifier = item.identifier ?? found.identifier;
238
+ if (identifier !== undefined && !IDENTIFIER_PATTERN.test(identifier)) throw invalid(`signing identifier for ${path.relative(app, item.file)} must be letters, digits, '.' and '-': ${JSON.stringify(identifier)}`);
239
+ const entitlements = item.entitlements ?? found.entitlements;
240
+ byPath.set(item.file, { ...item, ...(identifier !== undefined ? { identifier } : {}), ...(entitlements ? { entitlements } : {}) });
241
+ }
242
+ }
243
+ }
177
244
  const nested = [...byPath.values()].sort((a, b) => depth(b.file) - depth(a.file) || a.file.localeCompare(b.file));
178
245
  return { nested, top: entries };
179
246
  }
@@ -5,7 +5,7 @@ import { spawnSync } from "./background-process.mjs";
5
5
 
6
6
  import { requestSigningAgentSync, validateNotaryAgentReport } from "./signing-agent-client.mjs";
7
7
  import { installSigningAgent, requireMacGuiSession } from "./signing-agent-install.mjs";
8
- import { signingAgentPaths, defaultSigningRoots, ensurePrivateDirectory, privatePath, within, NOTARIZATION_TIMEOUT_MS, MAX_NOTARIZATION_TIMEOUT_MS } from "./rightkit-signing-agent.mjs";
8
+ import { signingAgentPaths, defaultSigningRoots, ensurePrivateDirectory, privatePath, within, NOTARIZATION_TIMEOUT_MS, MAX_NOTARIZATION_TIMEOUT_MS, AGENT_IDENTIFIER_PATTERN, CODESIGN_IDENTIFIER_SCHEMA } from "./rightkit-signing-agent.mjs";
9
9
  import { refuseManagedEnvSecrets } from "./managed-host-secrets.mjs";
10
10
 
11
11
  export class MacSigningSessionError extends Error {
@@ -48,11 +48,13 @@ export function codesignAgentItems(commands) {
48
48
  else if (option === "--timestamp" && item.timestamp === undefined) item.timestamp = true;
49
49
  else if (option === "--options" && args[i + 1] === "runtime" && item.runtime === undefined) { item.runtime = true; i++; }
50
50
  else if (option === "--sign" && item.identity === undefined) item.identity = args[++i];
51
+ else if (option === "--identifier" && item.identifier === undefined) item.identifier = args[++i];
51
52
  else if (option === "--entitlements" && item.entitlements === undefined) item.entitlements = args[++i];
52
53
  else if (option === "--preserve-metadata=entitlements" && item.preserveEntitlements === undefined) item.preserveEntitlements = true;
53
54
  else throw new TypeError(`Unsupported agent codesign option: ${option}`);
54
55
  }
55
56
  item.path = args.at(-1);
57
+ if (item.identifier !== undefined && !AGENT_IDENTIFIER_PATTERN.test(item.identifier)) throw new TypeError("Agent signing requires a reverse-DNS codesign identifier");
56
58
  if (!item.identity || item.identity === "-" || !path.isAbsolute(item.path ?? "")
57
59
  || (item.entitlements !== undefined && !path.isAbsolute(item.entitlements))) throw new TypeError("Agent signing requires real identity & absolute paths");
58
60
  return item;
@@ -76,6 +78,8 @@ export function runMacGuiSigning({ kind, commands, files, cwd = process.cwd(), e
76
78
  try {
77
79
  if (kind === "codesign") {
78
80
  plan.items = codesignAgentItems(commands);
81
+ // Only a request that carries a typed identifier uses schema 2; every other codesign request stays schema 1.
82
+ if (plan.items.some((item) => item.identifier !== undefined)) plan.schema = CODESIGN_IDENTIFIER_SCHEMA;
79
83
  // Development signing generates entitlements in TMPDIR. Copy only those
80
84
  // plist bytes into managed private staging, never broaden signing roots.
81
85
  for (const item of plan.items) {
@@ -99,7 +103,13 @@ export function runMacGuiSigning({ kind, commands, files, cwd = process.cwd(), e
99
103
  let report;
100
104
  try { report = request(plan, clientOptions); }
101
105
  catch (error) {
102
- if (error.code !== "MAC_SIGNING_AGENT_UNAVAILABLE") throw error;
106
+ let missing = error.code === "MAC_SIGNING_AGENT_UNAVAILABLE";
107
+ // An installed agent that predates typed identifiers refuses schema 2: upgrade it once, never loop on a rejected request.
108
+ if (!missing && plan.schema === CODESIGN_IDENTIFIER_SCHEMA && error.code === "MAC_SIGNING_AGENT_PROTOCOL") {
109
+ const health = request({ schema: 1, type: "health" }, { home, timeoutMs: 1500 });
110
+ missing = !health.capabilities?.includes("codesign-identifier");
111
+ }
112
+ if (!missing) throw error;
103
113
  requireMacGuiSession({ run, uid });
104
114
  install({ home, username, uid, run, io, managedRoot: env.RIGHTKIT_MANAGED_ROOT });
105
115
  report = request(plan, clientOptions); // One install attempt, one retry.
@@ -4,7 +4,10 @@
4
4
  // marker patch before signing, raw-EXE and installer Authenticode, the NSIS embedding verification, Developer ID
5
5
  // signing of the .app and its nested code, app notarization before packing, the signed DMG and its notarization.
6
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";
7
8
  import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { resolveTargetRoot as cargoResolveTargetRoot } from "./cargo-target.mjs";
8
11
  import { validateWinPackage, WinPackageConfigError } from "./win-package-recipe.mjs";
9
12
 
10
13
  export class PackageRecipeTargetError extends Error {
@@ -21,40 +24,163 @@ const withoutRecipe = (target) => {
21
24
  return rest;
22
25
  };
23
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
+ }
24
124
 
25
125
  /** `targets.win.winPackage` → prePackage (`tauri build --no-bundle`) and package (`tauri bundle --bundles nsis`). */
26
- export function expandWinPackageTarget(target, { appRoot, config = {} }) {
126
+ export function expandWinPackageTarget(target, { appRoot, config = {}, env = process.env, resolveTargetRoot = cargoResolveTargetRoot }) {
27
127
  if (!target?.winPackage) return withoutRecipe(target);
28
128
  if (target.prePackage !== undefined) fail("targets.win.winPackage replaces prePackage; remove the prePackage script");
29
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 });
30
137
  let plan;
31
- try { plan = validateWinPackage({ appRoot, config, target }); } catch (error) {
138
+ try { plan = validateWinPackage({ appRoot, config, target: { ...target, winPackage: coreSpec } }); } catch (error) {
32
139
  if (error instanceof WinPackageConfigError) fail(`targets.win.winPackage: ${(error.issues ?? [error.message]).join("; ")}`);
33
140
  throw error;
34
141
  }
35
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}`);
36
143
  const cwd = rel(plan.appRoot, plan.tauriCwd) || ".";
37
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;
38
151
  // `recipe` is set only here: a hand-written one is dropped so it can never select a recipe path.
39
152
  const { winPackage, recipe: _ignoredWin, ...rest } = target;
40
153
  return {
41
154
  ...rest,
42
155
  signingContract: "windows-raw-exe-authenticode-before-nsis-v1",
43
- prePackage: { cmd: "pnpm", args: ["exec", "tauri", "build", "--no-bundle", "--target", plan.triple], cwd, env: QUIET_TAURI_ENV },
44
- package: { cmd: "pnpm", args: ["exec", "tauri", "bundle", "--bundles", "nsis", "--target", plan.triple, "--config", JSON.stringify(override)], cwd, env: QUIET_TAURI_ENV },
45
- sign: { ...(target.sign ?? {}), prePackageFiles: [rel(plan.appRoot, plan.rawExe)], files: [rel(plan.appRoot, plan.installerFile)] },
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] },
46
159
  recipe: { kind: "winPackage", productName: plan.productName, updater: plan.updater },
47
160
  };
48
161
  }
49
162
 
50
- const MAC_KEYS = new Set(["universal", "tauriCwd", "features"]);
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
+ }
51
175
 
52
176
  /**
53
177
  * `targets.mac.macPackage` → prePackage (`tauri build --bundles app`, universal by default), the .app as the
54
178
  * Developer ID signing set, app notarization before packing, and right-release's own signed DMG from the stapled app.
55
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).
56
182
  */
57
- export function expandMacPackageTarget(target, { appRoot }) {
183
+ export function expandMacPackageTarget(target, { appRoot, env = process.env, resolveTargetRoot = cargoResolveTargetRoot }) {
58
184
  if (!target?.macPackage) return withoutRecipe(target);
59
185
  const spec = target.macPackage;
60
186
  if (!isObject(spec)) fail("targets.mac.macPackage must be an object");
@@ -66,30 +192,54 @@ export function expandMacPackageTarget(target, { appRoot }) {
66
192
  if (target.dmg === false) fail("targets.mac.macPackage always builds a DMG; remove dmg: false");
67
193
  if (spec.universal !== undefined && typeof spec.universal !== "boolean") fail("targets.mac.macPackage.universal must be a boolean");
68
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(", ")}`);
69
196
  const cwd = spec.tauriCwd ?? ".";
70
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);
71
199
  // Tauri's universal-apple-darwin target builds both slices and lipos them itself.
72
- const triple = spec.universal === false ? (process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin") : "universal-apple-darwin";
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
+ }
73
205
  const override = { bundle: { createUpdaterArtifacts: false, macOS: { signingIdentity: null } } };
74
- const args = ["exec", "tauri", "build", "--target", triple, "--bundles", "app", "--no-sign", "--config", JSON.stringify(override)];
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)];
75
208
  if (spec.features?.length) args.push("--features", spec.features.join(","));
76
- const prePackageFiles = target.sign?.prePackageFiles ?? [target.notarize.app];
77
- if (!prePackageFiles.some((entry) => (typeof entry === "string" ? entry : entry?.file) === target.notarize.app)) fail("targets.mac.macPackage: sign.prePackageFiles must include notarize.app");
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;
78
227
  const { macPackage, recipe: _ignoredMac, ...rest } = target;
79
228
  return {
80
229
  ...rest,
81
- prePackage: { cmd: "pnpm", args, cwd, env: QUIET_TAURI_ENV },
230
+ prePackage: wrapped ? recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup }) : { cmd: "pnpm", args, cwd, env: QUIET_TAURI_ENV },
82
231
  sign: { ...(target.sign ?? {}), prePackageFiles },
83
- notarize: { ...target.notarize, appBeforePackage: true },
232
+ notarize: { ...target.notarize, app, appBeforePackage: true },
84
233
  dmg: isObject(target.dmg) ? target.dmg : {},
85
234
  recipe: { kind: "macPackage", universal: triple === "universal-apple-darwin" },
86
235
  };
87
236
  }
88
237
 
89
238
  /** Expand whichever declarative package key the target carries. */
90
- export function expandPackageRecipeTarget(target, { platform, appRoot, config }) {
91
- if (platform === "win") return expandWinPackageTarget(target, { appRoot, config });
92
- if (platform === "mac") return expandMacPackageTarget(target, { appRoot, config });
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 });
93
243
  if (target?.winPackage || target?.macPackage) fail(`winPackage / macPackage are not valid on ${platform}`);
94
244
  return target;
95
245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.173",
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;
@@ -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;