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

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
@@ -23,209 +23,572 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  //#endregion
24
24
  let node_crypto = require("node:crypto");
25
25
  node_crypto = __toESM(node_crypto, 1);
26
- let node_fs = require("node:fs");
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);
31
- let node_os = require("node:os");
32
- node_os = __toESM(node_os, 1);
33
- let node_path = require("node:path");
34
- node_path = __toESM(node_path, 1);
26
+ let node_timers_promises = require("node:timers/promises");
35
27
  let _hot_updater_cli_tools = require("@hot-updater/cli-tools");
36
- let execa = require("execa");
37
28
  let fs = require("fs");
38
29
  fs = __toESM(fs, 1);
39
30
  let path = require("path");
40
31
  path = __toESM(path, 1);
32
+ let execa = require("execa");
33
+ let node_fs = require("node:fs");
34
+ let node_fs_promises = require("node:fs/promises");
35
+ let node_os = require("node:os");
36
+ let node_path = require("node:path");
41
37
  let child_process = require("child_process");
42
38
  let crypto = require("crypto");
43
39
  crypto = __toESM(crypto, 1);
44
40
  let os = require("os");
45
41
  os = __toESM(os, 1);
46
- //#region src/asc/ascCli.ts
42
+ //#region src/asc/appleSrp.ts
43
+ /**
44
+ * Apple's sign-in proof: SRP-6a over the RFC 5054 2048-bit group with
45
+ * SHA-256, the password stretched with PBKDF2, as Apple's sign-in page and
46
+ * asc, the open-source App Store Connect CLI
47
+ * (https://github.com/rorkai/App-Store-Connect-CLI, MIT), compute it.
48
+ */
49
+ /** RFC 5054, Appendix A, the 2048-bit group. */
50
+ const N = BigInt("0xAC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B855F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773BCA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73");
51
+ const G = 2n;
52
+ /** The hex length every padded value has: N's byte length, twice. */
53
+ const PADDED_HEX_LENGTH = 512;
54
+ const modPow = (base, exponent, modulus) => {
55
+ let result = 1n;
56
+ let b = (base % modulus + modulus) % modulus;
57
+ let e = exponent;
58
+ while (e > 0n) {
59
+ if (e & 1n) result = result * b % modulus;
60
+ b = b * b % modulus;
61
+ e >>= 1n;
62
+ }
63
+ return result;
64
+ };
65
+ /** An even-length lowercase hex of `value`, as the proof hashes it. */
66
+ const toHex = (value) => {
67
+ const hex = value.toString(16);
68
+ return hex.length % 2 === 1 ? `0${hex}` : hex;
69
+ };
70
+ const fromBytes = (bytes) => bytes.length === 0 ? 0n : BigInt(`0x${Buffer.from(bytes).toString("hex")}`);
71
+ const sha256Hex = (hex) => node_crypto.default.createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex");
72
+ /** SHA-256 of each value left-padded to N's length, as a number mod N. */
73
+ const paddedHash = (...values) => BigInt(`0x${sha256Hex(values.map((value) => value.padStart(PADDED_HEX_LENGTH, "0")).join(""))}`) % N;
74
+ /** The password as the protocol Apple named feeds it to PBKDF2. */
75
+ const preparePassword = (password, protocol) => {
76
+ const digest = node_crypto.default.createHash("sha256").update(password).digest();
77
+ if (protocol === "s2k") return digest;
78
+ if (protocol === "s2k_fo") return Buffer.from(digest.toString("hex"));
79
+ throw new Error(`Apple asked for the sign-in protocol ${protocol}.`);
80
+ };
81
+ /** A new SRP client; `secret` is the private value a, random unless set. */
82
+ const createSrpClient = (secret = fromBytes(node_crypto.default.randomBytes(256))) => {
83
+ const A = modPow(G, secret, N);
84
+ const aHex = toHex(A);
85
+ return {
86
+ a: Buffer.from(aHex, "hex").toString("base64"),
87
+ proof: ({ accountName, password, protocol, iterations, salt, b }) => {
88
+ const saltBytes = Buffer.from(salt, "base64");
89
+ const saltHex = saltBytes.toString("hex");
90
+ const bHex = Buffer.from(b, "base64").toString("hex");
91
+ const B = BigInt(`0x${bHex || "0"}`);
92
+ if (B % N === 0n) throw new Error("Apple answered an invalid SRP value.");
93
+ const derived = node_crypto.default.pbkdf2Sync(preparePassword(password, protocol), saltBytes, iterations, 32, "sha256");
94
+ const x = BigInt(`0x${sha256Hex(saltHex + sha256Hex(`3a${derived.toString("hex")}`))}`);
95
+ const k = paddedHash(toHex(N), toHex(G));
96
+ const u = paddedHash(aHex, bHex);
97
+ if (u === 0n) throw new Error("Apple answered an invalid SRP value.");
98
+ const S = modPow(B - k * modPow(G, x, N) % N, secret + u * x, N);
99
+ const K = sha256Hex(toHex(S));
100
+ const m1 = sha256Hex(toHex(paddedHash(toHex(N)) ^ paddedHash(toHex(G))) + node_crypto.default.createHash("sha256").update(accountName).digest("hex") + saltHex + aHex + bHex + K);
101
+ const m2 = toHex(BigInt(`0x${sha256Hex(aHex + m1 + K)}`));
102
+ return {
103
+ m1: Buffer.from(m1, "hex").toString("base64"),
104
+ m2: Buffer.from(m2, "hex").toString("base64")
105
+ };
106
+ }
107
+ };
108
+ };
109
+ const hasLeadingZeroBits = (digest, bits) => {
110
+ const full = Math.floor(bits / 8);
111
+ for (let index = 0; index < full; index++) if (digest[index] !== 0) return false;
112
+ const rest = bits % 8;
113
+ return rest === 0 || (digest[full] & 255 << 8 - rest & 255) === 0;
114
+ };
115
+ /**
116
+ * The hashcash stamp Apple's sign-in asks for in `X-Apple-HC`: the first
117
+ * `1:<bits>:<date>:<challenge>::<counter>` whose SHA-1 starts with `bits`
118
+ * zero bits.
119
+ */
120
+ const makeHashcash = (bits, challenge, now = /* @__PURE__ */ new Date()) => {
121
+ const date = now.toISOString().replace(/\.\d+Z$/u, "").replaceAll(/[-:T]/gu, "");
122
+ for (let counter = 0;; counter++) {
123
+ const stamp = `1:${bits}:${date}:${challenge}::${counter}`;
124
+ const digest = node_crypto.default.createHash("sha1").update(stamp).digest();
125
+ if (hasLeadingZeroBits(digest, bits)) return stamp;
126
+ }
127
+ };
128
+ //#endregion
129
+ //#region src/asc/appleSignIn.ts
47
130
  /**
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.
131
+ * Signing in to App Store Connect with an Apple Account, as its web page
132
+ * does: Apple's SRP sign-in at idmsa.apple.com, its two-factor check, then
133
+ * App Store Connect's session. The endpoints and their headers follow asc,
134
+ * the open-source App Store Connect CLI
135
+ * (https://github.com/rorkai/App-Store-Connect-CLI, MIT), 5.16.1, whose
136
+ * internal/web/auth.go has signed in this way against Apple.
52
137
  */
