@hot-updater/apple-helper 1.2.0-rc.1 → 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
47
43
  /**
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.
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.
52
48
  */
53
- const ASC_CLI_VERSION = "5.16.1";
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
+ };
54
115
  /**
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.
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.
58
119
  */
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 {
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
130
+ /**
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.
137
+ */
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`;
141
+ /**
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.
144
+ */
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$3 = [
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$3) process.off(signal, onSignal);
182
- };
183
- process.on("exit", remove);
184
- for (const signal of TERMINATION_SIGNALS$3) process.on(signal, onSignal);
185
- for (const directory of [
186
- "home",
187
- "session",
188
- "keys"
189
- ]) await node_fs_promises.default.mkdir(node_path.default.join(root, directory), { mode: 448 });
190
- let disposal;
191
- return {
192
- root,
193
- dispose: () => {
194
- disposal ??= (async () => {
195
- stop();
196
- await node_fs_promises.default.rm(root, {
197
- force: true,
198
- recursive: true
199
- });
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.");
303
654
  }
304
- if (key.asymmetricKeyType !== "ec") throw new Error("asc saved a .p8 that is not an EC private key.");
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);
667
+ }
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
  };
@@ -701,80 +1120,113 @@ const parseXcodeProjectInfo = async (sourceDir) => {
701
1120
  const prettifyXcodebuildError = (error) => {
702
1121
  if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
703
1122
  const errorLines = error.summary.join("\n");
704
- const message = errorLines.includes("error: No profiles for") || errorLines.includes("error: Provisioning profile") ? "Not valid provisioning profile is set in the project. Check out the Signing & Capabilities tab in Xcode." : error.message;
705
- return new _hot_updater_cli_tools.BuildFailedError(`Xcodebuild failed: ${message}`, {
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, {
706
1125
  logPath: error.logPath,
707
1126
  summary: error.summary,
1127
+ hint,
708
1128
  cause: error.cause
709
1129
  });
710
1130
  }
711
1131
  return /* @__PURE__ */ new Error(`Xcodebuild failed: ${error}`);
712
1132
  };
713
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
714
1209
  //#region src/utils/createXcodebuildLogger.ts
715
- const createXcodebuildLogger = ({ logPrefix }) => new _hot_updater_cli_tools.BuildLogger({
1210
+ const createXcodebuildLogger = ({ logPrefix, logFilePath }) => new _hot_updater_cli_tools.BuildLogger({
716
1211
  logPrefix,
717
- importantLogPatterns: [
718
- "error:",
719
- "** BUILD FAILED **",
720
- "** ARCHIVE FAILED **",
721
- "The following build commands failed:",
722
- "note: Building targets in dependency order",
723
- "note: Target dependency graph",
724
- "Running script",
725
- "[CP-User]",
726
- "[CP] Check Pods Manifest.lock",
727
- "Build Succeeded"
728
- ],
729
- progressStages: [
730
- ["Building targets in dependency order", "Target dependency graph"],
731
- ["Write Auxiliary File", "[CP] Check Pods Manifest.lock"],
732
- [
733
- "Running script",
734
- "[CP-User] [RN]Check rncore",
735
- "[CP-User] [Hermes] Replace Hermes",
736
- "[CP-User] [RN]Check FBReactNativeSpec"
737
- ],
738
- [
739
- "Processing",
740
- "ProcessProductPackaging",
741
- "ProcessProductPackagingDER"
742
- ],
743
- ["Compiling"],
744
- ["RegisterExecutionPolicyException"],
745
- ["Touching"],
746
- ["Building library", "Create Universal Binary"],
747
- ["Copying"],
748
- [
749
- "Build Succeeded",
750
- "BUILD SUCCEEDED",
751
- "ARCHIVE SUCCEEDED"
752
- ]
753
- ],
1212
+ logFilePath,
1213
+ createPhaseReader: createXcodebuildPhaseReader,
754
1214
  failureSummary: {
755
1215
  lines: [/(^|:\s)error: /, /^\*\* [A-Z ]+ FAILED \*\*$/],
756
1216
  blocks: [{
757
1217
  start: "The following build commands failed:",
758
1218
  end: /^\(\d+ failures?\)$/,
759
1219
  includeEnd: true
1220
+ }, {
1221
+ start: /^error: exportArchive /,
1222
+ end: /^$/,
1223
+ includeEnd: false
760
1224
  }]
761
1225
  }
762
1226
  });
763
1227
  //#endregion
764
1228
  //#region src/utils/runXcodebuildWithLogging.ts
765
- let cachedXcbeautifyAvailability = null;
766
1229
  const TERMINATION_SIGNALS$1 = ["SIGINT", "SIGTERM"];
767
- const isXcbeautifyAvailable = async () => {
768
- if (cachedXcbeautifyAvailability !== null) return cachedXcbeautifyAvailability;
769
- try {
770
- await (0, execa.execa)("which", ["xcbeautify"]);
771
- cachedXcbeautifyAvailability = true;
772
- return true;
773
- } catch {
774
- cachedXcbeautifyAvailability = false;
775
- return false;
776
- }
777
- };
778
1230
  const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
779
1231
  return (0, execa.execa)("xcodebuild", args, {
780
1232
  cwd: sourceDir,
@@ -787,13 +1239,6 @@ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
787
1239
  }
788
1240
  });
789
1241
  };
790
- const createXcbeautifyProcess = ({ cwd }) => {
791
- return (0, execa.execa)("xcbeautify", [], {
792
- cwd,
793
- all: true,
794
- buffer: false
795
- });
796
- };
797
1242
  /** Why xcodebuild failed, without its command line. */
798
1243
  const describeFailure = (error) => {
799
1244
  if (error instanceof execa.ExecaError) {
@@ -802,10 +1247,24 @@ const describeFailure = (error) => {
802
1247
  }
803
1248
  return error instanceof Error ? error.message : String(error);
804
1249
  };
805
- const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage, failureMessage, logPrefix }) => {
806
- const useXcbeautify = await isXcbeautifyAvailable();
807
- const logger = createXcodebuildLogger({ logPrefix });
808
- 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
+ });
809
1268
  const activeProcesses = [];
810
1269
  const terminateActiveProcesses = () => {
811
1270
  for (const activeProcess of activeProcesses) activeProcess.kill();
@@ -819,31 +1278,17 @@ const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage,
819
1278
  });
820
1279
  activeProcesses.push(xcodebuildProcess);
821
1280
  const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
822
- let xcbeautifyProcess = null;
823
- if (useXcbeautify && xcodebuildOutput) {
824
- xcbeautifyProcess = createXcbeautifyProcess({ cwd: sourceDir });
825
- activeProcesses.push(xcbeautifyProcess);
826
- }
827
- const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput, { display: xcbeautifyProcess ? xcbeautifyProcess.all ?? xcbeautifyProcess.stdout ?? void 0 : void 0 }) : Promise.resolve();
828
- const xcbeautifyInput = xcbeautifyProcess?.stdin;
829
- if (xcodebuildOutput && xcbeautifyInput) {
830
- xcbeautifyInput.on("error", () => {
831
- xcodebuildOutput.unpipe(xcbeautifyInput);
832
- });
833
- xcodebuildOutput.pipe(xcbeautifyInput);
834
- }
835
- const [xcodebuildResult, loggingResult] = await Promise.allSettled([
836
- xcodebuildProcess,
837
- logging,
838
- xcbeautifyProcess
839
- ]);
1281
+ const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput) : Promise.resolve();
1282
+ const [xcodebuildResult, loggingResult] = await Promise.allSettled([xcodebuildProcess, logging]);
840
1283
  if (xcodebuildResult.status === "rejected") throw xcodebuildResult.reason;
841
1284
  if (loggingResult.status === "rejected") throw loggingResult.reason;
842
1285
  logger.stop(successMessage);
1286
+ return { logFilePath: logger.logFilePath };
843
1287
  } catch (error) {
844
1288
  logger.writeError(error);
845
- logger.stop(failureMessage, false);
846
- 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}`, {
847
1292
  logPath: logger.logPath,
848
1293
  summary: logger.failureSummary,
849
1294
  cause: error
@@ -935,7 +1380,7 @@ const buildSettingsOverScheme = ({ minBundleId, authenticationArgs }) => ({
935
1380
  * written to a temporary directory, removed whether the archive succeeds or
936
1381
  * not.
937
1382
  */
938
- const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, minBundleId, authenticationArgs }) => {
1383
+ const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, header = [], minBundleId, authenticationArgs }) => {
939
1384
  if (installPods) await installPodsIfNeeded(sourceDir);
940
1385
  const xcodeProject = await parseXcodeProjectInfo(sourceDir);
941
1386
  const archiveName = `${xcodeProject.name.replace(".xcworkspace", "").replace(".xcodeproj", "")}.xcarchive`;
@@ -965,22 +1410,26 @@ const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, inst
965
1410
  xcconfig: build.xcconfig,
966
1411
  xcodeScheme
967
1412
  });
