@rightkit/release 0.2.174 → 0.4.0

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 (82) hide show
  1. package/README.md +200 -2
  2. package/addon-command.mjs +3 -4
  3. package/android-play-upload.mjs +2 -1
  4. package/apple-profile-renew.mjs +11 -3
  5. package/artifact-storage.mjs +35 -2
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +1 -1
  8. package/build-release.mjs +138 -47
  9. package/cli/right-release.mjs +40 -5
  10. package/codesign-environment.mjs +2 -2
  11. package/command-spec.mjs +80 -0
  12. package/content-digest.mjs +209 -0
  13. package/deps-cargo.mjs +55 -0
  14. package/deps.mjs +7 -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/dist-scan.mjs +102 -0
  21. package/dmg-build.mjs +53 -4
  22. package/github-updater-release.mjs +70 -7
  23. package/icon-pack.mjs +117 -0
  24. package/in-place-install.mjs +17 -14
  25. package/init-command.mjs +116 -0
  26. package/install-app-windows.mjs +24 -7
  27. package/install-app.mjs +56 -42
  28. package/ios-asc-credentials.mjs +163 -0
  29. package/ios-build-context.mjs +129 -0
  30. package/ios-build-upload.mjs +282 -0
  31. package/ios-credentials-command.mjs +70 -0
  32. package/ios-dev.mjs +248 -0
  33. package/ios-host.mjs +76 -0
  34. package/ios-lock.mjs +75 -0
  35. package/ios-package-recipe.mjs +707 -0
  36. package/ios-testflight.mjs +266 -0
  37. package/ios-upload.mjs +313 -0
  38. package/local-publish.mjs +390 -47
  39. package/macho-approval-hash.mjs +181 -0
  40. package/managed-cargo.mjs +57 -1
  41. package/media-sidecar.mjs +20 -4
  42. package/mirror-root-artifact.mjs +74 -22
  43. package/native-cargo-layout.mjs +47 -4
  44. package/npm-license-notices.mjs +159 -0
  45. package/ownership-command.mjs +2 -1
  46. package/ownership-dependency-allowlist.json +2 -1
  47. package/ownership-evidence.mjs +30 -18
  48. package/ownership-key-command.mjs +69 -2
  49. package/ownership-mandatory-set.mjs +66 -16
  50. package/ownership-repositories.json +1 -1
  51. package/ownership-scope.mjs +94 -0
  52. package/ownership.mjs +86 -11
  53. package/package-recipe-targets.mjs +56 -2
  54. package/package.json +1 -1
  55. package/pe-content.mjs +133 -0
  56. package/preflight.mjs +14 -7
  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 +83 -5
  61. package/publish-cargo.mjs +124 -18
  62. package/publish-update.mjs +15 -2
  63. package/qa-build-features.mjs +16 -13
  64. package/qa-contract.mjs +30 -2
  65. package/qa-control-scan.mjs +4 -0
  66. package/release-dependencies-command.mjs +45 -0
  67. package/release-dependencies.mjs +60 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +62 -24
  71. package/release.mjs +144 -21
  72. package/rightkit-ownership.json +46 -2
  73. package/rightkit-versions.json +380 -189
  74. package/runtime-budgets.mjs +134 -23
  75. package/sign-updater.mjs +56 -59
  76. package/source-gate.mjs +8 -1
  77. package/stage-sidecar.mjs +634 -69
  78. package/target-distribution.mjs +5 -0
  79. package/test-command.mjs +190 -0
  80. package/toolchain-swift.mjs +239 -0
  81. package/upload-release.mjs +7 -2
  82. package/web-build.mjs +67 -0
