@rightkit/release 0.2.138 → 0.2.140

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 CHANGED
@@ -35,6 +35,8 @@ right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id d
35
35
 
36
36
  It mounts the image read-only and hidden (`-nobrowse`), verifies the app (`codesign --verify --deep --strict`, `spctl --assess`, `stapler validate`, bundle id, and Team ID against the installed copy; a Team ID change is refused) before touching the installed copy, quits the running instance with SIGTERM (15 s, then SIGKILL), moves the old bundle to `~/.Trash/<App> <timestamp>.app`, copies the new one with `ditto` (restoring the old bundle on failure), re-verifies, and relaunches only if it was running or `--relaunch` is given, using `open -g -j -b` (background, hidden, never activated). `--json` emits one lifecycle event per line (`start`, `verified`, `quit`, `replaced`, `relaunched`, `done`/`failed`). Win32 exits with "not implemented on win32". Exit codes: 0 ok, 1 failed, 2 usage, 3 unsupported platform.
37
37
 
38
+ Development builds (from `@rightkit/git` `devArtifact`, Developer ID-signed but not notarized) install with `--dev` and exactly one of `--dmg`, `--zip <path.zip>` (extracted with `ditto -x -k`), or `--app <path.app>`: `right-release install-app --dev --zip dev-mac.zip`. `--dev` still requires `codesign --verify --deep --strict`, a Team ID, no Team ID change, and a bundle-id match; it skips only `spctl` and `stapler`, removes `com.apple.quarantine` from the new bundle only, prints `DEV BUILD (not notarized)`, and sets `dev: true` on every JSON event.
39
+
38
40
  ## Entry points
39
41
 
40
42
  Bin: `right-release` (`cli/right-release.mjs`). The package has no `exports` map. The CLI is the supported surface.
@@ -56,6 +58,20 @@ pnpm add -D @rightkit/release
56
58
  pnpm exec right-release deps --check
