@hot-updater/apple-helper 1.2.0-rc.12 → 1.2.0-rc.13

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
@@ -25,6 +25,9 @@ let node_crypto = require("node:crypto");
25
25
  node_crypto = __toESM(node_crypto, 1);
26
26
  let node_timers_promises = require("node:timers/promises");
27
27
  let _hot_updater_cli_tools = require("@hot-updater/cli-tools");
28
+ let _hot_updater_protocol_native_artifact = require("@hot-updater/protocol/native-artifact");
29
+ let crypto = require("crypto");
30
+ crypto = __toESM(crypto, 1);
28
31
  let fs = require("fs");
29
32
  fs = __toESM(fs, 1);
30
33
  let path = require("path");
@@ -35,8 +38,6 @@ let node_fs_promises = require("node:fs/promises");
35
38
  let node_os = require("node:os");
36
39
  let node_path = require("node:path");
37
40
  let child_process = require("child_process");
38
- let crypto = require("crypto");
39
- crypto = __toESM(crypto, 1);
40
41
  let os = require("os");
41
42
  os = __toESM(os, 1);
42
43
  //#region src/asc/appleSrp.ts
@@ -782,220 +783,6 @@ const ASC_INTEGRATIONS_URL = "https://appstoreconnect.apple.com/access/integrati
782
783
  */
783
784
  const issueAscApiKey = (request) => createAscKeyIssuer()(request);
784
785
  //#endregion
785
- //#region src/utils/formatCommand.ts
786
- /**
787
- * A command to print, broken at spaces with ` \` once a line would pass
788
- * `width`, each continuation indented two spaces, so a terminal never wraps
789
- * it mid-word and it still pastes as one command.
790
- */
791
- const formatCommand = (words, width = 74) => {
792
- const lines = [];
793
- let line = "";
794
- for (const word of words) {
795
- const next = line ? `${line} ${word}` : word;
796
- if (line && next.length > width - 2) {
797
- lines.push(`${line} \\`);
798
- line = ` ${word}`;
799
- } else line = next;
800
- }
801
- lines.push(line);
802
- return lines.join("\n");
803
- };
804
- //#endregion
805
- //#region src/utils/cocoapods.ts
806
- /**
807
- * A failed command's error: the command, then the last lines of its
808
- * output, stdout and stderr as printed, without execa's full message.
809
- */
810
- const commandFailure = (command, error) => {
811
- const { all, stderr, stdout } = error ?? {};
812
- const detail = String(all || [stdout, stderr].filter(Boolean).join("\n") || "").trimEnd().split("\n").filter((line) => line.trim()).slice(-12).join("\n") || (error instanceof Error ? error.message : String(error));
813
- return new Error(`${command} failed.\n${detail}`, { cause: error });
814
- };
815
- /**
816
- * A line of the progress meter curl prints on stderr for each pod it
817
- * downloads, such as React Native's prebuilt core.
818
- */
819
- const DOWNLOAD_PROGRESS = [
820
- /^% Total +% Received/,
821
- /^Dload +Upload/,
822
- /^\d{1,3} +[\d.]+[kMGT]? +\d{1,3} +[\d.]+[kMGT]? /
823
- ];
824
- /**
825
- * `pod install`'s output: its live line shows the pod it installs, its log
826
- * keeps the whole output, and a failure prints CocoaPods' `[!]` errors and a
827
- * crash's `SomeError - …` line with its last lines, without the download
828
- * progress CocoaPods' curl prints on stderr.
829
- */
830
- const createPodInstallLogger = () => new _hot_updater_cli_tools.BuildLogger({
831
- logPrefix: "pod-install",
832
- createPhaseReader: () => (line) => {
833
- const installing = /^Installing (\S+)/.exec(line);
834
- if (installing) return `Installing ${installing[1]}`;
835
- return /^(Analyzing dependencies|Downloading dependencies|Generating Pods project|Integrating client project)$/.test(line) ? line : void 0;
836
- },
837
- failureSummary: {
838
- lines: [/^[A-Z]\w*(::\w+)*Error - /],
839
- blocks: [{
840
- start: /^\[!\] /,
841
- end: /^$/,
842
- includeEnd: false
843
- }],
844
- ignore: DOWNLOAD_PROGRESS,
845
- tail: 4,
846
- stopAfter: /^\[!\] Oh no, an error occurred\.$/,
847
- maxWidth: 76
848
- }
849
- });
850
- /**
851
- * Runs `pod install` as a step whose failure prints CocoaPods' errors and
852
- * the log's path, then throws a `BuildFailedError`.
853
- */
854
- const runPodInstall = async ({ command, env, sourceDir }) => {
855
- const logger = createPodInstallLogger();
856
- const failureMessage = `${command.join(" ")} failed`;
857
- await logger.start({
858
- title: "Installing CocoaPods dependencies",
859
- header: [`$ ${formatCommand([...Object.entries(env).map(([name, value]) => `${name}=${value}`), ...command])}`, ""]
860
- });
861
- try {
862
- const podProcess = (0, execa.execa)(command[0], command.slice(1), {
863
- cwd: sourceDir,
864
- env,
865
- all: true,
866
- buffer: false
867
- });
868
- const logging = podProcess.all ? logger.processStream(podProcess.all) : Promise.resolve();
869
- const [podResult, loggingResult] = await Promise.allSettled([podProcess, logging]);
870
- if (podResult.status === "rejected") throw podResult.reason;
871
- if (loggingResult.status === "rejected") throw loggingResult.reason;
872
- logger.stop("CocoaPods dependencies installed");
873
- } catch (error) {
874
- logger.writeError(error);
875
- const reason = error instanceof execa.ExecaError && error.exitCode !== void 0 ? `exited with code ${error.exitCode}` : error instanceof Error ? error.message : String(error);
876
- logger.stop(`${failureMessage} · ${reason}`, false);
877
- throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${reason}`, {
878
- logPath: logger.logPath,
879
- summary: logger.failureSummary,
880
- cause: error
881
- });
882
- } finally {
883
- await logger.close();
884
- }
885
- };
886
- const installPodsIfNeeded = async (sourceDir) => {
887
- const podfilePath = path.default.join(sourceDir, "Podfile");
888
- if (!fs.default.existsSync(podfilePath)) {
889
- _hot_updater_cli_tools.p.log.info("No Podfile found, skipping CocoaPods installation");
890
- return {
891
- codegen: false,
892
- pods: false
893
- };
894
- }
895
- const gemfilePaths = [path.default.join(sourceDir, "Gemfile"), path.default.join((0, _hot_updater_cli_tools.getCwd)(), "Gemfile")];
896
- const shouldUseBundler = (await Promise.all(gemfilePaths.map(checkShouldUseBundler))).some(Boolean);
897
- const reactNative = (0, _hot_updater_cli_tools.getReactNativeMetadatas)();
898
- const plan = resolvePodInstallPlan({
899
- version: reactNative.version,
900
- env: process.env
901
- });
902
- if (plan.runCodegen) await runReactNativeCodegen({
903
- projectRoot: (0, _hot_updater_cli_tools.getCwd)(),
904
- reactNativePath: reactNative.packagePath,
905
- sourceDir
906
- });
907
- if (shouldUseBundler) {
908
- const bundleSpinner = _hot_updater_cli_tools.p.spinner();
909
- bundleSpinner.start("Installing Ruby gems");
910
- try {
911
- await (0, execa.execa)("bundle", ["install"], {
912
- cwd: sourceDir,
913
- all: true
914
- });
915
- } catch (error) {
916
- bundleSpinner.error("bundle install failed");
917
- throw commandFailure("bundle install", error);
918
- }
919
- bundleSpinner.stop("Ruby gems installed");
920
- }
921
- await runPodInstall({
922
- command: shouldUseBundler ? [
923
- "bundle",
924
- "exec",
925
- "pod",
926
- "install"
927
- ] : ["pod", "install"],
928
- env: plan.env,
929
- sourceDir
930
- });
931
- return {
932
- codegen: plan.runCodegen,
933
- pods: true
934
- };
935
- };
936
- const checkShouldUseBundler = async (gemfilePath) => {
937
- try {
938
- if (!fs.default.existsSync(gemfilePath)) return false;
939
- return (await fs.default.promises.readFile(gemfilePath, "utf-8")).includes("cocoapods");
940
- } catch {
941
- return false;
942
- }
943
- };
944
- /**
945
- * Runs React Native codegen into `<sourceDir>/build/generated/ios`, as the
946
- * React Native CLI does before `pod install`. Clearing the old output first
947
- * drops the modules of removed libraries.
948
- */
949
- const runReactNativeCodegen = async ({ projectRoot, reactNativePath, sourceDir }) => {
950
- await fs.default.promises.rm(path.default.join(sourceDir, "build", "generated", "ios"), {
951
- recursive: true,
952
- force: true
953
- });
954
- const spinner = _hot_updater_cli_tools.p.spinner();
955
- spinner.start("Running React Native codegen");
956
- try {
957
- await (0, execa.execa)("node", [
958
- path.default.join(reactNativePath, "scripts", "generate-codegen-artifacts.js"),
959
- "-p",
960
- projectRoot,
961
- "-o",
962
- sourceDir,
963
- "-t",
964
- "ios"
965
- ], {
966
- cwd: projectRoot,
967
- all: true
968
- });
969
- } catch (error) {
970
- spinner.error("React Native codegen failed");
971
- throw commandFailure("React Native codegen", error);
972
- }
973
- spinner.stop("React Native codegen done");
974
- };
975
- /**
976
- * The environment `pod install` runs with, and whether codegen runs first.
977
- *
978
- * From React Native 0.79, `RCT_IGNORE_PODS_DEPRECATION=1` silences the
979
- * deprecation notice for calling `pod install` directly and also sets
980
- * `RCT_SKIP_CODEGEN=1`, because the React Native CLI runs codegen itself
981
- * first. Hot Updater sets it the same way and runs codegen too, unless the
982
- * user's `RCT_SKIP_CODEGEN=1` takes codegen over. Before 0.79, and when the
983
- * version is unknown, CocoaPods runs codegen. A user's value of a flag wins.
984
- */
985
- const resolvePodInstallPlan = ({ version, env }) => {
986
- const podsSkipCodegen = version.major > 0 || version.minor >= 79;
987
- const usePrebuiltReactNative = version.major > 0 || version.minor >= 81;
988
- const ignorePodsDeprecation = env["RCT_IGNORE_PODS_DEPRECATION"] ?? (podsSkipCodegen ? "1" : void 0);
989
- return {
990
- runCodegen: podsSkipCodegen && ignorePodsDeprecation === "1" && env["RCT_SKIP_CODEGEN"] !== "1",
991
- env: {
992
- ...ignorePodsDeprecation !== void 0 && { RCT_IGNORE_PODS_DEPRECATION: ignorePodsDeprecation },
993
- RCT_USE_RN_DEP: env["RCT_USE_RN_DEP"] ?? (usePrebuiltReactNative ? "1" : "0"),
994
- RCT_USE_PREBUILT_RNCORE: env["RCT_USE_PREBUILT_RNCORE"] ?? (usePrebuiltReactNative ? "1" : "0")
995
- }
996
- };
997
- };
998
- //#endregion
999
786
  //#region src/utils/createRandomTmpDir.ts
1000
787
  const TERMINATION_SIGNALS$2 = [
1001
788
  "SIGHUP",
@@ -1048,844 +835,325 @@ const removeTmpDir = async (directory) => {
1048
835
  if (liveDirectories.delete(directory) && liveDirectories.size === 0) stopListening();
1049
836
  };
1050
837
  //#endregion
1051
- //#region src/utils/platform.ts
838
+ //#region src/utils/escapeUnzipPattern.ts
1052
839
  /**
1053
- * Platform configurations for different Apple platforms
840
+ * A zip entry name as `unzip` reads a literal member. It takes `*`, `?` and
841
+ * `[...]` in a member name as wildcards, which a backslash escapes, so an
842
+ * app named `My[Dev].app` would otherwise match nothing.
1054
843
  */
1055
- const platformConfigs = { ios: {
1056
- name: "ios",
1057
- deviceSdk: "iphoneos",
1058
- simulatorSdk: "iphonesimulator",
1059
- supportsSimulator: true,
1060
- deviceDestination: "platform=iOS",
1061
- simulatorDestination: "platform=iOS Simulator"
1062
- } };
844
+ const escapeUnzipPattern = (name) => name.replace(/[[\]*?\\]/gu, "\\$&");
1063
845
  //#endregion
1064
- //#region src/utils/destination.ts
1065
- const buildDestinationString = ({ deviceType, platform, useGeneric }) => `${useGeneric ? "generic/" : ""}${deviceType === "device" ? platformConfigs[platform].deviceDestination : platformConfigs[platform].simulatorDestination}`;
846
+ //#region src/utils/readIpaInfo.ts
847
+ const INFO_PLIST_KEYS = {
848
+ bundleId: "CFBundleIdentifier",
849
+ shortVersion: "CFBundleShortVersionString",
850
+ bundleVersion: "CFBundleVersion"
851
+ };
1066
852
  /**
1067
- * Resolves IosBuildDestination to xcodebuild destination format
853
+ * Runs `read` on a copy of the app's `Payload/<App>.app/Info.plist` out of an
854
+ * `.ipa`, never a framework's or an extension's, which nest deeper.
1068
855
  */
1069
- const resolveDestination = ({ destination, useGeneric }) => {
1070
- if (typeof destination === "object") {
1071
- if ("id" in destination) return `id=${destination.id}`;
1072
- if ("name" in destination) return `name=${destination.name}`;
1073
- }
1074
- switch (destination) {
1075
- case "ios-device": return buildDestinationString({
1076
- platform: "ios",
1077
- deviceType: "device",
1078
- useGeneric
1079
- });
1080
- case "ios-simulator": return buildDestinationString({
1081
- platform: "ios",
1082
- deviceType: "simulator",
1083
- useGeneric
856
+ const withIpaInfoPlist = async (ipaPath, read) => {
857
+ const { stdout } = await (0, execa.execa)("unzip", ["-Z1", ipaPath]);
858
+ const entry = stdout.split("\n").map((name) => name.trim()).find((name) => /^Payload\/[^/]+\.app\/Info\.plist$/u.test(name));
859
+ if (entry === void 0) throw new Error(`${path.default.basename(ipaPath)} has no Payload/<App>.app/Info.plist.`);
860
+ const directory = await createRandomTmpDir();
861
+ try {
862
+ const infoPlist = path.default.join(directory, "Info.plist");
863
+ await (0, execa.execa)("unzip", [
864
+ "-p",
865
+ ipaPath,
866
+ escapeUnzipPattern(entry)
867
+ ], { stdout: { file: infoPlist } });
868
+ return await read(infoPlist);
869
+ } finally {
870
+ await fs.default.promises.rm(directory, {
871
+ recursive: true,
872
+ force: true
1084
873
  });
1085
874
  }
1086
875
  };
1087
- const resolveDestinations = ({ destinations, useGeneric }) => {
1088
- return destinations.map((destination) => resolveDestination({
1089
- destination,
1090
- useGeneric
1091
- }));
1092
- };
1093
- const getDefaultDestination = ({ platform, useGeneric, deviceType }) => {
1094
- return buildDestinationString({
1095
- deviceType,
1096
- platform,
1097
- useGeneric
1098
- });
1099
- };
1100
- //#endregion
1101
- //#region src/utils/parseXcodeProjectInfo.ts
1102
- const parseXcodeProjectInfo = async (sourceDir) => {
1103
- const files = await fs.default.promises.readdir(sourceDir);
1104
- const workspace = files.find((file) => file.endsWith(".xcworkspace"));
1105
- if (workspace) return {
1106
- isWorkspace: true,
1107
- name: workspace,
1108
- path: path.default.join(sourceDir, workspace)
1109
- };
1110
- const project = files.find((file) => file.endsWith(".xcodeproj"));
1111
- if (project) return {
1112
- isWorkspace: false,
1113
- name: project,
1114
- path: path.default.join(sourceDir, project)
1115
- };
1116
- throw new Error(`No Xcode project or workspace found in ${sourceDir}`);
1117
- };
1118
- //#endregion
1119
- //#region src/utils/prettifyXcodebuildError.ts
1120
- const prettifyXcodebuildError = (error) => {
1121
- if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
1122
- const errorLines = error.summary.join("\n");
1123
- 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;
1124
- return new _hot_updater_cli_tools.BuildFailedError(hint ?? error.message, {
1125
- logPath: error.logPath,
1126
- summary: error.summary,
1127
- hint,
1128
- cause: error.cause
1129
- });
876
+ /** What App Store Connect files an `.ipa` under. */
877
+ const readIpaInfo = (ipaPath) => withIpaInfoPlist(ipaPath, async (infoPlist) => {
878
+ const info = {};
879
+ for (const [field, key] of Object.entries(INFO_PLIST_KEYS)) {
880
+ const { stdout: value, exitCode } = await (0, execa.execa)("plutil", [
881
+ "-extract",
882
+ key,
883
+ "raw",
884
+ "-o",
885
+ "-",
886
+ infoPlist
887
+ ], { reject: false });
888
+ if (exitCode !== 0 || !value.trim()) throw new Error(`${path.default.basename(ipaPath)}'s Info.plist has no ${key}.`);
889
+ info[field] = value.trim();
1130
890
  }
1131
- return /* @__PURE__ */ new Error(`Xcodebuild failed: ${error}`);
1132
- };
1133
- //#endregion
1134
- //#region src/utils/xcodebuildPhase.ts
1135
- /** The `(in target 'X' from project 'Y')` that ends an action's line. */
1136
- const TARGET = /\(in target '([^']+)' from project '[^']+'\)\s*$/;
1137
- /** A `PhaseScriptExecution` line's script phase, its spaces escaped. */
1138
- const SCRIPT = /^PhaseScriptExecution ((?:\\.|[^\s\\])+)/;
1139
- /** A batched `SwiftCompile`'s files: `Compiling\ A.swift,\ B.swift`. */
1140
- const SWIFT_BATCH = /^SwiftCompile \S+ \S+ Compiling\\ ((?:\\.|[^\s\\])+)/;
1141
- const unescape = (value) => value.replace(/\\(.)/g, "$1");
1142
- /** Actions whose lines say what xcodebuild is doing, by their first word. */
1143
- const ACTIONS = {
1144
- CompileAssetCatalog: "Compiling assets",
1145
- CompileAssetCatalogVariant: "Compiling assets",
1146
- LinkAssetCatalog: "Compiling assets",
1147
- CompileStoryboard: "Compiling storyboards",
1148
- CompileXIB: "Compiling storyboards",
1149
- LinkStoryboards: "Compiling storyboards",
1150
- Ld: "Linking",
1151
- Libtool: "Linking",
1152
- CodeSign: "Signing",
1153
- ProcessInfoPlistFile: "Processing",
1154
- ProcessProductPackaging: "Processing",
1155
- ProcessProductPackagingDER: "Processing",
1156
- CopySwiftLibs: "Copying",
1157
- CpResource: "Copying",
1158
- GenerateDSYMFile: "Generating dSYM",
1159
- Strip: "Stripping",
1160
- Validate: "Validating"
1161
- };
1162
- const COMPILES = /* @__PURE__ */ new Set([
1163
- "CompileC",
1164
- "SwiftCompile",
1165
- "CompileSwift"
1166
- ]);
1167
- const PRECOMPILES = /* @__PURE__ */ new Set([
1168
- "PrecompileModule",
1169
- "ScanDependencies",
1170
- "SwiftExplicitDependencyGeneratePcm",
1171
- "SwiftExplicitDependencyCompileModuleFromInterface"
1172
- ]);
1173
- const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
891
+ return info;
892
+ });
893
+ /** Whether Xcode marked an `.ipa`'s app for TestFlight internal testing only. */
894
+ const readIpaInternalTestingOnly = (ipaPath) => withIpaInfoPlist(ipaPath, async (infoPlist) => {
895
+ const { stdout: value, exitCode } = await (0, execa.execa)("plutil", [
896
+ "-extract",
897
+ "TFInternalTestingOnly",
898
+ "raw",
899
+ "-o",
900
+ "-",
901
+ infoPlist
902
+ ], { reject: false });
903
+ return exitCode === 0 && value.trim() === "true";
904
+ });
1174
905
  /**
1175
- * Reads the phase of an `xcodebuild` build, archive, or export from its raw
1176
- * output: `Compiling React-Core · 412 files`, `Linking CodexRelay`,
1177
- * `Signing CodexRelay`, `Bundling JavaScript`, with the target each
1178
- * action's line names.
906
+ * Whether an `.ipa`'s app answers export compliance in its Info.plist. Either
907
+ * value is an answer, so this asks only whether the key is there.
1179
908
  */
