@rightkit/release 0.3.0 → 0.4.1

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.
Files changed (83) hide show
  1. package/README.md +74 -10
  2. package/addon-command.mjs +3 -4
  3. package/adoption-evidence.mjs +650 -0
  4. package/android-play-upload.mjs +2 -1
  5. package/apple-profile-create.mjs +113 -10
  6. package/apple-profile-renew.mjs +11 -3
  7. package/artifact-storage.mjs +35 -2
  8. package/bin/darwin/rightkit-swap +0 -0
  9. package/binary-license-notices.mjs +161 -0
  10. package/build-release.mjs +138 -47
  11. package/cargo-license-notices.mjs +111 -0
  12. package/cli/right-release.mjs +21 -2
  13. package/codesign-environment.mjs +2 -2
  14. package/content-digest.mjs +209 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/github-updater-release.mjs +70 -7
  21. package/in-place-install.mjs +17 -14
  22. package/install-app-windows.mjs +24 -7
  23. package/install-app.mjs +21 -40
  24. package/ios-asc-credentials.mjs +163 -0
  25. package/ios-build-context.mjs +129 -0
  26. package/ios-build-upload.mjs +282 -0
  27. package/ios-credentials-command.mjs +70 -0
  28. package/ios-dev.mjs +248 -0
  29. package/ios-host.mjs +76 -0
  30. package/ios-lock.mjs +75 -0
  31. package/ios-package-recipe.mjs +707 -0
  32. package/ios-testflight.mjs +266 -0
  33. package/ios-upload.mjs +313 -0
  34. package/local-publish.mjs +222 -53
  35. package/managed-cargo.mjs +8 -0
  36. package/mirror-root-artifact.mjs +5 -1
  37. package/native-cargo-layout.mjs +47 -4
  38. package/ownership-command.mjs +2 -1
  39. package/ownership-dependency-allowlist.json +3 -1
  40. package/ownership-evidence.mjs +30 -18
  41. package/ownership-key-command.mjs +69 -2
  42. package/ownership-mandatory-set.json +117 -67
  43. package/ownership-mandatory-set.mjs +141 -21
  44. package/ownership-repositories.json +1 -1
  45. package/ownership-scope.mjs +94 -0
  46. package/ownership.mjs +49 -7
  47. package/package-recipe-targets.mjs +41 -1
  48. package/package.json +1 -1
  49. package/pipeline-admission.mjs +375 -0
  50. package/pipeline-config.mjs +100 -0
  51. package/pipeline-generators.json +4 -0
  52. package/pipeline-generators.mjs +137 -0
  53. package/pipeline-scan.mjs +285 -0
  54. package/pipeline-tools.mjs +171 -0
  55. package/preflight.mjs +14 -7
  56. package/production-mode.mjs +42 -0
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +94 -32
  61. package/publish-cargo.mjs +117 -25
  62. package/publish-swift.mjs +12 -3
  63. package/publish-update.mjs +15 -2
  64. package/qa-build-features.mjs +16 -13
  65. package/qa-contract.mjs +30 -2
  66. package/qa-control-scan.mjs +8 -0
  67. package/release-config.mjs +330 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +88 -21
  71. package/release.mjs +82 -20
  72. package/rightkit-ownership.json +1 -1
  73. package/rightkit-sync.mjs +8 -0
  74. package/rightkit-versions.json +428 -194
  75. package/runtime-budget-crates.mjs +14 -0
  76. package/runtime-budgets.mjs +248 -47
  77. package/sign-updater.mjs +56 -59
  78. package/source-gate.mjs +8 -1
  79. package/stage-sidecar.mjs +207 -40
  80. package/swap-helper-bundle.mjs +7 -3
  81. package/target-distribution.mjs +5 -0
  82. package/upload-release.mjs +7 -2
  83. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -0,0 +1,209 @@
