@rightkit/release 0.4.0 → 0.4.1

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.
@@ -4,7 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { spawnSync } from "./background-process.mjs";
7
- import { appleProfileAuth, decodeAppleProfile, signAppleApiJwt } from "./apple-profile-renew.mjs";
7
+ import { appleProfileAuth, decodeAppleProfile, findAppleSigningCertificate, signAppleApiJwt } from "./apple-profile-renew.mjs";
8
8
 
9
9
  // right-release apple-profile-create: issue a Mac Development provisioning profile
10
10
  // (MAC_APP_DEVELOPMENT) for one explicit bundle id with the ICLOUD/CloudKit capability,
@@ -14,6 +14,7 @@ import { appleProfileAuth, decodeAppleProfile, signAppleApiJwt } from "./apple-p
14
14
 
15
15
  const ASC = "https://api.appstoreconnect.apple.com";
16
16
  const PROFILE_TYPE = "MAC_APP_DEVELOPMENT";
17
+ const DIRECT_TYPE = "MAC_APP_DIRECT";
17
18
  export class AppleProfileCreateError extends Error {
18
19
  constructor(code, message) { super(message); this.name = "AppleProfileCreateError"; this.code = code; }
19
20
  }
@@ -56,7 +57,7 @@ const UDID_PATTERN = /^[A-Fa-f0-9-]{8,64}$/;
56
57
 
