@hot-updater/apple-helper 1.2.0-rc.5 → 1.2.0-rc.7

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/dist/index.cjs CHANGED
@@ -551,6 +551,23 @@ const signInToAppStoreConnect = async ({ appleId, password, prompts, fetch = glo
551
551
  }
552
552
  };
553
553
  //#endregion
554
+ //#region src/asc/programLicenseAgreement.ts
555
+ /**
556
+ * What to do when Apple holds a team's requests until the Account Holder
557
+ * accepts the latest Apple Developer Program License Agreement: App Store
558
+ * Connect answers 403, and xcodebuild prints "PLA Update available".
559
+ */
560
+ const PROGRAM_LICENSE_AGREEMENT_HINT = ["The Account Holder must accept the latest Program License Agreement:", "https://developer.apple.com/account"].join("\n");
561
+ /** The code App Store Connect answers a team with a pending agreement. */
562
+ const AGREEMENT_ERROR_CODE = "FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED";
563
+ /**
564
+ * Whether App Store Connect's JSON:API errors say the team has a Program
565
+ * License Agreement to accept, by its code or its detail.
566
+ */
567
+ const isProgramLicenseAgreementError = (errors) => errors.some(({ code, detail, title }) => code === AGREEMENT_ERROR_CODE || /Program License Agreement/iu.test(`${detail ?? ""} ${title ?? ""}`));
568
+ /** Whether xcodebuild's output says the team has an agreement to accept. */
569
+ const mentionsProgramLicenseAgreement = (output) => output.includes("PLA Update available");
570
+ //#endregion
554
571
  //#region src/asc/ascKeyIssuer.ts
555
572
  const IRIS_URL = "https://appstoreconnect.apple.com/iris/v1";
556
573
  /**
@@ -603,7 +620,7 @@ const irisDetail = (body) => {
603
620
  };
604
621
  /** What to do about a key App Store Connect would not create. */
605
622
  const createFailureHint = (status, body) => {
606
- if (/agreement/iu.test(body)) return "The Account Holder must accept the new Apple Developer Program License\nAgreement at https://developer.apple.com/account, then run this again.";
623
+ if (/agreement/iu.test(body)) return PROGRAM_LICENSE_AGREEMENT_HINT;
607
624
  if (/limit|maximum/iu.test(body)) return `The team has the most API keys App Store Connect allows. Revoke one at:\n${ASC_INTEGRATIONS_URL}`;
608
625
  if (status === 403 || /account holder|admin|permission/iu.test(body)) return "Creating an API key needs the Account Holder or Admin role in the team.";
609
626
  };
