@rightkit/release 0.2.135 → 0.2.136

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +62 -0
  2. package/addon-command.mjs +53 -34
  3. package/apple-profile-renew.mjs +30 -12
  4. package/artifact-storage.mjs +47 -10
  5. package/background-process.mjs +18 -0
  6. package/build-release.mjs +1 -1
  7. package/cache-command.mjs +4 -3
  8. package/cargo-contract.mjs +1 -1
  9. package/cargo-guard.mjs +4 -3
  10. package/cargo-registry-readback.mjs +51 -0
  11. package/cargo-target.mjs +3 -1
  12. package/cli/right-release.mjs +26 -0
  13. package/codesign-environment.mjs +26 -2
  14. package/codesign-shim.mjs +2 -1
  15. package/committed-source.mjs +3 -3
  16. package/development.mjs +7 -7
  17. package/direct-bootstrap.mjs +19 -9
  18. package/dmg-build.mjs +117 -0
  19. package/dmg-layout.mjs +123 -0
  20. package/github-release.mjs +74 -37
  21. package/heavy-command.mjs +3 -2
  22. package/install-app.mjs +358 -0
  23. package/inventory-check.mjs +980 -0
  24. package/macos-signing-session.mjs +3 -0
  25. package/managed-cargo.mjs +23 -19
  26. package/managed-host-secrets.mjs +30 -0
  27. package/managed-roots.mjs +39 -0
  28. package/model-promote.mjs +31 -37
  29. package/notary-auth.mjs +4 -0
  30. package/osascript-guard.mjs +24 -0
  31. package/ownership-approval-command.mjs +15 -4
  32. package/ownership-approval.mjs +114 -49
  33. package/ownership-availability.mjs +25 -0
  34. package/ownership-catalog-config.json +18 -0
  35. package/ownership-catalog.mjs +94 -0
  36. package/ownership-command.mjs +17 -10
  37. package/ownership-declaration.json +15 -0
  38. package/ownership-default-policy.json +1 -11
  39. package/ownership-evidence.mjs +10 -8
  40. package/ownership-git-inventory.mjs +29 -1
  41. package/ownership-helper-paths.json +2 -2
  42. package/ownership-hooks.mjs +107 -0
  43. package/ownership-internal-owners.json +44 -0
  44. package/ownership-key-authority.mjs +112 -7
  45. package/ownership-key-command.mjs +76 -19
  46. package/ownership-key-trust.mjs +97 -9
  47. package/ownership-lockfiles.mjs +311 -0
  48. package/ownership-report-command.mjs +12 -3
  49. package/ownership-report.mjs +20 -17
  50. package/ownership-repositories.json +19 -0
  51. package/ownership-scan-worker.mjs +96 -11
  52. package/ownership-source.mjs +39 -0
  53. package/ownership-staged.mjs +193 -0
  54. package/ownership-verdict.mjs +74 -0
  55. package/ownership.mjs +215 -107
  56. package/package.json +1 -1
  57. package/protected-credentials.mjs +111 -0
  58. package/prune-r2.mjs +4 -3
  59. package/pub-upload.mjs +3 -85
  60. package/publication-artifacts.mjs +108 -0
  61. package/publication-gate.mjs +28 -7
  62. package/publish-cargo.mjs +47 -16
  63. package/publish-swift.mjs +46 -17
  64. package/publish-update.mjs +61 -39
  65. package/r2-verified-upload.mjs +41 -0
  66. package/registry-parity.mjs +5 -3
  67. package/release-state.mjs +1 -0
  68. package/release-token.mjs +2 -0
  69. package/rightkit-drift.mjs +3 -2
  70. package/rightkit-ownership.json +2028 -139
  71. package/rightkit-signing-agent.mjs +5 -3
  72. package/rightkit-sync.mjs +6 -2
  73. package/rightkit-versions.json +213 -103
  74. package/runtime-artifact-manifest.mjs +20 -6
  75. package/sign-updater.mjs +5 -0
  76. package/signing-agent-client.mjs +2 -1
  77. package/signing-agent-install.mjs +3 -2
  78. package/standalone-clone-verify.mjs +3 -2
  79. package/tauri-bundle-marker.mjs +3 -2
  80. package/upload-large.mjs +36 -82
  81. package/verifier.mjs +5 -1
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash } from "node:crypto";
3
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import path from "node:path";
5
+ import os from "node:os";
6
+ import { readVerifiedArtifact, assertArtifactUnchanged, digestBytes, verifyArtifactFile } from "./publication-artifacts.mjs";
5
7
  import { spawnSync } from "./background-process.mjs";
6
8
  import { fileURLToPath, pathToFileURL } from "node:url";
7
9
  import { verifySealedRelease } from "./release-state.mjs";