53
- const ASC_CLI_VERSION = "5.16.1";
138
+ const AUTH_URL = "https://idmsa.apple.com/appleauth/auth";
139
+ const ASC_URL = "https://appstoreconnect.apple.com";
140
+ const SESSION_URL = `${ASC_URL}/olympus/v1/session`;
54
141
  /**
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.
142
+ * The public widget key App Store Connect's sign-in page ships, used when
143
+ * neither place Apple publishes the current one answers. Not a credential.
58
144
  */
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 {
145
+ const ASC_WIDGET_KEY = "e0b80c3bf78523bfe80974d320935bfa30add02e1bff88ec2166c6bd5a706c42";
146
+ /** Signing in, or a request in the signed-in session, failed. */
147
+ var AppleSignInError = class extends Error {
148
+ status;
149
+ /** The response body, for messages that depend on what Apple said. */
150
+ body;
71
151
  constructor(message, options) {
72
- super(message, options);
73
- this.name = "AscCliUnavailableError";
152
+ super(message, options?.cause ? { cause: options.cause } : void 0);
153
+ this.name = "AppleSignInError";
154
+ this.status = options?.status;
155
+ this.body = options?.body;
74
156
  }
75
157
  };
76
- const parseVersion = (output) => {
77
- const match = /(\d+)\.(\d+)\.(\d+)/u.exec(output);
78
- return match ? match.slice(1, 4).map(Number) : void 0;
158
+ /**
159
+ * A cookie jar for the sign-in's requests, as a browser keeps one: `fetch`
160
+ * keeps none, and Apple's session is its cookies.
161
+ */
162
+ const createCookieJar = () => {
163
+ let cookies = [];
164
+ const live = () => {
165
+ const now = Date.now();
166
+ cookies = cookies.filter((cookie) => cookie.expires === void 0 || cookie.expires > now);
167
+ return cookies;
168
+ };
169
+ return {
170
+ store(url, response) {
171
+ for (const header of response.headers.getSetCookie()) {
172
+ const [pair = "", ...attributes] = header.split(";");
173
+ const separator = pair.indexOf("=");
174
+ if (separator <= 0) continue;
175
+ const name = pair.slice(0, separator).trim();
176
+ const value = pair.slice(separator + 1).trim();
177
+ let domain = url.hostname;
178
+ let hostOnly = true;
179
+ let path = "/";
180
+ let expires;
181
+ let maxAge;
182
+ for (const attribute of attributes) {
183
+ const index = attribute.indexOf("=");
184
+ const key = (index === -1 ? attribute : attribute.slice(0, index)).trim().toLowerCase();
185
+ const raw = index === -1 ? "" : attribute.slice(index + 1).trim();
186
+ if (key === "domain" && raw) {
187
+ const candidate = raw.replace(/^\./u, "").toLowerCase();
188
+ if (url.hostname !== candidate && !url.hostname.endsWith(`.${candidate}`)) {
189
+ domain = "";
190
+ break;
191
+ }
192
+ domain = candidate;
193
+ hostOnly = false;
194
+ } else if (key === "path" && raw.startsWith("/")) path = raw;
195
+ else if (key === "expires") {
196
+ const time = Date.parse(raw);
197
+ if (!Number.isNaN(time)) expires = time;
198
+ } else if (key === "max-age" && /^-?\d+$/u.test(raw)) maxAge = Number(raw);
199
+ }
200
+ if (!domain) continue;
201
+ if (maxAge !== void 0) expires = Date.now() + maxAge * 1e3;
202
+ cookies = cookies.filter((cookie) => !(cookie.name === name && cookie.domain === domain && cookie.path === path));
203
+ if (expires === void 0 || expires > Date.now()) cookies.push({
204
+ name,
205
+ value,
206
+ domain,
207
+ hostOnly,
208
+ path,
209
+ expires
210
+ });
211
+ }
212
+ },
213
+ header(url) {
214
+ const matching = live().filter((cookie) => (cookie.hostOnly ? url.hostname === cookie.domain : url.hostname === cookie.domain || url.hostname.endsWith(`.${cookie.domain}`)) && (url.pathname === cookie.path || url.pathname.startsWith(cookie.path.endsWith("/") ? cookie.path : `${cookie.path}/`)));
215
+ return matching.length === 0 ? void 0 : matching.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
216
+ },
217
+ clear() {
218
+ cookies = [];
219
+ }
220
+ };
221
+ };
222
+ const REDIRECTS = /* @__PURE__ */ new Set([
223
+ 301,
224
+ 302,
225
+ 303,
226
+ 307,
227
+ 308
228
+ ]);
229
+ /** `fetch` with the jar's cookies, following redirects itself to keep them. */
230
+ const createClient = (fetch) => {
231
+ const jar = createCookieJar();
232
+ const request = async (url, init = {}) => {
233
+ let current = new URL(url);
234
+ let method = init.method ?? "GET";
235
+ let body = init.body;
236
+ for (let hop = 0; hop < 10; hop++) {
237
+ const headers = new Headers(init.headers);
238
+ const cookie = jar.header(current);
239
+ if (cookie) headers.set("Cookie", cookie);
240
+ const response = await fetch(current, {
241
+ method,
242
+ headers,
243
+ ...body === void 0 ? {} : { body },
244
+ redirect: "manual"
245
+ });
246
+ jar.store(current, response);
247
+ const location = response.headers.get("location");
248
+ if (!REDIRECTS.has(response.status) || !location) return response;
249
+ await response.text();
250
+ current = new URL(location, current);
251
+ if (response.status === 303 || response.status !== 307 && response.status !== 308 && method === "POST") {
252
+ method = "GET";
253
+ body = void 0;
254
+ }
255
+ }
256
+ throw new AppleSignInError(`${url} redirected too many times.`);
257
+ };
258
+ return {
259
+ request,
260
+ clear: jar.clear
261
+ };
79
262
  };
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];
263
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
264
+ const text$1 = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
265
+ const parseJson = (body) => {
266
+ try {
267
+ return JSON.parse(body);
268
+ } catch {
269
+ return;
84
270
  }
85
- return true;
86
271
  };
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")));
272
+ /** What Apple's sign-in said went wrong, from its `serviceErrors`. */
273
+ const serviceErrors = (body) => {
274
+ const parsed = parseJson(body);
275
+ const errors = isRecord$1(parsed) && Array.isArray(parsed.serviceErrors) ? parsed.serviceErrors.filter(isRecord$1) : [];
276
+ return {
277
+ codes: errors.map((error) => text$1(error.code)).filter(Boolean),
278
+ message: errors.map((error) => text$1(error.message)).find(Boolean)
279
+ };
280
+ };
281
+ const failure = (step, response, body) => new AppleSignInError(`${step} failed (HTTP ${response.status}).${serviceErrors(body).message ? `\n${serviceErrors(body).message}` : ""}`, {
282
+ status: response.status,
283
+ body
90
284
  });
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
285
  /**
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.
286
+ * The widget key Apple's sign-in wants: from the sign-out redirect App Store
287
+ * Connect answers without cookies, else its config, else the one its page
288
+ * ships.
98
289
  */
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
290
+ const findWidgetKey = async (fetch, client) => {
291
+ const signOut = await fetch(`${ASC_URL}/logout`, {
292
+ method: "HEAD",
293
+ redirect: "manual"
106
294
  }).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"
295
+ const location = signOut?.headers.get("location");
296
+ if (signOut && REDIRECTS.has(signOut.status) && location) {
297
+ const url = (() => {
298
+ try {
299
+ return new URL(location);
300
+ } catch {
301
+ return;
302
+ }
303
+ })();
304
+ const keys = url?.searchParams.getAll("widgetKey") ?? [];
305
+ if (url?.protocol === "https:" && url.hostname === "idmsa.apple.com" && url.pathname === "/appleauth/signout" && keys.length === 1 && keys[0].trim()) return keys[0].trim();
306
+ }
307
+ const config = await client.request(`${ASC_URL}/olympus/v1/app/config?hostname=itunesconnect.apple.com`);
308
+ const body = await config.text();
309
+ if (config.status === 404) return ASC_WIDGET_KEY;
310
+ const parsed = parseJson(body);
311
+ const key = isRecord$1(parsed) ? text$1(parsed.authServiceKey) ?? text$1(parsed.serviceKey) : void 0;
312
+ if (!config.ok || !key) throw new AppleSignInError(`Couldn't load Apple's sign-in configuration (HTTP ${config.status}).`, { status: config.status });
313
+ return key;
314
+ };
315
+ const hashcash = async (client, widgetKey) => {
316
+ const response = await client.request(`${AUTH_URL}/signin?widgetKey=${encodeURIComponent(widgetKey)}`, { headers: { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" } });
317
+ await response.text();
318
+ if (!response.ok) throw new AppleSignInError(`Loading Apple's sign-in page failed (HTTP ${response.status}).`, { status: response.status });
319
+ const bits = response.headers.get("x-apple-hc-bits")?.trim();
320
+ const challenge = response.headers.get("x-apple-hc-challenge")?.trim();
321
+ if (!bits || !challenge) return void 0;
322
+ if (!/^\d+$/u.test(bits) || Number(bits) > 32) throw new AppleSignInError(`Apple asked for a hashcash of ${bits} bits.`);
323
+ return makeHashcash(Number(bits), challenge);
324
+ };
325
+ const pendingHeaders = (pending) => ({
326
+ "X-Apple-ID-Session-Id": pending.sessionId,
327
+ "X-Apple-Widget-Key": pending.widgetKey,
328
+ scnt: pending.scnt,
329
+ Accept: "application/json"
330
+ });
331
+ /** Apple rotates `scnt` and the session ID; each request sends the latest. */
332
+ const track = (pending, response) => {
333
+ const scnt = response.headers.get("scnt")?.trim();
334
+ const sessionId = response.headers.get("x-apple-id-session-id")?.trim();
335
+ if (scnt) pending.scnt = scnt;
336
+ if (sessionId) pending.sessionId = sessionId;
337
+ };
338
+ const CANCELLED = "Signing in was cancelled.";
339
+ const twoFactor = async (client, pending, prompts) => {
340
+ const send = async (url, method, payload) => {
341
+ const response = await client.request(url, {
342
+ method,
343
+ headers: {
344
+ ...pendingHeaders(pending),
345
+ ...payload === void 0 ? {} : { "Content-Type": "application/json" }
346
+ },
347
+ ...payload === void 0 ? {} : { body: JSON.stringify(payload) }
348
+ });
349
+ track(pending, response);
350
+ return {
351
+ response,
352
+ body: await response.text()
353
+ };
112
354
  };
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"
355
+ const options = await send(AUTH_URL, "GET");
356
+ if (!options.response.ok) throw failure("Loading the two-factor options", options.response, options.body);
357
+ const parsed = parseJson(options.body);
358
+ const phone = (isRecord$1(parsed) && Array.isArray(parsed.trustedPhoneNumbers) ? parsed.trustedPhoneNumbers.filter(isRecord$1) : []).filter((phone) => typeof phone.id === "number")[0];
359
+ const noDevices = isRecord$1(parsed) && parsed.noTrustedDevices === true;
360
+ if (noDevices && !phone) throw new AppleSignInError("The Apple Account has no trusted device or phone number for two-factor codes.");
361
+ const phoneMode = text$1(phone?.pushMode) ?? "sms";
362
+ const destination = text$1(phone?.numberWithDialCode);
363
+ const securityCode = isRecord$1(parsed) && isRecord$1(parsed.securityCode) ? parsed.securityCode.length : void 0;
364
+ const length = typeof securityCode === "number" && securityCode > 0 ? securityCode : 6;
365
+ const textCode = async () => {
366
+ const requested = await send(`${AUTH_URL}/verify/phone`, "PUT", {
367
+ phoneNumber: { id: phone.id },
368
+ mode: phoneMode
369
+ });
370
+ if (!requested.response.ok) throw failure("Texting a two-factor code", requested.response, requested.body);
121
371
  };
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 });
372
+ const verify = async (via, code) => {
373
+ const verified = via === "device" ? await send(`${AUTH_URL}/verify/trusteddevice/securitycode`, "POST", { securityCode: { code } }) : await send(`${AUTH_URL}/verify/phone/securitycode`, "POST", {
374
+ securityCode: { code },
375
+ phoneNumber: { id: phone.id },
376
+ mode: phoneMode
377
+ });
378
+ if (verified.response.ok) return true;
379
+ if ([
380
+ 400,
381
+ 401,
382
+ 403
383
+ ].includes(verified.response.status)) return false;
384
+ throw failure("Checking the two-factor code", verified.response, verified.body);
385
+ };
386
+ const ask = async (via, afterWrongCode) => {
387
+ const code = await prompts.twoFactorCode({
388
+ via,
389
+ destination,
390
+ textsOnWrongCode: via === "device" && phone !== void 0,
391
+ afterWrongCode,
392
+ length
393
+ });
394
+ if (code === void 0) throw new AppleSignInError(CANCELLED);
395
+ return code;
396
+ };
397
+ let accepted;
398
+ if (noDevices) {
399
+ await textCode();
400
+ accepted = await verify("sms", await ask("sms", false));
401
+ } else {
402
+ accepted = await verify("device", await ask("device", false));
403
+ if (!accepted && phone) {
404
+ await textCode();
405
+ accepted = await verify("sms", await ask("sms", true));
130
406
  }
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
407
  }
408
+ if (!accepted) throw new AppleSignInError("Apple rejected the verification code.");
409
+ const trust = await send(`${AUTH_URL}/2sv/trust`, "GET");
410
+ if (!trust.response.ok) throw failure("Finishing the two-factor check", trust.response, trust.body);
411
+ };
412
+ const parseProvider = (value) => {
413
+ if (!isRecord$1(value) || typeof value.providerId !== "number") return void 0;
414
+ const publicProviderId = text$1(value.publicProviderId);
415
+ if (!publicProviderId) return void 0;
140
416
  return {
141
- path: binary,
142
- version: ASC_CLI_VERSION,
143
- source: "download"
417
+ providerId: value.providerId,
418
+ publicProviderId,
419
+ name: text$1(value.name)
144
420
  };
145
421
  };
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
- }
422
+ const readSession = async (client) => {
423
+ const response = await client.request(SESSION_URL, { headers: { Accept: "application/json" } });
424
+ const body = await response.text();
425
+ if (!response.ok) throw new AppleSignInError(response.status === 401 || response.status === 403 ? "App Store Connect didn't accept the sign-in. Check that the Apple Account is in an Apple Developer team." : `Loading the App Store Connect session failed (HTTP ${response.status}).`, {
426
+ status: response.status,
427
+ body
428
+ });
429
+ const parsed = parseJson(body);
430
+ const provider = isRecord$1(parsed) ? parseProvider(parsed.provider) : void 0;
431
+ if (!isRecord$1(parsed) || !provider) throw new AppleSignInError("App Store Connect's session names no team for the Apple Account.");
432
+ const providers = (Array.isArray(parsed.availableProviders) ? parsed.availableProviders : []).map(parseProvider).filter((value) => value !== void 0);
433
+ return {
434
+ provider,
435
+ providers: providers.some((value) => value.providerId === provider.providerId) ? providers : [provider, ...providers]
436
+ };
156
437
  };
157
- const TERMINATION_SIGNALS$2 = [
158
- "SIGHUP",
159
- "SIGINT",
160
- "SIGTERM"
161
- ];
162
438
  /**
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.
439
+ * Signs in to App Store Connect with `appleId` and `password`, asking
440
+ * `prompts` for the two-factor code. The session lives only in the returned
441
+ * object's memory.
166
442
  */
167
- const createWorkDirectory = async () => {
168
- const root = await node_fs_promises.default.mkdtemp(node_path.default.join(node_os.default.tmpdir(), "hot-updater-asc-"));
169
- await node_fs_promises.default.chmod(root, 448);
170
- const remove = () => (0, node_fs.rmSync)(root, {
171
- force: true,
172
- recursive: true
173
- });
174
- const onSignal = (signal) => {
175
- stop();
176
- remove();
177
- if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
178
- };
179
- const stop = () => {
180
- process.off("exit", remove);
181
- for (const signal of TERMINATION_SIGNALS$2) process.off(signal, onSignal);
182
- };
183
- process.on("exit", remove);
184
- for (const signal of TERMINATION_SIGNALS$2) process.on(signal, onSignal);
185
- for (const directory of [
186
- "home",
187
- "session",
188
- "keys"
189
- ]) await node_fs_promises.default.mkdir(node_path.default.join(root, directory), { mode: 448 });
190
- let disposal;
191
- return {
192
- root,
193
- dispose: () => {
194
- disposal ??= (async () => {
195
- stop();
196
- await node_fs_promises.default.rm(root, {
197
- force: true,
198
- recursive: true
199
- });
443
+ const signInToAppStoreConnect = async ({ appleId, password, prompts, fetch = globalThis.fetch }) => {
444
+ const client = createClient(fetch);
445
+ try {
446
+ const widgetKey = await findWidgetKey(fetch, client);
447
+ const json = {
448
+ "Content-Type": "application/json",
449
+ "X-Apple-Widget-Key": widgetKey,
450
+ "X-Requested-With": "XMLHttpRequest",
451
+ Accept: "application/json, text/javascript"
452
+ };
453
+ const srp = createSrpClient();
454
+ const init = await client.request(`${AUTH_URL}/signin/init`, {
455
+ method: "POST",
456
+ headers: json,
457
+ body: JSON.stringify({
458
+ accountName: appleId,
459
+ protocols: ["s2k", "s2k_fo"],
460
+ a: srp.a
461
+ })
462
+ });
463
+ const initBody = await init.text();
464
+ if (!init.ok) throw failure("Starting the sign-in", init, initBody);
465
+ const started = parseJson(initBody);
466
+ if (!isRecord$1(started) || typeof started.iteration !== "number" || !text$1(started.salt) || !text$1(started.b) || !text$1(started.protocol)) throw new AppleSignInError("Apple's sign-in answered no SRP challenge.");
467
+ const { m1, m2 } = srp.proof({
468
+ accountName: appleId,
469
+ password,
470
+ protocol: text$1(started.protocol),
471
+ iterations: started.iteration,
472
+ salt: text$1(started.salt),
473
+ b: text$1(started.b)
474
+ });
475
+ const stamp = await hashcash(client, widgetKey);
476
+ const complete = await client.request(`${AUTH_URL}/signin/complete?isRememberMeEnabled=false`, {
477
+ method: "POST",
478
+ headers: {
479
+ ...json,
480
+ ...stamp ? { "X-Apple-HC": stamp } : {}
481
+ },
482
+ body: JSON.stringify({
483
+ accountName: appleId,
484
+ rememberMe: false,
485
+ m1,
486
+ m2,
487
+ c: started.c
488
+ })
489
+ });
490
+ const completeBody = await complete.text();
491
+ if (complete.status === 409) {
492
+ const sessionId = complete.headers.get("x-apple-id-session-id")?.trim();
493
+ const scnt = complete.headers.get("scnt")?.trim();
494
+ if (!sessionId || !scnt) throw new AppleSignInError("Apple asked for a two-factor code without a sign-in session.");
495
+ await twoFactor(client, {
496
+ widgetKey,
497
+ sessionId,
498
+ scnt
499
+ }, prompts);
500
+ } else if (!complete.ok) {
501
+ const { codes } = serviceErrors(completeBody);
502
+ const authType = (() => {
503
+ const parsed = parseJson(completeBody);
504
+ return isRecord$1(parsed) ? text$1(parsed.authType) : void 0;
200
505
  })();
201
- return disposal;
506
+ if (complete.status === 401 && codes.includes("-20101")) throw new AppleSignInError("Apple rejected the email or password.", { status: 401 });
507
+ if (complete.status === 412 && authType && [
508
+ "sa",
509
+ "hsa",
510
+ "non-sa",
511
+ "hsa2"
512
+ ].includes(authType)) throw new AppleSignInError("The Apple Account has a pending prompt. Sign in at https://appstoreconnect.apple.com once, then run this again.", { status: 412 });
513
+ throw failure("Signing in", complete, completeBody);
202
514
  }
203
- };
515
+ let { provider, providers } = await readSession(client);
516
+ return {
517
+ appleId,
518
+ get provider() {
519
+ return provider;
520
+ },
521
+ get providers() {
522
+ return providers;
523
+ },
524
+ selectProvider: async (publicProviderId) => {
525
+ if (provider.publicProviderId === publicProviderId) return provider;
526
+ const selected = providers.find((value) => value.publicProviderId === publicProviderId);
527
+ if (!selected) throw new AppleSignInError(`The Apple Account is in no team ${publicProviderId}.`);
528
+ const response = await client.request(SESSION_URL, {
529
+ method: "POST",
530
+ headers: {
531
+ "Content-Type": "application/json",
532
+ "X-Requested-With": "olympus-ui"
533
+ },
534
+ body: JSON.stringify({ provider: { providerId: selected.providerId } })
535
+ });
536
+ const body = await response.text();
537
+ if (!response.ok) throw new AppleSignInError(`Switching to the team ${selected.name ?? publicProviderId} failed (HTTP ${response.status}).`, {
538
+ status: response.status,
539
+ body
540
+ });
541
+ ({provider, providers} = await readSession(client));
542
+ if (provider.providerId !== selected.providerId) throw new AppleSignInError(`App Store Connect stayed in the team ${provider.name ?? provider.publicProviderId}.`);
543
+ return provider;
544
+ },
545
+ request: client.request,
546
+ clear: client.clear
547
+ };
548
+ } catch (error) {
549
+ client.clear();
550
+ throw error;
551
+ }
204
552
  };
553
+ //#endregion
554
+ //#region src/asc/programLicenseAgreement.ts
205
555
  /**
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.
556
+ * What to do when Apple holds a team's requests until the Account Holder
557
+ * accepts the latest Apple Developer Program License Agreement: App Store
558
+ * Connect answers 403, and xcodebuild prints "PLA Update available".
210
559
  */
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
- };
560
+ const PROGRAM_LICENSE_AGREEMENT_HINT = ["The Account Holder must accept the latest Program License Agreement:", "https://developer.apple.com/account"].join("\n");
561
+ /** The code App Store Connect answers a team with a pending agreement. */
562
+ const AGREEMENT_ERROR_CODE = "FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED";
563
+ /**
564
+ * Whether App Store Connect's JSON:API errors say the team has a Program
565
+ * License Agreement to accept, by its code or its detail.
566
+ */
567
+ const isProgramLicenseAgreementError = (errors) => errors.some(({ code, detail, title }) => code === AGREEMENT_ERROR_CODE || /Program License Agreement/iu.test(`${detail ?? ""} ${title ?? ""}`));
568
+ /** Whether xcodebuild's output says the team has an agreement to accept. */
569
+ const mentionsProgramLicenseAgreement = (output) => output.includes("PLA Update available");
570
+ //#endregion
571
+ //#region src/asc/ascKeyIssuer.ts
572
+ const IRIS_URL = "https://appstoreconnect.apple.com/iris/v1";
573
+ /**
574
+ * The headers App Store Connect's Integrations page sends its API with. A
575
+ * function: `ascApiKey` imports this module, so its constants aren't set
576
+ * yet when this module loads.
577
+ */
578
+ const irisHeaders = () => ({
579
+ Accept: "application/vnd.api+json, application/json, text/csv",
580
+ "Content-Type": "application/json",
581
+ "X-CSRF-ITC": "[asc-ui]",
582
+ Origin: "https://appstoreconnect.apple.com",
583
+ Referer: ASC_INTEGRATIONS_URL
584
+ });
228
585
  const answer = (value) => _hot_updater_cli_tools.p.isCancel(value) ? void 0 : value;
586
+ /** One line: where the code went, and where a wrong one sends the next. */
587
+ const twoFactorMessage = (request) => {
588
+ const phone = request.destination ?? "your trusted phone";
589
+ if (request.via === "sms") return `${request.afterWrongCode ? "Wrong code. " : ""}Code texted to ${phone}`;
590
+ return request.textsOnWrongCode ? `Code on your Apple device (wrong code texts ${phone})` : "Code on your Apple device";
591
+ };
229
592
  /** Hot Updater's own prompts, so the sign-in looks like the rest of the CLI. */
230
593
  const cliPrompts = {
231
594
  password: async (appleId) => answer(await _hot_updater_cli_tools.p.password({
@@ -233,65 +596,53 @@ const cliPrompts = {
233
596
  mask: "•",
234
597
  validate: (value) => value ? void 0 : "Enter the password."
235
598
  })),
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."
599
+ twoFactorCode: async (request) => answer(await _hot_updater_cli_tools.p.text({
600
+ message: twoFactorMessage(request),
601
+ validate: (value) => new RegExp(`^\\d{${request.length}}$`, "u").test(value?.trim() ?? "") ? void 0 : `Enter the ${request.length}-digit code.`
239
602
  }))?.trim()
240
603
  };
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
604
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
287
605
  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.";
606
+ const toTeam = (appleId, provider) => ({
607
+ appleId,
608
+ teamId: provider.publicProviderId,
609
+ ...provider.name ? { name: provider.name } : {}
610
+ });
611
+ /** What App Store Connect's API said went wrong, from its JSON:API errors. */
612
+ const irisDetail = (body) => {
613
+ try {
614
+ const parsed = JSON.parse(body);
615
+ if (!isRecord(parsed) || !Array.isArray(parsed.errors)) return void 0;
616
+ return parsed.errors.filter(isRecord).map((error) => text(error.detail) ?? text(error.title)).find(Boolean);
617
+ } catch {
618
+ return;
619
+ }
620
+ };
621
+ /** What to do about a key App Store Connect would not create. */
622
+ const createFailureHint = (status, body) => {
623
+ if (/agreement/iu.test(body)) return PROGRAM_LICENSE_AGREEMENT_HINT;
624
+ if (/limit|maximum/iu.test(body)) return `The team has the most API keys App Store Connect allows. Revoke one at:\n${ASC_INTEGRATIONS_URL}`;
625
+ if (status === 403 || /account holder|admin|permission/iu.test(body)) return "Creating an API key needs the Account Holder or Admin role in the team.";
626
+ };
627
+ const irisRequest = async (session, step, method, path, payload) => {
628
+ const response = await session.request(`${IRIS_URL}${path}`, {
629
+ method,
630
+ headers: irisHeaders(),
631
+ ...payload === void 0 ? {} : { body: JSON.stringify(payload) }
632
+ });
633
+ const body = await response.text();
634
+ if (!response.ok) {
635
+ const detail = irisDetail(body);
636
+ throw new AppleSignInError(`${step} failed (HTTP ${response.status}).${detail ? `\n${detail}` : ""}`, {
637
+ status: response.status,
638
+ body
639
+ });
640
+ }
641
+ try {
642
+ const parsed = JSON.parse(body);
643
+ if (isRecord(parsed) && isRecord(parsed.data)) return parsed.data;
644
+ } catch {}
645
+ throw new AppleSignInError(`${step} answered no API key.`, { status: response.status });
295
646
  };
296
647
  /** A P-256 private key, as App Store Connect issues one, or a thrown error. */
297
648
  const assertP8 = (p8) => {
@@ -299,144 +650,108 @@ const assertP8 = (p8) => {
299
650
  try {
300
651
  key = node_crypto.default.createPrivateKey(p8);
301
652
  } catch {
302
- throw new Error("asc saved a .p8 that is not a private key.");
653
+ throw new Error("App Store Connect sent a .p8 that is not a private key.");
654
+ }
655
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") throw new Error("App Store Connect sent a .p8 that is not a P-256 private key.");
656
+ };
657
+ /** The one-time `.p8`; a key App Store Connect hasn't settled yet is retried. */
658
+ const downloadP8 = async (session, keyId, wait) => {
659
+ for (let attempt = 1;; attempt++) try {
660
+ const data = await irisRequest(session, "Downloading the key's .p8", "GET", `/apiKeys/${encodeURIComponent(keyId)}?${new URLSearchParams({ "fields[apiKeys]": "privateKey" })}`);
661
+ const encoded = isRecord(data.attributes) ? text(data.attributes.privateKey) : void 0;
662
+ if (text(data.id) !== keyId || !encoded) throw new Error("App Store Connect sent no .p8 for the key.");
663
+ return Buffer.from(encoded, "base64").toString("utf-8").trim();
664
+ } catch (error) {
665
+ if (!(error instanceof AppleSignInError && error.status !== void 0 && (error.status === 404 || error.status === 409 || error.status === 429 || error.status >= 500)) || attempt === 3) throw error;
666
+ await wait(attempt * 1e3);
303
667
  }
304
- if (key.asymmetricKeyType !== "ec") throw new Error("asc saved a .p8 that is not an EC private key.");
668
+ };
669
+ const issuerOf = (data) => {
670
+ const attributes = isRecord(data.attributes) ? data.attributes : {};
671
+ const relationships = isRecord(data.relationships) ? data.relationships : {};
672
+ const related = isRecord(relationships.provider) ? relationships.provider.data : void 0;
673
+ return (isRecord(attributes.provider) ? text(attributes.provider.id) : void 0) ?? (isRecord(related) ? text(related.id) : void 0);
305
674
  };
306
675
  /**
307
- * `IssueAscApiKey` with asc, the open-source App Store Connect CLI
308
- * (https://github.com/rorkai/App-Store-Connect-CLI, MIT):
676
+ * `IssueAscApiKey` by signing in as App Store Connect's web page does
677
+ * (`signInToAppStoreConnect`) and creating the key with the API its
678
+ * Integrations page uses:
309
679
  *
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.
680
+ * 1. The password typed into Hot Updater's prompt goes only to Apple's SRP
681
+ * sign-in, which sends a proof of it, never the password; the two-factor
682
+ * code goes only to Apple's check. Neither is kept or printed.
683
+ * 2. `chooseTeam` sees the teams the Apple Account is in, unless `teamId`
684
+ * is set.
685
+ * 3. The key is created, its one-time `.p8` downloaded and checked; a key
686
+ * that fails the check is revoked at once.
687
+ * 4. `revoke` deactivates the key in the same session.
319
688
  *
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.
689
+ * The session is cookies in this process's memory, which `dispose` drops.
323
690
  *
324
- * @param asc The asc binary; found or downloaded with `findAscCli` unless set.
325
- * @param env The environment asc's own is derived from.
691
+ * @param fetch Where requests go, Apple unless set.
326
692
  * @param prompts Where the password and two-factor codes are asked.
693
+ * @param wait How a retried download waits.
327
694
  */
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.");
695
+ const createAscKeyIssuer = ({ fetch = globalThis.fetch, prompts = cliPrompts, wait = node_timers_promises.setTimeout } = {}) => async ({ name, role, appleId, teamId, chooseTeam }) => {
696
+ if (!appleId) throw new AppleSignInError("Signing in needs the Apple Account's email.");
697
+ _hot_updater_cli_tools.p.log.info("Signing in to App Store Connect. Only the issued key is kept.");
331
698
  const password = await prompts.password(appleId);
332
- if (password === void 0) throw new AscCommandError("Signing in was cancelled.", void 0);
699
+ if (password === void 0) throw new AppleSignInError("Signing in was cancelled.");
333
700
  (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;
701
+ const session = await signInToAppStoreConnect({
702
+ appleId,
703
+ password,
704
+ prompts,
705
+ fetch
706
+ });
707
+ let disposed = false;
708
+ const dispose = async () => {
709
+ disposed = true;
710
+ session.clear();
358
711
  };
359
712
  try {
360
- const twoFactor = await listenForTwoFactorCodes(work.root, prompts);
361
- let login;
713
+ const teams = session.providers.map((provider) => toTeam(appleId, provider));
714
+ const chosen = teamId ?? await chooseTeam?.(teams, toTeam(appleId, session.provider));
715
+ const provider = chosen === void 0 ? session.provider : await session.selectProvider(chosen);
716
+ let data;
362
717
  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 });
718
+ data = await irisRequest(session, "Creating the App Store Connect API key", "POST", "/apiKeys", { data: {
719
+ type: "apiKeys",
720
+ attributes: {
721
+ nickname: name,
722
+ roles: [role],
723
+ allAppsVisible: true,
724
+ keyType: "PUBLIC_API"
725
+ }
726
+ } });
373
727
  } catch (error) {
374
- if (twoFactor.cancelled()) throw new AscCommandError("Signing in was cancelled.", void 0, { cause: error });
728
+ const hint = error instanceof AppleSignInError && error.status !== void 0 ? createFailureHint(error.status, error.body ?? "") : void 0;
729
+ if (hint) throw new AppleSignInError(`${error.message}\n${hint}`, {
730
+ ...error.status === void 0 ? {} : { status: error.status },
731
+ cause: error
732
+ });
375
733
  throw error;
376
- } finally {
377
- await twoFactor.close();
378
734
  }
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
735
+ const keyId = text(data.id);
736
+ if (!keyId) throw new AppleSignInError("App Store Connect created a key without an ID.");
737
+ const revoke = async () => {
738
+ if (disposed) return false;
739
+ return irisRequest(session, "Revoking the key", "PATCH", `/apiKeys/${encodeURIComponent(keyId)}`, { data: {
740
+ id: keyId,
741
+ type: "apiKeys",
742
+ attributes: { isActive: false }
743
+ } }).then(() => true, () => false);
389
744
  };
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
745
  let p8;
429
746
  let issuerId;
430
747
  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();
748
+ p8 = await downloadP8(session, keyId, wait);
437
749
  (0, _hot_updater_cli_tools.registerSecretValue)(p8);
438
750
  (0, _hot_updater_cli_tools.registerSecretValue)(Buffer.from(p8).toString("base64"));
439
751
  assertP8(p8);
752
+ const details = await irisRequest(session, "Reading the key's issuer ID", "GET", `/apiKeys/${encodeURIComponent(keyId)}?include=provider`);
753
+ const issuer = issuerOf(details) ?? issuerOf(data);
754
+ if (!issuer) throw new Error("App Store Connect named no issuer ID for the key.");
440
755
  issuerId = issuer;
441
756
  } catch (error) {
442
757
  const revoked = await revoke();
@@ -446,13 +761,13 @@ const createAscCliKeyIssuer = ({ asc, env = process.env, prompts = cliPrompts }
446
761
  p8,
447
762
  keyId,
448
763
  issuerId,
449
- name: text(created.name) ?? name,
450
- team,
764
+ name: text(isRecord(data.attributes) ? data.attributes.nickname : "") ?? name,
765
+ team: toTeam(appleId, provider),
451
766
  revoke,
452
- dispose: work.dispose
767
+ dispose
453
768
  };
454
769
  } catch (error) {
455
- await work.dispose();
770
+ await dispose();
456
771
  throw error;
457
772
  }
458
773
  };
@@ -461,18 +776,121 @@ const createAscCliKeyIssuer = ({ asc, env = process.env, prompts = cliPrompts }
461
776
  /** Where team API keys are listed and revoked in App Store Connect. */
462
777
  const ASC_INTEGRATIONS_URL = "https://appstoreconnect.apple.com/access/integrations/api";
463
778
  /**
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
779
+ * Hot Updater's sign-in, the one place that picks how it is done: App Store
780
+ * Connect's own web sign-in, with no other tool. Another implementation of
466
781
  * `IssueAscApiKey` replaces it here without touching its callers.
467
782
  */
468
- const issueAscApiKey = (request) => createAscCliKeyIssuer()(request);
783
+ const issueAscApiKey = (request) => createAscKeyIssuer()(request);
784
+ //#endregion
785
+ //#region src/utils/formatCommand.ts
786
+ /**
787
+ * A command to print, broken at spaces with ` \` once a line would pass
788
+ * `width`, each continuation indented two spaces, so a terminal never wraps
789
+ * it mid-word and it still pastes as one command.
790
+ */
791
+ const formatCommand = (words, width = 74) => {
792
+ const lines = [];
793
+ let line = "";
794
+ for (const word of words) {
795
+ const next = line ? `${line} ${word}` : word;
796
+ if (line && next.length > width - 2) {
797
+ lines.push(`${line} \\`);
798
+ line = ` ${word}`;
799
+ } else line = next;
800
+ }
801
+ lines.push(line);
802
+ return lines.join("\n");
803
+ };
469
804
  //#endregion
470
805
  //#region src/utils/cocoapods.ts
806
+ /**
807
+ * A failed command's error: the command, then the last lines of its
808
+ * output, stdout and stderr as printed, without execa's full message.
809
+ */
810
+ const commandFailure = (command, error) => {
811
+ const { all, stderr, stdout } = error ?? {};
812
+ const detail = String(all || [stdout, stderr].filter(Boolean).join("\n") || "").trimEnd().split("\n").filter((line) => line.trim()).slice(-12).join("\n") || (error instanceof Error ? error.message : String(error));
813
+ return new Error(`${command} failed.\n${detail}`, { cause: error });
814
+ };
815
+ /**
816
+ * A line of the progress meter curl prints on stderr for each pod it
817
+ * downloads, such as React Native's prebuilt core.
818
+ */
819
+ const DOWNLOAD_PROGRESS = [
820
+ /^% Total +% Received/,
821
+ /^Dload +Upload/,
822
+ /^\d{1,3} +[\d.]+[kMGT]? +\d{1,3} +[\d.]+[kMGT]? /
823
+ ];
824
+ /**
825
+ * `pod install`'s output: its live line shows the pod it installs, its log
826
+ * keeps the whole output, and a failure prints CocoaPods' `[!]` errors and a
827
+ * crash's `SomeError - …` line with its last lines, without the download
828
+ * progress CocoaPods' curl prints on stderr.
829
+ */
830
+ const createPodInstallLogger = () => new _hot_updater_cli_tools.BuildLogger({
831
+ logPrefix: "pod-install",
832
+ createPhaseReader: () => (line) => {
833
+ const installing = /^Installing (\S+)/.exec(line);
834
+ if (installing) return `Installing ${installing[1]}`;
835
+ return /^(Analyzing dependencies|Downloading dependencies|Generating Pods project|Integrating client project)$/.test(line) ? line : void 0;
836
+ },
837
+ failureSummary: {
838
+ lines: [/^[A-Z]\w*(::\w+)*Error - /],
839
+ blocks: [{
840
+ start: /^\[!\] /,
841
+ end: /^$/,
842
+ includeEnd: false
843
+ }],
844
+ ignore: DOWNLOAD_PROGRESS,
845
+ tail: 4,
846
+ stopAfter: /^\[!\] Oh no, an error occurred\.$/,
847
+ maxWidth: 76
848
+ }
849
+ });
850
+ /**
851
+ * Runs `pod install` as a step whose failure prints CocoaPods' errors and
852
+ * the log's path, then throws a `BuildFailedError`.
853
+ */
854
+ const runPodInstall = async ({ command, env, sourceDir }) => {
855
+ const logger = createPodInstallLogger();
856
+ const failureMessage = `${command.join(" ")} failed`;
857
+ await logger.start({
858
+ title: "Installing CocoaPods dependencies",
859
+ header: [`$ ${formatCommand([...Object.entries(env).map(([name, value]) => `${name}=${value}`), ...command])}`, ""]
860
+ });
861
+ try {
862
+ const podProcess = (0, execa.execa)(command[0], command.slice(1), {
863
+ cwd: sourceDir,
864
+ env,
865
+ all: true,
866
+ buffer: false
867
+ });
868
+ const logging = podProcess.all ? logger.processStream(podProcess.all) : Promise.resolve();
869
+ const [podResult, loggingResult] = await Promise.allSettled([podProcess, logging]);
870
+ if (podResult.status === "rejected") throw podResult.reason;
871
+ if (loggingResult.status === "rejected") throw loggingResult.reason;
872
+ logger.stop("CocoaPods dependencies installed");
873
+ } catch (error) {
874
+ logger.writeError(error);
875
+ const reason = error instanceof execa.ExecaError && error.exitCode !== void 0 ? `exited with code ${error.exitCode}` : error instanceof Error ? error.message : String(error);
876
+ logger.stop(`${failureMessage} · ${reason}`, false);
877
+ throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${reason}`, {
878
+ logPath: logger.logPath,
879
+ summary: logger.failureSummary,
880
+ cause: error
881
+ });
882
+ } finally {
883
+ await logger.close();
884
+ }
885
+ };
471
886
  const installPodsIfNeeded = async (sourceDir) => {
472
887
  const podfilePath = path.default.join(sourceDir, "Podfile");
473
888
  if (!fs.default.existsSync(podfilePath)) {
474
889
  _hot_updater_cli_tools.p.log.info("No Podfile found, skipping CocoaPods installation");
475
- return;
890
+ return {
891
+ codegen: false,
892
+ pods: false
893
+ };
476
894
  }
477
895
  const gemfilePaths = [path.default.join(sourceDir, "Gemfile"), path.default.join((0, _hot_updater_cli_tools.getCwd)(), "Gemfile")];
478
896
  const shouldUseBundler = (await Promise.all(gemfilePaths.map(checkShouldUseBundler))).some(Boolean);
@@ -486,36 +904,34 @@ const installPodsIfNeeded = async (sourceDir) => {
486
904
  reactNativePath: reactNative.packagePath,
487
905
  sourceDir
488
906
  });
489
- try {
490
- if (shouldUseBundler) {
491
- _hot_updater_cli_tools.p.log.info("Using bundler for CocoaPods installation");
492
- const bundleSpinner = _hot_updater_cli_tools.p.spinner();
493
- bundleSpinner.start("Installing Ruby gems");
494
- await (0, execa.execa)("bundle", ["install"], { cwd: sourceDir });
495
- bundleSpinner.stop("Ruby gems installed");
496
- const podSpinner = _hot_updater_cli_tools.p.spinner();
497
- podSpinner.start("Installing CocoaPods dependencies");
498
- await (0, execa.execa)("bundle", [
499
- "exec",
500
- "pod",
501
- "install"
502
- ], {
503
- cwd: sourceDir,
504
- env: plan.env
505
- });
506
- podSpinner.stop("CocoaPods dependencies installed");
507
- } else {
508
- const spinner = _hot_updater_cli_tools.p.spinner();
509
- spinner.start("Installing CocoaPods dependencies");
510
- await (0, execa.execa)("pod", ["install"], {
907
+ if (shouldUseBundler) {
908
+ const bundleSpinner = _hot_updater_cli_tools.p.spinner();
909
+ bundleSpinner.start("Installing Ruby gems");
910
+ try {
911
+ await (0, execa.execa)("bundle", ["install"], {
511
912
  cwd: sourceDir,
512
- env: plan.env
913
+ all: true
513
914
  });
514
- spinner.stop("CocoaPods dependencies installed");
915
+ } catch (error) {
916
+ bundleSpinner.error("bundle install failed");
917
+ throw commandFailure("bundle install", error);
515
918
  }
516
- } catch (error) {
517
- throw new Error(`pod install failed: ${error}`);
919
+ bundleSpinner.stop("Ruby gems installed");
518
920
  }
921
+ await runPodInstall({
922
+ command: shouldUseBundler ? [
923
+ "bundle",
924
+ "exec",
925
+ "pod",
926
+ "install"
927
+ ] : ["pod", "install"],
928
+ env: plan.env,
929
+ sourceDir
930
+ });
931
+ return {
932
+ codegen: plan.runCodegen,
933
+ pods: true
934
+ };
519
935
  };
520
936
  const checkShouldUseBundler = async (gemfilePath) => {
521
937
  try {
@@ -546,10 +962,13 @@ const runReactNativeCodegen = async ({ projectRoot, reactNativePath, sourceDir }
546
962
  sourceDir,
547
963
  "-t",
548
964
  "ios"
549
- ], { cwd: projectRoot });
965
+ ], {
966
+ cwd: projectRoot,
967
+ all: true
968
+ });
550
969
  } catch (error) {
551
- spinner.stop("React Native codegen failed");
552
- throw new Error(`React Native codegen failed: ${error}`);
970
+ spinner.error("React Native codegen failed");
971
+ throw commandFailure("React Native codegen", error);
553
972
  }
554
973
  spinner.stop("React Native codegen done");
555
974
  };
@@ -578,8 +997,55 @@ const resolvePodInstallPlan = ({ version, env }) => {
578
997
  };
579
998
  //#endregion
580
999
  //#region src/utils/createRandomTmpDir.ts
1000
+ const TERMINATION_SIGNALS$2 = [
1001
+ "SIGHUP",
1002
+ "SIGINT",
1003
+ "SIGTERM"
1004
+ ];
1005
+ const REMOVE_OPTIONS = {
1006
+ force: true,
1007
+ recursive: true,
1008
+ maxRetries: 3
1009
+ };
1010
+ const liveDirectories = /* @__PURE__ */ new Set();
1011
+ const onSignal = (signal) => {
1012
+ removeLiveDirectories();
1013
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
1014
+ };
1015
+ const removeLiveDirectories = () => {
1016
+ for (const directory of liveDirectories) try {
1017
+ (0, node_fs.rmSync)(directory, REMOVE_OPTIONS);
1018
+ } catch {}
1019
+ liveDirectories.clear();
1020
+ stopListening();
1021
+ };
1022
+ const startListening = () => {
1023
+ process.on("exit", removeLiveDirectories);
1024
+ for (const signal of TERMINATION_SIGNALS$2) process.on(signal, onSignal);
1025
+ };
1026
+ const stopListening = () => {
1027
+ process.off("exit", removeLiveDirectories);
1028
+ for (const signal of TERMINATION_SIGNALS$2) process.off(signal, onSignal);
1029
+ };
1030
+ /**
1031
+ * Creates a directory in the system's temporary directory, which
1032
+ * `removeTmpDir` removes, and which is otherwise removed when the process
1033
+ * exits or is stopped by a signal.
1034
+ */
581
1035
  const createRandomTmpDir = async (prefix = "apple-helper-") => {
582
- return await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), prefix));
1036
+ const directory = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), prefix));
1037
+ if (liveDirectories.size === 0) startListening();
1038
+ liveDirectories.add(directory);
1039
+ return directory;
1040
+ };
1041
+ /**
1042
+ * Removes a directory `createRandomTmpDir` created. It stays tracked until
1043
+ * it is gone, so a signal during a removal that takes seconds, such as
1044
+ * DerivedData's, still finishes it.
1045
+ */
1046
+ const removeTmpDir = async (directory) => {
1047
+ await (0, node_fs_promises.rm)(directory, REMOVE_OPTIONS);
1048
+ if (liveDirectories.delete(directory) && liveDirectories.size === 0) stopListening();
583
1049
  };
584
1050
  //#endregion
585
1051
  //#region src/utils/platform.ts
@@ -654,80 +1120,113 @@ const parseXcodeProjectInfo = async (sourceDir) => {
654
1120
  const prettifyXcodebuildError = (error) => {
655
1121
  if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
656
1122
  const errorLines = error.summary.join("\n");
657
- const message = errorLines.includes("error: No profiles for") || errorLines.includes("error: Provisioning profile") ? "Not valid provisioning profile is set in the project. Check out the Signing & Capabilities tab in Xcode." : error.message;
658
- return new _hot_updater_cli_tools.BuildFailedError(`Xcodebuild failed: ${message}`, {
1123
+ const hint = mentionsProgramLicenseAgreement(errorLines) ? PROGRAM_LICENSE_AGREEMENT_HINT : errorLines.includes("error: No profiles for") || errorLines.includes("error: Provisioning profile") ? "No valid provisioning profile is set in the project.\nCheck the Signing & Capabilities tab in Xcode." : null;
1124
+ return new _hot_updater_cli_tools.BuildFailedError(hint ?? error.message, {
659
1125
  logPath: error.logPath,
660
1126
  summary: error.summary,
1127
+ hint,
661
1128
  cause: error.cause
662
1129
  });
663
1130
  }
664
1131
  return /* @__PURE__ */ new Error(`Xcodebuild failed: ${error}`);
665
1132
  };
666
1133
  //#endregion
1134
+ //#region src/utils/xcodebuildPhase.ts
1135
+ /** The `(in target 'X' from project 'Y')` that ends an action's line. */
1136
+ const TARGET = /\(in target '([^']+)' from project '[^']+'\)\s*$/;
1137
+ /** A `PhaseScriptExecution` line's script phase, its spaces escaped. */
1138
+ const SCRIPT = /^PhaseScriptExecution ((?:\\.|[^\s\\])+)/;
1139
+ /** A batched `SwiftCompile`'s files: `Compiling\ A.swift,\ B.swift`. */
1140
+ const SWIFT_BATCH = /^SwiftCompile \S+ \S+ Compiling\\ ((?:\\.|[^\s\\])+)/;
1141
+ const unescape = (value) => value.replace(/\\(.)/g, "$1");
1142
+ /** Actions whose lines say what xcodebuild is doing, by their first word. */
1143
+ const ACTIONS = {
1144
+ CompileAssetCatalog: "Compiling assets",
1145
+ CompileAssetCatalogVariant: "Compiling assets",
1146
+ LinkAssetCatalog: "Compiling assets",
1147
+ CompileStoryboard: "Compiling storyboards",
1148
+ CompileXIB: "Compiling storyboards",
1149
+ LinkStoryboards: "Compiling storyboards",
1150
+ Ld: "Linking",
1151
+ Libtool: "Linking",
1152
+ CodeSign: "Signing",
1153
+ ProcessInfoPlistFile: "Processing",
1154
+ ProcessProductPackaging: "Processing",
1155
+ ProcessProductPackagingDER: "Processing",
1156
+ CopySwiftLibs: "Copying",
1157
+ CpResource: "Copying",
1158
+ GenerateDSYMFile: "Generating dSYM",
1159
+ Strip: "Stripping",
1160
+ Validate: "Validating"
1161
+ };
1162
+ const COMPILES = /* @__PURE__ */ new Set([
1163
+ "CompileC",
1164
+ "SwiftCompile",
1165
+ "CompileSwift"
1166
+ ]);
1167
+ const PRECOMPILES = /* @__PURE__ */ new Set([
1168
+ "PrecompileModule",
1169
+ "ScanDependencies",
1170
+ "SwiftExplicitDependencyGeneratePcm",
1171
+ "SwiftExplicitDependencyCompileModuleFromInterface"
1172
+ ]);
1173
+ const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
1174
+ /**
1175
+ * Reads the phase of an `xcodebuild` build, archive, or export from its raw
1176
+ * output: `Compiling React-Core · 412 files`, `Linking CodexRelay`,
1177
+ * `Signing CodexRelay`, `Bundling JavaScript`, with the target each
1178
+ * action's line names.
1179
+ */
1180
+ const createXcodebuildPhaseReader = () => {
1181
+ let compiled = 0;
1182
+ let precompiled = 0;
1183
+ return (line) => {
1184
+ const space = line.indexOf(" ");
1185
+ const action = space === -1 ? line : line.slice(0, space);
1186
+ const target = TARGET.exec(line)?.[1];
1187
+ const withTarget = (phase) => target ? `${phase} ${target}` : phase;
1188
+ if (COMPILES.has(action)) {
1189
+ const batch = SWIFT_BATCH.exec(line)?.[1];
1190
+ compiled += batch ? unescape(batch).split(",").length : 1;
1191
+ return `${withTarget("Compiling")} · ${plural(compiled, "file")}`;
1192
+ }
1193
+ if (PRECOMPILES.has(action)) {
1194
+ precompiled += 1;
1195
+ return `Precompiling modules · ${precompiled}`;
1196
+ }
1197
+ if (action === "PhaseScriptExecution") {
1198
+ const script = unescape(SCRIPT.exec(line)?.[1] ?? "");
1199
+ if (script === "Bundle React Native code and images") return "Bundling JavaScript";
1200
+ return script ? `Running ${script}` : withTarget("Running a script");
1201
+ }
1202
+ const phase = ACTIONS[action];
1203
+ if (phase) return withTarget(phase);
1204
+ if (line === "Prepare packages" || line.startsWith("Resolve Package")) return "Resolving packages";
1205
+ if (line === "ComputeTargetDependencyGraph") return "Planning the build";
1206
+ };
1207
+ };
1208
+ //#endregion
667
1209
  //#region src/utils/createXcodebuildLogger.ts
668
- const createXcodebuildLogger = ({ logPrefix }) => new _hot_updater_cli_tools.BuildLogger({
1210
+ const createXcodebuildLogger = ({ logPrefix, logFilePath }) => new _hot_updater_cli_tools.BuildLogger({
669
1211
  logPrefix,
670
- importantLogPatterns: [
671
- "error:",
672
- "** BUILD FAILED **",
673
- "** ARCHIVE FAILED **",
674
- "The following build commands failed:",
675
- "note: Building targets in dependency order",
676
- "note: Target dependency graph",
677
- "Running script",
678
- "[CP-User]",
679
- "[CP] Check Pods Manifest.lock",
680
- "Build Succeeded"
681
- ],
682
- progressStages: [
683
- ["Building targets in dependency order", "Target dependency graph"],
684
- ["Write Auxiliary File", "[CP] Check Pods Manifest.lock"],
685
- [
686
- "Running script",
687
- "[CP-User] [RN]Check rncore",
688
- "[CP-User] [Hermes] Replace Hermes",
689
- "[CP-User] [RN]Check FBReactNativeSpec"
690
- ],
691
- [
692
- "Processing",
693
- "ProcessProductPackaging",
694
- "ProcessProductPackagingDER"
695
- ],
696
- ["Compiling"],
697
- ["RegisterExecutionPolicyException"],
698
- ["Touching"],
699
- ["Building library", "Create Universal Binary"],
700
- ["Copying"],
701
- [
702
- "Build Succeeded",
703
- "BUILD SUCCEEDED",
704
- "ARCHIVE SUCCEEDED"
705
- ]
706
- ],
1212
+ logFilePath,
1213
+ createPhaseReader: createXcodebuildPhaseReader,
707
1214
  failureSummary: {
708
1215
  lines: [/(^|:\s)error: /, /^\*\* [A-Z ]+ FAILED \*\*$/],
709
1216
  blocks: [{
710
1217
  start: "The following build commands failed:",
711
1218
  end: /^\(\d+ failures?\)$/,
712
1219
  includeEnd: true
1220
+ }, {
1221
+ start: /^error: exportArchive /,
1222
+ end: /^$/,
1223
+ includeEnd: false
713
1224
  }]
714
1225
  }
715
1226
  });
716
1227
  //#endregion
717
1228
  //#region src/utils/runXcodebuildWithLogging.ts
718
- let cachedXcbeautifyAvailability = null;
719
1229
  const TERMINATION_SIGNALS$1 = ["SIGINT", "SIGTERM"];
720
- const isXcbeautifyAvailable = async () => {
721
- if (cachedXcbeautifyAvailability !== null) return cachedXcbeautifyAvailability;
722
- try {
723
- await (0, execa.execa)("which", ["xcbeautify"]);
724
- cachedXcbeautifyAvailability = true;
725
- return true;
726
- } catch {
727
- cachedXcbeautifyAvailability = false;
728
- return false;
729
- }
730
- };
731
1230
  const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
732
1231
  return (0, execa.execa)("xcodebuild", args, {
733
1232
  cwd: sourceDir,
@@ -740,13 +1239,6 @@ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
740
1239
  }
741
1240
  });
742
1241
  };
743
- const createXcbeautifyProcess = ({ cwd }) => {
744
- return (0, execa.execa)("xcbeautify", [], {
745
- cwd,
746
- all: true,
747
- buffer: false
748
- });
749
- };
750
1242
  /** Why xcodebuild failed, without its command line. */
751
1243
  const describeFailure = (error) => {
752
1244
  if (error instanceof execa.ExecaError) {
@@ -755,10 +1247,24 @@ const describeFailure = (error) => {
755
1247
  }
756
1248
  return error instanceof Error ? error.message : String(error);
757
1249
  };
758
- const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage, failureMessage, logPrefix }) => {
759
- const useXcbeautify = await isXcbeautifyAvailable();
760
- const logger = createXcodebuildLogger({ logPrefix });
761
- await logger.start();
1250
+ /**
1251
+ * Runs xcodebuild as a step titled `title`: its live line shows the build's
1252
+ * phase, the log file keeps the command and the whole output, and a failure
1253
+ * prints the output's errors and the log's path on stderr.
1254
+ */
1255
+ const runXcodebuildWithLogging = async ({ args, env, sourceDir, title, successMessage, failureMessage, logPrefix, logFilePath, header = [] }) => {
1256
+ const logger = createXcodebuildLogger({
1257
+ logPrefix,
1258
+ logFilePath
1259
+ });
1260
+ await logger.start({
1261
+ title,
1262
+ header: [
1263
+ ...header,
1264
+ `$ ${formatCommand(["xcodebuild", ...args])}`,
1265
+ ""
1266
+ ]
1267
+ });
762
1268
  const activeProcesses = [];
763
1269
  const terminateActiveProcesses = () => {
764
1270
  for (const activeProcess of activeProcesses) activeProcess.kill();
@@ -772,31 +1278,17 @@ const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage,
772
1278
  });
773
1279
  activeProcesses.push(xcodebuildProcess);
774
1280
  const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
775
- let xcbeautifyProcess = null;
776
- if (useXcbeautify && xcodebuildOutput) {
777
- xcbeautifyProcess = createXcbeautifyProcess({ cwd: sourceDir });
778
- activeProcesses.push(xcbeautifyProcess);
779
- }
780
- const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput, { display: xcbeautifyProcess ? xcbeautifyProcess.all ?? xcbeautifyProcess.stdout ?? void 0 : void 0 }) : Promise.resolve();
781
- const xcbeautifyInput = xcbeautifyProcess?.stdin;
782
- if (xcodebuildOutput && xcbeautifyInput) {
783
- xcbeautifyInput.on("error", () => {
784
- xcodebuildOutput.unpipe(xcbeautifyInput);
785
- });
786
- xcodebuildOutput.pipe(xcbeautifyInput);
787
- }
788
- const [xcodebuildResult, loggingResult] = await Promise.allSettled([
789
- xcodebuildProcess,
790
- logging,
791
- xcbeautifyProcess
792
- ]);
1281
+ const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput) : Promise.resolve();
1282
+ const [xcodebuildResult, loggingResult] = await Promise.allSettled([xcodebuildProcess, logging]);
793
1283
  if (xcodebuildResult.status === "rejected") throw xcodebuildResult.reason;
794
1284
  if (loggingResult.status === "rejected") throw loggingResult.reason;
795
1285
  logger.stop(successMessage);
1286
+ return { logFilePath: logger.logFilePath };
796
1287
  } catch (error) {
797
1288
  logger.writeError(error);
798
- logger.stop(failureMessage, false);
799
- throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${describeFailure(error)}`, {
1289
+ const reason = describeFailure(error);
1290
+ logger.stop(`${failureMessage} · ${reason.replace(/\.$/, "")}`, false);
1291
+ throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${reason}`, {
800
1292
  logPath: logger.logPath,
801
1293
  summary: logger.failureSummary,
802
1294
  cause: error
@@ -883,53 +1375,66 @@ const buildSettingsOverScheme = ({ minBundleId, authenticationArgs }) => ({
883
1375
  });
884
1376
  //#endregion
885
1377
  //#region src/builder/archiveXcodeProject.ts
886
- const archiveXcodeProject = async ({ sourceDir, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, minBundleId, authenticationArgs }) => {
1378
+ /**
1379
+ * Archives the scheme into `archiveDirectory`. The build's xcconfig is
1380
+ * written to a temporary directory, removed whether the archive succeeds or
1381
+ * not.
1382
+ */
1383
+ const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, header = [], minBundleId, authenticationArgs }) => {
887
1384
  if (installPods) await installPodsIfNeeded(sourceDir);
888
1385
  const xcodeProject = await parseXcodeProjectInfo(sourceDir);
889
- const tmpDir = await createRandomTmpDir();
890
1386
  const archiveName = `${xcodeProject.name.replace(".xcworkspace", "").replace(".xcodeproj", "")}.xcarchive`;
891
- const archivePath = path.default.join(tmpDir, archiveName);
892
- const build = await writeBuildXcconfig({
893
- directory: tmpDir,
894
- sourceDir,
895
- xcconfig,
896
- extraParams,
897
- settings: buildSettingsOverScheme({
898
- minBundleId,
899
- authenticationArgs
900
- })
901
- });
902
- const archiveArgs = prepareArchiveArgs({
903
- authenticationArgs,
904
- minBundleId,
905
- archivePath,
906
- platform,
907
- sourceDir,
908
- xcodeProject,
909
- configuration,
910
- extraParams: build.extraParams,
911
- destination,
912
- xcconfig: build.xcconfig,
913
- xcodeScheme
914
- });
915
- _hot_updater_cli_tools.p.log.info(`Xcode Archive Settings:
916
- Project ${xcodeProject.name}
917
- Scheme ${xcodeScheme}
918
- Platform ${platform}
919
- Command xcodebuild ${archiveArgs.join(" ")}
920
- `);
1387
+ const archivePath = path.default.join(archiveDirectory, archiveName);
1388
+ const tmpDir = await createRandomTmpDir();
921
1389
  try {
922
- await runXcodebuildWithLogging({
923
- args: archiveArgs,
924
- env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
925
- failureMessage: "Archive failed",
926
- logPrefix,
1390
+ const build = await writeBuildXcconfig({
1391
+ directory: tmpDir,
927
1392
  sourceDir,
928
- successMessage: "Archive completed successfully"
1393
+ xcconfig,
1394
+ extraParams,
1395
+ settings: buildSettingsOverScheme({
1396
+ minBundleId,
1397
+ authenticationArgs
1398
+ })
929
1399
  });
930
- return { archivePath };
931
- } catch (error) {
932
- throw prettifyXcodebuildError(error);
1400
+ const archiveArgs = prepareArchiveArgs({
1401
+ authenticationArgs,
1402
+ minBundleId,
1403
+ archivePath,
1404
+ platform,
1405
+ sourceDir,
1406
+ xcodeProject,
1407
+ configuration,
1408
+ extraParams: build.extraParams,
1409
+ destination,
1410
+ xcconfig: build.xcconfig,
1411
+ xcodeScheme
1412
+ });
1413
+ try {
1414
+ const { logFilePath } = await runXcodebuildWithLogging({
1415
+ args: archiveArgs,
1416
+ env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
1417
+ title: `Archiving ${xcodeScheme}`,
1418
+ successMessage: `Archived ${xcodeScheme}`,
1419
+ failureMessage: "Archive failed",
1420
+ logPrefix,
1421
+ header: [
1422
+ ...header,
1423
+ `Project ${xcodeProject.name}`,
1424
+ `Scheme ${xcodeScheme}`,
1425
+ `Platform ${platform}`
1426
+ ],
1427
+ sourceDir
1428
+ });
1429
+ return {
1430
+ archivePath,
1431
+ logFilePath
1432
+ };
1433
+ } catch (error) {
1434
+ throw prettifyXcodebuildError(error);
1435
+ }
1436
+ } finally {
1437
+ await removeTmpDir(tmpDir);
933
1438
  }
934
1439
  };
935
1440
  const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId, authenticationArgs }) => {
@@ -962,69 +1467,88 @@ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xco
962
1467
  };
963
1468
  //#endregion
964
1469
  //#region src/builder/buildXcodeProject.ts
965
- const buildXcodeProject = async ({ sourceDir, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams, minBundleId, authenticationArgs }) => {
1470
+ /**
1471
+ * Builds the scheme in a temporary DerivedData directory, copies the built
1472
+ * `.app` to `outputDirectory`, and removes DerivedData, which takes 10 GB or
1473
+ * more, whether the build succeeds or not.
1474
+ */
1475
+ const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams, minBundleId, authenticationArgs, header = [] }) => {
966
1476
  if (installPods) await installPodsIfNeeded(sourceDir);
967
1477
  const xcodeProject = await parseXcodeProjectInfo(sourceDir);
968
- const derivedDataPath = await createRandomTmpDir();
969
- const resolvedDestinations = resolveDestinations({
970
- destinations: destination,
971
- useGeneric: useGenericDestination
972
- });
973
- if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
974
- deviceType,
975
- platform,
976
- useGeneric: useGenericDestination
977
- }));
978
- const build = await writeBuildXcconfig({
979
- directory: await createRandomTmpDir(),
980
- sourceDir,
981
- extraParams,
982
- settings: buildSettingsOverScheme({
983
- minBundleId,
984
- authenticationArgs
985
- })
986
- });
987
- const buildArgs = prepareBuildArgs({
988
- authenticationArgs,
989
- xcconfig: build.xcconfig,
990
- minBundleId,
991
- configuration,
992
- derivedDataPath,
993
- deviceType,
994
- resolvedDestinations,
995
- extraParams: build.extraParams,
996
- platform,
997
- xcodeScheme,
998
- sourceDir,
999
- xcodeProject
1000
- });
1001
- _hot_updater_cli_tools.p.log.info(`Xcode Build Settings:
1002
- Project ${xcodeProject.name}
1003
- Scheme ${xcodeScheme}
1004
- Configuration ${configuration}
1005
- Platform ${platform}
1006
- Device Type ${deviceType}
1007
- Command xcodebuild ${buildArgs.join(" ")}
1008
- `);
1478
+ const tmpDir = await createRandomTmpDir();
1009
1479
  try {
1010
- await runXcodebuildWithLogging({
1011
- args: buildArgs,
1012
- env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
1480
+ const derivedDataPath = path.default.join(tmpDir, "DerivedData");
1481
+ const resolvedDestinations = resolveDestinations({
1482
+ destinations: destination,
1483
+ useGeneric: useGenericDestination
1484
+ });
1485
+ if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
1486
+ deviceType,
1487
+ platform,
1488
+ useGeneric: useGenericDestination
1489
+ }));
1490
+ const build = await writeBuildXcconfig({
1491
+ directory: tmpDir,
1013
1492
  sourceDir,
1014
- logPrefix,
1015
- successMessage: "Build completed successfully",
1016
- failureMessage: "Build failed"
1493
+ extraParams,
1494
+ settings: buildSettingsOverScheme({
1495
+ minBundleId,
1496
+ authenticationArgs
1497
+ })
1017
1498
  });
1018
- return await getBuildSettings({
1499
+ const buildArgs = prepareBuildArgs({
1500
+ authenticationArgs,
1501
+ xcconfig: build.xcconfig,
1502
+ minBundleId,
1019
1503
  configuration,
1020
1504
  derivedDataPath,
1505
+ deviceType,
1021
1506
  resolvedDestinations,
1507
+ extraParams: build.extraParams,
1508
+ platform,
1022
1509
  xcodeScheme,
1023
1510
  sourceDir,
1024
1511
  xcodeProject
1025
1512
  });
1026
- } catch (error) {
1027
- throw prettifyXcodebuildError(error);
1513
+ let builtAppPath;
1514
+ let logFilePath;
1515
+ try {
1516
+ ({logFilePath} = await runXcodebuildWithLogging({
1517
+ args: buildArgs,
1518
+ env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
1519
+ sourceDir,
1520
+ logPrefix,
1521
+ header: [
1522
+ ...header,
1523
+ `Project ${xcodeProject.name}`,
1524
+ `Scheme ${xcodeScheme}`,
1525
+ `Configuration ${configuration}`,
1526
+ `Platform ${platform}`,
1527
+ `Device type ${deviceType}`
1528
+ ],
1529
+ title: `Building ${xcodeScheme}`,
1530
+ successMessage: `Built ${xcodeScheme}`,
1531
+ failureMessage: "Build failed"
1532
+ }));
1533
+ ({appPath: builtAppPath} = await getBuildSettings({
1534
+ configuration,
1535
+ derivedDataPath,
1536
+ resolvedDestinations,
1537
+ xcodeScheme,
1538
+ sourceDir,
1539
+ xcodeProject
1540
+ }));
1541
+ } catch (error) {
1542
+ throw prettifyXcodebuildError(error);
1543
+ }
1544
+ const appPath = path.default.join(outputDirectory, path.default.basename(builtAppPath));
1545
+ await fs.default.promises.cp(builtAppPath, appPath, { recursive: true });
1546
+ return {
1547
+ appPath,
1548
+ logFilePath
1549
+ };
1550
+ } finally {
1551
+ await removeTmpDir(tmpDir);
1028
1552
  }
1029
1553
  };
1030
1554
  const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId, authenticationArgs, xcconfig }) => {
@@ -1081,8 +1605,8 @@ const getBuildSettings = async ({ xcodeProject, sourceDir, xcodeScheme, configur
1081
1605
  };
1082
1606
  //#endregion
1083
1607
  //#region src/builder/exportXcodeArchive.ts
1084
- const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, authenticationArgs }) => {
1085
- const exportPath = await createRandomTmpDir();
1608
+ /** Exports the archive into `exportPath`. */
1609
+ const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, logFilePath, title, authenticationArgs }) => {
1086
1610
  const exportArgs = prepareExportArgs({
1087
1611
  authenticationArgs,
1088
1612
  archivePath,
@@ -1094,9 +1618,11 @@ const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, e
1094
1618
  await runXcodebuildWithLogging({
1095
1619
  args: exportArgs,
1096
1620
  sourceDir,
1097
- successMessage: "Archive exported successfully",
1621
+ title: `Exporting ${title}`,
1622
+ successMessage: `Exported ${title}`,
1098
1623
  failureMessage: "Export failed",
1099
- logPrefix
1624
+ logPrefix,
1625
+ logFilePath
1100
1626
  });
1101
1627
  return { exportPath };
1102
1628
  } catch (error) {
@@ -1279,7 +1805,7 @@ var TestFlightBuildExistsError = class extends Error {
1279
1805
  bundleVersion;
1280
1806
  name = "TestFlightBuildExistsError";
1281
1807
  constructor(shortVersion, bundleVersion, options) {
1282
- super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}. Each build of a version needs a new build number (CFBundleVersion).`, options);
1808
+ super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}.\nUse a new build number (CFBundleVersion).`, options);
1283
1809
  this.shortVersion = shortVersion;
1284
1810
  this.bundleVersion = bundleVersion;
1285
1811
  }
@@ -1466,15 +1992,15 @@ const codeSigningOff = async (iosScheme) => {
1466
1992
  * it (`assertTestFlightExportOptions`).
1467
1993
  */
1468
1994
  const resolveTestFlightUploadKey = async ({ scheme, iosScheme }) => {
1469
- if (iosScheme.simulator) throw new Error(`TestFlight takes an .ipa, and the scheme ${scheme} builds a simulator .app.`);
1995
+ if (iosScheme.simulator) throw new Error(`The scheme ${scheme} builds a simulator .app, not an .ipa for TestFlight.`);
1470
1996
  const configuration = iosScheme.configuration ?? "Release";
1471
- if (/debug$/i.test(configuration)) throw new Error(`TestFlight takes a release build, and the scheme ${scheme} builds the ${configuration} configuration, which loads its JavaScript from Metro.`);
1997
+ if (/debug$/i.test(configuration)) throw new Error(`The scheme ${scheme} builds ${configuration}; TestFlight takes a release build.`);
1472
1998
  const unsigned = await codeSigningOff(iosScheme);
1473
- if (unsigned) throw new Error(`TestFlight takes a signed .ipa, and the scheme ${scheme} turns code signing off (${unsigned}).`);
1474
- if (!iosScheme.exportOptionsPlist && !iosScheme.signing) throw new Error(`The scheme ${scheme} archives without exporting an .ipa. Set signing.ascApiKey, or an exportOptionsPlist with the method app-store-connect.`);
1999
+ if (unsigned) throw new Error(`The scheme ${scheme} turns code signing off:\n${unsigned}`);
2000
+ if (!iosScheme.exportOptionsPlist && !iosScheme.signing) throw new Error(`The scheme ${scheme} archives without exporting an .ipa.\nSet signing.ascApiKey, or exportOptionsPlist with method app-store-connect.`);
1475
2001
  if (iosScheme.exportOptionsPlist && fs.default.existsSync(schemeFile(iosScheme.exportOptionsPlist))) {
1476
2002
  const method = await readExportMethod(schemeFile(iosScheme.exportOptionsPlist));
1477
- if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`TestFlight takes an .ipa exported for App Store Connect, and the scheme ${scheme}'s exportOptionsPlist exports with the method ${method || "(none)"}. Set its method to app-store-connect.`);
2003
+ if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`The scheme ${scheme}'s exportOptionsPlist uses method ${method || "(none)"}.\nSet its method to app-store-connect for TestFlight.`);
1478
2004
  }
1479
2005
  return {
1480
2006
  kind: "asc-api-key",
@@ -1489,7 +2015,7 @@ const resolveTestFlightUploadKey = async ({ scheme, iosScheme }) => {
1489
2015
  const assertTestFlightExportOptions = async (schemeConfig) => {
1490
2016
  if (!schemeConfig.exportOptionsPlist) return;
1491
2017
  const method = await readExportMethod(schemeConfig.exportOptionsPlist);
1492
- if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`TestFlight takes an .ipa exported for App Store Connect, and the scheme ${schemeConfig.hotUpdaterSchemeName}'s exportOptionsPlist exports with the method ${method || "(none)"}. Set its method to app-store-connect.`);
2018
+ if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`The scheme ${schemeConfig.hotUpdaterSchemeName}'s exportOptionsPlist uses method ${method || "(none)"}.\nSet its method to app-store-connect for TestFlight.`);
1493
2019
  };
1494
2020
  /**
1495
2021
  * Requests to the App Store Connect API with `apiKey`: each carries a token
@@ -1524,8 +2050,8 @@ const createAppStoreConnectRequest = ({ apiKey, privateKey, fetch }) => {
1524
2050
  });
1525
2051
  if (!response.ok) {
1526
2052
  const error = new AppStoreConnectError(action, response.status, await readApiErrors(response), response.headers.get("retry-after"));
1527
- if (response.status === 401) throw new Error("App Store Connect refused the API key: check its key ID and issuer ID, and that the key is not revoked.", { cause: error });
1528
- if (response.status === 403) throw new Error(`${error.message} An API key with the Admin, App Manager, or Developer role uploads builds.`, { cause: error });
2053
+ if (response.status === 401) throw new Error("App Store Connect refused the API key.\nCheck its key ID and issuer ID, and that it isn't revoked.", { cause: error });
2054
+ if (response.status === 403) throw new Error(isProgramLicenseAgreementError(error.errors) ? `${action} failed (HTTP 403).\n${PROGRAM_LICENSE_AGREEMENT_HINT}` : `${error.message}\nUploads need a key with the Admin, App Manager, or Developer role.`, { cause: error });
1529
2055
  throw error;
1530
2056
  }
1531
2057
  return response.status === 204 ? void 0 : await response.json();
@@ -1538,7 +2064,7 @@ const findApp = async (request, bundleId) => {
1538
2064
  "fields[apps]": "bundleId"
1539
2065
  })}`));
1540
2066
  const app = apps.find((app) => app.attributes?.bundleId === bundleId);
1541
- if (!app) throw new Error(`App Store Connect has no app with the bundle ID ${bundleId} that this API key can see. Create the app in App Store Connect, or use a key of its team.`);
2067
+ if (!app) throw new Error(`App Store Connect has no app ${bundleId} that this API key can see.\nCreate the app in App Store Connect, or use a key of its team.`);
1542
2068
  return {
1543
2069
  app,
1544
2070
  testFlightUrl: `https://appstoreconnect.apple.com/apps/${app.id}/testflight/ios`
@@ -1610,14 +2136,18 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
1610
2136
  } }));
1611
2137
  } catch (error) {
1612
2138
  if (error instanceof AppStoreConnectError && error.status === 409 && error.errors.some((apiError) => apiError.code === DUPLICATE_ERROR_CODE)) {
1613
- if (unanswered) throw new Error(`App Store Connect already has build ${info.bundleVersion} of version ${info.shortVersion}, which a request of this upload it did not answer may have reserved. Upload with a new build number (CFBundleVersion).`, { cause: error });
2139
+ if (unanswered) throw new Error([
2140
+ `App Store Connect already has build ${info.bundleVersion} of version ${info.shortVersion}.`,
2141
+ "An unanswered request of this upload may have reserved it.",
2142
+ "Upload with a new build number (CFBundleVersion)."
2143
+ ].join("\n"), { cause: error });
1614
2144
  throw new TestFlightBuildExistsError(info.shortVersion, info.bundleVersion, { cause: error });
1615
2145
  }
1616
2146
  if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
1617
2147
  if (!isRateLimited(error)) unanswered = true;
1618
2148
  await sleep(retryDelayMs(error, attempt));
1619
2149
  }
1620
- _hot_updater_cli_tools.p.log.info(`Uploading ${ipaName} (version ${info.shortVersion}, build ${info.bundleVersion}) to App Store Connect`);
2150
+ _hot_updater_cli_tools.p.log.info(`Uploading ${ipaName} ${info.shortVersion} (${info.bundleVersion}) to App Store Connect`);
1621
2151
  const removeUpload = async () => {
1622
2152
  for (let attempt = 1;; attempt++) try {
1623
2153
  await request("Removing the build upload", "DELETE", `/v1/buildUploads/${buildUploadId}`);
@@ -1625,7 +2155,11 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
1625
2155
  } catch (error) {
1626
2156
  if (error instanceof AppStoreConnectError && error.status === 404) return;
1627
2157
  if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) {
1628
- _hot_updater_cli_tools.p.log.warn(`App Store Connect kept the unfinished build upload ${buildUploadId}, which reserves build ${info.bundleVersion} of version ${info.shortVersion}: ${error.message} Upload with a new build number (CFBundleVersion).`);
2158
+ _hot_updater_cli_tools.p.log.warn([
2159
+ `App Store Connect kept the unfinished upload of build ${info.bundleVersion}.`,
2160
+ error.message,
2161
+ "Upload with a new build number (CFBundleVersion)."
2162
+ ].join("\n"));
1629
2163
  return;
1630
2164
  }
1631
2165
  await sleep(retryDelayMs(error, attempt));
@@ -1667,7 +2201,12 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
1667
2201
  } });
1668
2202
  break;
1669
2203
  } catch (error) {
1670
- const unconfirmed = () => new Error(`App Store Connect did not confirm the upload of build ${info.bundleVersion}: ${error.message} Check the build in TestFlight before you upload it again: ${testFlightUrl}`, { cause: error });
2204
+ const unconfirmed = () => new Error([
2205
+ `App Store Connect didn't confirm the upload of build ${info.bundleVersion}.`,
2206
+ error.message,
2207
+ "Check TestFlight before you upload it again:",
2208
+ testFlightUrl
2209
+ ].join("\n"), { cause: error });
1671
2210
  if (!isRetryable(error)) {
1672
2211
  if (commitUnanswered) throw unconfirmed();
1673
2212
  await removeUpload();
@@ -1693,8 +2232,13 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
1693
2232
  if (state.state === "PROCESSING") reportedProcessing = true;
1694
2233
  if (!state.state || IN_PROGRESS_STATES.has(state.state)) {
1695
2234
  if (Date.now() >= deadline) {
1696
- if (!reportedProcessing) throw new Error(`App Store Connect did not report build ${info.bundleVersion} processing${lastError instanceof Error ? `: ${lastError.message}` : state.state ? ` (${state.state}).` : "."} Check the build in TestFlight: ${testFlightUrl}`, { cause: lastError });
1697
- _hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${info.bundleVersion}. It appears in TestFlight once processing finishes: ${testFlightUrl}`);
2235
+ if (!reportedProcessing) throw new Error([
2236
+ `App Store Connect didn't report build ${info.bundleVersion} processing.`,
2237
+ lastError instanceof Error ? lastError.message : state.state ? `State: ${state.state}` : void 0,
2238
+ "Check the build in TestFlight:",
2239
+ testFlightUrl
2240
+ ].filter(Boolean).join("\n"), { cause: lastError });
2241
+ _hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${info.bundleVersion}.\nTestFlight lists it once processing finishes:\n${testFlightUrl}`);
1698
2242
  return {
1699
2243
  appId: app.id,
1700
2244
  buildUploadId,
@@ -1825,7 +2369,7 @@ const acquireWithLockf = async (lockPath) => {
1825
2369
  await fs.default.promises.mkdir(path.default.dirname(lockPath), { recursive: true });
1826
2370
  const release = await holdWithLockf(lockPath, false);
1827
2371
  if (release) return release;
1828
- _hot_updater_cli_tools.p.log.info("Waiting for another build that signs in a keychain of its own under this macOS user.");
2372
+ _hot_updater_cli_tools.p.log.info("Waiting for another build's signing keychain under this macOS user.");
1829
2373
  return await holdWithLockf(lockPath, true);
1830
2374
  };
1831
2375
  /**
@@ -1896,7 +2440,12 @@ const createSigningKeychain = async ({ security = runSecurity, directory = os.de
1896
2440
  live = void 0;
1897
2441
  process.off("exit", restoreLive);
1898
2442
  await releaseLock().catch(() => void 0);
1899
- if (failures.length > 0) _hot_updater_cli_tools.p.log.warn(`Restoring the keychains after the build failed (${failures.join("; ")}). Check \`security list-keychains -d user\` and \`security default-keychain -d user\`, and delete ${keychainPath} if it is left.`);
2443
+ if (failures.length > 0) _hot_updater_cli_tools.p.log.warn([
2444
+ "Couldn't restore the keychains after the build.",
2445
+ ...failures,
2446
+ "Check security list-keychains -d user and default-keychain -d user,",
2447
+ `and delete ${keychainPath} if it is left.`
2448
+ ].join("\n"));
1900
2449
  })();
1901
2450
  return disposal;
1902
2451
  };
@@ -1941,38 +2490,62 @@ const createSigningKeychain = async ({ security = runSecurity, directory = os.de
1941
2490
  return {
1942
2491
  path: keychainPath,
1943
2492
  certificates: async () => {
2493
+ const identities = new Set([...(await security([
2494
+ "find-identity",
2495
+ "-p",
2496
+ "codesigning",
2497
+ keychainPath
2498
+ ])).matchAll(/^\s*\d+\)\s+([0-9A-F]{40})\s/gm)].map((match) => match[1]));
2499
+ if (identities.size === 0) return [];
1944
2500
  return ((await security([
1945
2501
  "find-certificate",
1946
2502
  "-a",
1947
2503
  "-p",
1948
2504
  keychainPath
1949
- ])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map((pem) => {
2505
+ ])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).flatMap((pem) => {
1950
2506
  const certificate = new crypto.default.X509Certificate(pem);
1951
- return {
2507
+ if (!identities.has(certificate.fingerprint.replaceAll(":", ""))) return [];
2508
+ return [{
1952
2509
  serialNumber: normalizeSerial(certificate.serialNumber),
1953
2510
  commonName: certificate.subject.match(/^CN=(.*)$/m)?.[1] ?? certificate.subject
1954
- };
2511
+ }];
1955
2512
  });
1956
2513
  },
1957
2514
  dispose
1958
2515
  };
1959
2516
  };
2517
+ /** A request App Store Connect refused, in one line. */
2518
+ var AppStoreConnectRefusal = class extends Error {
2519
+ /** Whether the team has a Program License Agreement to accept. */
2520
+ agreement;
2521
+ constructor(message, agreement) {
2522
+ super(message);
2523
+ this.agreement = agreement;
2524
+ }
2525
+ };
2526
+ const refusal = async (action, response) => {
2527
+ const body = await response.json().catch(() => void 0);
2528
+ return new AppStoreConnectRefusal(`${action} failed (HTTP ${response.status}).`, isProgramLicenseAgreementError(Array.isArray(body?.errors) ? body.errors : []));
2529
+ };
1960
2530
  /**
1961
2531
  * Revokes the certificates Xcode created in the build's keychain, whose
1962
2532
  * private keys the keychain's deletion destroys: on a runner that starts
1963
2533
  * empty, Xcode creates a development certificate for every build, and App
1964
2534
  * Store Connect refuses new ones once a team has its maximum. Each is found
1965
2535
  * in the team's certificates by serial number. A revocation that fails is
1966
- * reported with where to revoke it by hand, and never fails the build.
2536
+ * reported with each certificate's name and serial and where to revoke it
2537
+ * by hand, and never fails the build.
1967
2538
  */
1968
2539
  const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fetch = globalThis.fetch }) => {
1969
2540
  const revoked = [];
1970
2541
  const failed = [];
2542
+ /** Why none could be revoked: the token or the listing failed. */
2543
+ let reason;
2544
+ let agreement = false;
1971
2545
  if (certificates.length === 0) return {
1972
- revoked,
1973
- failed
2546
+ revoked: [],
2547
+ failed: []
1974
2548
  };
1975
- const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
1976
2549
  try {
1977
2550
  const token = createAppStoreConnectToken({
1978
2551
  privateKey: privateKey ?? crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8")),
@@ -1988,7 +2561,7 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
1988
2561
  })}`;
1989
2562
  while (next) {
1990
2563
  const response = await fetch(next, { headers });
1991
- if (!response.ok) throw new Error(`Listing the team's certificates failed (HTTP ${response.status}).`);
2564
+ if (!response.ok) throw await refusal("Listing the team's certificates", response);
1992
2565
  const body = await response.json();
1993
2566
  for (const { id, attributes } of body.data ?? []) if (attributes?.serialNumber) idsBySerial.set(normalizeSerial(attributes.serialNumber), id);
1994
2567
  next = body.links?.next;
@@ -2000,18 +2573,32 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
2000
2573
  method: "DELETE",
2001
2574
  headers
2002
2575
  });
2003
- if (response.ok || response.status === 404) revoked.push(describe(certificate));
2004
- else failed.push(`${describe(certificate)}: HTTP ${response.status}`);
2576
+ if (response.ok || response.status === 404) revoked.push(certificate);
2577
+ else {
2578
+ agreement ||= (await refusal("Revoking", response)).agreement;
2579
+ failed.push({
2580
+ certificate,
2581
+ status: response.status
2582
+ });
2583
+ }
2005
2584
  }