1180
- const createXcodebuildPhaseReader = () => {
1181
- let compiled = 0;
1182
- let precompiled = 0;
1183
- return (line) => {
1184
- const space = line.indexOf(" ");
1185
- const action = space === -1 ? line : line.slice(0, space);
1186
- const target = TARGET.exec(line)?.[1];
1187
- const withTarget = (phase) => target ? `${phase} ${target}` : phase;
1188
- if (COMPILES.has(action)) {
1189
- const batch = SWIFT_BATCH.exec(line)?.[1];
1190
- compiled += batch ? unescape(batch).split(",").length : 1;
1191
- return `${withTarget("Compiling")} · ${plural(compiled, "file")}`;
1192
- }
1193
- if (PRECOMPILES.has(action)) {
1194
- precompiled += 1;
1195
- return `Precompiling modules · ${precompiled}`;
1196
- }
1197
- if (action === "PhaseScriptExecution") {
1198
- const script = unescape(SCRIPT.exec(line)?.[1] ?? "");
1199
- if (script === "Bundle React Native code and images") return "Bundling JavaScript";
1200
- return script ? `Running ${script}` : withTarget("Running a script");
1201
- }
1202
- const phase = ACTIONS[action];
1203
- if (phase) return withTarget(phase);
1204
- if (line === "Prepare packages" || line.startsWith("Resolve Package")) return "Resolving packages";
1205
- if (line === "ComputeTargetDependencyGraph") return "Planning the build";
1206
- };
1207
- };
1208
- //#endregion
1209
- //#region src/utils/createXcodebuildLogger.ts
1210
- const createXcodebuildLogger = ({ logPrefix, logFilePath }) => new _hot_updater_cli_tools.BuildLogger({
1211
- logPrefix,
1212
- logFilePath,
1213
- createPhaseReader: createXcodebuildPhaseReader,
1214
- failureSummary: {
1215
- lines: [/(^|:\s)error: /, /^\*\* [A-Z ]+ FAILED \*\*$/],
1216
- blocks: [{
1217
- start: "The following build commands failed:",
1218
- end: /^\(\d+ failures?\)$/,
1219
- includeEnd: true
1220
- }, {
1221
- start: /^error: exportArchive /,
1222
- end: /^$/,
1223
- includeEnd: false
1224
- }]
1225
- }
909
+ const readIpaHasExportComplianceKey = (ipaPath) => withIpaInfoPlist(ipaPath, async (infoPlist) => {
910
+ const { exitCode } = await (0, execa.execa)("plutil", [
911
+ "-extract",
912
+ "ITSAppUsesNonExemptEncryption",
913
+ "raw",
914
+ "-o",
915
+ "-",
916
+ infoPlist
917
+ ], { reject: false });
918
+ return exitCode === 0;
1226
919
  });
1227
920
  //#endregion
1228
- //#region src/utils/runXcodebuildWithLogging.ts
1229
- const TERMINATION_SIGNALS$1 = ["SIGINT", "SIGTERM"];
1230
- const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
1231
- return (0, execa.execa)("xcodebuild", args, {
1232
- cwd: sourceDir,
1233
- all: true,
1234
- buffer: false,
1235
- env: {
1236
- ...process.env,
1237
- ...env,
1238
- NSUnbufferedIO: "YES"
1239
- }
921
+ //#region src/utils/xcodeSigning.ts
922
+ /**
923
+ * How a device build signs with automatic signing, authenticated by the
924
+ * scheme's App Store Connect API key: the xcodebuild arguments, and the key.
925
+ * The key is resolved only here, for a device build of a scheme with
926
+ * `signing`. Without `credentials`, it resolves from the environment or the
927
+ * config's `secrets` adapter, and the temporary files are deleted when the
928
+ * process exits.
929
+ */
930
+ const resolveXcodeSigning = async ({ schemeConfig, credentials }) => {
931
+ const reference = schemeConfig.signing?.ascApiKey;
932
+ if (!reference) return;
933
+ const apiKey = await (credentials ?? (0, _hot_updater_cli_tools.createCredentialResolver)()).resolve({
934
+ kind: "asc-api-key",
935
+ name: (0, _hot_updater_cli_tools.parseSecretReference)(reference, "signing.ascApiKey")
1240
936
  });
937
+ return {
938
+ authenticationArgs: [
939
+ "-allowProvisioningUpdates",
940
+ "-authenticationKeyPath",
941
+ apiKey.keyPath,
942
+ "-authenticationKeyID",
943
+ apiKey.keyId,
944
+ "-authenticationKeyIssuerID",
945
+ apiKey.issuerId
946
+ ],
947
+ apiKey
948
+ };
1241
949
  };
1242
- /** Why xcodebuild failed, without its command line. */
1243
- const describeFailure = (error) => {
1244
- if (error instanceof execa.ExecaError) {
1245
- if (error.signal) return `xcodebuild was stopped by ${error.signal}.`;
1246
- if (error.exitCode !== void 0) return `xcodebuild exited with code ${error.exitCode}.`;
950
+ const APP_STORE_CONNECT_EXPORT_OPTIONS = `<?xml version="1.0" encoding="UTF-8"?>
951
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
952
+ <plist version="1.0">
953
+ <dict>
954
+ <key>method</key>
955
+ <string>app-store-connect</string>
956
+ <key>signingStyle</key>
957
+ <string>automatic</string>
958
+ </dict>
959
+ </plist>
960
+ `;
961
+ /** The export option that keeps a build from App Store review. */
962
+ const INTERNAL_TESTING_ONLY = "testFlightInternalTestingOnly";
963
+ /**
964
+ * Writes the export options of a scheme that signs with an App Store Connect
965
+ * API key and sets no `exportOptionsPlist`: an App Store Connect export with
966
+ * automatic signing, internal testing only for a sandbox build.
967
+ */
968
+ const writeAppStoreConnectExportOptions = async (directory, { internalTestingOnly = false } = {}) => {
969
+ const exportOptionsPlist = path.default.join(directory, "ExportOptions.plist");
970
+ await fs.default.promises.writeFile(exportOptionsPlist, internalTestingOnly ? APP_STORE_CONNECT_EXPORT_OPTIONS.replace("</dict>", ` <key>${INTERNAL_TESTING_ONLY}</key>\n <true/>\n</dict>`) : APP_STORE_CONNECT_EXPORT_OPTIONS);
971
+ return exportOptionsPlist;
972
+ };
973
+ /**
974
+ * `testFlightInternalTestingOnly` in export options: undefined without it.
975
+ */
976
+ const readInternalTestingOnly = async (exportOptionsPlist) => {
977
+ const uncommented = (await fs.default.promises.readFile(exportOptionsPlist, "utf-8").catch(() => void 0))?.replace(/<!--[\s\S]*?-->/gu, "");
978
+ if (uncommented?.includes("<plist")) {
979
+ const match = new RegExp(`<key>\\s*${INTERNAL_TESTING_ONLY}\\s*</key>\\s*<(true|false)\\s*/>`, "u").exec(uncommented);
980
+ return match ? match[1] === "true" : void 0;
1247
981
  }
1248
- return error instanceof Error ? error.message : String(error);
982
+ const { stdout, exitCode } = await (0, execa.execa)("plutil", [
983
+ "-extract",
984
+ INTERNAL_TESTING_ONLY,
985
+ "raw",
986
+ "-o",
987
+ "-",
988
+ exportOptionsPlist
989
+ ], { reject: false });
990
+ return exitCode === 0 ? stdout.trim() === "true" : void 0;
1249
991
  };
1250
992
  /**
1251
- * Runs xcodebuild as a step titled `title`: its live line shows the build's
1252
- * phase, the log file keeps the command and the whole output, and a failure
1253
- * prints the output's errors and the log's path on stderr.
993
+ * Refuses a sandbox scheme's App Store Connect export that turns internal
994
+ * testing off: Apple keeps a build out of review only with it on. Another
995
+ * method's `.ipa` never reaches App Store Connect.
1254
996
  */
1255
- const runXcodebuildWithLogging = async ({ args, env, sourceDir, title, successMessage, failureMessage, logPrefix, logFilePath, header = [] }) => {
1256
- const logger = createXcodebuildLogger({
1257
- logPrefix,
1258
- logFilePath
1259
- });
1260
- await logger.start({
1261
- title,
1262
- header: [
1263
- ...header,
1264
- `$ ${formatCommand(["xcodebuild", ...args])}`,
1265
- ""
1266
- ]
1267
- });
1268
- const activeProcesses = [];
1269
- const terminateActiveProcesses = () => {
1270
- for (const activeProcess of activeProcesses) activeProcess.kill();
1271
- };
1272
- for (const signal of TERMINATION_SIGNALS$1) process.once(signal, terminateActiveProcesses);
1273
- try {
1274
- const xcodebuildProcess = createRawXcodebuildProcess({
1275
- args,
1276
- env,
1277
- sourceDir
1278
- });
1279
- activeProcesses.push(xcodebuildProcess);
1280
- const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
1281
- const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput) : Promise.resolve();
1282
- const [xcodebuildResult, loggingResult] = await Promise.allSettled([xcodebuildProcess, logging]);
1283
- if (xcodebuildResult.status === "rejected") throw xcodebuildResult.reason;
1284
- if (loggingResult.status === "rejected") throw loggingResult.reason;
1285
- logger.stop(successMessage);
1286
- return { logFilePath: logger.logFilePath };
1287
- } catch (error) {
1288
- logger.writeError(error);
1289
- const reason = describeFailure(error);
1290
- logger.stop(`${failureMessage} · ${reason.replace(/\.$/, "")}`, false);
1291
- throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${reason}`, {
1292
- logPath: logger.logPath,
1293
- summary: logger.failureSummary,
1294
- cause: error
1295
- });
1296
- } finally {
1297
- for (const signal of TERMINATION_SIGNALS$1) process.off(signal, terminateActiveProcesses);
1298
- await logger.close();
1299
- }
997
+ const assertSandboxExportOptions = async (schemeName, exportOptionsPlist) => {
998
+ if (APP_STORE_EXPORT_METHODS.has(await readExportMethod(exportOptionsPlist)) && await readInternalTestingOnly(exportOptionsPlist) === false) throw new Error(`The sandbox scheme ${schemeName} turns ${INTERNAL_TESTING_ONLY} off in exportOptionsPlist.\nRemove the key, or set it to true.`);
1300
999
  };
1301
- //#endregion
1302
- //#region src/utils/writeBuildXcconfig.ts
1303
- const SDK_CONDITIONS = [
1304
- "",
1305
- "[sdk=iphoneos*]",
1306
- "[sdk=iphonesimulator*]"
1307
- ];
1308
1000
  /**
1309
- * Finds a setting of `keys` that an included xcconfig sets with a condition
1310
- * the build xcconfig does not, such as `[config=Release]` or `[arch=arm64]`,
1311
- * which would outrank the build's own. It follows `#include`s, which are
1312
- * relative to the including file.
1001
+ * The export options a sandbox scheme's `exportOptionsPlist` exports with:
1002
+ * the file itself, or a copy in `directory` that turns internal testing on
1003
+ * when an App Store Connect export leaves it out.
1313
1004
  */
1314
- const findConditionalSetting = async (files, keys, visited = /* @__PURE__ */ new Set()) => {
1315
- for (const file of files) {
1316
- if (visited.has(file) || !fs.default.existsSync(file)) continue;
1317
- visited.add(file);
1318
- const lines = (await fs.default.promises.readFile(file, "utf-8")).split("\n");
1319
- for (const [index, line] of lines.entries()) {
1320
- const include = line.match(/^\s*#include\??\s*"([^"]+)"/)?.[1];
1321
- if (include) {
1322
- const found = await findConditionalSetting([path.default.resolve(path.default.dirname(file), include)], keys, visited);
1323
- if (found) return found;
1324
- continue;
1325
- }
1326
- const [, key, conditions] = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)((?:\[[^\]]*\])+)\s*=/) ?? [];
1327
- if (key && keys.includes(key) && !SDK_CONDITIONS.includes(conditions)) return `${file}:${index + 1} sets ${key}${conditions}`;
1328
- }
1329
- }
1005
+ const resolveSandboxExportOptions = async (schemeName, exportOptionsPlist, directory) => {
1006
+ await assertSandboxExportOptions(schemeName, exportOptionsPlist);
1007
+ if (!APP_STORE_EXPORT_METHODS.has(await readExportMethod(exportOptionsPlist)) || await readInternalTestingOnly(exportOptionsPlist) === true) return exportOptionsPlist;
1008
+ const copy = path.default.join(directory, "ExportOptions.plist");
1009
+ await fs.default.promises.copyFile(exportOptionsPlist, copy);
1010
+ await (0, execa.execa)("plutil", [
1011
+ "-insert",
1012
+ INTERNAL_TESTING_ONLY,
1013
+ "-bool",
1014
+ "true",
1015
+ copy
1016
+ ]);
1017
+ return copy;
1330
1018
  };
1331
- /**
1332
- * Writes the xcconfig a build loads, which sets the CLI's build settings
1333
- * over every other. xcodebuild ranks an `-xcconfig`'s settings above the
1334
- * command line's, so the scheme's `xcconfig` and each `-xcconfig` in
1335
- * `extraParams` are included, in order, before `settings`, and the build
1336
- * loads only this one.
1337
- */
1338
- const writeBuildXcconfig = async ({ directory, sourceDir, xcconfig, extraParams = [], settings }) => {
1339
- const included = xcconfig ? [xcconfig] : [];
1340
- const otherParams = [];
1341
- for (let i = 0; i < extraParams.length; i++) if (extraParams[i] === "-xcconfig" && i + 1 < extraParams.length) included.push(extraParams[++i]);
1342
- else otherParams.push(extraParams[i]);
1343
- const includedFiles = included.map((file) => path.default.resolve(sourceDir, file));
1344
- const conditional = await findConditionalSetting(includedFiles, Object.keys(settings));
1345
- if (conditional) throw new Error(`${conditional}, which would outrank the value the build sets. Set it without that condition, or remove it.`);
1346
- const buildXcconfig = path.default.join(directory, "HotUpdater.xcconfig");
1347
- await fs.default.promises.writeFile(buildXcconfig, [
1348
- ...includedFiles.map((file) => `#include "${file}"`),
1349
- ...Object.entries(settings).flatMap(([key, value]) => SDK_CONDITIONS.map((condition) => `${key}${condition} = ${value}`)),
1350
- ""
1351
- ].join("\n"));
1352
- return {
1353
- xcconfig: buildXcconfig,
1354
- extraParams: otherParams
1355
- };
1019
+ /** The export methods whose `.ipa` App Store Connect takes. */
1020
+ const APP_STORE_EXPORT_METHODS = /* @__PURE__ */ new Set(["app-store", "app-store-connect"]);
1021
+ /** The `method` of export options, or "" when it has none or can't be read. */
1022
+ const readExportMethod = async (exportOptionsPlist) => {
1023
+ const xml = (await fs.default.promises.readFile(exportOptionsPlist, "utf-8").catch(() => void 0))?.replace(/<!--[\s\S]*?-->/gu, "").match(/<key>\s*method\s*<\/key>\s*<string>\s*([^<]*?)\s*<\/string>/u);
1024
+ if (xml) return xml[1];
1025
+ const { stdout, exitCode } = await (0, execa.execa)("plutil", [
1026
+ "-extract",
1027
+ "method",
1028
+ "raw",
1029
+ "-o",
1030
+ "-",
1031
+ exportOptionsPlist
1032
+ ], { reject: false });
1033
+ return exitCode === 0 ? stdout.trim() : "";
1356
1034
  };
1357
1035
  /**
1358
- * Automatic signing over a target set to manual signing, whose provisioning
1359
- * profile and identity Xcode would otherwise report as conflicting.
1036
+ * Whether a device build exports for the devices its profile lists: with
1037
+ * `release-testing`, or `ad-hoc`, Xcode's older name for it. On an empty
1038
+ * keychain with the API key, Apple's cloud-managed distribution certificate
1039
+ * signs it, as it does an App Store Connect export.
1360
1040
  */
1361
- const AUTOMATIC_SIGNING = {
1362
- CODE_SIGN_STYLE: "Automatic",
1363
- PROVISIONING_PROFILE: "",
1364
- PROVISIONING_PROFILE_SPECIFIER: "",
1365
- CODE_SIGN_IDENTITY: "Apple Development"
1366
- };
1041
+ const exportsForRegisteredDevices = async ({ exportOptionsPlist }) => exportOptionsPlist !== void 0 && ["release-testing", "ad-hoc"].includes(await readExportMethod(exportOptionsPlist));
1367
1042
  /**
1368
- * The settings a build sets over the scheme's: the MIN_BUNDLE_ID the CLI
1369
- * issues and reports, and automatic signing when an App Store Connect API
1370
- * key signs, which a target set to manual signing would not use.
1043
+ * Whether a device build exports for App Store Connect: with its
1044
+ * `exportOptionsPlist`'s method, or, without one, as a scheme signing with
1045
+ * an App Store Connect API key does. Such an `.ipa` is signed with Apple's
1046
+ * cloud-managed distribution certificate, never with a development
1047
+ * certificate the archive was signed with.
1371
1048
  */
1372
- const buildSettingsOverScheme = ({ minBundleId, authenticationArgs }) => ({
1373
- HOT_UPDATER_MIN_BUNDLE_ID: minBundleId,
1374
- ...authenticationArgs ? AUTOMATIC_SIGNING : {}
1375
- });
1049
+ const exportsForAppStoreConnect = async (schemeConfig) => !schemeConfig.exportOptionsPlist || APP_STORE_EXPORT_METHODS.has(await readExportMethod(schemeConfig.exportOptionsPlist));
1376
1050
  //#endregion
1377
- //#region src/builder/archiveXcodeProject.ts
1051
+ //#region src/uploadToTestFlight.ts
1052
+ const API_URL$2 = "https://api.appstoreconnect.apple.com";
1053
+ /** Apple accepts tokens that expire within 20 minutes. */
1054
+ const TOKEN_LIFETIME_SECONDS = 1200;
1055
+ /** A token is replaced well before it expires, so no request carries a stale one. */
1056
+ const TOKEN_REUSE_SECONDS = 600;
1057
+ /** The parts of an `.ipa` sent at once. */
1058
+ const UPLOAD_CONCURRENCY = 4;
1059
+ /** Attempts to create the build upload and to commit the uploaded `.ipa`. */
1060
+ const COMMIT_ATTEMPTS = 3;
1061
+ /** Attempts per part: a part is a plain PUT, safe to repeat. */
1062
+ const UPLOAD_ATTEMPTS = 3;
1063
+ const IN_PROGRESS_STATES = /* @__PURE__ */ new Set(["AWAITING_UPLOAD", "PROCESSING"]);
1064
+ /** The error App Store Connect answers with when a build number is taken. */
1065
+ const DUPLICATE_ERROR_CODE = "ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE";
1378
1066
  /**
1379
- * Archives the scheme into `archiveDirectory`. The build's xcconfig is
1380
- * written to a temporary directory, removed whether the archive succeeds or
1381
- * not.
1067
+ * App Store Connect refused the upload: it already has a build with the
1068
+ * `.ipa`'s version and build number, from this or another upload, and takes
1069
+ * each build number once per version.
1382
1070
  */
1383
- const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, header = [], minBundleId, authenticationArgs }) => {
1384
- if (installPods) await installPodsIfNeeded(sourceDir);
1385
- const xcodeProject = await parseXcodeProjectInfo(sourceDir);
1386
- const archiveName = `${xcodeProject.name.replace(".xcworkspace", "").replace(".xcodeproj", "")}.xcarchive`;
1387
- const archivePath = path.default.join(archiveDirectory, archiveName);
1388
- const tmpDir = await createRandomTmpDir();
1389
- try {
1390
- const build = await writeBuildXcconfig({
1391
- directory: tmpDir,
1392
- sourceDir,
1393
- xcconfig,
1394
- extraParams,
1395
- settings: buildSettingsOverScheme({
1396
- minBundleId,
1397
- authenticationArgs
1398
- })
1399
- });
1400
- const archiveArgs = prepareArchiveArgs({
1401
- authenticationArgs,
1402
- minBundleId,
1403
- archivePath,
1404
- platform,
1405
- sourceDir,
1406
- xcodeProject,
1407
- configuration,
1408
- extraParams: build.extraParams,
1409
- destination,
1410
- xcconfig: build.xcconfig,
1411
- xcodeScheme
1412
- });
1413
- try {
1414
- const { logFilePath } = await runXcodebuildWithLogging({
1415
- args: archiveArgs,
1416
- env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
1417
- title: `Archiving ${xcodeScheme}`,
1418
- successMessage: `Archived ${xcodeScheme}`,
1419
- failureMessage: "Archive failed",
1420
- logPrefix,
1421
- header: [
1422
- ...header,
1423
- `Project ${xcodeProject.name}`,
1424
- `Scheme ${xcodeScheme}`,
1425
- `Platform ${platform}`
1426
- ],
1427
- sourceDir
1428
- });
1429
- return {
1430
- archivePath,
1431
- logFilePath
1432
- };
1433
- } catch (error) {
1434
- throw prettifyXcodebuildError(error);
1435
- }
1436
- } finally {
1437
- await removeTmpDir(tmpDir);
1071
+ var TestFlightBuildExistsError = class extends Error {
1072
+ shortVersion;
1073
+ bundleVersion;
1074
+ name = "TestFlightBuildExistsError";
1075
+ constructor(shortVersion, bundleVersion, options) {
1076
+ super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}.\nUse a new build number (CFBundleVersion).`, options);
1077
+ this.shortVersion = shortVersion;
1078
+ this.bundleVersion = bundleVersion;
1438
1079
  }
1439
1080
  };
1440
- const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId, authenticationArgs }) => {
1441
- const args = [
1442
- xcodeProject.isWorkspace ? "-workspace" : "-project",
1443
- path.default.join(sourceDir, xcodeProject.name),
1444
- "-scheme",
1445
- xcodeScheme,
1446
- "-configuration",
1447
- configuration,
1448
- "archive",
1449
- "-archivePath",
1450
- archivePath,
1451
- `HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`
1452
- ];
1453
- if (authenticationArgs) args.push(...authenticationArgs);
1454
- if (xcconfig) args.push("-xcconfig", xcconfig);
1455
- if (extraParams) args.push(...extraParams);
1456
- const resolvedDestinations = resolveDestinations({
1457
- destinations: destination,
1458
- useGeneric: true
1459
- });
1460
- if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
1461
- deviceType: "device",
1462
- platform,
1463
- useGeneric: true
1464
- }));
1465
- for (const destination of resolvedDestinations) args.push("-destination", destination);
1466
- return args;
1081
+ /** An App Store Connect API request that failed, with the errors it answered. */
1082
+ var AppStoreConnectError = class extends Error {
1083
+ status;
1084
+ errors;
1085
+ /** The answer's `Retry-After` header, for a rate limit. */
1086
+ retryAfter;
1087
+ constructor(action, status, errors, retryAfter = null) {
1088
+ const details = errors.map((error) => error.detail ?? error.title ?? error.code).filter(Boolean);
1089
+ super(`${action} failed (HTTP ${status})${details.length > 0 ? `: ${details.join(" ")}` : "."}`);
1090
+ this.name = "AppStoreConnectError";
1091
+ this.status = status;
1092
+ this.errors = errors;
1093
+ this.retryAfter = retryAfter;
1094
+ }
1467
1095
  };
1468
- //#endregion
1469
- //#region src/builder/buildXcodeProject.ts
1470
1096
  /**
1471
- * Builds the scheme in a temporary DerivedData directory, copies the built
1472
- * `.app` to `outputDirectory`, and removes DerivedData, which takes 10 GB or
1473
- * more, whether the build succeeds or not.
1097
+ * An App Store Connect API token: an ES256 JWT, whose signature JWS encodes
1098
+ * as the raw 64-byte r || s, not the DER Node signs by default.
1474
1099
  */
1475
- const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams, minBundleId, authenticationArgs, header = [] }) => {
1476
- if (installPods) await installPodsIfNeeded(sourceDir);
1477
- const xcodeProject = await parseXcodeProjectInfo(sourceDir);
1478
- const tmpDir = await createRandomTmpDir();
1479
- try {
1480
- const derivedDataPath = path.default.join(tmpDir, "DerivedData");
1481
- const resolvedDestinations = resolveDestinations({
1482
- destinations: destination,
1483
- useGeneric: useGenericDestination
1484
- });
1485
- if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
1486
- deviceType,
1487
- platform,
1488
- useGeneric: useGenericDestination
1489
- }));
1490
- const build = await writeBuildXcconfig({
1491
- directory: tmpDir,
1492
- sourceDir,
1493
- extraParams,
1494
- settings: buildSettingsOverScheme({
1495
- minBundleId,
1496
- authenticationArgs
1497
- })
1498
- });
1499
- const buildArgs = prepareBuildArgs({
1500
- authenticationArgs,
1501
- xcconfig: build.xcconfig,
1502
- minBundleId,
1503
- configuration,
1504
- derivedDataPath,
1505
- deviceType,
1506
- resolvedDestinations,
1507
- extraParams: build.extraParams,
1508
- platform,
1509
- xcodeScheme,
1510
- sourceDir,
1511
- xcodeProject
1512
- });
1513
- let builtAppPath;
1514
- let logFilePath;
1515
- try {
1516
- ({logFilePath} = await runXcodebuildWithLogging({
1517
- args: buildArgs,
1518
- env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
1519
- sourceDir,
1520
- logPrefix,
1521
- header: [
1522
- ...header,
1523
- `Project ${xcodeProject.name}`,
1524
- `Scheme ${xcodeScheme}`,
1525
- `Configuration ${configuration}`,
1526
- `Platform ${platform}`,
1527
- `Device type ${deviceType}`
1528
- ],
1529
- title: `Building ${xcodeScheme}`,
1530
- successMessage: `Built ${xcodeScheme}`,
1531
- failureMessage: "Build failed"
1532
- }));
1533
- ({appPath: builtAppPath} = await getBuildSettings({
1534
- configuration,
1535
- derivedDataPath,
1536
- resolvedDestinations,
1537
- xcodeScheme,
1538
- sourceDir,
1539
- xcodeProject
1540
- }));
1541
- } catch (error) {
1542
- throw prettifyXcodebuildError(error);
1543
- }
1544
- const appPath = path.default.join(outputDirectory, path.default.basename(builtAppPath));
1545
- await fs.default.promises.cp(builtAppPath, appPath, { recursive: true });
1546
- return {
1547
- appPath,
1548
- logFilePath
1549
- };
1550
- } finally {
1551
- await removeTmpDir(tmpDir);
1552
- }
1553
- };
1554
- const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId, authenticationArgs, xcconfig }) => {
1555
- const sdk = deviceType === "simulator" ? platformConfigs[platform].simulatorSdk : platformConfigs[platform].deviceSdk;
1556
- const args = [
1557
- xcodeProject.isWorkspace ? "-workspace" : "-project",
1558
- path.default.join(sourceDir, xcodeProject.name),
1559
- "-scheme",
1560
- xcodeScheme,
1561
- "-configuration",
1562
- configuration,
1563
- "-sdk",
1564
- sdk,
1565
- "-derivedDataPath",
1566
- derivedDataPath,
1567
- `HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`,
1568
- "build"
1569
- ];
1570
- if (authenticationArgs) args.push(...authenticationArgs);
1571
- args.push("-xcconfig", xcconfig);
1572
- if (extraParams) args.push(...extraParams);
1573
- for (const dest of resolvedDestinations) args.push("-destination", dest);
1574
- return args;
1575
- };
1576
- const getBuildSettings = async ({ xcodeProject, sourceDir, xcodeScheme, configuration, resolvedDestinations, derivedDataPath }) => {
1577
- const destinationArgs = resolvedDestinations.flatMap((dest) => ["-destination", dest]);
1578
- const { stdout: buildSettings } = await (0, execa.execa)("xcodebuild", [
1579
- xcodeProject.isWorkspace ? "-workspace" : "-project",
1580
- xcodeProject.name,
1581
- "-scheme",
1582
- xcodeScheme,
1583
- "-configuration",
1584
- configuration,
1585
- ...destinationArgs,
1586
- "-derivedDataPath",
1587
- derivedDataPath,
1588
- "-showBuildSettings",
1589
- "-json"
1590
- ], { cwd: sourceDir });
1591
- const settings = JSON.parse(buildSettings).filter(({ target }) => target !== "React" && target !== "React-Core");
1592
- if (settings.length === 0) throw new Error("Failed to get build settings for your project");
1593
- const targetSettings = settings[0].buildSettings;
1594
- const wrapperExtension = targetSettings.WRAPPER_EXTENSION;
1595
- if (wrapperExtension !== "app" && wrapperExtension !== "framework") throw new Error(`Expected wrapper extension to be "app" or "framework" but found: ${wrapperExtension}`);
1596
- const targetBuildDir = targetSettings.TARGET_BUILD_DIR;
1597
- const executableFolderPath = targetSettings.EXECUTABLE_FOLDER_PATH;
1598
- const fullProductName = targetSettings.FULL_PRODUCT_NAME;
1599
- const infoPlistPath = targetSettings.INFOPLIST_PATH;
1600
- if (!targetBuildDir || !executableFolderPath || !fullProductName) throw new Error("Failed to get build paths from build settings");
1601
- return {
1602
- appPath: path.default.join(targetBuildDir, executableFolderPath),
1603
- infoPlistPath: path.default.join(targetBuildDir, infoPlistPath)
1604
- };
1605
- };
1606
- //#endregion
1607
- //#region src/builder/exportXcodeArchive.ts
1608
- /** Exports the archive into `exportPath`. */
1609
- const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, logFilePath, title, authenticationArgs }) => {
1610
- const exportArgs = prepareExportArgs({
1611
- authenticationArgs,
1612
- archivePath,
1613
- exportPath,
1614
- exportOptionsPlist,
1615
- exportExtraParams
1616
- });
1617
- try {
1618
- await runXcodebuildWithLogging({
1619
- args: exportArgs,
1620
- sourceDir,
1621
- title: `Exporting ${title}`,
1622
- successMessage: `Exported ${title}`,
1623
- failureMessage: "Export failed",
1624
- logPrefix,
1625
- logFilePath
1626
- });
1627
- return { exportPath };
1628
- } catch (error) {
1629
- throw prettifyXcodebuildError(error);
1630
- }
1631
- };
1632
- const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist, authenticationArgs }) => {
1633
- const args = [
1634
- "-exportArchive",
1635
- "-archivePath",
1636
- archivePath,
1637
- "-exportPath",
1638
- exportPath
1639
- ];
1640
- if (authenticationArgs) args.push(...authenticationArgs);
1641
- if (exportExtraParams) args.push(...exportExtraParams);
1642
- args.push("-exportOptionsPlist", exportOptionsPlist);
1643
- return args;
1100
+ const createAppStoreConnectToken = ({ privateKey, keyId, issuerId, issuedAtSeconds = Math.floor(Date.now() / 1e3) }) => {
1101
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
1102
+ const signingInput = `${encode({
1103
+ alg: "ES256",
1104
+ kid: keyId,
1105
+ typ: "JWT"
1106
+ })}.${encode({
1107
+ iss: issuerId,
1108
+ iat: issuedAtSeconds,
1109
+ exp: issuedAtSeconds + TOKEN_LIFETIME_SECONDS,
1110
+ aud: "appstoreconnect-v1"
1111
+ })}`;
1112
+ return `${signingInput}.${crypto.default.sign("sha256", Buffer.from(signingInput), {
1113
+ key: privateKey,
1114
+ dsaEncoding: "ieee-p1363"
1115
+ }).toString("base64url")}`;
1644
1116
  };
1645
- //#endregion
1646
- //#region src/utils/assertXcodebuildExist.ts
1647
- const assertXcodebuildExist = async () => {
1117
+ const readPrivateKey = async (keyPath) => {
1648
1118
  try {
1649
- await (0, execa.execa)("which", ["xcodebuild"]);
1119
+ return crypto.default.createPrivateKey(await fs.default.promises.readFile(keyPath, "utf-8"));
1650
1120
  } catch {
1651
- _hot_updater_cli_tools.p.log.error(`${_hot_updater_cli_tools.colors.blueBright("xcodebuild")} resolve failed. Ensure that xcode is installed on your machine or xcodebuild is included in your path`);
1652
- process.exit(1);
1121
+ throw new Error("The App Store Connect API key is not a .p8 private key.");
1653
1122
  }
1654
1123
  };
1655
- //#endregion
1656
- //#region src/utils/escapeUnzipPattern.ts
1124
+ const readApiErrors = async (response) => {
1125
+ const body = await response.json().catch(() => void 0);
1126
+ return Array.isArray(body?.errors) ? body.errors : [];
1127
+ };
1128
+ const describeDetails = (details) => details.map((detail) => `${detail.description ?? "(no description)"}${detail.code ? ` (${detail.code})` : ""}`);
1129
+ /** Answers a request may outlive: a timeout, a rate limit, a server error. */
1130
+ const isRetryableStatus = (status) => status === 408 || status === 429 || status >= 500;
1657
1131
  /**
1658
- * A zip entry name as `unzip` reads a literal member. It takes `*`, `?` and
1659
- * `[...]` in a member name as wildcards, which a backslash escapes, so an
1660
- * app named `My[Dev].app` would otherwise match nothing.
1132
+ * Whether a request may succeed when sent again: a retryable answer, or a
1133
+ * network failure, which fetch rejects with a TypeError. A refused key or
1134
+ * request is not.
1661
1135
  */
1662
- const escapeUnzipPattern = (name) => name.replace(/[[\]*?\\]/gu, "\\$&");
1663
- //#endregion
1664
- //#region src/utils/readIpaInfo.ts
1665
- const INFO_PLIST_KEYS = {
1666
- bundleId: "CFBundleIdentifier",
1667
- shortVersion: "CFBundleShortVersionString",
1668
- bundleVersion: "CFBundleVersion"
1669
- };
1136
+ const isRetryable = (error) => error instanceof AppStoreConnectError ? isRetryableStatus(error.status) : error instanceof TypeError;
1137
+ /** Whether App Store Connect refused a request for its rate limit only. */
1138
+ const isRateLimited = (error) => error instanceof AppStoreConnectError && error.status === 429;
1670
1139
  /**
1671
- * Reads the app's `Payload/<App>.app/Info.plist` out of an `.ipa`, never a
1672
- * framework's or an extension's, which nest deeper.
1140
+ * Runs `request`, again after a failure `retryable` accepts, up to the
1141
+ * attempts.
1673
1142
  */
1674
- const readIpaInfo = async (ipaPath) => {
1675
- const { stdout } = await (0, execa.execa)("unzip", ["-Z1", ipaPath]);
1676
- const entry = stdout.split("\n").map((name) => name.trim()).find((name) => /^Payload\/[^/]+\.app\/Info\.plist$/u.test(name));
1677
- if (entry === void 0) throw new Error(`${path.default.basename(ipaPath)} has no Payload/<App>.app/Info.plist.`);
1678
- const directory = await createRandomTmpDir();
1679
- try {
1680
- const infoPlist = path.default.join(directory, "Info.plist");
1681
- await (0, execa.execa)("unzip", [
1682
- "-p",
1683
- ipaPath,
1684
- escapeUnzipPattern(entry)
1685
- ], { stdout: { file: infoPlist } });
1686
- const info = {};
1687
- for (const [field, key] of Object.entries(INFO_PLIST_KEYS)) {
1688
- const { stdout: value, exitCode } = await (0, execa.execa)("plutil", [
1689
- "-extract",
1690
- key,
1691
- "raw",
1692
- "-o",
1693
- "-",
1694
- infoPlist
1695
- ], { reject: false });
1696
- if (exitCode !== 0 || !value.trim()) throw new Error(`${path.default.basename(ipaPath)}'s Info.plist has no ${key}.`);
1697
- info[field] = value.trim();
1698
- }
1699
- return info;
1700
- } finally {
1701
- await fs.default.promises.rm(directory, {
1702
- recursive: true,
1703
- force: true
1704
- });
1143
+ const retrying = async (request, retryable = isRetryable) => {
1144
+ for (let attempt = 1;; attempt++) try {
1145
+ return await request();
1146
+ } catch (error) {
1147
+ if (!retryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
1148
+ await sleep(retryDelayMs(error, attempt));
1705
1149
  }
1706
1150
  };
1707
- //#endregion
1708
- //#region src/utils/xcodeSigning.ts
1151
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1152
+ /** The longest wait before a retry, so a run stays bounded. */
1153
+ const MAX_RETRY_DELAY_MS = 6e4;
1709
1154
  /**
1710
- * How a device build signs with automatic signing, authenticated by the
1711
- * scheme's App Store Connect API key: the xcodebuild arguments, and the key.
1712
- * The key is resolved only here, for a device build of a scheme with
1713
- * `signing`. Without `credentials`, it resolves from the environment or the
1714
- * config's `secrets` adapter, and the temporary files are deleted when the
1715
- * process exits.
1716
- */
1717
- const resolveXcodeSigning = async ({ schemeConfig, credentials }) => {
1718
- const reference = schemeConfig.signing?.ascApiKey;
1719
- if (!reference) return;
1720
- const apiKey = await (credentials ?? (0, _hot_updater_cli_tools.createCredentialResolver)()).resolve({
1721
- kind: "asc-api-key",
1722
- name: (0, _hot_updater_cli_tools.parseSecretReference)(reference, "signing.ascApiKey")
1723
- });
1724
- return {
1725
- authenticationArgs: [
1726
- "-allowProvisioningUpdates",
1727
- "-authenticationKeyPath",
1728
- apiKey.keyPath,
1729
- "-authenticationKeyID",
1730
- apiKey.keyId,
1731
- "-authenticationKeyIssuerID",
1732
- apiKey.issuerId
1733
- ],
1734
- apiKey
1735
- };
1736
- };
1737
- const APP_STORE_CONNECT_EXPORT_OPTIONS = `<?xml version="1.0" encoding="UTF-8"?>
1738
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1739
- <plist version="1.0">
1740
- <dict>
1741
- <key>method</key>
1742
- <string>app-store-connect</string>
1743
- <key>signingStyle</key>
1744
- <string>automatic</string>
1745
- </dict>
1746
- </plist>
1747
- `;
1748
- /**
1749
- * Writes the export options of a scheme that signs with an App Store Connect
1750
- * API key and sets no `exportOptionsPlist`: an App Store Connect export with
1751
- * automatic signing.
1752
- */
1753
- const writeAppStoreConnectExportOptions = async (directory) => {
1754
- const exportOptionsPlist = path.default.join(directory, "ExportOptions.plist");
1755
- await fs.default.promises.writeFile(exportOptionsPlist, APP_STORE_CONNECT_EXPORT_OPTIONS);
1756
- return exportOptionsPlist;
1757
- };
1758
- /** The export methods whose `.ipa` App Store Connect takes. */
1759
- const APP_STORE_EXPORT_METHODS = /* @__PURE__ */ new Set(["app-store", "app-store-connect"]);
1760
- /** The `method` of export options, or "" when it has none or can't be read. */
1761
- const readExportMethod = async (exportOptionsPlist) => {
1762
- const xml = (await fs.default.promises.readFile(exportOptionsPlist, "utf-8").catch(() => void 0))?.replace(/<!--[\s\S]*?-->/gu, "").match(/<key>\s*method\s*<\/key>\s*<string>\s*([^<]*?)\s*<\/string>/u);
1763
- if (xml) return xml[1];
1764
- const { stdout, exitCode } = await (0, execa.execa)("plutil", [
1765
- "-extract",
1766
- "method",
1767
- "raw",
1768
- "-o",
1769
- "-",
1770
- exportOptionsPlist
1771
- ], { reject: false });
1772
- return exitCode === 0 ? stdout.trim() : "";
1773
- };
1774
- /**
1775
- * Whether a device build exports for App Store Connect: with its
1776
- * `exportOptionsPlist`'s method, or, without one, as a scheme signing with
1777
- * an App Store Connect API key does. Such an `.ipa` is signed with Apple's
1778
- * cloud-managed distribution certificate, never with a development
1779
- * certificate the archive was signed with.
1780
- */
1781
- const exportsForAppStoreConnect = async (schemeConfig) => !schemeConfig.exportOptionsPlist || APP_STORE_EXPORT_METHODS.has(await readExportMethod(schemeConfig.exportOptionsPlist));
1782
- //#endregion
1783
- //#region src/uploadToTestFlight.ts
1784
- const API_URL$1 = "https://api.appstoreconnect.apple.com";
1785
- /** Apple accepts tokens that expire within 20 minutes. */
1786
- const TOKEN_LIFETIME_SECONDS = 1200;
1787
- /** A token is replaced well before it expires, so no request carries a stale one. */
1788
- const TOKEN_REUSE_SECONDS = 600;
1789
- /** The parts of an `.ipa` sent at once. */
1790
- const UPLOAD_CONCURRENCY = 4;
1791
- /** Attempts to create the build upload and to commit the uploaded `.ipa`. */
1792
- const COMMIT_ATTEMPTS = 3;
1793
- /** Attempts per part: a part is a plain PUT, safe to repeat. */
1794
- const UPLOAD_ATTEMPTS = 3;
1795
- const IN_PROGRESS_STATES = /* @__PURE__ */ new Set(["AWAITING_UPLOAD", "PROCESSING"]);
1796
- /** The error App Store Connect answers with when a build number is taken. */
1797
- const DUPLICATE_ERROR_CODE = "ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE";
1798
- /**
1799
- * App Store Connect refused the upload: it already has a build with the
1800
- * `.ipa`'s version and build number, from this or another upload, and takes
1801
- * each build number once per version.
1802
- */
1803
- var TestFlightBuildExistsError = class extends Error {
1804
- shortVersion;
1805
- bundleVersion;
1806
- name = "TestFlightBuildExistsError";
1807
- constructor(shortVersion, bundleVersion, options) {
1808
- super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}.\nUse a new build number (CFBundleVersion).`, options);
1809
- this.shortVersion = shortVersion;
1810
- this.bundleVersion = bundleVersion;
1811
- }
1812
- };
1813
- /** An App Store Connect API request that failed, with the errors it answered. */
1814
- var AppStoreConnectError = class extends Error {
1815
- status;
1816
- errors;
1817
- /** The answer's `Retry-After` header, for a rate limit. */
1818
- retryAfter;
1819
- constructor(action, status, errors, retryAfter = null) {
1820
- const details = errors.map((error) => error.detail ?? error.title ?? error.code).filter(Boolean);
1821
- super(`${action} failed (HTTP ${status})${details.length > 0 ? `: ${details.join(" ")}` : "."}`);
1822
- this.name = "AppStoreConnectError";
1823
- this.status = status;
1824
- this.errors = errors;
1825
- this.retryAfter = retryAfter;
1826
- }
1827
- };
1828
- /**
1829
- * An App Store Connect API token: an ES256 JWT, whose signature JWS encodes
1830
- * as the raw 64-byte r || s, not the DER Node signs by default.
1831
- */
1832
- const createAppStoreConnectToken = ({ privateKey, keyId, issuerId, issuedAtSeconds = Math.floor(Date.now() / 1e3) }) => {
1833
- const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
1834
- const signingInput = `${encode({
1835
- alg: "ES256",
1836
- kid: keyId,
1837
- typ: "JWT"
1838
- })}.${encode({
1839
- iss: issuerId,
1840
- iat: issuedAtSeconds,
1841
- exp: issuedAtSeconds + TOKEN_LIFETIME_SECONDS,
1842
- aud: "appstoreconnect-v1"
1843
- })}`;
1844
- return `${signingInput}.${crypto.default.sign("sha256", Buffer.from(signingInput), {
1845
- key: privateKey,
1846
- dsaEncoding: "ieee-p1363"
1847
- }).toString("base64url")}`;
1848
- };
1849
- const readPrivateKey = async (keyPath) => {
1850
- try {
1851
- return crypto.default.createPrivateKey(await fs.default.promises.readFile(keyPath, "utf-8"));
1852
- } catch {
1853
- throw new Error("The App Store Connect API key is not a .p8 private key.");
1854
- }
1855
- };
1856
- const readApiErrors = async (response) => {
1857
- const body = await response.json().catch(() => void 0);
1858
- return Array.isArray(body?.errors) ? body.errors : [];
1859
- };
1860
- const describeDetails = (details) => details.map((detail) => `${detail.description ?? "(no description)"}${detail.code ? ` (${detail.code})` : ""}`);
1861
- /** Answers a request may outlive: a timeout, a rate limit, a server error. */
1862
- const isRetryableStatus = (status) => status === 408 || status === 429 || status >= 500;
1863
- /**
1864
- * Whether a request may succeed when sent again: a retryable answer, or a
1865
- * network failure, which fetch rejects with a TypeError. A refused key or
1866
- * request is not.
1867
- */
1868
- const isRetryable = (error) => error instanceof AppStoreConnectError ? isRetryableStatus(error.status) : error instanceof TypeError;
1869
- /** Whether App Store Connect refused a request for its rate limit only. */
1870
- const isRateLimited = (error) => error instanceof AppStoreConnectError && error.status === 429;
1871
- /**
1872
- * Runs `request`, again after a failure `retryable` accepts, up to the
1873
- * attempts.
1874
- */
1875
- const retrying = async (request, retryable = isRetryable) => {
1876
- for (let attempt = 1;; attempt++) try {
1877
- return await request();
1878
- } catch (error) {
1879
- if (!retryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
1880
- await sleep(retryDelayMs(error, attempt));
1881
- }
1882
- };
1883
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1884
- /** The longest wait before a retry, so a run stays bounded. */
1885
- const MAX_RETRY_DELAY_MS = 6e4;
1886
- /**
1887
- * The first wait after a rate limit without `Retry-After`: App Store Connect
1888
- * limits a key per rolling hour, so a retry within seconds meets it again.
1155
+ * The first wait after a rate limit without `Retry-After`: App Store Connect
1156
+ * limits a key per rolling hour, so a retry within seconds meets it again.
1889
1157
  */
1890
1158
  const RATE_LIMIT_DELAY_MS = 15e3;
1891
1159
  /**
@@ -2040,7 +1308,7 @@ const createAppStoreConnectRequest = ({ apiKey, privateKey, fetch }) => {
2040
1308
  return `Bearer ${token.value}`;
2041
1309
  };
2042
1310
  return async (action, method, pathname, body) => {
2043
- const response = await fetch(`${API_URL$1}${pathname}`, {
1311
+ const response = await fetch(`${API_URL$2}${pathname}`, {
2044
1312
  method,
2045
1313
  headers: {
2046
1314
  Authorization: authorization(),
@@ -2227,72 +1495,1041 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
2227
1495
  if (!isRetryable(error)) throw error;
2228
1496
  lastError = readError = error;
2229
1497
  });
2230
- failedReads = read ? 0 : failedReads + 1;
2231
- const state = read ? read.data.attributes?.state ?? {} : {};
2232
- if (state.state === "PROCESSING") reportedProcessing = true;
2233
- if (!state.state || IN_PROGRESS_STATES.has(state.state)) {
2234
- if (Date.now() >= deadline) {
2235
- if (!reportedProcessing) throw new Error([
2236
- `App Store Connect didn't report build ${info.bundleVersion} processing.`,
2237
- lastError instanceof Error ? lastError.message : state.state ? `State: ${state.state}` : void 0,
2238
- "Check the build in TestFlight:",
2239
- testFlightUrl
2240
- ].filter(Boolean).join("\n"), { cause: lastError });
2241
- _hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${info.bundleVersion}.\nTestFlight lists it once processing finishes:\n${testFlightUrl}`);
2242
- return {
2243
- appId: app.id,
2244
- buildUploadId,
2245
- state: "PROCESSING",
2246
- testFlightUrl,
2247
- shortVersion: info.shortVersion,
2248
- bundleVersion: info.bundleVersion
2249
- };
2250
- }
2251
- await sleep(readError === void 0 ? pollIntervalMs : Math.max(pollIntervalMs, retryDelayMs(readError, failedReads)));
2252
- continue;
2253
- }
2254
- for (const warning of describeDetails(state.warnings ?? [])) _hot_updater_cli_tools.p.log.warn(warning);
2255
- if (state.state !== "COMPLETE") {
2256
- const errors = describeDetails(state.errors ?? []);
2257
- throw new Error([`App Store Connect rejected build ${info.bundleVersion} of version ${info.shortVersion} (${state.state}).`, ...errors.map((error) => `- ${error}`)].join("\n"));
2258
- }
2259
- return {
2260
- appId: app.id,
2261
- buildUploadId,
2262
- state: "COMPLETE",
2263
- testFlightUrl,
2264
- shortVersion: info.shortVersion,
2265
- bundleVersion: info.bundleVersion
2266
- };
1498
+ failedReads = read ? 0 : failedReads + 1;
1499
+ const state = read ? read.data.attributes?.state ?? {} : {};
1500
+ if (state.state === "PROCESSING") reportedProcessing = true;
1501
+ if (!state.state || IN_PROGRESS_STATES.has(state.state)) {
1502
+ if (Date.now() >= deadline) {
1503
+ if (!reportedProcessing) throw new Error([
1504
+ `App Store Connect didn't report build ${info.bundleVersion} processing.`,
1505
+ lastError instanceof Error ? lastError.message : state.state ? `State: ${state.state}` : void 0,
1506
+ "Check the build in TestFlight:",
1507
+ testFlightUrl
1508
+ ].filter(Boolean).join("\n"), { cause: lastError });
1509
+ _hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${info.bundleVersion}.\nTestFlight lists it once processing finishes:\n${testFlightUrl}`);
1510
+ return {
1511
+ appId: app.id,
1512
+ buildUploadId,
1513
+ state: "PROCESSING",
1514
+ testFlightUrl,
1515
+ shortVersion: info.shortVersion,
1516
+ bundleVersion: info.bundleVersion
1517
+ };
1518
+ }
1519
+ await sleep(readError === void 0 ? pollIntervalMs : Math.max(pollIntervalMs, retryDelayMs(readError, failedReads)));
1520
+ continue;
1521
+ }
1522
+ for (const warning of describeDetails(state.warnings ?? [])) _hot_updater_cli_tools.p.log.warn(warning);
1523
+ if (state.state !== "COMPLETE") {
1524
+ const errors = describeDetails(state.errors ?? []);
1525
+ throw new Error([`App Store Connect rejected build ${info.bundleVersion} of version ${info.shortVersion} (${state.state}).`, ...errors.map((error) => `- ${error}`)].join("\n"));
1526
+ }
1527
+ return {
1528
+ appId: app.id,
1529
+ buildUploadId,
1530
+ state: "COMPLETE",
1531
+ testFlightUrl,
1532
+ shortVersion: info.shortVersion,
1533
+ bundleVersion: info.bundleVersion
1534
+ };
1535
+ }
1536
+ };
1537
+ /**
1538
+ * Sends each part App Store Connect asked for: the bytes at its offset, with
1539
+ * only the headers it lists, since the URLs are presigned: another header,
1540
+ * or the App Store Connect token, would break their signature.
1541
+ */
1542
+ const uploadParts = async ({ ipaPath, operations, fetch }) => {
1543
+ const file = await fs.default.promises.open(ipaPath, "r");
1544
+ try {
1545
+ await runConcurrently(operations, UPLOAD_CONCURRENCY, async (operation, index) => {
1546
+ const body = Buffer.alloc(operation.length);
1547
+ const { bytesRead } = await file.read(body, 0, operation.length, operation.offset);
1548
+ if (bytesRead !== operation.length) throw new Error(`${path.default.basename(ipaPath)} changed while it was uploading.`);
1549
+ const headers = {};
1550
+ for (const { name, value } of operation.requestHeaders ?? []) headers[name] = value;
1551
+ for (let attempt = 1;; attempt++) {
1552
+ const response = await fetch(operation.url, {
1553
+ method: operation.method,
1554
+ headers,
1555
+ body
1556
+ }).catch((error) => error);
1557
+ if (!(response instanceof Error) && response.ok) return;
1558
+ if (!(response instanceof Error || isRetryableStatus(response.status)) || attempt === UPLOAD_ATTEMPTS) throw new Error(`Uploading part ${index + 1} of ${operations.length} failed: ${response instanceof Error ? response.message : `HTTP ${response.status}`}.`);
1559
+ await sleep(retryDelayMs(response, attempt));
1560
+ }
1561
+ });
1562
+ } finally {
1563
+ await file.close();
1564
+ }
1565
+ };
1566
+ //#endregion
1567
+ //#region src/asc/devices.ts
1568
+ const API_URL$1 = "https://api.appstoreconnect.apple.com";
1569
+ const DEVICE_FIELDS = "name,udid,status,deviceClass";
1570
+ const PAGE_SIZE = 200;
1571
+ /** More pages than an account's yearly limit of devices can fill. */
1572
+ const MAX_PAGES = 50;
1573
+ const deviceOf = ({ id, attributes }) => ({
1574
+ id,
1575
+ name: attributes?.name ?? "",
1576
+ udid: attributes?.udid ?? "",
1577
+ status: attributes?.status ?? "",
1578
+ deviceClass: attributes?.deviceClass ?? null
1579
+ });
1580
+ /**
1581
+ * A 403 from this API means something else than an upload's. A key's role
1582
+ * decides it, and only the person who owns the key can change that, so no key
1583
+ * is issued here.
1584
+ */
1585
+ const describingAccess = async (run) => {
1586
+ try {
1587
+ return await run();
1588
+ } catch (error) {
1589
+ const cause = error instanceof Error ? error.cause : void 0;
1590
+ if (cause instanceof AppStoreConnectError && cause.status === 403 && !isProgramLicenseAgreementError(cause.errors)) throw new Error("App Store Connect doesn't let this API key register devices.\nUse a key with the Admin role: npx hot-updater apple login", { cause });
1591
+ throw error;
1592
+ }
1593
+ };
1594
+ const readPages = async (request, firstPath) => {
1595
+ const devices = [];
1596
+ let pathname = firstPath;
1597
+ for (let page = 0; pathname !== void 0; page += 1) {
1598
+ if (page === MAX_PAGES) throw new Error("App Store Connect's device list didn't end.");
1599
+ const path = pathname;
1600
+ const answer = await retrying(() => request("Reading App Store Connect's devices", "GET", path));
1601
+ devices.push(...answer.data.map(deviceOf));
1602
+ const next = answer.links?.next;
1603
+ if (next !== void 0 && !next.startsWith(`${API_URL$1}/`)) throw new Error("App Store Connect's next page is on another host.");
1604
+ pathname = next?.slice(37);
1605
+ }
1606
+ return devices;
1607
+ };
1608
+ const listPath = (filter = {}) => `/v1/devices?${new URLSearchParams({
1609
+ "filter[platform]": "IOS",
1610
+ ...filter,
1611
+ "fields[devices]": DEVICE_FIELDS,
1612
+ limit: String(PAGE_SIZE)
1613
+ })}`;
1614
+ /** Every iPhone and iPad the Apple account has registered. */
1615
+ const listAscDevices = async ({ apiKey, fetch = globalThis.fetch }) => {
1616
+ const privateKey = await readPrivateKey(apiKey.keyPath);
1617
+ const request = createAppStoreConnectRequest({
1618
+ apiKey,
1619
+ privateKey,
1620
+ fetch
1621
+ });
1622
+ return describingAccess(() => readPages(request, listPath()));
1623
+ };
1624
+ const keyOf = (udid) => (0, _hot_updater_protocol_native_artifact.normalizeIosUdid)(udid) ?? udid.toLowerCase();
1625
+ const findDevices = async (request, udids) => new Map((await readPages(request, listPath({ "filter[udid]": udids.join(",") }))).map((device) => [keyOf(device.udid), device]));
1626
+ /**
1627
+ * Registers devices with the Apple account, each at most once: a device that
1628
+ * is already there succeeds and changes nothing, so running it again is safe,
1629
+ * and so is a race with another run, which App Store Connect answers with 409.
1630
+ * A disabled device stays disabled, since its slot is already spent and someone
1631
+ * chose to disable it. It only registers: Xcode adds a registered device to the
1632
+ * profile at the next export, so an `.ipa` signed before never gains it.
1633
+ */
1634
+ const addAscDevices = async ({ devices, apiKey, fetch = globalThis.fetch }) => {
1635
+ const privateKey = await readPrivateKey(apiKey.keyPath);
1636
+ const request = createAppStoreConnectRequest({
1637
+ apiKey,
1638
+ privateKey,
1639
+ fetch
1640
+ });
1641
+ return describingAccess(async () => {
1642
+ const found = await findDevices(request, devices.map(({ udid }) => udid));
1643
+ const outcomes = [];
1644
+ for (const { udid, name } of devices) {
1645
+ const existing = found.get(keyOf(udid));
1646
+ if (existing !== void 0) {
1647
+ outcomes.push({
1648
+ udid,
1649
+ outcome: existing.status === "DISABLED" ? "disabled" : "already-registered",
1650
+ device: existing
1651
+ });
1652
+ continue;
1653
+ }
1654
+ try {
1655
+ const { data } = await retrying(() => request(`Registering ${udid}`, "POST", "/v1/devices", { data: {
1656
+ type: "devices",
1657
+ attributes: {
1658
+ name,
1659
+ platform: "IOS",
1660
+ udid
1661
+ }
1662
+ } }));
1663
+ outcomes.push({
1664
+ udid,
1665
+ outcome: "registered",
1666
+ device: deviceOf(data)
1667
+ });
1668
+ } catch (error) {
1669
+ if (!(error instanceof AppStoreConnectError) || error.status !== 409) throw error;
1670
+ const raced = (await findDevices(request, [udid])).get(keyOf(udid));
1671
+ if (raced !== void 0) {
1672
+ outcomes.push({
1673
+ udid,
1674
+ outcome: raced.status === "DISABLED" ? "disabled" : "already-registered",
1675
+ device: raced
1676
+ });
1677
+ continue;
1678
+ }
1679
+ const reason = error.errors.map(({ detail, title }) => detail ?? title).filter(Boolean).join(" ");
1680
+ outcomes.push({
1681
+ udid,
1682
+ outcome: "refused",
1683
+ reason: reason || "App Store Connect refused it.",
1684
+ limitReached: /maximum|limit/iu.test(reason)
1685
+ });
1686
+ }
1687
+ }
1688
+ return outcomes;
1689
+ });
1690
+ };
1691
+ //#endregion
1692
+ //#region src/utils/formatCommand.ts
1693
+ /**
1694
+ * A command to print, broken at spaces with ` \` once a line would pass
1695
+ * `width`, each continuation indented two spaces, so a terminal never wraps
1696
+ * it mid-word and it still pastes as one command.
1697
+ */
1698
+ const formatCommand = (words, width = 74) => {
1699
+ const lines = [];
1700
+ let line = "";
1701
+ for (const word of words) {
1702
+ const next = line ? `${line} ${word}` : word;
1703
+ if (line && next.length > width - 2) {
1704
+ lines.push(`${line} \\`);
1705
+ line = ` ${word}`;
1706
+ } else line = next;
1707
+ }
1708
+ lines.push(line);
1709
+ return lines.join("\n");
1710
+ };
1711
+ //#endregion
1712
+ //#region src/utils/cocoapods.ts
1713
+ /**
1714
+ * A failed command's error: the command, then the last lines of its
1715
+ * output, stdout and stderr as printed, without execa's full message.
1716
+ */
1717
+ const commandFailure = (command, error) => {
1718
+ const { all, stderr, stdout } = error ?? {};
1719
+ const detail = String(all || [stdout, stderr].filter(Boolean).join("\n") || "").trimEnd().split("\n").filter((line) => line.trim()).slice(-12).join("\n") || (error instanceof Error ? error.message : String(error));
1720
+ return new Error(`${command} failed.\n${detail}`, { cause: error });
1721
+ };
1722
+ /**
1723
+ * A line of the progress meter curl prints on stderr for each pod it
1724
+ * downloads, such as React Native's prebuilt core.
1725
+ */
1726
+ const DOWNLOAD_PROGRESS = [
1727
+ /^% Total +% Received/,
1728
+ /^Dload +Upload/,
1729
+ /^\d{1,3} +[\d.]+[kMGT]? +\d{1,3} +[\d.]+[kMGT]? /
1730
+ ];
1731
+ /**
1732
+ * `pod install`'s output: its live line shows the pod it installs, its log
1733
+ * keeps the whole output, and a failure prints CocoaPods' `[!]` errors and a
1734
+ * crash's `SomeError - …` line with its last lines, without the download
1735
+ * progress CocoaPods' curl prints on stderr.
1736
+ */
1737
+ const createPodInstallLogger = () => new _hot_updater_cli_tools.BuildLogger({
1738
+ logPrefix: "pod-install",
1739
+ createPhaseReader: () => (line) => {
1740
+ const installing = /^Installing (\S+)/.exec(line);
1741
+ if (installing) return `Installing ${installing[1]}`;
1742
+ return /^(Analyzing dependencies|Downloading dependencies|Generating Pods project|Integrating client project)$/.test(line) ? line : void 0;
1743
+ },
1744
+ failureSummary: {
1745
+ lines: [/^[A-Z]\w*(::\w+)*Error - /],
1746
+ blocks: [{
1747
+ start: /^\[!\] /,
1748
+ end: /^$/,
1749
+ includeEnd: false
1750
+ }],
1751
+ ignore: DOWNLOAD_PROGRESS,
1752
+ tail: 4,
1753
+ stopAfter: /^\[!\] Oh no, an error occurred\.$/,
1754
+ maxWidth: 76
1755
+ }
1756
+ });
1757
+ /**
1758
+ * Runs `pod install` as a step whose failure prints CocoaPods' errors and
1759
+ * the log's path, then throws a `BuildFailedError`.
1760
+ */
1761
+ const runPodInstall = async ({ command, env, sourceDir }) => {
1762
+ const logger = createPodInstallLogger();
1763
+ const failureMessage = `${command.join(" ")} failed`;
1764
+ await logger.start({
1765
+ title: "Installing CocoaPods dependencies",
1766
+ header: [`$ ${formatCommand([...Object.entries(env).map(([name, value]) => `${name}=${value}`), ...command])}`, ""]
1767
+ });
1768
+ try {
1769
+ const podProcess = (0, execa.execa)(command[0], command.slice(1), {
1770
+ cwd: sourceDir,
1771
+ env,
1772
+ all: true,
1773
+ buffer: false
1774
+ });
1775
+ const logging = podProcess.all ? logger.processStream(podProcess.all) : Promise.resolve();
1776
+ const [podResult, loggingResult] = await Promise.allSettled([podProcess, logging]);
1777
+ if (podResult.status === "rejected") throw podResult.reason;
1778
+ if (loggingResult.status === "rejected") throw loggingResult.reason;
1779
+ logger.stop("CocoaPods dependencies installed");
1780
+ } catch (error) {
1781
+ logger.writeError(error);
1782
+ const reason = error instanceof execa.ExecaError && error.exitCode !== void 0 ? `exited with code ${error.exitCode}` : error instanceof Error ? error.message : String(error);
1783
+ logger.stop(`${failureMessage} · ${reason}`, false);
1784
+ throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${reason}`, {
1785
+ logPath: logger.logPath,
1786
+ summary: logger.failureSummary,
1787
+ cause: error
1788
+ });
1789
+ } finally {
1790
+ await logger.close();
1791
+ }
1792
+ };
1793
+ const installPodsIfNeeded = async (sourceDir) => {
1794
+ const podfilePath = path.default.join(sourceDir, "Podfile");
1795
+ if (!fs.default.existsSync(podfilePath)) {
1796
+ _hot_updater_cli_tools.p.log.info("No Podfile found, skipping CocoaPods installation");
1797
+ return {
1798
+ codegen: false,
1799
+ pods: false
1800
+ };
1801
+ }
1802
+ const gemfilePaths = [path.default.join(sourceDir, "Gemfile"), path.default.join((0, _hot_updater_cli_tools.getCwd)(), "Gemfile")];
1803
+ const shouldUseBundler = (await Promise.all(gemfilePaths.map(checkShouldUseBundler))).some(Boolean);
1804
+ const reactNative = (0, _hot_updater_cli_tools.getReactNativeMetadatas)();
1805
+ const plan = resolvePodInstallPlan({
1806
+ version: reactNative.version,
1807
+ env: process.env
1808
+ });
1809
+ if (plan.runCodegen) await runReactNativeCodegen({
1810
+ projectRoot: (0, _hot_updater_cli_tools.getCwd)(),
1811
+ reactNativePath: reactNative.packagePath,
1812
+ sourceDir
1813
+ });
1814
+ if (shouldUseBundler) {
1815
+ const bundleSpinner = _hot_updater_cli_tools.p.spinner();
1816
+ bundleSpinner.start("Installing Ruby gems");
1817
+ try {
1818
+ await (0, execa.execa)("bundle", ["install"], {
1819
+ cwd: sourceDir,
1820
+ all: true
1821
+ });
1822
+ } catch (error) {
1823
+ bundleSpinner.error("bundle install failed");
1824
+ throw commandFailure("bundle install", error);
1825
+ }
1826
+ bundleSpinner.stop("Ruby gems installed");
1827
+ }
1828
+ await runPodInstall({
1829
+ command: shouldUseBundler ? [
1830
+ "bundle",
1831
+ "exec",
1832
+ "pod",
1833
+ "install"
1834
+ ] : ["pod", "install"],
1835
+ env: plan.env,
1836
+ sourceDir
1837
+ });
1838
+ return {
1839
+ codegen: plan.runCodegen,
1840
+ pods: true
1841
+ };
1842
+ };
1843
+ const checkShouldUseBundler = async (gemfilePath) => {
1844
+ try {
1845
+ if (!fs.default.existsSync(gemfilePath)) return false;
1846
+ return (await fs.default.promises.readFile(gemfilePath, "utf-8")).includes("cocoapods");
1847
+ } catch {
1848
+ return false;
1849
+ }
1850
+ };
1851
+ /**
1852
+ * Runs React Native codegen into `<sourceDir>/build/generated/ios`, as the
1853
+ * React Native CLI does before `pod install`. Clearing the old output first
1854
+ * drops the modules of removed libraries.
1855
+ */
1856
+ const runReactNativeCodegen = async ({ projectRoot, reactNativePath, sourceDir }) => {
1857
+ await fs.default.promises.rm(path.default.join(sourceDir, "build", "generated", "ios"), {
1858
+ recursive: true,
1859
+ force: true
1860
+ });
1861
+ const spinner = _hot_updater_cli_tools.p.spinner();
1862
+ spinner.start("Running React Native codegen");
1863
+ try {
1864
+ await (0, execa.execa)("node", [
1865
+ path.default.join(reactNativePath, "scripts", "generate-codegen-artifacts.js"),
1866
+ "-p",
1867
+ projectRoot,
1868
+ "-o",
1869
+ sourceDir,
1870
+ "-t",
1871
+ "ios"
1872
+ ], {
1873
+ cwd: projectRoot,
1874
+ all: true
1875
+ });
1876
+ } catch (error) {
1877
+ spinner.error("React Native codegen failed");
1878
+ throw commandFailure("React Native codegen", error);
1879
+ }
1880
+ spinner.stop("React Native codegen done");
1881
+ };
1882
+ /**
1883
+ * The environment `pod install` runs with, and whether codegen runs first.
1884
+ *
1885
+ * From React Native 0.79, `RCT_IGNORE_PODS_DEPRECATION=1` silences the
1886
+ * deprecation notice for calling `pod install` directly and also sets
1887
+ * `RCT_SKIP_CODEGEN=1`, because the React Native CLI runs codegen itself
1888
+ * first. Hot Updater sets it the same way and runs codegen too, unless the
1889
+ * user's `RCT_SKIP_CODEGEN=1` takes codegen over. Before 0.79, and when the
1890
+ * version is unknown, CocoaPods runs codegen. A user's value of a flag wins.
1891
+ */
1892
+ const resolvePodInstallPlan = ({ version, env }) => {
1893
+ const podsSkipCodegen = version.major > 0 || version.minor >= 79;
1894
+ const usePrebuiltReactNative = version.major > 0 || version.minor >= 81;
1895
+ const ignorePodsDeprecation = env["RCT_IGNORE_PODS_DEPRECATION"] ?? (podsSkipCodegen ? "1" : void 0);
1896
+ return {
1897
+ runCodegen: podsSkipCodegen && ignorePodsDeprecation === "1" && env["RCT_SKIP_CODEGEN"] !== "1",
1898
+ env: {
1899
+ ...ignorePodsDeprecation !== void 0 && { RCT_IGNORE_PODS_DEPRECATION: ignorePodsDeprecation },
1900
+ RCT_USE_RN_DEP: env["RCT_USE_RN_DEP"] ?? (usePrebuiltReactNative ? "1" : "0"),
1901
+ RCT_USE_PREBUILT_RNCORE: env["RCT_USE_PREBUILT_RNCORE"] ?? (usePrebuiltReactNative ? "1" : "0")
1902
+ }
1903
+ };
1904
+ };
1905
+ //#endregion
1906
+ //#region src/utils/platform.ts
1907
+ /**
1908
+ * Platform configurations for different Apple platforms
1909
+ */
1910
+ const platformConfigs = { ios: {
1911
+ name: "ios",
1912
+ deviceSdk: "iphoneos",
1913
+ simulatorSdk: "iphonesimulator",
1914
+ supportsSimulator: true,
1915
+ deviceDestination: "platform=iOS",
1916
+ simulatorDestination: "platform=iOS Simulator"
1917
+ } };
1918
+ //#endregion
1919
+ //#region src/utils/destination.ts
1920
+ const buildDestinationString = ({ deviceType, platform, useGeneric }) => `${useGeneric ? "generic/" : ""}${deviceType === "device" ? platformConfigs[platform].deviceDestination : platformConfigs[platform].simulatorDestination}`;
1921
+ /**
1922
+ * Resolves IosBuildDestination to xcodebuild destination format
1923
+ */
1924
+ const resolveDestination = ({ destination, useGeneric }) => {
1925
+ if (typeof destination === "object") {
1926
+ if ("id" in destination) return `id=${destination.id}`;
1927
+ if ("name" in destination) return `name=${destination.name}`;
1928
+ }
1929
+ switch (destination) {
1930
+ case "ios-device": return buildDestinationString({
1931
+ platform: "ios",
1932
+ deviceType: "device",
1933
+ useGeneric
1934
+ });
1935
+ case "ios-simulator": return buildDestinationString({
1936
+ platform: "ios",
1937
+ deviceType: "simulator",
1938
+ useGeneric
1939
+ });
1940
+ }
1941
+ };
1942
+ const resolveDestinations = ({ destinations, useGeneric }) => {
1943
+ return destinations.map((destination) => resolveDestination({
1944
+ destination,
1945
+ useGeneric
1946
+ }));
1947
+ };
1948
+ const getDefaultDestination = ({ platform, useGeneric, deviceType }) => {
1949
+ return buildDestinationString({
1950
+ deviceType,
1951
+ platform,
1952
+ useGeneric
1953
+ });
1954
+ };
1955
+ //#endregion
1956
+ //#region src/utils/parseXcodeProjectInfo.ts
1957
+ const parseXcodeProjectInfo = async (sourceDir) => {
1958
+ const files = await fs.default.promises.readdir(sourceDir);
1959
+ const workspace = files.find((file) => file.endsWith(".xcworkspace"));
1960
+ if (workspace) return {
1961
+ isWorkspace: true,
1962
+ name: workspace,
1963
+ path: path.default.join(sourceDir, workspace)
1964
+ };
1965
+ const project = files.find((file) => file.endsWith(".xcodeproj"));
1966
+ if (project) return {
1967
+ isWorkspace: false,
1968
+ name: project,
1969
+ path: path.default.join(sourceDir, project)
1970
+ };
1971
+ throw new Error(`No Xcode project or workspace found in ${sourceDir}`);
1972
+ };
1973
+ //#endregion
1974
+ //#region src/utils/prettifyXcodebuildError.ts
1975
+ const prettifyXcodebuildError = (error) => {
1976
+ if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
1977
+ const errorLines = error.summary.join("\n");
1978
+ 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;
1979
+ return new _hot_updater_cli_tools.BuildFailedError(hint ?? error.message, {
1980
+ logPath: error.logPath,
1981
+ summary: error.summary,
1982
+ hint,
1983
+ cause: error.cause
1984
+ });
1985
+ }
1986
+ return /* @__PURE__ */ new Error(`Xcodebuild failed: ${error}`);
1987
+ };
1988
+ //#endregion
1989
+ //#region src/utils/xcodebuildPhase.ts
1990
+ /** The `(in target 'X' from project 'Y')` that ends an action's line. */
1991
+ const TARGET = /\(in target '([^']+)' from project '[^']+'\)\s*$/;
1992
+ /** A `PhaseScriptExecution` line's script phase, its spaces escaped. */
1993
+ const SCRIPT = /^PhaseScriptExecution ((?:\\.|[^\s\\])+)/;
1994
+ /** A batched `SwiftCompile`'s files: `Compiling\ A.swift,\ B.swift`. */
1995
+ const SWIFT_BATCH = /^SwiftCompile \S+ \S+ Compiling\\ ((?:\\.|[^\s\\])+)/;
1996
+ const unescape = (value) => value.replace(/\\(.)/g, "$1");
1997
+ /** Actions whose lines say what xcodebuild is doing, by their first word. */
1998
+ const ACTIONS = {
1999
+ CompileAssetCatalog: "Compiling assets",
2000
+ CompileAssetCatalogVariant: "Compiling assets",
2001
+ LinkAssetCatalog: "Compiling assets",
2002
+ CompileStoryboard: "Compiling storyboards",
2003
+ CompileXIB: "Compiling storyboards",
2004
+ LinkStoryboards: "Compiling storyboards",
2005
+ Ld: "Linking",
2006
+ Libtool: "Linking",
2007
+ CodeSign: "Signing",
2008
+ ProcessInfoPlistFile: "Processing",
2009
+ ProcessProductPackaging: "Processing",
2010
+ ProcessProductPackagingDER: "Processing",
2011
+ CopySwiftLibs: "Copying",
2012
+ CpResource: "Copying",
2013
+ GenerateDSYMFile: "Generating dSYM",
2014
+ Strip: "Stripping",
2015
+ Validate: "Validating"
2016
+ };
2017
+ const COMPILES = /* @__PURE__ */ new Set([
2018
+ "CompileC",
2019
+ "SwiftCompile",
2020
+ "CompileSwift"
2021
+ ]);
2022
+ const PRECOMPILES = /* @__PURE__ */ new Set([
2023
+ "PrecompileModule",
2024
+ "ScanDependencies",
2025
+ "SwiftExplicitDependencyGeneratePcm",
2026
+ "SwiftExplicitDependencyCompileModuleFromInterface"
2027
+ ]);
2028
+ const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
2029
+ /**
2030
+ * Reads the phase of an `xcodebuild` build, archive, or export from its raw
2031
+ * output: `Compiling React-Core · 412 files`, `Linking CodexRelay`,
2032
+ * `Signing CodexRelay`, `Bundling JavaScript`, with the target each
2033
+ * action's line names.
2034
+ */
2035
+ const createXcodebuildPhaseReader = () => {
2036
+ let compiled = 0;
2037
+ let precompiled = 0;
2038
+ return (line) => {
2039
+ const space = line.indexOf(" ");
2040
+ const action = space === -1 ? line : line.slice(0, space);
2041
+ const target = TARGET.exec(line)?.[1];
2042
+ const withTarget = (phase) => target ? `${phase} ${target}` : phase;
2043
+ if (COMPILES.has(action)) {
2044
+ const batch = SWIFT_BATCH.exec(line)?.[1];
2045
+ compiled += batch ? unescape(batch).split(",").length : 1;
2046
+ return `${withTarget("Compiling")} · ${plural(compiled, "file")}`;
2047
+ }
2048
+ if (PRECOMPILES.has(action)) {
2049
+ precompiled += 1;
2050
+ return `Precompiling modules · ${precompiled}`;
2051
+ }
2052
+ if (action === "PhaseScriptExecution") {
2053
+ const script = unescape(SCRIPT.exec(line)?.[1] ?? "");
2054
+ if (script === "Bundle React Native code and images") return "Bundling JavaScript";
2055
+ return script ? `Running ${script}` : withTarget("Running a script");
2056
+ }
2057
+ const phase = ACTIONS[action];
2058
+ if (phase) return withTarget(phase);
2059
+ if (line === "Prepare packages" || line.startsWith("Resolve Package")) return "Resolving packages";
2060
+ if (line === "ComputeTargetDependencyGraph") return "Planning the build";
2061
+ };
2062
+ };
2063
+ //#endregion
2064
+ //#region src/utils/createXcodebuildLogger.ts
2065
+ const createXcodebuildLogger = ({ logPrefix, logFilePath }) => new _hot_updater_cli_tools.BuildLogger({
2066
+ logPrefix,
2067
+ logFilePath,
2068
+ createPhaseReader: createXcodebuildPhaseReader,
2069
+ failureSummary: {
2070
+ lines: [/(^|:\s)error: /, /^\*\* [A-Z ]+ FAILED \*\*$/],
2071
+ blocks: [{
2072
+ start: "The following build commands failed:",
2073
+ end: /^\(\d+ failures?\)$/,
2074
+ includeEnd: true
2075
+ }, {
2076
+ start: /^error: exportArchive /,
2077
+ end: /^$/,
2078
+ includeEnd: false
2079
+ }]
2080
+ }
2081
+ });
2082
+ //#endregion
2083
+ //#region src/utils/runXcodebuildWithLogging.ts
2084
+ const TERMINATION_SIGNALS$1 = ["SIGINT", "SIGTERM"];
2085
+ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
2086
+ return (0, execa.execa)("xcodebuild", args, {
2087
+ cwd: sourceDir,
2088
+ all: true,
2089
+ buffer: false,
2090
+ env: {
2091
+ ...process.env,
2092
+ ...env,
2093
+ NSUnbufferedIO: "YES"
2094
+ }
2095
+ });
2096
+ };
2097
+ /** Why xcodebuild failed, without its command line. */
2098
+ const describeFailure = (error) => {
2099
+ if (error instanceof execa.ExecaError) {
2100
+ if (error.signal) return `xcodebuild was stopped by ${error.signal}.`;
2101
+ if (error.exitCode !== void 0) return `xcodebuild exited with code ${error.exitCode}.`;
2102
+ }
2103
+ return error instanceof Error ? error.message : String(error);
2104
+ };
2105
+ /**
2106
+ * Runs xcodebuild as a step titled `title`: its live line shows the build's
2107
+ * phase, the log file keeps the command and the whole output, and a failure
2108
+ * prints the output's errors and the log's path on stderr.
2109
+ */
2110
+ const runXcodebuildWithLogging = async ({ args, env, sourceDir, title, successMessage, failureMessage, logPrefix, logFilePath, header = [] }) => {
2111
+ const logger = createXcodebuildLogger({
2112
+ logPrefix,
2113
+ logFilePath
2114
+ });
2115
+ await logger.start({
2116
+ title,
2117
+ header: [
2118
+ ...header,
2119
+ `$ ${formatCommand(["xcodebuild", ...args])}`,
2120
+ ""
2121
+ ]
2122
+ });
2123
+ const activeProcesses = [];
2124
+ const terminateActiveProcesses = () => {
2125
+ for (const activeProcess of activeProcesses) activeProcess.kill();
2126
+ };
2127
+ for (const signal of TERMINATION_SIGNALS$1) process.once(signal, terminateActiveProcesses);
2128
+ try {
2129
+ const xcodebuildProcess = createRawXcodebuildProcess({
2130
+ args,
2131
+ env,
2132
+ sourceDir
2133
+ });
2134
+ activeProcesses.push(xcodebuildProcess);
2135
+ const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
2136
+ const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput) : Promise.resolve();
2137
+ const [xcodebuildResult, loggingResult] = await Promise.allSettled([xcodebuildProcess, logging]);
2138
+ if (xcodebuildResult.status === "rejected") throw xcodebuildResult.reason;
2139
+ if (loggingResult.status === "rejected") throw loggingResult.reason;
2140
+ logger.stop(successMessage);
2141
+ return { logFilePath: logger.logFilePath };
2142
+ } catch (error) {
2143
+ logger.writeError(error);
2144
+ const reason = describeFailure(error);
2145
+ logger.stop(`${failureMessage} · ${reason.replace(/\.$/, "")}`, false);
2146
+ throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${reason}`, {
2147
+ logPath: logger.logPath,
2148
+ summary: logger.failureSummary,
2149
+ cause: error
2150
+ });
2151
+ } finally {
2152
+ for (const signal of TERMINATION_SIGNALS$1) process.off(signal, terminateActiveProcesses);
2153
+ await logger.close();
2154
+ }
2155
+ };
2156
+ //#endregion
2157
+ //#region src/utils/writeBuildXcconfig.ts
2158
+ const SDK_CONDITIONS = [
2159
+ "",
2160
+ "[sdk=iphoneos*]",
2161
+ "[sdk=iphonesimulator*]"
2162
+ ];
2163
+ /**
2164
+ * Finds a setting of `keys` that an included xcconfig sets with a condition
2165
+ * the build xcconfig does not, such as `[config=Release]` or `[arch=arm64]`,
2166
+ * which would outrank the build's own. It follows `#include`s, which are
2167
+ * relative to the including file.
2168
+ */
2169
+ const findConditionalSetting = async (files, keys, visited = /* @__PURE__ */ new Set()) => {
2170
+ for (const file of files) {
2171
+ if (visited.has(file) || !fs.default.existsSync(file)) continue;
2172
+ visited.add(file);
2173
+ const lines = (await fs.default.promises.readFile(file, "utf-8")).split("\n");
2174
+ for (const [index, line] of lines.entries()) {
2175
+ const include = line.match(/^\s*#include\??\s*"([^"]+)"/)?.[1];
2176
+ if (include) {
2177
+ const found = await findConditionalSetting([path.default.resolve(path.default.dirname(file), include)], keys, visited);
2178
+ if (found) return found;
2179
+ continue;
2180
+ }
2181
+ const [, key, conditions] = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)((?:\[[^\]]*\])+)\s*=/) ?? [];
2182
+ if (key && keys.includes(key) && !SDK_CONDITIONS.includes(conditions)) return `${file}:${index + 1} sets ${key}${conditions}`;
2183
+ }
2184
+ }
2185
+ };
2186
+ /**
2187
+ * Writes the xcconfig a build loads, which sets the CLI's build settings
2188
+ * over every other. xcodebuild ranks an `-xcconfig`'s settings above the
2189
+ * command line's, so the scheme's `xcconfig` and each `-xcconfig` in
2190
+ * `extraParams` are included, in order, before `settings`, and the build
2191
+ * loads only this one.
2192
+ */
2193
+ const writeBuildXcconfig = async ({ directory, sourceDir, xcconfig, extraParams = [], settings }) => {
2194
+ const included = xcconfig ? [xcconfig] : [];
2195
+ const otherParams = [];
2196
+ for (let i = 0; i < extraParams.length; i++) if (extraParams[i] === "-xcconfig" && i + 1 < extraParams.length) included.push(extraParams[++i]);
2197
+ else otherParams.push(extraParams[i]);
2198
+ const includedFiles = included.map((file) => path.default.resolve(sourceDir, file));
2199
+ const conditional = await findConditionalSetting(includedFiles, Object.keys(settings));
2200
+ if (conditional) throw new Error(`${conditional}, which would outrank the value the build sets. Set it without that condition, or remove it.`);
2201
+ const buildXcconfig = path.default.join(directory, "HotUpdater.xcconfig");
2202
+ await fs.default.promises.writeFile(buildXcconfig, [
2203
+ ...includedFiles.map((file) => `#include "${file}"`),
2204
+ ...Object.entries(settings).flatMap(([key, value]) => SDK_CONDITIONS.map((condition) => `${key}${condition} = ${value}`)),
2205
+ ""
2206
+ ].join("\n"));
2207
+ return {
2208
+ xcconfig: buildXcconfig,
2209
+ extraParams: otherParams
2210
+ };
2211
+ };
2212
+ /**
2213
+ * Automatic signing over a target set to manual signing, whose provisioning
2214
+ * profile and identity Xcode would otherwise report as conflicting.
2215
+ */
2216
+ const AUTOMATIC_SIGNING = {
2217
+ CODE_SIGN_STYLE: "Automatic",
2218
+ PROVISIONING_PROFILE: "",
2219
+ PROVISIONING_PROFILE_SPECIFIER: "",
2220
+ CODE_SIGN_IDENTITY: "Apple Development"
2221
+ };
2222
+ /** The sandbox layer's sources, by the file names the podspec excludes. */
2223
+ const SANDBOX_SOURCES = "HotUpdaterSandbox*";
2224
+ /** What each of those sources is wrapped in: without it, they compile to nothing. */
2225
+ const SANDBOX_CONDITION = "HOT_UPDATER_SANDBOX_LAYER";
2226
+ /**
2227
+ * The settings a build sets over the scheme's: the MIN_BUNDLE_ID the CLI
2228
+ * issues and reports, and automatic signing when an App Store Connect API key
2229
+ * signs, which a target set to manual signing would not use.
2230
+ *
2231
+ * A sandbox scheme brings the sandbox layer's sources back, and sets the
2232
+ * condition they compile under, which nothing else sets: any build can undo the
2233
+ * podspec's exclusion, and those sources then compile to nothing. Any other scheme
2234
+ * excludes them again: a scheme's `xcconfig`, which outranks the pod's settings
2235
+ * on every target, would otherwise replace the podspec's exclusion, and the
2236
+ * layer would compile into a store build.
2237
+ */
2238
+ const buildSettingsOverScheme = ({ minBundleId, authenticationArgs, sandbox = false }) => ({
2239
+ HOT_UPDATER_MIN_BUNDLE_ID: minBundleId,
2240
+ ...authenticationArgs ? AUTOMATIC_SIGNING : {},
2241
+ ...sandbox ? {
2242
+ INCLUDED_SOURCE_FILE_NAMES: SANDBOX_SOURCES,
2243
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS: `$(inherited) ${SANDBOX_CONDITION}`,
2244
+ GCC_PREPROCESSOR_DEFINITIONS: `$(inherited) ${SANDBOX_CONDITION}=1`
2245
+ } : { EXCLUDED_SOURCE_FILE_NAMES: `$(inherited) ${SANDBOX_SOURCES}` }
2246
+ });
2247
+ //#endregion
2248
+ //#region src/builder/archiveXcodeProject.ts
2249
+ /**
2250
+ * Archives the scheme into `archiveDirectory`. The build's xcconfig is
2251
+ * written to a temporary directory, removed whether the archive succeeds or
2252
+ * not.
2253
+ */
2254
+ const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, header = [], minBundleId, authenticationArgs, sandbox }) => {
2255
+ if (installPods) await installPodsIfNeeded(sourceDir);
2256
+ const xcodeProject = await parseXcodeProjectInfo(sourceDir);
2257
+ const archiveName = `${xcodeProject.name.replace(".xcworkspace", "").replace(".xcodeproj", "")}.xcarchive`;
2258
+ const archivePath = path.default.join(archiveDirectory, archiveName);
2259
+ const tmpDir = await createRandomTmpDir();
2260
+ try {
2261
+ const build = await writeBuildXcconfig({
2262
+ directory: tmpDir,
2263
+ sourceDir,
2264
+ xcconfig,
2265
+ extraParams,
2266
+ settings: buildSettingsOverScheme({
2267
+ minBundleId,
2268
+ authenticationArgs,
2269
+ sandbox
2270
+ })
2271
+ });
2272
+ const archiveArgs = prepareArchiveArgs({
2273
+ authenticationArgs,
2274
+ minBundleId,
2275
+ archivePath,
2276
+ platform,
2277
+ sourceDir,
2278
+ xcodeProject,
2279
+ configuration,
2280
+ extraParams: build.extraParams,
2281
+ destination,
2282
+ xcconfig: build.xcconfig,
2283
+ xcodeScheme
2284
+ });
2285
+ try {
2286
+ const { logFilePath } = await runXcodebuildWithLogging({
2287
+ args: archiveArgs,
2288
+ env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
2289
+ title: `Archiving ${xcodeScheme}`,
2290
+ successMessage: `Archived ${xcodeScheme}`,
2291
+ failureMessage: "Archive failed",
2292
+ logPrefix,
2293
+ header: [
2294
+ ...header,
2295
+ `Project ${xcodeProject.name}`,
2296
+ `Scheme ${xcodeScheme}`,
2297
+ `Platform ${platform}`
2298
+ ],
2299
+ sourceDir
2300
+ });
2301
+ return {
2302
+ archivePath,
2303
+ logFilePath
2304
+ };
2305
+ } catch (error) {
2306
+ throw prettifyXcodebuildError(error);
2307
+ }
2308
+ } finally {
2309
+ await removeTmpDir(tmpDir);
2310
+ }
2311
+ };
2312
+ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId, authenticationArgs }) => {
2313
+ const args = [
2314
+ xcodeProject.isWorkspace ? "-workspace" : "-project",
2315
+ path.default.join(sourceDir, xcodeProject.name),
2316
+ "-scheme",
2317
+ xcodeScheme,
2318
+ "-configuration",
2319
+ configuration,
2320
+ "archive",
2321
+ "-archivePath",
2322
+ archivePath,
2323
+ `HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`
2324
+ ];
2325
+ if (authenticationArgs) args.push(...authenticationArgs);
2326
+ if (xcconfig) args.push("-xcconfig", xcconfig);
2327
+ if (extraParams) args.push(...extraParams);
2328
+ const resolvedDestinations = resolveDestinations({
2329
+ destinations: destination,
2330
+ useGeneric: true
2331
+ });
2332
+ if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
2333
+ deviceType: "device",
2334
+ platform,
2335
+ useGeneric: true
2336
+ }));
2337
+ for (const destination of resolvedDestinations) args.push("-destination", destination);
2338
+ return args;
2339
+ };
2340
+ //#endregion
2341
+ //#region src/builder/buildXcodeProject.ts
2342
+ /**
2343
+ * Builds the scheme in a temporary DerivedData directory, copies the built
2344
+ * `.app` to `outputDirectory`, and removes DerivedData, which takes 10 GB or
2345
+ * more, whether the build succeeds or not.
2346
+ */
2347
+ const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, xcconfig, extraParams, minBundleId, authenticationArgs, sandbox, header = [] }) => {
2348
+ if (installPods) await installPodsIfNeeded(sourceDir);
2349
+ const xcodeProject = await parseXcodeProjectInfo(sourceDir);
2350
+ const tmpDir = await createRandomTmpDir();
2351
+ try {
2352
+ const derivedDataPath = path.default.join(tmpDir, "DerivedData");
2353
+ const resolvedDestinations = resolveDestinations({
2354
+ destinations: destination,
2355
+ useGeneric: useGenericDestination
2356
+ });
2357
+ if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
2358
+ deviceType,
2359
+ platform,
2360
+ useGeneric: useGenericDestination
2361
+ }));
2362
+ const build = await writeBuildXcconfig({
2363
+ directory: tmpDir,
2364
+ sourceDir,
2365
+ xcconfig,
2366
+ extraParams,
2367
+ settings: buildSettingsOverScheme({
2368
+ minBundleId,
2369
+ authenticationArgs,
2370
+ sandbox
2371
+ })
2372
+ });
2373
+ const buildArgs = prepareBuildArgs({
2374
+ authenticationArgs,
2375
+ xcconfig: build.xcconfig,
2376
+ minBundleId,
2377
+ configuration,
2378
+ derivedDataPath,
2379
+ deviceType,
2380
+ resolvedDestinations,
2381
+ extraParams: build.extraParams,
2382
+ platform,
2383
+ xcodeScheme,
2384
+ sourceDir,
2385
+ xcodeProject
2386
+ });
2387
+ let builtAppPath;
2388
+ let logFilePath;
2389
+ try {
2390
+ ({logFilePath} = await runXcodebuildWithLogging({
2391
+ args: buildArgs,
2392
+ env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
2393
+ sourceDir,
2394
+ logPrefix,
2395
+ header: [
2396
+ ...header,
2397
+ `Project ${xcodeProject.name}`,
2398
+ `Scheme ${xcodeScheme}`,
2399
+ `Configuration ${configuration}`,
2400
+ `Platform ${platform}`,
2401
+ `Device type ${deviceType}`
2402
+ ],
2403
+ title: `Building ${xcodeScheme}`,
2404
+ successMessage: `Built ${xcodeScheme}`,
2405
+ failureMessage: "Build failed"
2406
+ }));
2407
+ ({appPath: builtAppPath} = await getBuildSettings({
2408
+ configuration,
2409
+ derivedDataPath,
2410
+ resolvedDestinations,
2411
+ xcodeScheme,
2412
+ sourceDir,
2413
+ xcodeProject,
2414
+ xcconfig: build.xcconfig,
2415
+ extraParams: build.extraParams
2416
+ }));
2417
+ } catch (error) {
2418
+ throw prettifyXcodebuildError(error);
2419
+ }
2420
+ const appPath = path.default.join(outputDirectory, path.default.basename(builtAppPath));
2421
+ await fs.default.promises.cp(builtAppPath, appPath, { recursive: true });
2422
+ return {
2423
+ appPath,
2424
+ logFilePath
2425
+ };
2426
+ } finally {
2427
+ await removeTmpDir(tmpDir);
2428
+ }
2429
+ };
2430
+ const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId, authenticationArgs, xcconfig }) => {
2431
+ const sdk = deviceType === "simulator" ? platformConfigs[platform].simulatorSdk : platformConfigs[platform].deviceSdk;
2432
+ const args = [
2433
+ xcodeProject.isWorkspace ? "-workspace" : "-project",
2434
+ path.default.join(sourceDir, xcodeProject.name),
2435
+ "-scheme",
2436
+ xcodeScheme,
2437
+ "-configuration",
2438
+ configuration,
2439
+ "-sdk",
2440
+ sdk,
2441
+ "-derivedDataPath",
2442
+ derivedDataPath,
2443
+ `HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`,
2444
+ "build"
2445
+ ];
2446
+ if (authenticationArgs) args.push(...authenticationArgs);
2447
+ args.push("-xcconfig", xcconfig);
2448
+ if (extraParams) args.push(...extraParams);
2449
+ for (const dest of resolvedDestinations) args.push("-destination", dest);
2450
+ return args;
2451
+ };
2452
+ const BUILD_SETTING = /^[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])*=/u;
2453
+ const getBuildSettings = async ({ xcodeProject, sourceDir, xcodeScheme, configuration, resolvedDestinations, derivedDataPath, xcconfig, extraParams }) => {
2454
+ const destinationArgs = resolvedDestinations.flatMap((dest) => ["-destination", dest]);
2455
+ const { stdout: buildSettings } = await (0, execa.execa)("xcodebuild", [
2456
+ xcodeProject.isWorkspace ? "-workspace" : "-project",
2457
+ xcodeProject.name,
2458
+ "-scheme",
2459
+ xcodeScheme,
2460
+ "-configuration",
2461
+ configuration,
2462
+ ...destinationArgs,
2463
+ "-derivedDataPath",
2464
+ derivedDataPath,
2465
+ "-xcconfig",
2466
+ xcconfig,
2467
+ ...extraParams.filter((param) => BUILD_SETTING.test(param)),
2468
+ "-showBuildSettings",
2469
+ "-json"
2470
+ ], { cwd: sourceDir });
2471
+ const settings = JSON.parse(buildSettings).filter(({ target }) => target !== "React" && target !== "React-Core");
2472
+ if (settings.length === 0) throw new Error("Failed to get build settings for your project");
2473
+ const targetSettings = settings[0].buildSettings;
2474
+ const wrapperExtension = targetSettings.WRAPPER_EXTENSION;
2475
+ if (wrapperExtension !== "app" && wrapperExtension !== "framework") throw new Error(`Expected wrapper extension to be "app" or "framework" but found: ${wrapperExtension}`);
2476
+ const targetBuildDir = targetSettings.TARGET_BUILD_DIR;
2477
+ const executableFolderPath = targetSettings.EXECUTABLE_FOLDER_PATH;
2478
+ const fullProductName = targetSettings.FULL_PRODUCT_NAME;
2479
+ const infoPlistPath = targetSettings.INFOPLIST_PATH;
2480
+ if (!targetBuildDir || !executableFolderPath || !fullProductName) throw new Error("Failed to get build paths from build settings");
2481
+ return {
2482
+ appPath: path.default.join(targetBuildDir, executableFolderPath),
2483
+ infoPlistPath: path.default.join(targetBuildDir, infoPlistPath)
2484
+ };
2485
+ };
2486
+ //#endregion
2487
+ //#region src/builder/exportXcodeArchive.ts
2488
+ /** Exports the archive into `exportPath`. */
2489
+ const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, logFilePath, title, authenticationArgs }) => {
2490
+ const exportArgs = prepareExportArgs({
2491
+ authenticationArgs,
2492
+ archivePath,
2493
+ exportPath,
2494
+ exportOptionsPlist,
2495
+ exportExtraParams
2496
+ });
2497
+ try {
2498
+ await runXcodebuildWithLogging({
2499
+ args: exportArgs,
2500
+ sourceDir,
2501
+ title: `Exporting ${title}`,
2502
+ successMessage: `Exported ${title}`,
2503
+ failureMessage: "Export failed",
2504
+ logPrefix,
2505
+ logFilePath
2506
+ });
2507
+ return { exportPath };
2508
+ } catch (error) {
2509
+ throw prettifyXcodebuildError(error);
2267
2510
  }
2268
2511
  };
