@hot-updater/apple-helper 1.1.3 → 1.2.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -21,18 +21,452 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
- let fs = require("fs");
25
- fs = __toESM(fs, 1);
26
- let path = require("path");
27
- path = __toESM(path, 1);
28
- let _hot_updater_cli_tools = require("@hot-updater/cli-tools");
29
- let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
30
- let execa = require("execa");
24
+ let node_crypto = require("node:crypto");
25
+ node_crypto = __toESM(node_crypto, 1);
26
+ let node_fs = require("node:fs");
31
27
  let node_fs_promises = require("node:fs/promises");
28
+ node_fs_promises = __toESM(node_fs_promises, 1);
29
+ let node_net = require("node:net");
30
+ node_net = __toESM(node_net, 1);
32
31
  let node_os = require("node:os");
33
32
  node_os = __toESM(node_os, 1);
34
33
  let node_path = require("node:path");
35
- let stream_promises = require("stream/promises");
34
+ node_path = __toESM(node_path, 1);
35
+ let _hot_updater_cli_tools = require("@hot-updater/cli-tools");
36
+ let execa = require("execa");
37
+ let fs = require("fs");
38
+ fs = __toESM(fs, 1);
39
+ let path = require("path");
40
+ path = __toESM(path, 1);
41
+ let child_process = require("child_process");
42
+ let crypto = require("crypto");
43
+ crypto = __toESM(crypto, 1);
44
+ let os = require("os");
45
+ os = __toESM(os, 1);
46
+ //#region src/asc/ascCli.ts
47
+ /**
48
+ * The release of asc, the open-source App Store Connect CLI
49
+ * (https://github.com/rorkai/App-Store-Connect-CLI, MIT), that signs in with
50
+ * an Apple Account and issues App Store Connect API keys for Hot Updater. An
51
+ * `asc` on PATH is used when it is at least this version.
52
+ */
53
+ const ASC_CLI_VERSION = "5.16.1";
54
+ /**
55
+ * The SHA-256 of each macOS binary of the release, from its
56
+ * asc_5.16.1_checksums.txt. Pinned here, so a download is trusted only when
57
+ * it is the exact binary this version of Hot Updater was written against.
58
+ */
59
+ const ASC_CLI_SHA256 = {
60
+ arm64: "85a848ef33fe3490adc4f0348af30b7bbf95209a258dac06c735318f62495707",
61
+ x64: "5a978732e0c2f2b673da601568c489dfaa232ae32c93dcfa63b95f0b892a2580"
62
+ };
63
+ const ASC_RELEASE_ASSET = {
64
+ arm64: `asc_${ASC_CLI_VERSION}_macOS_arm64`,
65
+ x64: `asc_${ASC_CLI_VERSION}_macOS_amd64`
66
+ };
67
+ const ASC_RELEASE_URL = `https://github.com/rorkai/App-Store-Connect-CLI/releases/download/${ASC_CLI_VERSION}`;
68
+ const INSTALL_HINT = `Install asc ${ASC_CLI_VERSION} or later with \`brew install asc\` (https://github.com/rorkai/App-Store-Connect-CLI), then run the command again.`;
69
+ /** asc could not be found, downloaded, or verified. */
70
+ var AscCliUnavailableError = class extends Error {
71
+ constructor(message, options) {
72
+ super(message, options);
73
+ this.name = "AscCliUnavailableError";
74
+ }
75
+ };
76
+ const parseVersion = (output) => {
77
+ const match = /(\d+)\.(\d+)\.(\d+)/u.exec(output);
78
+ return match ? match.slice(1, 4).map(Number) : void 0;
79
+ };
80
+ const isAtLeast = (version, minimum) => {
81
+ for (let index = 0; index < minimum.length; index++) {
82
+ const part = version[index] ?? 0;
83
+ if (part !== minimum[index]) return part > minimum[index];
84
+ }
85
+ return true;
86
+ };
87
+ const sha256Of = (file) => new Promise((resolve, reject) => {
88
+ const hash = node_crypto.default.createHash("sha256");
89
+ (0, node_fs.createReadStream)(file).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve(hash.digest("hex")));
90
+ });
91
+ /** Where a downloaded asc is kept, per user, so every project shares it. */
92
+ const defaultAscCacheDir = () => node_path.default.join(node_os.default.homedir(), "Library", "Caches", "hot-updater");
93
+ /**
94
+ * Finds asc: the `asc` on PATH when it is at least `ASC_CLI_VERSION`, else
95
+ * the pinned release in `cacheDir`, downloaded from GitHub on first use and
96
+ * verified against its pinned SHA-256 each time it is used. Only macOS has
97
+ * one: TestFlight builds need Xcode.
98
+ */
99
+ const findAscCli = async ({ cacheDir = defaultAscCacheDir(), fetch = globalThis.fetch, platform = process.platform, arch = process.arch, pathCommand = "asc" } = {}) => {
100
+ if (platform !== "darwin") throw new AscCliUnavailableError("Signing in with an Apple Account to issue an App Store Connect API key runs on macOS, where iOS builds run.");
101
+ const minimum = parseVersion(ASC_CLI_VERSION);
102
+ const onPath = await (0, execa.execa)(pathCommand, ["--version"], {
103
+ reject: false,
104
+ env: { ASC_TELEMETRY_DISABLED: "1" },
105
+ timeout: 15e3
106
+ }).catch(() => void 0);
107
+ const pathVersion = onPath?.exitCode === 0 ? parseVersion(onPath.stdout) : void 0;
108
+ if (pathVersion && isAtLeast(pathVersion, minimum)) return {
109
+ path: pathCommand,
110
+ version: pathVersion.join("."),
111
+ source: "path"
112
+ };
113
+ const asset = ASC_RELEASE_ASSET[arch];
114
+ const expected = ASC_CLI_SHA256[arch];
115
+ if (!asset || !expected) throw new AscCliUnavailableError(`asc publishes no macOS binary for ${arch}. ${INSTALL_HINT}`);
116
+ const binary = node_path.default.join(cacheDir, "asc", ASC_CLI_VERSION, asset);
117
+ if (await sha256Of(binary).catch(() => void 0) === expected) return {
118
+ path: binary,
119
+ version: ASC_CLI_VERSION,
120
+ source: "cache"
121
+ };
122
+ await node_fs_promises.default.mkdir(node_path.default.dirname(binary), { recursive: true });
123
+ const partial = `${binary}.${process.pid}.download`;
124
+ try {
125
+ let response;
126
+ try {
127
+ response = await fetch(`${ASC_RELEASE_URL}/${asset}`, { redirect: "follow" });
128
+ } catch (error) {
129
+ throw new AscCliUnavailableError(`Could not download asc ${ASC_CLI_VERSION} from GitHub (${error instanceof Error ? error.message : String(error)}). ${INSTALL_HINT}`, { cause: error });
130
+ }
131
+ if (!response.ok || !response.body) throw new AscCliUnavailableError(`Could not download asc ${ASC_CLI_VERSION} from GitHub (HTTP ${response.status}). ${INSTALL_HINT}`);
132
+ await node_fs_promises.default.writeFile(partial, Buffer.from(await response.arrayBuffer()), { mode: 448 });
133
+ const actual = await sha256Of(partial);
134
+ if (actual !== expected) throw new AscCliUnavailableError(`The downloaded asc ${ASC_CLI_VERSION} does not match its published SHA-256 (expected ${expected}, got ${actual}), so it was deleted and not run. ${INSTALL_HINT}`);
135
+ await node_fs_promises.default.chmod(partial, 493);
136
+ await node_fs_promises.default.rename(partial, binary);
137
+ } finally {
138
+ await node_fs_promises.default.rm(partial, { force: true });
139
+ }
140
+ return {
141
+ path: binary,
142
+ version: ASC_CLI_VERSION,
143
+ source: "download"
144
+ };
145
+ };
146
+ //#endregion
147
+ //#region src/asc/ascCliKeyIssuer.ts
148
+ /** An asc command failed; its message is asc's own, which it printed too. */
149
+ var AscCommandError = class extends Error {
150
+ exitCode;
151
+ constructor(message, exitCode, options) {
152
+ super(message, options);
153
+ this.name = "AscCommandError";
154
+ this.exitCode = exitCode;
155
+ }
156
+ };
157
+ const TERMINATION_SIGNALS$2 = [
158
+ "SIGHUP",
159
+ "SIGINT",
160
+ "SIGTERM"
161
+ ];
162
+ /**
163
+ * A 0700 directory holding asc's whole state for one sign-in (its web
164
+ * session, config, and the downloaded `.p8`), removed by `dispose`, on
165
+ * `exit`, or on a termination signal, so nothing outlives the sign-in.
166
+ */
167
+ const createWorkDirectory = async () => {
168
+ const root = await node_fs_promises.default.mkdtemp(node_path.default.join(node_os.default.tmpdir(), "hot-updater-asc-"));
169
+ await node_fs_promises.default.chmod(root, 448);
170
+ const remove = () => (0, node_fs.rmSync)(root, {
171
+ force: true,
172
+ recursive: true
173
+ });
174
+ const onSignal = (signal) => {
175
+ stop();
176
+ remove();
177
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
178
+ };
179
+ const stop = () => {
180
+ process.off("exit", remove);
181
+ for (const signal of TERMINATION_SIGNALS$2) process.off(signal, onSignal);
182
+ };
183
+ process.on("exit", remove);
184
+ for (const signal of TERMINATION_SIGNALS$2) process.on(signal, onSignal);
185
+ for (const directory of [
186
+ "home",
187
+ "session",
188
+ "keys"
189
+ ]) await node_fs_promises.default.mkdir(node_path.default.join(root, directory), { mode: 448 });
190
+ let disposal;
191
+ return {
192
+ root,
193
+ dispose: () => {
194
+ disposal ??= (async () => {
195
+ stop();
196
+ await node_fs_promises.default.rm(root, {
197
+ force: true,
198
+ recursive: true
199
+ });
200
+ })();
201
+ return disposal;
202
+ }
203
+ };
204
+ };
205
+ /**
206
+ * asc's environment: the caller's, without any `ASC_*` variable (such as a
207
+ * saved password, a 2FA code command, or another API key), and with every
208
+ * file asc keeps (home, config, web session) in `root`. Passwords are never
209
+ * stored, the keychain is bypassed, and telemetry is off.
210
+ */
211
+ const ascEnvironment = (root, base = process.env) => {
212
+ const env = {};
213
+ for (const [name, value] of Object.entries(base)) if (!name.startsWith("ASC_") && name !== "DO_NOT_TRACK") env[name] = value;
214
+ return {
215
+ ...env,
216
+ HOME: node_path.default.join(root, "home"),
217
+ XDG_CONFIG_HOME: node_path.default.join(root, "home", ".config"),
218
+ XDG_CACHE_HOME: node_path.default.join(root, "home", ".cache"),
219
+ ASC_CONFIG_PATH: node_path.default.join(root, "home", "config.json"),
220
+ ASC_WEB_SESSION_CACHE_DIR: node_path.default.join(root, "session"),
221
+ ASC_WEB_SESSION_CACHE_BACKEND: "file",
222
+ ASC_WEB_DONT_STORE_PASSWORD: "1",
223
+ ASC_BYPASS_KEYCHAIN: "1",
224
+ ASC_TELEMETRY_DISABLED: "1",
225
+ ASC_SPINNER_DISABLED: "1"
226
+ };
227
+ };
228
+ const answer = (value) => _hot_updater_cli_tools.p.isCancel(value) ? void 0 : value;
229
+ /** Hot Updater's own prompts, so the sign-in looks like the rest of the CLI. */
230
+ const cliPrompts = {
231
+ password: async (appleId) => answer(await _hot_updater_cli_tools.p.password({
232
+ message: appleId ? `Password of ${appleId}` : "Apple Account password",
233
+ mask: "•",
234
+ validate: (value) => value ? void 0 : "Enter the password."
235
+ })),
236
+ twoFactorCode: async (attempt) => answer(await _hot_updater_cli_tools.p.text({
237
+ message: attempt === 1 ? "Two-factor code from your trusted device (no device at hand? enter any wrong code once, and Apple texts one to your phone)" : "Verification code Apple sent to your phone",
238
+ validate: (value) => /^\d{6}$/u.test(value?.trim() ?? "") ? void 0 : "Enter the 6-digit code."
239
+ }))?.trim()
240
+ };
241
+ /** `value` as one word for `sh`. */
242
+ const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
243
+ /**
244
+ * A two-factor code command for asc that asks `prompts` in this process:
245
+ * asc runs it when Apple wants a code, and it prints what the developer
246
+ * typed, which it gets over a socket in the 0700 work directory. A
247
+ * cancelled prompt makes it print nothing and fail, which stops the sign-in.
248
+ */
249
+ const listenForTwoFactorCodes = async (root, prompts) => {
250
+ const socketPath = node_path.default.join(root, "2fa.sock");
251
+ const scriptPath = node_path.default.join(root, "two-factor-code.cjs");
252
+ await node_fs_promises.default.writeFile(scriptPath, [
253
+ "const socket = require(\"node:net\").connect(" + JSON.stringify(socketPath) + ");",
254
+ "let code = \"\";",
255
+ "socket.on(\"data\", (chunk) => { code += chunk; });",
256
+ "socket.on(\"end\", () => {",
257
+ " code = code.trim();",
258
+ " if (!code) process.exit(1);",
259
+ " process.stdout.write(code + \"\\n\");",
260
+ "});",
261
+ "socket.on(\"error\", () => process.exit(1));",
262
+ ""
263
+ ].join("\n"), { mode: 384 });
264
+ let attempts = 0;
265
+ let cancelled = false;
266
+ const server = node_net.default.createServer((socket) => {
267
+ attempts += 1;
268
+ prompts.twoFactorCode(attempts).then((code) => {
269
+ if (!code) cancelled = true;
270
+ socket.end(code ? `${code}\n` : "");
271
+ }, () => {
272
+ cancelled = true;
273
+ socket.destroy();
274
+ });
275
+ });
276
+ await new Promise((resolve, reject) => {
277
+ server.once("error", reject);
278
+ server.listen(socketPath, resolve);
279
+ });
280
+ return {
281
+ command: `${shellQuote(process.execPath)} ${shellQuote(scriptPath)}`,
282
+ cancelled: () => cancelled,
283
+ close: () => new Promise((resolve) => server.close(() => resolve()))
284
+ };
285
+ };
286
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
287
+ const text = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
288
+ /** The last line of asc's error output, which names what failed. */
289
+ const lastLine = (stderr) => stderr.split("\n").map((line) => line.trim()).filter(Boolean).at(-1);
290
+ /** What to do about a key asc could not create, by what App Store Connect said. */
291
+ const createFailureHint = (stderr) => {
292
+ if (/agreement/iu.test(stderr)) return "The Apple Developer Program License Agreement has changed: the Account Holder accepts it at https://developer.apple.com/account, then run this again.";
293
+ if (/limit|maximum/iu.test(stderr)) return `The team has as many active API keys as App Store Connect allows: revoke one you no longer use at ${ASC_INTEGRATIONS_URL}, then run this again.`;
294
+ if (/account holder|admin|permission|forbidden|not allowed|403/iu.test(stderr)) return "Creating an App Store Connect API key needs the Account Holder or Admin role in the team: sign in with such an Apple Account, or ask one to run this.";
295
+ };
296
+ /** A P-256 private key, as App Store Connect issues one, or a thrown error. */
297
+ const assertP8 = (p8) => {
298
+ let key;
299
+ try {
300
+ key = node_crypto.default.createPrivateKey(p8);
301
+ } catch {
302
+ throw new Error("asc saved a .p8 that is not a private key.");
303
+ }
304
+ if (key.asymmetricKeyType !== "ec") throw new Error("asc saved a .p8 that is not an EC private key.");
305
+ };
306
+ /**
307
+ * `IssueAscApiKey` with asc, the open-source App Store Connect CLI
308
+ * (https://github.com/rorkai/App-Store-Connect-CLI, MIT):
309
+ *
310
+ * 1. `asc web auth login` signs in with the password and the two-factor
311
+ * codes typed into Hot Updater's prompts. The password reaches only that
312
+ * asc process, in its environment (never an argument), each code only
313
+ * asc's two-factor code command, and neither is kept or printed.
314
+ * 2. `chooseTeam` sees the team asc signed in to, unless `teamId` is set.
315
+ * 3. `asc web api-keys create` issues the key and saves its one-time `.p8`,
316
+ * which is checked before it is handed out; a key that fails the check is
317
+ * revoked at once.
318
+ * 4. `revoke` runs `asc web api-keys revoke` in the same session.
319
+ *
320
+ * asc's whole state (its web session, config, and the `.p8`) lives in a
321
+ * temporary 0700 directory, which `dispose` deletes, as does the process
322
+ * exiting first.
323
+ *
324
+ * @param asc The asc binary; found or downloaded with `findAscCli` unless set.
325
+ * @param env The environment asc's own is derived from.
326
+ * @param prompts Where the password and two-factor codes are asked.
327
+ */
328
+ const createAscCliKeyIssuer = ({ asc, env = process.env, prompts = cliPrompts } = {}) => async ({ name, role, appleId, teamId, chooseTeam }) => {
329
+ const cli = asc ?? await findAscCli();
330
+ _hot_updater_cli_tools.p.log.info("Hot Updater signs in with asc, the open-source App Store Connect CLI. It keeps only the API key it issues: neither the password nor the session.");
331
+ const password = await prompts.password(appleId);
332
+ if (password === void 0) throw new AscCommandError("Signing in was cancelled.", void 0);
333
+ (0, _hot_updater_cli_tools.registerSecretValue)(password);
334
+ const work = await createWorkDirectory();
335
+ const ascEnv = ascEnvironment(work.root, env);
336
+ const run = async (step, args, extraEnv = {}) => {
337
+ const result = await (0, execa.execa)(cli.path, args, {
338
+ env: {
339
+ ...ascEnv,
340
+ ...extraEnv
341
+ },
342
+ extendEnv: false,
343
+ stdin: "ignore",
344
+ stdout: "pipe",
345
+ stderr: "pipe",
346
+ reject: false
347
+ });
348
+ const stderr = String(result.stderr ?? "");
349
+ if (result.exitCode !== 0 || result.failed) throw new AscCommandError(`${step} failed: ${lastLine(stderr) ?? (result.signal ? `stopped by ${result.signal}` : void 0) ?? `exit code ${result.exitCode}`}`, result.exitCode, { cause: { stderr } });
350
+ let parsed;
351
+ try {
352
+ parsed = JSON.parse(String(result.stdout));
353
+ } catch {
354
+ parsed = void 0;
355
+ }
356
+ if (!isRecord(parsed)) throw new AscCommandError(`${step} printed no JSON this version of Hot Updater reads.`, result.exitCode);
357
+ return parsed;
358
+ };
359
+ try {
360
+ const twoFactor = await listenForTwoFactorCodes(work.root, prompts);
361
+ let login;
362
+ try {
363
+ login = await run("Signing in with asc web auth login", [
364
+ "web",
365
+ "auth",
366
+ "login",
367
+ ...appleId ? ["--apple-id", appleId] : [],
368
+ "--two-factor-code-command",
369
+ twoFactor.command,
370
+ "--output",
371
+ "json"
372
+ ], { ASC_WEB_PASSWORD: password });
373
+ } catch (error) {
374
+ if (twoFactor.cancelled()) throw new AscCommandError("Signing in was cancelled.", void 0, { cause: error });
375
+ throw error;
376
+ } finally {
377
+ await twoFactor.close();
378
+ }
379
+ if (login.authenticated !== true) throw new AscCommandError("asc web auth login finished without a signed-in session.", 0);
380
+ const signedIn = {
381
+ ...text(login.appleId) ? { appleId: text(login.appleId) } : {},
382
+ ...text(login.publicProviderId) ? { teamId: text(login.publicProviderId) } : {},
383
+ ...text(login.developerTeamId) ? { developerTeamId: text(login.developerTeamId) } : {}
384
+ };
385
+ const chosen = teamId ?? await chooseTeam?.(signedIn);
386
+ const team = chosen === void 0 || chosen === signedIn.teamId ? signedIn : {
387
+ ...signedIn.appleId ? { appleId: signedIn.appleId } : {},
388
+ teamId: chosen
389
+ };
390
+ const sessionArgs = [...signedIn.appleId ? ["--apple-id", signedIn.appleId] : [], ...chosen === void 0 ? [] : ["--public-provider-id", chosen]];
391
+ let created;
392
+ try {
393
+ created = await run("Creating the App Store Connect API key", [
394
+ "web",
395
+ "api-keys",
396
+ "create",
397
+ "--name",
398
+ name,
399
+ "--role",
400
+ role,
401
+ "--output-dir",
402
+ node_path.default.join(work.root, "keys"),
403
+ ...sessionArgs,
404
+ "--output",
405
+ "json"
406
+ ]);
407
+ } catch (error) {
408
+ const stderr = error instanceof AscCommandError && isRecord(error.cause) ? String(error.cause.stderr ?? "") : "";
409
+ const hint = createFailureHint(stderr);
410
+ if (hint && error instanceof AscCommandError) throw new AscCommandError(`${error.message}\n${hint}`, error.exitCode, { cause: error });
411
+ throw error;
412
+ }
413
+ const keyId = text(created.keyId);
414
+ if (!keyId) throw new AscCommandError("asc web api-keys create printed no key ID.", 0);
415
+ const revoke = () => run("Revoking the key", [
416
+ "web",
417
+ "api-keys",
418
+ "revoke",
419
+ "--key-id",
420
+ keyId,
421
+ "--type",
422
+ "team",
423
+ "--confirm",
424
+ ...sessionArgs,
425
+ "--output",
426
+ "json"
427
+ ]).then((json) => json.active === false || json.status === "revoked", () => false);
428
+ let p8;
429
+ let issuerId;
430
+ try {
431
+ const issuer = text(created.issuerId);
432
+ const p8Path = text(created.p8Path);
433
+ const keys = node_path.default.join(work.root, "keys");
434
+ if (!issuer) throw new Error("asc printed no issuer ID for the key.");
435
+ if (!p8Path || await node_fs_promises.default.realpath(node_path.default.dirname(node_path.default.resolve(p8Path))) !== await node_fs_promises.default.realpath(keys)) throw new Error("asc saved the key's .p8 outside its output directory.");
436
+ p8 = (await node_fs_promises.default.readFile(p8Path, "utf-8")).trim();
437
+ (0, _hot_updater_cli_tools.registerSecretValue)(p8);
438
+ (0, _hot_updater_cli_tools.registerSecretValue)(Buffer.from(p8).toString("base64"));
439
+ assertP8(p8);
440
+ issuerId = issuer;
441
+ } catch (error) {
442
+ const revoked = await revoke();
443
+ throw new Error(revoked ? `${error.message} The App Store Connect API key ${keyId} was revoked again.` : `${error.message} Revoking the App Store Connect API key ${keyId} failed too: revoke it at ${ASC_INTEGRATIONS_URL}.`, { cause: error });
444
+ }
445
+ return {
446
+ p8,
447
+ keyId,
448
+ issuerId,
449
+ name: text(created.name) ?? name,
450
+ team,
451
+ revoke,
452
+ dispose: work.dispose
453
+ };
454
+ } catch (error) {
455
+ await work.dispose();
456
+ throw error;
457
+ }
458
+ };
459
+ //#endregion
460
+ //#region src/ascApiKey.ts
461
+ /** Where team API keys are listed and revoked in App Store Connect. */
462
+ const ASC_INTEGRATIONS_URL = "https://appstoreconnect.apple.com/access/integrations/api";
463
+ /**
464
+ * Hot Updater's sign-in, the one place that picks how it is done: asc, the
465
+ * open-source App Store Connect CLI. Another implementation of
466
+ * `IssueAscApiKey` replaces it here without touching its callers.
467
+ */
468
+ const issueAscApiKey = (request) => createAscCliKeyIssuer()(request);
469
+ //#endregion
36
470
  //#region src/utils/cocoapods.ts
