@hot-updater/apple-helper 1.2.0-rc.6 → 1.2.0-rc.8

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
  };
@@ -766,11 +783,23 @@ const ASC_INTEGRATIONS_URL = "https://appstoreconnect.apple.com/access/integrati
766
783
  const issueAscApiKey = (request) => createAscKeyIssuer()(request);
767
784
  //#endregion
768
785
  //#region src/utils/cocoapods.ts
786
+ /**
787
+ * A failed command's error: the command, then the last lines of its
788
+ * output, without execa's full message.
789
+ */
790
+ const commandFailure = (command, error) => {
791
+ const { stderr, stdout } = error ?? {};
792
+ const detail = String(stderr || stdout || "").trimEnd().split("\n").slice(-12).join("\n") || (error instanceof Error ? error.message : String(error));
793
+ return new Error(`${command} failed.\n${detail}`, { cause: error });
794
+ };
769
795
  const installPodsIfNeeded = async (sourceDir) => {
770
796
  const podfilePath = path.default.join(sourceDir, "Podfile");
771
797
  if (!fs.default.existsSync(podfilePath)) {
772
798
  _hot_updater_cli_tools.p.log.info("No Podfile found, skipping CocoaPods installation");
773
- return;
799
+ return {
800
+ codegen: false,
801
+ pods: false
802
+ };
774
803
  }
775
804
  const gemfilePaths = [path.default.join(sourceDir, "Gemfile"), path.default.join((0, _hot_updater_cli_tools.getCwd)(), "Gemfile")];
776
805
  const shouldUseBundler = (await Promise.all(gemfilePaths.map(checkShouldUseBundler))).some(Boolean);
@@ -784,36 +813,39 @@ const installPodsIfNeeded = async (sourceDir) => {
784
813
  reactNativePath: reactNative.packagePath,
785
814
  sourceDir
786
815
  });
787
- try {
788
- if (shouldUseBundler) {
789
- _hot_updater_cli_tools.p.log.info("Using bundler for CocoaPods installation");
790
- const bundleSpinner = _hot_updater_cli_tools.p.spinner();
791
- bundleSpinner.start("Installing Ruby gems");
816
+ if (shouldUseBundler) {
817
+ const bundleSpinner = _hot_updater_cli_tools.p.spinner();
818
+ bundleSpinner.start("Installing Ruby gems");
819
+ try {
792
820
  await (0, execa.execa)("bundle", ["install"], { cwd: sourceDir });
793
- bundleSpinner.stop("Ruby gems installed");
794
- const podSpinner = _hot_updater_cli_tools.p.spinner();
795
- podSpinner.start("Installing CocoaPods dependencies");
796
- await (0, execa.execa)("bundle", [
797
- "exec",
798
- "pod",
799
- "install"
800
- ], {
801
- cwd: sourceDir,
802
- env: plan.env
803
- });
804
- podSpinner.stop("CocoaPods dependencies installed");
805
- } else {
806
- const spinner = _hot_updater_cli_tools.p.spinner();
807
- spinner.start("Installing CocoaPods dependencies");
808
- await (0, execa.execa)("pod", ["install"], {
809
- cwd: sourceDir,
810
- env: plan.env
811
- });
812
- spinner.stop("CocoaPods dependencies installed");
821
+ } catch (error) {
822
+ bundleSpinner.error("bundle install failed");
823
+ throw commandFailure("bundle install", error);
813
824
  }
825
+ bundleSpinner.stop("Ruby gems installed");
826
+ }
827
+ const podCommand = shouldUseBundler ? [
828
+ "bundle",
829
+ "exec",
830
+ "pod",
831
+ "install"
832
+ ] : ["pod", "install"];
833
+ const podSpinner = _hot_updater_cli_tools.p.spinner();
834
+ podSpinner.start("Installing CocoaPods dependencies");
835
+ try {
836
+ await (0, execa.execa)(podCommand[0], podCommand.slice(1), {
837
+ cwd: sourceDir,
838
+ env: plan.env
839
+ });
814
840
  } catch (error) {
815
- throw new Error(`pod install failed: ${error}`);
841
+ podSpinner.error(`${podCommand.join(" ")} failed`);
842
+ throw commandFailure(podCommand.join(" "), error);
816
843
  }
844
+ podSpinner.stop("CocoaPods dependencies installed");
845
+ return {
846
+ codegen: plan.runCodegen,
847
+ pods: true
848
+ };
817
849
  };
818
850
  const checkShouldUseBundler = async (gemfilePath) => {
819
851
  try {
@@ -846,8 +878,8 @@ const runReactNativeCodegen = async ({ projectRoot, reactNativePath, sourceDir }
846
878
  "ios"
847
879
  ], { cwd: projectRoot });
848
880
  } catch (error) {
849
- spinner.stop("React Native codegen failed");
850
- throw new Error(`React Native codegen failed: ${error}`);
881
+ spinner.error("React Native codegen failed");
882
+ throw commandFailure("React Native codegen", error);
851
883
  }
852
884
  spinner.stop("React Native codegen done");
853
885
  };
