@rightkit/release 0.6.8 → 0.6.10

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 (52) hide show
  1. package/README.md +25 -9
  2. package/admission-files.mjs +1 -1
  3. package/app-check.mjs +94 -20
  4. package/assemble-app-command.mjs +151 -0
  5. package/assembled-app.mjs +445 -0
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +6 -0
  8. package/bin/rightkit-gate.mjs +928 -82
  9. package/build-launch.mjs +88 -0
  10. package/build-ownership.mjs +1231 -0
  11. package/build-release.mjs +2 -1
  12. package/cargo-contract.mjs +3 -1
  13. package/cargo-target-shim.mjs +166 -0
  14. package/class-a-launch-guard.mjs +126 -0
  15. package/cli/right-release.mjs +3 -1
  16. package/codesign-shim.mjs +11 -1
  17. package/deterministic-scans.mjs +807 -56
  18. package/development-signing.mjs +4 -1
  19. package/development.mjs +110 -20
  20. package/door-policy.mjs +61 -6
  21. package/gate-executor.mjs +14 -2
  22. package/gate-stages.mjs +15 -5
  23. package/heavy-command.mjs +3 -2
  24. package/install-app.mjs +11 -2
  25. package/install-assembled-app.mjs +423 -0
  26. package/ios-dev.mjs +70 -3
  27. package/linux-artifacts.mjs +93 -0
  28. package/local-publish.mjs +161 -26
  29. package/managed-cargo.mjs +47 -5
  30. package/managed-root-guard.mjs +34 -0
  31. package/native-helpers.mjs +355 -0
  32. package/package-recipe-targets.mjs +52 -7
  33. package/package.json +4 -3
  34. package/pipeline-receipts.mjs +302 -302
  35. package/project-type.mjs +2 -2
  36. package/provenance-verify.mjs +115 -0
  37. package/publish-cargo.mjs +8 -6
  38. package/qa-admission-policy.json +10 -1
  39. package/recipe-build.mjs +73 -5
  40. package/release-config.mjs +1 -1
  41. package/release.mjs +15 -5
  42. package/rightkit-versions.json +13 -7
  43. package/snapshot-check.mjs +306 -0
  44. package/snapshot-git.mjs +166 -0
  45. package/snapshot-request.mjs +161 -0
  46. package/snapshot-root-config.mjs +76 -0
  47. package/snapshot.mjs +856 -0
  48. package/stage-commands.mjs +60 -0
  49. package/stage-sidecar.mjs +57 -7
  50. package/test-command.mjs +17 -4
  51. package/unit-gate.mjs +259 -0
  52. package/web-build.mjs +38 -6
@@ -1,4 +1,5 @@
1
1
  import { createHash, X509Certificate } from "node:crypto";