57
59
  ```
58
60
 
61
+ ## MSIX target and Swift app type
62
+
63
+ Swift (SwiftPM) apps are a first-class project type. `projectType: "swiftpm"` (config or target; auto-detected from a `Package.swift` with no Cargo manifest) replaces the cargo/rustc requirement with `swift`, and skips the Rust target-bridge, sccache and Perl checks. Tauri/Rust targets are unchanged. `makeappx` and `signtool` are required only when `targets.win.msix` is configured.
64
+
65
+ `targets.win` with `signingContract: "windows-msix-signed-v1"` packages an MSIX (schema in `msix-config.mjs`): `prePackage` builds and stages a flat payload (`right-release swift-package windows-stage`), then the msix step runs in the immutable order: verify the declared runtime-DLL inventory and PE import closure (fails closed), sign inner DLLs then EXEs through `sign-windows`, `makeappx pack`, sign the `.msix`, verify the signature and that the signer subject equals `Identity/@Publisher`, write the App Installer route, and write `msix-seal.json`. `msix.virtualization.fileSystemWrite|registryWrite` (`enabled|disabled`) are mandatory and emitted as `desktop6` manifest properties. List the `.msix` under `installer.artifacts` so it seals.
66
+
67
+ `updater.kind: "appinstaller"` (default remains the Tauri minisign flow) generates `msix.appInstaller.out` (https `uri`/`msixUri`); trust is the MSIX Authenticode signature, so no `.sig` is produced and `publish-update` records the sealed package digest.
68
+
69
+ macOS Swift apps use `right-release swift-package mac-app` (Info.plist from config, resources, frameworks, entitlements on the bundle only; nested code signed inside-out before the app).
70
+
71
+ Build a SwiftPM app with `right-release build --platform mac|win` (add `--config <path>` when the config is not `right-release.config.mjs`). Build is tier-neutral and never uploads, so it rejects `--tier` and takes no `--upload`/`--no-upload`; the old `release --tier patch --no-upload` form no longer works. Pick the tier only at upload: `right-release upload --release <sealed-id> --platform mac|win --tier patch|update`.
72
+
73
+ Set `targets.mac.notarize.appBeforePackage: true` (with `notarize.app` naming the `.app`) to notarize and staple the app itself right after signing and before the package step copies it into the DMG, so an app copied out of the DMG passes `stapler validate` offline. The DMG named by `notarize.file` is still notarized and stapled afterward; the app ticket receipt is `macos-app-notarization.json`. Without the flag, behavior is unchanged.
74
+
59
75
  ## Known issues
60
76
 
61
77
  - `smol-toml` is pinned exactly. A consumer that adds another `smol-toml` gets a second copy (npm audit 2026-10-08, release finding).
package/build-release.mjs CHANGED
@@ -33,6 +33,8 @@ import { brokerManagedHost } from "./cargo-contract.mjs";
33
33
  import { resolveReleaseCacheMode } from "./release-cache-mode.mjs";
34
34
  import { resolveTargetRoot } from "./cargo-target.mjs";
35
35
  import { assertNsisInPlaceUpgradeContract } from "./nsis-upgrade-contract.mjs";
36
+ import { collectSwiftToolVersions, planSwiftBuild } from "./swift-build-context.mjs";
37
+ import { packageCommandOptional, requiredBuildExecutables, resolveProjectType, targetUsesMsix } from "./project-type.mjs";
36
38
  import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
37
39
  import { assertCleanSource } from "./source-gate.mjs";
38
40
  import { acquireHeavyWorkSlot, heavyCommandEnvironment, summarizePreBrokerWaits, terminateProcessTree } from "./heavy-command.mjs";
@@ -46,14 +48,14 @@ import { macosCodesignEnvironment } from "./codesign-environment.mjs";
46
48
  // and test-only edits and would force a cold Rust rebuild of every app each time.
47
49
  // Cached target directories must not outlive changes to these signing helpers.
48
50
 
49
- const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "sign-macos.mjs", "macos-signing-session.mjs", "codesign-environment.mjs", "codesign-shim.mjs", "codesign-argv.mjs", "osascript-guard.mjs", "dmg-build.mjs", "dmg-layout.mjs", "rightkit-signing-agent.mjs", "notarize-macos.mjs", "macos-notarization.mjs", "signing-receipts.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs"];
51
+ const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "sign-macos.mjs", "macos-signing-session.mjs", "codesign-environment.mjs", "codesign-shim.mjs", "codesign-argv.mjs", "osascript-guard.mjs", "dmg-build.mjs", "dmg-layout.mjs", "rightkit-signing-agent.mjs", "notarize-macos.mjs", "macos-notarization.mjs", "signing-receipts.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs", "msix-build.mjs", "msix-plan.mjs"];
50
52
  const PIPELINE_FINGERPRINT = createHash("sha256")
51
53
  .update(PIPELINE_FINGERPRINT_SOURCES.map((file) => `${file}:${createHash("sha256").update(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file))).digest("hex")}`).join("\n"))
52
54
  .digest("hex")
53
55
  .slice(0, 16);
54
56
 
55
57
  /** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
56
- function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout }) {
58
+ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout, target }) {
57
59
  let version;
58
60
  try {
59
61
  version = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8")).version;
@@ -71,6 +73,8 @@ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativ
71
73
  // reject a legitimate resume of an in-flight build.
72
74
  version: undefined,
73
75
  cargoLockPaths: nativeLayout?.lockCandidates ?? [],
76
+ projectType: resolveProjectType({ appRoot, config, target }),
77
+ msix: targetUsesMsix(target),
74
78
  });
75
79
  }
76
80
 
@@ -124,7 +128,7 @@ if (dryRun) {
124
128
  const config = (await import(`${pathToFileURL(configPath).href}?dry-run=${commit}`)).default;
125
129
  if (!config?.app || !config?.version) fail("release config must expose app and version");
126
130
  const target = config.targets?.[platform];
127
- if (!target?.package) fail(`${config.app} has no ${platform} package command`);
131
+ if (!target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
128
132
  console.log(`right-release build dry-run: ${config.app} ${config.version} ${platform}`);
129
133
  console.log(`config: ${configPath}`);
130
134
  console.log(`commit: ${commit}`);
@@ -163,7 +167,8 @@ try {
163
167
  fail("app and version must be safe release-id components");
164
168
  }
165
169
  const target = config.targets?.[platform];
166
- if (!target?.package) fail(`${config.app} has no ${platform} package command`);
170
+ if (!target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
171
+ const isSwiftBuild = resolveProjectType({ appRoot, config, target }) === "swiftpm";
167
172
  const nativeLayout = resolveNativeCargoLayout({ appRoot, config });
168
173
  if (platform === "win" && target.signingContract === "windows-raw-exe-authenticode-before-nsis-v1") {
169
174
  assertNsisInPlaceUpgradeContract(appRoot, target.nsisUpgradeContract);
@@ -171,7 +176,7 @@ try {
171
176
  const { requiredInputs, buildInputs } = resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target });
172
177
  const dirtyInputs = dirtyBuildInputs(repoRoot, buildInputs, commit);
173
178
  if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
174
- const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
179
+ const toolVersions = isSwiftBuild ? collectSwiftToolVersions({ packageManager: config.packageManager, exec: commandOutput }) : collectToolVersions(config.packageManager ?? "pnpm");
175
180
  const inputHashes = hashInputs(requiredInputs);
176
181
  const releaseId = `${config.app}-${config.version}-${shortCommit}`;
177
182
  const platformDir = platform === "win" ? "windows" : "mac";
@@ -208,10 +213,12 @@ try {
208
213
  // shells take the Cache V2 branch, bridge resolved native target into release cache,
209
214
  // and then lose CARGO_TARGET_DIR to the broker's unconditional override — so the
210
215
  // app bundle landed in the broker workspace and packaging failed on an empty cache.
211
- const managedCargoTarget = brokerManagedHost(process.env) ? resolveTargetRoot(cargoToml) : null;
212
- const cacheIdentity = resolveSharedCacheIdentity({ cargoLockPath: cargoLock, cargoTomlPath: cargoToml, rustcVerbose: toolVersions.rustc, targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? toolVersions.rustHost, profile: target.profile ?? "release" });
216
+ const swiftPlan = isSwiftBuild ? planSwiftBuild({ appRoot, vaultRoot, platform, target, toolVersions }) : null;
217
+ // SwiftPM targets have no Cargo manifest: no broker cargo target, no Rust cache identity.
218
+ const managedCargoTarget = swiftPlan ? swiftPlan.managedCargoTarget : brokerManagedHost(process.env) ? resolveTargetRoot(cargoToml) : null;
219
+ const cacheIdentity = swiftPlan ? swiftPlan.identity : resolveSharedCacheIdentity({ cargoLockPath: cargoLock, cargoTomlPath: cargoToml, rustcVerbose: toolVersions.rustc, targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? toolVersions.rustHost, profile: target.profile ?? "release" });
213
220
  const cacheKey = hashFileText(JSON.stringify({ cache: cacheIdentity.fingerprint, signingIdentity })).slice(0, 16);
214
- const cacheModeRequested = resolveReleaseCacheMode({ requested: process.env.RIGHT_RELEASE_CACHE_MODE, platform, env: process.env });
221
+ const cacheModeRequested = swiftPlan ? swiftPlan.cacheMode : resolveReleaseCacheMode({ requested: process.env.RIGHT_RELEASE_CACHE_MODE, platform, env: process.env });
215
222
  // On a broker-managed host, Cache V2's lease/slot/prune/floor/mark chain is not
216
223
  // running against the directory that will actually hold build output — the
217
224
  // broker owns CARGO_TARGET_DIR unconditionally (see managedCargoTarget above).
@@ -233,7 +240,7 @@ try {
233
240
  // Preflight BEFORE any compile. These preconditions used to be discovered one
234
241
  // per build cycle, minutes deep, with errors that named a symptom instead of a
235
242
  // cause. Blocking problems stop here, in seconds, naming the fix.
236
- const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout });
243
+ const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout, target });
237
244
  const preflightBlockers = preflightFailures(preflight);
238
245
  if (preflightBlockers.length > 0) {
239
246
  fail(`preflight found ${preflightBlockers.length} blocking problem(s):\n${formatPreflight(preflightBlockers)}`);
@@ -252,7 +259,7 @@ try {
252
259
  // resolves to MSYS Perl, which cannot configure OpenSSL. Put the real Windows
253
260
  // Perl first so the build behaves the same from any shell.
254
261
  const pathPrefix = buildPathPrefix({ platform });
255
- const env = macosCodesignEnvironment({
262
+ const baseEnv = macosCodesignEnvironment({
256
263
  ...heavyCommandEnvironment(process.env),
257
264
  ...releaseEnvironment({ root: vaultRoot, cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, cacheKey, kind: "release", appRoot, mode: cacheMode }),
258
265
  ...(pathPrefix ? { PATH: `${pathPrefix}${path.delimiter}${process.env.PATH ?? ""}` } : {}),
@@ -261,6 +268,7 @@ try {
261
268
  RIGHTKIT_MANAGED_ROOT: resolveManagedRoot({ env: process.env }),
262
269
  RIGHT_RELEASE_ARTIFACT_DIR: resolveArtifactDir({ app: config.app, version: config.version, managedRoot: resolveManagedRoot({ env: process.env }) }),
263
270
  });
271
+ const env = swiftPlan ? swiftPlan.sanitizeEnv(baseEnv) : baseEnv;
264
272
  if (managedCargoTarget) {
265
273
  for (const name of ["CARGO_HOME", "CARGO_TARGET_DIR", "RUSTC_WRAPPER", "SCCACHE_DIR", "SCCACHE_BASEDIRS", "SCCACHE_CACHE_SIZE", "RUSTFLAGS"]) {
266
274
  delete env[name];
@@ -302,7 +310,7 @@ try {
302
310
  // ownedRoot is the per-app cache parent holding every fingerprint dir, so a
303
311
  // link left by an earlier fingerprint is recognised as ours and repointed
304
312
  // instead of hard-stopping the build.
305
- const targetBridge = createTargetBridge({
313
+ const targetBridge = swiftPlan ? swiftPlan.targetBridge : createTargetBridge({
306
314
  link: targetLink,
307
315
  target: managedCargoTarget ?? env.CARGO_TARGET_DIR,
308
316
  ownedRoot: path.dirname(managedCargoTarget ?? env.CARGO_TARGET_DIR),
@@ -329,7 +337,7 @@ try {
329
337
  const volumes = inspectWriteVolumes({ repoRoot, vaultRoot, cacheRoot: sharedCacheRoot });
330
338
  assertWriteVolumeFloors({ volumes, policy, configuredRepoMinimumBytes: Math.max(Number(target.preflight?.minFreeGb ?? 25), 25) * 1024 ** 3 });
331
339
  } else assertDiskSpace(repoRoot, target.preflight?.minFreeGb ?? 20);
332
- assertExecutables(["git", config.packageManager ?? "pnpm", "cargo", "rustc", ...(target.preflight?.executables ?? [])]);
340
+ assertExecutables([...requiredBuildExecutables({ projectType: resolveProjectType({ appRoot, config, target }), packageManager: config.packageManager ?? "pnpm", usesPackageManager: Boolean(config.packageManager) || existsSync(path.join(appRoot, "package.json")) }), ...(target.preflight?.executables ?? [])]);
333
341
  for (const name of target.preflight?.env ?? []) if (!process.env[name]) fail(`missing required environment variable: ${name}`);
334
342
  for (const command of target.preflight?.commands ?? []) runChecked(command.cmd, command.args ?? [], path.resolve(appRoot, command.cwd ?? "."), env);
335
343
  if (buildRoot) mkdirSync(buildRoot, { recursive: true });
@@ -341,7 +349,7 @@ try {
341
349
  },
342
350
  prepare: async () => {
343
351
  throwIfInterrupted();
344
- const cacheTarget = managedCargoTarget ?? env.CARGO_TARGET_DIR;
352
+ const cacheTarget = swiftPlan ? swiftPlan.buildDir : managedCargoTarget ?? env.CARGO_TARGET_DIR;
345
353
  mkdirSync(cacheTarget, { recursive: true });
346
354
  // Broker-managed hosts route through the same target bridge as shared
347
355
  // mode even though cacheMode is forced to "broker" above: the naive
@@ -349,12 +357,13 @@ try {
349
357
  // directory, which is exactly the "corruption" misreading that nearly
350
358
  // cost ~5.9 GB of warm broker intermediates. The bridge's brokerManaged
351
359
  // flag already knows to leave a real directory untouched instead.
352
- if (cacheMode === "shared" || managedCargoTarget) targetBridge.ensure();
360
+ if (swiftPlan) { /* SwiftPM: no Rust target link */ }
361
+ else if (cacheMode === "shared" || managedCargoTarget) targetBridge.ensure();
353
362
  else if (!existsSync(targetLink)) symlinkSync(cacheTarget, targetLink, process.platform === "win32" ? "junction" : "dir");
354
363
  else if (realpathSync(targetLink) !== realpathSync(cacheTarget)) {
355
364
  fail(`primary checkout target exists and is not the release cache link: ${targetLink}`);
356
365
  }
357
- await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget, path.join(stateRoot, "stall-install.log"));
366
+ if (resolveProjectType({ appRoot, config, target }) !== "swiftpm" || config.packageManager) await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget, path.join(stateRoot, "stall-install.log"));
358
367
  },
359
368
  build: async () => {
360
369
  throwIfInterrupted();
@@ -363,9 +372,9 @@ try {
363
372
  [WORKER, "--config", configPath, "--platform", platform, "--no-upload", "--resume", ...(resumeFrom ? ["--resume-from", resumeFrom] : []), ...(skipChecks ? ["--skip-checks"] : [])],
364
373
  appRoot,
365
374
  env,
366
- [managedCargoTarget ?? env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
375
+ swiftPlan ? swiftPlan.watchDirs : [managedCargoTarget ?? env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
367
376
  path.join(stateRoot, "stall-build.log"),
368
- { livenessOwnerTimeoutMs: target.package.livenessOwnerTimeoutMs ?? null },
377
+ { livenessOwnerTimeoutMs: target.package?.livenessOwnerTimeoutMs ?? null },
369
378
  );
370
379
  checkpoint(stateRoot, "build_complete");
371
380
  checkpoint(stateRoot, "signed");
@@ -461,7 +470,7 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
461
470
  for (const artifact of target.installer?.artifacts ?? []) addSource(resolveArtifactPath(artifact.file), "installer");
462
471
  for (const artifact of target.updater?.artifacts ?? []) {
463
472
  addSource(resolveArtifactPath(artifact.file), "updater");
464
- addSource(resolveArtifactPath(artifact.signature), "updater-signature");
473
+ if (target.updater?.kind !== "appinstaller") addSource(resolveArtifactPath(artifact.signature), "updater-signature");
465
474
  }
466
475
  for (const file of sources.keys()) if (!existsSync(file)) fail(`cannot seal missing artifact: ${file}`);
467
476
  const temp = `${sealedDir}.tmp-${process.pid}`;
@@ -485,10 +494,10 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
485
494
  }
486
495
  for (const artifact of target.updater?.artifacts ?? []) {
487
496
  const source = resolveArtifactPath(artifact.file);
488
- const signatureSource = resolveArtifactPath(artifact.signature);
497
+ const signatureSource = target.updater?.kind === "appinstaller" ? null : resolveArtifactPath(artifact.signature);
489
498
  const patchKey = artifact.patchKey ?? artifact.key.replace("/updates/", "/installers/");
490
- routes.patch.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(signatureSource), bucket: "public", key: patchKey, platform: artifact.platform });
491
- routes.update.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(signatureSource), bucket: "private", key: artifact.key, platform: artifact.platform });
499
+ routes.patch.push({ role: "updater", name: sourceNames.get(source), signature: signatureSource ? sourceNames.get(signatureSource) : undefined, bucket: "public", key: patchKey, platform: artifact.platform });
500
+ routes.update.push({ role: "updater", name: sourceNames.get(source), signature: signatureSource ? sourceNames.get(signatureSource) : undefined, bucket: "private", key: artifact.key, platform: artifact.platform });
492
501
  }
493
502
  writeJson(path.join(temp, "hardening.json"), { schema: 1, passed: true, checkedAt: new Date().toISOString(), files: files.map((file) => file.sha256) });
494
503
  const manifest = {
package/cache-policy.mjs CHANGED
@@ -518,3 +518,17 @@ function isAbsoluteForPlatform(value) { return isWindowsAbsolute(value) || path.
518
518
  function resolveForPlatform(value) { return isWindowsAbsolute(value) ? path.win32.resolve(value) : path.posix.resolve(value); }
519
519
  function readRustcVerbose() { const result = spawnSync("rustc", ["-vV"], { encoding: "utf8", windowsHide: true }); if (result.status !== 0) throw new Error(`rustc -vV failed: ${(result.stderr || result.error?.message || `exit ${result.status}`).trim()}`); return result.stdout.trim(); }
520
520
  function cargoNativeFeatures(cargoTomlPath) { if (!cargoTomlPath || !existsSync(cargoTomlPath)) return []; return [...readFileSync(cargoTomlPath, "utf8").matchAll(/features\s*=\s*\[([^\]]+)\]/g)].flatMap((match) => match[1].match(/"([^"]+)"/g) ?? []).map((value) => value.slice(1, -1)).filter((value) => /sqlcipher|openssl/i.test(value)); }
521
+
522
+ /**
523
+ * SwiftPM counterpart of resolveSharedCacheIdentity: identity is the Swift toolchain, Package.resolved
524
+ * (dependency pins; absent for dependency-free packages), target architecture and profile. No Rust inputs.
525
+ */
526
+ export function resolveSwiftCacheIdentity({ swiftVersion, packageResolvedPath, architecture, profile = "release" } = {}) {
527
+ if (!swiftVersion || !String(swiftVersion).trim()) throw new Error("swift --version did not report a toolchain");
528
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(architecture ?? "")) throw new Error("invalid Swift target architecture");
529
+ const resolved = packageResolvedPath && existsSync(packageResolvedPath) ? readFileSync(packageResolvedPath, "utf8").replace(/\r\n?/g, "\n") : null;
530
+ const packageResolvedSha256 = resolved === null ? "none" : sha256(resolved);
531
+ const swift = String(swiftVersion).trim();
532
+ return { projectType: "swiftpm", packageResolvedSha256, swift, target: architecture, architecture, profile, features: [],
533
+ fingerprint: sha256(JSON.stringify({ projectType: "swiftpm", packageResolvedSha256, swift, architecture, profile })).slice(0, 16) };
534
+ }
@@ -1,3 +1,4 @@
1
+ import { stripOwnLockRecords } from "./source-gate.mjs";
1
2
  import { createHash } from "node:crypto";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -193,7 +194,7 @@ export function assertCommittedCheckoutClean(root, { expectedCommit, expectedTre
193
194
  const output = runGit(path.resolve(root), ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
194
195
  encoding: "buffer", maxBuffer: MAX_TREE_BYTES, timeout: COMMAND_TIMEOUT_MS, runner, env: gitEnv(),
195
196
  });
196
- if (output.length) throw new CommittedSourceIncompleteError("committed ownership: protected release checkout has tracked or untracked changes");
197
+ if (stripOwnLockRecords(Buffer.from(output).toString("utf8")).length) throw new CommittedSourceIncompleteError("committed ownership: protected release checkout has tracked or untracked changes");
197
198
  return true;
198
199
  }
199
200
 
package/dmg-build.mjs CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { spawnSync } from "./background-process.mjs";
5
5
  import { backgroundAlias, dmgDSStore } from "./dmg-layout.mjs";
6
+ import { detachWithRetrySync } from "./hdiutil-detach.mjs";
6
7
  import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
7
8
 
8
9
  const fail = (code, message) => Object.assign(new Error(message), { code });
@@ -17,7 +18,7 @@ const pair = (value, label, positive = false) => {
17
18
  export function buildDmg({ app, out, volname, background, iconSize = 128, appPosition = [158, 231],
18
19
  applicationsPosition = [492, 231], window = [660, 400], volumeIcon } = {},
19
20
  { platform = process.platform, run = spawnSync, makeAlias = backgroundAlias,
20
- log = (event) => console.error(JSON.stringify(event)) } = {}) {
21
+ log = (event) => console.error(JSON.stringify(event)), sleep } = {}) {
21
22
  if (platform !== "darwin") throw fail("DMG_NATIVE_HOST_REQUIRED", "DMG creation requires macOS");
22
23
  if (!app || !out || !volname) throw fail("DMG_INVALID_OPTIONS", "--app, --out & --volname are required");
23
24
  app = path.resolve(app); out = path.resolve(out);
@@ -55,6 +56,12 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
55
56
  if (result.error || result.status !== 0) throw fail("DMG_COMMAND_FAILED", `${tool} ${args[0]} failed: ${result.error?.message ?? result.stderr ?? result.status}`);
56
57
  return result;
57
58
  };
59
+ // Detach flakes with "Resource busy": bounded retry with backoff, then -force as the last resort.
60
+ const detach = () => {
61
+ const outcome = detachWithRetrySync({ mount, log, ...(sleep ? { sleep } : {}),
62
+ detach: (args) => run("/usr/bin/hdiutil", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], shell: false, timeout: 300000 }) });
63
+ if (!outcome.ok) throw fail("DMG_COMMAND_FAILED", `hdiutil detach failed after ${outcome.attempts} tries and -force: ${outcome.result?.error?.message ?? outcome.result?.stderr ?? outcome.result?.status}`);
64
+ };
58
65
  log({ event: "dmg-build-started", app, out, volname });
59
66
  try {
60
67
  mkdirSync(stage); mkdirSync(mount);
@@ -78,7 +85,7 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
78
85
  const alias = relative ? makeAlias({ mount, volname, relative }) : undefined;
79
86
  writeFileSync(path.join(mount, ".DS_Store"), dmgDSStore({ appName: path.basename(app), iconSize,
80
87
  appPosition, applicationsPosition, window, alias }));
81
- command("/usr/bin/hdiutil", ["detach", mount]); attached = false;
88
+ detach(); attached = false;
82
89
  command("/usr/bin/hdiutil", ["convert", rw, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", compressed]);
83
90
  command("/usr/bin/hdiutil", ["verify", compressed]);
84
91
  renameSync(compressed, out);
@@ -89,7 +96,7 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
89
96
  throw error;
90
97
  } finally {
91
98
  if (attached || attaching) {
92
- try { command("/usr/bin/hdiutil", ["detach", "-force", mount]); attached = false; attaching = false; }
99
+ try { detach(); attached = false; attaching = false; }
93
100
  catch (error) { log({ event: "dmg-cleanup-failed", mount, work, message: error.message }); }
94
101
  }
95
102
  // Never recurse through a mount that failed to detach.
@@ -0,0 +1,42 @@
1
+ // Bounded detach for hdiutil mounts. Spotlight, Finder, and quick-look helpers can hold a freshly
2
+ // written volume for a moment, so a first `hdiutil detach` regularly fails with "Resource busy".
3
+ // Retry a fixed number of times with exponential backoff, then fall back to `-force` once.
4
+
5
+ export const DETACH_TRIES = 5;
6
+ export const DETACH_BACKOFF_MS = Object.freeze([500, 1000, 2000, 4000]);
7
+
8
+ const blockingSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
9
+ const describe = (result) => String(result?.error?.message ?? result?.stderr ?? result?.status ?? "failed").trim();
10
+
11
+ /**
12
+ * Synchronous. `detach(args)` runs `hdiutil <args>` and returns `{ status, stderr?, error? }`.
13
+ * Returns `{ ok, attempts, forced }`; throws only through the caller when `ok` is false.
14
+ */
15
+ export function detachWithRetrySync({ detach, mount, sleep = blockingSleep, log = () => {}, tries = DETACH_TRIES, backoffMs = DETACH_BACKOFF_MS }) {
16
+ let last;
17
+ for (let attempt = 1; attempt <= tries; attempt += 1) {
18
+ last = detach(["detach", mount]);
19
+ if (!last?.error && last?.status === 0) return { ok: true, attempts: attempt, forced: false, result: last };
20
+ const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)];
21
+ log({ event: "hdiutil-detach-retry", mount, attempt, of: tries, retryInMs: attempt < tries ? wait : 0, message: describe(last) });
22
+ if (attempt < tries) sleep(wait);
23
+ }
24
+ log({ event: "hdiutil-detach-force", mount, message: describe(last) });
25
+ const forced = detach(["detach", "-force", mount]);
26
+ return { ok: !forced?.error && forced?.status === 0, attempts: tries, forced: true, result: forced };
27
+ }
28
+
29
+ /** Async twin for promise-based runners: `detach(args)` resolves on success and rejects on failure. */
30
+ export async function detachWithRetry({ detach, mount, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), log = () => {}, tries = DETACH_TRIES, backoffMs = DETACH_BACKOFF_MS }) {
31
+ let last;
32
+ for (let attempt = 1; attempt <= tries; attempt += 1) {
33
+ try { await detach(["detach", mount]); return { ok: true, attempts: attempt, forced: false }; }
34
+ catch (error) { last = error; }
35
+ const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)];
36
+ log({ event: "hdiutil-detach-retry", mount, attempt, of: tries, retryInMs: attempt < tries ? wait : 0, message: String(last?.message ?? last) });
37
+ if (attempt < tries) await sleep(wait);
38
+ }
39
+ log({ event: "hdiutil-detach-force", mount, message: String(last?.message ?? last) });
40
+ try { await detach(["detach", "-force", mount]); return { ok: true, attempts: tries, forced: true }; }
41
+ catch (error) { return { ok: false, attempts: tries, forced: true, error }; }
42
+ }
package/install-app.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // Sanctioned, non-intrusive local app install from a signed DMG (macOS).
2
+ // Sanctioned, non-intrusive local app install from a signed DMG (macOS); --dev also accepts a
3
+ // Developer ID-signed, not-notarized zip or .app.
3
4
  // Never uses a shell, osascript, AppleEvents, or foreground activation.
4
5
  import { execFile as nodeExecFile } from "./background-process.mjs";
6
+ import { detachWithRetry } from "./hdiutil-detach.mjs";
5
7
  import fs from "node:fs";
6
8
  import os from "node:os";
7
9
  import path from "node:path";
@@ -17,11 +19,11 @@ export class InstallAppError extends Error {
17
19
  }
18
20
  }
19
21
 
20
- const VALUE_FLAGS = new Set(["--dmg", "--app-name", "--bundle-id", "--dest"]);
21
- const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json"]);
22
+ const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest"]);
23
+ const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json", "--dev"]);
22
24
 
23
25
  export function parseArgs(argv) {
24
- const opts = { dmg: null, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false };
26
+ const opts = { dmg: null, zip: null, app: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false };
25
27
  for (let i = 0; i < argv.length; i += 1) {
26
28
  const flag = argv[i];
27
29
  if (VALUE_FLAGS.has(flag)) {
@@ -29,25 +31,35 @@ export function parseArgs(argv) {
29
31
  if (value === undefined || value.startsWith("--")) throw new InstallAppError("usage", `${flag} requires a value`);
30
32
  i += 1;
31
33
  if (flag === "--dmg") opts.dmg = value;
34
+ else if (flag === "--zip") opts.zip = value;
35
+ else if (flag === "--app") opts.app = value;
32
36
  else if (flag === "--app-name") opts.appName = value;
33
37
  else if (flag === "--bundle-id") opts.bundleId = value;
34
38
  else opts.dest = value;
35
39
  } else if (BOOL_FLAGS.has(flag)) {
36
40
  if (flag === "--json") opts.json = true;
41
+ else if (flag === "--dev") opts.dev = true;
37
42
  else opts.relaunch = flag === "--relaunch";
38
43
  } else {
39
44
  throw new InstallAppError("usage", `unknown argument: ${flag}`);
40
45
  }
41
46
  }
42
47
  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");
48
+ const inputs = [opts.dmg, opts.zip, opts.app].filter((v) => v !== null).length;
49
+ if (inputs === 0) throw new InstallAppError("usage", opts.dev ? "one of --dmg <path.dmg>, --zip <path.zip>, or --app <path.app> is required" : "--dmg <path.dmg> is required");
50
+ if (inputs > 1) throw new InstallAppError("usage", "--dmg, --zip, and --app are mutually exclusive");
51
+ if ((opts.zip !== null || opts.app !== null) && !opts.dev) throw new InstallAppError("usage", "--zip and --app require --dev (only DMGs are installable as release builds)");
52
+ if (opts.dmg !== null && !opts.dmg.toLowerCase().endsWith(".dmg")) throw new InstallAppError("usage", "--dmg must name a .dmg file");
53
+ if (opts.zip !== null && !opts.zip.toLowerCase().endsWith(".zip")) throw new InstallAppError("usage", "--zip must name a .zip file");
54
+ if (opts.app !== null && !opts.app.replace(/\/+$/, "").toLowerCase().endsWith(".app")) throw new InstallAppError("usage", "--app must name a .app bundle");
45
55
  if (opts.appName !== null && (!/^[^/\0]+\.app$/.test(opts.appName) || opts.appName === ".app" || opts.appName.includes(".."))) {
46
56
  throw new InstallAppError("usage", "--app-name must be a bare bundle name ending in .app");
47
57
  }
48
58
  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
59
  if (!path.isAbsolute(opts.dest)) opts.dest = path.resolve(opts.dest);
50
- opts.dmg = path.resolve(opts.dmg);
60
+ if (opts.dmg !== null) opts.dmg = path.resolve(opts.dmg);
61
+ if (opts.zip !== null) opts.zip = path.resolve(opts.zip);
62
+ if (opts.app !== null) opts.app = path.resolve(opts.app);
51
63
  return opts;
52
64
  }
53
65
 
@@ -164,9 +176,11 @@ export async function installApp(opts, deps = {}) {
164
176
  emit = () => {},
165
177
  tmpdir = os.tmpdir(),
166
178
  } = deps;
167
- const event = (name, data = {}) => emit({ event: name, ...data });
179
+ const dev = opts.dev === true;
180
+ const event = (name, data = {}) => emit({ event: name, ...(dev ? { dev: true } : {}), ...data });
168
181
  const run = (cmd, args) => execFile(cmd, args);
169
182
  let mountRoot = null;
183
+ let extractRoot = null;
170
184
  let mounted = false;
171
185
  let mountPoint = null;
172
186
  let failedStage = "start";
@@ -174,51 +188,74 @@ export async function installApp(opts, deps = {}) {
174
188
  const detach = async () => {
175
189
  if (!mounted) return;
176
190
  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 */ } }
191
+ await detachWithRetry({ mount: mountPoint, log: (e) => event(e.event, e), sleep, detach: (args) => run("/usr/bin/hdiutil", args) });
179
192
  try { fsImpl.rmSync(mountRoot, { recursive: true, force: true }); } catch { /* best effort */ }
180
193
  };
194
+ const cleanupExtract = () => {
195
+ if (!extractRoot) return;
196
+ try { fsImpl.rmSync(extractRoot, { recursive: true, force: true }); } catch { /* best effort */ }
197
+ extractRoot = null;
198
+ };
181
199
 
182
200
  try {
183
201
  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}`);
