@rightkit/release 0.2.70 → 0.2.72

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/build-release.mjs CHANGED
@@ -23,6 +23,7 @@ import path from "node:path";
23
23
  import { spawn, spawnSync } from "node:child_process";
24
24
  import { fileURLToPath, pathToFileURL } from "node:url";
25
25
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
26
+ import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
26
27
  import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
27
28
  import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteVolumeFloors, inspectCache, inspectWriteVolumes, markCacheEntrySuccessful, planCachePrune, readCachePolicy, resolveCacheLayout, resolveSharedCacheIdentity, resolveSharedCacheRoot } from "./cache-policy.mjs";
28
29
  import { createTargetBridge } from "./target-bridge.mjs";
@@ -48,7 +49,7 @@ const PIPELINE_FINGERPRINT = createHash("sha256")
48
49
  .slice(0, 16);
49
50
 
50
51
  /** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
51
- function buildPreflight({ config, configPath, appRoot, repoRoot, platform }) {
52
+ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout }) {
52
53
  let version;
53
54
  try {
54
55
  version = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8")).version;
@@ -65,10 +66,7 @@ function buildPreflight({ config, configPath, appRoot, repoRoot, platform }) {
65
66
  // collision is already enforced downstream, and duplicating it here would
66
67
  // reject a legitimate resume of an in-flight build.
67
68
  version: undefined,
68
- cargoLockPaths: [
69
- path.join(appRoot, "src-tauri", "Cargo.lock"),
70
- path.join(appRoot, "Cargo.lock"),
71
- ],
69
+ cargoLockPaths: nativeLayout?.lockCandidates ?? [],
72
70
  });
73
71
  }
74
72
 
@@ -152,7 +150,10 @@ try {
152
150
  }
153
151
  const target = config.targets?.[platform];
154
152
  if (!target?.package) fail(`${config.app} has no ${platform} package command`);
155
- if (platform === "win") assertNsisInPlaceUpgradeContract(appRoot, target.nsisUpgradeContract);
153
+ const nativeLayout = resolveNativeCargoLayout({ appRoot, config });
154
+ if (platform === "win" && target.signingContract === "windows-raw-exe-authenticode-before-nsis-v1") {
155
+ assertNsisInPlaceUpgradeContract(appRoot, target.nsisUpgradeContract);
156
+ }
156
157
  const { requiredInputs, buildInputs } = resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target });
157
158
  const dirtyInputs = dirtyBuildInputs(repoRoot, buildInputs, commit);
158
159
  if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
@@ -170,12 +171,12 @@ try {
170
171
  receiptInputs: ["raw-exe", "installer", "embedding"].map((phase) => path.join(receiptRoot, `windows-${phase}.json`)),
171
172
  } : null;
172
173
  if (signingIdentity) inputHashes[".right-release/signing-identity.json"] = hashFileText(JSON.stringify(signingIdentity));
173
- const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
174
- const cargoToml = requiredInputs.find((file) => /Cargo\.toml$/i.test(file));
174
+ const cargoLock = nativeLayout.lockPath;
175
+ const cargoToml = nativeLayout.manifestPath;
175
176
  // A broker-managed host is not identified by RIGHTKIT_BUILD_BROKER_SOCKET alone:
176
177
  // that variable is exported by login shells only, while the managed cargo shim is
177
178
  // on PATH for every shell. Detecting on the variable alone made agent (non-login)
178
- // shells take the Cache V2 branch, bridge src-tauri/target into the release cache,
179
+ // shells take the Cache V2 branch, bridge resolved native target into release cache,
179
180
  // and then lose CARGO_TARGET_DIR to the broker's unconditional override — so the
180
181
  // app bundle landed in the broker workspace and packaging failed on an empty cache.
181
182
  const managedCargoTarget = brokerManagedHost(process.env) ? resolveTargetRoot(cargoToml) : null;
@@ -204,7 +205,7 @@ try {
204
205
  // Preflight BEFORE any compile. These preconditions used to be discovered one
205
206
  // per build cycle, minutes deep, with errors that named a symptom instead of a
206
207
  // cause. Blocking problems stop here, in seconds, naming the fix.
207
- const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform });
208
+ const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout });
208
209
  const preflightBlockers = preflightFailures(preflight);
209
210
  if (preflightBlockers.length > 0) {
210
211
  fail(`preflight found ${preflightBlockers.length} blocking problem(s):\n${formatPreflight(preflightBlockers)}`);
@@ -256,7 +257,7 @@ try {
256
257
  tools: toolVersions,
257
258
  createdAt: new Date().toISOString(),
258
259
  };
259
- const targetLink = path.join(appRoot, "src-tauri", "target");
260
+ const targetLink = nativeLayout.targetLink;
260
261
  // ownedRoot is the per-app cache parent holding every fingerprint dir, so a
261
262
  // link left by an earlier fingerprint is recognised as ours and repointed
262
263
  // instead of hard-stopping the build.
@@ -329,7 +330,7 @@ try {
329
330
  checkpoint(stateRoot, "hardened");
330
331
  },
331
332
  seal: async ({ sealedDir }) => {
332
- sealRelease({ configRoot: appRoot, managedCargoTarget, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity });
333
+ sealRelease({ configRoot: appRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity });
333
334
  verifySealedRelease(sealedDir);
334
335
  if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
335
336
  checkpoint(stateRoot, "sealed");
@@ -369,20 +370,24 @@ function sqlCipherFeatures(text) {
369
370
  .filter((value) => /sqlcipher|openssl/i.test(value));
370
371
  }
371
372
 
372
- function sealRelease({ configRoot, managedCargoTarget, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity }) {
373
+ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity }) {
373
374
  // Every app's right-release.config.mjs names installer/updater artifacts
374
- // relative to the local `src-tauri/target`. On a broker-managed host that
375
+ // relative to its resolved native target. On a broker-managed host that
375
376
  // directory is never populated (the broker owns CARGO_TARGET_DIR and
376
377
  // targetBridge.ensure() deliberately leaves a real local directory alone,
377
378
  // see target-bridge.mjs) so those paths must resolve against the broker's
378
379
  // own target root instead, or every managed build fails at seal after a
379
380
  // full compile, sign, and notarize.
380
381
  const targetPrefix = "src-tauri/target/";
382
+ const effectiveTargetPrefix = nativeLayout?.targetPrefix ?? targetPrefix;
381
383
  const resolveArtifactPath = (file) => {
382
384
  const normalized = file.replaceAll(path.win32.sep, "/");
383
385
  if (managedCargoTarget && normalized.startsWith(targetPrefix)) {
384
386
  return path.join(managedCargoTarget, normalized.slice(targetPrefix.length));
385
387
  }
388
+ if (managedCargoTarget && effectiveTargetPrefix !== targetPrefix && normalized.startsWith(effectiveTargetPrefix)) {
389
+ return path.join(managedCargoTarget, normalized.slice(effectiveTargetPrefix.length));
390
+ }
386
391
  return path.resolve(configRoot, file);
387
392
  };
388
393
  const sources = new Map();
package/cache-command.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  resolveSharedCacheRoot,
14
14
  } from "./cache-policy.mjs";
15
15
  import { resolveReleaseLayout } from "./release-state.mjs";
16
+ import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
16
17
  import { brokerManagedHost } from "./cargo-contract.mjs";
17
18
 
18
19
  // On a broker-managed host, CARGO_TARGET_DIR is not Cache V2's to own: the build
@@ -59,14 +60,16 @@ export async function runCacheCommand(args = process.argv.slice(2), { env = proc
59
60
  if (!config?.app) throw new Error("cache migrate config must expose app");
60
61
  const appRoot = path.dirname(configFile);
61
62
  const configuredTarget = config.targets?.[platform] ?? {};
62
- const lockPath = path.join(appRoot, "src-tauri", "Cargo.lock");
63
- const identity = resolveSharedCacheIdentity({ cargoLockPath: lockPath, cargoTomlPath: path.join(appRoot, "src-tauri", "Cargo.toml"), rustcVerbose: env.RIGHT_RELEASE_RUSTC_VERBOSE, targetTriple: configuredTarget.cargoTarget ?? configuredTarget.targetTriple ?? configuredTarget.rustTarget ?? env.TAURI_ENV_TARGET_TRIPLE, profile: configuredTarget.profile ?? "release" });
63
+ const nativeLayout = resolveNativeCargoLayout({ appRoot, config });
64
+ const lockPath = nativeLayout.lockPath;
65
+ if (!lockPath) throw new Error(`cache migrate could not find Cargo.lock for ${nativeLayout.manifestPath}`);
66
+ const identity = resolveSharedCacheIdentity({ cargoLockPath: lockPath, cargoTomlPath: nativeLayout.manifestPath, rustcVerbose: env.RIGHT_RELEASE_RUSTC_VERBOSE, targetTriple: configuredTarget.cargoTarget ?? configuredTarget.targetTriple ?? configuredTarget.rustTarget ?? env.TAURI_ENV_TARGET_TRIPLE, profile: configuredTarget.profile ?? "release" });
64
67
  const fingerprint = env.RIGHT_RELEASE_CACHE_KEY || identity.fingerprint;
65
68
  const layout = resolveCacheLayout({ cacheRoot, platform, architecture: identity.architecture, app: config.app, fingerprint, kind: "release" });
66
69
  const dryRun = !rest.includes("--apply");
67
- const legacy = resolveReleaseLayout({ repoRoot: findRepoRoot(appRoot), configPath: configFile });
70
+ const legacy = resolveReleaseLayout({ repoRoot: findRepoRoot(appRoot), configPath: configFile, config, nativeLayout });
68
71
  const legacyTargetRoot = path.join(legacy.vaultRoot, "cache", "cargo-target", platform, fingerprint);
69
- const result = migrateLegacyCache({ legacyRoot: path.join(appRoot, "src-tauri", "target"), legacyTargetRoot, legacyCargoHome: path.join(legacy.vaultRoot, "cache", "cargo-home"), legacyReleaseLockPath: path.join(legacy.vaultRoot, "locks", `${platform}.lock.json`), layout, app: config.app, dryRun });
72
+ const result = migrateLegacyCache({ legacyRoot: nativeLayout.targetLink, legacyTargetRoot, legacyCargoHome: path.join(legacy.vaultRoot, "cache", "cargo-home"), legacyReleaseLockPath: path.join(legacy.vaultRoot, "locks", `${platform}.lock.json`), layout, app: config.app, dryRun });
70
73
  return output({ schema: 1, command, cacheRoot, ...result }, json, stdout);
71
74
  }
72
75
  throw new Error("right-release cache: expected status, prune, or migrate");
File without changes
@@ -0,0 +1,482 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { RIGHTRELEASE_MANIFEST_SIGNER, signReleaseManifestWithAzure } from "./sign-release-manifest.mjs";
6
+ import { validateCycloneDxSbom, validateInTotoSlsaProvenance } from "./supply-chain-evidence.mjs";
7
+
8
+ const SHA256 = /^[a-f0-9]{64}$/;
9
+ const VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
10
+ const MAX_BOOTSTRAP_BYTES = 256 * 1024;
11
+ const TARGETS = new Set(["windows-x86_64", "windows-arm64", "macos-x86_64", "macos-arm64"]);
12
+
13
+ export function canonicalJson(value) {
14
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
15
+ if (value && typeof value === "object") {
16
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
17
+ }
18
+ return JSON.stringify(value);
19
+ }
20
+
21
+ export function sha256File(path) {
22
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
23
+ }
24
+
25
+ export function collectReleaseAsset({
26
+ target,
27
+ name,
28
+ url,
29
+ archivePath,
30
+ executablePath,
31
+ nativeSignaturePolicy,
32
+ provenancePath,
33
+ sbomPath,
34
+ }) {
35
+ const archive = statSync(archivePath);
36
+ if (!archive.isFile()) throw new Error("release archive is missing");
37
+ if (!statSync(provenancePath).isFile() || !statSync(sbomPath).isFile()) throw new Error("provenance/SBOM evidence is missing");
38
+ const archiveSha256 = sha256File(archivePath);
39
+ validateInTotoSlsaProvenance(provenancePath, { expectedSubject: { name, sha256: archiveSha256 } });
40
+ validateCycloneDxSbom(sbomPath, { expectedFile: { name, sha256: archiveSha256 } });
41
+ const releaseBase = url.slice(0, url.lastIndexOf("/") + 1);
42
+ const provenanceName = provenancePath.split(/[\\/]/).at(-1);
43
+ const sbomName = sbomPath.split(/[\\/]/).at(-1);
44
+ return {
45
+ target,
46
+ name,
47
+ url,
48
+ size: archive.size,
49
+ sha256: archiveSha256,
50
+ executablePath,
51
+ nativeSignaturePolicy,
52
+ provenanceName,
53
+ provenanceUrl: releaseBase + provenanceName,
54
+ provenanceSha256: sha256File(provenancePath),
55
+ sbomName,
56
+ sbomUrl: releaseBase + sbomName,
57
+ sbomSha256: sha256File(sbomPath),
58
+ };
59
+ }
60
+
61
+ function assertRelativeFile(path, label) {
62
+ if (typeof path !== "string" || !path || isAbsolute(path) || path.split(/[\\/]/).some((part) => !part || part === "." || part === "..")) {
63
+ throw new Error(`${label} must be a contained relative file path`);
64
+ }
65
+ }
66
+
67
+ function assertGitHubAssetUrl(url) {
68
+ const parsed = new URL(url);
69
+ if (parsed.protocol !== "https:" || parsed.hostname !== "github.com" || !/\/releases\/download\//.test(parsed.pathname)) {
70
+ throw new Error(`release payload must use immutable GitHub Release URL: ${url}`);
71
+ }
72
+ }
73
+
74
+ export function createPortableArchive({ sourceDir, outputPath, commandRunner = spawnSync }) {
75
+ const source = realpathSync(sourceDir);
76
+ mkdirSync(dirname(outputPath), { recursive: true });
77
+ const result = commandRunner("tar", ["-a", "-c", "-f", resolve(outputPath), "-C", source, "."], {
78
+ encoding: "utf8",
79
+ windowsHide: true,
80
+ });
81
+ if (result?.status !== 0) throw new Error(`portable archive failed: ${String(result?.stderr ?? "").trim()}`);
82
+ if (!statSync(outputPath).isFile()) throw new Error(`portable archive missing: ${outputPath}`);
83
+ return { path: resolve(outputPath), size: statSync(outputPath).size, sha256: sha256File(outputPath) };
84
+ }
85
+
86
+ export function buildChecksums(assets) {
87
+ const checksums = {};
88
+ for (const asset of assets) {
89
+ const files = [
90
+ { name: asset.name, sha256: asset.sha256 },
91
+ ...(asset.provenanceName ? [{ name: asset.provenanceName, sha256: asset.provenanceSha256 }] : []),
92
+ ...(asset.sbomName ? [{ name: asset.sbomName, sha256: asset.sbomSha256 }] : []),
93
+ ];
94
+ for (const file of files) {
95
+ assertRelativeFile(file.name, "asset name");
96
+ if (!SHA256.test(file.sha256)) throw new Error(`invalid SHA-256 for ${file.name}`);
97
+ if (checksums[file.name]) throw new Error(`duplicate release asset: ${file.name}`);
98
+ checksums[file.name] = file.sha256;
99
+ }
100
+ }
101
+ return { schemaVersion: 1, algorithm: "sha256", assets: checksums };
102
+ }
103
+
104
+ export function buildReleaseManifest({
105
+ product,
106
+ version,
107
+ tag = `v${version}`,
108
+ sourceCommit,
109
+ minimumBootstrapVersion,
110
+ assets,
111
+ checksumsDigest,
112
+ }) {
113
+ if (!/^[a-z][a-z0-9-]+$/.test(product)) throw new Error("invalid product");
114
+ if (!VERSION.test(version) || tag !== `v${version}`) throw new Error("version/tag mismatch");
115
+ if (!/^[a-f0-9]{40,64}$/.test(sourceCommit)) throw new Error("invalid source commit");
116
+ if (!VERSION.test(minimumBootstrapVersion)) throw new Error("invalid minimum bootstrap version");
117
+ if (!SHA256.test(checksumsDigest)) throw new Error("invalid checksums digest");
118
+ const seen = new Set();
119
+ const normalizedAssets = assets.map((asset) => {
120
+ if (!TARGETS.has(asset.target)) throw new Error(`unsupported release target: ${asset.target}`);
121
+ if (seen.has(asset.target)) throw new Error(`duplicate release target: ${asset.target}`);
122
+ seen.add(asset.target);
123
+ assertRelativeFile(asset.name, "asset name");
124
+ assertGitHubAssetUrl(asset.url);
125
+ if (!Number.isSafeInteger(asset.size) || asset.size < 1 || !SHA256.test(asset.sha256)) throw new Error(`invalid asset evidence: ${asset.name}`);
126
+ if (!SHA256.test(asset.provenanceSha256) || !SHA256.test(asset.sbomSha256)) throw new Error(`invalid bound evidence: ${asset.name}`);
127
+ assertRelativeFile(asset.provenanceName, "provenance name");
128
+ assertRelativeFile(asset.sbomName, "SBOM name");
129
+ assertGitHubAssetUrl(asset.provenanceUrl);
130
+ assertGitHubAssetUrl(asset.sbomUrl);
131
+ const releaseBase = asset.url.slice(0, asset.url.lastIndexOf("/") + 1);
132
+ if (asset.provenanceUrl !== releaseBase + asset.provenanceName || asset.sbomUrl !== releaseBase + asset.sbomName) throw new Error(`bound evidence URL mismatch: ${asset.name}`);
133
+ if (asset.target.startsWith("windows-") && asset.nativeSignaturePolicy !== "authenticode-valid") throw new Error(`Windows asset must require Authenticode: ${asset.name}`);
134
+ if (asset.target.startsWith("macos-") && asset.nativeSignaturePolicy !== "developer-id-notarized") throw new Error(`macOS asset must require Developer ID notarization: ${asset.name}`);
135
+ assertRelativeFile(asset.executablePath, "executable path");
136
+ return { ...asset };
137
+ }).sort((left, right) => left.target.localeCompare(right.target));
138
+ return {
139
+ schemaVersion: 1,
140
+ kind: "rightkit-direct-release-manifest",
141
+ product,
142
+ version,
143
+ tag,
144
+ sourceCommit,
145
+ minimumBootstrapVersion,
146
+ signingKeyId: RIGHTRELEASE_MANIFEST_SIGNER.id,
147
+ signatureAlgorithm: RIGHTRELEASE_MANIFEST_SIGNER.algorithm,
148
+ checksumsSha256: checksumsDigest,
149
+ assets: normalizedAssets,
150
+ };
151
+ }
152
+
153
+ export function materializeDirectRelease({ outputDir, manifestInput }) {
154
+ mkdirSync(outputDir, { recursive: true });
155
+ const checksums = buildChecksums(manifestInput.assets);
156
+ const checksumsText = `${canonicalJson(checksums)}\n`;
157
+ const checksumsPath = join(outputDir, "checksums.json");
158
+ writeFileSync(checksumsPath, checksumsText);
159
+ const manifest = buildReleaseManifest({
160
+ ...manifestInput,
161
+ checksumsDigest: createHash("sha256").update(checksumsText).digest("hex"),
162
+ });
163
+ const manifestText = `${canonicalJson(manifest)}\n`;
164
+ const manifestPath = join(outputDir, "release-manifest.json");
165
+ const signaturePath = join(outputDir, "release-manifest.sig");
166
+ writeFileSync(manifestPath, manifestText);
167
+ const signing = signReleaseManifestWithAzure({ manifestPath, signaturePath });
168
+ if (!statSync(signaturePath).isFile() || statSync(signaturePath).size < 1) throw new Error("detached release manifest signature is missing");
169
+ if (signing?.signer?.id !== RIGHTRELEASE_MANIFEST_SIGNER.id || signing.signer.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || !/^[a-f0-9]{64}$/.test(signing.signer.certificateSha256 ?? "")) {
170
+ throw new Error("detached release manifest signer is not RightRelease Azure authority");
171
+ }
172
+ return { manifest, manifestPath, signaturePath, signing, checksums };
173
+ }
174
+
175
+ function psQuote(value) {
176
+ return "'" + String(value).replaceAll("'", "''") + "'";
177
+ }
178
+
179
+ function psArray(values) {
180
+ return "@(" + values.map(psQuote).join(",") + ")";
181
+ }
182
+
183
+ export function renderPowerShellBootstrap({
184
+ product,
185
+ repository,
186
+ bootstrapVersion,
187
+ acceptedManifestSigners,
188
+ installRootSubdir,
189
+ executablePath,
190
+ activationArgs,
191
+ statusArgs,
192
+ expectedStatusKind,
193
+ expectedStatus = "complete",
194
+ healthAssertions = null,
195
+ preflightArgs = [],
196
+ }) {
197
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error("invalid GitHub repository");
198
+ if (!VERSION.test(bootstrapVersion)) throw new Error("invalid bootstrap version");
199
+ assertRelativeFile(executablePath, "executable path");
200
+ if (!Array.isArray(acceptedManifestSigners) || !acceptedManifestSigners.length) throw new Error("accepted manifest signers are required");
201
+ const signers = {};
202
+ for (const signer of acceptedManifestSigners) {
203
+ if (typeof signer.id !== "string" || !signer.id || typeof signer.subject !== "string" || !/^CN=/.test(signer.subject) || !/^[a-f0-9]{64}$/.test(signer.certificateSha256 ?? "")) {
204
+ throw new Error("accepted manifest signer requires id, certificate subject, and SHA-256 fingerprint");
205
+ }
206
+ signers[signer.id] = { subject: signer.subject, certificateSha256: signer.certificateSha256 };
207
+ }
208
+ if (healthAssertions === null && (typeof expectedStatusKind !== "string" || !expectedStatusKind)) {
209
+ throw new Error("expectedStatusKind or product-specific healthAssertions is required");
210
+ }
211
+ const assertions = healthAssertions ?? [
212
+ { path: "kind", equals: expectedStatusKind },
213
+ { path: "status", equals: expectedStatus },
214
+ ];
215
+ if (!Array.isArray(assertions) || !assertions.length) throw new Error("health assertions are required");
216
+ for (const assertion of assertions) {
217
+ if (!assertion || typeof assertion.path !== "string" || !/^[A-Za-z_][A-Za-z0-9_-]*(?:\.(?:[A-Za-z_][A-Za-z0-9_-]*|\d+))*$/.test(assertion.path)) {
218
+ throw new Error("health assertion requires safe JSON path");
219
+ }
220
+ const predicates = [Object.hasOwn(assertion, "equals"), assertion.nonempty === true, Object.hasOwn(assertion, "minCount")].filter(Boolean).length;
221
+ if (predicates !== 1) throw new Error("health assertion requires exactly one equals, nonempty, or minCount predicate");
222
+ if (Object.hasOwn(assertion, "minCount") && (!Number.isSafeInteger(assertion.minCount) || assertion.minCount < 0)) throw new Error("health minCount must be a nonnegative integer");
223
+ }
224
+ const contract = {
225
+ schemaVersion: 1,
226
+ generator: "rightkit-direct-bootstrap",
227
+ product,
228
+ repository,
229
+ bootstrapVersion,
230
+ installRootSubdir,
231
+ acceptedManifestSigners: signers,
232
+ executablePath: executablePath.replaceAll("\\", "/"),
233
+ activationArgs,
234
+ statusArgs,
235
+ healthAssertions: assertions,
236
+ preflightArgs,
237
+ };
238
+ // Bind generated contract metadata so a caller cannot pass a marker plus an
239
+ // arbitrary base64 payload and have it treated as a RightRelease bootstrap.
240
+ // This is not a replacement for CMS manifest signing; it is a structural
241
+ // integrity fence for the executable bootstrap source itself.
242
+ contract.contractSha256 = createHash("sha256").update(canonicalJson(contract)).digest("hex");
243
+ const contractB64 = Buffer.from(JSON.stringify(contract)).toString("base64");
244
+ const lines = [
245
+ "# rightkit-direct-bootstrap:v1",
246
+ "# generated contract: generator=rightkit-direct-bootstrap contractSha256-bound",
247
+ "[CmdletBinding()] param([string]$Version, [switch]$AllowDowngrade)",
248
+ "$ErrorActionPreference = 'Stop'",
249
+ "$ProgressPreference = 'SilentlyContinue'",
250
+ "$Contract = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(" + psQuote(contractB64) + ")) | ConvertFrom-Json",
251
+ "$AcceptedSigners = @{}; $Contract.acceptedManifestSigners.PSObject.Properties | ForEach-Object { $AcceptedSigners[$_.Name] = $_.Value }",
252
+ "$InstallRoot = Join-Path $env:LOCALAPPDATA " + psQuote(installRootSubdir),
253
+ "$VersionsRoot = Join-Path $InstallRoot 'versions'",
254
+ "$Current = Join-Path $InstallRoot 'current'",
255
+ "$PreviousLink = Join-Path $InstallRoot '.current-previous'",
256
+ "$NextLink = Join-Path $InstallRoot '.current-next'",
257
+ "$Journal = Join-Path $InstallRoot 'integration-journal.json'",
258
+ "function Expand-BootstrapArgs($Values, $ActiveVersionRoot, $ActiveVersion) { foreach ($Value in $Values) { ([string]$Value).Replace('{current}', $Current).Replace('{installRoot}', $InstallRoot).Replace('{versionRoot}', $ActiveVersionRoot).Replace('{version}', $ActiveVersion) } }",
259
+ "function Resolve-HealthPath($Value, [string]$JsonPath) { $CurrentValue = $Value; foreach ($Segment in $JsonPath.Split('.')) { if ($Segment -match '^\\d+$') { $Index = [int]$Segment; if ($null -eq $CurrentValue -or $Index -ge @($CurrentValue).Count) { throw ('Health path is missing: ' + $JsonPath) }; $CurrentValue = @($CurrentValue)[$Index] } else { $Property = $CurrentValue.PSObject.Properties[$Segment]; if ($null -eq $Property) { throw ('Health path is missing: ' + $JsonPath) }; $CurrentValue = $Property.Value } }; return $CurrentValue }",
260
+ "function Assert-Health($Health, [string]$ActiveVersion) { foreach ($Assertion in $Contract.healthAssertions) { $Actual = Resolve-HealthPath $Health ([string]$Assertion.path); if ($Assertion.PSObject.Properties.Name -contains 'equals') { $Expected = $Assertion.equals; if ($Expected -is [string]) { $Expected = $Expected.Replace('{version}', $ActiveVersion).Replace('{current}', $Current).Replace('{installRoot}', $InstallRoot) }; if ($Actual -ne $Expected) { throw ('Health assertion failed: ' + $Assertion.path) } } elseif ($Assertion.nonempty -eq $true) { if ($null -eq $Actual -or ([string]$Actual).Trim().Length -eq 0) { throw ('Health assertion failed: ' + $Assertion.path) } } elseif (@($Actual).Count -lt [int]$Assertion.minCount) { throw ('Health assertion failed: ' + $Assertion.path) } } }",
261
+ "New-Item -ItemType Directory -Force -Path $VersionsRoot | Out-Null",
262
+ "if (-not $Version) { $Version = ((Invoke-RestMethod " + psQuote("https://api.github.com/repos/" + repository + "/releases/latest") + ").tag_name -replace '^v','') }",
263
+ "if ($Version -notmatch '^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$') { throw 'Invalid stable release version' }",
264
+ "$PriorTarget = $null; if (Test-Path $Current) { $PriorTarget = (Get-Item $Current).Target }",
265
+ "$PriorVersion = if ($PriorTarget) { ((Split-Path ([string]$PriorTarget) -Leaf) -split '-')[0] } else { $null }",
266
+ "if ($PriorVersion -and -not $AllowDowngrade -and ([version]$Version -lt [version]$PriorVersion)) { throw 'Downgrade requires -AllowDowngrade' }",
267
+ "$ReleaseBase = " + psQuote("https://github.com/" + repository + "/releases/download/v") + " + $Version",
268
+ "$Work = Join-Path ([IO.Path]::GetTempPath()) (" + psQuote(product + "-install-") + " + [guid]::NewGuid())",
269
+ "New-Item -ItemType Directory -Force -Path $Work | Out-Null",
270
+ "$ManifestPath = Join-Path $Work 'release-manifest.json'; $SignaturePath = Join-Path $Work 'release-manifest.sig'; $ChecksumsPath = Join-Path $Work 'checksums.json'",
271
+ "Invoke-WebRequest ($ReleaseBase + '/release-manifest.json') -OutFile $ManifestPath",
272
+ "Invoke-WebRequest ($ReleaseBase + '/release-manifest.sig') -OutFile $SignaturePath",
273
+ "$ManifestBytes = [IO.File]::ReadAllBytes($ManifestPath); $SignatureBytes = [IO.File]::ReadAllBytes($SignaturePath)",
274
+ "Add-Type -AssemblyName System.Security; $Cms = New-Object Security.Cryptography.Pkcs.SignedCms((New-Object Security.Cryptography.Pkcs.ContentInfo(,$ManifestBytes)), $true); $Cms.Decode($SignatureBytes); $Cms.CheckSignature($true)",
275
+ "if ($Cms.SignerInfos.Count -ne 1) { throw 'Release manifest signature is invalid' }; $SignerCertificate = $Cms.SignerInfos[0].Certificate",
276
+ "$Sha256 = [Security.Cryptography.SHA256]::Create(); try { $SignerFingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($SignerCertificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
277
+ "$VerifiedKeyId = $null; foreach ($KeyId in $AcceptedSigners.Keys) { $Accepted = $AcceptedSigners[$KeyId]; if ($SignerCertificate.Subject -eq [string]$Accepted.subject -and $SignerFingerprint -eq [string]$Accepted.certificateSha256) { $VerifiedKeyId = $KeyId; break } }",
278
+ "if (-not $VerifiedKeyId) { throw 'Release manifest signer is not accepted' }",
279
+ "$Manifest = [Text.Encoding]::UTF8.GetString($ManifestBytes) | ConvertFrom-Json",
280
+ "if ($Manifest.signingKeyId -ne $VerifiedKeyId -or $Manifest.signatureAlgorithm -ne 'cms-sha256') { throw 'Release manifest signer identity mismatch' }",
281
+ "if ($Manifest.product -ne " + psQuote(product) + " -or $Manifest.version -ne $Version -or $Manifest.tag -ne ('v' + $Version)) { throw 'Release identity mismatch' }",
282
+ "if ([version]$Contract.bootstrapVersion -lt [version]$Manifest.minimumBootstrapVersion) { throw 'Bootstrap update required' }",
283
+ "$Machine = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString(); $Target = if ($Machine -eq 'Arm64') { 'windows-arm64' } elseif ($Machine -eq 'X64') { 'windows-x86_64' } else { throw ('Unsupported architecture: ' + $Machine) }",
284
+ "$Matches = @($Manifest.assets | Where-Object { $_.target -eq $Target }); if ($Matches.Count -ne 1) { throw 'Release has no unique exact target' }; $Asset = $Matches[0]",
285
+ "if ($Asset.url -ne ($ReleaseBase + '/' + $Asset.name)) { throw 'Asset URL is not exact immutable GitHub release URL' }",
286
+ "Invoke-WebRequest ($ReleaseBase + '/checksums.json') -OutFile $ChecksumsPath",
287
+ "$ChecksumHash = (Get-FileHash -Algorithm SHA256 $ChecksumsPath).Hash.ToLowerInvariant(); if ($ChecksumHash -ne $Manifest.checksumsSha256) { throw 'checksums.json is not manifest-bound' }",
288
+ "$Checksums = Get-Content -Raw $ChecksumsPath | ConvertFrom-Json; if ($Checksums.assets.($Asset.name) -ne $Asset.sha256) { throw 'Asset checksum differs from bound checksums' }",
289
+ "$Archive = Join-Path $Work $Asset.name; Invoke-WebRequest $Asset.url -OutFile $Archive",
290
+ "if ((Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant() -ne $Asset.sha256) { throw 'Archive checksum mismatch' }",
291
+ "if ($Checksums.assets.($Asset.provenanceName) -ne $Asset.provenanceSha256 -or $Checksums.assets.($Asset.sbomName) -ne $Asset.sbomSha256) { throw 'Bound provenance/SBOM checksums are missing' }",
292
+ "$ProvenancePath = Join-Path $Work $Asset.provenanceName; $SbomPath = Join-Path $Work $Asset.sbomName",
293
+ "Invoke-WebRequest $Asset.provenanceUrl -OutFile $ProvenancePath; Invoke-WebRequest $Asset.sbomUrl -OutFile $SbomPath",
294
+ "if ((Get-FileHash -Algorithm SHA256 $ProvenancePath).Hash.ToLowerInvariant() -ne $Asset.provenanceSha256) { throw 'Provenance checksum mismatch' }",
295
+ "if ((Get-FileHash -Algorithm SHA256 $SbomPath).Hash.ToLowerInvariant() -ne $Asset.sbomSha256) { throw 'SBOM checksum mismatch' }",
296
+ "$Stage = Join-Path $Work 'stage'; New-Item -ItemType Directory -Force -Path $Stage | Out-Null",
297
+ "Add-Type -AssemblyName System.IO.Compression.FileSystem; $Zip = [IO.Compression.ZipFile]::OpenRead($Archive); try { foreach ($Entry in $Zip.Entries) { $Dest = [IO.Path]::GetFullPath((Join-Path $Stage $Entry.FullName)); if (-not $Dest.StartsWith(([IO.Path]::GetFullPath($Stage) + [IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase)) { throw 'Archive path escapes staging root' } } } finally { $Zip.Dispose() }",
298
+ "Expand-Archive -Path $Archive -DestinationPath $Stage -Force",
299
+ "$VersionRoot = Join-Path $VersionsRoot ($Version + '-' + $Asset.sha256.Substring(0,12) + '-' + [guid]::NewGuid()); Move-Item $Stage $VersionRoot",
300
+ "$VersionExe = Join-Path $VersionRoot ($Asset.executablePath -replace '/', [IO.Path]::DirectorySeparatorChar); if (-not (Test-Path $VersionExe)) { throw 'Release executable missing' }",
301
+ "$Authenticode = Get-AuthenticodeSignature $VersionExe; if ($Asset.nativeSignaturePolicy -eq 'authenticode-valid' -and $Authenticode.Status -ne 'Valid') { throw 'Native signature invalid' }",
302
+ "$ActivationArgsTemplate = " + psArray(activationArgs),
303
+ "$StatusArgsTemplate = " + psArray(statusArgs),
304
+ "$PreflightArgsTemplate = " + psArray(preflightArgs),
305
+ "$PreflightArgs = @(Expand-BootstrapArgs $PreflightArgsTemplate $VersionRoot $Version)",
306
+ "$PriorStatus = $null; if ($PriorTarget) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); try { $PriorStatus = (& $PriorExe @PriorStatusArgs | Out-String).Trim() } catch {} }",
307
+ "@{ schemaVersion=1; priorTarget=$PriorTarget; priorVersion=$PriorVersion; targetVersion=$Version; priorStatus=$PriorStatus } | ConvertTo-Json -Depth 8 | Set-Content -Encoding UTF8 $Journal",
308
+ "if ($PreflightArgs.Count) { & $VersionExe @PreflightArgs; if ($LASTEXITCODE -ne 0) { throw 'Activation preflight failed' } }",
309
+ "if (Test-Path $NextLink) { Remove-Item -Force $NextLink }; if (Test-Path $PreviousLink) { Remove-Item -Force $PreviousLink }",
310
+ "New-Item -ItemType Junction -Path $NextLink -Target $VersionRoot | Out-Null",
311
+ "$Switched = $false; $PriorDetached = $false",
312
+ "try {",
313
+ " if (Test-Path $Current) { Move-Item $Current $PreviousLink; $PriorDetached = $true }; Move-Item $NextLink $Current; $Switched = $true",
314
+ " $StableExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")),
315
+ " $ActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate $VersionRoot $Version); $StatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate $VersionRoot $Version)",
316
+ " & $StableExe @ActivationArgs; if ($LASTEXITCODE -ne 0) { throw 'Activation failed' }",
317
+ " $HealthText = (& $StableExe @StatusArgs | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Health command failed' }; $Health = $HealthText | ConvertFrom-Json",
318
+ " Assert-Health $Health $Version",
319
+ " $StableBin = Split-Path $StableExe -Parent; $UserPath = [Environment]::GetEnvironmentVariable('Path','User'); if (-not (($UserPath -split ';') -contains $StableBin)) { [Environment]::SetEnvironmentVariable('Path', (($UserPath.TrimEnd(';') + ';' + $StableBin).Trim(';')), 'User') }",
320
+ // Journal cleanup must succeed before discarding the prior pointer. If
321
+ // cleanup fails, catch still has a rollback anchor to restore.
322
+ " Remove-Item -Force $Journal; if (Test-Path $PreviousLink) { Remove-Item -Force $PreviousLink }; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
323
+ "} catch {",
324
+ " if (($Switched -or $PriorDetached) -and (Test-Path $Current)) { Remove-Item -Force $Current }; if ($PriorDetached -and (Test-Path $PreviousLink)) { Move-Item $PreviousLink $Current }",
325
+ " if ($PriorTarget -and (Test-Path $Current)) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate ([string]$PriorTarget) $PriorVersion); $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); & $PriorExe @PriorActivationArgs; if ($LASTEXITCODE -ne 0) { throw 'Rollback activation failed' }; $PriorHealthText = (& $PriorExe @PriorStatusArgs | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Rollback health failed' }; $PriorHealth = $PriorHealthText | ConvertFrom-Json; try { Assert-Health $PriorHealth $PriorVersion } catch { throw 'Prior health was not restored' } }",
326
+ " if (Test-Path $VersionRoot) { Remove-Item -Recurse -Force $VersionRoot }; throw",
327
+ "} finally { if (Test-Path $Work) { Remove-Item -Recurse -Force $Work } }",
328
+ ];
329
+ const body = lines.join("\r\n") + "\r\n";
330
+ const sourceSha256 = createHash("sha256").update(body).digest("hex");
331
+ return `${body}# source-sha256:${sourceSha256}\r\n`;
332
+ }
333
+
334
+ export function validatePowerShellBootstrap(script, expected = {}) {
335
+ const required = [
336
+ "# rightkit-direct-bootstrap:v1",
337
+ "Release manifest signature is invalid",
338
+ "certificateSha256",
339
+ "CheckSignature($true)",
340
+ "checksums.json is not manifest-bound",
341
+ "Provenance checksum mismatch",
342
+ "SBOM checksum mismatch",
343
+ "New-Item -ItemType Junction",
344
+ "integration-journal.json",
345
+ "Rollback activation failed",
346
+ "Prior health was not restored",
347
+ "Downgrade requires -AllowDowngrade",
348
+ "Get-AuthenticodeSignature",
349
+ "generator",
350
+ "contractSha256",
351
+ "source-sha256:",
352
+ ];
353
+ const errors = required.filter((marker) => !script.includes(marker)).map((marker) => "missing bootstrap contract marker: " + marker);
354
+ const encoded = script.match(/FromBase64String\('([^']+)'\)/)?.[1];
355
+ let contract = {};
356
+ try { contract = encoded ? JSON.parse(Buffer.from(encoded, "base64").toString()) : {}; } catch { errors.push("bootstrap contract is not valid encoded JSON"); }
357
+ const { contractSha256, ...unsignedContract } = contract;
358
+ if (contract.schemaVersion !== 1 || contract.generator !== "rightkit-direct-bootstrap" || contract.bootstrapVersion == null || contract.repository == null || !/^[a-f0-9]{64}$/.test(contractSha256 ?? "") || contractSha256 !== createHash("sha256").update(canonicalJson(unsignedContract)).digest("hex")) errors.push("bootstrap encoded contract is incomplete or tampered");
359
+ try {
360
+ const acceptedManifestSigners = Object.entries(contract.acceptedManifestSigners ?? {}).map(([id, signer]) => ({ id, ...signer }));
361
+ const regenerated = renderPowerShellBootstrap({
362
+ product: contract.product,
363
+ repository: contract.repository,
364
+ bootstrapVersion: contract.bootstrapVersion,
365
+ acceptedManifestSigners,
366
+ installRootSubdir: contract.installRootSubdir,
367
+ executablePath: contract.executablePath,
368
+ activationArgs: contract.activationArgs,
369
+ statusArgs: contract.statusArgs,
370
+ healthAssertions: contract.healthAssertions,
371
+ preflightArgs: contract.preflightArgs,
372
+ });
373
+ if (regenerated !== script) errors.push("bootstrap source does not match canonical generated output");
374
+ } catch {
375
+ errors.push("bootstrap contract cannot be regenerated");
376
+ }
377
+ const sourceMarker = script.match(/# source-sha256:([a-f0-9]{64})\r?\n?$/);
378
+ const markerOffset = script.lastIndexOf("# source-sha256:");
379
+ if (!sourceMarker || markerOffset < 0 || createHash("sha256").update(script.slice(0, markerOffset)).digest("hex") !== sourceMarker[1]) errors.push("bootstrap source digest is missing or tampered");
380
+ if (expected.product && contract.product !== expected.product) errors.push("bootstrap product identity missing");
381
+ if (expected.acceptedSignerIds) for (const id of expected.acceptedSignerIds) if (!Object.hasOwn(contract.acceptedManifestSigners ?? {}, id)) errors.push("accepted manifest signer missing: " + id);
382
+ return { valid: errors.length === 0, errors };
383
+ }
384
+
385
+ export function planBootstrapPublication({ product, bootstrapVersion, scriptPath, stableKey = `${product}/install.ps1` }) {
386
+ if (!/^[a-z][a-z0-9-]+$/.test(product ?? "")) throw new Error("invalid bootstrap product");
387
+ if (!VERSION.test(bootstrapVersion)) throw new Error("invalid bootstrap version");
388
+ if (stableKey !== `${product}/install.ps1`) throw new Error("bootstrap stable key must be product/install.ps1");
389
+ const script = statSync(scriptPath);
390
+ if (!script.isFile() || script.size < 1 || script.size > MAX_BOOTSTRAP_BYTES || scriptPath.split(/[\\/]/).at(-1) !== "install.ps1") throw new Error("bootstrap must be one small install.ps1 file");
391
+ const validation = validatePowerShellBootstrap(readFileSync(scriptPath, "utf8"), { product });
392
+ if (!validation.valid) throw new Error("bootstrap script violates generated RightRelease contract: " + validation.errors.join("; "));
393
+ const versionedKey = `${product}/versions/${bootstrapVersion}/install.ps1`;
394
+ return {
395
+ product,
396
+ bootstrapVersion,
397
+ bucket: "rightapps-downloads",
398
+ stableKey,
399
+ versionedKey,
400
+ scriptPath: resolve(scriptPath),
401
+ sha256: sha256File(scriptPath),
402
+ };
403
+ }
404
+
405
+ export function createWranglerR2Client({ commandRunner = spawnSync, wranglerRunner = ["pnpm", ["dlx", "wrangler@4"]] } = {}) {
406
+ const run = (args) => {
407
+ const [command, prefix] = wranglerRunner;
408
+ const result = commandRunner(command, [...prefix, "r2", "object", ...args, "--remote"], { encoding: "utf8", windowsHide: true });
409
+ if (result?.status !== 0) throw new Error(`R2 operation failed: ${String(result?.stderr ?? "").trim()}`);
410
+ };
411
+ return {
412
+ put(bucket, key, path) { run(["put", `${bucket}/${key}`, "--file", path]); },
413
+ get(bucket, key, path) { run(["get", `${bucket}/${key}`, "--file", path]); },
414
+ };
415
+ }
416
+
417
+ export function publishBootstrapPlan(plan, r2, { verificationPath }) {
418
+ validateBootstrapPublicationPlan(plan);
419
+ r2.put(plan.bucket, plan.versionedKey, plan.scriptPath);
420
+ r2.get(plan.bucket, plan.versionedKey, verificationPath);
421
+ if (sha256File(verificationPath) !== plan.sha256) throw new Error("immutable bootstrap remote verification failed");
422
+ r2.put(plan.bucket, plan.stableKey, plan.scriptPath);
423
+ r2.get(plan.bucket, plan.stableKey, verificationPath);
424
+ if (sha256File(verificationPath) !== plan.sha256) throw new Error("stable bootstrap remote verification failed");
425
+ return { ...plan, verified: true };
426
+ }
427
+
428
+ export function validateBootstrapPublicationPlan(plan) {
429
+ if (!plan || !/^[a-z][a-z0-9-]+$/.test(plan.product ?? "")) throw new Error("bootstrap publication product is invalid");
430
+ if (!VERSION.test(plan.bootstrapVersion ?? "")) throw new Error("bootstrap publication version is invalid");
431
+ if (plan.bucket !== "rightapps-downloads") throw new Error("bootstrap publication bucket is not branded public R2");
432
+ if (plan.stableKey !== `${plan.product}/install.ps1`) throw new Error("bootstrap stable key is outside product boundary");
433
+ if (plan.versionedKey !== `${plan.product}/versions/${plan.bootstrapVersion}/install.ps1`) throw new Error("bootstrap versioned key is outside product boundary");
434
+ if (String(plan.stableKey).includes("..") || String(plan.versionedKey).includes("..")) throw new Error("bootstrap key traversal is forbidden");
435
+ return plan;
436
+ }
437
+
438
+ export async function runInstallTransaction({
439
+ targetVersion,
440
+ currentVersion,
441
+ allowDowngrade = false,
442
+ stage,
443
+ verify,
444
+ preflight = async () => {},
445
+ snapshotIntegrations,
446
+ switchCurrent,
447
+ activate,
448
+ verifyHealth,
449
+ restoreCurrent = async () => {},
450
+ restoreIntegrations = async () => {},
451
+ cleanup = async () => {},
452
+ }) {
453
+ if (!VERSION.test(targetVersion)) throw new Error("invalid target version");
454
+ if (currentVersion && !allowDowngrade && targetVersion.localeCompare(currentVersion, undefined, { numeric: true }) < 0) {
455
+ throw new Error("downgrade requires explicit allowDowngrade");
456
+ }
457
+ const integrationJournal = await snapshotIntegrations();
458
+ let staged;
459
+ let switched = false;
460
+ try {
461
+ staged = await stage();
462
+ await verify(staged);
463
+ await preflight(staged);
464
+ // switchCurrent is a compound operation (detach prior, install next). It
465
+ // may throw after changing state, so recovery must run even when its
466
+ // promise rejects before returning.
467
+ switched = true;
468
+ await switchCurrent(staged);
469
+ await activate(staged);
470
+ const health = await verifyHealth(staged);
471
+ if (!health || health.status !== "complete") throw new Error("installed product health is incomplete");
472
+ return { status: "complete", targetVersion, health };
473
+ } catch (error) {
474
+ try {
475
+ if (switched) await restoreCurrent();
476
+ } finally {
477
+ await restoreIntegrations(integrationJournal);
478
+ }
479
+ await cleanup(staged);
480
+ throw error;
481
+ }
482
+ }