2
+ import { nativeHelperEntitlementMap, nativeHelpersFromConfig } from "./native-helpers.mjs";
2
3
  import { closeSync, copyFileSync, existsSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
@@ -192,7 +193,9 @@ export function readDevSigningSettings(descriptor = {}, config = {}, { root, exi
192
193
  };
193
194
  const signMap = typeof sign.entitlements === "object" && sign.entitlements !== null && !Array.isArray(sign.entitlements) ? sign.entitlements : undefined;
194
195
  const entitlements = [...compileBundlePathMap(descriptor.entitlements, "install.entitlements", { reject: invalidConfig, value: plistPath }),
195
- ...compileBundlePathMap(signMap, "sign.entitlements", { reject: invalidConfig, value: plistPath })];
196
+ ...compileBundlePathMap(signMap, "sign.entitlements", { reject: invalidConfig, value: plistPath }),
197
+ // Native helpers embedded in the dev .app carry their own entitlements, exactly as the release lane signs them.
198
+ ...compileBundlePathMap((() => { const h = nativeHelpersFromConfig(config); return h ? nativeHelperEntitlementMap(h) : undefined; })(), "macPackage.nativeHelpers", { reject: invalidConfig, value: plistPath })];
196
199
  if (descriptor.timestamp !== undefined && typeof descriptor.timestamp !== "boolean") invalidConfig("install.timestamp must be true or false");
197
200
  if (descriptor.assertTeamSignedNested !== undefined && typeof descriptor.assertTeamSignedNested !== "boolean") invalidConfig("install.assertTeamSignedNested must be true or false");
198
201
  const rawAssertions = descriptor.assert ?? [];
package/development.mjs CHANGED
@@ -2,12 +2,13 @@ import { discoverBrokerSocket, resolveManagedRoots } from './managed-roots.mjs';
2
2
  import { spawnSync } from "./background-process.mjs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, lstatSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
5
+ import { buildLaunch } from "./build-launch.mjs";
5
6
  import path from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
8
9
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
9
10
  import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
10
- import { acquireManagedRunLease, managedRuntimeClient, managedRuntimeCommand, runCargoSync } from "./managed-cargo.mjs";
11
+ import { acquireManagedRunLease, directCargoEnv, directPlatform, managedRuntimeClient, managedRuntimeCommand, runCargoSync } from "./managed-cargo.mjs";
11
12
  import { BUNDLE_MARKERS, bundleMarkerBuffer, readTauriBundleMarkers } from "./tauri-bundle-marker.mjs";
12
13
  import { installPortableDevelopment } from "./portable-development.mjs";
13
14
  import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.mjs";
@@ -15,7 +16,10 @@ import { stageDevelopmentBinaries, validateStageEntries } from "./stage-binary.m
15
16
  import { assertStableDesignatedRequirement, readBundleExecutable, requestedCiAdhocSigning, signMacosDevelopmentBundle } from "./development-signing.mjs";
16
17
  import { macosCodesignEnvironment } from "./codesign-environment.mjs";
17
18
  import { installTauriDevId, resolveTauriDevIdDescriptor, TAURI_DEVID_KIND } from "./install-tauri-devid.mjs";
18
- import { assertIosDevelopmentSession, installIosDevelopment, prepareIosDevelopment, validateIosDevelopment } from "./ios-dev.mjs";
19
+ import { ASSEMBLED_APP_KIND, installAssembledMac, installAssembledWindows, resolveAssembledInstall } from "./install-assembled-app.mjs";
20
+ import { assertIosDevelopmentSession, installIosDevelopment, iosUiTestCommands, prepareIosDevelopment, prepareIosUiTests, validateIosDevelopment } from "./ios-dev.mjs";
21
+ import { packLinuxArtifacts, validateLinuxArchive } from "./linux-artifacts.mjs";
22
+ import { buildAndEmbedNativeHelpers, nativeHelpersFromConfig } from "./native-helpers.mjs";
19
23
  import { androidDevelopmentNdkCommand, androidDevelopmentSerial, installAndroidDevelopment, validateAndroidDevelopment } from "./android-dev.mjs";
20
24
 
21
25
  // One standard Cargo profile for every app (templates/cargo-profiles.toml): Cargo's built-in `dev`.
@@ -30,9 +34,9 @@ export function cargoProfileDirectory(profile) {
30
34
  }
31
35
  const RELEASE_ONLY_APPLE_ENV = ["APPLE_ID", "APPLE_PASSWORD", "APPLE_API_KEY", "APPLE_API_ISSUER", "APPLE_API_KEY_PATH", "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD"];
32
36
 
33
- export function developmentEnvironment(env = process.env) {
37
+ export function developmentEnvironment(env = process.env, platform = process.platform) {
34
38
  const result = {
35
- ...env,
39
+ ...(directPlatform(platform) ? directCargoEnv(env, platform) : env),
36
40
  RIGHTKIT_BUILD_MODE: "dev",
37
41
  RIGHTKIT_CARGO_PROFILE: DEVELOPMENT_PROFILE,
38
42
  TAURI_SIGNING_PRIVATE_KEY: "",
@@ -79,22 +83,36 @@ export function validateDevelopmentConfig(config, platform, { root = process.cwd
79
83
  if (platform !== "mac") throw new Error(`the ${TAURI_DEVID_KIND} development installer is for --platform mac`);
80
84
  resolveTauriDevIdDescriptor(target.install, { appRoot: root });
81
85
  if (!target.install.targetTriple && !target.install.bundleDirectory) throw new Error(`development.targets.${platform}.install requires targetTriple (or bundleDirectory) to find the built .app`);
86
+ } else if (target.install?.kind === ASSEMBLED_APP_KIND) {
87
+ if (platform !== "mac" && platform !== "win") throw new Error(`the ${ASSEMBLED_APP_KIND} development installer is for --platform mac or win`);
88
+ // The assembly (assembly.<platform>) and the install keys are validated together, before any build.
89
+ resolveAssembledInstall({ config, platform });
82
90
  } else if (target.install?.kind === "adb") {
83
91
  if (platform !== "android") throw new Error("the adb development installer is for --platform android");
84
92
  } else if (target.install) throw new Error(`unsupported development installer kind: ${target.install.kind}`);
93
+ // Linux is build-only: its artifacts are the staged sidecars bound in the stage receipt; nothing is installed on a Linux host.
94
+ if (platform === "linux" && target.install) throw new Error("development.targets.linux is build-only: it takes no install (the staged artifacts are the result)");
95
+ if (target.archive !== undefined && platform !== "linux") throw new Error(`development.targets.${platform}.archive: only the linux target packs an archive`);
96
+ if (platform === "linux") validateLinuxArchive(target.archive);
85
97
  validateStageEntries(target.stage, `development.targets.${platform}.stage`);
86
98
  if (platform === "android") return { ...target, ...validateAndroidDevelopment(target, { root }) };
87
99
  return target;
88
100
  }
89
101
 
90
- export function runDevelopment({ root, platform, config, buildOnly = false, dryRun = false, env = process.env, run = spawnSync,
91
- codesignEnvironment = macosCodesignEnvironment, serial }) {
92
- if (!new Set(["win", "mac", "android", "ios"]).has(platform)) throw new Error("--platform must be win, mac, android or ios");
102
+ export function runDevelopment({ root, platform, config, buildOnly = false, dryRun = false, env = process.env, run = spawnSync, door = buildLaunch,
103
+ codesignEnvironment = macosCodesignEnvironment, serial, uiTests = false }) {
104
+ // UI test journeys (`right-release test --platform ios`): the declared XCUITest journeys on the dev device, under the same guards.
105
+ if (uiTests && platform !== "ios") throw new Error("UI test journeys run for --platform ios only");
106
+ if (!new Set(["win", "mac", "linux", "android", "ios"]).has(platform)) throw new Error("--platform must be win, mac, linux, android or ios");
93
107
  // iOS: --dry-run validates the config and prints the plan on any host; everything else needs macOS with Xcode.
94
108
  if (!(platform === "ios" && dryRun)) assertNativeDevelopmentHost(platform);
95
109
  const target = validateDevelopmentConfig(config, platform, { root });
96
110
  const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
97
111
  assertPrimaryReleaseCheckout(repoRoot, env);
112
+ if (dryRun && uiTests) {
113
+ if (!target.uiTests) throw new Error("development.targets.ios.uiTests is not declared: list the journeys in uiTests.onlyTesting");
114
+ return { built: false, installed: false, plan: { tests: iosUiTestCommands(target, { root, storage: { derivedData: "<rustright storage xcode-args -derivedDataPath>" }, destinationId: "<device or simulator id>", resultBundle: "<fresh .xcresult per run>" }) } };
115
+ }
98
116
  if (dryRun) return { built: false, installed: false, plan: { ...(target.ndk ? { ndk: androidDevelopmentNdkCommand(target.ndk) } : {}), build: target.build, install: buildOnly ? null : target.install ?? null, profile: DEVELOPMENT_PROFILE } };
99
117
  assertReleaseExecutionAllowed({ cwd: repoRoot, environment: env, command: `right-release dev --platform ${platform}` });
100
118
  // Refuse an invalid or off-CI ad hoc opt-in before the (long) build, not after it.
@@ -103,7 +121,8 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
103
121
  let iosPrepared = null;
104
122
  if (platform === "ios") {
105
123
  assertIosDevelopmentSession(target, { env, run });
106
- iosPrepared = prepareIosDevelopment(target, { root, env, run, serial });
124
+ if (uiTests && !target.uiTests) throw new Error("development.targets.ios.uiTests is not declared: list the journeys in uiTests.onlyTesting");
125
+ iosPrepared = uiTests ? prepareIosUiTests(target, { root, env, run, serial }) : prepareIosDevelopment(target, { root, env, run, serial });
107
126
  }
108
127
  // Android: the device serial is required, so a missing one is refused before the (long) build too.
109
128
  if (!buildOnly && platform === "android" && target.install) androidDevelopmentSerial({ descriptor: target.install, serial, env });
@@ -114,34 +133,84 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
114
133
  try {
115
134
  const startedMs = Date.now();
116
135
  // Android: cargo-ndk (Cargo dev profile) fills the generated jniLibs, then the Gradle debug build packages it.
117
- if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, label: "native build" }); }
118
- for (const command of iosPrepared ? iosPrepared.build : target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
136
+ if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, door, label: "native build" }); }
137
+ if (uiTests) {
138
+ for (const command of iosPrepared.tests) execute(command, { root, env: childEnv, dryRun, run, door, label: "ui tests" });
139
+ console.log(`right-release test: ios journeys passed (${target.uiTests.onlyTesting.join(", ")}); result bundle ${iosPrepared.resultBundle}`);
140
+ return { built: true, installed: false, tested: true, resultBundle: iosPrepared.resultBundle };
141
+ }
142
+ for (const command of iosPrepared ? iosPrepared.build : target.build) execute(command, { root, env: childEnv, dryRun, run, door, label: "build" });
119
143
  // Dev and QA binaries run from one stable staged path, copied in place (no backup of the old copy), never from the
120
144
  // broker's per-generation target folder or a hashed dev-targets folder.
121
145
  const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
122
146
  for (const file of staged) console.log(`right-release dev: staged ${file}`);
147
+ // Linux: the declared files become one tarball per architecture plus the digest manifest a guest link pins (rightkit#1230).
148
+ // Only from a clean checkout: an artifact others install must name the exact commit it was built from.
149
+ if (platform === "linux" && target.archive && !dryRun) {
150
+ if (git(repoRoot, ["status", "--porcelain", "--untracked-files=normal"])) throw new Error("linux artifacts are packed only from a clean checkout: commit first, so the manifest names the exact source");
151
+ let version;
152
+ try { version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version; } catch { version = undefined; }
153
+ packLinuxArtifacts({ root, archive: validateLinuxArchive(target.archive), app: target.archive.name, version, commit: git(repoRoot, ["rev-parse", "HEAD"]), run });
154
+ }
123
155
  if (buildOnly || !target.install) return { built: true, installed: false, ...(staged.length ? { staged } : {}) };
124
156
  if (iosPrepared) return { built: true, installed: true, ios: installIosDevelopment(iosPrepared.install, { root, env: childEnv, run }) };
125
157
  if (target.install.kind === TAURI_DEVID_KIND) {
126
158
  // installTauriDevId is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
127
159
  const built = resolveTauriArtifact(target.install, { root, platform, env: childEnv });
160
+ // Dev = production + QA: the same native helpers the release embeds are built, embedded and signed into the dev .app too.
161
+ const helpers = platform === "mac" ? nativeHelpersFromConfig(config) : undefined;
162
+ if (helpers) {
163
+ let version;
164
+ try { version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version; } catch { version = undefined; }
165
+ buildAndEmbedNativeHelpers({ appRoot: root, app: built, helpers, version, forceArch: String(target.install.targetTriple).startsWith("x86_64") ? "x86_64" : "arm64", configuration: "debug", replaceExisting: true, env: childEnv });
166
+ }
128
167
  releaseLease = false;
129
168
  return installTauriDevId({ appRoot: root, config: target.install, releaseConfig: config, built, env: childEnv })
130
169
  .then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), devid: outcome }))