2269
- /**
2270
- * Sends each part App Store Connect asked for: the bytes at its offset, with
2271
- * only the headers it lists, since the URLs are presigned: another header,
2272
- * or the App Store Connect token, would break their signature.
2273
- */
2274
- const uploadParts = async ({ ipaPath, operations, fetch }) => {
2275
- const file = await fs.default.promises.open(ipaPath, "r");
2512
+ const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist, authenticationArgs }) => {
2513
+ const args = [
2514
+ "-exportArchive",
2515
+ "-archivePath",
2516
+ archivePath,
2517
+ "-exportPath",
2518
+ exportPath
2519
+ ];
2520
+ if (authenticationArgs) args.push(...authenticationArgs);
2521
+ if (exportExtraParams) args.push(...exportExtraParams);
2522
+ args.push("-exportOptionsPlist", exportOptionsPlist);
2523
+ return args;
2524
+ };
2525
+ //#endregion
2526
+ //#region src/utils/assertXcodebuildExist.ts
2527
+ const assertXcodebuildExist = async () => {
2276
2528
  try {
2277
- await runConcurrently(operations, UPLOAD_CONCURRENCY, async (operation, index) => {
2278
- const body = Buffer.alloc(operation.length);
2279
- const { bytesRead } = await file.read(body, 0, operation.length, operation.offset);
2280
- if (bytesRead !== operation.length) throw new Error(`${path.default.basename(ipaPath)} changed while it was uploading.`);
2281
- const headers = {};
2282
- for (const { name, value } of operation.requestHeaders ?? []) headers[name] = value;
2283
- for (let attempt = 1;; attempt++) {
2284
- const response = await fetch(operation.url, {
2285
- method: operation.method,
2286
- headers,
2287
- body
2288
- }).catch((error) => error);
2289
- if (!(response instanceof Error) && response.ok) return;
2290
- if (!(response instanceof Error || isRetryableStatus(response.status)) || attempt === UPLOAD_ATTEMPTS) throw new Error(`Uploading part ${index + 1} of ${operations.length} failed: ${response instanceof Error ? response.message : `HTTP ${response.status}`}.`);
2291
- await sleep(retryDelayMs(response, attempt));
2292
- }
2293
- });
2294
- } finally {
2295
- await file.close();
2529
+ await (0, execa.execa)("which", ["xcodebuild"]);
2530
+ } catch {
2531
+ _hot_updater_cli_tools.p.log.error(`${_hot_updater_cli_tools.colors.blueBright("xcodebuild")} resolve failed. Ensure that xcode is installed on your machine or xcodebuild is included in your path`);
2532
+ process.exit(1);
2296
2533
  }
2297
2534
  };