@@ -977,26 +1009,6 @@ const getDefaultDestination = ({ platform, useGeneric, deviceType }) => {
977
1009
  });
978
1010
  };
979
1011
  //#endregion
980
- //#region src/utils/formatCommand.ts
981
- /**
982
- * A command to print, broken at spaces with ` \` once a line would pass
983
- * `width`, each continuation indented two spaces, so a terminal never wraps
984
- * it mid-word and it still pastes as one command.
985
- */
986
- const formatCommand = (words, width = 74) => {
987
- const lines = [];
988
- let line = "";
989
- for (const word of words) {
990
- const next = line ? `${line} ${word}` : word;
991
- if (line && next.length > width - 2) {
992
- lines.push(`${line} \\`);
993
- line = ` ${word}`;
994
- } else line = next;
995
- }
996
- lines.push(line);
997
- return lines.join("\n");
998
- };
999
- //#endregion
1000
1012
  //#region src/utils/parseXcodeProjectInfo.ts
1001
1013
  const parseXcodeProjectInfo = async (sourceDir) => {
1002
1014
  const files = await fs.default.promises.readdir(sourceDir);
@@ -1019,80 +1031,133 @@ const parseXcodeProjectInfo = async (sourceDir) => {
1019
1031
  const prettifyXcodebuildError = (error) => {
1020
1032
  if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
1021
1033
  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;
1023
- return new _hot_updater_cli_tools.BuildFailedError(message, {
1034
+ const hint = 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." : null;
1035
+ return new _hot_updater_cli_tools.BuildFailedError(hint ?? error.message, {
1024
1036
  logPath: error.logPath,
1025
1037
  summary: error.summary,
1038
+ hint,
1026
1039
  cause: error.cause
1027
1040
  });
1028
1041
  }
1029
1042
  return /* @__PURE__ */ new Error(`Xcodebuild failed: ${error}`);
1030
1043
  };
1031
1044
  //#endregion
1045
+ //#region src/utils/xcodebuildPhase.ts
1046
+ /** The `(in target 'X' from project 'Y')` that ends an action's line. */
1047
+ const TARGET = /\(in target '([^']+)' from project '[^']+'\)\s*$/;
1048
+ /** A `PhaseScriptExecution` line's script phase, its spaces escaped. */
1049
+ const SCRIPT = /^PhaseScriptExecution ((?:\\.|[^\s\\])+)/;
1050
+ /** A batched `SwiftCompile`'s files: `Compiling\ A.swift,\ B.swift`. */
1051
+ const SWIFT_BATCH = /^SwiftCompile \S+ \S+ Compiling\\ ((?:\\.|[^\s\\])+)/;
1052
+ const unescape = (value) => value.replace(/\\(.)/g, "$1");
1053
+ /** Actions whose lines say what xcodebuild is doing, by their first word. */
1054
+ const ACTIONS = {
1055
+ CompileAssetCatalog: "Compiling assets",
1056
+ CompileAssetCatalogVariant: "Compiling assets",
1057
+ LinkAssetCatalog: "Compiling assets",
1058
+ CompileStoryboard: "Compiling storyboards",
1059
+ CompileXIB: "Compiling storyboards",
1060
+ LinkStoryboards: "Compiling storyboards",
1061
+ Ld: "Linking",
1062
+ Libtool: "Linking",
1063
+ CodeSign: "Signing",
1064
+ ProcessInfoPlistFile: "Processing",
1065
+ ProcessProductPackaging: "Processing",
1066
+ ProcessProductPackagingDER: "Processing",
1067
+ CopySwiftLibs: "Copying",
1068
+ CpResource: "Copying",
1069
+ GenerateDSYMFile: "Generating dSYM",
1070
+ Strip: "Stripping",
1071
+ Validate: "Validating"
1072
+ };
1073
+ const COMPILES = /* @__PURE__ */ new Set([
1074
+ "CompileC",
1075
+ "SwiftCompile",
1076
+ "CompileSwift"
1077
+ ]);
1078
+ const PRECOMPILES = /* @__PURE__ */ new Set([
1079
+ "PrecompileModule",
1080
+ "ScanDependencies",
1081
+ "SwiftExplicitDependencyGeneratePcm",
1082
+ "SwiftExplicitDependencyCompileModuleFromInterface"
1083
+ ]);
1084
+ const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
1085
+ /**
1086
+ * Reads the phase of an `xcodebuild` build, archive, or export from its raw
1087
+ * output: `Compiling React-Core · 412 files`, `Linking CodexRelay`,
1088
+ * `Signing CodexRelay`, `Bundling JavaScript`, with the target each
1089
+ * action's line names.
1090
+ */
1091
+ const createXcodebuildPhaseReader = () => {
1092
+ let compiled = 0;
1093
+ let precompiled = 0;
1094
+ return (line) => {
1095
+ const space = line.indexOf(" ");
1096
+ const action = space === -1 ? line : line.slice(0, space);
1097
+ const target = TARGET.exec(line)?.[1];
1098
+ const withTarget = (phase) => target ? `${phase} ${target}` : phase;
1099
+ if (COMPILES.has(action)) {
1100
+ const batch = SWIFT_BATCH.exec(line)?.[1];
1101
+ compiled += batch ? unescape(batch).split(",").length : 1;
1102
+ return `${withTarget("Compiling")} · ${plural(compiled, "file")}`;
1103
+ }
1104
+ if (PRECOMPILES.has(action)) {
1105
+ precompiled += 1;
1106
+ return `Precompiling modules · ${precompiled}`;
1107
+ }
1108
+ if (action === "PhaseScriptExecution") {
1109
+ const script = unescape(SCRIPT.exec(line)?.[1] ?? "");
1110
+ if (script === "Bundle React Native code and images") return "Bundling JavaScript";
1111
+ return script ? `Running ${script}` : withTarget("Running a script");
1112
+ }
1113
+ const phase = ACTIONS[action];
1114
+ if (phase) return withTarget(phase);
1115
+ if (line === "Prepare packages" || line.startsWith("Resolve Package")) return "Resolving packages";
1116
+ if (line === "ComputeTargetDependencyGraph") return "Planning the build";
1117
+ };
1118
+ };
1119
+ //#endregion
1032
1120
  //#region src/utils/createXcodebuildLogger.ts
1033
- const createXcodebuildLogger = ({ logPrefix }) => new _hot_updater_cli_tools.BuildLogger({
1121
+ const createXcodebuildLogger = ({ logPrefix, logFilePath }) => new _hot_updater_cli_tools.BuildLogger({
1034
1122
  logPrefix,
1035
- importantLogPatterns: [
1036
- "error:",
1037
- "** BUILD FAILED **",
1038
- "** ARCHIVE FAILED **",
1039
- "The following build commands failed:",
1040
- "note: Building targets in dependency order",
1041
- "note: Target dependency graph",
1042
- "Running script",
1043
- "[CP-User]",
1044
- "[CP] Check Pods Manifest.lock",
1045
- "Build Succeeded"
1046
- ],
1047
- progressStages: [
1048
- ["Building targets in dependency order", "Target dependency graph"],
1049
- ["Write Auxiliary File", "[CP] Check Pods Manifest.lock"],
1050
- [
1051
- "Running script",
1052
- "[CP-User] [RN]Check rncore",
1053
- "[CP-User] [Hermes] Replace Hermes",
1054
- "[CP-User] [RN]Check FBReactNativeSpec"
1055
- ],
1056
- [
1057
- "Processing",
1058
- "ProcessProductPackaging",
1059
- "ProcessProductPackagingDER"
1060
- ],
1061
- ["Compiling"],
1062
- ["RegisterExecutionPolicyException"],
1063
- ["Touching"],
1064
- ["Building library", "Create Universal Binary"],
1065
- ["Copying"],
1066
- [
1067
- "Build Succeeded",
1068
- "BUILD SUCCEEDED",
1069
- "ARCHIVE SUCCEEDED"
1070
- ]
1071
- ],
1123
+ logFilePath,
1124
+ createPhaseReader: createXcodebuildPhaseReader,
1072
1125
  failureSummary: {
1073
1126
  lines: [/(^|:\s)error: /, /^\*\* [A-Z ]+ FAILED \*\*$/],
1074
1127
  blocks: [{
1075
1128
  start: "The following build commands failed:",
1076
1129
  end: /^\(\d+ failures?\)$/,
1077
1130
  includeEnd: true
1131
+ }, {
1132
+ start: /^error: exportArchive /,
1133
+ end: /^$/,
1134
+ includeEnd: false
1078
1135
  }]
1079
1136
  }
1080
1137
  });
1081
1138
  //#endregion
1082
- //#region src/utils/runXcodebuildWithLogging.ts
1083
- let cachedXcbeautifyAvailability = null;
1084
- const TERMINATION_SIGNALS$1 = ["SIGINT", "SIGTERM"];
1085
- const isXcbeautifyAvailable = async () => {
1086
- if (cachedXcbeautifyAvailability !== null) return cachedXcbeautifyAvailability;
1087
- try {
1088
- await (0, execa.execa)("which", ["xcbeautify"]);
1089
- cachedXcbeautifyAvailability = true;
1090
- return true;
1091
- } catch {
1092
- cachedXcbeautifyAvailability = false;
1093
- return false;
1139
+ //#region src/utils/formatCommand.ts
1140
+ /**
1141
+ * A command to print, broken at spaces with ` \` once a line would pass
1142
+ * `width`, each continuation indented two spaces, so a terminal never wraps
1143
+ * it mid-word and it still pastes as one command.
1144
+ */
1145
+ const formatCommand = (words, width = 74) => {
1146
+ const lines = [];
1147
+ let line = "";
1148
+ for (const word of words) {
1149
+ const next = line ? `${line} ${word}` : word;
1150
+ if (line && next.length > width - 2) {
1151
+ lines.push(`${line} \\`);
1152
+ line = ` ${word}`;
1153
+ } else line = next;
1094
1154
  }
1155
+ lines.push(line);
1156
+ return lines.join("\n");
1095
1157
  };
1158
+ //#endregion
1159
+ //#region src/utils/runXcodebuildWithLogging.ts
1160
+ const TERMINATION_SIGNALS$1 = ["SIGINT", "SIGTERM"];
1096
1161
  const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
1097
1162
  return (0, execa.execa)("xcodebuild", args, {
1098
1163
  cwd: sourceDir,
@@ -1105,13 +1170,6 @@ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
1105
1170
  }
1106
1171
  });
1107
1172
  };
1108
- const createXcbeautifyProcess = ({ cwd }) => {
1109
- return (0, execa.execa)("xcbeautify", [], {
1110
- cwd,
1111
- all: true,
1112
- buffer: false
1113
- });
1114
- };
1115
1173
  /** Why xcodebuild failed, without its command line. */
1116
1174
  const describeFailure = (error) => {
1117
1175
  if (error instanceof execa.ExecaError) {
@@ -1120,10 +1178,24 @@ const describeFailure = (error) => {
1120
1178
  }
1121
1179
  return error instanceof Error ? error.message : String(error);
1122
1180
  };
1123
- const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage, failureMessage, logPrefix }) => {
1124
- const useXcbeautify = await isXcbeautifyAvailable();
1125
- const logger = createXcodebuildLogger({ logPrefix });
1126
- await logger.start();
1181
+ /**
1182
+ * Runs xcodebuild as a step titled `title`: its live line shows the build's
1183
+ * phase, the log file keeps the command and the whole output, and a failure
1184
+ * prints the output's errors and the log's path on stderr.
1185
+ */
1186
+ const runXcodebuildWithLogging = async ({ args, env, sourceDir, title, successMessage, failureMessage, logPrefix, logFilePath, header = [] }) => {
1187
+ const logger = createXcodebuildLogger({
1188
+ logPrefix,
1189
+ logFilePath
1190
+ });
1191
+ await logger.start({
1192
+ title,
1193
+ header: [
1194
+ ...header,
1195
+ `$ ${formatCommand(["xcodebuild", ...args])}`,
1196
+ ""
1197
+ ]
1198
+ });
1127
1199
  const activeProcesses = [];
1128
1200
  const terminateActiveProcesses = () => {
1129
1201
  for (const activeProcess of activeProcesses) activeProcess.kill();
@@ -1137,31 +1209,17 @@ const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage,
1137
1209
  });
1138
1210
  activeProcesses.push(xcodebuildProcess);
1139
1211
  const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
1140
- let xcbeautifyProcess = null;
1141
- if (useXcbeautify && xcodebuildOutput) {
1142
- xcbeautifyProcess = createXcbeautifyProcess({ cwd: sourceDir });
1143
- activeProcesses.push(xcbeautifyProcess);
1144
- }
1145
- const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput, { display: xcbeautifyProcess ? xcbeautifyProcess.all ?? xcbeautifyProcess.stdout ?? void 0 : void 0 }) : Promise.resolve();
1146
- const xcbeautifyInput = xcbeautifyProcess?.stdin;
1147
- if (xcodebuildOutput && xcbeautifyInput) {
1148
- xcbeautifyInput.on("error", () => {
1149
- xcodebuildOutput.unpipe(xcbeautifyInput);
1150
- });
1151
- xcodebuildOutput.pipe(xcbeautifyInput);
1152
- }
1153
- const [xcodebuildResult, loggingResult] = await Promise.allSettled([
1154
- xcodebuildProcess,
1155
- logging,
1156
- xcbeautifyProcess
1157
- ]);
1212
+ const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput) : Promise.resolve();
1213
+ const [xcodebuildResult, loggingResult] = await Promise.allSettled([xcodebuildProcess, logging]);
1158
1214
  if (xcodebuildResult.status === "rejected") throw xcodebuildResult.reason;
1159
1215
  if (loggingResult.status === "rejected") throw loggingResult.reason;
1160
1216
  logger.stop(successMessage);
1217
+ return { logFilePath: logger.logFilePath };
1161
1218
  } catch (error) {
1162
1219
  logger.writeError(error);
1163
- logger.stop(failureMessage, false);
1164
- throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${describeFailure(error)}`, {
1220
+ const reason = describeFailure(error);
1221
+ logger.stop(`${failureMessage} · ${reason.replace(/\.$/, "")}`, false);
1222
+ throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${reason}`, {
1165
1223
  logPath: logger.logPath,
1166
1224
  summary: logger.failureSummary,
1167
1225
  cause: error
@@ -1253,7 +1311,7 @@ const buildSettingsOverScheme = ({ minBundleId, authenticationArgs }) => ({
1253
1311
  * written to a temporary directory, removed whether the archive succeeds or
1254
1312
  * not.
1255
1313
  */
1256
- const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, minBundleId, authenticationArgs }) => {
1314
+ const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, header = [], minBundleId, authenticationArgs }) => {
1257
1315
  if (installPods) await installPodsIfNeeded(sourceDir);
1258
1316
  const xcodeProject = await parseXcodeProjectInfo(sourceDir);
1259
1317
  const archiveName = `${xcodeProject.name.replace(".xcworkspace", "").replace(".xcodeproj", "")}.xcarchive`;
@@ -1283,24 +1341,26 @@ const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, inst
1283
1341
  xcconfig: build.xcconfig,
1284
1342
  xcodeScheme
1285
1343
  });
1286
- _hot_updater_cli_tools.p.log.info([
1287
- "Xcode archive",
1288
- `Project ${xcodeProject.name}`,
1289
- `Scheme ${xcodeScheme}`,
1290
- `Platform ${platform}`,
1291
- "Command",
1292
- formatCommand(["xcodebuild", ...archiveArgs])
1293
- ].join("\n"));
1294
1344
  try {
1295
- await runXcodebuildWithLogging({
1345
+ const { logFilePath } = await runXcodebuildWithLogging({
1296
1346
  args: archiveArgs,
1297
1347
  env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
1348
+ title: `Archiving ${xcodeScheme}`,
1349
+ successMessage: `Archived ${xcodeScheme}`,
1298
1350
  failureMessage: "Archive failed",
1299
1351
  logPrefix,
1300
- sourceDir,
1301
- successMessage: "Archive completed successfully"
1352
+ header: [
1353
+ ...header,
1354
+ `Project ${xcodeProject.name}`,
1355
+ `Scheme ${xcodeScheme}`,
1356
+ `Platform ${platform}`
1357
+ ],
1358
+ sourceDir
1302
1359
  });
1303
- return { archivePath };
1360
+ return {
1361
+ archivePath,
1362
+ logFilePath
1363
+ };
1304
1364
  } catch (error) {
1305
1365
  throw prettifyXcodebuildError(error);
1306
1366
  }
@@ -1343,7 +1403,7 @@ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xco
1343
1403
  * `.app` to `outputDirectory`, and removes DerivedData, which takes 10 GB or
1344
1404
  * more, whether the build succeeds or not.
1345
1405
  */
1346
- const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams, minBundleId, authenticationArgs }) => {
1406
+ const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams, minBundleId, authenticationArgs, header = [] }) => {
1347
1407
  if (installPods) await installPodsIfNeeded(sourceDir);
1348
1408
  const xcodeProject = await parseXcodeProjectInfo(sourceDir);
1349
1409
  const tmpDir = await createRandomTmpDir();
@@ -1381,26 +1441,26 @@ const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeSc
1381
1441
  sourceDir,
1382
1442
  xcodeProject
1383
1443
  });
