@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.
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": "c6b47bc1767d45fc9a72a401a258d5f645a0c54185f2bf2b6c51634cff7c8d6b",
3
+ "sha256": "f52367947184283bf188d04e414743879c842ac359ed0a97acacecf23383b1ce",
4
4
  "identity": "Developer ID Application: Adrian D'souza (6KLGD3LLKF)",
5
5
  "codesign": "strict-valid"
6
6
  }
@@ -22,6 +22,7 @@ const commands = new Map([
22
22
  ["sign-candidate", "sign-candidate.mjs"],
23
23
  ["engine-locate", "engine-locator.mjs"],
24
24
  ["stage-sidecar", "stage-sidecar-command.mjs"],
25
+ ["recipe-build", "recipe-build.mjs"],
25
26
  ["signing-agent", "signing-agent-command.mjs"],
26
27
  ["create-mac-updater", "create-mac-updater.mjs"],
27
28
  ["mirror-root-artifact", "mirror-root-artifact.mjs"],
@@ -183,15 +184,16 @@ function printHelp() {
183
184
 
184
185
  Commands:
185
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)
186
188
  dmg build --app <.app> --out <dmg> --volname <name> [--background <png>] [--spec <appdmg.json>]
187
189
  [--icon-size N] [--app-position x,y] [--applications-position x,y] [--window w,h] [--window-position x,y]
188
190
  Build branded UDZO image without Finder
189
191
  install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id <id>] [--dest /Applications]
190
192
  [--relaunch|--no-relaunch] [--json]
191
193
  Verify a signed DMG, quit, replace & relaunch hidden; no UI, no osascript (macOS)
192
- The replaced app stays beside the fixed path as <App>.app.previous
193
- install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--json]
194
- 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)
195
197
  build [--platform mac|win] [--skip-checks] [--resume-from <phase>]
196
198
  Resume macOS at prepackage, sign, package, post-sign, or harden
197
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
@@ -105,7 +105,7 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
105
105
  // Android: cargo-ndk (Cargo dev profile) fills the generated jniLibs, then the Gradle debug build packages it.
106
106
  if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, label: "native build" }); }
107
107
  for (const command of target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
108
- // 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
109
109
  // broker's per-generation target folder or a hashed dev-targets folder.
110
110
  const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
111
111
  for (const file of staged) console.log(`right-release dev: staged ${file}`);
@@ -114,7 +114,7 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
114
114
  // installTauriDevId is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
115
115
  const built = resolveTauriArtifact(target.install, { root, platform, env: childEnv });
116
116
  releaseLease = false;
117
- return installTauriDevId({ appRoot: root, config: target.install, built, env: childEnv })
117
+ return installTauriDevId({ appRoot: root, config: target.install, releaseConfig: config, built, env: childEnv })
118
118
  .then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), devid: outcome }))
119
119
  .finally(() => lease.release());
120
120
  }
@@ -140,9 +140,10 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
140
140
  // was running, pass /R so the installer relaunches it as the user (RunAsUser, outside this process tree).
141
141
  const running = isWindowsProcessRunning(path.basename(descriptor.expectedInstalledPath), { run });
142
142
  if (running) console.log(`right-release dev: ${descriptor.productName} is running; the installer will close it and relaunch it`);
143
- // 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.
144
145
  const installedBefore = path.resolve(root, descriptor.expectedInstalledPath);