968
- _hot_updater_cli_tools.p.log.info(`Xcode Archive Settings:
969
- Project ${xcodeProject.name}
970
- Scheme ${xcodeScheme}
971
- Platform ${platform}
972
- Command xcodebuild ${archiveArgs.join(" ")}
973
- `);
974
1413
  try {
975
- await runXcodebuildWithLogging({
1414
+ const { logFilePath } = await runXcodebuildWithLogging({
976
1415
  args: archiveArgs,
977
1416
  env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
1417
+ title: `Archiving ${xcodeScheme}`,
1418
+ successMessage: `Archived ${xcodeScheme}`,
978
1419
  failureMessage: "Archive failed",
979
1420
  logPrefix,
980
- sourceDir,
981
- successMessage: "Archive completed successfully"
1421
+ header: [
1422
+ ...header,
1423
+ `Project ${xcodeProject.name}`,
1424
+ `Scheme ${xcodeScheme}`,
1425
+ `Platform ${platform}`
1426
+ ],
1427
+ sourceDir
982
1428
  });
983
- return { archivePath };
1429
+ return {
1430
+ archivePath,
1431
+ logFilePath
1432
+ };
984
1433
  } catch (error) {
985
1434
  throw prettifyXcodebuildError(error);
986
1435
  }
@@ -1023,7 +1472,7 @@ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xco
1023
1472
  * `.app` to `outputDirectory`, and removes DerivedData, which takes 10 GB or
1024
1473
  * more, whether the build succeeds or not.
1025
1474
  */
1026
- const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams, minBundleId, authenticationArgs }) => {
1475
+ const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams, minBundleId, authenticationArgs, header = [] }) => {
1027
1476
  if (installPods) await installPodsIfNeeded(sourceDir);
1028
1477
  const xcodeProject = await parseXcodeProjectInfo(sourceDir);
1029
1478
  const tmpDir = await createRandomTmpDir();
@@ -1061,24 +1510,26 @@ const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeSc
1061
1510
  sourceDir,
1062
1511
  xcodeProject
1063
1512
  });