2298
2535
  //#endregion
@@ -2523,7 +2760,7 @@ var AppStoreConnectRefusal = class extends Error {
2523
2760
  this.agreement = agreement;
2524
2761
  }
2525
2762
  };
2526
- const refusal = async (action, response) => {
2763
+ const refusal$1 = async (action, response) => {
2527
2764
  const body = await response.json().catch(() => void 0);
2528
2765
  return new AppStoreConnectRefusal(`${action} failed (HTTP ${response.status}).`, isProgramLicenseAgreementError(Array.isArray(body?.errors) ? body.errors : []));
2529
2766
  };
@@ -2561,7 +2798,7 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
2561
2798
  })}`;
2562
2799
  while (next) {
2563
2800
  const response = await fetch(next, { headers });
2564
- if (!response.ok) throw await refusal("Listing the team's certificates", response);
2801
+ if (!response.ok) throw await refusal$1("Listing the team's certificates", response);
2565
2802
  const body = await response.json();
2566
2803
  for (const { id, attributes } of body.data ?? []) if (attributes?.serialNumber) idsBySerial.set(normalizeSerial(attributes.serialNumber), id);
2567
2804
  next = body.links?.next;
@@ -2575,7 +2812,7 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
2575
2812
  });
2576
2813
  if (response.ok || response.status === 404) revoked.push(certificate);
2577
2814
  else {
2578
- agreement ||= (await refusal("Revoking", response)).agreement;
2815
+ agreement ||= (await refusal$1("Revoking", response)).agreement;
2579
2816
  failed.push({
2580
2817
  certificate,
2581
2818
  status: response.status
@@ -2587,111 +2824,711 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
2587
2824
  agreement ||= error instanceof AppStoreConnectRefusal && error.agreement;
2588
2825
  for (const certificate of certificates) if (!revoked.includes(certificate)) failed.push({ certificate });
2589
2826
  }
2590
- const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
2591
- 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"));
2592
- if (failed.length > 0) _hot_updater_cli_tools.p.log.warn([
2593
- "Couldn't revoke the certificates Xcode created for this build.",
2594
- ...reason === void 0 ? [] : [reason],
2595
- ...agreement ? [PROGRAM_LICENSE_AGREEMENT_HINT] : [],
2596
- ...failed.flatMap(({ certificate, status }) => [certificate.commonName, `Serial ${certificate.serialNumber}${status === void 0 ? "" : ` (HTTP ${status})`}`]),
2597
- CERTIFICATES_URL
2598
- ].join("\n"));
2827
+ const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
2828
+ 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"));
2829
+ if (failed.length > 0) _hot_updater_cli_tools.p.log.warn([
2830
+ "Couldn't revoke the certificates Xcode created for this build.",
2831
+ ...reason === void 0 ? [] : [reason],
2832
+ ...agreement ? [PROGRAM_LICENSE_AGREEMENT_HINT] : [],
2833
+ ...failed.flatMap(({ certificate, status }) => [certificate.commonName, `Serial ${certificate.serialNumber}${status === void 0 ? "" : ` (HTTP ${status})`}`]),
2834
+ CERTIFICATES_URL
2835
+ ].join("\n"));
2836
+ return {
2837
+ revoked: revoked.map(describe),
2838
+ failed: failed.map(({ certificate }) => describe(certificate))
2839
+ };
2840
+ };
2841
+ const TERMINATION_SIGNALS = [
2842
+ "SIGHUP",
2843
+ "SIGINT",
2844
+ "SIGTERM"
2845
+ ];
2846
+ /**
2847
+ * Runs `run`, the archive and export of a build that signs with an App
2848
+ * Store Connect API key, in a keychain of its own when `enabled` (in CI),
2849
+ * then revokes the certificates Xcode created in it and deletes it. A
2850
+ * termination signal does the same before the process ends with it: the
2851
+ * certificates are read from the keychain before it is deleted, and the key
2852
+ * is read into memory first, since the build's temporary key file is deleted
2853
+ * on the signal. A second signal restores the keychains at once.
2854
+ *
2855
+ * `keepDistribution` keeps a distribution certificate out of the revoking, for
2856
+ * an ad hoc export: Xcode may have signed the `.ipa` with one it created here,
2857
+ * rather than the cloud-managed one, and installs may need it valid. Only the
2858
+ * development certificates Xcode created for the archive are then revoked.
2859
+ */
2860
+ const withSigningKeychain = async ({ enabled, keepDistribution = false, apiKey, run, security, lockPath, acquireLock, fetch }) => {
2861
+ if (!enabled) return run();
2862
+ const privateKey = crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8"));
2863
+ const keychain = await createSigningKeychain({
2864
+ security,
2865
+ lockPath,
2866
+ acquireLock
2867
+ });
2868
+ _hot_updater_cli_tools.p.log.info("Signing in a keychain of this build's own (CI is set).");
2869
+ let cleanup;
2870
+ const release = () => {
2871
+ cleanup ??= (async () => {
2872
+ const certificates = await keychain.certificates().catch(() => []);
2873
+ await keychain.dispose();
2874
+ await revokeKeychainCertificates({
2875
+ certificates: keepDistribution ? certificates.filter(({ commonName }) => !commonName.includes("Distribution")) : certificates,
2876
+ apiKey,
2877
+ privateKey,
2878
+ fetch
2879
+ });
2880
+ })();
2881
+ return cleanup;
2882
+ };
2883
+ const stopListening = () => {
2884
+ for (const signal of TERMINATION_SIGNALS) process.off(signal, onSignal);
2885
+ };
2886
+ const onSignal = (signal) => {
2887
+ if (cleanup) {
2888
+ stopListening();
2889
+ restoreLive();
2890
+ process.kill(process.pid, signal);
2891
+ return;
2892
+ }
2893
+ release().finally(() => {
2894
+ stopListening();
2895
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
2896
+ });
2897
+ };
2898
+ for (const signal of TERMINATION_SIGNALS) process.on(signal, onSignal);
2899
+ try {
2900
+ return await run();
2901
+ } finally {
2902
+ await release();
2903
+ stopListening();
2904
+ }
2905
+ };
2906
+ //#endregion
2907
+ //#region src/utils/readBuiltApp.ts
2908
+ const readInfoValue = async (infoPlist, key) => {
2909
+ const { stdout, exitCode } = await (0, execa.execa)("plutil", [
2910
+ "-extract",
2911
+ key,
2912
+ "raw",
2913
+ "-o",
2914
+ "-",
2915
+ infoPlist
2916
+ ], { reject: false });
2917
+ return exitCode === 0 && stdout.trim() ? stdout.trim() : null;
2918
+ };
2919
+ /**
2920
+ * What the built `.app` reports at runtime, from its Info.plist: the
2921
+ * `CFBundleShortVersionString`, `CFBundleVersion` (the build number) and
2922
+ * `HOT_UPDATER_CHANNEL` the build settings,
2923
+ * an `xcconfig` and `extraParams` expanded.
2924
+ */
2925
+ const readBuiltApp = async (appPath) => {
2926
+ const infoPlist = path.default.join(appPath, "Info.plist");
2927
+ const [appVersion, channel, buildNumber] = await Promise.all([
2928
+ readInfoValue(infoPlist, "CFBundleShortVersionString"),
2929
+ readInfoValue(infoPlist, "HOT_UPDATER_CHANNEL"),
2930
+ readInfoValue(infoPlist, "CFBundleVersion")
2931
+ ]);
2932
+ return {
2933
+ appVersion,
2934
+ channel,
2935
+ buildNumber
2936
+ };
2937
+ };
2938
+ //#endregion
2939
+ //#region src/sandboxTestFlight.ts
2940
+ /**
2941
+ * The states of a version Apple hasn't approved, which still takes builds.
2942
+ * Any other state, such as one Apple adds later, counts as closed: a raise
2943
+ * costs a sandbox build nothing, and an upload to a closed train costs one.
2944
+ */
2945
+ const OPEN_VERSION_STATES = /* @__PURE__ */ new Set([
2946
+ "PREPARE_FOR_SUBMISSION",
2947
+ "READY_FOR_REVIEW",
2948
+ "WAITING_FOR_REVIEW",
2949
+ "IN_REVIEW",
2950
+ "WAITING_FOR_EXPORT_COMPLIANCE",
2951
+ "PENDING_CONTRACT",
2952
+ "INVALID_BINARY",
2953
+ "METADATA_REJECTED",
2954
+ "REJECTED",
2955
+ "DEVELOPER_REJECTED"
2956
+ ]);
2957
+ /** A version as numbers: `1.6.2`, or null for one that isn't only numbers. */
2958
+ const parseVersion = (version) => /^\d+(\.\d+)*$/u.test(version) ? version.split(".").map(Number) : null;
2959
+ /** Negative, zero or positive as `a` is below, equal to or above `b`. */
2960
+ const compareVersions = (a, b) => {
2961
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
2962
+ const difference = (a[i] ?? 0) - (b[i] ?? 0);
2963
+ if (difference !== 0) return difference;
2964
+ }
2965
+ return 0;
2966
+ };
2967
+ /** The highest of `versions`, ignoring any that aren't only numbers. */
2968
+ const highestVersion = (versions) => {
2969
+ let highest = null;
2970
+ for (const text of versions) {
2971
+ const parts = parseVersion(text);
2972
+ if (parts && (!highest || compareVersions(parts, highest.parts) > 0)) highest = {
2973
+ text,
2974
+ parts
2975
+ };
2976
+ }
2977
+ return highest?.text ?? null;
2978
+ };
2979
+ /** The highest integer build number among `versions`. */
2980
+ const highestBuildNumberOf = (versions) => {
2981
+ const numbers = versions.filter((version) => /^\d+$/u.test(version)).map(Number);
2982
+ return numbers.length > 0 ? Math.max(...numbers) : null;
2983
+ };
2984
+ /**
2985
+ * Reads what a sandbox build must fit before it is built: the versions Apple
2986
+ * approved, from `/v1/appStoreVersions`, since a closed train's builds look
2987
+ * like any others in `/v1/builds`, and the app's highest build number.
2988
+ */
2989
+ const readAppStoreTrain = async ({ bundleId, apiKey, fetch = globalThis.fetch }) => {
2990
+ const privateKey = await readPrivateKey(apiKey.keyPath);
2991
+ const request = createAppStoreConnectRequest({
2992
+ apiKey,
2993
+ privateKey,
2994
+ fetch
2995
+ });
2996
+ const { app } = await findApp(request, bundleId);
2997
+ const [{ data: versions }, { data: builds }] = await Promise.all([retrying(() => request("Reading the App Store versions", "GET", `/v1/apps/${app.id}/appStoreVersions?${new URLSearchParams({
2998
+ "filter[platform]": "IOS",
2999
+ "fields[appStoreVersions]": "versionString,appVersionState,appStoreState",
3000
+ limit: "200"
3001
+ })}`)), retrying(() => request("Reading the app's builds", "GET", `/v1/builds?${new URLSearchParams({
3002
+ "filter[app]": app.id,
3003
+ sort: "-uploadedDate",
3004
+ "fields[builds]": "version",
3005
+ limit: "200"
3006
+ })}`))]);
3007
+ const closed = versions.flatMap(({ attributes }) => {
3008
+ const state = attributes?.appVersionState ?? attributes?.appStoreState;
3009
+ return attributes?.versionString && !(state && OPEN_VERSION_STATES.has(state)) ? [attributes.versionString] : [];
3010
+ });
3011
+ return {
3012
+ highestClosedVersion: highestVersion(closed),
3013
+ highestBuildNumber: highestBuildNumberOf(builds.flatMap(({ attributes }) => attributes?.version ?? []))
3014
+ };
3015
+ };
3016
+ /**
3017
+ * What a sandbox device build needs from App Store Connect, read before the
3018
+ * build. An export for elsewhere has no train, and without an API key it
3019
+ * can't be read: the store would say so after the upload. What a closed train
3020
+ * does to the build waits for the archive (`patchSandboxArchive`): only the
3021
+ * archived app holds the version an `xcconfig` or `extraParams` settled on,
3022
+ * which the project's files don't.
3023
+ */
3024
+ const prepareSandboxArchive = async ({ schemeConfig, apiKey, updateStrategy, fetch }) => {
3025
+ if (!await exportsForAppStoreConnect(schemeConfig)) return { updateStrategy };
3026
+ if (!apiKey) {
3027
+ _hot_updater_cli_tools.p.log.warn("No App Store Connect API key, so the sandbox build can't check for a closed version.\nSet signing.ascApiKey on the scheme.");
3028
+ return { updateStrategy };
3029
+ }
3030
+ return {
3031
+ train: await readAppStoreTrain({
3032
+ bundleId: schemeConfig.bundleIdentifier,
3033
+ apiKey,
3034
+ ...fetch ? { fetch } : {}
3035
+ }),
3036
+ updateStrategy
3037
+ };
3038
+ };
3039
+ /** Whether Apple refuses a build of `version`, as `train` says. */
3040
+ const isVersionTrainClosed = (version, { highestClosedVersion }) => {
3041
+ const parts = parseVersion(version);
3042
+ const closed = highestClosedVersion === null ? null : parseVersion(highestClosedVersion);
3043
+ return parts !== null && closed !== null && compareVersions(parts, closed) <= 0;
3044
+ };
3045
+ /**
3046
+ * What a sandbox build does with a closed version train. Under the fingerprint
3047
+ * strategy it raises its own version past the closed one, which targets no
3048
+ * update. Under appVersion the version is what updates target, so it refuses
3049
+ * rather than move them silently.
3050
+ */
3051
+ const sandboxVersionDecision = ({ version, train, updateStrategy }) => {
3052
+ if (!isVersionTrainClosed(version, train)) return { kind: "keep" };
3053
+ const closed = train.highestClosedVersion;
3054
+ if (updateStrategy === "appVersion") return {
3055
+ kind: "refuse",
3056
+ message: `App Store Connect closed version ${closed}, so TestFlight refuses ${version}.\nRaise the app version.`
3057
+ };
3058
+ const [major = 0, minor = 0, patch = 0] = parseVersion(closed);
3059
+ return {
3060
+ kind: "raise",
3061
+ version: `${major}.${minor}.${patch + 1}`
3062
+ };
3063
+ };
3064
+ /**
3065
+ * Refuses, before the upload, a sandbox `.ipa` Xcode didn't mark for internal
3066
+ * testing only: App Store Connect would let it reach App Store review.
3067
+ */
3068
+ const assertIpaIsInternalOnly = async (ipaPath) => {
3069
+ if (!await readIpaInternalTestingOnly(ipaPath)) throw new Error(`${path.default.basename(ipaPath)} isn't marked for internal testing only (TFInternalTestingOnly), so it wasn't uploaded.\nExport it with testFlightInternalTestingOnly.`);
3070
+ };
3071
+ /** How long App Store Connect may take to list a build it accepted. */
3072
+ const AUDIENCE_TIMEOUT_MS = 3e5;
3073
+ const STATE_OF_INTERNAL_BUILD = {
3074
+ MISSING_EXPORT_COMPLIANCE: "missing-compliance",
3075
+ IN_EXPORT_COMPLIANCE_REVIEW: "missing-compliance",
3076
+ PROCESSING: "processing",
3077
+ PROCESSING_EXCEPTION: "failed",
3078
+ EXPIRED: "expired"
3079
+ };
3080
+ /**
3081
+ * One read of a version and build number: its audience, processing state,
3082
+ * expiry, and internal testing state. Null while App Store Connect lists none.
3083
+ */
3084
+ const readBuildStatus = async (request, { appId, shortVersion, bundleVersion }) => {
3085
+ const { data, included } = await retrying(() => request("Reading the build", "GET", `/v1/builds?${new URLSearchParams({
3086
+ "filter[app]": appId,
3087
+ "filter[version]": bundleVersion,
3088
+ ...shortVersion === null ? {} : { "filter[preReleaseVersion.version]": shortVersion },
3089
+ "fields[builds]": "buildAudienceType,processingState,expirationDate,expired,buildBetaDetail",
3090
+ include: "buildBetaDetail",
3091
+ "fields[buildBetaDetails]": "internalBuildState",
3092
+ limit: "1"
3093
+ })}`));
3094
+ const build = data[0];
3095
+ if (!build) return null;
3096
+ const internal = included?.find(({ type }) => type === "buildBetaDetails")?.attributes?.internalBuildState;
3097
+ const processing = build.attributes?.processingState;
3098
+ const expiration = build.attributes?.expirationDate;
3099
+ const state = build.attributes?.expired ? "expired" : processing === "PROCESSING" || !processing ? "processing" : processing !== "VALID" ? "failed" : STATE_OF_INTERNAL_BUILD[internal ?? ""] ?? "ready";
3100
+ return {
3101
+ buildId: build.id,
3102
+ audience: build.attributes?.buildAudienceType ?? null,
3103
+ state,
3104
+ ...state === "missing-compliance" && internal === "IN_EXPORT_COMPLIANCE_REVIEW" ? { inExportComplianceReview: true } : {},
3105
+ expiresAtMs: expiration ? Date.parse(expiration) : null
3106
+ };
3107
+ };
3108
+ /**
3109
+ * Fails unless App Store Connect holds the uploaded sandbox build for internal
3110
+ * testing only, as `buildAudienceType` says once the build is listed, and
3111
+ * returns what that read says of it.
3112
+ */
3113
+ const assertBuildIsInternalOnly = async ({ ipaPath, apiKey, fetch = globalThis.fetch, pollIntervalMs = 5e3, timeoutMs = AUDIENCE_TIMEOUT_MS }) => {
3114
+ const [info, privateKey] = await Promise.all([readIpaInfo(ipaPath), readPrivateKey(apiKey.keyPath)]);
3115
+ const request = createAppStoreConnectRequest({
3116
+ apiKey,
3117
+ privateKey,
3118
+ fetch
3119
+ });
3120
+ const { app, testFlightUrl } = await findApp(request, info.bundleId);
3121
+ const label = `${info.shortVersion} (${info.bundleVersion})`;
3122
+ const deadline = Date.now() + timeoutMs;
3123
+ for (;;) {
3124
+ const status = await readBuildStatus(request, {
3125
+ appId: app.id,
3126
+ shortVersion: info.shortVersion,
3127
+ bundleVersion: info.bundleVersion
3128
+ });
3129
+ if (status?.audience === "INTERNAL_ONLY") return {
3130
+ ...status,
3131
+ appId: app.id,
3132
+ testFlightUrl
3133
+ };
3134
+ if (status?.audience) throw new Error(`App Store Connect holds sandbox build ${label} for ${status.audience}, not INTERNAL_ONLY.\nExpire it in TestFlight:\n${testFlightUrl}`);
3135
+ if (Date.now() >= deadline) throw new Error(`App Store Connect didn't say who sandbox build ${label} is for.\nCheck that it is internal only in TestFlight:\n${testFlightUrl}`);
3136
+ await sleep(pollIntervalMs);
3137
+ }
3138
+ };
3139
+ /**
3140
+ * The internal TestFlight group named `name`, read before anything is built.
3141
+ * An external group is refused: its builds go to Beta App Review, and a sandbox
3142
+ * build is for internal testing only.
3143
+ */
3144
+ const findInternalBetaGroup = async ({ bundleId, name, apiKey, fetch = globalThis.fetch }) => {
3145
+ const privateKey = await readPrivateKey(apiKey.keyPath);
3146
+ const request = createAppStoreConnectRequest({
3147
+ apiKey,
3148
+ privateKey,
3149
+ fetch
3150
+ });
3151
+ const { app, testFlightUrl } = await findApp(request, bundleId);
3152
+ return readInternalBetaGroup(request, {
3153
+ appId: app.id,
3154
+ bundleId,
3155
+ name,
3156
+ testFlightUrl
3157
+ });
3158
+ };
3159
+ /** The group named `name` of an app, refused unless it is internal. */
3160
+ const readInternalBetaGroup = async (request, { appId, bundleId, name, testFlightUrl }) => {
3161
+ const { data } = await retrying(() => request("Reading the TestFlight groups", "GET", `/v1/betaGroups?${new URLSearchParams({
3162
+ "filter[app]": appId,
3163
+ "filter[name]": name,
3164
+ "fields[betaGroups]": "name,isInternalGroup,hasAccessToAllBuilds",
3165
+ limit: "200"
3166
+ })}`));
3167
+ const group = data.find(({ attributes }) => attributes?.name === name);
3168
+ if (!group) throw new Error(`App Store Connect has no TestFlight group ${name} for ${bundleId}.\nCreate it as an internal group:\n${testFlightUrl}`);
3169
+ if (group.attributes?.isInternalGroup !== true) throw new Error(`TestFlight group ${name} is external, and a sandbox build is for internal testing only.\nName an internal group.`);
2599
3170
  return {
2600
- revoked: revoked.map(describe),
2601
- failed: failed.map(({ certificate }) => describe(certificate))
3171
+ id: group.id,
3172
+ name,
3173
+ hasAccessToAllBuilds: group.attributes.hasAccessToAllBuilds === true
2602
3174
  };
2603
3175
  };