145
- if (existsSync(installedBefore) && statSync(installedBefore).isFile()) copyFileSync(installedBefore, previousPathOf(installedBefore));
146
+ rmSync(previousPathOf(installedBefore), { recursive: true, force: true });
146
147
  const result = run(app, windowsInstallerArguments({ running }), { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
147
148
  if (result.status !== 0) throw new Error(`development installer failed (${result.status}): ${app}`);
148
149
  const installed = path.resolve(root, descriptor.expectedInstalledPath);
@@ -156,7 +157,6 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
156
157
  mkdirSync(path.dirname(destination), { recursive: true });
157
158
  // Fixed staging name (not per-pid): the copy is signed and verified here, then renamed into place. Nothing runs from it.
158
159
  const staging = stagingPathOf(destination);
159
- const previous = previousPathOf(destination);
160
160
  rmSync(staging, { recursive: true, force: true });
161
161
  cpSync(app, staging, { recursive: true, verbatimSymlinks: true });
162
162
  let signing;
@@ -167,7 +167,7 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
167
167
  if (!signing.adhoc && existsSync(destination)) assertStableDesignatedRequirement({ root, installed: destination, expected: signing.designatedRequirement, env, run });
168
168
  executableName = readBundleExecutable(staging, { root, env, run });
169
169
  } catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
170
- swapInstalledMacosBundle({ staging, destination, previous, executableName });
170
+ swapInstalledMacosBundle({ staging, destination, executableName });
171
171
  if (signing.provisioningProfile) signing.provisioningProfile = path.join(destination, "Contents", "embedded.provisionprofile");
172
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`);
173
173
  else console.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; profile=${signing.provisioningProfile ?? "none"}; designated=${signing.designatedRequirement}`);
