@better-update/cli 0.14.2 → 0.15.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
@@ -13,11 +13,11 @@ import { once } from "node:events";
13
13
  import { createServer } from "node:http";
14
14
  import { maxBy, uniqBy } from "es-toolkit";
15
15
  import { createHash, randomBytes, randomUUID } from "node:crypto";
16
+ import forge from "node-forge";
16
17
  import { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs";
17
18
  import os from "node:os";
18
- import plist from "@expo/plist";
19
+ import plistMod from "@expo/plist";
19
20
  import { ExpoRunFormatter } from "@expo/xcpretty";
20
- import forge from "node-forge";
21
21
  import { Buffer as Buffer$1 } from "node:buffer";
22
22
  import { getFormattedSerialNumber, getX509Certificate, parsePKCS12 } from "@expo/pkcs12";
23
23
  import qrcode from "qrcode-terminal";
@@ -28,7 +28,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
28
28
 
29
29
  //#endregion
30
30
  //#region package.json
31
- var version = "0.14.2";
31
+ var version = "0.15.0";
32
32
 
33
33
  //#endregion
34
34
  //#region src/lib/interactive-mode.ts
@@ -738,13 +738,27 @@ const IosBuildPushKey = Schema.Struct({
738
738
  keyId: Schema.String,
739
739
  teamId: Schema.String
740
740
  });
741
+ /**
742
+ * Auto-provision context surfaced to the CLI. When `ascApiKeyId` is present,
743
+ * the CLI may use it to generate missing provisioning profiles for extension
744
+ * targets (auto-provision flow). `distributionCertificateId` + `appleTeamId`
745
+ * are backend row ids; `appleTeamIdentifier` is the 10-char Apple portal team
746
+ * id (e.g. "ABCDE12345").
747
+ */
748
+ const IosBuildContext = Schema.Struct({
749
+ ascApiKeyId: Schema.NullOr(Schema.String),
750
+ distributionCertificateId: Schema.String,
751
+ appleTeamId: Schema.String,
752
+ appleTeamIdentifier: Schema.String
753
+ });
741
754
  const ResolveBuildCredentialsIosResult = Schema.Struct({
742
755
  platform: Schema.Literal("ios"),
743
756
  distributionCertificate: IosBuildDistributionCertificate,
744
757
  provisioningProfile: IosBuildProvisioningProfile,
745
758
  pushKey: Schema.NullOr(IosBuildPushKey),
746
759
  profileStale: Schema.Boolean,
747
- currentDeviceRosterHash: Schema.NullOr(Schema.String)
760
+ currentDeviceRosterHash: Schema.NullOr(Schema.String),
761
+ context: IosBuildContext
748
762
  });
749
763
  const AndroidBuildKeystore = Schema.Struct({
750
764
  keystoreBase64: Schema.String,
@@ -1638,6 +1652,7 @@ var PresignedUrlExpiredError = class extends Data.TaggedError("PresignedUrlExpir
1638
1652
  var ArtifactNotFoundError = class extends Data.TaggedError("ArtifactNotFoundError") {};
1639
1653
  var KeychainError = class extends Data.TaggedError("KeychainError") {};
1640
1654
  var ProvisioningError = class extends Data.TaggedError("ProvisioningError") {};
1655
+ var XcodeProjectError = class extends Data.TaggedError("XcodeProjectError") {};
1641
1656
  var EnvExportError = class extends Data.TaggedError("EnvExportError") {};
1642
1657
  var UpdatePublishError = class extends Data.TaggedError("UpdatePublishError") {};
1643
1658
  var UpdateRollbackError = class extends Data.TaggedError("UpdateRollbackError") {};
@@ -3310,2046 +3325,2441 @@ const fromHex = (hex) => {
3310
3325
  };
3311
3326
 
3312
3327
  //#endregion
3313
- //#region src/lib/credentials-downloader.ts
3314
- const IOS_DISTRIBUTION_TO_TYPE = {
3315
- "app-store": "APP_STORE",
3316
- "ad-hoc": "AD_HOC",
3317
- development: "DEVELOPMENT",
3318
- enterprise: "ENTERPRISE"
3328
+ //#region src/lib/android-keystore.ts
3329
+ const DEFAULT_KEYSTORE_VALIDITY_DAYS = 1e4;
3330
+ const renderDistinguishedName = (params) => `CN=${params.commonName}, O=${params.organization}`;
3331
+ const generateAndroidKeystore = (input) => Command.exitCode(Command.make("keytool", "-genkeypair", "-v", "-storetype", "JKS", "-keystore", input.outputPath, "-alias", input.keyAlias, "-keyalg", "RSA", "-keysize", "2048", "-validity", String(input.validityDays ?? DEFAULT_KEYSTORE_VALIDITY_DAYS), "-storepass", input.storePassword, "-keypass", input.keyPassword, "-dname", renderDistinguishedName({
3332
+ commonName: input.commonName,
3333
+ organization: input.organization
3334
+ }), "-noprompt").pipe(Command.stdout("inherit"), Command.stderr("inherit"))).pipe(Effect.mapError((cause) => new BuildFailedError({
3335
+ step: "generate android keystore",
3336
+ exitCode: 1,
3337
+ message: `generate android keystore failed to spawn: ${String(cause)}`
3338
+ })), Effect.flatMap((code) => code === 0 ? Effect.void : Effect.fail(new BuildFailedError({
3339
+ step: "generate android keystore",
3340
+ exitCode: code,
3341
+ message: `generate android keystore exited with code ${code}`
3342
+ }))));
3343
+
3344
+ //#endregion
3345
+ //#region src/lib/apple-pem.ts
3346
+ const PEM_HEADER = "-----BEGIN PRIVATE KEY-----";
3347
+ const PEM_FOOTER = "-----END PRIVATE KEY-----";
3348
+ const pemToPkcs8Der = (pem) => {
3349
+ const normalized = pem.replaceAll("\r\n", "\n").trim();
3350
+ const start = normalized.indexOf(PEM_HEADER);
3351
+ const end = normalized.indexOf(PEM_FOOTER);
3352
+ if (start === -1 || end === -1 || end <= start) return null;
3353
+ const body = normalized.slice(start + 27, end).replaceAll(/\s+/gu, "").trim();
3354
+ if (body.length === 0) return null;
3355
+ try {
3356
+ return fromBase64(body);
3357
+ } catch {
3358
+ return null;
3359
+ }
3319
3360
  };
3320
- const bindHint = "Bind the bundle via the dashboard (Credentials → iOS Bundle Configurations) and make sure a distribution certificate, provisioning profile, and ASC API key are attached.";
3321
- const permissionHint = "Ask an org admin to grant the build-credentials download permission.";
3322
- const androidBindHint = "Register the package in the dashboard (Credentials → Android Build Credentials) and bind a keystore to the default group.";
3323
- const hasTag$1 = (cause) => typeof cause === "object" && cause !== null && "_tag" in cause;
3324
- const resolveErrorToMissingCredentials = (cause, platform) => {
3325
- const tag = hasTag$1(cause) ? cause._tag : null;
3326
- const message = hasTag$1(cause) && typeof cause.message === "string" ? cause.message : null;
3327
- const platformLabel = platform === "ios" ? "iOS" : "Android";
3328
- const bind = platform === "ios" ? bindHint : androidBindHint;
3329
- if (tag === "Forbidden") return new MissingCredentialsError({
3330
- message: message ?? `Permission denied when resolving ${platformLabel} build credentials`,
3331
- hint: permissionHint
3332
- });
3333
- if (tag === "NotFound") return new MissingCredentialsError({
3334
- message: message ?? `No ${platformLabel} build credentials configured`,
3335
- hint: bind
3336
- });
3337
- if (tag === "BadRequest") return new MissingCredentialsError({
3338
- message: message ?? `${platformLabel} build credentials are misconfigured`,
3339
- hint: bind
3340
- });
3341
- return new MissingCredentialsError({
3342
- message: message ?? `Failed to resolve ${platformLabel} build credentials`,
3343
- hint: bind
3344
- });
3361
+
3362
+ //#endregion
3363
+ //#region src/lib/apple-asc-jwt.ts
3364
+ var AppleAuthError = class extends Data.TaggedError("AppleAuthError") {};
3365
+ const MAX_JWT_LIFETIME_SECONDS = 1200;
3366
+ const asArrayBuffer = (bytes) => {
3367
+ const buffer = new ArrayBuffer(bytes.byteLength);
3368
+ new Uint8Array(buffer).set(bytes);
3369
+ return buffer;
3345
3370
  };
3346
- const downloadIosCredentials = (api, options) => Effect.gen(function* () {
3347
- const fs = yield* FileSystem.FileSystem;
3348
- const resolved = yield* api.buildCredentials.resolve({
3349
- path: { projectId: options.projectId },
3350
- payload: {
3351
- platform: "ios",
3352
- bundleIdentifier: options.bundleIdentifier,
3353
- distributionType: IOS_DISTRIBUTION_TO_TYPE[options.distribution]
3354
- }
3355
- }).pipe(Effect.mapError((cause) => resolveErrorToMissingCredentials(cause, "ios")));
3356
- if (resolved.platform !== "ios") return yield* Effect.fail(new MissingCredentialsError({
3357
- message: "Server returned non-iOS credentials for an iOS build request",
3358
- hint: bindHint
3359
- }));
3360
- const p12Path = path.join(options.tempDir, "signing.p12");
3361
- const profileFilename = `${resolved.provisioningProfile.uuid ?? "profile"}.mobileprovision`;
3362
- const profilePath = path.join(options.tempDir, profileFilename);
3363
- yield* fs.writeFile(p12Path, fromBase64(resolved.distributionCertificate.p12Base64));
3364
- yield* fs.writeFile(profilePath, fromBase64(resolved.provisioningProfile.mobileprovisionBase64));
3365
- return {
3366
- p12Path,
3367
- p12Password: resolved.distributionCertificate.p12Password,
3368
- profilePath,
3369
- profileFilename,
3370
- teamId: resolved.provisioningProfile.teamId
3371
+ const signAscJwt = (credentials) => Effect.gen(function* () {
3372
+ const der = pemToPkcs8Der(credentials.p8Pem);
3373
+ if (der === null) return yield* Effect.fail(new AppleAuthError({ cause: /* @__PURE__ */ new Error("Invalid .p8 PEM") }));
3374
+ const header = {
3375
+ alg: "ES256",
3376
+ kid: credentials.keyId,
3377
+ typ: "JWT"
3371
3378
  };
3372
- });
3373
- const downloadAndroidCredentials = (api, options) => Effect.gen(function* () {
3374
- const fs = yield* FileSystem.FileSystem;
3375
- const resolved = yield* api.buildCredentials.resolve({
3376
- path: { projectId: options.projectId },
3377
- payload: {
3378
- platform: "android",
3379
- applicationIdentifier: options.applicationIdentifier
3380
- }
3381
- }).pipe(Effect.mapError((cause) => resolveErrorToMissingCredentials(cause, "android")));
3382
- if (resolved.platform !== "android") return yield* Effect.fail(new MissingCredentialsError({
3383
- message: "Server returned non-Android credentials for an Android build request",
3384
- hint: androidBindHint
3385
- }));
3386
- const keystorePath = path.join(options.tempDir, "upload.keystore");
3387
- yield* fs.writeFile(keystorePath, fromBase64(resolved.keystore.keystoreBase64));
3388
- return {
3389
- keystorePath,
3390
- storePassword: resolved.keystore.storePassword,
3391
- keyAlias: resolved.keystore.keyAlias,
3392
- keyPassword: resolved.keystore.keyPassword
3379
+ const now = Math.floor(Date.now() / 1e3);
3380
+ const payload = {
3381
+ iss: credentials.issuerId,
3382
+ iat: now,
3383
+ exp: now + MAX_JWT_LIFETIME_SECONDS,
3384
+ aud: "appstoreconnect-v1"
3393
3385
  };
3386
+ const signingInput = `${toBase64Url(new TextEncoder().encode(JSON.stringify(header)))}.${toBase64Url(new TextEncoder().encode(JSON.stringify(payload)))}`;
3387
+ const key = yield* Effect.tryPromise({
3388
+ try: async () => crypto.subtle.importKey("pkcs8", asArrayBuffer(der), {
3389
+ name: "ECDSA",
3390
+ namedCurve: "P-256"
3391
+ }, false, ["sign"]),
3392
+ catch: (cause) => new AppleAuthError({ cause })
3393
+ });
3394
+ const signature = yield* Effect.tryPromise({
3395
+ try: async () => crypto.subtle.sign({
3396
+ name: "ECDSA",
3397
+ hash: "SHA-256"
3398
+ }, key, new TextEncoder().encode(signingInput)),
3399
+ catch: (cause) => new AppleAuthError({ cause })
3400
+ });
3401
+ return `${signingInput}.${toBase64Url(new Uint8Array(signature))}`;
3394
3402
  });
3395
3403
 
3396
3404
  //#endregion
3397
- //#region src/lib/credentials-json.ts
3398
- const CREDENTIALS_JSON_FILENAME = "credentials.json";
3399
- const asString$2 = (value, field) => typeof value === "string" && value.length > 0 ? Effect.succeed(value) : Effect.fail(new CredentialsJsonError({ message: `credentials.json: field "${field}" must be a non-empty string.` }));
3400
- const parseIosDistributionCertificate = (raw) => Effect.gen(function* () {
3401
- const record = asRecord(raw);
3402
- if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: ios.distributionCertificate must be an object." });
3403
- return {
3404
- path: yield* asString$2(record["path"], "ios.distributionCertificate.path"),
3405
- password: yield* asString$2(record["password"], "ios.distributionCertificate.password")
3406
- };
3405
+ //#region src/lib/apple-asc-client.ts
3406
+ var AscApiError = class extends Data.TaggedError("AscApiError") {};
3407
+ var AscNetworkError = class extends Data.TaggedError("AscNetworkError") {};
3408
+ const API_BASE = "https://api.appstoreconnect.apple.com";
3409
+ const extractErrors = (body) => {
3410
+ if (!isRecord(body) || !Array.isArray(body["errors"])) return [];
3411
+ return body["errors"].filter((value) => isRecord(value));
3412
+ };
3413
+ const parseApiError = (response, body, raw) => {
3414
+ const [first] = extractErrors(body);
3415
+ return new AscApiError({
3416
+ status: response.status,
3417
+ message: first?.detail ?? first?.title ?? response.statusText,
3418
+ code: first?.code,
3419
+ raw
3420
+ });
3421
+ };
3422
+ const fetchRaw = (jwt, path, init) => Effect.gen(function* () {
3423
+ const response = yield* Effect.tryPromise({
3424
+ try: async () => fetch(`${API_BASE}${path}`, {
3425
+ method: init?.method ?? "GET",
3426
+ ...init?.body === void 0 ? {} : { body: init.body },
3427
+ headers: {
3428
+ authorization: `Bearer ${jwt}`,
3429
+ "content-type": "application/json",
3430
+ accept: "application/json"
3431
+ }
3432
+ }),
3433
+ catch: (cause) => new AscNetworkError({ cause })
3434
+ });
3435
+ const text = yield* Effect.tryPromise({
3436
+ try: async () => response.text(),
3437
+ catch: (cause) => new AscNetworkError({ cause })
3438
+ });
3439
+ const body = text.length === 0 ? {} : JSON.parse(text);
3440
+ if (!response.ok) return yield* Effect.fail(parseApiError(response, body, text));
3441
+ return body;
3407
3442
  });
3408
- const parseIosPushKey = (raw) => Effect.gen(function* () {
3409
- const record = asRecord(raw);
3410
- if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: ios.pushKey must be an object." });
3443
+ const toAscCertificate = (value) => {
3444
+ if (!isRecord(value)) return null;
3445
+ const { id, attributes } = value;
3446
+ if (typeof id !== "string" || !isRecord(attributes)) return null;
3447
+ const { serialNumber, certificateType, expirationDate, certificateContent, displayName } = attributes;
3448
+ if (typeof serialNumber !== "string" || typeof certificateType !== "string" || typeof expirationDate !== "string") return null;
3411
3449
  return {
3412
- path: yield* asString$2(record["path"], "ios.pushKey.path"),
3413
- keyId: yield* asString$2(record["keyId"], "ios.pushKey.keyId"),
3414
- teamId: yield* asString$2(record["teamId"], "ios.pushKey.teamId")
3450
+ id,
3451
+ serialNumber,
3452
+ certificateType,
3453
+ expirationDate,
3454
+ certificateContent: typeof certificateContent === "string" ? certificateContent : null,
3455
+ displayName: typeof displayName === "string" ? displayName : null
3415
3456
  };
3416
- });
3417
- const parseIosAscApiKey = (raw) => Effect.gen(function* () {
3418
- const record = asRecord(raw);
3419
- if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: ios.ascApiKey must be an object." });
3457
+ };
3458
+ const toAscBundleId = (value) => {
3459
+ if (!isRecord(value)) return null;
3460
+ const { id, attributes } = value;
3461
+ if (typeof id !== "string" || !isRecord(attributes)) return null;
3462
+ const { identifier, name } = attributes;
3463
+ if (typeof identifier !== "string" || typeof name !== "string") return null;
3420
3464
  return {
3421
- path: yield* asString$2(record["path"], "ios.ascApiKey.path"),
3422
- keyId: yield* asString$2(record["keyId"], "ios.ascApiKey.keyId"),
3423
- issuerId: yield* asString$2(record["issuerId"], "ios.ascApiKey.issuerId")
3465
+ id,
3466
+ identifier,
3467
+ name
3424
3468
  };
3425
- });
3426
- const parseIos = (raw) => Effect.gen(function* () {
3427
- const record = asRecord(raw);
3428
- if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: \"ios\" must be an object." });
3429
- const provisioningProfilePath = yield* asString$2(record["provisioningProfilePath"], "ios.provisioningProfilePath");
3430
- const distributionCertificate = yield* parseIosDistributionCertificate(record["distributionCertificate"]);
3431
- const pushKey = record["pushKey"] === void 0 ? void 0 : yield* parseIosPushKey(record["pushKey"]);
3432
- const ascApiKey = record["ascApiKey"] === void 0 ? void 0 : yield* parseIosAscApiKey(record["ascApiKey"]);
3469
+ };
3470
+ const PROFILE_TYPES = [
3471
+ "IOS_APP_ADHOC",
3472
+ "IOS_APP_DEVELOPMENT",
3473
+ "IOS_APP_STORE",
3474
+ "IOS_APP_INHOUSE"
3475
+ ];
3476
+ const asProfileType = (value) => {
3477
+ const match = PROFILE_TYPES.find((entry) => entry === value);
3478
+ return match === void 0 ? null : match;
3479
+ };
3480
+ const toAscProfile = (value) => {
3481
+ if (!isRecord(value)) return null;
3482
+ const { id, attributes } = value;
3483
+ if (typeof id !== "string" || !isRecord(attributes)) return null;
3484
+ const { name, uuid, expirationDate, profileContent } = attributes;
3485
+ const profileType = asProfileType(attributes["profileType"]);
3486
+ if (typeof name !== "string" || typeof uuid !== "string" || typeof expirationDate !== "string" || typeof profileContent !== "string" || profileType === null) return null;
3433
3487
  return {
3434
- provisioningProfilePath,
3435
- distributionCertificate,
3436
- ...pushKey === void 0 ? {} : { pushKey },
3437
- ...ascApiKey === void 0 ? {} : { ascApiKey }
3488
+ id,
3489
+ name,
3490
+ uuid,
3491
+ expirationDate,
3492
+ profileContent,
3493
+ profileType
3438
3494
  };
3439
- });
3440
- const parseAndroidKeystore = (raw) => Effect.gen(function* () {
3441
- const record = asRecord(raw);
3442
- if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: android.keystore must be an object." });
3495
+ };
3496
+ const toAscDevice = (value) => {
3497
+ if (!isRecord(value)) return null;
3498
+ const { id, attributes } = value;
3499
+ if (typeof id !== "string" || !isRecord(attributes)) return null;
3500
+ const { udid, name } = attributes;
3501
+ if (typeof udid !== "string" || typeof name !== "string") return null;
3443
3502
  return {
3444
- keystorePath: yield* asString$2(record["keystorePath"], "android.keystore.keystorePath"),
3445
- keystorePassword: yield* asString$2(record["keystorePassword"], "android.keystore.keystorePassword"),
3446
- keyAlias: yield* asString$2(record["keyAlias"], "android.keystore.keyAlias"),
3447
- keyPassword: yield* asString$2(record["keyPassword"], "android.keystore.keyPassword")
3503
+ id,
3504
+ udid,
3505
+ name
3448
3506
  };
3507
+ };
3508
+ const extractList = (body, map) => {
3509
+ if (!isRecord(body) || !Array.isArray(body["data"])) return [];
3510
+ return body["data"].map(map).filter((value) => value !== null);
3511
+ };
3512
+ const extractSingle = (body, map) => {
3513
+ if (!isRecord(body)) return null;
3514
+ return map(body["data"]);
3515
+ };
3516
+ const malformed = (resource) => new AscApiError({
3517
+ status: 500,
3518
+ message: `Malformed ${resource} response`,
3519
+ code: void 0,
3520
+ raw: ""
3449
3521
  });
3450
- const parseGoogleServiceAccountKey = (raw) => Effect.gen(function* () {
3451
- const record = asRecord(raw);
3452
- if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: android.googleServiceAccountKey must be an object." });
3453
- return { path: yield* asString$2(record["path"], "android.googleServiceAccountKey.path") };
3522
+ const withJwt = (credentials, fn) => Effect.gen(function* () {
3523
+ return yield* fn(yield* signAscJwt(credentials));
3454
3524
  });
3455
- const parseAndroid = (raw) => Effect.gen(function* () {
3456
- const record = asRecord(raw);
3457
- if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: \"android\" must be an object." });
3458
- const keystore = yield* parseAndroidKeystore(record["keystore"]);
3459
- const googleServiceAccountKey = record["googleServiceAccountKey"] === void 0 ? void 0 : yield* parseGoogleServiceAccountKey(record["googleServiceAccountKey"]);
3460
- return {
3461
- keystore,
3462
- ...googleServiceAccountKey === void 0 ? {} : { googleServiceAccountKey }
3525
+ const listCertificates = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
3526
+ return extractList(yield* fetchRaw(jwt, `/v1/certificates${params?.certificateType ? `?filter[certificateType]=${params.certificateType}&limit=200` : "?limit=200"}`), toAscCertificate);
3527
+ }));
3528
+ const createCertificate = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
3529
+ const csrContent = params.csrPem.replaceAll("-----BEGIN CERTIFICATE REQUEST-----", "").replaceAll("-----END CERTIFICATE REQUEST-----", "").replaceAll(/\s+/gu, "");
3530
+ const resource = extractSingle(yield* fetchRaw(jwt, "/v1/certificates", {
3531
+ method: "POST",
3532
+ body: JSON.stringify({ data: {
3533
+ type: "certificates",
3534
+ attributes: {
3535
+ csrContent,
3536
+ certificateType: params.certificateType
3537
+ }
3538
+ } })
3539
+ }), toAscCertificate);
3540
+ if (resource === null) return yield* Effect.fail(malformed("certificate"));
3541
+ return resource;
3542
+ }));
3543
+ const deleteCertificate = (credentials, id) => withJwt(credentials, (jwt) => Effect.gen(function* () {
3544
+ yield* fetchRaw(jwt, `/v1/certificates/${encodeURIComponent(id)}`, { method: "DELETE" });
3545
+ }));
3546
+ const listBundleIds = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
3547
+ return extractList(yield* fetchRaw(jwt, "/v1/bundleIds?limit=200"), toAscBundleId);
3548
+ }));
3549
+ const createBundleId = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
3550
+ const resource = extractSingle(yield* fetchRaw(jwt, "/v1/bundleIds", {
3551
+ method: "POST",
3552
+ body: JSON.stringify({ data: {
3553
+ type: "bundleIds",
3554
+ attributes: {
3555
+ identifier: params.identifier,
3556
+ name: params.name,
3557
+ platform: "IOS"
3558
+ }
3559
+ } })
3560
+ }), toAscBundleId);
3561
+ if (resource === null) return yield* Effect.fail(malformed("bundleId"));
3562
+ return resource;
3563
+ }));
3564
+ const listDevices = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
3565
+ return extractList(yield* fetchRaw(jwt, "/v1/devices?limit=200"), toAscDevice);
3566
+ }));
3567
+ const createProvisioningProfile = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
3568
+ const relationships = {
3569
+ bundleId: { data: {
3570
+ type: "bundleIds",
3571
+ id: params.bundleIdAscId
3572
+ } },
3573
+ certificates: { data: params.certificateAscIds.map((id) => ({
3574
+ type: "certificates",
3575
+ id
3576
+ })) },
3577
+ ...params.deviceAscIds.length > 0 ? { devices: { data: params.deviceAscIds.map((id) => ({
3578
+ type: "devices",
3579
+ id
3580
+ })) } } : {}
3463
3581
  };
3464
- });
3465
- const parseCredentialsJson = (raw) => Effect.gen(function* () {
3466
- const root = asRecord(yield* Effect.try({
3467
- try: () => JSON.parse(raw),
3468
- catch: () => new CredentialsJsonError({ message: "credentials.json is not valid JSON." })
3469
- }));
3470
- if (!root) return yield* new CredentialsJsonError({ message: "credentials.json must be a JSON object at the top level." });
3471
- const ios = root["ios"] === void 0 ? void 0 : yield* parseIos(root["ios"]);
3472
- const android = root["android"] === void 0 ? void 0 : yield* parseAndroid(root["android"]);
3473
- if (!ios && !android) return yield* new CredentialsJsonError({ message: "credentials.json must contain at least one of \"ios\" or \"android\"." });
3582
+ const resource = extractSingle(yield* fetchRaw(jwt, "/v1/profiles", {
3583
+ method: "POST",
3584
+ body: JSON.stringify({ data: {
3585
+ type: "profiles",
3586
+ attributes: {
3587
+ name: params.profileName,
3588
+ profileType: params.profileType
3589
+ },
3590
+ relationships
3591
+ } })
3592
+ }), toAscProfile);
3593
+ if (resource === null) return yield* Effect.fail(malformed("profile"));
3594
+ return resource;
3595
+ }));
3596
+ const isCertificateLimitError = (error) => {
3597
+ if (error._tag !== "AscApiError") return false;
3598
+ return /already have a current.*certificate|pending certificate request/iu.test(error.message);
3599
+ };
3600
+
3601
+ //#endregion
3602
+ //#region src/lib/apple-cert-to-p12.ts
3603
+ var CertParseError = class extends Data.TaggedError("CertParseError") {};
3604
+ const APPLE_TEAM_ID_RE$1 = /^[A-Z0-9]{10}$/u;
3605
+ const stringField = (cert, name) => {
3606
+ const value = cert.subject.getField(name)?.value;
3607
+ return typeof value === "string" ? value : null;
3608
+ };
3609
+ const matchTeamFromCommonName = (cn) => {
3610
+ const match = /\(([A-Z0-9]{10})\)/u.exec(cn);
3611
+ if (match === null) return null;
3612
+ const [, captured] = match;
3613
+ return captured === void 0 ? null : captured;
3614
+ };
3615
+ const extractTeamId$1 = (cert) => {
3616
+ const ou = stringField(cert, "OU");
3617
+ if (ou !== null && APPLE_TEAM_ID_RE$1.test(ou)) return ou;
3618
+ const cn = stringField(cert, "CN");
3619
+ if (cn === null) return null;
3620
+ return matchTeamFromCommonName(cn);
3621
+ };
3622
+ const parseCert = (certDerBytes) => {
3623
+ const asn1 = forge.asn1.fromDer(certDerBytes);
3624
+ return forge.pki.certificateFromAsn1(asn1);
3625
+ };
3626
+ const generatePassword = () => forge.util.encode64(forge.random.getBytesSync(16));
3627
+ const extractCertMetadata = (cert) => Effect.gen(function* () {
3628
+ const appleTeamId = extractTeamId$1(cert);
3629
+ if (appleTeamId === null) return yield* Effect.fail(new CertParseError({ message: "Could not extract Apple team identifier from certificate subject" }));
3474
3630
  return {
3475
- ...ios === void 0 ? {} : { ios },
3476
- ...android === void 0 ? {} : { android }
3631
+ serialNumber: cert.serialNumber.toUpperCase(),
3632
+ validFrom: cert.validity.notBefore.toISOString(),
3633
+ validUntil: cert.validity.notAfter.toISOString(),
3634
+ appleTeamId,
3635
+ appleTeamName: stringField(cert, "O"),
3636
+ developerIdIdentifier: stringField(cert, "UID"),
3637
+ commonName: stringField(cert, "CN")
3477
3638
  };
3478
3639
  });
3479
- const credentialsJsonPath = (projectRoot) => path.join(projectRoot, CREDENTIALS_JSON_FILENAME);
3480
- const readCredentialsJson = (projectRoot) => Effect.gen(function* () {
3481
- const fs = yield* FileSystem.FileSystem;
3482
- const filePath = credentialsJsonPath(projectRoot);
3483
- if (!(yield* fs.exists(filePath).pipe(Effect.catchAll(() => Effect.succeed(false))))) return yield* new CredentialsJsonError({ message: `credentials.json not found at ${filePath}.` });
3484
- return yield* parseCredentialsJson(yield* fs.readFileString(filePath).pipe(Effect.mapError((cause) => new CredentialsJsonError({ message: `Failed to read credentials.json: ${String(cause)}` }))));
3485
- });
3486
- const writeCredentialsJson = (projectRoot, data) => Effect.gen(function* () {
3487
- const fs = yield* FileSystem.FileSystem;
3488
- const filePath = credentialsJsonPath(projectRoot);
3489
- const body = `${JSON.stringify(data, null, 2)}\n`;
3490
- yield* fs.writeFileString(filePath, body).pipe(Effect.mapError((cause) => new CredentialsJsonError({ message: `Failed to write credentials.json: ${String(cause)}` })));
3491
- return filePath;
3492
- });
3493
3640
  /**
3494
- * Resolve a path that may be either absolute or relative to the project root.
3641
+ * Parse a PKCS#12 base64 bundle and extract certificate metadata. Used by the
3642
+ * Apple-ID flow which receives a P12 directly from `createCertificateAndP12Async`
3643
+ * and needs metadata before uploading to the better-update server.
3495
3644
  */
