@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.
package/README.md CHANGED
@@ -100,13 +100,13 @@ The shipped `bin/darwin/rightkit-swap` is Developer ID signed. It is added by `r
100
100
 
101
101
  Development builds (from `@rightkit/ci` `devArtifact`, Developer ID-signed but not notarized) install with `--dev` and exactly one of `--dmg`, `--zip <path.zip>` (extracted with `ditto -x -k`), or `--app <path.app>`: `right-release install-app --dev --zip dev-mac.zip`. `--dev` still requires `codesign --verify --deep --strict`, a Team ID, no Team ID change, and a bundle-id match; it skips only `spctl` and `stapler`, removes `com.apple.quarantine` from the new bundle only, prints `DEV BUILD (not notarized)`, and sets `dev: true` on every JSON event.
102
102
 
103
- ## One fixed path, one identity, one `.previous` (every app)
103
+ ## One fixed path, one identity; release keeps one `.previous`, dev keeps none (every app)
104
104
 
105
- macOS and Windows tie privacy grants (mic, screen, Files and Folders, Removable Volumes, Full Disk Access) to the path an app runs from and to its code identity. So every app, helper and dev or QA binary installs and updates IN PLACE at ONE fixed path under ONE identity; there is no per-version, per-digest, per-pid or per-build install folder or bundle id. Rollback is a persistent `<name>.previous` beside the fixed path (rename it back); nothing ever runs from `.previous` or from a staging copy.
105
+ macOS and Windows tie privacy grants (mic, screen, Files and Folders, Removable Volumes, Full Disk Access) to the path an app runs from and to its code identity. So every app, helper and dev or QA binary installs and updates IN PLACE at ONE fixed path under ONE identity; there is no per-version, per-digest, per-pid or per-build install folder or bundle id. A RELEASE install may keep one rollback `<name>.previous` beside the fixed path (rename it back). A DEV or QA install keeps NO old copy of any kind (Adrian, bogusyogi/rightkit#348: "when installing dev, old copy must never be backedup"; reinstall from an older DMG): it replaces the path in place through a short-lived staging name and removes any stale `.previous`, and a failed dev install leaves the path absent or partial with a clear error. Nothing ever runs from `.previous` or from a staging copy.
106
106
 
107
- - `install-app` (macOS) renames the replaced app to `<App>.app.previous` (never the Trash); `install-app --installer <Setup.exe> --dest <folder> --exe <App.exe>` does the same for a Windows folder install. `right-release dev` (mac) installs to `install.destination` (required) with `<dest>.previous` and fails if its designated requirement differs from the installed copy; a portable dev payload installs as `<installRoot>/current` with `current.previous`; the Windows direct installer installs to `current` with `current.previous`.
107
+ - `install-app` (macOS) renames the replaced app to `<App>.app.previous` (never the Trash) for a release install only; `install-app --dev` removes the old app and keeps nothing. `install-app --installer <Setup.exe> --dest <folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>]` runs the NSIS installer in place. It NEVER copies or restores the whole install folder, because that folder can hold user data (CodeRight keeps `coderight.db`, its WAL, state and models there). Its backup and rollback are payload-only, from an explicit allowlist: the `--exe` file plus `--payload` entries and a `--payload-manifest` JSON of relative file paths (files, never directories); with no `--payload` it backs up and rolls back nothing. `right-release dev` (mac) installs to `install.destination` (required) with no `.previous` and fails if its designated requirement differs from the installed copy; Windows tauri dev runs the silent installer without copying `<exe>.previous`; a portable dev payload installs as `<installRoot>/current` with no `current.previous` (data beside `current` is never touched); the Windows direct installer installs to `current` with `current.previous`.
108
108
  - `right-release dev` (mac) signs with the same Developer ID Application identity as release (an app that needs a provisioning profile uses a Developer ID profile); Apple Development certificates are refused with `DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED`.
109
- - **Cargo target folders are not stable paths.** The broker's target tree is `<managed>/rightkit-build-control/ws/<hash>/g/<generation>/target/...` and the generation changes; `cargo-guard` uses a hashed `dev-targets/<project>-<hash>` folder. They are build output only, so nothing may run from them. Dev and QA binaries run from a fixed staged path: list them in `development.targets.<platform>.stage` (`[{ source } | { manifestPath, bin, targetTriple?, profile?, name? }]`) and `right-release dev` copies each in place to `<app root>/.rightkit-dev-bin/<triple-or-host>/<name>` (keeping `<name>.previous`); `rightkit-qa` launches from its `.rightkit-qa-bin` stash on every path.
109
+ - **Cargo target folders are not stable paths.** The broker's target tree is `<managed>/rightkit-build-control/ws/<hash>/g/<generation>/target/...` and the generation changes; `cargo-guard` uses a hashed `dev-targets/<project>-<hash>` folder. They are build output only, so nothing may run from them. Dev and QA binaries run from a fixed staged path: list them in `development.targets.<platform>.stage` (`[{ source } | { manifestPath, bin, targetTriple?, profile?, name? }]`) and `right-release dev` copies each in place to `<app root>/.rightkit-dev-bin/<triple-or-host>/<name>` (no backup of the old copy); `rightkit-qa` launches from its `.rightkit-qa-bin` stash on every path.
110
110
 
111
111
  ## Runtime budget gate (crates)
112
112
 
@@ -320,3 +320,9 @@ No agent may write to the internal drive (C: on Windows, the internal disk on ma
320
320
  - `smol-toml` is pinned exactly. A consumer that depends on a different `smol-toml` version gets a second copy.
321
321
  - Apps pin this package at different versions; `docs/ADOPTION.md` lists each pin and marks the stale ones.
322
322
  - Prepublish runs the test suite, the Windows signer integration test and the registry parity check.
323
+
324
+ ## Post-sign hook chains
325
+
326
+ `target.postSign` accepts either one command object or an array of command objects. An array runs in order, for example `create-mac-updater` followed by `lsclean`, and stops at the first failing command, so the release fails closed. Every command in the array is scanned for forbidden build features and release switches, exactly like a single command. A single object behaves as before.
327
+
328
+ Each command may set `timeoutMs`. When it expires the release kills the whole process tree (`taskkill /T /F` on Windows, the process group elsewhere) and exits non-zero, so a hung child such as Tauri leaves no orphan.
Binary file
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema": 1,
3
- "sha256": "68f8aba2f5ecbaa120e948619f10a90d27fc7b701271e9382a412c7c07aca781",
3
+ "sha256": "f52367947184283bf188d04e414743879c842ac359ed0a97acacecf23383b1ce",
4
4
  "identity": "Developer ID Application: Adrian D'souza (6KLGD3LLKF)",
5
5
  "codesign": "strict-valid"
6
6
  }
@@ -21,6 +21,8 @@ const commands = new Map([
21
21
  ["sign-dev-artifact", "sign-dev-artifact.mjs"],
22
22
  ["sign-candidate", "sign-candidate.mjs"],
23
23
  ["engine-locate", "engine-locator.mjs"],
24
+ ["stage-sidecar", "stage-sidecar-command.mjs"],
25
+ ["recipe-build", "recipe-build.mjs"],
24
26
  ["signing-agent", "signing-agent-command.mjs"],
25
27
  ["create-mac-updater", "create-mac-updater.mjs"],
26
28
  ["mirror-root-artifact", "mirror-root-artifact.mjs"],
@@ -181,15 +183,17 @@ function printHelp() {
181
183
  console.log(`right-release <command> [options]