1064
- _hot_updater_cli_tools.p.log.info(`Xcode Build Settings:
1065
- Project ${xcodeProject.name}
1066
- Scheme ${xcodeScheme}
1067
- Configuration ${configuration}
1068
- Platform ${platform}
1069
- Device Type ${deviceType}
1070
- Command xcodebuild ${buildArgs.join(" ")}
1071
- `);
1072
1513
  let builtAppPath;
1514
+ let logFilePath;
1073
1515
  try {
1074
- await runXcodebuildWithLogging({
1516
+ ({logFilePath} = await runXcodebuildWithLogging({
1075
1517
  args: buildArgs,
1076
1518
  env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
1077
1519
  sourceDir,
1078
1520
  logPrefix,
1079
- successMessage: "Build completed successfully",
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}`,
1080
1531
  failureMessage: "Build failed"
1081
- });
1532
+ }));
1082
1533
  ({appPath: builtAppPath} = await getBuildSettings({
1083
1534
  configuration,
1084
1535
  derivedDataPath,
@@ -1092,7 +1543,10 @@ Command xcodebuild ${buildArgs.join(" ")}
1092
1543
  }
1093
1544
  const appPath = path.default.join(outputDirectory, path.default.basename(builtAppPath));
1094
1545
  await fs.default.promises.cp(builtAppPath, appPath, { recursive: true });
1095
- return { appPath };
1546
+ return {
1547
+ appPath,
1548
+ logFilePath
1549
+ };
1096
1550
  } finally {
1097
1551
  await removeTmpDir(tmpDir);
1098
1552
  }
@@ -1152,7 +1606,7 @@ const getBuildSettings = async ({ xcodeProject, sourceDir, xcodeScheme, configur
1152
1606
  //#endregion
1153
1607
  //#region src/builder/exportXcodeArchive.ts
1154
1608
  /** Exports the archive into `exportPath`. */
1155
- const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, authenticationArgs }) => {
1609
+ const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, logFilePath, title, authenticationArgs }) => {
1156
1610
  const exportArgs = prepareExportArgs({
1157
1611
  authenticationArgs,
1158
1612
  archivePath,
@@ -1164,9 +1618,11 @@ const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportEx
1164
1618
  await runXcodebuildWithLogging({
1165
1619
  args: exportArgs,
1166
1620
  sourceDir,
1167
- successMessage: "Archive exported successfully",
1621
+ title: `Exporting ${title}`,
1622
+ successMessage: `Exported ${title}`,
1168
1623
  failureMessage: "Export failed",
1169
- logPrefix
1624
+ logPrefix,
1625
+ logFilePath
1170
1626
  });
1171
1627
  return { exportPath };
1172
1628
  } catch (error) {
@@ -1349,7 +1805,7 @@ var TestFlightBuildExistsError = class extends Error {
1349
1805
  bundleVersion;
1350
1806
  name = "TestFlightBuildExistsError";
1351
1807
  constructor(shortVersion, bundleVersion, options) {
1352
- super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}. Each build of a version needs a new build number (CFBundleVersion).`, options);
1808
+ super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}.\nUse a new build number (CFBundleVersion).`, options);
1353
1809
  this.shortVersion = shortVersion;
1354
1810
  this.bundleVersion = bundleVersion;
1355
1811
  }
@@ -1536,15 +1992,15 @@ const codeSigningOff = async (iosScheme) => {
1536
1992
  * it (`assertTestFlightExportOptions`).
1537
1993
  */
1538
1994
  const resolveTestFlightUploadKey = async ({ scheme, iosScheme }) => {
1539
- 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.`);
1540
1996
  const configuration = iosScheme.configuration ?? "Release";
1541
- if (/debug$/i.test(configuration)) throw new Error(`TestFlight takes a release build, and the scheme ${scheme} builds the ${configuration} configuration, which loads its JavaScript from Metro.`);
1997
+ if (/debug$/i.test(configuration)) throw new Error(`The scheme ${scheme} builds ${configuration}; TestFlight takes a release build.`);
1542
1998
  const unsigned = await codeSigningOff(iosScheme);
1543
- if (unsigned) throw new Error(`TestFlight takes a signed .ipa, and the scheme ${scheme} turns code signing off (${unsigned}).`);
1544
- if (!iosScheme.exportOptionsPlist && !iosScheme.signing) throw new Error(`The scheme ${scheme} archives without exporting an .ipa. Set signing.ascApiKey, or an exportOptionsPlist with the method app-store-connect.`);
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.`);
1545
2001
  if (iosScheme.exportOptionsPlist && fs.default.existsSync(schemeFile(iosScheme.exportOptionsPlist))) {
1546
2002
  const method = await readExportMethod(schemeFile(iosScheme.exportOptionsPlist));
1547
- if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`TestFlight takes an .ipa exported for App Store Connect, and the scheme ${scheme}'s exportOptionsPlist exports with the method ${method || "(none)"}. Set its method to app-store-connect.`);
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.`);
1548
2004
  }
1549
2005
  return {
1550
2006
  kind: "asc-api-key",
@@ -1559,7 +2015,7 @@ const resolveTestFlightUploadKey = async ({ scheme, iosScheme }) => {
1559
2015
  const assertTestFlightExportOptions = async (schemeConfig) => {
1560
2016
  if (!schemeConfig.exportOptionsPlist) return;
1561
2017
  const method = await readExportMethod(schemeConfig.exportOptionsPlist);
1562
- if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`TestFlight takes an .ipa exported for App Store Connect, and the scheme ${schemeConfig.hotUpdaterSchemeName}'s exportOptionsPlist exports with the method ${method || "(none)"}. Set its method to app-store-connect.`);
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.`);
1563
2019
  };
1564
2020
  /**
1565
2021
  * Requests to the App Store Connect API with `apiKey`: each carries a token
@@ -1594,8 +2050,8 @@ const createAppStoreConnectRequest = ({ apiKey, privateKey, fetch }) => {
1594
2050
  });
1595
2051
  if (!response.ok) {
1596
2052
  const error = new AppStoreConnectError(action, response.status, await readApiErrors(response), response.headers.get("retry-after"));
1597
- if (response.status === 401) throw new Error("App Store Connect refused the API key: check its key ID and issuer ID, and that the key is not revoked.", { cause: error });
1598
- if (response.status === 403) throw new Error(`${error.message} An API key with the Admin, App Manager, or Developer role uploads builds.`, { cause: error });
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 });
1599
2055
  throw error;
1600
2056
  }
1601
2057
  return response.status === 204 ? void 0 : await response.json();
@@ -1608,7 +2064,7 @@ const findApp = async (request, bundleId) => {
1608
2064
  "fields[apps]": "bundleId"
1609
2065
  })}`));