@@ -26,11 +28,20 @@ export function prepareGitHubRelease({ repoRoot, releaseId, platform, repo, stat
26
28
  mkdirSync(releaseState, { recursive: true });
27
29
  const manifestAsset = path.join(releaseState, `${sealed.manifest.app}-${sealed.manifest.version}-${platform}-release-manifest.json`);
28
30
  const checksumsAsset = path.join(releaseState, `${sealed.manifest.app}-${sealed.manifest.version}-${platform}-SHA256SUMS.txt`);
29
- copyFileSync(sealed.manifestPath, manifestAsset);
30
- writeFileSync(checksumsAsset, `${sealed.manifest.files.map((file) => `${file.sha256} ${file.name}`).join("\n")}\n`);
31
+ const manifestBytes = Buffer.from(`${JSON.stringify(sealed.manifest, null, 2)}\n`);
32
+ const checksumsBytes = Buffer.from(`${sealed.manifest.files.map((file) => `${file.sha256} ${file.name}`).join("\n")}\n`);
33
+ writeFileSync(manifestAsset, manifestBytes);
34
+ writeFileSync(checksumsAsset, checksumsBytes);
31
35
  const installer = path.join(sealed.sealedDir, installers[0].name);
32
36
  const assets = [...sealed.manifest.files.map((file) => path.join(sealed.sealedDir, file.name)), manifestAsset, checksumsAsset];
37
+ const publicationArtifacts = new Map(sealed.manifest.files.map((entry) => {
38
+ const file = path.join(sealed.sealedDir, entry.name);
39
+ return [file, verifyArtifactFile(file, entry)];
40
+ }));
41
+ publicationArtifacts.set(manifestAsset, readVerifiedArtifact(manifestAsset, { sha256: digestBytes(manifestBytes) }));
42
+ publicationArtifacts.set(checksumsAsset, readVerifiedArtifact(checksumsAsset, { sha256: digestBytes(checksumsBytes) }));
33
43
  return {
44
+ publicationArtifacts,
34
45
  sealed,
35
46
  installer,
36
47
  assets,
@@ -131,8 +142,7 @@ export function prepareGitHubDirectRelease({
131
142
  signaturePlatform: effectiveSignaturePlatform,
132
143
  };
133
144
  Object.defineProperty(plan, "signatureCommandRunner", { value: effectiveSignatureRunner, enumerable: false });
134
- validateGitHubDirectReleasePlan(plan, { commandRunner: effectiveSignatureRunner, platform: effectiveSignaturePlatform });
135
- return plan;
145
+ return validateGitHubDirectReleasePlan(plan, { commandRunner: effectiveSignatureRunner, platform: effectiveSignaturePlatform });
136
146
  }
137
147
 
138
148
  export const prepareGitHubPortableRelease = prepareGitHubDirectRelease;
@@ -153,16 +163,30 @@ export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.si
153
163
  if (!existsSync(file) || !statSync(file).isFile()) throw new Error(`direct release payload missing: ${file}`);
154
164
  }
155
165
  if (statSync(signatureFile).size < 1) throw new Error("detached release manifest signature is missing");
156
- const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
166
+ const manifestArtifact = readVerifiedArtifact(manifestFile, { sha256: plan.publicationArtifacts?.get(manifestFile)?.sha256 ?? sha256(manifestFile) });
167
+ const signatureArtifact = readVerifiedArtifact(signatureFile, { sha256: plan.publicationArtifacts?.get(signatureFile)?.sha256 ?? sha256(signatureFile) });
168
+ const manifest = JSON.parse(manifestArtifact.bytes);
157
169
  if (manifest.schemaVersion !== 1 || manifest.kind !== "rightkit-direct-release-manifest" || !STABLE_SEMVER_RE.test(manifest.version ?? "") || manifest.tag !== `v${manifest.version}`) {
158
170
  throw new Error("invalid direct release manifest");
159
171
  }
160
172
  if (plan.tag !== manifest.tag) throw new Error("direct GitHub release tag does not match manifest");
161
173
  if (manifest.signingKeyId !== RIGHTRELEASE_MANIFEST_SIGNER.id || manifest.signatureAlgorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm || manifest.signatureProvider !== RIGHTRELEASE_MANIFEST_SIGNER.provider || manifest.signatureProviderVersion !== RIGHTRELEASE_MANIFEST_SIGNER.providerVersion) throw new Error("direct release manifest signer contract is invalid");
162
- verifyManifestCatalogSignature({ manifestPath: manifestFile, signaturePath: signatureFile, expectedSigner: plan.signer, commandRunner, platform });
174
+ // Catalog verification operates on snapshots of the exact captured authority bytes.
175
+ const snapshot = mkdtempSync(path.join(os.tmpdir(), "rightkit-direct-authority-"));
176
+ try {
177
+ const manifestSnapshot = path.join(snapshot, path.basename(manifestFile));
178
+ const signatureSnapshot = path.join(snapshot, path.basename(signatureFile));
179
+ writeFileSync(manifestSnapshot, manifestArtifact.bytes);
180
+ writeFileSync(signatureSnapshot, signatureArtifact.bytes);
181
+ verifyManifestCatalogSignature({ manifestPath: manifestSnapshot, signaturePath: signatureSnapshot, expectedSigner: plan.signer, commandRunner, platform });
182
+ readVerifiedArtifact(manifestSnapshot, manifestArtifact);
183
+ readVerifiedArtifact(signatureSnapshot, signatureArtifact);
184
+ } finally { rmSync(snapshot, { recursive: true, force: true }); }
185
+ assertArtifactUnchanged(manifestArtifact);
186
+ assertArtifactUnchanged(signatureArtifact);
163
187
  if (!SHA256.test(manifest.checksumsSha256 ?? "")) throw new Error("direct release checksums digest is missing");
164
- if (sha256(checksumsFile) !== manifest.checksumsSha256) throw new Error("checksums.json is not manifest-bound");
165
- const checksums = JSON.parse(readFileSync(checksumsFile, "utf8"));
188
+ const checksumsArtifact = readVerifiedArtifact(checksumsFile, { sha256: manifest.checksumsSha256 });
189
+ const checksums = JSON.parse(checksumsArtifact.bytes);
166
190
  if (checksums.schemaVersion !== 1 || checksums.algorithm !== "sha256" || !checksums.assets || typeof checksums.assets !== "object" || Array.isArray(checksums.assets)) {
167
191
  throw new Error("invalid direct release checksums");
168
192
  }
@@ -191,14 +215,18 @@ export function validateGitHubDirectReleasePlan(plan, { commandRunner = plan?.si
191
215
  addExpected({ name: asset.provenanceName, sha256: asset.provenanceSha256 }, "provenance");
192
216
  addExpected({ name: asset.sbomName, sha256: asset.sbomSha256 }, "SBOM");
193
217
  }
218
+ const publicationArtifacts = new Map([[manifestFile, manifestArtifact], [signatureFile, signatureArtifact], [checksumsFile, checksumsArtifact]]);
194
219
  for (const [name, evidence] of expected) {
195
220
  const file = payloadByName.get(name);
196
221
  if (!file) throw new Error(`direct release evidence is not attached: ${name}`);
197
222
  if (evidence.size !== undefined && (!Number.isSafeInteger(evidence.size) || evidence.size < 1 || statSync(file).size !== evidence.size)) throw new Error(`direct release archive size mismatch: ${name}`);
198
- if (sha256(file) !== evidence.digest) throw new Error(`direct release payload hash mismatch: ${name}`);
223
+ publicationArtifacts.set(file, verifyArtifactFile(file, { sha256: evidence.digest, sizeBytes: evidence.size }));
199
224
  if (checksums.assets[name] !== evidence.digest) throw new Error(`direct release checksum mismatch: ${name}`);
200
225
  }
201
- return { ...plan, manifest, checksums };
226
+ for (const file of payloadByName.values()) if (!publicationArtifacts.has(file)) throw new Error(`direct release payload lacks signed evidence: ${path.basename(file)}`);
227
+ const result = { ...plan, manifest, checksums, publicationArtifacts };
228
+ Object.defineProperty(result, "signatureCommandRunner", { value: commandRunner, enumerable: false });
229
+ return result;
202
230
  }
203
231
 
204
232
  export function repositoryFromRemote(repoRoot) {
@@ -218,17 +246,19 @@ export function prepareGitHubAddonRelease({ repoRoot, config, configRoot = repoR
218
246
  if (!existsSync(manifestPath)) throw new Error(`sealed add-on manifest not found: ${manifestPath}`);
219
247
  const manifest = validateAddonManifest(JSON.parse(readFileSync(manifestPath, "utf8")));
220
248
  if (manifest.addon !== config.addon || manifest.version !== config.version || manifest.platform !== platform || manifest.commit !== commit) throw new Error("sealed add-on identity mismatch");
249
+ const publicationArtifacts = new Map();
221
250
  const assets = manifest.files.map((file) => {
222
251
  const asset = path.join(sealedDir, file.name);
223
- if (!existsSync(asset) || statSync(asset).size !== file.sizeBytes || sha256(asset) !== file.sha256) throw new Error(`sealed add-on file mismatch: ${file.name}`);
252
+ publicationArtifacts.set(asset, verifyArtifactFile(asset, file));
224
253
  return asset;
225
254
  });
226
255
  const digest = addonManifestSha256(manifest);
256
+ publicationArtifacts.set(manifestPath, readVerifiedArtifact(manifestPath, { sha256: digest }));
227
257
  const tag = `addon-${manifest.addon}-v${manifest.version}-${platform}-sha256-${digest}`;
228
258
  const stateDir = path.join(repoRoot, ".right-release", "state", "addons", manifest.addon, manifest.version, commit.slice(0, 8), platform, "github");
229
259
  mkdirSync(stateDir, { recursive: true });
230
260
  return {
231
- kind: "addon", manifest, manifestPath, sealedDir, assets: [...assets, manifestPath], tag,
261
+ publicationArtifacts, kind: "addon", manifest, manifestPath, sealedDir, assets: [...assets, manifestPath], tag,
232
262
  title: `${manifest.addon} ${manifest.version} ${platform} add-on`,
233
263
  notes: [`Signed ${platform === "mac" ? "macOS" : "Windows"} add-on component set.`, "", `Build commit: \`${manifest.commit}\``, `Manifest SHA-256: \`${digest}\``].join("\n"),
234
264
  notesFile: path.join(stateDir, "release-notes.md"),
@@ -236,8 +266,13 @@ export function prepareGitHubAddonRelease({ repoRoot, config, configRoot = repoR
236
266
  };
237
267
  }
238
268
 
239
- export function publishGitHubRelease(plan, { repo, dryRun = false, run = runCommand, signatureCommandRunner = plan?.signatureCommandRunner ?? plan?.cmsCommandRunner, signaturePlatform = plan?.signaturePlatform ?? plan?.cmsPlatform ?? process.platform } = {}) {
240
- if (plan.kind === "direct-release") validateGitHubDirectReleasePlan(plan, { commandRunner: signatureCommandRunner ?? spawnSync, platform: signaturePlatform });
269
+ export function publishGitHubRelease(plan, { repo, dryRun = false, run = runCommand, signatureCommandRunner = plan?.signatureCommandRunner ?? plan?.cmsCommandRunner, signaturePlatform = plan?.signaturePlatform ?? plan?.cmsPlatform ?? process.platform, trustCommandRunner = spawnSync } = {}) {
270
+ if (plan.kind === "direct-release") plan = validateGitHubDirectReleasePlan(plan, { commandRunner: signatureCommandRunner ?? spawnSync, platform: signaturePlatform });
271
+ if (!(plan.publicationArtifacts instanceof Map) || plan.assets.some((file) => !plan.publicationArtifacts.has(file))) throw new Error("GitHub publication requires sealed or signed hash evidence for every asset");
272
+ for (const artifact of plan.publicationArtifacts.values()) {
273
+ if (artifact.bytes) assertArtifactUnchanged(artifact);
274
+ else verifyArtifactFile(artifact.file, artifact);
275
+ }
241
276
  const visibility = JSON.parse(run("gh", ["repo", "view", repo, "--json", "visibility"]));
242
277
  if (visibility.visibility !== "PUBLIC" && !(plan.kind !== "addon" && visibility.visibility === "PRIVATE")) {
243
278
  throw new Error(`GitHub releases require a public or private repository: ${repo}`);
@@ -246,16 +281,16 @@ export function publishGitHubRelease(plan, { repo, dryRun = false, run = runComm
246
281
  // Direct payloads are already sealed by the signed manifest contract. Do
247
282
  // not route them through desktop-installer trust checks.
248
283
  } else if (!dryRun) {
249
- if (plan.kind === "addon") verifyAddonTrust(plan);
250
- else verifyPlatformTrust(plan);
284
+ if (plan.kind === "addon") verifyAddonTrust(plan, trustCommandRunner);
285
+ else verifyPlatformTrust(plan, trustCommandRunner);
251
286
  }
252
287
  const existing = run("gh", ["release", "view", plan.tag, "--repo", repo, "--json", "tagName"], { allowFailure: true });
253
288
  if (dryRun) return { status: existing.ok ? plan.kind === "addon" ? "would-verify" : "would-update" : "would-create", tag: plan.tag, assets: plan.assets };
254
289
  if (existing.ok) {
255
- const missing = plan.assets.filter((asset) => inspectRemoteAsset(plan.tag, repo, asset, run) === "missing");
290
+ const missing = plan.assets.filter((asset) => inspectRemoteAsset(plan.tag, repo, asset, run, plan.publicationArtifacts.get(asset)) === "missing");
256
291
  if (missing.length === 0) return { status: "already-verified", tag: plan.tag, assets: plan.assets, manifestUrl: plan.manifestUrl };
257
292
  run("gh", ["release", "upload", plan.tag, ...missing, "--repo", repo]);
258
- for (const asset of plan.assets) verifyRemoteAsset(plan.tag, repo, asset, run);
293
+ for (const asset of plan.assets) verifyRemoteAsset(plan.tag, repo, asset, run, plan.publicationArtifacts.get(asset));
259
294
  return { status: "resumed-verified", tag: plan.tag, assets: plan.assets, manifestUrl: plan.manifestUrl };
260
295
  }
261
296
  if (!existing.ok) {
@@ -265,35 +300,35 @@ export function publishGitHubRelease(plan, { repo, dryRun = false, run = runComm
265
300
  rmSync(notesFile, { force: true });
266
301
  }
267
302
  run("gh", ["release", "upload", plan.tag, ...plan.assets, "--repo", repo]);
268
- for (const asset of plan.assets) verifyRemoteAsset(plan.tag, repo, asset, run);
303
+ for (const asset of plan.assets) verifyRemoteAsset(plan.tag, repo, asset, run, plan.publicationArtifacts.get(asset));
269
304
  return { status: "verified", tag: plan.tag, assets: plan.assets, manifestUrl: plan.manifestUrl };
270
305
  }
271
306
 
272
- function verifyAddonTrust(plan) {
307
+ function verifyAddonTrust(plan, commandRunner) {
273
308
  for (const file of plan.manifest.files.filter((entry) => entry.executable)) {
274
309
  const executable = path.join(plan.sealedDir, file.name);
275
310
  if (plan.manifest.platform === "mac") {
276
- const output = runCombined("codesign", ["-dv", "--verbose=4", executable]);
311
+ const output = runCombined("codesign", ["-dv", "--verbose=4", executable], commandRunner);
277
312
  if (!/TeamIdentifier=6KLGD3LLKF/.test(output)) throw new Error(`codesign team mismatch: ${file.name}`);
278
- runCommand("codesign", ["--verify", "--strict", "--verbose=2", executable]);
279
- } else runCommand(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), "sign-windows.mjs"), "--verify-only", executable]);
313
+ runCombined("codesign", ["--verify", "--strict", "--verbose=2", executable], commandRunner);
314
+ } else runCombined(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), "sign-windows.mjs"), "--verify-only", executable], commandRunner);
280
315
  }
281
316
  }
282
317
 
283
- function verifyPlatformTrust(plan) {
318
+ export function verifyPlatformTrust(plan, commandRunner = spawnSync) {
284
319
  if (plan.sealed.manifest.platform === "win") {
285
- runCommand(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), "sign-windows.mjs"), "--verify-only", plan.installer]);
320
+ runCombined(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), "sign-windows.mjs"), "--verify-only", plan.installer], commandRunner);
286
321
  return;
287
322
  }
288
- runCommand("spctl", ["--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=2", plan.installer]);
289
- runCommand("xcrun", ["stapler", "validate", plan.installer]);
323
+ runCombined("spctl", ["--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=2", plan.installer], commandRunner);
324
+ runCombined("xcrun", ["stapler", "validate", plan.installer], commandRunner);
290
325
  }