182
184
 
183
185
  Commands:
186
+ stage-sidecar [--config <file>] [--target <triple>] [--json] Stage declarative sidecars into src-tauri/binaries (Tauri externalBin naming)
187
+ recipe-build --plan <json> | --plan-file <path> [--app-root <dir>] Run a package recipe: prepare steps, the Tauri build, postBuild steps (argv, no shell, fail closed)
184
188
  dmg build --app <.app> --out <dmg> --volname <name> [--background <png>] [--spec <appdmg.json>]
185
189
  [--icon-size N] [--app-position x,y] [--applications-position x,y] [--window w,h] [--window-position x,y]
186
190
  Build branded UDZO image without Finder
187
191
  install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id <id>] [--dest /Applications]
188
192
  [--relaunch|--no-relaunch] [--json]
189
193
  Verify a signed DMG, quit, replace & relaunch hidden; no UI, no osascript (macOS)
190
- The replaced app stays beside the fixed path as <App>.app.previous
191
- install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--json]
192
- Windows: run the installer in place at the fixed folder, keep <dest>.previous
194
+ A release install keeps the replaced app beside the fixed path as <App>.app.previous; --dev keeps NO old copy
195
+ install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>] [--json]
196
+ Windows: run the installer in place at the fixed folder; payload-only rollback (never the whole folder, user data beside the app is never copied)
193
197
  build [--platform mac|win] [--skip-checks] [--resume-from <phase>]
194
198
  Resume macOS at prepackage, sign, package, post-sign, or harden
195
199
  dev --platform win|mac [--config <path>] [--dry-run] [--build-only]
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawnSync } from "./background-process.mjs";
6
6
  import { runMacCodesignCommands } from "./macos-signing-session.mjs";
7
+ import { bundleRelativePath, compileBundlePathMap, globToRegExp, isGlobPattern, lookupBundlePath, resolveBundleIdentifier, validateBundleRelativeKey, validateIdentifierTemplate, validateSigningIdentifier } from "./mac-bundle-signing.mjs";
7
8
 
