@hot-updater/apple-helper 1.2.0-rc.0 → 1.2.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1234 -615
- package/dist/index.d.cts +30 -12
- package/dist/index.d.mts +30 -12
- package/dist/index.mjs +1304 -681
- package/package.json +3 -3
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
|
|
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/
|
|
42
|
+
//#region src/asc/appleSrp.ts
|
|
43
|
+
/**
|
|
44
|
+
* Apple's sign-in proof: SRP-6a over the RFC 5054 2048-bit group with
|
|
45
|
+
* SHA-256, the password stretched with PBKDF2, as Apple's sign-in page and
|
|
46
|
+
* asc, the open-source App Store Connect CLI
|
|
47
|
+
* (https://github.com/rorkai/App-Store-Connect-CLI, MIT), compute it.
|
|
48
|
+
*/
|
|
49
|
+
/** RFC 5054, Appendix A, the 2048-bit group. */
|
|
50
|
+
const N = BigInt("0xAC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B855F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773BCA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73");
|
|
51
|
+
const G = 2n;
|
|
52
|
+
/** The hex length every padded value has: N's byte length, twice. */
|
|
53
|
+
const PADDED_HEX_LENGTH = 512;
|
|
54
|
+
const modPow = (base, exponent, modulus) => {
|
|
55
|
+
let result = 1n;
|
|
56
|
+
let b = (base % modulus + modulus) % modulus;
|
|
57
|
+
let e = exponent;
|
|
58
|
+
while (e > 0n) {
|
|
59
|
+
if (e & 1n) result = result * b % modulus;
|
|
60
|
+
b = b * b % modulus;
|
|
61
|
+
e >>= 1n;
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
/** An even-length lowercase hex of `value`, as the proof hashes it. */
|
|
66
|
+
const toHex = (value) => {
|
|
67
|
+
const hex = value.toString(16);
|
|
68
|
+
return hex.length % 2 === 1 ? `0${hex}` : hex;
|
|
69
|
+
};
|
|
70
|
+
const fromBytes = (bytes) => bytes.length === 0 ? 0n : BigInt(`0x${Buffer.from(bytes).toString("hex")}`);
|
|
71
|
+
const sha256Hex = (hex) => node_crypto.default.createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex");
|
|
72
|
+
/** SHA-256 of each value left-padded to N's length, as a number mod N. */
|
|
73
|
+
const paddedHash = (...values) => BigInt(`0x${sha256Hex(values.map((value) => value.padStart(PADDED_HEX_LENGTH, "0")).join(""))}`) % N;
|
|
74
|
+
/** The password as the protocol Apple named feeds it to PBKDF2. */
|
|
75
|
+
const preparePassword = (password, protocol) => {
|
|
76
|
+
const digest = node_crypto.default.createHash("sha256").update(password).digest();
|
|
77
|
+
if (protocol === "s2k") return digest;
|
|
78
|
+
if (protocol === "s2k_fo") return Buffer.from(digest.toString("hex"));
|
|
79
|
+
throw new Error(`Apple asked for the sign-in protocol ${protocol}.`);
|
|
80
|
+
};
|
|
81
|
+
/** A new SRP client; `secret` is the private value a, random unless set. */
|
|
82
|
+
const createSrpClient = (secret = fromBytes(node_crypto.default.randomBytes(256))) => {
|
|
83
|
+
const A = modPow(G, secret, N);
|
|
84
|
+
const aHex = toHex(A);
|
|
85
|
+
return {
|
|
86
|
+
a: Buffer.from(aHex, "hex").toString("base64"),
|
|
87
|
+
proof: ({ accountName, password, protocol, iterations, salt, b }) => {
|
|
88
|
+
const saltBytes = Buffer.from(salt, "base64");
|
|
89
|
+
const saltHex = saltBytes.toString("hex");
|
|
90
|
+
const bHex = Buffer.from(b, "base64").toString("hex");
|
|
91
|
+
const B = BigInt(`0x${bHex || "0"}`);
|
|
92
|
+
if (B % N === 0n) throw new Error("Apple answered an invalid SRP value.");
|
|
93
|
+
const derived = node_crypto.default.pbkdf2Sync(preparePassword(password, protocol), saltBytes, iterations, 32, "sha256");
|
|
94
|
+
const x = BigInt(`0x${sha256Hex(saltHex + sha256Hex(`3a${derived.toString("hex")}`))}`);
|
|
95
|
+
const k = paddedHash(toHex(N), toHex(G));
|
|
96
|
+
const u = paddedHash(aHex, bHex);
|
|
97
|
+
if (u === 0n) throw new Error("Apple answered an invalid SRP value.");
|
|
98
|
+
const S = modPow(B - k * modPow(G, x, N) % N, secret + u * x, N);
|
|
99
|
+
const K = sha256Hex(toHex(S));
|
|
100
|
+
const m1 = sha256Hex(toHex(paddedHash(toHex(N)) ^ paddedHash(toHex(G))) + node_crypto.default.createHash("sha256").update(accountName).digest("hex") + saltHex + aHex + bHex + K);
|
|
101
|
+
const m2 = toHex(BigInt(`0x${sha256Hex(aHex + m1 + K)}`));
|
|
102
|
+
return {
|
|
103
|
+
m1: Buffer.from(m1, "hex").toString("base64"),
|
|
104
|
+
m2: Buffer.from(m2, "hex").toString("base64")
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
const hasLeadingZeroBits = (digest, bits) => {
|
|
110
|
+
const full = Math.floor(bits / 8);
|
|
111
|
+
for (let index = 0; index < full; index++) if (digest[index] !== 0) return false;
|
|
112
|
+
const rest = bits % 8;
|
|
113
|
+
return rest === 0 || (digest[full] & 255 << 8 - rest & 255) === 0;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* The hashcash stamp Apple's sign-in asks for in `X-Apple-HC`: the first
|
|
117
|
+
* `1:<bits>:<date>:<challenge>::<counter>` whose SHA-1 starts with `bits`
|
|
118
|
+
* zero bits.
|
|
119
|
+
*/
|
|
120
|
+
const makeHashcash = (bits, challenge, now = /* @__PURE__ */ new Date()) => {
|
|
121
|
+
const date = now.toISOString().replace(/\.\d+Z$/u, "").replaceAll(/[-:T]/gu, "");
|
|
122
|
+
for (let counter = 0;; counter++) {
|
|
123
|
+
const stamp = `1:${bits}:${date}:${challenge}::${counter}`;
|
|
124
|
+
const digest = node_crypto.default.createHash("sha1").update(stamp).digest();
|
|
125
|
+
if (hasLeadingZeroBits(digest, bits)) return stamp;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/asc/appleSignIn.ts
|
|
47
130
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
131
|
+
* Signing in to App Store Connect with an Apple Account, as its web page
|
|
132
|
+
* does: Apple's SRP sign-in at idmsa.apple.com, its two-factor check, then
|
|
133
|
+
* App Store Connect's session. The endpoints and their headers follow asc,
|
|
134
|
+
* the open-source App Store Connect CLI
|
|
135
|
+
* (https://github.com/rorkai/App-Store-Connect-CLI, MIT), 5.16.1, whose
|
|
136
|
+
* internal/web/auth.go has signed in this way against Apple.
|
|
52
137
|
*/
|
|
53
|
-
const
|
|
138
|
+
const AUTH_URL = "https://idmsa.apple.com/appleauth/auth";
|
|
139
|
+
const ASC_URL = "https://appstoreconnect.apple.com";
|
|
140
|
+
const SESSION_URL = `${ASC_URL}/olympus/v1/session`;
|
|
54
141
|
/**
|
|
55
|
-
* The
|
|
56
|
-
*
|
|
57
|
-
* it is the exact binary this version of Hot Updater was written against.
|
|
142
|
+
* The public widget key App Store Connect's sign-in page ships, used when
|
|
143
|
+
* neither place Apple publishes the current one answers. Not a credential.
|
|
58
144
|
*/
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
x64: `asc_${ASC_CLI_VERSION}_macOS_amd64`
|
|
66
|
-
};
|
|
67
|
-
const ASC_RELEASE_URL = `https://github.com/rorkai/App-Store-Connect-CLI/releases/download/${ASC_CLI_VERSION}`;
|
|
68
|
-
const INSTALL_HINT = `Install asc ${ASC_CLI_VERSION} or later with \`brew install asc\` (https://github.com/rorkai/App-Store-Connect-CLI), then run the command again.`;
|
|
69
|
-
/** asc could not be found, downloaded, or verified. */
|
|
70
|
-
var AscCliUnavailableError = class extends Error {
|
|
145
|
+
const ASC_WIDGET_KEY = "e0b80c3bf78523bfe80974d320935bfa30add02e1bff88ec2166c6bd5a706c42";
|
|
146
|
+
/** Signing in, or a request in the signed-in session, failed. */
|
|
147
|
+
var AppleSignInError = class extends Error {
|
|
148
|
+
status;
|
|
149
|
+
/** The response body, for messages that depend on what Apple said. */
|
|
150
|
+
body;
|
|
71
151
|
constructor(message, options) {
|
|
72
|
-
super(message, options);
|
|
73
|
-
this.name = "
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
417
|
+
providerId: value.providerId,
|
|
418
|
+
publicProviderId,
|
|
419
|
+
name: text$1(value.name)
|
|
144
420
|
};
|
|
145
421
|
};
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
422
|
+
const readSession = async (client) => {
|
|
423
|
+
const response = await client.request(SESSION_URL, { headers: { Accept: "application/json" } });
|
|
424
|
+
const body = await response.text();
|
|
425
|
+
if (!response.ok) throw new AppleSignInError(response.status === 401 || response.status === 403 ? "App Store Connect didn't accept the sign-in. Check that the Apple Account is in an Apple Developer team." : `Loading the App Store Connect session failed (HTTP ${response.status}).`, {
|
|
426
|
+
status: response.status,
|
|
427
|
+
body
|
|
428
|
+
});
|
|
429
|
+
const parsed = parseJson(body);
|
|
430
|
+
const provider = isRecord$1(parsed) ? parseProvider(parsed.provider) : void 0;
|
|
431
|
+
if (!isRecord$1(parsed) || !provider) throw new AppleSignInError("App Store Connect's session names no team for the Apple Account.");
|
|
432
|
+
const providers = (Array.isArray(parsed.availableProviders) ? parsed.availableProviders : []).map(parseProvider).filter((value) => value !== void 0);
|
|
433
|
+
return {
|
|
434
|
+
provider,
|
|
435
|
+
providers: providers.some((value) => value.providerId === provider.providerId) ? providers : [provider, ...providers]
|
|
436
|
+
};
|
|
156
437
|
};
|
|
157
|
-
const TERMINATION_SIGNALS$2 = [
|
|
158
|
-
"SIGHUP",
|
|
159
|
-
"SIGINT",
|
|
160
|
-
"SIGTERM"
|
|
161
|
-
];
|
|
162
438
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
"
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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 (
|
|
237
|
-
message:
|
|
238
|
-
validate: (value) =>
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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("
|
|
653
|
+
throw new Error("App Store Connect sent a .p8 that is not a private key.");
|
|
654
|
+
}
|
|
655
|
+
if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") throw new Error("App Store Connect sent a .p8 that is not a P-256 private key.");
|
|
656
|
+
};
|
|
657
|
+
/** The one-time `.p8`; a key App Store Connect hasn't settled yet is retried. */
|
|
658
|
+
const downloadP8 = async (session, keyId, wait) => {
|
|
659
|
+
for (let attempt = 1;; attempt++) try {
|
|
660
|
+
const data = await irisRequest(session, "Downloading the key's .p8", "GET", `/apiKeys/${encodeURIComponent(keyId)}?${new URLSearchParams({ "fields[apiKeys]": "privateKey" })}`);
|
|
661
|
+
const encoded = isRecord(data.attributes) ? text(data.attributes.privateKey) : void 0;
|
|
662
|
+
if (text(data.id) !== keyId || !encoded) throw new Error("App Store Connect sent no .p8 for the key.");
|
|
663
|
+
return Buffer.from(encoded, "base64").toString("utf-8").trim();
|
|
664
|
+
} catch (error) {
|
|
665
|
+
if (!(error instanceof AppleSignInError && error.status !== void 0 && (error.status === 404 || error.status === 409 || error.status === 429 || error.status >= 500)) || attempt === 3) throw error;
|
|
666
|
+
await wait(attempt * 1e3);
|
|
303
667
|
}
|
|
304
|
-
|
|
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`
|
|
308
|
-
* (
|
|
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.
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* 3.
|
|
316
|
-
*
|
|
317
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
329
|
-
|
|
330
|
-
_hot_updater_cli_tools.p.log.info("
|
|
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
|
|
699
|
+
if (password === void 0) throw new AppleSignInError("Signing in was cancelled.");
|
|
333
700
|
(0, _hot_updater_cli_tools.registerSecretValue)(password);
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
361
|
-
|
|
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
|
-
|
|
364
|
-
"
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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(
|
|
450
|
-
team,
|
|
764
|
+
name: text(isRecord(data.attributes) ? data.attributes.nickname : "") ?? name,
|
|
765
|
+
team: toTeam(appleId, provider),
|
|
451
766
|
revoke,
|
|
452
|
-
dispose
|
|
767
|
+
dispose
|
|
453
768
|
};
|
|
454
769
|
} catch (error) {
|
|
455
|
-
await
|
|
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:
|
|
465
|
-
*
|
|
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) =>
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
913
|
+
all: true
|
|
513
914
|
});
|
|
514
|
-
|
|
915
|
+
} catch (error) {
|
|
916
|
+
bundleSpinner.error("bundle install failed");
|
|
917
|
+
throw commandFailure("bundle install", error);
|
|
515
918
|
}
|
|
516
|
-
|
|
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
|
-
], {
|
|
965
|
+
], {
|
|
966
|
+
cwd: projectRoot,
|
|
967
|
+
all: true
|
|
968
|
+
});
|
|
550
969
|
} catch (error) {
|
|
551
|
-
spinner.
|
|
552
|
-
throw
|
|
970
|
+
spinner.error("React Native codegen failed");
|
|
971
|
+
throw commandFailure("React Native codegen", error);
|
|
553
972
|
}
|
|
554
973
|
spinner.stop("React Native codegen done");
|
|
555
974
|
};
|
|
@@ -578,8 +997,55 @@ const resolvePodInstallPlan = ({ version, env }) => {
|
|
|
578
997
|
};
|
|
579
998
|
//#endregion
|
|
580
999
|
//#region src/utils/createRandomTmpDir.ts
|
|
1000
|
+
const TERMINATION_SIGNALS$2 = [
|
|
1001
|
+
"SIGHUP",
|
|
1002
|
+
"SIGINT",
|
|
1003
|
+
"SIGTERM"
|
|
1004
|
+
];
|
|
1005
|
+
const REMOVE_OPTIONS = {
|
|
1006
|
+
force: true,
|
|
1007
|
+
recursive: true,
|
|
1008
|
+
maxRetries: 3
|
|
1009
|
+
};
|
|
1010
|
+
const liveDirectories = /* @__PURE__ */ new Set();
|
|
1011
|
+
const onSignal = (signal) => {
|
|
1012
|
+
removeLiveDirectories();
|
|
1013
|
+
if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
|
|
1014
|
+
};
|
|
1015
|
+
const removeLiveDirectories = () => {
|
|
1016
|
+
for (const directory of liveDirectories) try {
|
|
1017
|
+
(0, node_fs.rmSync)(directory, REMOVE_OPTIONS);
|
|
1018
|
+
} catch {}
|
|
1019
|
+
liveDirectories.clear();
|
|
1020
|
+
stopListening();
|
|
1021
|
+
};
|
|
1022
|
+
const startListening = () => {
|
|
1023
|
+
process.on("exit", removeLiveDirectories);
|
|
1024
|
+
for (const signal of TERMINATION_SIGNALS$2) process.on(signal, onSignal);
|
|
1025
|
+
};
|
|
1026
|
+
const stopListening = () => {
|
|
1027
|
+
process.off("exit", removeLiveDirectories);
|
|
1028
|
+
for (const signal of TERMINATION_SIGNALS$2) process.off(signal, onSignal);
|
|
1029
|
+
};
|
|
1030
|
+
/**
|
|
1031
|
+
* Creates a directory in the system's temporary directory, which
|
|
1032
|
+
* `removeTmpDir` removes, and which is otherwise removed when the process
|
|
1033
|
+
* exits or is stopped by a signal.
|
|
1034
|
+
*/
|
|
581
1035
|
const createRandomTmpDir = async (prefix = "apple-helper-") => {
|
|
582
|
-
|
|
1036
|
+
const directory = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), prefix));
|
|
1037
|
+
if (liveDirectories.size === 0) startListening();
|
|
1038
|
+
liveDirectories.add(directory);
|
|
1039
|
+
return directory;
|
|
1040
|
+
};
|
|
1041
|
+
/**
|
|
1042
|
+
* Removes a directory `createRandomTmpDir` created. It stays tracked until
|
|
1043
|
+
* it is gone, so a signal during a removal that takes seconds, such as
|
|
1044
|
+
* DerivedData's, still finishes it.
|
|
1045
|
+
*/
|
|
1046
|
+
const removeTmpDir = async (directory) => {
|
|
1047
|
+
await (0, node_fs_promises.rm)(directory, REMOVE_OPTIONS);
|
|
1048
|
+
if (liveDirectories.delete(directory) && liveDirectories.size === 0) stopListening();
|
|
583
1049
|
};
|
|
584
1050
|
//#endregion
|
|
585
1051
|
//#region src/utils/platform.ts
|
|
@@ -654,80 +1120,113 @@ const parseXcodeProjectInfo = async (sourceDir) => {
|
|
|
654
1120
|
const prettifyXcodebuildError = (error) => {
|
|
655
1121
|
if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
|
|
656
1122
|
const errorLines = error.summary.join("\n");
|
|
657
|
-
const
|
|
658
|
-
return new _hot_updater_cli_tools.BuildFailedError(
|
|
1123
|
+
const hint = mentionsProgramLicenseAgreement(errorLines) ? PROGRAM_LICENSE_AGREEMENT_HINT : errorLines.includes("error: No profiles for") || errorLines.includes("error: Provisioning profile") ? "No valid provisioning profile is set in the project.\nCheck the Signing & Capabilities tab in Xcode." : null;
|
|
1124
|
+
return new _hot_updater_cli_tools.BuildFailedError(hint ?? error.message, {
|
|
659
1125
|
logPath: error.logPath,
|
|
660
1126
|
summary: error.summary,
|
|
1127
|
+
hint,
|
|
661
1128
|
cause: error.cause
|
|
662
1129
|
});
|
|
663
1130
|
}
|
|
664
1131
|
return /* @__PURE__ */ new Error(`Xcodebuild failed: ${error}`);
|
|
665
1132
|
};
|
|
666
1133
|
//#endregion
|
|
1134
|
+
//#region src/utils/xcodebuildPhase.ts
|
|
1135
|
+
/** The `(in target 'X' from project 'Y')` that ends an action's line. */
|
|
1136
|
+
const TARGET = /\(in target '([^']+)' from project '[^']+'\)\s*$/;
|
|
1137
|
+
/** A `PhaseScriptExecution` line's script phase, its spaces escaped. */
|
|
1138
|
+
const SCRIPT = /^PhaseScriptExecution ((?:\\.|[^\s\\])+)/;
|
|
1139
|
+
/** A batched `SwiftCompile`'s files: `Compiling\ A.swift,\ B.swift`. */
|
|
1140
|
+
const SWIFT_BATCH = /^SwiftCompile \S+ \S+ Compiling\\ ((?:\\.|[^\s\\])+)/;
|
|
1141
|
+
const unescape = (value) => value.replace(/\\(.)/g, "$1");
|
|
1142
|
+
/** Actions whose lines say what xcodebuild is doing, by their first word. */
|
|
1143
|
+
const ACTIONS = {
|
|
1144
|
+
CompileAssetCatalog: "Compiling assets",
|
|
1145
|
+
CompileAssetCatalogVariant: "Compiling assets",
|
|
1146
|
+
LinkAssetCatalog: "Compiling assets",
|
|
1147
|
+
CompileStoryboard: "Compiling storyboards",
|
|
1148
|
+
CompileXIB: "Compiling storyboards",
|
|
1149
|
+
LinkStoryboards: "Compiling storyboards",
|
|
1150
|
+
Ld: "Linking",
|
|
1151
|
+
Libtool: "Linking",
|
|
1152
|
+
CodeSign: "Signing",
|
|
1153
|
+
ProcessInfoPlistFile: "Processing",
|
|
1154
|
+
ProcessProductPackaging: "Processing",
|
|
1155
|
+
ProcessProductPackagingDER: "Processing",
|
|
1156
|
+
CopySwiftLibs: "Copying",
|
|
1157
|
+
CpResource: "Copying",
|
|
1158
|
+
GenerateDSYMFile: "Generating dSYM",
|
|
1159
|
+
Strip: "Stripping",
|
|
1160
|
+
Validate: "Validating"
|
|
1161
|
+
};
|
|
1162
|
+
const COMPILES = /* @__PURE__ */ new Set([
|
|
1163
|
+
"CompileC",
|
|
1164
|
+
"SwiftCompile",
|
|
1165
|
+
"CompileSwift"
|
|
1166
|
+
]);
|
|
1167
|
+
const PRECOMPILES = /* @__PURE__ */ new Set([
|
|
1168
|
+
"PrecompileModule",
|
|
1169
|
+
"ScanDependencies",
|
|
1170
|
+
"SwiftExplicitDependencyGeneratePcm",
|
|
1171
|
+
"SwiftExplicitDependencyCompileModuleFromInterface"
|
|
1172
|
+
]);
|
|
1173
|
+
const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
1174
|
+
/**
|
|
1175
|
+
* Reads the phase of an `xcodebuild` build, archive, or export from its raw
|
|
1176
|
+
* output: `Compiling React-Core · 412 files`, `Linking CodexRelay`,
|
|
1177
|
+
* `Signing CodexRelay`, `Bundling JavaScript`, with the target each
|
|
1178
|
+
* action's line names.
|
|
1179
|
+
*/
|
|
1180
|
+
const createXcodebuildPhaseReader = () => {
|
|
1181
|
+
let compiled = 0;
|
|
1182
|
+
let precompiled = 0;
|
|
1183
|
+
return (line) => {
|
|
1184
|
+
const space = line.indexOf(" ");
|
|
1185
|
+
const action = space === -1 ? line : line.slice(0, space);
|
|
1186
|
+
const target = TARGET.exec(line)?.[1];
|
|
1187
|
+
const withTarget = (phase) => target ? `${phase} ${target}` : phase;
|
|
1188
|
+
if (COMPILES.has(action)) {
|
|
1189
|
+
const batch = SWIFT_BATCH.exec(line)?.[1];
|
|
1190
|
+
compiled += batch ? unescape(batch).split(",").length : 1;
|
|
1191
|
+
return `${withTarget("Compiling")} · ${plural(compiled, "file")}`;
|
|
1192
|
+
}
|
|
1193
|
+
if (PRECOMPILES.has(action)) {
|
|
1194
|
+
precompiled += 1;
|
|
1195
|
+
return `Precompiling modules · ${precompiled}`;
|
|
1196
|
+
}
|
|
1197
|
+
if (action === "PhaseScriptExecution") {
|
|
1198
|
+
const script = unescape(SCRIPT.exec(line)?.[1] ?? "");
|
|
1199
|
+
if (script === "Bundle React Native code and images") return "Bundling JavaScript";
|
|
1200
|
+
return script ? `Running ${script}` : withTarget("Running a script");
|
|
1201
|
+
}
|
|
1202
|
+
const phase = ACTIONS[action];
|
|
1203
|
+
if (phase) return withTarget(phase);
|
|
1204
|
+
if (line === "Prepare packages" || line.startsWith("Resolve Package")) return "Resolving packages";
|
|
1205
|
+
if (line === "ComputeTargetDependencyGraph") return "Planning the build";
|
|
1206
|
+
};
|
|
1207
|
+
};
|
|
1208
|
+
//#endregion
|
|
667
1209
|
//#region src/utils/createXcodebuildLogger.ts
|
|
668
|
-
const createXcodebuildLogger = ({ logPrefix }) => new _hot_updater_cli_tools.BuildLogger({
|
|
1210
|
+
const createXcodebuildLogger = ({ logPrefix, logFilePath }) => new _hot_updater_cli_tools.BuildLogger({
|
|
669
1211
|
logPrefix,
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
"** BUILD FAILED **",
|
|
673
|
-
"** ARCHIVE FAILED **",
|
|
674
|
-
"The following build commands failed:",
|
|
675
|
-
"note: Building targets in dependency order",
|
|
676
|
-
"note: Target dependency graph",
|
|
677
|
-
"Running script",
|
|
678
|
-
"[CP-User]",
|
|
679
|
-
"[CP] Check Pods Manifest.lock",
|
|
680
|
-
"Build Succeeded"
|
|
681
|
-
],
|
|
682
|
-
progressStages: [
|
|
683
|
-
["Building targets in dependency order", "Target dependency graph"],
|
|
684
|
-
["Write Auxiliary File", "[CP] Check Pods Manifest.lock"],
|
|
685
|
-
[
|
|
686
|
-
"Running script",
|
|
687
|
-
"[CP-User] [RN]Check rncore",
|
|
688
|
-
"[CP-User] [Hermes] Replace Hermes",
|
|
689
|
-
"[CP-User] [RN]Check FBReactNativeSpec"
|
|
690
|
-
],
|
|
691
|
-
[
|
|
692
|
-
"Processing",
|
|
693
|
-
"ProcessProductPackaging",
|
|
694
|
-
"ProcessProductPackagingDER"
|
|
695
|
-
],
|
|
696
|
-
["Compiling"],
|
|
697
|
-
["RegisterExecutionPolicyException"],
|
|
698
|
-
["Touching"],
|
|
699
|
-
["Building library", "Create Universal Binary"],
|
|
700
|
-
["Copying"],
|
|
701
|
-
[
|
|
702
|
-
"Build Succeeded",
|
|
703
|
-
"BUILD SUCCEEDED",
|
|
704
|
-
"ARCHIVE SUCCEEDED"
|
|
705
|
-
]
|
|
706
|
-
],
|
|
1212
|
+
logFilePath,
|
|
1213
|
+
createPhaseReader: createXcodebuildPhaseReader,
|
|
707
1214
|
failureSummary: {
|
|
708
1215
|
lines: [/(^|:\s)error: /, /^\*\* [A-Z ]+ FAILED \*\*$/],
|
|
709
1216
|
blocks: [{
|
|
710
1217
|
start: "The following build commands failed:",
|
|
711
1218
|
end: /^\(\d+ failures?\)$/,
|
|
712
1219
|
includeEnd: true
|
|
1220
|
+
}, {
|
|
1221
|
+
start: /^error: exportArchive /,
|
|
1222
|
+
end: /^$/,
|
|
1223
|
+
includeEnd: false
|
|
713
1224
|
}]
|
|
714
1225
|
}
|
|
715
1226
|
});
|
|
716
1227
|
//#endregion
|
|
717
1228
|
//#region src/utils/runXcodebuildWithLogging.ts
|
|
718
|
-
let cachedXcbeautifyAvailability = null;
|
|
719
1229
|
const TERMINATION_SIGNALS$1 = ["SIGINT", "SIGTERM"];
|
|
720
|
-
const isXcbeautifyAvailable = async () => {
|
|
721
|
-
if (cachedXcbeautifyAvailability !== null) return cachedXcbeautifyAvailability;
|
|
722
|
-
try {
|
|
723
|
-
await (0, execa.execa)("which", ["xcbeautify"]);
|
|
724
|
-
cachedXcbeautifyAvailability = true;
|
|
725
|
-
return true;
|
|
726
|
-
} catch {
|
|
727
|
-
cachedXcbeautifyAvailability = false;
|
|
728
|
-
return false;
|
|
729
|
-
}
|
|
730
|
-
};
|
|
731
1230
|
const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
|
|
732
1231
|
return (0, execa.execa)("xcodebuild", args, {
|
|
733
1232
|
cwd: sourceDir,
|
|
@@ -740,13 +1239,6 @@ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
|
|
|
740
1239
|
}
|
|
741
1240
|
});
|
|
742
1241
|
};
|
|
743
|
-
const createXcbeautifyProcess = ({ cwd }) => {
|
|
744
|
-
return (0, execa.execa)("xcbeautify", [], {
|
|
745
|
-
cwd,
|
|
746
|
-
all: true,
|
|
747
|
-
buffer: false
|
|
748
|
-
});
|
|
749
|
-
};
|
|
750
1242
|
/** Why xcodebuild failed, without its command line. */
|
|
751
1243
|
const describeFailure = (error) => {
|
|
752
1244
|
if (error instanceof execa.ExecaError) {
|
|
@@ -755,10 +1247,24 @@ const describeFailure = (error) => {
|
|
|
755
1247
|
}
|
|
756
1248
|
return error instanceof Error ? error.message : String(error);
|
|
757
1249
|
};
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1250
|
+
/**
|
|
1251
|
+
* Runs xcodebuild as a step titled `title`: its live line shows the build's
|
|
1252
|
+
* phase, the log file keeps the command and the whole output, and a failure
|
|
1253
|
+
* prints the output's errors and the log's path on stderr.
|
|
1254
|
+
*/
|
|
1255
|
+
const runXcodebuildWithLogging = async ({ args, env, sourceDir, title, successMessage, failureMessage, logPrefix, logFilePath, header = [] }) => {
|
|
1256
|
+
const logger = createXcodebuildLogger({
|
|
1257
|
+
logPrefix,
|
|
1258
|
+
logFilePath
|
|
1259
|
+
});
|
|
1260
|
+
await logger.start({
|
|
1261
|
+
title,
|
|
1262
|
+
header: [
|
|
1263
|
+
...header,
|
|
1264
|
+
`$ ${formatCommand(["xcodebuild", ...args])}`,
|
|
1265
|
+
""
|
|
1266
|
+
]
|
|
1267
|
+
});
|
|
762
1268
|
const activeProcesses = [];
|
|
763
1269
|
const terminateActiveProcesses = () => {
|
|
764
1270
|
for (const activeProcess of activeProcesses) activeProcess.kill();
|
|
@@ -772,31 +1278,17 @@ const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage,
|
|
|
772
1278
|
});
|
|
773
1279
|
activeProcesses.push(xcodebuildProcess);
|
|
774
1280
|
const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
xcbeautifyProcess = createXcbeautifyProcess({ cwd: sourceDir });
|
|
778
|
-
activeProcesses.push(xcbeautifyProcess);
|
|
779
|
-
}
|
|
780
|
-
const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput, { display: xcbeautifyProcess ? xcbeautifyProcess.all ?? xcbeautifyProcess.stdout ?? void 0 : void 0 }) : Promise.resolve();
|
|
781
|
-
const xcbeautifyInput = xcbeautifyProcess?.stdin;
|
|
782
|
-
if (xcodebuildOutput && xcbeautifyInput) {
|
|
783
|
-
xcbeautifyInput.on("error", () => {
|
|
784
|
-
xcodebuildOutput.unpipe(xcbeautifyInput);
|
|
785
|
-
});
|
|
786
|
-
xcodebuildOutput.pipe(xcbeautifyInput);
|
|
787
|
-
}
|
|
788
|
-
const [xcodebuildResult, loggingResult] = await Promise.allSettled([
|
|
789
|
-
xcodebuildProcess,
|
|
790
|
-
logging,
|
|
791
|
-
xcbeautifyProcess
|
|
792
|
-
]);
|
|
1281
|
+
const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput) : Promise.resolve();
|
|
1282
|
+
const [xcodebuildResult, loggingResult] = await Promise.allSettled([xcodebuildProcess, logging]);
|
|
793
1283
|
if (xcodebuildResult.status === "rejected") throw xcodebuildResult.reason;
|
|
794
1284
|
if (loggingResult.status === "rejected") throw loggingResult.reason;
|
|
795
1285
|
logger.stop(successMessage);
|
|
1286
|
+
return { logFilePath: logger.logFilePath };
|
|
796
1287
|
} catch (error) {
|
|
797
1288
|
logger.writeError(error);
|
|
798
|
-
|
|
799
|
-
|
|
1289
|
+
const reason = describeFailure(error);
|
|
1290
|
+
logger.stop(`${failureMessage} · ${reason.replace(/\.$/, "")}`, false);
|
|
1291
|
+
throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${reason}`, {
|
|
800
1292
|
logPath: logger.logPath,
|
|
801
1293
|
summary: logger.failureSummary,
|
|
802
1294
|
cause: error
|
|
@@ -883,53 +1375,66 @@ const buildSettingsOverScheme = ({ minBundleId, authenticationArgs }) => ({
|
|
|
883
1375
|
});
|
|
884
1376
|
//#endregion
|
|
885
1377
|
//#region src/builder/archiveXcodeProject.ts
|
|
886
|
-
|
|
1378
|
+
/**
|
|
1379
|
+
* Archives the scheme into `archiveDirectory`. The build's xcconfig is
|
|
1380
|
+
* written to a temporary directory, removed whether the archive succeeds or
|
|
1381
|
+
* not.
|
|
1382
|
+
*/
|
|
1383
|
+
const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, header = [], minBundleId, authenticationArgs }) => {
|
|
887
1384
|
if (installPods) await installPodsIfNeeded(sourceDir);
|
|
888
1385
|
const xcodeProject = await parseXcodeProjectInfo(sourceDir);
|
|
889
|
-
const tmpDir = await createRandomTmpDir();
|
|
890
1386
|
const archiveName = `${xcodeProject.name.replace(".xcworkspace", "").replace(".xcodeproj", "")}.xcarchive`;
|
|
891
|
-
const archivePath = path.default.join(
|
|
892
|
-
const
|
|
893
|
-
directory: tmpDir,
|
|
894
|
-
sourceDir,
|
|
895
|
-
xcconfig,
|
|
896
|
-
extraParams,
|
|
897
|
-
settings: buildSettingsOverScheme({
|
|
898
|
-
minBundleId,
|
|
899
|
-
authenticationArgs
|
|
900
|
-
})
|
|
901
|
-
});
|
|
902
|
-
const archiveArgs = prepareArchiveArgs({
|
|
903
|
-
authenticationArgs,
|
|
904
|
-
minBundleId,
|
|
905
|
-
archivePath,
|
|
906
|
-
platform,
|
|
907
|
-
sourceDir,
|
|
908
|
-
xcodeProject,
|
|
909
|
-
configuration,
|
|
910
|
-
extraParams: build.extraParams,
|
|
911
|
-
destination,
|
|
912
|
-
xcconfig: build.xcconfig,
|
|
913
|
-
xcodeScheme
|
|
914
|
-
});
|
|
915
|
-
_hot_updater_cli_tools.p.log.info(`Xcode Archive Settings:
|
|
916
|
-
Project ${xcodeProject.name}
|
|
917
|
-
Scheme ${xcodeScheme}
|
|
918
|
-
Platform ${platform}
|
|
919
|
-
Command xcodebuild ${archiveArgs.join(" ")}
|
|
920
|
-
`);
|
|
1387
|
+
const archivePath = path.default.join(archiveDirectory, archiveName);
|
|
1388
|
+
const tmpDir = await createRandomTmpDir();
|
|
921
1389
|
try {
|
|
922
|
-
await
|
|
923
|
-
|
|
924
|
-
env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
|
|
925
|
-
failureMessage: "Archive failed",
|
|
926
|
-
logPrefix,
|
|
1390
|
+
const build = await writeBuildXcconfig({
|
|
1391
|
+
directory: tmpDir,
|
|
927
1392
|
sourceDir,
|
|
928
|
-
|
|
1393
|
+
xcconfig,
|
|
1394
|
+
extraParams,
|
|
1395
|
+
settings: buildSettingsOverScheme({
|
|
1396
|
+
minBundleId,
|
|
1397
|
+
authenticationArgs
|
|
1398
|
+
})
|
|
929
1399
|
});
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1400
|
+
const archiveArgs = prepareArchiveArgs({
|
|
1401
|
+
authenticationArgs,
|
|
1402
|
+
minBundleId,
|
|
1403
|
+
archivePath,
|
|
1404
|
+
platform,
|
|
1405
|
+
sourceDir,
|
|
1406
|
+
xcodeProject,
|
|
1407
|
+
configuration,
|
|
1408
|
+
extraParams: build.extraParams,
|
|
1409
|
+
destination,
|
|
1410
|
+
xcconfig: build.xcconfig,
|
|
1411
|
+
xcodeScheme
|
|
1412
|
+
});
|
|
1413
|
+
try {
|
|
1414
|
+
const { logFilePath } = await runXcodebuildWithLogging({
|
|
1415
|
+
args: archiveArgs,
|
|
1416
|
+
env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
|
|
1417
|
+
title: `Archiving ${xcodeScheme}`,
|
|
1418
|
+
successMessage: `Archived ${xcodeScheme}`,
|
|
1419
|
+
failureMessage: "Archive failed",
|
|
1420
|
+
logPrefix,
|
|
1421
|
+
header: [
|
|
1422
|
+
...header,
|
|
1423
|
+
`Project ${xcodeProject.name}`,
|
|
1424
|
+
`Scheme ${xcodeScheme}`,
|
|
1425
|
+
`Platform ${platform}`
|
|
1426
|
+
],
|
|
1427
|
+
sourceDir
|
|
1428
|
+
});
|
|
1429
|
+
return {
|
|
1430
|
+
archivePath,
|
|
1431
|
+
logFilePath
|
|
1432
|
+
};
|
|
1433
|
+
} catch (error) {
|
|
1434
|
+
throw prettifyXcodebuildError(error);
|
|
1435
|
+
}
|
|
1436
|
+
} finally {
|
|
1437
|
+
await removeTmpDir(tmpDir);
|
|
933
1438
|
}
|
|
934
1439
|
};
|
|
935
1440
|
const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId, authenticationArgs }) => {
|
|
@@ -962,69 +1467,88 @@ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xco
|
|
|
962
1467
|
};
|
|
963
1468
|
//#endregion
|
|
964
1469
|
//#region src/builder/buildXcodeProject.ts
|
|
965
|
-
|
|
1470
|
+
/**
|
|
1471
|
+
* Builds the scheme in a temporary DerivedData directory, copies the built
|
|
1472
|
+
* `.app` to `outputDirectory`, and removes DerivedData, which takes 10 GB or
|
|
1473
|
+
* more, whether the build succeeds or not.
|
|
1474
|
+
*/
|
|
1475
|
+
const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams, minBundleId, authenticationArgs, header = [] }) => {
|
|
966
1476
|
if (installPods) await installPodsIfNeeded(sourceDir);
|
|
967
1477
|
const xcodeProject = await parseXcodeProjectInfo(sourceDir);
|
|
968
|
-
const
|
|
969
|
-
const resolvedDestinations = resolveDestinations({
|
|
970
|
-
destinations: destination,
|
|
971
|
-
useGeneric: useGenericDestination
|
|
972
|
-
});
|
|
973
|
-
if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
|
|
974
|
-
deviceType,
|
|
975
|
-
platform,
|
|
976
|
-
useGeneric: useGenericDestination
|
|
977
|
-
}));
|
|
978
|
-
const build = await writeBuildXcconfig({
|
|
979
|
-
directory: await createRandomTmpDir(),
|
|
980
|
-
sourceDir,
|
|
981
|
-
extraParams,
|
|
982
|
-
settings: buildSettingsOverScheme({
|
|
983
|
-
minBundleId,
|
|
984
|
-
authenticationArgs
|
|
985
|
-
})
|
|
986
|
-
});
|
|
987
|
-
const buildArgs = prepareBuildArgs({
|
|
988
|
-
authenticationArgs,
|
|
989
|
-
xcconfig: build.xcconfig,
|
|
990
|
-
minBundleId,
|
|
991
|
-
configuration,
|
|
992
|
-
derivedDataPath,
|
|
993
|
-
deviceType,
|
|
994
|
-
resolvedDestinations,
|
|
995
|
-
extraParams: build.extraParams,
|
|
996
|
-
platform,
|
|
997
|
-
xcodeScheme,
|
|
998
|
-
sourceDir,
|
|
999
|
-
xcodeProject
|
|
1000
|
-
});
|
|
1001
|
-
_hot_updater_cli_tools.p.log.info(`Xcode Build Settings:
|
|
1002
|
-
Project ${xcodeProject.name}
|
|
1003
|
-
Scheme ${xcodeScheme}
|
|
1004
|
-
Configuration ${configuration}
|
|
1005
|
-
Platform ${platform}
|
|
1006
|
-
Device Type ${deviceType}
|
|
1007
|
-
Command xcodebuild ${buildArgs.join(" ")}
|
|
1008
|
-
`);
|
|
1478
|
+
const tmpDir = await createRandomTmpDir();
|
|
1009
1479
|
try {
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1480
|
+
const derivedDataPath = path.default.join(tmpDir, "DerivedData");
|
|
1481
|
+
const resolvedDestinations = resolveDestinations({
|
|
1482
|
+
destinations: destination,
|
|
1483
|
+
useGeneric: useGenericDestination
|
|
1484
|
+
});
|
|
1485
|
+
if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
|
|
1486
|
+
deviceType,
|
|
1487
|
+
platform,
|
|
1488
|
+
useGeneric: useGenericDestination
|
|
1489
|
+
}));
|
|
1490
|
+
const build = await writeBuildXcconfig({
|
|
1491
|
+
directory: tmpDir,
|
|
1013
1492
|
sourceDir,
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1493
|
+
extraParams,
|
|
1494
|
+
settings: buildSettingsOverScheme({
|
|
1495
|
+
minBundleId,
|
|
1496
|
+
authenticationArgs
|
|
1497
|
+
})
|
|
1017
1498
|
});
|
|
1018
|
-
|
|
1499
|
+
const buildArgs = prepareBuildArgs({
|
|
1500
|
+
authenticationArgs,
|
|
1501
|
+
xcconfig: build.xcconfig,
|
|
1502
|
+
minBundleId,
|
|
1019
1503
|
configuration,
|
|
1020
1504
|
derivedDataPath,
|
|
1505
|
+
deviceType,
|
|
1021
1506
|
resolvedDestinations,
|
|
1507
|
+
extraParams: build.extraParams,
|
|
1508
|
+
platform,
|
|
1022
1509
|
xcodeScheme,
|
|
1023
1510
|
sourceDir,
|
|
1024
1511
|
xcodeProject
|
|
1025
1512
|
});
|
|
1026
|
-
|
|
1027
|
-
|
|
1513
|
+
let builtAppPath;
|
|
1514
|
+
let logFilePath;
|
|
1515
|
+
try {
|
|
1516
|
+
({logFilePath} = await runXcodebuildWithLogging({
|
|
1517
|
+
args: buildArgs,
|
|
1518
|
+
env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
|
|
1519
|
+
sourceDir,
|
|
1520
|
+
logPrefix,
|
|
1521
|
+
header: [
|
|
1522
|
+
...header,
|
|
1523
|
+
`Project ${xcodeProject.name}`,
|
|
1524
|
+
`Scheme ${xcodeScheme}`,
|
|
1525
|
+
`Configuration ${configuration}`,
|
|
1526
|
+
`Platform ${platform}`,
|
|
1527
|
+
`Device type ${deviceType}`
|
|
1528
|
+
],
|
|
1529
|
+
title: `Building ${xcodeScheme}`,
|
|
1530
|
+
successMessage: `Built ${xcodeScheme}`,
|
|
1531
|
+
failureMessage: "Build failed"
|
|
1532
|
+
}));
|
|
1533
|
+
({appPath: builtAppPath} = await getBuildSettings({
|
|
1534
|
+
configuration,
|
|
1535
|
+
derivedDataPath,
|
|
1536
|
+
resolvedDestinations,
|
|
1537
|
+
xcodeScheme,
|
|
1538
|
+
sourceDir,
|
|
1539
|
+
xcodeProject
|
|
1540
|
+
}));
|
|
1541
|
+
} catch (error) {
|
|
1542
|
+
throw prettifyXcodebuildError(error);
|
|
1543
|
+
}
|
|
1544
|
+
const appPath = path.default.join(outputDirectory, path.default.basename(builtAppPath));
|
|
1545
|
+
await fs.default.promises.cp(builtAppPath, appPath, { recursive: true });
|
|
1546
|
+
return {
|
|
1547
|
+
appPath,
|
|
1548
|
+
logFilePath
|
|
1549
|
+
};
|
|
1550
|
+
} finally {
|
|
1551
|
+
await removeTmpDir(tmpDir);
|
|
1028
1552
|
}
|
|
1029
1553
|
};
|
|
1030
1554
|
const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId, authenticationArgs, xcconfig }) => {
|
|
@@ -1081,8 +1605,8 @@ const getBuildSettings = async ({ xcodeProject, sourceDir, xcodeScheme, configur
|
|
|
1081
1605
|
};
|
|
1082
1606
|
//#endregion
|
|
1083
1607
|
//#region src/builder/exportXcodeArchive.ts
|
|
1084
|
-
|
|
1085
|
-
|
|
1608
|
+
/** Exports the archive into `exportPath`. */
|
|
1609
|
+
const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, logFilePath, title, authenticationArgs }) => {
|
|
1086
1610
|
const exportArgs = prepareExportArgs({
|
|
1087
1611
|
authenticationArgs,
|
|
1088
1612
|
archivePath,
|
|
@@ -1094,9 +1618,11 @@ const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, e
|
|
|
1094
1618
|
await runXcodebuildWithLogging({
|
|
1095
1619
|
args: exportArgs,
|
|
1096
1620
|
sourceDir,
|
|
1097
|
-
|
|
1621
|
+
title: `Exporting ${title}`,
|
|
1622
|
+
successMessage: `Exported ${title}`,
|
|
1098
1623
|
failureMessage: "Export failed",
|
|
1099
|
-
logPrefix
|
|
1624
|
+
logPrefix,
|
|
1625
|
+
logFilePath
|
|
1100
1626
|
});
|
|
1101
1627
|
return { exportPath };
|
|
1102
1628
|
} catch (error) {
|
|
@@ -1279,7 +1805,7 @@ var TestFlightBuildExistsError = class extends Error {
|
|
|
1279
1805
|
bundleVersion;
|
|
1280
1806
|
name = "TestFlightBuildExistsError";
|
|
1281
1807
|
constructor(shortVersion, bundleVersion, options) {
|
|
1282
|
-
super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}
|
|
1808
|
+
super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}.\nUse a new build number (CFBundleVersion).`, options);
|
|
1283
1809
|
this.shortVersion = shortVersion;
|
|
1284
1810
|
this.bundleVersion = bundleVersion;
|
|
1285
1811
|
}
|
|
@@ -1466,15 +1992,15 @@ const codeSigningOff = async (iosScheme) => {
|
|
|
1466
1992
|
* it (`assertTestFlightExportOptions`).
|
|
1467
1993
|
*/
|
|
1468
1994
|
const resolveTestFlightUploadKey = async ({ scheme, iosScheme }) => {
|
|
1469
|
-
if (iosScheme.simulator) throw new Error(`
|
|
1995
|
+
if (iosScheme.simulator) throw new Error(`The scheme ${scheme} builds a simulator .app, not an .ipa for TestFlight.`);
|
|
1470
1996
|
const configuration = iosScheme.configuration ?? "Release";
|
|
1471
|
-
if (/debug$/i.test(configuration)) throw new Error(`
|
|
1997
|
+
if (/debug$/i.test(configuration)) throw new Error(`The scheme ${scheme} builds ${configuration}; TestFlight takes a release build.`);
|
|
1472
1998
|
const unsigned = await codeSigningOff(iosScheme);
|
|
1473
|
-
if (unsigned) throw new Error(`
|
|
1474
|
-
if (!iosScheme.exportOptionsPlist && !iosScheme.signing) throw new Error(`The scheme ${scheme} archives without exporting an .ipa
|
|
1999
|
+
if (unsigned) throw new Error(`The scheme ${scheme} turns code signing off:\n${unsigned}`);
|
|
2000
|
+
if (!iosScheme.exportOptionsPlist && !iosScheme.signing) throw new Error(`The scheme ${scheme} archives without exporting an .ipa.\nSet signing.ascApiKey, or exportOptionsPlist with method app-store-connect.`);
|
|
1475
2001
|
if (iosScheme.exportOptionsPlist && fs.default.existsSync(schemeFile(iosScheme.exportOptionsPlist))) {
|
|
1476
2002
|
const method = await readExportMethod(schemeFile(iosScheme.exportOptionsPlist));
|
|
1477
|
-
if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`
|
|
2003
|
+
if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`The scheme ${scheme}'s exportOptionsPlist uses method ${method || "(none)"}.\nSet its method to app-store-connect for TestFlight.`);
|
|
1478
2004
|
}
|
|
1479
2005
|
return {
|
|
1480
2006
|
kind: "asc-api-key",
|
|
@@ -1489,7 +2015,7 @@ const resolveTestFlightUploadKey = async ({ scheme, iosScheme }) => {
|
|
|
1489
2015
|
const assertTestFlightExportOptions = async (schemeConfig) => {
|
|
1490
2016
|
if (!schemeConfig.exportOptionsPlist) return;
|
|
1491
2017
|
const method = await readExportMethod(schemeConfig.exportOptionsPlist);
|
|
1492
|
-
if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`
|
|
2018
|
+
if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`The scheme ${schemeConfig.hotUpdaterSchemeName}'s exportOptionsPlist uses method ${method || "(none)"}.\nSet its method to app-store-connect for TestFlight.`);
|
|
1493
2019
|
};
|
|
1494
2020
|
/**
|
|
1495
2021
|
* Requests to the App Store Connect API with `apiKey`: each carries a token
|
|
@@ -1524,8 +2050,8 @@ const createAppStoreConnectRequest = ({ apiKey, privateKey, fetch }) => {
|
|
|
1524
2050
|
});
|
|
1525
2051
|
if (!response.ok) {
|
|
1526
2052
|
const error = new AppStoreConnectError(action, response.status, await readApiErrors(response), response.headers.get("retry-after"));
|
|
1527
|
-
if (response.status === 401) throw new Error("App Store Connect refused the API key
|
|
1528
|
-
if (response.status === 403) throw new Error(`${error.message}
|
|
2053
|
+
if (response.status === 401) throw new Error("App Store Connect refused the API key.\nCheck its key ID and issuer ID, and that it isn't revoked.", { cause: error });
|
|
2054
|
+
if (response.status === 403) throw new Error(isProgramLicenseAgreementError(error.errors) ? `${action} failed (HTTP 403).\n${PROGRAM_LICENSE_AGREEMENT_HINT}` : `${error.message}\nUploads need a key with the Admin, App Manager, or Developer role.`, { cause: error });
|
|
1529
2055
|
throw error;
|
|
1530
2056
|
}
|
|
1531
2057
|
return response.status === 204 ? void 0 : await response.json();
|
|
@@ -1538,7 +2064,7 @@ const findApp = async (request, bundleId) => {
|
|
|
1538
2064
|
"fields[apps]": "bundleId"
|
|
1539
2065
|
})}`));
|
|
1540
2066
|
const app = apps.find((app) => app.attributes?.bundleId === bundleId);
|
|
1541
|
-
if (!app) throw new Error(`App Store Connect has no app
|
|
2067
|
+
if (!app) throw new Error(`App Store Connect has no app ${bundleId} that this API key can see.\nCreate the app in App Store Connect, or use a key of its team.`);
|
|
1542
2068
|
return {
|
|
1543
2069
|
app,
|
|
1544
2070
|
testFlightUrl: `https://appstoreconnect.apple.com/apps/${app.id}/testflight/ios`
|
|
@@ -1610,14 +2136,18 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
|
|
|
1610
2136
|
} }));
|
|
1611
2137
|
} catch (error) {
|
|
1612
2138
|
if (error instanceof AppStoreConnectError && error.status === 409 && error.errors.some((apiError) => apiError.code === DUPLICATE_ERROR_CODE)) {
|
|
1613
|
-
if (unanswered) throw new Error(
|
|
2139
|
+
if (unanswered) throw new Error([
|
|
2140
|
+
`App Store Connect already has build ${info.bundleVersion} of version ${info.shortVersion}.`,
|
|
2141
|
+
"An unanswered request of this upload may have reserved it.",
|
|
2142
|
+
"Upload with a new build number (CFBundleVersion)."
|
|
2143
|
+
].join("\n"), { cause: error });
|
|
1614
2144
|
throw new TestFlightBuildExistsError(info.shortVersion, info.bundleVersion, { cause: error });
|
|
1615
2145
|
}
|
|
1616
2146
|
if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
|
|
1617
2147
|
if (!isRateLimited(error)) unanswered = true;
|
|
1618
2148
|
await sleep(retryDelayMs(error, attempt));
|
|
1619
2149
|
}
|
|
1620
|
-
_hot_updater_cli_tools.p.log.info(`Uploading ${ipaName}
|
|
2150
|
+
_hot_updater_cli_tools.p.log.info(`Uploading ${ipaName} ${info.shortVersion} (${info.bundleVersion}) to App Store Connect`);
|
|
1621
2151
|
const removeUpload = async () => {
|
|
1622
2152
|
for (let attempt = 1;; attempt++) try {
|
|
1623
2153
|
await request("Removing the build upload", "DELETE", `/v1/buildUploads/${buildUploadId}`);
|
|
@@ -1625,7 +2155,11 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
|
|
|
1625
2155
|
} catch (error) {
|
|
1626
2156
|
if (error instanceof AppStoreConnectError && error.status === 404) return;
|
|
1627
2157
|
if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) {
|
|
1628
|
-
_hot_updater_cli_tools.p.log.warn(
|
|
2158
|
+
_hot_updater_cli_tools.p.log.warn([
|
|
2159
|
+
`App Store Connect kept the unfinished upload of build ${info.bundleVersion}.`,
|
|
2160
|
+
error.message,
|
|
2161
|
+
"Upload with a new build number (CFBundleVersion)."
|
|
2162
|
+
].join("\n"));
|
|
1629
2163
|
return;
|
|
1630
2164
|
}
|
|
1631
2165
|
await sleep(retryDelayMs(error, attempt));
|
|
@@ -1667,7 +2201,12 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
|
|
|
1667
2201
|
} });
|
|
1668
2202
|
break;
|
|
1669
2203
|
} catch (error) {
|
|
1670
|
-
const unconfirmed = () => new Error(
|
|
2204
|
+
const unconfirmed = () => new Error([
|
|
2205
|
+
`App Store Connect didn't confirm the upload of build ${info.bundleVersion}.`,
|
|
2206
|
+
error.message,
|
|
2207
|
+
"Check TestFlight before you upload it again:",
|
|
2208
|
+
testFlightUrl
|
|
2209
|
+
].join("\n"), { cause: error });
|
|
1671
2210
|
if (!isRetryable(error)) {
|
|
1672
2211
|
if (commitUnanswered) throw unconfirmed();
|
|
1673
2212
|
await removeUpload();
|
|
@@ -1693,8 +2232,13 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
|
|
|
1693
2232
|
if (state.state === "PROCESSING") reportedProcessing = true;
|
|
1694
2233
|
if (!state.state || IN_PROGRESS_STATES.has(state.state)) {
|
|
1695
2234
|
if (Date.now() >= deadline) {
|
|
1696
|
-
if (!reportedProcessing) throw new Error(
|
|
1697
|
-
|
|
2235
|
+
if (!reportedProcessing) throw new Error([
|
|
2236
|
+
`App Store Connect didn't report build ${info.bundleVersion} processing.`,
|
|
2237
|
+
lastError instanceof Error ? lastError.message : state.state ? `State: ${state.state}` : void 0,
|
|
2238
|
+
"Check the build in TestFlight:",
|
|
2239
|
+
testFlightUrl
|
|
2240
|
+
].filter(Boolean).join("\n"), { cause: lastError });
|
|
2241
|
+
_hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${info.bundleVersion}.\nTestFlight lists it once processing finishes:\n${testFlightUrl}`);
|
|
1698
2242
|
return {
|
|
1699
2243
|
appId: app.id,
|
|
1700
2244
|
buildUploadId,
|
|
@@ -1825,7 +2369,7 @@ const acquireWithLockf = async (lockPath) => {
|
|
|
1825
2369
|
await fs.default.promises.mkdir(path.default.dirname(lockPath), { recursive: true });
|
|
1826
2370
|
const release = await holdWithLockf(lockPath, false);
|
|
1827
2371
|
if (release) return release;
|
|
1828
|
-
_hot_updater_cli_tools.p.log.info("Waiting for another build
|
|
2372
|
+
_hot_updater_cli_tools.p.log.info("Waiting for another build's signing keychain under this macOS user.");
|
|
1829
2373
|
return await holdWithLockf(lockPath, true);
|
|
1830
2374
|
};
|
|
1831
2375
|
/**
|
|
@@ -1896,7 +2440,12 @@ const createSigningKeychain = async ({ security = runSecurity, directory = os.de
|
|
|
1896
2440
|
live = void 0;
|
|
1897
2441
|
process.off("exit", restoreLive);
|
|
1898
2442
|
await releaseLock().catch(() => void 0);
|
|
1899
|
-
if (failures.length > 0) _hot_updater_cli_tools.p.log.warn(
|
|
2443
|
+
if (failures.length > 0) _hot_updater_cli_tools.p.log.warn([
|
|
2444
|
+
"Couldn't restore the keychains after the build.",
|
|
2445
|
+
...failures,
|
|
2446
|
+
"Check security list-keychains -d user and default-keychain -d user,",
|
|
2447
|
+
`and delete ${keychainPath} if it is left.`
|
|
2448
|
+
].join("\n"));
|
|
1900
2449
|
})();
|
|
1901
2450
|
return disposal;
|
|
1902
2451
|
};
|
|
@@ -1941,38 +2490,62 @@ const createSigningKeychain = async ({ security = runSecurity, directory = os.de
|
|
|
1941
2490
|
return {
|
|
1942
2491
|
path: keychainPath,
|
|
1943
2492
|
certificates: async () => {
|
|
2493
|
+
const identities = new Set([...(await security([
|
|
2494
|
+
"find-identity",
|
|
2495
|
+
"-p",
|
|
2496
|
+
"codesigning",
|
|
2497
|
+
keychainPath
|
|
2498
|
+
])).matchAll(/^\s*\d+\)\s+([0-9A-F]{40})\s/gm)].map((match) => match[1]));
|
|
2499
|
+
if (identities.size === 0) return [];
|
|
1944
2500
|
return ((await security([
|
|
1945
2501
|
"find-certificate",
|
|
1946
2502
|
"-a",
|
|
1947
2503
|
"-p",
|
|
1948
2504
|
keychainPath
|
|
1949
|
-
])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).
|
|
2505
|
+
])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).flatMap((pem) => {
|
|
1950
2506
|
const certificate = new crypto.default.X509Certificate(pem);
|
|
1951
|
-
return
|
|
2507
|
+
if (!identities.has(certificate.fingerprint.replaceAll(":", ""))) return [];
|
|
2508
|
+
return [{
|
|
1952
2509
|
serialNumber: normalizeSerial(certificate.serialNumber),
|
|
1953
2510
|
commonName: certificate.subject.match(/^CN=(.*)$/m)?.[1] ?? certificate.subject
|
|
1954
|
-
};
|
|
2511
|
+
}];
|
|
1955
2512
|
});
|
|
1956
2513
|
},
|
|
1957
2514
|
dispose
|
|
1958
2515
|
};
|
|
1959
2516
|
};
|
|
2517
|
+
/** A request App Store Connect refused, in one line. */
|
|
2518
|
+
var AppStoreConnectRefusal = class extends Error {
|
|
2519
|
+
/** Whether the team has a Program License Agreement to accept. */
|
|
2520
|
+
agreement;
|
|
2521
|
+
constructor(message, agreement) {
|
|
2522
|
+
super(message);
|
|
2523
|
+
this.agreement = agreement;
|
|
2524
|
+
}
|
|
2525
|
+
};
|
|
2526
|
+
const refusal = async (action, response) => {
|
|
2527
|
+
const body = await response.json().catch(() => void 0);
|
|
2528
|
+
return new AppStoreConnectRefusal(`${action} failed (HTTP ${response.status}).`, isProgramLicenseAgreementError(Array.isArray(body?.errors) ? body.errors : []));
|
|
2529
|
+
};
|
|
1960
2530
|
/**
|
|
1961
2531
|
* Revokes the certificates Xcode created in the build's keychain, whose
|
|
1962
2532
|
* private keys the keychain's deletion destroys: on a runner that starts
|
|
1963
2533
|
* empty, Xcode creates a development certificate for every build, and App
|
|
1964
2534
|
* Store Connect refuses new ones once a team has its maximum. Each is found
|
|
1965
2535
|
* in the team's certificates by serial number. A revocation that fails is
|
|
1966
|
-
* reported with
|
|
2536
|
+
* reported with each certificate's name and serial and where to revoke it
|
|
2537
|
+
* by hand, and never fails the build.
|
|
1967
2538
|
*/
|
|
1968
2539
|
const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fetch = globalThis.fetch }) => {
|
|
1969
2540
|
const revoked = [];
|
|
1970
2541
|
const failed = [];
|
|
2542
|
+
/** Why none could be revoked: the token or the listing failed. */
|
|
2543
|
+
let reason;
|
|
2544
|
+
let agreement = false;
|
|
1971
2545
|
if (certificates.length === 0) return {
|
|
1972
|
-
revoked,
|
|
1973
|
-
failed
|
|
2546
|
+
revoked: [],
|
|
2547
|
+
failed: []
|
|
1974
2548
|
};
|
|
1975
|
-
const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
|
|
1976
2549
|
try {
|
|
1977
2550
|
const token = createAppStoreConnectToken({
|
|
1978
2551
|
privateKey: privateKey ?? crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8")),
|
|
@@ -1988,7 +2561,7 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
|
|
|
1988
2561
|
})}`;
|
|
1989
2562
|
while (next) {
|
|
1990
2563
|
const response = await fetch(next, { headers });
|
|
1991
|
-
if (!response.ok) throw
|
|
2564
|
+
if (!response.ok) throw await refusal("Listing the team's certificates", response);
|
|
1992
2565
|
const body = await response.json();
|
|
1993
2566
|
for (const { id, attributes } of body.data ?? []) if (attributes?.serialNumber) idsBySerial.set(normalizeSerial(attributes.serialNumber), id);
|
|
1994
2567
|
next = body.links?.next;
|
|
@@ -2000,18 +2573,32 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
|
|
|
2000
2573
|
method: "DELETE",
|
|
2001
2574
|
headers
|
|
2002
2575
|
});
|
|
2003
|
-
if (response.ok || response.status === 404) revoked.push(
|
|
2004
|
-
else
|
|
2576
|
+
if (response.ok || response.status === 404) revoked.push(certificate);
|
|
2577
|
+
else {
|
|
2578
|
+
agreement ||= (await refusal("Revoking", response)).agreement;
|
|
2579
|
+
failed.push({
|
|
2580
|
+
certificate,
|
|
2581
|
+
status: response.status
|
|
2582
|
+
});
|
|
2583
|
+
}
|
|
2005
2584
|
}
|
|
2006
2585
|
} catch (error) {
|
|
2007
|
-
|
|
2008
|
-
|
|
2586
|
+
reason = error instanceof Error ? error.message : String(error);
|
|
2587
|
+
agreement ||= error instanceof AppStoreConnectRefusal && error.agreement;
|
|
2588
|
+
for (const certificate of certificates) if (!revoked.includes(certificate)) failed.push({ certificate });
|
|
2009
2589
|
}
|
|
2010
|
-
|
|
2011
|
-
if (
|
|
2590
|
+
const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
|
|
2591
|
+
if (revoked.length > 0) _hot_updater_cli_tools.p.log.info(["Revoked the certificates Xcode created for this build:", ...revoked.flatMap(({ commonName, serialNumber }) => [commonName, `Serial ${serialNumber}`])].join("\n"));
|
|
2592
|
+
if (failed.length > 0) _hot_updater_cli_tools.p.log.warn([
|
|
2593
|
+
"Couldn't revoke the certificates Xcode created for this build.",
|
|
2594
|
+
...reason === void 0 ? [] : [reason],
|
|
2595
|
+
...agreement ? [PROGRAM_LICENSE_AGREEMENT_HINT] : [],
|
|
2596
|
+
...failed.flatMap(({ certificate, status }) => [certificate.commonName, `Serial ${certificate.serialNumber}${status === void 0 ? "" : ` (HTTP ${status})`}`]),
|
|
2597
|
+
CERTIFICATES_URL
|
|
2598
|
+
].join("\n"));
|
|
2012
2599
|
return {
|
|
2013
|
-
revoked,
|
|
2014
|
-
failed
|
|
2600
|
+
revoked: revoked.map(describe),
|
|
2601
|
+
failed: failed.map(({ certificate }) => describe(certificate))
|
|
2015
2602
|
};
|
|
2016
2603
|
};
|
|
2017
2604
|
const TERMINATION_SIGNALS = [
|
|
@@ -2036,7 +2623,7 @@ const withSigningKeychain = async ({ enabled, apiKey, run, security, lockPath, a
|
|
|
2036
2623
|
lockPath,
|
|
2037
2624
|
acquireLock
|
|
2038
2625
|
});
|
|
2039
|
-
_hot_updater_cli_tools.p.log.info("Signing in a keychain of this build's own
|
|
2626
|
+
_hot_updater_cli_tools.p.log.info("Signing in a keychain of this build's own (CI is set).");
|
|
2040
2627
|
let cleanup;
|
|
2041
2628
|
const release = () => {
|
|
2042
2629
|
cleanup ??= (async () => {
|
|
@@ -2112,22 +2699,44 @@ const readBuiltApp = async (appPath) => {
|
|
|
2112
2699
|
* Builds a simulator `.app` for a `simulator` scheme, and otherwise archives
|
|
2113
2700
|
* and exports an `.ipa`, or returns the `.xcarchive` of a scheme it cannot
|
|
2114
2701
|
* export: one with neither `exportOptionsPlist` nor `signing.ascApiKey`.
|
|
2702
|
+
*
|
|
2703
|
+
* The artifact is in a temporary `buildDirectory`, which `dispose` removes
|
|
2704
|
+
* once the caller has copied it out. A failed build removes it, and every
|
|
2705
|
+
* other directory the build used, itself.
|
|
2115
2706
|
*/
|
|
2116
|
-
const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials }) => {
|
|
2707
|
+
const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials, logHeader = [] }) => {
|
|
2117
2708
|
await assertXcodebuildExist();
|
|
2118
|
-
|
|
2709
|
+
if (schemeConfig.simulator && schemeConfig.destination.length) {
|
|
2710
|
+
_hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
|
|
2711
|
+
process.exit(1);
|
|
2712
|
+
}
|
|
2119
2713
|
const buildDirectory = await createRandomTmpDir();
|
|
2120
|
-
|
|
2714
|
+
try {
|
|
2715
|
+
return {
|
|
2716
|
+
...await buildInto({
|
|
2717
|
+
schemeConfig,
|
|
2718
|
+
minBundleId,
|
|
2719
|
+
credentials,
|
|
2720
|
+
buildDirectory,
|
|
2721
|
+
logHeader
|
|
2722
|
+
}),
|
|
2723
|
+
dispose: () => removeTmpDir(buildDirectory)
|
|
2724
|
+
};
|
|
2725
|
+
} catch (error) {
|
|
2726
|
+
await removeTmpDir(buildDirectory);
|
|
2727
|
+
throw error;
|
|
2728
|
+
}
|
|
2729
|
+
};
|
|
2730
|
+
const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirectory, logHeader }) => {
|
|
2731
|
+
const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
|
|
2121
2732
|
if (schemeConfig.simulator) {
|
|
2122
|
-
if (schemeConfig.destination.length) {
|
|
2123
|
-
_hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
|
|
2124
|
-
process.exit(1);
|
|
2125
|
-
}
|
|
2126
2733
|
const appDir = path.default.join(buildDirectory, "app");
|
|
2127
2734
|
await fs.default.promises.mkdir(appDir, { recursive: true });
|
|
2128
|
-
const { appPath } = await buildXcodeProject({
|
|
2735
|
+
const { appPath, logFilePath } = await buildXcodeProject({
|
|
2129
2736
|
sourceDir: iosProjectRoot,
|
|
2737
|
+
outputDirectory: appDir,
|
|
2130
2738
|
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
|
|
2739
|
+
header: logHeader,
|
|
2131
2740
|
platform: schemeConfig.platform,
|
|
2132
2741
|
xcodeScheme: schemeConfig.scheme,
|
|
2133
2742
|
configuration: schemeConfig.configuration,
|
|
@@ -2137,13 +2746,11 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
|
|
|
2137
2746
|
useGenericDestination: true,
|
|
2138
2747
|
minBundleId
|
|
2139
2748
|
});
|
|
2140
|
-
const finalAppPath = path.default.join(appDir, path.default.basename(appPath));
|
|
2141
|
-
await fs.default.promises.cp(appPath, finalAppPath, { recursive: true });
|
|
2142
|
-
_hot_updater_cli_tools.p.log.success(`.app file created: ${path.default.basename(appPath)}`);
|
|
2143
2749
|
return {
|
|
2144
2750
|
buildDirectory,
|
|
2145
|
-
buildArtifactPath:
|
|
2146
|
-
|
|
2751
|
+
buildArtifactPath: appPath,
|
|
2752
|
+
logFilePath,
|
|
2753
|
+
builtApp: await readBuiltApp(appPath)
|
|
2147
2754
|
};
|
|
2148
2755
|
} else {
|
|
2149
2756
|
const signing = await resolveXcodeSigning({
|
|
@@ -2153,10 +2760,11 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
|
|
|
2153
2760
|
if (!signing) return archiveAndExport({
|
|
2154
2761
|
schemeConfig,
|
|
2155
2762
|
minBundleId,
|
|
2156
|
-
buildDirectory
|
|
2763
|
+
buildDirectory,
|
|
2764
|
+
logHeader
|
|
2157
2765
|
});
|
|
2158
2766
|
const appStoreConnect = await exportsForAppStoreConnect(schemeConfig);
|
|
2159
|
-
if ((0, _hot_updater_cli_tools.isCI)() && !appStoreConnect) _hot_updater_cli_tools.p.log.info("
|
|
2767
|
+
if ((0, _hot_updater_cli_tools.isCI)() && !appStoreConnect) _hot_updater_cli_tools.p.log.info("Signing in the runner's own keychains (not an App Store Connect export).");
|
|
2160
2768
|
return withSigningKeychain({
|
|
2161
2769
|
enabled: (0, _hot_updater_cli_tools.isCI)() && appStoreConnect,
|
|
2162
2770
|
apiKey: signing.apiKey,
|
|
@@ -2164,6 +2772,7 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
|
|
|
2164
2772
|
schemeConfig,
|
|
2165
2773
|
minBundleId,
|
|
2166
2774
|
buildDirectory,
|
|
2775
|
+
logHeader,
|
|
2167
2776
|
authenticationArgs: signing.authenticationArgs
|
|
2168
2777
|
})
|
|
2169
2778
|
});
|
|
@@ -2173,11 +2782,13 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
|
|
|
2173
2782
|
* Archives a device build and exports its `.ipa`, or returns the
|
|
2174
2783
|
* `.xcarchive` of a scheme it cannot export.
|
|
2175
2784
|
*/
|
|
2176
|
-
const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, authenticationArgs }) => {
|
|
2785
|
+
const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, logHeader, authenticationArgs }) => {
|
|
2177
2786
|
const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
|
|
2178
2787
|
const archiveDir = path.default.join(buildDirectory, "archive");
|
|
2179
2788
|
const exportDir = path.default.join(buildDirectory, "export");
|
|
2180
|
-
|
|
2789
|
+
await fs.default.promises.mkdir(archiveDir, { recursive: true });
|
|
2790
|
+
const { archivePath, logFilePath } = await archiveXcodeProject({
|
|
2791
|
+
archiveDirectory: archiveDir,
|
|
2181
2792
|
authenticationArgs,
|
|
2182
2793
|
minBundleId,
|
|
2183
2794
|
platform: schemeConfig.platform,
|
|
@@ -2188,37 +2799,45 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, aut
|
|
|
2188
2799
|
extraParams: schemeConfig.extraParams,
|
|
2189
2800
|
configuration: schemeConfig.configuration,
|
|
2190
2801
|
installPods: schemeConfig.installPods,
|
|
2191
|
-
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build
|
|
2802
|
+
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
|
|
2803
|
+
header: logHeader
|
|
2192
2804
|
});
|
|
2193
|
-
const
|
|
2194
|
-
await fs.default.promises.cp(archivePath, finalArchivePath, { recursive: true });
|
|
2195
|
-
const appFromArchive = extractAppFromXcarchive(finalArchivePath);
|
|
2805
|
+
const appFromArchive = extractAppFromXcarchive(archivePath);
|
|
2196
2806
|
const archivedAppPath = appFromArchive ? path.default.join(archiveDir, path.default.basename(appFromArchive)) : void 0;
|
|
2197
|
-
if (appFromArchive && archivedAppPath) {
|
|
2198
|
-
await fs.default.promises.cp(appFromArchive, archivedAppPath, { recursive: true });
|
|
2199
|
-
_hot_updater_cli_tools.p.log.success(".app extracted from .xcarchive");
|
|
2200
|
-
}
|
|
2807
|
+
if (appFromArchive && archivedAppPath) await fs.default.promises.cp(appFromArchive, archivedAppPath, { recursive: true });
|
|
2201
2808
|
if (!schemeConfig.exportOptionsPlist && !authenticationArgs) return {
|
|
2202
2809
|
buildDirectory,
|
|
2203
|
-
buildArtifactPath:
|
|
2810
|
+
buildArtifactPath: archivePath,
|
|
2811
|
+
logFilePath,
|
|
2204
2812
|
builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
|
|
2205
2813
|
};
|
|
2206
2814
|
await fs.default.promises.mkdir(exportDir, { recursive: true });
|
|
2207
|
-
const
|
|
2208
|
-
const { exportPath } = await exportXcodeArchive({
|
|
2815
|
+
const exportArchive = (exportOptionsPlist) => exportXcodeArchive({
|
|
2209
2816
|
archivePath,
|
|
2817
|
+
exportPath: exportDir,
|
|
2210
2818
|
authenticationArgs,
|
|
2211
2819
|
exportExtraParams: schemeConfig.exportExtraParams,
|
|
2212
2820
|
exportOptionsPlist,
|
|
2213
2821
|
sourceDir: iosProjectRoot,
|
|
2214
|
-
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build
|
|
2822
|
+
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
|
|
2823
|
+
logFilePath: logFilePath ?? void 0,
|
|
2824
|
+
title: schemeConfig.scheme
|
|
2215
2825
|
});
|
|
2216
|
-
|
|
2826
|
+
if (schemeConfig.exportOptionsPlist) await exportArchive(schemeConfig.exportOptionsPlist);
|
|
2827
|
+
else {
|
|
2828
|
+
const exportOptionsDir = await createRandomTmpDir();
|
|
2829
|
+
try {
|
|
2830
|
+
await exportArchive(await writeAppStoreConnectExportOptions(exportOptionsDir));
|
|
2831
|
+
} finally {
|
|
2832
|
+
await removeTmpDir(exportOptionsDir);
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2217
2835
|
const ipaFile = fs.default.readdirSync(exportDir).find((file) => file.endsWith(".ipa"));
|
|
2218
2836
|
if (!ipaFile) throw new Error("IPA file not found after export");
|
|
2219
2837
|
return {
|
|
2220
2838
|
buildDirectory,
|
|
2221
2839
|
buildArtifactPath: path.default.join(exportDir, ipaFile),
|
|
2840
|
+
logFilePath,
|
|
2222
2841
|
builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
|
|
2223
2842
|
};
|
|
2224
2843
|
};
|