1384
- _hot_updater_cli_tools.p.log.info([
1385
- "Xcode build",
1386
- `Project ${xcodeProject.name}`,
1387
- `Scheme ${xcodeScheme}`,
1388
- `Configuration ${configuration}`,
1389
- `Platform ${platform}`,
1390
- `Device type ${deviceType}`,
1391
- "Command",
1392
- formatCommand(["xcodebuild", ...buildArgs])
1393
- ].join("\n"));
1394
1444
  let builtAppPath;
1445
+ let logFilePath;
1395
1446
  try {
1396
- await runXcodebuildWithLogging({
1447
+ ({logFilePath} = await runXcodebuildWithLogging({
1397
1448
  args: buildArgs,
1398
1449
  env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
1399
1450
  sourceDir,
1400
1451
  logPrefix,
1401
- successMessage: "Build completed successfully",
1452
+ header: [
1453
+ ...header,
1454
+ `Project ${xcodeProject.name}`,
1455
+ `Scheme ${xcodeScheme}`,
1456
+ `Configuration ${configuration}`,
1457
+ `Platform ${platform}`,
1458
+ `Device type ${deviceType}`
1459
+ ],
1460
+ title: `Building ${xcodeScheme}`,
1461
+ successMessage: `Built ${xcodeScheme}`,
1402
1462
  failureMessage: "Build failed"
1403
- });
1463
+ }));
1404
1464
  ({appPath: builtAppPath} = await getBuildSettings({
1405
1465
  configuration,
1406
1466
  derivedDataPath,
@@ -1414,7 +1474,10 @@ const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeSc
1414
1474
  }
1415
1475
  const appPath = path.default.join(outputDirectory, path.default.basename(builtAppPath));
1416
1476
  await fs.default.promises.cp(builtAppPath, appPath, { recursive: true });
1417
- return { appPath };
1477
+ return {
1478
+ appPath,
1479
+ logFilePath
1480
+ };
1418
1481
  } finally {
1419
1482
  await removeTmpDir(tmpDir);
1420
1483
  }
@@ -1474,7 +1537,7 @@ const getBuildSettings = async ({ xcodeProject, sourceDir, xcodeScheme, configur
1474
1537
  //#endregion
1475
1538
  //#region src/builder/exportXcodeArchive.ts
1476
1539
  /** Exports the archive into `exportPath`. */
1477
- const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, authenticationArgs }) => {
1540
+ const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, logFilePath, title, authenticationArgs }) => {
1478
1541
  const exportArgs = prepareExportArgs({
1479
1542
  authenticationArgs,
1480
1543
  archivePath,
@@ -1486,9 +1549,11 @@ const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportEx
1486
1549
  await runXcodebuildWithLogging({
1487
1550
  args: exportArgs,
1488
1551
  sourceDir,
1489
- successMessage: "Archive exported successfully",
1552
+ title: `Exporting ${title}`,
1553
+ successMessage: `Exported ${title}`,
1490
1554
  failureMessage: "Export failed",
1491
- logPrefix
1555
+ logPrefix,
1556
+ logFilePath
1492
1557
  });
1493
1558
  return { exportPath };
1494
1559
  } catch (error) {
@@ -1917,7 +1982,7 @@ const createAppStoreConnectRequest = ({ apiKey, privateKey, fetch }) => {
1917
1982
  if (!response.ok) {
1918
1983
  const error = new AppStoreConnectError(action, response.status, await readApiErrors(response), response.headers.get("retry-after"));
1919
1984
  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 });
1985
+ 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
1986
  throw error;
1922
1987
  }
1923
1988
  return response.status === 204 ? void 0 : await response.json();
@@ -2356,38 +2421,62 @@ const createSigningKeychain = async ({ security = runSecurity, directory = os.de
2356
2421
  return {
2357
2422
  path: keychainPath,
2358
2423
  certificates: async () => {
2424
+ const identities = new Set([...(await security([
2425
+ "find-identity",
2426
+ "-p",
2427
+ "codesigning",
2428
+ keychainPath
2429
+ ])).matchAll(/^\s*\d+\)\s+([0-9A-F]{40})\s/gm)].map((match) => match[1]));
2430
+ if (identities.size === 0) return [];
2359
2431
  return ((await security([
2360
2432
  "find-certificate",
2361
2433
  "-a",
2362
2434
  "-p",
2363
2435
  keychainPath
2364
- ])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map((pem) => {
2436
+ ])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).flatMap((pem) => {
2365
2437
  const certificate = new crypto.default.X509Certificate(pem);
2366
- return {
2438
+ if (!identities.has(certificate.fingerprint.replaceAll(":", ""))) return [];
2439
+ return [{
2367
2440
  serialNumber: normalizeSerial(certificate.serialNumber),
2368
2441
  commonName: certificate.subject.match(/^CN=(.*)$/m)?.[1] ?? certificate.subject
2369
- };
2442
+ }];
2370
2443
  });
2371
2444
  },
2372
2445
  dispose
2373
2446
  };
2374
2447
  };
2448
+ /** A request App Store Connect refused, in one line. */
2449
+ var AppStoreConnectRefusal = class extends Error {
2450
+ /** Whether the team has a Program License Agreement to accept. */
2451
+ agreement;
2452
+ constructor(message, agreement) {
2453
+ super(message);
2454
+ this.agreement = agreement;
2455
+ }
2456
+ };
2457
+ const refusal = async (action, response) => {
2458
+ const body = await response.json().catch(() => void 0);
2459
+ return new AppStoreConnectRefusal(`${action} failed (HTTP ${response.status}).`, isProgramLicenseAgreementError(Array.isArray(body?.errors) ? body.errors : []));
2460
+ };
2375
2461
  /**
2376
2462
  * Revokes the certificates Xcode created in the build's keychain, whose
2377
2463
  * private keys the keychain's deletion destroys: on a runner that starts
2378
2464
  * empty, Xcode creates a development certificate for every build, and App
2379
2465
  * Store Connect refuses new ones once a team has its maximum. Each is found
2380
2466
  * 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.
2467
+ * reported with each certificate's name and serial and where to revoke it
2468
+ * by hand, and never fails the build.
2382
2469
  */
2383
2470
  const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fetch = globalThis.fetch }) => {
2384
2471
  const revoked = [];
2385
2472
  const failed = [];
2473
+ /** Why none could be revoked: the token or the listing failed. */
2474
+ let reason;
2475
+ let agreement = false;
2386
2476
  if (certificates.length === 0) return {
2387
- revoked,
2388
- failed
2477
+ revoked: [],
2478
+ failed: []
2389
2479
  };
2390
- const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
2391
2480
  try {
2392
2481
  const token = createAppStoreConnectToken({
2393
2482
  privateKey: privateKey ?? crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8")),
@@ -2403,7 +2492,7 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
2403
2492
  })}`;
2404
2493
  while (next) {
2405
2494
  const response = await fetch(next, { headers });
2406
- if (!response.ok) throw new Error(`Listing the team's certificates failed (HTTP ${response.status}).`);
2495
+ if (!response.ok) throw await refusal("Listing the team's certificates", response);
2407
2496
  const body = await response.json();
2408
2497
  for (const { id, attributes } of body.data ?? []) if (attributes?.serialNumber) idsBySerial.set(normalizeSerial(attributes.serialNumber), id);
2409
2498
  next = body.links?.next;
@@ -2415,23 +2504,32 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
2415
2504
  method: "DELETE",
2416
2505
  headers
2417
2506
  });
2418
- if (response.ok || response.status === 404) revoked.push(describe(certificate));
2419
- else failed.push(`${describe(certificate)}: HTTP ${response.status}`);
2507
+ if (response.ok || response.status === 404) revoked.push(certificate);
2508
+ else {
2509
+ agreement ||= (await refusal("Revoking", response)).agreement;
2510
+ failed.push({
2511
+ certificate,
2512
+ status: response.status
2513
+ });
2514
+ }
2420
2515
  }