@@ -181,12 +181,13 @@ export class DevelopmentInstallError extends Error {
181
181
  const NODE_FS = { existsSync, lstatSync, mkdirSync, copyFileSync, readFileSync, renameSync, rmSync, statSync };
182
182
 
183
183
  /**
184
- * 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
185
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
186
- * 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,
187
188
  * DEV_INSTALL_EXECUTABLE_MISSING or DEV_INSTALL_EXECUTABLE_MISMATCH). `fs` is the file-system seam (tests inject it).
188
189
  */
189
- export function swapInstalledMacosBundle({ staging, destination, previous = previousPathOf(destination), executableName, fs = NODE_FS }) {
190
+ export function swapInstalledMacosBundle({ staging, destination, executableName, fs = NODE_FS }) {
190
191
  if (typeof executableName !== "string" || !executableName) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", "swapInstalledMacosBundle requires the staged CFBundleExecutable name");
191
192
  const executable = path.join("Contents", "MacOS", executableName);
192
193
  const digest = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
@@ -196,9 +197,9 @@ export function swapInstalledMacosBundle({ staging, destination, previous = prev
196
197
  fs.rmSync(staging, { recursive: true, force: true });
197
198
  throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `staged app has no main executable ${executable}: ${staging} (${error.message})`);
198
199
  }
199
- // 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.
200
201
  swapInPlace({
201
- staging, destination, previous, fs,
202
+ staging, destination, previous: null, fs,
202
203
  verify: () => {
203
204
  if (!fs.existsSync(path.join(destination, "Contents", "Info.plist"))) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", `installed app verification failed: ${destination}`);
204
205
  const installed = path.join(destination, executable);
@@ -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
  }
@@ -5,15 +5,23 @@ import { spawnSync } from "./background-process.mjs";
5
5
  import { previousPathOf } from "./in-place-install.mjs";
6
6
 
7
7
  /**
8
- * `right-release install-app --installer <Setup.exe> --dest <install folder> --exe <App.exe>` on Windows (Orthic-Labs/rightkit#258).
8
+ * `right-release install-app --installer <Setup.exe> --dest <install folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>]` on Windows (Orthic-Labs/rightkit#258).
9
9
  *
10
10
  * The same rule as the macOS install: the app is installed and updated IN PLACE at one fixed folder (`--dest`, the
11
- * folder the NSIS installer already owns, for example `%LOCALAPPDATA%\Programs\Pulse`). Rollback is one
12
- * `<dest>.previous` beside it (a copy of the folder as it was; replaced on each install; nothing runs from it), never a
13
- * versioned, digest or per-run folder. The silent installer is pinned to the fixed folder with `/D=` so it cannot choose another one.
11
+ * folder the NSIS installer already owns, for example `%LOCALAPPDATA%\Programs\Pulse`). The silent installer is pinned to the
12
+ * fixed folder with `/D=` so it cannot choose another one.
14
13
  *
15
- * Why a copy and not a rename: a running app holds its folder open on Windows, so a rename fails; the installer closes
16
- * the app and updates the same path in place instead. A failed install copies `<dest>.previous` back over `<dest>`.
14
+ * DATA SAFETY (Orthic-Labs/rightkit#348): the install folder can also hold USER DATA (CodeRight keeps coderight.db, its WAL,
15
+ * state and models in `%LOCALAPPDATA%\CodeRight`). So this module NEVER copies, restores, removes or overwrites the whole
16
+ * install folder. Rollback is PAYLOAD-ONLY, from an explicit allowlist of the files the installer itself writes:
17
+ * - the allowlist is `--payload <relative file>` (repeatable) and/or `--payload-manifest <json file>` (`{ "files": [...] }` or a bare array)
18
+ * plus the `--exe` file; entries are relative regular files inside `--dest`, no directories, wildcards, `..` or drive paths;
19
+ * - with NO declared payload the allowlist is just the `--exe` file (always backed up and restored; nothing else is);
20
+ * - no path segment from `--dest` down to an allowlisted file may be a symlink or junction (reparse point); one is refused before backup and before restore or removal;
21
+ * - the backup is `<dest>.previous`, holding only the allowlisted files that existed before (replaced on each install; nothing runs from it);
22
+ * - a failed install restores only allowlisted files that existed before and removes only allowlisted files the failed run newly wrote;
23
+ * it never touches any other file in `--dest`.
24
+ * Dev installs do not pass through this module: they keep no backup at all (see development.mjs, install-app.mjs --dev).
17
25
  */
18
26
  export class InstallAppWindowsError extends Error {
19
27
  constructor(code, message) { super(message); this.name = "InstallAppWindowsError"; this.code = code; }
@@ -26,6 +34,40 @@ export function windowsInstallArguments(dest) {
26
34
 
27
35
  const sha256 = (fs, file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
28
36
 
37
+ const SEGMENT_BAD = /[<>:"|?*\0]/;
38
+
39
+ /** One allowlist entry as a normalized `a/b/c` relative file path, or a usage error. */
40
+ function normalizePayloadEntry(entry) {
41
+ if (typeof entry !== "string" || !entry.trim()) throw new InstallAppWindowsError("usage", "a payload entry must be a non-empty relative file path");
42
+ if (path.win32.isAbsolute(entry) || path.posix.isAbsolute(entry) || /^[A-Za-z]:/.test(entry)) throw new InstallAppWindowsError("usage", `payload entry ${JSON.stringify(entry)} must be relative to --dest, not absolute`);
43
+ const segments = entry.split(/[\\/]/);
44
+ if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || SEGMENT_BAD.test(segment))) {
45
+ throw new InstallAppWindowsError("usage", `payload entry ${JSON.stringify(entry)} must be a plain relative file path (no directories, wildcards, "..", or empty segments)`);
46
+ }
47
+ return segments.join("/");
48
+ }
49
+
50
+ /** The payload allowlist: `--exe`, `--payload` entries and a `--payload-manifest` file, normalized and de-duplicated. Empty when nothing is declared. */
51
+ export function resolvePayloadAllowlist({ exe, payload = [], payloadManifest = null }, { fs = nodeFs } = {}) {
52
+ const declared = [...(Array.isArray(payload) ? payload : [payload])];
53
+ if (payloadManifest) {
54
+ let parsed;
55
+ try { parsed = JSON.parse(String(fs.readFileSync(payloadManifest, "utf8"))); }
56
+ catch (error) { throw new InstallAppWindowsError("usage", `--payload-manifest ${payloadManifest} cannot be read as JSON: ${error.message}`); }
57
+ const files = Array.isArray(parsed) ? parsed : parsed?.files;
58
+ if (!Array.isArray(files)) throw new InstallAppWindowsError("usage", `--payload-manifest ${payloadManifest} must be a JSON array of relative files, or { "files": [...] }`);
59
+ declared.push(...files);
60
+ }
61
+ // `--exe` alone is the minimal allowlist: the executable is always backed up and restored; data files never are.
62
+ const normalized = [...new Set([exe, ...declared].map(normalizePayloadEntry))];
63
+ const lower = new Set();
64
+ for (const entry of normalized) {
65
+ if (lower.has(entry.toLowerCase())) throw new InstallAppWindowsError("usage", `payload entry ${JSON.stringify(entry)} is listed twice (Windows paths are case-insensitive)`);
66
+ lower.add(entry.toLowerCase());
67
+ }
68
+ return normalized.sort();
69
+ }
70
+
29
71
  export function installAppWindows(opts, { platform = process.platform, fs = nodeFs, run = spawnSync, event = () => {} } = {}) {
30
72
  if (platform !== "win32") throw new InstallAppWindowsError("unsupported", `the Windows installer mode runs on win32, not ${platform}`);
31
73
  const { installer, dest, exe } = opts;
@@ -33,31 +75,80 @@ export function installAppWindows(opts, { platform = process.platform, fs = node
33
75
  if (!dest || !path.win32.isAbsolute(dest) || path.win32.parse(dest).root === dest) throw new InstallAppWindowsError("usage", "--dest must be the fixed absolute install folder, not a drive root");
34
76
  if (!exe || exe !== path.win32.basename(exe)) throw new InstallAppWindowsError("usage", "--exe must be the bare executable name inside --dest");
35
77
  if (!fs.existsSync(installer)) throw new InstallAppWindowsError("installer-missing", `installer not found: ${installer}`);
78
+ const allowlist = resolvePayloadAllowlist({ exe, payload: opts.payload, payloadManifest: opts.payloadManifest }, { fs });
36
79
  const previous = previousPathOf(dest);
37
80
  const installed = path.win32.join(dest, exe);
38
81
  const hadInstall = fs.existsSync(dest);
39
- event("start", { installer, dest, replacing: hadInstall });
40
- if (hadInstall) {
41
- // One rollback copy, replaced on every install.
82
+ const inDest = (relative) => path.win32.join(dest, ...relative.split("/"));
83
+ const inPrevious = (relative) => path.win32.join(previous, ...relative.split("/"));
84
+ const isFile = (file) => { try { return (fs.lstatSync ?? fs.statSync).call(fs, file).isFile(); } catch { return false; } };
85
+ // lstat every segment from dest down to the file; a symlink or junction anywhere in the chain is refused. A missing segment ends the walk.
86
+ const assertNoLinks = (relative) => {
87
+ const lstat = fs.lstatSync ?? fs.statSync;
88
+ let current = dest;
89
+ for (const segment of [null, ...relative.split("/")]) {
90
+ if (segment !== null) current = path.win32.join(current, segment);
91
+ let stat;
92
+ try { stat = lstat.call(fs, current); } catch { return; }
93
+ if (stat.isSymbolicLink?.() || stat.isReparsePoint?.()) throw new InstallAppWindowsError("usage", `${current} is a symlink or junction; refusing to back up, restore or remove payload entry ${relative} through it`);
94
+ }
95
+ };
96
+ event("start", { installer, dest, replacing: hadInstall, payloadFiles: allowlist.length });
97
+
98
+ // Backup: ONLY the allowlisted files that exist now, into `<dest>.previous`. The rest of --dest (user data) is never copied.
99
+ const backedUp = [];
100
+ const absentBefore = [];
101
+ let backupMade = false;
102
+ if (hadInstall && allowlist.length > 0) {
103
+ for (const relative of allowlist) {
104
+ const file = inDest(relative);
105
+ assertNoLinks(relative);
106
+ if (!fs.existsSync(file)) { absentBefore.push(relative); continue; }
107
+ if (!isFile(file)) throw new InstallAppWindowsError("usage", `payload entry ${relative} is not a regular file in ${dest}; refusing to back it up or roll it back`);
108
+ backedUp.push(relative);
109
+ }
110
+ // One payload-only rollback copy, replaced on every install (this also clears a whole-folder copy an older RightKit left).
42
111
  fs.rmSync(previous, { recursive: true, force: true });
43
- fs.cpSync(dest, previous, { recursive: true });
44
- event("previous-kept", { previous });
112
+ for (const relative of backedUp) {
113
+ fs.mkdirSync(path.win32.dirname(inPrevious(relative)), { recursive: true });
114
+ fs.copyFileSync(inDest(relative), inPrevious(relative));
115
+ }
116
+ backupMade = true;
117
+ event("previous-kept", { previous, files: backedUp.length, payloadOnly: true });
45
118
  }
119
+ // Roll back the payload and nothing else: restore allowlisted files that existed, remove allowlisted files this run newly wrote.
120
+ // Each file is restored independently; failures are collected and reported together, never masking the installer error.
121
+ const restoreFailures = [];
46
122
  const restore = () => {
47
- if (!hadInstall || !fs.existsSync(previous)) return false;
48
- fs.cpSync(previous, dest, { recursive: true, force: true });
123
+ if (!backupMade) return false;
124
+ for (const relative of backedUp) {
125
+ try {
126
+ assertNoLinks(relative);
127
+ fs.mkdirSync(path.win32.dirname(inDest(relative)), { recursive: true });
128
+ fs.copyFileSync(inPrevious(relative), inDest(relative));
129
+ } catch (error) { restoreFailures.push(`${relative}: ${error.message}`); }
130
+ }
131
+ for (const relative of absentBefore) {
132
+ try {
133
+ assertNoLinks(relative);
134
+ const file = inDest(relative);
135
+ if (fs.existsSync(file) && isFile(file)) fs.rmSync(file, { force: true });
136
+ } catch (error) { restoreFailures.push(`${relative}: ${error.message}`); }
137
+ }
49
138
  return true;
50
139
  };
140
+ const failureNote = () => (restoreFailures.length ? `; ROLLBACK INCOMPLETE, ${restoreFailures.length} file(s) failed: ${restoreFailures.join(" | ")}` : "");
141
+ const noRollback = "nothing was installed before, so nothing was rolled back and no other file in the install folder was touched";
51
142
  const result = run(installer, windowsInstallArguments(dest), { stdio: "inherit", windowsHide: true, shell: false });
52
143
  if (result.status !== 0) {
53
144
  const restored = restore();
54
- throw new InstallAppWindowsError("installer-failed", `installer exited ${result.status ?? "abnormally"}${result.error ? `: ${result.error.message}` : ""}; ${restored ? `restored ${dest} from ${previous}` : "nothing was installed before"}`);
145
+ throw new InstallAppWindowsError("installer-failed", `installer exited ${result.status ?? "abnormally"}${result.error ? `: ${result.error.message}` : ""}; ${restored ? `restored the ${backedUp.length} payload file(s) of ${dest} from ${previous}; no other file was touched` : noRollback}${failureNote()}`);
55
146
  }
56
147
  // The installer must have updated the fixed folder: the executable is at the same path as before, not in a new folder.
57
148
  if (!fs.existsSync(installed) || fs.statSync(installed).size === 0) {
58
149
  const restored = restore();
59
- throw new InstallAppWindowsError("path-changed", `the installer did not place ${exe} at the fixed path ${installed}; ${restored ? `restored from ${previous}` : "nothing was installed before"}`);
150
+ throw new InstallAppWindowsError("path-changed", `the installer did not place ${exe} at the fixed path ${installed}; ${restored ? `restored the ${backedUp.length} payload file(s) from ${previous}; no other file was touched` : noRollback}${failureNote()}`);
60
151
  }
61
- event("replaced", { installed, previous: hadInstall ? previous : null, sha256: sha256(fs, installed) });
62
- return { ok: true, installed, previous: hadInstall ? previous : null };
152
+ event("replaced", { installed, previous: backupMade ? previous : null, sha256: sha256(fs, installed) });
153
+ return { ok: true, installed, previous: backupMade ? previous : null };
63
154
  }