@hot-updater/apple-helper 1.1.4 → 1.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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$3 = [
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$3) process.off(signal, onSignal);
182
+ };
183
+ process.on("exit", remove);
184
+ for (const signal of TERMINATION_SIGNALS$3) 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,19 +525,108 @@ 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
94
580
  //#region src/utils/createRandomTmpDir.ts
581
+ const TERMINATION_SIGNALS$2 = [
582
+ "SIGHUP",
583
+ "SIGINT",
584
+ "SIGTERM"
585
+ ];
586
+ const REMOVE_OPTIONS = {
587
+ force: true,
588
+ recursive: true,
589
+ maxRetries: 3
590
+ };
591
+ const liveDirectories = /* @__PURE__ */ new Set();
592
+ const onSignal = (signal) => {
593
+ removeLiveDirectories();
594
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
595
+ };
596
+ const removeLiveDirectories = () => {
597
+ for (const directory of liveDirectories) try {
598
+ (0, node_fs.rmSync)(directory, REMOVE_OPTIONS);
599
+ } catch {}
600
+ liveDirectories.clear();
601
+ stopListening();
602
+ };
603
+ const startListening = () => {
604
+ process.on("exit", removeLiveDirectories);
605
+ for (const signal of TERMINATION_SIGNALS$2) process.on(signal, onSignal);
606
+ };
607
+ const stopListening = () => {
608
+ process.off("exit", removeLiveDirectories);
609
+ for (const signal of TERMINATION_SIGNALS$2) process.off(signal, onSignal);
610
+ };
611
+ /**
612
+ * Creates a directory in the system's temporary directory, which
613
+ * `removeTmpDir` removes, and which is otherwise removed when the process
614
+ * exits or is stopped by a signal.
615
+ */
95
616
  const createRandomTmpDir = async (prefix = "apple-helper-") => {
96
- return await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), prefix));
617
+ const directory = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), prefix));
618
+ if (liveDirectories.size === 0) startListening();
619
+ liveDirectories.add(directory);
620
+ return directory;
621
+ };
622
+ /**
623
+ * Removes a directory `createRandomTmpDir` created. It stays tracked until
624
+ * it is gone, so a signal during a removal that takes seconds, such as
625
+ * DerivedData's, still finishes it.
626
+ */
627
+ const removeTmpDir = async (directory) => {
628
+ await (0, node_fs_promises.rm)(directory, REMOVE_OPTIONS);
629
+ if (liveDirectories.delete(directory) && liveDirectories.size === 0) stopListening();
97
630
  };
98
631
  //#endregion
99
632
  //#region src/utils/platform.ts
@@ -166,9 +699,15 @@ const parseXcodeProjectInfo = async (sourceDir) => {
166
699
  //#endregion
167
700
  //#region src/utils/prettifyXcodebuildError.ts
168
701
  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.");
702
+ if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
703
+ const errorLines = error.summary.join("\n");
704
+ 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;
705
+ return new _hot_updater_cli_tools.BuildFailedError(`Xcodebuild failed: ${message}`, {
706
+ logPath: error.logPath,
707
+ summary: error.summary,
708
+ cause: error.cause
709
+ });
710
+ }
172
711
  return /* @__PURE__ */ new Error(`Xcodebuild failed: ${error}`);
173
712
  };
174
713
  //#endregion
@@ -211,13 +750,20 @@ const createXcodebuildLogger = ({ logPrefix }) => new _hot_updater_cli_tools.Bui
211
750
  "BUILD SUCCEEDED",
212
751
  "ARCHIVE SUCCEEDED"
213
752
  ]
214
- ]
753
+ ],
754
+ failureSummary: {
755
+ lines: [/(^|:\s)error: /, /^\*\* [A-Z ]+ FAILED \*\*$/],
756
+ blocks: [{
757
+ start: "The following build commands failed:",
758
+ end: /^\(\d+ failures?\)$/,
759
+ includeEnd: true
760
+ }]
761
+ }
215
762
  });
216
763
  //#endregion
217
764
  //#region src/utils/runXcodebuildWithLogging.ts
218
765
  let cachedXcbeautifyAvailability = null;