2006
2585
  } catch (error) {
2007
- const reason = error instanceof Error ? error.message : String(error);
2008
- for (const certificate of certificates) if (!revoked.includes(describe(certificate))) failed.push(`${describe(certificate)}: ${reason}`);
2586
+ reason = error instanceof Error ? error.message : String(error);
2587
+ agreement ||= error instanceof AppStoreConnectRefusal && error.agreement;
2588
+ for (const certificate of certificates) if (!revoked.includes(certificate)) failed.push({ certificate });
2009
2589
  }
2010
- if (revoked.length > 0) _hot_updater_cli_tools.p.log.info(`Revoked the signing certificates Xcode created for this build, whose keys the build's keychain held: ${revoked.join(", ")}.`);
2011
- if (failed.length > 0) _hot_updater_cli_tools.p.log.warn(`Revoking the signing certificates Xcode created for this build failed, and their keys are deleted with the build's keychain: ${failed.join("; ")}. Revoke them at ${CERTIFICATES_URL}, since App Store Connect stops creating certificates at a team's maximum.`);
2590
+ const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
2591
+ if (revoked.length > 0) _hot_updater_cli_tools.p.log.info(["Revoked the certificates Xcode created for this build:", ...revoked.flatMap(({ commonName, serialNumber }) => [commonName, `Serial ${serialNumber}`])].join("\n"));
2592
+ if (failed.length > 0) _hot_updater_cli_tools.p.log.warn([
2593
+ "Couldn't revoke the certificates Xcode created for this build.",
2594
+ ...reason === void 0 ? [] : [reason],
2595
+ ...agreement ? [PROGRAM_LICENSE_AGREEMENT_HINT] : [],
2596
+ ...failed.flatMap(({ certificate, status }) => [certificate.commonName, `Serial ${certificate.serialNumber}${status === void 0 ? "" : ` (HTTP ${status})`}`]),
2597
+ CERTIFICATES_URL
2598
+ ].join("\n"));
2012
2599
  return {
2013
- revoked,
2014
- failed
2600
+ revoked: revoked.map(describe),
2601
+ failed: failed.map(({ certificate }) => describe(certificate))
2015
2602
  };