2421
2516
  } 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}`);
2517
+ reason = error instanceof Error ? error.message : String(error);
2518
+ agreement ||= error instanceof AppStoreConnectRefusal && error.agreement;
2519
+ for (const certificate of certificates) if (!revoked.includes(certificate)) failed.push({ certificate });
2424
2520
  }
2425
- if (revoked.length > 0) _hot_updater_cli_tools.p.log.info(["Revoked the certificates Xcode created for this build:", ...revoked].join("\n"));
2521
+ const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
2522
+ 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
2523
  if (failed.length > 0) _hot_updater_cli_tools.p.log.warn([
2427
2524
  "Couldn't revoke the certificates Xcode created for this build.",
2428
- ...failed,
2429
- "Revoke them at:",
2525
+ ...reason === void 0 ? [] : [reason],
2526
+ ...agreement ? [PROGRAM_LICENSE_AGREEMENT_HINT] : [],
2527
+ ...failed.flatMap(({ certificate, status }) => [certificate.commonName, `Serial ${certificate.serialNumber}${status === void 0 ? "" : ` (HTTP ${status})`}`]),
2430
2528
  CERTIFICATES_URL
2431
2529
  ].join("\n"));
2432
2530
  return {
2433
- revoked,
2434
- failed
2531
+ revoked: revoked.map(describe),
2532
+ failed: failed.map(({ certificate }) => describe(certificate))
2435
2533
  };
2436
2534
  };
2437
2535
  const TERMINATION_SIGNALS = [
@@ -2537,7 +2635,7 @@ const readBuiltApp = async (appPath) => {
2537
2635
  * once the caller has copied it out. A failed build removes it, and every
2538
2636
  * other directory the build used, itself.
2539
2637
  */
2540
- const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials }) => {
2638
+ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials, logHeader = [] }) => {
2541
2639
  await assertXcodebuildExist();
2542
2640
  if (schemeConfig.simulator && schemeConfig.destination.length) {
2543
2641
  _hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
@@ -2550,7 +2648,8 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
2550
2648
  schemeConfig,
2551
2649
  minBundleId,
2552
2650
  credentials,
2553
- buildDirectory
2651
+ buildDirectory,
2652
+ logHeader
2554
2653
  }),
2555
2654
  dispose: () => removeTmpDir(buildDirectory)
2556
2655
  };
@@ -2559,15 +2658,16 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
2559
2658
  throw error;
2560
2659
  }
2561
2660
  };
2562
- const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirectory }) => {
2661
+ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirectory, logHeader }) => {
2563
2662
  const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2564
2663
  if (schemeConfig.simulator) {
2565
2664
  const appDir = path.default.join(buildDirectory, "app");
2566
2665
  await fs.default.promises.mkdir(appDir, { recursive: true });
2567
- const { appPath } = await buildXcodeProject({
2666
+ const { appPath, logFilePath } = await buildXcodeProject({
2568
2667
  sourceDir: iosProjectRoot,
2569
2668
  outputDirectory: appDir,
2570
2669
  logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
2670
+ header: logHeader,
2571
2671
  platform: schemeConfig.platform,
2572
2672
  xcodeScheme: schemeConfig.scheme,
2573
2673
  configuration: schemeConfig.configuration,
@@ -2580,6 +2680,7 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
2580
2680
  return {
2581
2681
  buildDirectory,
2582
2682
  buildArtifactPath: appPath,
2683
+ logFilePath,
2583
2684
  builtApp: await readBuiltApp(appPath)
2584
2685
  };
2585
2686
  } else {
@@ -2590,7 +2691,8 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
2590
2691
  if (!signing) return archiveAndExport({
2591
2692
  schemeConfig,
2592
2693
  minBundleId,
2593
- buildDirectory
2694
+ buildDirectory,
2695
+ logHeader
2594
2696
  });
2595
2697
  const appStoreConnect = await exportsForAppStoreConnect(schemeConfig);
2596
2698
  if ((0, _hot_updater_cli_tools.isCI)() && !appStoreConnect) _hot_updater_cli_tools.p.log.info("Signing in the runner's own keychains (not an App Store Connect export).");
@@ -2601,6 +2703,7 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
2601
2703
  schemeConfig,
2602
2704
  minBundleId,
2603
2705
  buildDirectory,
2706
+ logHeader,
2604
2707
  authenticationArgs: signing.authenticationArgs
2605
2708
  })
2606
2709
  });
@@ -2610,12 +2713,12 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
2610
2713
  * Archives a device build and exports its `.ipa`, or returns the
2611
2714
  * `.xcarchive` of a scheme it cannot export.
2612
2715
  */
2613
- const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, authenticationArgs }) => {
2716
+ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, logHeader, authenticationArgs }) => {
2614
2717
  const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2615
2718
  const archiveDir = path.default.join(buildDirectory, "archive");
2616
2719
  const exportDir = path.default.join(buildDirectory, "export");
2617
2720
  await fs.default.promises.mkdir(archiveDir, { recursive: true });
2618
- const { archivePath } = await archiveXcodeProject({
2721
+ const { archivePath, logFilePath } = await archiveXcodeProject({
2619
2722
  archiveDirectory: archiveDir,
2620
2723
  authenticationArgs,
2621
2724
  minBundleId,
@@ -2627,7 +2730,8 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, aut
2627
2730
  extraParams: schemeConfig.extraParams,
2628
2731
  configuration: schemeConfig.configuration,
2629
2732
  installPods: schemeConfig.installPods,
2630
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-archive`
2733
+ logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
2734
+ header: logHeader
2631
2735
  });