219
- const TERMINATION_SIGNALS = ["SIGINT", "SIGTERM"];
220
- const IGNORED_PIPELINE_ERROR_CODES = ["EPIPE", "ERR_STREAM_PREMATURE_CLOSE"];
766
+ const TERMINATION_SIGNALS$1 = ["SIGINT", "SIGTERM"];
221
767
  const isXcbeautifyAvailable = async () => {
222
768
  if (cachedXcbeautifyAvailability !== null) return cachedXcbeautifyAvailability;
223
769
  try {
@@ -233,6 +779,7 @@ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
233
779
  return (0, execa.execa)("xcodebuild", args, {
234
780
  cwd: sourceDir,
235
781
  all: true,
782
+ buffer: false,
236
783
  env: {
237
784
  ...process.env,
238
785
  ...env,
@@ -243,29 +790,17 @@ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
243
790
  const createXcbeautifyProcess = ({ cwd }) => {
244
791
  return (0, execa.execa)("xcbeautify", [], {
245
792
  cwd,
246
- all: true
793
+ all: true,
794
+ buffer: false
247
795
  });
248
796
  };
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;
797
+ /** Why xcodebuild failed, without its command line. */
798
+ const describeFailure = (error) => {
799
+ if (error instanceof execa.ExecaError) {
800
+ if (error.signal) return `xcodebuild was stopped by ${error.signal}.`;
801
+ if (error.exitCode !== void 0) return `xcodebuild exited with code ${error.exitCode}.`;
264
802
  }
265
- };
266
- const processOutputStream = async ({ outputStream, logger }) => {
267
- if (!outputStream) return;
268
- await logger.processStream(outputStream);
803
+ return error instanceof Error ? error.message : String(error);
269
804
  };
270
805
  const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage, failureMessage, logPrefix }) => {
271
806
  const useXcbeautify = await isXcbeautifyAvailable();
@@ -275,97 +810,185 @@ const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage,
275
810
  const terminateActiveProcesses = () => {
276
811
  for (const activeProcess of activeProcesses) activeProcess.kill();
277
812
  };
278
- for (const signal of TERMINATION_SIGNALS) process.once(signal, terminateActiveProcesses);
813
+ for (const signal of TERMINATION_SIGNALS$1) process.once(signal, terminateActiveProcesses);
279
814
  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
815
+ const xcodebuildProcess = createRawXcodebuildProcess({
816
+ args,
817
+ env,
818
+ sourceDir
819
+ });
820
+ activeProcesses.push(xcodebuildProcess);
821
+ const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
822
+ let xcbeautifyProcess = null;
823
+ if (useXcbeautify && xcodebuildOutput) {
824
+ xcbeautifyProcess = createXcbeautifyProcess({ cwd: sourceDir });
825
+ activeProcesses.push(xcbeautifyProcess);
826
+ }
827
+ const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput, { display: xcbeautifyProcess ? xcbeautifyProcess.all ?? xcbeautifyProcess.stdout ?? void 0 : void 0 }) : Promise.resolve();
828
+ const xcbeautifyInput = xcbeautifyProcess?.stdin;
829
+ if (xcodebuildOutput && xcbeautifyInput) {
830
+ xcbeautifyInput.on("error", () => {
831
+ xcodebuildOutput.unpipe(xcbeautifyInput);
314
832
  });
315
- await xcodebuildProcess;
833
+ xcodebuildOutput.pipe(xcbeautifyInput);
316
834
  }
835
+ const [xcodebuildResult, loggingResult] = await Promise.allSettled([
836
+ xcodebuildProcess,
837
+ logging,
838
+ xcbeautifyProcess
839
+ ]);
840
+ if (xcodebuildResult.status === "rejected") throw xcodebuildResult.reason;
841
+ if (loggingResult.status === "rejected") throw loggingResult.reason;
317
842
  logger.stop(successMessage);
318
843
  } catch (error) {
319
844
  logger.writeError(error);
320
845
  logger.stop(failureMessage, false);
321
- throw error;
846
+ throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${describeFailure(error)}`, {
847
+ logPath: logger.logPath,
848
+ summary: logger.failureSummary,
849
+ cause: error
850
+ });
322
851
  } finally {
323
- for (const signal of TERMINATION_SIGNALS) process.off(signal, terminateActiveProcesses);
852
+ for (const signal of TERMINATION_SIGNALS$1) process.off(signal, terminateActiveProcesses);
324
853
  await logger.close();
325
854
  }
326
855
  };
327
856
  //#endregion
857
+ //#region src/utils/writeBuildXcconfig.ts
858
+ const SDK_CONDITIONS = [
859
+ "",
860
+ "[sdk=iphoneos*]",
861
+ "[sdk=iphonesimulator*]"
862
+ ];
863
+ /**
864
+ * Finds a setting of `keys` that an included xcconfig sets with a condition
865
+ * the build xcconfig does not, such as `[config=Release]` or `[arch=arm64]`,
866
+ * which would outrank the build's own. It follows `#include`s, which are
867
+ * relative to the including file.
868
+ */
869
+ const findConditionalSetting = async (files, keys, visited = /* @__PURE__ */ new Set()) => {
870
+ for (const file of files) {
871
+ if (visited.has(file) || !fs.default.existsSync(file)) continue;
872
+ visited.add(file);
873
+ const lines = (await fs.default.promises.readFile(file, "utf-8")).split("\n");
874
+ for (const [index, line] of lines.entries()) {
875
+ const include = line.match(/^\s*#include\??\s*"([^"]+)"/)?.[1];
876
+ if (include) {
877
+ const found = await findConditionalSetting([path.default.resolve(path.default.dirname(file), include)], keys, visited);
878
+ if (found) return found;
879
+ continue;
880
+ }
881
+ const [, key, conditions] = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)((?:\[[^\]]*\])+)\s*=/) ?? [];
882
+ if (key && keys.includes(key) && !SDK_CONDITIONS.includes(conditions)) return `${file}:${index + 1} sets ${key}${conditions}`;
883
+ }
884
+ }
885
+ };
886
+ /**
887
+ * Writes the xcconfig a build loads, which sets the CLI's build settings
888
+ * over every other. xcodebuild ranks an `-xcconfig`'s settings above the
889
+ * command line's, so the scheme's `xcconfig` and each `-xcconfig` in
890
+ * `extraParams` are included, in order, before `settings`, and the build
891
+ * loads only this one.
892
+ */
893
+ const writeBuildXcconfig = async ({ directory, sourceDir, xcconfig, extraParams = [], settings }) => {
894
+ const included = xcconfig ? [xcconfig] : [];
895
+ const otherParams = [];
896
+ for (let i = 0; i < extraParams.length; i++) if (extraParams[i] === "-xcconfig" && i + 1 < extraParams.length) included.push(extraParams[++i]);
897
+ else otherParams.push(extraParams[i]);
898
+ const includedFiles = included.map((file) => path.default.resolve(sourceDir, file));
899
+ const conditional = await findConditionalSetting(includedFiles, Object.keys(settings));
900
+ if (conditional) throw new Error(`${conditional}, which would outrank the value the build sets. Set it without that condition, or remove it.`);
901
+ const buildXcconfig = path.default.join(directory, "HotUpdater.xcconfig");
902
+ await fs.default.promises.writeFile(buildXcconfig, [
903
+ ...includedFiles.map((file) => `#include "${file}"`),
904
+ ...Object.entries(settings).flatMap(([key, value]) => SDK_CONDITIONS.map((condition) => `${key}${condition} = ${value}`)),
905
+ ""
906
+ ].join("\n"));
907
+ return {
908
+ xcconfig: buildXcconfig,
909
+ extraParams: otherParams
910
+ };
911
+ };
912
+ /**
913
+ * Automatic signing over a target set to manual signing, whose provisioning
914
+ * profile and identity Xcode would otherwise report as conflicting.
915
+ */
916
+ const AUTOMATIC_SIGNING = {
917
+ CODE_SIGN_STYLE: "Automatic",
918
+ PROVISIONING_PROFILE: "",
919
+ PROVISIONING_PROFILE_SPECIFIER: "",
920
+ CODE_SIGN_IDENTITY: "Apple Development"
921
+ };
922
+ /**
923
+ * The settings a build sets over the scheme's: the MIN_BUNDLE_ID the CLI
924
+ * issues and reports, and automatic signing when an App Store Connect API
925
+ * key signs, which a target set to manual signing would not use.
926
+ */
927
+ const buildSettingsOverScheme = ({ minBundleId, authenticationArgs }) => ({
928
+ HOT_UPDATER_MIN_BUNDLE_ID: minBundleId,
929
+ ...authenticationArgs ? AUTOMATIC_SIGNING : {}
930
+ });
931
+ //#endregion
328
932
  //#region src/builder/archiveXcodeProject.ts
329
- const archiveXcodeProject = async ({ sourceDir, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix }) => {
330
- const xcodeProject = await parseXcodeProjectInfo(sourceDir);
933
+ /**
934
+ * Archives the scheme into `archiveDirectory`. The build's xcconfig is
935
+ * written to a temporary directory, removed whether the archive succeeds or
936
+ * not.
937
+ */
938
+ const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, minBundleId, authenticationArgs }) => {
331
939
  if (installPods) await installPodsIfNeeded(sourceDir);
332
- const tmpDir = await createRandomTmpDir();
940
+ const xcodeProject = await parseXcodeProjectInfo(sourceDir);
333
941
  const archiveName = `${xcodeProject.name.replace(".xcworkspace", "").replace(".xcodeproj", "")}.xcarchive`;
334
- const archivePath = path.default.join(tmpDir, archiveName);
335
- const minBundleId = (0, _hot_updater_plugin_core.generateMinBundleId)();
336
- const archiveArgs = prepareArchiveArgs({
337
- minBundleId,
338
- archivePath,
339
- platform,
340
- sourceDir,
341
- xcodeProject,
342
- configuration,
343
- extraParams,
344
- destination,
345
- xcconfig,
346
- xcodeScheme
347
- });
348
- _hot_updater_cli_tools.p.log.info(`Xcode Archive Settings:
942
+ const archivePath = path.default.join(archiveDirectory, archiveName);
943
+ const tmpDir = await createRandomTmpDir();
944
+ try {
945
+ const build = await writeBuildXcconfig({
946
+ directory: tmpDir,
947
+ sourceDir,
948
+ xcconfig,
949
+ extraParams,
950
+ settings: buildSettingsOverScheme({
951
+ minBundleId,
952
+ authenticationArgs
953
+ })
954
+ });
955
+ const archiveArgs = prepareArchiveArgs({
956
+ authenticationArgs,
957
+ minBundleId,
958
+ archivePath,
959
+ platform,
960
+ sourceDir,
961
+ xcodeProject,
962
+ configuration,
963
+ extraParams: build.extraParams,
964
+ destination,
965
+ xcconfig: build.xcconfig,
966
+ xcodeScheme
967
+ });
968
+ _hot_updater_cli_tools.p.log.info(`Xcode Archive Settings:
349
969
  Project ${xcodeProject.name}
350
970
  Scheme ${xcodeScheme}
351
971
  Platform ${platform}
352
972
  Command xcodebuild ${archiveArgs.join(" ")}
353
973
  `);
354
- try {
355
- await runXcodebuildWithLogging({
356
- args: archiveArgs,
357
- env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
358
- failureMessage: "Archive failed",
359
- logPrefix,
360
- sourceDir,
361
- successMessage: "Archive completed successfully"
362
- });
363
- return { archivePath };
364
- } catch (error) {
365
- throw prettifyXcodebuildError(error);
974
+ try {
975
+ await runXcodebuildWithLogging({
976
+ args: archiveArgs,
977
+ env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
978
+ failureMessage: "Archive failed",
979
+ logPrefix,
980
+ sourceDir,
981
+ successMessage: "Archive completed successfully"
982
+ });
983
+ return { archivePath };
984
+ } catch (error) {
985
+ throw prettifyXcodebuildError(error);
986
+ }
987
+ } finally {
988
+ await removeTmpDir(tmpDir);
366
989
  }
367
990
  };
368
- const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId }) => {
991
+ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId, authenticationArgs }) => {
369
992
  const args = [
370
993
  xcodeProject.isWorkspace ? "-workspace" : "-project",
371
994
  path.default.join(sourceDir, xcodeProject.name),
@@ -378,6 +1001,7 @@ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xco
378
1001
  archivePath,
379
1002
  `HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`
380
1003
  ];
1004
+ if (authenticationArgs) args.push(...authenticationArgs);
381
1005
  if (xcconfig) args.push("-xcconfig", xcconfig);
382
1006
  if (extraParams) args.push(...extraParams);
383
1007
  const resolvedDestinations = resolveDestinations({
@@ -394,62 +1018,86 @@ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xco
394
1018
  };
395
1019
  //#endregion
396
1020
  //#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);
1021
+ /**
1022
+ * Builds the scheme in a temporary DerivedData directory, copies the built
1023
+ * `.app` to `outputDirectory`, and removes DerivedData, which takes 10 GB or
1024
+ * more, whether the build succeeds or not.
1025
+ */
1026
+ const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams, minBundleId, authenticationArgs }) => {
399
1027
  if (installPods) await installPodsIfNeeded(sourceDir);
400
- const derivedDataPath = await createRandomTmpDir();
401
- const resolvedDestinations = resolveDestinations({
402
- destinations: destination,
403
- useGeneric: useGenericDestination
404
- });
405
- if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
406
- deviceType,
407
- platform,
408
- useGeneric: useGenericDestination
409
- }));
410
- const minBundleId = (0, _hot_updater_plugin_core.generateMinBundleId)();
411
- const buildArgs = prepareBuildArgs({
412
- minBundleId,
413
- configuration,
414
- derivedDataPath,
415
- deviceType,
416
- resolvedDestinations,
417
- extraParams,
418
- platform,
419
- xcodeScheme,
420
- sourceDir,
421
- xcodeProject
422
- });
423
- _hot_updater_cli_tools.p.log.info(`Xcode Build Settings:
424
- Project ${xcodeProject.name}
425
- Scheme ${xcodeScheme}
426
- Configuration ${configuration}
427
- Platform ${platform}
428
- Device Type ${deviceType}
429
- Command xcodebuild ${buildArgs.join(" ")}
430
- `);
1028
+ const xcodeProject = await parseXcodeProjectInfo(sourceDir);
1029
+ const tmpDir = await createRandomTmpDir();
431
1030
  try {
432
- await runXcodebuildWithLogging({
433
- args: buildArgs,
434
- env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
1031
+ const derivedDataPath = path.default.join(tmpDir, "DerivedData");
1032
+ const resolvedDestinations = resolveDestinations({
1033
+ destinations: destination,
1034
+ useGeneric: useGenericDestination
1035
+ });
1036
+ if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
1037
+ deviceType,
1038
+ platform,
1039
+ useGeneric: useGenericDestination
1040
+ }));
1041
+ const build = await writeBuildXcconfig({
1042
+ directory: tmpDir,
435
1043
  sourceDir,
436
- logPrefix,
437
- successMessage: "Build completed successfully",
438
- failureMessage: "Build failed"
1044
+ extraParams,
1045
+ settings: buildSettingsOverScheme({
1046
+ minBundleId,
1047
+ authenticationArgs
1048
+ })
439
1049
  });
440
- return await getBuildSettings({
1050
+ const buildArgs = prepareBuildArgs({
1051
+ authenticationArgs,
1052
+ xcconfig: build.xcconfig,
1053
+ minBundleId,
441
1054
  configuration,
442
1055
  derivedDataPath,
1056
+ deviceType,
443
1057
  resolvedDestinations,
1058
+ extraParams: build.extraParams,
1059
+ platform,
444
1060
  xcodeScheme,
445
1061
  sourceDir,
446
1062
  xcodeProject
447
1063
  });
448
- } catch (error) {
449
- throw prettifyXcodebuildError(error);
1064
+ _hot_updater_cli_tools.p.log.info(`Xcode Build Settings:
1065
+ Project ${xcodeProject.name}
1066
+ Scheme ${xcodeScheme}
1067
+ Configuration ${configuration}
1068
+ Platform ${platform}
1069
+ Device Type ${deviceType}
1070
+ Command xcodebuild ${buildArgs.join(" ")}
1071
+ `);
1072
+ let builtAppPath;
1073
+ try {
1074
+ await runXcodebuildWithLogging({
1075
+ args: buildArgs,
1076
+ env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
1077
+ sourceDir,
1078
+ logPrefix,
1079
+ successMessage: "Build completed successfully",
1080
+ failureMessage: "Build failed"
1081
+ });
1082
+ ({appPath: builtAppPath} = await getBuildSettings({
1083
+ configuration,
1084
+ derivedDataPath,
1085
+ resolvedDestinations,
1086
+ xcodeScheme,
1087
+ sourceDir,
1088
+ xcodeProject
1089
+ }));
1090
+ } catch (error) {
1091
+ throw prettifyXcodebuildError(error);
1092
+ }
1093
+ const appPath = path.default.join(outputDirectory, path.default.basename(builtAppPath));
1094
+ await fs.default.promises.cp(builtAppPath, appPath, { recursive: true });
1095
+ return { appPath };
1096
+ } finally {
1097
+ await removeTmpDir(tmpDir);
450
1098
  }
451
1099
  };
452
- const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId }) => {
1100
+ const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId, authenticationArgs, xcconfig }) => {
453
1101
  const sdk = deviceType === "simulator" ? platformConfigs[platform].simulatorSdk : platformConfigs[platform].deviceSdk;
454
1102
  const args = [
455
1103
  xcodeProject.isWorkspace ? "-workspace" : "-project",
@@ -465,6 +1113,8 @@ const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, conf
465
1113
  `HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`,
466
1114
  "build"
467
1115
  ];
1116
+ if (authenticationArgs) args.push(...authenticationArgs);
1117
+ args.push("-xcconfig", xcconfig);
468
1118
  if (extraParams) args.push(...extraParams);
469
1119
  for (const dest of resolvedDestinations) args.push("-destination", dest);
470
1120
  return args;
@@ -501,9 +1151,10 @@ const getBuildSettings = async ({ xcodeProject, sourceDir, xcodeScheme, configur
501
1151
  };
502
1152
  //#endregion
503
1153
  //#region src/builder/exportXcodeArchive.ts
504
- const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix }) => {
505
- const exportPath = await createRandomTmpDir();
1154
+ /** Exports the archive into `exportPath`. */
1155
+ const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, authenticationArgs }) => {
506
1156
  const exportArgs = prepareExportArgs({
1157
+ authenticationArgs,
507
1158
  archivePath,
508
1159
  exportPath,
509
1160
  exportOptionsPlist,
@@ -522,7 +1173,7 @@ const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, e
522
1173
  throw prettifyXcodebuildError(error);
523
1174
  }
524
1175
  };
525
- const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist }) => {
1176
+ const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist, authenticationArgs }) => {
526
1177
  const args = [
527
1178
  "-exportArchive",
528
1179
  "-archivePath",
@@ -530,6 +1181,7 @@ const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportO
530
1181
  "-exportPath",
531
1182
  exportPath
532
1183
  ];
1184
+ if (authenticationArgs) args.push(...authenticationArgs);
533
1185
  if (exportExtraParams) args.push(...exportExtraParams);
534
1186
  args.push("-exportOptionsPlist", exportOptionsPlist);
535
1187
  return args;
@@ -545,446 +1197,1133 @@ const assertXcodebuildExist = async () => {
545
1197
  }
546
1198
  };
547
1199
  //#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);
1200
+ //#region src/utils/escapeUnzipPattern.ts
1201
+ /**
1202
+ * A zip entry name as `unzip` reads a literal member. It takes `*`, `?` and
1203
+ * `[...]` in a member name as wildcards, which a backslash escapes, so an
1204
+ * app named `My[Dev].app` would otherwise match nothing.
1205
+ */
1206
+ const escapeUnzipPattern = (name) => name.replace(/[[\]*?\\]/gu, "\\$&");
1207
+ //#endregion
1208
+ //#region src/utils/readIpaInfo.ts
1209
+ const INFO_PLIST_KEYS = {
1210
+ bundleId: "CFBundleIdentifier",
1211
+ shortVersion: "CFBundleShortVersionString",
1212
+ bundleVersion: "CFBundleVersion"
1213
+ };
1214
+ /**
1215
+ * Reads the app's `Payload/<App>.app/Info.plist` out of an `.ipa`, never a
1216
+ * framework's or an extension's, which nest deeper.
1217
+ */
1218
+ const readIpaInfo = async (ipaPath) => {
1219
+ const { stdout } = await (0, execa.execa)("unzip", ["-Z1", ipaPath]);
1220
+ const entry = stdout.split("\n").map((name) => name.trim()).find((name) => /^Payload\/[^/]+\.app\/Info\.plist$/u.test(name));
1221
+ if (entry === void 0) throw new Error(`${path.default.basename(ipaPath)} has no Payload/<App>.app/Info.plist.`);
1222
+ const directory = await createRandomTmpDir();
1223
+ try {
1224
+ const infoPlist = path.default.join(directory, "Info.plist");
1225
+ await (0, execa.execa)("unzip", [
1226
+ "-p",
1227
+ ipaPath,
1228
+ escapeUnzipPattern(entry)
1229
+ ], { stdout: { file: infoPlist } });
1230
+ const info = {};
1231
+ for (const [field, key] of Object.entries(INFO_PLIST_KEYS)) {
1232
+ const { stdout: value, exitCode } = await (0, execa.execa)("plutil", [
1233
+ "-extract",
1234
+ key,
1235
+ "raw",
1236
+ "-o",
1237
+ "-",
1238
+ infoPlist
1239
+ ], { reject: false });
1240
+ if (exitCode !== 0 || !value.trim()) throw new Error(`${path.default.basename(ipaPath)}'s Info.plist has no ${key}.`);
1241
+ info[field] = value.trim();
560
1242
  }
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
1243
+ return info;
1244
+ } finally {
1245
+ await fs.default.promises.rm(directory, {
1246
+ recursive: true,
1247
+ force: true
573
1248
  });
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`
592
- });
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
1249
  }
622
1250
  };
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
1251
  //#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
1252
+ //#region src/utils/xcodeSigning.ts
1253
+ /**
1254
+ * How a device build signs with automatic signing, authenticated by the
1255
+ * scheme's App Store Connect API key: the xcodebuild arguments, and the key.
1256
+ * The key is resolved only here, for a device build of a scheme with
1257
+ * `signing`. Without `credentials`, it resolves from the environment or the
1258
+ * config's `secrets` adapter, and the temporary files are deleted when the
1259
+ * process exits.
1260
+ */
1261
+ const resolveXcodeSigning = async ({ schemeConfig, credentials }) => {
1262
+ const reference = schemeConfig.signing?.ascApiKey;
1263
+ if (!reference) return;
1264
+ const apiKey = await (credentials ?? (0, _hot_updater_cli_tools.createCredentialResolver)()).resolve({
1265
+ kind: "asc-api-key",
1266
+ name: (0, _hot_updater_cli_tools.parseSecretReference)(reference, "signing.ascApiKey")
641
1267
  });
1268
+ return {
1269
+ authenticationArgs: [
1270
+ "-allowProvisioningUpdates",
1271
+ "-authenticationKeyPath",
1272
+ apiKey.keyPath,
1273
+ "-authenticationKeyID",
1274
+ apiKey.keyId,
1275
+ "-authenticationKeyIssuerID",
1276
+ apiKey.issuerId
1277
+ ],
1278
+ apiKey
1279
+ };
642
1280
  };
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
- }
1281
+ const APP_STORE_CONNECT_EXPORT_OPTIONS = `<?xml version="1.0" encoding="UTF-8"?>
1282
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1283
+ <plist version="1.0">
1284
+ <dict>
1285
+ <key>method</key>
1286
+ <string>app-store-connect</string>
1287
+ <key>signingStyle</key>
1288
+ <string>automatic</string>
1289
+ </dict>
1290
+ </plist>
1291
+ `;
1292
+ /**
1293
+ * Writes the export options of a scheme that signs with an App Store Connect
1294
+ * API key and sets no `exportOptionsPlist`: an App Store Connect export with
1295
+ * automatic signing.
1296
+ */
1297
+ const writeAppStoreConnectExportOptions = async (directory) => {
1298
+ const exportOptionsPlist = path.default.join(directory, "ExportOptions.plist");
1299
+ await fs.default.promises.writeFile(exportOptionsPlist, APP_STORE_CONNECT_EXPORT_OPTIONS);
1300
+ return exportOptionsPlist;
682
1301
  };
1302
+ /** The export methods whose `.ipa` App Store Connect takes. */
1303
+ const APP_STORE_EXPORT_METHODS = /* @__PURE__ */ new Set(["app-store", "app-store-connect"]);
1304
+ /** The `method` of export options, or "" when it has none or can't be read. */
1305
+ const readExportMethod = async (exportOptionsPlist) => {
1306
+ 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);
1307
+ if (xml) return xml[1];
1308
+ const { stdout, exitCode } = await (0, execa.execa)("plutil", [
1309
+ "-extract",
1310
+ "method",
1311
+ "raw",
1312
+ "-o",
1313
+ "-",
1314
+ exportOptionsPlist
1315
+ ], { reject: false });
1316
+ return exitCode === 0 ? stdout.trim() : "";
1317
+ };
1318
+ /**
1319
+ * Whether a device build exports for App Store Connect: with its
1320
+ * `exportOptionsPlist`'s method, or, without one, as a scheme signing with
1321
+ * an App Store Connect API key does. Such an `.ipa` is signed with Apple's
1322
+ * cloud-managed distribution certificate, never with a development
1323
+ * certificate the archive was signed with.
1324
+ */
1325
+ const exportsForAppStoreConnect = async (schemeConfig) => !schemeConfig.exportOptionsPlist || APP_STORE_EXPORT_METHODS.has(await readExportMethod(schemeConfig.exportOptionsPlist));
683
1326
  //#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
- });
1327
+ //#region src/uploadToTestFlight.ts
1328
+ const API_URL$1 = "https://api.appstoreconnect.apple.com";
1329
+ /** Apple accepts tokens that expire within 20 minutes. */
1330
+ const TOKEN_LIFETIME_SECONDS = 1200;
1331
+ /** A token is replaced well before it expires, so no request carries a stale one. */
1332
+ const TOKEN_REUSE_SECONDS = 600;
1333
+ /** The parts of an `.ipa` sent at once. */
1334
+ const UPLOAD_CONCURRENCY = 4;
1335
+ /** Attempts to create the build upload and to commit the uploaded `.ipa`. */
1336
+ const COMMIT_ATTEMPTS = 3;
1337
+ /** Attempts per part: a part is a plain PUT, safe to repeat. */
1338
+ const UPLOAD_ATTEMPTS = 3;
1339
+ const IN_PROGRESS_STATES = /* @__PURE__ */ new Set(["AWAITING_UPLOAD", "PROCESSING"]);
1340
+ /** The error App Store Connect answers with when a build number is taken. */
1341
+ const DUPLICATE_ERROR_CODE = "ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE";
1342
+ /**
1343
+ * App Store Connect refused the upload: it already has a build with the
1344
+ * `.ipa`'s version and build number, from this or another upload, and takes
1345
+ * each build number once per version.
1346
+ */
1347
+ var TestFlightBuildExistsError = class extends Error {
1348
+ shortVersion;
1349
+ bundleVersion;
1350
+ name = "TestFlightBuildExistsError";
1351
+ constructor(shortVersion, bundleVersion, options) {
1352
+ super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}. Each build of a version needs a new build number (CFBundleVersion).`, options);
1353
+ this.shortVersion = shortVersion;
1354
+ this.bundleVersion = bundleVersion;
1355
+ }
696
1356
  };
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 });
1357
+ /** An App Store Connect API request that failed, with the errors it answered. */
1358
+ var AppStoreConnectError = class extends Error {
1359
+ status;
1360
+ errors;
1361
+ /** The answer's `Retry-After` header, for a rate limit. */
1362
+ retryAfter;
1363
+ constructor(action, status, errors, retryAfter = null) {
1364
+ const details = errors.map((error) => error.detail ?? error.title ?? error.code).filter(Boolean);
1365
+ super(`${action} failed (HTTP ${status})${details.length > 0 ? `: ${details.join(" ")}` : "."}`);
1366
+ this.name = "AppStoreConnectError";
1367
+ this.status = status;
1368
+ this.errors = errors;
1369
+ this.retryAfter = retryAfter;
1370
+ }
707
1371
  };
