@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 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.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/google-play.ts
20172
- var GooglePlayAuthError = class extends Schema.TaggedError()("GooglePlayAuthError", {
20173
- message: Schema.String,
20174
- cause: Schema.optional(Schema.Unknown)
20175
- }) {};
20176
- var GooglePlayApiError = class extends Schema.TaggedError()("GooglePlayApiError", {
20177
- message: Schema.String,
20178
- httpStatus: Schema.optional(Schema.Number),
20179
- cause: Schema.optional(Schema.Unknown)
20180
- }) {};
20181
- const ANDROID_PUBLISHER_SCOPE = "https://www.googleapis.com/auth/androidpublisher";
20182
- const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
20183
- const ANDROID_PUBLISHER_BASE = "https://androidpublisher.googleapis.com";
20184
- const ANDROID_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload";
20185
- const ServiceAccountJsonSchema = Schema.Struct({
20186
- type: Schema.String,
20187
- client_email: Schema.String,
20188
- private_key: Schema.String,
20189
- token_uri: Schema.optional(Schema.String)
20190
- });
20191
- const TokenResponseSchema = Schema.Struct({
20192
- access_token: Schema.String,
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 importPrivateKey = (pem) => Effect.tryPromise({
20202
- try: async () => {
20203
- const pkcs8 = fromBase64(stripPemHeaders(pem));
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: "RS256",
20474
+ alg: "ES256",
20475
+ kid: credentials.keyId,
20217
20476
  typ: "JWT"
20218
20477
  };
20219
- const claims = {
20220
- iss: params.clientEmail,
20221
- scope: ANDROID_PUBLISHER_SCOPE,
20222
- aud: params.tokenUri,
20223
- exp: params.nowSeconds + 3600,
20224
- iat: params.nowSeconds
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
- return `${toBase64Url(new TextEncoder().encode(JSON.stringify(header)))}.${toBase64Url(new TextEncoder().encode(JSON.stringify(claims)))}`;
20227
- };
20228
- const signJwt = (key, payload) => Effect.tryPromise({
20229
- try: async () => {
20230
- const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(payload));
20231
- return `${payload}.${toBase64Url(new Uint8Array(signature))}`;
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
- return (yield* Schema.decodeUnknown(TokenResponseSchema)(json).pipe(Effect.mapError((cause) => new GooglePlayAuthError({
20272
- message: "OAuth token response missing access_token",
20273
- cause
20274
- })))).access_token;
20275
- });
20276
- const acquireGooglePlayAccessToken = (serviceAccountJson) => Effect.gen(function* () {
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
- const parsed = yield* Schema.decodeUnknown(ServiceAccountJsonSchema)(raw).pipe(Effect.mapError((cause) => new GooglePlayAuthError({
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
- const authHeaders = (accessToken) => ({ Authorization: `Bearer ${accessToken}` });
20300
- const performFetch = (params) => Effect.tryPromise({
20301
- try: async () => {
20302
- const init = params.body === void 0 ? {
20303
- method: params.method,
20304
- headers: authHeaders(params.accessToken)
20305
- } : {
20306
- method: params.method,
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
- ...authHeaders(params.accessToken),
20309
- "Content-Type": "application/json"
20310
- },
20311
- body: JSON.stringify(params.body)
20312
- };
20313
- const response = await fetch(params.url, init);
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
- return yield* Effect.try({
20333
- try: () => result.text === "" ? {} : JSON.parse(result.text),
20334
- catch: (cause) => new GooglePlayApiError({
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
- const AppEditSchema = Schema.Struct({
20341
- id: Schema.String,
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* Schema.decodeUnknown(AppEditSchema)(raw).pipe(Effect.mapError((cause) => new GooglePlayApiError({
20353
- message: "edits.insert response missing id",
20354
- cause
20355
- })));
20556
+ if (!response.ok) return yield* parseApiError(response, body, text);
20557
+ return body;
20356
20558
  });
20357
- const UploadedBundleSchema = Schema.Struct({
20358
- versionCode: Schema.Number,
20359
- sha256: Schema.optional(Schema.String)
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 performBundleUpload = (params) => Effect.tryPromise({
20362
- try: async () => {
20363
- const url = `${ANDROID_UPLOAD_BASE}/androidpublisher/v3/applications/${encodeURIComponent(params.packageName)}/edits/${encodeURIComponent(params.editId)}/bundles?uploadType=media`;
20364
- const response = await fetch(url, {
20365
- method: "POST",
20366
- headers: {
20367
- ...authHeaders(params.accessToken),
20368
- "Content-Type": "application/octet-stream"
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
- body: new Uint8Array(params.aabBytes)
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
- const text = await response.text();
20373
- return {
20374
- ok: response.ok,
20375
- status: response.status,
20376
- text
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 uploadBundle = (params) => Effect.gen(function* () {
20385
- const result = yield* performBundleUpload(params);
20386
- if (!result.ok) return yield* new GooglePlayApiError({
20387
- message: `edits.bundles.upload failed: ${String(result.status)} ${result.text}`,
20388
- httpStatus: result.status
20389
- });
20390
- const raw = yield* Effect.try({
20391
- try: () => JSON.parse(result.text),
20392
- catch: (cause) => new GooglePlayApiError({
20393
- message: "edits.bundles.upload response is not JSON",
20394
- cause
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
- return yield* Schema.decodeUnknown(UploadedBundleSchema)(raw).pipe(Effect.mapError((cause) => new GooglePlayApiError({
20398
- message: "Bundle upload response missing versionCode",
20399
- cause
20400
- })));
20401
- });
20402
- const updateTrack = (params) => {
20403
- const release = {
20404
- status: params.releaseStatus,
20405
- versionCodes: [String(params.versionCode)],
20406
- ...compact({
20407
- userFraction: toOptional(params.rollout),
20408
- releaseNotes: params.releaseNotes ? [{
20409
- language: "en-US",
20410
- text: params.releaseNotes
20411
- }] : void 0
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 writeAscApiKeyP8 = (api, ascApiKeyId) => Effect.gen(function* () {
20499
- const creds = yield* fetchAscCredentials(api, ascApiKeyId).pipe(Effect.mapError(() => new CliSubmitError({
20500
- code: "SUBMISSION_ASC_KEY_FETCH_FAILED",
20501
- message: `Failed to fetch or decrypt ASC API key ${ascApiKeyId}`
20502
- })));
20503
- const target = path.join(tmpdir(), `better-update-submit-AuthKey_${creds.keyId}.p8`);
20504
- yield* Effect.promise(async () => writeFile(target, creds.p8Pem, "utf8"));
20505
- return {
20506
- p8Path: target,
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 AAB from ${url}: ${cause instanceof Error ? cause.message : String(cause)}`
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 AAB at ${archive.value}: ${cause instanceof Error ? cause.message : String(cause)}`), (buf) => new Uint8Array(buf)) : fetchArchiveOverHttp(archive.value);
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 Effect.map(readLocalFile(params.serviceAccountKeyPath, "SUBMISSION_ANDROID_SA_KEY_LOCAL_READ_FAILED", (cause) => `Failed to read service account JSON at ${String(params.serviceAccountKeyPath)}: ${cause instanceof Error ? cause.message : String(cause)}`), (buf) => new TextDecoder().decode(buf));
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: inputs.androidProfile.releaseStatus ?? "completed",
21393
+ releaseStatus,
20677
21394
  changesNotSentForReview: inputs.androidProfile.changesNotSentForReview ?? false,
20678
- rollout: toDbNull(inputs.androidProfile.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" && easProfile.ios?.ascApiKeyId !== void 0) {
20741
- yield* printHuman("Running xcrun altool upload...");
20742
- yield* runIosAltoolUpload({
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
- ipaPath: archiveUrl,
20746
- ascApiKeyId: easProfile.ios.ascApiKeyId
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 ascApiKeyId = args.easProfile.ios?.ascApiKeyId;
33435
- if (ascApiKeyId === void 0) {
33436
- yield* printHuman("iOS submission queued. Resolve ascApiKeyId in eas.json submit profile to enable client-side altool upload.");
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 locally...");
33440
- yield* runIosAltoolUpload({
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
- ipaPath: args.archive.archiveUrl,
33444
- ascApiKeyId
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) {