1
+ // Signature-independent content digest: exact JS port of crates/rightkit-ffmpeg/src/content_digest.rs.
2
+ // PE (Authenticode rule), thin 64-bit LE Mach-O (canonical unsigned image) & universal Mach-O (per-slice).
3
+ // A malformed recognized format FAILS (ContentDigestError); never falls back to a plain hash.
4
+ // Anything else is plain SHA-256. Archive/download SHA-256 remains a separate, independent value.
5
+ import { createHash } from "node:crypto";
6
+ import { readFileSync } from "node:fs";
7
+
8
+ export class ContentDigestError extends Error {
9
+ constructor(message) { super(message); this.name = "ContentDigestError"; this.code = "CONTENT_DIGEST_MALFORMED"; }
10
+ }
11
+ const malformed = (why) => { throw new ContentDigestError(why); };
12
+
13
+ const MH_MAGIC_64 = 0xfeedfacf, LC_SEGMENT_64 = 0x19, LC_CODE_SIGNATURE = 0x1d;
14
+ const FAT_MAGIC = 0xcafebabe, FAT_MAGIC_64 = 0xcafebabf;
15
+ const CPU_X86_64 = 0x01000007, CPU_ARM64 = 0x0100000c;
16
+ const MAX_FAT_ARCHS = 16;
17
+
18
+ const sha256Parts = (parts) => { const h = createHash("sha256"); for (const p of parts) h.update(p); return h.digest("hex"); };
19
+ export const sha256Bytes = (bytes) => sha256Parts([Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)]);
20
+
21
+ const le32 = (b, at) => (at + 4 <= b.length ? b.readUInt32LE(at) : null);
22
+ const le16 = (b, at) => (at + 2 <= b.length ? b.readUInt16LE(at) : null);
23
+ const be32 = (b, at) => (at + 4 <= b.length ? b.readUInt32BE(at) : null);
24
+ const le64n = (b, at) => (at + 8 <= b.length ? Number(b.readBigUInt64LE(at)) : null);
25
+ const be64n = (b, at) => (at + 8 <= b.length ? Number(b.readBigUInt64BE(at)) : null);
26
+
27
+ function archName(cputype) {
28
+ if (cputype === CPU_ARM64) return "aarch64";
29
+ if (cputype === CPU_X86_64) return "x86_64";
30
+ malformed("unsupported CPU type");
31
+ }
32
+ const roundUp = (v, to) => Math.floor((v + to - 1) / to) * to;
33
+
34
+ /** @returns {{format:"macho"|"macho-universal"|"pe"|"plain", slices:{arch:string|null, sha256:string}[]}} */
35
+ export function contentIdentity(input) {
36
+ const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input);
37
+ const magicBe = be32(bytes, 0), magicLe = le32(bytes, 0);
38
+ if (magicBe === FAT_MAGIC || magicBe === FAT_MAGIC_64) return fatIdentity(bytes);
39
+ if (magicLe === MH_MAGIC_64) {
40
+ const { arch, sha256 } = machoSliceDigest(bytes);
41
+ return { format: "macho", slices: [{ arch, sha256 }] };
42
+ }
43
+ if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xbebafeca, 0xbfbafeca].includes(magicBe)) {
44
+ malformed("unsupported Mach-O flavour (only 64-bit little-endian is catalogued)");
45
+ }
46
+ if (bytes.subarray(0, 2).toString("latin1") === "MZ") {
47
+ const { arch, sha256 } = peDigest(bytes);
48
+ return { format: "pe", slices: [{ arch, sha256 }] };
49
+ }
50
+ return { format: "plain", slices: [{ arch: null, sha256: sha256Bytes(bytes) }] };
51
+ }
52
+
53
+ export function contentIdentityFile(file) { return contentIdentity(readFileSync(file)); }
54
+ export function digestFor(identity, arch) {
55
+ return identity.slices.find((s) => s.arch?.toLowerCase() === String(arch).toLowerCase())?.sha256 ?? null;
56
+ }
57
+
58
+ function machoSliceDigest(slice) {
59
+ const bad = (why) => malformed(`Mach-O: ${why}`);
60
+ if (le32(slice, 0) !== MH_MAGIC_64) bad("not a 64-bit little-endian Mach-O");
61
+ const cputype = le32(slice, 4); if (cputype === null) bad("truncated header");
62
+ const arch = archName(cputype);
63
+ const ncmds = le32(slice, 16), sizeofcmds = le32(slice, 20);
64
+ if (ncmds === null || sizeofcmds === null) bad("truncated header");
65
+ const cmdsEnd = 32 + sizeofcmds;
66
+ if (cmdsEnd > slice.length) bad("load commands run past the end of the file");
67
+ const commands = [];
68
+ let offset = 32;
69
+ for (let i = 0; i < ncmds; i += 1) {
70
+ const cmd = le32(slice, offset), size = le32(slice, offset + 4);
71
+ if (cmd === null || size === null) bad("truncated load command");
72
+ if (size < 8 || size % 8 !== 0 || offset + size > cmdsEnd) bad("load command size is invalid");
73
+ commands.push({ offset, size, cmd });
74
+ offset += size;
75
+ }
76
+ if (offset !== cmdsEnd) bad("load commands do not fill sizeofcmds");
77
+ let signature = null, linkedit = null;
78
+ commands.forEach((c, index) => {
79
+ if (c.cmd === LC_CODE_SIGNATURE) {
80
+ if (c.size !== 16 || signature !== null) bad("invalid or repeated LC_CODE_SIGNATURE");
81
+ signature = index;
82
+ } else if (c.cmd === LC_SEGMENT_64) {
83
+ if (c.size < 72) bad("short LC_SEGMENT_64");
84
+ const name = slice.subarray(c.offset + 8, c.offset + 24);
85
+ const end = name.indexOf(0) === -1 ? name.length : name.indexOf(0);
86
+ if (name.subarray(0, end).toString("latin1") === "__LINKEDIT") {
87
+ if (linkedit !== null) bad("repeated __LINKEDIT");
88
+ linkedit = index;
89
+ }
90
+ }
91
+ });
92
+ if (linkedit === null) bad("no __LINKEDIT segment");
93
+ const leOff = commands[linkedit].offset;
94
+ const vmsize = le64n(slice, leOff + 32), fileoff = le64n(slice, leOff + 40), filesize = le64n(slice, leOff + 48);
95
+ if (vmsize === null || fileoff === null || filesize === null) bad("truncated __LINKEDIT");
96
+ const len = slice.length;
97
+ if (fileoff < cmdsEnd || fileoff + filesize !== len || vmsize < filesize || vmsize % 0x1000 !== 0) bad("__LINKEDIT does not run to the end of the file");
98
+ let contentEnd = len;
99
+ if (signature !== null) {
100
+ const off = commands[signature].offset;
101
+ const dataoff = le32(slice, off + 8), datasize = le32(slice, off + 12);
102
+ if (dataoff === null || datasize === null) bad("truncated signature");
103
+ if (dataoff < fileoff || dataoff + datasize !== len) bad("code signature is not the tail of __LINKEDIT");
104
+ contentEnd = dataoff;
105
+ }
106
+ let end = contentEnd;
107
+ while (end > fileoff && slice[end - 1] === 0) end -= 1;
108
+ const canonicalFilesize = end - fileoff;
109
+ const page = cputype === CPU_ARM64 ? 0x4000 : 0x1000;
110
+ const canonicalVmsize = roundUp(canonicalFilesize, page);
111
+ const head = Buffer.alloc(cmdsEnd);
112
+ slice.copy(head, 0, 0, 32);
113
+ let cursor = 32, newLinkeditAt = 0, removed = 0;
114
+ commands.forEach((c, index) => {
115
+ if (index === signature) { removed = c.size; return; }
116
+ if (index === linkedit) newLinkeditAt = cursor;
117
+ slice.copy(head, cursor, c.offset, c.offset + c.size);
118
+ cursor += c.size;
119
+ });
120
+ head.writeBigUInt64LE(BigInt(canonicalVmsize), newLinkeditAt + 32);
121
+ head.writeBigUInt64LE(BigInt(canonicalFilesize), newLinkeditAt + 48);
122
+ head.writeUInt32LE(ncmds - (signature === null ? 0 : 1), 16);
123
+ head.writeUInt32LE(sizeofcmds - removed, 20);
124
+ return { arch, sha256: sha256Parts([head, slice.subarray(cmdsEnd, end)]) };
125
+ }
126
+
127
+ function fatIdentity(bytes) {
128
+ const bad = (why) => malformed(`universal Mach-O: ${why}`);
129
+ const wide = be32(bytes, 0) === FAT_MAGIC_64;
130
+ const count = be32(bytes, 4);
131
+ if (count === null) bad("truncated header");
132
+ if (count === 0 || count > MAX_FAT_ARCHS) bad("implausible architecture count");
133
+ const entrySize = wide ? 32 : 20;
134
+ const tableEnd = 8 + entrySize * count;
135
+ if (tableEnd > bytes.length) bad("architecture table runs past the end of the file");
136
+ const slices = [], seen = [], ranges = [];
137
+ for (let index = 0; index < count; index += 1) {
138
+ const at = 8 + index * entrySize;
139
+ const cputype = be32(bytes, at);
140
+ const off = wide ? be64n(bytes, at + 8) : be32(bytes, at + 8);
141
+ const size = wide ? be64n(bytes, at + 16) : be32(bytes, at + 12);
142
+ if (cputype === null || off === null || size === null) bad("truncated entry");
143
+ const end = off + size;
144
+ if (size === 0 || off < tableEnd || end > bytes.length) bad("slice is empty or out of bounds");
145
+ if (seen.includes(cputype)) bad("duplicate architecture");
146
+ if (ranges.some(([s, e]) => off < e && s < end)) bad("slices overlap");
147
+ seen.push(cputype); ranges.push([off, end]);
148
+ const { arch, sha256 } = machoSliceDigest(bytes.subarray(off, end));
149
+ if (arch !== archName(cputype)) bad("slice architecture differs from the fat header");
150
+ slices.push({ arch, sha256 });
151
+ }
152
+ return { format: "macho-universal", slices };
153
+ }
154
+
155
+ function peDigest(bytes) {
156
+ const bad = (why) => malformed(`PE: ${why}`);
157
+ const len = bytes.length;
158
+ const pe = le32(bytes, 0x3c);
159
+ if (pe === null) bad("no PE header offset");
160
+ if (bytes.subarray(pe, pe + 4).toString("latin1") !== "PE\0\0") bad("missing PE signature");
161
+ const machine = le16(bytes, pe + 4);
162
+ const arch = machine === 0x8664 ? "x86_64" : machine === 0xaa64 ? "aarch64" : bad("unsupported machine type");
163
+ const nsections = le16(bytes, pe + 6), optSize = le16(bytes, pe + 20);
164
+ if (nsections === null || optSize === null) bad("truncated COFF header");
165
+ const opt = pe + 24;
166
+ const sectionsAt = opt + optSize;
167
+ if (sectionsAt > len) bad("optional header runs past the end of the file");
168
+ const magic = le16(bytes, opt);
169
+ const dirs = magic === 0x10b ? 96 : magic === 0x20b ? 112 : bad("unknown optional header magic");
170
+ if (optSize < dirs) bad("optional header too short");
171
+ const checksumAt = opt + 64;
172
+ const sizeOfHeaders = le32(bytes, opt + 60), dirCount = le32(bytes, opt + dirs - 4);
173
+ if (sizeOfHeaders === null || dirCount === null) bad("truncated optional header");
174
+ const certEntry = dirCount > 4 && optSize >= dirs + 40 ? opt + dirs + 32 : null;
175
+ const tableEnd = sectionsAt + nsections * 40;
176
+ if (tableEnd > len) bad("section table runs past the end of the file");
177
+ let floor = checksumAt + 4;
178
+ if (certEntry !== null) floor = Math.max(floor, certEntry + 8);
179
+ if (sizeOfHeaders > len || sizeOfHeaders < Math.max(floor, tableEnd)) bad("SizeOfHeaders is inconsistent");
180
+ let certOffset = 0, certSize = 0;
181
+ if (certEntry !== null) {
182
+ certOffset = le32(bytes, certEntry); certSize = le32(bytes, certEntry + 4);
183
+ if (certOffset === null || certSize === null) bad("truncated directory");
184
+ }
185
+ if (certSize > 0 && (certOffset < sizeOfHeaders || certOffset + certSize !== len)) bad("certificate table is not the tail of the file");
186
+ const sections = [];
187
+ for (let i = 0; i < nsections; i += 1) {
188
+ const at = sectionsAt + i * 40;
189
+ const size = le32(bytes, at + 16), off = le32(bytes, at + 20);
190
+ if (size === null || off === null) bad("truncated section");
191
+ if (size > 0) sections.push([off, size]);
192
+ }
193
+ sections.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
194
+ const parts = [bytes.subarray(0, checksumAt)];
195
+ if (certEntry !== null) {
196
+ parts.push(bytes.subarray(checksumAt + 4, certEntry), bytes.subarray(certEntry + 8, sizeOfHeaders));
197
+ } else parts.push(bytes.subarray(checksumAt + 4, sizeOfHeaders));
198
+ let hashed = sizeOfHeaders, previousEnd = sizeOfHeaders;
199
+ const bodyEnd = len - certSize;
200
+ for (const [off, size] of sections) {
201
+ const end = off + size;
202
+ if (off < previousEnd || end > bodyEnd) bad("section overlaps the headers or the certificate table");
203
+ parts.push(bytes.subarray(off, end));
204
+ hashed += size; previousEnd = end;
205
+ }
206
+ if (hashed > bodyEnd) bad("hashed bytes exceed the file");
207
+ parts.push(bytes.subarray(hashed, bodyEnd));
208
+ return { arch, sha256: sha256Parts(parts) };
209
+ }
package/development.mjs CHANGED
@@ -14,6 +14,7 @@ import { stageDevelopmentBinaries, validateStageEntries } from "./stage-binary.m
14
14
  import { assertStableDesignatedRequirement, readBundleExecutable, requestedCiAdhocSigning, signMacosDevelopmentBundle } from "./development-signing.mjs";