708
- const installOnSimulator = async ({ device, appPath, options }) => {
709
- const spinner = _hot_updater_cli_tools.p.spinner();
710
- spinner.start(`Installing app on "${device.name}"`);
1372
+ /**
1373
+ * An App Store Connect API token: an ES256 JWT, whose signature JWS encodes
1374
+ * as the raw 64-byte r || s, not the DER Node signs by default.
1375
+ */
1376
+ const createAppStoreConnectToken = ({ privateKey, keyId, issuerId, issuedAtSeconds = Math.floor(Date.now() / 1e3) }) => {
1377
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
1378
+ const signingInput = `${encode({
1379
+ alg: "ES256",
1380
+ kid: keyId,
1381
+ typ: "JWT"
1382
+ })}.${encode({
1383
+ iss: issuerId,
1384
+ iat: issuedAtSeconds,
1385
+ exp: issuedAtSeconds + TOKEN_LIFETIME_SECONDS,
1386
+ aud: "appstoreconnect-v1"
1387
+ })}`;
1388
+ return `${signingInput}.${crypto.default.sign("sha256", Buffer.from(signingInput), {
1389
+ key: privateKey,
1390
+ dsaEncoding: "ieee-p1363"
1391
+ }).toString("base64url")}`;
1392
+ };
1393
+ const readPrivateKey = async (keyPath) => {
711
1394
  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}`);
1395
+ return crypto.default.createPrivateKey(await fs.default.promises.readFile(keyPath, "utf-8"));
1396
+ } catch {
1397
+ throw new Error("The App Store Connect API key is not a .p8 private key.");
722
1398
  }
723
1399
  };
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}"`);
1400
+ const readApiErrors = async (response) => {
1401
+ const body = await response.json().catch(() => void 0);
1402
+ return Array.isArray(body?.errors) ? body.errors : [];
1403
+ };
1404
+ const describeDetails = (details) => details.map((detail) => `${detail.description ?? "(no description)"}${detail.code ? ` (${detail.code})` : ""}`);
1405
+ /** Answers a request may outlive: a timeout, a rate limit, a server error. */
1406
+ const isRetryableStatus = (status) => status === 408 || status === 429 || status >= 500;
1407
+ /**
1408
+ * Whether a request may succeed when sent again: a retryable answer, or a
1409
+ * network failure, which fetch rejects with a TypeError. A refused key or
1410
+ * request is not.
1411
+ */
1412
+ const isRetryable = (error) => error instanceof AppStoreConnectError ? isRetryableStatus(error.status) : error instanceof TypeError;
1413
+ /** Whether App Store Connect refused a request for its rate limit only. */
1414
+ const isRateLimited = (error) => error instanceof AppStoreConnectError && error.status === 429;
1415
+ /**
1416
+ * Runs `request`, again after a failure `retryable` accepts, up to the
1417
+ * attempts.
1418
+ */
1419
+ const retrying = async (request, retryable = isRetryable) => {
1420
+ for (let attempt = 1;; attempt++) try {
1421
+ return await request();
735
1422
  } catch (error) {
736
- spinner.stop(`Failed to launch app on "${device.name}"`);
737
- throw new Error(`Failed to launch the app on simulator: ${error}`);
1423
+ if (!retryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
1424
+ await sleep(retryDelayMs(error, attempt));
738
1425
  }
739
1426
  };
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;
1427
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1428
+ /** The longest wait before a retry, so a run stays bounded. */
1429
+ const MAX_RETRY_DELAY_MS = 6e4;
1430
+ /**
1431
+ * The first wait after a rate limit without `Retry-After`: App Store Connect
1432
+ * limits a key per rolling hour, so a retry within seconds meets it again.
1433
+ */
1434
+ const RATE_LIMIT_DELAY_MS = 15e3;
1435
+ /**
1436
+ * How long to wait before sending a request again after `failure`, an
1437
+ * answer or the error it raised: the `Retry-After` seconds it asks for, a
1438
+ * longer backoff for a rate limit without one, and else a growing backoff,
1439
+ * all within a bound.
1440
+ */
1441
+ const retryDelayMs = (failure, attempt) => {
1442
+ const { status, retryAfter } = failure instanceof Response ? {
1443
+ status: failure.status,
1444
+ retryAfter: failure.headers.get("retry-after")
1445
+ } : failure instanceof AppStoreConnectError ? failure : {
1446
+ status: 0,
1447
+ retryAfter: null
1448
+ };
1449
+ const seconds = retryAfter === null || retryAfter.trim() === "" ? NaN : Number(retryAfter);
1450
+ const delay = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : status === 429 ? RATE_LIMIT_DELAY_MS * attempt : 1e3 * attempt;
1451
+ return Math.min(delay, MAX_RETRY_DELAY_MS);
1452
+ };
1453
+ /**
1454
+ * Runs `run` over `items`, at most `concurrency` at a time. After the first
1455
+ * failure no item starts, and it throws that failure once every running item
1456
+ * has settled, so nothing is still sending when the caller cleans up.
1457
+ */
1458
+ const runConcurrently = async (items, concurrency, run) => {
1459
+ let next = 0;
1460
+ let failure;
1461
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
1462
+ while (!failure && next < items.length) {
1463
+ const index = next++;
1464
+ try {
1465
+ await run(items[index], index);
1466
+ } catch (error) {
1467
+ failure ??= { error };
1468
+ }
751
1469
  }
752
- throw new Error(`Failed to boot simulator: ${error}`);
1470
+ }));
1471
+ if (failure) throw failure.error;
1472
+ };
1473
+ /**
1474
+ * A signing build setting's assignment: the key, any conditions such as
1475
+ * `[sdk=iphoneos*]`, and the value, without a trailing comment.
1476
+ */
1477
+ const SIGNING_SETTING = /^\s*(CODE_SIGNING_ALLOWED|CODE_SIGNING_REQUIRED)((?:\[[^\]]*\])*)\s*=\s*([^\s/]*)\s*(?:\/\/.*)?$/u;
1478
+ /** A scheme's `exportOptionsPlist`, which resolves from the project's root. */
1479
+ const schemeFile = (file) => path.default.isAbsolute(file) ? file : path.default.resolve((0, _hot_updater_cli_tools.getCwd)(), file);
1480
+ /** Applies one assignment, as Xcode does: the last unconditional one wins. */
1481
+ const applySetting = (settings, line, label) => {
1482
+ const [, key, conditions, value] = line.match(SIGNING_SETTING) ?? [];
1483
+ if (!key) return;
1484
+ if (conditions) settings.conditional.add(key);
1485
+ else settings.values.set(key, {
1486
+ value,
1487
+ where: `${line.trim()} in ${label}`
1488
+ });
1489
+ };
1490
+ /**
1491
+ * Applies an xcconfig's signing settings over `settings`, in order,
1492
+ * following its `#include`s, which resolve from the including file, as
1493
+ * Xcode reads it. A file included again applies again; only an include
1494
+ * cycle (`including`) stops.
1495
+ */
1496
+ const applyXcconfig = async (file, label, settings, including) => {
1497
+ if (including.has(file)) return;
1498
+ including.add(file);
1499
+ const uncommented = (await fs.default.promises.readFile(file, "utf-8").catch(() => void 0))?.replace(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replace(/[^\n]/gu, ""));
1500
+ for (const line of uncommented?.split("\n") ?? []) {
1501
+ const include = line.match(/^\s*#include\??\s*"([^"]+)"/u)?.[1];
1502
+ if (include) await applyXcconfig(path.default.resolve(path.default.dirname(file), include), include, settings, including);
1503
+ else applySetting(settings, line, label);
753
1504
  }
1505
+ including.delete(file);
754
1506
  };
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
- }));
1507
+ /**
1508
+ * Where the scheme turns code signing off, after every override the build
1509
+ * applies: the `extraParams` settings first, then the xcconfigs the build
1510
+ * xcconfig includes over them, in order, its `xcconfig` and each
1511
+ * `-xcconfig` in `extraParams`, which resolve from `ios/`, where xcodebuild
1512
+ * runs (`writeBuildXcconfig`). Undefined when it signs, or when a
1513
+ * conditional setting, such as `[sdk=iphoneos*]`, may decide: the build
1514
+ * does.
1515
+ */
1516
+ const codeSigningOff = async (iosScheme) => {
1517
+ const extraParams = iosScheme.extraParams ?? [];
1518
+ const settings = {
1519
+ values: /* @__PURE__ */ new Map(),
1520
+ conditional: /* @__PURE__ */ new Set()
1521
+ };
1522
+ for (const entry of extraParams) applySetting(settings, entry, "extraParams");
1523
+ const xcconfigs = [...iosScheme.xcconfig ? [iosScheme.xcconfig] : [], ...extraParams.flatMap((entry, index) => entry === "-xcconfig" && index + 1 < extraParams.length ? [extraParams[index + 1]] : [])];
1524
+ for (const xcconfig of xcconfigs) await applyXcconfig(path.default.resolve((0, _hot_updater_cli_tools.getCwd)(), "ios", xcconfig), xcconfig, settings, /* @__PURE__ */ new Set());
1525
+ for (const [key, { value, where }] of settings.values) if (/^no$/iu.test(value) && !settings.conditional.has(key)) return where;
771
1526
  };