37
471
  const installPodsIfNeeded = async (sourceDir) => {
38
472
  const podfilePath = path.default.join(sourceDir, "Podfile");
@@ -42,6 +476,16 @@ const installPodsIfNeeded = async (sourceDir) => {
42
476
  }
43
477
  const gemfilePaths = [path.default.join(sourceDir, "Gemfile"), path.default.join((0, _hot_updater_cli_tools.getCwd)(), "Gemfile")];
44
478
  const shouldUseBundler = (await Promise.all(gemfilePaths.map(checkShouldUseBundler))).some(Boolean);
479
+ const reactNative = (0, _hot_updater_cli_tools.getReactNativeMetadatas)();
480
+ const plan = resolvePodInstallPlan({
481
+ version: reactNative.version,
482
+ env: process.env
483
+ });
484
+ if (plan.runCodegen) await runReactNativeCodegen({
485
+ projectRoot: (0, _hot_updater_cli_tools.getCwd)(),
486
+ reactNativePath: reactNative.packagePath,
487
+ sourceDir
488
+ });
45
489
  try {
46
490
  if (shouldUseBundler) {
47
491
  _hot_updater_cli_tools.p.log.info("Using bundler for CocoaPods installation");
@@ -57,7 +501,7 @@ const installPodsIfNeeded = async (sourceDir) => {
57
501
  "install"
58
502
  ], {
59
503
  cwd: sourceDir,
60
- env: preparePodInstallEnvVars()
504
+ env: plan.env
61
505
  });
62
506
  podSpinner.stop("CocoaPods dependencies installed");
63
507
  } else {
@@ -65,7 +509,7 @@ const installPodsIfNeeded = async (sourceDir) => {
65
509
  spinner.start("Installing CocoaPods dependencies");
66
510
  await (0, execa.execa)("pod", ["install"], {
67
511
  cwd: sourceDir,
68
- env: preparePodInstallEnvVars()
512
+ env: plan.env
69
513
  });
70
514
  spinner.stop("CocoaPods dependencies installed");
71
515
  }
@@ -81,13 +525,55 @@ const checkShouldUseBundler = async (gemfilePath) => {
81
525
  return false;
82
526
  }
83
527
  };
84
- const preparePodInstallEnvVars = () => {
85
- const { minor } = (0, _hot_updater_cli_tools.getReactNativeMetadatas)().version;
86
- const usePrebuiltReactNative = minor >= 81;
528
+ /**
529
+ * Runs React Native codegen into `<sourceDir>/build/generated/ios`, as the
530
+ * React Native CLI does before `pod install`. Clearing the old output first
531
+ * drops the modules of removed libraries.
532
+ */
533
+ const runReactNativeCodegen = async ({ projectRoot, reactNativePath, sourceDir }) => {
534
+ await fs.default.promises.rm(path.default.join(sourceDir, "build", "generated", "ios"), {
535
+ recursive: true,
536
+ force: true
537
+ });
538
+ const spinner = _hot_updater_cli_tools.p.spinner();
539
+ spinner.start("Running React Native codegen");
540
+ try {
541
+ await (0, execa.execa)("node", [
542
+ path.default.join(reactNativePath, "scripts", "generate-codegen-artifacts.js"),
543
+ "-p",
544
+ projectRoot,
545
+ "-o",
546
+ sourceDir,
547
+ "-t",
548
+ "ios"
549
+ ], { cwd: projectRoot });
550
+ } catch (error) {
551
+ spinner.stop("React Native codegen failed");
552
+ throw new Error(`React Native codegen failed: ${error}`);
553
+ }
554
+ spinner.stop("React Native codegen done");
555
+ };
556
+ /**
557
+ * The environment `pod install` runs with, and whether codegen runs first.
558
+ *
559
+ * From React Native 0.79, `RCT_IGNORE_PODS_DEPRECATION=1` silences the
560
+ * deprecation notice for calling `pod install` directly and also sets
561
+ * `RCT_SKIP_CODEGEN=1`, because the React Native CLI runs codegen itself
562
+ * first. Hot Updater sets it the same way and runs codegen too, unless the
563
+ * user's `RCT_SKIP_CODEGEN=1` takes codegen over. Before 0.79, and when the
564
+ * version is unknown, CocoaPods runs codegen. A user's value of a flag wins.
565
+ */
566
+ const resolvePodInstallPlan = ({ version, env }) => {
567
+ const podsSkipCodegen = version.major > 0 || version.minor >= 79;
568
+ const usePrebuiltReactNative = version.major > 0 || version.minor >= 81;
569
+ const ignorePodsDeprecation = env["RCT_IGNORE_PODS_DEPRECATION"] ?? (podsSkipCodegen ? "1" : void 0);
87
570
  return {
88
- RCT_IGNORE_PODS_DEPRECATION: "1",
89
- RCT_USE_RN_DEP: process.env["RCT_USE_RN_DEP"] !== void 0 ? String(process.env["RCT_USE_RN_DEP"]) : usePrebuiltReactNative ? "1" : "0",
90
- RCT_USE_PREBUILT_RNCORE: process.env["RCT_USE_PREBUILT_RNCORE"] !== void 0 ? String(process.env["RCT_USE_PREBUILT_RNCORE"]) : usePrebuiltReactNative ? "1" : "0"
571
+ runCodegen: podsSkipCodegen && ignorePodsDeprecation === "1" && env["RCT_SKIP_CODEGEN"] !== "1",
572
+ env: {
573
+ ...ignorePodsDeprecation !== void 0 && { RCT_IGNORE_PODS_DEPRECATION: ignorePodsDeprecation },
574
+ RCT_USE_RN_DEP: env["RCT_USE_RN_DEP"] ?? (usePrebuiltReactNative ? "1" : "0"),
575
+ RCT_USE_PREBUILT_RNCORE: env["RCT_USE_PREBUILT_RNCORE"] ?? (usePrebuiltReactNative ? "1" : "0")
576
+ }
91
577
  };
92
578
  };
93
579
  //#endregion
@@ -166,9 +652,15 @@ const parseXcodeProjectInfo = async (sourceDir) => {
166
652
  //#endregion
167
653
  //#region src/utils/prettifyXcodebuildError.ts
168
654
  const prettifyXcodebuildError = (error) => {
169
- let errorString = "";
170
- if (error instanceof execa.ExecaError || error instanceof Error || "message" in error) errorString = error.message;
171
- if (errorString.includes("error: No profiles for") || errorString.includes("error: Provisioning profile")) return /* @__PURE__ */ new Error("Xcodebuild failed: Not valid provisioning profile is set in the project. Check out Singing & Capabilities tab in the Xcode.");
655
+ if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
656
+ const errorLines = error.summary.join("\n");
657
+ const message = errorLines.includes("error: No profiles for") || errorLines.includes("error: Provisioning profile") ? "Not valid provisioning profile is set in the project. Check out the Signing & Capabilities tab in Xcode." : error.message;
658
+ return new _hot_updater_cli_tools.BuildFailedError(`Xcodebuild failed: ${message}`, {
659
+ logPath: error.logPath,
660
+ summary: error.summary,
661
+ cause: error.cause
662
+ });
663
+ }
172
664
  return /* @__PURE__ */ new Error(`Xcodebuild failed: ${error}`);
173
665
  };
174
666
  //#endregion
@@ -211,13 +703,20 @@ const createXcodebuildLogger = ({ logPrefix }) => new _hot_updater_cli_tools.Bui
211
703
  "BUILD SUCCEEDED",
212
704
  "ARCHIVE SUCCEEDED"
213
705
  ]
214
- ]
706
+ ],
707
+ failureSummary: {
708
+ lines: [/(^|:\s)error: /, /^\*\* [A-Z ]+ FAILED \*\*$/],
709
+ blocks: [{
710
+ start: "The following build commands failed:",
711
+ end: /^\(\d+ failures?\)$/,
712
+ includeEnd: true
713
+ }]
714
+ }
215
715
  });
216
716
  //#endregion
