@rightkit/release 0.2.173 → 0.3.0

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 (50) hide show
  1. package/README.md +188 -5
  2. package/bin/darwin/rightkit-swap +0 -0
  3. package/bin/darwin/rightkit-swap.sign.json +1 -1
  4. package/cli/right-release.mjs +34 -6
  5. package/command-spec.mjs +80 -0
  6. package/deps-cargo.mjs +55 -0
  7. package/deps.mjs +7 -0
  8. package/development-signing.mjs +135 -4
  9. package/development.mjs +12 -11
  10. package/dist-scan.mjs +102 -0
  11. package/dmg-build.mjs +53 -4
  12. package/icon-pack.mjs +117 -0
  13. package/in-place-install.mjs +32 -7
  14. package/init-command.mjs +116 -0
  15. package/install-app-windows.mjs +108 -17
  16. package/install-app.mjs +68 -11
  17. package/install-tauri-devid.mjs +106 -47
  18. package/local-publish.mjs +317 -29
  19. package/mac-bundle-signing.mjs +143 -1
  20. package/mac-signing-matrix.mjs +73 -6
  21. package/macho-approval-hash.mjs +181 -0
  22. package/macos-signing-session.mjs +12 -2
  23. package/managed-cargo.mjs +49 -1
  24. package/media-sidecar.mjs +20 -4
  25. package/mirror-root-artifact.mjs +69 -21
  26. package/npm-license-notices.mjs +159 -0
  27. package/ownership-dependency-allowlist.json +1 -0
  28. package/ownership.mjs +37 -4
  29. package/package-recipe-targets.mjs +180 -16
  30. package/package.json +1 -1
  31. package/pe-content.mjs +133 -0
  32. package/portable-development.mjs +9 -9
  33. package/publication-gate.mjs +70 -3
  34. package/publish-cargo.mjs +26 -7
  35. package/qa-build-features.mjs +7 -1
  36. package/recipe-build.mjs +346 -0
  37. package/release-dependencies-command.mjs +45 -0
  38. package/release-dependencies.mjs +60 -0
  39. package/release-switch-scan.mjs +17 -7
  40. package/release.mjs +98 -12
  41. package/rightkit-ownership.json +46 -2
  42. package/rightkit-signing-agent.mjs +10 -4
  43. package/rightkit-versions.json +11 -6
  44. package/runtime-budgets.mjs +28 -12
  45. package/stage-binary.mjs +1 -1
  46. package/stage-sidecar-command.mjs +36 -4
  47. package/stage-sidecar.mjs +926 -74
  48. package/test-command.mjs +190 -0
  49. package/toolchain-swift.mjs +239 -0
  50. package/web-build.mjs +67 -0
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawnSync } from "./background-process.mjs";
6
6
  import { runMacCodesignCommands } from "./macos-signing-session.mjs";
7
+ import { bundleRelativePath, compileBundlePathMap, globToRegExp, isGlobPattern, lookupBundlePath, resolveBundleIdentifier, validateBundleRelativeKey, validateIdentifierTemplate, validateSigningIdentifier } from "./mac-bundle-signing.mjs";
7
8
 