772
1527
  /**
773
- * Gets physical iOS devices using devicectl
1528
+ * The App Store Connect API key a scheme's `.ipa` uploads with: the
1529
+ * `signing.ascApiKey` it signs with, or else the `asc-api-key` item. It reads
1530
+ * the config, and the scheme's files that exist before the native projects
1531
+ * are written, and throws, saying why, for a scheme whose build App Store
1532
+ * Connect would not take: a simulator `.app`, a Debug configuration, which
1533
+ * loads its JavaScript from Metro, code signing turned off, an
1534
+ * `.xcarchive` it cannot export, or export options of another method. An
1535
+ * `exportOptionsPlist` the adapter's prebuild writes later is checked after
1536
+ * it (`assertTestFlightExportOptions`).
774
1537
  */
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}`);
1538
+ const resolveTestFlightUploadKey = async ({ scheme, iosScheme }) => {
1539
+ if (iosScheme.simulator) throw new Error(`TestFlight takes an .ipa, and the scheme ${scheme} builds a simulator .app.`);
1540
+ const configuration = iosScheme.configuration ?? "Release";
1541
+ 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.`);
1542
+ const unsigned = await codeSigningOff(iosScheme);
1543
+ if (unsigned) throw new Error(`TestFlight takes a signed .ipa, and the scheme ${scheme} turns code signing off (${unsigned}).`);
1544
+ 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.`);
1545
+ if (iosScheme.exportOptionsPlist && fs.default.existsSync(schemeFile(iosScheme.exportOptionsPlist))) {
1546
+ const method = await readExportMethod(schemeFile(iosScheme.exportOptionsPlist));
1547
+ 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
1548
  }
1549
+ return {
1550
+ kind: "asc-api-key",
1551
+ name: iosScheme.signing ? (0, _hot_updater_cli_tools.parseSecretReference)(iosScheme.signing.ascApiKey, "signing.ascApiKey") : "asc-api-key"
1552
+ };
1553
+ };
1554
+ /**
1555
+ * Throws when the scheme's `exportOptionsPlist` exports with another method
1556
+ * than App Store Connect's, whose `.ipa` App Store Connect refuses. It reads
1557
+ * the file, which an adapter's prebuild may write, so it runs after it.
1558
+ */
1559
+ const assertTestFlightExportOptions = async (schemeConfig) => {
1560
+ if (!schemeConfig.exportOptionsPlist) return;
1561
+ const method = await readExportMethod(schemeConfig.exportOptionsPlist);
1562
+ 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.`);
1563
+ };
1564
+ /**
1565
+ * Requests to the App Store Connect API with `apiKey`: each carries a token
1566
+ * signed with its private key, replaced well before it expires.
1567
+ */
1568
+ const createAppStoreConnectRequest = ({ apiKey, privateKey, fetch }) => {
1569
+ let token;
1570
+ const authorization = () => {
1571
+ const now = Math.floor(Date.now() / 1e3);
1572
+ if (!token || now - token.issuedAtSeconds >= TOKEN_REUSE_SECONDS) {
1573
+ token = {
1574
+ value: createAppStoreConnectToken({
1575
+ privateKey,
1576
+ keyId: apiKey.keyId,
1577
+ issuerId: apiKey.issuerId,
1578
+ issuedAtSeconds: now
1579
+ }),
1580
+ issuedAtSeconds: now
1581
+ };
1582
+ (0, _hot_updater_cli_tools.registerSecretValue)(token.value);
1583
+ }
1584
+ return `Bearer ${token.value}`;
1585
+ };
1586
+ return async (action, method, pathname, body) => {
1587
+ const response = await fetch(`${API_URL$1}${pathname}`, {
1588
+ method,
1589
+ headers: {
1590
+ Authorization: authorization(),
1591
+ ...body ? { "Content-Type": "application/json" } : {}
1592
+ },
1593
+ ...body ? { body: JSON.stringify(body) } : {}
1594
+ });
1595
+ if (!response.ok) {
1596
+ const error = new AppStoreConnectError(action, response.status, await readApiErrors(response), response.headers.get("retry-after"));
1597
+ 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 });
1598
+ if (response.status === 403) throw new Error(`${error.message} An API key with the Admin, App Manager, or Developer role uploads builds.`, { cause: error });
1599
+ throw error;
1600
+ }
1601
+ return response.status === 204 ? void 0 : await response.json();
1602
+ };
1603
+ };
1604
+ /** The App Store Connect app of `bundleId`, and its TestFlight page. */
1605
+ const findApp = async (request, bundleId) => {
1606
+ const { data: apps } = await retrying(() => request("Finding the App Store Connect app", "GET", `/v1/apps?${new URLSearchParams({
1607
+ "filter[bundleId]": bundleId,
1608
+ "fields[apps]": "bundleId"
1609
+ })}`));
1610
+ const app = apps.find((app) => app.attributes?.bundleId === bundleId);
1611
+ 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.`);
1612
+ return {
1613
+ app,
1614
+ testFlightUrl: `https://appstoreconnect.apple.com/apps/${app.id}/testflight/ios`
1615
+ };
790
1616
  };
