@better-update/cli 0.38.0 → 0.39.0
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.mjs +1086 -628
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import chalk from "chalk";
|
|
|
20
20
|
import { promisify } from "node:util";
|
|
21
21
|
import ignore from "ignore";
|
|
22
22
|
import os, { tmpdir } from "node:os";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
23
24
|
import { maxBy, uniqBy } from "es-toolkit";
|
|
24
25
|
import forge from "node-forge";
|
|
25
26
|
import { AndroidConfig } from "@expo/config-plugins";
|
|
@@ -28,14 +29,13 @@ import { ExpoRunFormatter } from "@expo/xcpretty";
|
|
|
28
29
|
import { Buffer as Buffer$1 } from "node:buffer";
|
|
29
30
|
import { getFormattedSerialNumber, getX509Certificate, parsePKCS12 } from "@expo/pkcs12";
|
|
30
31
|
import qrcode from "qrcode-terminal";
|
|
31
|
-
import { fileURLToPath } from "node:url";
|
|
32
32
|
|
|
33
33
|
//#region \0rolldown/runtime.js
|
|
34
34
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
35
35
|
|
|
36
36
|
//#endregion
|
|
37
37
|
//#region package.json
|
|
38
|
-
var version = "0.
|
|
38
|
+
var version = "0.39.0";
|
|
39
39
|
|
|
40
40
|
//#endregion
|
|
41
41
|
//#region src/lib/interactive-mode.ts
|
|
@@ -20135,6 +20135,279 @@ const acquireBuildTempDir = Effect.gen(function* () {
|
|
|
20135
20135
|
return dir;
|
|
20136
20136
|
});
|
|
20137
20137
|
|
|
20138
|
+
//#endregion
|
|
20139
|
+
//#region src/lib/google-play.ts
|
|
20140
|
+
var GooglePlayAuthError = class extends Schema.TaggedError()("GooglePlayAuthError", {
|
|
20141
|
+
message: Schema.String,
|
|
20142
|
+
cause: Schema.optional(Schema.Unknown)
|
|
20143
|
+
}) {};
|
|
20144
|
+
var GooglePlayApiError = class extends Schema.TaggedError()("GooglePlayApiError", {
|
|
20145
|
+
message: Schema.String,
|
|
20146
|
+
httpStatus: Schema.optional(Schema.Number),
|
|
20147
|
+
cause: Schema.optional(Schema.Unknown)
|
|
20148
|
+
}) {};
|
|
20149
|
+
const ANDROID_PUBLISHER_SCOPE = "https://www.googleapis.com/auth/androidpublisher";
|
|
20150
|
+
const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
20151
|
+
const ANDROID_PUBLISHER_BASE = "https://androidpublisher.googleapis.com";
|
|
20152
|
+
const ANDROID_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload";
|
|
20153
|
+
const ServiceAccountJsonSchema = Schema.Struct({
|
|
20154
|
+
type: Schema.String,
|
|
20155
|
+
client_email: Schema.String,
|
|
20156
|
+
private_key: Schema.String,
|
|
20157
|
+
token_uri: Schema.optional(Schema.String)
|
|
20158
|
+
});
|
|
20159
|
+
const TokenResponseSchema = Schema.Struct({
|
|
20160
|
+
access_token: Schema.String,
|
|
20161
|
+
expires_in: Schema.optional(Schema.Number)
|
|
20162
|
+
});
|
|
20163
|
+
const stripPemHeaders = (pem) => pem.replace(/-----BEGIN [A-Z ]+-----/u, "").replace(/-----END [A-Z ]+-----/u, "").replaceAll(/\s+/gu, "");
|
|
20164
|
+
const asArrayBuffer$1 = (bytes) => {
|
|
20165
|
+
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
20166
|
+
new Uint8Array(buffer).set(bytes);
|
|
20167
|
+
return buffer;
|
|
20168
|
+
};
|
|
20169
|
+
const importPrivateKey = (pem) => Effect.tryPromise({
|
|
20170
|
+
try: async () => {
|
|
20171
|
+
const pkcs8 = fromBase64(stripPemHeaders(pem));
|
|
20172
|
+
return crypto.subtle.importKey("pkcs8", asArrayBuffer$1(pkcs8), {
|
|
20173
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
20174
|
+
hash: "SHA-256"
|
|
20175
|
+
}, false, ["sign"]);
|
|
20176
|
+
},
|
|
20177
|
+
catch: (cause) => new GooglePlayAuthError({
|
|
20178
|
+
message: "Failed to import service account private key",
|
|
20179
|
+
cause
|
|
20180
|
+
})
|
|
20181
|
+
});
|
|
20182
|
+
const buildJwtAssertion = (params) => {
|
|
20183
|
+
const header = {
|
|
20184
|
+
alg: "RS256",
|
|
20185
|
+
typ: "JWT"
|
|
20186
|
+
};
|
|
20187
|
+
const claims = {
|
|
20188
|
+
iss: params.clientEmail,
|
|
20189
|
+
scope: ANDROID_PUBLISHER_SCOPE,
|
|
20190
|
+
aud: params.tokenUri,
|
|
20191
|
+
exp: params.nowSeconds + 3600,
|
|
20192
|
+
iat: params.nowSeconds
|
|
20193
|
+
};
|
|
20194
|
+
return `${toBase64Url(new TextEncoder().encode(JSON.stringify(header)))}.${toBase64Url(new TextEncoder().encode(JSON.stringify(claims)))}`;
|
|
20195
|
+
};
|
|
20196
|
+
const signJwt = (key, payload) => Effect.tryPromise({
|
|
20197
|
+
try: async () => {
|
|
20198
|
+
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(payload));
|
|
20199
|
+
return `${payload}.${toBase64Url(new Uint8Array(signature))}`;
|
|
20200
|
+
},
|
|
20201
|
+
catch: (cause) => new GooglePlayAuthError({
|
|
20202
|
+
message: "Failed to sign JWT",
|
|
20203
|
+
cause
|
|
20204
|
+
})
|
|
20205
|
+
});
|
|
20206
|
+
const postTokenRequest = (tokenUri, jwt) => Effect.tryPromise({
|
|
20207
|
+
try: async () => {
|
|
20208
|
+
const body = new URLSearchParams({
|
|
20209
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
20210
|
+
assertion: jwt
|
|
20211
|
+
});
|
|
20212
|
+
const response = await fetch(tokenUri, {
|
|
20213
|
+
method: "POST",
|
|
20214
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
20215
|
+
body
|
|
20216
|
+
});
|
|
20217
|
+
const text = await response.text();
|
|
20218
|
+
return {
|
|
20219
|
+
ok: response.ok,
|
|
20220
|
+
status: response.status,
|
|
20221
|
+
text
|
|
20222
|
+
};
|
|
20223
|
+
},
|
|
20224
|
+
catch: (cause) => new GooglePlayAuthError({
|
|
20225
|
+
message: "Failed to exchange JWT for access token",
|
|
20226
|
+
cause
|
|
20227
|
+
})
|
|
20228
|
+
});
|
|
20229
|
+
const exchangeJwtForAccessToken = (tokenUri, jwt) => Effect.gen(function* () {
|
|
20230
|
+
const result = yield* postTokenRequest(tokenUri, jwt);
|
|
20231
|
+
if (!result.ok) return yield* new GooglePlayAuthError({ message: `OAuth token exchange failed: ${String(result.status)} ${result.text}` });
|
|
20232
|
+
const json = yield* Effect.try({
|
|
20233
|
+
try: () => JSON.parse(result.text),
|
|
20234
|
+
catch: (cause) => new GooglePlayAuthError({
|
|
20235
|
+
message: "OAuth token response is not JSON",
|
|
20236
|
+
cause
|
|
20237
|
+
})
|
|
20238
|
+
});
|
|
20239
|
+
return (yield* Schema.decodeUnknown(TokenResponseSchema)(json).pipe(Effect.mapError((cause) => new GooglePlayAuthError({
|
|
20240
|
+
message: "OAuth token response missing access_token",
|
|
20241
|
+
cause
|
|
20242
|
+
})))).access_token;
|
|
20243
|
+
});
|
|
20244
|
+
const acquireGooglePlayAccessToken = (serviceAccountJson) => Effect.gen(function* () {
|
|
20245
|
+
const raw = yield* Effect.try({
|
|
20246
|
+
try: () => JSON.parse(serviceAccountJson),
|
|
20247
|
+
catch: (cause) => new GooglePlayAuthError({
|
|
20248
|
+
message: "Service account JSON is not valid JSON",
|
|
20249
|
+
cause
|
|
20250
|
+
})
|
|
20251
|
+
});
|
|
20252
|
+
const parsed = yield* Schema.decodeUnknown(ServiceAccountJsonSchema)(raw).pipe(Effect.mapError((cause) => new GooglePlayAuthError({
|
|
20253
|
+
message: "Service account JSON missing required fields (type, client_email, private_key)",
|
|
20254
|
+
cause
|
|
20255
|
+
})));
|
|
20256
|
+
if (parsed.type !== "service_account") return yield* new GooglePlayAuthError({ message: `Service account JSON has wrong type: ${parsed.type}` });
|
|
20257
|
+
const tokenUri = parsed.token_uri ?? GOOGLE_OAUTH_TOKEN_URL;
|
|
20258
|
+
return {
|
|
20259
|
+
accessToken: yield* exchangeJwtForAccessToken(tokenUri, yield* signJwt(yield* importPrivateKey(parsed.private_key), buildJwtAssertion({
|
|
20260
|
+
clientEmail: parsed.client_email,
|
|
20261
|
+
tokenUri,
|
|
20262
|
+
nowSeconds: Math.floor(Date.now() / 1e3)
|
|
20263
|
+
}))),
|
|
20264
|
+
clientEmail: parsed.client_email
|
|
20265
|
+
};
|
|
20266
|
+
});
|
|
20267
|
+
const authHeaders = (accessToken) => ({ Authorization: `Bearer ${accessToken}` });
|
|
20268
|
+
const performFetch = (params) => Effect.tryPromise({
|
|
20269
|
+
try: async () => {
|
|
20270
|
+
const init = params.body === void 0 ? {
|
|
20271
|
+
method: params.method,
|
|
20272
|
+
headers: authHeaders(params.accessToken)
|
|
20273
|
+
} : {
|
|
20274
|
+
method: params.method,
|
|
20275
|
+
headers: {
|
|
20276
|
+
...authHeaders(params.accessToken),
|
|
20277
|
+
"Content-Type": "application/json"
|
|
20278
|
+
},
|
|
20279
|
+
body: JSON.stringify(params.body)
|
|
20280
|
+
};
|
|
20281
|
+
const response = await fetch(params.url, init);
|
|
20282
|
+
const text = await response.text();
|
|
20283
|
+
return {
|
|
20284
|
+
ok: response.ok,
|
|
20285
|
+
status: response.status,
|
|
20286
|
+
text
|
|
20287
|
+
};
|
|
20288
|
+
},
|
|
20289
|
+
catch: (cause) => new GooglePlayApiError({
|
|
20290
|
+
message: `${params.label} request failed`,
|
|
20291
|
+
cause
|
|
20292
|
+
})
|
|
20293
|
+
});
|
|
20294
|
+
const callJsonRaw = (params) => Effect.gen(function* () {
|
|
20295
|
+
const result = yield* performFetch(params);
|
|
20296
|
+
if (!result.ok) return yield* new GooglePlayApiError({
|
|
20297
|
+
message: `${params.label} failed: ${String(result.status)} ${result.text}`,
|
|
20298
|
+
httpStatus: result.status
|
|
20299
|
+
});
|
|
20300
|
+
return yield* Effect.try({
|
|
20301
|
+
try: () => result.text === "" ? {} : JSON.parse(result.text),
|
|
20302
|
+
catch: (cause) => new GooglePlayApiError({
|
|
20303
|
+
message: `${params.label} response is not JSON`,
|
|
20304
|
+
cause
|
|
20305
|
+
})
|
|
20306
|
+
});
|
|
20307
|
+
});
|
|
20308
|
+
const AppEditSchema = Schema.Struct({
|
|
20309
|
+
id: Schema.String,
|
|
20310
|
+
expiryTimeSeconds: Schema.optional(Schema.String)
|
|
20311
|
+
});
|
|
20312
|
+
const insertEdit = (params) => Effect.gen(function* () {
|
|
20313
|
+
const raw = yield* callJsonRaw({
|
|
20314
|
+
url: `${ANDROID_PUBLISHER_BASE}/androidpublisher/v3/applications/${encodeURIComponent(params.packageName)}/edits`,
|
|
20315
|
+
method: "POST",
|
|
20316
|
+
accessToken: params.accessToken,
|
|
20317
|
+
body: {},
|
|
20318
|
+
label: "edits.insert"
|
|
20319
|
+
});
|
|
20320
|
+
return yield* Schema.decodeUnknown(AppEditSchema)(raw).pipe(Effect.mapError((cause) => new GooglePlayApiError({
|
|
20321
|
+
message: "edits.insert response missing id",
|
|
20322
|
+
cause
|
|
20323
|
+
})));
|
|
20324
|
+
});
|
|
20325
|
+
const UploadedBundleSchema = Schema.Struct({
|
|
20326
|
+
versionCode: Schema.Number,
|
|
20327
|
+
sha256: Schema.optional(Schema.String)
|
|
20328
|
+
});
|
|
20329
|
+
const performBundleUpload = (params) => Effect.tryPromise({
|
|
20330
|
+
try: async () => {
|
|
20331
|
+
const url = `${ANDROID_UPLOAD_BASE}/androidpublisher/v3/applications/${encodeURIComponent(params.packageName)}/edits/${encodeURIComponent(params.editId)}/bundles?uploadType=media`;
|
|
20332
|
+
const response = await fetch(url, {
|
|
20333
|
+
method: "POST",
|
|
20334
|
+
headers: {
|
|
20335
|
+
...authHeaders(params.accessToken),
|
|
20336
|
+
"Content-Type": "application/octet-stream"
|
|
20337
|
+
},
|
|
20338
|
+
body: new Uint8Array(params.aabBytes)
|
|
20339
|
+
});
|
|
20340
|
+
const text = await response.text();
|
|
20341
|
+
return {
|
|
20342
|
+
ok: response.ok,
|
|
20343
|
+
status: response.status,
|
|
20344
|
+
text
|
|
20345
|
+
};
|
|
20346
|
+
},
|
|
20347
|
+
catch: (cause) => new GooglePlayApiError({
|
|
20348
|
+
message: "edits.bundles.upload request failed",
|
|
20349
|
+
cause
|
|
20350
|
+
})
|
|
20351
|
+
});
|
|
20352
|
+
const uploadBundle = (params) => Effect.gen(function* () {
|
|
20353
|
+
const result = yield* performBundleUpload(params);
|
|
20354
|
+
if (!result.ok) return yield* new GooglePlayApiError({
|
|
20355
|
+
message: `edits.bundles.upload failed: ${String(result.status)} ${result.text}`,
|
|
20356
|
+
httpStatus: result.status
|
|
20357
|
+
});
|
|
20358
|
+
const raw = yield* Effect.try({
|
|
20359
|
+
try: () => JSON.parse(result.text),
|
|
20360
|
+
catch: (cause) => new GooglePlayApiError({
|
|
20361
|
+
message: "edits.bundles.upload response is not JSON",
|
|
20362
|
+
cause
|
|
20363
|
+
})
|
|
20364
|
+
});
|
|
20365
|
+
return yield* Schema.decodeUnknown(UploadedBundleSchema)(raw).pipe(Effect.mapError((cause) => new GooglePlayApiError({
|
|
20366
|
+
message: "Bundle upload response missing versionCode",
|
|
20367
|
+
cause
|
|
20368
|
+
})));
|
|
20369
|
+
});
|
|
20370
|
+
/**
|
|
20371
|
+
* Build the track release object. Google Play accepts `userFraction` only for a
|
|
20372
|
+
* staged (`inProgress`) rollout and rejects it for `completed`/`draft`/`halted`,
|
|
20373
|
+
* so the fraction is gated on the status rather than passed through blindly.
|
|
20374
|
+
*/
|
|
20375
|
+
const buildTrackRelease = (params) => ({
|
|
20376
|
+
status: params.releaseStatus,
|
|
20377
|
+
versionCodes: [String(params.versionCode)],
|
|
20378
|
+
...compact({
|
|
20379
|
+
userFraction: params.releaseStatus === "inProgress" ? toOptional(params.rollout) : void 0,
|
|
20380
|
+
releaseNotes: params.releaseNotes ? [{
|
|
20381
|
+
language: "en-US",
|
|
20382
|
+
text: params.releaseNotes
|
|
20383
|
+
}] : void 0
|
|
20384
|
+
})
|
|
20385
|
+
});
|
|
20386
|
+
const updateTrack = (params) => {
|
|
20387
|
+
const release = buildTrackRelease({
|
|
20388
|
+
releaseStatus: params.releaseStatus,
|
|
20389
|
+
versionCode: params.versionCode,
|
|
20390
|
+
rollout: params.rollout,
|
|
20391
|
+
releaseNotes: params.releaseNotes
|
|
20392
|
+
});
|
|
20393
|
+
return callJsonRaw({
|
|
20394
|
+
url: `${ANDROID_PUBLISHER_BASE}/androidpublisher/v3/applications/${encodeURIComponent(params.packageName)}/edits/${encodeURIComponent(params.editId)}/tracks/${encodeURIComponent(params.track)}`,
|
|
20395
|
+
method: "PUT",
|
|
20396
|
+
accessToken: params.accessToken,
|
|
20397
|
+
body: {
|
|
20398
|
+
track: params.track,
|
|
20399
|
+
releases: [release]
|
|
20400
|
+
},
|
|
20401
|
+
label: "edits.tracks.update"
|
|
20402
|
+
});
|
|
20403
|
+
};
|
|
20404
|
+
const commitEdit = (params) => callJsonRaw({
|
|
20405
|
+
url: `${ANDROID_PUBLISHER_BASE}/androidpublisher/v3/applications/${encodeURIComponent(params.packageName)}/edits/${encodeURIComponent(params.editId)}:commit?changesNotSentForReview=${String(params.changesNotSentForReview)}`,
|
|
20406
|
+
method: "POST",
|
|
20407
|
+
accessToken: params.accessToken,
|
|
20408
|
+
label: "edits.commit"
|
|
20409
|
+
});
|
|
20410
|
+
|
|
20138
20411
|
//#endregion
|
|
20139
20412
|
//#region src/lib/asc-credentials.ts
|
|
20140
20413
|
/**
|
|
@@ -20168,265 +20441,589 @@ const fetchAscCredentials = (api, ascApiKeyId) => Effect.gen(function* () {
|
|
|
20168
20441
|
});
|
|
20169
20442
|
|
|
20170
20443
|
//#endregion
|
|
20171
|
-
//#region src/lib/
|
|
20172
|
-
|
|
20173
|
-
|
|
20174
|
-
|
|
20175
|
-
|
|
20176
|
-
|
|
20177
|
-
|
|
20178
|
-
|
|
20179
|
-
|
|
20180
|
-
|
|
20181
|
-
|
|
20182
|
-
|
|
20183
|
-
|
|
20184
|
-
|
|
20185
|
-
|
|
20186
|
-
|
|
20187
|
-
|
|
20188
|
-
|
|
20189
|
-
|
|
20190
|
-
}
|
|
20191
|
-
const
|
|
20192
|
-
|
|
20193
|
-
expires_in: Schema.optional(Schema.Number)
|
|
20194
|
-
});
|
|
20195
|
-
const stripPemHeaders = (pem) => pem.replace(/-----BEGIN [A-Z ]+-----/u, "").replace(/-----END [A-Z ]+-----/u, "").replaceAll(/\s+/gu, "");
|
|
20196
|
-
const asArrayBuffer$1 = (bytes) => {
|
|
20444
|
+
//#region src/lib/apple-pem.ts
|
|
20445
|
+
const PEM_HEADER = "-----BEGIN PRIVATE KEY-----";
|
|
20446
|
+
const PEM_FOOTER = "-----END PRIVATE KEY-----";
|
|
20447
|
+
const pemToPkcs8Der = (pem) => {
|
|
20448
|
+
const normalized = pem.replaceAll("\r\n", "\n").trim();
|
|
20449
|
+
const start = normalized.indexOf(PEM_HEADER);
|
|
20450
|
+
const end = normalized.indexOf(PEM_FOOTER);
|
|
20451
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
20452
|
+
const body = normalized.slice(start + 27, end).replaceAll(/\s+/gu, "").trim();
|
|
20453
|
+
if (body.length === 0) return null;
|
|
20454
|
+
try {
|
|
20455
|
+
return fromBase64(body);
|
|
20456
|
+
} catch {
|
|
20457
|
+
return null;
|
|
20458
|
+
}
|
|
20459
|
+
};
|
|
20460
|
+
|
|
20461
|
+
//#endregion
|
|
20462
|
+
//#region src/lib/apple-asc-jwt.ts
|
|
20463
|
+
var AppleAuthError = class extends Data.TaggedError("AppleAuthError") {};
|
|
20464
|
+
const MAX_JWT_LIFETIME_SECONDS = 1200;
|
|
20465
|
+
const asArrayBuffer = (bytes) => {
|
|
20197
20466
|
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
20198
20467
|
new Uint8Array(buffer).set(bytes);
|
|
20199
20468
|
return buffer;
|
|
20200
20469
|
};
|
|
20201
|
-
const
|
|
20202
|
-
|
|
20203
|
-
|
|
20204
|
-
return crypto.subtle.importKey("pkcs8", asArrayBuffer$1(pkcs8), {
|
|
20205
|
-
name: "RSASSA-PKCS1-v1_5",
|
|
20206
|
-
hash: "SHA-256"
|
|
20207
|
-
}, false, ["sign"]);
|
|
20208
|
-
},
|
|
20209
|
-
catch: (cause) => new GooglePlayAuthError({
|
|
20210
|
-
message: "Failed to import service account private key",
|
|
20211
|
-
cause
|
|
20212
|
-
})
|
|
20213
|
-
});
|
|
20214
|
-
const buildJwtAssertion = (params) => {
|
|
20470
|
+
const signAscJwt = (credentials) => Effect.gen(function* () {
|
|
20471
|
+
const der = pemToPkcs8Der(credentials.p8Pem);
|
|
20472
|
+
if (der === null) return yield* new AppleAuthError({ cause: /* @__PURE__ */ new Error("Invalid .p8 PEM") });
|
|
20215
20473
|
const header = {
|
|
20216
|
-
alg: "
|
|
20474
|
+
alg: "ES256",
|
|
20475
|
+
kid: credentials.keyId,
|
|
20217
20476
|
typ: "JWT"
|
|
20218
20477
|
};
|
|
20219
|
-
const
|
|
20220
|
-
|
|
20221
|
-
|
|
20222
|
-
|
|
20223
|
-
exp:
|
|
20224
|
-
|
|
20478
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
20479
|
+
const payload = {
|
|
20480
|
+
iss: credentials.issuerId,
|
|
20481
|
+
iat: now,
|
|
20482
|
+
exp: now + MAX_JWT_LIFETIME_SECONDS,
|
|
20483
|
+
aud: "appstoreconnect-v1"
|
|
20225
20484
|
};
|
|
20226
|
-
|
|
20227
|
-
|
|
20228
|
-
|
|
20229
|
-
|
|
20230
|
-
|
|
20231
|
-
|
|
20232
|
-
|
|
20233
|
-
catch: (cause) => new GooglePlayAuthError({
|
|
20234
|
-
message: "Failed to sign JWT",
|
|
20235
|
-
cause
|
|
20236
|
-
})
|
|
20237
|
-
});
|
|
20238
|
-
const postTokenRequest = (tokenUri, jwt) => Effect.tryPromise({
|
|
20239
|
-
try: async () => {
|
|
20240
|
-
const body = new URLSearchParams({
|
|
20241
|
-
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
20242
|
-
assertion: jwt
|
|
20243
|
-
});
|
|
20244
|
-
const response = await fetch(tokenUri, {
|
|
20245
|
-
method: "POST",
|
|
20246
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
20247
|
-
body
|
|
20248
|
-
});
|
|
20249
|
-
const text = await response.text();
|
|
20250
|
-
return {
|
|
20251
|
-
ok: response.ok,
|
|
20252
|
-
status: response.status,
|
|
20253
|
-
text
|
|
20254
|
-
};
|
|
20255
|
-
},
|
|
20256
|
-
catch: (cause) => new GooglePlayAuthError({
|
|
20257
|
-
message: "Failed to exchange JWT for access token",
|
|
20258
|
-
cause
|
|
20259
|
-
})
|
|
20260
|
-
});
|
|
20261
|
-
const exchangeJwtForAccessToken = (tokenUri, jwt) => Effect.gen(function* () {
|
|
20262
|
-
const result = yield* postTokenRequest(tokenUri, jwt);
|
|
20263
|
-
if (!result.ok) return yield* new GooglePlayAuthError({ message: `OAuth token exchange failed: ${String(result.status)} ${result.text}` });
|
|
20264
|
-
const json = yield* Effect.try({
|
|
20265
|
-
try: () => JSON.parse(result.text),
|
|
20266
|
-
catch: (cause) => new GooglePlayAuthError({
|
|
20267
|
-
message: "OAuth token response is not JSON",
|
|
20268
|
-
cause
|
|
20269
|
-
})
|
|
20485
|
+
const signingInput = `${toBase64Url(new TextEncoder().encode(JSON.stringify(header)))}.${toBase64Url(new TextEncoder().encode(JSON.stringify(payload)))}`;
|
|
20486
|
+
const key = yield* Effect.tryPromise({
|
|
20487
|
+
try: async () => crypto.subtle.importKey("pkcs8", asArrayBuffer(der), {
|
|
20488
|
+
name: "ECDSA",
|
|
20489
|
+
namedCurve: "P-256"
|
|
20490
|
+
}, false, ["sign"]),
|
|
20491
|
+
catch: (cause) => new AppleAuthError({ cause })
|
|
20270
20492
|
});
|
|
20271
|
-
|
|
20272
|
-
|
|
20273
|
-
|
|
20274
|
-
|
|
20275
|
-
})
|
|
20276
|
-
|
|
20277
|
-
const raw = yield* Effect.try({
|
|
20278
|
-
try: () => JSON.parse(serviceAccountJson),
|
|
20279
|
-
catch: (cause) => new GooglePlayAuthError({
|
|
20280
|
-
message: "Service account JSON is not valid JSON",
|
|
20281
|
-
cause
|
|
20282
|
-
})
|
|
20493
|
+
const signature = yield* Effect.tryPromise({
|
|
20494
|
+
try: async () => crypto.subtle.sign({
|
|
20495
|
+
name: "ECDSA",
|
|
20496
|
+
hash: "SHA-256"
|
|
20497
|
+
}, key, new TextEncoder().encode(signingInput)),
|
|
20498
|
+
catch: (cause) => new AppleAuthError({ cause })
|
|
20283
20499
|
});
|
|
20284
|
-
|
|
20285
|
-
message: "Service account JSON missing required fields (type, client_email, private_key)",
|
|
20286
|
-
cause
|
|
20287
|
-
})));
|
|
20288
|
-
if (parsed.type !== "service_account") return yield* new GooglePlayAuthError({ message: `Service account JSON has wrong type: ${parsed.type}` });
|
|
20289
|
-
const tokenUri = parsed.token_uri ?? GOOGLE_OAUTH_TOKEN_URL;
|
|
20290
|
-
return {
|
|
20291
|
-
accessToken: yield* exchangeJwtForAccessToken(tokenUri, yield* signJwt(yield* importPrivateKey(parsed.private_key), buildJwtAssertion({
|
|
20292
|
-
clientEmail: parsed.client_email,
|
|
20293
|
-
tokenUri,
|
|
20294
|
-
nowSeconds: Math.floor(Date.now() / 1e3)
|
|
20295
|
-
}))),
|
|
20296
|
-
clientEmail: parsed.client_email
|
|
20297
|
-
};
|
|
20500
|
+
return `${signingInput}.${toBase64Url(new Uint8Array(signature))}`;
|
|
20298
20501
|
});
|
|
20299
|
-
|
|
20300
|
-
|
|
20301
|
-
|
|
20302
|
-
|
|
20303
|
-
|
|
20304
|
-
|
|
20305
|
-
|
|
20306
|
-
|
|
20502
|
+
|
|
20503
|
+
//#endregion
|
|
20504
|
+
//#region src/lib/apple-asc-client.ts
|
|
20505
|
+
/**
|
|
20506
|
+
* App Store Connect REST client authenticated with an ASC **API key** — a JWT
|
|
20507
|
+
* signed from a `.p8` private key (see `apple-asc-jwt.ts`). Credentials are
|
|
20508
|
+
* resolved non-interactively from the server (`fetchAscCredentials`), so this
|
|
20509
|
+
* powers headless flows: build-credential resolution, provisioning-profile
|
|
20510
|
+
* generation, and device sync.
|
|
20511
|
+
*
|
|
20512
|
+
* Intentionally NOT built on `@expo/apple-utils`: that library authenticates
|
|
20513
|
+
* via an interactive Apple-ID **cookie session** (username/password + 2FA, see
|
|
20514
|
+
* `services/apple-auth.ts`) and exposes a cookie-based `RequestContext`. That is
|
|
20515
|
+
* a different auth model that would force an interactive login here. The two
|
|
20516
|
+
* coexist by design — apple-utils backs `apple login`; this client backs
|
|
20517
|
+
* non-interactive ASC API-key access.
|
|
20518
|
+
*/
|
|
20519
|
+
var AscApiError = class extends Data.TaggedError("AscApiError") {};
|
|
20520
|
+
var AscNetworkError = class extends Data.TaggedError("AscNetworkError") {};
|
|
20521
|
+
const API_BASE = "https://api.appstoreconnect.apple.com";
|
|
20522
|
+
const extractErrors = (body) => {
|
|
20523
|
+
if (!isRecord$1(body) || !Array.isArray(body["errors"])) return [];
|
|
20524
|
+
return body["errors"].filter((value) => isRecord$1(value));
|
|
20525
|
+
};
|
|
20526
|
+
const parseApiError = (response, body, raw) => {
|
|
20527
|
+
const [first] = extractErrors(body);
|
|
20528
|
+
return new AscApiError({
|
|
20529
|
+
status: response.status,
|
|
20530
|
+
message: first?.detail ?? first?.title ?? response.statusText,
|
|
20531
|
+
code: first?.code,
|
|
20532
|
+
raw
|
|
20533
|
+
});
|
|
20534
|
+
};
|
|
20535
|
+
const fetchRaw = (jwt, path, init) => Effect.gen(function* () {
|
|
20536
|
+
const response = yield* Effect.tryPromise({
|
|
20537
|
+
try: async () => fetch(`${API_BASE}${path}`, compact({
|
|
20538
|
+
method: init?.method ?? "GET",
|
|
20539
|
+
body: init?.body,
|
|
20307
20540
|
headers: {
|
|
20308
|
-
|
|
20309
|
-
"
|
|
20310
|
-
|
|
20311
|
-
|
|
20312
|
-
}
|
|
20313
|
-
|
|
20314
|
-
const text = await response.text();
|
|
20315
|
-
return {
|
|
20316
|
-
ok: response.ok,
|
|
20317
|
-
status: response.status,
|
|
20318
|
-
text
|
|
20319
|
-
};
|
|
20320
|
-
},
|
|
20321
|
-
catch: (cause) => new GooglePlayApiError({
|
|
20322
|
-
message: `${params.label} request failed`,
|
|
20323
|
-
cause
|
|
20324
|
-
})
|
|
20325
|
-
});
|
|
20326
|
-
const callJsonRaw = (params) => Effect.gen(function* () {
|
|
20327
|
-
const result = yield* performFetch(params);
|
|
20328
|
-
if (!result.ok) return yield* new GooglePlayApiError({
|
|
20329
|
-
message: `${params.label} failed: ${String(result.status)} ${result.text}`,
|
|
20330
|
-
httpStatus: result.status
|
|
20541
|
+
authorization: `Bearer ${jwt}`,
|
|
20542
|
+
"content-type": "application/json",
|
|
20543
|
+
accept: "application/json"
|
|
20544
|
+
}
|
|
20545
|
+
})),
|
|
20546
|
+
catch: (cause) => new AscNetworkError({ cause })
|
|
20331
20547
|
});
|
|
20332
|
-
|
|
20333
|
-
try: () =>
|
|
20334
|
-
catch: (cause) => new
|
|
20335
|
-
message: `${params.label} response is not JSON`,
|
|
20336
|
-
cause
|
|
20337
|
-
})
|
|
20548
|
+
const text = yield* Effect.tryPromise({
|
|
20549
|
+
try: async () => response.text(),
|
|
20550
|
+
catch: (cause) => new AscNetworkError({ cause })
|
|
20338
20551
|
});
|
|
20339
|
-
|
|
20340
|
-
|
|
20341
|
-
|
|
20342
|
-
expiryTimeSeconds: Schema.optional(Schema.String)
|
|
20343
|
-
});
|
|
20344
|
-
const insertEdit = (params) => Effect.gen(function* () {
|
|
20345
|
-
const raw = yield* callJsonRaw({
|
|
20346
|
-
url: `${ANDROID_PUBLISHER_BASE}/androidpublisher/v3/applications/${encodeURIComponent(params.packageName)}/edits`,
|
|
20347
|
-
method: "POST",
|
|
20348
|
-
accessToken: params.accessToken,
|
|
20349
|
-
body: {},
|
|
20350
|
-
label: "edits.insert"
|
|
20552
|
+
const body = yield* Effect.try({
|
|
20553
|
+
try: () => text.length === 0 ? {} : JSON.parse(text),
|
|
20554
|
+
catch: (cause) => new AscNetworkError({ cause })
|
|
20351
20555
|
});
|
|
20352
|
-
return yield*
|
|
20353
|
-
|
|
20354
|
-
cause
|
|
20355
|
-
})));
|
|
20556
|
+
if (!response.ok) return yield* parseApiError(response, body, text);
|
|
20557
|
+
return body;
|
|
20356
20558
|
});
|
|
20357
|
-
const
|
|
20358
|
-
|
|
20359
|
-
|
|
20559
|
+
const toAscCertificate = (value) => {
|
|
20560
|
+
if (!isRecord$1(value)) return null;
|
|
20561
|
+
const { id, attributes } = value;
|
|
20562
|
+
if (typeof id !== "string" || !isRecord$1(attributes)) return null;
|
|
20563
|
+
const { serialNumber, certificateType, expirationDate, certificateContent, displayName } = attributes;
|
|
20564
|
+
if (typeof serialNumber !== "string" || typeof certificateType !== "string" || typeof expirationDate !== "string") return null;
|
|
20565
|
+
return {
|
|
20566
|
+
id,
|
|
20567
|
+
serialNumber,
|
|
20568
|
+
certificateType,
|
|
20569
|
+
expirationDate,
|
|
20570
|
+
certificateContent: typeof certificateContent === "string" ? certificateContent : null,
|
|
20571
|
+
displayName: typeof displayName === "string" ? displayName : null
|
|
20572
|
+
};
|
|
20573
|
+
};
|
|
20574
|
+
const toAscBundleId = (value) => {
|
|
20575
|
+
if (!isRecord$1(value)) return null;
|
|
20576
|
+
const { id, attributes } = value;
|
|
20577
|
+
if (typeof id !== "string" || !isRecord$1(attributes)) return null;
|
|
20578
|
+
const { identifier, name } = attributes;
|
|
20579
|
+
if (typeof identifier !== "string" || typeof name !== "string") return null;
|
|
20580
|
+
return {
|
|
20581
|
+
id,
|
|
20582
|
+
identifier,
|
|
20583
|
+
name
|
|
20584
|
+
};
|
|
20585
|
+
};
|
|
20586
|
+
const PROFILE_TYPES = [
|
|
20587
|
+
"IOS_APP_ADHOC",
|
|
20588
|
+
"IOS_APP_DEVELOPMENT",
|
|
20589
|
+
"IOS_APP_STORE",
|
|
20590
|
+
"IOS_APP_INHOUSE"
|
|
20591
|
+
];
|
|
20592
|
+
const asProfileType = (value) => {
|
|
20593
|
+
const match = PROFILE_TYPES.find((entry) => entry === value);
|
|
20594
|
+
return match === void 0 ? null : match;
|
|
20595
|
+
};
|
|
20596
|
+
const toAscProfile = (value) => {
|
|
20597
|
+
if (!isRecord$1(value)) return null;
|
|
20598
|
+
const { id, attributes } = value;
|
|
20599
|
+
if (typeof id !== "string" || !isRecord$1(attributes)) return null;
|
|
20600
|
+
const { name, uuid, expirationDate, profileContent } = attributes;
|
|
20601
|
+
const profileType = asProfileType(attributes["profileType"]);
|
|
20602
|
+
if (typeof name !== "string" || typeof uuid !== "string" || typeof expirationDate !== "string" || typeof profileContent !== "string" || profileType === null) return null;
|
|
20603
|
+
return {
|
|
20604
|
+
id,
|
|
20605
|
+
name,
|
|
20606
|
+
uuid,
|
|
20607
|
+
expirationDate,
|
|
20608
|
+
profileContent,
|
|
20609
|
+
profileType
|
|
20610
|
+
};
|
|
20611
|
+
};
|
|
20612
|
+
const toAscDevice = (value) => {
|
|
20613
|
+
if (!isRecord$1(value)) return null;
|
|
20614
|
+
const { id, attributes } = value;
|
|
20615
|
+
if (typeof id !== "string" || !isRecord$1(attributes)) return null;
|
|
20616
|
+
const { udid, name, deviceClass } = attributes;
|
|
20617
|
+
if (typeof udid !== "string" || typeof name !== "string") return null;
|
|
20618
|
+
return {
|
|
20619
|
+
id,
|
|
20620
|
+
udid,
|
|
20621
|
+
name,
|
|
20622
|
+
deviceClass: typeof deviceClass === "string" ? deviceClass : null
|
|
20623
|
+
};
|
|
20624
|
+
};
|
|
20625
|
+
const extractList = (body, map) => {
|
|
20626
|
+
if (!isRecord$1(body) || !Array.isArray(body["data"])) return [];
|
|
20627
|
+
return body["data"].map(map).filter((value) => value !== null);
|
|
20628
|
+
};
|
|
20629
|
+
const extractSingle = (body, map) => {
|
|
20630
|
+
if (!isRecord$1(body)) return null;
|
|
20631
|
+
return map(body["data"]);
|
|
20632
|
+
};
|
|
20633
|
+
/**
|
|
20634
|
+
* App Store Connect paginates list responses (default 200/page) and returns the
|
|
20635
|
+
* absolute URL of the next page under `links.next`. Strip the base so it can be
|
|
20636
|
+
* fed back into `fetchRaw`; return null when there is no further page.
|
|
20637
|
+
*/
|
|
20638
|
+
const nextPagePath = (body) => {
|
|
20639
|
+
if (!isRecord$1(body)) return null;
|
|
20640
|
+
const { links } = body;
|
|
20641
|
+
if (!isRecord$1(links) || typeof links["next"] !== "string") return null;
|
|
20642
|
+
const { next } = links;
|
|
20643
|
+
return next.startsWith(API_BASE) ? next.slice(37) : next;
|
|
20644
|
+
};
|
|
20645
|
+
const malformed = (resource) => new AscApiError({
|
|
20646
|
+
status: 500,
|
|
20647
|
+
message: `Malformed ${resource} response`,
|
|
20648
|
+
code: void 0,
|
|
20649
|
+
raw: ""
|
|
20360
20650
|
});
|
|
20361
|
-
const
|
|
20362
|
-
|
|
20363
|
-
|
|
20364
|
-
|
|
20365
|
-
|
|
20366
|
-
|
|
20367
|
-
|
|
20368
|
-
|
|
20651
|
+
const withJwt = (credentials, fn) => Effect.gen(function* () {
|
|
20652
|
+
return yield* fn(yield* signAscJwt(credentials));
|
|
20653
|
+
});
|
|
20654
|
+
const listCertificates = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
20655
|
+
return extractList(yield* fetchRaw(jwt, `/v1/certificates${params?.certificateType ? `?filter[certificateType]=${params.certificateType}&limit=200` : "?limit=200"}`), toAscCertificate);
|
|
20656
|
+
}));
|
|
20657
|
+
const createCertificate = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
20658
|
+
const csrContent = params.csrPem.replaceAll("-----BEGIN CERTIFICATE REQUEST-----", "").replaceAll("-----END CERTIFICATE REQUEST-----", "").replaceAll(/\s+/gu, "");
|
|
20659
|
+
const resource = extractSingle(yield* fetchRaw(jwt, "/v1/certificates", {
|
|
20660
|
+
method: "POST",
|
|
20661
|
+
body: JSON.stringify({ data: {
|
|
20662
|
+
type: "certificates",
|
|
20663
|
+
attributes: {
|
|
20664
|
+
csrContent,
|
|
20665
|
+
certificateType: params.certificateType
|
|
20666
|
+
}
|
|
20667
|
+
} })
|
|
20668
|
+
}), toAscCertificate);
|
|
20669
|
+
if (resource === null) return yield* malformed("certificate");
|
|
20670
|
+
return resource;
|
|
20671
|
+
}));
|
|
20672
|
+
const deleteCertificate = (credentials, id) => withJwt(credentials, (jwt) => Effect.asVoid(fetchRaw(jwt, `/v1/certificates/${encodeURIComponent(id)}`, { method: "DELETE" })));
|
|
20673
|
+
const listBundleIds = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
20674
|
+
return extractList(yield* fetchRaw(jwt, "/v1/bundleIds?limit=200"), toAscBundleId);
|
|
20675
|
+
}));
|
|
20676
|
+
const createBundleId = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
20677
|
+
const resource = extractSingle(yield* fetchRaw(jwt, "/v1/bundleIds", {
|
|
20678
|
+
method: "POST",
|
|
20679
|
+
body: JSON.stringify({ data: {
|
|
20680
|
+
type: "bundleIds",
|
|
20681
|
+
attributes: {
|
|
20682
|
+
identifier: params.identifier,
|
|
20683
|
+
name: params.name,
|
|
20684
|
+
platform: "IOS"
|
|
20685
|
+
}
|
|
20686
|
+
} })
|
|
20687
|
+
}), toAscBundleId);
|
|
20688
|
+
if (resource === null) return yield* malformed("bundleId");
|
|
20689
|
+
return resource;
|
|
20690
|
+
}));
|
|
20691
|
+
const listDevices = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
20692
|
+
const devices = [];
|
|
20693
|
+
let path = "/v1/devices?limit=200";
|
|
20694
|
+
while (path !== null) {
|
|
20695
|
+
const body = yield* fetchRaw(jwt, path);
|
|
20696
|
+
devices.push(...extractList(body, toAscDevice));
|
|
20697
|
+
path = nextPagePath(body);
|
|
20698
|
+
}
|
|
20699
|
+
return devices;
|
|
20700
|
+
}));
|
|
20701
|
+
const createDevice = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
20702
|
+
const resource = extractSingle(yield* fetchRaw(jwt, "/v1/devices", {
|
|
20703
|
+
method: "POST",
|
|
20704
|
+
body: JSON.stringify({ data: {
|
|
20705
|
+
type: "devices",
|
|
20706
|
+
attributes: {
|
|
20707
|
+
name: params.name,
|
|
20708
|
+
udid: params.udid,
|
|
20709
|
+
platform: "IOS"
|
|
20710
|
+
}
|
|
20711
|
+
} })
|
|
20712
|
+
}), toAscDevice);
|
|
20713
|
+
if (resource === null) return yield* malformed("device");
|
|
20714
|
+
return resource;
|
|
20715
|
+
}));
|
|
20716
|
+
const createProvisioningProfile = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
20717
|
+
const relationships = {
|
|
20718
|
+
bundleId: { data: {
|
|
20719
|
+
type: "bundleIds",
|
|
20720
|
+
id: params.bundleIdAscId
|
|
20721
|
+
} },
|
|
20722
|
+
certificates: { data: params.certificateAscIds.map((id) => ({
|
|
20723
|
+
type: "certificates",
|
|
20724
|
+
id
|
|
20725
|
+
})) },
|
|
20726
|
+
...params.deviceAscIds.length > 0 ? { devices: { data: params.deviceAscIds.map((id) => ({
|
|
20727
|
+
type: "devices",
|
|
20728
|
+
id
|
|
20729
|
+
})) } } : {}
|
|
20730
|
+
};
|
|
20731
|
+
const resource = extractSingle(yield* fetchRaw(jwt, "/v1/profiles", {
|
|
20732
|
+
method: "POST",
|
|
20733
|
+
body: JSON.stringify({ data: {
|
|
20734
|
+
type: "profiles",
|
|
20735
|
+
attributes: {
|
|
20736
|
+
name: params.profileName,
|
|
20737
|
+
profileType: params.profileType
|
|
20369
20738
|
},
|
|
20370
|
-
|
|
20739
|
+
relationships
|
|
20740
|
+
} })
|
|
20741
|
+
}), toAscProfile);
|
|
20742
|
+
if (resource === null) return yield* malformed("profile");
|
|
20743
|
+
return resource;
|
|
20744
|
+
}));
|
|
20745
|
+
const isCertificateLimitError = (error) => {
|
|
20746
|
+
if (error._tag !== "AscApiError") return false;
|
|
20747
|
+
return /already have a current.*certificate|pending certificate request/iu.test(error.message);
|
|
20748
|
+
};
|
|
20749
|
+
|
|
20750
|
+
//#endregion
|
|
20751
|
+
//#region src/lib/apple-asc-testflight.ts
|
|
20752
|
+
/**
|
|
20753
|
+
* App Store Connect TestFlight operations layered on the ASC API-key client
|
|
20754
|
+
* ({@link ./apple-asc-client}). Used by the iOS submit flow to configure a build
|
|
20755
|
+
* *after* `altool` uploads it: set the "What to Test" text and assign the build
|
|
20756
|
+
* to internal beta groups — matching `eas submit`'s post-upload behaviour.
|
|
20757
|
+
*/
|
|
20758
|
+
const toAscApp = (value) => {
|
|
20759
|
+
if (!isRecord$1(value)) return null;
|
|
20760
|
+
const { id, attributes } = value;
|
|
20761
|
+
if (typeof id !== "string") return null;
|
|
20762
|
+
const attrs = isRecord$1(attributes) ? attributes : {};
|
|
20763
|
+
return {
|
|
20764
|
+
id,
|
|
20765
|
+
bundleId: typeof attrs["bundleId"] === "string" ? attrs["bundleId"] : null,
|
|
20766
|
+
name: typeof attrs["name"] === "string" ? attrs["name"] : null
|
|
20767
|
+
};
|
|
20768
|
+
};
|
|
20769
|
+
const toAscBuild = (value) => {
|
|
20770
|
+
if (!isRecord$1(value)) return null;
|
|
20771
|
+
const { id, attributes } = value;
|
|
20772
|
+
if (typeof id !== "string") return null;
|
|
20773
|
+
const attrs = isRecord$1(attributes) ? attributes : {};
|
|
20774
|
+
return {
|
|
20775
|
+
id,
|
|
20776
|
+
version: typeof attrs["version"] === "string" ? attrs["version"] : null,
|
|
20777
|
+
uploadedDate: typeof attrs["uploadedDate"] === "string" ? attrs["uploadedDate"] : null,
|
|
20778
|
+
processingState: typeof attrs["processingState"] === "string" ? attrs["processingState"] : null
|
|
20779
|
+
};
|
|
20780
|
+
};
|
|
20781
|
+
const toAscBetaGroup = (value) => {
|
|
20782
|
+
if (!isRecord$1(value)) return null;
|
|
20783
|
+
const { id, attributes } = value;
|
|
20784
|
+
if (typeof id !== "string" || !isRecord$1(attributes)) return null;
|
|
20785
|
+
const { name, isInternalGroup } = attributes;
|
|
20786
|
+
if (typeof name !== "string") return null;
|
|
20787
|
+
return {
|
|
20788
|
+
id,
|
|
20789
|
+
name,
|
|
20790
|
+
isInternal: isInternalGroup === true
|
|
20791
|
+
};
|
|
20792
|
+
};
|
|
20793
|
+
const toAscBetaBuildLocalization = (value) => {
|
|
20794
|
+
if (!isRecord$1(value)) return null;
|
|
20795
|
+
const { id, attributes } = value;
|
|
20796
|
+
if (typeof id !== "string" || !isRecord$1(attributes)) return null;
|
|
20797
|
+
const { locale, whatsNew } = attributes;
|
|
20798
|
+
if (typeof locale !== "string") return null;
|
|
20799
|
+
return {
|
|
20800
|
+
id,
|
|
20801
|
+
locale,
|
|
20802
|
+
whatsNew: typeof whatsNew === "string" ? whatsNew : null
|
|
20803
|
+
};
|
|
20804
|
+
};
|
|
20805
|
+
/** Classify a raw `processingState`. Unknown/absent states stay `processing`
|
|
20806
|
+
* so the poller keeps waiting rather than failing early. */
|
|
20807
|
+
const classifyProcessingState = (state) => {
|
|
20808
|
+
if (state === "VALID") return "valid";
|
|
20809
|
+
if (state === "FAILED" || state === "INVALID") return "failed";
|
|
20810
|
+
return "processing";
|
|
20811
|
+
};
|
|
20812
|
+
/**
|
|
20813
|
+
* Identify the build produced by *our* upload. `listRecentBuilds` returns builds
|
|
20814
|
+
* newest-first; the freshly-uploaded build is the newest one whose id differs
|
|
20815
|
+
* from the baseline captured before upload. Comparing ids (not timestamps) avoids
|
|
20816
|
+
* both clock-skew misses and accidentally matching a pre-existing build.
|
|
20817
|
+
*/
|
|
20818
|
+
const pickNewBuild = (builds, baselineLatestBuildId) => {
|
|
20819
|
+
const [newest] = builds;
|
|
20820
|
+
if (newest === void 0 || newest.id === baselineLatestBuildId) return null;
|
|
20821
|
+
return newest;
|
|
20822
|
+
};
|
|
20823
|
+
const matchBetaGroupsByName = (groups, names) => {
|
|
20824
|
+
const byName = new Map(groups.map((group) => [group.name, group]));
|
|
20825
|
+
const matched = [];
|
|
20826
|
+
const missing = [];
|
|
20827
|
+
for (const name of names) {
|
|
20828
|
+
const group = byName.get(name);
|
|
20829
|
+
if (group === void 0) missing.push(name);
|
|
20830
|
+
else matched.push(group);
|
|
20831
|
+
}
|
|
20832
|
+
return {
|
|
20833
|
+
matched,
|
|
20834
|
+
missing
|
|
20835
|
+
};
|
|
20836
|
+
};
|
|
20837
|
+
/** Resolve the ASC app record for a bundle identifier, or null when none exists. */
|
|
20838
|
+
const getAppByBundleId = (credentials, bundleId) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
20839
|
+
const [first] = extractList(yield* fetchRaw(jwt, `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`), toAscApp);
|
|
20840
|
+
return first === void 0 ? null : first;
|
|
20841
|
+
}));
|
|
20842
|
+
/** Builds for an app, newest upload first. */
|
|
20843
|
+
const listRecentBuilds = (credentials, appId, limit = 20) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
20844
|
+
return extractList(yield* fetchRaw(jwt, `/v1/builds?filter[app]=${encodeURIComponent(appId)}&sort=-uploadedDate&limit=${String(limit)}`), toAscBuild);
|
|
20845
|
+
}));
|
|
20846
|
+
const listBetaGroups = (credentials, appId) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
20847
|
+
const groups = [];
|
|
20848
|
+
let path = `/v1/betaGroups?filter[app]=${encodeURIComponent(appId)}&limit=200`;
|
|
20849
|
+
while (path !== null) {
|
|
20850
|
+
const body = yield* fetchRaw(jwt, path);
|
|
20851
|
+
groups.push(...extractList(body, toAscBetaGroup));
|
|
20852
|
+
path = nextPagePath(body);
|
|
20853
|
+
}
|
|
20854
|
+
return groups;
|
|
20855
|
+
}));
|
|
20856
|
+
const listBuildBetaLocalizations = (credentials, buildId) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
20857
|
+
return extractList(yield* fetchRaw(jwt, `/v1/builds/${encodeURIComponent(buildId)}/betaBuildLocalizations?limit=200`), toAscBetaBuildLocalization);
|
|
20858
|
+
}));
|
|
20859
|
+
const createBetaBuildLocalization = (credentials, params) => withJwt(credentials, (jwt) => Effect.asVoid(fetchRaw(jwt, "/v1/betaBuildLocalizations", {
|
|
20860
|
+
method: "POST",
|
|
20861
|
+
body: JSON.stringify({ data: {
|
|
20862
|
+
type: "betaBuildLocalizations",
|
|
20863
|
+
attributes: {
|
|
20864
|
+
locale: params.locale,
|
|
20865
|
+
whatsNew: params.whatsNew
|
|
20866
|
+
},
|
|
20867
|
+
relationships: { build: { data: {
|
|
20868
|
+
type: "builds",
|
|
20869
|
+
id: params.buildId
|
|
20870
|
+
} } }
|
|
20871
|
+
} })
|
|
20872
|
+
})));
|
|
20873
|
+
const updateBetaBuildLocalization = (credentials, params) => withJwt(credentials, (jwt) => Effect.asVoid(fetchRaw(jwt, `/v1/betaBuildLocalizations/${encodeURIComponent(params.id)}`, {
|
|
20874
|
+
method: "PATCH",
|
|
20875
|
+
body: JSON.stringify({ data: {
|
|
20876
|
+
type: "betaBuildLocalizations",
|
|
20877
|
+
id: params.id,
|
|
20878
|
+
attributes: { whatsNew: params.whatsNew }
|
|
20879
|
+
} })
|
|
20880
|
+
})));
|
|
20881
|
+
const addBuildToBetaGroups = (credentials, buildId, groupIds) => withJwt(credentials, (jwt) => Effect.asVoid(fetchRaw(jwt, `/v1/builds/${encodeURIComponent(buildId)}/relationships/betaGroups`, {
|
|
20882
|
+
method: "POST",
|
|
20883
|
+
body: JSON.stringify({ data: groupIds.map((id) => ({
|
|
20884
|
+
type: "betaGroups",
|
|
20885
|
+
id
|
|
20886
|
+
})) })
|
|
20887
|
+
})));
|
|
20888
|
+
|
|
20889
|
+
//#endregion
|
|
20890
|
+
//#region src/application/ios-testflight-config.ts
|
|
20891
|
+
/**
|
|
20892
|
+
* Post-upload TestFlight configuration for iOS submissions. After `altool`
|
|
20893
|
+
* uploads the `.ipa`, App Store Connect spends several minutes *processing* the
|
|
20894
|
+
* binary before it can be configured. This module waits for that processing to
|
|
20895
|
+
* finish, then sets the build's "What to Test" text and assigns it to internal
|
|
20896
|
+
* TestFlight groups — the same follow-up `eas submit` performs server-side.
|
|
20897
|
+
*
|
|
20898
|
+
* Auth reuses the ASC **API key** already decrypted for the upload (no second
|
|
20899
|
+
* credential prompt). Failures surface as {@link TestFlightConfigError} so the
|
|
20900
|
+
* caller can mark the submission ERRORED with a precise reason.
|
|
20901
|
+
*/
|
|
20902
|
+
var TestFlightConfigError = class extends Data.TaggedError("TestFlightConfigError") {};
|
|
20903
|
+
const DEFAULT_POLL_TIMEOUT_MS = 15 * 6e4;
|
|
20904
|
+
const DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
20905
|
+
const DEFAULT_LOCALE = "en-US";
|
|
20906
|
+
const ascErrorMessage$1 = (error) => {
|
|
20907
|
+
if (error._tag === "AscApiError") return `App Store Connect API error ${String(error.status)}: ${error.message}`;
|
|
20908
|
+
if (error._tag === "AscNetworkError") return `App Store Connect network error: ${String(error.cause)}`;
|
|
20909
|
+
return `App Store Connect auth error: ${String(error.cause)}`;
|
|
20910
|
+
};
|
|
20911
|
+
const wrapAsc = (code) => (error) => new TestFlightConfigError({
|
|
20912
|
+
code,
|
|
20913
|
+
message: ascErrorMessage$1(error)
|
|
20914
|
+
});
|
|
20915
|
+
/**
|
|
20916
|
+
* Resolve the ASC app id (preferring the explicit `ascAppId`) and snapshot the
|
|
20917
|
+
* latest existing build. Run this *before* `altool` so the freshly-uploaded
|
|
20918
|
+
* build can be distinguished from prior ones.
|
|
20919
|
+
*/
|
|
20920
|
+
const captureTestFlightContext = (params) => Effect.gen(function* () {
|
|
20921
|
+
const appId = params.ascAppId ?? (yield* Effect.gen(function* () {
|
|
20922
|
+
const app = yield* getAppByBundleId(params.credentials, params.bundleIdentifier).pipe(Effect.mapError(wrapAsc("TESTFLIGHT_APP_LOOKUP_FAILED")));
|
|
20923
|
+
if (app === null) return yield* new TestFlightConfigError({
|
|
20924
|
+
code: "TESTFLIGHT_APP_NOT_FOUND",
|
|
20925
|
+
message: `No App Store Connect app found for bundle id ${params.bundleIdentifier}. Set ascAppId in the eas.json submit profile.`
|
|
20371
20926
|
});
|
|
20372
|
-
|
|
20373
|
-
|
|
20374
|
-
|
|
20375
|
-
|
|
20376
|
-
|
|
20377
|
-
|
|
20378
|
-
},
|
|
20379
|
-
catch: (cause) => new GooglePlayApiError({
|
|
20380
|
-
message: "edits.bundles.upload request failed",
|
|
20381
|
-
cause
|
|
20382
|
-
})
|
|
20927
|
+
return app.id;
|
|
20928
|
+
}));
|
|
20929
|
+
return {
|
|
20930
|
+
appId,
|
|
20931
|
+
baselineLatestBuildId: toDbNull((yield* listRecentBuilds(params.credentials, appId, 1).pipe(Effect.mapError(wrapAsc("TESTFLIGHT_LIST_BUILDS_FAILED"))))[0]?.id)
|
|
20932
|
+
};
|
|
20383
20933
|
});
|
|
20384
|
-
const
|
|
20385
|
-
const
|
|
20386
|
-
|
|
20387
|
-
|
|
20388
|
-
|
|
20389
|
-
}
|
|
20390
|
-
|
|
20391
|
-
|
|
20392
|
-
|
|
20393
|
-
|
|
20394
|
-
|
|
20934
|
+
const pollForProcessedBuild = (params) => Effect.gen(function* () {
|
|
20935
|
+
const deadline = Date.now() + params.pollTimeoutMs;
|
|
20936
|
+
const final = yield* Effect.iterate({
|
|
20937
|
+
build: null,
|
|
20938
|
+
attempt: 0
|
|
20939
|
+
}, {
|
|
20940
|
+
while: (state) => state.build === null,
|
|
20941
|
+
body: (state) => Effect.gen(function* () {
|
|
20942
|
+
if (state.attempt > 0) yield* Effect.sleep(Duration.millis(params.pollIntervalMs));
|
|
20943
|
+
const candidate = pickNewBuild(yield* listRecentBuilds(params.credentials, params.context.appId, 20).pipe(Effect.mapError(wrapAsc("TESTFLIGHT_LIST_BUILDS_FAILED"))), params.context.baselineLatestBuildId);
|
|
20944
|
+
if (candidate !== null) {
|
|
20945
|
+
const processing = classifyProcessingState(candidate.processingState);
|
|
20946
|
+
if (processing === "failed") return yield* new TestFlightConfigError({
|
|
20947
|
+
code: "TESTFLIGHT_BUILD_PROCESSING_FAILED",
|
|
20948
|
+
message: `App Store Connect rejected build ${candidate.version ?? candidate.id} during processing (state ${candidate.processingState ?? "unknown"}).`
|
|
20949
|
+
});
|
|
20950
|
+
if (processing === "valid") return {
|
|
20951
|
+
build: candidate,
|
|
20952
|
+
attempt: state.attempt + 1
|
|
20953
|
+
};
|
|
20954
|
+
}
|
|
20955
|
+
if (Date.now() > deadline) return yield* new TestFlightConfigError({
|
|
20956
|
+
code: "TESTFLIGHT_BUILD_PROCESSING_TIMEOUT",
|
|
20957
|
+
message: `Timed out after ${String(Math.round(params.pollTimeoutMs / 6e4))} min waiting for the uploaded build to finish processing on App Store Connect. The binary uploaded successfully — re-run the TestFlight configuration later.`
|
|
20958
|
+
});
|
|
20959
|
+
yield* printHuman(candidate === null ? "Waiting for the uploaded build to appear on App Store Connect..." : "Build is processing on App Store Connect...");
|
|
20960
|
+
return {
|
|
20961
|
+
build: null,
|
|
20962
|
+
attempt: state.attempt + 1
|
|
20963
|
+
};
|
|
20395
20964
|
})
|
|
20396
20965
|
});
|
|
20397
|
-
|
|
20398
|
-
|
|
20399
|
-
|
|
20400
|
-
})
|
|
20401
|
-
|
|
20402
|
-
|
|
20403
|
-
|
|
20404
|
-
|
|
20405
|
-
|
|
20406
|
-
|
|
20407
|
-
|
|
20408
|
-
|
|
20409
|
-
|
|
20410
|
-
|
|
20411
|
-
|
|
20412
|
-
|
|
20966
|
+
if (final.build === null) return yield* new TestFlightConfigError({
|
|
20967
|
+
code: "TESTFLIGHT_BUILD_NOT_FOUND",
|
|
20968
|
+
message: "Could not locate the uploaded build on App Store Connect."
|
|
20969
|
+
});
|
|
20970
|
+
return final.build;
|
|
20971
|
+
});
|
|
20972
|
+
const applyWhatToTest = (params) => Effect.gen(function* () {
|
|
20973
|
+
const existing = (yield* listBuildBetaLocalizations(params.credentials, params.buildId).pipe(Effect.mapError(wrapAsc("TESTFLIGHT_LIST_LOCALIZATIONS_FAILED")))).find((loc) => loc.locale === params.locale);
|
|
20974
|
+
yield* (existing === void 0 ? createBetaBuildLocalization(params.credentials, {
|
|
20975
|
+
buildId: params.buildId,
|
|
20976
|
+
locale: params.locale,
|
|
20977
|
+
whatsNew: params.whatToTest
|
|
20978
|
+
}) : updateBetaBuildLocalization(params.credentials, {
|
|
20979
|
+
id: existing.id,
|
|
20980
|
+
whatsNew: params.whatToTest
|
|
20981
|
+
})).pipe(Effect.mapError(wrapAsc("TESTFLIGHT_SET_WHAT_TO_TEST_FAILED")));
|
|
20982
|
+
});
|
|
20983
|
+
const applyGroups = (params) => Effect.gen(function* () {
|
|
20984
|
+
const allGroups = yield* listBetaGroups(params.credentials, params.appId).pipe(Effect.mapError(wrapAsc("TESTFLIGHT_LIST_GROUPS_FAILED")));
|
|
20985
|
+
const { matched, missing } = matchBetaGroupsByName(allGroups, params.groups);
|
|
20986
|
+
if (missing.length > 0) {
|
|
20987
|
+
const available = allGroups.map((group) => group.name).join(", ") || "(none)";
|
|
20988
|
+
return yield* new TestFlightConfigError({
|
|
20989
|
+
code: "TESTFLIGHT_GROUP_NOT_FOUND",
|
|
20990
|
+
message: `TestFlight group(s) not found: ${missing.join(", ")}. Available groups: ${available}.`
|
|
20991
|
+
});
|
|
20992
|
+
}
|
|
20993
|
+
yield* addBuildToBetaGroups(params.credentials, params.buildId, matched.map((group) => group.id)).pipe(Effect.mapError(wrapAsc("TESTFLIGHT_ADD_TO_GROUPS_FAILED")));
|
|
20994
|
+
});
|
|
20995
|
+
/** Whether a profile has any TestFlight config that warrants the processing wait. */
|
|
20996
|
+
const needsTestFlightConfig = (params) => params.whatToTest !== void 0 || params.groups.length > 0;
|
|
20997
|
+
const applyTestFlightConfig = (inputs) => Effect.gen(function* () {
|
|
20998
|
+
yield* printHuman("Configuring TestFlight (waiting for build processing)...");
|
|
20999
|
+
const build = yield* pollForProcessedBuild({
|
|
21000
|
+
credentials: inputs.credentials,
|
|
21001
|
+
context: inputs.context,
|
|
21002
|
+
pollTimeoutMs: inputs.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS,
|
|
21003
|
+
pollIntervalMs: inputs.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
|
21004
|
+
});
|
|
21005
|
+
if (inputs.whatToTest !== void 0) {
|
|
21006
|
+
yield* applyWhatToTest({
|
|
21007
|
+
credentials: inputs.credentials,
|
|
21008
|
+
buildId: build.id,
|
|
21009
|
+
locale: inputs.language ?? DEFAULT_LOCALE,
|
|
21010
|
+
whatToTest: inputs.whatToTest
|
|
21011
|
+
});
|
|
21012
|
+
yield* printHuman(`Set "What to Test" on build ${build.version ?? build.id}.`);
|
|
21013
|
+
}
|
|
21014
|
+
if (inputs.groups.length > 0) {
|
|
21015
|
+
yield* applyGroups({
|
|
21016
|
+
credentials: inputs.credentials,
|
|
21017
|
+
appId: inputs.context.appId,
|
|
21018
|
+
buildId: build.id,
|
|
21019
|
+
groups: inputs.groups
|
|
21020
|
+
});
|
|
21021
|
+
yield* printHuman(`Assigned build to TestFlight group(s): ${inputs.groups.join(", ")}.`);
|
|
21022
|
+
}
|
|
21023
|
+
return {
|
|
21024
|
+
buildId: build.id,
|
|
21025
|
+
buildVersion: build.version
|
|
20413
21026
|
};
|
|
20414
|
-
return callJsonRaw({
|
|
20415
|
-
url: `${ANDROID_PUBLISHER_BASE}/androidpublisher/v3/applications/${encodeURIComponent(params.packageName)}/edits/${encodeURIComponent(params.editId)}/tracks/${encodeURIComponent(params.track)}`,
|
|
20416
|
-
method: "PUT",
|
|
20417
|
-
accessToken: params.accessToken,
|
|
20418
|
-
body: {
|
|
20419
|
-
track: params.track,
|
|
20420
|
-
releases: [release]
|
|
20421
|
-
},
|
|
20422
|
-
label: "edits.tracks.update"
|
|
20423
|
-
});
|
|
20424
|
-
};
|
|
20425
|
-
const commitEdit = (params) => callJsonRaw({
|
|
20426
|
-
url: `${ANDROID_PUBLISHER_BASE}/androidpublisher/v3/applications/${encodeURIComponent(params.packageName)}/edits/${encodeURIComponent(params.editId)}:commit?changesNotSentForReview=${String(params.changesNotSentForReview)}`,
|
|
20427
|
-
method: "POST",
|
|
20428
|
-
accessToken: params.accessToken,
|
|
20429
|
-
label: "edits.commit"
|
|
20430
21027
|
});
|
|
20431
21028
|
|
|
20432
21029
|
//#endregion
|
|
@@ -20495,65 +21092,15 @@ const pollSubmissionUntilTerminal = (api, submissionId, pollIntervalMs = 5e3) =>
|
|
|
20495
21092
|
code: "SUBMISSION_POLL_NO_RESULT",
|
|
20496
21093
|
message: "Polling completed without producing a submission"
|
|
20497
21094
|
})) : Effect.succeed(final)));
|
|
20498
|
-
const
|
|
20499
|
-
|
|
20500
|
-
|
|
20501
|
-
|
|
20502
|
-
|
|
20503
|
-
|
|
20504
|
-
|
|
20505
|
-
|
|
20506
|
-
|
|
20507
|
-
keyId: creds.keyId,
|
|
20508
|
-
issuerId: creds.issuerId
|
|
20509
|
-
};
|
|
20510
|
-
});
|
|
20511
|
-
const runIosAltoolUpload = (inputs) => Effect.gen(function* () {
|
|
20512
|
-
const creds = yield* writeAscApiKeyP8(inputs.api, inputs.ascApiKeyId);
|
|
20513
|
-
const apiKeyDir = path.dirname(creds.p8Path);
|
|
20514
|
-
yield* inputs.api.submissions.updateStatus({
|
|
20515
|
-
path: { id: inputs.submissionId },
|
|
20516
|
-
payload: { status: "IN_PROGRESS" }
|
|
20517
|
-
}).pipe(Effect.mapError(() => new CliSubmitError({
|
|
20518
|
-
code: "SUBMISSION_PATCH_FAILED",
|
|
20519
|
-
message: "Failed to PATCH submission status to IN_PROGRESS"
|
|
20520
|
-
})));
|
|
20521
|
-
const result = yield* runAltool([
|
|
20522
|
-
"--upload-app",
|
|
20523
|
-
"--type",
|
|
20524
|
-
"ios",
|
|
20525
|
-
"--apiKey",
|
|
20526
|
-
creds.keyId,
|
|
20527
|
-
"--apiIssuer",
|
|
20528
|
-
creds.issuerId,
|
|
20529
|
-
"--apiKeyDir",
|
|
20530
|
-
apiKeyDir,
|
|
20531
|
-
"--file",
|
|
20532
|
-
inputs.ipaPath,
|
|
20533
|
-
"--output-format",
|
|
20534
|
-
"xml"
|
|
20535
|
-
]);
|
|
20536
|
-
const terminalStatus = result.exitCode === 0 ? "FINISHED" : "ERRORED";
|
|
20537
|
-
const errorMessage = result.exitCode === 0 ? null : `xcrun altool exited ${String(result.exitCode)}: ${result.stderr}`;
|
|
20538
|
-
yield* inputs.api.submissions.updateStatus({
|
|
20539
|
-
path: { id: inputs.submissionId },
|
|
20540
|
-
payload: {
|
|
20541
|
-
status: terminalStatus,
|
|
20542
|
-
...errorMessage ? {
|
|
20543
|
-
errorCode: "SUBMISSION_SERVICE_IOS_ALTOOL_FAILED",
|
|
20544
|
-
errorMessage
|
|
20545
|
-
} : {}
|
|
20546
|
-
}
|
|
20547
|
-
}).pipe(Effect.mapError(() => new CliSubmitError({
|
|
20548
|
-
code: "SUBMISSION_PATCH_FAILED",
|
|
20549
|
-
message: "Failed to PATCH submission terminal status"
|
|
20550
|
-
})));
|
|
20551
|
-
return {
|
|
20552
|
-
status: terminalStatus,
|
|
20553
|
-
stdout: result.stdout,
|
|
20554
|
-
stderr: result.stderr
|
|
20555
|
-
};
|
|
20556
|
-
});
|
|
21095
|
+
const patchSubmissionStatus = (api, submissionId, payload) => api.submissions.updateStatus({
|
|
21096
|
+
path: { id: submissionId },
|
|
21097
|
+
payload
|
|
21098
|
+
}).pipe(Effect.mapError(() => new CliSubmitError({
|
|
21099
|
+
code: "SUBMISSION_PATCH_FAILED",
|
|
21100
|
+
message: `Failed to PATCH submission status to ${payload.status}`
|
|
21101
|
+
})));
|
|
21102
|
+
/** A local `path` archive may be given as a plain path or a `file://` URL. */
|
|
21103
|
+
const localPathFromArchiveValue = (value) => value.startsWith("file://") ? fileURLToPath(value) : value;
|
|
20557
21104
|
const readLocalFile = (filePath, errorCode, errorMessageFmt) => Effect.tryPromise({
|
|
20558
21105
|
try: async () => readFile(filePath),
|
|
20559
21106
|
catch: (cause) => new CliSubmitError({
|
|
@@ -20574,7 +21121,7 @@ const fetchArchiveOverHttp = (url) => Effect.gen(function* () {
|
|
|
20574
21121
|
},
|
|
20575
21122
|
catch: (cause) => new CliSubmitError({
|
|
20576
21123
|
code: "SUBMISSION_ARCHIVE_DOWNLOAD_FAILED",
|
|
20577
|
-
message: `Failed to download
|
|
21124
|
+
message: `Failed to download archive from ${url}: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
20578
21125
|
})
|
|
20579
21126
|
});
|
|
20580
21127
|
if (!result.ok || result.bytes === null) return yield* new CliSubmitError({
|
|
@@ -20583,7 +21130,153 @@ const fetchArchiveOverHttp = (url) => Effect.gen(function* () {
|
|
|
20583
21130
|
});
|
|
20584
21131
|
return result.bytes;
|
|
20585
21132
|
});
|
|
20586
|
-
const readArchiveBytes = (archive) => archive.source === "path" ? Effect.map(readLocalFile(archive.value, "SUBMISSION_ARCHIVE_READ_FAILED", (cause) => `Failed to read
|
|
21133
|
+
const readArchiveBytes = (archive) => archive.source === "path" ? Effect.map(readLocalFile(localPathFromArchiveValue(archive.value), "SUBMISSION_ARCHIVE_READ_FAILED", (cause) => `Failed to read archive at ${archive.value}: ${cause instanceof Error ? cause.message : String(cause)}`), (buf) => new Uint8Array(buf)) : fetchArchiveOverHttp(archive.value);
|
|
21134
|
+
const downloadArchiveToTempFile = (url, extension) => Effect.gen(function* () {
|
|
21135
|
+
const bytes = yield* fetchArchiveOverHttp(url);
|
|
21136
|
+
const target = path.join(tmpdir(), `better-update-submit-${crypto.randomUUID()}${extension}`);
|
|
21137
|
+
yield* Effect.tryPromise({
|
|
21138
|
+
try: async () => writeFile(target, bytes),
|
|
21139
|
+
catch: (cause) => new CliSubmitError({
|
|
21140
|
+
code: "SUBMISSION_ARCHIVE_WRITE_FAILED",
|
|
21141
|
+
message: `Failed to stage archive to ${target}: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
21142
|
+
})
|
|
21143
|
+
});
|
|
21144
|
+
return target;
|
|
21145
|
+
});
|
|
21146
|
+
/**
|
|
21147
|
+
* Resolve an archive to a **local file path** on disk, downloading remote
|
|
21148
|
+
* (`build`/`url`) sources first. Store upload tools (`altool`) require a path
|
|
21149
|
+
* they can open — handing them an https URL fails.
|
|
21150
|
+
*/
|
|
21151
|
+
const resolveLocalArchivePath = (archive, extension) => archive.source === "path" ? Effect.succeed(localPathFromArchiveValue(archive.value)) : downloadArchiveToTempFile(archive.value, extension);
|
|
21152
|
+
/** EAS-compatible env var carrying the Apple ID app-specific password. */
|
|
21153
|
+
const APPLE_APP_SPECIFIC_PASSWORD_ENV = "EXPO_APPLE_APP_SPECIFIC_PASSWORD";
|
|
21154
|
+
const hasAppleAppSpecificPassword = () => {
|
|
21155
|
+
const value = process.env[APPLE_APP_SPECIFIC_PASSWORD_ENV];
|
|
21156
|
+
return value !== void 0 && value !== "";
|
|
21157
|
+
};
|
|
21158
|
+
/**
|
|
21159
|
+
* Resolve the upload auth, matching `eas submit` precedence: an app-specific
|
|
21160
|
+
* password (env var + `appleId`) wins when usable; otherwise fall back to the
|
|
21161
|
+
* ASC API key. Returns null when neither is configured.
|
|
21162
|
+
*/
|
|
21163
|
+
const resolveIosUploadAuth = (params) => {
|
|
21164
|
+
if (params.hasAppSpecificPassword && params.appleId !== void 0) return {
|
|
21165
|
+
kind: "app-specific-password",
|
|
21166
|
+
appleId: params.appleId
|
|
21167
|
+
};
|
|
21168
|
+
if (params.ascApiKeyId !== void 0) return {
|
|
21169
|
+
kind: "asc-api-key",
|
|
21170
|
+
ascApiKeyId: params.ascApiKeyId
|
|
21171
|
+
};
|
|
21172
|
+
return null;
|
|
21173
|
+
};
|
|
21174
|
+
const resolveAscCredentials = (api, ascApiKeyId) => fetchAscCredentials(api, ascApiKeyId).pipe(Effect.mapError(() => new CliSubmitError({
|
|
21175
|
+
code: "SUBMISSION_ASC_KEY_FETCH_FAILED",
|
|
21176
|
+
message: `Failed to fetch or decrypt ASC API key ${ascApiKeyId}`
|
|
21177
|
+
})));
|
|
21178
|
+
/** `altool` reads the API key from `--apiKeyDir`; write the decrypted `.p8` there. */
|
|
21179
|
+
const writeP8ForAltool = (credentials) => Effect.gen(function* () {
|
|
21180
|
+
const target = path.join(tmpdir(), `better-update-submit-AuthKey_${credentials.keyId}.p8`);
|
|
21181
|
+
yield* Effect.promise(async () => writeFile(target, credentials.p8Pem, "utf8"));
|
|
21182
|
+
return target;
|
|
21183
|
+
});
|
|
21184
|
+
const baseAltoolArgs = (ipaPath) => [
|
|
21185
|
+
"--upload-app",
|
|
21186
|
+
"--type",
|
|
21187
|
+
"ios",
|
|
21188
|
+
"--file",
|
|
21189
|
+
ipaPath,
|
|
21190
|
+
"--output-format",
|
|
21191
|
+
"xml"
|
|
21192
|
+
];
|
|
21193
|
+
/** Build `altool` args for the chosen auth. The app-specific password is passed
|
|
21194
|
+
* as `@env:` so it never enters argv; `altool` reads it from the inherited env. */
|
|
21195
|
+
const buildAltoolArgs = (params) => Effect.gen(function* () {
|
|
21196
|
+
if (params.auth.kind === "app-specific-password") return [
|
|
21197
|
+
...baseAltoolArgs(params.ipaPath),
|
|
21198
|
+
"--username",
|
|
21199
|
+
params.auth.appleId,
|
|
21200
|
+
"--password",
|
|
21201
|
+
`@env:${APPLE_APP_SPECIFIC_PASSWORD_ENV}`
|
|
21202
|
+
];
|
|
21203
|
+
if (params.ascCredentials === null) return yield* new CliSubmitError({
|
|
21204
|
+
code: "SUBMISSION_ASC_KEY_FETCH_FAILED",
|
|
21205
|
+
message: "ASC API key is required for an asc-api-key upload but was not resolved."
|
|
21206
|
+
});
|
|
21207
|
+
const p8Path = yield* writeP8ForAltool(params.ascCredentials);
|
|
21208
|
+
return [
|
|
21209
|
+
...baseAltoolArgs(params.ipaPath),
|
|
21210
|
+
"--apiKey",
|
|
21211
|
+
params.ascCredentials.keyId,
|
|
21212
|
+
"--apiIssuer",
|
|
21213
|
+
params.ascCredentials.issuerId,
|
|
21214
|
+
"--apiKeyDir",
|
|
21215
|
+
path.dirname(p8Path)
|
|
21216
|
+
];
|
|
21217
|
+
});
|
|
21218
|
+
const runIosSubmit = (inputs) => Effect.gen(function* () {
|
|
21219
|
+
const wantsConfig = needsTestFlightConfig({
|
|
21220
|
+
whatToTest: inputs.config.whatToTest,
|
|
21221
|
+
groups: inputs.config.groups
|
|
21222
|
+
});
|
|
21223
|
+
const credsKeyId = inputs.auth.kind === "asc-api-key" ? inputs.auth.ascApiKeyId : inputs.ascApiKeyId;
|
|
21224
|
+
const ascCredentials = (inputs.auth.kind === "asc-api-key" || wantsConfig) && credsKeyId !== void 0 ? yield* resolveAscCredentials(inputs.api, credsKeyId) : null;
|
|
21225
|
+
const ipaPath = yield* resolveLocalArchivePath(inputs.archive, ".ipa");
|
|
21226
|
+
let tfContext = null;
|
|
21227
|
+
if (wantsConfig && ascCredentials !== null) tfContext = yield* captureTestFlightContext({
|
|
21228
|
+
credentials: ascCredentials,
|
|
21229
|
+
ascAppId: inputs.config.ascAppId,
|
|
21230
|
+
bundleIdentifier: inputs.config.bundleIdentifier
|
|
21231
|
+
}).pipe(Effect.mapError((error) => new CliSubmitError({
|
|
21232
|
+
code: error.code,
|
|
21233
|
+
message: error.message
|
|
21234
|
+
})));
|
|
21235
|
+
else if (wantsConfig) yield* printHuman("Note: \"What to Test\" and TestFlight groups require an ASC API key (ascApiKeyId) — skipping that step for the app-specific-password upload.");
|
|
21236
|
+
const altoolArgs = yield* buildAltoolArgs({
|
|
21237
|
+
auth: inputs.auth,
|
|
21238
|
+
ascCredentials,
|
|
21239
|
+
ipaPath
|
|
21240
|
+
});
|
|
21241
|
+
yield* patchSubmissionStatus(inputs.api, inputs.submissionId, { status: "IN_PROGRESS" });
|
|
21242
|
+
const result = yield* runAltool(altoolArgs);
|
|
21243
|
+
if (result.exitCode !== 0) {
|
|
21244
|
+
yield* patchSubmissionStatus(inputs.api, inputs.submissionId, {
|
|
21245
|
+
status: "ERRORED",
|
|
21246
|
+
errorCode: "SUBMISSION_SERVICE_IOS_ALTOOL_FAILED",
|
|
21247
|
+
errorMessage: `xcrun altool exited ${String(result.exitCode)}: ${result.stderr}`
|
|
21248
|
+
});
|
|
21249
|
+
return { status: "ERRORED" };
|
|
21250
|
+
}
|
|
21251
|
+
yield* printHuman("altool upload complete.");
|
|
21252
|
+
if (tfContext !== null && ascCredentials !== null) yield* applyTestFlightConfig({
|
|
21253
|
+
credentials: ascCredentials,
|
|
21254
|
+
context: tfContext,
|
|
21255
|
+
language: inputs.config.language,
|
|
21256
|
+
whatToTest: inputs.config.whatToTest,
|
|
21257
|
+
groups: inputs.config.groups
|
|
21258
|
+
}).pipe(Effect.catchTag("TestFlightConfigError", (configError) => Effect.gen(function* () {
|
|
21259
|
+
yield* patchSubmissionStatus(inputs.api, inputs.submissionId, {
|
|
21260
|
+
status: "ERRORED",
|
|
21261
|
+
errorCode: configError.code,
|
|
21262
|
+
errorMessage: configError.message
|
|
21263
|
+
});
|
|
21264
|
+
return yield* new CliSubmitError({
|
|
21265
|
+
code: configError.code,
|
|
21266
|
+
message: configError.message
|
|
21267
|
+
});
|
|
21268
|
+
})));
|
|
21269
|
+
yield* patchSubmissionStatus(inputs.api, inputs.submissionId, { status: "FINISHED" });
|
|
21270
|
+
return { status: "FINISHED" };
|
|
21271
|
+
});
|
|
21272
|
+
|
|
21273
|
+
//#endregion
|
|
21274
|
+
//#region src/application/android-play-submit.ts
|
|
21275
|
+
/**
|
|
21276
|
+
* Client-side Google Play submission: decrypt the service account key, then run
|
|
21277
|
+
* the Play Developer API edit pipeline (insert → upload bundle → assign track →
|
|
21278
|
+
* commit) — the same steps `eas submit` performs server-side.
|
|
21279
|
+
*/
|
|
20587
21280
|
const fetchServiceAccountKeyById = (api, id) => Effect.gen(function* () {
|
|
20588
21281
|
const data = yield* api.googleServiceAccountKeys.download({ path: { id } }).pipe(Effect.mapError(() => new CliSubmitError({
|
|
20589
21282
|
code: "SUBMISSION_ANDROID_SA_KEY_FETCH_FAILED",
|
|
@@ -20606,25 +21299,35 @@ const fetchServiceAccountKeyById = (api, id) => Effect.gen(function* () {
|
|
|
20606
21299
|
});
|
|
20607
21300
|
return json;
|
|
20608
21301
|
});
|
|
21302
|
+
const readServiceAccountFile = (filePath) => Effect.tryPromise({
|
|
21303
|
+
try: async () => new TextDecoder().decode(await readFile(filePath)),
|
|
21304
|
+
catch: (cause) => new CliSubmitError({
|
|
21305
|
+
code: "SUBMISSION_ANDROID_SA_KEY_LOCAL_READ_FAILED",
|
|
21306
|
+
message: `Failed to read service account JSON at ${filePath}: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
21307
|
+
})
|
|
21308
|
+
});
|
|
20609
21309
|
const resolveServiceAccountJson = (params) => {
|
|
20610
21310
|
if (params.serviceAccountKeyId !== void 0) return fetchServiceAccountKeyById(params.api, params.serviceAccountKeyId);
|
|
20611
|
-
if (params.serviceAccountKeyPath !== void 0) return
|
|
21311
|
+
if (params.serviceAccountKeyPath !== void 0) return readServiceAccountFile(params.serviceAccountKeyPath);
|
|
20612
21312
|
return Effect.fail(new CliSubmitError({
|
|
20613
21313
|
code: "SUBMISSION_ANDROID_SA_KEY_MISSING",
|
|
20614
21314
|
message: "Android submission requires a service account key. Pass --service-account-key-id <id>, set serviceAccountKeyId in eas.json submit profile, or set serviceAccountKeyPath to a local JSON file."
|
|
20615
21315
|
}));
|
|
20616
21316
|
};
|
|
20617
|
-
const patchSubmissionStatus = (api, submissionId, payload) => api.submissions.updateStatus({
|
|
20618
|
-
path: { id: submissionId },
|
|
20619
|
-
payload
|
|
20620
|
-
}).pipe(Effect.mapError(() => new CliSubmitError({
|
|
20621
|
-
code: "SUBMISSION_PATCH_FAILED",
|
|
20622
|
-
message: `Failed to PATCH submission status to ${payload.status}`
|
|
20623
|
-
})));
|
|
20624
21317
|
const wrapGooglePlayError = (label) => (cause) => new CliSubmitError({
|
|
20625
21318
|
code: `SUBMISSION_ANDROID_${label}`,
|
|
20626
21319
|
message: cause.message
|
|
20627
21320
|
});
|
|
21321
|
+
/**
|
|
21322
|
+
* EAS/Google Play rule: a staged rollout fraction is required for — and only
|
|
21323
|
+
* valid with — releaseStatus `inProgress`. Returns an error message, or null
|
|
21324
|
+
* when the combination is valid.
|
|
21325
|
+
*/
|
|
21326
|
+
const androidRolloutError = (releaseStatus, rollout) => {
|
|
21327
|
+
if (releaseStatus === "inProgress" && rollout === null) return "rollout is required when releaseStatus is 'inProgress' — set submit.<profile>.android.rollout to a 0–1 fraction.";
|
|
21328
|
+
if (releaseStatus !== "inProgress" && rollout !== null) return `rollout is only allowed when releaseStatus is 'inProgress', not '${releaseStatus}'.`;
|
|
21329
|
+
return null;
|
|
21330
|
+
};
|
|
20628
21331
|
const runGooglePlayPipeline = (params) => Effect.gen(function* () {
|
|
20629
21332
|
const edit = yield* insertEdit({
|
|
20630
21333
|
accessToken: params.accessToken,
|
|
@@ -20659,6 +21362,20 @@ const runAndroidGooglePlayUpload = (inputs) => Effect.gen(function* () {
|
|
|
20659
21362
|
code: "SUBMISSION_ANDROID_APP_ID_MISSING",
|
|
20660
21363
|
message: "Android submit profile requires applicationId — set submit.<profile>.android.applicationId in eas.json"
|
|
20661
21364
|
});
|
|
21365
|
+
const releaseStatus = inputs.androidProfile.releaseStatus ?? "completed";
|
|
21366
|
+
const rollout = toDbNull(inputs.androidProfile.rollout);
|
|
21367
|
+
const rolloutError = androidRolloutError(releaseStatus, rollout);
|
|
21368
|
+
if (rolloutError !== null) {
|
|
21369
|
+
yield* patchSubmissionStatus(inputs.api, inputs.submissionId, {
|
|
21370
|
+
status: "ERRORED",
|
|
21371
|
+
errorCode: "SUBMISSION_ANDROID_ROLLOUT_INVALID",
|
|
21372
|
+
errorMessage: rolloutError
|
|
21373
|
+
});
|
|
21374
|
+
return yield* new CliSubmitError({
|
|
21375
|
+
code: "SUBMISSION_ANDROID_ROLLOUT_INVALID",
|
|
21376
|
+
message: rolloutError
|
|
21377
|
+
});
|
|
21378
|
+
}
|
|
20662
21379
|
const serviceAccountJson = yield* resolveServiceAccountJson({
|
|
20663
21380
|
api: inputs.api,
|
|
20664
21381
|
serviceAccountKeyId: inputs.serviceAccountKeyId,
|
|
@@ -20673,9 +21390,9 @@ const runAndroidGooglePlayUpload = (inputs) => Effect.gen(function* () {
|
|
|
20673
21390
|
applicationId,
|
|
20674
21391
|
aab,
|
|
20675
21392
|
track: inputs.androidProfile.track ?? "internal",
|
|
20676
|
-
releaseStatus
|
|
21393
|
+
releaseStatus,
|
|
20677
21394
|
changesNotSentForReview: inputs.androidProfile.changesNotSentForReview ?? false,
|
|
20678
|
-
rollout
|
|
21395
|
+
rollout
|
|
20679
21396
|
});
|
|
20680
21397
|
}).pipe(Effect.catchTag("CliSubmitError", (engineError) => Effect.gen(function* () {
|
|
20681
21398
|
yield* patchSubmissionStatus(inputs.api, inputs.submissionId, {
|
|
@@ -20737,13 +21454,45 @@ const runAutoSubmit = (input) => Effect.gen(function* () {
|
|
|
20737
21454
|
})
|
|
20738
21455
|
});
|
|
20739
21456
|
yield* printHuman(`Submission created: ${submission.id} (${submission.status})`);
|
|
20740
|
-
if (input.platform === "ios" &&
|
|
20741
|
-
|
|
20742
|
-
|
|
21457
|
+
if (input.platform === "ios" && iosConfig !== void 0) {
|
|
21458
|
+
const auth = resolveIosUploadAuth({
|
|
21459
|
+
appleId: easProfile.ios?.appleId,
|
|
21460
|
+
ascApiKeyId: easProfile.ios?.ascApiKeyId,
|
|
21461
|
+
hasAppSpecificPassword: hasAppleAppSpecificPassword()
|
|
21462
|
+
});
|
|
21463
|
+
if (auth === null) yield* printHuman("Skipping iOS upload: configure ascApiKeyId or set EXPO_APPLE_APP_SPECIFIC_PASSWORD (+ appleId).");
|
|
21464
|
+
else {
|
|
21465
|
+
yield* printHuman(auth.kind === "app-specific-password" ? "Running xcrun altool upload (Apple ID app-specific password)..." : "Running xcrun altool upload (ASC API key)...");
|
|
21466
|
+
yield* runIosSubmit({
|
|
21467
|
+
api: input.api,
|
|
21468
|
+
submissionId: submission.id,
|
|
21469
|
+
archive: {
|
|
21470
|
+
source: "build",
|
|
21471
|
+
value: archiveUrl
|
|
21472
|
+
},
|
|
21473
|
+
auth,
|
|
21474
|
+
ascApiKeyId: easProfile.ios?.ascApiKeyId,
|
|
21475
|
+
config: {
|
|
21476
|
+
bundleIdentifier: iosConfig.bundleIdentifier,
|
|
21477
|
+
ascAppId: easProfile.ios?.ascAppId,
|
|
21478
|
+
language: easProfile.ios?.language,
|
|
21479
|
+
whatToTest: input.whatToTest,
|
|
21480
|
+
groups: easProfile.ios?.groups ?? []
|
|
21481
|
+
}
|
|
21482
|
+
});
|
|
21483
|
+
}
|
|
21484
|
+
}
|
|
21485
|
+
if (input.platform === "android" && androidConfig !== void 0 && easProfile.android !== void 0) {
|
|
21486
|
+
yield* printHuman("Uploading bundle to Google Play...");
|
|
21487
|
+
yield* runAndroidGooglePlayUpload({
|
|
20743
21488
|
api: input.api,
|
|
20744
21489
|
submissionId: submission.id,
|
|
20745
|
-
|
|
20746
|
-
|
|
21490
|
+
archive: {
|
|
21491
|
+
source: "build",
|
|
21492
|
+
value: archiveUrl
|
|
21493
|
+
},
|
|
21494
|
+
androidProfile: easProfile.android,
|
|
21495
|
+
serviceAccountKeyId: easProfile.android.serviceAccountKeyId
|
|
20747
21496
|
});
|
|
20748
21497
|
}
|
|
20749
21498
|
yield* printHuman(`Submission final status: ${(yield* pollSubmissionUntilTerminal(input.api, submission.id)).status}`);
|
|
@@ -20914,313 +21663,6 @@ const generateAndroidKeystore = (input) => Command.exitCode(Command.make("keytoo
|
|
|
20914
21663
|
message: `generate android keystore exited with code ${code}`
|
|
20915
21664
|
}))));
|
|
20916
21665
|
|
|
20917
|
-
//#endregion
|
|
20918
|
-
//#region src/lib/apple-pem.ts
|
|
20919
|
-
const PEM_HEADER = "-----BEGIN PRIVATE KEY-----";
|
|
20920
|
-
const PEM_FOOTER = "-----END PRIVATE KEY-----";
|
|
20921
|
-
const pemToPkcs8Der = (pem) => {
|
|
20922
|
-
const normalized = pem.replaceAll("\r\n", "\n").trim();
|
|
20923
|
-
const start = normalized.indexOf(PEM_HEADER);
|
|
20924
|
-
const end = normalized.indexOf(PEM_FOOTER);
|
|
20925
|
-
if (start === -1 || end === -1 || end <= start) return null;
|
|
20926
|
-
const body = normalized.slice(start + 27, end).replaceAll(/\s+/gu, "").trim();
|
|
20927
|
-
if (body.length === 0) return null;
|
|
20928
|
-
try {
|
|
20929
|
-
return fromBase64(body);
|
|
20930
|
-
} catch {
|
|
20931
|
-
return null;
|
|
20932
|
-
}
|
|
20933
|
-
};
|
|
20934
|
-
|
|
20935
|
-
//#endregion
|
|
20936
|
-
//#region src/lib/apple-asc-jwt.ts
|
|
20937
|
-
var AppleAuthError = class extends Data.TaggedError("AppleAuthError") {};
|
|
20938
|
-
const MAX_JWT_LIFETIME_SECONDS = 1200;
|
|
20939
|
-
const asArrayBuffer = (bytes) => {
|
|
20940
|
-
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
20941
|
-
new Uint8Array(buffer).set(bytes);
|
|
20942
|
-
return buffer;
|
|
20943
|
-
};
|
|
20944
|
-
const signAscJwt = (credentials) => Effect.gen(function* () {
|
|
20945
|
-
const der = pemToPkcs8Der(credentials.p8Pem);
|
|
20946
|
-
if (der === null) return yield* new AppleAuthError({ cause: /* @__PURE__ */ new Error("Invalid .p8 PEM") });
|
|
20947
|
-
const header = {
|
|
20948
|
-
alg: "ES256",
|
|
20949
|
-
kid: credentials.keyId,
|
|
20950
|
-
typ: "JWT"
|
|
20951
|
-
};
|
|
20952
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
20953
|
-
const payload = {
|
|
20954
|
-
iss: credentials.issuerId,
|
|
20955
|
-
iat: now,
|
|
20956
|
-
exp: now + MAX_JWT_LIFETIME_SECONDS,
|
|
20957
|
-
aud: "appstoreconnect-v1"
|
|
20958
|
-
};
|
|
20959
|
-
const signingInput = `${toBase64Url(new TextEncoder().encode(JSON.stringify(header)))}.${toBase64Url(new TextEncoder().encode(JSON.stringify(payload)))}`;
|
|
20960
|
-
const key = yield* Effect.tryPromise({
|
|
20961
|
-
try: async () => crypto.subtle.importKey("pkcs8", asArrayBuffer(der), {
|
|
20962
|
-
name: "ECDSA",
|
|
20963
|
-
namedCurve: "P-256"
|
|
20964
|
-
}, false, ["sign"]),
|
|
20965
|
-
catch: (cause) => new AppleAuthError({ cause })
|
|
20966
|
-
});
|
|
20967
|
-
const signature = yield* Effect.tryPromise({
|
|
20968
|
-
try: async () => crypto.subtle.sign({
|
|
20969
|
-
name: "ECDSA",
|
|
20970
|
-
hash: "SHA-256"
|
|
20971
|
-
}, key, new TextEncoder().encode(signingInput)),
|
|
20972
|
-
catch: (cause) => new AppleAuthError({ cause })
|
|
20973
|
-
});
|
|
20974
|
-
return `${signingInput}.${toBase64Url(new Uint8Array(signature))}`;
|
|
20975
|
-
});
|
|
20976
|
-
|
|
20977
|
-
//#endregion
|
|
20978
|
-
//#region src/lib/apple-asc-client.ts
|
|
20979
|
-
/**
|
|
20980
|
-
* App Store Connect REST client authenticated with an ASC **API key** — a JWT
|
|
20981
|
-
* signed from a `.p8` private key (see `apple-asc-jwt.ts`). Credentials are
|
|
20982
|
-
* resolved non-interactively from the server (`fetchAscCredentials`), so this
|
|
20983
|
-
* powers headless flows: build-credential resolution, provisioning-profile
|
|
20984
|
-
* generation, and device sync.
|
|
20985
|
-
*
|
|
20986
|
-
* Intentionally NOT built on `@expo/apple-utils`: that library authenticates
|
|
20987
|
-
* via an interactive Apple-ID **cookie session** (username/password + 2FA, see
|
|
20988
|
-
* `services/apple-auth.ts`) and exposes a cookie-based `RequestContext`. That is
|
|
20989
|
-
* a different auth model that would force an interactive login here. The two
|
|
20990
|
-
* coexist by design — apple-utils backs `apple login`; this client backs
|
|
20991
|
-
* non-interactive ASC API-key access.
|
|
20992
|
-
*/
|
|
20993
|
-
var AscApiError = class extends Data.TaggedError("AscApiError") {};
|
|
20994
|
-
var AscNetworkError = class extends Data.TaggedError("AscNetworkError") {};
|
|
20995
|
-
const API_BASE = "https://api.appstoreconnect.apple.com";
|
|
20996
|
-
const extractErrors = (body) => {
|
|
20997
|
-
if (!isRecord$1(body) || !Array.isArray(body["errors"])) return [];
|
|
20998
|
-
return body["errors"].filter((value) => isRecord$1(value));
|
|
20999
|
-
};
|
|
21000
|
-
const parseApiError = (response, body, raw) => {
|
|
21001
|
-
const [first] = extractErrors(body);
|
|
21002
|
-
return new AscApiError({
|
|
21003
|
-
status: response.status,
|
|
21004
|
-
message: first?.detail ?? first?.title ?? response.statusText,
|
|
21005
|
-
code: first?.code,
|
|
21006
|
-
raw
|
|
21007
|
-
});
|
|
21008
|
-
};
|
|
21009
|
-
const fetchRaw = (jwt, path, init) => Effect.gen(function* () {
|
|
21010
|
-
const response = yield* Effect.tryPromise({
|
|
21011
|
-
try: async () => fetch(`${API_BASE}${path}`, compact({
|
|
21012
|
-
method: init?.method ?? "GET",
|
|
21013
|
-
body: init?.body,
|
|
21014
|
-
headers: {
|
|
21015
|
-
authorization: `Bearer ${jwt}`,
|
|
21016
|
-
"content-type": "application/json",
|
|
21017
|
-
accept: "application/json"
|
|
21018
|
-
}
|
|
21019
|
-
})),
|
|
21020
|
-
catch: (cause) => new AscNetworkError({ cause })
|
|
21021
|
-
});
|
|
21022
|
-
const text = yield* Effect.tryPromise({
|
|
21023
|
-
try: async () => response.text(),
|
|
21024
|
-
catch: (cause) => new AscNetworkError({ cause })
|
|
21025
|
-
});
|
|
21026
|
-
const body = yield* Effect.try({
|
|
21027
|
-
try: () => text.length === 0 ? {} : JSON.parse(text),
|
|
21028
|
-
catch: (cause) => new AscNetworkError({ cause })
|
|
21029
|
-
});
|
|
21030
|
-
if (!response.ok) return yield* parseApiError(response, body, text);
|
|
21031
|
-
return body;
|
|
21032
|
-
});
|
|
21033
|
-
const toAscCertificate = (value) => {
|
|
21034
|
-
if (!isRecord$1(value)) return null;
|
|
21035
|
-
const { id, attributes } = value;
|
|
21036
|
-
if (typeof id !== "string" || !isRecord$1(attributes)) return null;
|
|
21037
|
-
const { serialNumber, certificateType, expirationDate, certificateContent, displayName } = attributes;
|
|
21038
|
-
if (typeof serialNumber !== "string" || typeof certificateType !== "string" || typeof expirationDate !== "string") return null;
|
|
21039
|
-
return {
|
|
21040
|
-
id,
|
|
21041
|
-
serialNumber,
|
|
21042
|
-
certificateType,
|
|
21043
|
-
expirationDate,
|
|
21044
|
-
certificateContent: typeof certificateContent === "string" ? certificateContent : null,
|
|
21045
|
-
displayName: typeof displayName === "string" ? displayName : null
|
|
21046
|
-
};
|
|
21047
|
-
};
|
|
21048
|
-
const toAscBundleId = (value) => {
|
|
21049
|
-
if (!isRecord$1(value)) return null;
|
|
21050
|
-
const { id, attributes } = value;
|
|
21051
|
-
if (typeof id !== "string" || !isRecord$1(attributes)) return null;
|
|
21052
|
-
const { identifier, name } = attributes;
|
|
21053
|
-
if (typeof identifier !== "string" || typeof name !== "string") return null;
|
|
21054
|
-
return {
|
|
21055
|
-
id,
|
|
21056
|
-
identifier,
|
|
21057
|
-
name
|
|
21058
|
-
};
|
|
21059
|
-
};
|
|
21060
|
-
const PROFILE_TYPES = [
|
|
21061
|
-
"IOS_APP_ADHOC",
|
|
21062
|
-
"IOS_APP_DEVELOPMENT",
|
|
21063
|
-
"IOS_APP_STORE",
|
|
21064
|
-
"IOS_APP_INHOUSE"
|
|
21065
|
-
];
|
|
21066
|
-
const asProfileType = (value) => {
|
|
21067
|
-
const match = PROFILE_TYPES.find((entry) => entry === value);
|
|
21068
|
-
return match === void 0 ? null : match;
|
|
21069
|
-
};
|
|
21070
|
-
const toAscProfile = (value) => {
|
|
21071
|
-
if (!isRecord$1(value)) return null;
|
|
21072
|
-
const { id, attributes } = value;
|
|
21073
|
-
if (typeof id !== "string" || !isRecord$1(attributes)) return null;
|
|
21074
|
-
const { name, uuid, expirationDate, profileContent } = attributes;
|
|
21075
|
-
const profileType = asProfileType(attributes["profileType"]);
|
|
21076
|
-
if (typeof name !== "string" || typeof uuid !== "string" || typeof expirationDate !== "string" || typeof profileContent !== "string" || profileType === null) return null;
|
|
21077
|
-
return {
|
|
21078
|
-
id,
|
|
21079
|
-
name,
|
|
21080
|
-
uuid,
|
|
21081
|
-
expirationDate,
|
|
21082
|
-
profileContent,
|
|
21083
|
-
profileType
|
|
21084
|
-
};
|
|
21085
|
-
};
|
|
21086
|
-
const toAscDevice = (value) => {
|
|
21087
|
-
if (!isRecord$1(value)) return null;
|
|
21088
|
-
const { id, attributes } = value;
|
|
21089
|
-
if (typeof id !== "string" || !isRecord$1(attributes)) return null;
|
|
21090
|
-
const { udid, name, deviceClass } = attributes;
|
|
21091
|
-
if (typeof udid !== "string" || typeof name !== "string") return null;
|
|
21092
|
-
return {
|
|
21093
|
-
id,
|
|
21094
|
-
udid,
|
|
21095
|
-
name,
|
|
21096
|
-
deviceClass: typeof deviceClass === "string" ? deviceClass : null
|
|
21097
|
-
};
|
|
21098
|
-
};
|
|
21099
|
-
const extractList = (body, map) => {
|
|
21100
|
-
if (!isRecord$1(body) || !Array.isArray(body["data"])) return [];
|
|
21101
|
-
return body["data"].map(map).filter((value) => value !== null);
|
|
21102
|
-
};
|
|
21103
|
-
const extractSingle = (body, map) => {
|
|
21104
|
-
if (!isRecord$1(body)) return null;
|
|
21105
|
-
return map(body["data"]);
|
|
21106
|
-
};
|
|
21107
|
-
/**
|
|
21108
|
-
* App Store Connect paginates list responses (default 200/page) and returns the
|
|
21109
|
-
* absolute URL of the next page under `links.next`. Strip the base so it can be
|
|
21110
|
-
* fed back into `fetchRaw`; return null when there is no further page.
|
|
21111
|
-
*/
|
|
21112
|
-
const nextPagePath = (body) => {
|
|
21113
|
-
if (!isRecord$1(body)) return null;
|
|
21114
|
-
const { links } = body;
|
|
21115
|
-
if (!isRecord$1(links) || typeof links["next"] !== "string") return null;
|
|
21116
|
-
const { next } = links;
|
|
21117
|
-
return next.startsWith(API_BASE) ? next.slice(37) : next;
|
|
21118
|
-
};
|
|
21119
|
-
const malformed = (resource) => new AscApiError({
|
|
21120
|
-
status: 500,
|
|
21121
|
-
message: `Malformed ${resource} response`,
|
|
21122
|
-
code: void 0,
|
|
21123
|
-
raw: ""
|
|
21124
|
-
});
|
|
21125
|
-
const withJwt = (credentials, fn) => Effect.gen(function* () {
|
|
21126
|
-
return yield* fn(yield* signAscJwt(credentials));
|
|
21127
|
-
});
|
|
21128
|
-
const listCertificates = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
21129
|
-
return extractList(yield* fetchRaw(jwt, `/v1/certificates${params?.certificateType ? `?filter[certificateType]=${params.certificateType}&limit=200` : "?limit=200"}`), toAscCertificate);
|
|
21130
|
-
}));
|
|
21131
|
-
const createCertificate = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
21132
|
-
const csrContent = params.csrPem.replaceAll("-----BEGIN CERTIFICATE REQUEST-----", "").replaceAll("-----END CERTIFICATE REQUEST-----", "").replaceAll(/\s+/gu, "");
|
|
21133
|
-
const resource = extractSingle(yield* fetchRaw(jwt, "/v1/certificates", {
|
|
21134
|
-
method: "POST",
|
|
21135
|
-
body: JSON.stringify({ data: {
|
|
21136
|
-
type: "certificates",
|
|
21137
|
-
attributes: {
|
|
21138
|
-
csrContent,
|
|
21139
|
-
certificateType: params.certificateType
|
|
21140
|
-
}
|
|
21141
|
-
} })
|
|
21142
|
-
}), toAscCertificate);
|
|
21143
|
-
if (resource === null) return yield* malformed("certificate");
|
|
21144
|
-
return resource;
|
|
21145
|
-
}));
|
|
21146
|
-
const deleteCertificate = (credentials, id) => withJwt(credentials, (jwt) => Effect.asVoid(fetchRaw(jwt, `/v1/certificates/${encodeURIComponent(id)}`, { method: "DELETE" })));
|
|
21147
|
-
const listBundleIds = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
21148
|
-
return extractList(yield* fetchRaw(jwt, "/v1/bundleIds?limit=200"), toAscBundleId);
|
|
21149
|
-
}));
|
|
21150
|
-
const createBundleId = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
21151
|
-
const resource = extractSingle(yield* fetchRaw(jwt, "/v1/bundleIds", {
|
|
21152
|
-
method: "POST",
|
|
21153
|
-
body: JSON.stringify({ data: {
|
|
21154
|
-
type: "bundleIds",
|
|
21155
|
-
attributes: {
|
|
21156
|
-
identifier: params.identifier,
|
|
21157
|
-
name: params.name,
|
|
21158
|
-
platform: "IOS"
|
|
21159
|
-
}
|
|
21160
|
-
} })
|
|
21161
|
-
}), toAscBundleId);
|
|
21162
|
-
if (resource === null) return yield* malformed("bundleId");
|
|
21163
|
-
return resource;
|
|
21164
|
-
}));
|
|
21165
|
-
const listDevices = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
21166
|
-
const devices = [];
|
|
21167
|
-
let path = "/v1/devices?limit=200";
|
|
21168
|
-
while (path !== null) {
|
|
21169
|
-
const body = yield* fetchRaw(jwt, path);
|
|
21170
|
-
devices.push(...extractList(body, toAscDevice));
|
|
21171
|
-
path = nextPagePath(body);
|
|
21172
|
-
}
|
|
21173
|
-
return devices;
|
|
21174
|
-
}));
|
|
21175
|
-
const createDevice = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
21176
|
-
const resource = extractSingle(yield* fetchRaw(jwt, "/v1/devices", {
|
|
21177
|
-
method: "POST",
|
|
21178
|
-
body: JSON.stringify({ data: {
|
|
21179
|
-
type: "devices",
|
|
21180
|
-
attributes: {
|
|
21181
|
-
name: params.name,
|
|
21182
|
-
udid: params.udid,
|
|
21183
|
-
platform: "IOS"
|
|
21184
|
-
}
|
|
21185
|
-
} })
|
|
21186
|
-
}), toAscDevice);
|
|
21187
|
-
if (resource === null) return yield* malformed("device");
|
|
21188
|
-
return resource;
|
|
21189
|
-
}));
|
|
21190
|
-
const createProvisioningProfile = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
|
|
21191
|
-
const relationships = {
|
|
21192
|
-
bundleId: { data: {
|
|
21193
|
-
type: "bundleIds",
|
|
21194
|
-
id: params.bundleIdAscId
|
|
21195
|
-
} },
|
|
21196
|
-
certificates: { data: params.certificateAscIds.map((id) => ({
|
|
21197
|
-
type: "certificates",
|
|
21198
|
-
id
|
|
21199
|
-
})) },
|
|
21200
|
-
...params.deviceAscIds.length > 0 ? { devices: { data: params.deviceAscIds.map((id) => ({
|
|
21201
|
-
type: "devices",
|
|
21202
|
-
id
|
|
21203
|
-
})) } } : {}
|
|
21204
|
-
};
|
|
21205
|
-
const resource = extractSingle(yield* fetchRaw(jwt, "/v1/profiles", {
|
|
21206
|
-
method: "POST",
|
|
21207
|
-
body: JSON.stringify({ data: {
|
|
21208
|
-
type: "profiles",
|
|
21209
|
-
attributes: {
|
|
21210
|
-
name: params.profileName,
|
|
21211
|
-
profileType: params.profileType
|
|
21212
|
-
},
|
|
21213
|
-
relationships
|
|
21214
|
-
} })
|
|
21215
|
-
}), toAscProfile);
|
|
21216
|
-
if (resource === null) return yield* malformed("profile");
|
|
21217
|
-
return resource;
|
|
21218
|
-
}));
|
|
21219
|
-
const isCertificateLimitError = (error) => {
|
|
21220
|
-
if (error._tag !== "AscApiError") return false;
|
|
21221
|
-
return /already have a current.*certificate|pending certificate request/iu.test(error.message);
|
|
21222
|
-
};
|
|
21223
|
-
|
|
21224
21666
|
//#endregion
|
|
21225
21667
|
//#region src/lib/apple-cert-to-p12.ts
|
|
21226
21668
|
var CertParseError = class extends Data.TaggedError("CertParseError") {};
|
|
@@ -33431,17 +33873,33 @@ const runFlow = (api, projectId, args) => Effect.gen(function* () {
|
|
|
33431
33873
|
});
|
|
33432
33874
|
yield* printHuman(`Submission created: ${submission.id} (${submission.status})`);
|
|
33433
33875
|
if (args.platform === "ios" && iosConfig !== void 0) {
|
|
33434
|
-
const
|
|
33435
|
-
|
|
33436
|
-
|
|
33876
|
+
const iosProfile = args.easProfile.ios;
|
|
33877
|
+
const auth = resolveIosUploadAuth({
|
|
33878
|
+
appleId: iosProfile?.appleId,
|
|
33879
|
+
ascApiKeyId: iosProfile?.ascApiKeyId,
|
|
33880
|
+
hasAppSpecificPassword: hasAppleAppSpecificPassword()
|
|
33881
|
+
});
|
|
33882
|
+
if (auth === null) {
|
|
33883
|
+
yield* printHuman("iOS submission queued. Add ascApiKeyId to the eas.json submit profile, or set appleId + the EXPO_APPLE_APP_SPECIFIC_PASSWORD env var, to enable client-side altool upload.");
|
|
33437
33884
|
return submission;
|
|
33438
33885
|
}
|
|
33439
|
-
yield* printHuman("Running xcrun altool upload
|
|
33440
|
-
yield*
|
|
33886
|
+
yield* printHuman(auth.kind === "app-specific-password" ? "Running xcrun altool upload (Apple ID app-specific password)..." : "Running xcrun altool upload (ASC API key)...");
|
|
33887
|
+
yield* runIosSubmit({
|
|
33441
33888
|
api,
|
|
33442
33889
|
submissionId: submission.id,
|
|
33443
|
-
|
|
33444
|
-
|
|
33890
|
+
archive: {
|
|
33891
|
+
source: args.archive.archiveSource,
|
|
33892
|
+
value: args.archive.archiveUrl
|
|
33893
|
+
},
|
|
33894
|
+
auth,
|
|
33895
|
+
ascApiKeyId: iosProfile?.ascApiKeyId,
|
|
33896
|
+
config: {
|
|
33897
|
+
bundleIdentifier: iosConfig.bundleIdentifier,
|
|
33898
|
+
ascAppId: iosProfile?.ascAppId,
|
|
33899
|
+
language: iosProfile?.language,
|
|
33900
|
+
whatToTest: args.whatToTest,
|
|
33901
|
+
groups: iosProfile?.groups ?? []
|
|
33902
|
+
}
|
|
33445
33903
|
});
|
|
33446
33904
|
}
|
|
33447
33905
|
if (args.platform === "android" && args.easProfile.android !== void 0) {
|