8
9
  export class MacosDevelopmentSigningError extends Error {
9
10
  constructor(code, message, details = {}) {
@@ -100,7 +101,8 @@ export function resolveMacosDevelopmentSigning({ root, bundle, descriptor = {},
100
101
  if (existsSync(file)) mac = { ...mac, ...JSON.parse(readFileSync(file, "utf8")).bundle?.macOS };
101
102
  }
102
103
  const sign = config.targets?.mac?.sign ?? {};
103
- const entitlements = sign.entitlements ? path.resolve(root, sign.entitlements) : mac.entitlements ? path.resolve(tauriRoot, mac.entitlements) : null;
104
+ // `sign.entitlements` is the app-level file when a string; an object is the per-path map (readDevSigningSettings).
105
+ const entitlements = typeof sign.entitlements === "string" && sign.entitlements ? path.resolve(root, sign.entitlements) : mac.entitlements ? path.resolve(tauriRoot, mac.entitlements) : null;
104
106
  if (entitlements && !existsSync(entitlements)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", `Configured entitlements file is missing: ${entitlements}`);
105
107
  // CI-only ad hoc: no Keychain, no provisioning profile (a profile authorizes restricted entitlements that an ad hoc signature cannot carry).
106
108
  if (ciAdhoc) return { identity: "-", certificateSha1: "-", teamIdentifier: null, entitlements, provisioningProfile: null, mode: CI_ADHOC_SIGNING, adhoc: true };
@@ -158,9 +160,128 @@ function macho(file) {
158
160
  } finally { closeSync(fd); }
159
161
  }
160
162
 
163
+ const invalidConfig = (message) => { throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", message); };
164
+ const ASSERT_KEYS = new Set(["path", "identifier", "teamIdentifier", "authority"]);
165
+
166
+ /**
167
+ * Declarative per-binary signing settings of a dev install. Everything is optional; with none declared the result is
168
+ * empty and signing is exactly what it was before these keys existed.
169
+ * install.identifiers / sign.identifiers { path-or-glob: "--identifier value" } ("." is the bundle)
170
+ * install.identifierTemplate / sign.identifierTemplate default identifier for remaining plain Mach-O files
171
+ * install.entitlements / sign.entitlements (object) { path-or-glob: plist path } (a string sign.entitlements is the app file)
172
+ * install.timestamp true adds --timestamp (a secure timestamp contacts Apple's server)
173
+ * install.assert [{ path-or-glob, identifier?, teamIdentifier?, authority? }]
174
+ * install.assertTeamSignedNested true: every Mach-O in the bundle carries the app's TeamIdentifier
175
+ * install.* entries win over sign.* entries (so one declaration in `sign` keeps dev and release identifiers identical).
176
+ * `root` resolves entitlement plist paths and checks they exist; without it only the shape is validated.
177
+ */
178
+ export function readDevSigningSettings(descriptor = {}, config = {}, { root, exists = existsSync } = {}) {
179
+ const sign = config?.targets?.mac?.sign ?? {};
180
+ const identifier = (raw, label) => validateSigningIdentifier(raw, label, invalidConfig);
181
+ const identifiers = [...compileBundlePathMap(descriptor.identifiers, "install.identifiers", { reject: invalidConfig, value: identifier }),
182
+ ...compileBundlePathMap(sign.identifiers, "sign.identifiers", { reject: invalidConfig, value: identifier })];
183
+ const templateSource = descriptor.identifierTemplate !== undefined ? ["install.identifierTemplate", descriptor.identifierTemplate] : sign.identifierTemplate !== undefined ? ["sign.identifierTemplate", sign.identifierTemplate] : null;
184
+ const identifierTemplate = templateSource ? validateIdentifierTemplate(templateSource[1], templateSource[0], invalidConfig) : undefined;
185
+ const plistPath = (raw, label) => {
186
+ if (typeof raw !== "string" || !raw.trim()) invalidConfig(`${label} must be an entitlements plist path`);
187
+ if (!root) return raw;
188
+ const file = path.resolve(root, raw);
189
+ if (!exists(file)) invalidConfig(`${label} entitlements file not found: ${file}`);
190
+ return file;
191
+ };
192
+ const signMap = typeof sign.entitlements === "object" && sign.entitlements !== null && !Array.isArray(sign.entitlements) ? sign.entitlements : undefined;
193
+ const entitlements = [...compileBundlePathMap(descriptor.entitlements, "install.entitlements", { reject: invalidConfig, value: plistPath }),
194
+ ...compileBundlePathMap(signMap, "sign.entitlements", { reject: invalidConfig, value: plistPath })];
195
+ if (descriptor.timestamp !== undefined && typeof descriptor.timestamp !== "boolean") invalidConfig("install.timestamp must be true or false");
196
+ if (descriptor.assertTeamSignedNested !== undefined && typeof descriptor.assertTeamSignedNested !== "boolean") invalidConfig("install.assertTeamSignedNested must be true or false");
197
+ const rawAssertions = descriptor.assert ?? [];
198
+ if (!Array.isArray(rawAssertions)) invalidConfig("install.assert must be an array of { path, identifier?, teamIdentifier?, authority? }");
199
+ const assertions = rawAssertions.map((item, index) => {
200
+ const label = `install.assert[${index}]`;
201
+ if (!item || typeof item !== "object" || Array.isArray(item)) invalidConfig(`${label} must be an object`);
202
+ for (const key of Object.keys(item)) if (!ASSERT_KEYS.has(key)) invalidConfig(`${label}.${key} is not a known key (path, identifier, teamIdentifier, authority)`);
203
+ validateBundleRelativeKey(item.path, `${label}.path`, invalidConfig);
204
+ const checks = ["identifier", "teamIdentifier", "authority"].filter((key) => item[key] !== undefined);
205
+ if (!checks.length) invalidConfig(`${label} needs at least one of identifier, teamIdentifier, authority`);
206
+ for (const key of checks) if (typeof item[key] !== "string" || !item[key].trim()) invalidConfig(`${label}.${key} must be a non-empty string`);
207
+ return { path: item.path, ...Object.fromEntries(checks.map((key) => [key, item[key]])) };
208
+ });
209
+ return { identifiers, identifierTemplate, entitlements, timestamp: descriptor.timestamp === true, assertions, assertTeamSignedNested: descriptor.assertTeamSignedNested === true };
210
+ }
211
+
212
+ function listBundleFiles(bundle) {
213
+ const out = [];
214
+ const walk = (directory) => {
215
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
216
+ const file = path.join(directory, entry.name);
217
+ if (entry.isSymbolicLink()) continue;
218
+ if (entry.isDirectory()) walk(file);
219
+ else if (entry.isFile()) out.push(bundleRelativePath(bundle, file));
220
+ }
221
+ };
222
+ walk(bundle);
223
+ return out;
224
+ }
225
+
226
+ /**
227
+ * Post-sign identity assertions, read from `codesign -dvv` (stdout and stderr: codesign reports on stderr). Fails closed with
228
+ * DEV_MAC_SIGNING_ASSERT_FAILED listing every mismatch. A glob that matches no file is a failure, so a typo cannot pass.
229
+ * `assertTeamSignedNested` additionally requires every Mach-O in the bundle (dylibs included) to be signed by
230
+ * `teamIdentifier`, not ad hoc. Used on the signed staging copy and again on the installed copy (a failure there rolls back).
231
+ * Injectable: run, listFiles(bundle) -> bundle-relative file paths, isMachO(file), join.
232
+ */
233
+ export function assertSignedIdentities({ root, bundle, settings, teamIdentifier, identity, env = process.env, run = spawnSync, listFiles = listBundleFiles, isMachO = macho, join = path.join, label = "signed bundle" }) {
234
+ const { assertions = [], assertTeamSignedNested = false } = settings ?? {};
235
+ if (!assertions.length && !assertTeamSignedNested) return { checked: 0 };
236
+ const reports = new Map();
237
+ const report = (file) => {
238
+ if (!reports.has(file)) {
239
+ const result = run("codesign", ["-dvv", file], captureOptions(root, env));
240
+ if (result.error || result.status !== 0) {
241
+ throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_ASSERT_FAILED", `${label}: cannot inspect the signing identity of ${file}: ${result.error?.message || output(result) || `exit ${result.status ?? "unavailable"}`}`, { identity });
242
+ }
243
+ const text = output(result);
244
+ reports.set(file, { identifier: /^Identifier=(.+)$/m.exec(text)?.[1]?.trim(), team: /^TeamIdentifier=(.+)$/m.exec(text)?.[1]?.trim(), authorities: [...text.matchAll(/^Authority=(.+)$/gm)].map((match) => match[1].trim()), adhoc: /^Signature=adhoc$/m.test(text) });
245
+ }
246
+ return reports.get(file);
247
+ };
248
+ const absolute = (relative) => (relative === "." ? bundle : join(bundle, ...relative.split("/")));
249
+ let files;
250
+ const listed = () => (files ??= listFiles(bundle));
251
+ const problems = [];
252
+ let checked = 0;
253
+ for (const item of assertions) {
254
+ const targets = isGlobPattern(item.path) ? listed().filter((relative) => globToRegExp(item.path).test(relative)) : [item.path];
255
+ if (!targets.length) { problems.push(`${item.path}: matches no file in the bundle`); continue; }
256
+ for (const relative of targets) {
257
+ const actual = report(absolute(relative));
258
+ checked += 1;
259
+ if (item.identifier !== undefined && actual.identifier !== item.identifier) problems.push(`${relative}: Identifier=${actual.identifier ?? "missing"}, expected ${item.identifier}`);
260
+ if (item.teamIdentifier !== undefined && actual.team !== item.teamIdentifier) problems.push(`${relative}: TeamIdentifier=${actual.team ?? "missing"}, expected ${item.teamIdentifier}`);
261
+ if (item.authority !== undefined && !actual.authorities.includes(item.authority)) problems.push(`${relative}: Authority does not include ${JSON.stringify(item.authority)} (found ${actual.authorities.map((name) => JSON.stringify(name)).join(", ") || "none"})`);
262
+ }
263
+ }
264
+ if (assertTeamSignedNested) {
265
+ if (!teamIdentifier) problems.push("assertTeamSignedNested: the expected TeamIdentifier is unknown (ad hoc signing has none)");
266
+ else {
267
+ for (const relative of [".", ...listed().filter((entry) => isMachO(absolute(entry)))]) {
268
+ const actual = report(absolute(relative));
269
+ checked += 1;
270
+ if (actual.adhoc || actual.team !== teamIdentifier) problems.push(`${relative}: ${actual.adhoc ? "ad hoc signed" : `TeamIdentifier=${actual.team ?? "missing"}`}, expected TeamIdentifier=${teamIdentifier}`);
271
+ }
272
+ }
273
+ }
274
+ if (problems.length) {
275
+ throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_ASSERT_FAILED", `${label}: signing identity assertions failed (${problems.length}): ${problems.slice(0, 20).join("; ")}${problems.length > 20 ? "; ..." : ""}. Refusing dev installation.`, { identity, problems });
276
+ }
277
+ return { checked };
278
+ }
279
+
161
280
  export function signMacosDevelopmentBundle(options) {
162
281
  const { root, bundle, env = process.env, run = spawnSync } = options;
163
282
  const context = { root, env, run };
283
+ // Declared per-binary settings are validated before any Keychain or codesign call.
284
+ const settings = readDevSigningSettings(options.descriptor, options.config, { root });
164
285
  const signing = resolveMacosDevelopmentSigning({ ...options, env, run });
165
286
  const { identity, certificateSha1, teamIdentifier, entitlements, provisioningProfile } = signing;
166
287
  const embedded = path.join(bundle, "Contents", "embedded.provisionprofile");
@@ -218,12 +339,20 @@ export function signMacosDevelopmentBundle(options) {
218
339
  const commands = [];
219
340
  for (const target of ordered) {
220
341
  // Bind the exact profile-authorized certificate even when names collide in Keychain.
221
- const args = ["--force", "--options", "runtime", "--sign", certificateSha1];
342
+ const relative = bundleRelativePath(bundle, target.file);
343
+ let identifier;
344
+ try {
345
+ identifier = resolveBundleIdentifier({ relative, identifiers: settings.identifiers, template: settings.identifierTemplate, bundleId: options.descriptor?.bundleId ?? appInfo.CFBundleIdentifier, plain: !target.info });
346
+ } catch (cause) { throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", cause.message, { cause }); }
347
+ // --timestamp only when declared (install.timestamp): it needs Apple's timestamp server, and dev builds otherwise sign offline.
348
+ const args = ["--force", "--options", "runtime", ...(settings.timestamp ? ["--timestamp"] : []), ...(identifier ? ["--identifier", identifier] : []), "--sign", certificateSha1];
222
349
  const parent = target.file === bundle;
350
+ const mappedEntitlements = lookupBundlePath(settings.entitlements, relative || ".");
223
351
  const profiledHelper = target.info && existsSync(path.join(target.file, "Contents/embedded.provisionprofile")) && target.info.CFBundleIdentifier === appInfo.CFBundleIdentifier;
224
352
  if (profiledHelper && !parent && provisioningProfile) copyFileSync(embedded, path.join(target.file, "Contents/embedded.provisionprofile"));
225
353
  const variant = !parent && entitlementVariants.find((item) => path.basename(target.file).replace(/\.(app|xpc|appex|bundle)$/, "").toLowerCase().endsWith(item.suffix));
226
- if (variant) args.push("--entitlements", entitlementFile(variant.file));
354
+ if (mappedEntitlements) args.push("--entitlements", entitlementFile(mappedEntitlements));
355
+ else if (variant) args.push("--entitlements", entitlementFile(variant.file));
227
356
  else if (entitlements && (parent || profiledHelper)) args.push("--entitlements", entitlementFile(entitlements));
228
357
  else {
229
358
  const existing = run("codesign", ["--display", "--entitlements", ":-", target.file], captureOptions(root, env));
@@ -248,7 +377,9 @@ export function signMacosDevelopmentBundle(options) {
248
377
  const requirement = output(checked("codesign", ["--display", "-r-", bundle], context, "DEV_MAC_SIGNATURE_INVALID", identity));
249
378
  const designated = /^designated => (.+)$/m.exec(requirement)?.[1]?.trim();
250
379
  if (!designated || /\bcdhash\b/.test(designated)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_UNSTABLE_REQUIREMENT", `Signing identity ${JSON.stringify(identity)}: designated requirement ${designated ? JSON.stringify(designated) : "is missing"} is not stable across rebuilds (cdhash-bound). Refusing dev installation.`, { identity, designatedRequirement: designated });
251
- return { ...signing, provisioningProfile: provisioningProfile ? embedded : null, verified: "strict-deep", designatedRequirement: designated, signedTargets: ordered.length };
380
+ // Declared identity assertions (install.assert, install.assertTeamSignedNested) on the signed staging copy, before any install.
381
+ const asserted = assertSignedIdentities({ root, bundle, settings, teamIdentifier, identity, env, run, label: "signed staging copy", ...(options.listFiles ? { listFiles: options.listFiles } : {}), ...(options.isMachO ? { isMachO: options.isMachO } : {}) });
382
+ return { ...signing, provisioningProfile: provisioningProfile ? embedded : null, verified: "strict-deep", designatedRequirement: designated, signedTargets: ordered.length, ...(asserted.checked ? { assertedIdentities: asserted.checked } : {}) };
252
383
  } finally { rmSync(scratch, { recursive: true, force: true }); }
253
384
  }
254
385
 
package/development.mjs CHANGED
@@ -105,7 +105,7 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
105
105
  // Android: cargo-ndk (Cargo dev profile) fills the generated jniLibs, then the Gradle debug build packages it.
106
106
  if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, label: "native build" }); }
107
107
  for (const command of target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
108
- // Dev and QA binaries run from one stable staged path, copied in place (<name>.previous kept), never from the
108
+ // Dev and QA binaries run from one stable staged path, copied in place (no backup of the old copy), never from the
109
109
  // broker's per-generation target folder or a hashed dev-targets folder.
110
110
  const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
111
111
  for (const file of staged) console.log(`right-release dev: staged ${file}`);
@@ -114,7 +114,7 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
114
114
  // installTauriDevId is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
115
115
  const built = resolveTauriArtifact(target.install, { root, platform, env: childEnv });
116
116
  releaseLease = false;
117
- return installTauriDevId({ appRoot: root, config: target.install, built, env: childEnv })
117
+ return installTauriDevId({ appRoot: root, config: target.install, releaseConfig: config, built, env: childEnv })
118
118
  .then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), devid: outcome }))
119
119
  .finally(() => lease.release());
120
120
  }
@@ -140,9 +140,10 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
140
140
  // was running, pass /R so the installer relaunches it as the user (RunAsUser, outside this process tree).
141
141
  const running = isWindowsProcessRunning(path.basename(descriptor.expectedInstalledPath), { run });
142
142
  if (running) console.log(`right-release dev: ${descriptor.productName} is running; the installer will close it and relaunch it`);
143
- // The silent installer updates the fixed path in place. Keep the copy it replaces once, as <exe>.previous (never run).
143
+ // The silent installer updates the fixed path in place. NO backup of the old copy (Orthic-Labs/rightkit#348): a stale
144
+ // <exe>.previous left by an older RightKit is removed, nothing is copied, and a failed install is reinstalled from an older build.
144
145
  const installedBefore = path.resolve(root, descriptor.expectedInstalledPath);
145
- if (existsSync(installedBefore) && statSync(installedBefore).isFile()) copyFileSync(installedBefore, previousPathOf(installedBefore));
146
+ rmSync(previousPathOf(installedBefore), { recursive: true, force: true });
146
147
  const result = run(app, windowsInstallerArguments({ running }), { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
147
148
  if (result.status !== 0) throw new Error(`development installer failed (${result.status}): ${app}`);
148
149
  const installed = path.resolve(root, descriptor.expectedInstalledPath);
@@ -156,7 +157,6 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
156
157
  mkdirSync(path.dirname(destination), { recursive: true });
157
158
  // Fixed staging name (not per-pid): the copy is signed and verified here, then renamed into place. Nothing runs from it.
158
159
  const staging = stagingPathOf(destination);
159
- const previous = previousPathOf(destination);
160
160
  rmSync(staging, { recursive: true, force: true });
161
161
  cpSync(app, staging, { recursive: true, verbatimSymlinks: true });
162
162
  let signing;
@@ -167,7 +167,7 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
167
167
  if (!signing.adhoc && existsSync(destination)) assertStableDesignatedRequirement({ root, installed: destination, expected: signing.designatedRequirement, env, run });
168
168
  executableName = readBundleExecutable(staging, { root, env, run });
169
169
  } catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
170
- swapInstalledMacosBundle({ staging, destination, previous, executableName });
170
+ swapInstalledMacosBundle({ staging, destination, executableName });
171
171
  if (signing.provisioningProfile) signing.provisioningProfile = path.join(destination, "Contents", "embedded.provisionprofile");
172
172
  if (signing.adhoc) console.log(`right-release dev: installed ${destination}; signing=ci-adhoc (ad hoc signed on a CI runner; NOT a release artifact); signature=adhoc; TeamIdentifier=none`);
173
173
  else console.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; profile=${signing.provisioningProfile ?? "none"}; designated=${signing.designatedRequirement}`);
@@ -181,12 +181,13 @@ export class DevelopmentInstallError extends Error {
181
181
  const NODE_FS = { existsSync, lstatSync, mkdirSync, copyFileSync, readFileSync, renameSync, rmSync, statSync };
182
182
 
183
183
  /**
184
- * Move the signed staging bundle into place, keeping the previous app as `<destination>.previous`, and verify it: Info.plist exists and
184
+ * Move the signed staging bundle into place IN PLACE with NO backup (Orthic-Labs/rightkit#348: the old app is removed,
185
+ * never kept as `<destination>.previous`; a stale `.previous` is removed too), and verify it: Info.plist exists and
185
186
  * the installed main executable (`executableName`, the staged CFBundleExecutable read through plutil by the caller) exists with the same SHA-256 as the staged one. Any failure removes the
186
- * new install, renames `.previous` back and throws a DevelopmentInstallError (code DEV_INSTALL_VERIFICATION_FAILED,
187
+ * new (absent or partial) install, does NOT restore the old app (reinstall from an older DMG), and throws a DevelopmentInstallError (code DEV_INSTALL_VERIFICATION_FAILED,
187
188
  * DEV_INSTALL_EXECUTABLE_MISSING or DEV_INSTALL_EXECUTABLE_MISMATCH). `fs` is the file-system seam (tests inject it).
188
189
  */
189
- export function swapInstalledMacosBundle({ staging, destination, previous = previousPathOf(destination), executableName, fs = NODE_FS }) {
190
+ export function swapInstalledMacosBundle({ staging, destination, executableName, fs = NODE_FS }) {
190
191
  if (typeof executableName !== "string" || !executableName) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", "swapInstalledMacosBundle requires the staged CFBundleExecutable name");
191
192
  const executable = path.join("Contents", "MacOS", executableName);
192
193
  const digest = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
@@ -196,9 +197,9 @@ export function swapInstalledMacosBundle({ staging, destination, previous = prev
196
197
  fs.rmSync(staging, { recursive: true, force: true });
197
198
  throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `staged app has no main executable ${executable}: ${staging} (${error.message})`);
198
199
  }
199
- // The old app becomes `<destination>.previous` (replacing any older one) and stays there for rollback; any failure renames it back.
200
+ // The old app is removed and the staged one renamed over it; no old copy survives, even on failure.
200
201
  swapInPlace({
201
- staging, destination, previous, fs,
202
+ staging, destination, previous: null, fs,
202
203
  verify: () => {
203
204
  if (!fs.existsSync(path.join(destination, "Contents", "Info.plist"))) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", `installed app verification failed: ${destination}`);
204
205
  const installed = path.join(destination, executable);
package/dist-scan.mjs ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ // Fails when a built web bundle (vite `dist/`) carries dev, mock or fixture code (replaces per-app
3
+ // checkReleaseBundle.mjs copies). Scans text files for forbidden marker strings and flags chunk file
4
+ // names that match a forbidden name pattern. Read-only: it never writes, builds or follows a symlink.
5
+ // Usage: right-release dist-scan --dist <dir> --forbid <string>... [--forbid-file <path>]
6
+ // [--forbid-name <regex>]... [--ext js,mjs,css,html,json,map]
7
+ // A --forbid-file is JSON (`["a","b"]` or `{ "forbid": [...], "forbidNames": [...] }`) or plain text with
8
+ // one marker per line (blank lines and `#` comments ignored).
9
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
10
+ import { basename, join, relative, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ export const DEFAULT_EXTENSIONS = Object.freeze(["js", "mjs", "css", "html", "json", "map"]);
14
+ export const DEFAULT_FORBIDDEN_NAMES = Object.freeze([/^(devSource|mockDataSource|fixtures)[-.]/]);
15
+
16
+ export function parseForbidFile(text, name = "forbid file") {
17
+ const trimmed = text.trim();
18
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
19
+ let data;
20
+ try { data = JSON.parse(trimmed); } catch (error) { throw new Error(`${name}: invalid JSON (${error.message})`); }
21
+ const forbid = Array.isArray(data) ? data : data.forbid ?? [];
22
+ const forbidNames = Array.isArray(data) ? [] : data.forbidNames ?? [];
23
+ if (!Array.isArray(forbid) || !Array.isArray(forbidNames) || ![...forbid, ...forbidNames].every((s) => typeof s === "string" && s)) throw new Error(`${name}: entries must be non-empty strings`);
24
+ return { forbid, forbidNames };
25
+ }
26
+ return { forbid: trimmed.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#")), forbidNames: [] };
27
+ }
28
+
29
+ /** Scan `dist`. Returns { dist, files, scanned, markers, offenders: [{file, reason}] }. */
30
+ export function scanDist(dist, { forbid = [], forbidNames = [], extensions = DEFAULT_EXTENSIONS } = {}) {
31
+ const root = resolve(dist);
32
+ if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`No ${root}: build first.`);
33
+ const nameRes = [...DEFAULT_FORBIDDEN_NAMES, ...forbidNames.map((pattern) => {
34
+ try { return new RegExp(pattern); } catch (error) { throw new Error(`--forbid-name ${JSON.stringify(pattern)} is not a regular expression (${error.message})`); }
35
+ })];
36
+ const extRe = new RegExp(`\\.(${extensions.map((e) => e.replace(/[^\w]/g, "")).filter(Boolean).join("|")})$`);
37
+ const offenders = [];
38
+ let files = 0;
39
+ let scanned = 0;
40
+ const walk = (dir) => {
41
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
42
+ const path = join(dir, entry.name);
43
+ if (entry.isSymbolicLink()) continue;
44
+ if (entry.isDirectory()) { walk(path); continue; }
45
+ files += 1;
46
+ const rel = relative(root, path);
47
+ if (nameRes.some((re) => re.test(basename(path)))) offenders.push({ file: rel, reason: "forbidden chunk name" });
48
+ if (!extRe.test(path)) continue;
49
+ scanned += 1;
50
+ const text = readFileSync(path, "utf8");
51
+ for (const marker of forbid) if (text.includes(marker)) offenders.push({ file: rel, reason: `marker: ${marker}` });
52
+ }
53
+ };
54
+ walk(root);
55
+ return { dist: root, files, scanned, markers: forbid.length, offenders };
56
+ }
57
+
58
+ export function distScanFailed(result) {
59
+ return result.offenders.length > 0 || result.files === 0 || result.markers === 0;
60
+ }
61
+
62
+ const USAGE = "usage: right-release dist-scan --dist <dir> --forbid <string>... [--forbid-file <path>] [--forbid-name <regex>]... [--ext js,css,...]";
63
+
64
+ export function parseArgs(argv) {
65
+ const out = { dist: null, forbid: [], forbidNames: [], extensions: undefined };
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const a = argv[i];
68
+ const value = () => { const v = argv[++i]; if (v === undefined || v.startsWith("--")) throw new Error(`${a} needs a value`); return v; };
69
+ if (a === "--dist") out.dist = value();
70
+ else if (a === "--forbid") out.forbid.push(value());
71
+ else if (a === "--forbid-name") out.forbidNames.push(value());
72
+ else if (a === "--ext") out.extensions = value().split(",").filter(Boolean);
73
+ else if (a === "--forbid-file") {
74
+ const file = value();
75
+ const parsed = parseForbidFile(readFileSync(file, "utf8"), file);
76
+ out.forbid.push(...parsed.forbid);
77
+ out.forbidNames.push(...parsed.forbidNames);
78
+ } else throw new Error(`unknown argument ${a}`);
79
+ }
80
+ if (!out.dist) throw new Error("--dist is required");
81
+ return out;
82
+ }
83
+
84
+ export function main(argv) {
85
+ let result;
86
+ try {
87
+ const args = parseArgs(argv);
88
+ if (args.forbid.length === 0) throw new Error("no --forbid markers given");
89
+ result = scanDist(args.dist, args);
90
+ } catch (error) {
91
+ console.error(`right-release dist-scan: ${error.message}\n${USAGE}`);
92
+ return 2;
93
+ }
94
+ if (distScanFailed(result)) {
95
+ console.error(`Release bundle contains forbidden code:\n${result.offenders.map((o) => `${o.file}: ${o.reason}`).join("\n") || `no files in ${result.dist}`}`);
96
+ return 1;
97
+ }
98
+ console.log(`Release bundle clean: ${result.markers} markers absent from ${result.dist} (${result.scanned} files scanned)`);
99
+ return 0;
100
+ }
101
+
102
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) process.exit(main(process.argv.slice(2)));
package/dmg-build.mjs CHANGED
@@ -7,6 +7,10 @@ import { readDmgSpec } from "./dmg-spec.mjs";
7
7
  import { detachWithRetrySync } from "./hdiutil-detach.mjs";