791
1617
  /**
792
- * Gets iOS simulators using simctl with JSON output
1618
+ * Whether App Store Connect already has the `.ipa`'s build: a build of its
1619
+ * version with its build number, which TestFlight lists or is processing,
1620
+ * as when a reused native build was uploaded before. A build upload that
1621
+ * never finished is no build, so uploading again fails as it would without
1622
+ * this check.
793
1623
  */
794
- const getSimulators = async () => {
1624
+ const findTestFlightBuild = async ({ ipaPath, apiKey, fetch = globalThis.fetch }) => {
1625
+ const [info, privateKey] = await Promise.all([readIpaInfo(ipaPath), readPrivateKey(apiKey.keyPath)]);
1626
+ const request = createAppStoreConnectRequest({
1627
+ apiKey,
1628
+ privateKey,
1629
+ fetch
1630
+ });
1631
+ const { app, testFlightUrl } = await findApp(request, info.bundleId);
1632
+ const { data: builds } = await retrying(() => request("Finding the build in App Store Connect", "GET", `/v1/builds?${new URLSearchParams({
1633
+ "filter[app]": app.id,
1634
+ "filter[version]": info.bundleVersion,
1635
+ "filter[preReleaseVersion.version]": info.shortVersion,
1636
+ "fields[builds]": "version",
1637
+ limit: "1"
1638
+ })}`));
1639
+ return {
1640
+ found: builds.length > 0,
1641
+ testFlightUrl,
1642
+ shortVersion: info.shortVersion,
1643
+ bundleVersion: info.bundleVersion
1644
+ };
1645
+ };
1646
+ /**
1647
+ * Uploads an `.ipa` to App Store Connect, which lists it in TestFlight, with
1648
+ * the App Store Connect API's build uploads: it finds the app by the
1649
+ * `.ipa`'s bundle ID, reserves the build's version and build number, sends
1650
+ * the file in the parts App Store Connect asks for, commits it, and waits
1651
+ * for App Store Connect to accept or reject the build.
1652
+ */
1653
+ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, pollIntervalMs = 5e3, processingTimeoutMs = 18e5 }) => {
1654
+ const ipaName = path.default.basename(ipaPath);
1655
+ const [info, privateKey, { size: fileSize }] = await Promise.all([
1656
+ readIpaInfo(ipaPath),
1657
+ readPrivateKey(apiKey.keyPath),
1658
+ fs.default.promises.stat(ipaPath)
1659
+ ]);
1660
+ const request = createAppStoreConnectRequest({
1661
+ apiKey,
1662
+ privateKey,
1663
+ fetch
1664
+ });
1665
+ const { app, testFlightUrl } = await findApp(request, info.bundleId);
1666
+ let buildUploadId;
1667
+ let unanswered = false;
1668
+ for (let attempt = 1; buildUploadId === void 0; attempt++) try {
1669
+ ({data: {id: buildUploadId}} = await request("Creating the build upload", "POST", "/v1/buildUploads", { data: {
1670
+ type: "buildUploads",
1671
+ attributes: {
1672
+ platform: "IOS",
1673
+ cfBundleShortVersionString: info.shortVersion,
1674
+ cfBundleVersion: info.bundleVersion
1675
+ },
1676
+ relationships: { app: { data: {
1677
+ type: "apps",
1678
+ id: app.id
1679
+ } } }
1680
+ } }));
1681
+ } catch (error) {
1682
+ if (error instanceof AppStoreConnectError && error.status === 409 && error.errors.some((apiError) => apiError.code === DUPLICATE_ERROR_CODE)) {
1683
+ 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 });
1684
+ throw new TestFlightBuildExistsError(info.shortVersion, info.bundleVersion, { cause: error });
1685
+ }
1686
+ if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
1687
+ if (!isRateLimited(error)) unanswered = true;
1688
+ await sleep(retryDelayMs(error, attempt));
1689
+ }
1690
+ _hot_updater_cli_tools.p.log.info(`Uploading ${ipaName} (version ${info.shortVersion}, build ${info.bundleVersion}) to App Store Connect`);
1691
+ const removeUpload = async () => {
1692
+ for (let attempt = 1;; attempt++) try {
1693
+ await request("Removing the build upload", "DELETE", `/v1/buildUploads/${buildUploadId}`);
1694
+ return;
1695
+ } catch (error) {
1696
+ if (error instanceof AppStoreConnectError && error.status === 404) return;
1697
+ if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) {
1698
+ _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).`);
1699
+ return;
1700
+ }
1701
+ await sleep(retryDelayMs(error, attempt));
1702
+ }
1703
+ };
1704
+ let fileId;
795
1705
  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);
1706
+ const { data } = await retrying(() => request("Reserving the .ipa upload", "POST", "/v1/buildUploadFiles", { data: {
1707
+ type: "buildUploadFiles",
1708
+ attributes: {
1709
+ assetType: "ASSET",
1710
+ fileName: ipaName,
1711
+ fileSize,
1712
+ uti: "com.apple.ipa"
1713
+ },
1714
+ relationships: { buildUpload: { data: {
1715
+ type: "buildUploads",
1716
+ id: buildUploadId
1717
+ } } }
1718
+ } }), isRateLimited);
1719
+ fileId = data.id;
1720
+ const operations = data.attributes?.uploadOperations ?? [];
1721
+ if (operations.length === 0) throw new Error("App Store Connect returned no upload operations.");
1722
+ await uploadParts({
1723
+ ipaPath,
1724
+ operations,
1725
+ fetch
1726
+ });
805
1727
  } catch (error) {
806
- throw new Error(`Failed to get simulators: ${error}`);
1728
+ await removeUpload();
1729
+ throw error;
1730
+ }
1731
+ let commitUnanswered = false;
1732
+ for (let attempt = 1;; attempt++) try {
1733
+ await request("Committing the .ipa upload", "PATCH", `/v1/buildUploadFiles/${fileId}`, { data: {
1734
+ type: "buildUploadFiles",
1735
+ id: fileId,
1736
+ attributes: { uploaded: true }
1737
+ } });
1738
+ break;
1739
+ } catch (error) {
1740
+ 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 });
1741
+ if (!isRetryable(error)) {
1742
+ if (commitUnanswered) throw unconfirmed();
1743
+ await removeUpload();
1744
+ throw error;
1745
+ }
1746
+ commitUnanswered = true;
1747
+ 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);
1748
+ if (fileState && fileState !== "AWAITING_UPLOAD") break;
1749
+ if (attempt === COMMIT_ATTEMPTS) throw unconfirmed();
1750
+ await sleep(retryDelayMs(error, attempt));
1751
+ }
1752
+ const deadline = Date.now() + processingTimeoutMs;
1753
+ let reportedProcessing = false;
1754
+ let lastError;
1755
+ for (let failedReads = 0;;) {
1756
+ let readError;
1757
+ const read = await request("Reading the build upload", "GET", `/v1/buildUploads/${buildUploadId}`).catch((error) => {
1758
+ if (!isRetryable(error)) throw error;
1759
+ lastError = readError = error;
1760
+ });
1761
+ failedReads = read ? 0 : failedReads + 1;
1762
+ const state = read ? read.data.attributes?.state ?? {} : {};
1763
+ if (state.state === "PROCESSING") reportedProcessing = true;
1764
+ if (!state.state || IN_PROGRESS_STATES.has(state.state)) {
1765
+ if (Date.now() >= deadline) {
1766
+ 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 });
1767
+ _hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${info.bundleVersion}. It appears in TestFlight once processing finishes: ${testFlightUrl}`);
1768
+ return {
1769
+ appId: app.id,
1770
+ buildUploadId,
1771
+ state: "PROCESSING",
1772
+ testFlightUrl,
1773
+ shortVersion: info.shortVersion,
1774
+ bundleVersion: info.bundleVersion
1775
+ };
1776
+ }
1777
+ await sleep(readError === void 0 ? pollIntervalMs : Math.max(pollIntervalMs, retryDelayMs(readError, failedReads)));
1778
+ continue;
1779
+ }
1780
+ for (const warning of describeDetails(state.warnings ?? [])) _hot_updater_cli_tools.p.log.warn(warning);
1781
+ if (state.state !== "COMPLETE") {
1782
+ const errors = describeDetails(state.errors ?? []);
1783
+ throw new Error([`App Store Connect rejected build ${info.bundleVersion} of version ${info.shortVersion} (${state.state}).`, ...errors.map((error) => `- ${error}`)].join("\n"));
1784
+ }
1785
+ return {
1786
+ appId: app.id,
1787
+ buildUploadId,
1788
+ state: "COMPLETE",
1789
+ testFlightUrl,
1790
+ shortVersion: info.shortVersion,
1791
+ bundleVersion: info.bundleVersion
1792
+ };
807
1793
  }