8
9
  export class MacosDevelopmentSigningError extends Error {
9
10
  constructor(code, message, details = {}) {
@@ -100,7 +101,8 @@ export function resolveMacosDevelopmentSigning({ root, bundle, descriptor = {},
100
101
  if (existsSync(file)) mac = { ...mac, ...JSON.parse(readFileSync(file, "utf8")).bundle?.macOS };
101
102
  }
102
103
  const sign = config.targets?.mac?.sign ?? {};
103
- const entitlements = sign.entitlements ? path.resolve(root, sign.entitlements) : mac.entitlements ? path.resolve(tauriRoot, mac.entitlements) : null;
104
+ // `sign.entitlements` is the app-level file when a string; an object is the per-path map (readDevSigningSettings).
105
+ const entitlements = typeof sign.entitlements === "string" && sign.entitlements ? path.resolve(root, sign.entitlements) : mac.entitlements ? path.resolve(tauriRoot, mac.entitlements) : null;
104
106
  if (entitlements && !existsSync(entitlements)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", `Configured entitlements file is missing: ${entitlements}`);
105
107
  // CI-only ad hoc: no Keychain, no provisioning profile (a profile authorizes restricted entitlements that an ad hoc signature cannot carry).
106
108
  if (ciAdhoc) return { identity: "-", certificateSha1: "-", teamIdentifier: null, entitlements, provisioningProfile: null, mode: CI_ADHOC_SIGNING, adhoc: true };
@@ -158,9 +160,128 @@ function macho(file) {
158
160
  } finally { closeSync(fd); }
159
161
  }
160
162
 
163
+ const invalidConfig = (message) => { throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", message); };
164
+ const ASSERT_KEYS = new Set(["path", "identifier", "teamIdentifier", "authority"]);
165
+
166
+ /**
167
+ * Declarative per-binary signing settings of a dev install. Everything is optional; with none declared the result is
168
+ * empty and signing is exactly what it was before these keys existed.
169
+ * install.identifiers / sign.identifiers { path-or-glob: "--identifier value" } ("." is the bundle)
170
+ * install.identifierTemplate / sign.identifierTemplate default identifier for remaining plain Mach-O files
171
+ * install.entitlements / sign.entitlements (object) { path-or-glob: plist path } (a string sign.entitlements is the app file)
172
+ * install.timestamp true adds --timestamp (a secure timestamp contacts Apple's server)
173
+ * install.assert [{ path-or-glob, identifier?, teamIdentifier?, authority? }]
174
+ * install.assertTeamSignedNested true: every Mach-O in the bundle carries the app's TeamIdentifier
175
+ * install.* entries win over sign.* entries (so one declaration in `sign` keeps dev and release identifiers identical).
176
+ * `root` resolves entitlement plist paths and checks they exist; without it only the shape is validated.
177
+ */
178
+ export function readDevSigningSettings(descriptor = {}, config = {}, { root, exists = existsSync } = {}) {
179
+ const sign = config?.targets?.mac?.sign ?? {};
180
+ const identifier = (raw, label) => validateSigningIdentifier(raw, label, invalidConfig);
181
+ const identifiers = [...compileBundlePathMap(descriptor.identifiers, "install.identifiers", { reject: invalidConfig, value: identifier }),
182
+ ...compileBundlePathMap(sign.identifiers, "sign.identifiers", { reject: invalidConfig, value: identifier })];
183
+ const templateSource = descriptor.identifierTemplate !== undefined ? ["install.identifierTemplate", descriptor.identifierTemplate] : sign.identifierTemplate !== undefined ? ["sign.identifierTemplate", sign.identifierTemplate] : null;
184
+ const identifierTemplate = templateSource ? validateIdentifierTemplate(templateSource[1], templateSource[0], invalidConfig) : undefined;
185
+ const plistPath = (raw, label) => {
186
+ if (typeof raw !== "string" || !raw.trim()) invalidConfig(`${label} must be an entitlements plist path`);
187
+ if (!root) return raw;
188
+ const file = path.resolve(root, raw);
189
+ if (!exists(file)) invalidConfig(`${label} entitlements file not found: ${file}`);
190
+ return file;
191
+ };
192
+ const signMap = typeof sign.entitlements === "object" && sign.entitlements !== null && !Array.isArray(sign.entitlements) ? sign.entitlements : undefined;
193
+ const entitlements = [...compileBundlePathMap(descriptor.entitlements, "install.entitlements", { reject: invalidConfig, value: plistPath }),
194
+ ...compileBundlePathMap(signMap, "sign.entitlements", { reject: invalidConfig, value: plistPath })];
195
+ if (descriptor.timestamp !== undefined && typeof descriptor.timestamp !== "boolean") invalidConfig("install.timestamp must be true or false");
196
+ if (descriptor.assertTeamSignedNested !== undefined && typeof descriptor.assertTeamSignedNested !== "boolean") invalidConfig("install.assertTeamSignedNested must be true or false");
197
+ const rawAssertions = descriptor.assert ?? [];
198
+ if (!Array.isArray(rawAssertions)) invalidConfig("install.assert must be an array of { path, identifier?, teamIdentifier?, authority? }");
199
+ const assertions = rawAssertions.map((item, index) => {
200
+ const label = `install.assert[${index}]`;
201
+ if (!item || typeof item !== "object" || Array.isArray(item)) invalidConfig(`${label} must be an object`);
202
+ for (const key of Object.keys(item)) if (!ASSERT_KEYS.has(key)) invalidConfig(`${label}.${key} is not a known key (path, identifier, teamIdentifier, authority)`);
203
+ validateBundleRelativeKey(item.path, `${label}.path`, invalidConfig);
204
+ const checks = ["identifier", "teamIdentifier", "authority"].filter((key) => item[key] !== undefined);
205
+ if (!checks.length) invalidConfig(`${label} needs at least one of identifier, teamIdentifier, authority`);
206
+ for (const key of checks) if (typeof item[key] !== "string" || !item[key].trim()) invalidConfig(`${label}.${key} must be a non-empty string`);
207
+ return { path: item.path, ...Object.fromEntries(checks.map((key) => [key, item[key]])) };
208
+ });
209
+ return { identifiers, identifierTemplate, entitlements, timestamp: descriptor.timestamp === true, assertions, assertTeamSignedNested: descriptor.assertTeamSignedNested === true };
210
+ }
211
+
212
+ function listBundleFiles(bundle) {
213
+ const out = [];
214
+ const walk = (directory) => {
215
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
216
+ const file = path.join(directory, entry.name);
217
+ if (entry.isSymbolicLink()) continue;
218
+ if (entry.isDirectory()) walk(file);
219
+ else if (entry.isFile()) out.push(bundleRelativePath(bundle, file));
220
+ }
221
+ };
222
+ walk(bundle);
223
+ return out;
224
+ }
225
+
226
+ /**
227
+ * Post-sign identity assertions, read from `codesign -dvv` (stdout and stderr: codesign reports on stderr). Fails closed with
228
+ * DEV_MAC_SIGNING_ASSERT_FAILED listing every mismatch. A glob that matches no file is a failure, so a typo cannot pass.
229
+ * `assertTeamSignedNested` additionally requires every Mach-O in the bundle (dylibs included) to be signed by
230
+ * `teamIdentifier`, not ad hoc. Used on the signed staging copy and again on the installed copy (a failure there rolls back).
231
+ * Injectable: run, listFiles(bundle) -> bundle-relative file paths, isMachO(file), join.
232
+ */
233
+ export function assertSignedIdentities({ root, bundle, settings, teamIdentifier, identity, env = process.env, run = spawnSync, listFiles = listBundleFiles, isMachO = macho, join = path.join, label = "signed bundle" }) {
234
+ const { assertions = [], assertTeamSignedNested = false } = settings ?? {};
235
+ if (!assertions.length && !assertTeamSignedNested) return { checked: 0 };
236
+ const reports = new Map();
237
+ const report = (file) => {
238
+ if (!reports.has(file)) {
239
+ const result = run("codesign", ["-dvv", file], captureOptions(root, env));
240
+ if (result.error || result.status !== 0) {
241
+ throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_ASSERT_FAILED", `${label}: cannot inspect the signing identity of ${file}: ${result.error?.message || output(result) || `exit ${result.status ?? "unavailable"}`}`, { identity });
242
+ }
243
+ const text = output(result);
244
+ reports.set(file, { identifier: /^Identifier=(.+)$/m.exec(text)?.[1]?.trim(), team: /^TeamIdentifier=(.+)$/m.exec(text)?.[1]?.trim(), authorities: [...text.matchAll(/^Authority=(.+)$/gm)].map((match) => match[1].trim()), adhoc: /^Signature=adhoc$/m.test(text) });
245
+ }
246
+ return reports.get(file);
247
+ };
248
+ const absolute = (relative) => (relative === "." ? bundle : join(bundle, ...relative.split("/")));
249
+ let files;
250
+ const listed = () => (files ??= listFiles(bundle));
251
+ const problems = [];
252
+ let checked = 0;
253
+ for (const item of assertions) {
254
+ const targets = isGlobPattern(item.path) ? listed().filter((relative) => globToRegExp(item.path).test(relative)) : [item.path];
255
+ if (!targets.length) { problems.push(`${item.path}: matches no file in the bundle`); continue; }
256
+ for (const relative of targets) {
257
+ const actual = report(absolute(relative));
258
+ checked += 1;
259
+ if (item.identifier !== undefined && actual.identifier !== item.identifier) problems.push(`${relative}: Identifier=${actual.identifier ?? "missing"}, expected ${item.identifier}`);
260
+ if (item.teamIdentifier !== undefined && actual.team !== item.teamIdentifier) problems.push(`${relative}: TeamIdentifier=${actual.team ?? "missing"}, expected ${item.teamIdentifier}`);
261
+ if (item.authority !== undefined && !actual.authorities.includes(item.authority)) problems.push(`${relative}: Authority does not include ${JSON.stringify(item.authority)} (found ${actual.authorities.map((name) => JSON.stringify(name)).join(", ") || "none"})`);
262
+ }
263
+ }
264
+ if (assertTeamSignedNested) {
265
+ if (!teamIdentifier) problems.push("assertTeamSignedNested: the expected TeamIdentifier is unknown (ad hoc signing has none)");
266
+ else {
267
+ for (const relative of [".", ...listed().filter((entry) => isMachO(absolute(entry)))]) {
268
+ const actual = report(absolute(relative));
269
+ checked += 1;
270
+ if (actual.adhoc || actual.team !== teamIdentifier) problems.push(`${relative}: ${actual.adhoc ? "ad hoc signed" : `TeamIdentifier=${actual.team ?? "missing"}`}, expected TeamIdentifier=${teamIdentifier}`);
271
+ }
272
+ }
273
+ }
274
+ if (problems.length) {
275
+ throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_ASSERT_FAILED", `${label}: signing identity assertions failed (${problems.length}): ${problems.slice(0, 20).join("; ")}${problems.length > 20 ? "; ..." : ""}. Refusing dev installation.`, { identity, problems });
276
+ }
277
+ return { checked };
278
+ }
279
+
161
280
  export function signMacosDevelopmentBundle(options) {
162
281
  const { root, bundle, env = process.env, run = spawnSync } = options;
163
282
  const context = { root, env, run };
283
+ // Declared per-binary settings are validated before any Keychain or codesign call.
284
+ const settings = readDevSigningSettings(options.descriptor, options.config, { root });
164
285
  const signing = resolveMacosDevelopmentSigning({ ...options, env, run });
165
286
  const { identity, certificateSha1, teamIdentifier, entitlements, provisioningProfile } = signing;
166
287
  const embedded = path.join(bundle, "Contents", "embedded.provisionprofile");
@@ -218,12 +339,20 @@ export function signMacosDevelopmentBundle(options) {
218
339
  const commands = [];
219
340
  for (const target of ordered) {
220
341
  // Bind the exact profile-authorized certificate even when names collide in Keychain.
221
- const args = ["--force", "--options", "runtime", "--sign", certificateSha1];
342
+ const relative = bundleRelativePath(bundle, target.file);
343
+ let identifier;
344
+ try {
345
+ identifier = resolveBundleIdentifier({ relative, identifiers: settings.identifiers, template: settings.identifierTemplate, bundleId: options.descriptor?.bundleId ?? appInfo.CFBundleIdentifier, plain: !target.info });
346
+ } catch (cause) { throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", cause.message, { cause }); }
347
+ // --timestamp only when declared (install.timestamp): it needs Apple's timestamp server, and dev builds otherwise sign offline.
348
+ const args = ["--force", "--options", "runtime", ...(settings.timestamp ? ["--timestamp"] : []), ...(identifier ? ["--identifier", identifier] : []), "--sign", certificateSha1];
222
349
  const parent = target.file === bundle;
350
+ const mappedEntitlements = lookupBundlePath(settings.entitlements, relative || ".");
223
351
  const profiledHelper = target.info && existsSync(path.join(target.file, "Contents/embedded.provisionprofile")) && target.info.CFBundleIdentifier === appInfo.CFBundleIdentifier;
224
352
  if (profiledHelper && !parent && provisioningProfile) copyFileSync(embedded, path.join(target.file, "Contents/embedded.provisionprofile"));
225
353
  const variant = !parent && entitlementVariants.find((item) => path.basename(target.file).replace(/\.(app|xpc|appex|bundle)$/, "").toLowerCase().endsWith(item.suffix));
226
- if (variant) args.push("--entitlements", entitlementFile(variant.file));
354
+ if (mappedEntitlements) args.push("--entitlements", entitlementFile(mappedEntitlements));
355
+ else if (variant) args.push("--entitlements", entitlementFile(variant.file));
227
356
  else if (entitlements && (parent || profiledHelper)) args.push("--entitlements", entitlementFile(entitlements));
228
357
  else {
229
358
  const existing = run("codesign", ["--display", "--entitlements", ":-", target.file], captureOptions(root, env));
@@ -248,7 +377,9 @@ export function signMacosDevelopmentBundle(options) {
248
377
  const requirement = output(checked("codesign", ["--display", "-r-", bundle], context, "DEV_MAC_SIGNATURE_INVALID", identity));
249
378
  const designated = /^designated => (.+)$/m.exec(requirement)?.[1]?.trim();
250
379
  if (!designated || /\bcdhash\b/.test(designated)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_UNSTABLE_REQUIREMENT", `Signing identity ${JSON.stringify(identity)}: designated requirement ${designated ? JSON.stringify(designated) : "is missing"} is not stable across rebuilds (cdhash-bound). Refusing dev installation.`, { identity, designatedRequirement: designated });
251
- return { ...signing, provisioningProfile: provisioningProfile ? embedded : null, verified: "strict-deep", designatedRequirement: designated, signedTargets: ordered.length };
380
+ // Declared identity assertions (install.assert, install.assertTeamSignedNested) on the signed staging copy, before any install.
381
+ const asserted = assertSignedIdentities({ root, bundle, settings, teamIdentifier, identity, env, run, label: "signed staging copy", ...(options.listFiles ? { listFiles: options.listFiles } : {}), ...(options.isMachO ? { isMachO: options.isMachO } : {}) });
382
+ return { ...signing, provisioningProfile: provisioningProfile ? embedded : null, verified: "strict-deep", designatedRequirement: designated, signedTargets: ordered.length, ...(asserted.checked ? { assertedIdentities: asserted.checked } : {}) };
252
383
  } finally { rmSync(scratch, { recursive: true, force: true }); }
253
384
  }
254
385
 
package/development.mjs CHANGED
@@ -13,6 +13,7 @@ import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.m
13
13
  import { stageDevelopmentBinaries, validateStageEntries } from "./stage-binary.mjs";
14
14
  import { assertStableDesignatedRequirement, readBundleExecutable, requestedCiAdhocSigning, signMacosDevelopmentBundle } from "./development-signing.mjs";
15
15
  import { macosCodesignEnvironment } from "./codesign-environment.mjs";
16
+ import { installTauriDevId, resolveTauriDevIdDescriptor, TAURI_DEVID_KIND } from "./install-tauri-devid.mjs";
16
17
  import { androidDevelopmentNdkCommand, androidDevelopmentSerial, installAndroidDevelopment, validateAndroidDevelopment } from "./android-dev.mjs";
17
18
 
18
19
  // One standard Cargo profile for every app (templates/cargo-profiles.toml): Cargo's built-in `dev`.
@@ -70,6 +71,10 @@ export function validateDevelopmentConfig(config, platform, { root = process.cwd
70
71
  for (const key of ["sourceDirectory", "entrypoint"]) {
71
72
  if (typeof descriptor[key] !== "string" || !descriptor[key] || path.isAbsolute(descriptor[key]) || path.win32.isAbsolute(descriptor[key]) || descriptor[key].split(/[\\/]/).includes("..")) throw new Error(`portable development ${key} must be a contained relative path`);
72
73
  }
74
+ } else if (target.install?.kind === TAURI_DEVID_KIND) {
75
+ if (platform !== "mac") throw new Error(`the ${TAURI_DEVID_KIND} development installer is for --platform mac`);
76
+ resolveTauriDevIdDescriptor(target.install, { appRoot: root });
77
+ if (!target.install.targetTriple && !target.install.bundleDirectory) throw new Error(`development.targets.${platform}.install requires targetTriple (or bundleDirectory) to find the built .app`);
73
78
  } else if (target.install?.kind === "adb") {
74
79
  if (platform !== "android") throw new Error("the adb development installer is for --platform android");
75
80
  } else if (target.install) throw new Error(`unsupported development installer kind: ${target.install.kind}`);
@@ -94,19 +99,28 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
94
99
  const childEnv = codesignEnvironment(developmentEnvironment(env));
95
100
  // One lease spans every managed request this run makes, so a broker cutover cannot land between them.
96
101
  const lease = acquireManagedRunLease({ command: `right-release dev --platform ${platform}`, env });
102
+ let releaseLease = true;
97
103
  try {
98
104
  const startedMs = Date.now();
99
105
  // Android: cargo-ndk (Cargo dev profile) fills the generated jniLibs, then the Gradle debug build packages it.
100
106
  if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, label: "native build" }); }
101
107
  for (const command of target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
102
- // Dev and QA binaries run from one stable staged path, copied in place (<name>.previous kept), never from the
108
+ // Dev and QA binaries run from one stable staged path, copied in place (no backup of the old copy), never from the
103
109
  // broker's per-generation target folder or a hashed dev-targets folder.
104
110
  const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
105
111
  for (const file of staged) console.log(`right-release dev: staged ${file}`);
106
112
  if (buildOnly || !target.install) return { built: true, installed: false, ...(staged.length ? { staged } : {}) };
113
+ if (target.install.kind === TAURI_DEVID_KIND) {
114
+ // installTauriDevId is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
115
+ const built = resolveTauriArtifact(target.install, { root, platform, env: childEnv });
116
+ releaseLease = false;
117
+ return installTauriDevId({ appRoot: root, config: target.install, releaseConfig: config, built, env: childEnv })
118
+ .then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), devid: outcome }))
119
+ .finally(() => lease.release());
120
+ }
107
121
  const signing = install(target.install, { root, platform, config, env: childEnv, run, sinceMs: startedMs, serial });
108
122
  return { built: true, installed: true, ...(staged.length ? { staged } : {}), ...(platform === "mac" && target.install.kind === "tauri" ? { signing } : {}) };
109
- } finally { lease.release(); }
123
+ } finally { if (releaseLease) lease.release(); }
110
124
  }
111
125
 
112
126
  export function assertNativeDevelopmentHost(platform, host = process.platform) {
@@ -126,9 +140,10 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
126
140
  // was running, pass /R so the installer relaunches it as the user (RunAsUser, outside this process tree).
127
141
  const running = isWindowsProcessRunning(path.basename(descriptor.expectedInstalledPath), { run });
128
142
  if (running) console.log(`right-release dev: ${descriptor.productName} is running; the installer will close it and relaunch it`);
129
- // The silent installer updates the fixed path in place. Keep the copy it replaces once, as <exe>.previous (never run).
143
+ // The silent installer updates the fixed path in place. NO backup of the old copy (Orthic-Labs/rightkit#348): a stale
144
+ // <exe>.previous left by an older RightKit is removed, nothing is copied, and a failed install is reinstalled from an older build.
130
145
  const installedBefore = path.resolve(root, descriptor.expectedInstalledPath);
131
- if (existsSync(installedBefore) && statSync(installedBefore).isFile()) copyFileSync(installedBefore, previousPathOf(installedBefore));
146
+ rmSync(previousPathOf(installedBefore), { recursive: true, force: true });
132
147
  const result = run(app, windowsInstallerArguments({ running }), { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
133
148
  if (result.status !== 0) throw new Error(`development installer failed (${result.status}): ${app}`);
134
149
  const installed = path.resolve(root, descriptor.expectedInstalledPath);
@@ -142,7 +157,6 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
142
157
  mkdirSync(path.dirname(destination), { recursive: true });
143
158
  // Fixed staging name (not per-pid): the copy is signed and verified here, then renamed into place. Nothing runs from it.
144
159
  const staging = stagingPathOf(destination);
145
- const previous = previousPathOf(destination);
146
160
  rmSync(staging, { recursive: true, force: true });
147
161
  cpSync(app, staging, { recursive: true, verbatimSymlinks: true });
148
162
  let signing;
@@ -153,7 +167,7 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
153
167
  if (!signing.adhoc && existsSync(destination)) assertStableDesignatedRequirement({ root, installed: destination, expected: signing.designatedRequirement, env, run });
154
168
  executableName = readBundleExecutable(staging, { root, env, run });
155
169
  } catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
156
- swapInstalledMacosBundle({ staging, destination, previous, executableName });
170
+ swapInstalledMacosBundle({ staging, destination, executableName });
157
171
  if (signing.provisioningProfile) signing.provisioningProfile = path.join(destination, "Contents", "embedded.provisionprofile");
158
172
  if (signing.adhoc) console.log(`right-release dev: installed ${destination}; signing=ci-adhoc (ad hoc signed on a CI runner; NOT a release artifact); signature=adhoc; TeamIdentifier=none`);
159
173
  else console.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; profile=${signing.provisioningProfile ?? "none"}; designated=${signing.designatedRequirement}`);
@@ -167,12 +181,13 @@ export class DevelopmentInstallError extends Error {
167
181
  const NODE_FS = { existsSync, lstatSync, mkdirSync, copyFileSync, readFileSync, renameSync, rmSync, statSync };
168
182
 
169
183
  /**
170
- * Move the signed staging bundle into place, keeping the previous app as `<destination>.previous`, and verify it: Info.plist exists and
184
+ * Move the signed staging bundle into place IN PLACE with NO backup (Orthic-Labs/rightkit#348: the old app is removed,
185
+ * never kept as `<destination>.previous`; a stale `.previous` is removed too), and verify it: Info.plist exists and
171
186
  * the installed main executable (`executableName`, the staged CFBundleExecutable read through plutil by the caller) exists with the same SHA-256 as the staged one. Any failure removes the
172
- * new install, renames `.previous` back and throws a DevelopmentInstallError (code DEV_INSTALL_VERIFICATION_FAILED,
187
+ * new (absent or partial) install, does NOT restore the old app (reinstall from an older DMG), and throws a DevelopmentInstallError (code DEV_INSTALL_VERIFICATION_FAILED,
173
188
  * DEV_INSTALL_EXECUTABLE_MISSING or DEV_INSTALL_EXECUTABLE_MISMATCH). `fs` is the file-system seam (tests inject it).
174
189
  */
175
- export function swapInstalledMacosBundle({ staging, destination, previous = previousPathOf(destination), executableName, fs = NODE_FS }) {
190
+ export function swapInstalledMacosBundle({ staging, destination, executableName, fs = NODE_FS }) {
176
191
  if (typeof executableName !== "string" || !executableName) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", "swapInstalledMacosBundle requires the staged CFBundleExecutable name");
177
192
  const executable = path.join("Contents", "MacOS", executableName);
178
193
  const digest = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
@@ -182,9 +197,9 @@ export function swapInstalledMacosBundle({ staging, destination, previous = prev
182
197
  fs.rmSync(staging, { recursive: true, force: true });
183
198
  throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `staged app has no main executable ${executable}: ${staging} (${error.message})`);
184
199
  }
185
- // The old app becomes `<destination>.previous` (replacing any older one) and stays there for rollback; any failure renames it back.
200
+ // The old app is removed and the staged one renamed over it; no old copy survives, even on failure.
186
201
  swapInPlace({
187
- staging, destination, previous, fs,
202
+ staging, destination, previous: null, fs,
188
203
  verify: () => {
189
204
  if (!fs.existsSync(path.join(destination, "Contents", "Info.plist"))) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", `installed app verification failed: ${destination}`);
190
205
  const installed = path.join(destination, executable);
@@ -341,7 +356,7 @@ export async function main(args = process.argv.slice(2)) {
341
356
  if (pathKey) process.env[pathKey] = safeEnv[pathKey] ?? process.env[pathKey];
342
357
  const loaded = await import(pathToFileURL(absoluteConfig));
343
358
  const config = loaded.default ?? loaded.config ?? loaded;
344
- const result = runDevelopment({ root: path.dirname(absoluteConfig), platform, config, dryRun, buildOnly, serial });
359
+ const result = await runDevelopment({ root: path.dirname(absoluteConfig), platform, config, dryRun, buildOnly, serial });
345
360
  if (dryRun) console.log(JSON.stringify(result.plan, null, 2));
346
361
  return result;
347
362
  }
@@ -4,8 +4,10 @@ import path from "node:path";
4
4
  /**
5
5
  * The one install rule for every app, helper and dev or QA binary (Adrian, 2026-10-17: "No new id, no new folder"):
6
6
  * macOS and Windows tie privacy grants to the path and the code identity, so an install or update replaces the
7
- * copy at ONE fixed path. Rollback is a persistent `<name>.previous` beside it (rename it back). Nothing ever runs
8
- * from `.previous` or from the staging copy.
7
+ * copy at ONE fixed path. Release installs may keep a persistent `<name>.previous` beside it (rename it back).
8
+ * Dev and QA installs NEVER keep an old copy (Orthic-Labs/rightkit#348: "when installing dev, old copy must never be
9
+ * backedup"): they pass `previous: null` to `swapInPlace`, or use `replaceFileInPlace`, which has no backup at all.
10
+ * Nothing ever runs from `.previous` or from the staging copy.
9
11
  */
10
12
  export const PREVIOUS_SUFFIX = ".previous";
11
13
  export const STAGING_SUFFIX = ".staging";
@@ -20,12 +22,19 @@ const exists = (fs, file) => {
20
22
  };
21
23
 
22
24
  /**
23
- * Swap `staging` into `destination`: the old destination becomes `previous` (replacing any older `.previous`),
24
- * then `verify(destination)` runs. Any failure removes the new copy and renames `previous` back, so the fixed
25
- * path is never left empty. Renames only: no copy, no new path.
25
+ * Swap `staging` into `destination`, then `verify(destination)` runs.
26
+ *
27
+ * With a `previous` path (release): the old destination becomes `previous` (replacing any older `.previous`). Any
28
+ * failure removes the new copy and renames `previous` back, so the fixed path is never left empty. Renames only.
26
29
  * @returns {{destination: string, previous: string|null}} previous is null when nothing was installed before.
30
+ *
31
+ * With `previous: null` (dev and QA, NO backup of any kind): the old destination is removed and `staging` is renamed
32
+ * over the fixed path; a stale `<destination>.previous` left by an older RightKit is removed too. Any failure removes
33
+ * the new (absent or partial) copy and the staging copy and rethrows; the old copy is NOT restored, the user reinstalls
34
+ * from an older build. `previous` is always null in the result.
27
35
  */
28
36
  export function swapInPlace({ staging, destination, previous = previousPathOf(destination), verify = () => {}, fs = nodeFs }) {
37
+ if (previous === null) return replaceWithoutBackup({ staging, destination, verify, fs });
29
38
  if (previous === destination || staging === destination || staging === previous) throw new Error("swapInPlace needs three distinct paths");
30
39
  let movedAside = false;
31
40
  try {
@@ -45,12 +54,28 @@ export function swapInPlace({ staging, destination, previous = previousPathOf(de
45
54
  return { destination, previous: movedAside ? previous : null };
46
55
  }
47
56
 
48
- /** Copy one file to its fixed path in place: temp sibling, then rename, keeping `<file>.previous`. Returns the path. */
57
+ function replaceWithoutBackup({ staging, destination, verify, fs }) {
58
+ if (staging === destination) throw new Error("swapInPlace needs distinct staging and destination paths");
59
+ try {
60
+ if (exists(fs, destination)) fs.rmSync(destination, { recursive: true, force: true });
61
+ fs.renameSync(staging, destination);
62
+ verify(destination);
63
+ } catch (error) {
64
+ try { fs.rmSync(destination, { recursive: true, force: true }); } catch { /* keep the original error */ }
65
+ try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
66
+ throw error;
67
+ }
68
+ // No old copy may survive a dev install, including one an older RightKit left behind.
69
+ try { fs.rmSync(previousPathOf(destination), { recursive: true, force: true }); } catch { /* best effort */ }
70
+ return { destination, previous: null };
71
+ }
72
+
73
+ /** Copy one dev or QA file to its fixed path in place: temp sibling, then rename. NO backup of the old file. Returns the path. */
49
74
  export function replaceFileInPlace(from, destination, { fs = nodeFs } = {}) {
50
75
  fs.mkdirSync(path.dirname(destination), { recursive: true });
51
76
  const staging = stagingPathOf(destination);
52
77
  fs.rmSync(staging, { force: true });
53
78
  fs.copyFileSync(from, staging);
54
- swapInPlace({ staging, destination });
79
+ swapInPlace({ staging, destination, previous: null, fs });
55
80
  return destination;
56
81
  }