1610
2066
  const app = apps.find((app) => app.attributes?.bundleId === bundleId);
1611
- if (!app) throw new Error(`App Store Connect has no app with the bundle ID ${bundleId} that this API key can see. Create the app in App Store Connect, or use a key of its team.`);
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.`);
1612
2068
  return {
1613
2069
  app,
1614
2070
  testFlightUrl: `https://appstoreconnect.apple.com/apps/${app.id}/testflight/ios`
@@ -1680,14 +2136,18 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
1680
2136
  } }));
1681
2137
  } catch (error) {
1682
2138
  if (error instanceof AppStoreConnectError && error.status === 409 && error.errors.some((apiError) => apiError.code === DUPLICATE_ERROR_CODE)) {
1683
- if (unanswered) throw new Error(`App Store Connect already has build ${info.bundleVersion} of version ${info.shortVersion}, which a request of this upload it did not answer may have reserved. Upload with a new build number (CFBundleVersion).`, { cause: error });
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 });
1684
2144
  throw new TestFlightBuildExistsError(info.shortVersion, info.bundleVersion, { cause: error });
1685
2145
  }
1686
2146
  if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
1687
2147
  if (!isRateLimited(error)) unanswered = true;
1688
2148
  await sleep(retryDelayMs(error, attempt));