808
1794
  };
809
1795
  /**
810
- * Parses simctl JSON output to Device array
1796
+ * Sends each part App Store Connect asked for: the bytes at its offset, with
1797
+ * only the headers it lists, since the URLs are presigned: another header,
1798
+ * or the App Store Connect token, would break their signature.
811
1799
  */
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"
1800
+ const uploadParts = async ({ ipaPath, operations, fetch }) => {
1801
+ const file = await fs.default.promises.open(ipaPath, "r");
1802
+ try {
1803
+ await runConcurrently(operations, UPLOAD_CONCURRENCY, async (operation, index) => {
1804
+ const body = Buffer.alloc(operation.length);
1805
+ const { bytesRead } = await file.read(body, 0, operation.length, operation.offset);
1806
+ if (bytesRead !== operation.length) throw new Error(`${path.default.basename(ipaPath)} changed while it was uploading.`);
1807
+ const headers = {};
1808
+ for (const { name, value } of operation.requestHeaders ?? []) headers[name] = value;
1809
+ for (let attempt = 1;; attempt++) {
1810
+ const response = await fetch(operation.url, {
1811
+ method: operation.method,
1812
+ headers,
1813
+ body
1814
+ }).catch((error) => error);
1815
+ if (!(response instanceof Error) && response.ok) return;
1816
+ 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}`}.`);
1817
+ await sleep(retryDelayMs(response, attempt));
1818
+ }
825
1819
  });
1820
+ } finally {
1821
+ await file.close();
826
1822
  }
827
- return devices;
1823
+ };
1824
+ //#endregion
1825
+ //#region src/utils/ciSigningKeychain.ts
1826
+ const API_URL = "https://api.appstoreconnect.apple.com";
1827
+ const CERTIFICATES_URL = "https://developer.apple.com/account/resources/certificates/list";
1828
+ /** Long enough for any build: the keychain never locks while one runs. */
1829
+ const KEYCHAIN_TIMEOUT_SECONDS = 21600;
1830
+ const runSecurity = async (args) => (await (0, execa.execa)("security", args)).stdout;
1831
+ /** The keychain paths `security` prints, one quoted path per line. */
1832
+ const parseKeychains = (stdout) => [...stdout.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
1833
+ const normalizeSerial = (serial) => serial.toUpperCase().replace(/^0+(?=.)/, "");
1834
+ /** The keychain whose state the process restores if it exits first. */
1835
+ let live;
1836
+ const restoreLive = () => {
1837
+ live?.restore();
1838
+ live = void 0;
1839
+ process.off("exit", restoreLive);
828
1840
  };
829
1841
  /**
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
1842
+ * Where builds under one macOS user take turns: the default keychain and the
1843
+ * search list are the user's, not a process's, so two builds that overlap,
1844
+ * such as two self-hosted runner instances, would each restore the other's
1845
+ * keychain. It is in the user's caches, whatever TMPDIR a runner sets.
833
1846
  */
834
- const getPlatformFromOsVersion = (osVersion) => {
835
- switch (osVersion) {
836
- case "iOS": return "ios";
837
- default: return "ios";
838
- }
1847
+ const defaultLockPath = () => path.default.join(os.default.homedir(), "Library", "Caches", "hot-updater", "signing-keychain.lock");
1848
+ /** lockf's exit code when `-t 0` finds the lock held. */
1849
+ const EX_TEMPFAIL = 75;
1850
+ /**
1851
+ * Holds `lockPath` with macOS's `lockf`, a kernel lock on the file kept by a
1852
+ * helper that waits on this process's pipe: the kernel releases it when the
1853
+ * helper ends, which is when the pipe closes, so a build that dies, even
1854
+ * killed outright, leaves no lock behind and none needs taking over. The
1855
+ * helper runs in its own process group, out of reach of a terminal's
1856
+ * Ctrl+C, and never keeps this process alive.
1857
+ */
1858
+ const holdWithLockf = (lockPath, wait) => new Promise((resolve, reject) => {
1859
+ const child = (0, child_process.spawn)("lockf", [
1860
+ "-k",
1861
+ ...wait ? [] : ["-t", "0"],
1862
+ lockPath,
1863
+ "/bin/sh",
1864
+ "-c",
1865
+ "echo held; exec cat >/dev/null"
1866
+ ], {
1867
+ detached: true,
1868
+ stdio: [
1869
+ "pipe",
1870
+ "pipe",
1871
+ "ignore"
1872
+ ]
1873
+ });
1874
+ let held = false;
1875
+ child.stdout.once("data", () => {
1876
+ held = true;
1877
+ child.unref();
1878
+ child.stdin.unref?.();
1879
+ child.stdout.unref?.();
1880
+ const exited = new Promise((done) => child.once("exit", () => done()));
1881
+ resolve(async () => {
1882
+ child.ref();
1883
+ child.stdin.end();
1884
+ await exited;
1885
+ });
1886
+ });
1887
+ child.once("error", reject);
1888
+ child.once("exit", (code) => {
1889
+ if (held) return;
1890
+ if (!wait && code === EX_TEMPFAIL) resolve(void 0);
1891
+ else reject(/* @__PURE__ */ new Error(`lockf ${lockPath} exited with ${code}.`));
1892
+ });
1893
+ });
1894
+ const acquireWithLockf = async (lockPath) => {
1895
+ await fs.default.promises.mkdir(path.default.dirname(lockPath), { recursive: true });
1896
+ const release = await holdWithLockf(lockPath, false);
1897
+ if (release) return release;
1898
+ _hot_updater_cli_tools.p.log.info("Waiting for another build that signs in a keychain of its own under this macOS user.");
1899
+ return await holdWithLockf(lockPath, true);
839
1900
  };
840
1901
  /**
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
1902
+ * Creates the build's keychain and makes it the user's default keychain,
1903
+ * first in the search list, ahead of the keychains already there, so Xcode
1904
+ * still finds a signing identity a self-hosted runner keeps and creates a
1905
+ * new one only when it has none.
845
1906
  */
846
- const listDevices = async (platform, options = {}) => {
847
- const spinner = _hot_updater_cli_tools.p.spinner();
848
- spinner.start(`Discovering ${platform} devices`);
1907
+ const createSigningKeychain = async ({ security = runSecurity, directory = os.default.tmpdir(), lockPath = defaultLockPath(), acquireLock = acquireWithLockf } = {}) => {
1908
+ if (live) throw new Error("A signing keychain is already in use by this process.");
1909
+ live = { restore: () => void 0 };
1910
+ let releaseLock;
849
1911
  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;
1912
+ releaseLock = await acquireLock(lockPath);
856
1913
  } catch (error) {
857
- spinner.stop(`Failed to discover ${platform} devices`);
1914
+ live = void 0;
858
1915
  throw error;
859
1916
  }
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);
1917
+ process.on("exit", restoreLive);
1918
+ let originalDefault;
1919
+ let originalSearchList;
1920
+ try {
1921
+ [originalDefault] = parseKeychains(await security([
1922
+ "default-keychain",
1923
+ "-d",
1924
+ "user"
1925
+ ]).catch(() => ""));
1926
+ originalSearchList = parseKeychains(await security([
1927
+ "list-keychains",
1928
+ "-d",
1929
+ "user"
1930
+ ]));
1931
+ } catch (error) {
1932
+ restoreLive();
1933
+ await releaseLock();
1934
+ throw error;
880
1935
  }
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);
1936
+ const keychainPath = path.default.join(directory, `hot-updater-${crypto.default.randomBytes(6).toString("hex")}.keychain-db`);
1937
+ const password = crypto.default.randomBytes(24).toString("base64url");
1938
+ (0, _hot_updater_cli_tools.registerSecretValue)(password);
1939
+ const restoreArgs = [
1940
+ ...originalDefault ? [[
1941
+ "default-keychain",
1942
+ "-d",
1943
+ "user",
1944
+ "-s",
1945
+ originalDefault
1946
+ ]] : [],
1947
+ [
1948
+ "list-keychains",
1949
+ "-d",
1950
+ "user",
1951
+ "-s",
1952
+ ...originalSearchList
1953
+ ],
1954
+ ["delete-keychain", keychainPath]
1955
+ ];
1956
+ live = { restore: () => {
1957
+ for (const args of restoreArgs) (0, child_process.spawnSync)("security", args);
1958
+ } };
1959
+ let disposal;
1960
+ const dispose = () => {
1961
+ disposal ??= (async () => {
1962
+ const failures = [];
1963
+ for (const args of restoreArgs) await security(args).catch((error) => {
1964
+ failures.push(`security ${args[0]}: ${error instanceof Error ? error.message : String(error)}`);
1965
+ });
1966
+ live = void 0;
1967
+ process.off("exit", restoreLive);
1968
+ await releaseLock().catch(() => void 0);
1969
+ 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.`);
1970
+ })();
1971
+ return disposal;
1972
+ };
1973
+ try {
1974
+ await security([
1975
+ "create-keychain",
1976
+ "-p",
1977
+ password,
1978
+ keychainPath
1979
+ ]);
1980
+ await security([
1981
+ "set-keychain-settings",
1982
+ "-lut",
1983
+ String(KEYCHAIN_TIMEOUT_SECONDS),
1984
+ keychainPath
1985
+ ]);
1986
+ await security([
1987
+ "unlock-keychain",
1988
+ "-p",
1989
+ password,
1990
+ keychainPath
1991
+ ]);
1992
+ await security([
1993
+ "list-keychains",
1994
+ "-d",
1995
+ "user",
1996
+ "-s",
1997
+ keychainPath,
1998
+ ...originalSearchList.filter((keychain) => keychain !== keychainPath)
1999
+ ]);
2000
+ await security([
2001
+ "default-keychain",
2002
+ "-d",
2003
+ "user",
2004
+ "-s",
2005
+ keychainPath
2006
+ ]);
2007
+ } catch (error) {
2008
+ await dispose();
2009
+ throw error;
2010
+ }
2011
+ return {
2012
+ path: keychainPath,
2013
+ certificates: async () => {
2014
+ return ((await security([
2015
+ "find-certificate",
2016
+ "-a",
2017
+ "-p",
2018
+ keychainPath
2019
+ ])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map((pem) => {
2020
+ const certificate = new crypto.default.X509Certificate(pem);
2021
+ return {
2022
+ serialNumber: normalizeSerial(certificate.serialNumber),
2023
+ commonName: certificate.subject.match(/^CN=(.*)$/m)?.[1] ?? certificate.subject
2024
+ };
2025
+ });
2026
+ },
2027
+ dispose
2028
+ };
2029
+ };
2030
+ /**
2031
+ * Revokes the certificates Xcode created in the build's keychain, whose
2032
+ * private keys the keychain's deletion destroys: on a runner that starts
2033
+ * empty, Xcode creates a development certificate for every build, and App
2034
+ * Store Connect refuses new ones once a team has its maximum. Each is found
2035
+ * in the team's certificates by serial number. A revocation that fails is
2036
+ * reported with where to revoke it by hand, and never fails the build.
2037
+ */
2038
+ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fetch = globalThis.fetch }) => {
2039
+ const revoked = [];
2040
+ const failed = [];
2041
+ if (certificates.length === 0) return {
2042
+ revoked,
2043
+ failed
2044
+ };
2045
+ const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
2046
+ try {
2047
+ const token = createAppStoreConnectToken({
2048
+ privateKey: privateKey ?? crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8")),
2049
+ keyId: apiKey.keyId,
2050
+ issuerId: apiKey.issuerId
2051
+ });
2052
+ (0, _hot_updater_cli_tools.registerSecretValue)(token);
2053
+ const headers = { Authorization: `Bearer ${token}` };
2054
+ const idsBySerial = /* @__PURE__ */ new Map();
2055
+ let next = `${API_URL}/v1/certificates?${new URLSearchParams({
2056
+ "fields[certificates]": "serialNumber",
2057
+ limit: "200"
2058
+ })}`;
2059
+ while (next) {
2060
+ const response = await fetch(next, { headers });
2061
+ if (!response.ok) throw new Error(`Listing the team's certificates failed (HTTP ${response.status}).`);
2062
+ const body = await response.json();
2063
+ for (const { id, attributes } of body.data ?? []) if (attributes?.serialNumber) idsBySerial.set(normalizeSerial(attributes.serialNumber), id);
2064
+ next = body.links?.next;
886
2065
  }
887
- return matchedDevice;
2066
+ for (const certificate of certificates) {
2067
+ const id = idsBySerial.get(certificate.serialNumber);
2068
+ if (id === void 0) continue;
2069
+ const response = await fetch(`${API_URL}/v1/certificates/${id}`, {
2070
+ method: "DELETE",
2071
+ headers
2072
+ });
2073
+ if (response.ok || response.status === 404) revoked.push(describe(certificate));
2074
+ else failed.push(`${describe(certificate)}: HTTP ${response.status}`);
2075
+ }
2076
+ } catch (error) {
2077
+ const reason = error instanceof Error ? error.message : String(error);
2078
+ for (const certificate of certificates) if (!revoked.includes(describe(certificate))) failed.push(`${describe(certificate)}: ${reason}`);
888
2079
  }
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);
2080
+ 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(", ")}.`);
2081
+ 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.`);
2082
+ return {
2083
+ revoked,
2084
+ failed
2085
+ };
2086
+ };
2087
+ const TERMINATION_SIGNALS = [
2088
+ "SIGHUP",
2089
+ "SIGINT",
2090
+ "SIGTERM"
2091
+ ];
2092
+ /**
2093
+ * Runs `run`, the archive and export of a build that signs with an App
2094
+ * Store Connect API key, in a keychain of its own when `enabled` (in CI),
2095
+ * then revokes the certificates Xcode created in it and deletes it. A
2096
+ * termination signal does the same before the process ends with it: the
2097
+ * certificates are read from the keychain before it is deleted, and the key
2098
+ * is read into memory first, since the build's temporary key file is deleted
2099
+ * on the signal. A second signal restores the keychains at once.
2100
+ */
2101
+ const withSigningKeychain = async ({ enabled, apiKey, run, security, lockPath, acquireLock, fetch }) => {
2102
+ if (!enabled) return run();
2103
+ const privateKey = crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8"));
2104
+ const keychain = await createSigningKeychain({
2105
+ security,
2106
+ lockPath,
2107
+ acquireLock
2108
+ });
2109
+ _hot_updater_cli_tools.p.log.info("Signing in a keychain of this build's own, deleted when it ends (CI is set).");
2110
+ let cleanup;
2111
+ const release = () => {
2112
+ cleanup ??= (async () => {
2113
+ const certificates = await keychain.certificates().catch(() => []);
2114
+ await keychain.dispose();
2115
+ await revokeKeychainCertificates({
2116
+ certificates,
2117
+ apiKey,
2118
+ privateKey,
2119
+ fetch
2120
+ });
2121
+ })();
2122
+ return cleanup;
2123
+ };
2124
+ const stopListening = () => {
2125
+ for (const signal of TERMINATION_SIGNALS) process.off(signal, onSignal);
2126
+ };
2127
+ const onSignal = (signal) => {
2128
+ if (cleanup) {
2129
+ stopListening();
2130
+ restoreLive();
2131
+ process.kill(process.pid, signal);
2132
+ return;
893
2133
  }
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
- }))
2134
+ release().finally(() => {
2135
+ stopListening();
2136
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
901
2137
  });