2016
2603
  };
2017
2604
  const TERMINATION_SIGNALS = [
@@ -2036,7 +2623,7 @@ const withSigningKeychain = async ({ enabled, apiKey, run, security, lockPath, a
2036
2623
  lockPath,
2037
2624
  acquireLock
2038
2625
  });
2039
- _hot_updater_cli_tools.p.log.info("Signing in a keychain of this build's own, deleted when it ends (CI is set).");
2626
+ _hot_updater_cli_tools.p.log.info("Signing in a keychain of this build's own (CI is set).");
2040
2627
  let cleanup;
2041
2628
  const release = () => {
2042
2629
  cleanup ??= (async () => {
@@ -2112,22 +2699,44 @@ const readBuiltApp = async (appPath) => {
2112
2699
  * Builds a simulator `.app` for a `simulator` scheme, and otherwise archives
2113
2700
  * and exports an `.ipa`, or returns the `.xcarchive` of a scheme it cannot
2114
2701
  * export: one with neither `exportOptionsPlist` nor `signing.ascApiKey`.
2702
+ *
2703
+ * The artifact is in a temporary `buildDirectory`, which `dispose` removes
2704
+ * once the caller has copied it out. A failed build removes it, and every
2705
+ * other directory the build used, itself.
2115
2706
  */
2116
- const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials }) => {
2707
+ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials, logHeader = [] }) => {
2117
2708
  await assertXcodebuildExist();
2118
- const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2709
+ if (schemeConfig.simulator && schemeConfig.destination.length) {
2710
+ _hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
2711
+ process.exit(1);
2712
+ }
2119
2713
  const buildDirectory = await createRandomTmpDir();
2120
- await fs.default.promises.mkdir(path.default.join(buildDirectory, "archive"), { recursive: true });
2714
+ try {
2715
+ return {
2716
+ ...await buildInto({
2717
+ schemeConfig,
2718
+ minBundleId,
2719
+ credentials,
2720
+ buildDirectory,
2721
+ logHeader
2722
+ }),
2723
+ dispose: () => removeTmpDir(buildDirectory)
2724
+ };
2725
+ } catch (error) {
2726
+ await removeTmpDir(buildDirectory);
2727
+ throw error;
2728
+ }
2729
+ };
2730
+ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirectory, logHeader }) => {
2731
+ const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2121
2732
  if (schemeConfig.simulator) {
2122
- if (schemeConfig.destination.length) {
2123
- _hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
2124
- process.exit(1);
2125
- }
2126
2733
  const appDir = path.default.join(buildDirectory, "app");
2127
2734
  await fs.default.promises.mkdir(appDir, { recursive: true });
2128
- const { appPath } = await buildXcodeProject({
2735
+ const { appPath, logFilePath } = await buildXcodeProject({
2129
2736
  sourceDir: iosProjectRoot,
2737
+ outputDirectory: appDir,
2130
2738
  logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
2739
+ header: logHeader,
2131
2740
  platform: schemeConfig.platform,
2132
2741
  xcodeScheme: schemeConfig.scheme,
2133
2742
  configuration: schemeConfig.configuration,
@@ -2137,13 +2746,11 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
2137
2746
  useGenericDestination: true,
2138
2747
  minBundleId
2139
2748
  });
2140
- const finalAppPath = path.default.join(appDir, path.default.basename(appPath));
2141
- await fs.default.promises.cp(appPath, finalAppPath, { recursive: true });
2142
- _hot_updater_cli_tools.p.log.success(`.app file created: ${path.default.basename(appPath)}`);
2143
2749
  return {
2144
2750
  buildDirectory,
2145
- buildArtifactPath: finalAppPath,
2146
- builtApp: await readBuiltApp(finalAppPath)
2751
+ buildArtifactPath: appPath,
2752
+ logFilePath,
2753
+ builtApp: await readBuiltApp(appPath)
2147
2754
  };
2148
2755
  } else {
2149
2756
  const signing = await resolveXcodeSigning({
@@ -2153,10 +2760,11 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
2153
2760
  if (!signing) return archiveAndExport({
2154
2761
  schemeConfig,
2155
2762
  minBundleId,
2156
- buildDirectory
2763
+ buildDirectory,
2764
+ logHeader
2157
2765
  });
2158
2766
  const appStoreConnect = await exportsForAppStoreConnect(schemeConfig);
2159
- if ((0, _hot_updater_cli_tools.isCI)() && !appStoreConnect) _hot_updater_cli_tools.p.log.info("This export isn't for App Store Connect, so it signs in the runner's own keychains and keeps the certificates Xcode creates.");
2767
+ if ((0, _hot_updater_cli_tools.isCI)() && !appStoreConnect) _hot_updater_cli_tools.p.log.info("Signing in the runner's own keychains (not an App Store Connect export).");
2160
2768
  return withSigningKeychain({
2161
2769
  enabled: (0, _hot_updater_cli_tools.isCI)() && appStoreConnect,
2162
2770
  apiKey: signing.apiKey,
@@ -2164,6 +2772,7 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
2164
2772
  schemeConfig,
2165
2773
  minBundleId,
2166
2774
  buildDirectory,
2775
+ logHeader,
2167
2776
  authenticationArgs: signing.authenticationArgs
2168
2777
  })
2169
2778
  });
@@ -2173,11 +2782,13 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
2173
2782
  * Archives a device build and exports its `.ipa`, or returns the
2174
2783
  * `.xcarchive` of a scheme it cannot export.
2175
2784
  */
2176
- const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, authenticationArgs }) => {
2785
+ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, logHeader, authenticationArgs }) => {
2177
2786
  const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2178
2787
  const archiveDir = path.default.join(buildDirectory, "archive");
2179
2788
  const exportDir = path.default.join(buildDirectory, "export");
2180
- const { archivePath } = await archiveXcodeProject({
2789
+ await fs.default.promises.mkdir(archiveDir, { recursive: true });
2790
+ const { archivePath, logFilePath } = await archiveXcodeProject({
2791
+ archiveDirectory: archiveDir,
2181
2792
  authenticationArgs,
2182
2793
  minBundleId,
2183
2794
  platform: schemeConfig.platform,
@@ -2188,37 +2799,45 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, aut
2188
2799
  extraParams: schemeConfig.extraParams,
2189
2800
  configuration: schemeConfig.configuration,
2190
2801
  installPods: schemeConfig.installPods,
2191
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-archive`
2802
+ logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
2803
+ header: logHeader
2192
2804
  });
2193
- const finalArchivePath = path.default.join(archiveDir, path.default.basename(archivePath));
2194
- await fs.default.promises.cp(archivePath, finalArchivePath, { recursive: true });
2195
- const appFromArchive = extractAppFromXcarchive(finalArchivePath);
2805
+ const appFromArchive = extractAppFromXcarchive(archivePath);
2196
2806
  const archivedAppPath = appFromArchive ? path.default.join(archiveDir, path.default.basename(appFromArchive)) : void 0;
2197
- if (appFromArchive && archivedAppPath) {
2198
- await fs.default.promises.cp(appFromArchive, archivedAppPath, { recursive: true });
2199
- _hot_updater_cli_tools.p.log.success(".app extracted from .xcarchive");
2200
- }
2807
+ if (appFromArchive && archivedAppPath) await fs.default.promises.cp(appFromArchive, archivedAppPath, { recursive: true });
2201
2808
  if (!schemeConfig.exportOptionsPlist && !authenticationArgs) return {
2202
2809
  buildDirectory,
2203
- buildArtifactPath: finalArchivePath,
2810
+ buildArtifactPath: archivePath,
2811
+ logFilePath,
2204
2812
  builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
2205
2813
  };
2206
2814
  await fs.default.promises.mkdir(exportDir, { recursive: true });
2207
- const exportOptionsPlist = schemeConfig.exportOptionsPlist ?? await writeAppStoreConnectExportOptions(await createRandomTmpDir());
2208
- const { exportPath } = await exportXcodeArchive({
2815
+ const exportArchive = (exportOptionsPlist) => exportXcodeArchive({
2209
2816
  archivePath,
2817
+ exportPath: exportDir,
2210
2818
  authenticationArgs,
2211
2819
  exportExtraParams: schemeConfig.exportExtraParams,
2212
2820
  exportOptionsPlist,
2213
2821
  sourceDir: iosProjectRoot,
2214
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-export`
2822
+ logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
2823
+ logFilePath: logFilePath ?? void 0,
2824
+ title: schemeConfig.scheme
2215
2825
  });
2216
- await fs.default.promises.cp(exportPath, exportDir, { recursive: true });
2826
+ if (schemeConfig.exportOptionsPlist) await exportArchive(schemeConfig.exportOptionsPlist);
2827
+ else {
2828
+ const exportOptionsDir = await createRandomTmpDir();
2829
+ try {
2830
+ await exportArchive(await writeAppStoreConnectExportOptions(exportOptionsDir));
2831
+ } finally {
2832
+ await removeTmpDir(exportOptionsDir);
2833
+ }
2834
+ }
2217
2835
  const ipaFile = fs.default.readdirSync(exportDir).find((file) => file.endsWith(".ipa"));
2218
2836
  if (!ipaFile) throw new Error("IPA file not found after export");
2219
2837
  return {
2220
2838
  buildDirectory,
2221
2839
  buildArtifactPath: path.default.join(exportDir, ipaFile),
2840
+ logFilePath,
2222
2841
  builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
2223
2842
  };
2224
2843
  };