1689
2149
  }
1690
- _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`);
1691
2151
  const removeUpload = async () => {
1692
2152
  for (let attempt = 1;; attempt++) try {
1693
2153
  await request("Removing the build upload", "DELETE", `/v1/buildUploads/${buildUploadId}`);
@@ -1695,7 +2155,11 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
1695
2155
  } catch (error) {
1696
2156
  if (error instanceof AppStoreConnectError && error.status === 404) return;
1697
2157
  if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) {
1698
- _hot_updater_cli_tools.p.log.warn(`App Store Connect kept the unfinished build upload ${buildUploadId}, which reserves build ${info.bundleVersion} of version ${info.shortVersion}: ${error.message} Upload with a new build number (CFBundleVersion).`);
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"));
1699
2163
  return;
1700
2164
  }
1701
2165
  await sleep(retryDelayMs(error, attempt));
@@ -1737,7 +2201,12 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
1737
2201
  } });
1738
2202
  break;
1739
2203
  } catch (error) {
1740
- const unconfirmed = () => new Error(`App Store Connect did not confirm the upload of build ${info.bundleVersion}: ${error.message} Check the build in TestFlight before you upload it again: ${testFlightUrl}`, { cause: error });
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 });
1741
2210
  if (!isRetryable(error)) {
1742
2211
  if (commitUnanswered) throw unconfirmed();
1743
2212
  await removeUpload();
@@ -1763,8 +2232,13 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
1763
2232
  if (state.state === "PROCESSING") reportedProcessing = true;
1764
2233
  if (!state.state || IN_PROGRESS_STATES.has(state.state)) {
1765
2234
  if (Date.now() >= deadline) {
1766
- if (!reportedProcessing) throw new Error(`App Store Connect did not report build ${info.bundleVersion} processing${lastError instanceof Error ? `: ${lastError.message}` : state.state ? ` (${state.state}).` : "."} Check the build in TestFlight: ${testFlightUrl}`, { cause: lastError });
1767
- _hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${info.bundleVersion}. It appears in TestFlight once processing finishes: ${testFlightUrl}`);
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}`);
1768
2242
  return {
1769
2243
  appId: app.id,
1770
2244
  buildUploadId,
@@ -1895,7 +2369,7 @@ const acquireWithLockf = async (lockPath) => {
1895
2369
  await fs.default.promises.mkdir(path.default.dirname(lockPath), { recursive: true });
1896
2370
  const release = await holdWithLockf(lockPath, false);
1897
2371
  if (release) return release;
1898
- _hot_updater_cli_tools.p.log.info("Waiting for another build that signs in a keychain of its own under this macOS user.");
2372
+ _hot_updater_cli_tools.p.log.info("Waiting for another build's signing keychain under this macOS user.");
1899
2373
  return await holdWithLockf(lockPath, true);
1900
2374
  };
1901
2375
  /**
@@ -1966,7 +2440,12 @@ const createSigningKeychain = async ({ security = runSecurity, directory = os.de
1966
2440
  live = void 0;
1967
2441
  process.off("exit", restoreLive);
1968
2442
  await releaseLock().catch(() => void 0);
1969
- if (failures.length > 0) _hot_updater_cli_tools.p.log.warn(`Restoring the keychains after the build failed (${failures.join("; ")}). Check \`security list-keychains -d user\` and \`security default-keychain -d user\`, and delete ${keychainPath} if it is left.`);
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"));
1970
2449
  })();