902
- if (_hot_updater_cli_tools.p.isCancel(device)) process.exit(1);
903
- return device;
2138
+ };
2139
+ for (const signal of TERMINATION_SIGNALS) process.on(signal, onSignal);
2140
+ try {
2141
+ return await run();
2142
+ } finally {
2143
+ await release();
2144
+ stopListening();
904
2145
  }
905
- const bootedDevices = await listDevices(platform, { state: "Booted" });
906
- if (bootedDevices.length > 0) return bootedDevices[0];
907
2146
  };
908
- const Device = {
909
- listDevices,
910
- selectTargetDevice
2147
+ //#endregion
2148
+ //#region src/utils/readBuiltApp.ts
2149
+ const readInfoValue = async (infoPlist, key) => {
2150
+ const { stdout, exitCode } = await (0, execa.execa)("plutil", [
2151
+ "-extract",
2152
+ key,
2153
+ "raw",
2154
+ "-o",
2155
+ "-",
2156
+ infoPlist
2157
+ ], { reject: false });
2158
+ return exitCode === 0 && stdout.trim() ? stdout.trim() : null;
2159
+ };
2160
+ /**
2161
+ * What the built `.app` reports at runtime, from its Info.plist: the
2162
+ * `CFBundleShortVersionString`, `CFBundleVersion` (the build number) and
2163
+ * `HOT_UPDATER_CHANNEL` the build settings,
2164
+ * an `xcconfig` and `extraParams` expanded.
2165
+ */
2166
+ const readBuiltApp = async (appPath) => {
2167
+ const infoPlist = path.default.join(appPath, "Info.plist");
2168
+ const [appVersion, channel, buildNumber] = await Promise.all([
2169
+ readInfoValue(infoPlist, "CFBundleShortVersionString"),
2170
+ readInfoValue(infoPlist, "HOT_UPDATER_CHANNEL"),
2171
+ readInfoValue(infoPlist, "CFBundleVersion")
2172
+ ]);
2173
+ return {
2174
+ appVersion,
2175
+ channel,
2176
+ buildNumber
2177
+ };
911
2178
  };
912
2179
  //#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.");
926
- process.exit(1);
927
- }
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,
2180
+ //#region src/buildIos.ts
2181
+ /**
2182
+ * Builds a simulator `.app` for a `simulator` scheme, and otherwise archives
2183
+ * and exports an `.ipa`, or returns the `.xcarchive` of a scheme it cannot
2184
+ * export: one with neither `exportOptionsPlist` nor `signing.ascApiKey`.
2185
+ *
2186
+ * The artifact is in a temporary `buildDirectory`, which `dispose` removes
2187
+ * once the caller has copied it out. A failed build removes it, and every
2188
+ * other directory the build used, itself.
2189
+ */
2190
+ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials }) => {
2191
+ await assertXcodebuildExist();
2192
+ if (schemeConfig.simulator && schemeConfig.destination.length) {
2193
+ _hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
2194
+ process.exit(1);
2195
+ }
2196
+ const buildDirectory = await createRandomTmpDir();
2197
+ try {
2198
+ return {
2199
+ ...await buildInto({
2200
+ schemeConfig,
2201
+ minBundleId,
2202
+ credentials,
2203
+ buildDirectory
2204
+ }),
2205
+ dispose: () => removeTmpDir(buildDirectory)
2206
+ };
2207
+ } catch (error) {
2208
+ await removeTmpDir(buildDirectory);
2209
+ throw error;
2210
+ }
2211
+ };
2212
+ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirectory }) => {
2213
+ const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2214
+ if (schemeConfig.simulator) {
2215
+ const appDir = path.default.join(buildDirectory, "app");
2216
+ await fs.default.promises.mkdir(appDir, { recursive: true });
2217
+ const { appPath } = await buildXcodeProject({
2218
+ sourceDir: iosProjectRoot,
2219
+ outputDirectory: appDir,
2220
+ logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
933
2221
  platform: schemeConfig.platform,
934
2222
  xcodeScheme: schemeConfig.scheme,
935
2223
  configuration: schemeConfig.configuration,
936
- deviceType: selectedDevice.type,
937
- destination: [{ id: selectedDevice.udid }],
2224
+ deviceType: "simulator",
938
2225
  installPods: schemeConfig.installPods,
939
2226
  extraParams: schemeConfig.extraParams,
940
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-run`
2227
+ useGenericDestination: true,
2228
+ minBundleId
941
2229
  });
942
- const runnerOptions = {
943
- bundleIdentifier: schemeConfig.bundleIdentifier,
944
- sourceDir: iosProjectPath,
945
- launch: true,
946
- infoPlistPath: result.infoPlistPath
2230
+ _hot_updater_cli_tools.p.log.success(`.app file created: ${path.default.basename(appPath)}`);
2231
+ return {
2232
+ buildDirectory,
2233
+ buildArtifactPath: appPath,
2234
+ builtApp: await readBuiltApp(appPath)
947
2235
  };
948
- await installAndLaunchOnSimulator({
949
- device: selectedDevice,
950
- appPath: result.appPath,
951
- options: runnerOptions
2236
+ } else {
2237
+ const signing = await resolveXcodeSigning({
2238
+ schemeConfig,
2239
+ credentials
2240
+ });
2241
+ if (!signing) return archiveAndExport({
2242
+ schemeConfig,
2243
+ minBundleId,
2244
+ buildDirectory
2245
+ });
2246
+ const appStoreConnect = await exportsForAppStoreConnect(schemeConfig);
2247
+ 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.");
2248
+ return withSigningKeychain({
2249
+ enabled: (0, _hot_updater_cli_tools.isCI)() && appStoreConnect,
2250
+ apiKey: signing.apiKey,
2251
+ run: () => archiveAndExport({
2252
+ schemeConfig,
2253
+ minBundleId,
2254
+ buildDirectory,
2255
+ authenticationArgs: signing.authenticationArgs
2256
+ })
952
2257
  });
953
- _hot_updater_cli_tools.p.outro("Success 🎉");
954
- return result;
955
2258
  }
956
- _hot_updater_cli_tools.p.log.info(`Building for ${device.name}...`);
957
- const result = await buildXcodeProject({
958
- sourceDir: iosProjectPath,
2259
+ };
2260
+ /**
2261
+ * Archives a device build and exports its `.ipa`, or returns the
2262
+ * `.xcarchive` of a scheme it cannot export.
2263
+ */
2264
+ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, authenticationArgs }) => {
2265
+ const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2266
+ const archiveDir = path.default.join(buildDirectory, "archive");
2267
+ const exportDir = path.default.join(buildDirectory, "export");
2268
+ await fs.default.promises.mkdir(archiveDir, { recursive: true });
2269
+ const { archivePath } = await archiveXcodeProject({
2270
+ archiveDirectory: archiveDir,
2271
+ authenticationArgs,
2272
+ minBundleId,
959
2273
  platform: schemeConfig.platform,
2274
+ sourceDir: iosProjectRoot,
2275
+ xcconfig: schemeConfig.xcconfig,
960
2276
  xcodeScheme: schemeConfig.scheme,
2277
+ destination: schemeConfig.destination,
2278
+ extraParams: schemeConfig.extraParams,
961
2279
  configuration: schemeConfig.configuration,
962
- deviceType: device.type,
963
- destination: [{ id: device.udid }],
964
2280
  installPods: schemeConfig.installPods,
965
- extraParams: schemeConfig.extraParams,
966
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-run`
967
- });
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
- }
2281
+ logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-archive`
980
2282
  });
981
- else await installAndLaunchOnDevice({
982
- device,
983
- appPath: result.appPath,
984
- options: runnerOptions
2283
+ const appFromArchive = extractAppFromXcarchive(archivePath);
2284
+ const archivedAppPath = appFromArchive ? path.default.join(archiveDir, path.default.basename(appFromArchive)) : void 0;
2285
+ if (appFromArchive && archivedAppPath) {
2286
+ await fs.default.promises.cp(appFromArchive, archivedAppPath, { recursive: true });
2287
+ _hot_updater_cli_tools.p.log.success(".app extracted from .xcarchive");
2288
+ }
2289
+ if (!schemeConfig.exportOptionsPlist && !authenticationArgs) return {
2290
+ buildDirectory,
2291
+ buildArtifactPath: archivePath,
2292
+ builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
2293
+ };
2294
+ await fs.default.promises.mkdir(exportDir, { recursive: true });
2295
+ const exportArchive = (exportOptionsPlist) => exportXcodeArchive({
2296
+ archivePath,
2297
+ exportPath: exportDir,
2298
+ authenticationArgs,
2299
+ exportExtraParams: schemeConfig.exportExtraParams,
2300
+ exportOptionsPlist,
2301
+ sourceDir: iosProjectRoot,
2302
+ logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-export`
985
2303
  });