2632
2736
  const appFromArchive = extractAppFromXcarchive(archivePath);
2633
2737
  const archivedAppPath = appFromArchive ? path.default.join(archiveDir, path.default.basename(appFromArchive)) : void 0;
@@ -2635,6 +2739,7 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, aut
2635
2739
  if (!schemeConfig.exportOptionsPlist && !authenticationArgs) return {
2636
2740
  buildDirectory,
2637
2741
  buildArtifactPath: archivePath,
2742
+ logFilePath,
2638
2743
  builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
2639
2744
  };
2640
2745
  await fs.default.promises.mkdir(exportDir, { recursive: true });
@@ -2645,7 +2750,9 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, aut
2645
2750
  exportExtraParams: schemeConfig.exportExtraParams,
2646
2751
  exportOptionsPlist,
2647
2752
  sourceDir: iosProjectRoot,
2648
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-export`
2753
+ logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
2754
+ logFilePath: logFilePath ?? void 0,
2755
+ title: schemeConfig.scheme
2649
2756
  });
2650
2757
  if (schemeConfig.exportOptionsPlist) await exportArchive(schemeConfig.exportOptionsPlist);
2651
2758
  else {
@@ -2661,6 +2768,7 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, aut
2661
2768
  return {
2662
2769
  buildDirectory,
2663
2770
  buildArtifactPath: path.default.join(exportDir, ipaFile),
2771
+ logFilePath,
2664
2772
  builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
2665
2773
  };
2666
2774
  };