291
326
 
292
- function verifyRemoteAsset(tag, repo, asset, run) {
293
- if (inspectRemoteAsset(tag, repo, asset, run) !== "match") throw new Error(`GitHub release asset missing: ${path.basename(asset)}`);
327
+ function verifyRemoteAsset(tag, repo, asset, run, evidence) {
328
+ if (inspectRemoteAsset(tag, repo, asset, run, evidence) !== "match") throw new Error(`GitHub release asset missing; inspect remote asset ${repo}/${tag}/${path.basename(asset)}`);
294
329
  }
295
330
 
296
- function inspectRemoteAsset(tag, repo, asset, run) {
331
+ function inspectRemoteAsset(tag, repo, asset, run, evidence) {
297
332
  const verifyDir = `${path.dirname(asset)}/verify-${path.basename(asset)}`;
298
333
  rmSync(verifyDir, { recursive: true, force: true });
299
334
  mkdirSync(verifyDir, { recursive: true });
@@ -301,11 +336,12 @@ function inspectRemoteAsset(tag, repo, asset, run) {
301
336
  if (!result.ok) {
302
337
  rmSync(verifyDir, { recursive: true, force: true });
303
338
  if (/no assets match|not found/i.test(`${result.output ?? ""}\n${result.error ?? ""}`)) return "missing";
304
- throw new Error(`cannot inspect GitHub release asset: ${path.basename(asset)}: ${result.error || result.output}`);
339
+ throw new Error(`cannot inspect remote asset ${repo}/${tag}/${path.basename(asset)}: ${result.error || result.output}`);
305
340
  }
306
341
  const downloaded = path.join(verifyDir, path.basename(asset));
307
- if (!existsSync(downloaded) || sha256(downloaded) !== sha256(asset)) throw new Error(`GitHub release asset hash mismatch: ${path.basename(asset)}`);
308
- rmSync(verifyDir, { recursive: true, force: true });
342
+ try { verifyArtifactFile(downloaded, evidence); }
343
+ catch { throw new Error(`GitHub release asset hash mismatch; remove remote asset ${repo}/${tag}/${path.basename(asset)}`); }
344
+ finally { rmSync(verifyDir, { recursive: true, force: true }); }
309
345
  return "match";
310
346
  }
311
347
 
@@ -323,8 +359,8 @@ function runCommand(command, args, { allowFailure = false, cwd } = {}) {
323
359
  return allowFailure ? { ok: true, output } : output;
324
360
  }
325
361
 
326
- function runCombined(command, args) {
327
- const result = spawnSync(command, args, { encoding: "utf8", windowsHide: true });
362
+ function runCombined(command, args, commandRunner = spawnSync) {
363
+ const result = commandRunner(command, args, { encoding: "utf8", windowsHide: true });
328
364
  if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr?.trim() || `exit ${result.status}`}`);
329
365
  return `${result.stdout ?? ""}${result.stderr ?? ""}`;
330
366
  }
@@ -334,7 +370,8 @@ function usage(code) {
334
370
  process.exit(code);
335
371
  }
336
372
 
337
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
373
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
374
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
338
375
  const args = process.argv.slice(2);
339
376
  const options = { platform: process.platform === "win32" ? "win" : "mac", releaseId: "", addon: "", repo: "", dryRun: false };
340
377
  for (let index = 0; index < args.length; index += 1) {
package/heavy-command.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, realpathSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawn, spawnSync } from "./background-process.mjs";
@@ -355,7 +355,8 @@ function spawnAttached(command, args, env, slot) {
355
355
  });
356
356
  }
357
357
 
358
- if (process.argv[1] && path.resolve(process.argv[1]) === SELF) {
358
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
359
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
359
360
  const args = process.argv.slice(2);
360
361
  const task = args[0] === "--watch-owner-base64"
361
362
  ? watchOwnedProcessTree(JSON.parse(Buffer.from(args[1] || "", "base64url").toString("utf8"))).then(() => 0)
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env node
2
+ // Sanctioned, non-intrusive local app install from a signed DMG (macOS).
3
+ // Never uses a shell, osascript, AppleEvents, or foreground activation.
4
+ import { execFile as nodeExecFile } from "./background-process.mjs";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ const SIGTERM_WAIT_MS = 15_000;
11
+ const POLL_MS = 250;
12
+
13
+ export class InstallAppError extends Error {
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.code = code;
17
+ }
18
+ }
19
+
20
+ const VALUE_FLAGS = new Set(["--dmg", "--app-name", "--bundle-id", "--dest"]);
21
+ const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json"]);
22
+
23
+ export function parseArgs(argv) {
24
+ const opts = { dmg: null, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false };
25
+ for (let i = 0; i < argv.length; i += 1) {
26
+ const flag = argv[i];
27
+ if (VALUE_FLAGS.has(flag)) {
28
+ const value = argv[i + 1];
29
+ if (value === undefined || value.startsWith("--")) throw new InstallAppError("usage", `${flag} requires a value`);
30
+ i += 1;
31
+ if (flag === "--dmg") opts.dmg = value;
32
+ else if (flag === "--app-name") opts.appName = value;
33
+ else if (flag === "--bundle-id") opts.bundleId = value;
34
+ else opts.dest = value;
35
+ } else if (BOOL_FLAGS.has(flag)) {
36
+ if (flag === "--json") opts.json = true;
37
+ else opts.relaunch = flag === "--relaunch";
38
+ } else {
39
+ throw new InstallAppError("usage", `unknown argument: ${flag}`);
40
+ }
41
+ }
42
+ if (argv.includes("--relaunch") && argv.includes("--no-relaunch")) throw new InstallAppError("usage", "--relaunch and --no-relaunch are mutually exclusive");
43
+ if (!opts.dmg) throw new InstallAppError("usage", "--dmg <path.dmg> is required");
44
+ if (!opts.dmg.toLowerCase().endsWith(".dmg")) throw new InstallAppError("usage", "--dmg must name a .dmg file");
45
+ if (opts.appName !== null && (!/^[^/\0]+\.app$/.test(opts.appName) || opts.appName === ".app" || opts.appName.includes(".."))) {
46
+ throw new InstallAppError("usage", "--app-name must be a bare bundle name ending in .app");
47
+ }
48
+ if (opts.bundleId !== null && !/^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(opts.bundleId)) throw new InstallAppError("usage", "--bundle-id is not a valid bundle identifier");
49
+ if (!path.isAbsolute(opts.dest)) opts.dest = path.resolve(opts.dest);
50
+ opts.dmg = path.resolve(opts.dmg);
51
+ return opts;
52
+ }
53
+
54
+ // --- pure parsers ---------------------------------------------------------
55
+
56
+ function xmlUnescape(text) {
57
+ return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
58
+ }
59
+
60
+ // Minimal plist reader for hdiutil attach -plist output (dict/array/string/data/integer/true/false).
61
+ export function parsePlist(xml) {
62
+ const cleaned = String(xml).replace(/<\?[\s\S]*?\?>/g, "").replace(/<!--[\s\S]*?-->/g, "").replace(/<!DOCTYPE[^>]*>/gi, "");
63
+ const tokens = [...cleaned.matchAll(/<(\/?)([a-zA-Z]+)(?:\s[^>]*?)?(\s*\/)?>|([^<]+)/g)];
64
+ let pos = 0;
65
+ const skipText = () => { while (pos < tokens.length && tokens[pos][4] !== undefined && tokens[pos][4].trim() === "") pos += 1; };
66
+ function readValue() {
67
+ skipText();
68
+ const t = tokens[pos];
69
+ if (!t || t[4] !== undefined || t[1]) throw new InstallAppError("plist", "malformed plist");
70
+ const name = t[2];
71
+ pos += 1;
72
+ if (t[3]) return name === "true" ? true : name === "false" ? false : "";
73
+ if (name === "dict") {
74
+ const out = {};
75
+ for (;;) {
76
+ skipText();
77
+ const n = tokens[pos];
78
+ if (!n) throw new InstallAppError("plist", "unterminated dict");
79
+ if (n[1] && n[2] === "dict") { pos += 1; return out; }
80
+ if (n[2] !== "key") throw new InstallAppError("plist", "expected key");
81
+ pos += 1;
82
+ const key = tokens[pos]?.[4] !== undefined ? xmlUnescape(tokens[pos++][4]) : "";
83
+ pos += 1; // </key>
84
+ out[key] = readValue();
85
+ }
86
+ }
87
+ if (name === "array") {
88
+ const out = [];
89
+ for (;;) {
90
+ skipText();
91
+ const n = tokens[pos];
92
+ if (!n) throw new InstallAppError("plist", "unterminated array");
93
+ if (n[1] && n[2] === "array") { pos += 1; return out; }
94
+ out.push(readValue());
95
+ }
96
+ }
97
+ let text = "";
98
+ if (tokens[pos]?.[4] !== undefined) { text = xmlUnescape(tokens[pos][4]); pos += 1; }
99
+ pos += 1; // closing tag
100
+ if (name === "integer") return Number(text);
101
+ if (name === "real") return Number(text);
102
+ return text;
103
+ }
104
+ while (pos < tokens.length && !(tokens[pos][2] === "plist" && !tokens[pos][1])) pos += 1;
105
+ if (pos >= tokens.length) throw new InstallAppError("plist", "no <plist> element");
106
+ pos += 1;
107
+ return readValue();
108
+ }
109
+
110
+ export function mountPointsFromAttach(plistXml) {
111
+ const parsed = parsePlist(plistXml);
112
+ const entities = parsed?.["system-entities"];
113
+ if (!Array.isArray(entities)) throw new InstallAppError("attach", "hdiutil attach output has no system-entities");
114
+ return entities.map((e) => e?.["mount-point"]).filter((m) => typeof m === "string" && m.length > 0);
115
+ }
116
+
117
+ export function parseTeamId(codesignDvvOutput) {
118
+ const match = /^TeamIdentifier=(.+)$/m.exec(codesignDvvOutput);
119
+ const value = match?.[1].trim();
120
+ return value && value !== "not set" ? value : null;
121
+ }
122
+
123
+ // Parse `ps -axo pid=,comm=` and return pids whose executable lives inside <app>/Contents/MacOS/.
124
+ export function matchPids(psOutput, appBundlePath, { selfPid = process.pid } = {}) {
125
+ const prefix = `${appBundlePath.replace(/\/+$/, "")}/Contents/MacOS/`;
126
+ const pids = [];
127
+ for (const line of String(psOutput).split("\n")) {
128
+ const m = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
129
+ if (!m) continue;
130
+ const pid = Number(m[1]);
131
+ if (pid === selfPid || pid <= 1) continue;
132
+ if (m[2].startsWith(prefix)) pids.push(pid);
133
+ }
134
+ return pids;
135
+ }
136
+
137
+ export function trashName(appName, now = new Date()) {
138
+ const stamp = now.toISOString().replace(/[:T]/g, "-").replace(/\..*$/, "");
139
+ const base = appName.replace(/\.app$/, "");
140
+ return `${base} ${stamp}.app`;
141
+ }
142
+
143
+ // --- orchestration --------------------------------------------------------
144
+
145
+ const realExecFile = (cmd, args) => new Promise((resolve, reject) => {
146
+ nodeExecFile(cmd, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
147
+ if (error) { error.stdout = stdout; error.stderr = stderr; reject(error); } else resolve({ stdout, stderr });
148
+ });
149
+ });
150
+
151
+ function errText(error) {
152
+ return `${error?.stderr || ""}${error?.stdout || ""}`.trim() || error?.message || String(error);
153
+ }
154
+
155
+ export async function installApp(opts, deps = {}) {
156
+ const {
157
+ execFile = realExecFile,
158
+ platform = process.platform,
159
+ fsImpl = fs,
160
+ home = os.homedir(),
161
+ now = () => new Date(),
162
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
163
+ kill = (pid, sig) => process.kill(pid, sig),
164
+ emit = () => {},
165
+ tmpdir = os.tmpdir(),
166
+ } = deps;
167
+ const event = (name, data = {}) => emit({ event: name, ...data });
168
+ const run = (cmd, args) => execFile(cmd, args);
169
+ let mountRoot = null;
170
+ let mounted = false;
171
+ let mountPoint = null;
172
+ let failedStage = "start";
173
+
174
+ const detach = async () => {
175
+ if (!mounted) return;
176
+ mounted = false;
177
+ try { await run("/usr/bin/hdiutil", ["detach", mountPoint]); }
178
+ catch { try { await run("/usr/bin/hdiutil", ["detach", "-force", mountPoint]); } catch { /* best effort */ } }
179
+ try { fsImpl.rmSync(mountRoot, { recursive: true, force: true }); } catch { /* best effort */ }
180
+ };
181
+
182
+ try {
183
+ if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
184
+ event("start", { dmg: opts.dmg, dest: opts.dest });
185
+ if (!fsImpl.existsSync(opts.dmg)) throw new InstallAppError("dmg-missing", `DMG not found: ${opts.dmg}`);
186
+ if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
187
+
188
+ failedStage = "attach";
189
+ mountRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-"));
190
+ const attached = await run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountrandom", mountRoot, "-plist", opts.dmg]);
191
+ const mounts = mountPointsFromAttach(attached.stdout);
192
+ if (mounts.length === 0) throw new InstallAppError("attach", "DMG attached but exposed no mount point");
193
+ mountPoint = mounts[0];
194
+ mounted = true;
195
+
196
+ let sourceApp = null;
197
+ for (const mp of mounts) {
198
+ const apps = fsImpl.readdirSync(mp).filter((n) => n.endsWith(".app"));
199
+ if (opts.appName) { if (apps.includes(opts.appName)) { sourceApp = path.join(mp, opts.appName); break; } }
200
+ else if (apps.length === 1) { sourceApp = path.join(mp, apps[0]); break; }
201
+ else if (apps.length > 1) throw new InstallAppError("ambiguous-app", `DMG contains multiple apps (${apps.join(", ")}); pass --app-name`);
202
+ }
203
+ if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in DMG` : "no .app found in DMG");
204
+ const appName = path.basename(sourceApp);
205
+ const installed = path.join(opts.dest, appName);
206
+ const hadInstalled = fsImpl.existsSync(installed);
207
+
208
+ // Verify the mounted app before touching anything installed.
209
+ failedStage = "verify";
210
+ const newTeam = await verifyBundle(run, sourceApp, { assess: true, staple: true });
211
+ const newBundleId = await readBundleId(run, sourceApp);
212
+ if (opts.bundleId && newBundleId !== opts.bundleId) throw new InstallAppError("bundle-id-mismatch", `DMG app bundle id ${newBundleId} != --bundle-id ${opts.bundleId}`);
213
+ let installedTeam = null;
214
+ if (hadInstalled) {
215
+ const installedBundleId = await readBundleId(run, installed);
216
+ if (installedBundleId !== newBundleId) throw new InstallAppError("bundle-id-mismatch", `DMG app bundle id ${newBundleId} != installed bundle id ${installedBundleId}`);
217
+ try { installedTeam = await teamIdOf(run, installed); } catch { installedTeam = null; }
218
+ if (installedTeam && installedTeam !== newTeam) throw new InstallAppError("team-id-changed", `refusing team change: installed ${installedTeam}, DMG ${newTeam}`);
219
+ }
220
+ const bundleId = opts.bundleId || newBundleId;
221
+ event("verified", { app: appName, bundleId, teamId: newTeam, replacing: hadInstalled });
222
+
223
+ // Quit running instance without UI.
224
+ failedStage = "quit";
225
+ const wasRunning = hadInstalled ? (await findPids(run, installed)).length > 0 : false;
226
+ const quit = hadInstalled ? await quitApp(run, kill, sleep, installed) : { pids: [], killed: [] };
227
+ event("quit", { wasRunning, pids: quit.pids, forced: quit.killed });
228
+
229
+ // Replace.
230
+ failedStage = "replace";
231
+ let trashed = null;
232
+ if (hadInstalled) {
233
+ const trashDir = path.join(home, ".Trash");
234
+ fsImpl.mkdirSync(trashDir, { recursive: true });
235
+ trashed = path.join(trashDir, trashName(appName, now()));
236
+ await moveAside(run, fsImpl, installed, trashed);
237
+ }
238
+ try {
239
+ await run("/usr/bin/ditto", [sourceApp, installed]);
240
+ } catch (error) {
241
+ try { fsImpl.rmSync(installed, { recursive: true, force: true }); } catch { /* ignore */ }
242
+ let restored = false;
243
+ if (trashed) { try { await moveAside(run, fsImpl, trashed, installed); restored = true; } catch { /* reported below */ } }
244
+ throw new InstallAppError("ditto-failed", `ditto failed (${errText(error)}); ${trashed ? (restored ? "previous app restored" : `RESTORE FAILED, previous app is at ${trashed}`) : "no previous app to restore"}`);
245
+ }
246
+ event("replaced", { installed, previous: trashed });
247
+ await detach();
248
+
249
+ // Re-verify installed copy.
250
+ failedStage = "reverify";
251
+ await verifyBundle(run, installed, { assess: true, staple: false });
252
+
253
+ // Relaunch hidden and in the background only.
254
+ const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
255
+ if (relaunch) {
256
+ failedStage = "relaunch";
257
+ await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
258
+ event("relaunched", { bundleId });
259
+ }
260
+ const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch };
261
+ event("done", result);
262
+ return result;
263
+ } catch (error) {
264
+ const reason = error instanceof InstallAppError ? error.message : errText(error);
265
+ event("failed", { stage: failedStage, code: error.code || "error", reason });
266
+ if (!(error instanceof InstallAppError)) throw new InstallAppError("error", reason);
267
+ throw error;
268
+ } finally {
269
+ await detach();
270
+ }
271
+ }
272
+
273
+ async function verifyBundle(run, app, { assess, staple }) {
274
+ const step = async (code, cmd, args) => {
275
+ try { return await run(cmd, args); }
276
+ catch (error) { throw new InstallAppError(code, `${code} failed for ${app}: ${errText(error)}`); }
277
+ };
278
+ await step("codesign-verify", "/usr/bin/codesign", ["--verify", "--deep", "--strict", app]);
279
+ const team = await teamIdOf(run, app);
280
+ if (!team) throw new InstallAppError("team-id-missing", `${app} has no Team ID (ad-hoc or unsigned)`);
281
+ await step("codesign-requirement", "/usr/bin/codesign", ["-dr", "-", app]);
282
+ if (assess) await step("spctl-assess", "/usr/sbin/spctl", ["--assess", "--type", "execute", app]);
283
+ if (staple) await step("stapler-validate", "/usr/bin/xcrun", ["stapler", "validate", app]);
284
+ return team;
285
+ }
286
+
287
+ async function teamIdOf(run, app) {
288
+ const out = await run("/usr/bin/codesign", ["-dvv", app]);
289
+ return parseTeamId(`${out.stderr || ""}\n${out.stdout || ""}`);
290
+ }
291
+
292
+ async function readBundleId(run, app) {
293
+ let out;
294
+ try { out = await run("/usr/bin/plutil", ["-convert", "json", "-o", "-", path.join(app, "Contents", "Info.plist")]); }
295
+ catch (error) { throw new InstallAppError("info-plist", `cannot read Info.plist of ${app}: ${errText(error)}`); }
296
+ const id = JSON.parse(out.stdout)?.CFBundleIdentifier;
297
+ if (typeof id !== "string" || !id) throw new InstallAppError("info-plist", `no CFBundleIdentifier in ${app}`);
298
+ return id;
299
+ }
300
+
301
+ async function findPids(run, app) {
302
+ const ps = await run("/bin/ps", ["-axo", "pid=,comm="]);
303
+ return matchPids(ps.stdout, app);
304
+ }
305
+
306
+ async function quitApp(run, kill, sleep, app) {
307
+ const pids = await findPids(run, app);
308
+ const send = (pid, sig) => { try { kill(pid, sig); } catch (e) { if (e.code !== "ESRCH") throw e; } };
309
+ for (const pid of pids) send(pid, "SIGTERM");
310
+ let waited = 0;
311
+ let remaining = pids.length ? await findPids(run, app) : [];
312
+ while (remaining.length && waited < SIGTERM_WAIT_MS) {
313
+ await sleep(POLL_MS);
314
+ waited += POLL_MS;
315
+ remaining = await findPids(run, app);
316
+ }
317
+ for (const pid of remaining) send(pid, "SIGKILL");
318
+ if (remaining.length) await sleep(POLL_MS);
319
+ return { pids, killed: remaining };
320
+ }
321
+
322
+ async function moveAside(run, fsImpl, from, to) {
323
+ try {
324
+ fsImpl.renameSync(from, to);
325
+ } catch (error) {
326
+ if (error.code !== "EXDEV") throw error;
327
+ await run("/usr/bin/ditto", [from, to]);
328
+ fsImpl.rmSync(from, { recursive: true, force: true });
329
+ }
330
+ }
331
+
332
+ export async function main(argv = process.argv.slice(2), io = {}) {
333
+ const stdout = io.stdout || ((s) => console.log(s));
334
+ const stderr = io.stderr || ((s) => console.error(s));
335
+ let opts;
336
+ try { opts = parseArgs(argv); }
337
+ catch (error) {
338
+ stderr(`right-release install-app: ${error.message}`);
339
+ stderr("usage: right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id dev.orthic.pulse] [--dest /Applications] [--relaunch|--no-relaunch] [--json]");
340
+ return 2;
341
+ }
342
+ const emit = (e) => {
343
+ if (opts.json) { stdout(JSON.stringify({ schema: 1, ts: new Date().toISOString(), ...e })); return; }
344
+ const { event, ...rest } = e;
345
+ if (event === "failed") stderr(`install-app: failed at ${rest.stage}: ${rest.reason}`);
346
+ else stdout(`install-app: ${event}${Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ""}`);
347
+ };
348
+ try {
349
+ await installApp(opts, { ...io.deps, emit });
350
+ return 0;
351
+ } catch (error) {
352
+ return error.code === "unsupported" ? 3 : 1;
353
+ }
354
+ }
355
+
356
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
357
+ main().then((code) => { process.exitCode = code; });
358
+ }