@hot-updater/apple-helper 1.2.0-rc.4 → 1.2.0-rc.6

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,214 +23,555 @@ 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, then run this 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 to issue an App Store Connect API key needs macOS.");
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}.\n${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(`Couldn't download asc ${ASC_CLI_VERSION} from GitHub.\n${error instanceof Error ? error.message : String(error)}\n${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(`Couldn't download asc ${ASC_CLI_VERSION} from GitHub (HTTP ${response.status}).\n${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([
135
- `The downloaded asc ${ASC_CLI_VERSION} failed its SHA-256 check; it was deleted.`,
136
- `Expected ${expected}`,
137
- `Got ${actual}`,
138
- INSTALL_HINT
139
- ].join("\n"));
140
- await node_fs_promises.default.chmod(partial, 493);
141
- await node_fs_promises.default.rename(partial, binary);
142
- } finally {
143
- await node_fs_promises.default.rm(partial, { force: true });
144
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;
145
416
  return {
146
- path: binary,
147
- version: ASC_CLI_VERSION,
148
- source: "download"
417
+ providerId: value.providerId,
418
+ publicProviderId,
419
+ name: text$1(value.name)
149
420
  };
150
421
  };
151
- //#endregion
152
- //#region src/asc/ascCliKeyIssuer.ts
153
- /** An asc command failed; its message is asc's own, which it printed too. */
154
- var AscCommandError = class extends Error {
155
- exitCode;
156
- constructor(message, exitCode, options) {
157
- super(message, options);
158
- this.name = "AscCommandError";
159
- this.exitCode = exitCode;
160
- }
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
+ };
161
437
  };
162
- const TERMINATION_SIGNALS$3 = [
163
- "SIGHUP",
164
- "SIGINT",
165
- "SIGTERM"
166
- ];
167
438
  /**
168
- * A 0700 directory holding asc's whole state for one sign-in (its web
169
- * session, config, and the downloaded `.p8`), removed by `dispose`, on
170
- * `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.
171
442
  */
172
- const createWorkDirectory = async () => {
173
- const root = await node_fs_promises.default.mkdtemp(node_path.default.join(node_os.default.tmpdir(), "hot-updater-asc-"));
174
- await node_fs_promises.default.chmod(root, 448);
175
- const remove = () => (0, node_fs.rmSync)(root, {
176
- force: true,
177
- recursive: true
178
- });
179
- const onSignal = (signal) => {
180
- stop();
181
- remove();
182
- if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
183
- };
184
- const stop = () => {
185
- process.off("exit", remove);
186
- for (const signal of TERMINATION_SIGNALS$3) process.off(signal, onSignal);
187
- };
188
- process.on("exit", remove);
189
- for (const signal of TERMINATION_SIGNALS$3) process.on(signal, onSignal);
190
- for (const directory of [
191
- "home",
192
- "session",
193
- "keys"
194
- ]) await node_fs_promises.default.mkdir(node_path.default.join(root, directory), { mode: 448 });
195
- let disposal;
196
- return {
197
- root,
198
- dispose: () => {
199
- disposal ??= (async () => {
200
- stop();
201
- await node_fs_promises.default.rm(root, {
202
- force: true,
203
- recursive: true
204
- });
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;
205
505
  })();
206
- 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);
207
514
  }
208
- };
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
+ }
209
552
  };
553
+ //#endregion
554
+ //#region src/asc/ascKeyIssuer.ts
555
+ const IRIS_URL = "https://appstoreconnect.apple.com/iris/v1";
210
556
  /**
211
- * asc's environment: the caller's, without any `ASC_*` variable (such as a
212
- * saved password, a 2FA code command, or another API key), and with every
213
- * file asc keeps (home, config, web session) in `root`. Passwords are never
214
- * stored, the keychain is bypassed, and telemetry is off.
557
+ * The headers App Store Connect's Integrations page sends its API with. A
558
+ * function: `ascApiKey` imports this module, so its constants aren't set
559
+ * yet when this module loads.
215
560
  */