217
717
  //#region src/utils/runXcodebuildWithLogging.ts
218
718
  let cachedXcbeautifyAvailability = null;
219
- const TERMINATION_SIGNALS = ["SIGINT", "SIGTERM"];
220
- const IGNORED_PIPELINE_ERROR_CODES = ["EPIPE", "ERR_STREAM_PREMATURE_CLOSE"];
719
+ const TERMINATION_SIGNALS$1 = ["SIGINT", "SIGTERM"];
221
720
  const isXcbeautifyAvailable = async () => {
222
721
  if (cachedXcbeautifyAvailability !== null) return cachedXcbeautifyAvailability;
223
722
  try {
@@ -233,6 +732,7 @@ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
233
732
  return (0, execa.execa)("xcodebuild", args, {
234
733
  cwd: sourceDir,
235
734
  all: true,
735
+ buffer: false,
236
736
  env: {
237
737
  ...process.env,
238
738
  ...env,
@@ -243,29 +743,17 @@ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
243
743
  const createXcbeautifyProcess = ({ cwd }) => {
244
744
  return (0, execa.execa)("xcbeautify", [], {
245
745
  cwd,
246
- all: true
746
+ all: true,
747
+ buffer: false
247
748
  });
248
749
  };
249
- const getErrorCode = (error) => {
250
- if (typeof error !== "object" || error === null || !("code" in error)) return "";
251
- const { code } = error;
252
- return typeof code === "string" ? code : "";
253
- };
254
- const shouldIgnorePipelineError = (error) => {
255
- const errorCode = getErrorCode(error);
256
- return IGNORED_PIPELINE_ERROR_CODES.includes(errorCode);
257
- };
258
- const createXcodebuildOutputPipingPromise = async ({ xcodebuildOutput, xcbeautifyInput }) => {
259
- try {
260
- return await (0, stream_promises.pipeline)(xcodebuildOutput, xcbeautifyInput);
261
- } catch (error) {
262
- if (shouldIgnorePipelineError(error)) return;
263
- throw error;
750
+ /** Why xcodebuild failed, without its command line. */
751
+ const describeFailure = (error) => {
752
+ if (error instanceof execa.ExecaError) {
753
+ if (error.signal) return `xcodebuild was stopped by ${error.signal}.`;
754
+ if (error.exitCode !== void 0) return `xcodebuild exited with code ${error.exitCode}.`;
264
755
  }
265
- };
266
- const processOutputStream = async ({ outputStream, logger }) => {
267
- if (!outputStream) return;
268
- await logger.processStream(outputStream);
756
+ return error instanceof Error ? error.message : String(error);
269
757
  };
270
758
  const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage, failureMessage, logPrefix }) => {
271
759
  const useXcbeautify = await isXcbeautifyAvailable();
@@ -275,74 +763,153 @@ const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage,
275
763
  const terminateActiveProcesses = () => {
276
764
  for (const activeProcess of activeProcesses) activeProcess.kill();
277
765
  };
278
- for (const signal of TERMINATION_SIGNALS) process.once(signal, terminateActiveProcesses);
766
+ for (const signal of TERMINATION_SIGNALS$1) process.once(signal, terminateActiveProcesses);
279
767
  try {
280
- if (useXcbeautify) {
281
- const xcodebuildProcess = createRawXcodebuildProcess({
282
- args,
283
- env,
284
- sourceDir
285
- });
286
- const xcbeautifyProcess = createXcbeautifyProcess({ cwd: sourceDir });
287
- activeProcesses.push(xcodebuildProcess, xcbeautifyProcess);
288
- const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
289
- const xcbeautifyInput = xcbeautifyProcess.stdin;
290
- if (!xcodebuildOutput || !xcbeautifyInput) throw new Error("Failed to pipe xcodebuild output into xcbeautify");
291
- const pipingPromise = createXcodebuildOutputPipingPromise({
292
- xcodebuildOutput,
293
- xcbeautifyInput
294
- });
295
- await processOutputStream({
296
- logger,
297
- outputStream: xcbeautifyProcess.all ?? xcbeautifyProcess.stdout
298
- });
299
- await Promise.all([
300
- xcbeautifyProcess,
301
- xcodebuildProcess,
302
- pipingPromise
303
- ]);
304
- } else {
305
- const xcodebuildProcess = createRawXcodebuildProcess({
306
- args,
307
- env,
308
- sourceDir
309
- });
310
- activeProcesses.push(xcodebuildProcess);
311
- await processOutputStream({
312
- logger,
313
- outputStream: xcodebuildProcess.all ?? xcodebuildProcess.stdout
768
+ const xcodebuildProcess = createRawXcodebuildProcess({
769
+ args,
770
+ env,
771
+ sourceDir
772
+ });
773
+ activeProcesses.push(xcodebuildProcess);
774
+ const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
775
+ let xcbeautifyProcess = null;
776
+ if (useXcbeautify && xcodebuildOutput) {
777
+ xcbeautifyProcess = createXcbeautifyProcess({ cwd: sourceDir });
778
+ activeProcesses.push(xcbeautifyProcess);
779
+ }
780
+ const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput, { display: xcbeautifyProcess ? xcbeautifyProcess.all ?? xcbeautifyProcess.stdout ?? void 0 : void 0 }) : Promise.resolve();
781
+ const xcbeautifyInput = xcbeautifyProcess?.stdin;
782
+ if (xcodebuildOutput && xcbeautifyInput) {
783
+ xcbeautifyInput.on("error", () => {
784
+ xcodebuildOutput.unpipe(xcbeautifyInput);
314
785
  });
315
- await xcodebuildProcess;
786
+ xcodebuildOutput.pipe(xcbeautifyInput);
316
787
  }
788
+ const [xcodebuildResult, loggingResult] = await Promise.allSettled([
789
+ xcodebuildProcess,
790
+ logging,
791
+ xcbeautifyProcess
792
+ ]);
793
+ if (xcodebuildResult.status === "rejected") throw xcodebuildResult.reason;
794
+ if (loggingResult.status === "rejected") throw loggingResult.reason;
317
795
  logger.stop(successMessage);
318
796
  } catch (error) {
319
797
  logger.writeError(error);
320
798
  logger.stop(failureMessage, false);
321
- throw error;
799
+ throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${describeFailure(error)}`, {
800
+ logPath: logger.logPath,
801
+ summary: logger.failureSummary,
802
+ cause: error
803
+ });
322
804
  } finally {
323
- for (const signal of TERMINATION_SIGNALS) process.off(signal, terminateActiveProcesses);
805
+ for (const signal of TERMINATION_SIGNALS$1) process.off(signal, terminateActiveProcesses);
324
806
  await logger.close();
325
807
  }
326
808
  };
327
809
  //#endregion
810
+ //#region src/utils/writeBuildXcconfig.ts
811
+ const SDK_CONDITIONS = [
812
+ "",
813
+ "[sdk=iphoneos*]",
814
+ "[sdk=iphonesimulator*]"
815
+ ];
816
+ /**
817
+ * Finds a setting of `keys` that an included xcconfig sets with a condition
818
+ * the build xcconfig does not, such as `[config=Release]` or `[arch=arm64]`,
819
+ * which would outrank the build's own. It follows `#include`s, which are
820
+ * relative to the including file.
821
+ */
822
+ const findConditionalSetting = async (files, keys, visited = /* @__PURE__ */ new Set()) => {
823
+ for (const file of files) {
824
+ if (visited.has(file) || !fs.default.existsSync(file)) continue;
825
+ visited.add(file);
826
+ const lines = (await fs.default.promises.readFile(file, "utf-8")).split("\n");
827
+ for (const [index, line] of lines.entries()) {
828
+ const include = line.match(/^\s*#include\??\s*"([^"]+)"/)?.[1];
829
+ if (include) {
830
+ const found = await findConditionalSetting([path.default.resolve(path.default.dirname(file), include)], keys, visited);
831
+ if (found) return found;
832
+ continue;
833
+ }
834
+ const [, key, conditions] = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)((?:\[[^\]]*\])+)\s*=/) ?? [];
835
+ if (key && keys.includes(key) && !SDK_CONDITIONS.includes(conditions)) return `${file}:${index + 1} sets ${key}${conditions}`;
836
+ }
837
+ }
838
+ };
839
+ /**
840
+ * Writes the xcconfig a build loads, which sets the CLI's build settings
841
+ * over every other. xcodebuild ranks an `-xcconfig`'s settings above the
842
+ * command line's, so the scheme's `xcconfig` and each `-xcconfig` in
843
+ * `extraParams` are included, in order, before `settings`, and the build
844
+ * loads only this one.
845
+ */
846
+ const writeBuildXcconfig = async ({ directory, sourceDir, xcconfig, extraParams = [], settings }) => {
847
+ const included = xcconfig ? [xcconfig] : [];
848
+ const otherParams = [];
849
+ for (let i = 0; i < extraParams.length; i++) if (extraParams[i] === "-xcconfig" && i + 1 < extraParams.length) included.push(extraParams[++i]);
850
+ else otherParams.push(extraParams[i]);
851
+ const includedFiles = included.map((file) => path.default.resolve(sourceDir, file));
852
+ const conditional = await findConditionalSetting(includedFiles, Object.keys(settings));
853
+ if (conditional) throw new Error(`${conditional}, which would outrank the value the build sets. Set it without that condition, or remove it.`);
854
+ const buildXcconfig = path.default.join(directory, "HotUpdater.xcconfig");
855
+ await fs.default.promises.writeFile(buildXcconfig, [
856
+ ...includedFiles.map((file) => `#include "${file}"`),
857
+ ...Object.entries(settings).flatMap(([key, value]) => SDK_CONDITIONS.map((condition) => `${key}${condition} = ${value}`)),
858
+ ""
859
+ ].join("\n"));
860
+ return {
861
+ xcconfig: buildXcconfig,
862
+ extraParams: otherParams
863
+ };
864
+ };
865
+ /**
866
+ * Automatic signing over a target set to manual signing, whose provisioning
867
+ * profile and identity Xcode would otherwise report as conflicting.
868
+ */
869
+ const AUTOMATIC_SIGNING = {
870
+ CODE_SIGN_STYLE: "Automatic",
871
+ PROVISIONING_PROFILE: "",
872
+ PROVISIONING_PROFILE_SPECIFIER: "",
873
+ CODE_SIGN_IDENTITY: "Apple Development"
874
+ };
875
+ /**
876
+ * The settings a build sets over the scheme's: the MIN_BUNDLE_ID the CLI
877
+ * issues and reports, and automatic signing when an App Store Connect API
878
+ * key signs, which a target set to manual signing would not use.
879
+ */
880
+ const buildSettingsOverScheme = ({ minBundleId, authenticationArgs }) => ({
881
+ HOT_UPDATER_MIN_BUNDLE_ID: minBundleId,
882
+ ...authenticationArgs ? AUTOMATIC_SIGNING : {}
883
+ });
884
+ //#endregion
328
885
  //#region src/builder/archiveXcodeProject.ts
329
- const archiveXcodeProject = async ({ sourceDir, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix }) => {
330
- const xcodeProject = await parseXcodeProjectInfo(sourceDir);
886
+ const archiveXcodeProject = async ({ sourceDir, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, minBundleId, authenticationArgs }) => {
331
887
  if (installPods) await installPodsIfNeeded(sourceDir);
888
+ const xcodeProject = await parseXcodeProjectInfo(sourceDir);
332
889
  const tmpDir = await createRandomTmpDir();
333
890
  const archiveName = `${xcodeProject.name.replace(".xcworkspace", "").replace(".xcodeproj", "")}.xcarchive`;
334
891
  const archivePath = path.default.join(tmpDir, archiveName);
335
- const minBundleId = (0, _hot_updater_plugin_core.generateMinBundleId)();
892
+ const build = await writeBuildXcconfig({
893
+ directory: tmpDir,
894
+ sourceDir,
895
+ xcconfig,
896
+ extraParams,
897
+ settings: buildSettingsOverScheme({
898
+ minBundleId,
899
+ authenticationArgs
900
+ })
901
+ });
336
902
  const archiveArgs = prepareArchiveArgs({
903
+ authenticationArgs,
337
904
  minBundleId,
338
905
  archivePath,
339
906
  platform,
340
907
  sourceDir,
341
908
  xcodeProject,
342
909
  configuration,
343
- extraParams,
910
+ extraParams: build.extraParams,
344
911
  destination,
345
- xcconfig,
912
+ xcconfig: build.xcconfig,
346
913
  xcodeScheme
347
914
  });
348
915
  _hot_updater_cli_tools.p.log.info(`Xcode Archive Settings:
@@ -365,7 +932,7 @@ Command xcodebuild ${archiveArgs.join(" ")}
365
932
  throw prettifyXcodebuildError(error);
366
933
  }
367
934
  };
368
- const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId }) => {
935
+ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId, authenticationArgs }) => {
369
936
  const args = [
370
937
  xcodeProject.isWorkspace ? "-workspace" : "-project",
371
938
  path.default.join(sourceDir, xcodeProject.name),
@@ -378,6 +945,7 @@ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xco
378
945
  archivePath,
379
946
  `HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`
380
947
  ];
948
+ if (authenticationArgs) args.push(...authenticationArgs);
381
949
  if (xcconfig) args.push("-xcconfig", xcconfig);
382
950
  if (extraParams) args.push(...extraParams);
383
951
  const resolvedDestinations = resolveDestinations({
@@ -394,9 +962,9 @@ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xco
394
962
  };
395
963
  //#endregion
396
964
  //#region src/builder/buildXcodeProject.ts
397
- const buildXcodeProject = async ({ sourceDir, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams }) => {
398
- const xcodeProject = await parseXcodeProjectInfo(sourceDir);
965
+ const buildXcodeProject = async ({ sourceDir, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams, minBundleId, authenticationArgs }) => {
399
966
  if (installPods) await installPodsIfNeeded(sourceDir);
967
+ const xcodeProject = await parseXcodeProjectInfo(sourceDir);
400
968
  const derivedDataPath = await createRandomTmpDir();
401
969
  const resolvedDestinations = resolveDestinations({
402
970
  destinations: destination,
@@ -407,14 +975,24 @@ const buildXcodeProject = async ({ sourceDir, platform, xcodeScheme, configurati
407
975
  platform,
408
976
  useGeneric: useGenericDestination
409
977
  }));
410
- const minBundleId = (0, _hot_updater_plugin_core.generateMinBundleId)();
978
+ const build = await writeBuildXcconfig({
979
+ directory: await createRandomTmpDir(),
980
+ sourceDir,
981
+ extraParams,
982
+ settings: buildSettingsOverScheme({
983
+ minBundleId,
984
+ authenticationArgs
985
+ })
986
+ });
411
987
  const buildArgs = prepareBuildArgs({
988
+ authenticationArgs,
989
+ xcconfig: build.xcconfig,
412
990
  minBundleId,
413
991
  configuration,
414
992
  derivedDataPath,
415
993
  deviceType,
416
994
  resolvedDestinations,
417
- extraParams,
995
+ extraParams: build.extraParams,
418
996
  platform,
419
997
  xcodeScheme,
420
998
  sourceDir,
@@ -449,7 +1027,7 @@ Command xcodebuild ${buildArgs.join(" ")}
449
1027
  throw prettifyXcodebuildError(error);
450
1028
  }
451
1029
  };
452
- const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId }) => {
1030
+ const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId, authenticationArgs, xcconfig }) => {
453
1031
  const sdk = deviceType === "simulator" ? platformConfigs[platform].simulatorSdk : platformConfigs[platform].deviceSdk;
454
1032
  const args = [
455
1033
  xcodeProject.isWorkspace ? "-workspace" : "-project",
@@ -465,6 +1043,8 @@ const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, conf
465
1043
  `HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`,
466
1044
  "build"
467
1045
  ];
1046
+ if (authenticationArgs) args.push(...authenticationArgs);
1047
+ args.push("-xcconfig", xcconfig);
468
1048
  if (extraParams) args.push(...extraParams);
469
1049
  for (const dest of resolvedDestinations) args.push("-destination", dest);
470
1050
  return args;
@@ -501,9 +1081,10 @@ const getBuildSettings = async ({ xcodeProject, sourceDir, xcodeScheme, configur
501
1081
  };
502
1082
  //#endregion
503
1083
  //#region src/builder/exportXcodeArchive.ts
504
- const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix }) => {
1084
+ const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, authenticationArgs }) => {
505
1085
  const exportPath = await createRandomTmpDir();
506
1086
  const exportArgs = prepareExportArgs({
1087
+ authenticationArgs,
507
1088
  archivePath,
508
1089
  exportPath,
509
1090
  exportOptionsPlist,
@@ -522,7 +1103,7 @@ const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, e
522
1103
  throw prettifyXcodebuildError(error);
523
1104
  }
524
1105
  };
525
- const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist }) => {
1106
+ const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist, authenticationArgs }) => {
526
1107
  const args = [
527
1108
  "-exportArchive",
528
1109
  "-archivePath",
@@ -530,6 +1111,7 @@ const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportO
530
1111
  "-exportPath",
531
1112
  exportPath
532
1113
  ];
1114
+ if (authenticationArgs) args.push(...authenticationArgs);
533
1115
  if (exportExtraParams) args.push(...exportExtraParams);
534
1116
  args.push("-exportOptionsPlist", exportOptionsPlist);
535
1117
  return args;
@@ -545,446 +1127,1107 @@ const assertXcodebuildExist = async () => {
545
1127
  }
546
1128
  };
547
1129
  //#endregion
548
- //#region src/buildIos.ts
549
- const buildIos = async ({ schemeConfig }) => {
550
- await assertXcodebuildExist();
551
- const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
552
- const buildDirectory = await createRandomTmpDir();
553
- const archiveDir = path.default.join(buildDirectory, "archive");
554
- const exportDir = path.default.join(buildDirectory, "export");
555
- await fs.default.promises.mkdir(archiveDir, { recursive: true });
556
- if (schemeConfig.simulator) {
557
- if (schemeConfig.destination.length) {
558
- _hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
559
- process.exit(1);
1130
+ //#region src/utils/escapeUnzipPattern.ts
1131
+ /**
1132
+ * A zip entry name as `unzip` reads a literal member. It takes `*`, `?` and
1133
+ * `[...]` in a member name as wildcards, which a backslash escapes, so an
1134
+ * app named `My[Dev].app` would otherwise match nothing.
1135
+ */
1136
+ const escapeUnzipPattern = (name) => name.replace(/[[\]*?\\]/gu, "\\$&");
1137
+ //#endregion
1138
+ //#region src/utils/readIpaInfo.ts
1139
+ const INFO_PLIST_KEYS = {
1140
+ bundleId: "CFBundleIdentifier",
1141
+ shortVersion: "CFBundleShortVersionString",
1142
+ bundleVersion: "CFBundleVersion"
1143
+ };
1144
+ /**
1145
+ * Reads the app's `Payload/<App>.app/Info.plist` out of an `.ipa`, never a
1146
+ * framework's or an extension's, which nest deeper.
1147
+ */
1148
+ const readIpaInfo = async (ipaPath) => {
1149
+ const { stdout } = await (0, execa.execa)("unzip", ["-Z1", ipaPath]);
1150
+ const entry = stdout.split("\n").map((name) => name.trim()).find((name) => /^Payload\/[^/]+\.app\/Info\.plist$/u.test(name));
1151
+ if (entry === void 0) throw new Error(`${path.default.basename(ipaPath)} has no Payload/<App>.app/Info.plist.`);
1152
+ const directory = await createRandomTmpDir();
1153
+ try {
1154
+ const infoPlist = path.default.join(directory, "Info.plist");
1155
+ await (0, execa.execa)("unzip", [
1156
+ "-p",
1157
+ ipaPath,
1158
+ escapeUnzipPattern(entry)
1159
+ ], { stdout: { file: infoPlist } });
1160
+ const info = {};
1161
+ for (const [field, key] of Object.entries(INFO_PLIST_KEYS)) {
1162
+ const { stdout: value, exitCode } = await (0, execa.execa)("plutil", [
1163
+ "-extract",
1164
+ key,
1165
+ "raw",
1166
+ "-o",
1167
+ "-",
1168
+ infoPlist
1169
+ ], { reject: false });
1170
+ if (exitCode !== 0 || !value.trim()) throw new Error(`${path.default.basename(ipaPath)}'s Info.plist has no ${key}.`);
1171
+ info[field] = value.trim();
560
1172
  }
561
- const appDir = path.default.join(buildDirectory, "app");
562
- await fs.default.promises.mkdir(appDir, { recursive: true });
563
- const { appPath } = await buildXcodeProject({
564
- sourceDir: iosProjectRoot,
565
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
566
- platform: schemeConfig.platform,
567
- xcodeScheme: schemeConfig.scheme,
568
- configuration: schemeConfig.configuration,
569
- deviceType: "simulator",
570
- installPods: schemeConfig.installPods,
571
- extraParams: schemeConfig.extraParams,
572
- useGenericDestination: true
573
- });
574
- const finalAppPath = path.default.join(appDir, path.default.basename(appPath));
575
- await fs.default.promises.cp(appPath, finalAppPath, { recursive: true });
576
- _hot_updater_cli_tools.p.log.success(`.app file created: ${path.default.basename(appPath)}`);
577
- return {
578
- buildDirectory,
579
- buildArtifactPath: finalAppPath
580
- };
581
- } else {
582
- const { archivePath } = await archiveXcodeProject({
583
- platform: schemeConfig.platform,
584
- sourceDir: iosProjectRoot,
585
- xcconfig: schemeConfig.xcconfig,
586
- xcodeScheme: schemeConfig.scheme,
587
- destination: schemeConfig.destination,
588
- extraParams: schemeConfig.extraParams,
589
- configuration: schemeConfig.configuration,
590
- installPods: schemeConfig.installPods,
591
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-archive`
1173
+ return info;
1174
+ } finally {
1175
+ await fs.default.promises.rm(directory, {
1176
+ recursive: true,
1177
+ force: true
592
1178
  });
593
- const finalArchivePath = path.default.join(archiveDir, path.default.basename(archivePath));
594
- await fs.default.promises.cp(archivePath, finalArchivePath, { recursive: true });
595
- const appFromArchive = extractAppFromXcarchive(finalArchivePath);
596
- if (appFromArchive) {
597
- await fs.default.promises.cp(appFromArchive, path.default.join(archiveDir, path.default.basename(appFromArchive)), { recursive: true });
598
- _hot_updater_cli_tools.p.log.success(".app extracted from .xcarchive");
599
- }
600
- if (schemeConfig.exportOptionsPlist) {
601
- await fs.default.promises.mkdir(exportDir, { recursive: true });
602
- const { exportPath } = await exportXcodeArchive({
603
- archivePath,
604
- exportExtraParams: schemeConfig.exportExtraParams,
605
- exportOptionsPlist: schemeConfig.exportOptionsPlist,
606
- sourceDir: iosProjectRoot,
607
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-export`
608
- });
609
- await fs.default.promises.cp(exportPath, exportDir, { recursive: true });
610
- const ipaFile = fs.default.readdirSync(exportDir).find((file) => file.endsWith(".ipa"));
611
- if (ipaFile) return {
612
- buildDirectory,
613
- buildArtifactPath: path.default.join(exportDir, ipaFile)
614
- };
615
- throw new Error("IPA file not found after export");
616
- }
617
- return {
618
- buildDirectory,
619
- buildArtifactPath: finalArchivePath
620
- };
621
1179
  }
622
1180
  };
623
- const extractAppFromXcarchive = (archivePath) => {
624
- const productsPath = path.default.join(archivePath, "Products", "Applications");
625
- if (!fs.default.existsSync(productsPath)) return;
626
- const appFile = fs.default.readdirSync(productsPath).find((file) => file.endsWith(".app"));
627
- if (appFile) return path.default.join(productsPath, appFile);
628
- _hot_updater_cli_tools.p.log.warn("Failed to extract .app from xcarchive");
629
- };
630
1181
  //#endregion
631
- //#region src/runner/deviceRunner.ts
632
- const installAndLaunchOnDevice = async ({ device, appPath, options }) => {
633
- await installOnDevice({
634
- device,
635
- appPath,
636
- options
637
- });
638
- if (options.launch !== false) await launchAppOnDevice({
639
- device,
640
- options
1182
+ //#region src/utils/xcodeSigning.ts
1183
+ /**
1184
+ * How a device build signs with automatic signing, authenticated by the
1185
+ * scheme's App Store Connect API key: the xcodebuild arguments, and the key.
1186
+ * The key is resolved only here, for a device build of a scheme with
1187
+ * `signing`. Without `credentials`, it resolves from the environment or the
1188
+ * config's `secrets` adapter, and the temporary files are deleted when the
1189
+ * process exits.
1190
+ */
1191
+ const resolveXcodeSigning = async ({ schemeConfig, credentials }) => {
1192
+ const reference = schemeConfig.signing?.ascApiKey;
1193
+ if (!reference) return;
1194
+ const apiKey = await (credentials ?? (0, _hot_updater_cli_tools.createCredentialResolver)()).resolve({
1195
+ kind: "asc-api-key",
1196
+ name: (0, _hot_updater_cli_tools.parseSecretReference)(reference, "signing.ascApiKey")
641
1197
  });
1198
+ return {
1199
+ authenticationArgs: [
1200
+ "-allowProvisioningUpdates",
1201
+ "-authenticationKeyPath",
1202
+ apiKey.keyPath,
1203
+ "-authenticationKeyID",
1204
+ apiKey.keyId,
1205
+ "-authenticationKeyIssuerID",
1206
+ apiKey.issuerId
1207
+ ],
1208
+ apiKey
1209
+ };
642
1210
  };
643
- const installOnDevice = async ({ device, appPath, options }) => {
644
- const deviceCtlArgs = [
645
- "devicectl",
646
- "device",
647
- "install",
648
- "app",
649
- "--device",
650
- device.udid,
651
- appPath
652
- ];
653
- const spinner = _hot_updater_cli_tools.p.spinner();
654
- spinner.start(`Installing app on ${device.name}`);
655
- try {
656
- await (0, execa.execa)("xcrun", deviceCtlArgs, { cwd: options.sourceDir });
657
- spinner.stop(`Successfully installed app on ${device.name}`);
658
- } catch (error) {
659
- spinner.stop(`Failed to install app on ${device.name}`);
660
- throw new Error(`Failed to install the app on ${device.name}: ${error}`);
661
- }
662
- };
663
- const launchAppOnDevice = async ({ device, options }) => {
664
- const deviceCtlArgs = [
665
- "devicectl",
666
- "device",
667
- "process",
668
- "launch",
669
- "--device",
670
- device.udid,
671
- options.bundleIdentifier
672
- ];
673
- const spinner = _hot_updater_cli_tools.p.spinner();
674
- spinner.start(`Launching app on ${device.name}`);
675
- try {
676
- await (0, execa.execa)("xcrun", deviceCtlArgs, { cwd: options.sourceDir });
677
- spinner.stop(`Successfully launched app on ${device.name}`);
678
- } catch (error) {
679
- spinner.stop(`Failed to launch app on ${device.name}`);
680
- throw new Error(`Failed to launch the app on ${device.name}: ${error}`);
681
- }
1211
+ const APP_STORE_CONNECT_EXPORT_OPTIONS = `<?xml version="1.0" encoding="UTF-8"?>
1212
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1213
+ <plist version="1.0">
1214
+ <dict>
1215
+ <key>method</key>
1216
+ <string>app-store-connect</string>
1217
+ <key>signingStyle</key>
1218
+ <string>automatic</string>
1219
+ </dict>
1220
+ </plist>
1221
+ `;
1222
+ /**
1223
+ * Writes the export options of a scheme that signs with an App Store Connect
1224
+ * API key and sets no `exportOptionsPlist`: an App Store Connect export with
1225
+ * automatic signing.
1226
+ */
1227
+ const writeAppStoreConnectExportOptions = async (directory) => {
1228
+ const exportOptionsPlist = path.default.join(directory, "ExportOptions.plist");
1229
+ await fs.default.promises.writeFile(exportOptionsPlist, APP_STORE_CONNECT_EXPORT_OPTIONS);
1230
+ return exportOptionsPlist;
682
1231
  };
1232
+ /** The export methods whose `.ipa` App Store Connect takes. */
1233
+ const APP_STORE_EXPORT_METHODS = /* @__PURE__ */ new Set(["app-store", "app-store-connect"]);
1234
+ /** The `method` of export options, or "" when it has none or can't be read. */
1235
+ const readExportMethod = async (exportOptionsPlist) => {
1236
+ 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);
1237
+ if (xml) return xml[1];
1238
+ const { stdout, exitCode } = await (0, execa.execa)("plutil", [
1239
+ "-extract",
1240
+ "method",
1241
+ "raw",
1242
+ "-o",
1243
+ "-",
1244
+ exportOptionsPlist
1245
+ ], { reject: false });
1246
+ return exitCode === 0 ? stdout.trim() : "";
1247
+ };
1248
+ /**
1249
+ * Whether a device build exports for App Store Connect: with its
1250
+ * `exportOptionsPlist`'s method, or, without one, as a scheme signing with
1251
+ * an App Store Connect API key does. Such an `.ipa` is signed with Apple's
1252
+ * cloud-managed distribution certificate, never with a development
1253
+ * certificate the archive was signed with.
1254
+ */
1255
+ const exportsForAppStoreConnect = async (schemeConfig) => !schemeConfig.exportOptionsPlist || APP_STORE_EXPORT_METHODS.has(await readExportMethod(schemeConfig.exportOptionsPlist));
683
1256
  //#endregion
684
- //#region src/runner/simulatorRunner.ts
685
- const installAndLaunchOnSimulator = async ({ device, appPath, options }) => {
686
- await launchSimulator({ device });
687
- await installOnSimulator({
688
- device,
689
- appPath,
690
- options
691
- });
692
- if (options.launch !== false) await launchAppOnSimulator({
693
- device,
694
- options
695
- });
1257
+ //#region src/uploadToTestFlight.ts
1258
+ const API_URL$1 = "https://api.appstoreconnect.apple.com";
1259
+ /** Apple accepts tokens that expire within 20 minutes. */
1260
+ const TOKEN_LIFETIME_SECONDS = 1200;
1261
+ /** A token is replaced well before it expires, so no request carries a stale one. */
1262
+ const TOKEN_REUSE_SECONDS = 600;
1263
+ /** The parts of an `.ipa` sent at once. */
1264
+ const UPLOAD_CONCURRENCY = 4;
1265
+ /** Attempts to create the build upload and to commit the uploaded `.ipa`. */
1266
+ const COMMIT_ATTEMPTS = 3;
1267
+ /** Attempts per part: a part is a plain PUT, safe to repeat. */
1268
+ const UPLOAD_ATTEMPTS = 3;
1269
+ const IN_PROGRESS_STATES = /* @__PURE__ */ new Set(["AWAITING_UPLOAD", "PROCESSING"]);
1270
+ /** The error App Store Connect answers with when a build number is taken. */
1271
+ const DUPLICATE_ERROR_CODE = "ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE";
1272
+ /**
1273
+ * App Store Connect refused the upload: it already has a build with the
1274
+ * `.ipa`'s version and build number, from this or another upload, and takes
1275
+ * each build number once per version.
1276
+ */
1277
+ var TestFlightBuildExistsError = class extends Error {
1278
+ shortVersion;
1279
+ bundleVersion;
1280
+ name = "TestFlightBuildExistsError";
1281
+ constructor(shortVersion, bundleVersion, options) {
1282
+ super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}. Each build of a version needs a new build number (CFBundleVersion).`, options);
1283
+ this.shortVersion = shortVersion;
1284
+ this.bundleVersion = bundleVersion;
1285
+ }
696
1286
  };
697
- const launchSimulator = async ({ device }) => {
698
- if (device.type !== "simulator") return;
699
- const { stdout: activeDeveloperDir } = await (0, execa.execa)("xcode-select", ["-p"]);
700
- await (0, execa.execa)("open", [
701
- `${activeDeveloperDir}/Applications/Simulator.app`,
702
- "--args",
703
- "-CurrentDeviceUDID",
704
- device.udid
705
- ]);
706
- if (device.state !== "Booted") await bootSimulator({ device });
1287
+ /** An App Store Connect API request that failed, with the errors it answered. */
1288
+ var AppStoreConnectError = class extends Error {
1289
+ status;
1290
+ errors;
1291
+ /** The answer's `Retry-After` header, for a rate limit. */
1292
+ retryAfter;
1293
+ constructor(action, status, errors, retryAfter = null) {
1294
+ const details = errors.map((error) => error.detail ?? error.title ?? error.code).filter(Boolean);
1295
+ super(`${action} failed (HTTP ${status})${details.length > 0 ? `: ${details.join(" ")}` : "."}`);
1296
+ this.name = "AppStoreConnectError";
1297
+ this.status = status;
1298
+ this.errors = errors;
1299
+ this.retryAfter = retryAfter;
1300
+ }
707
1301
  };
708
- const installOnSimulator = async ({ device, appPath, options }) => {
709
- const spinner = _hot_updater_cli_tools.p.spinner();
710
- spinner.start(`Installing app on "${device.name}"`);
1302
+ /**
1303
+ * An App Store Connect API token: an ES256 JWT, whose signature JWS encodes
1304
+ * as the raw 64-byte r || s, not the DER Node signs by default.
1305
+ */
1306
+ const createAppStoreConnectToken = ({ privateKey, keyId, issuerId, issuedAtSeconds = Math.floor(Date.now() / 1e3) }) => {
1307
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
1308
+ const signingInput = `${encode({
1309
+ alg: "ES256",
1310
+ kid: keyId,
1311
+ typ: "JWT"
1312
+ })}.${encode({
1313
+ iss: issuerId,
1314
+ iat: issuedAtSeconds,
1315
+ exp: issuedAtSeconds + TOKEN_LIFETIME_SECONDS,
1316
+ aud: "appstoreconnect-v1"
1317
+ })}`;
1318
+ return `${signingInput}.${crypto.default.sign("sha256", Buffer.from(signingInput), {
1319
+ key: privateKey,
1320
+ dsaEncoding: "ieee-p1363"
1321
+ }).toString("base64url")}`;
1322
+ };
1323
+ const readPrivateKey = async (keyPath) => {
711
1324
  try {
712
- await (0, execa.execa)("xcrun", [
713
- "simctl",
714
- "install",
715
- device.udid,
716
- appPath
717
- ], { cwd: options.sourceDir });
718
- spinner.stop(`Successfully installed app on "${device.name}"`);
719
- } catch (error) {
720
- spinner.stop(`Failed to install app on "${device.name}"`);
721
- throw new Error(`Failed to install the app on simulator: ${error}`);
1325
+ return crypto.default.createPrivateKey(await fs.default.promises.readFile(keyPath, "utf-8"));
1326
+ } catch {
1327
+ throw new Error("The App Store Connect API key is not a .p8 private key.");
722
1328
  }
723
1329
  };
724
- const launchAppOnSimulator = async ({ device, options }) => {
725
- const spinner = _hot_updater_cli_tools.p.spinner();
726
- spinner.start(`Launching app on "${device.name}"`);
727
- try {
728
- await (0, execa.execa)("xcrun", [
729
- "simctl",
730
- "launch",
731
- device.udid,
732
- options.bundleIdentifier
733
- ], { cwd: options.sourceDir });
734
- spinner.stop(`Successfully launched app on "${device.name}"`);
1330
+ const readApiErrors = async (response) => {
1331
+ const body = await response.json().catch(() => void 0);
1332
+ return Array.isArray(body?.errors) ? body.errors : [];
1333
+ };
1334
+ const describeDetails = (details) => details.map((detail) => `${detail.description ?? "(no description)"}${detail.code ? ` (${detail.code})` : ""}`);
1335
+ /** Answers a request may outlive: a timeout, a rate limit, a server error. */
1336
+ const isRetryableStatus = (status) => status === 408 || status === 429 || status >= 500;
1337
+ /**
1338
+ * Whether a request may succeed when sent again: a retryable answer, or a
1339
+ * network failure, which fetch rejects with a TypeError. A refused key or
1340
+ * request is not.
1341
+ */
1342
+ const isRetryable = (error) => error instanceof AppStoreConnectError ? isRetryableStatus(error.status) : error instanceof TypeError;
1343
+ /** Whether App Store Connect refused a request for its rate limit only. */
1344
+ const isRateLimited = (error) => error instanceof AppStoreConnectError && error.status === 429;
1345
+ /**
1346
+ * Runs `request`, again after a failure `retryable` accepts, up to the
1347
+ * attempts.
1348
+ */
1349
+ const retrying = async (request, retryable = isRetryable) => {
1350
+ for (let attempt = 1;; attempt++) try {
1351
+ return await request();
735
1352
  } catch (error) {
736
- spinner.stop(`Failed to launch app on "${device.name}"`);
737
- throw new Error(`Failed to launch the app on simulator: ${error}`);
1353
+ if (!retryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
1354
+ await sleep(retryDelayMs(error, attempt));
738
1355
  }
739
1356
  };
740
- const bootSimulator = async ({ device }) => {
741
- try {
742
- await (0, execa.execa)("xcrun", [
743
- "simctl",
744
- "boot",
745
- device.udid
746
- ]);
747
- } catch (error) {
748
- if ((error instanceof Error ? error.message : String(error)).includes("Unable to boot device in current state: Booted")) {
749
- _hot_updater_cli_tools.p.log.info(`Simulator ${device.udid} already booted. Skipping.`);
750
- return;
1357
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1358
+ /** The longest wait before a retry, so a run stays bounded. */
1359
+ const MAX_RETRY_DELAY_MS = 6e4;
1360
+ /**
1361
+ * The first wait after a rate limit without `Retry-After`: App Store Connect
1362
+ * limits a key per rolling hour, so a retry within seconds meets it again.
1363
+ */
1364
+ const RATE_LIMIT_DELAY_MS = 15e3;
1365
+ /**
1366
+ * How long to wait before sending a request again after `failure`, an
1367
+ * answer or the error it raised: the `Retry-After` seconds it asks for, a
1368
+ * longer backoff for a rate limit without one, and else a growing backoff,
1369
+ * all within a bound.
1370
+ */
1371
+ const retryDelayMs = (failure, attempt) => {
1372
+ const { status, retryAfter } = failure instanceof Response ? {
1373
+ status: failure.status,
1374
+ retryAfter: failure.headers.get("retry-after")
1375
+ } : failure instanceof AppStoreConnectError ? failure : {
1376
+ status: 0,
1377
+ retryAfter: null
1378
+ };
1379
+ const seconds = retryAfter === null || retryAfter.trim() === "" ? NaN : Number(retryAfter);
1380
+ const delay = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : status === 429 ? RATE_LIMIT_DELAY_MS * attempt : 1e3 * attempt;
1381
+ return Math.min(delay, MAX_RETRY_DELAY_MS);
1382
+ };
1383
+ /**
1384
+ * Runs `run` over `items`, at most `concurrency` at a time. After the first
1385
+ * failure no item starts, and it throws that failure once every running item
1386
+ * has settled, so nothing is still sending when the caller cleans up.
1387
+ */
1388
+ const runConcurrently = async (items, concurrency, run) => {
1389
+ let next = 0;
1390
+ let failure;
1391
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
1392
+ while (!failure && next < items.length) {
1393
+ const index = next++;
1394
+ try {
1395
+ await run(items[index], index);
1396
+ } catch (error) {
1397
+ failure ??= { error };
1398
+ }
751
1399
  }
752
- throw new Error(`Failed to boot simulator: ${error}`);
1400
+ }));
1401
+ if (failure) throw failure.error;
1402
+ };
1403
+ /**
1404
+ * A signing build setting's assignment: the key, any conditions such as
1405
+ * `[sdk=iphoneos*]`, and the value, without a trailing comment.
1406
+ */
1407
+ const SIGNING_SETTING = /^\s*(CODE_SIGNING_ALLOWED|CODE_SIGNING_REQUIRED)((?:\[[^\]]*\])*)\s*=\s*([^\s/]*)\s*(?:\/\/.*)?$/u;
1408
+ /** A scheme's `exportOptionsPlist`, which resolves from the project's root. */
1409
+ const schemeFile = (file) => path.default.isAbsolute(file) ? file : path.default.resolve((0, _hot_updater_cli_tools.getCwd)(), file);
1410
+ /** Applies one assignment, as Xcode does: the last unconditional one wins. */
1411
+ const applySetting = (settings, line, label) => {
1412
+ const [, key, conditions, value] = line.match(SIGNING_SETTING) ?? [];
1413
+ if (!key) return;
1414
+ if (conditions) settings.conditional.add(key);
1415
+ else settings.values.set(key, {
1416
+ value,
1417
+ where: `${line.trim()} in ${label}`
1418
+ });
1419
+ };
1420
+ /**
1421
+ * Applies an xcconfig's signing settings over `settings`, in order,
1422
+ * following its `#include`s, which resolve from the including file, as
1423
+ * Xcode reads it. A file included again applies again; only an include
1424
+ * cycle (`including`) stops.
1425
+ */
1426
+ const applyXcconfig = async (file, label, settings, including) => {
1427
+ if (including.has(file)) return;
1428
+ including.add(file);
1429
+ const uncommented = (await fs.default.promises.readFile(file, "utf-8").catch(() => void 0))?.replace(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replace(/[^\n]/gu, ""));
1430
+ for (const line of uncommented?.split("\n") ?? []) {
1431
+ const include = line.match(/^\s*#include\??\s*"([^"]+)"/u)?.[1];
1432
+ if (include) await applyXcconfig(path.default.resolve(path.default.dirname(file), include), include, settings, including);
1433
+ else applySetting(settings, line, label);
753
1434
  }
1435
+ including.delete(file);
754
1436
  };
755
- //#endregion
756
- //#region src/utils/device.ts
757
- /**
758
- * Parses devicectl output to Device array
759
- * @param devicectlOutput - Raw devicectl JSON output
760
- * @returns Array of parsed devices
761
- */
762
- const parseDevicectlList = (devicectlOutput) => {
763
- return devicectlOutput.map((device) => ({
764
- name: device.deviceProperties.name,
765
- udid: device.hardwareProperties.udid,
766
- version: `${device.hardwareProperties.platform} ${device.deviceProperties.osVersionNumber}`,
767
- platform: getPlatformFromOsVersion(device.hardwareProperties.platform),
768
- state: device.deviceProperties.bootState === "booted" ? "Booted" : "Shutdown",
769
- type: "device"
770
- }));
1437
+ /**
1438
+ * Where the scheme turns code signing off, after every override the build
1439
+ * applies: the `extraParams` settings first, then the xcconfigs the build
1440
+ * xcconfig includes over them, in order, its `xcconfig` and each
1441
+ * `-xcconfig` in `extraParams`, which resolve from `ios/`, where xcodebuild
1442
+ * runs (`writeBuildXcconfig`). Undefined when it signs, or when a
1443
+ * conditional setting, such as `[sdk=iphoneos*]`, may decide: the build
1444
+ * does.
1445
+ */
1446
+ const codeSigningOff = async (iosScheme) => {
1447
+ const extraParams = iosScheme.extraParams ?? [];
1448
+ const settings = {
1449
+ values: /* @__PURE__ */ new Map(),
1450
+ conditional: /* @__PURE__ */ new Set()
1451
+ };
1452
+ for (const entry of extraParams) applySetting(settings, entry, "extraParams");
1453
+ const xcconfigs = [...iosScheme.xcconfig ? [iosScheme.xcconfig] : [], ...extraParams.flatMap((entry, index) => entry === "-xcconfig" && index + 1 < extraParams.length ? [extraParams[index + 1]] : [])];
1454
+ for (const xcconfig of xcconfigs) await applyXcconfig(path.default.resolve((0, _hot_updater_cli_tools.getCwd)(), "ios", xcconfig), xcconfig, settings, /* @__PURE__ */ new Set());
1455
+ for (const [key, { value, where }] of settings.values) if (/^no$/iu.test(value) && !settings.conditional.has(key)) return where;
771
1456
  };
772
1457
  /**
773
- * Gets physical iOS devices using devicectl
1458
+ * The App Store Connect API key a scheme's `.ipa` uploads with: the
1459
+ * `signing.ascApiKey` it signs with, or else the `asc-api-key` item. It reads
1460
+ * the config, and the scheme's files that exist before the native projects
1461
+ * are written, and throws, saying why, for a scheme whose build App Store
1462
+ * Connect would not take: a simulator `.app`, a Debug configuration, which
1463
+ * loads its JavaScript from Metro, code signing turned off, an
1464
+ * `.xcarchive` it cannot export, or export options of another method. An
1465
+ * `exportOptionsPlist` the adapter's prebuild writes later is checked after
1466
+ * it (`assertTestFlightExportOptions`).
774
1467
  */
775
- const getDevices = async () => {
776
- const tmpPath = path.default.resolve(node_os.default.tmpdir(), "iosPhysicalDevices.json");
777
- try {
778
- await (0, execa.execa)("xcrun", [
779
- "devicectl",
780
- "list",
781
- "devices",
782
- "-j",
783
- tmpPath
784
- ]);
785
- const output = JSON.parse(fs.default.readFileSync(tmpPath, "utf8"));
786
- return parseDevicectlList(output.result.devices);
787
- } catch (error) {
788
- throw new Error(`Failed to get devices: ${error}`);
1468
+ const resolveTestFlightUploadKey = async ({ scheme, iosScheme }) => {
1469
+ if (iosScheme.simulator) throw new Error(`TestFlight takes an .ipa, and the scheme ${scheme} builds a simulator .app.`);
1470
+ const configuration = iosScheme.configuration ?? "Release";
1471
+ if (/debug$/i.test(configuration)) throw new Error(`TestFlight takes a release build, and the scheme ${scheme} builds the ${configuration} configuration, which loads its JavaScript from Metro.`);
1472
+ const unsigned = await codeSigningOff(iosScheme);
1473
+ if (unsigned) throw new Error(`TestFlight takes a signed .ipa, and the scheme ${scheme} turns code signing off (${unsigned}).`);
1474
+ if (!iosScheme.exportOptionsPlist && !iosScheme.signing) throw new Error(`The scheme ${scheme} archives without exporting an .ipa. Set signing.ascApiKey, or an exportOptionsPlist with the method app-store-connect.`);
1475
+ if (iosScheme.exportOptionsPlist && fs.default.existsSync(schemeFile(iosScheme.exportOptionsPlist))) {
1476
+ const method = await readExportMethod(schemeFile(iosScheme.exportOptionsPlist));
1477
+ if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`TestFlight takes an .ipa exported for App Store Connect, and the scheme ${scheme}'s exportOptionsPlist exports with the method ${method || "(none)"}. Set its method to app-store-connect.`);
789
1478
  }
1479
+ return {
1480
+ kind: "asc-api-key",
1481
+ name: iosScheme.signing ? (0, _hot_updater_cli_tools.parseSecretReference)(iosScheme.signing.ascApiKey, "signing.ascApiKey") : "asc-api-key"
1482
+ };
790
1483
  };
791
1484
  /**
792
- * Gets iOS simulators using simctl with JSON output
1485
+ * Throws when the scheme's `exportOptionsPlist` exports with another method
1486
+ * than App Store Connect's, whose `.ipa` App Store Connect refuses. It reads
1487
+ * the file, which an adapter's prebuild may write, so it runs after it.
793
1488
  */
794
- const getSimulators = async () => {
1489
+ const assertTestFlightExportOptions = async (schemeConfig) => {
1490
+ if (!schemeConfig.exportOptionsPlist) return;
1491
+ const method = await readExportMethod(schemeConfig.exportOptionsPlist);
1492
+ if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`TestFlight takes an .ipa exported for App Store Connect, and the scheme ${schemeConfig.hotUpdaterSchemeName}'s exportOptionsPlist exports with the method ${method || "(none)"}. Set its method to app-store-connect.`);
1493
+ };
1494
+ /**
1495
+ * Requests to the App Store Connect API with `apiKey`: each carries a token
1496
+ * signed with its private key, replaced well before it expires.
1497
+ */
1498
+ const createAppStoreConnectRequest = ({ apiKey, privateKey, fetch }) => {
1499
+ let token;
1500
+ const authorization = () => {
1501
+ const now = Math.floor(Date.now() / 1e3);
1502
+ if (!token || now - token.issuedAtSeconds >= TOKEN_REUSE_SECONDS) {
1503
+ token = {
1504
+ value: createAppStoreConnectToken({
1505
+ privateKey,
1506
+ keyId: apiKey.keyId,
1507
+ issuerId: apiKey.issuerId,
1508
+ issuedAtSeconds: now
1509
+ }),
1510
+ issuedAtSeconds: now
1511
+ };
1512
+ (0, _hot_updater_cli_tools.registerSecretValue)(token.value);
1513
+ }
1514
+ return `Bearer ${token.value}`;
1515
+ };
1516
+ return async (action, method, pathname, body) => {
1517
+ const response = await fetch(`${API_URL$1}${pathname}`, {
1518
+ method,
1519
+ headers: {
1520
+ Authorization: authorization(),
1521
+ ...body ? { "Content-Type": "application/json" } : {}
1522
+ },
1523
+ ...body ? { body: JSON.stringify(body) } : {}
1524
+ });
1525
+ if (!response.ok) {
1526
+ const error = new AppStoreConnectError(action, response.status, await readApiErrors(response), response.headers.get("retry-after"));
1527
+ if (response.status === 401) throw new Error("App Store Connect refused the API key: check its key ID and issuer ID, and that the key is not revoked.", { cause: error });
1528
+ if (response.status === 403) throw new Error(`${error.message} An API key with the Admin, App Manager, or Developer role uploads builds.`, { cause: error });
1529
+ throw error;
1530
+ }
1531
+ return response.status === 204 ? void 0 : await response.json();
1532
+ };
1533
+ };
1534
+ /** The App Store Connect app of `bundleId`, and its TestFlight page. */
1535
+ const findApp = async (request, bundleId) => {
1536
+ const { data: apps } = await retrying(() => request("Finding the App Store Connect app", "GET", `/v1/apps?${new URLSearchParams({
1537
+ "filter[bundleId]": bundleId,
1538
+ "fields[apps]": "bundleId"
1539
+ })}`));
1540
+ const app = apps.find((app) => app.attributes?.bundleId === bundleId);
1541
+ if (!app) throw new Error(`App Store Connect has no app with the bundle ID ${bundleId} that this API key can see. Create the app in App Store Connect, or use a key of its team.`);
1542
+ return {
1543
+ app,
1544
+ testFlightUrl: `https://appstoreconnect.apple.com/apps/${app.id}/testflight/ios`
1545
+ };
1546
+ };
1547
+ /**
1548
+ * Whether App Store Connect already has the `.ipa`'s build: a build of its
1549
+ * version with its build number, which TestFlight lists or is processing,
1550
+ * as when a reused native build was uploaded before. A build upload that
1551
+ * never finished is no build, so uploading again fails as it would without
1552
+ * this check.
1553
+ */
1554
+ const findTestFlightBuild = async ({ ipaPath, apiKey, fetch = globalThis.fetch }) => {
1555
+ const [info, privateKey] = await Promise.all([readIpaInfo(ipaPath), readPrivateKey(apiKey.keyPath)]);
1556
+ const request = createAppStoreConnectRequest({
1557
+ apiKey,
1558
+ privateKey,
1559
+ fetch
1560
+ });
1561
+ const { app, testFlightUrl } = await findApp(request, info.bundleId);
1562
+ const { data: builds } = await retrying(() => request("Finding the build in App Store Connect", "GET", `/v1/builds?${new URLSearchParams({
1563
+ "filter[app]": app.id,
1564
+ "filter[version]": info.bundleVersion,
1565
+ "filter[preReleaseVersion.version]": info.shortVersion,
1566
+ "fields[builds]": "version",
1567
+ limit: "1"
1568
+ })}`));
1569
+ return {
1570
+ found: builds.length > 0,
1571
+ testFlightUrl,
1572
+ shortVersion: info.shortVersion,
1573
+ bundleVersion: info.bundleVersion
1574
+ };
1575
+ };
1576
+ /**
1577
+ * Uploads an `.ipa` to App Store Connect, which lists it in TestFlight, with
1578
+ * the App Store Connect API's build uploads: it finds the app by the
1579
+ * `.ipa`'s bundle ID, reserves the build's version and build number, sends
1580
+ * the file in the parts App Store Connect asks for, commits it, and waits
1581
+ * for App Store Connect to accept or reject the build.
1582
+ */
1583
+ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, pollIntervalMs = 5e3, processingTimeoutMs = 18e5 }) => {
1584
+ const ipaName = path.default.basename(ipaPath);
1585
+ const [info, privateKey, { size: fileSize }] = await Promise.all([
1586
+ readIpaInfo(ipaPath),
1587
+ readPrivateKey(apiKey.keyPath),
1588
+ fs.default.promises.stat(ipaPath)
1589
+ ]);
1590
+ const request = createAppStoreConnectRequest({
1591
+ apiKey,
1592
+ privateKey,
1593
+ fetch
1594
+ });
1595
+ const { app, testFlightUrl } = await findApp(request, info.bundleId);
1596
+ let buildUploadId;
1597
+ let unanswered = false;
1598
+ for (let attempt = 1; buildUploadId === void 0; attempt++) try {
1599
+ ({data: {id: buildUploadId}} = await request("Creating the build upload", "POST", "/v1/buildUploads", { data: {
1600
+ type: "buildUploads",
1601
+ attributes: {
1602
+ platform: "IOS",
1603
+ cfBundleShortVersionString: info.shortVersion,
1604
+ cfBundleVersion: info.bundleVersion
1605
+ },
1606
+ relationships: { app: { data: {
1607
+ type: "apps",
1608
+ id: app.id
1609
+ } } }
1610
+ } }));
1611
+ } catch (error) {
1612
+ if (error instanceof AppStoreConnectError && error.status === 409 && error.errors.some((apiError) => apiError.code === DUPLICATE_ERROR_CODE)) {
1613
+ if (unanswered) throw new Error(`App Store Connect already has build ${info.bundleVersion} of version ${info.shortVersion}, which a request of this upload it did not answer may have reserved. Upload with a new build number (CFBundleVersion).`, { cause: error });
1614
+ throw new TestFlightBuildExistsError(info.shortVersion, info.bundleVersion, { cause: error });
1615
+ }
1616
+ if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
1617
+ if (!isRateLimited(error)) unanswered = true;
1618
+ await sleep(retryDelayMs(error, attempt));
1619
+ }
1620
+ _hot_updater_cli_tools.p.log.info(`Uploading ${ipaName} (version ${info.shortVersion}, build ${info.bundleVersion}) to App Store Connect`);
1621
+ const removeUpload = async () => {
1622
+ for (let attempt = 1;; attempt++) try {
1623
+ await request("Removing the build upload", "DELETE", `/v1/buildUploads/${buildUploadId}`);
1624
+ return;
1625
+ } catch (error) {
1626
+ if (error instanceof AppStoreConnectError && error.status === 404) return;
1627
+ if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) {
1628
+ _hot_updater_cli_tools.p.log.warn(`App Store Connect kept the unfinished build upload ${buildUploadId}, which reserves build ${info.bundleVersion} of version ${info.shortVersion}: ${error.message} Upload with a new build number (CFBundleVersion).`);
1629
+ return;
1630
+ }
1631
+ await sleep(retryDelayMs(error, attempt));
1632
+ }
1633
+ };
1634
+ let fileId;
795
1635
  try {
796
- const { stdout } = await (0, execa.execa)("xcrun", [
797
- "simctl",
798
- "list",
799
- "devices",
800
- "available",
801
- "-j"
802
- ]);
803
- const output = JSON.parse(stdout);
804
- return parseSimctlJson(output);
1636
+ const { data } = await retrying(() => request("Reserving the .ipa upload", "POST", "/v1/buildUploadFiles", { data: {
1637
+ type: "buildUploadFiles",
1638
+ attributes: {
1639
+ assetType: "ASSET",
1640
+ fileName: ipaName,
1641
+ fileSize,
1642
+ uti: "com.apple.ipa"
1643
+ },
1644
+ relationships: { buildUpload: { data: {
1645
+ type: "buildUploads",
1646
+ id: buildUploadId
1647
+ } } }
1648
+ } }), isRateLimited);
1649
+ fileId = data.id;
1650
+ const operations = data.attributes?.uploadOperations ?? [];
1651
+ if (operations.length === 0) throw new Error("App Store Connect returned no upload operations.");
1652
+ await uploadParts({
1653
+ ipaPath,
1654
+ operations,
1655
+ fetch
1656
+ });
805
1657
  } catch (error) {
806
- throw new Error(`Failed to get simulators: ${error}`);
1658
+ await removeUpload();
1659
+ throw error;
1660
+ }
1661
+ let commitUnanswered = false;
1662
+ for (let attempt = 1;; attempt++) try {
1663
+ await request("Committing the .ipa upload", "PATCH", `/v1/buildUploadFiles/${fileId}`, { data: {
1664
+ type: "buildUploadFiles",
1665
+ id: fileId,
1666
+ attributes: { uploaded: true }
1667
+ } });
1668
+ break;
1669
+ } catch (error) {
1670
+ const unconfirmed = () => new Error(`App Store Connect did not confirm the upload of build ${info.bundleVersion}: ${error.message} Check the build in TestFlight before you upload it again: ${testFlightUrl}`, { cause: error });
1671
+ if (!isRetryable(error)) {
1672
+ if (commitUnanswered) throw unconfirmed();
1673
+ await removeUpload();
1674
+ throw error;
1675
+ }
1676
+ commitUnanswered = true;
1677
+ const fileState = await retrying(() => request("Reading the .ipa upload", "GET", `/v1/buildUploadFiles/${fileId}?fields[buildUploadFiles]=assetDeliveryState`)).then(({ data }) => data.attributes?.assetDeliveryState?.state).catch(() => void 0);
1678
+ if (fileState && fileState !== "AWAITING_UPLOAD") break;
1679
+ if (attempt === COMMIT_ATTEMPTS) throw unconfirmed();
1680
+ await sleep(retryDelayMs(error, attempt));
1681
+ }
1682
+ const deadline = Date.now() + processingTimeoutMs;
1683
+ let reportedProcessing = false;
1684
+ let lastError;
1685
+ for (let failedReads = 0;;) {
1686
+ let readError;
1687
+ const read = await request("Reading the build upload", "GET", `/v1/buildUploads/${buildUploadId}`).catch((error) => {
1688
+ if (!isRetryable(error)) throw error;
1689
+ lastError = readError = error;
1690
+ });
1691
+ failedReads = read ? 0 : failedReads + 1;
1692
+ const state = read ? read.data.attributes?.state ?? {} : {};
1693
+ if (state.state === "PROCESSING") reportedProcessing = true;
1694
+ if (!state.state || IN_PROGRESS_STATES.has(state.state)) {
1695
+ if (Date.now() >= deadline) {
1696
+ if (!reportedProcessing) throw new Error(`App Store Connect did not report build ${info.bundleVersion} processing${lastError instanceof Error ? `: ${lastError.message}` : state.state ? ` (${state.state}).` : "."} Check the build in TestFlight: ${testFlightUrl}`, { cause: lastError });
1697
+ _hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${info.bundleVersion}. It appears in TestFlight once processing finishes: ${testFlightUrl}`);
1698
+ return {
1699
+ appId: app.id,
1700
+ buildUploadId,
1701
+ state: "PROCESSING",
1702
+ testFlightUrl,
1703
+ shortVersion: info.shortVersion,
1704
+ bundleVersion: info.bundleVersion
1705
+ };
1706
+ }
1707
+ await sleep(readError === void 0 ? pollIntervalMs : Math.max(pollIntervalMs, retryDelayMs(readError, failedReads)));
1708
+ continue;
1709
+ }
1710
+ for (const warning of describeDetails(state.warnings ?? [])) _hot_updater_cli_tools.p.log.warn(warning);
1711
+ if (state.state !== "COMPLETE") {
1712
+ const errors = describeDetails(state.errors ?? []);
1713
+ throw new Error([`App Store Connect rejected build ${info.bundleVersion} of version ${info.shortVersion} (${state.state}).`, ...errors.map((error) => `- ${error}`)].join("\n"));
1714
+ }
1715
+ return {
1716
+ appId: app.id,
1717
+ buildUploadId,
1718
+ state: "COMPLETE",
1719
+ testFlightUrl,
1720
+ shortVersion: info.shortVersion,
1721
+ bundleVersion: info.bundleVersion
1722
+ };
807
1723
  }
808
1724
  };
809
1725
  /**
810
- * Parses simctl JSON output to Device array
1726
+ * Sends each part App Store Connect asked for: the bytes at its offset, with
1727
+ * only the headers it lists, since the URLs are presigned: another header,
1728
+ * or the App Store Connect token, would break their signature.
811
1729
  */
812
- const parseSimctlJson = (simctlOutput) => {
813
- const devices = [];
814
- for (const [runtime, runtimeDevices] of Object.entries(simctlOutput.devices)) {
815
- const osVersion = runtime.replace("com.apple.CoreSimulator.SimRuntime.", "").replace(/-/g, " ");
816
- const platform = getPlatformFromOsVersion(osVersion.split(" ")[0]);
817
- if (!platform) continue;
818
- for (const device of runtimeDevices) if (device.isAvailable) devices.push({
819
- name: device.name,
820
- udid: device.udid,
821
- version: osVersion,
822
- platform,
823
- state: device.state,
824
- type: "simulator"
1730
+ const uploadParts = async ({ ipaPath, operations, fetch }) => {
1731
+ const file = await fs.default.promises.open(ipaPath, "r");
1732
+ try {
1733
+ await runConcurrently(operations, UPLOAD_CONCURRENCY, async (operation, index) => {
1734
+ const body = Buffer.alloc(operation.length);
1735
+ const { bytesRead } = await file.read(body, 0, operation.length, operation.offset);
1736
+ if (bytesRead !== operation.length) throw new Error(`${path.default.basename(ipaPath)} changed while it was uploading.`);
1737
+ const headers = {};
1738
+ for (const { name, value } of operation.requestHeaders ?? []) headers[name] = value;
1739
+ for (let attempt = 1;; attempt++) {
1740
+ const response = await fetch(operation.url, {
1741
+ method: operation.method,
1742
+ headers,
1743
+ body
1744
+ }).catch((error) => error);
1745
+ if (!(response instanceof Error) && response.ok) return;
1746
+ 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}`}.`);
1747
+ await sleep(retryDelayMs(response, attempt));
1748
+ }
825
1749
  });
1750
+ } finally {
1751
+ await file.close();
826
1752
  }
827
- return devices;
1753
+ };
1754
+ //#endregion
1755
+ //#region src/utils/ciSigningKeychain.ts
1756
+ const API_URL = "https://api.appstoreconnect.apple.com";
1757
+ const CERTIFICATES_URL = "https://developer.apple.com/account/resources/certificates/list";
1758
+ /** Long enough for any build: the keychain never locks while one runs. */
1759
+ const KEYCHAIN_TIMEOUT_SECONDS = 21600;
1760
+ const runSecurity = async (args) => (await (0, execa.execa)("security", args)).stdout;
1761
+ /** The keychain paths `security` prints, one quoted path per line. */
1762
+ const parseKeychains = (stdout) => [...stdout.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
1763
+ const normalizeSerial = (serial) => serial.toUpperCase().replace(/^0+(?=.)/, "");
1764
+ /** The keychain whose state the process restores if it exits first. */
1765
+ let live;
1766
+ const restoreLive = () => {
1767
+ live?.restore();
1768
+ live = void 0;
1769
+ process.off("exit", restoreLive);
828
1770
  };
829
1771
  /**
830
- * Maps OS version string to Apple platform
831
- * @param osVersion - OS version string from devicectl/simctl
832
- * @returns Apple platform or undefined if unknown
1772
+ * Where builds under one macOS user take turns: the default keychain and the
1773
+ * search list are the user's, not a process's, so two builds that overlap,
1774
+ * such as two self-hosted runner instances, would each restore the other's
1775
+ * keychain. It is in the user's caches, whatever TMPDIR a runner sets.
833
1776
  */
834
- const getPlatformFromOsVersion = (osVersion) => {
835
- switch (osVersion) {
836
- case "iOS": return "ios";
837
- default: return "ios";
838
- }
1777
+ const defaultLockPath = () => path.default.join(os.default.homedir(), "Library", "Caches", "hot-updater", "signing-keychain.lock");
1778
+ /** lockf's exit code when `-t 0` finds the lock held. */
1779
+ const EX_TEMPFAIL = 75;
1780
+ /**
1781
+ * Holds `lockPath` with macOS's `lockf`, a kernel lock on the file kept by a
1782
+ * helper that waits on this process's pipe: the kernel releases it when the
1783
+ * helper ends, which is when the pipe closes, so a build that dies, even
1784
+ * killed outright, leaves no lock behind and none needs taking over. The
1785
+ * helper runs in its own process group, out of reach of a terminal's
1786
+ * Ctrl+C, and never keeps this process alive.
1787
+ */
1788
+ const holdWithLockf = (lockPath, wait) => new Promise((resolve, reject) => {
1789
+ const child = (0, child_process.spawn)("lockf", [
1790
+ "-k",
1791
+ ...wait ? [] : ["-t", "0"],
1792
+ lockPath,
1793
+ "/bin/sh",
1794
+ "-c",
1795
+ "echo held; exec cat >/dev/null"
1796
+ ], {
1797
+ detached: true,
1798
+ stdio: [
1799
+ "pipe",
1800
+ "pipe",
1801
+ "ignore"
1802
+ ]
1803
+ });
1804
+ let held = false;
1805
+ child.stdout.once("data", () => {
1806
+ held = true;
1807
+ child.unref();
1808
+ child.stdin.unref?.();
1809
+ child.stdout.unref?.();
1810
+ const exited = new Promise((done) => child.once("exit", () => done()));
1811
+ resolve(async () => {
1812
+ child.ref();
1813
+ child.stdin.end();
1814
+ await exited;
1815
+ });
1816
+ });
1817
+ child.once("error", reject);
1818
+ child.once("exit", (code) => {
1819
+ if (held) return;
1820
+ if (!wait && code === EX_TEMPFAIL) resolve(void 0);
1821
+ else reject(/* @__PURE__ */ new Error(`lockf ${lockPath} exited with ${code}.`));
1822
+ });
1823
+ });
1824
+ const acquireWithLockf = async (lockPath) => {
1825
+ await fs.default.promises.mkdir(path.default.dirname(lockPath), { recursive: true });
1826
+ const release = await holdWithLockf(lockPath, false);
1827
+ if (release) return release;
1828
+ _hot_updater_cli_tools.p.log.info("Waiting for another build that signs in a keychain of its own under this macOS user.");
1829
+ return await holdWithLockf(lockPath, true);
839
1830
  };
840
1831
  /**
841
- * Lists devices and simulators for a specific platform
842
- * @param platform - Apple platform to filter by
843
- * @param options - Filtering options
844
- * @returns Array of devices filtered by options
1832
+ * Creates the build's keychain and makes it the user's default keychain,
1833
+ * first in the search list, ahead of the keychains already there, so Xcode
1834
+ * still finds a signing identity a self-hosted runner keeps and creates a
1835
+ * new one only when it has none.
845
1836
  */
846
- const listDevices = async (platform, options = {}) => {
847
- const spinner = _hot_updater_cli_tools.p.spinner();
848
- spinner.start(`Discovering ${platform} devices`);
1837
+ const createSigningKeychain = async ({ security = runSecurity, directory = os.default.tmpdir(), lockPath = defaultLockPath(), acquireLock = acquireWithLockf } = {}) => {
1838
+ if (live) throw new Error("A signing keychain is already in use by this process.");
1839
+ live = { restore: () => void 0 };
1840
+ let releaseLock;
849
1841
  try {
850
- const [simulators, devices] = await Promise.all([getSimulators(), getDevices().catch(() => [])]);
851
- let allDevices = [...devices, ...simulators].filter((device) => device.platform === platform);
852
- if (options.deviceType) allDevices = allDevices.filter((d) => d.type === options.deviceType);
853
- if (options.state && options.state !== "all") allDevices = allDevices.filter((d) => d.state === options.state);
854
- spinner.stop(`Found ${allDevices.length} ${platform} devices`);
855
- return allDevices;
1842
+ releaseLock = await acquireLock(lockPath);
856
1843
  } catch (error) {
857
- spinner.stop(`Failed to discover ${platform} devices`);
1844
+ live = void 0;
858
1845
  throw error;
859
1846
  }
860
- };
861
- const matchingDevice = (devices, deviceArg, options = {}) => {
862
- let filtered = [...devices];
863
- if (options.deviceType) filtered = filtered.filter((d) => d.type === options.deviceType);
864
- if (options.bootedOnly) filtered = filtered.filter((d) => d.state === "Booted");
865
- const byName = filtered.find((d) => d.name === deviceArg);
866
- if (byName) return byName;
867
- const byFormattedName = filtered.find((d) => formatDeviceName(d) === deviceArg);
868
- if (byFormattedName) return byFormattedName;
869
- const byUdid = filtered.find((d) => d.udid === deviceArg);
870
- if (byUdid) return byUdid;
871
- };
872
- const formatDeviceName = (device) => {
873
- return device.version ? `${device.name} (${device.version})` : device.name;
874
- };
875
- const selectTargetDevice = async ({ platform, interactive, deviceOption }) => {
876
- const availableDevices = await listDevices(platform);
877
- if (deviceOption === true && !interactive) {
878
- _hot_updater_cli_tools.p.log.error("you can't select device manually with non-interactive cli mode(without -i option).");
879
- process.exit(1);
1847
+ process.on("exit", restoreLive);
1848
+ let originalDefault;
1849
+ let originalSearchList;
1850
+ try {
1851
+ [originalDefault] = parseKeychains(await security([
1852
+ "default-keychain",
1853
+ "-d",
1854
+ "user"
1855
+ ]).catch(() => ""));
1856
+ originalSearchList = parseKeychains(await security([
1857
+ "list-keychains",
1858
+ "-d",
1859
+ "user"
1860
+ ]));
1861
+ } catch (error) {
1862
+ restoreLive();
1863
+ await releaseLock();
1864
+ throw error;
880
1865
  }
881
- if (typeof deviceOption === "string") {
882
- const matchedDevice = matchingDevice(availableDevices, deviceOption);
883
- if (!matchedDevice) {
884
- _hot_updater_cli_tools.p.log.error(`device '${deviceOption}' isn't included in the attached devices.`);
885
- process.exit(1);
1866
+ const keychainPath = path.default.join(directory, `hot-updater-${crypto.default.randomBytes(6).toString("hex")}.keychain-db`);
1867
+ const password = crypto.default.randomBytes(24).toString("base64url");
1868
+ (0, _hot_updater_cli_tools.registerSecretValue)(password);
1869
+ const restoreArgs = [
1870
+ ...originalDefault ? [[
1871
+ "default-keychain",
1872
+ "-d",
1873
+ "user",
1874
+ "-s",
1875
+ originalDefault
1876
+ ]] : [],
1877
+ [
1878
+ "list-keychains",
1879
+ "-d",
1880
+ "user",
1881
+ "-s",
1882
+ ...originalSearchList
1883
+ ],
1884
+ ["delete-keychain", keychainPath]
1885
+ ];
1886
+ live = { restore: () => {
1887
+ for (const args of restoreArgs) (0, child_process.spawnSync)("security", args);
1888
+ } };
1889
+ let disposal;
1890
+ const dispose = () => {
1891
+ disposal ??= (async () => {
1892
+ const failures = [];
1893
+ for (const args of restoreArgs) await security(args).catch((error) => {
1894
+ failures.push(`security ${args[0]}: ${error instanceof Error ? error.message : String(error)}`);
1895
+ });
1896
+ live = void 0;
1897
+ process.off("exit", restoreLive);
1898
+ await releaseLock().catch(() => void 0);
1899
+ if (failures.length > 0) _hot_updater_cli_tools.p.log.warn(`Restoring the keychains after the build failed (${failures.join("; ")}). Check \`security list-keychains -d user\` and \`security default-keychain -d user\`, and delete ${keychainPath} if it is left.`);
1900
+ })();
1901
+ return disposal;
1902
+ };
1903
+ try {
1904
+ await security([
1905
+ "create-keychain",
1906
+ "-p",
1907
+ password,
1908
+ keychainPath
1909
+ ]);
1910
+ await security([
1911
+ "set-keychain-settings",
1912
+ "-lut",
1913
+ String(KEYCHAIN_TIMEOUT_SECONDS),
1914
+ keychainPath
1915
+ ]);
1916
+ await security([
1917
+ "unlock-keychain",
1918
+ "-p",
1919
+ password,
1920
+ keychainPath
1921
+ ]);
1922
+ await security([
1923
+ "list-keychains",
1924
+ "-d",
1925
+ "user",
1926
+ "-s",
1927
+ keychainPath,
1928
+ ...originalSearchList.filter((keychain) => keychain !== keychainPath)
1929
+ ]);
1930
+ await security([
1931
+ "default-keychain",
1932
+ "-d",
1933
+ "user",
1934
+ "-s",
1935
+ keychainPath
1936
+ ]);
1937
+ } catch (error) {
1938
+ await dispose();
1939
+ throw error;
1940
+ }
1941
+ return {
1942
+ path: keychainPath,
1943
+ certificates: async () => {
1944
+ return ((await security([
1945
+ "find-certificate",
1946
+ "-a",
1947
+ "-p",
1948
+ keychainPath
1949
+ ])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map((pem) => {
1950
+ const certificate = new crypto.default.X509Certificate(pem);
1951
+ return {
1952
+ serialNumber: normalizeSerial(certificate.serialNumber),
1953
+ commonName: certificate.subject.match(/^CN=(.*)$/m)?.[1] ?? certificate.subject
1954
+ };
1955
+ });
1956
+ },
1957
+ dispose
1958
+ };
1959
+ };
1960
+ /**
1961
+ * Revokes the certificates Xcode created in the build's keychain, whose
1962
+ * private keys the keychain's deletion destroys: on a runner that starts
1963
+ * empty, Xcode creates a development certificate for every build, and App
1964
+ * Store Connect refuses new ones once a team has its maximum. Each is found
1965
+ * in the team's certificates by serial number. A revocation that fails is
1966
+ * reported with where to revoke it by hand, and never fails the build.
1967
+ */
1968
+ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fetch = globalThis.fetch }) => {
1969
+ const revoked = [];
1970
+ const failed = [];
1971
+ if (certificates.length === 0) return {
1972
+ revoked,
1973
+ failed
1974
+ };
1975
+ const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
1976
+ try {
1977
+ const token = createAppStoreConnectToken({
1978
+ privateKey: privateKey ?? crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8")),
1979
+ keyId: apiKey.keyId,
1980
+ issuerId: apiKey.issuerId
1981
+ });
1982
+ (0, _hot_updater_cli_tools.registerSecretValue)(token);
1983
+ const headers = { Authorization: `Bearer ${token}` };
1984
+ const idsBySerial = /* @__PURE__ */ new Map();
1985
+ let next = `${API_URL}/v1/certificates?${new URLSearchParams({
1986
+ "fields[certificates]": "serialNumber",
1987
+ limit: "200"
1988
+ })}`;
1989
+ while (next) {
1990
+ const response = await fetch(next, { headers });
1991
+ if (!response.ok) throw new Error(`Listing the team's certificates failed (HTTP ${response.status}).`);
1992
+ const body = await response.json();
1993
+ for (const { id, attributes } of body.data ?? []) if (attributes?.serialNumber) idsBySerial.set(normalizeSerial(attributes.serialNumber), id);
1994
+ next = body.links?.next;
886
1995
  }
887
- return matchedDevice;
1996
+ for (const certificate of certificates) {
1997
+ const id = idsBySerial.get(certificate.serialNumber);
1998
+ if (id === void 0) continue;
1999
+ const response = await fetch(`${API_URL}/v1/certificates/${id}`, {
2000
+ method: "DELETE",
2001
+ headers
2002
+ });
2003
+ if (response.ok || response.status === 404) revoked.push(describe(certificate));
2004
+ else failed.push(`${describe(certificate)}: HTTP ${response.status}`);
2005
+ }
2006
+ } catch (error) {
2007
+ const reason = error instanceof Error ? error.message : String(error);
2008
+ for (const certificate of certificates) if (!revoked.includes(describe(certificate))) failed.push(`${describe(certificate)}: ${reason}`);
888
2009
  }
889
- if (interactive) {
890
- if (!availableDevices.length) {
891
- _hot_updater_cli_tools.p.log.error("you passed -d option but there is no attached devices.");
892
- process.exit(1);
2010
+ if (revoked.length > 0) _hot_updater_cli_tools.p.log.info(`Revoked the signing certificates Xcode created for this build, whose keys the build's keychain held: ${revoked.join(", ")}.`);
2011
+ if (failed.length > 0) _hot_updater_cli_tools.p.log.warn(`Revoking the signing certificates Xcode created for this build failed, and their keys are deleted with the build's keychain: ${failed.join("; ")}. Revoke them at ${CERTIFICATES_URL}, since App Store Connect stops creating certificates at a team's maximum.`);
2012
+ return {
2013
+ revoked,
2014
+ failed
2015
+ };
2016
+ };
2017
+ const TERMINATION_SIGNALS = [
2018
+ "SIGHUP",
2019
+ "SIGINT",
2020
+ "SIGTERM"
2021
+ ];
2022
+ /**
2023
+ * Runs `run`, the archive and export of a build that signs with an App
2024
+ * Store Connect API key, in a keychain of its own when `enabled` (in CI),
2025
+ * then revokes the certificates Xcode created in it and deletes it. A
2026
+ * termination signal does the same before the process ends with it: the
2027
+ * certificates are read from the keychain before it is deleted, and the key
2028
+ * is read into memory first, since the build's temporary key file is deleted
2029
+ * on the signal. A second signal restores the keychains at once.
2030
+ */
2031
+ const withSigningKeychain = async ({ enabled, apiKey, run, security, lockPath, acquireLock, fetch }) => {
2032
+ if (!enabled) return run();
2033
+ const privateKey = crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8"));
2034
+ const keychain = await createSigningKeychain({
2035
+ security,
2036
+ lockPath,
2037
+ acquireLock
2038
+ });
2039
+ _hot_updater_cli_tools.p.log.info("Signing in a keychain of this build's own, deleted when it ends (CI is set).");
2040
+ let cleanup;
2041
+ const release = () => {
2042
+ cleanup ??= (async () => {
2043
+ const certificates = await keychain.certificates().catch(() => []);
2044
+ await keychain.dispose();
2045
+ await revokeKeychainCertificates({
2046
+ certificates,
2047
+ apiKey,
2048
+ privateKey,
2049
+ fetch
2050
+ });
2051
+ })();
2052
+ return cleanup;
2053
+ };
2054
+ const stopListening = () => {
2055
+ for (const signal of TERMINATION_SIGNALS) process.off(signal, onSignal);
2056
+ };
2057
+ const onSignal = (signal) => {
2058
+ if (cleanup) {
2059
+ stopListening();
2060
+ restoreLive();
2061
+ process.kill(process.pid, signal);
2062
+ return;
893
2063
  }
894
- const device = await _hot_updater_cli_tools.p.select({
895
- message: "Target Device",
896
- options: availableDevices.map((d) => ({
897
- value: d,
898
- label: `${d.name} (${d.type}) - ${d.state}`,
899
- hint: d.udid
900
- }))
2064
+ release().finally(() => {
2065
+ stopListening();
2066
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
901
2067
  });
902
- if (_hot_updater_cli_tools.p.isCancel(device)) process.exit(1);
903
- return device;
2068
+ };
2069
+ for (const signal of TERMINATION_SIGNALS) process.on(signal, onSignal);
2070
+ try {
2071
+ return await run();
2072
+ } finally {
2073
+ await release();
2074
+ stopListening();
904
2075
  }
905
- const bootedDevices = await listDevices(platform, { state: "Booted" });
906
- if (bootedDevices.length > 0) return bootedDevices[0];
907
2076
  };
908
- const Device = {
909
- listDevices,
910
- selectTargetDevice
2077
+ //#endregion
2078
+ //#region src/utils/readBuiltApp.ts
2079
+ const readInfoValue = async (infoPlist, key) => {
2080
+ const { stdout, exitCode } = await (0, execa.execa)("plutil", [
2081
+ "-extract",
2082
+ key,
2083
+ "raw",
2084
+ "-o",
2085
+ "-",
2086
+ infoPlist
2087
+ ], { reject: false });
2088
+ return exitCode === 0 && stdout.trim() ? stdout.trim() : null;
2089
+ };
2090
+ /**
2091
+ * What the built `.app` reports at runtime, from its Info.plist: the
2092
+ * `CFBundleShortVersionString`, `CFBundleVersion` (the build number) and
2093
+ * `HOT_UPDATER_CHANNEL` the build settings,
2094
+ * an `xcconfig` and `extraParams` expanded.
2095
+ */
2096
+ const readBuiltApp = async (appPath) => {
2097
+ const infoPlist = path.default.join(appPath, "Info.plist");
2098
+ const [appVersion, channel, buildNumber] = await Promise.all([
2099
+ readInfoValue(infoPlist, "CFBundleShortVersionString"),
2100
+ readInfoValue(infoPlist, "HOT_UPDATER_CHANNEL"),
2101
+ readInfoValue(infoPlist, "CFBundleVersion")
2102
+ ]);
2103
+ return {
2104
+ appVersion,
2105
+ channel,
2106
+ buildNumber
2107
+ };
911
2108
  };
912
2109
  //#endregion
913
- //#region src/runIos.ts
914
- const runIos = async ({ schemeConfig, runOption }) => {
915
- const { interactive, device: deviceOption } = runOption;
916
- const iosProjectPath = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
917
- const device = await Device.selectTargetDevice({
918
- platform: "ios",
919
- deviceOption,
920
- interactive
921
- });
922
- if (!device) {
923
- const simulators = await Device.listDevices("ios", { deviceType: "simulator" });
924
- if (simulators.length === 0) {
925
- _hot_updater_cli_tools.p.log.error("No simulators found. Please create a simulator in Xcode.");
2110
+ //#region src/buildIos.ts
2111
+ /**
2112
+ * Builds a simulator `.app` for a `simulator` scheme, and otherwise archives
2113
+ * and exports an `.ipa`, or returns the `.xcarchive` of a scheme it cannot
2114
+ * export: one with neither `exportOptionsPlist` nor `signing.ascApiKey`.
2115
+ */
2116
+ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials }) => {
2117
+ await assertXcodebuildExist();
2118
+ const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2119
+ const buildDirectory = await createRandomTmpDir();
2120
+ await fs.default.promises.mkdir(path.default.join(buildDirectory, "archive"), { recursive: true });
2121
+ if (schemeConfig.simulator) {
2122
+ if (schemeConfig.destination.length) {
2123
+ _hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
926
2124
  process.exit(1);
927
2125
  }
928
- _hot_updater_cli_tools.p.log.info(`Using first available simulator: ${simulators[0].name}`);
929
- const selectedDevice = simulators[0];
930
- _hot_updater_cli_tools.p.log.info(`Building for ${selectedDevice.name}...`);
931
- const result = await buildXcodeProject({
932
- sourceDir: iosProjectPath,
2126
+ const appDir = path.default.join(buildDirectory, "app");
2127
+ await fs.default.promises.mkdir(appDir, { recursive: true });
2128
+ const { appPath } = await buildXcodeProject({
2129
+ sourceDir: iosProjectRoot,
2130
+ logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
933
2131
  platform: schemeConfig.platform,
934
2132
  xcodeScheme: schemeConfig.scheme,
935
2133
  configuration: schemeConfig.configuration,
936
- deviceType: selectedDevice.type,
937
- destination: [{ id: selectedDevice.udid }],
2134
+ deviceType: "simulator",
938
2135
  installPods: schemeConfig.installPods,
939
2136
  extraParams: schemeConfig.extraParams,
940
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-run`
2137
+ useGenericDestination: true,
2138
+ minBundleId
941
2139
  });
942
- const runnerOptions = {
943
- bundleIdentifier: schemeConfig.bundleIdentifier,
944
- sourceDir: iosProjectPath,
945
- launch: true,
946
- infoPlistPath: result.infoPlistPath
2140
+ const finalAppPath = path.default.join(appDir, path.default.basename(appPath));
2141
+ await fs.default.promises.cp(appPath, finalAppPath, { recursive: true });
2142
+ _hot_updater_cli_tools.p.log.success(`.app file created: ${path.default.basename(appPath)}`);
2143
+ return {
2144
+ buildDirectory,
2145
+ buildArtifactPath: finalAppPath,
2146
+ builtApp: await readBuiltApp(finalAppPath)
947
2147
  };
948
- await installAndLaunchOnSimulator({
949
- device: selectedDevice,
950
- appPath: result.appPath,
951
- options: runnerOptions
2148
+ } else {
2149
+ const signing = await resolveXcodeSigning({
2150
+ schemeConfig,
2151
+ credentials
2152
+ });
2153
+ if (!signing) return archiveAndExport({
2154
+ schemeConfig,
2155
+ minBundleId,
2156
+ buildDirectory
2157
+ });
2158
+ const appStoreConnect = await exportsForAppStoreConnect(schemeConfig);
2159
+ if ((0, _hot_updater_cli_tools.isCI)() && !appStoreConnect) _hot_updater_cli_tools.p.log.info("This export isn't for App Store Connect, so it signs in the runner's own keychains and keeps the certificates Xcode creates.");
2160
+ return withSigningKeychain({
2161
+ enabled: (0, _hot_updater_cli_tools.isCI)() && appStoreConnect,
2162
+ apiKey: signing.apiKey,
2163
+ run: () => archiveAndExport({
2164
+ schemeConfig,
2165
+ minBundleId,
2166
+ buildDirectory,
2167
+ authenticationArgs: signing.authenticationArgs
2168
+ })
952
2169
  });
953
- _hot_updater_cli_tools.p.outro("Success 🎉");
954
- return result;
955
2170
  }
956
- _hot_updater_cli_tools.p.log.info(`Building for ${device.name}...`);
957
- const result = await buildXcodeProject({
958
- sourceDir: iosProjectPath,
2171
+ };
2172
+ /**
2173
+ * Archives a device build and exports its `.ipa`, or returns the
2174
+ * `.xcarchive` of a scheme it cannot export.
2175
+ */
2176
+ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, authenticationArgs }) => {
2177
+ const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2178
+ const archiveDir = path.default.join(buildDirectory, "archive");
2179
+ const exportDir = path.default.join(buildDirectory, "export");
2180
+ const { archivePath } = await archiveXcodeProject({
2181
+ authenticationArgs,
2182
+ minBundleId,
959
2183
  platform: schemeConfig.platform,
2184
+ sourceDir: iosProjectRoot,
2185
+ xcconfig: schemeConfig.xcconfig,
960
2186
  xcodeScheme: schemeConfig.scheme,
2187
+ destination: schemeConfig.destination,
2188
+ extraParams: schemeConfig.extraParams,
961
2189
  configuration: schemeConfig.configuration,
962
- deviceType: device.type,
963
- destination: [{ id: device.udid }],
964
2190
  installPods: schemeConfig.installPods,
965
- extraParams: schemeConfig.extraParams,
966
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-run`
2191
+ logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-archive`
967
2192
  });
968
- const runnerOptions = {
969
- bundleIdentifier: schemeConfig.bundleIdentifier,
970
- sourceDir: iosProjectPath,
971
- launch: true
972
- };
973
- if (device.type === "simulator") await installAndLaunchOnSimulator({
974
- device,
975
- appPath: result.appPath,
976
- options: {
977
- ...runnerOptions,
978
- infoPlistPath: result.infoPlistPath
979
- }
980
- });
981
- else await installAndLaunchOnDevice({
982
- device,
983
- appPath: result.appPath,
984
- options: runnerOptions
2193
+ const finalArchivePath = path.default.join(archiveDir, path.default.basename(archivePath));
2194
+ await fs.default.promises.cp(archivePath, finalArchivePath, { recursive: true });
2195
+ const appFromArchive = extractAppFromXcarchive(finalArchivePath);
2196
+ const archivedAppPath = appFromArchive ? path.default.join(archiveDir, path.default.basename(appFromArchive)) : void 0;
2197
+ if (appFromArchive && archivedAppPath) {
2198
+ await fs.default.promises.cp(appFromArchive, archivedAppPath, { recursive: true });
2199
+ _hot_updater_cli_tools.p.log.success(".app extracted from .xcarchive");
2200
+ }
2201
+ if (!schemeConfig.exportOptionsPlist && !authenticationArgs) return {
2202
+ buildDirectory,
2203
+ buildArtifactPath: finalArchivePath,
2204
+ builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
2205
+ };
2206
+ await fs.default.promises.mkdir(exportDir, { recursive: true });
2207
+ const exportOptionsPlist = schemeConfig.exportOptionsPlist ?? await writeAppStoreConnectExportOptions(await createRandomTmpDir());
2208
+ const { exportPath } = await exportXcodeArchive({
2209
+ archivePath,
2210
+ authenticationArgs,
2211
+ exportExtraParams: schemeConfig.exportExtraParams,
2212
+ exportOptionsPlist,
2213
+ sourceDir: iosProjectRoot,
2214
+ logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-export`
985
2215
  });
986
- _hot_updater_cli_tools.p.outro("Success 🎉");
987
- return result;
2216
+ await fs.default.promises.cp(exportPath, exportDir, { recursive: true });
2217
+ const ipaFile = fs.default.readdirSync(exportDir).find((file) => file.endsWith(".ipa"));
2218
+ if (!ipaFile) throw new Error("IPA file not found after export");
2219
+ return {
2220
+ buildDirectory,
2221
+ buildArtifactPath: path.default.join(exportDir, ipaFile),
2222
+ builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
2223
+ };
2224
+ };
2225
+ const extractAppFromXcarchive = (archivePath) => {
2226
+ const productsPath = path.default.join(archivePath, "Products", "Applications");
2227
+ if (!fs.default.existsSync(productsPath)) return;
2228
+ const appFile = fs.default.readdirSync(productsPath).find((file) => file.endsWith(".app"));
2229
+ if (appFile) return path.default.join(productsPath, appFile);
2230
+ _hot_updater_cli_tools.p.log.warn("Failed to extract .app from xcarchive");
988
2231
  };
989
2232
  //#endregion
990
2233
  //#region src/utils/enrichNativeBuildIosScheme.ts
@@ -1009,6 +2252,71 @@ const enrichNativeBuildIosScheme = async ({ scheme, hotUpdaterSchemeName }) => {
1009
2252
  };
1010
2253
  };
1011
2254
  //#endregion
2255
+ //#region src/utils/extractBuiltInBundle.ts
2256
+ /** The file React Native's Xcode build phase writes into the `.app`. */
2257
+ const BUILT_IN_BUNDLE = "main.jsbundle";
2258
+ /**
2259
+ * Copies the JavaScript bundle React Native's Xcode build phase wrote into a
2260
+ * `.app` or an `.ipa` (`Payload/<App>.app/main.jsbundle`) to `outputPath`:
2261
+ * the built-in bundle, byte for byte, which no rebuild reproduces.
2262
+ */
2263
+ const extractBuiltInBundle = async ({ artifactPath, outputPath }) => {
2264
+ await fs.default.promises.mkdir(path.default.dirname(outputPath), { recursive: true });
2265
+ if (path.default.extname(artifactPath) === ".app") {
2266
+ const source = path.default.join(artifactPath, BUILT_IN_BUNDLE);
2267
+ if (!fs.default.existsSync(source)) throw new Error(`${path.default.basename(artifactPath)} has no ${BUILT_IN_BUNDLE}. A debug build loads its bundle from Metro instead.`);
2268
+ await fs.default.promises.copyFile(source, outputPath);
2269
+ return;
2270
+ }
2271
+ const { stdout } = await (0, execa.execa)("unzip", ["-Z1", artifactPath]);
2272
+ const entry = stdout.split("\n").find((name) => /^Payload\/[^/]+\.app\/main\.jsbundle$/u.test(name.trim()))?.trim();
2273
+ if (entry === void 0) throw new Error(`${path.default.basename(artifactPath)} has no Payload/<App>.app/${BUILT_IN_BUNDLE}.`);
2274
+ await (0, execa.execa)("unzip", [
2275
+ "-p",
2276
+ artifactPath,
2277
+ escapeUnzipPattern(entry)
2278
+ ], { stdout: { file: outputPath } });
2279
+ };
2280
+ /**
2281
+ * Unpacks a `.app` that `archiveAppBundle` stored as one zip file into
2282
+ * `outputPath`, which then holds the `.app` directory.
2283
+ */
2284
+ const extractAppBundle = async ({ archivePath, outputPath }) => {
2285
+ await fs.default.promises.mkdir(outputPath, { recursive: true });
2286
+ await (0, execa.execa)("ditto", [
2287
+ "-x",
2288
+ "-k",
2289
+ archivePath,
2290
+ outputPath
2291
+ ]);
2292
+ };
2293
+ /**
2294
+ * A `.app` directory as one zip file, the way Finder and Xcode archive it,
2295
+ * so a simulator build uploads and downloads as a single artifact.
2296
+ */
2297
+ const archiveAppBundle = async ({ appPath, outputPath }) => {
2298
+ await fs.default.promises.mkdir(path.default.dirname(outputPath), { recursive: true });
2299
+ await fs.default.promises.rm(outputPath, { force: true });
2300
+ await (0, execa.execa)("ditto", [
2301
+ "-c",
2302
+ "-k",
2303
+ "--sequesterRsrc",
2304
+ "--keepParent",
2305
+ appPath,
2306
+ outputPath
2307
+ ]);
2308
+ };
2309
+ //#endregion
2310
+ exports.ASC_INTEGRATIONS_URL = ASC_INTEGRATIONS_URL;
2311
+ exports.TestFlightBuildExistsError = TestFlightBuildExistsError;
2312
+ exports.archiveAppBundle = archiveAppBundle;
2313
+ exports.assertTestFlightExportOptions = assertTestFlightExportOptions;
1012
2314
  exports.buildIos = buildIos;
1013
2315
  exports.enrichNativeBuildIosScheme = enrichNativeBuildIosScheme;
1014
- exports.runIos = runIos;
2316
+ exports.extractAppBundle = extractAppBundle;
2317
+ exports.extractBuiltInBundle = extractBuiltInBundle;
2318
+ exports.findTestFlightBuild = findTestFlightBuild;
2319
+ exports.installPodsIfNeeded = installPodsIfNeeded;
2320
+ exports.issueAscApiKey = issueAscApiKey;
2321
+ exports.resolveTestFlightUploadKey = resolveTestFlightUploadKey;
2322
+ exports.uploadToTestFlight = uploadToTestFlight;