15
15
  import { macosCodesignEnvironment } from "./codesign-environment.mjs";
16
16
  import { installTauriDevId, resolveTauriDevIdDescriptor, TAURI_DEVID_KIND } from "./install-tauri-devid.mjs";
17
+ import { assertIosDevelopmentSession, installIosDevelopment, prepareIosDevelopment, validateIosDevelopment } from "./ios-dev.mjs";
17
18
  import { androidDevelopmentNdkCommand, androidDevelopmentSerial, installAndroidDevelopment, validateAndroidDevelopment } from "./android-dev.mjs";
18
19
 
19
20
  // One standard Cargo profile for every app (templates/cargo-profiles.toml): Cargo's built-in `dev`.
@@ -58,6 +59,8 @@ export function developmentEnvironment(env = process.env) {
58
59
 
59
60
  export function validateDevelopmentConfig(config, platform, { root = process.cwd() } = {}) {
60
61
  const target = config?.development?.targets?.[platform];
62
+ // iOS builds its commands from project, scheme and install (ios-dev.mjs); there is no hand-written build array.
63
+ if (platform === "ios") return validateIosDevelopment(target, { root });
61
64
  if (!target || !Array.isArray(target.build) || target.build.length === 0) throw new Error(`development.targets.${platform}.build must be a non-empty command array`);
62
65
  for (const [index, command] of target.build.entries()) validateCommand(command, `development.targets.${platform}.build[${index}]`);
63
66
  if (target.install?.kind === "command") validateCommand(target.install, `development.targets.${platform}.install`);
@@ -85,8 +88,9 @@ export function validateDevelopmentConfig(config, platform, { root = process.cwd
85
88
 
86
89
  export function runDevelopment({ root, platform, config, buildOnly = false, dryRun = false, env = process.env, run = spawnSync,
87
90
  codesignEnvironment = macosCodesignEnvironment, serial }) {
88
- if (!new Set(["win", "mac", "android"]).has(platform)) throw new Error("--platform must be win, mac or android");
89
- assertNativeDevelopmentHost(platform);
91
+ if (!new Set(["win", "mac", "android", "ios"]).has(platform)) throw new Error("--platform must be win, mac, android or ios");
92
+ // iOS: --dry-run validates the config and prints the plan on any host; everything else needs macOS with Xcode.
93
+ if (!(platform === "ios" && dryRun)) assertNativeDevelopmentHost(platform);
90
94
  const target = validateDevelopmentConfig(config, platform, { root });
91
95
  const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
92
96
  assertPrimaryReleaseCheckout(repoRoot, env);
@@ -94,6 +98,12 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
94
98
  assertReleaseExecutionAllowed({ cwd: repoRoot, environment: env, command: `right-release dev --platform ${platform}` });
95
99
  // Refuse an invalid or off-CI ad hoc opt-in before the (long) build, not after it.
96
100
  if (!buildOnly && platform === "mac" && target.install?.kind === "tauri") requestedCiAdhocSigning({ descriptor: target.install, env });
101
+ // iOS: the device or simulator is resolved (and a device proven ready) before the build, and a device build needs the GUI signing session.
102
+ let iosPrepared = null;
103
+ if (platform === "ios") {
104
+ assertIosDevelopmentSession(target, { env, run });
105
+ iosPrepared = prepareIosDevelopment(target, { root, env, run, serial });
106
+ }
97
107
  // Android: the device serial is required, so a missing one is refused before the (long) build too.
98
108
  if (!buildOnly && platform === "android" && target.install) androidDevelopmentSerial({ descriptor: target.install, serial, env });
99
109
  const childEnv = codesignEnvironment(developmentEnvironment(env));
@@ -104,12 +114,13 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
104
114
  const startedMs = Date.now();
105
115
  // Android: cargo-ndk (Cargo dev profile) fills the generated jniLibs, then the Gradle debug build packages it.
106
116
  if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, label: "native build" }); }
107
- for (const command of target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
117
+ for (const command of iosPrepared ? iosPrepared.build : target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
108
118
  // Dev and QA binaries run from one stable staged path, copied in place (no backup of the old copy), never from the
109
119
  // broker's per-generation target folder or a hashed dev-targets folder.
110
120
  const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
111
121
  for (const file of staged) console.log(`right-release dev: staged ${file}`);
112
122
  if (buildOnly || !target.install) return { built: true, installed: false, ...(staged.length ? { staged } : {}) };
123
+ if (iosPrepared) return { built: true, installed: true, ios: installIosDevelopment(iosPrepared.install, { root, env: childEnv, run }) };
113
124
  if (target.install.kind === TAURI_DEVID_KIND) {
114
125
  // installTauriDevId is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
115
126
  const built = resolveTauriArtifact(target.install, { root, platform, env: childEnv });
@@ -124,7 +135,7 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
124
135
  }
125
136
 
126
137
  export function assertNativeDevelopmentHost(platform, host = process.platform) {
127
- if ((platform === "win" && host !== "win32") || (platform === "mac" && host !== "darwin")) throw new Error(`development target ${platform} requires its native host`);
138
+ if ((platform === "win" && host !== "win32") || ((platform === "mac" || platform === "ios") && host !== "darwin")) throw new Error(`development target ${platform} requires its native host`);
128
139
  }
129
140
 
130
141
  function install(descriptor, { root, platform, config, env, run, sinceMs = null, serial }) {
@@ -338,7 +349,7 @@ function inside(root, child) { const rel = path.relative(path.resolve(root), pat
338
349
  export async function main(args = process.argv.slice(2)) {
339
350
  let platform, configPath = "right-release.config.mjs", dryRun = false, buildOnly = false, serial;
340
351
  for (let i = 0; i < args.length; i++) {
341
- if (args[i] === "-h" || args[i] === "--help") { console.log("usage: right-release dev --platform win|mac|android [--config right-release.config.mjs] [--dry-run] [--build-only] [--serial <adb serial>]"); return; }
352
+ if (args[i] === "-h" || args[i] === "--help") { console.log("usage: right-release dev --platform win|mac|android|ios [--config right-release.config.mjs] [--dry-run] [--build-only] [--serial <adb serial | iOS device id>]"); return; }
342
353
  if (args[i] === "--platform") platform = args[++i];
343
354
  else if (args[i] === "--config") configPath = args[++i];
344
355
  else if (args[i] === "--dry-run") dryRun = true;
@@ -346,7 +357,7 @@ export async function main(args = process.argv.slice(2)) {
346
357
  else if (args[i] === "--serial") serial = args[++i];
347
358
  else throw new Error(`unknown dev option: ${args[i]}`);
348
359
  }
349
- if (!platform) throw new Error("right-release dev requires --platform win|mac|android");
360
+ if (!platform) throw new Error("right-release dev requires --platform win|mac|android|ios");
350
361
  const absoluteConfig = path.resolve(configPath);
351
362
  const safeEnv = developmentEnvironment(process.env);
352
363
  for (const [key, value] of Object.entries(safeEnv)) process.env[key] = value;
@@ -289,7 +289,7 @@ export function renderPowerShellBootstrap({
289
289
  "$AcceptedSigners = @{}; $Contract.acceptedManifestSigners.PSObject.Properties | ForEach-Object { $AcceptedSigners[$_.Name] = $_.Value }",
290
290
  "$InstallRoot = Join-Path $env:LOCALAPPDATA " + psQuote(installRootSubdir),
291
291
  // In place at one fixed path: no versions/ folder, no per-version, per-digest or per-run directory, no junction.
292
- // `current` is the real install; `current.staging` is written and verified but never run; `current.previous` is the one rollback copy.
292
+ // `current` is the real install; `current.staging` is written and verified but never run; `current.previous` is a transient park, deleted on success.
293
293
  "$Current = Join-Path $InstallRoot 'current'",
294
294
  "$Staging = Join-Path $InstallRoot 'current.staging'",
295
295
  "$Previous = Join-Path $InstallRoot 'current.previous'",
@@ -436,7 +436,6 @@ function Invoke-BoundedExe($Path, [string[]]$Arguments, [int]$TimeoutSeconds) {
436
436
  "$PriorCliCapable = $true; if ($Contract.priorExecutableMinVersion) { $PriorCliCapable = ($null -ne $PriorVersion -and ([version]$PriorVersion -ge [version]$Contract.priorExecutableMinVersion)) }",
437
437
  "$PriorStatus = $null; if ($PriorTarget -and $PriorCliCapable) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); try { $PriorStatus = ((Invoke-BoundedExe $PriorExe $PriorStatusArgs 60) | Out-String).Trim() } catch {} }",
438
438
  "@{ schemaVersion=1; priorTarget=$PriorTarget; priorVersion=$PriorVersion; targetVersion=$Version; priorStatus=$PriorStatus } | ConvertTo-Json -Depth 8 | Set-Content -Encoding UTF8 $Journal",
439
- "$PriorWasJunction = $false; $CurrentItem = Get-Item -LiteralPath $Current -Force -ErrorAction SilentlyContinue; if ($CurrentItem -and $CurrentItem.LinkType -eq 'Junction') { $PriorWasJunction = $true }",
440
439
  "Remove-Retired $Previous",
441
440
  "$Switched = $false; $PriorDetached = $false",
442
441
  "try {",
@@ -449,10 +448,10 @@ function Invoke-BoundedExe($Path, [string[]]$Arguments, [int]$TimeoutSeconds) {
449
448
  " $HealthText = ((Invoke-BoundedExe $StableExe $StatusArgs 120) | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Health command failed' }; $Health = $HealthText | ConvertFrom-Json",
450
449
  " Assert-Health $Health $Version",
451
450
  " $StableBin = Split-Path $StableExe -Parent; $InstallRootResolved = [IO.Path]::GetFullPath($InstallRoot).TrimEnd([IO.Path]::DirectorySeparatorChar); $UserPath = [Environment]::GetEnvironmentVariable('Path','User'); $Kept = @($UserPath -split ';' | Where-Object { $Entry = $_.Trim(); $Entry -ne '' -and -not ($Entry.TrimEnd([IO.Path]::DirectorySeparatorChar).StartsWith(($InstallRootResolved + [IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase) -and -not $Entry.TrimEnd([IO.Path]::DirectorySeparatorChar).Equals($StableBin.TrimEnd([IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase)) }); $NewPath = ((@($StableBin) + $Kept) -join ';'); if ($NewPath -ne $UserPath) { [Environment]::SetEnvironmentVariable('Path', $NewPath, 'User') }",
452
- // Legacy cleanup only: the old layout's versions/ folder and its pointer junctions are removed once nothing needs them
453
- // (a legacy `current` junction that was just moved to current.previous keeps its versions/ target until the next install).
454
- " $LegacyVersions = Join-Path $InstallRoot 'versions'; if (-not $PriorWasJunction -and (Test-Path -LiteralPath $LegacyVersions)) { try { Remove-Item -LiteralPath $LegacyVersions -Recurse -Force -ErrorAction SilentlyContinue } catch { } }; foreach ($Legacy in @('.current-previous', '.current-next')) { try { Remove-Retired (Join-Path $InstallRoot $Legacy) } catch { } }",
455
- " Remove-Item -Force $Journal; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
451
+ // Fixed-path install only (D01): no versioned folders, pointer junctions or legacy-layout cleanup in the
452
+ // generated script. A pre-existing legacy layout under $InstallRoot is left untouched, never deleted whole.
453
+ // No retained rollback copy (D01): $Previous is a transient park, deleted once health passes.
454
+ " Remove-Retired $Previous; Remove-Item -Force $Journal; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
456
455
  "} catch {",
457
456
  " $InstallError = $_; $RollbackErrors = New-Object 'System.Collections.Generic.List[string]'",
458
457
  " try { if ($Switched) { Remove-Retired $Current }; if ($PriorDetached -and (Test-Path -LiteralPath $Previous)) { Rename-Item -LiteralPath $Previous -NewName (Split-Path -Leaf $Current) } } catch { $RollbackErrors.Add($_.Exception.Message) }",
package/disk-command.mjs CHANGED
@@ -580,6 +580,7 @@ export async function runDiskCommand(args = process.argv.slice(2), { env = proce
580
580
  if (!self.ok) {
581
581
  for (const problem of self.problems) stdout(`ERROR: disk run self-check: ${problem}`);
582
582
  logEvent(logFile, { event: "disk.run.selfcheck.fail", problems: self.problems, version: self.version });
583
+ return { exitCode: 2, selfCheck: self };
583
584
  }
584
585
  ensureScratchRoots({ policy, platform, env });
585
586
  const before = check();
package/disk-policy.json CHANGED
@@ -33,13 +33,14 @@
33
33
  {"id": "cache-parakeet", "platform": "mac", "path": "~/Library/Caches/parakeet_*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
34
34
  {"id": "cache-swiftright-factory", "platform": "mac", "path": "~/Library/Caches/SwiftRightFactory", "capBytes": 10737418240, "action": "sweep", "unit": "children"},
35
35
  {"id": "cache-cutright-engine", "platform": "mac", "path": "~/Library/Caches/cutright-engine", "capBytes": 2147483648, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
36
+ {"id": "cache-cutright-app", "platform": "mac", "path": "~/Library/Caches/app.orthiclabs.cutright", "capBytes": 2147483648, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
36
37
  {"id": "cache-heardright-engine", "platform": "mac", "path": "~/Library/Caches/heardright-engine", "capBytes": 2147483648, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
37
38
  {"id": "cache-pnpm", "platform": "mac", "path": "~/Library/Caches/pnpm", "capBytes": 3221225472, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
38
39
  {"id": "cache-python", "platform": "mac", "path": "~/Library/Caches/python", "capBytes": 3221225472, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
39
40
  {"id": "xcode-deriveddata", "platform": "mac", "path": "~/Library/Developer/Xcode/DerivedData", "capBytes": 10737418240, "action": "sweep", "unit": "children"},
40
41
  {"id": "coresimulator", "platform": "mac", "path": "~/Library/Developer/CoreSimulator", "capBytes": 26843545600, "action": "report"},
41
42
  {"id": "documents-scratch", "platform": "mac", "path": "~/Documents", "capBytes": 21474836480, "action": "report"},
42
- {"id": "shared-models", "platform": "mac", "path": "/Volumes/D/Orthic Labs/shared/models", "capBytes": 107374182400, "action": "report"},
43
+ {"id": "shared-models", "platform": "mac", "path": "~/Library/Application Support/Orthic Labs/shared/models", "capBytes": 107374182400, "action": "report"},
43
44
  {"id": "stale-release-tarballs", "platform": "mac", "path": "/Volumes/D/claude/rightkit/packages/release/rightkit-release-*.tgz", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
44
45
  {"id": "win-temp", "platform": "win", "path": "${TEMP}", "capBytes": 10737418240, "action": "sweep", "unit": "children"},
45
46
  {"id": "win-pnpm", "platform": "win", "path": "${LOCALAPPDATA}\\pnpm", "capBytes": 21474836480, "action": "report"},
@@ -61,7 +62,10 @@
61
62
  {"id": "leftover-legion-final", "platform": "mac", "path": "/Volumes/D/claude/legion-final-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
62
63
  {"id": "leftover-pulse-bak", "platform": "win", "path": "${LOCALAPPDATA}\\Programs\\Pulse.bak-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
63
64
  {"id": "qa-evidence-runs", "platform": "mac", "path": "/Volumes/D/claude/*/.cache/rightkit-qa/*", "capBytes": 5368709120, "action": "sweep", "unit": "self", "staleDays": 3, "skipTracked": true},
64
- {"id": "qa-evidence-runs", "platform": "win", "path": "D:\\claude\\*\\.cache\\rightkit-qa\\*", "capBytes": 5368709120, "action": "sweep", "unit": "self", "staleDays": 3, "skipTracked": true}
65
+ {"id": "qa-evidence-runs", "platform": "win", "path": "D:\\claude\\*\\.cache\\rightkit-qa\\*", "capBytes": 5368709120, "action": "sweep", "unit": "self", "staleDays": 3, "skipTracked": true},
66
+ {"id": "build-control-targets", "platform": "mac", "path": "/Volumes/D/.rightkit-managed/rightkit-build-control", "capBytes": 429496729600, "action": "report"},
67
+ {"id": "scratch-total", "platform": "mac", "path": "/Volumes/D/scratch", "capBytes": 322122547200, "action": "report"},
68
+ {"id": "claude-tmp", "platform": "mac", "path": "/Volumes/D/claude/tmp", "capBytes": 53687091200, "action": "report"}
65
69
  ],
66
70
  "protectedSegments": ["Application Support", "Keychains", "Keychain", "HeardRightNext", "Credentials", ".ssh", ".rightkit-managed", ".rightkit-swept"]
67
71
  }
@@ -64,9 +64,10 @@ export function collectDependencies(pkgRoot) {
64
64
  return [...found].map(([name, dir]) => ({ name, dir }));
65
65
  }
66
66
 
67
- /** Copy the installed @rightkit/release package (and its dependencies) into the stable root. The new tree is built and
68
- * built in the fixed `current.staging`, swapped in by rename (the old copy stays as `current.previous`), then smoke-tested at
69
- * `current`; a failed swap or smoke run renames `current.previous` back. Returns `{ stable, cli, version, replaced, unchanged }`. */
67
+ /** Copy the installed @rightkit/release package (and its dependencies) into the stable root. The new tree is built in
68
+ * the fixed `current.staging`, swapped in by rename (the old copy is parked at `current.previous` only until the
69
+ * smoke test at `current` passes, then deleted; a failed swap or smoke run renames it back). No copy is retained.
70
+ * Returns `{ stable, cli, version, replaced, unchanged }`. */
70
71
  export function installStableRoot({ cli, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), nodePath = process.execPath, smoke = true } = {}) {
71
72
  const stable = stableRootPath({ platform, env, home });
72
73
  const stableCli = stableCliPath(stable, platform);
@@ -107,10 +108,10 @@ export function installStableRoot({ cli, platform = process.platform, env = proc
107
108
  throw error;
108
109
  }
109
110
  // Swap first, then smoke-run at the fixed `current` path (never from the staging path). A failed run renames
110
- // `current.previous` back. Exactly one `.previous` is kept.
111
- let replaced = false;
111
+ // the transiently parked `current.previous` back; a passed run deletes it (nothing is retained).
112
+ const replaced = existsSync(stable);
112
113
  try {
113
- const result = swapInPlace({
114
+ swapInPlace({
114
115
  staging, destination: stable, previous,
115
116
  verify: () => {
116
117
  if (!smoke) return;
@@ -118,7 +119,6 @@ export function installStableRoot({ cli, platform = process.platform, env = proc
118
119
  if (r.status !== 0) throw new Error(`the installed copy does not run: ${r.stderr || r.stdout || r.error?.message}`);
119
120
  },
120
121
  });
121
- replaced = result.previous !== null;
122
122
  } catch (error) {
123
123
  throw new Error(`disk: could not replace ${stable}: ${error.message}`);
124
124
  }
@@ -130,8 +130,12 @@ export function readScheduleBinding({ platform = process.platform, home = os.hom
130
130
  if (platform === "darwin") {
131
131
  const plist = plistFile ?? file ?? path.join(home, "Library", "LaunchAgents", "dev.rightkit.disk-hygiene.plist");
132
132
  if (!existsSync(plist)) return null;
133
- const m = /<key>ProgramArguments<\/key><array>\s*<string>([^<]*)<\/string>\s*<string>([^<]*)<\/string>/.exec(readFileSync(plist, "utf8"));
134
- return m ? { node: m[1], cli: m[2], source: plist } : { node: null, cli: null, source: plist };
133
+ // Parse the plist natively: launchd/plutil formatting and XML escaping must not change the binding.
134
+ const result = run("/usr/bin/plutil", ["-extract", "ProgramArguments", "json", "-o", "-", "--", plist], { encoding: "utf8" });
135
+ let args;
136
+ try { if (result.status === 0) args = JSON.parse(String(result.stdout)); } catch { /* malformed binding fails evaluation below */ }
137
+ const valid = Array.isArray(args) && typeof args[0] === "string" && typeof args[1] === "string";
138
+ return valid ? { node: args[0], cli: args[1], source: plist } : { node: null, cli: null, source: plist };
135
139
  }
136
140
  const r = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `$t = Get-ScheduledTask -TaskName '${label}' -ErrorAction SilentlyContinue; if (-not $t) { exit 3 }; $a = $t.Actions[0]; Write-Output $a.Execute; Write-Output $a.Arguments`], { encoding: "utf8" });
137
141
  if (r.status !== 0) return null;
@@ -1,11 +1,12 @@
1
1
  // GitHub Releases lane of `right-release publish-update` (Orthic-Labs/rightkit#237).
2
2
  //
3
3
  // The updater artifacts and their minisign signatures are the ones the r2-rightapps lane already uses: they are
4
- // signed once by `sign-updater.mjs` with the suite updater key (the same TAURI_SIGNING_PRIVATE_KEY / key file as
5
- // every other app), sealed, and re-verified by `verifyPublicationRelease`. This module only turns the same
4
+ // signed once by `sign-updater.mjs` with the suite updater key (loaded from the OS credential store, never a
5
+ // key file), sealed, and re-verified by `verifyPublicationRelease`. This module only turns the same
6
6
  // `platforms` map the r2 lane builds ({ <platform>: { signature, url } }) into a Tauri v2 static `latest.json`
7
7
  // and uploads it, with the bundles and their `.sig` files, as assets of the GitHub release `v<version>`, so
8
8
  // `https://github.com/<owner>/<repo>/releases/latest/download/latest.json` resolves. It adds no key and no trust root.
9
+ import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
9
10
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
11
  import path from "node:path";
11
12
  import { PublicationError, digestBytes, readVerifiedArtifact, verifyArtifactFile } from "./publication-artifacts.mjs";
@@ -58,13 +59,75 @@ export function githubLatestJsonUrl(repo) {
58
59
  * [2-byte algorithm][8-byte key id, little endian][64-byte signature].
59
60
  */
60
61
  export function updaterSignatureKeyId(signatureText) {
62
+ return parseMinisignSignature(signatureText)?.keyId ?? null;
63
+ }
64
+
65
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
66
+
67
+ function parseMinisignSignature(signatureText) {
68
+ try {
69
+ const decode = (text) => {
70
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(text)) throw new Error("invalid base64");
71
+ const bytes = Buffer.from(text, "base64");
72
+ if (bytes.toString("base64") !== text) throw new Error("invalid base64");
73
+ return bytes;
74
+ };
75
+ const envelope = decode(String(signatureText).trim());
76
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(envelope);
77
+ const lines = text.split(/\r?\n/);
78
+ if (lines.at(-1) === "") lines.pop();
79
+ if (lines.length !== 4 || !lines[2].startsWith("trusted comment: ")) return null;
80
+ const raw = decode(lines[1]);
81
+ const globalSignature = decode(lines[3]);
82
+ if (globalSignature.length !== 64) return null;
83
+ if (raw.length !== 74) return null;
84
+ const algorithm = raw.subarray(0, 2).toString("latin1");
85
+ if (algorithm !== "Ed" && algorithm !== "ED") return null;
86
+ return { algorithm, keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString("hex").toUpperCase(), signature: raw.subarray(10), globalSignature, trustedComment: Buffer.from(lines[2].slice(17), "utf8") };
87
+ } catch { return null; }
88
+ }
89
+
90
+ function parseMinisignPublicKey(publicKeyText) {
91
+ const text = String(publicKeyText ?? "").trim();
61
92
  try {
62
- const lines = Buffer.from(String(signatureText).trim(), "base64").toString("utf8").split(/\r?\n/);
93
+ const lines = Buffer.from(text, "base64").toString("utf8").split(/\r?\n/);
63
94
  const raw = Buffer.from(lines[1] ?? "", "base64");
64
- if (raw.length !== 74 || raw[0] !== 0x45 || (raw[1] !== 0x64 && raw[1] !== 0x44)) return null;
65
- return Buffer.from(raw.subarray(2, 10)).reverse().toString("hex").toUpperCase();
66
- } catch {
67
- return null;
95
+ if (raw.length === 42 && ["Ed", "ED"].includes(raw.subarray(0, 2).toString("latin1"))) {
96
+ return { keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString("hex").toUpperCase(), key: raw.subarray(10) };
97
+ }
98
+ } catch { /* fall through to raw forms */ }
99
+ try {
100
+ const raw = Buffer.from(text, "base64");
101
+ if (raw.length === 42 && ["Ed", "ED"].includes(raw.subarray(0, 2).toString("latin1"))) return { keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString("hex").toUpperCase(), key: raw.subarray(10) };
102
+ if (raw.length === 32) return { keyId: null, key: raw };
103
+ } catch { /* invalid below */ }
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Cryptographic minisign (Ed25519) verification over the EXACT artifact bytes. `Ed` signatures
109
+ * cover the bytes directly; `ED` signatures cover their BLAKE2b-512 prehash. Returns false (never
110
+ * throws) for any malformed signature/key, key-id mismatch, or bad signature.
111
+ */
112
+ export function verifyMinisignSignature(bytes, signatureText, publicKeyText) {
113
+ const sig = parseMinisignSignature(signatureText);
114
+ const pub = parseMinisignPublicKey(publicKeyText);
115
+ if (!sig || !pub || (pub.keyId && pub.keyId !== sig.keyId)) return false;
116
+ try {
117
+ const data = sig.algorithm === "ED" ? createHash("blake2b512").update(bytes).digest() : Buffer.from(bytes);
118
+ const key = createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, pub.key]), format: "der", type: "spki" });
119
+ return cryptoVerify(null, data, key, sig.signature)
120
+ && cryptoVerify(null, Buffer.concat([sig.signature, sig.trustedComment]), key, sig.globalSignature);
121
+ } catch { return false; }
122
+ }
123
+
124
+ /**
125
+ * Require a cryptographically valid minisign signature over `bytes` under the declared public key.
126
+ * The key-id check (`assertUpdaterKeyId`) is supplementary only; this is the seal/upload gate.
127
+ */
128
+ export function assertUpdaterSignature(bytes, signatureText, publicKeyText, label = "updater signature") {
129
+ if (!verifyMinisignSignature(bytes, signatureText, publicKeyText)) {
130
+ throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", `${label} does not cryptographically verify over the exact artifact bytes under the declared updater public key`);
68
131
  }
69
132
  }
70
133
 
@@ -4,10 +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. 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.
7
+ * copy at ONE fixed path. NO install keeps a retained `<name>.previous` copy (D01/#771): a `previous` path passed
8
+ * to `swapInPlace` is only a TRANSIENT park for the old copy while the new one is verified; it is deleted on
9
+ * success and, on failure, the parked copy is renamed back and the park path is gone. Nothing ever runs from
10
+ * `.previous` or from the staging copy. A stale `<destination>.previous` left by an older RightKit is removed.
11
11
  */
12
12
  export const PREVIOUS_SUFFIX = ".previous";
13
13
  export const STAGING_SUFFIX = ".staging";
@@ -24,16 +24,16 @@ const exists = (fs, file) => {
24
24
  /**
25
25
  * Swap `staging` into `destination`, then `verify(destination)` runs.
26
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.
29
- * @returns {{destination: string, previous: string|null}} previous is null when nothing was installed before.
27
+ * With a `previous` path: the old destination is parked there ONLY until verification passes, then deleted
28
+ * (never retained as a rollback copy). Any failure removes the new copy and renames the parked copy back,
29
+ * so the fixed path is never left empty and no `.previous` remains either way. Renames only.
30
+ * @returns {{destination: string, previous: null}} previous is always null: no copy is retained.
30
31
  *
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.
32
+ * With `previous: null` (NO park of any kind): the old destination is removed and `staging` is renamed
33
+ * over the fixed path. Any failure removes the new (absent or partial) copy and the staging copy and
34
+ * rethrows; the old copy is NOT restored, the user reinstalls from an older build.
35
35
  */
36
- export function swapInPlace({ staging, destination, previous = previousPathOf(destination), verify = () => {}, fs = nodeFs }) {
36
+ export function swapInPlace({ staging, destination, previous = null, verify = () => {}, fs = nodeFs }) {
37
37
  if (previous === null) return replaceWithoutBackup({ staging, destination, verify, fs });
38
38
  if (previous === destination || staging === destination || staging === previous) throw new Error("swapInPlace needs three distinct paths");
39
39
  let movedAside = false;
@@ -51,7 +51,10 @@ export function swapInPlace({ staging, destination, previous = previousPathOf(de
51
51
  try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
52
52
  throw error;
53
53
  }
54
- return { destination, previous: movedAside ? previous : null };
54
+ // Verified: the transient park (and any stale retained copy) is deleted, never kept.
55
+ try { if (movedAside) fs.rmSync(previous, { recursive: true, force: true }); } catch { /* best effort */ }
56
+ try { fs.rmSync(previousPathOf(destination), { recursive: true, force: true }); } catch { /* best effort */ }
57
+ return { destination, previous: null };
55
58
  }
56
59
 
57
60
  function replaceWithoutBackup({ staging, destination, verify, fs }) {
@@ -65,7 +68,7 @@ function replaceWithoutBackup({ staging, destination, verify, fs }) {
65
68
  try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
66
69
  throw error;
67
70
  }
68
- // No old copy may survive a dev install, including one an older RightKit left behind.
71
+ // No old copy may survive an install, including one an older RightKit left behind.
69
72
  try { fs.rmSync(previousPathOf(destination), { recursive: true, force: true }); } catch { /* best effort */ }
70
73
  return { destination, previous: null };
71
74
  }