1971
2450
  return disposal;
1972
2451
  };
@@ -2011,38 +2490,62 @@ const createSigningKeychain = async ({ security = runSecurity, directory = os.de
2011
2490
  return {
2012
2491
  path: keychainPath,
2013
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 [];
2014
2500
  return ((await security([
2015
2501
  "find-certificate",
2016
2502
  "-a",
2017
2503
  "-p",
2018
2504
  keychainPath
2019
- ])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map((pem) => {
2505
+ ])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).flatMap((pem) => {
2020
2506
  const certificate = new crypto.default.X509Certificate(pem);
2021
- return {
2507
+ if (!identities.has(certificate.fingerprint.replaceAll(":", ""))) return [];
2508
+ return [{
2022
2509
  serialNumber: normalizeSerial(certificate.serialNumber),
2023
2510
  commonName: certificate.subject.match(/^CN=(.*)$/m)?.[1] ?? certificate.subject
2024
- };
2511
+ }];
2025
2512
  });
2026
2513
  },
2027
2514
  dispose
2028
2515
  };
2029
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
+ };
2030
2530
  /**
2031
2531
  * Revokes the certificates Xcode created in the build's keychain, whose
2032
2532
  * private keys the keychain's deletion destroys: on a runner that starts
2033
2533
  * empty, Xcode creates a development certificate for every build, and App
2034
2534
  * Store Connect refuses new ones once a team has its maximum. Each is found
2035
2535
  * in the team's certificates by serial number. A revocation that fails is
2036
- * reported with where to revoke it by hand, and never fails the build.
2536
+ * reported with each certificate's name and serial and where to revoke it
2537
+ * by hand, and never fails the build.
2037
2538
  */
2038
2539
  const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fetch = globalThis.fetch }) => {
2039
2540
  const revoked = [];
2040
2541
  const failed = [];
2542
+ /** Why none could be revoked: the token or the listing failed. */
2543
+ let reason;
2544
+ let agreement = false;
2041
2545
  if (certificates.length === 0) return {
2042
- revoked,
2043
- failed
2546
+ revoked: [],
2547
+ failed: []
2044
2548
  };
2045
- const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
2046
2549
  try {
2047
2550
  const token = createAppStoreConnectToken({
2048
2551
  privateKey: privateKey ?? crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8")),
@@ -2058,7 +2561,7 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
2058
2561
  })}`;
2059
2562
  while (next) {
2060
2563
  const response = await fetch(next, { headers });
2061
- 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);
2062
2565
  const body = await response.json();
2063
2566
  for (const { id, attributes } of body.data ?? []) if (attributes?.serialNumber) idsBySerial.set(normalizeSerial(attributes.serialNumber), id);
2064
2567
  next = body.links?.next;
@@ -2070,18 +2573,32 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
2070
2573
  method: "DELETE",
2071
2574
  headers
2072
2575
  });
2073
- if (response.ok || response.status === 404) revoked.push(describe(certificate));
2074
- 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
+ }
2075
2584
  }
2076
2585
  } catch (error) {
2077
- const reason = error instanceof Error ? error.message : String(error);
2078
- for (const certificate of certificates) if (!revoked.includes(describe(certificate))) failed.push(`${describe(certificate)}: ${reason}`);
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 });
2079
2589
  }
2080
- if (revoked.length > 0) _hot_updater_cli_tools.p.log.info(`Revoked the signing certificates Xcode created for this build, whose keys the build's keychain held: ${revoked.join(", ")}.`);
2081
- if (failed.length > 0) _hot_updater_cli_tools.p.log.warn(`Revoking the signing certificates Xcode created for this build failed, and their keys are deleted with the build's keychain: ${failed.join("; ")}. Revoke them at ${CERTIFICATES_URL}, since App Store Connect stops creating certificates at a team's maximum.`);
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"));
2082
2599
  return {
2083
- revoked,
2084
- failed
2600
+ revoked: revoked.map(describe),
2601
+ failed: failed.map(({ certificate }) => describe(certificate))
2085
2602
  };
2086
2603
  };