2604
- const TERMINATION_SIGNALS = [
2605
- "SIGHUP",
2606
- "SIGINT",
2607
- "SIGTERM"
2608
- ];
3176
+ /** How long a build's processing may hold up its group and What to Test. */
3177
+ const DISTRIBUTION_TIMEOUT_MS = 9e5;
3178
+ /** What to Test's locale: the language the CLI writes it in. */
3179
+ const WHAT_TO_TEST_LOCALE = "en-US";
2609
3180
  /**
2610
- * Runs `run`, the archive and export of a build that signs with an App
2611
- * Store Connect API key, in a keychain of its own when `enabled` (in CI),
2612
- * then revokes the certificates Xcode created in it and deletes it. A
2613
- * termination signal does the same before the process ends with it: the
2614
- * certificates are read from the keychain before it is deleted, and the key
2615
- * is read into memory first, since the build's temporary key file is deleted
2616
- * on the signal. A second signal restores the keychains at once.
3181
+ * Adds a processed build to its internal group and sets its What to Test, which
3182
+ * App Store Connect takes once the build is processed. A build still processing
3183
+ * after `timeoutMs` keeps its upload: a warning says what to do by hand. Returns
3184
+ * the build's state.
2617
3185
  */
2618
- const withSigningKeychain = async ({ enabled, apiKey, run, security, lockPath, acquireLock, fetch }) => {
2619
- if (!enabled) return run();
2620
- const privateKey = crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8"));
2621
- const keychain = await createSigningKeychain({
2622
- security,
2623
- lockPath,
2624
- acquireLock
3186
+ const distributeTestFlightBuild = async ({ build: { appId, shortVersion, bundleVersion, testFlightUrl }, group, whatToTest, apiKey, fetch = globalThis.fetch, pollIntervalMs = 15e3, timeoutMs = DISTRIBUTION_TIMEOUT_MS }) => {
3187
+ const privateKey = await readPrivateKey(apiKey.keyPath);
3188
+ const request = createAppStoreConnectRequest({
3189
+ apiKey,
3190
+ privateKey,
3191
+ fetch
2625
3192
  });
2626
- _hot_updater_cli_tools.p.log.info("Signing in a keychain of this build's own (CI is set).");
2627
- let cleanup;
2628
- const release = () => {
2629
- cleanup ??= (async () => {
2630
- const certificates = await keychain.certificates().catch(() => []);
2631
- await keychain.dispose();
2632
- await revokeKeychainCertificates({
2633
- certificates,
2634
- apiKey,
2635
- privateKey,
2636
- fetch
2637
- });
2638
- })();
2639
- return cleanup;
3193
+ const build = {
3194
+ appId,
3195
+ shortVersion,
3196
+ bundleVersion,
3197
+ testFlightUrl
2640
3198
  };
2641
- const stopListening = () => {
2642
- for (const signal of TERMINATION_SIGNALS) process.off(signal, onSignal);
2643
- };
2644
- const onSignal = (signal) => {
2645
- if (cleanup) {
2646
- stopListening();
2647
- restoreLive();
2648
- process.kill(process.pid, signal);
2649
- return;
3199
+ const status = await waitUntilProcessed(request, build, await readBuildStatus(request, build), {
3200
+ pollIntervalMs,
3201
+ timeoutMs,
3202
+ unset: "its testers and What to Test"
3203
+ });
3204
+ if (!status || status.state === "processing") return status;
3205
+ const { buildId } = status;
3206
+ if (group && !group.hasAccessToAllBuilds) await addBuildToGroup(request, build, buildId, group);
3207
+ if (whatToTest !== void 0) await setWhatToTest(request, buildId, whatToTest);
3208
+ return status;
3209
+ };
3210
+ /**
3211
+ * Reads the build until App Store Connect has processed it, from the status a
3212
+ * read already has, for what it takes only then. A build still processing after
3213
+ * `timeoutMs` keeps its upload: a warning says what is `unset`. One that can't
3214
+ * be processed fails.
3215
+ */
3216
+ const waitUntilProcessed = async (request, build, listed, { pollIntervalMs, timeoutMs, unset }) => {
3217
+ const label = `${build.shortVersion} (${build.bundleVersion})`;
3218
+ const deadline = Date.now() + timeoutMs;
3219
+ let status = listed;
3220
+ while (!status || status.state === "processing") {
3221
+ if (Date.now() >= deadline) {
3222
+ _hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${label}, so ${unset} aren't set.\nSet them in TestFlight:\n${build.testFlightUrl}`);
3223
+ return status;
2650
3224
  }
2651
- release().finally(() => {
2652
- stopListening();
2653
- if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
2654
- });
3225
+ await sleep(pollIntervalMs);
3226
+ status = await readBuildStatus(request, build);
3227
+ }
3228
+ if (status.state === "failed") throw new Error(`App Store Connect couldn't process build ${label}.\nCheck it in TestFlight:\n${build.testFlightUrl}`);
3229
+ return status;
3230
+ };
3231
+ const addBuildToGroup = (request, { shortVersion, bundleVersion }, buildId, group) => retrying(() => request(`Adding build ${shortVersion} (${bundleVersion}) to ${group.name}`, "POST", `/v1/betaGroups/${group.id}/relationships/builds`, { data: [{
3232
+ type: "builds",
3233
+ id: buildId
3234
+ }] }));
3235
+ /**
3236
+ * Applies an internal group to a build already on TestFlight, as an upload does,
3237
+ * so a reused build reaches the group named now. One process reads the app, the
3238
+ * build and the group once each. Reading a build's `betaGroups` relationship
3239
+ * answered 403 when we tried it, so this doesn't check membership first: every
3240
+ * run adds the build. A build not `isOffered` gets nothing: it needs a new
3241
+ * build, which checks the group itself. A build still processing waits as an
3242
+ * upload's does, and the next run applies the group again.
3243
+ */
3244
+ const applyTestFlightGroup = async ({ bundleId, shortVersion, bundleVersion, name, isOffered, apiKey, fetch = globalThis.fetch, pollIntervalMs = 15e3, timeoutMs = DISTRIBUTION_TIMEOUT_MS }) => {
3245
+ const privateKey = await readPrivateKey(apiKey.keyPath);
3246
+ const request = createAppStoreConnectRequest({
3247
+ apiKey,
3248
+ privateKey,
3249
+ fetch
3250
+ });
3251
+ const { app, testFlightUrl } = await findApp(request, bundleId);
3252
+ const build = {
3253
+ appId: app.id,
3254
+ shortVersion,
3255
+ bundleVersion,
3256
+ testFlightUrl
2655
3257
  };
2656
- for (const signal of TERMINATION_SIGNALS) process.on(signal, onSignal);
3258
+ const listed = await readBuildStatus(request, build);
3259
+ if (!isOffered(listed)) return {
3260
+ testFlightUrl,
3261
+ status: listed,
3262
+ outcome: "not-offered"
3263
+ };
3264
+ const group = await readInternalBetaGroup(request, {
3265
+ appId: app.id,
3266
+ bundleId,
3267
+ name,
3268
+ testFlightUrl
3269
+ });
3270
+ if (group.hasAccessToAllBuilds) return {
3271
+ testFlightUrl,
3272
+ status: listed,
3273
+ outcome: "takes-every-build"
3274
+ };
3275
+ const status = await waitUntilProcessed(request, build, listed, {
3276
+ pollIntervalMs,
3277
+ timeoutMs,
3278
+ unset: "its testers"
3279
+ });
3280
+ if (!status || status.state === "processing") return {
3281
+ testFlightUrl,
3282
+ status,
3283
+ outcome: "pending"
3284
+ };
3285
+ await addBuildToGroup(request, build, status.buildId, group);
3286
+ return {
3287
+ testFlightUrl,
3288
+ status,
3289
+ outcome: "added"
3290
+ };
3291
+ };
3292
+ /** Sets a build's What to Test: creates it, or updates the one a rerun left. */
3293
+ const setWhatToTest = async (request, buildId, whatsNew) => {
2657
3294
  try {
2658
- return await run();
2659
- } finally {
2660
- await release();
2661
- stopListening();
3295
+ await retrying(() => request("Setting What to Test", "POST", "/v1/betaBuildLocalizations", { data: {
3296
+ type: "betaBuildLocalizations",
3297
+ attributes: {
3298
+ locale: WHAT_TO_TEST_LOCALE,
3299
+ whatsNew
3300
+ },
3301
+ relationships: { build: { data: {
3302
+ type: "builds",
3303
+ id: buildId
3304
+ } } }
3305
+ } }));
3306
+ } catch (error) {
3307
+ if (!(error instanceof AppStoreConnectError) || error.status !== 409) throw error;
3308
+ const { data } = await retrying(() => request("Reading What to Test", "GET", `/v1/builds/${buildId}/betaBuildLocalizations?${new URLSearchParams({
3309
+ "fields[betaBuildLocalizations]": "locale",
3310
+ limit: "200"
3311
+ })}`));
3312
+ const existing = data.find(({ attributes }) => attributes?.locale === WHAT_TO_TEST_LOCALE);
3313
+ if (!existing) throw error;
3314
+ await retrying(() => request("Setting What to Test", "PATCH", `/v1/betaBuildLocalizations/${existing.id}`, { data: {
3315
+ type: "betaBuildLocalizations",
3316
+ id: existing.id,
3317
+ attributes: { whatsNew }
3318
+ } }));
2662
3319
  }
2663
3320
  };
3321
+ /**
3322
+ * What App Store Connect says of a build a run recorded as uploaded, which a
3323
+ * later run reuses: its page, and its state and expiry, or a null status when
3324
+ * it no longer lists the build.
3325
+ */
3326
+ const readTestFlightBuildStatus = async ({ bundleId, shortVersion, bundleVersion, apiKey, fetch = globalThis.fetch }) => {
3327
+ const privateKey = await readPrivateKey(apiKey.keyPath);
3328
+ const request = createAppStoreConnectRequest({
3329
+ apiKey,
3330
+ privateKey,
3331
+ fetch
3332
+ });
3333
+ const { app, testFlightUrl } = await findApp(request, bundleId);
3334
+ return {
3335
+ testFlightUrl,
3336
+ status: await readBuildStatus(request, {
3337
+ appId: app.id,
3338
+ shortVersion,
3339
+ bundleVersion
3340
+ })
3341
+ };
3342
+ };
2664
3343
  //#endregion
2665
- //#region src/utils/readBuiltApp.ts
2666
- const readInfoValue = async (infoPlist, key) => {
3344
+ //#region src/utils/sandboxInfoPlist.ts
3345
+ const URL_TYPE_NAME = "hot-updater-sandbox";
3346
+ const CAMERA_USAGE = "Scans the QR code of a preview to open it in this build.";
3347
+ const LOCAL_NETWORK_USAGE = "Connects to the dev server you choose on this network.";
3348
+ const ATS_KEY = "NSAppTransportSecurity";
3349
+ const ATS_LOCAL_NETWORKING_KEY = "NSAllowsLocalNetworking";
3350
+ /** A refusal the command prints as it is: the build ran as it should. */
3351
+ const refusal = (message) => new _hot_updater_cli_tools.BuildFailedError(message, {
3352
+ logPath: null,
3353
+ summary: [],
3354
+ hint: message
3355
+ });
3356
+ const plutil = (args) => (0, execa.execa)("plutil", args);
3357
+ const readValue = async (plist, key) => {
2667
3358
  const { stdout, exitCode } = await (0, execa.execa)("plutil", [
2668
3359
  "-extract",
2669
3360
  key,
2670
3361
  "raw",
2671
3362
  "-o",
2672
3363
  "-",
2673
- infoPlist
3364
+ plist
2674
3365
  ], { reject: false });
2675
3366
  return exitCode === 0 && stdout.trim() ? stdout.trim() : null;
2676
3367
  };
3368
+ const setString = (plist, key, value) => plutil([
3369
+ "-replace",
3370
+ key,
3371
+ "-string",
3372
+ value,
3373
+ plist
3374
+ ]);
3375
+ /** Appends a URL type with the sandbox's scheme to the app's own. */
3376
+ const addUrlScheme = async (plist, urlScheme) => {
3377
+ const urlType = JSON.stringify({
3378
+ CFBundleURLName: URL_TYPE_NAME,
3379
+ CFBundleURLSchemes: [urlScheme]
3380
+ });
3381
+ const { stdout, exitCode } = await (0, execa.execa)("plutil", [
3382
+ "-extract",
3383
+ "CFBundleURLTypes",
3384
+ "json",
3385
+ "-o",
3386
+ "-",
3387
+ plist
3388
+ ], { reject: false });
3389
+ if (exitCode !== 0) {
3390
+ await plutil([
3391
+ "-insert",
3392
+ "CFBundleURLTypes",
3393
+ "-json",
3394
+ `[${urlType}]`,
3395
+ plist
3396
+ ]);
3397
+ return;
3398
+ }
3399
+ const existing = JSON.parse(stdout);
3400
+ await plutil([
3401
+ "-insert",
3402
+ `CFBundleURLTypes.${existing.length}`,
3403
+ "-json",
3404
+ urlType,
3405
+ plist
3406
+ ]);
3407
+ };
3408
+ const hasKey = async (plist, key) => {
3409
+ const { exitCode } = await (0, execa.execa)("plutil", [
3410
+ "-extract",
3411
+ key,
3412
+ "xml1",
3413
+ "-o",
3414
+ "-",
3415
+ plist
3416
+ ], { reject: false });
3417
+ return exitCode === 0;
3418
+ };
2677
3419
  /**
2678
- * What the built `.app` reports at runtime, from its Info.plist: the
2679
- * `CFBundleShortVersionString`, `CFBundleVersion` (the build number) and
2680
- * `HOT_UPDATER_CHANNEL` the build settings,
2681
- * an `xcconfig` and `extraParams` expanded.
3420
+ * What the sandbox's screen asks the system for: the camera for its QR scanner,
3421
+ * and the local network for a dev server. A value the app already set stays, and
3422
+ * nested App Transport Security entries stay beside the one this adds.
2682
3423
  */
2683
- const readBuiltApp = async (appPath) => {
2684
- const infoPlist = path.default.join(appPath, "Info.plist");
2685
- const [appVersion, channel, buildNumber] = await Promise.all([
2686
- readInfoValue(infoPlist, "CFBundleShortVersionString"),
2687
- readInfoValue(infoPlist, "HOT_UPDATER_CHANNEL"),
2688
- readInfoValue(infoPlist, "CFBundleVersion")
3424
+ const addSandboxPermissions = async (plist) => {
3425
+ for (const [key, value] of [["NSCameraUsageDescription", CAMERA_USAGE], ["NSLocalNetworkUsageDescription", LOCAL_NETWORK_USAGE]]) if (!await hasKey(plist, key)) await plutil([
3426
+ "-insert",
3427
+ key,
3428
+ "-string",
3429
+ value,
3430
+ plist
3431
+ ]);
3432
+ if (!await hasKey(plist, ATS_KEY)) await plutil([
3433
+ "-insert",
3434
+ ATS_KEY,
3435
+ "-json",
3436
+ `{"${ATS_LOCAL_NETWORKING_KEY}":true}`,
3437
+ plist
3438
+ ]);
3439
+ else if (!await hasKey(plist, `${ATS_KEY}.${ATS_LOCAL_NETWORKING_KEY}`)) await plutil([
3440
+ "-insert",
3441
+ `${ATS_KEY}.${ATS_LOCAL_NETWORKING_KEY}`,
3442
+ "-bool",
3443
+ "true",
3444
+ plist
3445
+ ]);
3446
+ };
3447
+ /**
3448
+ * The app's dedicated URL scheme, default channel, and the sandbox's key. The
3449
+ * URL scheme and App Store Connect's app come from the scheme's
3450
+ * `bundleIdentifier`, while Xcode's build settings decide the app's ID, so an
3451
+ * app of another ID stops here, before anything is signed or uploaded.
3452
+ */
3453
+ const patchAppInfoPlist = async (plist, scheme) => {
3454
+ const built = await readValue(plist, "CFBundleIdentifier");
3455
+ if (built !== scheme.bundleIdentifier) throw refusal(`The sandbox scheme ${scheme.hotUpdaterSchemeName} sets bundleIdentifier ${scheme.bundleIdentifier}, but Xcode built ${built ?? "no bundle ID"}.\nSet bundleIdentifier to the ID its configuration builds.`);
3456
+ await setString(plist, "HOT_UPDATER_CHANNEL", (0, _hot_updater_protocol_native_artifact.sandboxChannelOf)(scheme.hotUpdaterSchemeName));
3457
+ await plutil([
3458
+ "-replace",
3459
+ _hot_updater_protocol_native_artifact.SANDBOX_INFO_PLIST_KEY,
3460
+ "-bool",
3461
+ "true",
3462
+ plist
3463
+ ]);
3464
+ await addUrlScheme(plist, (0, _hot_updater_protocol_native_artifact.sandboxUrlSchemeOf)(scheme.bundleIdentifier));
3465
+ await addSandboxPermissions(plist);
3466
+ };
3467
+ /** The Info.plist of each app extension inside an `.app`. */
3468
+ const extensionInfoPlists = (appPath) => ["PlugIns", "Extensions"].flatMap((directory) => {
3469
+ const extensions = path.default.join(appPath, directory);
3470
+ if (!fs.default.existsSync(extensions)) return [];
3471
+ return fs.default.readdirSync(extensions).filter((name) => name.endsWith(".appex")).map((name) => path.default.join(extensions, name, "Info.plist"));
3472
+ });
3473
+ /**
3474
+ * Makes the archived app a sandbox app before `-exportArchive`, which signs
3475
+ * what it finds, so no project file changes: its URL scheme, default channel
3476
+ * and key, and from App Store Connect, the version a closed train needs and
3477
+ * the next build number, in its extensions too, which must match it.
3478
+ */
3479
+ const patchSandboxArchive = async ({ archivePath, appPath, schemeConfig, plan }) => {
3480
+ const appPlist = path.default.join(appPath, "Info.plist");
3481
+ await patchAppInfoPlist(appPlist, schemeConfig);
3482
+ const train = plan?.train;
3483
+ if (!train) return;
3484
+ const version = await readValue(appPlist, "CFBundleShortVersionString");
3485
+ const buildNumber = await readValue(appPlist, "CFBundleVersion");
3486
+ const raised = version === null ? void 0 : sandboxVersionDecision({
3487
+ version,
3488
+ train,
3489
+ updateStrategy: plan.updateStrategy
3490
+ });
3491
+ if (raised?.kind === "refuse") throw refusal(raised.message);
3492
+ const newVersion = raised?.kind === "raise" ? raised.version : null;
3493
+ const newBuildNumber = nextBuildNumber(buildNumber, train.highestBuildNumber);
3494
+ const bundles = [appPlist, ...extensionInfoPlists(appPath)];
3495
+ const archivePlist = path.default.join(archivePath, "Info.plist");
3496
+ const properties = "ApplicationProperties";
3497
+ if (newVersion !== null) {
3498
+ for (const plist of bundles) await setString(plist, "CFBundleShortVersionString", newVersion);
3499
+ await setString(archivePlist, `${properties}.CFBundleShortVersionString`, newVersion);
3500
+ _hot_updater_cli_tools.p.log.info(`Sandbox version ${newVersion}: App Store Connect closed ${version}.`);
3501
+ }
3502
+ if (newBuildNumber !== null) {
3503
+ for (const plist of bundles) await setString(plist, "CFBundleVersion", newBuildNumber);
3504
+ await setString(archivePlist, `${properties}.CFBundleVersion`, newBuildNumber);
3505
+ }
3506
+ };
3507
+ /**
3508
+ * The build number to set over `current`: the one after App Store Connect's
3509
+ * highest, or null to keep `current`, already higher, or one that isn't an
3510
+ * integer such as `1.2.3`.
3511
+ */
3512
+ const nextBuildNumber = (current, highest) => {
3513
+ if (highest === null) return null;
3514
+ if (current !== null && !/^\d+$/u.test(current)) return null;
3515
+ if (current !== null && Number(current) > highest) return null;
3516
+ return String(highest + 1);
3517
+ };
3518
+ /**
3519
+ * Makes a built simulator `.app` a sandbox app, and signs it again ad hoc, as
3520
+ * Xcode does for the simulator, since its signature covers the Info.plist.
3521
+ */
3522
+ const patchSandboxSimulatorApp = async (appPath, schemeConfig) => {
3523
+ await patchAppInfoPlist(path.default.join(appPath, "Info.plist"), schemeConfig);
3524
+ await (0, execa.execa)("codesign", [
3525
+ "--force",
3526
+ "--sign",
3527
+ "-",
3528
+ "--timestamp=none",
3529
+ "--preserve-metadata=entitlements",
3530
+ appPath
2689
3531
  ]);
2690
- return {
2691
- appVersion,
2692
- channel,
2693
- buildNumber
2694
- };
2695
3532
  };
2696
3533
  //#endregion
2697
3534
  //#region src/buildIos.ts
@@ -2704,7 +3541,7 @@ const readBuiltApp = async (appPath) => {
2704
3541
  * once the caller has copied it out. A failed build removes it, and every
2705
3542
  * other directory the build used, itself.
2706
3543
  */
2707
- const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials, logHeader = [] }) => {
3544
+ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials, sandbox, logHeader = [] }) => {
2708
3545
  await assertXcodebuildExist();
2709
3546
  if (schemeConfig.simulator && schemeConfig.destination.length) {
2710
3547
  _hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
@@ -2717,6 +3554,7 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
2717
3554
  schemeConfig,
2718
3555
  minBundleId,
2719
3556
  credentials,
3557
+ sandbox,
2720
3558
  buildDirectory,
2721
3559
  logHeader
2722
3560
  }),
@@ -2727,7 +3565,7 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
2727
3565
  throw error;
2728
3566
  }
2729
3567
  };
2730
- const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirectory, logHeader }) => {
3568
+ const buildInto = async ({ schemeConfig, minBundleId, credentials, sandbox, buildDirectory, logHeader }) => {
2731
3569
  const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2732
3570
  if (schemeConfig.simulator) {
2733
3571
  const appDir = path.default.join(buildDirectory, "app");
@@ -2742,10 +3580,13 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
2742
3580
  configuration: schemeConfig.configuration,
2743
3581
  deviceType: "simulator",
2744
3582
  installPods: schemeConfig.installPods,
3583
+ xcconfig: schemeConfig.xcconfig,
2745
3584
  extraParams: schemeConfig.extraParams,
2746
3585
  useGenericDestination: true,
2747
- minBundleId
3586
+ minBundleId,
3587
+ sandbox: schemeConfig.sandbox
2748
3588
  });
3589
+ if (schemeConfig.sandbox) await patchSandboxSimulatorApp(appPath, schemeConfig);
2749
3590
  return {
2750
3591
  buildDirectory,
2751
3592
  buildArtifactPath: appPath,
@@ -2760,17 +3601,21 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
2760
3601
  if (!signing) return archiveAndExport({
2761
3602
  schemeConfig,
2762
3603
  minBundleId,
3604
+ sandbox,
2763
3605
  buildDirectory,
2764
3606
  logHeader
2765
3607
  });
2766
- const appStoreConnect = await exportsForAppStoreConnect(schemeConfig);
2767
- 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).");
3608
+ const registeredDevices = await exportsForRegisteredDevices(schemeConfig);
3609
+ const ownKeychain = registeredDevices || await exportsForAppStoreConnect(schemeConfig);
3610
+ if ((0, _hot_updater_cli_tools.isCI)() && !ownKeychain) _hot_updater_cli_tools.p.log.info("Signing in the runner's own keychains (not an App Store Connect or ad hoc export).");
2768
3611
  return withSigningKeychain({
2769
- enabled: (0, _hot_updater_cli_tools.isCI)() && appStoreConnect,
3612
+ enabled: (0, _hot_updater_cli_tools.isCI)() && ownKeychain,
3613
+ keepDistribution: registeredDevices,
2770
3614
  apiKey: signing.apiKey,
2771
3615
  run: () => archiveAndExport({
2772
3616
  schemeConfig,
2773
3617
  minBundleId,
3618
+ sandbox,
2774
3619
  buildDirectory,
2775
3620
  logHeader,
2776
3621
  authenticationArgs: signing.authenticationArgs
@@ -2782,7 +3627,7 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
2782
3627
  * Archives a device build and exports its `.ipa`, or returns the
2783
3628
  * `.xcarchive` of a scheme it cannot export.
2784
3629
  */
2785
- const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, logHeader, authenticationArgs }) => {
3630
+ const archiveAndExport = async ({ schemeConfig, minBundleId, sandbox, buildDirectory, logHeader, authenticationArgs }) => {
2786
3631
  const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2787
3632
  const archiveDir = path.default.join(buildDirectory, "archive");
2788
3633
  const exportDir = path.default.join(buildDirectory, "export");
@@ -2800,9 +3645,19 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, log
2800
3645
  configuration: schemeConfig.configuration,
2801
3646
  installPods: schemeConfig.installPods,
2802
3647
  logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
2803
- header: logHeader
3648
+ header: logHeader,
3649
+ sandbox: schemeConfig.sandbox
2804
3650
  });
2805
3651
  const appFromArchive = extractAppFromXcarchive(archivePath);
3652
+ if (schemeConfig.sandbox) {
3653
+ if (!appFromArchive) throw new Error(`The archive ${archivePath} has no app to patch.`);
3654
+ await patchSandboxArchive({
3655
+ archivePath,
3656
+ appPath: appFromArchive,
3657
+ schemeConfig,
3658
+ plan: sandbox
3659
+ });
3660
+ }
2806
3661
  const archivedAppPath = appFromArchive ? path.default.join(archiveDir, path.default.basename(appFromArchive)) : void 0;
2807
3662
  if (appFromArchive && archivedAppPath) await fs.default.promises.cp(appFromArchive, archivedAppPath, { recursive: true });
2808
3663
  if (!schemeConfig.exportOptionsPlist && !authenticationArgs) return {
@@ -2823,15 +3678,17 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, log
2823
3678
  logFilePath: logFilePath ?? void 0,
2824
3679
  title: schemeConfig.scheme
2825
3680
  });
2826
- if (schemeConfig.exportOptionsPlist) await exportArchive(schemeConfig.exportOptionsPlist);
3681
+ const { exportOptionsPlist } = schemeConfig;
3682
+ if (exportOptionsPlist && !schemeConfig.sandbox) await exportArchive(exportOptionsPlist);
2827
3683
  else {
2828
3684
  const exportOptionsDir = await createRandomTmpDir();
2829
3685
  try {
2830
- await exportArchive(await writeAppStoreConnectExportOptions(exportOptionsDir));
3686
+ await exportArchive(exportOptionsPlist ? await resolveSandboxExportOptions(schemeConfig.hotUpdaterSchemeName, exportOptionsPlist, exportOptionsDir) : await writeAppStoreConnectExportOptions(exportOptionsDir, { internalTestingOnly: schemeConfig.sandbox }));
2831
3687
  } finally {
2832
3688
  await removeTmpDir(exportOptionsDir);
2833
3689
  }
2834
3690
  }
3691
+ await keepOnlyIpas(exportDir);
2835
3692
  const ipaFile = fs.default.readdirSync(exportDir).find((file) => file.endsWith(".ipa"));
2836
3693
  if (!ipaFile) throw new Error("IPA file not found after export");
2837
3694
  return {
@@ -2841,6 +3698,17 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, log
2841
3698
  builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
2842
3699
  };
2843
3700
  };
3701
+ /**
3702
+ * Leaves only the `.ipa` in an export's folder. Xcode writes `Packaging.log`
3703
+ * beside it, which holds the API key's bearer token for its 20 minutes, and the
3704
+ * build's output and its cache copy the whole build directory into the project.
3705
+ */
3706
+ const keepOnlyIpas = async (exportDir) => {
3707
+ for (const entry of await fs.default.promises.readdir(exportDir)) if (!entry.endsWith(".ipa")) await fs.default.promises.rm(path.default.join(exportDir, entry), {
3708
+ recursive: true,
3709
+ force: true
3710
+ });
3711
+ };
2844
3712
  const extractAppFromXcarchive = (archivePath) => {
2845
3713
  const productsPath = path.default.join(archivePath, "Products", "Applications");
2846
3714
  if (!fs.default.existsSync(productsPath)) return;
@@ -2849,28 +3717,6 @@ const extractAppFromXcarchive = (archivePath) => {
2849
3717
  _hot_updater_cli_tools.p.log.warn("Failed to extract .app from xcarchive");
2850
3718
  };
2851
3719
  //#endregion
2852
- //#region src/utils/enrichNativeBuildIosScheme.ts
2853
- const resolveExportOptionsPlist = (exportOptionsPlist) => {
2854
- const resolvedExportOptionsPlist = exportOptionsPlist ? path.default.isAbsolute(exportOptionsPlist) ? exportOptionsPlist : path.default.resolve((0, _hot_updater_cli_tools.getCwd)(), exportOptionsPlist) : void 0;
2855
- if (resolvedExportOptionsPlist && !fs.default.existsSync(resolvedExportOptionsPlist)) {
2856
- _hot_updater_cli_tools.p.log.error(`exportOptionsPlist doesn't exist in ${resolvedExportOptionsPlist}`);
2857
- process.exit(1);
2858
- }
2859
- return resolvedExportOptionsPlist;
2860
- };
2861
- const enrichNativeBuildIosScheme = async ({ scheme, hotUpdaterSchemeName }) => {
2862
- return {
2863
- platform: "ios",
2864
- installPods: false,
2865
- configuration: "Release",
2866
- destination: [],
2867
- simulator: false,
2868
- ...scheme,
2869
- exportOptionsPlist: resolveExportOptionsPlist(scheme.exportOptionsPlist),
2870
- hotUpdaterSchemeName
2871
- };
2872
- };
2873
- //#endregion
2874
3720
  //#region src/utils/extractBuiltInBundle.ts
2875
3721
  /** The file React Native's Xcode build phase writes into the `.app`. */
2876
3722
  const BUILT_IN_BUNDLE = "main.jsbundle";
@@ -2926,16 +3772,125 @@ const archiveAppBundle = async ({ appPath, outputPath }) => {
2926
3772
  ]);
2927
3773
  };
2928
3774
  //#endregion
3775
+ //#region src/simulator.ts
3776
+ const simctl = async (args) => {
3777
+ try {
3778
+ return (await (0, execa.execa)("xcrun", ["simctl", ...args])).stdout;
3779
+ } catch (error) {
3780
+ const { code, stderr, shortMessage } = error;
3781
+ if (code === "ENOENT") throw new Error("The iOS Simulator needs Xcode on a Mac.\nInstall Xcode, then run this again.");
3782
+ throw new Error(stderr?.trim() || shortMessage || String(error));
3783
+ }
3784
+ };
3785
+ /** Every simulator on this Mac, booted or not. */
3786
+ const listSimulators = async () => {
3787
+ const { devices } = JSON.parse(await simctl([
3788
+ "list",
3789
+ "devices",
3790
+ "--json"
3791
+ ]));
3792
+ return Object.values(devices).flat().map(({ udid, name, state }) => ({
3793
+ udid,
3794
+ name,
3795
+ state
3796
+ }));
3797
+ };
3798
+ /**
3799
+ * The simulator to install on: `udid`, or else the only booted one, by its
3800
+ * UDID. `simctl`'s own `booted` alias picks one of several by itself.
3801
+ */
3802
+ const selectSimulator = (simulators, udid) => {
3803
+ if (udid !== void 0) {
3804
+ const simulator = simulators.find((candidate) => candidate.udid === udid);
3805
+ if (simulator === void 0) throw new Error(`No iOS Simulator has the UDID ${udid}.\nList them: xcrun simctl list devices`);
3806
+ if (simulator.state !== "Booted") throw new Error(`${simulator.name} isn't booted.\nBoot it: xcrun simctl boot ${udid}`);
3807
+ return simulator;
3808
+ }
3809
+ const booted = simulators.filter(({ state }) => state === "Booted");
3810
+ const [only] = booted;
3811
+ if (only === void 0) throw new Error("No iOS Simulator is booted.\nBoot one: open -a Simulator");
3812
+ if (booted.length > 1) throw new Error([
3813
+ `${booted.length} iOS Simulators are booted:`,
3814
+ ...booted.map(({ udid, name }) => ` ${udid} ${name}`),
3815
+ "Pick one with --simulator <udid>."
3816
+ ].join("\n"));
3817
+ return only;
3818
+ };
3819
+ /**
3820
+ * Unpacks a simulator build's `.app.zip` into `outputPath`, and returns the
3821
+ * `.app` in it.
3822
+ */
3823
+ const extractSimulatorApp = async ({ archivePath, outputPath }) => {
3824
+ await extractAppBundle({
3825
+ archivePath,
3826
+ outputPath
3827
+ });
3828
+ const app = (await fs.default.promises.readdir(outputPath)).find((entry) => entry.endsWith(".app"));
3829
+ if (app === void 0) throw new Error(`${path.default.basename(archivePath)} holds no .app.`);
3830
+ return path.default.join(outputPath, app);
3831
+ };
3832
+ /** Installs a `.app` on a booted simulator, and opens it. */
3833
+ const installOnSimulator = async ({ udid, appPath, appId }) => {
3834
+ await simctl([
3835
+ "install",
3836
+ udid,
3837
+ appPath
3838
+ ]);
3839
+ await simctl([
3840
+ "launch",
3841
+ udid,
3842
+ appId
3843
+ ]);
3844
+ };
3845
+ //#endregion
3846
+ //#region src/utils/enrichNativeBuildIosScheme.ts
3847
+ const resolveExportOptionsPlist = (exportOptionsPlist) => {
3848
+ const resolvedExportOptionsPlist = exportOptionsPlist ? path.default.isAbsolute(exportOptionsPlist) ? exportOptionsPlist : path.default.resolve((0, _hot_updater_cli_tools.getCwd)(), exportOptionsPlist) : void 0;
3849
+ if (resolvedExportOptionsPlist && !fs.default.existsSync(resolvedExportOptionsPlist)) {
3850
+ _hot_updater_cli_tools.p.log.error(`exportOptionsPlist doesn't exist in ${resolvedExportOptionsPlist}`);
3851
+ process.exit(1);
3852
+ }
3853
+ return resolvedExportOptionsPlist;
3854
+ };
3855
+ const enrichNativeBuildIosScheme = async ({ scheme, hotUpdaterSchemeName }) => {
3856
+ return {
3857
+ platform: "ios",
3858
+ installPods: false,
3859
+ configuration: "Release",
3860
+ destination: [],
3861
+ simulator: false,
3862
+ ...scheme,
3863
+ exportOptionsPlist: resolveExportOptionsPlist(scheme.exportOptionsPlist),
3864
+ hotUpdaterSchemeName
3865
+ };
3866
+ };
3867
+ //#endregion
2929
3868
  exports.ASC_INTEGRATIONS_URL = ASC_INTEGRATIONS_URL;
2930
3869
  exports.TestFlightBuildExistsError = TestFlightBuildExistsError;
3870
+ exports.addAscDevices = addAscDevices;
3871
+ exports.applyTestFlightGroup = applyTestFlightGroup;
2931
3872
  exports.archiveAppBundle = archiveAppBundle;
3873
+ exports.assertBuildIsInternalOnly = assertBuildIsInternalOnly;
3874
+ exports.assertIpaIsInternalOnly = assertIpaIsInternalOnly;
3875
+ exports.assertSandboxExportOptions = assertSandboxExportOptions;
2932
3876
  exports.assertTestFlightExportOptions = assertTestFlightExportOptions;
2933
3877
  exports.buildIos = buildIos;
3878
+ exports.distributeTestFlightBuild = distributeTestFlightBuild;
2934
3879
  exports.enrichNativeBuildIosScheme = enrichNativeBuildIosScheme;
2935
3880
  exports.extractAppBundle = extractAppBundle;
2936
3881
  exports.extractBuiltInBundle = extractBuiltInBundle;
3882
+ exports.extractSimulatorApp = extractSimulatorApp;
3883
+ exports.findInternalBetaGroup = findInternalBetaGroup;
2937
3884
  exports.findTestFlightBuild = findTestFlightBuild;
3885
+ exports.installOnSimulator = installOnSimulator;
2938
3886
  exports.installPodsIfNeeded = installPodsIfNeeded;
3887
+ exports.isVersionTrainClosed = isVersionTrainClosed;
2939
3888
  exports.issueAscApiKey = issueAscApiKey;
3889
+ exports.listAscDevices = listAscDevices;
3890
+ exports.listSimulators = listSimulators;
3891
+ exports.prepareSandboxArchive = prepareSandboxArchive;
3892
+ exports.readIpaHasExportComplianceKey = readIpaHasExportComplianceKey;
3893
+ exports.readTestFlightBuildStatus = readTestFlightBuildStatus;
2940
3894
  exports.resolveTestFlightUploadKey = resolveTestFlightUploadKey;
3895
+ exports.selectSimulator = selectSimulator;
2941
3896
  exports.uploadToTestFlight = uploadToTestFlight;