131
170
  .finally(() => lease.release());
132
171
  }
133
- const signing = install(target.install, { root, platform, config, env: childEnv, run, sinceMs: startedMs, serial });
172
+ if (target.install.kind === ASSEMBLED_APP_KIND) {
173
+ const { spec, descriptor } = resolveAssembledInstall({ config, platform });
174
+ if (platform === "win") {
175
+ const assembled = installAssembledWindows({ root, config, descriptor, spec, env: childEnv });
176
+ return { built: true, installed: true, ...(staged.length ? { staged } : {}), assembled };
177
+ }
178
+ // installAssembledMac is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
179
+ releaseLease = false;
180
+ return installAssembledMac({ root, config, descriptor, spec, env: childEnv })
181
+ .then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), assembled: outcome }))
182
+ .finally(() => lease.release());
183
+ }
184
+ const signing = install(target.install, { root, platform, config, env: childEnv, run, door, sinceMs: startedMs, serial });
134
185
  return { built: true, installed: true, ...(staged.length ? { staged } : {}), ...(platform === "mac" && target.install.kind === "tauri" ? { signing } : {}) };
135
186
  } finally { if (releaseLease) lease.release(); }
136
187
  }
137
188
 
138
- export function assertNativeDevelopmentHost(platform, host = process.platform) {
139
- if ((platform === "win" && host !== "win32") || ((platform === "mac" || platform === "ios") && host !== "darwin")) throw new Error(`development target ${platform} requires its native host`);
189
+ /** WSL reports `linux` as its platform under both generations; which one it is decides whether a Linux kernel runs at all. */
190
+ export function isWsl({ env = process.env, readFile = (f) => readFileSync(f, "utf8") } = {}) {
191
+ if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
192
+ try { return /microsoft|wsl/i.test(readFile("/proc/sys/kernel/osrelease")) || /microsoft|wsl/i.test(readFile("/proc/version")); } catch { return false; }
140
193
  }
141
194
 
142
- function install(descriptor, { root, platform, config, env, run, sinceMs = null, serial }) {
195
+ /**
196
+ * WSL1 translates Linux system calls onto the Windows kernel (osrelease like `4.4.0-19041-Microsoft`), so it is not a Linux host.
197
+ * WSL2 runs a genuine Linux kernel in a Hyper-V utility VM (osrelease `…-microsoft-standard-WSL2`): a Linux VM, which is what
198
+ * native Linux builds need (rightkit#1230: "x86_64 on a Linux VM on the Dell"; Astra 2026-10-28). Unknown WSL counts as WSL1.
199
+ */
200
+ export function isWsl1({ env = process.env, readFile = (f) => readFileSync(f, "utf8") } = {}) {
201
+ if (!isWsl({ env, readFile })) return false;
202
+ try { return !/microsoft-standard-WSL2|WSL2/i.test(readFile("/proc/sys/kernel/osrelease")); } catch { return true; }
203
+ }
204
+
205
+ export function assertNativeDevelopmentHost(platform, host = process.platform, { wsl1 = () => isWsl1() } = {}) {
206
+ // Linux builds run on a Linux kernel of the target's architecture (no cross-compiling); WSL1 has no Linux kernel.
207
+ if ((platform === "win" && host !== "win32") || ((platform === "mac" || platform === "ios") && host !== "darwin") || (platform === "linux" && host !== "linux")) throw new Error(`development target ${platform} requires its native host`);
208
+ if (platform === "linux" && wsl1()) throw new Error("development target linux requires a Linux kernel; this is WSL1 (Linux system calls translated onto the Windows kernel). Use WSL2 or a Linux VM");
209
+ }
210
+
211
+ function install(descriptor, { root, platform, config, env, run, door, sinceMs = null, serial }) {
143
212
  if (descriptor.kind === "adb") return installAndroidDevelopment(descriptor, { root, env, run, serial });
144
- if (descriptor.kind === "command") return execute(descriptor, { root, env, run, label: "install" });
213
+ if (descriptor.kind === "command") return execute(descriptor, { root, env, run, door, label: "install" });
145
214
  if (descriptor.kind === "portable") return installPortableDevelopment(descriptor, { root, platform: platform === "win" ? "win32" : "darwin", env });
146
215
  if (platform === "win") {
147
216
  if (!descriptor.expectedInstalledPath) throw new Error("Windows Tauri installer requires expectedInstalledPath for installed verification");
@@ -286,20 +355,41 @@ function resolveBundleRoot(descriptor, { root, env, runCargo }) {
286
355
 
287
356
  function platformBundleKind(descriptor) { return descriptor.targetTriple.includes("apple") ? "macos" : "nsis"; }
288
357
  function validateCommand(command, label) { if (!command || typeof command.cmd !== "string" || !command.cmd.trim() || (command.args !== undefined && !Array.isArray(command.args))) throw new Error(`${label} must declare cmd and args`); }
289
- function execute(command, { root, env, dryRun, run, label }) {
358
+ function execute(command, { root, env, dryRun, run, label, door = null }) {
290
359
  validateCommand(command, `development ${label}`);
291
360
  const cwd = path.resolve(root, command.cwd ?? ".");
292
361
  if (!inside(root, cwd)) throw new Error(`development ${label} cwd must stay inside app root`);
293
362
  const args = (command.args ?? []).map(String);
294
363
  if (dryRun) return { cmd: command.cmd, args, cwd };
295
- const launch = resolveCommand(command.cmd, args, { root, env });
364
+ const resolved = resolveCommand(command.cmd, args, { root, env });
365
+ // Every dev build step goes through the one door (build-launch.mjs): the door's own right-release stays in process order, a pnpm step
366
+ // is the rightkit-pnpm broker job (by its own name, not the node entry resolveCommand finds), anything else as resolved.
367
+ // A configured command can run code regardless of its label, so installation commands use the door too.
368
+ const own = resolved.command === process.execPath && resolved.args[0] === OWN_RIGHT_RELEASE;
369
+ const launch = own || !door ? resolved : { ...resolved, ...door(command.cmd === "pnpm" ? "pnpm" : resolved.command, command.cmd === "pnpm" ? args : resolved.args, { env: resolved.env ?? env, cwd, exists: existsSync }) };
296
370
  const result = run(launch.command, launch.args, { cwd, env: launch.env ?? env, stdio: "inherit", windowsHide: true, shell: false, timeout: command.timeoutMs });
297
371
  if (result.status !== 0) throw new Error(`development ${label} command failed (${result.status}): ${command.name ?? command.cmd}`);
298
372
  }
299
- export function resolveCommand(command, args, { root, env = process.env } = {}) {
373
+ /** This package's own right-release entry: the door's RightKit, whatever the app's node_modules holds. */
374
+ const OWN_RIGHT_RELEASE = fileURLToPath(new URL("./cli/right-release.mjs", import.meta.url));
375
+ /** `rustright <tool>` forms that map to the real toolchain on a direct (Linux) host. */
376
+ const DIRECT_TOOLS = new Set(["cargo", "rustc", "rustdoc"]);
377
+ export function resolveCommand(command, args, { root, env = process.env, platform = process.platform } = {}) {
300
378
  if (command === "node") return { command: process.execPath, args };
379
+ // A dev target's `right-release …` step runs the door's own right-release (this package), never the app's node_modules copy:
380
+ // `pnpm exec right-release` puts the app's bin first on PATH, so PATH order cannot decide it (HeardRight on the Dell, 2026-10-27:
381
+ // the app's older guard refused what the door had admitted). `pnpm exec right-release …` and `pnpm right-release …` are the same step.
382
+ if (command === "right-release") return { command: process.execPath, args: [OWN_RIGHT_RELEASE, ...args] };
383
+ if (command === "pnpm" && ((args[0] === "exec" && args[1] === "right-release") || args[0] === "right-release")) {
384
+ return { command: process.execPath, args: [OWN_RIGHT_RELEASE, ...args.slice(args[0] === "exec" ? 2 : 1)] };
385
+ }
301
386
  if (command === "pnpm") return { command: process.execPath, args: [resolvePnpmCli({ root, env }), ...args] };
302
387
  if (command === "rightkit") throw new Error("`rightkit` is the retired name of the RustRight client (rustright#91); use rustright (cmd: \"rustright\")");
388
+ // A direct (Linux) host has no broker: `rustright cargo <args>` runs the real cargo, RustRight shims off PATH (managed-cargo directPlatform).
389
+ if (command === "rustright" && directPlatform(platform)) {
390
+ if (!DIRECT_TOOLS.has(args[0])) throw new Error(`\`rustright ${args[0] ?? ""}\` has no direct form on this ${platform} host (no broker on Linux); only \`rustright cargo|rustc|rustdoc\` map to the toolchain`);
391
+ return { command: args[0], args: args.slice(1), env: directCargoEnv(env, platform) };
392
+ }
303
393
  if (command === "rustright") {
304
394
  const client = managedRuntimeClient(env);
305
395
  if (!client) throw new Error("installed RightKit runtime client is unavailable for development command");
@@ -351,7 +441,7 @@ function inside(root, child) { const rel = path.relative(path.resolve(root), pat
351
441
  export async function main(args = process.argv.slice(2)) {
352
442
  let platform, configPath = defaultReleaseConfigFile(), dryRun = false, buildOnly = false, serial;
353
443
  for (let i = 0; i < args.length; i++) {
354
- if (args[i] === "-h" || args[i] === "--help") { console.log("usage: right-release dev --platform win|mac|android|ios [--config right-release.config.json] [--dry-run] [--build-only] [--serial <adb serial | iOS device id>]"); return; }
444
+ if (args[i] === "-h" || args[i] === "--help") { console.log("usage: right-release dev --platform win|mac|linux|android|ios [--config right-release.config.json] [--dry-run] [--build-only] [--serial <adb serial | iOS device id>]"); return; }
355
445
  if (args[i] === "--platform") platform = args[++i];
356
446
  else if (args[i] === "--config") configPath = args[++i];
357
447
  else if (args[i] === "--dry-run") dryRun = true;
@@ -359,7 +449,7 @@ export async function main(args = process.argv.slice(2)) {
359
449
  else if (args[i] === "--serial") serial = args[++i];
360
450
  else throw new Error(`unknown dev option: ${args[i]}`);
361
451
  }
362
- if (!platform) throw new Error("right-release dev requires --platform win|mac|android|ios");
452
+ if (!platform) throw new Error("right-release dev requires --platform win|mac|linux|android|ios");
363
453
  const absoluteConfig = path.resolve(configPath);
364
454
  const safeEnv = developmentEnvironment(process.env);
365
455
  for (const [key, value] of Object.entries(safeEnv)) process.env[key] = value;
package/door-policy.mjs CHANGED
@@ -17,7 +17,25 @@ const WRAPPERS = new Set(["env", "command", "exec", "time", "nice", "nohup", "su
17
17
  // Shell reserved words and grouping that can precede a command in the same simple command (`if true; then cargo build; fi`,
18
18
  // `{ cargo build; }`, `f() { cargo build; }`, `! cargo test`): they are skipped, never taken for the command.
19
19
  const KEYWORDS = new Set(["if", "then", "elif", "else", "fi", "do", "done", "while", "until", "{", "}", "!", "function"]);
20
- const DOOR_TOOLS = new Set(["cargo", "tauri", "right-release"]);
20
+ // Adrian, 2026-10-26: every native and frontend build tool an app can be built with, not only cargo. xcodebuild's actions, swift's
21
+ // compiling subcommands, Gradle tasks, the frontend bundlers, and a package script whose name says it builds.
22
+ const XCODE_ACTIONS = new Set(["build", "archive", "test", "build-for-testing", "test-without-building", "analyze", "install", "installsrc", "clean"]);
23
+ const XCODE_READONLY = new Set(["-version", "-list", "-showsdks", "-showBuildSettings", "-showdestinations", "-showTestPlans", "-help", "-usage", "-checkFirstLaunchStatus", "-exportLocalizations"]);
24
+ const SWIFT_COMPILES = new Set(["build", "test", "run"]);
25
+ const GRADLE_READONLY = new Set(["--version", "-v", "-version", "--help", "-h", "-?", "help", "tasks", "dependencies", "projects", "properties", "--stop", "--status"]);
26
+ const FRONTEND_BUNDLERS = new Set(["webpack", "rollup", "esbuild", "tsup", "parcel"]);
27
+ // A package script whose name says it builds (`pnpm build`, `pnpm run build:mac`, `npm run tauri`): the apps have no scripts, and a
28
+ // script that builds would be a second door even if they did.
29
+ const SCRIPT_BUILDS = /^(build|bundle|package|dist|compile|release|tauri|android|ios|xcode|gradle|test|tests|e2e|qa)(:|$)/;
30
+ // Tools whose own options take a value, so the word after them is not the subcommand.
31
+ const XCRUN_VALUE_OPTIONS = new Set(["--sdk", "-sdk", "--toolchain", "-toolchain", "--log", "-f", "--find", "--run"]);
32
+ const SWIFT_VALUE_OPTIONS = new Set(["--package-path", "-c", "--configuration", "--build-path", "--scratch-path", "--cache-path", "--sdk", "--toolchain", "-Xswiftc", "-Xcc", "-Xlinker", "--triple", "--arch", "-j", "--jobs"]);
33
+ const GRADLE_VALUE_OPTIONS = new Set(["-p", "--project-dir", "-b", "--build-file", "-c", "--settings-file", "-g", "--gradle-user-home", "-I", "--init-script", "--max-workers", "--console", "-x", "--exclude-task", "--project-cache-dir"]);
34
+ const VITE_VALUE_OPTIONS = new Set(["--config", "-c", "--mode", "-m", "--base", "--logLevel", "-l", "--outDir", "--target", "--assetsDir", "--filter"]);
35
+ /** The non-option words of argv, skipping the value of each option named in `valued` (`--config x`, and `--config=x` as one word). */
36
+ const wordsOf = (argv, valued) => { const out = []; for (let i = 0; i < argv.length; i++) { const w = argv[i]; if (w.startsWith("-")) { if (valued.has(w)) i++; continue; } out.push(w); } return out; };
37
+ const firstWord = (argv, valued) => wordsOf(argv, valued)[0] ?? null;
38
+ const DOOR_TOOLS = new Set(["cargo", "tauri", "right-release", "xcodebuild", "swift", "swiftc", "gradle", "gradlew", "vite", ...FRONTEND_BUNDLERS]);
21
39
 
22
40
  /** Splits a shell command into simple commands (on ; && || | and newlines), each an argv of words. Quotes are respected. */
23
41
  export function simpleCommands(command) {
@@ -98,7 +116,8 @@ function argvViolation(argv, depth = 0, stdin = null) {
98
116
  let tool = base(a[0]);
99
117
  // A command string handed to another shell (`bash -c "cargo build"`, `eval "..."`) is judged as the command it is.
100
118
  if (depth < 4 && (SHELLS.has(tool.toLowerCase()) || tool === "eval")) {
101
- const flag = a.findIndex((w, i) => i > 0 && /^(-c|\/c|-Command|-command)$/.test(w));
119
+ // `-c`, and combined short flags that carry it (`-lc`, `-xec`); pwsh `-Command`; cmd `/c`.
120
+ const flag = a.findIndex((w, i) => i > 0 && /^(-[a-zA-Z]*c[a-zA-Z]*|\/c|-Command|-command)$/.test(w));
102
121
  const script = tool === "eval" ? a.slice(1).join(" ") : flag > 0 ? a.slice(flag + 1).join(" ") : null;
103
122
  return script ? doorViolation(script, depth + 1) : null;
104
123
  }
@@ -115,12 +134,43 @@ function argvViolation(argv, depth = 0, stdin = null) {
115
134
  if (RUNNERS.has(tool)) {
116
135
  let rest = a.slice(1).filter((w, i, all) => !(w.startsWith("-") || (i > 0 && ["--filter", "-F", "--dir", "-C", "--prefix"].includes(all[i - 1]))));
117
136
  if (["exec", "dlx", "x"].includes(rest[0])) rest = rest.slice(1);
118
- if (rest[0] === "run" || rest[0] === "run-script") return null; // the apps have no scripts; a script name is not a tool
137
+ const script = rest[0] === "run" || rest[0] === "run-script";
138
+ if (script) rest = rest.slice(1);
119
139
  if (!rest.length) return null;
120
- a = rest; tool = base(a[0]);
140
+ // A script whose name says it builds is denied as the runner's script (`pnpm build`, `npm run tauri`); any other script name is
141
+ // not a tool (the apps have none). Without `run`, the word is judged as the tool it names (`pnpm tauri build`, `pnpm exec vite build`).
142
+ if (!script && DOOR_TOOLS.has(base(rest[0]))) { a = rest; tool = base(a[0]); }
143
+ else if (SCRIPT_BUILDS.test(rest[0])) return `${tool} ${rest[0]}`;
144
+ else if (script) return null;
145
+ else { a = rest; tool = base(a[0]); }
121
146
  }
122
147
  if (tool === "rightkit-gate" || tool === "rustright") return null;
123
148
  if (tool === "tauri" && TAURI_BUILDS.has(a[1])) return `tauri ${a[1]}`;
149
+ // Apple: xcrun wraps the tool it names; xcodebuild builds by default (no action word is `build`); swift build|test|run and swiftc compile.
150
+ if (tool === "xcrun" && a.length > 1) {
151
+ // xcrun's own options come first (`--sdk iphoneos`, `--toolchain x`, `-f`); the tool it runs is the first word after them.
152
+ let rest = a.slice(1);
153
+ while (rest.length && rest[0].startsWith("-")) rest = XCRUN_VALUE_OPTIONS.has(rest[0]) ? rest.slice(2) : rest.slice(1);
154
+ return rest.length ? argvViolation(rest, depth + 1, stdin) : null;
155
+ }
156
+ if (tool === "xcodebuild") {
157
+ const words = a.slice(1);
158
+ const action = words.find((w) => XCODE_ACTIONS.has(w));
159
+ if (action) return `xcodebuild ${action}`;
160
+ return words.some((w) => XCODE_READONLY.has(w)) ? null : "xcodebuild build";
161
+ }
162
+ if (tool === "swift") { const sub = firstWord(a.slice(1), SWIFT_VALUE_OPTIONS); if (sub && SWIFT_COMPILES.has(sub)) return `swift ${sub}`; }
163
+ if (tool === "swiftc") return "swiftc";
164
+ // Android: a Gradle task that is not one of the read-only reports.
165
+ if (tool === "gradle" || tool === "gradlew") {
166
+ // Every task named counts (`gradle tasks assembleRelease` builds); a bare call prints help.
167
+ const tasks = wordsOf(a.slice(1), GRADLE_VALUE_OPTIONS);
168
+ const building = tasks.find((t) => !GRADLE_READONLY.has(t));
169
+ return building ? `gradle ${building}` : null;
170
+ }
171
+ // Frontend: a production bundle is the compile stage's frontend build.
172
+ if (tool === "vite" && wordsOf(a.slice(1), VITE_VALUE_OPTIONS).includes("build")) return "vite build";
173
+ if (FRONTEND_BUNDLERS.has(tool)) return tool;
124
174
  if (tool === "cargo") {
125
175
  let sub = null;
126
176
  for (let i = 1; i < a.length; i++) {
@@ -213,9 +263,14 @@ export function doorViolation(command, depth = 0) {
213
263
  }
214
264
 
215
265
  export function doorDenial(violation) {
266
+ const mobile = /^(cargo )?tauri (android|ios)$|^xcodebuild (archive|install)|^gradle (assemble|bundle|build|publish|install)/.test(violation) ? (/ios|xcodebuild/.test(violation) ? "ios" : "android") : null;
216
267
  const stage = /^right-release (dev|test)$/.test(violation) ? violation.slice("right-release ".length) : /^(cargo )?tauri dev$/.test(violation) ? "dev"
217
- : /^right-release (upload|publish|publish-update|github)$/.test(violation) ? "release" : /^right-release qa$/.test(violation) ? "qualify" : /^right-release /.test(violation) || /^(cargo )?tauri (build|bundle|android|ios)$/.test(violation) ? "package" : "compile";
218
- return `[HOOK:one-door:block]\nWhy: \`${violation}\` builds outside the one door. Every check, compile, test, package and release goes through rightkit-gate, class A first.\nRequired: rightkit-gate --to ${stage}${stage === "package" || stage === "qualify" || stage === "release" ? " --platform mac|win" : ""}${stage === "dev" || stage === "test" ? " -- <arguments>" : ""}${stage === "release" ? " --tier patch|update" : ""} (on a machine with the RustRight broker, \`rustright cargo ...\` is also admitted: the broker runs class A first).`;
268
+ : /^right-release (upload|publish|publish-update|github)$/.test(violation) ? "release" : /^right-release qa$/.test(violation) ? "qualify"
269
+ : /^right-release /.test(violation) || /^(cargo )?tauri (build|bundle|android|ios)$/.test(violation) || /^xcodebuild (archive|install)/.test(violation) || /^gradle /.test(violation) ? "package"
270
+ : "compile";
271
+ const platform = mobile ?? "mac|win";
272
+ const tail = stage === "package" || stage === "qualify" ? ` --platform ${platform}` : stage === "release" ? ` --platform ${platform}${mobile ? "" : " --tier patch|update"}` : stage === "dev" || stage === "test" ? " -- <arguments>" : "";
273
+ return `[HOOK:one-door:block]\nWhy: \`${violation}\` builds outside the one door. Every check, compile, test, package and release goes through rightkit-gate, class A first.\nRequired: rightkit-gate --to ${stage}${tail} (or rustright cargo ... on a broker host, which runs class A first).`;
219
274
  }
220
275
 
221
276
  /** Claude Code PreToolUse decision for one hook input. */
package/gate-executor.mjs CHANGED
@@ -2,16 +2,28 @@
2
2
  // broker - `rustright` is on PATH, or the first `cargo` is the broker's managed shim: every cargo call goes through the broker.
3
3
  // refuse - a broker-managed machine (Mac/Dell markers, broker socket, managed agent-bin) whose broker cannot be reached from here:
4
4
  // the gate never compiles around it.
5
- // direct - any other host (a throwaway cloud container, a CI runner): cargo runs directly, through the same gates and stages.
5
+ // direct - every Linux host (WSL2, VM, cloud, CI, Linux hosted on the Dell) always, and any unmanaged Mac or Windows host: cargo
6
+ // runs directly, through the same gates and stages.
6
7
  import { brokerManagedHost, managedCargoShimOnPath } from "./cargo-contract.mjs";
8
+ import { directCargoPath, directPlatform } from "./managed-cargo.mjs";
9
+
10
+ export { directCargoPath };
7
11
 
8
12
  export const EXECUTOR_KINDS = Object.freeze(["broker", "refuse", "direct"]);
9
13
 
10
14
  /**
11
15
  * @param {{ env?: object, platform?: string, exists?: (p: string) => boolean, which: (cmd: string) => string[], readFile: (p: string) => string }} o
12
- * @returns {{ kind: "broker" | "refuse" | "direct", cargo: string[] | null, problem?: string }}
16
+ * @returns {{ kind: "broker" | "refuse" | "direct", cargo: string[] | null, problem?: string, env?: { PATH: string }, note?: string }}
13
17
  */
14
18
  export function resolveExecutor({ env = process.env, platform = process.platform, exists, which, readFile }) {
19
+ // Every Linux (and any other non-Mac, non-Windows) execution host runs direct, whatever is on PATH or in the environment: WSL2, a VM,
20
+ // a cloud or CI runner, or Linux hosted on the Dell (GATE-CONTRACT §1 "One entry"; joint plan step 4). Only Adrian's Mac and Windows
21
+ // hosts use the broker.
22
+ // `cargo` there must be a real toolchain: RustRight shim directories are taken off the PATH the gate hands its children (`env`).
23
+ if (directPlatform(platform)) {
24
+ const direct = directCargoPath({ env, exists });
25
+ return { kind: "direct", cargo: ["cargo"], ...(direct.dropped.length ? { env: { PATH: direct.path }, note: `RustRight shims taken off PATH for direct cargo: ${direct.dropped.join(", ")}` } : {}), ...(direct.cargo ? {} : { note: `no direct cargo on PATH${direct.dropped.length ? ` (RustRight shims skipped: ${direct.dropped.join(", ")})` : ""}; cargo scans fail as a missing tool` }) };
26
+ }
15
27
  if (which("rustright").length) return { kind: "broker", cargo: ["rustright", "cargo"] };
16
28
  const first = which("cargo")[0];
17
29
  if (first) {
package/gate-stages.mjs CHANGED
@@ -63,11 +63,21 @@ function pidAlive(pid) {
63
63
  try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
64
64
  }
65
65
 
66
- /** Reads the receipt for `commit`, or a fresh one bound to it. */
67
- export function readReceipt({ commonDir, commit, readFile = (f) => fs.readFileSync(f, "utf8") }) {
66
+ /**
67
+ * The receipt file for `commit` and the app it was run for: a repository with one app at its root keeps `<commit>.json`; an app in a
68
+ * nested directory (CodeRight's apps/coderight-tauri and apps/coderight-ios, CutRight's apps/cutright, HeardRight's tauri-app-next)
69
+ * gets `<commit>--<dir, _ as _u, / as __>.json`, so two apps of one repository never share or overwrite a receipt (Astra, 2026-10-26).
70
+ */
71
+ export function receiptKey(commit, app = ".") {
72
+ // Reversible: "_" becomes "_u" before "/" becomes "__", so apps/a__b (apps__a_u_ub) and apps/a/b (apps__a__b) never share a key.
73
+ return app && app !== "." ? `${commit}--${app.replaceAll("_", "_u").replaceAll("/", "__")}` : commit;
74
+ }
75
+
76
+ /** Reads the receipt for `commit` (and `app`), or null. */
77
+ export function readReceipt({ commonDir, commit, app = ".", readFile = (f) => fs.readFileSync(f, "utf8") }) {
68
78
  try {
69
- const r = JSON.parse(readFile(path.join(gateStateDir(commonDir), "receipts", `${commit}.json`)));
70
- if (r.schema === RECEIPT_SCHEMA && r.commit === commit) return r;
79
+ const r = JSON.parse(readFile(path.join(gateStateDir(commonDir), "receipts", `${receiptKey(commit, app)}.json`)));
80
+ if (r.schema === RECEIPT_SCHEMA && r.commit === commit && (r.app ?? ".") === (app ?? ".")) return r;
71
81
  } catch { /* none yet */ }
72
82
  return null;
73
83
  }
@@ -75,7 +85,7 @@ export function readReceipt({ commonDir, commit, readFile = (f) => fs.readFileSy
75
85
  export function writeReceipt({ commonDir, receipt, write = fs.writeFileSync, mkdir = fs.mkdirSync }) {
76
86
  const dir = path.join(gateStateDir(commonDir), "receipts");
77
87
  mkdir(dir, { recursive: true });
78
- write(path.join(dir, `${receipt.commit}.json`), `${JSON.stringify(receipt, null, 2)}\n`);
88
+ write(path.join(dir, `${receiptKey(receipt.commit, receipt.app ?? ".")}.json`), `${JSON.stringify(receipt, null, 2)}\n`);
79
89
  }
80
90
 
81
91
  /**
package/heavy-command.mjs CHANGED
@@ -3,6 +3,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, r
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawn, spawnSync } from "./background-process.mjs";
6
+ import { spawnUnderOwnership } from "./build-ownership.mjs";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { randomUUID } from "node:crypto";
8
9
 
@@ -208,7 +209,7 @@ export async function watchOwnedProcessTree(
208
209
 
209
210
  function spawnOwnerWatcher(details) {
210
211
  const payload = Buffer.from(JSON.stringify(details)).toString("base64url");
211
- const watcher = spawn(process.execPath, [SELF, "--watch-owner-base64", payload], {
212
+ const watcher = spawnUnderOwnership(spawn, process.execPath, [SELF, "--watch-owner-base64", payload], {
212
213
  detached: true, env: process.env, stdio: "ignore", windowsHide: true,
213
214
  });
214
215
  watcher.once("error", () => {
@@ -335,7 +336,7 @@ export async function runHeavyCommand(args, { env = process.env } = {}) {
335
336
 
336
337
  function spawnAttached(command, args, env, slot) {
337
338
  return new Promise((resolve, reject) => {
338
- const child = spawn(command, args, { cwd: process.cwd(), env, stdio: "inherit", windowsHide: true, detached: heavyChildDetached({ slot }) });
339
+ const child = spawnUnderOwnership(spawn, command, args, { cwd: process.cwd(), env, stdio: "inherit", windowsHide: true, detached: heavyChildDetached({ slot }) });
339
340
  try {
340
341
  if (child.pid && slot) slot.trackChild(child.pid, command);
341
342
  } catch (error) {
package/install-app.mjs CHANGED
@@ -12,6 +12,7 @@ import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { fileURLToPath, pathToFileURL } from "node:url";
14
14
 
15
+ const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
15
16
  const SIGTERM_WAIT_MS = 15_000;
16
17
  const POLL_MS = 250;
17
18
 
@@ -632,12 +633,20 @@ export async function installApp(opts, deps = {}) {
632
633
  swap = null;
633
634
  event("replaced", { installed, previous: null, swap: swapMode });
634
635
 
636
+ // A dev install from a build folder (--app) leaves that product registered with Launch Services, which then resolves the bundle
637
+ // id to it instead of the installed copy (rightkit#1225): unregister it so only the installed copy is known.
638
+ if (opts.app && path.resolve(sourceApp) !== path.resolve(installed)) {
639
+ try { await run(LSREGISTER, ["-u", sourceApp]); event("unregistered", { path: sourceApp }); }
640
+ catch (error) { event("unregister-failed", { path: sourceApp, reason: errText(error) }); }
641
+ }
642
+
635
643
  // Relaunch hidden and in the background only.
636
644
  const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
637
645
  if (relaunch) {
638
646
  failedStage = "relaunch";
639
- await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
640
- event("relaunched", { bundleId });
647
+ // By the installed path, never by bundle id: Launch Services may resolve the id to another registered copy (rightkit#1225).
648
+ await run("/usr/bin/open", ["-g", "-j", installed]);
649
+ event("relaunched", { bundleId, installed });
641
650
  }
642
651
  const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: null, swap: swapMode, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
643
652
  event("done", result);