2087
2604
  const TERMINATION_SIGNALS = [
@@ -2106,7 +2623,7 @@ const withSigningKeychain = async ({ enabled, apiKey, run, security, lockPath, a
2106
2623
  lockPath,
2107
2624
  acquireLock
2108
2625
  });
2109
- _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).");
2110
2627
  let cleanup;
2111
2628
  const release = () => {
2112
2629
  cleanup ??= (async () => {
@@ -2187,7 +2704,7 @@ const readBuiltApp = async (appPath) => {
2187
2704
  * once the caller has copied it out. A failed build removes it, and every
2188
2705
  * other directory the build used, itself.
2189
2706
  */
2190
- 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 = [] }) => {
2191
2708
  await assertXcodebuildExist();
2192
2709
  if (schemeConfig.simulator && schemeConfig.destination.length) {
2193
2710
  _hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
@@ -2200,7 +2717,8 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
2200
2717
  schemeConfig,
2201
2718
  minBundleId,
2202
2719
  credentials,
2203
- buildDirectory
2720
+ buildDirectory,
2721
+ logHeader
2204
2722
  }),
2205
2723
  dispose: () => removeTmpDir(buildDirectory)
2206
2724
  };
@@ -2209,15 +2727,16 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
2209
2727
  throw error;
2210
2728
  }
2211
2729
  };