@@ -0,0 +1,80 @@
1
+ // Shared argv command shape for the declarative `tests` and `web` config sections (`right-release test`, `web-build`).
2
+ // A step is one command run without a shell:
3
+ // { cmd, args?, cwd?, env? } any executable ("node" means this Node binary)
4
+ // { node, args?, nodeFlags?, nodeOptions? } a Node script path inside the app, run by this Node binary
5
+ // `cwd` and `node` are app-relative and may not leave the app. `nodeFlags` go before the script, `nodeOptions` are merged
6
+ // (de-duplicated) into NODE_OPTIONS for the child. Unknown keys are refused so a typo cannot silently change a run.
7
+ import path from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ /** Load an app's `right-release.config.mjs`; the app root is the config file's directory. */
11
+ export async function loadAppConfig(configFile = "right-release.config.mjs") {
12
+ const configPath = path.resolve(configFile);
13
+ const loaded = await import(pathToFileURL(configPath).href);
14
+ return { config: loaded.default ?? loaded.config ?? loaded, appRoot: path.dirname(configPath), configPath };
15
+ }
16
+
17
+ export class CommandSpecError extends Error {
18
+ constructor(message) { super(message); this.name = "CommandSpecError"; this.code = "RK_COMMAND_SPEC"; }
19
+ }
20
+
21
+ const fail = (message) => { throw new CommandSpecError(message); };
22
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
23
+ export const STEP_KEYS = ["cmd", "node", "args", "cwd", "env", "nodeFlags", "nodeOptions"];
24
+
25
+ export function relativeInside(value, label) {
26
+ if (typeof value !== "string" || !value.trim()) fail(`${label} must be a non-empty relative path`);
27
+ const normalized = value.replaceAll("\\", "/");
28
+ if (path.isAbsolute(value) || /^[A-Za-z]:/.test(value) || normalized.split("/").includes("..")) fail(`${label} must be relative and stay inside the app: ${value}`);
29
+ return normalized;
30
+ }
31
+
32
+ const stringList = (value, label) => {
33
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) fail(`${label} must be a list of strings`);
34
+ return [...value];
35
+ };
36
+
37
+ /** Validate one step; `extraKeys` are accepted and left to the caller. Returns the step. */
38
+ export function validateStep(step, label, extraKeys = []) {
39
+ if (!isObject(step)) fail(`${label} must be an object`);
40
+ const allowed = new Set([...STEP_KEYS, ...extraKeys]);
41
+ const unknown = Object.keys(step).filter((key) => !allowed.has(key));
42
+ if (unknown.length) fail(`${label} has unknown key(s): ${unknown.join(", ")} (allowed: ${[...allowed].join(", ")})`);
43
+ if (step.cmd !== undefined && step.node !== undefined) fail(`${label} sets both cmd and node; use one`);
44
+ if (step.cmd !== undefined && (typeof step.cmd !== "string" || !step.cmd.trim())) fail(`${label}.cmd must be a non-empty string`);
45
+ if (step.node !== undefined) relativeInside(step.node, `${label}.node`);
46
+ if (step.cwd !== undefined) relativeInside(step.cwd, `${label}.cwd`);
47
+ if (step.args !== undefined) stringList(step.args, `${label}.args`);
48
+ if (step.nodeFlags !== undefined) stringList(step.nodeFlags, `${label}.nodeFlags`);
49
+ if (step.nodeOptions !== undefined) stringList(step.nodeOptions, `${label}.nodeOptions`);
50
+ if (step.env !== undefined && (!isObject(step.env) || Object.values(step.env).some((value) => typeof value !== "string"))) fail(`${label}.env must be an object of strings`);
51
+ return step;
52
+ }
53
+
54
+ /** Add flags to a NODE_OPTIONS string without repeating one already present. */
55
+ export function mergeNodeOptions(existing, flags) {
56
+ const present = (existing ?? "").trim().split(/\s+/).filter(Boolean);
57
+ for (const flag of flags) if (!present.includes(flag)) present.push(flag);
58
+ return present.join(" ");
59
+ }
60
+
61
+ /**
62
+ * Resolve a validated step to what to spawn: `{ cmd, args, cwd, env }`. `extraArgs` are appended after the step's own
63
+ * arguments (the `--` passthrough of `right-release test`). `cmd: "node"` and `node:` use `process.execPath`.
64
+ */
65
+ export function resolveInvocation(step, { appRoot, baseEnv = process.env, extraArgs = [] }) {
66
+ const args = [...(step.args ?? []), ...extraArgs];
67
+ let cmd;
68
+ let argv;
69
+ if (step.node !== undefined) {
70
+ cmd = process.execPath;
71
+ argv = [...(step.nodeFlags ?? []), path.resolve(appRoot, step.node), ...args];
72
+ } else {
73
+ if (typeof step.cmd !== "string" || !step.cmd.trim()) fail("a step needs cmd or node");
74
+ cmd = step.cmd === "node" ? process.execPath : step.cmd;
75
+ argv = [...(step.cmd === "node" ? (step.nodeFlags ?? []) : []), ...args];
76
+ }
77
+ const env = { ...baseEnv, ...(step.env ?? {}) };
78
+ if (step.nodeOptions?.length) env.NODE_OPTIONS = mergeNodeOptions(env.NODE_OPTIONS, step.nodeOptions);
79
+ return { cmd, args: argv, cwd: path.resolve(appRoot, step.cwd ?? "."), env };
80
+ }
@@ -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/deps-cargo.mjs ADDED
@@ -0,0 +1,55 @@
1
+ // `right-release deps --audit` for Cargo workdirs (Orthic-Labs/rightkit#674): replaces an app's own audit-rust script.
2
+ //
3
+ // deps: { workdirs: ["."], cargoWorkdirs: ["src-tauri"] } // app-relative folders that hold a Cargo.lock
4
+ //
5
+ // Declared only: an app with no `deps.cargoWorkdirs` audits no Rust, exactly as before. Each folder must be inside the app and
6
+ // hold a `Cargo.lock` (an unlocked workspace cannot be audited, so it is refused, never skipped). `cargo audit` runs through
7
+ // the managed cargo client (`runCargoSync`): on a managed host it goes through the RightKit broker, and a host with no
8
+ // broker or no `cargo-audit` fails closed with the reason; there is never a bare-cargo fallback on a managed Mac.
9
+ import { existsSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { runCargoSync } from "./managed-cargo.mjs";
12
+
13
+ const isInside = (root, target) => {
14
+ const relative = path.relative(root, target);
15
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
16
+ };
17
+
18
+ /** The validated Cargo workdirs `[{ rel, cwd }]` of a release config (empty when none are declared). Throws on a bad declaration. */
19
+ export function cargoAuditWorkdirs(config, root, { exists = existsSync } = {}) {
20
+ const declared = config?.deps?.cargoWorkdirs;
21
+ if (declared === undefined) return [];
22
+ if (!Array.isArray(declared) || declared.some((item) => typeof item !== "string" || !item.trim())) {
23
+ throw new Error("deps.cargoWorkdirs must be a list of app-relative folders");
24
+ }
25
+ const seen = new Set();
26
+ const workdirs = [];
27
+ for (const rel of declared) {
28
+ const normalized = rel.replaceAll("\\", "/");
29
+ if (path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel) || normalized.split("/").includes("..")) {
30
+ throw new Error(`deps.cargoWorkdirs entry must be relative and stay inside the app: ${rel}`);
31
+ }
32
+ const cwd = path.resolve(root, normalized);
33
+ if (!isInside(root, cwd)) throw new Error(`deps.cargoWorkdirs entry must stay inside the app: ${rel}`);
34
+ if (seen.has(cwd)) continue;
35
+ seen.add(cwd);
36
+ if (!exists(path.join(cwd, "Cargo.lock"))) throw new Error(`deps.cargoWorkdirs entry ${rel} has no Cargo.lock (${path.join(cwd, "Cargo.lock")}); an unlocked workspace cannot be audited`);
37
+ workdirs.push({ rel, cwd });
38
+ }
39
+ return workdirs;
40
+ }
41
+
42
+ /**
43
+ * Run `cargo audit` in each workdir and collect failures so every workdir reports. `run(args, options)` is the managed
44
+ * cargo runner (injectable). Returns `{ failures: string[] }`: one entry per workdir that could not start or exited non-zero.
45
+ */
46
+ export function runCargoAudits(workdirs, { run = runCargoSync, log = console.log, env = process.env } = {}) {
47
+ const failures = [];
48
+ for (const { rel, cwd } of workdirs) {
49
+ log(`\n> cargo audit (${cwd})`);
50
+ const result = run(["audit"], { cwd, env, stdio: "inherit" });
51
+ if (result?.error) failures.push(`${rel}: cargo audit could not start (${result.error.message})`);
52
+ else if (result?.status !== 0) failures.push(`${rel}: cargo audit exited ${result?.status ?? "without status"}`);
53
+ }
54
+ return { failures };
55
+ }
package/deps.mjs CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { spawnSync } from "./background-process.mjs";
5
5
  import { existsSync } from "node:fs";
6
+ import { cargoAuditWorkdirs, runCargoAudits } from "./deps-cargo.mjs";
6
7
 
7
8
  const args = process.argv.slice(2);
8
9
  const opts = {
@@ -55,6 +56,12 @@ for (const { rel, cwd } of resolvedWorkdirs) {
55
56
  run("pnpm", ["install", ...workspaceArgs, "--lockfile-only", "--ignore-scripts"], cwd, { ignoreScripts: true });
56
57
  }
57
58
  }
59
+ if (opts.mode === "audit") {
60
+ // Rust advisories (#674): every deps.cargoWorkdirs folder, through the managed cargo client; declared only.
61
+ let cargoWorkdirs;
62
+ try { cargoWorkdirs = cargoAuditWorkdirs(config, root); } catch (error) { fail(error.message); }
63
+ failures.push(...runCargoAudits(cargoWorkdirs).failures);
64
+ }
58
65
  if (failures.length > 0) {
59
66
  for (const failure of failures) console.error(`right-deps: ${failure}`);
60
67
  fail(`${failures.length} dependency workdir(s) failed`);
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;