202
+ const inputPath = opts.dmg ?? opts.zip ?? opts.app;
203
+ const inputKind = opts.dmg ? "dmg" : opts.zip ? "zip" : "app";
204
+ event("start", { [inputKind]: inputPath, dest: opts.dest });
205
+ if (dev) event("dev-build", { note: "DEV BUILD (not notarized)" });
206
+ if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
186
207
  if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
187
208
 
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
209
  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`);
210
+ const pickApp = (dir, label) => {
211
+ const apps = fsImpl.readdirSync(dir).filter((n) => n.endsWith(".app"));
212
+ if (opts.appName) return apps.includes(opts.appName) ? path.join(dir, opts.appName) : null;
213
+ if (apps.length === 1) return path.join(dir, apps[0]);
214
+ if (apps.length > 1) throw new InstallAppError("ambiguous-app", `${label} contains multiple apps (${apps.join(", ")}); pass --app-name`);
215
+ return null;
216
+ };
217
+ if (opts.dmg) {
218
+ failedStage = "attach";
219
+ mountRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-"));
220
+ const attached = await run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountrandom", mountRoot, "-plist", opts.dmg]);
221
+ const mounts = mountPointsFromAttach(attached.stdout);
222
+ if (mounts.length === 0) throw new InstallAppError("attach", "DMG attached but exposed no mount point");
223
+ mountPoint = mounts[0];
224
+ mounted = true;
225
+ for (const mp of mounts) {
226
+ sourceApp = pickApp(mp, "DMG");
227
+ if (sourceApp) break;
228
+ }
229
+ if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in DMG` : "no .app found in DMG");
230
+ } else if (opts.zip) {
231
+ failedStage = "extract";
232
+ extractRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-zip-"));
233
+ try { await run("/usr/bin/ditto", ["-x", "-k", opts.zip, extractRoot]); }
234
+ catch (error) { throw new InstallAppError("zip-extract", `ditto -x -k failed for ${opts.zip}: ${errText(error)}`); }
235
+ sourceApp = pickApp(extractRoot, "zip");
236
+ if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in zip` : "no .app found in zip");
237
+ } else {
238
+ sourceApp = opts.app.replace(/\/+$/, "");
239
+ if (opts.appName && path.basename(sourceApp) !== opts.appName) throw new InstallAppError("app-not-found", `--app is ${path.basename(sourceApp)}, not ${opts.appName}`);
202
240
  }
203
- if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in DMG` : "no .app found in DMG");
204
241
  const appName = path.basename(sourceApp);
