@rightkit/release 0.6.8 → 0.6.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -12
- package/admission-files.mjs +4 -1
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +105 -27
- package/assemble-app-command.mjs +151 -0
- package/assembled-app.mjs +445 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +1608 -345
- package/broker-pins.mjs +152 -0
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +82 -28
- package/cargo-contract.mjs +3 -1
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +238 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +31 -5
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/codesign-shim.mjs +11 -1
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +997 -71
- package/development-signing.mjs +4 -1
- package/development.mjs +120 -21
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +71 -6
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +91 -15
- package/heavy-command.mjs +3 -2
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/inventory-check.mjs +11 -8
- package/ios-dev.mjs +70 -3
- package/ios-upload.mjs +20 -15
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +224 -53
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/mirror-root-artifact.mjs +18 -5
- package/native-helpers.mjs +355 -0
- package/ownership-class-rules.mjs +1 -1
- package/ownership-evidence.mjs +37 -13
- package/ownership-hooks.mjs +20 -59
- package/ownership-lockfiles.mjs +14 -1
- package/ownership-policy-bundle.mjs +3 -2
- package/ownership-staged.mjs +2 -1
- package/ownership.mjs +24 -0
- package/package-recipe-targets.mjs +84 -10
- package/package.json +4 -3
- package/pipeline-admission.mjs +4 -4
- package/pipeline-receipts.mjs +302 -302
- package/preflight.mjs +27 -4
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +12 -9
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +106 -14
- package/release-artifact-path.mjs +341 -0
- package/release-config.mjs +1 -1
- package/release-invocation.mjs +8 -1
- package/release-qa-command.mjs +25 -9
- package/release-source-brand.mjs +17 -0
- package/release-source-identity.mjs +171 -0
- package/release.mjs +59 -22
- package/rightkit-versions.json +73 -47
- package/runtime-budgets.mjs +6 -2
- package/scan-digest.mjs +71 -5
- package/sign-updater.mjs +6 -3
- package/snapshot-broker.mjs +172 -0
- package/snapshot-check.mjs +312 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +363 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +969 -0
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +60 -0
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +157 -27
- package/swap-helper-bundle.mjs +5 -2
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +301 -0
- package/web-build.mjs +40 -6
- package/win-package-recipe.mjs +20 -7
package/development-signing.mjs
CHANGED
|
@@ -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,14 @@ 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";
|
|
10
|
+
import { readSourceSplit } from "./source-split.mjs";
|
|
9
11
|
import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
|
|
10
|
-
import { acquireManagedRunLease, managedRuntimeClient, managedRuntimeCommand, runCargoSync } from "./managed-cargo.mjs";
|
|
12
|
+
import { acquireManagedRunLease, directCargoEnv, directPlatform, managedRuntimeClient, managedRuntimeCommand, runCargoSync } from "./managed-cargo.mjs";
|
|
11
13
|
import { BUNDLE_MARKERS, bundleMarkerBuffer, readTauriBundleMarkers } from "./tauri-bundle-marker.mjs";
|
|
12
14
|
import { installPortableDevelopment } from "./portable-development.mjs";
|
|
13
15
|
import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.mjs";
|
|
@@ -15,7 +17,10 @@ import { stageDevelopmentBinaries, validateStageEntries } from "./stage-binary.m
|
|
|
15
17
|
import { assertStableDesignatedRequirement, readBundleExecutable, requestedCiAdhocSigning, signMacosDevelopmentBundle } from "./development-signing.mjs";
|
|
16
18
|
import { macosCodesignEnvironment } from "./codesign-environment.mjs";
|
|
17
19
|
import { installTauriDevId, resolveTauriDevIdDescriptor, TAURI_DEVID_KIND } from "./install-tauri-devid.mjs";
|
|
18
|
-
import {
|
|
20
|
+
import { ASSEMBLED_APP_KIND, installAssembledMac, installAssembledWindows, resolveAssembledInstall } from "./install-assembled-app.mjs";
|
|
21
|
+
import { assertIosDevelopmentSession, installIosDevelopment, iosUiTestCommands, prepareIosDevelopment, prepareIosUiTests, validateIosDevelopment } from "./ios-dev.mjs";
|
|
22
|
+
import { packLinuxArtifacts, validateLinuxArchive } from "./linux-artifacts.mjs";
|
|
23
|
+
import { buildAndEmbedNativeHelpers, nativeHelpersFromConfig } from "./native-helpers.mjs";
|
|
19
24
|
import { androidDevelopmentNdkCommand, androidDevelopmentSerial, installAndroidDevelopment, validateAndroidDevelopment } from "./android-dev.mjs";
|
|
20
25
|
|
|
21
26
|
// One standard Cargo profile for every app (templates/cargo-profiles.toml): Cargo's built-in `dev`.
|
|
@@ -30,9 +35,9 @@ export function cargoProfileDirectory(profile) {
|
|
|
30
35
|
}
|
|
31
36
|
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
37
|
|
|
33
|
-
export function developmentEnvironment(env = process.env) {
|
|
38
|
+
export function developmentEnvironment(env = process.env, platform = process.platform) {
|
|
34
39
|
const result = {
|
|
35
|
-
...env,
|
|
40
|
+
...(directPlatform(platform) ? directCargoEnv(env, platform) : env),
|
|
36
41
|
RIGHTKIT_BUILD_MODE: "dev",
|
|
37
42
|
RIGHTKIT_CARGO_PROFILE: DEVELOPMENT_PROFILE,
|
|
38
43
|
TAURI_SIGNING_PRIVATE_KEY: "",
|
|
@@ -79,22 +84,39 @@ export function validateDevelopmentConfig(config, platform, { root = process.cwd
|
|
|
79
84
|
if (platform !== "mac") throw new Error(`the ${TAURI_DEVID_KIND} development installer is for --platform mac`);
|
|
80
85
|
resolveTauriDevIdDescriptor(target.install, { appRoot: root });
|
|
81
86
|
if (!target.install.targetTriple && !target.install.bundleDirectory) throw new Error(`development.targets.${platform}.install requires targetTriple (or bundleDirectory) to find the built .app`);
|
|
87
|
+
} else if (target.install?.kind === ASSEMBLED_APP_KIND) {
|
|
88
|
+
if (platform !== "mac" && platform !== "win") throw new Error(`the ${ASSEMBLED_APP_KIND} development installer is for --platform mac or win`);
|
|
89
|
+
// The assembly (assembly.<platform>) and the install keys are validated together, before any build.
|
|
90
|
+
resolveAssembledInstall({ config, platform });
|
|
82
91
|
} else if (target.install?.kind === "adb") {
|
|
83
92
|
if (platform !== "android") throw new Error("the adb development installer is for --platform android");
|
|
84
93
|
} else if (target.install) throw new Error(`unsupported development installer kind: ${target.install.kind}`);
|
|
94
|
+
// Linux is build-only: its artifacts are the staged sidecars bound in the stage receipt; nothing is installed on a Linux host.
|
|
95
|
+
if (platform === "linux" && target.install) throw new Error("development.targets.linux is build-only: it takes no install (the staged artifacts are the result)");
|
|
96
|
+
if (target.archive !== undefined && platform !== "linux") throw new Error(`development.targets.${platform}.archive: only the linux target packs an archive`);
|
|
97
|
+
if (platform === "linux") validateLinuxArchive(target.archive);
|
|
85
98
|
validateStageEntries(target.stage, `development.targets.${platform}.stage`);
|
|
86
99
|
if (platform === "android") return { ...target, ...validateAndroidDevelopment(target, { root }) };
|
|
87
100
|
return target;
|
|
88
101
|
}
|
|
89
102
|
|
|
90
|
-
export function runDevelopment({ root, platform, config, buildOnly = false, dryRun = false, env = process.env, run = spawnSync,
|
|
91
|
-
codesignEnvironment = macosCodesignEnvironment, serial }) {
|
|
92
|
-
|
|
103
|
+
export function runDevelopment({ root, platform, config, buildOnly = false, dryRun = false, env = process.env, run = spawnSync, door = buildLaunch,
|
|
104
|
+
codesignEnvironment = macosCodesignEnvironment, serial, uiTests = false }) {
|
|
105
|
+
// UI test journeys (`right-release test --platform ios`): the declared XCUITest journeys on the dev device, under the same guards.
|
|
106
|
+
if (uiTests && platform !== "ios") throw new Error("UI test journeys run for --platform ios only");
|
|
107
|
+
if (!new Set(["win", "mac", "linux", "android", "ios"]).has(platform)) throw new Error("--platform must be win, mac, linux, android or ios");
|
|
93
108
|
// iOS: --dry-run validates the config and prints the plan on any host; everything else needs macOS with Xcode.
|
|
94
109
|
if (!(platform === "ios" && dryRun)) assertNativeDevelopmentHost(platform);
|
|
95
110
|
const target = validateDevelopmentConfig(config, platform, { root });
|
|
96
|
-
|
|
111
|
+
// Inside the door's source split `root` is the snapshot of a commit (no git directory): the repository is the checkout the door
|
|
112
|
+
// ran in, whose primary-checkout rule and execution policy still apply, and the commit is the split's.
|
|
113
|
+
const split = readSourceSplit({ env, cwd: root });
|
|
114
|
+
const repoRoot = split ? split.checkout : git(root, ["rev-parse", "--show-toplevel"]);
|
|
97
115
|
assertPrimaryReleaseCheckout(repoRoot, env);
|
|
116
|
+
if (dryRun && uiTests) {
|
|
117
|
+
if (!target.uiTests) throw new Error("development.targets.ios.uiTests is not declared: list the journeys in uiTests.onlyTesting");
|
|
118
|
+
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>" }) } };
|
|
119
|
+
}
|
|
98
120
|
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
121
|
assertReleaseExecutionAllowed({ cwd: repoRoot, environment: env, command: `right-release dev --platform ${platform}` });
|
|
100
122
|
// Refuse an invalid or off-CI ad hoc opt-in before the (long) build, not after it.
|
|
@@ -103,7 +125,8 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
103
125
|
let iosPrepared = null;
|
|
104
126
|
if (platform === "ios") {
|
|
105
127
|
assertIosDevelopmentSession(target, { env, run });
|
|
106
|
-
|
|
128
|
+
if (uiTests && !target.uiTests) throw new Error("development.targets.ios.uiTests is not declared: list the journeys in uiTests.onlyTesting");
|
|
129
|
+
iosPrepared = uiTests ? prepareIosUiTests(target, { root, env, run, serial }) : prepareIosDevelopment(target, { root, env, run, serial });
|
|
107
130
|
}
|
|
108
131
|
// Android: the device serial is required, so a missing one is refused before the (long) build too.
|
|
109
132
|
if (!buildOnly && platform === "android" && target.install) androidDevelopmentSerial({ descriptor: target.install, serial, env });
|
|
@@ -114,34 +137,85 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
114
137
|
try {
|
|
115
138
|
const startedMs = Date.now();
|
|
116
139
|
// 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
|
-
|
|
140
|
+
if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, door, label: "native build" }); }
|
|
141
|
+
if (uiTests) {
|
|
142
|
+
for (const command of iosPrepared.tests) execute(command, { root, env: childEnv, dryRun, run, door, label: "ui tests" });
|
|
143
|
+
console.log(`right-release test: ios journeys passed (${target.uiTests.onlyTesting.join(", ")}); result bundle ${iosPrepared.resultBundle}`);
|
|
144
|
+
return { built: true, installed: false, tested: true, resultBundle: iosPrepared.resultBundle };
|
|
145
|
+
}
|
|
146
|
+
for (const command of iosPrepared ? iosPrepared.build : target.build) execute(command, { root, env: childEnv, dryRun, run, door, label: "build" });
|
|
119
147
|
// Dev and QA binaries run from one stable staged path, copied in place (no backup of the old copy), never from the
|
|
120
148
|
// broker's per-generation target folder or a hashed dev-targets folder.
|
|
121
149
|
const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
|
|
122
150
|
for (const file of staged) console.log(`right-release dev: staged ${file}`);
|
|
151
|
+
// Linux: the declared files become one tarball per architecture plus the digest manifest a guest link pins (rightkit#1230).
|
|
152
|
+
// Only from a clean checkout: an artifact others install must name the exact commit it was built from.
|
|
153
|
+
if (platform === "linux" && target.archive && !dryRun) {
|
|
154
|
+
// A split's snapshot is exactly its commit (the door verifies it at the start and end of the stage); the checkout may hold anything.
|
|
155
|
+
if (!split && 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");
|
|
156
|
+
let version;
|
|
157
|
+
try { version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version; } catch { version = undefined; }
|
|
158
|
+
packLinuxArtifacts({ root, archive: validateLinuxArchive(target.archive), app: target.archive.name, version, commit: split ? split.commit : git(repoRoot, ["rev-parse", "HEAD"]), run });
|
|
159
|
+
}
|
|
123
160
|
if (buildOnly || !target.install) return { built: true, installed: false, ...(staged.length ? { staged } : {}) };
|
|
124
161
|
if (iosPrepared) return { built: true, installed: true, ios: installIosDevelopment(iosPrepared.install, { root, env: childEnv, run }) };
|
|
125
162
|
if (target.install.kind === TAURI_DEVID_KIND) {
|
|
126
163
|
// installTauriDevId is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
|
|
127
164
|
const built = resolveTauriArtifact(target.install, { root, platform, env: childEnv });
|
|
165
|
+
// Dev = production + QA: the same native helpers the release embeds are built, embedded and signed into the dev .app too.
|
|
166
|
+
const helpers = platform === "mac" ? nativeHelpersFromConfig(config) : undefined;
|
|
167
|
+
if (helpers) {
|
|
168
|
+
let version;
|
|
169
|
+
try { version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version; } catch { version = undefined; }
|
|
170
|
+
buildAndEmbedNativeHelpers({ appRoot: root, app: built, helpers, version, forceArch: String(target.install.targetTriple).startsWith("x86_64") ? "x86_64" : "arm64", configuration: "debug", replaceExisting: true, env: childEnv });
|
|
171
|
+
}
|
|
128
172
|
releaseLease = false;
|
|
129
173
|
return installTauriDevId({ appRoot: root, config: target.install, releaseConfig: config, built, env: childEnv })
|
|
130
174
|
.then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), devid: outcome }))
|
|
131
175
|
.finally(() => lease.release());
|
|
132
176
|
}
|
|
133
|
-
|
|
177
|
+
if (target.install.kind === ASSEMBLED_APP_KIND) {
|
|
178
|
+
const { spec, descriptor } = resolveAssembledInstall({ config, platform });
|
|
179
|
+
if (platform === "win") {
|
|
180
|
+
const assembled = installAssembledWindows({ root, config, descriptor, spec, env: childEnv });
|
|
181
|
+
return { built: true, installed: true, ...(staged.length ? { staged } : {}), assembled };
|
|
182
|
+
}
|
|
183
|
+
// installAssembledMac is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
|
|
184
|
+
releaseLease = false;
|
|
185
|
+
return installAssembledMac({ root, config, descriptor, spec, env: childEnv })
|
|
186
|
+
.then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), assembled: outcome }))
|
|
187
|
+
.finally(() => lease.release());
|
|
188
|
+
}
|
|
189
|
+
const signing = install(target.install, { root, platform, config, env: childEnv, run, door, sinceMs: startedMs, serial });
|
|
134
190
|
return { built: true, installed: true, ...(staged.length ? { staged } : {}), ...(platform === "mac" && target.install.kind === "tauri" ? { signing } : {}) };
|
|
135
191
|
} finally { if (releaseLease) lease.release(); }
|
|
136
192
|
}
|
|
137
193
|
|
|
138
|
-
|
|
139
|
-
|
|
194
|
+
/** WSL reports `linux` as its platform under both generations; which one it is decides whether a Linux kernel runs at all. */
|
|
195
|
+
export function isWsl({ env = process.env, readFile = (f) => readFileSync(f, "utf8") } = {}) {
|
|
196
|
+
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
|
|
197
|
+
try { return /microsoft|wsl/i.test(readFile("/proc/sys/kernel/osrelease")) || /microsoft|wsl/i.test(readFile("/proc/version")); } catch { return false; }
|
|
140
198
|
}
|
|
141
199
|
|
|
142
|
-
|
|
200
|
+
/**
|
|
201
|
+
* WSL1 translates Linux system calls onto the Windows kernel (osrelease like `4.4.0-19041-Microsoft`), so it is not a Linux host.
|
|
202
|
+
* WSL2 runs a genuine Linux kernel in a Hyper-V utility VM (osrelease `…-microsoft-standard-WSL2`): a Linux VM, which is what
|
|
203
|
+
* native Linux builds need (rightkit#1230: "x86_64 on a Linux VM on the Dell"; Astra 2026-10-28). Unknown WSL counts as WSL1.
|
|
204
|
+
*/
|
|
205
|
+
export function isWsl1({ env = process.env, readFile = (f) => readFileSync(f, "utf8") } = {}) {
|
|
206
|
+
if (!isWsl({ env, readFile })) return false;
|
|
207
|
+
try { return !/microsoft-standard-WSL2|WSL2/i.test(readFile("/proc/sys/kernel/osrelease")); } catch { return true; }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function assertNativeDevelopmentHost(platform, host = process.platform, { wsl1 = () => isWsl1() } = {}) {
|
|
211
|
+
// Linux builds run on a Linux kernel of the target's architecture (no cross-compiling); WSL1 has no Linux kernel.
|
|
212
|
+
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`);
|
|
213
|
+
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");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function install(descriptor, { root, platform, config, env, run, door, sinceMs = null, serial }) {
|
|
143
217
|
if (descriptor.kind === "adb") return installAndroidDevelopment(descriptor, { root, env, run, serial });
|
|
144
|
-
if (descriptor.kind === "command") return execute(descriptor, { root, env, run, label: "install" });
|
|
218
|
+
if (descriptor.kind === "command") return execute(descriptor, { root, env, run, door, label: "install" });
|
|
145
219
|
if (descriptor.kind === "portable") return installPortableDevelopment(descriptor, { root, platform: platform === "win" ? "win32" : "darwin", env });
|
|
146
220
|
if (platform === "win") {
|
|
147
221
|
if (!descriptor.expectedInstalledPath) throw new Error("Windows Tauri installer requires expectedInstalledPath for installed verification");
|
|
@@ -286,20 +360,45 @@ function resolveBundleRoot(descriptor, { root, env, runCargo }) {
|
|
|
286
360
|
|
|
287
361
|
function platformBundleKind(descriptor) { return descriptor.targetTriple.includes("apple") ? "macos" : "nsis"; }
|
|
288
362
|
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 }) {
|
|
363
|
+
function execute(command, { root, env, dryRun, run, label, door = null }) {
|
|
290
364
|
validateCommand(command, `development ${label}`);
|
|
291
365
|
const cwd = path.resolve(root, command.cwd ?? ".");
|
|
292
366
|
if (!inside(root, cwd)) throw new Error(`development ${label} cwd must stay inside app root`);
|
|
293
367
|
const args = (command.args ?? []).map(String);
|
|
294
368
|
if (dryRun) return { cmd: command.cmd, args, cwd };
|
|
295
|
-
|
|
369
|
+
// A pnpm step through the door is the broker's rightkit-pnpm job (or plain pnpm on a direct host), launched by name: the door never uses
|
|
370
|
+
// the node entry resolveCommand would find, so resolving pnpm's JavaScript CLI here would only fail a host that does not need it.
|
|
371
|
+
// The door's own `pnpm exec right-release` forms still resolve to this package's right-release.
|
|
372
|
+
const ownReleaseStep = (args[0] === "exec" && args[1] === "right-release") || args[0] === "right-release";
|
|
373
|
+
const resolved = door && command.cmd === "pnpm" && !ownReleaseStep ? { command: "pnpm", args } : resolveCommand(command.cmd, args, { root, env });
|
|
374
|
+
// 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
|
|
375
|
+
// is the rightkit-pnpm broker job (by its own name, not the node entry resolveCommand finds), anything else as resolved.
|
|
376
|
+
// A configured command can run code regardless of its label, so installation commands use the door too.
|
|
377
|
+
const own = resolved.command === process.execPath && resolved.args[0] === OWN_RIGHT_RELEASE;
|
|
378
|
+
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
379
|
const result = run(launch.command, launch.args, { cwd, env: launch.env ?? env, stdio: "inherit", windowsHide: true, shell: false, timeout: command.timeoutMs });
|
|
297
380
|
if (result.status !== 0) throw new Error(`development ${label} command failed (${result.status}): ${command.name ?? command.cmd}`);
|
|
298
381
|
}
|
|
299
|
-
|
|
382
|
+
/** This package's own right-release entry: the door's RightKit, whatever the app's node_modules holds. */
|
|
383
|
+
const OWN_RIGHT_RELEASE = fileURLToPath(new URL("./cli/right-release.mjs", import.meta.url));
|
|
384
|
+
/** `rustright <tool>` forms that map to the real toolchain on a direct (Linux) host. */
|
|
385
|
+
const DIRECT_TOOLS = new Set(["cargo", "rustc", "rustdoc"]);
|
|
386
|
+
export function resolveCommand(command, args, { root, env = process.env, platform = process.platform } = {}) {
|
|
300
387
|
if (command === "node") return { command: process.execPath, args };
|
|
388
|
+
// A dev target's `right-release …` step runs the door's own right-release (this package), never the app's node_modules copy:
|
|
389
|
+
// `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:
|
|
390
|
+
// the app's older guard refused what the door had admitted). `pnpm exec right-release …` and `pnpm right-release …` are the same step.
|
|
391
|
+
if (command === "right-release") return { command: process.execPath, args: [OWN_RIGHT_RELEASE, ...args] };
|
|
392
|
+
if (command === "pnpm" && ((args[0] === "exec" && args[1] === "right-release") || args[0] === "right-release")) {
|
|
393
|
+
return { command: process.execPath, args: [OWN_RIGHT_RELEASE, ...args.slice(args[0] === "exec" ? 2 : 1)] };
|
|
394
|
+
}
|
|
301
395
|
if (command === "pnpm") return { command: process.execPath, args: [resolvePnpmCli({ root, env }), ...args] };
|
|
302
396
|
if (command === "rightkit") throw new Error("`rightkit` is the retired name of the RustRight client (rustright#91); use rustright (cmd: \"rustright\")");
|
|
397
|
+
// A direct (Linux) host has no broker: `rustright cargo <args>` runs the real cargo, RustRight shims off PATH (managed-cargo directPlatform).
|
|
398
|
+
if (command === "rustright" && directPlatform(platform)) {
|
|
399
|
+
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`);
|
|
400
|
+
return { command: args[0], args: args.slice(1), env: directCargoEnv(env, platform) };
|
|
401
|
+
}
|
|
303
402
|
if (command === "rustright") {
|
|
304
403
|
const client = managedRuntimeClient(env);
|
|
305
404
|
if (!client) throw new Error("installed RightKit runtime client is unavailable for development command");
|
|
@@ -351,7 +450,7 @@ function inside(root, child) { const rel = path.relative(path.resolve(root), pat
|
|
|
351
450
|
export async function main(args = process.argv.slice(2)) {
|
|
352
451
|
let platform, configPath = defaultReleaseConfigFile(), dryRun = false, buildOnly = false, serial;
|
|
353
452
|
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; }
|
|
453
|
+
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
454
|
if (args[i] === "--platform") platform = args[++i];
|
|
356
455
|
else if (args[i] === "--config") configPath = args[++i];
|
|
357
456
|
else if (args[i] === "--dry-run") dryRun = true;
|
|
@@ -359,7 +458,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
359
458
|
else if (args[i] === "--serial") serial = args[++i];
|
|
360
459
|
else throw new Error(`unknown dev option: ${args[i]}`);
|
|
361
460
|
}
|
|
362
|
-
if (!platform) throw new Error("right-release dev requires --platform win|mac|android|ios");
|
|
461
|
+
if (!platform) throw new Error("right-release dev requires --platform win|mac|linux|android|ios");
|
|
363
462
|
const absoluteConfig = path.resolve(configPath);
|
|
364
463
|
const safeEnv = developmentEnvironment(process.env);
|
|
365
464
|
for (const [key, value] of Object.entries(safeEnv)) process.env[key] = value;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The direct (Linux) host's evidence for a snapshot compile (item 7 rows 7.09, 7.12, 7.18; GATE-CONTRACT "Broker interface"):
|
|
2
|
+
// - the output directory, resolved in the PRIMARY CHECKOUT with the stage's environment before the source location changes, with
|
|
3
|
+
// its inputs recorded (7.09);
|
|
4
|
+
// - the receipt the gate writes itself, "the same field names minus the broker-only ones (admission, queue, reusedFrom)" (7.18),
|
|
5
|
+
// and the consumer-side matrix that refuses a receipt that lacks one, carries a broker-only one, or holds no census (7.22 Linux half).
|
|
6
|
+
// Pure functions with injected spawn and file system: the gate (bin/rightkit-gate.mjs) wires them to the host.
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { validateFreshness } from "./freshness-census.mjs";
|
|
11
|
+
|
|
12
|
+
export const DIRECT_RECEIPT_SCHEMA = "rightkit.gate-direct-receipt/1";
|
|
13
|
+
export const DIAGNOSTICS_SCHEMA = "rightkit.gate-direct-diagnostics/1";
|
|
14
|
+
/** The names GATE-CONTRACT lists as broker-only: a direct receipt never carries them. */
|
|
15
|
+
export const BROKER_ONLY_FIELDS = Object.freeze(["admission", "queue", "reusedFrom"]);
|
|
16
|
+
/** The contract's returned-evidence names a direct receipt carries (7.18). `snapshot.path|treeAtStart|treeAtExit` are checked inside `snapshot`. */
|
|
17
|
+
export const DIRECT_RECEIPT_REQUIRED = Object.freeze(["requestId", "brokerVersion", "executor", "commit", "snapshot", "declaredInputDigests", "verdict", "freshness", "timingsMs", "exit", "diagnosticsPath", "outputDir", "refreshed"]);
|
|
18
|
+
export const TIMING_FIELDS = Object.freeze(["bind", "cargo", "afterExitBind", "diagnostics"]);
|
|
19
|
+
/** The environment a `cargo metadata` target resolution reads: recorded by value (the rest of the environment by digest only). */
|
|
20
|
+
export const TARGET_ENV_NAMES = Object.freeze(["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR", "CARGO_HOME", "RUSTUP_TOOLCHAIN"]);
|
|
21
|
+
|
|
22
|
+
const failed = (r) => !r || Boolean(r.error) || r.status !== 0;
|
|
23
|
+
const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r?\n/).slice(-3).join(" | ");
|
|
24
|
+
|
|
25
|
+
/** True when `child` is `parent` or below it (lexically, after resolving both). */
|
|
26
|
+
export function isWithin(parent, child) {
|
|
27
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child));
|
|
28
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The stage environment a resolution ran with: the target-relevant names by value, the whole environment by digest. */
|
|
32
|
+
export function envInputs(env) {
|
|
33
|
+
const record = {};
|
|
34
|
+
for (const name of TARGET_ENV_NAMES) record[name] = typeof env?.[name] === "string" ? env[name] : null;
|
|
35
|
+
const all = Object.keys(env ?? {}).sort().map((k) => `${k}=${env[k]}`).join("\n");
|
|
36
|
+
return { ...record, digest: createHash("sha256").update(all).digest("hex") };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The absolute output directory Cargo would use for one workspace, resolved where the contract says: `cargo metadata --format-version 1
|
|
41
|
+
* --no-deps` run with its working directory in the PRIMARY CHECKOUT (never the snapshot) and with the stage's environment, so
|
|
42
|
+
* `CARGO_TARGET_DIR`, `.cargo/config.toml` `build.target-dir` (relative to the checkout's config, or absolute) and the default `target/`
|
|
43
|
+
* all resolve as they do for a developer's own build. Returns `{ dir, inputs }` (the inputs are recorded in the receipt) or throws an
|
|
44
|
+
* `RK_TARGET_RESOLUTION` error whose message names why: a resolution made in the snapshot, no absolute directory, or a directory that
|
|
45
|
+
* lies inside the snapshot (a second output directory).
|
|
46
|
+
*/
|
|
47
|
+
export function resolveTargetDirectory({ spawn, cargoCommand, manifest, env, checkoutRoot, snapshotRoot = null, workspace = "." }) {
|
|
48
|
+
const fail = (message) => Object.assign(new Error(message), { code: "RK_TARGET_RESOLUTION" });
|
|
49
|
+
const cwd = path.dirname(manifest);
|
|
50
|
+
if (!isWithin(checkoutRoot, cwd)) throw fail(`the output directory of workspace ${workspace} must be resolved in the primary checkout ${checkoutRoot}, not in ${cwd}`);
|
|
51
|
+
if (snapshotRoot && isWithin(snapshotRoot, cwd)) throw fail(`the output directory of workspace ${workspace} must be resolved before the source location changes, not in the snapshot ${snapshotRoot}`);
|
|
52
|
+
const args = [...cargoCommand.slice(1), "metadata", "--offline", "--format-version", "1", "--no-deps", "--manifest-path", manifest];
|
|
53
|
+
const r = spawn(cargoCommand[0], args, { cwd, env });
|
|
54
|
+
let dir = null;
|
|
55
|
+
try { dir = failed(r) ? null : JSON.parse(String(r.stdout ?? "")).target_directory; } catch { dir = null; }
|
|
56
|
+
if (typeof dir !== "string" || !path.isAbsolute(dir)) throw fail(`cargo metadata in the checkout reported no absolute target_directory for ${workspace}${failed(r) ? ` (${why(r)})` : ""}`);
|
|
57
|
+
const resolved = path.resolve(dir);
|
|
58
|
+
if (snapshotRoot && isWithin(snapshotRoot, resolved)) throw fail(`the output directory of workspace ${workspace} resolves to ${resolved}, inside the snapshot ${snapshotRoot}; a snapshot compile writes only to the checkout's directory, never a second one under the commit`);
|
|
59
|
+
return { dir: resolved, inputs: { cwd, manifest, command: [cargoCommand[0], ...args], env: envInputs(env), targetDirectory: resolved } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The Class A and B result the receipt's `verdict` carries (a direct compile reuses nothing, so no `reusedFrom`). */
|
|
63
|
+
export function directVerdict({ scans = [], failures = [], deferrals = [] } = {}) {
|
|
64
|
+
const byClass = (c) => { const rows = scans.filter((s) => s.class === c); return { scans: rows.length, failed: rows.filter((s) => s.status === "fail").length }; };
|
|
65
|
+
return { result: failures.length ? "FAILED" : "PASSED", classA: byClass("A"), classB: byClass("B"), deferred: deferrals.length };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Writes the structured diagnostics of one direct compile and returns the file's absolute path (the receipt's `diagnosticsPath`). */
|
|
69
|
+
export function writeDirectDiagnostics({ dir, requestId, commit, exit, scans = [], failures = [], deferrals = [], notes = [], freshness = null, writeFile = fs.writeFileSync, mkdir = fs.mkdirSync }) {
|
|
70
|
+
mkdir(dir, { recursive: true });
|
|
71
|
+
const file = path.resolve(dir, `direct-diagnostics-${requestId}.json`);
|
|
72
|
+
const rows = scans.map((s) => ({ id: s.id, class: s.class ?? null, status: s.status, ...(s.code ? { code: s.code } : {}), ...(s.detail ? { detail: String(s.detail).slice(0, 1000) } : {}) }));
|
|
73
|
+
writeFile(file, `${JSON.stringify({ schema: DIAGNOSTICS_SCHEMA, requestId, commit, exit, scans: rows, failures, deferrals, notes, freshness }, null, 2)}\n`);
|
|
74
|
+
return file;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The receipt of one direct compile: the contract's returned-evidence names, no broker-only one. Throws nothing; `verifyDirectReceipt` judges it. */
|
|
78
|
+
export function buildDirectReceipt({ requestId, issuedAt, commit, snapshotPath, treeAtStart, treeAtExit, ownershipHandle = null, declaredInputDigests = [], verdict, freshness, timingsMs, exit, diagnosticsPath, outputDirs = {}, outputResolution = {}, cargoWrapper = null }) {
|
|
79
|
+
const dirs = [...new Set(Object.values(outputDirs))];
|
|
80
|
+
return {
|
|
81
|
+
schema: DIRECT_RECEIPT_SCHEMA, requestId, brokerVersion: null, executor: "direct", issuedAt,
|
|
82
|
+
commit: { sha: commit.sha, tree: commit.tree },
|
|
83
|
+
snapshot: { path: snapshotPath, treeAtStart, treeAtExit },
|
|
84
|
+
declaredInputDigests, ownership: { handle: ownershipHandle },
|
|
85
|
+
verdict, freshness, timingsMs, exit, exitStatus: exit, diagnosticsPath,
|
|
86
|
+
outputDir: dirs.length === 1 ? dirs[0] : null, outputDirs: { ...outputDirs }, outputResolution,
|
|
87
|
+
refreshed: [], cargoWrapper,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The consumer's matrix over a direct receipt (the Linux half of row 7.22): every contract name present with the right type, the
|
|
93
|
+
* snapshot bound to the commit's tree at start and exit, a well formed freshness census, and none of the broker-only names.
|
|
94
|
+
* Returns the list of problems; an empty list means the receipt verifies.
|
|
95
|
+
*/
|
|
96
|
+
export function verifyDirectReceipt(receipt) {
|
|
97
|
+
const problems = [];
|
|
98
|
+
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return ["the receipt is not an object"];
|
|
99
|
+
if (receipt.schema !== DIRECT_RECEIPT_SCHEMA) problems.push(`schema is ${JSON.stringify(receipt.schema)}, not ${DIRECT_RECEIPT_SCHEMA}`);
|
|
100
|
+
for (const name of DIRECT_RECEIPT_REQUIRED) if (!Object.hasOwn(receipt, name)) problems.push(`${name} is absent`);
|
|
101
|
+
for (const name of BROKER_ONLY_FIELDS) if (Object.hasOwn(receipt, name)) problems.push(`${name} is a broker-only field; a direct receipt never carries it`);
|
|
102
|
+
const text = (v) => typeof v === "string" && v !== "";
|
|
103
|
+
if (Object.hasOwn(receipt, "requestId") && !text(receipt.requestId)) problems.push("requestId is not a string");
|
|
104
|
+
if (Object.hasOwn(receipt, "brokerVersion") && receipt.brokerVersion !== null) problems.push("brokerVersion is not null on a direct host");
|
|
105
|
+
if (Object.hasOwn(receipt, "executor") && receipt.executor !== "direct") problems.push(`executor is ${JSON.stringify(receipt.executor)}, not "direct"`);
|
|
106
|
+
const commit = receipt.commit;
|
|
107
|
+
if (Object.hasOwn(receipt, "commit") && !(commit && text(commit.sha) && text(commit.tree))) problems.push("commit has no sha and tree");
|
|
108
|
+
const snap = receipt.snapshot;
|
|
109
|
+
if (Object.hasOwn(receipt, "snapshot")) {
|
|
110
|
+
if (!(snap && text(snap.path) && text(snap.treeAtStart) && text(snap.treeAtExit))) problems.push("snapshot has no path, treeAtStart and treeAtExit");
|
|
111
|
+
else if (commit && text(commit.tree) && (snap.treeAtStart !== commit.tree || snap.treeAtExit !== commit.tree)) problems.push("snapshot.treeAtStart and treeAtExit are not the commit's tree");
|
|
112
|
+
}
|
|
113
|
+
if (Object.hasOwn(receipt, "declaredInputDigests") && !Array.isArray(receipt.declaredInputDigests)) problems.push("declaredInputDigests is not a list");
|
|
114
|
+
const verdict = receipt.verdict;
|
|
115
|
+
if (Object.hasOwn(receipt, "verdict") && !(verdict && (verdict.result === "PASSED" || verdict.result === "FAILED"))) problems.push("verdict has no PASSED or FAILED result");
|
|
116
|
+
if (Object.hasOwn(receipt, "freshness")) for (const p of validateFreshness(receipt.freshness)) problems.push(p);
|
|
117
|
+
const timings = receipt.timingsMs;
|
|
118
|
+
if (Object.hasOwn(receipt, "timingsMs")) {
|
|
119
|
+
if (!timings || typeof timings !== "object") problems.push("timingsMs is not an object");
|
|
120
|
+
else {
|
|
121
|
+
for (const name of TIMING_FIELDS) if (!(Number.isFinite(timings[name]) && timings[name] >= 0)) problems.push(`timingsMs.${name} is not a duration`);
|
|
122
|
+
for (const name of BROKER_ONLY_FIELDS) if (Object.hasOwn(timings, name)) problems.push(`timingsMs.${name} is a broker-only field`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (Object.hasOwn(receipt, "exit") && !Number.isInteger(receipt.exit)) problems.push("exit is not an exit status");
|
|
126
|
+
if (Object.hasOwn(receipt, "diagnosticsPath") && !(text(receipt.diagnosticsPath) && path.isAbsolute(receipt.diagnosticsPath))) problems.push("diagnosticsPath is not an absolute path");
|
|
127
|
+
if (Object.hasOwn(receipt, "outputDir")) {
|
|
128
|
+
const dirs = receipt.outputDirs && typeof receipt.outputDirs === "object" ? Object.values(receipt.outputDirs) : [];
|
|
129
|
+
if (text(receipt.outputDir)) { if (!path.isAbsolute(receipt.outputDir)) problems.push("outputDir is not absolute"); }
|
|
130
|
+
else if (!(receipt.outputDir === null && new Set(dirs).size !== 1)) problems.push("outputDir is not the resolved absolute path (null only when the commit has no Cargo workspace, or several workspaces name several directories)");
|
|
131
|
+
for (const d of dirs) if (!(text(d) && path.isAbsolute(d))) problems.push(`outputDirs holds ${JSON.stringify(d)}, not an absolute path`);
|
|
132
|
+
}
|
|
133
|
+
if (Object.hasOwn(receipt, "refreshed") && !Array.isArray(receipt.refreshed)) problems.push("refreshed is not a list");
|
|
134
|
+
return problems;
|
|
135
|
+
}
|
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
|
-
|
|
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
|
-
|
|
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,47 @@ 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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
147
|
+
// `rightkit-gate clippy [<cargo clippy args>]` is the agent entry for Clippy (GATE-CONTRACT §1 "Clippy route & batching", item 8 row
|
|
148
|
+
// 8.14): like every `rightkit-gate` subcommand it is the door, taken through the pinned gate (build-control-policy agentDoorViolation
|
|
149
|
+
// denies an unpinned copy as untrusted-gate) and run in the same order for a human and an agent. Direct `cargo clippy` and
|
|
150
|
+
// `cargo +<toolchain> clippy` stay denied by CARGO_COMPILES above, with the route named in the denial.
|
|
122
151
|
if (tool === "rightkit-gate" || tool === "rustright") return null;
|
|
123
152
|
if (tool === "tauri" && TAURI_BUILDS.has(a[1])) return `tauri ${a[1]}`;
|
|
153
|
+
// Apple: xcrun wraps the tool it names; xcodebuild builds by default (no action word is `build`); swift build|test|run and swiftc compile.
|
|
154
|
+
if (tool === "xcrun" && a.length > 1) {
|
|
155
|
+
// xcrun's own options come first (`--sdk iphoneos`, `--toolchain x`, `-f`); the tool it runs is the first word after them.
|
|
156
|
+
let rest = a.slice(1);
|
|
157
|
+
while (rest.length && rest[0].startsWith("-")) rest = XCRUN_VALUE_OPTIONS.has(rest[0]) ? rest.slice(2) : rest.slice(1);
|
|
158
|
+
return rest.length ? argvViolation(rest, depth + 1, stdin) : null;
|
|
159
|
+
}
|
|
160
|
+
if (tool === "xcodebuild") {
|
|
161
|
+
const words = a.slice(1);
|
|
162
|
+
const action = words.find((w) => XCODE_ACTIONS.has(w));
|
|
163
|
+
if (action) return `xcodebuild ${action}`;
|
|
164
|
+
return words.some((w) => XCODE_READONLY.has(w)) ? null : "xcodebuild build";
|
|
165
|
+
}
|
|
166
|
+
if (tool === "swift") { const sub = firstWord(a.slice(1), SWIFT_VALUE_OPTIONS); if (sub && SWIFT_COMPILES.has(sub)) return `swift ${sub}`; }
|
|
167
|
+
if (tool === "swiftc") return "swiftc";
|
|
168
|
+
// Android: a Gradle task that is not one of the read-only reports.
|
|
169
|
+
if (tool === "gradle" || tool === "gradlew") {
|
|
170
|
+
// Every task named counts (`gradle tasks assembleRelease` builds); a bare call prints help.
|
|
171
|
+
const tasks = wordsOf(a.slice(1), GRADLE_VALUE_OPTIONS);
|
|
172
|
+
const building = tasks.find((t) => !GRADLE_READONLY.has(t));
|
|
173
|
+
return building ? `gradle ${building}` : null;
|
|
174
|
+
}
|
|
175
|
+
// Frontend: a production bundle is the compile stage's frontend build.
|
|
176
|
+
if (tool === "vite" && wordsOf(a.slice(1), VITE_VALUE_OPTIONS).includes("build")) return "vite build";
|
|
177
|
+
if (FRONTEND_BUNDLERS.has(tool)) return tool;
|
|
124
178
|
if (tool === "cargo") {
|
|
125
179
|
let sub = null;
|
|
126
180
|
for (let i = 1; i < a.length; i++) {
|
|
@@ -212,10 +266,21 @@ export function doorViolation(command, depth = 0) {
|
|
|
212
266
|
return null;
|
|
213
267
|
}
|
|
214
268
|
|
|
269
|
+
/** The agent entry for Clippy; a direct `cargo clippy` is denied in its favour. */
|
|
270
|
+
export const CLIPPY_ROUTE = "rightkit-gate clippy [<cargo clippy args>]";
|
|
271
|
+
|
|
215
272
|
export function doorDenial(violation) {
|
|
273
|
+
if (violation === "cargo clippy") {
|
|
274
|
+
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: ${CLIPPY_ROUTE} (Clippy on HEAD's commit, class A first).`;
|
|
275
|
+
}
|
|
276
|
+
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
277
|
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"
|
|
218
|
-
|
|
278
|
+
: /^right-release (upload|publish|publish-update|github)$/.test(violation) ? "release" : /^right-release qa$/.test(violation) ? "qualify"
|
|
279
|
+
: /^right-release /.test(violation) || /^(cargo )?tauri (build|bundle|android|ios)$/.test(violation) || /^xcodebuild (archive|install)/.test(violation) || /^gradle /.test(violation) ? "package"
|
|
280
|
+
: "compile";
|
|
281
|
+
const platform = mobile ?? "mac|win";
|
|
282
|
+
const tail = stage === "package" || stage === "qualify" ? ` --platform ${platform}` : stage === "release" ? ` --platform ${platform}${mobile ? "" : " --tier patch|update"}` : stage === "dev" || stage === "test" ? " -- <arguments>" : "";
|
|
283
|
+
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
284
|
}
|
|
220
285
|
|
|
221
286
|
/** Claude Code PreToolUse decision for one hook input. */
|
package/door.mjs
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
// packager) that right-release runs inside a stage. The step proceeds only when a live `rightkit-gate --to` run for HEAD launched it,
|
|
3
3
|
// the same check right-release makes for itself (gate-stages.mjs requireDoorRun), so `node scripts/release/<step>.mjs` run by hand is
|
|
4
4
|
// refused instead of becoming a side door around class A.
|
|
5
|
-
import path from "node:path";
|
|
6
5
|
import { spawnSync } from "./background-process.mjs";
|
|
7
6
|
import { requireDoorRun } from "./gate-stages.mjs";
|
|
7
|
+
import { doorContextFor } from "./source-split.mjs";
|
|
8
8
|
|
|
9
|
-
/** null when a live door run for HEAD launched this process, else the refusal message. */
|
|
9
|
+
/** null when a live door run for HEAD (or, inside a source split, for the split's commit) launched this process, else the refusal message. */
|
|
10
10
|
export function doorRefusal({ cwd = process.cwd(), env = process.env, command = "this build step", run = spawnSync, alive } = {}) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (!
|
|
14
|
-
return requireDoorRun({ env, commonDir:
|
|
11
|
+
let context;
|
|
12
|
+
try { context = doorContextFor({ cwd, env, run }); } catch (error) { return `${command}: ${error.code ?? "RK_SOURCE_SPLIT"}: ${error.message}`; }
|
|
13
|
+
if (!context) return `${command} runs only in a git checkout, through the one door: rightkit-gate --to <stage>`;
|
|
14
|
+
return requireDoorRun({ env, commonDir: context.commonDir, commit: context.commit, command, ...(alive ? { alive } : {}) });
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** Exits 2 with the refusal unless a live door run launched this process. */
|