@@ -1019,7 +1036,7 @@ const parseXcodeProjectInfo = async (sourceDir) => {
1019
1036
  const prettifyXcodebuildError = (error) => {
1020
1037
  if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
1021
1038
  const errorLines = error.summary.join("\n");
1022
- const message = errorLines.includes("error: No profiles for") || errorLines.includes("error: Provisioning profile") ? "No valid provisioning profile is set in the project.\nCheck the Signing & Capabilities tab in Xcode." : error.message;
1039
+ const message = mentionsProgramLicenseAgreement(errorLines) ? PROGRAM_LICENSE_AGREEMENT_HINT : errorLines.includes("error: No profiles for") || errorLines.includes("error: Provisioning profile") ? "No valid provisioning profile is set in the project.\nCheck the Signing & Capabilities tab in Xcode." : error.message;
1023
1040
  return new _hot_updater_cli_tools.BuildFailedError(message, {
1024
1041
  logPath: error.logPath,
1025
1042
  summary: error.summary,
@@ -1917,7 +1934,7 @@ const createAppStoreConnectRequest = ({ apiKey, privateKey, fetch }) => {
1917
1934
  if (!response.ok) {
1918
1935
  const error = new AppStoreConnectError(action, response.status, await readApiErrors(response), response.headers.get("retry-after"));
1919
1936
  if (response.status === 401) throw new Error("App Store Connect refused the API key.\nCheck its key ID and issuer ID, and that it isn't revoked.", { cause: error });
1920
- if (response.status === 403) throw new Error(`${error.message}\nUploads need a key with the Admin, App Manager, or Developer role.`, { cause: error });
1937
+ if (response.status === 403) throw new Error(isProgramLicenseAgreementError(error.errors) ? `${action} failed (HTTP 403).\n${PROGRAM_LICENSE_AGREEMENT_HINT}` : `${error.message}\nUploads need a key with the Admin, App Manager, or Developer role.`, { cause: error });
1921
1938
  throw error;
1922
1939
  }
1923
1940
  return response.status === 204 ? void 0 : await response.json();
@@ -2356,38 +2373,62 @@ const createSigningKeychain = async ({ security = runSecurity, directory = os.de
2356
2373
  return {
2357
2374
  path: keychainPath,
2358
2375
  certificates: async () => {
2376
+ const identities = new Set([...(await security([
2377
+ "find-identity",
2378
+ "-p",
2379
+ "codesigning",
2380
+ keychainPath
2381
+ ])).matchAll(/^\s*\d+\)\s+([0-9A-F]{40})\s/gm)].map((match) => match[1]));
2382
+ if (identities.size === 0) return [];
2359
2383
  return ((await security([
2360
2384
  "find-certificate",
2361
2385
  "-a",
2362
2386
  "-p",
2363
2387
  keychainPath
2364
- ])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map((pem) => {
2388
+ ])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).flatMap((pem) => {
2365
2389
  const certificate = new crypto.default.X509Certificate(pem);
2366
- return {
2390
+ if (!identities.has(certificate.fingerprint.replaceAll(":", ""))) return [];
2391
+ return [{
2367
2392
  serialNumber: normalizeSerial(certificate.serialNumber),
2368
2393
  commonName: certificate.subject.match(/^CN=(.*)$/m)?.[1] ?? certificate.subject
2369
- };
2394
+ }];
2370
2395
  });
2371
2396
  },
2372
2397
  dispose
2373
2398
  };
2374
2399
  };
2400
+ /** A request App Store Connect refused, in one line. */
2401
+ var AppStoreConnectRefusal = class extends Error {
2402
+ /** Whether the team has a Program License Agreement to accept. */
2403
+ agreement;
2404
+ constructor(message, agreement) {
2405
+ super(message);
2406
+ this.agreement = agreement;
2407
+ }
2408
+ };
2409
+ const refusal = async (action, response) => {
2410
+ const body = await response.json().catch(() => void 0);
2411
+ return new AppStoreConnectRefusal(`${action} failed (HTTP ${response.status}).`, isProgramLicenseAgreementError(Array.isArray(body?.errors) ? body.errors : []));
2412
+ };
2375
2413
  /**
2376
2414
  * Revokes the certificates Xcode created in the build's keychain, whose
2377
2415
  * private keys the keychain's deletion destroys: on a runner that starts
2378
2416
  * empty, Xcode creates a development certificate for every build, and App
2379
2417
  * Store Connect refuses new ones once a team has its maximum. Each is found
2380
2418
  * in the team's certificates by serial number. A revocation that fails is
2381
- * reported with where to revoke it by hand, and never fails the build.
2419
+ * reported with each certificate's name and serial and where to revoke it
2420
+ * by hand, and never fails the build.
2382
2421
  */
2383
2422
  const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fetch = globalThis.fetch }) => {
2384
2423
  const revoked = [];
2385
2424
  const failed = [];
2425
+ /** Why none could be revoked: the token or the listing failed. */
2426
+ let reason;
2427
+ let agreement = false;
2386
2428
  if (certificates.length === 0) return {
2387
- revoked,
2388
- failed
2429
+ revoked: [],
2430
+ failed: []
2389
2431
  };
2390
- const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
2391
2432
  try {
2392
2433
  const token = createAppStoreConnectToken({
2393
2434
  privateKey: privateKey ?? crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8")),
@@ -2403,7 +2444,7 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
2403
2444
  })}`;
2404
2445
  while (next) {
2405
2446
  const response = await fetch(next, { headers });
2406
- if (!response.ok) throw new Error(`Listing the team's certificates failed (HTTP ${response.status}).`);
2447
+ if (!response.ok) throw await refusal("Listing the team's certificates", response);
2407
2448
  const body = await response.json();
2408
2449
  for (const { id, attributes } of body.data ?? []) if (attributes?.serialNumber) idsBySerial.set(normalizeSerial(attributes.serialNumber), id);
2409
2450
  next = body.links?.next;
@@ -2415,23 +2456,32 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
2415
2456
  method: "DELETE",
2416
2457
  headers
2417
2458
  });
2418
- if (response.ok || response.status === 404) revoked.push(describe(certificate));
2419
- else failed.push(`${describe(certificate)}: HTTP ${response.status}`);
2459
+ if (response.ok || response.status === 404) revoked.push(certificate);
2460
+ else {
2461
+ agreement ||= (await refusal("Revoking", response)).agreement;
2462
+ failed.push({
2463
+ certificate,
2464
+ status: response.status
2465
+ });
2466
+ }
2420
2467
  }
2421
2468
  } catch (error) {
2422
- const reason = error instanceof Error ? error.message : String(error);
2423
- for (const certificate of certificates) if (!revoked.includes(describe(certificate))) failed.push(`${describe(certificate)}: ${reason}`);
2469
+ reason = error instanceof Error ? error.message : String(error);
2470
+ agreement ||= error instanceof AppStoreConnectRefusal && error.agreement;
2471
+ for (const certificate of certificates) if (!revoked.includes(certificate)) failed.push({ certificate });
2424
2472
  }
2425
- if (revoked.length > 0) _hot_updater_cli_tools.p.log.info(["Revoked the certificates Xcode created for this build:", ...revoked].join("\n"));
2473
+ const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
2474
+ if (revoked.length > 0) _hot_updater_cli_tools.p.log.info(["Revoked the certificates Xcode created for this build:", ...revoked.flatMap(({ commonName, serialNumber }) => [commonName, `Serial ${serialNumber}`])].join("\n"));
2426
2475
  if (failed.length > 0) _hot_updater_cli_tools.p.log.warn([
2427
2476
  "Couldn't revoke the certificates Xcode created for this build.",
2428
- ...failed,
2429
- "Revoke them at:",
2477
+ ...reason === void 0 ? [] : [reason],
2478
+ ...agreement ? [PROGRAM_LICENSE_AGREEMENT_HINT] : [],
2479
+ ...failed.flatMap(({ certificate, status }) => [certificate.commonName, `Serial ${certificate.serialNumber}${status === void 0 ? "" : ` (HTTP ${status})`}`]),
2430
2480
  CERTIFICATES_URL
2431
2481
  ].join("\n"));
2432
2482
  return {
2433
- revoked,
2434
- failed
2483
+ revoked: revoked.map(describe),
2484
+ failed: failed.map(({ certificate }) => describe(certificate))
2435
2485
  };
2436
2486
  };