8
8
  import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
9
9
  import { assertNoQaControl } from "./qa-control-scan.mjs";
10
+ import { signMacosEntries, validateCodesignIdentifier } from "./mac-sign-core.mjs";
11
+ import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
12
+ import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
13
+ import { runMacCodesignCommands } from "./macos-signing-session.mjs";
10
14
 
11
15
  const fail = (code, message) => Object.assign(new Error(message), { code });
12
16
  const pair = (value, label, positive = false) => {
@@ -25,12 +29,44 @@ export function resolveDmgOptions(input = {}) {
25
29
  return spec ? { ...readDmgSpec(spec, { app: explicit.app }), ...definedOnly(explicit) } : explicit;
26
30
  }
27
31
 
32
+ /**
33
+ * Developer ID sign a finished disk image (#646): secure timestamp, no hardened runtime (a disk image has none), an
34
+ * optional stable `--identifier`, strict verification, and an optional signing receipt (the sign-macos.mjs shape) so a
35
+ * later notarize step can bind to it. It never notarizes or uploads. Developer ID only: no ad hoc or Apple Development fallback.
36
+ */
37
+ export function signBuiltDmg({ file, identifier, receipt }) {
38
+ const identity = resolveMacosDeveloperIdIdentity();
39
+ const files = signMacosEntries([{ file, hardenedRuntime: false, options: [], ...(identifier ? { identifier } : {}) }], {
40
+ identity,
41
+ signOne: (codesignArgs) => runMacCodesignCommands([codesignArgs])[0],
42
+ inspect: (tool, args) => {
43
+ const result = spawnSync(tool, args, { encoding: "utf8" });
44
+ const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
45
+ if (result.status !== 0) throw new Error(`${tool} ${args.join(" ")} failed: ${output.trim()}`);
46
+ return output;
47
+ },
48
+ });
49
+ if (receipt) {
50
+ mkdirSync(path.dirname(receipt), { recursive: true });
51
+ writeFileSync(receipt, `${JSON.stringify({ schema: 1, signingContract: MACOS_PORTABLE_SIGNING_CONTRACT, identity, files }, null, 2)}\n`);
52
+ }
53
+ return files;
54
+ }
55
+
56
+ /**
57
+ * Build the branded DMG; with `sign: true` (CLI `--sign`) it is then Developer ID signed (`identifier`, `signReceipt`
58
+ * optional) and a signing failure removes the unsigned image so nothing downstream can pick it up. `signDmg` is injectable.
59
+ */
28
60
  export function buildDmg(input = {},
29
61
  { platform = process.platform, run = spawnSync, makeAlias = backgroundAlias,
30
- log = (event) => console.error(JSON.stringify(event)), sleep } = {}) {
62
+ log = (event) => console.error(JSON.stringify(event)), sleep, signDmg = signBuiltDmg } = {}) {
31
63
  if (platform !== "darwin") throw fail("DMG_NATIVE_HOST_REQUIRED", "DMG creation requires macOS");
32
64
  let { app, out, volname, background, iconSize = 128, appPosition = [158, 231],
33
- applicationsPosition = [492, 231], window = [660, 400], windowPosition = [100, 100], volumeIcon } = resolveDmgOptions(input);
65
+ applicationsPosition = [492, 231], window = [660, 400], windowPosition = [100, 100], volumeIcon,
66
+ sign = false, identifier, signReceipt } = resolveDmgOptions(input);
67
+ if (identifier !== undefined && !sign) throw fail("DMG_INVALID_OPTIONS", "--identifier needs --sign");
68
+ if (signReceipt !== undefined && !sign) throw fail("DMG_INVALID_OPTIONS", "--receipt needs --sign");
69
+ if (identifier !== undefined) { try { validateCodesignIdentifier(identifier, "--identifier"); } catch (error) { throw fail("DMG_INVALID_OPTIONS", error.message); } }
34
70
  if (!app || !out || !volname) throw fail("DMG_INVALID_OPTIONS", "--app, --out & --volname are required");