2212
- const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirectory }) => {
2730
+ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirectory, logHeader }) => {
2213
2731
  const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2214
2732
  if (schemeConfig.simulator) {
2215
2733
  const appDir = path.default.join(buildDirectory, "app");
2216
2734
  await fs.default.promises.mkdir(appDir, { recursive: true });
2217
- const { appPath } = await buildXcodeProject({
2735
+ const { appPath, logFilePath } = await buildXcodeProject({
2218
2736
  sourceDir: iosProjectRoot,
2219
2737
  outputDirectory: appDir,
2220
2738
  logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
2739
+ header: logHeader,
2221
2740
  platform: schemeConfig.platform,
2222
2741
  xcodeScheme: schemeConfig.scheme,
2223
2742
  configuration: schemeConfig.configuration,
@@ -2227,10 +2746,10 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
2227
2746
  useGenericDestination: true,
2228
2747
  minBundleId
2229
2748
  });
2230
- _hot_updater_cli_tools.p.log.success(`.app file created: ${path.default.basename(appPath)}`);
2231
2749
  return {
2232
2750
  buildDirectory,
2233
2751
  buildArtifactPath: appPath,
2752
+ logFilePath,
2234
2753
  builtApp: await readBuiltApp(appPath)
2235
2754
  };
2236
2755
  } else {
@@ -2241,10 +2760,11 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
2241
2760
  if (!signing) return archiveAndExport({
2242
2761
  schemeConfig,
2243
2762
  minBundleId,
2244
- buildDirectory
2763
+ buildDirectory,
2764
+ logHeader
2245
2765
  });
2246
2766
  const appStoreConnect = await exportsForAppStoreConnect(schemeConfig);
2247
- if ((0, _hot_updater_cli_tools.isCI)() && !appStoreConnect) _hot_updater_cli_tools.p.log.info("This export isn't for App Store Connect, so it signs in the runner's own keychains and keeps the certificates Xcode creates.");
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).");
2248
2768
  return withSigningKeychain({
2249
2769
  enabled: (0, _hot_updater_cli_tools.isCI)() && appStoreConnect,
2250
2770
  apiKey: signing.apiKey,
@@ -2252,6 +2772,7 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
2252
2772
  schemeConfig,
2253
2773
  minBundleId,
2254
2774
  buildDirectory,
2775
+ logHeader,
2255
2776
  authenticationArgs: signing.authenticationArgs
2256
2777
  })
2257
2778
  });
@@ -2261,12 +2782,12 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
2261
2782
  * Archives a device build and exports its `.ipa`, or returns the
2262
2783
  * `.xcarchive` of a scheme it cannot export.
2263
2784
  */
2264
- const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, authenticationArgs }) => {
2785
+ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, logHeader, authenticationArgs }) => {
2265
2786
  const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
2266
2787
  const archiveDir = path.default.join(buildDirectory, "archive");
2267
2788
  const exportDir = path.default.join(buildDirectory, "export");
2268
2789
  await fs.default.promises.mkdir(archiveDir, { recursive: true });
2269
- const { archivePath } = await archiveXcodeProject({
2790
+ const { archivePath, logFilePath } = await archiveXcodeProject({
2270
2791
  archiveDirectory: archiveDir,
2271
2792
  authenticationArgs,
2272
2793
  minBundleId,
@@ -2278,17 +2799,16 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, aut
2278
2799
  extraParams: schemeConfig.extraParams,
2279
2800
  configuration: schemeConfig.configuration,
2280
2801
  installPods: schemeConfig.installPods,
2281
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-archive`
2802
+ logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
2803
+ header: logHeader
2282
2804
  });
2283
2805
  const appFromArchive = extractAppFromXcarchive(archivePath);
2284
2806
  const archivedAppPath = appFromArchive ? path.default.join(archiveDir, path.default.basename(appFromArchive)) : void 0;
2285
- if (appFromArchive && archivedAppPath) {
2286
- await fs.default.promises.cp(appFromArchive, archivedAppPath, { recursive: true });
2287
- _hot_updater_cli_tools.p.log.success(".app extracted from .xcarchive");
2288
- }
2807
+ if (appFromArchive && archivedAppPath) await fs.default.promises.cp(appFromArchive, archivedAppPath, { recursive: true });
2289
2808
  if (!schemeConfig.exportOptionsPlist && !authenticationArgs) return {
2290
2809
  buildDirectory,
2291
2810
  buildArtifactPath: archivePath,
2811
+ logFilePath,
2292
2812
  builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
2293
2813
  };
2294
2814
  await fs.default.promises.mkdir(exportDir, { recursive: true });
@@ -2299,7 +2819,9 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, aut
2299
2819
  exportExtraParams: schemeConfig.exportExtraParams,
2300
2820
  exportOptionsPlist,
2301
2821
  sourceDir: iosProjectRoot,
2302
- logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-export`
2822
+ logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
2823
+ logFilePath: logFilePath ?? void 0,
2824
+ title: schemeConfig.scheme
2303
2825
  });
2304
2826
  if (schemeConfig.exportOptionsPlist) await exportArchive(schemeConfig.exportOptionsPlist);
2305
2827
  else {
@@ -2315,6 +2837,7 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, aut
2315
2837
  return {
2316
2838
  buildDirectory,
2317
2839
  buildArtifactPath: path.default.join(exportDir, ipaFile),
2840
+ logFilePath,
2318
2841
  builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
2319
2842
  };
2320
2843
  };