3496
- const resolveCredentialPath = (projectRoot, candidate) => path.isAbsolute(candidate) ? candidate : path.join(projectRoot, candidate);
3497
-
3498
- //#endregion
3499
- //#region src/lib/local-credentials.ts
3500
- const requirePath = (fs, absolutePath, label) => fs.exists(absolutePath).pipe(Effect.catchAll(() => Effect.succeed(false)), Effect.flatMap((exists) => exists ? Effect.void : Effect.fail(new MissingCredentialsError({
3501
- message: `Local credentials.json: ${label} not found at ${absolutePath}.`,
3502
- hint: "Run `better-update credentials sync pull` to materialize files, or fix the path in credentials.json."
3503
- }))));
3504
- const loadLocalIosCredentials = (options) => Effect.gen(function* () {
3505
- const fs = yield* FileSystem.FileSystem;
3506
- const data = yield* readCredentialsJson(options.projectRoot).pipe(Effect.mapError((cause) => new MissingCredentialsError({
3507
- message: `Local credentials.json: ${cause.message}`,
3508
- hint: "Create credentials.json or switch the build profile's credentialsSource back to \"remote\"."
3509
- })));
3510
- if (!data.ios) return yield* new MissingCredentialsError({
3511
- message: "credentials.json has no `ios` section but the build is for iOS.",
3512
- hint: "Add an `ios` block to credentials.json or switch credentialsSource to remote."
3645
+ const extractMetadataFromP12 = (params) => Effect.gen(function* () {
3646
+ const certBagOid = forge.pki.oids["certBag"];
3647
+ if (certBagOid === void 0) return yield* Effect.fail(new CertParseError({ message: "PKCS#12 OID lookup for certBag failed" }));
3648
+ const [first] = yield* Effect.try({
3649
+ try: () => {
3650
+ const p12Der = forge.util.decode64(params.p12Base64);
3651
+ const p12Asn1 = forge.asn1.fromDer(p12Der);
3652
+ return forge.pkcs12.pkcs12FromAsn1(p12Asn1, false, params.password).getBags({ bagType: certBagOid })[certBagOid] ?? [];
3653
+ },
3654
+ catch: (error) => new CertParseError({ message: `Failed to parse PKCS#12 bundle: ${error instanceof Error ? error.message : String(error)}` })
3513
3655
  });
3514
- const p12Path = resolveCredentialPath(options.projectRoot, data.ios.distributionCertificate.path);
3515
- const profilePath = resolveCredentialPath(options.projectRoot, data.ios.provisioningProfilePath);
3516
- yield* requirePath(fs, p12Path, "distribution certificate (.p12)");
3517
- yield* requirePath(fs, profilePath, "provisioning profile (.mobileprovision)");
3518
- return {
3519
- p12Path,
3520
- p12Password: data.ios.distributionCertificate.password,
3521
- profilePath,
3522
- profileFilename: path.basename(profilePath),
3523
- teamId: ""
3524
- };
3656
+ if (first?.cert === void 0) return yield* Effect.fail(new CertParseError({ message: "PKCS#12 bundle does not contain a certificate" }));
3657
+ return yield* extractCertMetadata(first.cert);
3525
3658
  });
3526
- const loadLocalAndroidCredentials = (options) => Effect.gen(function* () {
3527
- const fs = yield* FileSystem.FileSystem;
3528
- const data = yield* readCredentialsJson(options.projectRoot).pipe(Effect.mapError((cause) => new MissingCredentialsError({
3529
- message: `Local credentials.json: ${cause.message}`,
3530
- hint: "Create credentials.json or switch the build profile's credentialsSource back to \"remote\"."
3531
- })));
3532
- if (!data.android) return yield* new MissingCredentialsError({
3533
- message: "credentials.json has no `android` section but the build is for Android.",
3534
- hint: "Add an `android` block to credentials.json or switch credentialsSource to remote."
3659
+ const buildDistributionCertP12 = (params) => Effect.gen(function* () {
3660
+ const result = yield* Effect.try({
3661
+ try: () => {
3662
+ const cert = parseCert(forge.util.decode64(params.certificateContentBase64));
3663
+ const password = generatePassword();
3664
+ const p12Asn1 = forge.pkcs12.toPkcs12Asn1(params.privateKey, [cert], password, {
3665
+ friendlyName: "key",
3666
+ algorithm: "3des"
3667
+ });
3668
+ return {
3669
+ cert,
3670
+ p12Base64: forge.util.encode64(forge.asn1.toDer(p12Asn1).getBytes()),
3671
+ password
3672
+ };
3673
+ },
3674
+ catch: (error) => new CertParseError({ message: `Failed to assemble .p12: ${error instanceof Error ? error.message : String(error)}` })
3535
3675
  });
3536
- const keystorePath = resolveCredentialPath(options.projectRoot, data.android.keystore.keystorePath);
3537
- yield* requirePath(fs, keystorePath, "keystore");
3676
+ const metadata = yield* extractCertMetadata(result.cert);
3538
3677
  return {
3539
- keystorePath,
3540
- storePassword: data.android.keystore.keystorePassword,
3541
- keyAlias: data.android.keystore.keyAlias,
3542
- keyPassword: data.android.keystore.keyPassword
3543
- };
3544
- });
3678
+ p12Base64: result.p12Base64,
3679
+ password: result.password,
3680
+ metadata
3681
+ };
3682
+ });
3545
3683
 
3546
3684
  //#endregion
3547
- //#region src/lib/sha256.ts
3548
- const hashReadError = (message) => new BuildFailedError({
3549
- step: "sha256",
3550
- exitCode: 1,
3551
- message
3552
- });
3553
- const hashFile = (path, formatDigest) => Effect.async((resume) => {
3554
- const hash = createHash("sha256");
3555
- const stream = createReadStream(path);
3556
- let byteSize = 0;
3557
- stream.on("data", (chunk) => {
3558
- const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
3559
- byteSize += buffer.byteLength;
3560
- hash.update(buffer);
3561
- });
3562
- stream.on("error", (error) => {
3563
- resume(Effect.fail(hashReadError(`Failed to read file for SHA-256: ${error.message}`)));
3564
- });
3565
- stream.on("end", () => {
3566
- resume(Effect.succeed({
3567
- digest: formatDigest(hash.digest()),
3568
- byteSize
3569
- }));
3685
+ //#region src/lib/apple-csr.ts
3686
+ const generateRsaKeyPair = async () => new Promise((resolve) => {
3687
+ forge.pki.rsa.generateKeyPair({
3688
+ bits: 2048,
3689
+ workers: 2
3690
+ }, (_err, keyPair) => {
3691
+ resolve(keyPair);
3570
3692
  });
3571
3693
  });
3572
- /**
3573
- * Compute the SHA-256 digest and byte size of a file using Node's streaming
3574
- * hash API. The file is never fully loaded into memory — chunks flow through
3575
- * `createReadStream` into `crypto.createHash("sha256")`.
3576
- */
3577
- const sha256File = (path) => hashFile(path, (digest) => digest.toString("hex")).pipe(Effect.map(({ digest, byteSize }) => ({
3578
- sha256: digest,
3579
- byteSize
3580
- })));
3581
- /**
3582
- * Compute a content-type-namespaced hash: `SHA-256(contentType + '\0' + SHA-256_hex(fileBytes))`.
3583
- *
3584
- * This prevents hash collisions when identical bytes are served with different
3585
- * MIME types (e.g. same file used as both `application/javascript` and `text/plain`).
3586
- * The raw content hash is still needed separately for R2 upload verification.
3587
- */
3588
- const sha256Namespaced = (contentType, contentSha256Hex) => {
3589
- const input = `${contentType}\0${contentSha256Hex}`;
3590
- return toBase64Url(createHash("sha256").update(input).digest());
3694
+ const generateCertificateSigningRequest = async () => {
3695
+ const keyPair = await generateRsaKeyPair();
3696
+ const csr = forge.pki.createCertificationRequest();
3697
+ csr.publicKey = keyPair.publicKey;
3698
+ csr.setSubject([{
3699
+ name: "commonName",
3700
+ shortName: "CN",
3701
+ value: "PEM"
3702
+ }]);
3703
+ csr.sign(keyPair.privateKey, forge.md.sha1.create());
3704
+ return {
3705
+ csrPem: forge.pki.certificationRequestToPem(csr),
3706
+ privateKeyPem: forge.pki.privateKeyToPem(keyPair.privateKey),
3707
+ privateKey: keyPair.privateKey
3708
+ };
3591
3709
  };
3592
3710
 
3593
3711
  //#endregion
3594
- //#region src/commands/build/run-step.ts
3595
- const runStep = (cmd, step) => Command.exitCode(cmd.pipe(Command.stdout("inherit"), Command.stderr("inherit"))).pipe(Effect.mapError((cause) => new BuildFailedError({
3596
- step,
3597
- exitCode: 1,
3598
- message: `${step} failed to spawn: ${String(cause)}`
3599
- })), Effect.flatMap((code) => code === 0 ? Effect.void : Effect.fail(new BuildFailedError({
3600
- step,
3601
- exitCode: code,
3602
- message: `${step} exited with code ${code}`
3603
- }))));
3712
+ //#region src/lib/temp-dir.ts
3604
3713
  /**
3605
- * Run a build step with stdout piped through a formatter (e.g., xcpretty).
3606
- * stderr passes through to the terminal directly.
3714
+ * Create a scoped temp directory prefixed with "better-update-" and `chmod 0o700`
3715
+ * it so only the current user can read its contents. The directory and all files
3716
+ * inside it are removed when the enclosing scope closes.
3607
3717
  */
3608
- const runStepFormatted = (cmd, step, formatter) => Effect.gen(function* () {
3609
- const proc = yield* Command.start(cmd.pipe(Command.stdout("pipe"), Command.stderr("pipe"))).pipe(Effect.mapError((cause) => new BuildFailedError({
3610
- step,
3611
- exitCode: 1,
3612
- message: `${step} failed to spawn: ${String(cause)}`
3613
- })));
3614
- const stdoutFiber = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.runForEach((line) => {
3615
- const formatted = formatter.pipe(line);
3616
- return formatted.length > 0 ? Effect.sync(() => {
3617
- for (const output of formatted) process$1.stdout.write(`${output}\n`);
3618
- }) : Effect.void;
3619
- }), Effect.mapError((cause) => new BuildFailedError({
3620
- step,
3621
- exitCode: 1,
3622
- message: `${step} stdout stream error: ${String(cause)}`
3623
- })), Effect.fork);
3624
- const stderrFiber = yield* proc.stderr.pipe(Stream.decodeText(), Stream.splitLines, Stream.runForEach((line) => Effect.sync(() => process$1.stderr.write(`${line}\n`))), Effect.mapError((cause) => new BuildFailedError({
3625
- step,
3626
- exitCode: 1,
3627
- message: `${step} stderr stream error: ${String(cause)}`
3628
- })), Effect.fork);
3629
- yield* Effect.all([Fiber.join(stdoutFiber), Fiber.join(stderrFiber)], { concurrency: 2 }).pipe(Effect.catchAll(() => Effect.void));
3630
- const code = yield* proc.exitCode.pipe(Effect.mapError((cause) => new BuildFailedError({
3631
- step,
3632
- exitCode: 1,
3633
- message: `${step} exit code error: ${String(cause)}`
3634
- })));
3635
- if (code !== 0) {
3636
- const summary = formatter.getBuildSummary();
3637
- if (summary) process$1.stderr.write(`${summary}\n`);
3638
- return yield* new BuildFailedError({
3639
- step,
3640
- exitCode: code,
3641
- message: `${step} exited with code ${code}`
3642
- });
3643
- }
3718
+ const acquireBuildTempDir = Effect.gen(function* () {
3719
+ const fs = yield* FileSystem.FileSystem;
3720
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "better-update-" });
3721
+ yield* fs.chmod(dir, 448);
3722
+ return dir;
3644
3723
  });
3645
3724
 
3646
3725
  //#endregion
3647
- //#region src/commands/build/android.ts
3648
- /**
3649
- * Compose the Gradle task name from flavor, format, and buildType.
3650
- *
3651
- * Gradle naming convention: `<verb><Flavor><Variant>`, e.g.
3652
- * - no flavor + apk + release → `assembleRelease`
3653
- * - no flavor + aab + release → `bundleRelease`
3654
- * - flavor=prod + aab + release → `bundleProdRelease`
3655
- * - flavor=prod + apk + debug → `assembleProdDebug`
3656
- */
3657
- const gradleTaskName = (format, flavor, buildType) => {
3658
- const verb = format === "aab" ? "bundle" : "assemble";
3659
- return flavor ? `${verb}${capitalize(flavor)}${capitalize(buildType)}` : `${verb}${capitalize(buildType)}`;
3726
+ //#region src/lib/credentials-generator.ts
3727
+ const DISTRIBUTION_TO_PROFILE_TYPE$1 = {
3728
+ APP_STORE: "IOS_APP_STORE",
3729
+ AD_HOC: "IOS_APP_ADHOC",
3730
+ DEVELOPMENT: "IOS_APP_DEVELOPMENT",
3731
+ ENTERPRISE: "IOS_APP_INHOUSE"
3660
3732
  };
3661
- const runAndroidBuild = (input) => Effect.gen(function* () {
3662
- const { api, tempDir, projectRoot, androidProfile, applicationIdentifier, envVars, projectId } = input;
3663
- const runtime = yield* CliRuntime;
3664
- const buildStartMs = Date.now();
3665
- const { format } = androidProfile;
3666
- const { flavor } = androidProfile;
3667
- const buildType = androidProfile.buildType ?? "release";
3668
- const androidDir = path.join(projectRoot, "android");
3669
- const commandEnv = yield* runtime.commandEnvironment(envVars);
3670
- const credentials = input.credentialsSource === "local" ? yield* loadLocalAndroidCredentials({ projectRoot }) : yield* downloadAndroidCredentials(api, {
3671
- projectId,
3672
- applicationIdentifier,
3673
- tempDir
3733
+ const computeDeviceRosterHashHex = (ascDeviceIds) => {
3734
+ const sorted = [...ascDeviceIds].toSorted();
3735
+ return createHash("sha256").update(sorted.join(","), "utf8").digest("hex");
3736
+ };
3737
+ var CertificateLimitError = class extends Data.TaggedError("CertificateLimitError") {};
3738
+ var GenerateFailedError = class extends Data.TaggedError("GenerateFailedError") {};
3739
+ const messageForAscCause = (cause) => {
3740
+ if (cause._tag === "AscApiError") return cause.message;
3741
+ if (cause._tag === "AppleAuthError") return "Apple JWT signing failed";
3742
+ return "Network error talking to Apple";
3743
+ };
3744
+ const wrapAscError = (step) => (cause) => {
3745
+ if (cause._tag === "AscApiError" && isCertificateLimitError(cause)) return new CertificateLimitError({ message: cause.message });
3746
+ return new GenerateFailedError({
3747
+ step,
3748
+ message: messageForAscCause(cause)
3674
3749
  });
3675
- yield* runStep(Command.make("bunx", "expo", "prebuild", "--platform", "android", "--clean").pipe(Command.workingDirectory(projectRoot), Command.env(commandEnv)), "expo prebuild android");
3750
+ };
3751
+ const generateAndUploadKeystore = (api, input) => Effect.scoped(Effect.gen(function* () {
3676
3752
  const fs = yield* FileSystem.FileSystem;
3677
- const signingGradlePath = path.join(tempDir, "signing.gradle");
3678
- yield* fs.writeFileString(signingGradlePath, renderSigningGradle({
3679
- keystorePath: credentials.keystorePath,
3680
- storePassword: credentials.storePassword,
3681
- keyAlias: credentials.keyAlias,
3682
- keyPassword: credentials.keyPassword
3683
- }));
3684
- const taskName = gradleTaskName(format, flavor, buildType);
3685
- yield* runStep(Command.make("./gradlew", "--init-script", signingGradlePath, `:app:${taskName}`).pipe(Command.workingDirectory(androidDir), Command.env(commandEnv)), "gradlew");
3686
- const artifactPath = yield* findAndroidArtifact({
3687
- projectRoot,
3688
- format,
3689
- ...flavor === void 0 ? {} : { flavor },
3690
- buildType,
3691
- minMtimeMs: buildStartMs
3753
+ const tempDir = yield* acquireBuildTempDir;
3754
+ const keystorePath = path.join(tempDir, "release.keystore");
3755
+ yield* generateAndroidKeystore({
3756
+ outputPath: keystorePath,
3757
+ keyAlias: input.keyAlias,
3758
+ storePassword: input.storePassword,
3759
+ keyPassword: input.keyPassword,
3760
+ commonName: input.commonName,
3761
+ organization: input.organization,
3762
+ ...input.validityDays === void 0 ? {} : { validityDays: input.validityDays }
3692
3763
  });
3693
- const { sha256, byteSize } = yield* sha256File(artifactPath);
3764
+ const bytes = yield* fs.readFile(keystorePath);
3765
+ const created = yield* api.androidUploadKeystores.upload({ payload: {
3766
+ keystoreBase64: toBase64(bytes),
3767
+ keyAlias: input.keyAlias,
3768
+ keystorePassword: input.storePassword,
3769
+ keyPassword: input.keyPassword
3770
+ } });
3694
3771
  return {
3695
- artifactPath,
3696
- byteSize,
3697
- sha256
3772
+ id: created.id,
3773
+ keyAlias: created.keyAlias
3698
3774
  };
3699
- });
3700
-
3701
- //#endregion
3702
- //#region src/lib/ios-export-options.ts
3703
- const escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
3704
- const boolTag = (value) => value ? "<true/>" : "<false/>";
3705
- /**
3706
- * Render an Xcode `ExportOptions.plist` for `xcodebuild -exportArchive`.
3707
- *
3708
- * - `signingStyle` is always `manual` (ephemeral keychain + downloaded profile)
3709
- * - `uploadSymbols` is emitted only for `app-store` exports
3710
- * - `provisioningProfiles` dict maps bundleId → profile name
3711
- * - `compileBitcode` defaults to `false`
3712
- */
3713
- const renderExportOptionsPlist = ({ method, teamId, bundleId, provisioningProfileName, compileBitcode = false }) => {
3714
- const lines = [
3715
- "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
3716
- "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
3717
- "<plist version=\"1.0\">",
3718
- "<dict>",
3719
- " <key>method</key>",
3720
- `\t<string>${escapeXml(method)}</string>`,
3721
- " <key>teamID</key>",
3722
- `\t<string>${escapeXml(teamId)}</string>`,
3723
- " <key>signingStyle</key>",
3724
- " <string>manual</string>",
3725
- " <key>compileBitcode</key>",
3726
- `\t${boolTag(compileBitcode)}`,
3727
- " <key>provisioningProfiles</key>",
3728
- " <dict>",
3729
- `\t\t<key>${escapeXml(bundleId)}</key>`,
3730
- `\t\t<string>${escapeXml(provisioningProfileName)}</string>`,
3731
- " </dict>"
3732
- ];
3733
- if (method === "app-store") lines.push(" <key>uploadSymbols</key>", " <true/>");
3734
- lines.push("</dict>", "</plist>", "");
3735
- return lines.join("\n");
3736
- };
3737
-
3738
- //#endregion
3739
- //#region src/lib/ios-keychain.ts
3740
- const runOrFail = (cmd, step) => Command.string(cmd).pipe(Effect.mapError((cause) => new KeychainError({ message: `keychain ${step} failed: ${String(cause)}` })));
3741
- const listCurrentKeychains = Effect.gen(function* () {
3742
- return (yield* runOrFail(Command.make("security", "list-keychains", "-d", "user"), "list-keychains")).split("\n").map((line) => line.trim().replace(/^"/u, "").replace(/"$/u, "")).filter((line) => line.length > 0);
3743
- });
3744
- const parseSigningIdentity = (output) => {
3745
- const lines = output.split("\n");
3746
- for (const line of lines) {
3747
- const match = /"([^"]+)"/u.exec(line);
3748
- if (match?.[1]) return match[1];
3749
- }
3750
- };
3751
- /**
3752
- * Acquire an ephemeral macOS keychain, import a `.p12` into it, add it to the
3753
- * user search list, and tear it all down on scope close. The keychain name is
3754
- * namespaced as `better-update-<uuid>` and lives in `$tempDir`, so cleanup is
3755
- * guaranteed under all termination paths.
3756
- */
3757
- const acquireKeychain = ({ tempDir, p12Path, p12Password }) => {
3758
- const keychainName = `better-update-${randomUUID()}.keychain-db`;
3759
- const keychainPath = path.join(tempDir, keychainName);
3760
- const keychainPassword = randomBytes(32).toString("hex");
3761
- return Effect.acquireRelease(Effect.gen(function* () {
3762
- const priorKeychains = yield* listCurrentKeychains;
3763
- yield* runOrFail(Command.make("security", "create-keychain", "-p", keychainPassword, keychainPath), "create-keychain");
3764
- yield* runOrFail(Command.make("security", "unlock-keychain", "-p", keychainPassword, keychainPath), "unlock-keychain");
3765
- yield* runOrFail(Command.make("security", "set-keychain-settings", "-t", "3600", "-l", keychainPath), "set-keychain-settings");
3766
- yield* runOrFail(Command.make("security", "import", p12Path, "-k", keychainPath, "-P", p12Password, "-T", "/usr/bin/codesign"), "import");
3767
- yield* runOrFail(Command.make("security", "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", keychainPassword, keychainPath), "set-key-partition-list");
3768
- yield* runOrFail(Command.make("security", "list-keychains", "-d", "user", "-s", keychainPath, ...priorKeychains), "list-keychains -s (add)");
3769
- const signingIdentity = parseSigningIdentity(yield* runOrFail(Command.make("security", "find-identity", "-v", "-p", "codesigning", keychainPath), "find-identity"));
3770
- if (!signingIdentity) return yield* new KeychainError({ message: "No code signing identity found after importing .p12 into ephemeral keychain." });
3771
- return {
3772
- handle: {
3773
- keychainName,
3774
- keychainPath,
3775
- signingIdentity
3776
- },
3777
- priorKeychains
3778
- };
3779
- }), ({ priorKeychains }) => Effect.gen(function* () {
3780
- yield* Command.string(Command.make("security", "list-keychains", "-d", "user", "-s", ...priorKeychains)).pipe(Effect.catchAll(() => Effect.void));
3781
- yield* Command.string(Command.make("security", "delete-keychain", keychainPath)).pipe(Effect.catchAll(() => Effect.void));
3782
- })).pipe(Effect.map(({ handle }) => handle));
3783
- };
3784
-
3785
- //#endregion
3786
- //#region src/lib/plist.ts
3787
- /**
3788
- * Parse an XML plist string into a typed object.
3789
- * Throws on malformed XML — callers should wrap in Effect.try.
3790
- */
3791
- const parsePlistXml = (xml) => plist.parse(xml);
3792
- /**
3793
- * Parse a binary plist buffer into a typed object.
3794
- * Uses bplist-parser for Apple's binary plist format.
3795
- */
3796
- const parsePlistBinary = (buffer) => {
3797
- const [result] = __require("bplist-parser").parseBuffer(buffer);
3798
- return result;
3799
- };
3800
- const BPLIST_MAGIC = Buffer.from("bplist00");
3801
- /**
3802
- * Auto-detect plist format (binary vs XML) and parse accordingly.
3803
- */
3804
- const parsePlist = (data) => data.subarray(0, 8).equals(BPLIST_MAGIC) ? parsePlistBinary(data) : parsePlistXml(data.toString("utf8"));
3805
-
3806
- //#endregion
3807
- //#region src/lib/ios-provisioning.ts
3808
- const getString = (obj, key) => {
3809
- const value = obj[key];
3810
- return typeof value === "string" ? value : void 0;
3811
- };
3812
- const getFirstArrayString = (obj, key) => {
3813
- const value = obj[key];
3814
- if (Array.isArray(value) && typeof value[0] === "string") return value[0];
3815
- };
3816
- /**
3817
- * Extract `UUID`, `Name`, and the first `TeamIdentifier` from the XML plist
3818
- * output of `security cms -D -i <path>`. Returns `ProvisioningError` when any
3819
- * of the three fields are missing.
3820
- */
3821
- const extractProvisioningInfo = (plistXml) => Effect.gen(function* () {
3822
- const parsed = yield* Effect.try({
3823
- try: () => parsePlistXml(plistXml),
3824
- catch: (error) => new ProvisioningError({ message: `Failed to parse provisioning profile plist: ${error instanceof Error ? error.message : String(error)}` })
3775
+ }));
3776
+ const fetchAscCredentials = (api, ascApiKeyId) => api.ascApiKeys.getCredentials({ path: { id: ascApiKeyId } });
3777
+ const generateAndUploadDistributionCertificate = (api, input) => Effect.gen(function* () {
3778
+ const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
3779
+ const ascCreds = {
3780
+ keyId: creds.keyId,
3781
+ issuerId: creds.issuerId,
3782
+ p8Pem: creds.p8Pem
3783
+ };
3784
+ const csrResult = yield* Effect.tryPromise({
3785
+ try: generateCertificateSigningRequest,
3786
+ catch: (cause) => new GenerateFailedError({
3787
+ step: "csr",
3788
+ message: `CSR generation failed: ${cause instanceof Error ? cause.message : String(cause)}`
3789
+ })
3825
3790
  });
3826
- const uuid = getString(parsed, "UUID");
3827
- const name = getString(parsed, "Name");
3828
- const teamId = getFirstArrayString(parsed, "TeamIdentifier");
3829
- if (!uuid || !name || !teamId) return yield* new ProvisioningError({ message: `Failed to parse provisioning profile: missing ${uuid ? "" : "UUID "}${name ? "" : "Name "}${teamId ? "" : "TeamIdentifier "}`.trim() });
3791
+ const certificateType = input.certificateType ?? "IOS_DISTRIBUTION";
3792
+ const apple = yield* createCertificate(ascCreds, {
3793
+ csrPem: csrResult.csrPem,
3794
+ certificateType
3795
+ }).pipe(Effect.mapError(wrapAscError("apple-create-certificate")));
3796
+ if (apple.certificateContent === null) return yield* Effect.fail(new GenerateFailedError({
3797
+ step: "apple-create-certificate",
3798
+ message: "Apple response missing certificateContent"
3799
+ }));
3800
+ const bundle = yield* buildDistributionCertP12({
3801
+ certificateContentBase64: apple.certificateContent,
3802
+ privateKey: csrResult.privateKey
3803
+ }).pipe(Effect.mapError((cause) => new GenerateFailedError({
3804
+ step: "p12-build",
3805
+ message: cause.message
3806
+ })));
3807
+ const created = yield* api.appleDistributionCertificates.upload({ payload: {
3808
+ p12Base64: bundle.p12Base64,
3809
+ p12Password: bundle.password,
3810
+ serialNumber: bundle.metadata.serialNumber,
3811
+ appleTeamIdentifier: bundle.metadata.appleTeamId,
3812
+ ...bundle.metadata.appleTeamName === null ? {} : { appleTeamName: bundle.metadata.appleTeamName },
3813
+ ...bundle.metadata.developerIdIdentifier === null ? {} : { developerIdIdentifier: bundle.metadata.developerIdIdentifier },
3814
+ validFrom: bundle.metadata.validFrom,
3815
+ validUntil: bundle.metadata.validUntil
3816
+ } });
3830
3817
  return {
3831
- uuid,
3832
- name,
3833
- teamId
3818
+ id: created.id,
3819
+ serialNumber: bundle.metadata.serialNumber,
3820
+ appleTeamId: created.appleTeamId,
3821
+ appleTeamIdentifier: bundle.metadata.appleTeamId,
3822
+ developerPortalIdentifier: apple.id
3834
3823
  };
3835
3824
  });
3836
- const userProvisioningProfilesDir = () => path.join(os.homedir(), "Library", "MobileDevice", "Provisioning Profiles");
3837
- /**
3838
- * Scoped installation of a provisioning profile: parses its metadata via
3839
- * `security cms -D -i`, copies it into `~/Library/MobileDevice/Provisioning Profiles`
3840
- * under `<uuid>.mobileprovision`, and removes the copy on scope close — but
3841
- * only if we installed it. If the target file already existed when we arrived
3842
- * (e.g., Xcode had it), we leave both the file and the contents untouched.
3843
- */
3844
- const installProvisioningProfile = ({ profilePath }) => Effect.acquireRelease(Effect.gen(function* () {
3845
- const fs = yield* FileSystem.FileSystem;
3846
- const info = yield* extractProvisioningInfo(yield* Command.string(Command.make("security", "cms", "-D", "-i", profilePath)).pipe(Effect.mapError((cause) => new ProvisioningError({ message: `security cms -D failed for ${profilePath}: ${String(cause)}` }))));
3847
- const targetDir = userProvisioningProfilesDir();
3848
- const installedPath = path.join(targetDir, `${info.uuid}.mobileprovision`);
3849
- yield* fs.makeDirectory(targetDir, { recursive: true }).pipe(Effect.catchAll((cause) => new ProvisioningError({ message: `Failed to create provisioning profiles dir: ${String(cause)}` })));
3850
- if (yield* fs.exists(installedPath).pipe(Effect.orElseSucceed(() => false))) return {
3851
- ...info,
3852
- installedPath,
3853
- ownsInstallation: false
3854
- };
3855
- yield* fs.copyFile(profilePath, installedPath).pipe(Effect.catchAll((cause) => new ProvisioningError({ message: `Failed to copy provisioning profile into ${installedPath}: ${String(cause)}` })));
3856
- return {
3857
- ...info,
3858
- installedPath,
3859
- ownsInstallation: true
3860
- };
3861
- }), (acquired) => Effect.gen(function* () {
3862
- if (!acquired.ownsInstallation) return;
3863
- yield* (yield* FileSystem.FileSystem).remove(acquired.installedPath).pipe(Effect.catchAll(() => Effect.void));
3864
- })).pipe(Effect.map(({ uuid, name, teamId, installedPath }) => ({
3865
- uuid,
3866
- name,
3867
- teamId,
3868
- installedPath
3869
- })));
3870
-
3871
- //#endregion
3872
- //#region src/lib/post-build-validation.ts
3873
- /**
3874
- * Validate an iOS build after xcodebuild completes. Checks:
3875
- * 1. Bundle ID matches expected value
3876
- * 2. Provisioning profile UUID matches
3877
- * 3. Team ID matches
3878
- *
3879
- * All checks are non-blocking — returns warnings, never fails the build.
3880
- */
3881
- const validateIosBuild = (params) => Effect.gen(function* () {
3882
- const appDir = yield* findAppDirectory$1(params.archivePath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
3883
- if (!appDir) return {
3884
- passed: false,
3885
- warnings: ["Could not locate .app bundle in archive — skipping post-build validation"]
3825
+ const revokeAppleCertificate = (api, input) => Effect.gen(function* () {
3826
+ const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
3827
+ yield* deleteCertificate({
3828
+ keyId: creds.keyId,
3829
+ issuerId: creds.issuerId,
3830
+ p8Pem: creds.p8Pem
3831
+ }, input.developerPortalIdentifier).pipe(Effect.mapError(wrapAscError("apple-revoke-certificate")));
3832
+ });
3833
+ const revokeLocalDistributionCertificate = (api, input) => Effect.gen(function* () {
3834
+ const local = (yield* api.appleDistributionCertificates.list()).items.find((entry) => entry.id === input.distributionCertificateId);
3835
+ if (local === void 0) return yield* Effect.fail(new GenerateFailedError({
3836
+ step: "load-distribution-certificate",
3837
+ message: `Distribution certificate ${input.distributionCertificateId} not found on this account`
3838
+ }));
3839
+ const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
3840
+ const ascCreds = {
3841
+ keyId: creds.keyId,
3842
+ issuerId: creds.issuerId,
3843
+ p8Pem: creds.p8Pem
3886
3844
  };
3887
- const bundleWarning = yield* checkBundleId(appDir, params.expectedBundleId).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
3888
- const profileWarnings = yield* checkEmbeddedProfile(appDir, params.expectedProfileUuid, params.expectedTeamId).pipe(Effect.catchAll(() => Effect.succeed([])));
3889
- const warnings = [...bundleWarning ? [bundleWarning] : [], ...profileWarnings];
3890
- if (warnings.length > 0) {
3891
- yield* Console.warn("Post-build validation warnings:");
3892
- for (const warning of warnings) yield* Console.warn(` - ${warning}`);
3845
+ const targetSerial = local.serialNumber.toUpperCase();
3846
+ const matching = yield* Effect.all([listCertificates(ascCreds, { certificateType: "IOS_DISTRIBUTION" }), listCertificates(ascCreds, { certificateType: "IOS_DEVELOPMENT" })], { concurrency: 2 }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
3847
+ const ascMatch = [...matching[0], ...matching[1]].find((entry) => entry.serialNumber.toUpperCase() === targetSerial);
3848
+ let revokedOnApple = false;
3849
+ if (ascMatch !== void 0) {
3850
+ yield* deleteCertificate(ascCreds, ascMatch.id).pipe(Effect.mapError(wrapAscError("apple-revoke-certificate")));
3851
+ revokedOnApple = true;
3852
+ }
3853
+ let deletedLocally = false;
3854
+ if (input.keepLocal !== true) {
3855
+ yield* api.appleDistributionCertificates.delete({ path: { id: input.distributionCertificateId } });
3856
+ deletedLocally = true;
3893
3857
  }
3894
3858
  return {
3895
- passed: warnings.length === 0,
3896
- warnings
3859
+ localId: input.distributionCertificateId,
3860
+ serialNumber: local.serialNumber,
3861
+ revokedOnApple,
3862
+ deletedLocally
3897
3863
  };
3898
3864
  });
3899
- const findAppDirectory$1 = (archivePath) => Effect.gen(function* () {
3900
- const fs = yield* FileSystem.FileSystem;
3901
- const productsDir = path.join(archivePath, "Products", "Applications");
3902
- const appEntry = (yield* fs.readDirectory(productsDir)).find((entry) => entry.endsWith(".app"));
3903
- if (!appEntry) return yield* Effect.fail("No .app found");
3904
- return path.join(productsDir, appEntry);
3865
+ const listAppleCertificates = (api, input) => Effect.gen(function* () {
3866
+ const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
3867
+ return yield* listCertificates({
3868
+ keyId: creds.keyId,
3869
+ issuerId: creds.issuerId,
3870
+ p8Pem: creds.p8Pem
3871
+ }, input.certificateType === void 0 ? {} : { certificateType: input.certificateType }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
3905
3872
  });
3906
- const checkBundleId = (appDir, expectedBundleId) => Effect.gen(function* () {
3907
- const fs = yield* FileSystem.FileSystem;
3908
- const plistPath = path.join(appDir, "Info.plist");
3909
- const data = yield* fs.readFile(plistPath);
3910
- const actualBundleId = parsePlist(Buffer.from(data))["CFBundleIdentifier"];
3911
- if (typeof actualBundleId === "string" && actualBundleId !== expectedBundleId) return `Bundle ID mismatch: expected "${expectedBundleId}", got "${actualBundleId}"`;
3873
+ const resolveCertAscId = (creds, serialNumber, certificateType) => Effect.gen(function* () {
3874
+ const match = (yield* listCertificates(creds, { certificateType }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")))).find((entry) => entry.serialNumber.toUpperCase() === serialNumber);
3875
+ if (match === void 0) return yield* Effect.fail(new GenerateFailedError({
3876
+ step: "match-apple-certificate",
3877
+ message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
3878
+ }));
3879
+ return match.id;
3912
3880
  });
3913
- const checkEmbeddedProfile = (appDir, expectedUuid, expectedTeamId) => Effect.gen(function* () {
3914
- const warnings = [];
3915
- const profilePath = path.join(appDir, "embedded.mobileprovision");
3916
- const parsed = parsePlistXml(yield* Command.string(Command.make("security", "cms", "-D", "-i", profilePath)));
3917
- const actualUuid = parsed["UUID"];
3918
- if (typeof actualUuid === "string" && actualUuid !== expectedUuid) warnings.push(`Profile UUID mismatch: expected "${expectedUuid}", got "${actualUuid}"`);
3919
- const teamIdentifiers = parsed["TeamIdentifier"];
3920
- if (Array.isArray(teamIdentifiers)) {
3921
- const [actualTeamId] = teamIdentifiers;
3922
- if (typeof actualTeamId === "string" && actualTeamId !== expectedTeamId) warnings.push(`Team ID mismatch: expected "${expectedTeamId}", got "${actualTeamId}"`);
3923
- }
3924
- const expirationDate = parsed["ExpirationDate"];
3925
- if (expirationDate instanceof Date && expirationDate.getTime() < Date.now()) warnings.push(`Embedded provisioning profile expired on ${expirationDate.toISOString()}`);
3926
- return warnings;
3881
+ const ensureBundleId = (creds, bundleIdentifier) => Effect.gen(function* () {
3882
+ const existing = (yield* listBundleIds(creds).pipe(Effect.mapError(wrapAscError("apple-list-bundle-ids")))).find((entry) => entry.identifier === bundleIdentifier);
3883
+ if (existing !== void 0) return existing.id;
3884
+ return (yield* createBundleId(creds, {
3885
+ identifier: bundleIdentifier,
3886
+ name: bundleIdentifier
3887
+ }).pipe(Effect.mapError(wrapAscError("apple-create-bundle-id")))).id;
3888
+ });
3889
+ const collectDeviceAscIds = (creds, appleTeamId, deviceIds) => Effect.gen(function* () {
3890
+ const devices = yield* listDevices(creds).pipe(Effect.mapError(wrapAscError("apple-list-devices")));
3891
+ return {
3892
+ ids: deviceIds === void 0 ? devices.map((device) => device.id) : devices.filter((device) => new Set(deviceIds).has(device.id)).map((device) => device.id),
3893
+ appleTeamId
3894
+ };
3895
+ });
3896
+ const generateAndUploadProvisioningProfile = (api, input) => Effect.gen(function* () {
3897
+ const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
3898
+ const ascCreds = {
3899
+ keyId: creds.keyId,
3900
+ issuerId: creds.issuerId,
3901
+ p8Pem: creds.p8Pem
3902
+ };
3903
+ const cert = yield* api.appleDistributionCertificates.list().pipe(Effect.map(({ items }) => items.find((item) => item.id === input.distributionCertificateId)), Effect.flatMap((match) => match === void 0 ? Effect.fail(new GenerateFailedError({
3904
+ step: "load-distribution-certificate",
3905
+ message: `Distribution certificate ${input.distributionCertificateId} not found`
3906
+ })) : Effect.succeed(match)));
3907
+ const certificateType = input.distributionType === "DEVELOPMENT" ? "IOS_DEVELOPMENT" : "IOS_DISTRIBUTION";
3908
+ const [certAscId, bundleIdAscId] = yield* Effect.all([resolveCertAscId(ascCreds, cert.serialNumber.toUpperCase(), certificateType), ensureBundleId(ascCreds, input.bundleIdentifier)], { concurrency: 2 });
3909
+ const useDevices = input.distributionType === "AD_HOC" || input.distributionType === "DEVELOPMENT";
3910
+ const { ids: deviceAscIds } = useDevices ? yield* collectDeviceAscIds(ascCreds, cert.appleTeamId, input.deviceIds) : { ids: [] };
3911
+ if (useDevices && deviceAscIds.length === 0) return yield* Effect.fail(new GenerateFailedError({
3912
+ step: "collect-devices",
3913
+ message: "No registered devices to attach to the provisioning profile"
3914
+ }));
3915
+ const profileBytes = fromBase64((yield* createProvisioningProfile(ascCreds, {
3916
+ profileName: `${input.bundleIdentifier} ${input.distributionType} ${Date.now()}`,
3917
+ profileType: DISTRIBUTION_TO_PROFILE_TYPE$1[input.distributionType],
3918
+ bundleIdAscId,
3919
+ certificateAscIds: [certAscId],
3920
+ deviceAscIds
3921
+ }).pipe(Effect.mapError(wrapAscError("apple-create-profile")))).profileContent);
3922
+ const rosterHash = useDevices ? computeDeviceRosterHashHex(deviceAscIds) : void 0;
3923
+ const profileBase64 = toBase64(profileBytes);
3924
+ const created = yield* api.appleProvisioningProfiles.upload({ payload: {
3925
+ profileBase64,
3926
+ appleDistributionCertificateId: input.distributionCertificateId,
3927
+ isManaged: true,
3928
+ ...rosterHash === void 0 ? {} : { deviceRosterHash: rosterHash }
3929
+ } });
3930
+ return {
3931
+ id: created.id,
3932
+ bundleIdentifier: created.bundleIdentifier,
3933
+ distributionType: created.distributionType,
3934
+ profileName: created.profileName,
3935
+ validUntil: created.validUntil,
3936
+ developerPortalIdentifier: created.developerPortalIdentifier,
3937
+ /** Raw .mobileprovision bytes (base64) — callers can install directly without re-downloading. */
3938
+ profileBase64
3939
+ };
3927
3940
  });
3928
3941
 
3929
3942
  //#endregion
3930
- //#region src/lib/xcpretty-formatter.ts
3943
+ //#region src/lib/auto-provision-extension-profiles.ts
3931
3944
  /**
3932
- * Create a stateful xcodebuild output formatter backed by `@expo/xcpretty`.
3933
- * Each `pipe(line)` call may return zero or more formatted lines — zero means
3934
- * the line was suppressed (e.g., intermediate compiler invocations).
3945
+ * Mint a fresh provisioning profile for an extension bundle that has no
3946
+ * registered IosBundleConfiguration yet:
3947
+ * 1. Generate the profile via Apple ASC (reusing the main app's dist cert).
3948
+ * 2. Upload the .mobileprovision bytes to the backend so future resolves work.
3949
+ * 3. Create an IosBundleConfiguration binding so the new profile is wired up.
3950
+ *
3951
+ * Returns the profile bytes in-line so the build can install + sign immediately
3952
+ * without a follow-up resolve round-trip.
3935
3953
  */
3936
- const createXcodebuildFormatter = (projectRoot) => {
3937
- const formatter = ExpoRunFormatter.create(projectRoot);
3954
+ const autoProvisionExtensionProfile = (api, input) => Effect.gen(function* () {
3955
+ const generated = yield* generateAndUploadProvisioningProfile(api, {
3956
+ ascApiKeyId: input.ascApiKeyId,
3957
+ distributionCertificateId: input.distributionCertificateId,
3958
+ bundleIdentifier: input.bundleIdentifier,
3959
+ distributionType: input.distributionType
3960
+ });
3961
+ const binding = yield* api.iosBundleConfigurations.create({
3962
+ path: { projectId: input.projectId },
3963
+ payload: {
3964
+ bundleIdentifier: input.bundleIdentifier,
3965
+ distributionType: input.distributionType,
3966
+ appleTeamId: input.appleTeamId,
3967
+ appleDistributionCertificateId: input.distributionCertificateId,
3968
+ appleProvisioningProfileId: generated.id,
3969
+ ascApiKeyId: input.ascApiKeyId
3970
+ }
3971
+ });
3938
3972
  return {
3939
- pipe: (line) => formatter.pipe(line),
3940
- getBuildSummary: () => formatter.getBuildSummary()
3973
+ bundleIdentifier: input.bundleIdentifier,
3974
+ profileBase64: generated.profileBase64,
3975
+ profileUuid: generated.developerPortalIdentifier ?? input.bundleIdentifier,
3976
+ profileName: generated.profileName,
3977
+ appleProvisioningProfileId: generated.id,
3978
+ iosBundleConfigurationId: binding.id
3941
3979
  };
3942
- };
3980
+ });
3943
3981
 
3944
3982
  //#endregion
3945
- //#region src/commands/build/ios.ts
3946
- const findXcworkspace = (iosDir) => Effect.gen(function* () {
3947
- const workspace = (yield* (yield* FileSystem.FileSystem).readDirectory(iosDir)).find((entry) => entry.endsWith(".xcworkspace"));
3948
- if (!workspace) return yield* new BuildFailedError({
3949
- step: "detect xcworkspace",
3950
- exitCode: 1,
3951
- message: `No .xcworkspace found under ${iosDir}. Did "pod install" run?`
3983
+ //#region src/lib/credentials-downloader.ts
3984
+ const IOS_DISTRIBUTION_TO_TYPE = {
3985
+ "app-store": "APP_STORE",
3986
+ "ad-hoc": "AD_HOC",
3987
+ development: "DEVELOPMENT",
3988
+ enterprise: "ENTERPRISE"
3989
+ };
3990
+ const bindHint = "Bind the bundle via the dashboard (Credentials → iOS Bundle Configurations) and make sure a distribution certificate, provisioning profile, and ASC API key are attached.";
3991
+ const permissionHint = "Ask an org admin to grant the build-credentials download permission.";
3992
+ const androidBindHint = "Register the package in the dashboard (Credentials → Android Build Credentials) and bind a keystore to the default group.";
3993
+ const hasTag$1 = (cause) => typeof cause === "object" && cause !== null && "_tag" in cause;
3994
+ const resolveErrorToMissingCredentials = (cause, platform, bundleIdentifier) => {
3995
+ const tag = hasTag$1(cause) ? cause._tag : null;
3996
+ const message = hasTag$1(cause) && typeof cause.message === "string" ? cause.message : null;
3997
+ const platformLabel = platform === "ios" ? "iOS" : "Android";
3998
+ const bind = platform === "ios" ? bindHint : androidBindHint;
3999
+ const bundleSuffix = bundleIdentifier ? ` (bundle "${bundleIdentifier}")` : "";
4000
+ if (tag === "Forbidden") return new MissingCredentialsError({
4001
+ message: message ?? `Permission denied when resolving ${platformLabel} build credentials${bundleSuffix}`,
4002
+ hint: permissionHint
3952
4003
  });
3953
- return workspace;
3954
- });
3955
- const prebuildAndPods = (params) => Effect.gen(function* () {
3956
- yield* runStep(Command.make("bunx", "expo", "prebuild", "--platform", "ios", "--clean").pipe(Command.workingDirectory(params.projectRoot), Command.env(params.commandEnv)), "expo prebuild ios");
3957
- yield* runStep(Command.make("pod", "install").pipe(Command.workingDirectory(params.iosDir), Command.env(params.commandEnv)), "pod install");
3958
- });
3959
- const findAppDirectory = (root) => Effect.gen(function* () {
3960
- const fs = yield* FileSystem.FileSystem;
3961
- const stack = [root];
3962
- let depth = 0;
3963
- while (stack.length > 0 && depth < 6) {
3964
- const layer = stack.splice(0);
3965
- depth += 1;
3966
- for (const dir of layer) {
3967
- const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => []));
3968
- for (const entry of entries) {
3969
- const full = path.join(dir, entry);
3970
- if (entry.endsWith(".app")) return full;
3971
- const stat = yield* fs.stat(full).pipe(Effect.option);
3972
- if (stat._tag === "Some" && stat.value.type === "Directory") stack.push(full);
3973
- }
3974
- }
4004
+ if (tag === "NotFound") return new MissingCredentialsError({
4005
+ message: message ?? `No ${platformLabel} build credentials configured${bundleSuffix}`,
4006
+ hint: bind
4007
+ });
4008
+ if (tag === "BadRequest") return new MissingCredentialsError({
4009
+ message: message ?? `${platformLabel} build credentials are misconfigured${bundleSuffix}`,
4010
+ hint: bind
4011
+ });
4012
+ return new MissingCredentialsError({
4013
+ message: message ?? `Failed to resolve ${platformLabel} build credentials${bundleSuffix}`,
4014
+ hint: bind
4015
+ });
4016
+ };
4017
+ const resolveOneBundleSettled = (api, options) => api.buildCredentials.resolve({
4018
+ path: { projectId: options.projectId },
4019
+ payload: {
4020
+ platform: "ios",
4021
+ bundleIdentifier: options.bundleIdentifier,
4022
+ distributionType: IOS_DISTRIBUTION_TO_TYPE[options.distribution]
3975
4023
  }
3976
- return yield* new ArtifactNotFoundError({ message: `No .app bundle found under "${root}".` });
3977
- });
3978
- const runIosSimulatorBuild = (input) => Effect.gen(function* () {
3979
- const { projectRoot, iosProfile, envVars, tempDir } = input;
3980
- const runtime = yield* CliRuntime;
3981
- const iosDir = path.join(projectRoot, "ios");
3982
- const commandEnv = yield* runtime.commandEnvironment(envVars);
3983
- yield* prebuildAndPods({
3984
- projectRoot,
3985
- iosDir,
3986
- commandEnv
4024
+ }).pipe(Effect.flatMap((resolved) => {
4025
+ if (resolved.platform !== "ios") return Effect.succeed({
4026
+ status: "failed",
4027
+ bundleIdentifier: options.bundleIdentifier,
4028
+ error: new MissingCredentialsError({
4029
+ message: `Server returned non-iOS credentials for iOS bundle "${options.bundleIdentifier}"`,
4030
+ hint: bindHint
4031
+ })
3987
4032
  });
3988
- const workspaceFilename = yield* findXcworkspace(iosDir);
3989
- const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
3990
- const configuration = iosProfile.buildConfiguration ?? "Release";
3991
- const derivedDataPath = path.join(tempDir, "derived-data");
3992
- const buildCmd = Command.make("xcodebuild", "-workspace", workspaceFilename, "-scheme", scheme, "-configuration", configuration, "-sdk", "iphonesimulator", "-destination", "generic/platform=iOS Simulator", "-derivedDataPath", derivedDataPath, "build", "CODE_SIGNING_ALLOWED=NO", "CODE_SIGNING_REQUIRED=NO", "CODE_SIGN_IDENTITY=").pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
3993
- const formatter = input.rawOutput ? void 0 : createXcodebuildFormatter(projectRoot);
3994
- yield* formatter ? runStepFormatted(buildCmd, "xcodebuild build (simulator)", formatter) : runStep(buildCmd, "xcodebuild build (simulator)");
3995
- const appDir = yield* findAppDirectory(path.join(derivedDataPath, "Build", "Products", `${configuration}-iphonesimulator`));
3996
- const archiveName = `${path.basename(appDir, ".app")}-simulator.tar.gz`;
3997
- const archivePath = path.join(tempDir, archiveName);
3998
- yield* runStep(Command.make("tar", "-czf", archivePath, "-C", path.dirname(appDir), path.basename(appDir)).pipe(Command.env(commandEnv)), "tar simulator .app");
3999
- const { sha256, byteSize } = yield* sha256File(archivePath);
4000
- return {
4001
- artifactPath: archivePath,
4002
- byteSize,
4003
- sha256
4004
- };
4005
- });
4006
- const runIosDeviceBuild = (input) => Effect.gen(function* () {
4007
- const { api, tempDir, projectRoot, iosProfile, bundleId, envVars, projectId } = input;
4008
- const runtime = yield* CliRuntime;
4009
- const iosDir = path.join(projectRoot, "ios");
4010
- const { distribution } = iosProfile;
4011
- const commandEnv = yield* runtime.commandEnvironment(envVars);
4012
- const credentials = input.credentialsSource === "local" ? yield* loadLocalIosCredentials({ projectRoot }) : yield* downloadIosCredentials(api, {
4013
- projectId,
4014
- bundleIdentifier: bundleId,
4015
- distribution,
4016
- tempDir
4033
+ return Effect.succeed({
4034
+ status: "ok",
4035
+ bundleIdentifier: options.bundleIdentifier,
4036
+ value: {
4037
+ bundleIdentifier: options.bundleIdentifier,
4038
+ p12Base64: resolved.distributionCertificate.p12Base64,
4039
+ p12Password: resolved.distributionCertificate.p12Password,
4040
+ mobileprovisionBase64: resolved.provisioningProfile.mobileprovisionBase64,
4041
+ profileUuid: resolved.provisioningProfile.uuid,
4042
+ context: resolved.context
4043
+ }
4017
4044
  });
4018
- yield* prebuildAndPods({
4019
- projectRoot,
4020
- iosDir,
4021
- commandEnv
4045
+ }), Effect.catchAll((cause) => {
4046
+ if ((hasTag$1(cause) ? cause._tag : null) === "NotFound") return Effect.succeed({
4047
+ status: "not-registered",
4048
+ bundleIdentifier: options.bundleIdentifier
4022
4049
  });
4023
- const keychain = yield* acquireKeychain({
4024
- tempDir,
4025
- p12Path: credentials.p12Path,
4026
- p12Password: credentials.p12Password
4050
+ return Effect.succeed({
4051
+ status: "failed",
4052
+ bundleIdentifier: options.bundleIdentifier,
4053
+ error: resolveErrorToMissingCredentials(cause, "ios", options.bundleIdentifier)
4027
4054
  });
4028
- const provisioning = yield* installProvisioningProfile({ profilePath: credentials.profilePath });
4029
- const workspaceFilename = yield* findXcworkspace(iosDir);
4030
- const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
4031
- const configuration = iosProfile.buildConfiguration ?? "Release";
4032
- const archivePath = path.join(tempDir, "build.xcarchive");
4033
- const archiveCmd = Command.make("xcodebuild", "-workspace", workspaceFilename, "-scheme", scheme, "-configuration", configuration, "-archivePath", archivePath, "-allowProvisioningUpdates", "archive", "CODE_SIGN_STYLE=Manual", `DEVELOPMENT_TEAM=${provisioning.teamId}`, `CODE_SIGN_IDENTITY=${keychain.signingIdentity}`, `PROVISIONING_PROFILE_SPECIFIER=${provisioning.name}`).pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
4034
- const formatter = input.rawOutput ? void 0 : createXcodebuildFormatter(projectRoot);
4035
- yield* formatter ? runStepFormatted(archiveCmd, "xcodebuild archive", formatter) : runStep(archiveCmd, "xcodebuild archive");
4055
+ }));
4056
+ const autoProvisionHint = "Bind an ASC API key to the main app bundle in the dashboard so missing extension bundles can be auto-provisioned, or register them manually.";
4057
+ const downloadIosCredentials = (api, options) => Effect.gen(function* () {
4036
4058
  const fs = yield* FileSystem.FileSystem;
4037
- const exportOptionsPath = path.join(tempDir, "ExportOptions.plist");
4038
- yield* fs.writeFileString(exportOptionsPath, renderExportOptionsPlist({
4039
- method: distribution,
4040
- teamId: provisioning.teamId,
4041
- bundleId,
4042
- provisioningProfileName: provisioning.name
4059
+ if (options.bundleIdentifiers.length === 0) return yield* new MissingCredentialsError({
4060
+ message: "downloadIosCredentials called with an empty bundleIdentifiers list.",
4061
+ hint: bindHint
4062
+ });
4063
+ if (!options.bundleIdentifiers.includes(options.mainBundleIdentifier)) return yield* new MissingCredentialsError({
4064
+ message: `Main bundle "${options.mainBundleIdentifier}" missing from bundleIdentifiers list.`,
4065
+ hint: bindHint
4066
+ });
4067
+ const settled = yield* Effect.forEach(options.bundleIdentifiers, (bundleIdentifier) => resolveOneBundleSettled(api, {
4068
+ projectId: options.projectId,
4069
+ bundleIdentifier,
4070
+ distribution: options.distribution
4071
+ }), { concurrency: 4 });
4072
+ const hardFailure = settled.find((entry) => entry.status === "failed");
4073
+ if (hardFailure) return yield* hardFailure.error;
4074
+ const mainEntry = settled.find((entry) => entry.bundleIdentifier === options.mainBundleIdentifier);
4075
+ if (mainEntry?.status !== "ok") return yield* new MissingCredentialsError({
4076
+ message: `Main app bundle "${options.mainBundleIdentifier}" is not registered on the backend.`,
4077
+ hint: bindHint
4078
+ });
4079
+ const resolved = settled.filter((entry) => entry.status === "ok");
4080
+ const missing = settled.filter((entry) => entry.status === "not-registered").map((entry) => entry.bundleIdentifier);
4081
+ const provisioned = yield* maybeAutoProvision(api, {
4082
+ mainContext: mainEntry.value.context,
4083
+ missing,
4084
+ projectId: options.projectId,
4085
+ distributionType: IOS_DISTRIBUTION_TO_TYPE[options.distribution]
4086
+ });
4087
+ const p12Path = path.join(options.tempDir, "signing.p12");
4088
+ yield* fs.writeFile(p12Path, fromBase64(mainEntry.value.p12Base64));
4089
+ const profiles = [];
4090
+ for (const entry of resolved) profiles.push(yield* writeProfile(fs, options.tempDir, {
4091
+ bundleIdentifier: entry.value.bundleIdentifier,
4092
+ base64: entry.value.mobileprovisionBase64,
4093
+ uuid: entry.value.profileUuid
4043
4094
  }));
4044
- const exportPath = path.join(tempDir, "export");
4045
- const exportCmd = Command.make("xcodebuild", "-exportArchive", "-archivePath", archivePath, "-exportPath", exportPath, "-exportOptionsPlist", exportOptionsPath, "-allowProvisioningUpdates").pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
4046
- yield* formatter ? runStepFormatted(exportCmd, "xcodebuild exportArchive", formatter) : runStep(exportCmd, "xcodebuild exportArchive");
4047
- yield* validateIosBuild({
4048
- archivePath,
4049
- expectedBundleId: bundleId,
4050
- expectedTeamId: provisioning.teamId,
4051
- expectedProfileUuid: provisioning.uuid
4095
+ for (const entry of provisioned) profiles.push(yield* writeProfile(fs, options.tempDir, {
4096
+ bundleIdentifier: entry.bundleIdentifier,
4097
+ base64: entry.profileBase64,
4098
+ uuid: entry.profileUuid
4099
+ }));
4100
+ return {
4101
+ p12Path,
4102
+ p12Password: mainEntry.value.p12Password,
4103
+ profiles
4104
+ };
4105
+ });
4106
+ const maybeAutoProvision = (api, params) => Effect.gen(function* () {
4107
+ if (params.missing.length === 0) return [];
4108
+ const { ascApiKeyId } = params.mainContext;
4109
+ if (ascApiKeyId === null) return yield* new MissingCredentialsError({
4110
+ message: `No iOS bundle configuration for extension bundle(s) ${params.missing.map((id) => `"${id}"`).join(", ")}, and the main bundle has no ASC API key bound for auto-provisioning.`,
4111
+ hint: autoProvisionHint
4052
4112
  });
4053
- const artifactPath = yield* findIosArtifact({ exportPath });
4054
- const { sha256, byteSize } = yield* sha256File(artifactPath);
4113
+ yield* Console.log(`Auto-provisioning ${params.missing.length} missing extension profile(s) via Apple ASC...`);
4114
+ return yield* Effect.forEach(params.missing, (bundleIdentifier) => autoProvisionExtensionProfile(api, {
4115
+ projectId: params.projectId,
4116
+ bundleIdentifier,
4117
+ distributionType: params.distributionType,
4118
+ ascApiKeyId,
4119
+ distributionCertificateId: params.mainContext.distributionCertificateId,
4120
+ appleTeamId: params.mainContext.appleTeamId
4121
+ }).pipe(Effect.map((created) => ({
4122
+ bundleIdentifier: created.bundleIdentifier,
4123
+ profileBase64: created.profileBase64,
4124
+ profileUuid: created.profileUuid
4125
+ })), Effect.mapError((cause) => {
4126
+ const tag = hasTag$1(cause) ? cause._tag : "AutoProvisionError";
4127
+ return new MissingCredentialsError({
4128
+ message: `Auto-provision failed for "${bundleIdentifier}": ${hasTag$1(cause) && typeof cause.message === "string" ? cause.message : `Failed to auto-provision profile for "${bundleIdentifier}" (${tag})`}`,
4129
+ hint: autoProvisionHint
4130
+ });
4131
+ })), { concurrency: 2 });
4132
+ });
4133
+ const writeProfile = (fs, tempDir, params) => Effect.gen(function* () {
4134
+ const profileFilename = `${params.uuid ?? params.bundleIdentifier}.mobileprovision`;
4135
+ const profilePath = path.join(tempDir, profileFilename);
4136
+ yield* fs.writeFile(profilePath, fromBase64(params.base64));
4055
4137
  return {
4056
- artifactPath,
4057
- byteSize,
4058
- sha256
4138
+ bundleIdentifier: params.bundleIdentifier,
4139
+ profilePath,
4140
+ profileFilename
4141
+ };
4142
+ });
4143
+ const downloadAndroidCredentials = (api, options) => Effect.gen(function* () {
4144
+ const fs = yield* FileSystem.FileSystem;
4145
+ const resolved = yield* api.buildCredentials.resolve({
4146
+ path: { projectId: options.projectId },
4147
+ payload: {
4148
+ platform: "android",
4149
+ applicationIdentifier: options.applicationIdentifier
4150
+ }
4151
+ }).pipe(Effect.mapError((cause) => resolveErrorToMissingCredentials(cause, "android")));
4152
+ if (resolved.platform !== "android") return yield* Effect.fail(new MissingCredentialsError({
4153
+ message: "Server returned non-Android credentials for an Android build request",
4154
+ hint: androidBindHint
4155
+ }));
4156
+ const keystorePath = path.join(options.tempDir, "upload.keystore");
4157
+ yield* fs.writeFile(keystorePath, fromBase64(resolved.keystore.keystoreBase64));
4158
+ return {
4159
+ keystorePath,
4160
+ storePassword: resolved.keystore.storePassword,
4161
+ keyAlias: resolved.keystore.keyAlias,
4162
+ keyPassword: resolved.keystore.keyPassword
4059
4163
  };
4060
4164
  });
4061
- const runIosBuild = (input) => input.iosProfile.simulator === true ? runIosSimulatorBuild(input) : runIosDeviceBuild(input);
4062
4165
 
4063
4166
  //#endregion
4064
- //#region src/commands/build/reserve-and-upload.ts
4065
- const buildReserveCommon = (input) => ({
4066
- projectId: input.projectId,
4067
- profile: input.profileName,
4068
- runtimeVersion: input.runtimeVersion,
4069
- bundleId: input.bundleId,
4070
- sha256: input.sha256,
4071
- byteSize: input.byteSize,
4072
- ...input.appVersion === void 0 ? {} : { appVersion: input.appVersion },
4073
- ...input.buildNumber === void 0 ? {} : { buildNumber: input.buildNumber },
4074
- ...input.gitContext.ref === void 0 ? {} : { gitRef: input.gitContext.ref },
4075
- ...input.gitContext.commit === void 0 ? {} : { gitCommit: input.gitContext.commit },
4076
- ...input.message === void 0 ? {} : { message: input.message }
4077
- });
4078
- const callReserve = (api, input) => {
4079
- const common = buildReserveCommon(input);
4080
- const { target } = input;
4081
- if (target.platform === "ios") return target.distribution === "simulator" ? api.builds.reserve({ payload: {
4082
- ...common,
4083
- platform: "ios",
4084
- distribution: "simulator",
4085
- artifactFormat: "tar.gz"
4086
- } }) : api.builds.reserve({ payload: {
4087
- ...common,
4088
- platform: "ios",
4089
- distribution: target.distribution,
4090
- artifactFormat: "ipa"
4091
- } });
4092
- return target.distribution === "play-store" ? api.builds.reserve({ payload: {
4093
- ...common,
4094
- platform: "android",
4095
- distribution: "play-store",
4096
- artifactFormat: "aab"
4097
- } }) : api.builds.reserve({ payload: {
4098
- ...common,
4099
- platform: "android",
4100
- distribution: "direct",
4101
- artifactFormat: "apk"
4102
- } });
4103
- };
4104
- /**
4105
- * Reserve a build record on the server, upload the artifact to the returned
4106
- * presigned URL, and finalize the build with its sha256 + byteSize.
4107
- */
4108
- const reserveAndUpload = (api, input) => Effect.gen(function* () {
4109
- const presignedUploadClient = yield* PresignedUploadClient;
4110
- const reserveResult = yield* callReserve(api, input).pipe(Effect.mapError((cause) => new ReserveError({ message: `Failed to reserve build: ${formatCause(cause)}` })));
4111
- yield* presignedUploadClient.putToPresignedUrl({
4112
- url: reserveResult.uploadUrl,
4113
- filePath: input.artifactPath,
4114
- byteSize: input.byteSize,
4115
- expiresAt: reserveResult.uploadExpiresAt,
4116
- headers: reserveResult.uploadHeaders
4117
- });
4118
- const completed = yield* api.builds.complete({
4119
- path: { id: reserveResult.id },
4120
- payload: {
4121
- sha256: input.sha256,
4122
- byteSize: input.byteSize
4123
- }
4124
- }).pipe(Effect.mapError((cause) => new CompleteError({ message: `Failed to complete build ${reserveResult.id}: ${formatCause(cause)}` })));
4125
- if (!completed.artifact) return yield* new CompleteError({ message: `Build ${completed.id} completed but server returned no artifact record.` });
4167
+ //#region src/lib/credentials-json.ts
4168
+ const CREDENTIALS_JSON_FILENAME = "credentials.json";
4169
+ const asString$2 = (value, field) => typeof value === "string" && value.length > 0 ? Effect.succeed(value) : Effect.fail(new CredentialsJsonError({ message: `credentials.json: field "${field}" must be a non-empty string.` }));
4170
+ const parseIosDistributionCertificate = (raw) => Effect.gen(function* () {
4171
+ const record = asRecord(raw);
4172
+ if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: ios.distributionCertificate must be an object." });
4126
4173
  return {
4127
- id: completed.id,
4128
- status: "uploaded"
4174
+ path: yield* asString$2(record["path"], "ios.distributionCertificate.path"),
4175
+ password: yield* asString$2(record["password"], "ios.distributionCertificate.password")
4129
4176
  };
4130
4177
  });
4131
-
4132
- //#endregion
4133
- //#region src/lib/auto-increment.ts
4134
- const bumpBuildNumber = (current) => Effect.gen(function* () {
4135
- const raw = current ?? "0";
4136
- const parsed = Number.parseInt(raw, 10);
4137
- if (Number.isNaN(parsed)) return yield* new BuildProfileError({ message: `Cannot autoIncrement ios.buildNumber: current value "${raw}" is not a base-10 integer.` });
4138
- return String(parsed + 1);
4139
- });
4140
- const bumpVersionCode = (current) => Effect.gen(function* () {
4141
- const value = current ?? 0;
4142
- if (!Number.isInteger(value) || value < 0) return yield* new BuildProfileError({ message: `Cannot autoIncrement android.versionCode: current value ${String(value)} is not a non-negative integer.` });
4143
- return value + 1;
4144
- });
4145
- const SEMVER_PATCH = /^(\d+)\.(\d+)\.(\d+)(.*)$/u;
4146
- const bumpVersion = (current) => Effect.gen(function* () {
4147
- if (current === void 0) return yield* new BuildProfileError({ message: "Cannot autoIncrement version: no `version` field set in Expo config." });
4148
- const match = SEMVER_PATCH.exec(current);
4149
- if (!match) return yield* new BuildProfileError({ message: `Cannot autoIncrement version: "${current}" is not a semver string like "1.2.3".` });
4150
- const [, major, minor, patch, suffix] = match;
4151
- const nextPatch = Number.parseInt(patch ?? "0", 10) + 1;
4152
- return `${major ?? "0"}.${minor ?? "0"}.${String(nextPatch)}${suffix ?? ""}`;
4178
+ const parseIosPushKey = (raw) => Effect.gen(function* () {
4179
+ const record = asRecord(raw);
4180
+ if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: ios.pushKey must be an object." });
4181
+ return {
4182
+ path: yield* asString$2(record["path"], "ios.pushKey.path"),
4183
+ keyId: yield* asString$2(record["keyId"], "ios.pushKey.keyId"),
4184
+ teamId: yield* asString$2(record["teamId"], "ios.pushKey.teamId")
4185
+ };
4153
4186
  });
4154
- const computeIosBumps = (config, mode) => Effect.gen(function* () {
4155
- if (mode === "buildNumber") return { nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber) };
4187
+ const parseIosAscApiKey = (raw) => Effect.gen(function* () {
4188
+ const record = asRecord(raw);
4189
+ if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: ios.ascApiKey must be an object." });
4156
4190
  return {
4157
- nextVersion: yield* bumpVersion(config.version),
4158
- nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber)
4191
+ path: yield* asString$2(record["path"], "ios.ascApiKey.path"),
4192
+ keyId: yield* asString$2(record["keyId"], "ios.ascApiKey.keyId"),
4193
+ issuerId: yield* asString$2(record["issuerId"], "ios.ascApiKey.issuerId")
4159
4194
  };
4160
4195
  });
4161
- const computeAndroidBumps = (config, mode) => Effect.gen(function* () {
4162
- if (mode === "versionCode") return { nextVersionCode: yield* bumpVersionCode(config.android?.versionCode) };
4196
+ const parseIosAdditionalProvisioningProfile = (raw, index) => Effect.gen(function* () {
4197
+ const record = asRecord(raw);
4198
+ if (!record) return yield* new CredentialsJsonError({ message: `credentials.json: ios.additionalProvisioningProfiles[${index}] must be an object.` });
4163
4199
  return {
4164
- nextVersion: yield* bumpVersion(config.version),
4165
- nextVersionCode: yield* bumpVersionCode(config.android?.versionCode)
4200
+ bundleIdentifier: yield* asString$2(record["bundleIdentifier"], `ios.additionalProvisioningProfiles[${index}].bundleIdentifier`),
4201
+ path: yield* asString$2(record["path"], `ios.additionalProvisioningProfiles[${index}].path`)
4166
4202
  };
4167
4203
  });
4168
- const buildPatch = (platform, bumps) => {
4169
- const patch = {};
4170
- if (bumps.nextVersion !== void 0) patch["version"] = bumps.nextVersion;
4171
- if (platform === "ios" && bumps.nextBuildNumber !== void 0) patch["ios"] = { buildNumber: bumps.nextBuildNumber };
4172
- if (platform === "android" && bumps.nextVersionCode !== void 0) patch["android"] = { versionCode: bumps.nextVersionCode };
4173
- return patch;
4174
- };
4175
- const describeBumps = (platform, bumps) => {
4176
- const parts = [];
4177
- if (bumps.nextVersion !== void 0) parts.push(`version=${bumps.nextVersion}`);
4178
- if (platform === "ios" && bumps.nextBuildNumber !== void 0) parts.push(`ios.buildNumber=${bumps.nextBuildNumber}`);
4179
- if (platform === "android" && bumps.nextVersionCode !== void 0) parts.push(`android.versionCode=${String(bumps.nextVersionCode)}`);
4180
- return parts.join(", ");
4181
- };
4182
- const computeBumps = (input) => {
4183
- if (input.platform === "ios") return input.iosMode === void 0 ? Effect.succeed({}) : computeIosBumps(input.config, input.iosMode);
4184
- return input.androidMode === void 0 ? Effect.succeed({}) : computeAndroidBumps(input.config, input.androidMode);
4185
- };
4186
- const hasAnyBump = (bumps) => bumps.nextVersion !== void 0 || bumps.nextBuildNumber !== void 0 || bumps.nextVersionCode !== void 0;
4187
- /**
4188
- * Bump `version` / `ios.buildNumber` / `android.versionCode` per the resolved
4189
- * autoIncrement mode, persist via `@expo/config.modifyConfigAsync`, and log a
4190
- * Human-readable summary. No-op when the mode is undefined. Returns the new
4191
- * Bumped values so callers can refresh their in-memory ExpoConfig.
4192
- */
4193
- const applyAutoIncrement = (input) => Effect.gen(function* () {
4194
- const bumps = yield* computeBumps(input);
4195
- if (!hasAnyBump(bumps)) return bumps;
4196
- const patch = buildPatch(input.platform, bumps);
4197
- const result = yield* writeExpoConfigPatch(input.projectRoot, patch).pipe(Effect.mapError((cause) => new BuildProfileError({ message: `Failed to persist autoIncrement: ${cause.message}` })));
4198
- if (result.type === "warn" && result.configPath === null) {
4199
- yield* Console.log(`autoIncrement: dynamic Expo config detected, cannot write back. Update manually: ${describeBumps(input.platform, bumps)}`);
4200
- return bumps;
4201
- }
4202
- yield* Console.log(`autoIncrement: bumped ${describeBumps(input.platform, bumps)}`);
4203
- return bumps;
4204
+ const parseIosAdditionalProvisioningProfiles = (raw) => Effect.gen(function* () {
4205
+ if (!Array.isArray(raw)) return yield* new CredentialsJsonError({ message: "credentials.json: ios.additionalProvisioningProfiles must be an array." });
4206
+ const entries = [];
4207
+ for (const [index, item] of raw.entries()) entries.push(yield* parseIosAdditionalProvisioningProfile(item, index));
4208
+ return entries;
4204
4209
  });
4205
-
4206
- //#endregion
4207
- //#region src/lib/eas-config.ts
4208
- const MAX_EXTENDS_DEPTH = 10;
4209
- const asStringValue = (value) => typeof value === "string" ? value : void 0;
4210
- const asBooleanValue = (value) => typeof value === "boolean" ? value : void 0;
4211
- const asEnv = (value) => {
4212
- const record = asRecord(value);
4213
- if (!record) return;
4214
- const env = {};
4215
- for (const [key, raw] of Object.entries(record)) if (typeof raw === "string") env[key] = raw;
4216
- return Object.keys(env).length === 0 ? void 0 : env;
4217
- };
4218
- const asIosDistribution = (raw) => {
4219
- const value = asStringValue(raw);
4220
- if (value === "app-store" || value === "ad-hoc" || value === "development" || value === "enterprise") return value;
4221
- };
4222
- const asEnterpriseProvisioning = (raw) => {
4223
- const value = asStringValue(raw);
4224
- return value === "adhoc" || value === "universal" ? value : void 0;
4225
- };
4226
- const asAndroidBuildType = (raw) => {
4227
- const value = asStringValue(raw);
4228
- return value === "debug" || value === "release" ? value : void 0;
4229
- };
4230
- const asAndroidFormat = (raw) => {
4231
- const value = asStringValue(raw);
4232
- return value === "apk" || value === "aab" ? value : void 0;
4233
- };
4234
- const asAndroidDistribution = (raw) => {
4235
- const value = asStringValue(raw);
4236
- return value === "play-store" || value === "direct" ? value : void 0;
4237
- };
4238
- const asIosAutoIncrement = (raw) => {
4239
- if (typeof raw === "boolean") return raw;
4240
- const value = asStringValue(raw);
4241
- return value === "buildNumber" || value === "version" ? value : void 0;
4242
- };
4243
- const asAndroidAutoIncrement = (raw) => {
4244
- if (typeof raw === "boolean") return raw;
4245
- const value = asStringValue(raw);
4246
- return value === "versionCode" || value === "version" ? value : void 0;
4247
- };
4248
- const asAutoIncrement = (raw) => {
4249
- if (typeof raw === "boolean") return raw;
4250
- const value = asStringValue(raw);
4251
- return value === "buildNumber" || value === "versionCode" || value === "version" ? value : void 0;
4252
- };
4253
- const asEasDistribution = (raw) => {
4254
- const value = asStringValue(raw);
4255
- return value === "internal" || value === "store" ? value : void 0;
4256
- };
4257
- const asCredentialsSource = (raw) => {
4258
- const value = asStringValue(raw);
4259
- return value === "remote" || value === "local" ? value : void 0;
4260
- };
4261
- const parseIosProfile = (raw) => {
4210
+ const parseIos = (raw) => Effect.gen(function* () {
4262
4211
  const record = asRecord(raw);
4263
- if (!record) return;
4264
- const distribution = asIosDistribution(record["distribution"]);
4265
- const buildConfiguration = asStringValue(record["buildConfiguration"]);
4266
- const scheme = asStringValue(record["scheme"]);
4267
- const simulator = asBooleanValue(record["simulator"]);
4268
- const enterpriseProvisioning = asEnterpriseProvisioning(record["enterpriseProvisioning"]);
4269
- const autoIncrement = asIosAutoIncrement(record["autoIncrement"]);
4212
+ if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: \"ios\" must be an object." });
4213
+ const provisioningProfilePath = yield* asString$2(record["provisioningProfilePath"], "ios.provisioningProfilePath");
4214
+ const distributionCertificate = yield* parseIosDistributionCertificate(record["distributionCertificate"]);
4215
+ const additionalProvisioningProfiles = record["additionalProvisioningProfiles"] === void 0 ? void 0 : yield* parseIosAdditionalProvisioningProfiles(record["additionalProvisioningProfiles"]);
4216
+ const pushKey = record["pushKey"] === void 0 ? void 0 : yield* parseIosPushKey(record["pushKey"]);
4217
+ const ascApiKey = record["ascApiKey"] === void 0 ? void 0 : yield* parseIosAscApiKey(record["ascApiKey"]);
4270
4218
  return {
4271
- ...distribution === void 0 ? {} : { distribution },
4272
- ...buildConfiguration === void 0 ? {} : { buildConfiguration },
4273
- ...scheme === void 0 ? {} : { scheme },
4274
- ...simulator === void 0 ? {} : { simulator },
4275
- ...enterpriseProvisioning === void 0 ? {} : { enterpriseProvisioning },
4276
- ...autoIncrement === void 0 ? {} : { autoIncrement }
4219
+ provisioningProfilePath,
4220
+ distributionCertificate,
4221
+ ...additionalProvisioningProfiles === void 0 ? {} : { additionalProvisioningProfiles },
4222
+ ...pushKey === void 0 ? {} : { pushKey },
4223
+ ...ascApiKey === void 0 ? {} : { ascApiKey }
4277
4224
  };
4278
- };
4279
- const parseAndroidProfile = (raw) => {
4225
+ });
4226
+ const parseAndroidKeystore = (raw) => Effect.gen(function* () {
4280
4227
  const record = asRecord(raw);
4281
- if (!record) return;
4282
- const buildType = asAndroidBuildType(record["buildType"]);
4283
- const flavor = asStringValue(record["flavor"]);
4284
- const gradleCommand = asStringValue(record["gradleCommand"]);
4285
- const format = asAndroidFormat(record["format"]);
4286
- const distribution = asAndroidDistribution(record["distribution"]);
4287
- const autoIncrement = asAndroidAutoIncrement(record["autoIncrement"]);
4228
+ if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: android.keystore must be an object." });
4288
4229
  return {
4289
- ...buildType === void 0 ? {} : { buildType },
4290
- ...flavor === void 0 ? {} : { flavor },
4291
- ...gradleCommand === void 0 ? {} : { gradleCommand },
4292
- ...format === void 0 ? {} : { format },
4293
- ...distribution === void 0 ? {} : { distribution },
4294
- ...autoIncrement === void 0 ? {} : { autoIncrement }
4230
+ keystorePath: yield* asString$2(record["keystorePath"], "android.keystore.keystorePath"),
4231
+ keystorePassword: yield* asString$2(record["keystorePassword"], "android.keystore.keystorePassword"),
4232
+ keyAlias: yield* asString$2(record["keyAlias"], "android.keystore.keyAlias"),
4233
+ keyPassword: yield* asString$2(record["keyPassword"], "android.keystore.keyPassword")
4295
4234
  };
4296
- };
4297
- const parseBuildProfile = (raw) => {
4235
+ });
4236
+ const parseGoogleServiceAccountKey = (raw) => Effect.gen(function* () {
4298
4237
  const record = asRecord(raw);
4299
- if (!record) return;
4300
- const extendsName = asStringValue(record["extends"]);
4301
- const developmentClient = asBooleanValue(record["developmentClient"]);
4302
- const distribution = asEasDistribution(record["distribution"]);
4303
- const channel = asStringValue(record["channel"]);
4304
- const environment = asStringValue(record["environment"]);
4305
- const env = asEnv(record["env"]);
4306
- const ios = parseIosProfile(record["ios"]);
4307
- const android = parseAndroidProfile(record["android"]);
4308
- const credentialsSource = asCredentialsSource(record["credentialsSource"]);
4309
- const autoIncrement = asAutoIncrement(record["autoIncrement"]);
4238
+ if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: android.googleServiceAccountKey must be an object." });
4239
+ return { path: yield* asString$2(record["path"], "android.googleServiceAccountKey.path") };
4240
+ });
4241
+ const parseAndroid = (raw) => Effect.gen(function* () {
4242
+ const record = asRecord(raw);
4243
+ if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: \"android\" must be an object." });
4244
+ const keystore = yield* parseAndroidKeystore(record["keystore"]);
4245
+ const googleServiceAccountKey = record["googleServiceAccountKey"] === void 0 ? void 0 : yield* parseGoogleServiceAccountKey(record["googleServiceAccountKey"]);
4310
4246
  return {
4311
- ...extendsName === void 0 ? {} : { extends: extendsName },
4312
- ...developmentClient === void 0 ? {} : { developmentClient },
4313
- ...distribution === void 0 ? {} : { distribution },
4314
- ...channel === void 0 ? {} : { channel },
4315
- ...environment === void 0 ? {} : { environment },
4316
- ...env === void 0 ? {} : { env },
4317
- ...ios === void 0 ? {} : { ios },
4318
- ...android === void 0 ? {} : { android },
4319
- ...credentialsSource === void 0 ? {} : { credentialsSource },
4320
- ...autoIncrement === void 0 ? {} : { autoIncrement }
4247
+ keystore,
4248
+ ...googleServiceAccountKey === void 0 ? {} : { googleServiceAccountKey }
4321
4249
  };
4322
- };
4323
- const parseEasConfig = (text) => Effect.gen(function* () {
4250
+ });
4251
+ const parseCredentialsJson = (raw) => Effect.gen(function* () {
4324
4252
  const root = asRecord(yield* Effect.try({
4325
- try: () => JSON.parse(text),
4326
- catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
4253
+ try: () => JSON.parse(raw),
4254
+ catch: () => new CredentialsJsonError({ message: "credentials.json is not valid JSON." })
4327
4255
  }));
4328
- if (!root) return yield* new BuildProfileError({ message: "eas.json must be a JSON object at the top level." });
4329
- const buildRecord = asRecord(root["build"]);
4330
- if (!buildRecord) return asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {};
4331
- const profiles = {};
4332
- for (const [name, value] of Object.entries(buildRecord)) {
4333
- const profile = parseBuildProfile(value);
4334
- if (profile) profiles[name] = profile;
4335
- }
4256
+ if (!root) return yield* new CredentialsJsonError({ message: "credentials.json must be a JSON object at the top level." });
4257
+ const ios = root["ios"] === void 0 ? void 0 : yield* parseIos(root["ios"]);
4258
+ const android = root["android"] === void 0 ? void 0 : yield* parseAndroid(root["android"]);
4259
+ if (!ios && !android) return yield* new CredentialsJsonError({ message: "credentials.json must contain at least one of \"ios\" or \"android\"." });
4336
4260
  return {
4337
- ...asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {},
4338
- build: profiles
4261
+ ...ios === void 0 ? {} : { ios },
4262
+ ...android === void 0 ? {} : { android }
4339
4263
  };
4340
4264
  });
4341
- const parseCli = (raw) => {
4342
- const record = asRecord(raw);
4343
- if (!record) return {};
4344
- const version = asStringValue(record["version"]);
4345
- return version === void 0 ? {} : { version };
4346
- };
4347
- const easJsonPath = (projectRoot) => Effect.gen(function* () {
4348
- return (yield* Path.Path).join(projectRoot, "eas.json");
4265
+ const credentialsJsonPath = (projectRoot) => path.join(projectRoot, CREDENTIALS_JSON_FILENAME);
4266
+ const readCredentialsJson = (projectRoot) => Effect.gen(function* () {
4267
+ const fs = yield* FileSystem.FileSystem;
4268
+ const filePath = credentialsJsonPath(projectRoot);
4269
+ if (!(yield* fs.exists(filePath).pipe(Effect.catchAll(() => Effect.succeed(false))))) return yield* new CredentialsJsonError({ message: `credentials.json not found at ${filePath}.` });
4270
+ return yield* parseCredentialsJson(yield* fs.readFileString(filePath).pipe(Effect.mapError((cause) => new CredentialsJsonError({ message: `Failed to read credentials.json: ${String(cause)}` }))));
4349
4271
  });
4350
- const readEasJson = (projectRoot) => Effect.gen(function* () {
4272
+ const writeCredentialsJson = (projectRoot, data) => Effect.gen(function* () {
4351
4273
  const fs = yield* FileSystem.FileSystem;
4352
- const filePath = yield* easJsonPath(projectRoot);
4353
- return yield* parseEasConfig(yield* fs.readFileString(filePath).pipe(Effect.catchAll((cause) => Effect.fail(new BuildProfileError({ message: cause._tag === "SystemError" && cause.reason === "NotFound" ? `No eas.json found at ${filePath}. Create one with a "build" section.` : `Failed to read eas.json: ${cause.message}` })))));
4274
+ const filePath = credentialsJsonPath(projectRoot);
4275
+ const body = `${JSON.stringify(data, null, 2)}\n`;
4276
+ yield* fs.writeFileString(filePath, body).pipe(Effect.mapError((cause) => new CredentialsJsonError({ message: `Failed to write credentials.json: ${String(cause)}` })));
4277
+ return filePath;
4354
4278
  });
4355
- const mergeIos = (base, overlay) => {
4356
- if (!base) return overlay;
4357
- if (!overlay) return base;
4358
- return {
4359
- ...base,
4360
- ...overlay
4361
- };
4362
- };
4363
- const mergeAndroid = (base, overlay) => {
4364
- if (!base) return overlay;
4365
- if (!overlay) return base;
4366
- return {
4367
- ...base,
4368
- ...overlay
4369
- };
4370
- };
4371
- const mergeEnv = (base, overlay) => {
4372
- if (!base) return overlay;
4373
- if (!overlay) return base;
4279
+ /**
4280
+ * Resolve a path that may be either absolute or relative to the project root.
4281
+ */
4282
+ const resolveCredentialPath = (projectRoot, candidate) => path.isAbsolute(candidate) ? candidate : path.join(projectRoot, candidate);
4283
+
4284
+ //#endregion
4285
+ //#region src/lib/local-credentials.ts
4286
+ const requirePath = (fs, absolutePath, label) => fs.exists(absolutePath).pipe(Effect.catchAll(() => Effect.succeed(false)), Effect.flatMap((exists) => exists ? Effect.void : Effect.fail(new MissingCredentialsError({
4287
+ message: `Local credentials.json: ${label} not found at ${absolutePath}.`,
4288
+ hint: "Run `better-update credentials sync pull` to materialize files, or fix the path in credentials.json."
4289
+ }))));
4290
+ const loadLocalIosCredentials = (options) => Effect.gen(function* () {
4291
+ const fs = yield* FileSystem.FileSystem;
4292
+ const data = yield* readCredentialsJson(options.projectRoot).pipe(Effect.mapError((cause) => new MissingCredentialsError({
4293
+ message: `Local credentials.json: ${cause.message}`,
4294
+ hint: "Create credentials.json or switch the build profile's credentialsSource back to \"remote\"."
4295
+ })));
4296
+ if (!data.ios) return yield* new MissingCredentialsError({
4297
+ message: "credentials.json has no `ios` section but the build is for iOS.",
4298
+ hint: "Add an `ios` block to credentials.json or switch credentialsSource to remote."
4299
+ });
4300
+ const p12Path = resolveCredentialPath(options.projectRoot, data.ios.distributionCertificate.path);
4301
+ yield* requirePath(fs, p12Path, "distribution certificate (.p12)");
4302
+ const mainProfilePath = resolveCredentialPath(options.projectRoot, data.ios.provisioningProfilePath);
4303
+ yield* requirePath(fs, mainProfilePath, "provisioning profile (.mobileprovision)");
4304
+ const profiles = [{
4305
+ bundleIdentifier: options.mainBundleIdentifier,
4306
+ profilePath: mainProfilePath,
4307
+ profileFilename: path.basename(mainProfilePath)
4308
+ }];
4309
+ for (const extra of data.ios.additionalProvisioningProfiles ?? []) {
4310
+ const extraPath = resolveCredentialPath(options.projectRoot, extra.path);
4311
+ yield* requirePath(fs, extraPath, `provisioning profile for ${extra.bundleIdentifier} (.mobileprovision)`);
4312
+ profiles.push({
4313
+ bundleIdentifier: extra.bundleIdentifier,
4314
+ profilePath: extraPath,
4315
+ profileFilename: path.basename(extraPath)
4316
+ });
4317
+ }
4374
4318
  return {
4375
- ...base,
4376
- ...overlay
4319
+ p12Path,
4320
+ p12Password: data.ios.distributionCertificate.password,
4321
+ profiles
4377
4322
  };
4378
- };
4379
- const mergeProfile = (base, overlay) => {
4380
- const ios = mergeIos(base.ios, overlay.ios);
4381
- const android = mergeAndroid(base.android, overlay.android);
4382
- const env = mergeEnv(base.env, overlay.env);
4383
- const developmentClient = overlay.developmentClient ?? base.developmentClient;
4384
- const distribution = overlay.distribution ?? base.distribution;
4385
- const channel = overlay.channel ?? base.channel;
4386
- const environment = overlay.environment ?? base.environment;
4387
- const credentialsSource = overlay.credentialsSource ?? base.credentialsSource;
4388
- const autoIncrement = overlay.autoIncrement ?? base.autoIncrement;
4323
+ });
4324
+ const loadLocalAndroidCredentials = (options) => Effect.gen(function* () {
4325
+ const fs = yield* FileSystem.FileSystem;
4326
+ const data = yield* readCredentialsJson(options.projectRoot).pipe(Effect.mapError((cause) => new MissingCredentialsError({
4327
+ message: `Local credentials.json: ${cause.message}`,
4328
+ hint: "Create credentials.json or switch the build profile's credentialsSource back to \"remote\"."
4329
+ })));
4330
+ if (!data.android) return yield* new MissingCredentialsError({
4331
+ message: "credentials.json has no `android` section but the build is for Android.",
4332
+ hint: "Add an `android` block to credentials.json or switch credentialsSource to remote."
4333
+ });
4334
+ const keystorePath = resolveCredentialPath(options.projectRoot, data.android.keystore.keystorePath);
4335
+ yield* requirePath(fs, keystorePath, "keystore");
4389
4336
  return {
4390
- ...overlay.extends === void 0 ? {} : { extends: overlay.extends },
4391
- ...developmentClient === void 0 ? {} : { developmentClient },
4392
- ...distribution === void 0 ? {} : { distribution },
4393
- ...channel === void 0 ? {} : { channel },
4394
- ...environment === void 0 ? {} : { environment },
4395
- ...env === void 0 ? {} : { env },
4396
- ...ios === void 0 ? {} : { ios },
4397
- ...android === void 0 ? {} : { android },
4398
- ...credentialsSource === void 0 ? {} : { credentialsSource },
4399
- ...autoIncrement === void 0 ? {} : { autoIncrement }
4337
+ keystorePath,
4338
+ storePassword: data.android.keystore.keystorePassword,
4339
+ keyAlias: data.android.keystore.keyAlias,
4340
+ keyPassword: data.android.keystore.keyPassword
4400
4341
  };
4401
- };
4402
- const collectExtendsChain = (profiles, profileName) => Effect.gen(function* () {
4403
- const chain = [];
4404
- const visited = /* @__PURE__ */ new Set();
4405
- let current = profileName;
4406
- let depth = 0;
4407
- while (current !== void 0) {
4408
- if (visited.has(current)) return yield* new BuildProfileError({ message: `Cycle detected in eas.json build.${profileName} extends chain at "${current}".` });
4409
- visited.add(current);
4410
- const profile = profiles[current];
4411
- if (!profile) return yield* new BuildProfileError({ message: current === profileName ? `Build profile "${profileName}" not found in eas.json.` : `Build profile "${profileName}" extends missing profile "${current}".` });
4412
- chain.unshift(profile);
4413
- current = profile.extends;
4414
- depth += 1;
4415
- if (depth > MAX_EXTENDS_DEPTH) return yield* new BuildProfileError({ message: `Too many "extends" levels (max ${String(MAX_EXTENDS_DEPTH)}) in eas.json build.${profileName}.` });
4416
- }
4417
- return chain;
4418
- });
4419
- const stripExtends = (profile) => {
4420
- if (profile.extends === void 0) return profile;
4421
- const { extends: _omit, ...rest } = profile;
4422
- return rest;
4423
- };
4424
- const resolveEasBuildProfile = (config, profileName) => Effect.gen(function* () {
4425
- const profiles = config.build;
4426
- if (!profiles) return yield* new BuildProfileError({ message: "eas.json has no \"build\" section. Add at least one profile." });
4427
- return stripExtends((yield* collectExtendsChain(profiles, profileName)).reduce((acc, next, index) => index === 0 ? next : mergeProfile(acc, next), {}));
4428
4342
  });
4429
4343
 
4430
4344
  //#endregion
4431
- //#region src/lib/build-profile.ts
4432
- const asString$1 = (value) => typeof value === "string" ? value : void 0;
4433
- const deriveIosDistribution = (eas) => {
4434
- const override = eas.ios?.distribution;
4435
- if (override) return override;
4436
- if (eas.developmentClient === true) return "development";
4437
- if (eas.distribution === "internal") return "ad-hoc";
4438
- if (eas.distribution === "store") return "app-store";
4439
- };
4440
- const deriveAndroidFormat = (eas) => {
4441
- if (eas.android?.format) return eas.android.format;
4442
- if (eas.distribution === "store") return "aab";
4443
- if (eas.distribution === "internal") return "apk";
4444
- if (eas.developmentClient === true) return "apk";
4445
- };
4446
- const deriveAndroidDistribution = (eas, format) => {
4447
- if (eas.android?.distribution) return eas.android.distribution;
4448
- if (format === "aab") return "play-store";
4449
- return "direct";
4450
- };
4451
- const hasIosIntent = (eas) => eas.ios !== void 0 || eas.distribution !== void 0 || eas.developmentClient === true;
4452
- const hasAndroidIntent = (eas) => eas.android !== void 0 || eas.distribution !== void 0 || eas.developmentClient === true;
4453
- const resolveIosAutoIncrement = (eas) => {
4454
- const override = eas.ios?.autoIncrement;
4455
- if (override === false) return;
4456
- if (override === true) return "buildNumber";
4457
- if (override === "buildNumber" || override === "version") return override;
4458
- const top = eas.autoIncrement;
4459
- if (top === true || top === "buildNumber") return "buildNumber";
4460
- if (top === "version") return "version";
4461
- };
4462
- const resolveAndroidAutoIncrement = (eas) => {
4463
- const override = eas.android?.autoIncrement;
4464
- if (override === false) return;
4465
- if (override === true) return "versionCode";
4466
- if (override === "versionCode" || override === "version") return override;
4467
- const top = eas.autoIncrement;
4468
- if (top === true || top === "versionCode") return "versionCode";
4469
- if (top === "version") return "version";
4470
- };
4471
- const toIosProfile = (eas) => {
4472
- if (!hasIosIntent(eas)) return;
4473
- const distribution = deriveIosDistribution(eas);
4474
- if (!distribution) return;
4475
- const ios = eas.ios ?? {};
4476
- const autoIncrement = resolveIosAutoIncrement(eas);
4477
- return {
4478
- distribution,
4479
- ...ios.buildConfiguration === void 0 ? {} : { buildConfiguration: ios.buildConfiguration },
4480
- ...ios.scheme === void 0 ? {} : { scheme: ios.scheme },
4481
- ...ios.simulator === void 0 ? {} : { simulator: ios.simulator },
4482
- ...autoIncrement === void 0 ? {} : { autoIncrement }
4483
- };
4484
- };
4485
- const toAndroidProfile = (eas) => {
4486
- if (!hasAndroidIntent(eas)) return;
4487
- const format = deriveAndroidFormat(eas);
4488
- if (!format) return;
4489
- const android = eas.android ?? {};
4490
- const distribution = deriveAndroidDistribution(eas, format);
4491
- const autoIncrement = resolveAndroidAutoIncrement(eas);
4492
- return {
4493
- format,
4494
- distribution,
4495
- ...android.buildType === void 0 ? {} : { buildType: android.buildType },
4496
- ...android.flavor === void 0 ? {} : { flavor: android.flavor },
4497
- ...android.gradleCommand === void 0 ? {} : { gradleCommand: android.gradleCommand },
4498
- ...autoIncrement === void 0 ? {} : { autoIncrement }
4499
- };
4500
- };
4501
- const fromEasProfile = (eas, profileName) => {
4502
- const ios = toIosProfile(eas);
4503
- const android = toAndroidProfile(eas);
4504
- return {
4505
- name: profileName,
4506
- environment: eas.environment ?? "production",
4507
- ...eas.channel === void 0 ? {} : { channel: eas.channel },
4508
- ...eas.env === void 0 ? {} : { env: eas.env },
4509
- ...ios === void 0 ? {} : { ios },
4510
- ...android === void 0 ? {} : { android },
4511
- ...eas.credentialsSource === void 0 ? {} : { credentialsSource: eas.credentialsSource }
4512
- };
4513
- };
4514
- const readBuildProfile = (projectRoot, profileName) => Effect.gen(function* () {
4515
- return fromEasProfile(yield* resolveEasBuildProfile(yield* readEasJson(projectRoot), profileName), profileName);
4516
- });
4517
- const readRuntimeVersionMeta = (config) => ({
4518
- appVersion: config.version,
4519
- rawRuntimeVersion: readRawRuntimeVersion(config.runtimeVersion)
4520
- });
4521
- const readRawRuntimeVersion = (value) => {
4522
- if (typeof value === "string") return value;
4523
- const policy = asString$1(asRecord(value)?.["policy"]);
4524
- if (policy) return { policy };
4345
+ //#region src/lib/sha256.ts
4346
+ const hashReadError = (message) => new BuildFailedError({
4347
+ step: "sha256",
4348
+ exitCode: 1,
4349
+ message
4350
+ });
4351
+ const hashFile = (path, formatDigest) => Effect.async((resume) => {
4352
+ const hash = createHash("sha256");
4353
+ const stream = createReadStream(path);
4354
+ let byteSize = 0;
4355
+ stream.on("data", (chunk) => {
4356
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
4357
+ byteSize += buffer.byteLength;
4358
+ hash.update(buffer);
4359
+ });
4360
+ stream.on("error", (error) => {
4361
+ resume(Effect.fail(hashReadError(`Failed to read file for SHA-256: ${error.message}`)));
4362
+ });
4363
+ stream.on("end", () => {
4364
+ resume(Effect.succeed({
4365
+ digest: formatDigest(hash.digest()),
4366
+ byteSize
4367
+ }));
4368
+ });
4369
+ });
4370
+ /**
4371
+ * Compute the SHA-256 digest and byte size of a file using Node's streaming
4372
+ * hash API. The file is never fully loaded into memory — chunks flow through
4373
+ * `createReadStream` into `crypto.createHash("sha256")`.
4374
+ */
4375
+ const sha256File = (path) => hashFile(path, (digest) => digest.toString("hex")).pipe(Effect.map(({ digest, byteSize }) => ({
4376
+ sha256: digest,
4377
+ byteSize
4378
+ })));
4379
+ /**
4380
+ * Compute a content-type-namespaced hash: `SHA-256(contentType + '\0' + SHA-256_hex(fileBytes))`.
4381
+ *
4382
+ * This prevents hash collisions when identical bytes are served with different
4383
+ * MIME types (e.g. same file used as both `application/javascript` and `text/plain`).
4384
+ * The raw content hash is still needed separately for R2 upload verification.
4385
+ */
4386
+ const sha256Namespaced = (contentType, contentSha256Hex) => {
4387
+ const input = `${contentType}\0${contentSha256Hex}`;
4388
+ return toBase64Url(createHash("sha256").update(input).digest());
4525
4389
  };
4526
4390
 
4527
4391
  //#endregion
4528
- //#region src/lib/clear-cache.ts
4392
+ //#region src/commands/build/run-step.ts
4393
+ const runStep = (cmd, step) => Command.exitCode(cmd.pipe(Command.stdout("inherit"), Command.stderr("inherit"))).pipe(Effect.mapError((cause) => new BuildFailedError({
4394
+ step,
4395
+ exitCode: 1,
4396
+ message: `${step} failed to spawn: ${String(cause)}`
4397
+ })), Effect.flatMap((code) => code === 0 ? Effect.void : Effect.fail(new BuildFailedError({
4398
+ step,
4399
+ exitCode: code,
4400
+ message: `${step} exited with code ${code}`
4401
+ }))));
4529
4402
  /**
4530
- * Project-scoped build cache directories to remove when --clear-cache is passed.
4531
- * Intentionally avoids `~/.gradle/caches` (global) and `ios/Pods/` (requires
4532
- * pod install rebuild — leave to the user).
4403
+ * Run a build step with stdout piped through a formatter (e.g., xcpretty).
4404
+ * stderr passes through to the terminal directly.
4533
4405
  */
4534
- const CACHE_DIRS = [
4535
- "android/.gradle",
4536
- "android/app/build",
4537
- "android/build",
4538
- "ios/build",
4539
- ".expo",
4540
- "node_modules/.cache"
4541
- ];
4542
- const clearBuildCaches = (projectRoot) => Effect.gen(function* () {
4543
- const fs = yield* FileSystem.FileSystem;
4544
- const removed = [];
4545
- yield* Effect.forEach(CACHE_DIRS, (rel) => Effect.gen(function* () {
4546
- const target = path.join(projectRoot, rel);
4547
- if (!(yield* fs.exists(target).pipe(Effect.catchAll(() => Effect.succeed(false))))) return;
4548
- yield* fs.remove(target, { recursive: true }).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
4549
- removed.push(rel);
4550
- }), { concurrency: 4 });
4551
- if (removed.length > 0) yield* Console.error(`Cleared caches: ${removed.join(", ")}`);
4406
+ const runStepFormatted = (cmd, step, formatter) => Effect.gen(function* () {
4407
+ const proc = yield* Command.start(cmd.pipe(Command.stdout("pipe"), Command.stderr("pipe"))).pipe(Effect.mapError((cause) => new BuildFailedError({
4408
+ step,
4409
+ exitCode: 1,
4410
+ message: `${step} failed to spawn: ${String(cause)}`
4411
+ })));
4412
+ const stdoutFiber = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.runForEach((line) => {
4413
+ const formatted = formatter.pipe(line);
4414
+ return formatted.length > 0 ? Effect.sync(() => {
4415
+ for (const output of formatted) process$1.stdout.write(`${output}\n`);
4416
+ }) : Effect.void;
4417
+ }), Effect.mapError((cause) => new BuildFailedError({
4418
+ step,
4419
+ exitCode: 1,
4420
+ message: `${step} stdout stream error: ${String(cause)}`
4421
+ })), Effect.fork);
4422
+ const stderrFiber = yield* proc.stderr.pipe(Stream.decodeText(), Stream.splitLines, Stream.runForEach((line) => Effect.sync(() => process$1.stderr.write(`${line}\n`))), Effect.mapError((cause) => new BuildFailedError({
4423
+ step,
4424
+ exitCode: 1,
4425
+ message: `${step} stderr stream error: ${String(cause)}`
4426
+ })), Effect.fork);
4427
+ yield* Effect.all([Fiber.join(stdoutFiber), Fiber.join(stderrFiber)], { concurrency: 2 }).pipe(Effect.catchAll(() => Effect.void));
4428
+ const code = yield* proc.exitCode.pipe(Effect.mapError((cause) => new BuildFailedError({
4429
+ step,
4430
+ exitCode: 1,
4431
+ message: `${step} exit code error: ${String(cause)}`
4432
+ })));
4433
+ if (code !== 0) {
4434
+ const summary = formatter.getBuildSummary();
4435
+ if (summary) process$1.stderr.write(`${summary}\n`);
4436
+ return yield* new BuildFailedError({
4437
+ step,
4438
+ exitCode: code,
4439
+ message: `${step} exited with code ${code}`
4440
+ });
4441
+ }
4552
4442
  });
4553
4443
 
4554
4444
  //#endregion
4555
- //#region src/lib/env-exporter.ts
4556
- const coerceEnvironment = (raw) => raw === "development" || raw === "preview" || raw === "production" ? raw : void 0;
4445
+ //#region src/commands/build/android.ts
4557
4446
  /**
4558
- * Pull environment variables for a project + environment and flatten them into
4559
- * a key/value map. Returns an empty map when the project has no variables.
4447
+ * Compose the Gradle task name from flavor, format, and buildType.
4448
+ *
4449
+ * Gradle naming convention: `<verb><Flavor><Variant>`, e.g.
4450
+ * - no flavor + apk + release → `assembleRelease`
4451
+ * - no flavor + aab + release → `bundleRelease`
4452
+ * - flavor=prod + aab + release → `bundleProdRelease`
4453
+ * - flavor=prod + apk + debug → `assembleProdDebug`
4560
4454
  */
4561
- const pullEnvVars = (api, { projectId, environment }) => {
4562
- const validated = coerceEnvironment(environment);
4563
- if (!validated) return Effect.fail(new EnvExportError({ message: `Invalid environment "${environment}". Must be one of: development, preview, production.` }));
4564
- return api["env-vars"].export({ urlParams: {
4565
- projectId,
4566
- environment: validated
4567
- } }).pipe(Effect.map((result) => Object.fromEntries(result.items.map((item) => [item.key, item.value]))), Effect.mapError((cause) => new EnvExportError({ message: `Failed to export environment variables for "${environment}": ${String(cause)}` })));
4455
+ const gradleTaskName = (format, flavor, buildType) => {
4456
+ const verb = format === "aab" ? "bundle" : "assemble";
4457
+ return flavor ? `${verb}${capitalize(flavor)}${capitalize(buildType)}` : `${verb}${capitalize(buildType)}`;
4568
4458
  };
4569
-
4570
- //#endregion
4571
- //#region src/lib/git-context.ts
4572
- const runString = (cmd, cwd) => Command.string(Command.workingDirectory(cmd, cwd));
4573
- /**
4574
- * Best-effort git context extraction. If git is missing, the directory isn't
4575
- * a repo, or any command fails, we silently return undefined fields so the
4576
- * build can still proceed. This is intentional — git context is metadata,
4577
- * not a requirement.
4578
- */
4579
- const readGitContext = (projectRoot) => Effect.gen(function* () {
4580
- const [commit, ref, commitMessage, status] = yield* Effect.all([
4581
- runString(Command.make("git", "rev-parse", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
4582
- runString(Command.make("git", "symbolic-ref", "--short", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
4583
- runString(Command.make("git", "log", "-1", "--format=%s"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
4584
- runString(Command.make("git", "status", "--porcelain"), projectRoot).pipe(Effect.catchAll(() => Effect.succeed("")))
4585
- ], { concurrency: "unbounded" });
4459
+ const runAndroidBuild = (input) => Effect.gen(function* () {
4460
+ const { api, tempDir, projectRoot, androidProfile, applicationIdentifier, envVars, projectId } = input;
4461
+ const runtime = yield* CliRuntime;
4462
+ const buildStartMs = Date.now();
4463
+ const { format } = androidProfile;
4464
+ const { flavor } = androidProfile;
4465
+ const buildType = androidProfile.buildType ?? "release";
4466
+ const androidDir = path.join(projectRoot, "android");
4467
+ const commandEnv = yield* runtime.commandEnvironment(envVars);
4468
+ const credentials = input.credentialsSource === "local" ? yield* loadLocalAndroidCredentials({ projectRoot }) : yield* downloadAndroidCredentials(api, {
4469
+ projectId,
4470
+ applicationIdentifier,
4471
+ tempDir
4472
+ });
4473
+ yield* runStep(Command.make("bunx", "expo", "prebuild", "--platform", "android", "--clean").pipe(Command.workingDirectory(projectRoot), Command.env(commandEnv)), "expo prebuild android");
4474
+ const fs = yield* FileSystem.FileSystem;
4475
+ const signingGradlePath = path.join(tempDir, "signing.gradle");
4476
+ yield* fs.writeFileString(signingGradlePath, renderSigningGradle({
4477
+ keystorePath: credentials.keystorePath,
4478
+ storePassword: credentials.storePassword,
4479
+ keyAlias: credentials.keyAlias,
4480
+ keyPassword: credentials.keyPassword
4481
+ }));
4482
+ const taskName = gradleTaskName(format, flavor, buildType);
4483
+ yield* runStep(Command.make("./gradlew", "--init-script", signingGradlePath, `:app:${taskName}`).pipe(Command.workingDirectory(androidDir), Command.env(commandEnv)), "gradlew");
4484
+ const artifactPath = yield* findAndroidArtifact({
4485
+ projectRoot,
4486
+ format,
4487
+ ...flavor === void 0 ? {} : { flavor },
4488
+ buildType,
4489
+ minMtimeMs: buildStartMs
4490
+ });
4491
+ const { sha256, byteSize } = yield* sha256File(artifactPath);
4586
4492
  return {
4587
- ref: ref.length > 0 ? ref : void 0,
4588
- commit: commit.length > 0 ? commit : void 0,
4589
- commitMessage: commitMessage.length > 0 ? commitMessage : void 0,
4590
- dirty: status.trim().length > 0
4493
+ artifactPath,
4494
+ byteSize,
4495
+ sha256
4591
4496
  };
4592
4497
  });
4593
4498
 
4594
4499
  //#endregion
4595
- //#region src/lib/gradle-config.ts
4500
+ //#region src/lib/ios-codesign-pbxproj.ts
4501
+ const loadXcodeModule$1 = () => __require("xcode");
4502
+ const findXcodeProjectDir$1 = (iosDir) => Effect.gen(function* () {
4503
+ const projectDir = (yield* (yield* FileSystem.FileSystem).readDirectory(iosDir).pipe(Effect.mapError((cause) => new XcodeProjectError({ message: `Failed to read ${iosDir}: ${String(cause)}` })))).find((entry) => entry.endsWith(".xcodeproj"));
4504
+ if (!projectDir) return yield* new XcodeProjectError({ message: `No .xcodeproj directory found under ${iosDir}.` });
4505
+ return path.join(iosDir, projectDir);
4506
+ });
4507
+ const parseProject$1 = (pbxprojPath) => Effect.try({
4508
+ try: () => loadXcodeModule$1().project(pbxprojPath).parseSync(),
4509
+ catch: (cause) => new XcodeProjectError({ message: `Failed to parse ${pbxprojPath}: ${cause instanceof Error ? cause.message : String(cause)}` })
4510
+ });
4596
4511
  /**
4597
- * Parse Groovy `build.gradle` to extract key Android config values.
4598
- * Returns `undefined` if:
4599
- * - Only `build.gradle.kts` exists (Kotlin DSL not supported by gradle-to-js)
4600
- * - No build.gradle found at all
4601
- * - Parse fails
4512
+ * Always wrap a value in double quotes for safe pbxproj serialization. The
4513
+ * `xcode` writer emits values verbatim (e.g. `KEY = %s;`), so any string with
4514
+ * spaces, brackets or non-identifier characters needs explicit quoting.
4515
+ */
4516
+ const quote = (value) => `"${value.replaceAll("\"", String.raw`\"`)}"`;
4517
+ const SDK_CONDITIONAL_IDENTITY_KEYS = ["\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\"", "CODE_SIGN_IDENTITY[sdk=iphoneos*]"];
4518
+ const mutateConfig = (project, configUuid, settings) => {
4519
+ const cfg = project.pbxXCBuildConfigurationSection()[configUuid];
4520
+ if (!cfg || typeof cfg === "string") return false;
4521
+ cfg.buildSettings["CODE_SIGN_STYLE"] = "Manual";
4522
+ cfg.buildSettings["DEVELOPMENT_TEAM"] = quote(settings.teamId);
4523
+ cfg.buildSettings["CODE_SIGN_IDENTITY"] = quote(settings.signingIdentity);
4524
+ cfg.buildSettings["PROVISIONING_PROFILE_SPECIFIER"] = quote(settings.profileSpecifier);
4525
+ delete cfg.buildSettings["PROVISIONING_PROFILE"];
4526
+ for (const key of SDK_CONDITIONAL_IDENTITY_KEYS) delete cfg.buildSettings[key];
4527
+ return true;
4528
+ };
4529
+ /**
4530
+ * Write `CODE_SIGN_STYLE=Manual`, `DEVELOPMENT_TEAM`, `CODE_SIGN_IDENTITY`, and
4531
+ * `PROVISIONING_PROFILE_SPECIFIER` into the specified XCBuildConfiguration
4532
+ * entries of the project under `iosDir`, then serialize back to disk.
4602
4533
  *
4603
- * Informational onlynever blocks the build.
4534
+ * Only mutates the main app project `Pods.xcodeproj` is left untouched. The
4535
+ * caller is responsible for ensuring each entry's `buildConfigurationUuids`
4536
+ * only includes configurations that belong to a signed target (see
4537
+ * `discoverSignedTargets`).
4604
4538
  */
4605
- const readGradleConfig = (androidDir) => Effect.gen(function* () {
4539
+ const applyTargetSigning = (options) => Effect.gen(function* () {
4606
4540
  const fs = yield* FileSystem.FileSystem;
4607
- const gradlePath = path.join(androidDir, "app", "build.gradle");
4608
- const ktsPath = path.join(androidDir, "app", "build.gradle.kts");
4609
- const hasGroovy = yield* fs.exists(gradlePath).pipe(Effect.orElseSucceed(() => false));
4610
- const hasKts = yield* fs.exists(ktsPath).pipe(Effect.orElseSucceed(() => false));
4611
- if (!hasGroovy && hasKts) return;
4612
- if (!hasGroovy) return;
4613
- const content = yield* fs.readFileString(gradlePath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
4614
- if (!content) return;
4615
- return yield* Effect.tryPromise({
4616
- try: async () => {
4617
- return __require("gradle-to-js").parseText(stripGroovyComments(content));
4618
- },
4619
- catch: () => void 0
4620
- }).pipe(Effect.map(extractGradleConfig), Effect.catchAll(() => Effect.succeed(void 0)));
4541
+ const projectDir = yield* findXcodeProjectDir$1(options.iosDir);
4542
+ const pbxprojPath = path.join(projectDir, "project.pbxproj");
4543
+ const project = yield* parseProject$1(pbxprojPath);
4544
+ for (const entry of options.entries) for (const configUuid of entry.buildConfigurationUuids) if (!mutateConfig(project, configUuid, entry.settings)) yield* new XcodeProjectError({ message: `Build configuration ${configUuid} not found for target "${entry.targetName}" in ${pbxprojPath}.` });
4545
+ const serialized = yield* Effect.try({
4546
+ try: () => project.writeSync(),
4547
+ catch: (cause) => new XcodeProjectError({ message: `Failed to serialize ${pbxprojPath}: ${cause instanceof Error ? cause.message : String(cause)}` })
4548
+ });
4549
+ yield* fs.writeFileString(pbxprojPath, serialized).pipe(Effect.mapError((cause) => new XcodeProjectError({ message: `Failed to write ${pbxprojPath}: ${String(cause)}` })));
4621
4550
  });
4551
+
4552
+ //#endregion
4553
+ //#region src/lib/ios-export-options.ts
4554
+ const escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
4555
+ const boolTag = (value) => value ? "<true/>" : "<false/>";
4622
4556
  /**
4623
- * Log a warning if Gradle applicationId differs from app.json package name.
4557
+ * Render an Xcode `ExportOptions.plist` for `xcodebuild -exportArchive`.
4558
+ *
4559
+ * - `signingStyle` is always `manual` (ephemeral keychain + downloaded profile)
4560
+ * - `uploadSymbols` is emitted only for `app-store` exports
4561
+ * - `provisioningProfiles` dict maps each bundleId → profile name (one entry
4562
+ * per signed target: main app + any extensions like notification service)
4563
+ * - `compileBitcode` defaults to `false`
4624
4564
  */
4625
- const warnOnGradleMismatch = (gradleConfig, expectedPackage) => {
4626
- if (!gradleConfig?.applicationId) return Effect.void;
4627
- if (gradleConfig.applicationId === expectedPackage) return Effect.void;
4628
- return Console.warn(`Gradle applicationId "${gradleConfig.applicationId}" differs from app.json package "${expectedPackage}". The Gradle value will be used in the built APK/AAB.`);
4629
- };
4630
- /**
4631
- * Strip Groovy single-line and block comments.
4632
- * gradle-to-js chokes on comments — EAS CLI does this same pre-processing.
4633
- */
4634
- const stripGroovyComments = (text) => text.replaceAll(/\/\/.*$/gmu, "").replaceAll(/\/\*[\s\S]*?\*\//gu, "");
4635
- const parseVersionCode = (raw) => {
4636
- if (typeof raw === "number") return raw;
4637
- if (typeof raw === "string") return Number.parseInt(raw, 10) || void 0;
4638
- };
4639
- const extractGradleConfig = (parsed) => {
4640
- const defaultConfig = asRecord(asRecord(parsed["android"])?.["defaultConfig"]);
4641
- const applicationId = typeof defaultConfig?.["applicationId"] === "string" ? unquote(defaultConfig["applicationId"]) : void 0;
4642
- const versionCode = parseVersionCode(defaultConfig?.["versionCode"]);
4643
- const versionName = typeof defaultConfig?.["versionName"] === "string" ? unquote(defaultConfig["versionName"]) : void 0;
4644
- return {
4645
- ...applicationId === void 0 ? {} : { applicationId },
4646
- ...versionCode === void 0 ? {} : { versionCode },
4647
- ...versionName === void 0 ? {} : { versionName }
4648
- };
4565
+ const renderExportOptionsPlist = ({ method, teamId, provisioningProfiles, compileBitcode = false }) => {
4566
+ const lines = [
4567
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
4568
+ "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
4569
+ "<plist version=\"1.0\">",
4570
+ "<dict>",
4571
+ " <key>method</key>",
4572
+ `\t<string>${escapeXml(method)}</string>`,
4573
+ " <key>teamID</key>",
4574
+ `\t<string>${escapeXml(teamId)}</string>`,
4575
+ " <key>signingStyle</key>",
4576
+ " <string>manual</string>",
4577
+ " <key>compileBitcode</key>",
4578
+ `\t${boolTag(compileBitcode)}`,
4579
+ " <key>provisioningProfiles</key>",
4580
+ " <dict>"
4581
+ ];
4582
+ for (const { bundleId, profileName } of provisioningProfiles) lines.push(`\t\t<key>${escapeXml(bundleId)}</key>`, `\t\t<string>${escapeXml(profileName)}</string>`);
4583
+ lines.push(" </dict>");
4584
+ if (method === "app-store") lines.push(" <key>uploadSymbols</key>", " <true/>");
4585
+ lines.push("</dict>", "</plist>", "");
4586
+ return lines.join("\n");
4649
4587
  };
4650
- const unquote = (input) => input.startsWith("\"") && input.endsWith("\"") ? input.slice(1, -1) : input;
4651
4588
 
4652
4589
  //#endregion
4653
- //#region src/lib/platform-detect.ts
4654
- const PLATFORMS = ["ios", "android"];
4655
- const inferPlatforms = (config) => {
4656
- const fromConfig = config["platforms"];
4657
- if (Array.isArray(fromConfig)) return fromConfig.filter((entry) => entry === "ios" || entry === "android");
4658
- const present = [];
4659
- if (config.ios !== void 0) present.push("ios");
4660
- if (config.android !== void 0) present.push("android");
4661
- return present;
4590
+ //#region src/lib/ios-keychain.ts
4591
+ const runOrFail = (cmd, step) => Command.string(cmd).pipe(Effect.mapError((cause) => new KeychainError({ message: `keychain ${step} failed: ${String(cause)}` })));
4592
+ const listCurrentKeychains = Effect.gen(function* () {
4593
+ return (yield* runOrFail(Command.make("security", "list-keychains", "-d", "user"), "list-keychains")).split("\n").map((line) => line.trim().replace(/^"/u, "").replace(/"$/u, "")).filter((line) => line.length > 0);
4594
+ });
4595
+ const parseSigningIdentity = (output) => {
4596
+ const lines = output.split("\n");
4597
+ for (const line of lines) {
4598
+ const match = /"([^"]+)"/u.exec(line);
4599
+ if (match?.[1]) return match[1];
4600
+ }
4662
4601
  };
4663
4602
  /**
4664
- * Resolve a build platform from an explicit flag, or fall back to the Expo
4665
- * config (`expo.platforms` or the presence of `ios`/`android` sections). Prompts
4666
- * when the config declares both platforms; fails when ambiguous and prompts are
4667
- * disallowed.
4603
+ * Acquire an ephemeral macOS keychain, import a `.p12` into it, add it to the
4604
+ * user search list, and tear it all down on scope close. The keychain name is
4605
+ * namespaced as `better-update-<uuid>` and lives in `$tempDir`, so cleanup is
4606
+ * guaranteed under all termination paths.
4668
4607
  */
4669
- const detectPlatform = (explicit, config) => Effect.gen(function* () {
4670
- if (explicit !== void 0) return explicit;
4671
- const candidates = inferPlatforms(config);
4672
- if (candidates.length === 0) return yield* new BuildProfileError({ message: "Cannot infer build platform. Add an `ios` or `android` section to your Expo config, or pass --platform." });
4673
- if (candidates.length === 1) {
4674
- const [only] = candidates;
4675
- if (only === void 0) return yield* new BuildProfileError({ message: "Internal: empty platform candidate list." });
4676
- return only;
4677
- }
4678
- if (!(yield* InteractiveMode).allow) return yield* new BuildProfileError({ message: `Multiple platforms detected (${candidates.join(", ")}). Pass --platform explicitly when running non-interactively.` });
4679
- return yield* promptSelect("Which platform to build?", PLATFORMS.filter((entry) => candidates.includes(entry)).map((entry) => ({
4680
- value: entry,
4681
- label: entry
4682
- })));
4683
- });
4608
+ const acquireKeychain = ({ tempDir, p12Path, p12Password }) => {
4609
+ const keychainName = `better-update-${randomUUID()}.keychain-db`;
4610
+ const keychainPath = path.join(tempDir, keychainName);
4611
+ const keychainPassword = randomBytes(32).toString("hex");
4612
+ return Effect.acquireRelease(Effect.gen(function* () {
4613
+ const priorKeychains = yield* listCurrentKeychains;
4614
+ yield* runOrFail(Command.make("security", "create-keychain", "-p", keychainPassword, keychainPath), "create-keychain");
4615
+ yield* runOrFail(Command.make("security", "unlock-keychain", "-p", keychainPassword, keychainPath), "unlock-keychain");
4616
+ yield* runOrFail(Command.make("security", "set-keychain-settings", "-t", "3600", "-l", keychainPath), "set-keychain-settings");
4617
+ yield* runOrFail(Command.make("security", "import", p12Path, "-k", keychainPath, "-P", p12Password, "-T", "/usr/bin/codesign"), "import");
4618
+ yield* runOrFail(Command.make("security", "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", keychainPassword, keychainPath), "set-key-partition-list");
4619
+ yield* runOrFail(Command.make("security", "list-keychains", "-d", "user", "-s", keychainPath, ...priorKeychains), "list-keychains -s (add)");
4620
+ const signingIdentity = parseSigningIdentity(yield* runOrFail(Command.make("security", "find-identity", "-v", "-p", "codesigning", keychainPath), "find-identity"));
4621
+ if (!signingIdentity) return yield* new KeychainError({ message: "No code signing identity found after importing .p12 into ephemeral keychain." });
4622
+ return {
4623
+ handle: {
4624
+ keychainName,
4625
+ keychainPath,
4626
+ signingIdentity
4627
+ },
4628
+ priorKeychains
4629
+ };
4630
+ }), ({ priorKeychains }) => Effect.gen(function* () {
4631
+ yield* Command.string(Command.make("security", "list-keychains", "-d", "user", "-s", ...priorKeychains)).pipe(Effect.catchAll(() => Effect.void));
4632
+ yield* Command.string(Command.make("security", "delete-keychain", keychainPath)).pipe(Effect.catchAll(() => Effect.void));
4633
+ })).pipe(Effect.map(({ handle }) => handle));
4634
+ };
4684
4635
 
4685
4636
  //#endregion
4686
- //#region src/lib/repo-clean.ts
4687
- const MAX_FILES_SHOWN = 10;
4688
- const readPorcelain = (projectRoot) => Command.make("git", "status", "--porcelain").pipe(Command.workingDirectory(projectRoot), Command.string, Effect.map((output) => output.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0)), Effect.catchAll(() => Effect.succeed([])));
4637
+ //#region src/lib/plist.ts
4638
+ const plist = typeof plistMod.parse === "function" ? plistMod : plistMod.default;
4689
4639
  /**
4690
- * Refuse to proceed when the working tree has uncommitted changes. Skipped when
4691
- * `allowDirty` is true. In interactive mode, prompts the user to confirm; in
4692
- * non-interactive mode, fails with `DirtyRepoError`.
4640
+ * Parse an XML plist string into a typed object.
4641
+ * Throws on malformed XML callers should wrap in Effect.try.
4693
4642
  */
4694
- const ensureRepoClean = ({ projectRoot, allowDirty, label }) => Effect.gen(function* () {
4695
- if (allowDirty) return;
4696
- const dirty = yield* readPorcelain(projectRoot);
4697
- if (dirty.length === 0) return;
4698
- const preview = dirty.slice(0, MAX_FILES_SHOWN).join("\n ");
4699
- const overflow = dirty.length > MAX_FILES_SHOWN ? `\n ... and ${String(dirty.length - MAX_FILES_SHOWN)} more` : "";
4700
- yield* Console.error(`Uncommitted changes (${String(dirty.length)} file(s)):\n ${preview}${overflow}`);
4701
- if (!(yield* InteractiveMode).allow) {
4702
- yield* new DirtyRepoError({ message: `Refusing to ${label} with a dirty working tree. Commit your changes or pass --allow-dirty.` });
4703
- return;
4704
- }
4705
- if (!(yield* promptConfirm(`Continue ${label} with uncommitted changes?`, { initialValue: false }))) yield* new DirtyRepoError({ message: `${label} cancelled by user.` });
4706
- });
4643
+ const parsePlistXml = (xml) => plist.parse(xml);
4644
+ /**
4645
+ * Parse a binary plist buffer into a typed object.
4646
+ * Uses bplist-parser for Apple's binary plist format.
4647
+ */
4648
+ const parsePlistBinary = (buffer) => {
4649
+ const [result] = __require("bplist-parser").parseBuffer(buffer);
4650
+ return result;
4651
+ };
4652
+ const BPLIST_MAGIC = Buffer.from("bplist00");
4653
+ /**
4654
+ * Auto-detect plist format (binary vs XML) and parse accordingly.
4655
+ */
4656
+ const parsePlist = (data) => data.subarray(0, 8).equals(BPLIST_MAGIC) ? parsePlistBinary(data) : parsePlistXml(data.toString("utf8"));
4707
4657
 
4708
4658
  //#endregion
4709
- //#region src/lib/fingerprint.ts
4710
- var FingerprintError = class extends Data.TaggedError("FingerprintError") {};
4711
- const runFingerprintFull = (projectRoot) => Effect.gen(function* () {
4712
- const cmd = Command.make("bunx", "@expo/fingerprint", projectRoot).pipe(Command.workingDirectory(projectRoot));
4713
- const stdout = yield* Command.string(cmd).pipe(Effect.mapError((cause) => new FingerprintError({ message: `Failed to run "@expo/fingerprint": ${cause.message}` })));
4659
+ //#region src/lib/ios-provisioning.ts
4660
+ const getString = (obj, key) => {
4661
+ const value = obj[key];
4662
+ return typeof value === "string" ? value : void 0;
4663
+ };
4664
+ const getFirstArrayString = (obj, key) => {
4665
+ const value = obj[key];
4666
+ if (Array.isArray(value) && typeof value[0] === "string") return value[0];
4667
+ };
4668
+ /**
4669
+ * Extract `UUID`, `Name`, and the first `TeamIdentifier` from the XML plist
4670
+ * output of `security cms -D -i <path>`. Returns `ProvisioningError` when any
4671
+ * of the three fields are missing.
4672
+ */
4673
+ const extractProvisioningInfo = (plistXml) => Effect.gen(function* () {
4714
4674
  const parsed = yield* Effect.try({
4715
- try: () => JSON.parse(stdout),
4716
- catch: () => new FingerprintError({ message: "Failed to parse @expo/fingerprint output as JSON." })
4675
+ try: () => parsePlistXml(plistXml),
4676
+ catch: (error) => new ProvisioningError({ message: `Failed to parse provisioning profile plist: ${error instanceof Error ? error.message : String(error)}` })
4717
4677
  });
4718
- if (!isRecord(parsed)) return yield* new FingerprintError({ message: "@expo/fingerprint output was not a JSON object." });
4719
- const { hash } = parsed;
4720
- if (typeof hash !== "string" || hash.length === 0) return yield* new FingerprintError({ message: "@expo/fingerprint output did not contain a \"hash\" string field." });
4721
- const sourcesRaw = parsed["sources"];
4678
+ const uuid = getString(parsed, "UUID");
4679
+ const name = getString(parsed, "Name");
4680
+ const teamId = getFirstArrayString(parsed, "TeamIdentifier");
4681
+ if (!uuid || !name || !teamId) return yield* new ProvisioningError({ message: `Failed to parse provisioning profile: missing ${uuid ? "" : "UUID "}${name ? "" : "Name "}${teamId ? "" : "TeamIdentifier "}`.trim() });
4722
4682
  return {
4723
- hash,
4724
- sources: Array.isArray(sourcesRaw) ? sourcesRaw : []
4683
+ uuid,
4684
+ name,
4685
+ teamId
4725
4686
  };
4726
4687
  });
4727
-
4728
- //#endregion
4729
- //#region src/lib/runtime-version.ts
4730
- const resolveRuntimeVersion = ({ raw, appVersion, projectRoot }) => Effect.gen(function* () {
4731
- if (typeof raw === "string") return raw;
4732
- if (raw === void 0) return yield* new RuntimeVersionError({ message: "No runtimeVersion configured in expo section of app.json." });
4733
- const { policy } = raw;
4734
- if (policy === "appVersion") {
4735
- if (appVersion === void 0) return yield* new RuntimeVersionError({ message: "runtimeVersion policy is \"appVersion\" but expo.version is missing in app.json." });
4736
- return appVersion;
4737
- }
4738
- if (policy === "fingerprint") return yield* runFingerprintFull(projectRoot).pipe(Effect.map((result) => result.hash), Effect.mapError((cause) => new RuntimeVersionError({ message: cause.message })));
4739
- if (policy === "nativeVersion") return yield* new RuntimeVersionError({ message: "runtimeVersion policy \"nativeVersion\" is not supported. Set a static runtimeVersion string in your Expo config." });
4740
- return yield* new RuntimeVersionError({ message: `Unsupported runtimeVersion policy "${policy}". Use a static string, "appVersion", or "fingerprint".` });
4741
- });
4742
-
4743
- //#endregion
4744
- //#region src/lib/temp-dir.ts
4688
+ const userProvisioningProfilesDir = () => path.join(os.homedir(), "Library", "MobileDevice", "Provisioning Profiles");
4745
4689
  /**
4746
- * Create a scoped temp directory prefixed with "better-update-" and `chmod 0o700`
4747
- * it so only the current user can read its contents. The directory and all files
4748
- * inside it are removed when the enclosing scope closes.
4690
+ * Scoped installation of a provisioning profile: parses its metadata via
4691
+ * `security cms -D -i`, copies it into `~/Library/MobileDevice/Provisioning Profiles`
4692
+ * under `<uuid>.mobileprovision`, and removes the copy on scope close — but
4693
+ * only if we installed it. If the target file already existed when we arrived
4694
+ * (e.g., Xcode had it), we leave both the file and the contents untouched.
4749
4695
  */
4750
- const acquireBuildTempDir = Effect.gen(function* () {
4696
+ const installProvisioningProfile = ({ profilePath }) => Effect.acquireRelease(Effect.gen(function* () {
4751
4697
  const fs = yield* FileSystem.FileSystem;
4752
- const dir = yield* fs.makeTempDirectoryScoped({ prefix: "better-update-" });
4753
- yield* fs.chmod(dir, 448);
4754
- return dir;
4755
- });
4756
-
4757
- //#endregion
4758
- //#region src/lib/android-keystore.ts
4759
- const DEFAULT_KEYSTORE_VALIDITY_DAYS = 1e4;
4760
- const renderDistinguishedName = (params) => `CN=${params.commonName}, O=${params.organization}`;
4761
- const generateAndroidKeystore = (input) => Command.exitCode(Command.make("keytool", "-genkeypair", "-v", "-storetype", "JKS", "-keystore", input.outputPath, "-alias", input.keyAlias, "-keyalg", "RSA", "-keysize", "2048", "-validity", String(input.validityDays ?? DEFAULT_KEYSTORE_VALIDITY_DAYS), "-storepass", input.storePassword, "-keypass", input.keyPassword, "-dname", renderDistinguishedName({
4762
- commonName: input.commonName,
4763
- organization: input.organization
4764
- }), "-noprompt").pipe(Command.stdout("inherit"), Command.stderr("inherit"))).pipe(Effect.mapError((cause) => new BuildFailedError({
4765
- step: "generate android keystore",
4766
- exitCode: 1,
4767
- message: `generate android keystore failed to spawn: ${String(cause)}`
4768
- })), Effect.flatMap((code) => code === 0 ? Effect.void : Effect.fail(new BuildFailedError({
4769
- step: "generate android keystore",
4770
- exitCode: code,
4771
- message: `generate android keystore exited with code ${code}`
4772
- }))));
4698
+ const info = yield* extractProvisioningInfo(yield* Command.string(Command.make("security", "cms", "-D", "-i", profilePath)).pipe(Effect.mapError((cause) => new ProvisioningError({ message: `security cms -D failed for ${profilePath}: ${String(cause)}` }))));
4699
+ const targetDir = userProvisioningProfilesDir();
4700
+ const installedPath = path.join(targetDir, `${info.uuid}.mobileprovision`);
4701
+ yield* fs.makeDirectory(targetDir, { recursive: true }).pipe(Effect.catchAll((cause) => new ProvisioningError({ message: `Failed to create provisioning profiles dir: ${String(cause)}` })));
4702
+ if (yield* fs.exists(installedPath).pipe(Effect.orElseSucceed(() => false))) return {
4703
+ ...info,
4704
+ installedPath,
4705
+ ownsInstallation: false
4706
+ };
4707
+ yield* fs.copyFile(profilePath, installedPath).pipe(Effect.catchAll((cause) => new ProvisioningError({ message: `Failed to copy provisioning profile into ${installedPath}: ${String(cause)}` })));
4708
+ return {
4709
+ ...info,
4710
+ installedPath,
4711
+ ownsInstallation: true
4712
+ };
4713
+ }), (acquired) => Effect.gen(function* () {
4714
+ if (!acquired.ownsInstallation) return;
4715
+ yield* (yield* FileSystem.FileSystem).remove(acquired.installedPath).pipe(Effect.catchAll(() => Effect.void));
4716
+ })).pipe(Effect.map(({ uuid, name, teamId, installedPath }) => ({
4717
+ uuid,
4718
+ name,
4719
+ teamId,
4720
+ installedPath
4721
+ })));
4773
4722
 
4774
4723
  //#endregion
4775
- //#region src/lib/apple-pem.ts
4776
- const PEM_HEADER = "-----BEGIN PRIVATE KEY-----";
4777
- const PEM_FOOTER = "-----END PRIVATE KEY-----";
4778
- const pemToPkcs8Der = (pem) => {
4779
- const normalized = pem.replaceAll("\r\n", "\n").trim();
4780
- const start = normalized.indexOf(PEM_HEADER);
4781
- const end = normalized.indexOf(PEM_FOOTER);
4782
- if (start === -1 || end === -1 || end <= start) return null;
4783
- const body = normalized.slice(start + 27, end).replaceAll(/\s+/gu, "").trim();
4784
- if (body.length === 0) return null;
4785
- try {
4786
- return fromBase64(body);
4787
- } catch {
4788
- return null;
4724
+ //#region src/lib/post-build-validation.ts
4725
+ const validateOneBundle = (bundleDir, expectedByBundleId, expectedTeamId) => Effect.gen(function* () {
4726
+ const bundleId = yield* readBundleId(bundleDir).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
4727
+ if (!bundleId) return {
4728
+ bundleId: void 0,
4729
+ warnings: [`Missing CFBundleIdentifier in Info.plist at ${bundleDir}`]
4730
+ };
4731
+ const expected = expectedByBundleId.get(bundleId);
4732
+ if (!expected) return {
4733
+ bundleId,
4734
+ warnings: [`Unexpected signed bundle "${bundleId}" found in archive at ${bundleDir}`]
4735
+ };
4736
+ return {
4737
+ bundleId,
4738
+ warnings: yield* validateEmbeddedProfile(bundleDir, expected.profileUuid, expectedTeamId, bundleId).pipe(Effect.catchAll(() => Effect.succeed([])))
4739
+ };
4740
+ });
4741
+ const validateIosBuild = (params) => Effect.gen(function* () {
4742
+ const appDir = yield* findAppDirectory$1(params.archivePath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
4743
+ if (!appDir) return {
4744
+ passed: false,
4745
+ warnings: ["Could not locate .app bundle in archive — skipping post-build validation"]
4746
+ };
4747
+ const bundleDirs = yield* listSignedBundleDirs(appDir).pipe(Effect.catchAll(() => Effect.succeed([appDir])));
4748
+ const expectedByBundleId = new Map(params.expectedTargets.map((target) => [target.bundleId, target]));
4749
+ const perBundle = yield* Effect.forEach(bundleDirs, (bundleDir) => validateOneBundle(bundleDir, expectedByBundleId, params.expectedTeamId));
4750
+ const warnings = perBundle.flatMap((entry) => [...entry.warnings]);
4751
+ const validatedBundleIds = new Set(perBundle.map((entry) => entry.bundleId).filter((id) => id !== void 0));
4752
+ for (const expected of params.expectedTargets) if (!validatedBundleIds.has(expected.bundleId)) warnings.push(`Expected signed target "${expected.bundleId}" was not found in the archive.`);
4753
+ if (warnings.length > 0) {
4754
+ yield* Console.warn("Post-build validation warnings:");
4755
+ for (const warning of warnings) yield* Console.warn(` - ${warning}`);
4789
4756
  }
4790
- };
4757
+ return {
4758
+ passed: warnings.length === 0,
4759
+ warnings
4760
+ };
4761
+ });
4762
+ const findAppDirectory$1 = (archivePath) => Effect.gen(function* () {
4763
+ const fs = yield* FileSystem.FileSystem;
4764
+ const productsDir = path.join(archivePath, "Products", "Applications");
4765
+ const appEntry = (yield* fs.readDirectory(productsDir)).find((entry) => entry.endsWith(".app"));
4766
+ if (!appEntry) return yield* Effect.fail("No .app found");
4767
+ return path.join(productsDir, appEntry);
4768
+ });
4769
+ /**
4770
+ * Return the main `.app` plus every `.appex` extension under `<app>/PlugIns/`.
4771
+ * Each returned path is a signed bundle that should carry its own embedded
4772
+ * provisioning profile + Info.plist with CFBundleIdentifier.
4773
+ */
4774
+ const listSignedBundleDirs = (appDir) => Effect.gen(function* () {
4775
+ const fs = yield* FileSystem.FileSystem;
4776
+ const plugInsDir = path.join(appDir, "PlugIns");
4777
+ if (!(yield* fs.exists(plugInsDir).pipe(Effect.catchAll(() => Effect.succeed(false))))) return [appDir];
4778
+ return [appDir, ...(yield* fs.readDirectory(plugInsDir)).filter((entry) => entry.endsWith(".appex")).map((entry) => path.join(plugInsDir, entry))];
4779
+ });
4780
+ const readBundleId = (bundleDir) => Effect.gen(function* () {
4781
+ const fs = yield* FileSystem.FileSystem;
4782
+ const plistPath = path.join(bundleDir, "Info.plist");
4783
+ const data = yield* fs.readFile(plistPath);
4784
+ const bundleId = parsePlist(Buffer.from(data))["CFBundleIdentifier"];
4785
+ return typeof bundleId === "string" ? bundleId : void 0;
4786
+ });
4787
+ const validateEmbeddedProfile = (bundleDir, expectedUuid, expectedTeamId, bundleId) => Effect.gen(function* () {
4788
+ const warnings = [];
4789
+ const profilePath = path.join(bundleDir, "embedded.mobileprovision");
4790
+ const parsed = parsePlistXml(yield* Command.string(Command.make("security", "cms", "-D", "-i", profilePath)));
4791
+ const actualUuid = parsed["UUID"];
4792
+ if (typeof actualUuid === "string" && actualUuid !== expectedUuid) warnings.push(`[${bundleId}] Profile UUID mismatch: expected "${expectedUuid}", got "${actualUuid}"`);
4793
+ const teamIdentifiers = parsed["TeamIdentifier"];
4794
+ if (Array.isArray(teamIdentifiers)) {
4795
+ const [actualTeamId] = teamIdentifiers;
4796
+ if (typeof actualTeamId === "string" && actualTeamId !== expectedTeamId) warnings.push(`[${bundleId}] Team ID mismatch: expected "${expectedTeamId}", got "${actualTeamId}"`);
4797
+ }
4798
+ const expirationDate = parsed["ExpirationDate"];
4799
+ if (expirationDate instanceof Date && expirationDate.getTime() < Date.now()) warnings.push(`[${bundleId}] Embedded provisioning profile expired on ${expirationDate.toISOString()}`);
4800
+ return warnings;
4801
+ });
4791
4802
 
4792
4803
  //#endregion
4793
- //#region src/lib/apple-asc-jwt.ts
4794
- var AppleAuthError = class extends Data.TaggedError("AppleAuthError") {};
4795
- const MAX_JWT_LIFETIME_SECONDS = 1200;
4796
- const asArrayBuffer = (bytes) => {
4797
- const buffer = new ArrayBuffer(bytes.byteLength);
4798
- new Uint8Array(buffer).set(bytes);
4799
- return buffer;
4800
- };
4801
- const signAscJwt = (credentials) => Effect.gen(function* () {
4802
- const der = pemToPkcs8Der(credentials.p8Pem);
4803
- if (der === null) return yield* Effect.fail(new AppleAuthError({ cause: /* @__PURE__ */ new Error("Invalid .p8 PEM") }));
4804
- const header = {
4805
- alg: "ES256",
4806
- kid: credentials.keyId,
4807
- typ: "JWT"
4808
- };
4809
- const now = Math.floor(Date.now() / 1e3);
4810
- const payload = {
4811
- iss: credentials.issuerId,
4812
- iat: now,
4813
- exp: now + MAX_JWT_LIFETIME_SECONDS,
4814
- aud: "appstoreconnect-v1"
4815
- };
4816
- const signingInput = `${toBase64Url(new TextEncoder().encode(JSON.stringify(header)))}.${toBase64Url(new TextEncoder().encode(JSON.stringify(payload)))}`;
4817
- const key = yield* Effect.tryPromise({
4818
- try: async () => crypto.subtle.importKey("pkcs8", asArrayBuffer(der), {
4819
- name: "ECDSA",
4820
- namedCurve: "P-256"
4821
- }, false, ["sign"]),
4822
- catch: (cause) => new AppleAuthError({ cause })
4823
- });
4824
- const signature = yield* Effect.tryPromise({
4825
- try: async () => crypto.subtle.sign({
4826
- name: "ECDSA",
4827
- hash: "SHA-256"
4828
- }, key, new TextEncoder().encode(signingInput)),
4829
- catch: (cause) => new AppleAuthError({ cause })
4830
- });
4831
- return `${signingInput}.${toBase64Url(new Uint8Array(signature))}`;
4804
+ //#region src/lib/xcode-targets.ts
4805
+ /** Product types whose targets require code-signing with a provisioning profile. */
4806
+ const SIGNED_PRODUCT_TYPES = new Set([
4807
+ "com.apple.product-type.application",
4808
+ "com.apple.product-type.app-extension",
4809
+ "com.apple.product-type.messages-extension",
4810
+ "com.apple.product-type.tv-app-extension",
4811
+ "com.apple.product-type.watchapp2",
4812
+ "com.apple.product-type.watchkit2-extension"
4813
+ ]);
4814
+ const loadXcodeModule = () => __require("xcode");
4815
+ /**
4816
+ * Strip surrounding quotes from a pbxproj string value. `xcode` returns values
4817
+ * verbatim from the project file, so identifiers like productType are usually
4818
+ * wrapped in double quotes (e.g. `"com.apple.product-type.application"`).
4819
+ */
4820
+ const unquote$1 = (value) => value.length >= 2 && value.startsWith("\"") && value.endsWith("\"") ? value.slice(1, -1) : value;
4821
+ const findXcodeProjectDir = (iosDir) => Effect.gen(function* () {
4822
+ const projectDir = (yield* (yield* FileSystem.FileSystem).readDirectory(iosDir).pipe(Effect.mapError((cause) => new XcodeProjectError({ message: `Failed to read ${iosDir}: ${String(cause)}` })))).find((entry) => entry.endsWith(".xcodeproj"));
4823
+ if (!projectDir) return yield* new XcodeProjectError({ message: `No .xcodeproj directory found under ${iosDir}. Did "expo prebuild" run?` });
4824
+ return path.join(iosDir, projectDir);
4825
+ });
4826
+ const parseProject = (pbxprojPath) => Effect.try({
4827
+ try: () => loadXcodeModule().project(pbxprojPath).parseSync(),
4828
+ catch: (cause) => new XcodeProjectError({ message: `Failed to parse ${pbxprojPath}: ${cause instanceof Error ? cause.message : String(cause)}` })
4829
+ });
4830
+ const collectConfigUuidsForTarget = (project, target, configurationName) => {
4831
+ const configList = project.pbxXCConfigurationList()[target.buildConfigurationList];
4832
+ if (!configList || typeof configList === "string") return [];
4833
+ const buildConfigSection = project.pbxXCBuildConfigurationSection();
4834
+ return configList.buildConfigurations.map((entry) => entry.value).filter((uuid) => {
4835
+ const cfg = buildConfigSection[uuid];
4836
+ if (!cfg || typeof cfg === "string") return false;
4837
+ return unquote$1(cfg.name) === configurationName;
4838
+ });
4839
+ };
4840
+ const extractBundleIdForConfig = (project, configUuid) => {
4841
+ const cfg = project.pbxXCBuildConfigurationSection()[configUuid];
4842
+ if (!cfg || typeof cfg === "string") return;
4843
+ const raw = cfg.buildSettings["PRODUCT_BUNDLE_IDENTIFIER"];
4844
+ if (typeof raw !== "string") return;
4845
+ return unquote$1(raw);
4846
+ };
4847
+ const collectSignedTargets = (project, pbxprojPath, configurationName) => Effect.gen(function* () {
4848
+ const results = [];
4849
+ const nativeTargets = project.pbxNativeTargetSection();
4850
+ for (const [uuid, entry] of Object.entries(nativeTargets)) {
4851
+ if (uuid.endsWith("_comment") || typeof entry === "string") continue;
4852
+ const productType = unquote$1(entry.productType);
4853
+ if (!SIGNED_PRODUCT_TYPES.has(productType)) continue;
4854
+ const configUuids = collectConfigUuidsForTarget(project, entry, configurationName);
4855
+ const [firstConfigUuid] = configUuids;
4856
+ if (!firstConfigUuid) return yield* new XcodeProjectError({ message: `Target "${unquote$1(entry.name)}" has no "${configurationName}" build configuration in ${pbxprojPath}.` });
4857
+ const bundleId = extractBundleIdForConfig(project, firstConfigUuid);
4858
+ if (!bundleId) return yield* new XcodeProjectError({ message: `Target "${unquote$1(entry.name)}" is missing PRODUCT_BUNDLE_IDENTIFIER in the "${configurationName}" configuration.` });
4859
+ results.push({
4860
+ targetName: unquote$1(entry.name),
4861
+ bundleId,
4862
+ productType,
4863
+ buildConfigurationUuids: configUuids
4864
+ });
4865
+ }
4866
+ return results;
4867
+ });
4868
+ /**
4869
+ * Enumerate code-signed native targets (main app + extensions) declared in the
4870
+ * single `.xcodeproj` under `iosDir`, restricted to a given build configuration
4871
+ * (e.g. "Release"). Pod targets and other library product types are excluded.
4872
+ *
4873
+ * The returned `buildConfigurationUuids` list is the set of XCBuildConfiguration
4874
+ * UUIDs that belong to this target *and* match `configurationName` — the
4875
+ * per-target signing mutator writes settings into exactly those configurations.
4876
+ */
4877
+ const discoverSignedTargets = (options) => Effect.gen(function* () {
4878
+ const projectDir = yield* findXcodeProjectDir(options.iosDir);
4879
+ const pbxprojPath = path.join(projectDir, "project.pbxproj");
4880
+ const results = yield* collectSignedTargets(yield* parseProject(pbxprojPath), pbxprojPath, options.configurationName);
4881
+ if (results.length === 0) return yield* new XcodeProjectError({ message: `No signed native targets found in ${pbxprojPath} for configuration "${options.configurationName}".` });
4882
+ return results;
4832
4883
  });
4833
4884
 
4834
4885
  //#endregion
4835
- //#region src/lib/apple-asc-client.ts
4836
- var AscApiError = class extends Data.TaggedError("AscApiError") {};
4837
- var AscNetworkError = class extends Data.TaggedError("AscNetworkError") {};
4838
- const API_BASE = "https://api.appstoreconnect.apple.com";
4839
- const extractErrors = (body) => {
4840
- if (!isRecord(body) || !Array.isArray(body["errors"])) return [];
4841
- return body["errors"].filter((value) => isRecord(value));
4886
+ //#region src/lib/xcpretty-formatter.ts
4887
+ /**
4888
+ * Create a stateful xcodebuild output formatter backed by `@expo/xcpretty`.
4889
+ * Each `pipe(line)` call may return zero or more formatted lines — zero means
4890
+ * the line was suppressed (e.g., intermediate compiler invocations).
4891
+ */
4892
+ const createXcodebuildFormatter = (projectRoot) => {
4893
+ const formatter = ExpoRunFormatter.create(projectRoot);
4894
+ return {
4895
+ pipe: (line) => formatter.pipe(line),
4896
+ getBuildSummary: () => formatter.getBuildSummary()
4897
+ };
4842
4898
  };
4843
- const parseApiError = (response, body, raw) => {
4844
- const [first] = extractErrors(body);
4845
- return new AscApiError({
4846
- status: response.status,
4847
- message: first?.detail ?? first?.title ?? response.statusText,
4848
- code: first?.code,
4849
- raw
4899
+
4900
+ //#endregion
4901
+ //#region src/commands/build/ios.ts
4902
+ const findXcworkspace = (iosDir) => Effect.gen(function* () {
4903
+ const workspace = (yield* (yield* FileSystem.FileSystem).readDirectory(iosDir)).find((entry) => entry.endsWith(".xcworkspace"));
4904
+ if (!workspace) return yield* new BuildFailedError({
4905
+ step: "detect xcworkspace",
4906
+ exitCode: 1,
4907
+ message: `No .xcworkspace found under ${iosDir}. Did "pod install" run?`
4850
4908
  });
4851
- };
4852
- const fetchRaw = (jwt, path, init) => Effect.gen(function* () {
4853
- const response = yield* Effect.tryPromise({
4854
- try: async () => fetch(`${API_BASE}${path}`, {
4855
- method: init?.method ?? "GET",
4856
- ...init?.body === void 0 ? {} : { body: init.body },
4857
- headers: {
4858
- authorization: `Bearer ${jwt}`,
4859
- "content-type": "application/json",
4860
- accept: "application/json"
4909
+ return workspace;
4910
+ });
4911
+ const prebuildAndPods = (params) => Effect.gen(function* () {
4912
+ yield* runStep(Command.make("bunx", "expo", "prebuild", "--platform", "ios", "--clean").pipe(Command.workingDirectory(params.projectRoot), Command.env(params.commandEnv)), "expo prebuild ios");
4913
+ yield* runStep(Command.make("pod", "install").pipe(Command.workingDirectory(params.iosDir), Command.env(params.commandEnv)), "pod install");
4914
+ });
4915
+ const findAppDirectory = (root) => Effect.gen(function* () {
4916
+ const fs = yield* FileSystem.FileSystem;
4917
+ const stack = [root];
4918
+ let depth = 0;
4919
+ while (stack.length > 0 && depth < 6) {
4920
+ const layer = stack.splice(0);
4921
+ depth += 1;
4922
+ for (const dir of layer) {
4923
+ const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => []));
4924
+ for (const entry of entries) {
4925
+ const full = path.join(dir, entry);
4926
+ if (entry.endsWith(".app")) return full;
4927
+ const stat = yield* fs.stat(full).pipe(Effect.option);
4928
+ if (stat._tag === "Some" && stat.value.type === "Directory") stack.push(full);
4861
4929
  }
4862
- }),
4863
- catch: (cause) => new AscNetworkError({ cause })
4864
- });
4865
- const text = yield* Effect.tryPromise({
4866
- try: async () => response.text(),
4867
- catch: (cause) => new AscNetworkError({ cause })
4868
- });
4869
- const body = text.length === 0 ? {} : JSON.parse(text);
4870
- if (!response.ok) return yield* Effect.fail(parseApiError(response, body, text));
4871
- return body;
4930
+ }
4931
+ }
4932
+ return yield* new ArtifactNotFoundError({ message: `No .app bundle found under "${root}".` });
4872
4933
  });
4873
- const toAscCertificate = (value) => {
4874
- if (!isRecord(value)) return null;
4875
- const { id, attributes } = value;
4876
- if (typeof id !== "string" || !isRecord(attributes)) return null;
4877
- const { serialNumber, certificateType, expirationDate, certificateContent, displayName } = attributes;
4878
- if (typeof serialNumber !== "string" || typeof certificateType !== "string" || typeof expirationDate !== "string") return null;
4879
- return {
4880
- id,
4881
- serialNumber,
4882
- certificateType,
4883
- expirationDate,
4884
- certificateContent: typeof certificateContent === "string" ? certificateContent : null,
4885
- displayName: typeof displayName === "string" ? displayName : null
4886
- };
4887
- };
4888
- const toAscBundleId = (value) => {
4889
- if (!isRecord(value)) return null;
4890
- const { id, attributes } = value;
4891
- if (typeof id !== "string" || !isRecord(attributes)) return null;
4892
- const { identifier, name } = attributes;
4893
- if (typeof identifier !== "string" || typeof name !== "string") return null;
4934
+ const runIosSimulatorBuild = (input) => Effect.gen(function* () {
4935
+ const { projectRoot, iosProfile, envVars, tempDir } = input;
4936
+ const runtime = yield* CliRuntime;
4937
+ const iosDir = path.join(projectRoot, "ios");
4938
+ const commandEnv = yield* runtime.commandEnvironment(envVars);
4939
+ yield* prebuildAndPods({
4940
+ projectRoot,
4941
+ iosDir,
4942
+ commandEnv
4943
+ });
4944
+ const workspaceFilename = yield* findXcworkspace(iosDir);
4945
+ const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
4946
+ const configuration = iosProfile.buildConfiguration ?? "Release";
4947
+ const derivedDataPath = path.join(tempDir, "derived-data");
4948
+ const buildCmd = Command.make("xcodebuild", "-workspace", workspaceFilename, "-scheme", scheme, "-configuration", configuration, "-sdk", "iphonesimulator", "-destination", "generic/platform=iOS Simulator", "-derivedDataPath", derivedDataPath, "build", "CODE_SIGNING_ALLOWED=NO", "CODE_SIGNING_REQUIRED=NO", "CODE_SIGN_IDENTITY=").pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
4949
+ const formatter = input.rawOutput ? void 0 : createXcodebuildFormatter(projectRoot);
4950
+ yield* formatter ? runStepFormatted(buildCmd, "xcodebuild build (simulator)", formatter) : runStep(buildCmd, "xcodebuild build (simulator)");
4951
+ const appDir = yield* findAppDirectory(path.join(derivedDataPath, "Build", "Products", `${configuration}-iphonesimulator`));
4952
+ const archiveName = `${path.basename(appDir, ".app")}-simulator.tar.gz`;
4953
+ const archivePath = path.join(tempDir, archiveName);
4954
+ yield* runStep(Command.make("tar", "-czf", archivePath, "-C", path.dirname(appDir), path.basename(appDir)).pipe(Command.env(commandEnv)), "tar simulator .app");
4955
+ const { sha256, byteSize } = yield* sha256File(archivePath);
4894
4956
  return {
4895
- id,
4896
- identifier,
4897
- name
4957
+ artifactPath: archivePath,
4958
+ byteSize,
4959
+ sha256
4898
4960
  };
4961
+ });
4962
+ const fetchAllCredentials = (params) => params.input.credentialsSource === "local" ? loadLocalIosCredentials({
4963
+ projectRoot: params.input.projectRoot,
4964
+ mainBundleIdentifier: params.mainBundleIdentifier
4965
+ }) : downloadIosCredentials(params.api, {
4966
+ projectId: params.input.projectId,
4967
+ mainBundleIdentifier: params.mainBundleIdentifier,
4968
+ bundleIdentifiers: params.allBundleIdentifiers,
4969
+ distribution: params.input.iosProfile.distribution,
4970
+ tempDir: params.input.tempDir
4971
+ });
4972
+ const installPerTarget = (signedTargets, credentials, credentialsSource) => Effect.gen(function* () {
4973
+ const profileByBundle = new Map(credentials.profiles.map((profile) => [profile.bundleIdentifier, profile]));
4974
+ const missing = signedTargets.filter((target) => !profileByBundle.has(target.bundleId));
4975
+ if (missing.length > 0) {
4976
+ const list = missing.map((target) => `"${target.targetName}" (${target.bundleId})`).join(", ");
4977
+ const hint = credentialsSource === "local" ? "Add the missing entries to credentials.json under ios.additionalProvisioningProfiles." : "Register the bundle identifier(s) in the dashboard and bind a provisioning profile.";
4978
+ return yield* new MissingCredentialsError({
4979
+ message: `Missing provisioning profile for signed target(s): ${list}.`,
4980
+ hint
4981
+ });
4982
+ }
4983
+ return yield* Effect.forEach(signedTargets, (target) => installProfileForTarget(target, profileByBundle));
4984
+ });
4985
+ const installProfileForTarget = (target, profileByBundle) => {
4986
+ const profile = profileByBundle.get(target.bundleId);
4987
+ if (!profile) return Effect.fail(new ProvisioningError({ message: `Internal: no profile for ${target.bundleId} after pre-check.` }));
4988
+ return installProvisioningProfile({ profilePath: profile.profilePath }).pipe(Effect.map((installed) => ({
4989
+ target,
4990
+ profile,
4991
+ installed
4992
+ })));
4899
4993
  };
4900
- const PROFILE_TYPES = [
4901
- "IOS_APP_ADHOC",
4902
- "IOS_APP_DEVELOPMENT",
4903
- "IOS_APP_STORE",
4904
- "IOS_APP_INHOUSE"
4905
- ];
4906
- const asProfileType = (value) => {
4907
- const match = PROFILE_TYPES.find((entry) => entry === value);
4908
- return match === void 0 ? null : match;
4909
- };
4910
- const toAscProfile = (value) => {
4911
- if (!isRecord(value)) return null;
4912
- const { id, attributes } = value;
4913
- if (typeof id !== "string" || !isRecord(attributes)) return null;
4914
- const { name, uuid, expirationDate, profileContent } = attributes;
4915
- const profileType = asProfileType(attributes["profileType"]);
4916
- if (typeof name !== "string" || typeof uuid !== "string" || typeof expirationDate !== "string" || typeof profileContent !== "string" || profileType === null) return null;
4994
+ const pickMainTarget = (signedTargets) => signedTargets.find((target) => target.productType === "com.apple.product-type.application") ?? signedTargets[0];
4995
+ const runIosDeviceBuild = (input) => Effect.gen(function* () {
4996
+ const { api, tempDir, projectRoot, iosProfile, envVars } = input;
4997
+ const runtime = yield* CliRuntime;
4998
+ const fs = yield* FileSystem.FileSystem;
4999
+ const iosDir = path.join(projectRoot, "ios");
5000
+ const { distribution } = iosProfile;
5001
+ const commandEnv = yield* runtime.commandEnvironment(envVars);
5002
+ yield* prebuildAndPods({
5003
+ projectRoot,
5004
+ iosDir,
5005
+ commandEnv
5006
+ });
5007
+ const workspaceFilename = yield* findXcworkspace(iosDir);
5008
+ const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
5009
+ const configuration = iosProfile.buildConfiguration ?? "Release";
5010
+ const signedTargets = yield* discoverSignedTargets({
5011
+ iosDir,
5012
+ configurationName: configuration
5013
+ });
5014
+ const mainTarget = pickMainTarget(signedTargets);
5015
+ if (!mainTarget) return yield* new BuildFailedError({
5016
+ step: "discover signed targets",
5017
+ exitCode: 1,
5018
+ message: `No signed iOS targets found in the Xcode project for configuration "${configuration}".`
5019
+ });
5020
+ const credentials = yield* fetchAllCredentials({
5021
+ api,
5022
+ input,
5023
+ mainBundleIdentifier: mainTarget.bundleId,
5024
+ allBundleIdentifiers: signedTargets.map((target) => target.bundleId)
5025
+ });
5026
+ const keychain = yield* acquireKeychain({
5027
+ tempDir,
5028
+ p12Path: credentials.p12Path,
5029
+ p12Password: credentials.p12Password
5030
+ });
5031
+ const installedTargets = yield* installPerTarget(signedTargets, credentials, input.credentialsSource);
5032
+ yield* applyTargetSigning({
5033
+ iosDir,
5034
+ entries: installedTargets.map(({ target, installed }) => ({
5035
+ targetName: target.targetName,
5036
+ buildConfigurationUuids: target.buildConfigurationUuids,
5037
+ settings: {
5038
+ teamId: installed.teamId,
5039
+ signingIdentity: keychain.signingIdentity,
5040
+ profileSpecifier: installed.name
5041
+ }
5042
+ }))
5043
+ });
5044
+ const archivePath = path.join(tempDir, "build.xcarchive");
5045
+ const archiveCmd = Command.make("xcodebuild", "-workspace", workspaceFilename, "-scheme", scheme, "-configuration", configuration, "-archivePath", archivePath, "-allowProvisioningUpdates", "archive").pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
5046
+ const formatter = input.rawOutput ? void 0 : createXcodebuildFormatter(projectRoot);
5047
+ yield* formatter ? runStepFormatted(archiveCmd, "xcodebuild archive", formatter) : runStep(archiveCmd, "xcodebuild archive");
5048
+ const exportOptionsPath = path.join(tempDir, "ExportOptions.plist");
5049
+ const mainInstall = installedTargets.find((entry) => entry.target.targetName === mainTarget.targetName);
5050
+ if (!mainInstall) return yield* new BuildFailedError({
5051
+ step: "resolve main target signing",
5052
+ exitCode: 1,
5053
+ message: `Internal: main target "${mainTarget.targetName}" was not in the installed list.`
5054
+ });
5055
+ const { teamId } = mainInstall.installed;
5056
+ yield* fs.writeFileString(exportOptionsPath, renderExportOptionsPlist({
5057
+ method: distribution,
5058
+ teamId,
5059
+ provisioningProfiles: installedTargets.map(({ target, installed }) => ({
5060
+ bundleId: target.bundleId,
5061
+ profileName: installed.name
5062
+ }))
5063
+ }));
5064
+ const exportPath = path.join(tempDir, "export");
5065
+ const exportCmd = Command.make("xcodebuild", "-exportArchive", "-archivePath", archivePath, "-exportPath", exportPath, "-exportOptionsPlist", exportOptionsPath, "-allowProvisioningUpdates").pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
5066
+ yield* formatter ? runStepFormatted(exportCmd, "xcodebuild exportArchive", formatter) : runStep(exportCmd, "xcodebuild exportArchive");
5067
+ yield* validateIosBuild({
5068
+ archivePath,
5069
+ expectedTeamId: teamId,
5070
+ expectedTargets: installedTargets.map(({ target, installed }) => ({
5071
+ bundleId: target.bundleId,
5072
+ profileUuid: installed.uuid
5073
+ }))
5074
+ });
5075
+ const artifactPath = yield* findIosArtifact({ exportPath });
5076
+ const { sha256, byteSize } = yield* sha256File(artifactPath);
5077
+ return {
5078
+ artifactPath,
5079
+ byteSize,
5080
+ sha256
5081
+ };
5082
+ });
5083
+ const runIosBuild = (input) => input.iosProfile.simulator === true ? runIosSimulatorBuild(input) : runIosDeviceBuild(input);
5084
+
5085
+ //#endregion
5086
+ //#region src/commands/build/reserve-and-upload.ts
5087
+ const buildReserveCommon = (input) => ({
5088
+ projectId: input.projectId,
5089
+ profile: input.profileName,
5090
+ runtimeVersion: input.runtimeVersion,
5091
+ bundleId: input.bundleId,
5092
+ sha256: input.sha256,
5093
+ byteSize: input.byteSize,
5094
+ ...input.appVersion === void 0 ? {} : { appVersion: input.appVersion },
5095
+ ...input.buildNumber === void 0 ? {} : { buildNumber: input.buildNumber },
5096
+ ...input.gitContext.ref === void 0 ? {} : { gitRef: input.gitContext.ref },
5097
+ ...input.gitContext.commit === void 0 ? {} : { gitCommit: input.gitContext.commit },
5098
+ ...input.message === void 0 ? {} : { message: input.message }
5099
+ });
5100
+ const callReserve = (api, input) => {
5101
+ const common = buildReserveCommon(input);
5102
+ const { target } = input;
5103
+ if (target.platform === "ios") return target.distribution === "simulator" ? api.builds.reserve({ payload: {
5104
+ ...common,
5105
+ platform: "ios",
5106
+ distribution: "simulator",
5107
+ artifactFormat: "tar.gz"
5108
+ } }) : api.builds.reserve({ payload: {
5109
+ ...common,
5110
+ platform: "ios",
5111
+ distribution: target.distribution,
5112
+ artifactFormat: "ipa"
5113
+ } });
5114
+ return target.distribution === "play-store" ? api.builds.reserve({ payload: {
5115
+ ...common,
5116
+ platform: "android",
5117
+ distribution: "play-store",
5118
+ artifactFormat: "aab"
5119
+ } }) : api.builds.reserve({ payload: {
5120
+ ...common,
5121
+ platform: "android",
5122
+ distribution: "direct",
5123
+ artifactFormat: "apk"
5124
+ } });
5125
+ };
5126
+ /**
5127
+ * Reserve a build record on the server, upload the artifact to the returned
5128
+ * presigned URL, and finalize the build with its sha256 + byteSize.
5129
+ */
5130
+ const reserveAndUpload = (api, input) => Effect.gen(function* () {
5131
+ const presignedUploadClient = yield* PresignedUploadClient;
5132
+ const reserveResult = yield* callReserve(api, input).pipe(Effect.mapError((cause) => new ReserveError({ message: `Failed to reserve build: ${formatCause(cause)}` })));
5133
+ yield* presignedUploadClient.putToPresignedUrl({
5134
+ url: reserveResult.uploadUrl,
5135
+ filePath: input.artifactPath,
5136
+ byteSize: input.byteSize,
5137
+ expiresAt: reserveResult.uploadExpiresAt,
5138
+ headers: reserveResult.uploadHeaders
5139
+ });
5140
+ const completed = yield* api.builds.complete({
5141
+ path: { id: reserveResult.id },
5142
+ payload: {
5143
+ sha256: input.sha256,
5144
+ byteSize: input.byteSize
5145
+ }
5146
+ }).pipe(Effect.mapError((cause) => new CompleteError({ message: `Failed to complete build ${reserveResult.id}: ${formatCause(cause)}` })));
5147
+ if (!completed.artifact) return yield* new CompleteError({ message: `Build ${completed.id} completed but server returned no artifact record.` });
5148
+ return {
5149
+ id: completed.id,
5150
+ status: "uploaded"
5151
+ };
5152
+ });
5153
+
5154
+ //#endregion
5155
+ //#region src/lib/auto-increment.ts
5156
+ const bumpBuildNumber = (current) => Effect.gen(function* () {
5157
+ const raw = current ?? "0";
5158
+ const parsed = Number.parseInt(raw, 10);
5159
+ if (Number.isNaN(parsed)) return yield* new BuildProfileError({ message: `Cannot autoIncrement ios.buildNumber: current value "${raw}" is not a base-10 integer.` });
5160
+ return String(parsed + 1);
5161
+ });
5162
+ const bumpVersionCode = (current) => Effect.gen(function* () {
5163
+ const value = current ?? 0;
5164
+ if (!Number.isInteger(value) || value < 0) return yield* new BuildProfileError({ message: `Cannot autoIncrement android.versionCode: current value ${String(value)} is not a non-negative integer.` });
5165
+ return value + 1;
5166
+ });
5167
+ const SEMVER_PATCH = /^(\d+)\.(\d+)\.(\d+)(.*)$/u;
5168
+ const bumpVersion = (current) => Effect.gen(function* () {
5169
+ if (current === void 0) return yield* new BuildProfileError({ message: "Cannot autoIncrement version: no `version` field set in Expo config." });
5170
+ const match = SEMVER_PATCH.exec(current);
5171
+ if (!match) return yield* new BuildProfileError({ message: `Cannot autoIncrement version: "${current}" is not a semver string like "1.2.3".` });
5172
+ const [, major, minor, patch, suffix] = match;
5173
+ const nextPatch = Number.parseInt(patch ?? "0", 10) + 1;
5174
+ return `${major ?? "0"}.${minor ?? "0"}.${String(nextPatch)}${suffix ?? ""}`;
5175
+ });
5176
+ const computeIosBumps = (config, mode) => Effect.gen(function* () {
5177
+ if (mode === "buildNumber") return { nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber) };
5178
+ return {
5179
+ nextVersion: yield* bumpVersion(config.version),
5180
+ nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber)
5181
+ };
5182
+ });
5183
+ const computeAndroidBumps = (config, mode) => Effect.gen(function* () {
5184
+ if (mode === "versionCode") return { nextVersionCode: yield* bumpVersionCode(config.android?.versionCode) };
5185
+ return {
5186
+ nextVersion: yield* bumpVersion(config.version),
5187
+ nextVersionCode: yield* bumpVersionCode(config.android?.versionCode)
5188
+ };
5189
+ });
5190
+ const buildPatch = (platform, bumps) => {
5191
+ const patch = {};
5192
+ if (bumps.nextVersion !== void 0) patch["version"] = bumps.nextVersion;
5193
+ if (platform === "ios" && bumps.nextBuildNumber !== void 0) patch["ios"] = { buildNumber: bumps.nextBuildNumber };
5194
+ if (platform === "android" && bumps.nextVersionCode !== void 0) patch["android"] = { versionCode: bumps.nextVersionCode };
5195
+ return patch;
5196
+ };
5197
+ const describeBumps = (platform, bumps) => {
5198
+ const parts = [];
5199
+ if (bumps.nextVersion !== void 0) parts.push(`version=${bumps.nextVersion}`);
5200
+ if (platform === "ios" && bumps.nextBuildNumber !== void 0) parts.push(`ios.buildNumber=${bumps.nextBuildNumber}`);
5201
+ if (platform === "android" && bumps.nextVersionCode !== void 0) parts.push(`android.versionCode=${String(bumps.nextVersionCode)}`);
5202
+ return parts.join(", ");
5203
+ };
5204
+ const computeBumps = (input) => {
5205
+ if (input.platform === "ios") return input.iosMode === void 0 ? Effect.succeed({}) : computeIosBumps(input.config, input.iosMode);
5206
+ return input.androidMode === void 0 ? Effect.succeed({}) : computeAndroidBumps(input.config, input.androidMode);
5207
+ };
5208
+ const hasAnyBump = (bumps) => bumps.nextVersion !== void 0 || bumps.nextBuildNumber !== void 0 || bumps.nextVersionCode !== void 0;
5209
+ /**
5210
+ * Bump `version` / `ios.buildNumber` / `android.versionCode` per the resolved
5211
+ * autoIncrement mode, persist via `@expo/config.modifyConfigAsync`, and log a
5212
+ * Human-readable summary. No-op when the mode is undefined. Returns the new
5213
+ * Bumped values so callers can refresh their in-memory ExpoConfig.
5214
+ */
5215
+ const applyAutoIncrement = (input) => Effect.gen(function* () {
5216
+ const bumps = yield* computeBumps(input);
5217
+ if (!hasAnyBump(bumps)) return bumps;
5218
+ const patch = buildPatch(input.platform, bumps);
5219
+ const result = yield* writeExpoConfigPatch(input.projectRoot, patch).pipe(Effect.mapError((cause) => new BuildProfileError({ message: `Failed to persist autoIncrement: ${cause.message}` })));
5220
+ if (result.type === "warn" && result.configPath === null) {
5221
+ yield* Console.log(`autoIncrement: dynamic Expo config detected, cannot write back. Update manually: ${describeBumps(input.platform, bumps)}`);
5222
+ return bumps;
5223
+ }
5224
+ yield* Console.log(`autoIncrement: bumped ${describeBumps(input.platform, bumps)}`);
5225
+ return bumps;
5226
+ });
5227
+
5228
+ //#endregion
5229
+ //#region src/lib/eas-config.ts
5230
+ const MAX_EXTENDS_DEPTH = 10;
5231
+ const asStringValue = (value) => typeof value === "string" ? value : void 0;
5232
+ const asBooleanValue = (value) => typeof value === "boolean" ? value : void 0;
5233
+ const asEnv = (value) => {
5234
+ const record = asRecord(value);
5235
+ if (!record) return;
5236
+ const env = {};
5237
+ for (const [key, raw] of Object.entries(record)) if (typeof raw === "string") env[key] = raw;
5238
+ return Object.keys(env).length === 0 ? void 0 : env;
5239
+ };
5240
+ const asIosDistribution = (raw) => {
5241
+ const value = asStringValue(raw);
5242
+ if (value === "app-store" || value === "ad-hoc" || value === "development" || value === "enterprise") return value;
5243
+ };
5244
+ const asEnterpriseProvisioning = (raw) => {
5245
+ const value = asStringValue(raw);
5246
+ return value === "adhoc" || value === "universal" ? value : void 0;
5247
+ };
5248
+ const asAndroidBuildType = (raw) => {
5249
+ const value = asStringValue(raw);
5250
+ return value === "debug" || value === "release" ? value : void 0;
5251
+ };
5252
+ const asAndroidFormat = (raw) => {
5253
+ const value = asStringValue(raw);
5254
+ return value === "apk" || value === "aab" ? value : void 0;
5255
+ };
5256
+ const asAndroidDistribution = (raw) => {
5257
+ const value = asStringValue(raw);
5258
+ return value === "play-store" || value === "direct" ? value : void 0;
5259
+ };
5260
+ const asIosAutoIncrement = (raw) => {
5261
+ if (typeof raw === "boolean") return raw;
5262
+ const value = asStringValue(raw);
5263
+ return value === "buildNumber" || value === "version" ? value : void 0;
5264
+ };
5265
+ const asAndroidAutoIncrement = (raw) => {
5266
+ if (typeof raw === "boolean") return raw;
5267
+ const value = asStringValue(raw);
5268
+ return value === "versionCode" || value === "version" ? value : void 0;
5269
+ };
5270
+ const asAutoIncrement = (raw) => {
5271
+ if (typeof raw === "boolean") return raw;
5272
+ const value = asStringValue(raw);
5273
+ return value === "buildNumber" || value === "versionCode" || value === "version" ? value : void 0;
5274
+ };
5275
+ const asEasDistribution = (raw) => {
5276
+ const value = asStringValue(raw);
5277
+ return value === "internal" || value === "store" ? value : void 0;
5278
+ };
5279
+ const asCredentialsSource = (raw) => {
5280
+ const value = asStringValue(raw);
5281
+ return value === "remote" || value === "local" ? value : void 0;
5282
+ };
5283
+ const parseIosProfile = (raw) => {
5284
+ const record = asRecord(raw);
5285
+ if (!record) return;
5286
+ const distribution = asIosDistribution(record["distribution"]);
5287
+ const buildConfiguration = asStringValue(record["buildConfiguration"]);
5288
+ const scheme = asStringValue(record["scheme"]);
5289
+ const simulator = asBooleanValue(record["simulator"]);
5290
+ const enterpriseProvisioning = asEnterpriseProvisioning(record["enterpriseProvisioning"]);
5291
+ const autoIncrement = asIosAutoIncrement(record["autoIncrement"]);
5292
+ return {
5293
+ ...distribution === void 0 ? {} : { distribution },
5294
+ ...buildConfiguration === void 0 ? {} : { buildConfiguration },
5295
+ ...scheme === void 0 ? {} : { scheme },
5296
+ ...simulator === void 0 ? {} : { simulator },
5297
+ ...enterpriseProvisioning === void 0 ? {} : { enterpriseProvisioning },
5298
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
5299
+ };
5300
+ };
5301
+ const parseAndroidProfile = (raw) => {
5302
+ const record = asRecord(raw);
5303
+ if (!record) return;
5304
+ const buildType = asAndroidBuildType(record["buildType"]);
5305
+ const flavor = asStringValue(record["flavor"]);
5306
+ const gradleCommand = asStringValue(record["gradleCommand"]);
5307
+ const format = asAndroidFormat(record["format"]);
5308
+ const distribution = asAndroidDistribution(record["distribution"]);
5309
+ const autoIncrement = asAndroidAutoIncrement(record["autoIncrement"]);
5310
+ return {
5311
+ ...buildType === void 0 ? {} : { buildType },
5312
+ ...flavor === void 0 ? {} : { flavor },
5313
+ ...gradleCommand === void 0 ? {} : { gradleCommand },
5314
+ ...format === void 0 ? {} : { format },
5315
+ ...distribution === void 0 ? {} : { distribution },
5316
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
5317
+ };
5318
+ };
5319
+ const parseBuildProfile = (raw) => {
5320
+ const record = asRecord(raw);
5321
+ if (!record) return;
5322
+ const extendsName = asStringValue(record["extends"]);
5323
+ const developmentClient = asBooleanValue(record["developmentClient"]);
5324
+ const distribution = asEasDistribution(record["distribution"]);
5325
+ const channel = asStringValue(record["channel"]);
5326
+ const environment = asStringValue(record["environment"]);
5327
+ const env = asEnv(record["env"]);
5328
+ const ios = parseIosProfile(record["ios"]);
5329
+ const android = parseAndroidProfile(record["android"]);
5330
+ const credentialsSource = asCredentialsSource(record["credentialsSource"]);
5331
+ const autoIncrement = asAutoIncrement(record["autoIncrement"]);
5332
+ return {
5333
+ ...extendsName === void 0 ? {} : { extends: extendsName },
5334
+ ...developmentClient === void 0 ? {} : { developmentClient },
5335
+ ...distribution === void 0 ? {} : { distribution },
5336
+ ...channel === void 0 ? {} : { channel },
5337
+ ...environment === void 0 ? {} : { environment },
5338
+ ...env === void 0 ? {} : { env },
5339
+ ...ios === void 0 ? {} : { ios },
5340
+ ...android === void 0 ? {} : { android },
5341
+ ...credentialsSource === void 0 ? {} : { credentialsSource },
5342
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
5343
+ };
5344
+ };
5345
+ const parseEasConfig = (text) => Effect.gen(function* () {
5346
+ const root = asRecord(yield* Effect.try({
5347
+ try: () => JSON.parse(text),
5348
+ catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
5349
+ }));
5350
+ if (!root) return yield* new BuildProfileError({ message: "eas.json must be a JSON object at the top level." });
5351
+ const buildRecord = asRecord(root["build"]);
5352
+ if (!buildRecord) return asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {};
5353
+ const profiles = {};
5354
+ for (const [name, value] of Object.entries(buildRecord)) {
5355
+ const profile = parseBuildProfile(value);
5356
+ if (profile) profiles[name] = profile;
5357
+ }
4917
5358
  return {
4918
- id,
4919
- name,
4920
- uuid,
4921
- expirationDate,
4922
- profileContent,
4923
- profileType
5359
+ ...asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {},
5360
+ build: profiles
4924
5361
  };
5362
+ });
5363
+ const parseCli = (raw) => {
5364
+ const record = asRecord(raw);
5365
+ if (!record) return {};
5366
+ const version = asStringValue(record["version"]);
5367
+ return version === void 0 ? {} : { version };
4925
5368
  };
4926
- const toAscDevice = (value) => {
4927
- if (!isRecord(value)) return null;
4928
- const { id, attributes } = value;
4929
- if (typeof id !== "string" || !isRecord(attributes)) return null;
4930
- const { udid, name } = attributes;
4931
- if (typeof udid !== "string" || typeof name !== "string") return null;
5369
+ const easJsonPath = (projectRoot) => Effect.gen(function* () {
5370
+ return (yield* Path.Path).join(projectRoot, "eas.json");
5371
+ });
5372
+ const readEasJson = (projectRoot) => Effect.gen(function* () {
5373
+ const fs = yield* FileSystem.FileSystem;
5374
+ const filePath = yield* easJsonPath(projectRoot);
5375
+ return yield* parseEasConfig(yield* fs.readFileString(filePath).pipe(Effect.catchAll((cause) => Effect.fail(new BuildProfileError({ message: cause._tag === "SystemError" && cause.reason === "NotFound" ? `No eas.json found at ${filePath}. Create one with a "build" section.` : `Failed to read eas.json: ${cause.message}` })))));
5376
+ });
5377
+ const mergeIos = (base, overlay) => {
5378
+ if (!base) return overlay;
5379
+ if (!overlay) return base;
4932
5380
  return {
4933
- id,
4934
- udid,
4935
- name
5381
+ ...base,
5382
+ ...overlay
4936
5383
  };
4937
5384
  };
4938
- const extractList = (body, map) => {
4939
- if (!isRecord(body) || !Array.isArray(body["data"])) return [];
4940
- return body["data"].map(map).filter((value) => value !== null);
5385
+ const mergeAndroid = (base, overlay) => {
5386
+ if (!base) return overlay;
5387
+ if (!overlay) return base;
5388
+ return {
5389
+ ...base,
5390
+ ...overlay
5391
+ };
4941
5392
  };
4942
- const extractSingle = (body, map) => {
4943
- if (!isRecord(body)) return null;
4944
- return map(body["data"]);
5393
+ const mergeEnv = (base, overlay) => {
5394
+ if (!base) return overlay;
5395
+ if (!overlay) return base;
5396
+ return {
5397
+ ...base,
5398
+ ...overlay
5399
+ };
4945
5400
  };
4946
- const malformed = (resource) => new AscApiError({
4947
- status: 500,
4948
- message: `Malformed ${resource} response`,
4949
- code: void 0,
4950
- raw: ""
4951
- });
4952
- const withJwt = (credentials, fn) => Effect.gen(function* () {
4953
- return yield* fn(yield* signAscJwt(credentials));
4954
- });
4955
- const listCertificates = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4956
- return extractList(yield* fetchRaw(jwt, `/v1/certificates${params?.certificateType ? `?filter[certificateType]=${params.certificateType}&limit=200` : "?limit=200"}`), toAscCertificate);
4957
- }));
4958
- const createCertificate = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4959
- const csrContent = params.csrPem.replaceAll("-----BEGIN CERTIFICATE REQUEST-----", "").replaceAll("-----END CERTIFICATE REQUEST-----", "").replaceAll(/\s+/gu, "");
4960
- const resource = extractSingle(yield* fetchRaw(jwt, "/v1/certificates", {
4961
- method: "POST",
4962
- body: JSON.stringify({ data: {
4963
- type: "certificates",
4964
- attributes: {
4965
- csrContent,
4966
- certificateType: params.certificateType
4967
- }
4968
- } })
4969
- }), toAscCertificate);
4970
- if (resource === null) return yield* Effect.fail(malformed("certificate"));
4971
- return resource;
4972
- }));
4973
- const deleteCertificate = (credentials, id) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4974
- yield* fetchRaw(jwt, `/v1/certificates/${encodeURIComponent(id)}`, { method: "DELETE" });
4975
- }));
4976
- const listBundleIds = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4977
- return extractList(yield* fetchRaw(jwt, "/v1/bundleIds?limit=200"), toAscBundleId);
4978
- }));
4979
- const createBundleId = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4980
- const resource = extractSingle(yield* fetchRaw(jwt, "/v1/bundleIds", {
4981
- method: "POST",
4982
- body: JSON.stringify({ data: {
4983
- type: "bundleIds",
4984
- attributes: {
4985
- identifier: params.identifier,
4986
- name: params.name,
4987
- platform: "IOS"
4988
- }
4989
- } })
4990
- }), toAscBundleId);
4991
- if (resource === null) return yield* Effect.fail(malformed("bundleId"));
4992
- return resource;
4993
- }));
4994
- const listDevices = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4995
- return extractList(yield* fetchRaw(jwt, "/v1/devices?limit=200"), toAscDevice);
4996
- }));
4997
- const createProvisioningProfile = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4998
- const relationships = {
4999
- bundleId: { data: {
5000
- type: "bundleIds",
5001
- id: params.bundleIdAscId
5002
- } },
5003
- certificates: { data: params.certificateAscIds.map((id) => ({
5004
- type: "certificates",
5005
- id
5006
- })) },
5007
- ...params.deviceAscIds.length > 0 ? { devices: { data: params.deviceAscIds.map((id) => ({
5008
- type: "devices",
5009
- id
5010
- })) } } : {}
5401
+ const mergeProfile = (base, overlay) => {
5402
+ const ios = mergeIos(base.ios, overlay.ios);
5403
+ const android = mergeAndroid(base.android, overlay.android);
5404
+ const env = mergeEnv(base.env, overlay.env);
5405
+ const developmentClient = overlay.developmentClient ?? base.developmentClient;
5406
+ const distribution = overlay.distribution ?? base.distribution;
5407
+ const channel = overlay.channel ?? base.channel;
5408
+ const environment = overlay.environment ?? base.environment;
5409
+ const credentialsSource = overlay.credentialsSource ?? base.credentialsSource;
5410
+ const autoIncrement = overlay.autoIncrement ?? base.autoIncrement;
5411
+ return {
5412
+ ...overlay.extends === void 0 ? {} : { extends: overlay.extends },
5413
+ ...developmentClient === void 0 ? {} : { developmentClient },
5414
+ ...distribution === void 0 ? {} : { distribution },
5415
+ ...channel === void 0 ? {} : { channel },
5416
+ ...environment === void 0 ? {} : { environment },
5417
+ ...env === void 0 ? {} : { env },
5418
+ ...ios === void 0 ? {} : { ios },
5419
+ ...android === void 0 ? {} : { android },
5420
+ ...credentialsSource === void 0 ? {} : { credentialsSource },
5421
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
5011
5422
  };
5012
- const resource = extractSingle(yield* fetchRaw(jwt, "/v1/profiles", {
5013
- method: "POST",
5014
- body: JSON.stringify({ data: {
5015
- type: "profiles",
5016
- attributes: {
5017
- name: params.profileName,
5018
- profileType: params.profileType
5019
- },
5020
- relationships
5021
- } })
5022
- }), toAscProfile);
5023
- if (resource === null) return yield* Effect.fail(malformed("profile"));
5024
- return resource;
5025
- }));
5026
- const isCertificateLimitError = (error) => {
5027
- if (error._tag !== "AscApiError") return false;
5028
- return /already have a current.*certificate|pending certificate request/iu.test(error.message);
5029
5423
  };
5424
+ const collectExtendsChain = (profiles, profileName) => Effect.gen(function* () {
5425
+ const chain = [];
5426
+ const visited = /* @__PURE__ */ new Set();
5427
+ let current = profileName;
5428
+ let depth = 0;
5429
+ while (current !== void 0) {
5430
+ if (visited.has(current)) return yield* new BuildProfileError({ message: `Cycle detected in eas.json build.${profileName} extends chain at "${current}".` });
5431
+ visited.add(current);
5432
+ const profile = profiles[current];
5433
+ if (!profile) return yield* new BuildProfileError({ message: current === profileName ? `Build profile "${profileName}" not found in eas.json.` : `Build profile "${profileName}" extends missing profile "${current}".` });
5434
+ chain.unshift(profile);
5435
+ current = profile.extends;
5436
+ depth += 1;
5437
+ if (depth > MAX_EXTENDS_DEPTH) return yield* new BuildProfileError({ message: `Too many "extends" levels (max ${String(MAX_EXTENDS_DEPTH)}) in eas.json build.${profileName}.` });
5438
+ }
5439
+ return chain;
5440
+ });
5441
+ const stripExtends = (profile) => {
5442
+ if (profile.extends === void 0) return profile;
5443
+ const { extends: _omit, ...rest } = profile;
5444
+ return rest;
5445
+ };
5446
+ const resolveEasBuildProfile = (config, profileName) => Effect.gen(function* () {
5447
+ const profiles = config.build;
5448
+ if (!profiles) return yield* new BuildProfileError({ message: "eas.json has no \"build\" section. Add at least one profile." });
5449
+ return stripExtends((yield* collectExtendsChain(profiles, profileName)).reduce((acc, next, index) => index === 0 ? next : mergeProfile(acc, next), {}));
5450
+ });
5030
5451
 
5031
5452
  //#endregion
5032
- //#region src/lib/apple-cert-to-p12.ts
5033
- var CertParseError = class extends Data.TaggedError("CertParseError") {};
5034
- const APPLE_TEAM_ID_RE$1 = /^[A-Z0-9]{10}$/u;
5035
- const stringField = (cert, name) => {
5036
- const value = cert.subject.getField(name)?.value;
5037
- return typeof value === "string" ? value : null;
5453
+ //#region src/lib/build-profile.ts
5454
+ const asString$1 = (value) => typeof value === "string" ? value : void 0;
5455
+ const deriveIosDistribution = (eas) => {
5456
+ const override = eas.ios?.distribution;
5457
+ if (override) return override;
5458
+ if (eas.developmentClient === true) return "development";
5459
+ if (eas.distribution === "internal") return "ad-hoc";
5460
+ if (eas.distribution === "store") return "app-store";
5038
5461
  };
5039
- const matchTeamFromCommonName = (cn) => {
5040
- const match = /\(([A-Z0-9]{10})\)/u.exec(cn);
5041
- if (match === null) return null;
5042
- const [, captured] = match;
5043
- return captured === void 0 ? null : captured;
5462
+ const deriveAndroidFormat = (eas) => {
5463
+ if (eas.android?.format) return eas.android.format;
5464
+ if (eas.distribution === "store") return "aab";
5465
+ if (eas.distribution === "internal") return "apk";
5466
+ if (eas.developmentClient === true) return "apk";
5044
5467
  };
5045
- const extractTeamId$1 = (cert) => {
5046
- const ou = stringField(cert, "OU");
5047
- if (ou !== null && APPLE_TEAM_ID_RE$1.test(ou)) return ou;
5048
- const cn = stringField(cert, "CN");
5049
- if (cn === null) return null;
5050
- return matchTeamFromCommonName(cn);
5468
+ const deriveAndroidDistribution = (eas, format) => {
5469
+ if (eas.android?.distribution) return eas.android.distribution;
5470
+ if (format === "aab") return "play-store";
5471
+ return "direct";
5472
+ };
5473
+ const hasIosIntent = (eas) => eas.ios !== void 0 || eas.distribution !== void 0 || eas.developmentClient === true;
5474
+ const hasAndroidIntent = (eas) => eas.android !== void 0 || eas.distribution !== void 0 || eas.developmentClient === true;
5475
+ const resolveIosAutoIncrement = (eas) => {
5476
+ const override = eas.ios?.autoIncrement;
5477
+ if (override === false) return;
5478
+ if (override === true) return "buildNumber";
5479
+ if (override === "buildNumber" || override === "version") return override;
5480
+ const top = eas.autoIncrement;
5481
+ if (top === true || top === "buildNumber") return "buildNumber";
5482
+ if (top === "version") return "version";
5483
+ };
5484
+ const resolveAndroidAutoIncrement = (eas) => {
5485
+ const override = eas.android?.autoIncrement;
5486
+ if (override === false) return;
5487
+ if (override === true) return "versionCode";
5488
+ if (override === "versionCode" || override === "version") return override;
5489
+ const top = eas.autoIncrement;
5490
+ if (top === true || top === "versionCode") return "versionCode";
5491
+ if (top === "version") return "version";
5492
+ };
5493
+ const toIosProfile = (eas) => {
5494
+ if (!hasIosIntent(eas)) return;
5495
+ const distribution = deriveIosDistribution(eas);
5496
+ if (!distribution) return;
5497
+ const ios = eas.ios ?? {};
5498
+ const autoIncrement = resolveIosAutoIncrement(eas);
5499
+ return {
5500
+ distribution,
5501
+ ...ios.buildConfiguration === void 0 ? {} : { buildConfiguration: ios.buildConfiguration },
5502
+ ...ios.scheme === void 0 ? {} : { scheme: ios.scheme },
5503
+ ...ios.simulator === void 0 ? {} : { simulator: ios.simulator },
5504
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
5505
+ };
5051
5506
  };
5052
- const parseCert = (certDerBytes) => {
5053
- const asn1 = forge.asn1.fromDer(certDerBytes);
5054
- return forge.pki.certificateFromAsn1(asn1);
5507
+ const toAndroidProfile = (eas) => {
5508
+ if (!hasAndroidIntent(eas)) return;
5509
+ const format = deriveAndroidFormat(eas);
5510
+ if (!format) return;
5511
+ const android = eas.android ?? {};
5512
+ const distribution = deriveAndroidDistribution(eas, format);
5513
+ const autoIncrement = resolveAndroidAutoIncrement(eas);
5514
+ return {
5515
+ format,
5516
+ distribution,
5517
+ ...android.buildType === void 0 ? {} : { buildType: android.buildType },
5518
+ ...android.flavor === void 0 ? {} : { flavor: android.flavor },
5519
+ ...android.gradleCommand === void 0 ? {} : { gradleCommand: android.gradleCommand },
5520
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
5521
+ };
5055
5522
  };
5056
- const generatePassword = () => forge.util.encode64(forge.random.getBytesSync(16));
5057
- const extractCertMetadata = (cert) => Effect.gen(function* () {
5058
- const appleTeamId = extractTeamId$1(cert);
5059
- if (appleTeamId === null) return yield* Effect.fail(new CertParseError({ message: "Could not extract Apple team identifier from certificate subject" }));
5523
+ const fromEasProfile = (eas, profileName) => {
5524
+ const ios = toIosProfile(eas);
5525
+ const android = toAndroidProfile(eas);
5060
5526
  return {
5061
- serialNumber: cert.serialNumber.toUpperCase(),
5062
- validFrom: cert.validity.notBefore.toISOString(),
5063
- validUntil: cert.validity.notAfter.toISOString(),
5064
- appleTeamId,
5065
- appleTeamName: stringField(cert, "O"),
5066
- developerIdIdentifier: stringField(cert, "UID"),
5067
- commonName: stringField(cert, "CN")
5527
+ name: profileName,
5528
+ environment: eas.environment ?? "production",
5529
+ ...eas.channel === void 0 ? {} : { channel: eas.channel },
5530
+ ...eas.env === void 0 ? {} : { env: eas.env },
5531
+ ...ios === void 0 ? {} : { ios },
5532
+ ...android === void 0 ? {} : { android },
5533
+ ...eas.credentialsSource === void 0 ? {} : { credentialsSource: eas.credentialsSource }
5068
5534
  };
5535
+ };
5536
+ const readBuildProfile = (projectRoot, profileName) => Effect.gen(function* () {
5537
+ return fromEasProfile(yield* resolveEasBuildProfile(yield* readEasJson(projectRoot), profileName), profileName);
5538
+ });
5539
+ const readRuntimeVersionMeta = (config) => ({
5540
+ appVersion: config.version,
5541
+ rawRuntimeVersion: readRawRuntimeVersion(config.runtimeVersion)
5069
5542
  });
5543
+ const readRawRuntimeVersion = (value) => {
5544
+ if (typeof value === "string") return value;
5545
+ const policy = asString$1(asRecord(value)?.["policy"]);
5546
+ if (policy) return { policy };
5547
+ };
5548
+
5549
+ //#endregion
5550
+ //#region src/lib/clear-cache.ts
5070
5551
  /**
5071
- * Parse a PKCS#12 base64 bundle and extract certificate metadata. Used by the
5072
- * Apple-ID flow which receives a P12 directly from `createCertificateAndP12Async`
5073
- * and needs metadata before uploading to the better-update server.
5552
+ * Project-scoped build cache directories to remove when --clear-cache is passed.
5553
+ * Intentionally avoids `~/.gradle/caches` (global) and `ios/Pods/` (requires
5554
+ * pod install rebuild leave to the user).
5074
5555
  */
5075
- const extractMetadataFromP12 = (params) => Effect.gen(function* () {
5076
- const certBagOid = forge.pki.oids["certBag"];
5077
- if (certBagOid === void 0) return yield* Effect.fail(new CertParseError({ message: "PKCS#12 OID lookup for certBag failed" }));
5078
- const [first] = yield* Effect.try({
5079
- try: () => {
5080
- const p12Der = forge.util.decode64(params.p12Base64);
5081
- const p12Asn1 = forge.asn1.fromDer(p12Der);
5082
- return forge.pkcs12.pkcs12FromAsn1(p12Asn1, false, params.password).getBags({ bagType: certBagOid })[certBagOid] ?? [];
5083
- },
5084
- catch: (error) => new CertParseError({ message: `Failed to parse PKCS#12 bundle: ${error instanceof Error ? error.message : String(error)}` })
5085
- });
5086
- if (first?.cert === void 0) return yield* Effect.fail(new CertParseError({ message: "PKCS#12 bundle does not contain a certificate" }));
5087
- return yield* extractCertMetadata(first.cert);
5088
- });
5089
- const buildDistributionCertP12 = (params) => Effect.gen(function* () {
5090
- const result = yield* Effect.try({
5091
- try: () => {
5092
- const cert = parseCert(forge.util.decode64(params.certificateContentBase64));
5093
- const password = generatePassword();
5094
- const p12Asn1 = forge.pkcs12.toPkcs12Asn1(params.privateKey, [cert], password, {
5095
- friendlyName: "key",
5096
- algorithm: "3des"
5097
- });
5098
- return {
5099
- cert,
5100
- p12Base64: forge.util.encode64(forge.asn1.toDer(p12Asn1).getBytes()),
5101
- password
5102
- };
5103
- },
5104
- catch: (error) => new CertParseError({ message: `Failed to assemble .p12: ${error instanceof Error ? error.message : String(error)}` })
5105
- });
5106
- const metadata = yield* extractCertMetadata(result.cert);
5107
- return {
5108
- p12Base64: result.p12Base64,
5109
- password: result.password,
5110
- metadata
5111
- };
5556
+ const CACHE_DIRS = [
5557
+ "android/.gradle",
5558
+ "android/app/build",
5559
+ "android/build",
5560
+ "ios/build",
5561
+ ".expo",
5562
+ "node_modules/.cache"
5563
+ ];
5564
+ const clearBuildCaches = (projectRoot) => Effect.gen(function* () {
5565
+ const fs = yield* FileSystem.FileSystem;
5566
+ const removed = [];
5567
+ yield* Effect.forEach(CACHE_DIRS, (rel) => Effect.gen(function* () {
5568
+ const target = path.join(projectRoot, rel);
5569
+ if (!(yield* fs.exists(target).pipe(Effect.catchAll(() => Effect.succeed(false))))) return;
5570
+ yield* fs.remove(target, { recursive: true }).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
5571
+ removed.push(rel);
5572
+ }), { concurrency: 4 });
5573
+ if (removed.length > 0) yield* Console.error(`Cleared caches: ${removed.join(", ")}`);
5112
5574
  });
5113
5575
 
5114
5576
  //#endregion
5115
- //#region src/lib/apple-csr.ts
5116
- const generateRsaKeyPair = async () => new Promise((resolve) => {
5117
- forge.pki.rsa.generateKeyPair({
5118
- bits: 2048,
5119
- workers: 2
5120
- }, (_err, keyPair) => {
5121
- resolve(keyPair);
5122
- });
5123
- });
5124
- const generateCertificateSigningRequest = async () => {
5125
- const keyPair = await generateRsaKeyPair();
5126
- const csr = forge.pki.createCertificationRequest();
5127
- csr.publicKey = keyPair.publicKey;
5128
- csr.setSubject([{
5129
- name: "commonName",
5130
- shortName: "CN",
5131
- value: "PEM"
5132
- }]);
5133
- csr.sign(keyPair.privateKey, forge.md.sha1.create());
5134
- return {
5135
- csrPem: forge.pki.certificationRequestToPem(csr),
5136
- privateKeyPem: forge.pki.privateKeyToPem(keyPair.privateKey),
5137
- privateKey: keyPair.privateKey
5138
- };
5577
+ //#region src/lib/env-exporter.ts
5578
+ const coerceEnvironment = (raw) => raw === "development" || raw === "preview" || raw === "production" ? raw : void 0;
5579
+ /**
5580
+ * Pull environment variables for a project + environment and flatten them into
5581
+ * a key/value map. Returns an empty map when the project has no variables.
5582
+ */
5583
+ const pullEnvVars = (api, { projectId, environment }) => {
5584
+ const validated = coerceEnvironment(environment);
5585
+ if (!validated) return Effect.fail(new EnvExportError({ message: `Invalid environment "${environment}". Must be one of: development, preview, production.` }));
5586
+ return api["env-vars"].export({ urlParams: {
5587
+ projectId,
5588
+ environment: validated
5589
+ } }).pipe(Effect.map((result) => Object.fromEntries(result.items.map((item) => [item.key, item.value]))), Effect.mapError((cause) => new EnvExportError({ message: `Failed to export environment variables for "${environment}": ${String(cause)}` })));
5139
5590
  };
5140
5591
 
5141
5592
  //#endregion
5142
- //#region src/lib/credentials-generator.ts
5143
- const DISTRIBUTION_TO_PROFILE_TYPE$1 = {
5144
- APP_STORE: "IOS_APP_STORE",
5145
- AD_HOC: "IOS_APP_ADHOC",
5146
- DEVELOPMENT: "IOS_APP_DEVELOPMENT",
5147
- ENTERPRISE: "IOS_APP_INHOUSE"
5148
- };
5149
- const computeDeviceRosterHashHex = (ascDeviceIds) => {
5150
- const sorted = [...ascDeviceIds].toSorted();
5151
- return createHash("sha256").update(sorted.join(","), "utf8").digest("hex");
5152
- };
5153
- var CertificateLimitError = class extends Data.TaggedError("CertificateLimitError") {};
5154
- var GenerateFailedError = class extends Data.TaggedError("GenerateFailedError") {};
5155
- const messageForAscCause = (cause) => {
5156
- if (cause._tag === "AscApiError") return cause.message;
5157
- if (cause._tag === "AppleAuthError") return "Apple JWT signing failed";
5158
- return "Network error talking to Apple";
5159
- };
5160
- const wrapAscError = (step) => (cause) => {
5161
- if (cause._tag === "AscApiError" && isCertificateLimitError(cause)) return new CertificateLimitError({ message: cause.message });
5162
- return new GenerateFailedError({
5163
- step,
5164
- message: messageForAscCause(cause)
5165
- });
5166
- };
5167
- const generateAndUploadKeystore = (api, input) => Effect.scoped(Effect.gen(function* () {
5168
- const fs = yield* FileSystem.FileSystem;
5169
- const tempDir = yield* acquireBuildTempDir;
5170
- const keystorePath = path.join(tempDir, "release.keystore");
5171
- yield* generateAndroidKeystore({
5172
- outputPath: keystorePath,
5173
- keyAlias: input.keyAlias,
5174
- storePassword: input.storePassword,
5175
- keyPassword: input.keyPassword,
5176
- commonName: input.commonName,
5177
- organization: input.organization,
5178
- ...input.validityDays === void 0 ? {} : { validityDays: input.validityDays }
5179
- });
5180
- const bytes = yield* fs.readFile(keystorePath);
5181
- const created = yield* api.androidUploadKeystores.upload({ payload: {
5182
- keystoreBase64: toBase64(bytes),
5183
- keyAlias: input.keyAlias,
5184
- keystorePassword: input.storePassword,
5185
- keyPassword: input.keyPassword
5186
- } });
5187
- return {
5188
- id: created.id,
5189
- keyAlias: created.keyAlias
5190
- };
5191
- }));
5192
- const fetchAscCredentials = (api, ascApiKeyId) => api.ascApiKeys.getCredentials({ path: { id: ascApiKeyId } });
5193
- const generateAndUploadDistributionCertificate = (api, input) => Effect.gen(function* () {
5194
- const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
5195
- const ascCreds = {
5196
- keyId: creds.keyId,
5197
- issuerId: creds.issuerId,
5198
- p8Pem: creds.p8Pem
5199
- };
5200
- const csrResult = yield* Effect.tryPromise({
5201
- try: generateCertificateSigningRequest,
5202
- catch: (cause) => new GenerateFailedError({
5203
- step: "csr",
5204
- message: `CSR generation failed: ${cause instanceof Error ? cause.message : String(cause)}`
5205
- })
5206
- });
5207
- const certificateType = input.certificateType ?? "IOS_DISTRIBUTION";
5208
- const apple = yield* createCertificate(ascCreds, {
5209
- csrPem: csrResult.csrPem,
5210
- certificateType
5211
- }).pipe(Effect.mapError(wrapAscError("apple-create-certificate")));
5212
- if (apple.certificateContent === null) return yield* Effect.fail(new GenerateFailedError({
5213
- step: "apple-create-certificate",
5214
- message: "Apple response missing certificateContent"
5215
- }));
5216
- const bundle = yield* buildDistributionCertP12({
5217
- certificateContentBase64: apple.certificateContent,
5218
- privateKey: csrResult.privateKey
5219
- }).pipe(Effect.mapError((cause) => new GenerateFailedError({
5220
- step: "p12-build",
5221
- message: cause.message
5222
- })));
5223
- const created = yield* api.appleDistributionCertificates.upload({ payload: {
5224
- p12Base64: bundle.p12Base64,
5225
- p12Password: bundle.password,
5226
- serialNumber: bundle.metadata.serialNumber,
5227
- appleTeamIdentifier: bundle.metadata.appleTeamId,
5228
- ...bundle.metadata.appleTeamName === null ? {} : { appleTeamName: bundle.metadata.appleTeamName },
5229
- ...bundle.metadata.developerIdIdentifier === null ? {} : { developerIdIdentifier: bundle.metadata.developerIdIdentifier },
5230
- validFrom: bundle.metadata.validFrom,
5231
- validUntil: bundle.metadata.validUntil
5232
- } });
5593
+ //#region src/lib/git-context.ts
5594
+ const runString = (cmd, cwd) => Command.string(Command.workingDirectory(cmd, cwd));
5595
+ /**
5596
+ * Best-effort git context extraction. If git is missing, the directory isn't
5597
+ * a repo, or any command fails, we silently return undefined fields so the
5598
+ * build can still proceed. This is intentional — git context is metadata,
5599
+ * not a requirement.
5600
+ */
5601
+ const readGitContext = (projectRoot) => Effect.gen(function* () {
5602
+ const [commit, ref, commitMessage, status] = yield* Effect.all([
5603
+ runString(Command.make("git", "rev-parse", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
5604
+ runString(Command.make("git", "symbolic-ref", "--short", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
5605
+ runString(Command.make("git", "log", "-1", "--format=%s"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
5606
+ runString(Command.make("git", "status", "--porcelain"), projectRoot).pipe(Effect.catchAll(() => Effect.succeed("")))
5607
+ ], { concurrency: "unbounded" });
5233
5608
  return {
5234
- id: created.id,
5235
- serialNumber: bundle.metadata.serialNumber,
5236
- appleTeamId: created.appleTeamId,
5237
- appleTeamIdentifier: bundle.metadata.appleTeamId,
5238
- developerPortalIdentifier: apple.id
5609
+ ref: ref.length > 0 ? ref : void 0,
5610
+ commit: commit.length > 0 ? commit : void 0,
5611
+ commitMessage: commitMessage.length > 0 ? commitMessage : void 0,
5612
+ dirty: status.trim().length > 0
5239
5613
  };
5240
5614
  });
5241
- const revokeAppleCertificate = (api, input) => Effect.gen(function* () {
5242
- const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
5243
- yield* deleteCertificate({
5244
- keyId: creds.keyId,
5245
- issuerId: creds.issuerId,
5246
- p8Pem: creds.p8Pem
5247
- }, input.developerPortalIdentifier).pipe(Effect.mapError(wrapAscError("apple-revoke-certificate")));
5615
+
5616
+ //#endregion
5617
+ //#region src/lib/gradle-config.ts
5618
+ /**
5619
+ * Parse Groovy `build.gradle` to extract key Android config values.
5620
+ * Returns `undefined` if:
5621
+ * - Only `build.gradle.kts` exists (Kotlin DSL not supported by gradle-to-js)
5622
+ * - No build.gradle found at all
5623
+ * - Parse fails
5624
+ *
5625
+ * Informational only — never blocks the build.
5626
+ */
5627
+ const readGradleConfig = (androidDir) => Effect.gen(function* () {
5628
+ const fs = yield* FileSystem.FileSystem;
5629
+ const gradlePath = path.join(androidDir, "app", "build.gradle");
5630
+ const ktsPath = path.join(androidDir, "app", "build.gradle.kts");
5631
+ const hasGroovy = yield* fs.exists(gradlePath).pipe(Effect.orElseSucceed(() => false));
5632
+ const hasKts = yield* fs.exists(ktsPath).pipe(Effect.orElseSucceed(() => false));
5633
+ if (!hasGroovy && hasKts) return;
5634
+ if (!hasGroovy) return;
5635
+ const content = yield* fs.readFileString(gradlePath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
5636
+ if (!content) return;
5637
+ return yield* Effect.tryPromise({
5638
+ try: async () => {
5639
+ return __require("gradle-to-js").parseText(stripGroovyComments(content));
5640
+ },
5641
+ catch: () => void 0
5642
+ }).pipe(Effect.map(extractGradleConfig), Effect.catchAll(() => Effect.succeed(void 0)));
5248
5643
  });
5249
- const revokeLocalDistributionCertificate = (api, input) => Effect.gen(function* () {
5250
- const local = (yield* api.appleDistributionCertificates.list()).items.find((entry) => entry.id === input.distributionCertificateId);
5251
- if (local === void 0) return yield* Effect.fail(new GenerateFailedError({
5252
- step: "load-distribution-certificate",
5253
- message: `Distribution certificate ${input.distributionCertificateId} not found on this account`
5254
- }));
5255
- const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
5256
- const ascCreds = {
5257
- keyId: creds.keyId,
5258
- issuerId: creds.issuerId,
5259
- p8Pem: creds.p8Pem
5260
- };
5261
- const targetSerial = local.serialNumber.toUpperCase();
5262
- const matching = yield* Effect.all([listCertificates(ascCreds, { certificateType: "IOS_DISTRIBUTION" }), listCertificates(ascCreds, { certificateType: "IOS_DEVELOPMENT" })], { concurrency: 2 }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
5263
- const ascMatch = [...matching[0], ...matching[1]].find((entry) => entry.serialNumber.toUpperCase() === targetSerial);
5264
- let revokedOnApple = false;
5265
- if (ascMatch !== void 0) {
5266
- yield* deleteCertificate(ascCreds, ascMatch.id).pipe(Effect.mapError(wrapAscError("apple-revoke-certificate")));
5267
- revokedOnApple = true;
5268
- }
5269
- let deletedLocally = false;
5270
- if (input.keepLocal !== true) {
5271
- yield* api.appleDistributionCertificates.delete({ path: { id: input.distributionCertificateId } });
5272
- deletedLocally = true;
5273
- }
5644
+ /**
5645
+ * Log a warning if Gradle applicationId differs from app.json package name.
5646
+ */
5647
+ const warnOnGradleMismatch = (gradleConfig, expectedPackage) => {
5648
+ if (!gradleConfig?.applicationId) return Effect.void;
5649
+ if (gradleConfig.applicationId === expectedPackage) return Effect.void;
5650
+ return Console.warn(`Gradle applicationId "${gradleConfig.applicationId}" differs from app.json package "${expectedPackage}". The Gradle value will be used in the built APK/AAB.`);
5651
+ };
5652
+ /**
5653
+ * Strip Groovy single-line and block comments.
5654
+ * gradle-to-js chokes on comments — EAS CLI does this same pre-processing.
5655
+ */
5656
+ const stripGroovyComments = (text) => text.replaceAll(/\/\/.*$/gmu, "").replaceAll(/\/\*[\s\S]*?\*\//gu, "");
5657
+ const parseVersionCode = (raw) => {
5658
+ if (typeof raw === "number") return raw;
5659
+ if (typeof raw === "string") return Number.parseInt(raw, 10) || void 0;
5660
+ };
5661
+ const extractGradleConfig = (parsed) => {
5662
+ const defaultConfig = asRecord(asRecord(parsed["android"])?.["defaultConfig"]);
5663
+ const applicationId = typeof defaultConfig?.["applicationId"] === "string" ? unquote(defaultConfig["applicationId"]) : void 0;
5664
+ const versionCode = parseVersionCode(defaultConfig?.["versionCode"]);
5665
+ const versionName = typeof defaultConfig?.["versionName"] === "string" ? unquote(defaultConfig["versionName"]) : void 0;
5274
5666
  return {
5275
- localId: input.distributionCertificateId,
5276
- serialNumber: local.serialNumber,
5277
- revokedOnApple,
5278
- deletedLocally
5667
+ ...applicationId === void 0 ? {} : { applicationId },
5668
+ ...versionCode === void 0 ? {} : { versionCode },
5669
+ ...versionName === void 0 ? {} : { versionName }
5279
5670
  };
5671
+ };
5672
+ const unquote = (input) => input.startsWith("\"") && input.endsWith("\"") ? input.slice(1, -1) : input;
5673
+
5674
+ //#endregion
5675
+ //#region src/lib/platform-detect.ts
5676
+ const PLATFORMS = ["ios", "android"];
5677
+ const inferPlatforms = (config) => {
5678
+ const fromConfig = config["platforms"];
5679
+ if (Array.isArray(fromConfig)) return fromConfig.filter((entry) => entry === "ios" || entry === "android");
5680
+ const present = [];
5681
+ if (config.ios !== void 0) present.push("ios");
5682
+ if (config.android !== void 0) present.push("android");
5683
+ return present;
5684
+ };
5685
+ /**
5686
+ * Resolve a build platform from an explicit flag, or fall back to the Expo
5687
+ * config (`expo.platforms` or the presence of `ios`/`android` sections). Prompts
5688
+ * when the config declares both platforms; fails when ambiguous and prompts are
5689
+ * disallowed.
5690
+ */
5691
+ const detectPlatform = (explicit, config) => Effect.gen(function* () {
5692
+ if (explicit !== void 0) return explicit;
5693
+ const candidates = inferPlatforms(config);
5694
+ if (candidates.length === 0) return yield* new BuildProfileError({ message: "Cannot infer build platform. Add an `ios` or `android` section to your Expo config, or pass --platform." });
5695
+ if (candidates.length === 1) {
5696
+ const [only] = candidates;
5697
+ if (only === void 0) return yield* new BuildProfileError({ message: "Internal: empty platform candidate list." });
5698
+ return only;
5699
+ }
5700
+ if (!(yield* InteractiveMode).allow) return yield* new BuildProfileError({ message: `Multiple platforms detected (${candidates.join(", ")}). Pass --platform explicitly when running non-interactively.` });
5701
+ return yield* promptSelect("Which platform to build?", PLATFORMS.filter((entry) => candidates.includes(entry)).map((entry) => ({
5702
+ value: entry,
5703
+ label: entry
5704
+ })));
5280
5705
  });
5281
- const listAppleCertificates = (api, input) => Effect.gen(function* () {
5282
- const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
5283
- return yield* listCertificates({
5284
- keyId: creds.keyId,
5285
- issuerId: creds.issuerId,
5286
- p8Pem: creds.p8Pem
5287
- }, input.certificateType === void 0 ? {} : { certificateType: input.certificateType }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
5288
- });
5289
- const resolveCertAscId = (creds, serialNumber, certificateType) => Effect.gen(function* () {
5290
- const match = (yield* listCertificates(creds, { certificateType }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")))).find((entry) => entry.serialNumber.toUpperCase() === serialNumber);
5291
- if (match === void 0) return yield* Effect.fail(new GenerateFailedError({
5292
- step: "match-apple-certificate",
5293
- message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
5294
- }));
5295
- return match.id;
5296
- });
5297
- const ensureBundleId = (creds, bundleIdentifier) => Effect.gen(function* () {
5298
- const existing = (yield* listBundleIds(creds).pipe(Effect.mapError(wrapAscError("apple-list-bundle-ids")))).find((entry) => entry.identifier === bundleIdentifier);
5299
- if (existing !== void 0) return existing.id;
5300
- return (yield* createBundleId(creds, {
5301
- identifier: bundleIdentifier,
5302
- name: bundleIdentifier
5303
- }).pipe(Effect.mapError(wrapAscError("apple-create-bundle-id")))).id;
5706
+
5707
+ //#endregion
5708
+ //#region src/lib/repo-clean.ts
5709
+ const MAX_FILES_SHOWN = 10;
5710
+ const readPorcelain = (projectRoot) => Command.make("git", "status", "--porcelain").pipe(Command.workingDirectory(projectRoot), Command.string, Effect.map((output) => output.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0)), Effect.catchAll(() => Effect.succeed([])));
5711
+ /**
5712
+ * Refuse to proceed when the working tree has uncommitted changes. Skipped when
5713
+ * `allowDirty` is true. In interactive mode, prompts the user to confirm; in
5714
+ * non-interactive mode, fails with `DirtyRepoError`.
5715
+ */
5716
+ const ensureRepoClean = ({ projectRoot, allowDirty, label }) => Effect.gen(function* () {
5717
+ if (allowDirty) return;
5718
+ const dirty = yield* readPorcelain(projectRoot);
5719
+ if (dirty.length === 0) return;
5720
+ const preview = dirty.slice(0, MAX_FILES_SHOWN).join("\n ");
5721
+ const overflow = dirty.length > MAX_FILES_SHOWN ? `\n ... and ${String(dirty.length - MAX_FILES_SHOWN)} more` : "";
5722
+ yield* Console.error(`Uncommitted changes (${String(dirty.length)} file(s)):\n ${preview}${overflow}`);
5723
+ if (!(yield* InteractiveMode).allow) {
5724
+ yield* new DirtyRepoError({ message: `Refusing to ${label} with a dirty working tree. Commit your changes or pass --allow-dirty.` });
5725
+ return;
5726
+ }
5727
+ if (!(yield* promptConfirm(`Continue ${label} with uncommitted changes?`, { initialValue: false }))) yield* new DirtyRepoError({ message: `${label} cancelled by user.` });
5304
5728
  });
5305
- const collectDeviceAscIds = (creds, appleTeamId, deviceIds) => Effect.gen(function* () {
5306
- const devices = yield* listDevices(creds).pipe(Effect.mapError(wrapAscError("apple-list-devices")));
5729
+
5730
+ //#endregion
5731
+ //#region src/lib/fingerprint.ts
5732
+ var FingerprintError = class extends Data.TaggedError("FingerprintError") {};
5733
+ const runFingerprintFull = (projectRoot) => Effect.gen(function* () {
5734
+ const cmd = Command.make("bunx", "@expo/fingerprint", projectRoot).pipe(Command.workingDirectory(projectRoot));
5735
+ const stdout = yield* Command.string(cmd).pipe(Effect.mapError((cause) => new FingerprintError({ message: `Failed to run "@expo/fingerprint": ${cause.message}` })));
5736
+ const parsed = yield* Effect.try({
5737
+ try: () => JSON.parse(stdout),
5738
+ catch: () => new FingerprintError({ message: "Failed to parse @expo/fingerprint output as JSON." })
5739
+ });
5740
+ if (!isRecord(parsed)) return yield* new FingerprintError({ message: "@expo/fingerprint output was not a JSON object." });
5741
+ const { hash } = parsed;
5742
+ if (typeof hash !== "string" || hash.length === 0) return yield* new FingerprintError({ message: "@expo/fingerprint output did not contain a \"hash\" string field." });
5743
+ const sourcesRaw = parsed["sources"];
5307
5744
  return {
5308
- ids: deviceIds === void 0 ? devices.map((device) => device.id) : devices.filter((device) => new Set(deviceIds).has(device.id)).map((device) => device.id),
5309
- appleTeamId
5745
+ hash,
5746
+ sources: Array.isArray(sourcesRaw) ? sourcesRaw : []
5310
5747
  };
5311
5748
  });
5312
- const generateAndUploadProvisioningProfile = (api, input) => Effect.gen(function* () {
5313
- const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
5314
- const ascCreds = {
5315
- keyId: creds.keyId,
5316
- issuerId: creds.issuerId,
5317
- p8Pem: creds.p8Pem
5318
- };
5319
- const cert = yield* api.appleDistributionCertificates.list().pipe(Effect.map(({ items }) => items.find((item) => item.id === input.distributionCertificateId)), Effect.flatMap((match) => match === void 0 ? Effect.fail(new GenerateFailedError({
5320
- step: "load-distribution-certificate",
5321
- message: `Distribution certificate ${input.distributionCertificateId} not found`
5322
- })) : Effect.succeed(match)));
5323
- const certificateType = input.distributionType === "DEVELOPMENT" ? "IOS_DEVELOPMENT" : "IOS_DISTRIBUTION";
5324
- const [certAscId, bundleIdAscId] = yield* Effect.all([resolveCertAscId(ascCreds, cert.serialNumber.toUpperCase(), certificateType), ensureBundleId(ascCreds, input.bundleIdentifier)], { concurrency: 2 });
5325
- const useDevices = input.distributionType === "AD_HOC" || input.distributionType === "DEVELOPMENT";
5326
- const { ids: deviceAscIds } = useDevices ? yield* collectDeviceAscIds(ascCreds, cert.appleTeamId, input.deviceIds) : { ids: [] };
5327
- if (useDevices && deviceAscIds.length === 0) return yield* Effect.fail(new GenerateFailedError({
5328
- step: "collect-devices",
5329
- message: "No registered devices to attach to the provisioning profile"
5330
- }));
5331
- const profileBytes = fromBase64((yield* createProvisioningProfile(ascCreds, {
5332
- profileName: `${input.bundleIdentifier} ${input.distributionType} ${Date.now()}`,
5333
- profileType: DISTRIBUTION_TO_PROFILE_TYPE$1[input.distributionType],
5334
- bundleIdAscId,
5335
- certificateAscIds: [certAscId],
5336
- deviceAscIds
5337
- }).pipe(Effect.mapError(wrapAscError("apple-create-profile")))).profileContent);
5338
- const rosterHash = useDevices ? computeDeviceRosterHashHex(deviceAscIds) : void 0;
5339
- const created = yield* api.appleProvisioningProfiles.upload({ payload: {
5340
- profileBase64: toBase64(profileBytes),
5341
- appleDistributionCertificateId: input.distributionCertificateId,
5342
- isManaged: true,
5343
- ...rosterHash === void 0 ? {} : { deviceRosterHash: rosterHash }
5344
- } });
5345
- return {
5346
- id: created.id,
5347
- bundleIdentifier: created.bundleIdentifier,
5348
- distributionType: created.distributionType,
5349
- profileName: created.profileName,
5350
- validUntil: created.validUntil,
5351
- developerPortalIdentifier: created.developerPortalIdentifier
5352
- };
5749
+
5750
+ //#endregion
5751
+ //#region src/lib/runtime-version.ts
5752
+ const resolveRuntimeVersion = ({ raw, appVersion, projectRoot }) => Effect.gen(function* () {
5753
+ if (typeof raw === "string") return raw;
5754
+ if (raw === void 0) return yield* new RuntimeVersionError({ message: "No runtimeVersion configured in expo section of app.json." });
5755
+ const { policy } = raw;
5756
+ if (policy === "appVersion") {
5757
+ if (appVersion === void 0) return yield* new RuntimeVersionError({ message: "runtimeVersion policy is \"appVersion\" but expo.version is missing in app.json." });
5758
+ return appVersion;
5759
+ }
5760
+ if (policy === "fingerprint") return yield* runFingerprintFull(projectRoot).pipe(Effect.map((result) => result.hash), Effect.mapError((cause) => new RuntimeVersionError({ message: cause.message })));
5761
+ if (policy === "nativeVersion") return yield* new RuntimeVersionError({ message: "runtimeVersion policy \"nativeVersion\" is not supported. Set a static runtimeVersion string in your Expo config." });
5762
+ return yield* new RuntimeVersionError({ message: `Unsupported runtimeVersion policy "${policy}". Use a static string, "appVersion", or "fingerprint".` });
5353
5763
  });
5354
5764
 
5355
5765
  //#endregion