35
71
  app = path.resolve(app); out = path.resolve(out);
36
72
  if (!app.endsWith(".app") || !statSync(app).isDirectory() || !existsSync(path.join(app, "Contents", "Info.plist"))) {
@@ -103,8 +139,18 @@ export function buildDmg(input = {},
103
139
  command("/usr/bin/hdiutil", ["convert", rw, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", compressed]);
104
140
  command("/usr/bin/hdiutil", ["verify", compressed]);
105
141
  renameSync(compressed, out);
106
- log({ event: "dmg-build-completed", out, format: "UDZO" });
107
- return { out, format: "UDZO" };
142
+ let signed;
143
+ if (sign) {
144
+ // Developer ID sign the finished image. A failure removes the unsigned image: nothing downstream may pick it up.
145
+ try { signed = signDmg({ file: out, ...(identifier ? { identifier } : {}), ...(signReceipt ? { receipt: path.resolve(signReceipt) } : {}) }); }
146
+ catch (error) {
147
+ rmSync(out, { force: true });
148
+ throw fail("DMG_SIGN_FAILED", `signing ${out} failed (the unsigned image was removed): ${error.message}`);
149
+ }
150
+ log({ event: "dmg-signed", out, ...(identifier ? { identifier } : {}) });
151
+ }
152
+ log({ event: "dmg-build-completed", out, format: "UDZO", signed: Boolean(sign) });
153
+ return { out, format: "UDZO", ...(sign ? { signed: true, signing: signed } : {}) };
108
154
  } catch (error) {
109
155
  log({ event: "dmg-build-failed", out, code: error.code ?? "DMG_BUILD_FAILED", message: error.message });
110
156
  throw error;
@@ -122,8 +168,11 @@ export function parseDmgArguments(args) {
122
168
  if (args[0] !== "build") throw fail("DMG_INVALID_OPTIONS", "expected dmg build");
123
169
  const flags = new Map([["--app", "app"], ["--out", "out"], ["--volname", "volname"], ["--background", "background"],
124
170
  ["--icon-size", "iconSize"], ["--app-position", "appPosition"], ["--applications-position", "applicationsPosition"], ["--window", "window"], ["--window-position", "windowPosition"], ["--volume-icon", "volumeIcon"], ["--spec", "spec"]]);
171
+ flags.set("--identifier", "identifier"); flags.set("--receipt", "signReceipt");
125
172
  const options = {};
126
173
  for (let i = 1; i < args.length; i += 2) {
174
+ // `--sign` is the one value-less flag: Developer ID sign the finished image (#646).
175
+ if (args[i] === "--sign") { if (options.sign !== undefined) throw fail("DMG_INVALID_OPTIONS", "invalid option: --sign"); options.sign = true; i -= 1; continue; }
127
176
  const key = flags.get(args[i]);
128
177
  if (!key || options[key] !== undefined || !args[i + 1] || args[i + 1].startsWith("--")) throw fail("DMG_INVALID_OPTIONS", `invalid option: ${args[i]}`);
129
178
  options[key] = args[i + 1];
package/icon-pack.mjs ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // App icon packers (bogusyogi/rightkit#675): assemble a Windows ICO or a macOS ICNS from per-size PNG frames, so
3
+ // hand-tuned small frames (16/24/32/48) are kept exactly as drawn instead of being down-scaled from one master.
4
+ // right-release icons ico --out <file.ico> --frame <PNG>... [--dir <folder of <size>.png>]
5
+ // right-release icons icns --out <file.icns> --frame <PNG>... [--dir <folder of <size>.png>]
6
+ // Every frame must be a square PNG; its size comes from its IHDR, never from the file name. ICO frames are stored
7
+ // as PNG (Vista+), sizes 1..256. ICNS accepts 16, 32, 64, 128, 256, 512 and 1024. Pure Buffer work, any host.
8
+ // Rendering a master image to PNG frames and DMG background art stay with the app's design source
9
+ // (dmg background: generate-dmg-background.py).
10
+ import { readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
11
+ import path from "node:path";
12
+
13
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
14
+ export const ICNS_TYPES = Object.freeze({ 16: "icp4", 32: "icp5", 64: "icp6", 128: "ic07", 256: "ic08", 512: "ic09", 1024: "ic10" });
15
+ function fail(message, code = "ICON_INVALID") { throw Object.assign(new Error(message), { code }); }
16
+
17
+ /** Read the pixel size of a square PNG from its IHDR. */
18
+ export function pngSize(bytes, label = "frame") {
19
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
20
+ if (buf.length < 33 || !buf.subarray(0, 8).equals(PNG_MAGIC) || buf.toString("latin1", 12, 16) !== "IHDR") fail(`${label} is not a PNG`);
21
+ const width = buf.readUInt32BE(16), height = buf.readUInt32BE(20);
22
+ if (width !== height) fail(`${label} must be square, got ${width}x${height}`);
23
+ // Walk the chunk list: IHDR first (13 bytes), at least one IDAT, IEND last with nothing after it.
24
+ if (buf.readUInt32BE(8) !== 13) fail(`${label} has a malformed IHDR chunk`);
25
+ let at = 8, idat = false, iend = false, first = true;
26
+ while (at < buf.length) {
27
+ if (at + 12 > buf.length) fail(`${label} is a truncated PNG`);
28
+ const length = buf.readUInt32BE(at), type = buf.toString("latin1", at + 4, at + 8);
29
+ if (at + 12 + length > buf.length) fail(`${label} is a truncated PNG`);
30
+ if (iend) fail(`${label} has data after IEND`);
31
+ if (first && type !== "IHDR") fail(`${label} is not a PNG`);
32
+ first = false;
33
+ if (type === "IDAT") idat = true;
34
+ if (type === "IEND") iend = true;
35
+ at += 12 + length;
36
+ }
37
+ if (!idat) fail(`${label} has no image data (IDAT)`);
38
+ if (!iend) fail(`${label} is missing IEND`);
39
+ const depth = buf[24], colorType = buf[25];
40
+ if (depth !== 8 || colorType !== 6) fail(`${label} must be 8-bit RGBA (color type 6, bit depth 8), got bit depth ${depth}, color type ${colorType}`);
41
+ return width;
42
+ }
43
+
44
+ function normalizeFrames(frames, { min = 1, max = 1024, only = null } = {}) {
45
+ if (!Array.isArray(frames) || frames.length === 0) fail("at least one PNG frame is required", "ICON_USAGE");
46
+ const bySize = new Map();
47
+ for (const frame of frames) {
48
+ const bytes = Buffer.isBuffer(frame) ? frame : Buffer.from(frame);
49
+ const size = pngSize(bytes);
50
+ if (size < min || size > max || (only && !only.includes(size))) fail(`unsupported frame size ${size}${only ? ` (allowed: ${only.join(", ")})` : ` (allowed ${min}..${max})`}`);
51
+ if (bySize.has(size)) fail(`duplicate ${size}x${size} frame`);
52
+ bySize.set(size, bytes);
53
+ }
54
+ return [...bySize.entries()].sort((a, b) => a[0] - b[0]);
55
+ }
56
+
57
+ /** @param {Buffer[]} frames PNG buffers, one per size (1..256). @returns {Buffer} ICO file. */
58
+ export function packIco(frames) {
59
+ const sorted = normalizeFrames(frames, { max: 256 });
60
+ const header = Buffer.alloc(6 + 16 * sorted.length);
61
+ header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(sorted.length, 4);
62
+ let offset = header.length;
63
+ sorted.forEach(([size, png], i) => {
64
+ const at = 6 + i * 16;
65
+ header[at] = size === 256 ? 0 : size; header[at + 1] = size === 256 ? 0 : size;
66
+ header.writeUInt16LE(1, at + 4); header.writeUInt16LE(32, at + 6);
67
+ header.writeUInt32LE(png.length, at + 8); header.writeUInt32LE(offset, at + 12);
68
+ offset += png.length;
69
+ });
70
+ return Buffer.concat([header, ...sorted.map(([, png]) => png)]);
71
+ }
72
+
73
+ /** @param {Buffer[]} frames PNG buffers (16, 32, 64, 128, 256, 512, 1024). @returns {Buffer} ICNS file. */
74
+ export function packIcns(frames) {
75
+ const sorted = normalizeFrames(frames, { only: Object.keys(ICNS_TYPES).map(Number) });
76
+ const chunks = sorted.map(([size, png]) => {
77
+ const head = Buffer.alloc(8);
78
+ head.write(ICNS_TYPES[size], 0, "latin1"); head.writeUInt32BE(png.length + 8, 4);
79
+ return Buffer.concat([head, png]);
80
+ });
81
+ const total = 8 + chunks.reduce((n, c) => n + c.length, 0);
82
+ const head = Buffer.alloc(8);
83
+ head.write("icns", 0, "latin1"); head.writeUInt32BE(total, 4);
84
+ return Buffer.concat([head, ...chunks]);
85
+ }
86
+
87
+ function parseArgs(argv) {
88
+ const [kind, ...rest] = argv;
89
+ const opts = { kind, out: null, frames: [], dirs: [] };
90
+ for (let i = 0; i < rest.length; i++) {
91
+ const arg = rest[i], value = rest[i + 1];
92
+ if (!["--out", "--frame", "--dir"].includes(arg)) fail(`unknown icons argument: ${arg}`, "ICON_USAGE");
93
+ if (value === undefined || value.startsWith("--")) fail(`${arg} needs a value`, "ICON_USAGE");
94
+ i += 1;
95
+ if (arg === "--out") opts.out = value; else if (arg === "--frame") opts.frames.push(value); else opts.dirs.push(value);
96
+ }
97
+ if (!["ico", "icns"].includes(kind) || !opts.out) fail("usage: right-release icons ico|icns --out <file> --frame <PNG>... [--dir <folder>]", "ICON_USAGE");
98
+ for (const dir of opts.dirs) for (const name of readdirSync(dir).filter((n) => n.toLowerCase().endsWith(".png")).sort()) opts.frames.push(path.join(dir, name));
99
+ return opts;
100
+ }
101
+
102
+ export function main(argv = process.argv.slice(2), out = console) {
103
+ try {
104
+ const opts = parseArgs(argv);
105
+ const frames = opts.frames.map((file) => readFileSync(file));
106
+ const bytes = opts.kind === "ico" ? packIco(frames) : packIcns(frames);
107
+ mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true });
108
+ writeFileSync(opts.out, bytes);
109
+ out.log(JSON.stringify({ out: opts.out, kind: opts.kind, frames: opts.frames.length, bytes: bytes.length }));
110
+ return 0;
111
+ } catch (error) {
112
+ out.error(error.message);
113
+ return error.code === "ICON_USAGE" ? 2 : 1;
114
+ }
115
+ }
116
+
117
+ if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) process.exit(main());