216
- const ascEnvironment = (root, base = process.env) => {
217
- const env = {};
218
- for (const [name, value] of Object.entries(base)) if (!name.startsWith("ASC_") && name !== "DO_NOT_TRACK") env[name] = value;
219
- return {
220
- ...env,
221
- HOME: node_path.default.join(root, "home"),
222
- XDG_CONFIG_HOME: node_path.default.join(root, "home", ".config"),
223
- XDG_CACHE_HOME: node_path.default.join(root, "home", ".cache"),
224
- ASC_CONFIG_PATH: node_path.default.join(root, "home", "config.json"),
225
- ASC_WEB_SESSION_CACHE_DIR: node_path.default.join(root, "session"),
226
- ASC_WEB_SESSION_CACHE_BACKEND: "file",
227
- ASC_WEB_DONT_STORE_PASSWORD: "1",
228
- ASC_BYPASS_KEYCHAIN: "1",
229
- ASC_TELEMETRY_DISABLED: "1",
230
- ASC_SPINNER_DISABLED: "1"
231
- };
232
- };
561
+ const irisHeaders = () => ({
562
+ Accept: "application/vnd.api+json, application/json, text/csv",
563
+ "Content-Type": "application/json",
564
+ "X-CSRF-ITC": "[asc-ui]",
565
+ Origin: "https://appstoreconnect.apple.com",
566
+ Referer: ASC_INTEGRATIONS_URL
567
+ });
233
568
  const answer = (value) => _hot_updater_cli_tools.p.isCancel(value) ? void 0 : value;
569
+ /** One line: where the code went, and where a wrong one sends the next. */
570
+ const twoFactorMessage = (request) => {
571
+ const phone = request.destination ?? "your trusted phone";
572
+ if (request.via === "sms") return `${request.afterWrongCode ? "Wrong code. " : ""}Code texted to ${phone}`;
573
+ return request.textsOnWrongCode ? `Code on your Apple device (wrong code texts ${phone})` : "Code on your Apple device";
574
+ };
234
575
  /** Hot Updater's own prompts, so the sign-in looks like the rest of the CLI. */