57
58
  export function parseCreateArgs(args) {
58
59
  const out = { configPath: undefined, bundleId: "", name: "", containers: [], type: PROFILE_TYPE, certificate: "create", out: "", dryRun: true, apply: false, registerDevices: [], keyOut: "" };
59
- const values = { "--config": "configPath", "--bundle-id": "bundleId", "--name": "name", "--type": "type", "--certificate": "certificate", "--out": "out", "--key-out": "keyOut" };
60
+ const values = { "--config": "configPath", "--bundle-id": "bundleId", "--name": "name", "--type": "type", "--certificate": "certificate", "--out": "out", "--key-out": "keyOut", "--expected-team": "expectedTeam", "--expected-keychain-group": "expectedKeychainGroup" };
60
61
  for (let i = 0; i < args.length; i++) {
61
62
  const arg = args[i];
62
63
  if (arg === "--dry-run") out.dryRun = true;
@@ -71,10 +72,10 @@ export function parseCreateArgs(args) {
71
72
  }
72
73
  if (out.apply && args.includes("--dry-run")) fail("APPLE_PROFILE_USAGE", "--dry-run and --apply are mutually exclusive.");
73
74
  out.dryRun = !out.apply;
74
- if (out.type !== PROFILE_TYPE) fail("APPLE_PROFILE_USAGE", `--type must be ${PROFILE_TYPE} (the only supported value).`);
75
+ validateProfileMode(out);
75
76
  if (!BUNDLE_PATTERN.test(out.bundleId)) fail("APPLE_PROFILE_USAGE", "--bundle-id must be an explicit bundle identifier (no wildcard).");
76
77
  if (!out.name.trim()) fail("APPLE_PROFILE_USAGE", "--name is required.");
77
- if (!out.containers.length) fail("APPLE_PROFILE_USAGE", "At least one --icloud-container is required.");
78
+ if (out.type === PROFILE_TYPE && !out.containers.length) fail("APPLE_PROFILE_USAGE", "At least one --icloud-container is required.");
78
79
  for (const container of out.containers) if (!CONTAINER_PATTERN.test(container)) fail("APPLE_PROFILE_USAGE", `Invalid iCloud container identifier: ${container}`);
79
80
  for (const udid of out.registerDevices) if (!UDID_PATTERN.test(udid)) fail("APPLE_PROFILE_USAGE", `Invalid device UDID: ${udid}`);
80
81
  if (!out.out) fail("APPLE_PROFILE_USAGE", "--out <profile path> is required.");
@@ -82,6 +83,15 @@ export function parseCreateArgs(args) {
82
83
  return out;
83
84
  }
84
85
 
86
+ function validateProfileMode({ type, certificate, containers = [], registerDevices = [], keyOut, expectedTeam, expectedKeychainGroup }) {
87
+ if (![PROFILE_TYPE, DIRECT_TYPE].includes(type)) fail("APPLE_PROFILE_USAGE", `--type must be ${PROFILE_TYPE} or ${DIRECT_TYPE}.`);
88
+ if (type !== DIRECT_TYPE) return;
89
+ if (!/^[A-Z0-9]{10}$/.test(expectedTeam ?? "")) fail("APPLE_PROFILE_USAGE", "Direct profiles require --expected-team <10-character team id>.");
90
+ if (typeof expectedKeychainGroup !== "string" || !expectedKeychainGroup.startsWith(`${expectedTeam}.`) || !BUNDLE_PATTERN.test(expectedKeychainGroup.slice(expectedTeam.length + 1))) fail("APPLE_PROFILE_USAGE", "Direct profiles require one exact --expected-keychain-group prefixed by expected team (no wildcard).");
91
+ if (!/^[A-Za-z0-9]{4,40}$/.test(certificate ?? "") || certificate === "create") fail("APPLE_PROFILE_USAGE", "Direct profiles require --certificate <existing ASC certificate id>.");
92
+ if (containers.length || registerDevices.length || keyOut) fail("APPLE_PROFILE_USAGE", "Direct profiles refuse iCloud containers, device registration & key output.");
93
+ }
94
+
85
95
  // Key material must never ride in config, the environment or arguments. Unlike the
86
96
  // renew command this refusal applies on every host.
87
97
  export function assertNoInlinePem(env, config, extra = []) {
@@ -113,10 +123,11 @@ function localDevelopmentIdentities(run, now, { tolerant }) {
113
123
 
114
124
  // ---- main flow ---------------------------------------------------------------------
115
125
 
116
- export async function createAppleProfile({ root, config = {}, bundleId, name, containers, type = PROFILE_TYPE, certificate = "create", out, dryRun = true, registerDevices = [], keyOut = "", env = process.env, run = spawnSync, fetch: fetchApi = globalThis.fetch, jwtSigner = signAppleApiJwt, now = new Date(), log = console.log, platform = process.platform, home = os.homedir() }) {
117
- if (type !== PROFILE_TYPE) fail("APPLE_PROFILE_USAGE", `--type must be ${PROFILE_TYPE}.`);
126
+ export async function createAppleProfile({ root, config = {}, bundleId, name, containers = [], type = PROFILE_TYPE, certificate = "create", out, dryRun = true, registerDevices = [], keyOut = "", expectedTeam, expectedKeychainGroup, env = process.env, run = spawnSync, fetch: fetchApi = globalThis.fetch, jwtSigner = signAppleApiJwt, now = new Date(), log = console.log, platform = process.platform, home = os.homedir() }) {
127
+ validateProfileMode({ type, certificate, containers, registerDevices, keyOut, expectedTeam, expectedKeychainGroup });
118
128
  if (!BUNDLE_PATTERN.test(bundleId ?? "")) fail("APPLE_PROFILE_USAGE", "bundleId must be an explicit bundle identifier.");
119
- if (!containers?.length) fail("APPLE_PROFILE_USAGE", "At least one iCloud container is required.");
129
+ if (!name?.trim() || !out) fail("APPLE_PROFILE_USAGE", "Profile name & output path are required.");
130
+ if (type === PROFILE_TYPE && !containers?.length) fail("APPLE_PROFILE_USAGE", "At least one iCloud container is required.");
120
131
  assertNoInlinePem(env, config, [["--certificate", certificate], ["--name", name]]);
121
132
  if (!dryRun && platform !== "darwin") fail("APPLE_PROFILE_PLATFORM", "A real run decodes the issued profile and uses the Keychain; run it on the macOS host.");
122
133
  const outFile = path.resolve(root, out);
@@ -155,12 +166,14 @@ export async function createAppleProfile({ root, config = {}, bundleId, name, co
155
166
  }
156
167
  return items;
157
168
  };
158
- const plan = { dryRun, command: "apple-profile-create", profileType: PROFILE_TYPE, out: outFile, name, calls, planned };
169
+ const plan = { dryRun, command: "apple-profile-create", profileType: type, out: outFile, name, calls, planned };
159
170
  const mutate = async (method, endpoint, body) => {
160
171
  if (dryRun) { planned.push({ method, path: endpoint, ...(body ? { body } : {}) }); log(`PLANNED ${method} ${endpoint}${body ? ` ${JSON.stringify(body)}` : ""}`); return null; }
161
172
  return request(method, endpoint, body);
162
173
  };
163
174
 
175
+ if (type === DIRECT_TYPE) return createDirectProfile({ bundleId, name, certificate, expectedTeam, expectedKeychainGroup, outFile, dryRun, env, run, now, log, request, list, mutate, plan });
176
+
164
177
  // (1) bundle id
165
178
  const bundles = await list(query("/v1/bundleIds", { "filter[identifier]": bundleId, limit: "200" }));
166
179
  let bundle = bundles.find((item) => item.attributes?.identifier === bundleId && ["MAC_OS", "UNIVERSAL"].includes(item.attributes?.platform));
@@ -290,6 +303,96 @@ export async function createAppleProfile({ root, config = {}, bundleId, name, co
290
303
  return { ...plan, profileId: profile.id, identitySha1: certificateSha1 };
291
304
  }
292
305
 
306
+ // Direct profiles authorize one selected existing Developer ID identity. No device,
307
+ // capability, certificate or key creation is reachable from this branch.
308
+ async function createDirectProfile({ bundleId, name, certificate, expectedTeam, expectedKeychainGroup, outFile, dryRun, env, run, now, log, request, list, mutate, plan }) {
309
+ const ascCert = (await request("GET", `/v1/certificates/${enc(certificate)}`)).data;
310
+ if (!ascCert?.id || ascCert.id !== certificate) fail("APPLE_PROFILE_CERT_NOT_IN_ASC", "Selected certificate is absent from ASC.");
311
+ if (!["DEVELOPER_ID_APPLICATION", "DEVELOPER_ID_APPLICATION_G2"].includes(ascCert.attributes?.certificateType)) fail("APPLE_PROFILE_CERT_TYPE", "Direct profiles require an existing DEVELOPER_ID_APPLICATION or DEVELOPER_ID_APPLICATION_G2 certificate.");
312
+ if (!(Date.parse(ascCert.attributes?.expirationDate) > now.getTime())) fail("APPLE_PROFILE_CERT_EXPIRED", "Selected ASC certificate is expired.");
313
+ let leaf;
314
+ try { leaf = new X509Certificate(Buffer.from(ascCert.attributes?.certificateContent ?? "", "base64")); }
315
+ catch { fail("APPLE_PROFILE_API_INVALID", "Selected ASC certificate has no valid public DER certificate."); }
316
+ const certificateSha1 = sha1(leaf.raw);
317
+ const identity = findAppleSigningCertificate({ type: DIRECT_TYPE, team: expectedTeam }, { env: { ...env, APPLE_SIGNING_IDENTITY: certificateSha1 }, run, now });
318
+ if (identity.sha1 !== certificateSha1 || serial(identity.serialNumber) !== serial(ascCert.attributes?.serialNumber)) fail("APPLE_PROFILE_CERT_MISMATCH", "Selected ASC certificate does not match local Developer ID identity.");
319
+ plan.teamIdentifier = expectedTeam;
320
+ plan.expectedKeychainGroup = expectedKeychainGroup;
321
+ plan.certificate = { ...identity, id: ascCert.id, mode: "explicit", privateKeyInKeychain: true };
322
+
323
+ const bundles = await list(query("/v1/bundleIds", { "filter[identifier]": bundleId, limit: "200" }));
324
+ const matching = bundles.filter((item) => item.attributes?.identifier === bundleId);
325
+ if (matching.length > 1 || (matching.length && !["MAC_OS", "UNIVERSAL"].includes(matching[0].attributes?.platform))) fail("APPLE_PROFILE_BUNDLE_CONFLICT", "Existing bundle identifier has conflicting platform or duplicate resources.");
326
+ let bundle = matching[0];
327
+ // Look across types so a dedicated name is never silently duplicated.
328
+ const profiles = (await list(query("/v1/profiles", { "filter[name]": name, limit: "200" }))).filter((item) => item.attributes?.name === name);
329
+ if (profiles.length > 1) fail("APPLE_PROFILE_NAME_CONFLICT", "More than one profile already uses this dedicated name.");
330
+ let profile = profiles[0];
331
+ if (profile) {
332
+ const [bundleRel, certRel] = await Promise.all([request("GET", `/v1/profiles/${enc(profile.id)}/bundleId`), list(`/v1/profiles/${enc(profile.id)}/certificates`)]);
333
+ if (!bundle?.id || profile.attributes?.profileType !== DIRECT_TYPE || profile.attributes?.profileState !== "ACTIVE" || !(Date.parse(profile.attributes?.expirationDate) > now.getTime())
334
+ || bundleRel.data?.id !== bundle.id || certRel.length !== 1 || certRel[0].id !== ascCert.id) fail("APPLE_PROFILE_NAME_CONFLICT", "Existing named profile does not match direct type, bundle & selected certificate.");
335
+ }
336
+ plan.bundleId = { identifier: bundleId, id: bundle?.id ?? null, action: bundle ? "reuse" : "create" };
337
+ if (!bundle) {
338
+ const result = await mutate("POST", "/v1/bundleIds", { data: { type: "bundleIds", attributes: { identifier: bundleId, name: name.replace(/[^A-Za-z0-9 ]/g, " ").trim() || bundleId, platform: "MAC_OS" } } });
339
+ if (result) {
340
+ bundle = result.data;
341
+ if (!bundle?.id || bundle.attributes?.identifier !== bundleId || !["MAC_OS", "UNIVERSAL"].includes(bundle.attributes?.platform)) fail("APPLE_PROFILE_API_INVALID", "ASC returned an unexpected created bundle identifier.");
342
+ plan.bundleId.id = bundle.id;
343
+ }
344
+ }
345
+ plan.profile = { id: profile?.id ?? null, action: profile ? "reuse" : "create" };
346
+ if (!profile) {
347
+ const body = { data: { type: "profiles", attributes: { name, profileType: DIRECT_TYPE }, relationships: {
348
+ bundleId: { data: { type: "bundleIds", id: bundle?.id ?? "<new bundleId>" } }, certificates: { data: [{ type: "certificates", id: ascCert.id }] } } } };
349
+ const result = await mutate("POST", "/v1/profiles", body);
350
+ if (result) {
351
+ profile = result.data;
352
+ if (!profile?.id) fail("APPLE_PROFILE_API_INVALID", "ASC did not return a created profile id.");
353
+ plan.profile.id = profile.id;
354
+ }
355
+ }
356
+ plan.verification = "Exact team, application identifier, OSX, expiry, all devices, debugging disabled, selected DER fingerprint & exact keychain group authorization; app must request only its exact group.";
357
+ log(JSON.stringify({ ...plan, calls: undefined }, null, 2));
358
+ if (dryRun) { plan.writes = `would write ${outFile} (0600)`; return plan; }
359
+ const content = profile.attributes?.profileContent ?? (await request("GET", `/v1/profiles/${enc(profile.id)}`)).data?.attributes?.profileContent;
360
+ if (typeof content !== "string" || !content || !/^[A-Za-z0-9+/]+={0,2}$/.test(content)) fail("APPLE_PROFILE_API_INVALID", "ASC profileContent is missing or invalid.");
361
+ mkdirSync(path.dirname(outFile), { recursive: true });
362
+ const temporary = `${outFile}.create-${randomUUID()}.tmp`;
363
+ try {
364
+ writeFileSync(temporary, Buffer.from(content, "base64"), { flag: "wx", mode: 0o600 });
365
+ verifyDirectProfile(decodeAppleProfile(temporary, { run }), { bundleId, name, expectedTeam, expectedKeychainGroup, certificateSha1, now });
366
+ if (existsSync(outFile)) copyFileSync(outFile, `${outFile}.backup-${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID()}`, constants.COPYFILE_EXCL);
367
+ renameSync(temporary, outFile);
368
+ chmodSync(outFile, 0o600);
369
+ } finally { rmSync(temporary, { force: true }); }
370
+ log(`Wrote ${outFile}; Developer ID identity SHA-1 ${certificateSha1}`);
371
+ return { ...plan, profileId: profile.id, identitySha1: certificateSha1 };
372
+ }
373
+
374
+ export function verifyDirectProfile(decoded, { bundleId, name, expectedTeam, expectedKeychainGroup, certificateSha1, now }) {
375
+ const problems = [], entitlements = decoded.Entitlements ?? {};
376
+ if (decoded.Name !== name) problems.push("profile name mismatch");
377
+ if (!Array.isArray(decoded.TeamIdentifier) || decoded.TeamIdentifier.length !== 1 || decoded.TeamIdentifier[0] !== expectedTeam) problems.push("team mismatch");
378
+ if (entitlements["com.apple.developer.team-identifier"] !== expectedTeam) problems.push("entitlement team mismatch");
379
+ const appKeys = ["com.apple.application-identifier", "application-identifier"].filter((key) => Object.hasOwn(entitlements, key));
380
+ if (!appKeys.length || appKeys.some((key) => entitlements[key] !== `${expectedTeam}.${bundleId}`)) problems.push("application identifier mismatch");
381
+ if (!Array.isArray(decoded.Platform) || !decoded.Platform.includes("OSX")) problems.push("not a macOS profile");
382
+ if (!(Date.parse(decoded.ExpirationDate) > now.getTime())) problems.push("expired or missing expiration");
383
+ if (decoded.ProvisionsAllDevices !== true) problems.push("not an all-devices direct profile");
384
+ const debugKeys = ["get-task-allow", "com.apple.security.get-task-allow"].filter((key) => Object.hasOwn(entitlements, key));
385
+ // Profiles carry an entitlement allowlist (Apple TN3125). Direct profiles may
386
+ // omit debugging entirely; every present alias must still be boolean false.
387
+ if (debugKeys.some((key) => entitlements[key] !== false)) problems.push("debugging is not disabled");
388
+ if (!certificateSha1 || !(decoded.DeveloperCertificates ?? []).some((data) => sha1(Buffer.from(data, "base64")) === certificateSha1)) problems.push("selected certificate not authorized");
389
+ const groups = entitlements["keychain-access-groups"];
390
+ // Apple-issued terminal wildcards may authorize an exact app group. This never
391
+ // changes the app's requested entitlements or permits wildcard CLI input.
392
+ if (!Array.isArray(groups) || !groups.some((group) => group === expectedKeychainGroup || (typeof group === "string" && group.startsWith(`${expectedTeam}.`) && /^[^*]+\*$/u.test(group) && expectedKeychainGroup.startsWith(group.slice(0, -1))))) problems.push("keychain group not authorized");
393
+ if (problems.length) fail("APPLE_PROFILE_VERIFY_FAILED", `Direct profile failed verification: ${problems.join("; ")}.`);
394
+ }
395
+
293
396
  export function verifyIssuedProfile(decoded, { bundleId, containers, certificateSha1, deviceUdids, now }) {
294
397
  const other = [], missing = [];
295
398
  const entitlements = decoded.Entitlements ?? {};
@@ -345,7 +448,7 @@ async function createCertificate({ commonName, request, run, keyOut, home, log }
345
448
 
346
449
  export async function main(args = process.argv.slice(2), options = {}) {
347
450
  if (args.includes("--help") || args.includes("-h")) {
348
- (options.log ?? console.log)("right-release apple-profile-create --bundle-id <id> --name <profile name> --icloud-container <iCloud.x> [--icloud-container ...] --out <profile path>\n [--config <file>] [--type MAC_APP_DEVELOPMENT] [--certificate <asc cert id>|create] [--register-device <UDID>]... [--key-out <file>] [--dry-run | --apply]\nDefault is --dry-run (GET discovery only). Uses APPLE_API_ISSUER, APPLE_API_KEY & APPLE_API_KEY_PATH or appStoreConnect.* in the config; inline PEM is refused.\nThe ASC API cannot create or assign iCloud containers; the issued profile is verified and the command prints manual steps if the container is missing.");
451
+ (options.log ?? console.log)("right-release apple-profile-create --bundle-id <id> --name <profile name> --out <profile path>\n [--config <file>] [--type MAC_APP_DEVELOPMENT|MAC_APP_DIRECT] [--certificate <asc cert id>|create] [--dry-run | --apply]\nDevelopment: --icloud-container <iCloud.x>... [--register-device <UDID>]... [--key-out <file>]\nDirect: --certificate <existing ASC Developer ID cert id> --expected-team <team id> --expected-keychain-group <exact group>; refuses iCloud, devices & key output.\nDefault is --dry-run (GET discovery only). Uses APPLE_API_ISSUER, APPLE_API_KEY & APPLE_API_KEY_PATH or appStoreConnect.* in the config; inline PEM is refused.\nThe ASC API cannot create or assign iCloud containers; the issued profile is verified and the command prints manual steps if the container is missing.");
349
452
  return;
350
453
  }
351
454
  const parsed = parseCreateArgs(args);
@@ -354,7 +457,7 @@ export async function main(args = process.argv.slice(2), options = {}) {
354
457
  let config = {};
355
458
  if (existsSync(absolute)) { const loaded = await import(pathToFileURL(absolute)); config = loaded.default ?? loaded.config ?? loaded; }
356
459
  else if (parsed.configPath) fail("APPLE_PROFILE_USAGE", `Config file not found: ${parsed.configPath}`);
357
- return createAppleProfile({ ...options, root: path.dirname(absolute), config, bundleId: parsed.bundleId, name: parsed.name, containers: parsed.containers, type: parsed.type, certificate: parsed.certificate, out: parsed.out, dryRun: parsed.dryRun, registerDevices: parsed.registerDevices, keyOut: parsed.keyOut });
460
+ return createAppleProfile({ ...options, root: path.dirname(absolute), config, ...parsed });
358
461
  }
359
462
 
360
463
  const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
Binary file
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ // `right-release license-notices binaries [--config <file>] [--output <file>] [--product-name <name>] [--receipt <path>] [--check]`:
3
+ // writes the notice appendix (one HTML file) for the binaries an app ships that no RightKit catalog row covers (a downloaded yt-dlp, a
4
+ // vendored tool, a command-built helper). Catalog-backed sidecars (ffmpeg, onnxruntime, pdfium, whisper) write their own notices when
5
+ // they are staged; everything else must be declared here, with its licence text, or the build has no right to ship it.
6
+ //
7
+ // legal: { binaryNotices: { output: "legal/THIRD_PARTY_BINARIES.html", productName: "ScrapeRight", receipt: "src-tauri/binaries/stage-sidecar.json",
8
+ // binaries: [{ name: "yt-dlp", version: "2026.03.17" | "self-updating", license: "Unlicense", sourceUrl: "https://github.com/yt-dlp/yt-dlp",
9
+ // files: ["legal/texts/yt-dlp-UNLICENSE.txt"], covers?: ["yt-dlp-helper"] }] } }
10
+ //
11
+ // Every `files` entry is an app-relative text file that must exist; no licence text is ever invented. With a `receipt` (the
12
+ // stage-sidecar receipt, also settable with `--receipt`) every staged file of kind remote, command, cmake or prebuilt must belong to a
13
+ // declared binary - by its entry name, or a name listed in `covers` - or the run fails naming the uncovered ones. The page records the
14
+ // receipt-independent inputs only (declared fields and the sha256 of each text), so the same declaration always produces the same bytes.
15
+ // `--check` writes nothing and exits 1 when the file on disk differs.
16
+ import { createHash } from "node:crypto";
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import path from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+ import { CommandSpecError, loadAppConfig, relativeInside } from "./command-spec.mjs";
21
+ import { escapeHtml } from "./npm-license-notices.mjs";
22
+
23
+ const fail = (message) => { throw new CommandSpecError(message); };
24
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
25
+ const KEYS = ["output", "productName", "receipt", "binaries"];
26
+ const BINARY_KEYS = ["name", "version", "license", "sourceUrl", "files", "covers"];
27
+ /** Receipt kinds with no catalog row behind them. */
28
+ export const NON_CATALOG_KINDS = Object.freeze(["remote", "command", "cmake", "prebuilt"]);
29
+
30
+ /** Resolve and validate `legal.binaryNotices` plus flags. */
31
+ export function resolveBinaryNoticeOptions(config, flags = {}) {
32
+ const declared = config?.legal?.binaryNotices;
33
+ if (!isObject(declared)) fail("legal.binaryNotices must be an object");
34
+ const unknown = Object.keys(declared).filter((key) => !KEYS.includes(key));
35
+ if (unknown.length) fail(`legal.binaryNotices has unknown key(s): ${unknown.join(", ")} (allowed: ${KEYS.join(", ")})`);
36
+ const output = flags.output ?? declared.output;
37
+ if (output === undefined) fail("no output file: set legal.binaryNotices.output or pass --output");
38
+ relativeInside(output, "legal.binaryNotices.output");
39
+ const productName = flags.productName ?? declared.productName;
40
+ if (typeof productName !== "string" || !productName.trim()) fail("no product name: set legal.binaryNotices.productName or pass --product-name");
41
+ const receipt = flags.receipt ?? declared.receipt;
42
+ if (receipt !== undefined) relativeInside(receipt, "legal.binaryNotices.receipt");
43
+ if (!Array.isArray(declared.binaries) || !declared.binaries.length) fail("legal.binaryNotices.binaries must be a non-empty list");
44
+ const names = new Set();
45
+ const binaries = declared.binaries.map((item, index) => {
46
+ const label = `legal.binaryNotices.binaries[${index}]`;
47
+ if (!isObject(item)) fail(`${label} must be an object`);
48
+ const extra = Object.keys(item).filter((key) => !BINARY_KEYS.includes(key));
49
+ if (extra.length) fail(`${label} has unknown key(s): ${extra.join(", ")} (allowed: ${BINARY_KEYS.join(", ")})`);
50
+ for (const key of ["name", "version", "license"]) if (typeof item[key] !== "string" || !item[key].trim()) fail(`${label}.${key} must be a non-empty string`);
51
+ if (typeof item.sourceUrl !== "string" || !/^https:\/\/[^\s/]+\/\S*$/.test(item.sourceUrl)) fail(`${label}.sourceUrl must be an https:// URL of the source or release`);
52
+ if (!Array.isArray(item.files) || !item.files.length) fail(`${label}.files must list at least one licence/notice text file`);
53
+ const files = item.files.map((file, fileIndex) => relativeInside(file, `${label}.files[${fileIndex}]`));
54
+ const covers = item.covers ?? [];
55
+ if (!Array.isArray(covers) || covers.some((name) => typeof name !== "string" || !name.trim())) fail(`${label}.covers must be a list of staged entry names`);
56
+ for (const name of [item.name, ...covers]) {
57
+ if (names.has(name)) fail(`${label}: ${name} is declared twice`);
58
+ names.add(name);
59
+ }
60
+ return { name: item.name, version: item.version, license: item.license, sourceUrl: item.sourceUrl, files, covers: [...covers] };
61
+ });
62
+ return { output, productName, receipt, binaries };
63
+ }
64
+
65
+ /** The names of staged non-catalog entries that no declared binary covers. */
66
+ export function uncoveredBinaries(receipt, binaries) {
67
+ const covered = new Set(binaries.flatMap((binary) => [binary.name, ...(binary.covers ?? [])]));
68
+ const missing = new Set();
69
+ for (const file of receipt?.files ?? []) {
70
+ if (!NON_CATALOG_KINDS.includes(file.kind)) continue;
71
+ const name = file.entryName ?? file.command ?? file.name;
72
+ if (!covered.has(name)) missing.add(name);
73
+ }
74
+ return [...missing].sort();
75
+ }
76
+
77
+ /** Render the appendix. `texts` maps an app-relative file to its text. Returns the HTML. */
78
+ export function renderBinaryNotices({ productName, binaries, texts }) {
79
+ const sections = binaries.map((binary) => {
80
+ const bodies = binary.files.map((file) => `<h3>${escapeHtml(path.posix.basename(file))}</h3><pre>${escapeHtml(texts.get(file))}</pre>`).join("\n");
81
+ const digests = binary.files.map((file) => `${escapeHtml(path.posix.basename(file))} sha256=${createHash("sha256").update(texts.get(file)).digest("hex")}`).join("; ");
82
+ return `<section><h2>${escapeHtml(binary.name)} ${escapeHtml(binary.version)}</h2><p>License: ${escapeHtml(binary.license)}</p><p>Source: ${escapeHtml(binary.sourceUrl)}</p><p>Texts: ${digests}</p>\n${bodies}</section>`;
83
+ });
84
+ return `<!doctype html>
85
+ <html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(productName)} bundled program licenses</title></head><body>
86
+ <h1>${escapeHtml(productName)} bundled program licenses</h1>
87
+ <p>Programs shipped with ${escapeHtml(productName)} that are not Cargo or npm dependencies; generated-by=right-release license-notices binaries</p>
88
+ ${sections.join("\n")}
89
+ </body></html>
90
+ `;
91
+ }
92
+
93
+ /** Generate (or check) the appendix for the app at `appRoot`. File access is injectable. */
94
+ export function generateBinaryNotices({ appRoot, config, flags = {}, check = false, exists = existsSync, readText = (file) => readFileSync(file, "utf8"), writeText = (file, text) => { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, text); } }) {
95
+ const { output, productName, receipt, binaries } = resolveBinaryNoticeOptions(config, flags);
96
+ const texts = new Map();
97
+ for (const binary of binaries) {
98
+ for (const file of binary.files) {
99
+ const absolute = path.join(appRoot, file);
100
+ if (!exists(absolute)) fail(`${binary.name}: licence text ${file} does not exist; no text is invented, add the file`);
101
+ const text = String(readText(absolute)).replace(/\r\n/g, "\n");
102
+ if (!text.trim()) fail(`${binary.name}: licence text ${file} is empty`);
103
+ texts.set(file, text);
104
+ }
105
+ }
106
+ let uncovered = [];
107
+ if (receipt !== undefined) {
108
+ const receiptFile = path.join(appRoot, receipt);
109
+ if (!exists(receiptFile)) fail(`the stage-sidecar receipt ${receipt} does not exist; run right-release stage-sidecar first`);
110
+ let parsed;
111
+ try { parsed = JSON.parse(readText(receiptFile)); } catch (error) { fail(`the stage-sidecar receipt ${receipt} is not JSON (${error.message})`); }
112
+ uncovered = uncoveredBinaries(parsed, binaries);
113
+ if (uncovered.length) fail(`staged program(s) with no declared licence notice: ${uncovered.join(", ")}; declare them in legal.binaryNotices.binaries (name or covers)`);
114
+ }
115
+ const html = renderBinaryNotices({ productName, binaries, texts });
116
+ const target = path.join(appRoot, output);
117
+ if (check) {
118
+ const current = exists(target) ? String(readText(target)).replace(/\r\n/g, "\n") : null;
119
+ return { target, binaryCount: binaries.length, uncovered, upToDate: current === html };
120
+ }
121
+ writeText(target, html);
122
+ return { target, binaryCount: binaries.length, uncovered, upToDate: true };
123
+ }
124
+
125
+ export function parseBinaryNoticeArgs(argv) {
126
+ const opts = { config: "right-release.config.mjs", output: undefined, productName: undefined, receipt: undefined, check: false };
127
+ for (let i = 0; i < argv.length; i += 1) {
128
+ const arg = argv[i];
129
+ if (arg === "--config" || arg === "--output" || arg === "--product-name" || arg === "--receipt") {
130
+ const value = argv[i + 1];
131
+ if (!value || value.startsWith("--")) throw new Error(`${arg} needs a value`);
132
+ opts[arg === "--product-name" ? "productName" : arg.slice(2)] = value;
133
+ i += 1;
134
+ } else if (arg === "--check") opts.check = true;
135
+ else throw new Error(`unknown license-notices argument: ${arg}`);
136
+ }
137
+ return opts;
138
+ }
139
+
140
+ export async function main(argv = process.argv.slice(2)) {
141
+ const opts = parseBinaryNoticeArgs(argv);
142
+ const { config, appRoot } = await loadAppConfig(opts.config);
143
+ const outcome = generateBinaryNotices({ appRoot, config, flags: { output: opts.output, productName: opts.productName, receipt: opts.receipt }, check: opts.check });
144
+ if (opts.check) {
145
+ if (!outcome.upToDate) {
146
+ console.error(`right-release license-notices: ${outcome.target} is out of date; run right-release license-notices binaries`);
147
+ return 1;
148
+ }
149
+ console.log(`${outcome.target} is up to date (${outcome.binaryCount} program notices)`);
150
+ return 0;
151
+ }
152
+ console.log(`wrote ${outcome.binaryCount} program notices to ${outcome.target}`);
153
+ return 0;
154
+ }
155
+
156
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
157
+ main().then((code) => { process.exitCode = code; }, (error) => {
158
+ console.error(`right-release license-notices: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
159
+ process.exitCode = 1;
160
+ });
161
+ }
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // `right-release license-notices cargo [--config <file>] [--output <file>] [--check]`: writes the third-party Cargo license appendix
3
+ // (one file) for an app's Rust dependency closure with `cargo about`, run through the managed cargo client (`runCargoSync`: the
4
+ // RightKit broker on a managed host, never a bare-cargo fallback on a managed Mac).
5
+ //
6
+ // legal: { cargoNotices: { output: "legal/THIRD_PARTY_CARGO_LICENSES.html", template: "legal/about.hbs",
7
+ // config?: "legal/about.toml", manifestPath?: "src-tauri/Cargo.toml", features?: ["a"], allFeatures?: true } }
8
+ //
9
+ // `output`, `template`, `config` and `manifestPath` are app-relative and may not leave the app. The run is `cargo about generate --locked
10
+ // --fail [--config <config>] [--manifest-path <path>] [--all-features | --features a,b] <template>`; cargo-about writes the rendered
11
+ // appendix to stdout, which is normalised to LF and one trailing newline so the same lockfile always produces the same bytes. `--fail`
12
+ // makes an unaccepted license or an unresolved clarification an error instead of a warning. `--check` writes nothing and exits 1 when
13
+ // the file on disk differs.
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import path from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { CommandSpecError, loadAppConfig, relativeInside } from "./command-spec.mjs";
18
+ import { runCargoSync } from "./managed-cargo.mjs";
19
+
20
+ const fail = (message) => { throw new CommandSpecError(message); };
21
+ const KEYS = ["output", "template", "config", "manifestPath", "features", "allFeatures"];
22
+
23
+ /** Resolve and validate `legal.cargoNotices` plus flags. */
24
+ export function resolveCargoNoticeOptions(config, flags = {}) {
25
+ const declared = config?.legal?.cargoNotices ?? {};
26
+ if (typeof declared !== "object" || declared === null || Array.isArray(declared)) fail("legal.cargoNotices must be an object");
27
+ const unknown = Object.keys(declared).filter((key) => !KEYS.includes(key));
28
+ if (unknown.length) fail(`legal.cargoNotices has unknown key(s): ${unknown.join(", ")} (allowed: ${KEYS.join(", ")})`);
29
+ const output = flags.output ?? declared.output;
30
+ if (output === undefined) fail("no output file: set legal.cargoNotices.output or pass --output");
31
+ relativeInside(output, "legal.cargoNotices.output");
32
+ if (declared.template === undefined) fail("legal.cargoNotices.template (the cargo-about handlebars template) is required");
33
+ relativeInside(declared.template, "legal.cargoNotices.template");
34
+ for (const key of ["config", "manifestPath"]) if (declared[key] !== undefined) relativeInside(declared[key], `legal.cargoNotices.${key}`);
35
+ const features = declared.features ?? [];
36
+ if (!Array.isArray(features) || features.some((feature) => typeof feature !== "string" || !/^[A-Za-z0-9_/-]+$/.test(feature))) fail("legal.cargoNotices.features must be a list of feature names");
37
+ if (declared.allFeatures !== undefined && typeof declared.allFeatures !== "boolean") fail("legal.cargoNotices.allFeatures must be a boolean");
38
+ if (declared.allFeatures === true && features.length) fail("legal.cargoNotices: give allFeatures or features, not both");
39
+ return { output, template: declared.template, config: declared.config, manifestPath: declared.manifestPath, features, allFeatures: declared.allFeatures === true };
40
+ }
41
+
42
+ /** The `cargo about` argument list (after the managed client adds `cargo`). */
43
+ export function cargoAboutArgs(options) {
44
+ return [
45
+ "about", "generate", "--locked", "--fail",
46
+ ...(options.config ? ["--config", options.config] : []),
47
+ ...(options.manifestPath ? ["--manifest-path", options.manifestPath] : []),
48
+ ...(options.allFeatures ? ["--all-features"] : options.features.length ? ["--features", options.features.join(",")] : []),
49
+ options.template,
50
+ ];
51
+ }
52
+
53
+ const normalise = (text) => `${String(text).replace(/\r\n/g, "\n").replace(/\s+$/, "")}\n`;
54
+
55
+ /** Generate (or check) the Cargo appendix for the app at `appRoot`. `run` is the managed cargo runner (injectable). */
56
+ export function generateCargoNotices({ appRoot, config, flags = {}, check = false, run = runCargoSync, env = process.env, exists = existsSync, readText = (file) => readFileSync(file, "utf8"), writeText = (file, text) => { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, text); } }) {
57
+ const options = resolveCargoNoticeOptions(config, flags);
58
+ for (const key of ["template", "config", "manifestPath"]) {
59
+ if (options[key] !== undefined && !exists(path.join(appRoot, options[key]))) fail(`legal.cargoNotices.${key} ${options[key]} does not exist`);
60
+ }
61
+ const result = run(cargoAboutArgs(options), { cwd: appRoot, env, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
62
+ if (result?.error) fail(`cargo about could not start (${result.error.message}); install cargo-about on the build host`);
63
+ if (result?.status !== 0) fail(`cargo about exited ${result?.status ?? "without status"}${result?.stderr ? `:\n${String(result.stderr).trim()}` : ""}`);
64
+ const text = normalise(result.stdout ?? "");
65
+ if (text.trim() === "") fail("cargo about produced no output");
66
+ const target = path.join(appRoot, options.output);
67
+ if (check) {
68
+ const current = exists(target) ? normalise(readText(target)) : null;
69
+ return { target, bytes: Buffer.byteLength(text), upToDate: current === text };
70
+ }
71
+ writeText(target, text);
72
+ return { target, bytes: Buffer.byteLength(text), upToDate: true };
73
+ }
74
+
75
+ export function parseCargoNoticeArgs(argv) {
76
+ const opts = { config: "right-release.config.mjs", output: undefined, check: false };
77
+ for (let i = 0; i < argv.length; i += 1) {
78
+ const arg = argv[i];
79
+ if (arg === "--config" || arg === "--output") {
80
+ const value = argv[i + 1];
81
+ if (!value || value.startsWith("--")) throw new Error(`${arg} needs a value`);
82
+ opts[arg.slice(2)] = value;
83
+ i += 1;
84
+ } else if (arg === "--check") opts.check = true;
85
+ else throw new Error(`unknown license-notices argument: ${arg}`);
86
+ }
87
+ return opts;
88
+ }
89
+
90
+ export async function main(argv = process.argv.slice(2)) {
91
+ const opts = parseCargoNoticeArgs(argv);
92
+ const { config, appRoot } = await loadAppConfig(opts.config);
93
+ const outcome = generateCargoNotices({ appRoot, config, flags: { output: opts.output }, check: opts.check });
94
+ if (opts.check) {
95
+ if (!outcome.upToDate) {
96
+ console.error(`right-release license-notices: ${outcome.target} is out of date; run right-release license-notices cargo`);
97
+ return 1;
98
+ }
99
+ console.log(`${outcome.target} is up to date`);
100
+ return 0;
101
+ }
102
+ console.log(`wrote ${outcome.target} (${outcome.bytes} bytes)`);
103
+ return 0;
104
+ }
105
+
106
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
107
+ main().then((code) => { process.exitCode = code; }, (error) => {
108
+ console.error(`right-release license-notices: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
109
+ process.exitCode = 1;
110
+ });
111
+ }
@@ -40,6 +40,7 @@ const commands = new Map([
40
40
  ["ownership-approval", "ownership-approval-command.mjs"],
41
41
  ["ownership-report", "ownership-report-command.mjs"],
42
42
  ["ownership-mandatory", "ownership-mandatory-set.mjs"],
43
+ ["adoption-evidence", "adoption-evidence.mjs"],
43
44
  ["swift-package", "swift-package-command.mjs"],
44
45
  ["bundle-scan", "runtime-bundle-scan.mjs"],
45
46
  ["check-release-dependencies", "release-dependencies-command.mjs"],
@@ -136,6 +137,9 @@ if (first === "--version" || first === "-v") {
136
137
  runBinary("python3", [path.join(packageRoot, "generate-dmg-background.py"), ...args.slice(1)]);
137
138
  } else if (first === "build" && args.includes("--web")) {
138
139
  run("web-build.mjs", args.slice(1).filter((arg) => arg !== "--web"));
140
+ } else if (first === "license-notices" && (args[1] === "cargo" || args[1] === "binaries")) {
141
+ // `license-notices npm` stays on the commands map; the Rust and native-binary appendices have their own modules.
142
+ run(args[1] === "cargo" ? "cargo-license-notices.mjs" : "binary-license-notices.mjs", args.slice(2));
139
143
  } else if (commands.has(first)) {
140
144
  run(commands.get(first), args.slice(1));
141
145
  } else if (first.startsWith("-")) {
@@ -217,6 +221,10 @@ Commands:
217
221
  Run the app's declared test suites (config tests.suites / tests.runners) in order; stops at the first failure
218
222
  license-notices npm [--config <file>] [--output <file>] [--product-name <name>] [--check]
219
223
  Write the third-party npm license appendix HTML from pnpm licenses (config legal.npmNotices); --check only compares
224
+ license-notices cargo [--config <file>] [--output <file>] [--check]
225
+ Write the third-party Cargo license appendix HTML through cargo about (config legal.cargoNotices); --check only compares
226
+ license-notices binaries [--config <file>] [--output <file>] [--product-name <name>] [--receipt <path>] [--check]
227
+ Write the notices for shipped programs that are not Cargo or npm dependencies (config legal.binaryNotices); --check only compares
220
228
  toolchain swift --version <x.y.z> --sha256 <pin> [--url <https url>] [--github-env] [--json]
221
229
  Windows: install the pinned swift.org Swift toolchain (sha256 verified before it runs), check swift --version, find the runtime bin; --github-env exports what the installer changed to GITHUB_ENV / GITHUB_PATH
222
230
  dev --platform win|mac [--config <path>] [--dry-run] [--build-only]
@@ -288,6 +296,8 @@ Commands:
288
296
  Report cross-app ownership & pending requests
289
297
  ownership-mandatory [--workspace <dir>] [--app <name>]... [--amendment v2.1] [--enforce] [--json]
290
298
  Check each app declares every Table 1 owner at its required version (report by default)
299
+ adoption-evidence --manifest <published-release-manifest.json> [--packets <dir>] [--app <dir> [--adoption <file>] [--target <target>]] [--json]
300
+ Validate the handoff manifest (derived fields, AP set = frozen packets) and, with --app, an app's adoption record (lock pins, closure, receipts)
291
301
  cache status [--json] Inspect the shared local build cache
292
302
  cache prune [--dry-run|--apply] [--json] Plan or apply safe shared-target pruning
293
303
  cache migrate --config <file> [--dry-run|--apply] [--json]