2437
2487
  const TERMINATION_SIGNALS = [
package/dist/index.mjs CHANGED
@@ -523,6 +523,23 @@ const signInToAppStoreConnect = async ({ appleId, password, prompts, fetch = glo
523
523
  }
524
524
  };
525
525
  //#endregion
526
+ //#region src/asc/programLicenseAgreement.ts
527
+ /**
528
+ * What to do when Apple holds a team's requests until the Account Holder
529
+ * accepts the latest Apple Developer Program License Agreement: App Store
530
+ * Connect answers 403, and xcodebuild prints "PLA Update available".
531
+ */
532
+ const PROGRAM_LICENSE_AGREEMENT_HINT = ["The Account Holder must accept the latest Program License Agreement:", "https://developer.apple.com/account"].join("\n");
533
+ /** The code App Store Connect answers a team with a pending agreement. */
534
+ const AGREEMENT_ERROR_CODE = "FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED";
535
+ /**
536
+ * Whether App Store Connect's JSON:API errors say the team has a Program
537
+ * License Agreement to accept, by its code or its detail.
538
+ */
539
+ const isProgramLicenseAgreementError = (errors) => errors.some(({ code, detail, title }) => code === AGREEMENT_ERROR_CODE || /Program License Agreement/iu.test(`${detail ?? ""} ${title ?? ""}`));
540
+ /** Whether xcodebuild's output says the team has an agreement to accept. */
541
+ const mentionsProgramLicenseAgreement = (output) => output.includes("PLA Update available");
542
+ //#endregion
526
543
  //#region src/asc/ascKeyIssuer.ts
527
544
  const IRIS_URL = "https://appstoreconnect.apple.com/iris/v1";
528
545
  /**
@@ -575,7 +592,7 @@ const irisDetail = (body) => {
575
592
  };
576
593
  /** What to do about a key App Store Connect would not create. */
577
594
  const createFailureHint = (status, body) => {
578
- if (/agreement/iu.test(body)) return "The Account Holder must accept the new Apple Developer Program License\nAgreement at https://developer.apple.com/account, then run this again.";
595
+ if (/agreement/iu.test(body)) return PROGRAM_LICENSE_AGREEMENT_HINT;
579
596
  if (/limit|maximum/iu.test(body)) return `The team has the most API keys App Store Connect allows. Revoke one at:\n${ASC_INTEGRATIONS_URL}`;
580
597
  if (status === 403 || /account holder|admin|permission/iu.test(body)) return "Creating an API key needs the Account Holder or Admin role in the team.";
581
598
  };
@@ -991,7 +1008,7 @@ const parseXcodeProjectInfo = async (sourceDir) => {
991
1008
  const prettifyXcodebuildError = (error) => {
992
1009
  if (error instanceof BuildFailedError) {
993
1010
  const errorLines = error.summary.join("\n");
994
- const message = errorLines.includes("error: No profiles for") || errorLines.includes("error: Provisioning profile") ? "No valid provisioning profile is set in the project.\nCheck the Signing & Capabilities tab in Xcode." : error.message;
1011
+ const message = mentionsProgramLicenseAgreement(errorLines) ? PROGRAM_LICENSE_AGREEMENT_HINT : errorLines.includes("error: No profiles for") || errorLines.includes("error: Provisioning profile") ? "No valid provisioning profile is set in the project.\nCheck the Signing & Capabilities tab in Xcode." : error.message;
995
1012
  return new BuildFailedError(message, {
996
1013
  logPath: error.logPath,
997
1014
  summary: error.summary,
@@ -1889,7 +1906,7 @@ const createAppStoreConnectRequest = ({ apiKey, privateKey, fetch }) => {
1889
1906
  if (!response.ok) {
1890
1907
  const error = new AppStoreConnectError(action, response.status, await readApiErrors(response), response.headers.get("retry-after"));
1891
1908
  if (response.status === 401) throw new Error("App Store Connect refused the API key.\nCheck its key ID and issuer ID, and that it isn't revoked.", { cause: error });
1892
- if (response.status === 403) throw new Error(`${error.message}\nUploads need a key with the Admin, App Manager, or Developer role.`, { cause: error });
1909
+ if (response.status === 403) throw new Error(isProgramLicenseAgreementError(error.errors) ? `${action} failed (HTTP 403).\n${PROGRAM_LICENSE_AGREEMENT_HINT}` : `${error.message}\nUploads need a key with the Admin, App Manager, or Developer role.`, { cause: error });
1893
1910
  throw error;
1894
1911
  }
1895
1912
  return response.status === 204 ? void 0 : await response.json();
@@ -2328,38 +2345,62 @@ const createSigningKeychain = async ({ security = runSecurity, directory = os.tm
2328
2345
  return {
2329
2346
  path: keychainPath,
2330
2347
  certificates: async () => {
2348
+ const identities = new Set([...(await security([
2349
+ "find-identity",
2350
+ "-p",
2351
+ "codesigning",
2352
+ keychainPath
2353
+ ])).matchAll(/^\s*\d+\)\s+([0-9A-F]{40})\s/gm)].map((match) => match[1]));
2354
+ if (identities.size === 0) return [];
2331
2355
  return ((await security([
2332
2356
  "find-certificate",
2333
2357
  "-a",
2334
2358
  "-p",
2335
2359
  keychainPath
2336
- ])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map((pem) => {
2360
+ ])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).flatMap((pem) => {
2337
2361
  const certificate = new crypto$1.X509Certificate(pem);
2338
- return {
2362
+ if (!identities.has(certificate.fingerprint.replaceAll(":", ""))) return [];
2363
+ return [{
2339
2364
  serialNumber: normalizeSerial(certificate.serialNumber),
2340
2365
  commonName: certificate.subject.match(/^CN=(.*)$/m)?.[1] ?? certificate.subject
2341
- };
2366
+ }];
2342
2367
  });
2343
2368
  },
2344
2369
  dispose
2345
2370
  };
2346
2371
  };
2372
+ /** A request App Store Connect refused, in one line. */
2373
+ var AppStoreConnectRefusal = class extends Error {
2374
+ /** Whether the team has a Program License Agreement to accept. */
2375
+ agreement;
2376
+ constructor(message, agreement) {
2377
+ super(message);
2378
+ this.agreement = agreement;
2379
+ }
2380
+ };
2381
+ const refusal = async (action, response) => {
2382
+ const body = await response.json().catch(() => void 0);
2383
+ return new AppStoreConnectRefusal(`${action} failed (HTTP ${response.status}).`, isProgramLicenseAgreementError(Array.isArray(body?.errors) ? body.errors : []));
2384
+ };
2347
2385
  /**
2348
2386
  * Revokes the certificates Xcode created in the build's keychain, whose
2349
2387
  * private keys the keychain's deletion destroys: on a runner that starts
2350
2388
  * empty, Xcode creates a development certificate for every build, and App
2351
2389
  * Store Connect refuses new ones once a team has its maximum. Each is found
2352
2390
  * in the team's certificates by serial number. A revocation that fails is
2353
- * reported with where to revoke it by hand, and never fails the build.
2391
+ * reported with each certificate's name and serial and where to revoke it
2392
+ * by hand, and never fails the build.
2354
2393
  */
2355
2394
  const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fetch = globalThis.fetch }) => {
2356
2395
  const revoked = [];
2357
2396
  const failed = [];
2397
+ /** Why none could be revoked: the token or the listing failed. */
2398
+ let reason;
2399
+ let agreement = false;
2358
2400
  if (certificates.length === 0) return {
2359
- revoked,
2360
- failed
2401
+ revoked: [],
2402
+ failed: []
2361
2403
  };
2362
- const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
2363
2404
  try {
2364
2405
  const token = createAppStoreConnectToken({
2365
2406
  privateKey: privateKey ?? crypto$1.createPrivateKey(await fs.promises.readFile(apiKey.keyPath, "utf-8")),
@@ -2375,7 +2416,7 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
2375
2416
  })}`;
2376
2417
  while (next) {
2377
2418
  const response = await fetch(next, { headers });
2378
- if (!response.ok) throw new Error(`Listing the team's certificates failed (HTTP ${response.status}).`);
2419
+ if (!response.ok) throw await refusal("Listing the team's certificates", response);
2379
2420
  const body = await response.json();
2380
2421
  for (const { id, attributes } of body.data ?? []) if (attributes?.serialNumber) idsBySerial.set(normalizeSerial(attributes.serialNumber), id);
2381
2422
  next = body.links?.next;
@@ -2387,23 +2428,32 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
2387
2428
  method: "DELETE",
2388
2429
  headers
2389
2430
  });
2390
- if (response.ok || response.status === 404) revoked.push(describe(certificate));
2391
- else failed.push(`${describe(certificate)}: HTTP ${response.status}`);
2431
+ if (response.ok || response.status === 404) revoked.push(certificate);
2432
+ else {
2433
+ agreement ||= (await refusal("Revoking", response)).agreement;
2434
+ failed.push({
2435
+ certificate,
2436
+ status: response.status
2437
+ });
2438
+ }
2392
2439
  }
2393
2440
  } catch (error) {
2394
- const reason = error instanceof Error ? error.message : String(error);
2395
- for (const certificate of certificates) if (!revoked.includes(describe(certificate))) failed.push(`${describe(certificate)}: ${reason}`);
2441
+ reason = error instanceof Error ? error.message : String(error);
2442
+ agreement ||= error instanceof AppStoreConnectRefusal && error.agreement;
2443
+ for (const certificate of certificates) if (!revoked.includes(certificate)) failed.push({ certificate });
2396
2444
  }
2397
- if (revoked.length > 0) p.log.info(["Revoked the certificates Xcode created for this build:", ...revoked].join("\n"));
2445
+ const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
2446
+ if (revoked.length > 0) p.log.info(["Revoked the certificates Xcode created for this build:", ...revoked.flatMap(({ commonName, serialNumber }) => [commonName, `Serial ${serialNumber}`])].join("\n"));
2398
2447
  if (failed.length > 0) p.log.warn([
2399
2448
  "Couldn't revoke the certificates Xcode created for this build.",
2400
- ...failed,
2401
- "Revoke them at:",
2449
+ ...reason === void 0 ? [] : [reason],
2450
+ ...agreement ? [PROGRAM_LICENSE_AGREEMENT_HINT] : [],
2451
+ ...failed.flatMap(({ certificate, status }) => [certificate.commonName, `Serial ${certificate.serialNumber}${status === void 0 ? "" : ` (HTTP ${status})`}`]),
2402
2452
  CERTIFICATES_URL
2403
2453
  ].join("\n"));
2404
2454
  return {
2405
- revoked,
2406
- failed
2455
+ revoked: revoked.map(describe),
2456
+ failed: failed.map(({ certificate }) => describe(certificate))
2407
2457
  };
2408
2458
  };
2409
2459
  const TERMINATION_SIGNALS = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/apple-helper",
3
- "version": "1.2.0-rc.5",
3
+ "version": "1.2.0-rc.7",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",
@@ -29,8 +29,8 @@
29
29
  "dependencies": {
30
30
  "execa": "9.5.2",
31
31
  "fast-xml-parser": "5.8.0",
32
- "@hot-updater/cli-tools": "1.2.0-rc.5",
33
- "@hot-updater/plugin-core": "1.2.0-rc.5"
32
+ "@hot-updater/cli-tools": "1.2.0-rc.7",
33
+ "@hot-updater/plugin-core": "1.2.0-rc.7"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^20"