235
576
  const cliPrompts = {
236
577
  password: async (appleId) => answer(await _hot_updater_cli_tools.p.password({
@@ -238,65 +579,53 @@ const cliPrompts = {
238
579
  mask: "•",
239
580
  validate: (value) => value ? void 0 : "Enter the password."
240
581
  })),
241
- twoFactorCode: async (attempt) => answer(await _hot_updater_cli_tools.p.text({
242
- message: attempt === 1 ? "Two-factor code (no trusted device? enter a wrong one for an SMS)" : "Verification code Apple sent to your phone",
243
- validate: (value) => /^\d{6}$/u.test(value?.trim() ?? "") ? void 0 : "Enter the 6-digit code."
582
+ twoFactorCode: async (request) => answer(await _hot_updater_cli_tools.p.text({
583
+ message: twoFactorMessage(request),
584
+ validate: (value) => new RegExp(`^\\d{${request.length}}$`, "u").test(value?.trim() ?? "") ? void 0 : `Enter the ${request.length}-digit code.`
244
585
  }))?.trim()
245
586
  };
246
- /** `value` as one word for `sh`. */
247
- const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
248
- /**
249
- * A two-factor code command for asc that asks `prompts` in this process:
250
- * asc runs it when Apple wants a code, and it prints what the developer
251
- * typed, which it gets over a socket in the 0700 work directory. A
252
- * cancelled prompt makes it print nothing and fail, which stops the sign-in.
253
- */
254
- const listenForTwoFactorCodes = async (root, prompts) => {
255
- const socketPath = node_path.default.join(root, "2fa.sock");
256
- const scriptPath = node_path.default.join(root, "two-factor-code.cjs");
257
- await node_fs_promises.default.writeFile(scriptPath, [
258
- "const socket = require(\"node:net\").connect(" + JSON.stringify(socketPath) + ");",
259
- "let code = \"\";",
260
- "socket.on(\"data\", (chunk) => { code += chunk; });",
261
- "socket.on(\"end\", () => {",
262
- " code = code.trim();",
263
- " if (!code) process.exit(1);",
264
- " process.stdout.write(code + \"\\n\");",
265
- "});",
266
- "socket.on(\"error\", () => process.exit(1));",
267
- ""
268
- ].join("\n"), { mode: 384 });
269
- let attempts = 0;
270
- let cancelled = false;
271
- const server = node_net.default.createServer((socket) => {
272
- attempts += 1;
273
- prompts.twoFactorCode(attempts).then((code) => {
274
- if (!code) cancelled = true;
275
- socket.end(code ? `${code}\n` : "");
276
- }, () => {
277
- cancelled = true;
278
- socket.destroy();
279
- });
280
- });
281
- await new Promise((resolve, reject) => {
282
- server.once("error", reject);
283
- server.listen(socketPath, resolve);
284
- });
285
- return {
286
- command: `${shellQuote(process.execPath)} ${shellQuote(scriptPath)}`,
287
- cancelled: () => cancelled,
288
- close: () => new Promise((resolve) => server.close(() => resolve()))
289
- };
290
- };
291
587
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
292
588
  const text = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
293
- /** The last line of asc's error output, which names what failed. */
294
- const lastLine = (stderr) => stderr.split("\n").map((line) => line.trim()).filter(Boolean).at(-1);
295
- /** What to do about a key asc could not create, by what App Store Connect said. */
296
- const createFailureHint = (stderr) => {
297
- if (/agreement/iu.test(stderr)) return "The Account Holder must accept the new Apple Developer Program License\nAgreement at https://developer.apple.com/account, then run this again.";
298
- if (/limit|maximum/iu.test(stderr)) return `The team has the most API keys App Store Connect allows. Revoke one at:\n${ASC_INTEGRATIONS_URL}`;
299
- if (/account holder|admin|permission|forbidden|not allowed|403/iu.test(stderr)) return "Creating an API key needs the Account Holder or Admin role in the team.";
589
+ const toTeam = (appleId, provider) => ({
590
+ appleId,
591
+ teamId: provider.publicProviderId,
592
+ ...provider.name ? { name: provider.name } : {}
593
+ });
594
+ /** What App Store Connect's API said went wrong, from its JSON:API errors. */
595
+ const irisDetail = (body) => {
596
+ try {
597
+ const parsed = JSON.parse(body);
598
+ if (!isRecord(parsed) || !Array.isArray(parsed.errors)) return void 0;
599
+ return parsed.errors.filter(isRecord).map((error) => text(error.detail) ?? text(error.title)).find(Boolean);
600
+ } catch {
601
+ return;
602
+ }
603
+ };
604
+ /** What to do about a key App Store Connect would not create. */
605
+ const createFailureHint = (status, body) => {
606
+ if (/agreement/iu.test(body)) return "The Account Holder must accept the new Apple Developer Program License\nAgreement at https://developer.apple.com/account, then run this again.";
607
+ 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}`;
608
+ 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.";
609
+ };
610
+ const irisRequest = async (session, step, method, path, payload) => {
611
+ const response = await session.request(`${IRIS_URL}${path}`, {
612
+ method,
613
+ headers: irisHeaders(),
614
+ ...payload === void 0 ? {} : { body: JSON.stringify(payload) }
615
+ });
616
+ const body = await response.text();
617
+ if (!response.ok) {
618
+ const detail = irisDetail(body);
619
+ throw new AppleSignInError(`${step} failed (HTTP ${response.status}).${detail ? `\n${detail}` : ""}`, {
620
+ status: response.status,
621
+ body
622
+ });
623
+ }
624
+ try {
625
+ const parsed = JSON.parse(body);
626
+ if (isRecord(parsed) && isRecord(parsed.data)) return parsed.data;
627
+ } catch {}
628
+ throw new AppleSignInError(`${step} answered no API key.`, { status: response.status });
300
629
  };
301
630
  /** A P-256 private key, as App Store Connect issues one, or a thrown error. */
302
631
  const assertP8 = (p8) => {
@@ -304,144 +633,108 @@ const assertP8 = (p8) => {
304
633
  try {
305
634
  key = node_crypto.default.createPrivateKey(p8);
306
635
  } catch {
307
- throw new Error("asc saved a .p8 that is not a private key.");
636
+ throw new Error("App Store Connect sent a .p8 that is not a private key.");
308
637
  }
309
- if (key.asymmetricKeyType !== "ec") throw new Error("asc saved a .p8 that is not an EC private key.");
638
+ 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.");
639
+ };
640
+ /** The one-time `.p8`; a key App Store Connect hasn't settled yet is retried. */
641
+ const downloadP8 = async (session, keyId, wait) => {
642
+ for (let attempt = 1;; attempt++) try {
643
+ const data = await irisRequest(session, "Downloading the key's .p8", "GET", `/apiKeys/${encodeURIComponent(keyId)}?${new URLSearchParams({ "fields[apiKeys]": "privateKey" })}`);
644
+ const encoded = isRecord(data.attributes) ? text(data.attributes.privateKey) : void 0;
645
+ if (text(data.id) !== keyId || !encoded) throw new Error("App Store Connect sent no .p8 for the key.");
646
+ return Buffer.from(encoded, "base64").toString("utf-8").trim();
647
+ } catch (error) {
648
+ if (!(error instanceof AppleSignInError && error.status !== void 0 && (error.status === 404 || error.status === 409 || error.status === 429 || error.status >= 500)) || attempt === 3) throw error;
649
+ await wait(attempt * 1e3);
650
+ }
651
+ };
652
+ const issuerOf = (data) => {
653
+ const attributes = isRecord(data.attributes) ? data.attributes : {};
654
+ const relationships = isRecord(data.relationships) ? data.relationships : {};
655
+ const related = isRecord(relationships.provider) ? relationships.provider.data : void 0;
656
+ return (isRecord(attributes.provider) ? text(attributes.provider.id) : void 0) ?? (isRecord(related) ? text(related.id) : void 0);
310
657
  };
311
658
  /**
312
- * `IssueAscApiKey` with asc, the open-source App Store Connect CLI
313
- * (https://github.com/rorkai/App-Store-Connect-CLI, MIT):
659
+ * `IssueAscApiKey` by signing in as App Store Connect's web page does
660
+ * (`signInToAppStoreConnect`) and creating the key with the API its
661
+ * Integrations page uses:
314
662
  *
315
- * 1. `asc web auth login` signs in with the password and the two-factor
316
- * codes typed into Hot Updater's prompts. The password reaches only that
317
- * asc process, in its environment (never an argument), each code only
318
- * asc's two-factor code command, and neither is kept or printed.
319
- * 2. `chooseTeam` sees the team asc signed in to, unless `teamId` is set.
320
- * 3. `asc web api-keys create` issues the key and saves its one-time `.p8`,
321
- * which is checked before it is handed out; a key that fails the check is
322
- * revoked at once.
323
- * 4. `revoke` runs `asc web api-keys revoke` in the same session.
663
+ * 1. The password typed into Hot Updater's prompt goes only to Apple's SRP
664
+ * sign-in, which sends a proof of it, never the password; the two-factor
665
+ * code goes only to Apple's check. Neither is kept or printed.
666
+ * 2. `chooseTeam` sees the teams the Apple Account is in, unless `teamId`
667
+ * is set.
668
+ * 3. The key is created, its one-time `.p8` downloaded and checked; a key
669
+ * that fails the check is revoked at once.
670
+ * 4. `revoke` deactivates the key in the same session.
324
671
  *
325
- * asc's whole state (its web session, config, and the `.p8`) lives in a
326
- * temporary 0700 directory, which `dispose` deletes, as does the process
327
- * exiting first.
672
+ * The session is cookies in this process's memory, which `dispose` drops.
328
673
  *
329
- * @param asc The asc binary; found or downloaded with `findAscCli` unless set.
330
- * @param env The environment asc's own is derived from.
674
+ * @param fetch Where requests go, Apple unless set.
331
675
  * @param prompts Where the password and two-factor codes are asked.
676
+ * @param wait How a retried download waits.
332
677
  */
333
- const createAscCliKeyIssuer = ({ asc, env = process.env, prompts = cliPrompts } = {}) => async ({ name, role, appleId, teamId, chooseTeam }) => {
334
- const cli = asc ?? await findAscCli();
335
- _hot_updater_cli_tools.p.log.info("Signing in with asc, the App Store Connect CLI. Only the issued key is kept.");
678
+ const createAscKeyIssuer = ({ fetch = globalThis.fetch, prompts = cliPrompts, wait = node_timers_promises.setTimeout } = {}) => async ({ name, role, appleId, teamId, chooseTeam }) => {
679
+ if (!appleId) throw new AppleSignInError("Signing in needs the Apple Account's email.");
680
+ _hot_updater_cli_tools.p.log.info("Signing in to App Store Connect. Only the issued key is kept.");
336
681
  const password = await prompts.password(appleId);
337
- if (password === void 0) throw new AscCommandError("Signing in was cancelled.", void 0);
682
+ if (password === void 0) throw new AppleSignInError("Signing in was cancelled.");
338
683
  (0, _hot_updater_cli_tools.registerSecretValue)(password);
339
- const work = await createWorkDirectory();
340
- const ascEnv = ascEnvironment(work.root, env);
341
- const run = async (step, args, extraEnv = {}) => {
342
- const result = await (0, execa.execa)(cli.path, args, {
343
- env: {
344
- ...ascEnv,
345
- ...extraEnv
346
- },
347
- extendEnv: false,
348
- stdin: "ignore",
349
- stdout: "pipe",
350
- stderr: "pipe",
351
- reject: false
352
- });
353
- const stderr = String(result.stderr ?? "");
354
- 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 } });
355
- let parsed;
356
- try {
357
- parsed = JSON.parse(String(result.stdout));
358
- } catch {
359
- parsed = void 0;
360
- }
361
- if (!isRecord(parsed)) throw new AscCommandError(`${step} printed no JSON this version of Hot Updater reads.`, result.exitCode);
362
- return parsed;
684
+ const session = await signInToAppStoreConnect({
685
+ appleId,
686
+ password,
687
+ prompts,
688
+ fetch
689
+ });
690
+ let disposed = false;
691
+ const dispose = async () => {
692
+ disposed = true;
693
+ session.clear();
363
694
  };
364
695
  try {
365
- const twoFactor = await listenForTwoFactorCodes(work.root, prompts);
366
- let login;
696
+ const teams = session.providers.map((provider) => toTeam(appleId, provider));
697
+ const chosen = teamId ?? await chooseTeam?.(teams, toTeam(appleId, session.provider));
698
+ const provider = chosen === void 0 ? session.provider : await session.selectProvider(chosen);
699
+ let data;
367
700
  try {
368
- login = await run("Signing in with asc web auth login", [
369
- "web",
370
- "auth",
371
- "login",
372
- ...appleId ? ["--apple-id", appleId] : [],
373
- "--two-factor-code-command",
374
- twoFactor.command,
375
- "--output",
376
- "json"
377
- ], { ASC_WEB_PASSWORD: password });
701
+ data = await irisRequest(session, "Creating the App Store Connect API key", "POST", "/apiKeys", { data: {
702
+ type: "apiKeys",
703
+ attributes: {
704
+ nickname: name,
705
+ roles: [role],
706
+ allAppsVisible: true,
707
+ keyType: "PUBLIC_API"
708
+ }
709
+ } });
378
710
  } catch (error) {
379
- if (twoFactor.cancelled()) throw new AscCommandError("Signing in was cancelled.", void 0, { cause: error });
711
+ const hint = error instanceof AppleSignInError && error.status !== void 0 ? createFailureHint(error.status, error.body ?? "") : void 0;
712
+ if (hint) throw new AppleSignInError(`${error.message}\n${hint}`, {
713
+ ...error.status === void 0 ? {} : { status: error.status },
714
+ cause: error
715
+ });
380
716
  throw error;
381
- } finally {
382
- await twoFactor.close();
383
717
  }
384
- if (login.authenticated !== true) throw new AscCommandError("asc web auth login finished without a signed-in session.", 0);
385
- const signedIn = {
386
- ...text(login.appleId) ? { appleId: text(login.appleId) } : {},
387
- ...text(login.publicProviderId) ? { teamId: text(login.publicProviderId) } : {},
388
- ...text(login.developerTeamId) ? { developerTeamId: text(login.developerTeamId) } : {}
389
- };
390
- const chosen = teamId ?? await chooseTeam?.(signedIn);
391
- const team = chosen === void 0 || chosen === signedIn.teamId ? signedIn : {
392
- ...signedIn.appleId ? { appleId: signedIn.appleId } : {},
393
- teamId: chosen
718
+ const keyId = text(data.id);
719
+ if (!keyId) throw new AppleSignInError("App Store Connect created a key without an ID.");
720
+ const revoke = async () => {
721
+ if (disposed) return false;
722
+ return irisRequest(session, "Revoking the key", "PATCH", `/apiKeys/${encodeURIComponent(keyId)}`, { data: {
723
+ id: keyId,
724
+ type: "apiKeys",
725
+ attributes: { isActive: false }
726
+ } }).then(() => true, () => false);
394
727
  };
395
- const sessionArgs = [...signedIn.appleId ? ["--apple-id", signedIn.appleId] : [], ...chosen === void 0 ? [] : ["--public-provider-id", chosen]];
396
- let created;
397
- try {
398
- created = await run("Creating the App Store Connect API key", [
399
- "web",
400
- "api-keys",
401
- "create",
402
- "--name",
403
- name,
404
- "--role",
405
- role,
406
- "--output-dir",
407
- node_path.default.join(work.root, "keys"),
408
- ...sessionArgs,
409
- "--output",
410
- "json"
411
- ]);
412
- } catch (error) {
413
- const stderr = error instanceof AscCommandError && isRecord(error.cause) ? String(error.cause.stderr ?? "") : "";
414
- const hint = createFailureHint(stderr);
415
- if (hint && error instanceof AscCommandError) throw new AscCommandError(`${error.message}\n${hint}`, error.exitCode, { cause: error });
416
- throw error;
417
- }
418
- const keyId = text(created.keyId);
419
- if (!keyId) throw new AscCommandError("asc web api-keys create printed no key ID.", 0);
420
- const revoke = () => run("Revoking the key", [
421
- "web",
422
- "api-keys",
423
- "revoke",
424
- "--key-id",
425
- keyId,
426
- "--type",
427
- "team",
428
- "--confirm",
429
- ...sessionArgs,
430
- "--output",
431
- "json"
432
- ]).then((json) => json.active === false || json.status === "revoked", () => false);
433
728
  let p8;
434
729
  let issuerId;
435
730
  try {
436
- const issuer = text(created.issuerId);
437
- const p8Path = text(created.p8Path);
438
- const keys = node_path.default.join(work.root, "keys");
439
- if (!issuer) throw new Error("asc printed no issuer ID for the key.");
440
- 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.");
441
- p8 = (await node_fs_promises.default.readFile(p8Path, "utf-8")).trim();
731
+ p8 = await downloadP8(session, keyId, wait);
442
732
  (0, _hot_updater_cli_tools.registerSecretValue)(p8);
443
733
  (0, _hot_updater_cli_tools.registerSecretValue)(Buffer.from(p8).toString("base64"));
444
734
  assertP8(p8);
735
+ const details = await irisRequest(session, "Reading the key's issuer ID", "GET", `/apiKeys/${encodeURIComponent(keyId)}?include=provider`);
736
+ const issuer = issuerOf(details) ?? issuerOf(data);
737
+ if (!issuer) throw new Error("App Store Connect named no issuer ID for the key.");
445
738
  issuerId = issuer;
446
739
  } catch (error) {
447
740
  const revoked = await revoke();
@@ -451,13 +744,13 @@ const createAscCliKeyIssuer = ({ asc, env = process.env, prompts = cliPrompts }
451
744
  p8,
452
745
  keyId,
453
746
  issuerId,
454
- name: text(created.name) ?? name,
455
- team,
747
+ name: text(isRecord(data.attributes) ? data.attributes.nickname : "") ?? name,
748
+ team: toTeam(appleId, provider),
456
749
  revoke,
457
- dispose: work.dispose
750
+ dispose
458
751
  };
459
752
  } catch (error) {
460
- await work.dispose();
753
+ await dispose();
461
754
  throw error;
462
755
  }
463
756
  };
@@ -466,11 +759,11 @@ const createAscCliKeyIssuer = ({ asc, env = process.env, prompts = cliPrompts }
466
759
  /** Where team API keys are listed and revoked in App Store Connect. */
467
760
  const ASC_INTEGRATIONS_URL = "https://appstoreconnect.apple.com/access/integrations/api";
468
761
  /**
469
- * Hot Updater's sign-in, the one place that picks how it is done: asc, the
470
- * open-source App Store Connect CLI. Another implementation of
762
+ * Hot Updater's sign-in, the one place that picks how it is done: App Store
763
+ * Connect's own web sign-in, with no other tool. Another implementation of
471
764
  * `IssueAscApiKey` replaces it here without touching its callers.
472
765
  */
473
- const issueAscApiKey = (request) => createAscCliKeyIssuer()(request);
766
+ const issueAscApiKey = (request) => createAscKeyIssuer()(request);
474
767
  //#endregion
475
768
  //#region src/utils/cocoapods.ts
476
769
  const installPodsIfNeeded = async (sourceDir) => {