205
242
  const installed = path.join(opts.dest, appName);
206
243
  const hadInstalled = fsImpl.existsSync(installed);
207
244
 
208
245
  // Verify the mounted app before touching anything installed.
209
246
  failedStage = "verify";
210
- const newTeam = await verifyBundle(run, sourceApp, { assess: true, staple: true });
247
+ const { team: newTeam, ticket } = await verifyBundle(run, sourceApp, { assess: !dev, staple: !dev, dmg: opts.dmg });
211
248
  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}`);
249
+ if (opts.bundleId && newBundleId !== opts.bundleId) throw new InstallAppError("bundle-id-mismatch", `${inputKind.toUpperCase()} app bundle id ${newBundleId} != --bundle-id ${opts.bundleId}`);
213
250
  let installedTeam = null;
214
251
  if (hadInstalled) {
215
252
  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}`);
253
+ if (installedBundleId !== newBundleId) throw new InstallAppError("bundle-id-mismatch", `${inputKind.toUpperCase()} app bundle id ${newBundleId} != installed bundle id ${installedBundleId}`);
217
254
  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}`);
255
+ if (installedTeam && installedTeam !== newTeam) throw new InstallAppError("team-id-changed", `refusing team change: installed ${installedTeam}, ${inputKind.toUpperCase()} ${newTeam}`);
219
256
  }
220
257
  const bundleId = opts.bundleId || newBundleId;
221
- event("verified", { app: appName, bundleId, teamId: newTeam, replacing: hadInstalled });
258
+ event("verified", { app: appName, bundleId, teamId: newTeam, replacing: hadInstalled, ...(ticket ? { ticket } : {}) });
222
259
 
223
260
  // Quit running instance without UI.
224
261
  failedStage = "quit";
@@ -244,11 +281,17 @@ export async function installApp(opts, deps = {}) {
244
281
  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
282
  }
246
283
  event("replaced", { installed, previous: trashed });
284
+ // Dev builds are not notarized: clear quarantine on the NEW bundle only so Gatekeeper does not block it.
285
+ if (dev) {
286
+ try { await run("/usr/bin/xattr", ["-dr", "com.apple.quarantine", installed]); }
287
+ catch (error) { event("quarantine-clear-failed", { installed, reason: errText(error) }); }
288
+ }
247
289
  await detach();
290
+ cleanupExtract();
248
291
 
249
292
  // Re-verify installed copy.
250
293
  failedStage = "reverify";
251
- await verifyBundle(run, installed, { assess: true, staple: false });
294
+ await verifyBundle(run, installed, { assess: !dev, staple: false });
252
295
 
253
296
  // Relaunch hidden and in the background only.
254
297
  const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
@@ -257,7 +300,7 @@ export async function installApp(opts, deps = {}) {
257
300
  await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
258
301
  event("relaunched", { bundleId });
259
302
  }
260
- const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch };
303
+ const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
261
304
  event("done", result);
262
305
  return result;
263
306
  } catch (error) {
@@ -267,10 +310,11 @@ export async function installApp(opts, deps = {}) {
267
310
  throw error;
268
311
  } finally {
269
312
  await detach();
313
+ cleanupExtract();
270
314
  }
271
315
  }
272
316
 
273
- async function verifyBundle(run, app, { assess, staple }) {
317
+ async function verifyBundle(run, app, { assess, staple, dmg = null }) {
274
318
  const step = async (code, cmd, args) => {
275
319
  try { return await run(cmd, args); }
276
320
  catch (error) { throw new InstallAppError(code, `${code} failed for ${app}: ${errText(error)}`); }
@@ -280,8 +324,23 @@ async function verifyBundle(run, app, { assess, staple }) {
280
324
  if (!team) throw new InstallAppError("team-id-missing", `${app} has no Team ID (ad-hoc or unsigned)`);
281
325
  await step("codesign-requirement", "/usr/bin/codesign", ["-dr", "-", app]);
282
326
  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;
327
+ let ticket = null;
328
+ if (staple) {
329
+ try { await run("/usr/bin/xcrun", ["stapler", "validate", app]); ticket = "app-ticket"; }
330
+ catch (error) {
331
+ if (!dmg) throw new InstallAppError("stapler-validate", `stapler-validate failed for ${app}: ${errText(error)}`);
332
+ // Standard Tauri flow: the DMG carries the notarization ticket, not the app. Accept only when the
333
+ // stapled DMG validates, its primary signature is accepted, and Gatekeeper reports the app Notarized Developer ID.
334
+ await step("stapler-validate", "/usr/bin/xcrun", ["stapler", "validate", dmg]);
335
+ await step("spctl-dmg", "/usr/sbin/spctl", ["-a", "-t", "open", "--context", "context:primary-signature", dmg]);
336
+ const assessed = await step("spctl-notarized", "/usr/sbin/spctl", ["-a", "-t", "exec", "-vv", app]);
337
+ if (!/Notarized Developer ID/.test(`${assessed?.stderr || ""}\n${assessed?.stdout || ""}`)) {
338
+ throw new InstallAppError("spctl-notarized", `spctl-notarized failed for ${app}: not accepted as Notarized Developer ID`);
339
+ }
340
+ ticket = "dmg-ticket";
341
+ }
342
+ }
343
+ return { team, ticket };
285
344
  }
286
345
 
287
346
  async function teamIdOf(run, app) {
@@ -336,13 +395,15 @@ export async function main(argv = process.argv.slice(2), io = {}) {
336
395
  try { opts = parseArgs(argv); }
337
396
  catch (error) {
338
397
  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]");
398
+ stderr("usage: right-release install-app (--dmg <path.dmg> | --dev (--zip <path.zip> | --app <path.app> | --dmg <path.dmg>)) [--app-name Pulse.app] [--bundle-id dev.orthic.pulse] [--dest /Applications] [--relaunch|--no-relaunch] [--json]");
399
+ stderr(" --dev installs a Developer ID-signed, NOT notarized build (skips spctl/stapler, clears quarantine on the new bundle).");
340
400
  return 2;
341
401
  }
342
402
  const emit = (e) => {
343
403
  if (opts.json) { stdout(JSON.stringify({ schema: 1, ts: new Date().toISOString(), ...e })); return; }
344
404
  const { event, ...rest } = e;
345
405
  if (event === "failed") stderr(`install-app: failed at ${rest.stage}: ${rest.reason}`);
406
+ else if (event === "dev-build") stdout(`install-app: ${rest.note}`);
346
407
  else stdout(`install-app: ${event}${Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ""}`);
347
408
  };
348
409
  try {