986
- _hot_updater_cli_tools.p.outro("Success 🎉");
987
- return result;
2304
+ if (schemeConfig.exportOptionsPlist) await exportArchive(schemeConfig.exportOptionsPlist);
2305
+ else {
2306
+ const exportOptionsDir = await createRandomTmpDir();
2307
+ try {
2308
+ await exportArchive(await writeAppStoreConnectExportOptions(exportOptionsDir));
2309
+ } finally {
2310
+ await removeTmpDir(exportOptionsDir);
2311
+ }
2312
+ }
2313
+ const ipaFile = fs.default.readdirSync(exportDir).find((file) => file.endsWith(".ipa"));
2314
+ if (!ipaFile) throw new Error("IPA file not found after export");
2315
+ return {
2316
+ buildDirectory,
2317
+ buildArtifactPath: path.default.join(exportDir, ipaFile),
2318
+ builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
2319
+ };
2320
+ };
2321
+ const extractAppFromXcarchive = (archivePath) => {
2322
+ const productsPath = path.default.join(archivePath, "Products", "Applications");
2323
+ if (!fs.default.existsSync(productsPath)) return;
2324
+ const appFile = fs.default.readdirSync(productsPath).find((file) => file.endsWith(".app"));
2325
+ if (appFile) return path.default.join(productsPath, appFile);
2326
+ _hot_updater_cli_tools.p.log.warn("Failed to extract .app from xcarchive");
988
2327
  };
989
2328
  //#endregion
990
2329
  //#region src/utils/enrichNativeBuildIosScheme.ts
@@ -1009,6 +2348,71 @@ const enrichNativeBuildIosScheme = async ({ scheme, hotUpdaterSchemeName }) => {
1009
2348
  };
1010
2349
  };
1011
2350
  //#endregion
2351
+ //#region src/utils/extractBuiltInBundle.ts
2352
+ /** The file React Native's Xcode build phase writes into the `.app`. */
2353
+ const BUILT_IN_BUNDLE = "main.jsbundle";
2354
+ /**
2355
+ * Copies the JavaScript bundle React Native's Xcode build phase wrote into a
2356
+ * `.app` or an `.ipa` (`Payload/<App>.app/main.jsbundle`) to `outputPath`:
2357
+ * the built-in bundle, byte for byte, which no rebuild reproduces.
2358
+ */
2359
+ const extractBuiltInBundle = async ({ artifactPath, outputPath }) => {
2360
+ await fs.default.promises.mkdir(path.default.dirname(outputPath), { recursive: true });
2361
+ if (path.default.extname(artifactPath) === ".app") {
2362
+ const source = path.default.join(artifactPath, BUILT_IN_BUNDLE);
2363
+ 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.`);
2364
+ await fs.default.promises.copyFile(source, outputPath);
2365
+ return;
2366
+ }
2367
+ const { stdout } = await (0, execa.execa)("unzip", ["-Z1", artifactPath]);
2368
+ const entry = stdout.split("\n").find((name) => /^Payload\/[^/]+\.app\/main\.jsbundle$/u.test(name.trim()))?.trim();
2369
+ if (entry === void 0) throw new Error(`${path.default.basename(artifactPath)} has no Payload/<App>.app/${BUILT_IN_BUNDLE}.`);
2370
+ await (0, execa.execa)("unzip", [
2371
+ "-p",
2372
+ artifactPath,
2373
+ escapeUnzipPattern(entry)
2374
+ ], { stdout: { file: outputPath } });
2375
+ };
2376
+ /**
2377
+ * Unpacks a `.app` that `archiveAppBundle` stored as one zip file into
2378
+ * `outputPath`, which then holds the `.app` directory.
2379
+ */
2380
+ const extractAppBundle = async ({ archivePath, outputPath }) => {
2381
+ await fs.default.promises.mkdir(outputPath, { recursive: true });
2382
+ await (0, execa.execa)("ditto", [
2383
+ "-x",
2384
+ "-k",
2385
+ archivePath,
2386
+ outputPath
2387
+ ]);
2388
+ };
2389
+ /**
2390
+ * A `.app` directory as one zip file, the way Finder and Xcode archive it,
2391
+ * so a simulator build uploads and downloads as a single artifact.
2392
+ */
2393
+ const archiveAppBundle = async ({ appPath, outputPath }) => {
2394
+ await fs.default.promises.mkdir(path.default.dirname(outputPath), { recursive: true });
2395
+ await fs.default.promises.rm(outputPath, { force: true });
2396
+ await (0, execa.execa)("ditto", [
2397
+ "-c",
2398
+ "-k",
2399
+ "--sequesterRsrc",
2400
+ "--keepParent",
2401
+ appPath,
2402
+ outputPath
2403
+ ]);
2404
+ };
2405
+ //#endregion
2406
+ exports.ASC_INTEGRATIONS_URL = ASC_INTEGRATIONS_URL;
2407
+ exports.TestFlightBuildExistsError = TestFlightBuildExistsError;
2408
+ exports.archiveAppBundle = archiveAppBundle;
2409
+ exports.assertTestFlightExportOptions = assertTestFlightExportOptions;
1012
2410
  exports.buildIos = buildIos;
1013
2411
  exports.enrichNativeBuildIosScheme = enrichNativeBuildIosScheme;
1014
- exports.runIos = runIos;
2412
+ exports.extractAppBundle = extractAppBundle;
2413
+ exports.extractBuiltInBundle = extractBuiltInBundle;
2414
+ exports.findTestFlightBuild = findTestFlightBuild;
2415
+ exports.installPodsIfNeeded = installPodsIfNeeded;
2416
+ exports.issueAscApiKey = issueAscApiKey;
2417
+ exports.resolveTestFlightUploadKey = resolveTestFlightUploadKey;
2418
+ exports.uploadToTestFlight = uploadToTestFlight;