@better-update/cli 0.14.3 → 0.15.1

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
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.3";
31
+ var version = "0.15.1";
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,2047 +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
- const plist = typeof plistMod.parse === "function" ? plistMod : plistMod.default;
3788
- /**
3789
- * Parse an XML plist string into a typed object.
3790
- * Throws on malformed XML — callers should wrap in Effect.try.
3791
- */
3792
- const parsePlistXml = (xml) => plist.parse(xml);
3793
- /**
3794
- * Parse a binary plist buffer into a typed object.
3795
- * Uses bplist-parser for Apple's binary plist format.
3796
- */
3797
- const parsePlistBinary = (buffer) => {
3798
- const [result] = __require("bplist-parser").parseBuffer(buffer);
3799
- return result;
3800
- };
3801
- const BPLIST_MAGIC = Buffer.from("bplist00");
3802
- /**
3803
- * Auto-detect plist format (binary vs XML) and parse accordingly.
3804
- */
3805
- const parsePlist = (data) => data.subarray(0, 8).equals(BPLIST_MAGIC) ? parsePlistBinary(data) : parsePlistXml(data.toString("utf8"));
3806
-
3807
- //#endregion
3808
- //#region src/lib/ios-provisioning.ts
3809
- const getString = (obj, key) => {
3810
- const value = obj[key];
3811
- return typeof value === "string" ? value : void 0;
3812
- };
3813
- const getFirstArrayString = (obj, key) => {
3814
- const value = obj[key];
3815
- if (Array.isArray(value) && typeof value[0] === "string") return value[0];
3816
- };
3817
- /**
3818
- * Extract `UUID`, `Name`, and the first `TeamIdentifier` from the XML plist
3819
- * output of `security cms -D -i <path>`. Returns `ProvisioningError` when any
3820
- * of the three fields are missing.
3821
- */
3822
- const extractProvisioningInfo = (plistXml) => Effect.gen(function* () {
3823
- const parsed = yield* Effect.try({
3824
- try: () => parsePlistXml(plistXml),
3825
- 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
+ })
3826
3790
  });
3827
- const uuid = getString(parsed, "UUID");
3828
- const name = getString(parsed, "Name");
3829
- const teamId = getFirstArrayString(parsed, "TeamIdentifier");
3830
- 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
+ } });
3831
3817
  return {
3832
- uuid,
3833
- name,
3834
- teamId
3818
+ id: created.id,
3819
+ serialNumber: bundle.metadata.serialNumber,
3820
+ appleTeamId: created.appleTeamId,
3821
+ appleTeamIdentifier: bundle.metadata.appleTeamId,
3822
+ developerPortalIdentifier: apple.id
3835
3823
  };
3836
3824
  });
3837
- const userProvisioningProfilesDir = () => path.join(os.homedir(), "Library", "MobileDevice", "Provisioning Profiles");
3838
- /**
3839
- * Scoped installation of a provisioning profile: parses its metadata via
3840
- * `security cms -D -i`, copies it into `~/Library/MobileDevice/Provisioning Profiles`
3841
- * under `<uuid>.mobileprovision`, and removes the copy on scope close — but
3842
- * only if we installed it. If the target file already existed when we arrived
3843
- * (e.g., Xcode had it), we leave both the file and the contents untouched.
3844
- */
3845
- const installProvisioningProfile = ({ profilePath }) => Effect.acquireRelease(Effect.gen(function* () {
3846
- const fs = yield* FileSystem.FileSystem;
3847
- 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)}` }))));
3848
- const targetDir = userProvisioningProfilesDir();
3849
- const installedPath = path.join(targetDir, `${info.uuid}.mobileprovision`);
3850
- yield* fs.makeDirectory(targetDir, { recursive: true }).pipe(Effect.catchAll((cause) => new ProvisioningError({ message: `Failed to create provisioning profiles dir: ${String(cause)}` })));
3851
- if (yield* fs.exists(installedPath).pipe(Effect.orElseSucceed(() => false))) return {
3852
- ...info,
3853
- installedPath,
3854
- ownsInstallation: false
3855
- };
3856
- yield* fs.copyFile(profilePath, installedPath).pipe(Effect.catchAll((cause) => new ProvisioningError({ message: `Failed to copy provisioning profile into ${installedPath}: ${String(cause)}` })));
3857
- return {
3858
- ...info,
3859
- installedPath,
3860
- ownsInstallation: true
3861
- };
3862
- }), (acquired) => Effect.gen(function* () {
3863
- if (!acquired.ownsInstallation) return;
3864
- yield* (yield* FileSystem.FileSystem).remove(acquired.installedPath).pipe(Effect.catchAll(() => Effect.void));
3865
- })).pipe(Effect.map(({ uuid, name, teamId, installedPath }) => ({
3866
- uuid,
3867
- name,
3868
- teamId,
3869
- installedPath
3870
- })));
3871
-
3872
- //#endregion
3873
- //#region src/lib/post-build-validation.ts
3874
- /**
3875
- * Validate an iOS build after xcodebuild completes. Checks:
3876
- * 1. Bundle ID matches expected value
3877
- * 2. Provisioning profile UUID matches
3878
- * 3. Team ID matches
3879
- *
3880
- * All checks are non-blocking — returns warnings, never fails the build.
3881
- */
3882
- const validateIosBuild = (params) => Effect.gen(function* () {
3883
- const appDir = yield* findAppDirectory$1(params.archivePath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
3884
- if (!appDir) return {
3885
- passed: false,
3886
- 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
3887
3844
  };
3888
- const bundleWarning = yield* checkBundleId(appDir, params.expectedBundleId).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
3889
- const profileWarnings = yield* checkEmbeddedProfile(appDir, params.expectedProfileUuid, params.expectedTeamId).pipe(Effect.catchAll(() => Effect.succeed([])));
3890
- const warnings = [...bundleWarning ? [bundleWarning] : [], ...profileWarnings];
3891
- if (warnings.length > 0) {
3892
- yield* Console.warn("Post-build validation warnings:");
3893
- 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;
3894
3857
  }
3895
3858
  return {
3896
- passed: warnings.length === 0,
3897
- warnings
3859
+ localId: input.distributionCertificateId,
3860
+ serialNumber: local.serialNumber,
3861
+ revokedOnApple,
3862
+ deletedLocally
3898
3863
  };
3899
3864
  });
3900
- const findAppDirectory$1 = (archivePath) => Effect.gen(function* () {
3901
- const fs = yield* FileSystem.FileSystem;
3902
- const productsDir = path.join(archivePath, "Products", "Applications");
3903
- const appEntry = (yield* fs.readDirectory(productsDir)).find((entry) => entry.endsWith(".app"));
3904
- if (!appEntry) return yield* Effect.fail("No .app found");
3905
- return path.join(productsDir, appEntry);
3906
- });
3907
- const checkBundleId = (appDir, expectedBundleId) => Effect.gen(function* () {
3908
- const fs = yield* FileSystem.FileSystem;
3909
- const plistPath = path.join(appDir, "Info.plist");
3910
- const data = yield* fs.readFile(plistPath);
3911
- const actualBundleId = parsePlist(Buffer.from(data))["CFBundleIdentifier"];
3912
- if (typeof actualBundleId === "string" && actualBundleId !== expectedBundleId) return `Bundle ID mismatch: expected "${expectedBundleId}", got "${actualBundleId}"`;
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")));
3913
3872
  });
3914
- const checkEmbeddedProfile = (appDir, expectedUuid, expectedTeamId) => Effect.gen(function* () {
3915
- const warnings = [];
3916
- const profilePath = path.join(appDir, "embedded.mobileprovision");
3917
- const parsed = parsePlistXml(yield* Command.string(Command.make("security", "cms", "-D", "-i", profilePath)));
3918
- const actualUuid = parsed["UUID"];
3919
- if (typeof actualUuid === "string" && actualUuid !== expectedUuid) warnings.push(`Profile UUID mismatch: expected "${expectedUuid}", got "${actualUuid}"`);
3920
- const teamIdentifiers = parsed["TeamIdentifier"];
3921
- if (Array.isArray(teamIdentifiers)) {
3922
- const [actualTeamId] = teamIdentifiers;
3923
- if (typeof actualTeamId === "string" && actualTeamId !== expectedTeamId) warnings.push(`Team ID mismatch: expected "${expectedTeamId}", got "${actualTeamId}"`);
3924
- }
3925
- const expirationDate = parsed["ExpirationDate"];
3926
- if (expirationDate instanceof Date && expirationDate.getTime() < Date.now()) warnings.push(`Embedded provisioning profile expired on ${expirationDate.toISOString()}`);
3927
- return warnings;
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;
3928
3880
  });
3929
-
3930
- //#endregion
3931
- //#region src/lib/xcpretty-formatter.ts
3932
- /**
3933
- * Create a stateful xcodebuild output formatter backed by `@expo/xcpretty`.
3934
- * Each `pipe(line)` call may return zero or more formatted lines — zero means
3935
- * the line was suppressed (e.g., intermediate compiler invocations).
3936
- */
3937
- const createXcodebuildFormatter = (projectRoot) => {
3938
- const formatter = ExpoRunFormatter.create(projectRoot);
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")));
3939
3891
  return {
3940
- pipe: (line) => formatter.pipe(line),
3941
- getBuildSummary: () => formatter.getBuildSummary()
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
3942
3894
  };
3943
- };
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
+ };
3940
+ });
3944
3941
 
3945
3942
  //#endregion
3946
- //#region src/commands/build/ios.ts
3947
- const findXcworkspace = (iosDir) => Effect.gen(function* () {
3948
- const workspace = (yield* (yield* FileSystem.FileSystem).readDirectory(iosDir)).find((entry) => entry.endsWith(".xcworkspace"));
3949
- if (!workspace) return yield* new BuildFailedError({
3950
- step: "detect xcworkspace",
3951
- exitCode: 1,
3952
- message: `No .xcworkspace found under ${iosDir}. Did "pod install" run?`
3943
+ //#region src/lib/auto-provision-extension-profiles.ts
3944
+ /**
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.
3953
+ */
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
3953
3960
  });
3954
- return workspace;
3955
- });
3956
- const prebuildAndPods = (params) => Effect.gen(function* () {
3957
- yield* runStep(Command.make("bunx", "expo", "prebuild", "--platform", "ios", "--clean").pipe(Command.workingDirectory(params.projectRoot), Command.env(params.commandEnv)), "expo prebuild ios");
3958
- yield* runStep(Command.make("pod", "install").pipe(Command.workingDirectory(params.iosDir), Command.env(params.commandEnv)), "pod install");
3959
- });
3960
- const findAppDirectory = (root) => Effect.gen(function* () {
3961
- const fs = yield* FileSystem.FileSystem;
3962
- const stack = [root];
3963
- let depth = 0;
3964
- while (stack.length > 0 && depth < 6) {
3965
- const layer = stack.splice(0);
3966
- depth += 1;
3967
- for (const dir of layer) {
3968
- const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => []));
3969
- for (const entry of entries) {
3970
- const full = path.join(dir, entry);
3971
- if (entry.endsWith(".app")) return full;
3972
- const stat = yield* fs.stat(full).pipe(Effect.option);
3973
- if (stat._tag === "Some" && stat.value.type === "Directory") stack.push(full);
3974
- }
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
3975
3970
  }
3976
- }
3977
- return yield* new ArtifactNotFoundError({ message: `No .app bundle found under "${root}".` });
3978
- });
3979
- const runIosSimulatorBuild = (input) => Effect.gen(function* () {
3980
- const { projectRoot, iosProfile, envVars, tempDir } = input;
3981
- const runtime = yield* CliRuntime;
3982
- const iosDir = path.join(projectRoot, "ios");
3983
- const commandEnv = yield* runtime.commandEnvironment(envVars);
3984
- yield* prebuildAndPods({
3985
- projectRoot,
3986
- iosDir,
3987
- commandEnv
3988
3971
  });
3989
- const workspaceFilename = yield* findXcworkspace(iosDir);
3990
- const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
3991
- const configuration = iosProfile.buildConfiguration ?? "Release";
3992
- const derivedDataPath = path.join(tempDir, "derived-data");
3993
- 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));
3994
- const formatter = input.rawOutput ? void 0 : createXcodebuildFormatter(projectRoot);
3995
- yield* formatter ? runStepFormatted(buildCmd, "xcodebuild build (simulator)", formatter) : runStep(buildCmd, "xcodebuild build (simulator)");
3996
- const appDir = yield* findAppDirectory(path.join(derivedDataPath, "Build", "Products", `${configuration}-iphonesimulator`));
3997
- const archiveName = `${path.basename(appDir, ".app")}-simulator.tar.gz`;
3998
- const archivePath = path.join(tempDir, archiveName);
3999
- yield* runStep(Command.make("tar", "-czf", archivePath, "-C", path.dirname(appDir), path.basename(appDir)).pipe(Command.env(commandEnv)), "tar simulator .app");
4000
- const { sha256, byteSize } = yield* sha256File(archivePath);
4001
3972
  return {
4002
- artifactPath: archivePath,
4003
- byteSize,
4004
- sha256
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
4005
3979
  };
4006
3980
  });
4007
- const runIosDeviceBuild = (input) => Effect.gen(function* () {
4008
- const { api, tempDir, projectRoot, iosProfile, bundleId, envVars, projectId } = input;
4009
- const runtime = yield* CliRuntime;
4010
- const iosDir = path.join(projectRoot, "ios");
4011
- const { distribution } = iosProfile;
4012
- const commandEnv = yield* runtime.commandEnvironment(envVars);
4013
- const credentials = input.credentialsSource === "local" ? yield* loadLocalIosCredentials({ projectRoot }) : yield* downloadIosCredentials(api, {
4014
- projectId,
4015
- bundleIdentifier: bundleId,
4016
- distribution,
4017
- tempDir
3981
+
3982
+ //#endregion
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
4018
4003
  });
4019
- yield* prebuildAndPods({
4020
- projectRoot,
4021
- iosDir,
4022
- commandEnv
4004
+ if (tag === "NotFound") return new MissingCredentialsError({
4005
+ message: message ?? `No ${platformLabel} build credentials configured${bundleSuffix}`,
4006
+ hint: bind
4023
4007
  });
4024
- const keychain = yield* acquireKeychain({
4025
- tempDir,
4026
- p12Path: credentials.p12Path,
4027
- p12Password: credentials.p12Password
4008
+ if (tag === "BadRequest") return new MissingCredentialsError({
4009
+ message: message ?? `${platformLabel} build credentials are misconfigured${bundleSuffix}`,
4010
+ hint: bind
4028
4011
  });
4029
- const provisioning = yield* installProvisioningProfile({ profilePath: credentials.profilePath });
4030
- const workspaceFilename = yield* findXcworkspace(iosDir);
4031
- const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
4032
- const configuration = iosProfile.buildConfiguration ?? "Release";
4033
- const archivePath = path.join(tempDir, "build.xcarchive");
4034
- 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));
4035
- const formatter = input.rawOutput ? void 0 : createXcodebuildFormatter(projectRoot);
4036
- yield* formatter ? runStepFormatted(archiveCmd, "xcodebuild archive", formatter) : runStep(archiveCmd, "xcodebuild archive");
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]
4023
+ }
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
+ })
4032
+ });
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
+ }
4044
+ });
4045
+ }), Effect.catchAll((cause) => {
4046
+ if ((hasTag$1(cause) ? cause._tag : null) === "NotFound") return Effect.succeed({
4047
+ status: "not-registered",
4048
+ bundleIdentifier: options.bundleIdentifier
4049
+ });
4050
+ return Effect.succeed({
4051
+ status: "failed",
4052
+ bundleIdentifier: options.bundleIdentifier,
4053
+ error: resolveErrorToMissingCredentials(cause, "ios", options.bundleIdentifier)
4054
+ });
4055
+ }));
4056
+ const autoProvisionHint = "Upload an ASC API key for this Apple team in the dashboard (Credentials → ASC API Keys) so missing extension bundles can be auto-provisioned, or register them manually.";
4057
+ const downloadIosCredentials = (api, options) => Effect.gen(function* () {
4037
4058
  const fs = yield* FileSystem.FileSystem;
4038
- const exportOptionsPath = path.join(tempDir, "ExportOptions.plist");
4039
- yield* fs.writeFileString(exportOptionsPath, renderExportOptionsPlist({
4040
- method: distribution,
4041
- teamId: provisioning.teamId,
4042
- bundleId,
4043
- 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
4044
4094
  }));
4045
- const exportPath = path.join(tempDir, "export");
4046
- const exportCmd = Command.make("xcodebuild", "-exportArchive", "-archivePath", archivePath, "-exportPath", exportPath, "-exportOptionsPlist", exportOptionsPath, "-allowProvisioningUpdates").pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
4047
- yield* formatter ? runStepFormatted(exportCmd, "xcodebuild exportArchive", formatter) : runStep(exportCmd, "xcodebuild exportArchive");
4048
- yield* validateIosBuild({
4049
- archivePath,
4050
- expectedBundleId: bundleId,
4051
- expectedTeamId: provisioning.teamId,
4052
- 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 no ASC API key is available for this Apple team to auto-provision them.`,
4111
+ hint: autoProvisionHint
4053
4112
  });
4054
- const artifactPath = yield* findIosArtifact({ exportPath });
4055
- 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));
4056
4137
  return {
4057
- artifactPath,
4058
- byteSize,
4059
- 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
4060
4163
  };
4061
4164
  });
4062
- const runIosBuild = (input) => input.iosProfile.simulator === true ? runIosSimulatorBuild(input) : runIosDeviceBuild(input);
4063
4165
 
4064
4166
  //#endregion
4065
- //#region src/commands/build/reserve-and-upload.ts
4066
- const buildReserveCommon = (input) => ({
4067
- projectId: input.projectId,
4068
- profile: input.profileName,
4069
- runtimeVersion: input.runtimeVersion,
4070
- bundleId: input.bundleId,
4071
- sha256: input.sha256,
4072
- byteSize: input.byteSize,
4073
- ...input.appVersion === void 0 ? {} : { appVersion: input.appVersion },
4074
- ...input.buildNumber === void 0 ? {} : { buildNumber: input.buildNumber },
4075
- ...input.gitContext.ref === void 0 ? {} : { gitRef: input.gitContext.ref },
4076
- ...input.gitContext.commit === void 0 ? {} : { gitCommit: input.gitContext.commit },
4077
- ...input.message === void 0 ? {} : { message: input.message }
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." });
4173
+ return {
4174
+ path: yield* asString$2(record["path"], "ios.distributionCertificate.path"),
4175
+ password: yield* asString$2(record["password"], "ios.distributionCertificate.password")
4176
+ };
4078
4177
  });
4079
- const callReserve = (api, input) => {
4080
- const common = buildReserveCommon(input);
4081
- const { target } = input;
4082
- if (target.platform === "ios") return target.distribution === "simulator" ? api.builds.reserve({ payload: {
4083
- ...common,
4084
- platform: "ios",
4085
- distribution: "simulator",
4086
- artifactFormat: "tar.gz"
4087
- } }) : api.builds.reserve({ payload: {
4088
- ...common,
4089
- platform: "ios",
4090
- distribution: target.distribution,
4091
- artifactFormat: "ipa"
4092
- } });
4093
- return target.distribution === "play-store" ? api.builds.reserve({ payload: {
4094
- ...common,
4095
- platform: "android",
4096
- distribution: "play-store",
4097
- artifactFormat: "aab"
4098
- } }) : api.builds.reserve({ payload: {
4099
- ...common,
4100
- platform: "android",
4101
- distribution: "direct",
4102
- artifactFormat: "apk"
4103
- } });
4104
- };
4105
- /**
4106
- * Reserve a build record on the server, upload the artifact to the returned
4107
- * presigned URL, and finalize the build with its sha256 + byteSize.
4108
- */
4109
- const reserveAndUpload = (api, input) => Effect.gen(function* () {
4110
- const presignedUploadClient = yield* PresignedUploadClient;
4111
- const reserveResult = yield* callReserve(api, input).pipe(Effect.mapError((cause) => new ReserveError({ message: `Failed to reserve build: ${formatCause(cause)}` })));
4112
- yield* presignedUploadClient.putToPresignedUrl({
4113
- url: reserveResult.uploadUrl,
4114
- filePath: input.artifactPath,
4115
- byteSize: input.byteSize,
4116
- expiresAt: reserveResult.uploadExpiresAt,
4117
- headers: reserveResult.uploadHeaders
4118
- });
4119
- const completed = yield* api.builds.complete({
4120
- path: { id: reserveResult.id },
4121
- payload: {
4122
- sha256: input.sha256,
4123
- byteSize: input.byteSize
4124
- }
4125
- }).pipe(Effect.mapError((cause) => new CompleteError({ message: `Failed to complete build ${reserveResult.id}: ${formatCause(cause)}` })));
4126
- if (!completed.artifact) return yield* new CompleteError({ message: `Build ${completed.id} completed but server returned no artifact record.` });
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." });
4127
4181
  return {
4128
- id: completed.id,
4129
- status: "uploaded"
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")
4130
4185
  };
4131
4186
  });
4132
-
4133
- //#endregion
4134
- //#region src/lib/auto-increment.ts
4135
- const bumpBuildNumber = (current) => Effect.gen(function* () {
4136
- const raw = current ?? "0";
4137
- const parsed = Number.parseInt(raw, 10);
4138
- if (Number.isNaN(parsed)) return yield* new BuildProfileError({ message: `Cannot autoIncrement ios.buildNumber: current value "${raw}" is not a base-10 integer.` });
4139
- return String(parsed + 1);
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." });
4190
+ return {
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")
4194
+ };
4140
4195
  });
4141
- const bumpVersionCode = (current) => Effect.gen(function* () {
4142
- const value = current ?? 0;
4143
- 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.` });
4144
- return value + 1;
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.` });
4199
+ return {
4200
+ bundleIdentifier: yield* asString$2(record["bundleIdentifier"], `ios.additionalProvisioningProfiles[${index}].bundleIdentifier`),
4201
+ path: yield* asString$2(record["path"], `ios.additionalProvisioningProfiles[${index}].path`)
4202
+ };
4145
4203
  });
4146
- const SEMVER_PATCH = /^(\d+)\.(\d+)\.(\d+)(.*)$/u;
4147
- const bumpVersion = (current) => Effect.gen(function* () {
4148
- if (current === void 0) return yield* new BuildProfileError({ message: "Cannot autoIncrement version: no `version` field set in Expo config." });
4149
- const match = SEMVER_PATCH.exec(current);
4150
- if (!match) return yield* new BuildProfileError({ message: `Cannot autoIncrement version: "${current}" is not a semver string like "1.2.3".` });
4151
- const [, major, minor, patch, suffix] = match;
4152
- const nextPatch = Number.parseInt(patch ?? "0", 10) + 1;
4153
- return `${major ?? "0"}.${minor ?? "0"}.${String(nextPatch)}${suffix ?? ""}`;
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;
4154
4209
  });
4155
- const computeIosBumps = (config, mode) => Effect.gen(function* () {
4156
- if (mode === "buildNumber") return { nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber) };
4210
+ const parseIos = (raw) => Effect.gen(function* () {
4211
+ const record = asRecord(raw);
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"]);
4157
4218
  return {
4158
- nextVersion: yield* bumpVersion(config.version),
4159
- nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber)
4219
+ provisioningProfilePath,
4220
+ distributionCertificate,
4221
+ ...additionalProvisioningProfiles === void 0 ? {} : { additionalProvisioningProfiles },
4222
+ ...pushKey === void 0 ? {} : { pushKey },
4223
+ ...ascApiKey === void 0 ? {} : { ascApiKey }
4160
4224
  };
4161
4225
  });
4162
- const computeAndroidBumps = (config, mode) => Effect.gen(function* () {
4163
- if (mode === "versionCode") return { nextVersionCode: yield* bumpVersionCode(config.android?.versionCode) };
4226
+ const parseAndroidKeystore = (raw) => Effect.gen(function* () {
4227
+ const record = asRecord(raw);
4228
+ if (!record) return yield* new CredentialsJsonError({ message: "credentials.json: android.keystore must be an object." });
4164
4229
  return {
4165
- nextVersion: yield* bumpVersion(config.version),
4166
- nextVersionCode: yield* bumpVersionCode(config.android?.versionCode)
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")
4167
4234
  };
4168
4235
  });
4169
- const buildPatch = (platform, bumps) => {
4170
- const patch = {};
4171
- if (bumps.nextVersion !== void 0) patch["version"] = bumps.nextVersion;
4172
- if (platform === "ios" && bumps.nextBuildNumber !== void 0) patch["ios"] = { buildNumber: bumps.nextBuildNumber };
4173
- if (platform === "android" && bumps.nextVersionCode !== void 0) patch["android"] = { versionCode: bumps.nextVersionCode };
4174
- return patch;
4175
- };
4176
- const describeBumps = (platform, bumps) => {
4177
- const parts = [];
4178
- if (bumps.nextVersion !== void 0) parts.push(`version=${bumps.nextVersion}`);
4179
- if (platform === "ios" && bumps.nextBuildNumber !== void 0) parts.push(`ios.buildNumber=${bumps.nextBuildNumber}`);
4180
- if (platform === "android" && bumps.nextVersionCode !== void 0) parts.push(`android.versionCode=${String(bumps.nextVersionCode)}`);
4181
- return parts.join(", ");
4182
- };
4183
- const computeBumps = (input) => {
4184
- if (input.platform === "ios") return input.iosMode === void 0 ? Effect.succeed({}) : computeIosBumps(input.config, input.iosMode);
4185
- return input.androidMode === void 0 ? Effect.succeed({}) : computeAndroidBumps(input.config, input.androidMode);
4186
- };
4187
- const hasAnyBump = (bumps) => bumps.nextVersion !== void 0 || bumps.nextBuildNumber !== void 0 || bumps.nextVersionCode !== void 0;
4236
+ const parseGoogleServiceAccountKey = (raw) => Effect.gen(function* () {
4237
+ const record = asRecord(raw);
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"]);
4246
+ return {
4247
+ keystore,
4248
+ ...googleServiceAccountKey === void 0 ? {} : { googleServiceAccountKey }
4249
+ };
4250
+ });
4251
+ const parseCredentialsJson = (raw) => Effect.gen(function* () {
4252
+ const root = asRecord(yield* Effect.try({
4253
+ try: () => JSON.parse(raw),
4254
+ catch: () => new CredentialsJsonError({ message: "credentials.json is not valid JSON." })
4255
+ }));
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\"." });
4260
+ return {
4261
+ ...ios === void 0 ? {} : { ios },
4262
+ ...android === void 0 ? {} : { android }
4263
+ };
4264
+ });
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)}` }))));
4271
+ });
4272
+ const writeCredentialsJson = (projectRoot, data) => Effect.gen(function* () {
4273
+ const fs = yield* FileSystem.FileSystem;
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;
4278
+ });
4188
4279
  /**
4189
- * Bump `version` / `ios.buildNumber` / `android.versionCode` per the resolved
4190
- * autoIncrement mode, persist via `@expo/config.modifyConfigAsync`, and log a
4191
- * Human-readable summary. No-op when the mode is undefined. Returns the new
4192
- * Bumped values so callers can refresh their in-memory ExpoConfig.
4280
+ * Resolve a path that may be either absolute or relative to the project root.
4193
4281
  */
4194
- const applyAutoIncrement = (input) => Effect.gen(function* () {
4195
- const bumps = yield* computeBumps(input);
4196
- if (!hasAnyBump(bumps)) return bumps;
4197
- const patch = buildPatch(input.platform, bumps);
4198
- const result = yield* writeExpoConfigPatch(input.projectRoot, patch).pipe(Effect.mapError((cause) => new BuildProfileError({ message: `Failed to persist autoIncrement: ${cause.message}` })));
4199
- if (result.type === "warn" && result.configPath === null) {
4200
- yield* Console.log(`autoIncrement: dynamic Expo config detected, cannot write back. Update manually: ${describeBumps(input.platform, bumps)}`);
4201
- return bumps;
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
+ });
4202
4317
  }
4203
- yield* Console.log(`autoIncrement: bumped ${describeBumps(input.platform, bumps)}`);
4204
- return bumps;
4318
+ return {
4319
+ p12Path,
4320
+ p12Password: data.ios.distributionCertificate.password,
4321
+ profiles
4322
+ };
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");
4336
+ return {
4337
+ keystorePath,
4338
+ storePassword: data.android.keystore.keystorePassword,
4339
+ keyAlias: data.android.keystore.keyAlias,
4340
+ keyPassword: data.android.keystore.keyPassword
4341
+ };
4205
4342
  });
4206
4343
 
4207
4344
  //#endregion
4208
- //#region src/lib/eas-config.ts
4209
- const MAX_EXTENDS_DEPTH = 10;
4210
- const asStringValue = (value) => typeof value === "string" ? value : void 0;
4211
- const asBooleanValue = (value) => typeof value === "boolean" ? value : void 0;
4212
- const asEnv = (value) => {
4213
- const record = asRecord(value);
4214
- if (!record) return;
4215
- const env = {};
4216
- for (const [key, raw] of Object.entries(record)) if (typeof raw === "string") env[key] = raw;
4217
- return Object.keys(env).length === 0 ? void 0 : env;
4218
- };
4219
- const asIosDistribution = (raw) => {
4220
- const value = asStringValue(raw);
4221
- if (value === "app-store" || value === "ad-hoc" || value === "development" || value === "enterprise") return value;
4222
- };
4223
- const asEnterpriseProvisioning = (raw) => {
4224
- const value = asStringValue(raw);
4225
- return value === "adhoc" || value === "universal" ? value : void 0;
4226
- };
4227
- const asAndroidBuildType = (raw) => {
4228
- const value = asStringValue(raw);
4229
- return value === "debug" || value === "release" ? value : void 0;
4230
- };
4231
- const asAndroidFormat = (raw) => {
4232
- const value = asStringValue(raw);
4233
- return value === "apk" || value === "aab" ? value : void 0;
4234
- };
4235
- const asAndroidDistribution = (raw) => {
4236
- const value = asStringValue(raw);
4237
- return value === "play-store" || value === "direct" ? value : void 0;
4238
- };
4239
- const asIosAutoIncrement = (raw) => {
4240
- if (typeof raw === "boolean") return raw;
4241
- const value = asStringValue(raw);
4242
- return value === "buildNumber" || value === "version" ? value : void 0;
4243
- };
4244
- const asAndroidAutoIncrement = (raw) => {
4245
- if (typeof raw === "boolean") return raw;
4246
- const value = asStringValue(raw);
4247
- return value === "versionCode" || value === "version" ? value : void 0;
4248
- };
4249
- const asAutoIncrement = (raw) => {
4250
- if (typeof raw === "boolean") return raw;
4251
- const value = asStringValue(raw);
4252
- return value === "buildNumber" || value === "versionCode" || value === "version" ? value : void 0;
4253
- };
4254
- const asEasDistribution = (raw) => {
4255
- const value = asStringValue(raw);
4256
- return value === "internal" || value === "store" ? value : void 0;
4257
- };
4258
- const asCredentialsSource = (raw) => {
4259
- const value = asStringValue(raw);
4260
- return value === "remote" || value === "local" ? value : void 0;
4261
- };
4262
- const parseIosProfile = (raw) => {
4263
- const record = asRecord(raw);
4264
- if (!record) return;
4265
- const distribution = asIosDistribution(record["distribution"]);
4266
- const buildConfiguration = asStringValue(record["buildConfiguration"]);
4267
- const scheme = asStringValue(record["scheme"]);
4268
- const simulator = asBooleanValue(record["simulator"]);
4269
- const enterpriseProvisioning = asEnterpriseProvisioning(record["enterpriseProvisioning"]);
4270
- const autoIncrement = asIosAutoIncrement(record["autoIncrement"]);
4271
- return {
4272
- ...distribution === void 0 ? {} : { distribution },
4273
- ...buildConfiguration === void 0 ? {} : { buildConfiguration },
4274
- ...scheme === void 0 ? {} : { scheme },
4275
- ...simulator === void 0 ? {} : { simulator },
4276
- ...enterpriseProvisioning === void 0 ? {} : { enterpriseProvisioning },
4277
- ...autoIncrement === void 0 ? {} : { autoIncrement }
4278
- };
4279
- };
4280
- const parseAndroidProfile = (raw) => {
4281
- const record = asRecord(raw);
4282
- if (!record) return;
4283
- const buildType = asAndroidBuildType(record["buildType"]);
4284
- const flavor = asStringValue(record["flavor"]);
4285
- const gradleCommand = asStringValue(record["gradleCommand"]);
4286
- const format = asAndroidFormat(record["format"]);
4287
- const distribution = asAndroidDistribution(record["distribution"]);
4288
- const autoIncrement = asAndroidAutoIncrement(record["autoIncrement"]);
4289
- return {
4290
- ...buildType === void 0 ? {} : { buildType },
4291
- ...flavor === void 0 ? {} : { flavor },
4292
- ...gradleCommand === void 0 ? {} : { gradleCommand },
4293
- ...format === void 0 ? {} : { format },
4294
- ...distribution === void 0 ? {} : { distribution },
4295
- ...autoIncrement === void 0 ? {} : { autoIncrement }
4296
- };
4297
- };
4298
- const parseBuildProfile = (raw) => {
4299
- const record = asRecord(raw);
4300
- if (!record) return;
4301
- const extendsName = asStringValue(record["extends"]);
4302
- const developmentClient = asBooleanValue(record["developmentClient"]);
4303
- const distribution = asEasDistribution(record["distribution"]);
4304
- const channel = asStringValue(record["channel"]);
4305
- const environment = asStringValue(record["environment"]);
4306
- const env = asEnv(record["env"]);
4307
- const ios = parseIosProfile(record["ios"]);
4308
- const android = parseAndroidProfile(record["android"]);
4309
- const credentialsSource = asCredentialsSource(record["credentialsSource"]);
4310
- const autoIncrement = asAutoIncrement(record["autoIncrement"]);
4311
- return {
4312
- ...extendsName === void 0 ? {} : { extends: extendsName },
4313
- ...developmentClient === void 0 ? {} : { developmentClient },
4314
- ...distribution === void 0 ? {} : { distribution },
4315
- ...channel === void 0 ? {} : { channel },
4316
- ...environment === void 0 ? {} : { environment },
4317
- ...env === void 0 ? {} : { env },
4318
- ...ios === void 0 ? {} : { ios },
4319
- ...android === void 0 ? {} : { android },
4320
- ...credentialsSource === void 0 ? {} : { credentialsSource },
4321
- ...autoIncrement === void 0 ? {} : { autoIncrement }
4322
- };
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());
4323
4389
  };
4324
- const parseEasConfig = (text) => Effect.gen(function* () {
4325
- const root = asRecord(yield* Effect.try({
4326
- try: () => JSON.parse(text),
4327
- catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
4328
- }));
4329
- if (!root) return yield* new BuildProfileError({ message: "eas.json must be a JSON object at the top level." });
4330
- const buildRecord = asRecord(root["build"]);
4331
- if (!buildRecord) return asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {};
4332
- const profiles = {};
4333
- for (const [name, value] of Object.entries(buildRecord)) {
4334
- const profile = parseBuildProfile(value);
4335
- if (profile) profiles[name] = profile;
4390
+
4391
+ //#endregion
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
+ }))));
4402
+ /**
4403
+ * Run a build step with stdout piped through a formatter (e.g., xcpretty).
4404
+ * stderr passes through to the terminal directly.
4405
+ */
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
+ });
4336
4441
  }
4337
- return {
4338
- ...asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {},
4339
- build: profiles
4340
- };
4341
4442
  });
4342
- const parseCli = (raw) => {
4343
- const record = asRecord(raw);
4344
- if (!record) return {};
4345
- const version = asStringValue(record["version"]);
4346
- return version === void 0 ? {} : { version };
4443
+
4444
+ //#endregion
4445
+ //#region src/commands/build/android.ts
4446
+ /**
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`
4454
+ */
4455
+ const gradleTaskName = (format, flavor, buildType) => {
4456
+ const verb = format === "aab" ? "bundle" : "assemble";
4457
+ return flavor ? `${verb}${capitalize(flavor)}${capitalize(buildType)}` : `${verb}${capitalize(buildType)}`;
4347
4458
  };
4348
- const easJsonPath = (projectRoot) => Effect.gen(function* () {
4349
- return (yield* Path.Path).join(projectRoot, "eas.json");
4350
- });
4351
- const readEasJson = (projectRoot) => Effect.gen(function* () {
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");
4352
4474
  const fs = yield* FileSystem.FileSystem;
4353
- const filePath = yield* easJsonPath(projectRoot);
4354
- 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}` })))));
4355
- });
4356
- const mergeIos = (base, overlay) => {
4357
- if (!base) return overlay;
4358
- if (!overlay) return base;
4359
- return {
4360
- ...base,
4361
- ...overlay
4362
- };
4363
- };
4364
- const mergeAndroid = (base, overlay) => {
4365
- if (!base) return overlay;
4366
- if (!overlay) return base;
4367
- return {
4368
- ...base,
4369
- ...overlay
4370
- };
4371
- };
4372
- const mergeEnv = (base, overlay) => {
4373
- if (!base) return overlay;
4374
- if (!overlay) return base;
4375
- return {
4376
- ...base,
4377
- ...overlay
4378
- };
4379
- };
4380
- const mergeProfile = (base, overlay) => {
4381
- const ios = mergeIos(base.ios, overlay.ios);
4382
- const android = mergeAndroid(base.android, overlay.android);
4383
- const env = mergeEnv(base.env, overlay.env);
4384
- const developmentClient = overlay.developmentClient ?? base.developmentClient;
4385
- const distribution = overlay.distribution ?? base.distribution;
4386
- const channel = overlay.channel ?? base.channel;
4387
- const environment = overlay.environment ?? base.environment;
4388
- const credentialsSource = overlay.credentialsSource ?? base.credentialsSource;
4389
- const autoIncrement = overlay.autoIncrement ?? base.autoIncrement;
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);
4390
4492
  return {
4391
- ...overlay.extends === void 0 ? {} : { extends: overlay.extends },
4392
- ...developmentClient === void 0 ? {} : { developmentClient },
4393
- ...distribution === void 0 ? {} : { distribution },
4394
- ...channel === void 0 ? {} : { channel },
4395
- ...environment === void 0 ? {} : { environment },
4396
- ...env === void 0 ? {} : { env },
4397
- ...ios === void 0 ? {} : { ios },
4398
- ...android === void 0 ? {} : { android },
4399
- ...credentialsSource === void 0 ? {} : { credentialsSource },
4400
- ...autoIncrement === void 0 ? {} : { autoIncrement }
4493
+ artifactPath,
4494
+ byteSize,
4495
+ sha256
4401
4496
  };
4402
- };
4403
- const collectExtendsChain = (profiles, profileName) => Effect.gen(function* () {
4404
- const chain = [];
4405
- const visited = /* @__PURE__ */ new Set();
4406
- let current = profileName;
4407
- let depth = 0;
4408
- while (current !== void 0) {
4409
- if (visited.has(current)) return yield* new BuildProfileError({ message: `Cycle detected in eas.json build.${profileName} extends chain at "${current}".` });
4410
- visited.add(current);
4411
- const profile = profiles[current];
4412
- if (!profile) return yield* new BuildProfileError({ message: current === profileName ? `Build profile "${profileName}" not found in eas.json.` : `Build profile "${profileName}" extends missing profile "${current}".` });
4413
- chain.unshift(profile);
4414
- current = profile.extends;
4415
- depth += 1;
4416
- if (depth > MAX_EXTENDS_DEPTH) return yield* new BuildProfileError({ message: `Too many "extends" levels (max ${String(MAX_EXTENDS_DEPTH)}) in eas.json build.${profileName}.` });
4417
- }
4418
- return chain;
4419
4497
  });
4420
- const stripExtends = (profile) => {
4421
- if (profile.extends === void 0) return profile;
4422
- const { extends: _omit, ...rest } = profile;
4423
- return rest;
4498
+
4499
+ //#endregion
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
+ });
4511
+ /**
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;
4424
4528
  };
4425
- const resolveEasBuildProfile = (config, profileName) => Effect.gen(function* () {
4426
- const profiles = config.build;
4427
- if (!profiles) return yield* new BuildProfileError({ message: "eas.json has no \"build\" section. Add at least one profile." });
4428
- return stripExtends((yield* collectExtendsChain(profiles, profileName)).reduce((acc, next, index) => index === 0 ? next : mergeProfile(acc, next), {}));
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.
4533
+ *
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`).
4538
+ */
4539
+ const applyTargetSigning = (options) => Effect.gen(function* () {
4540
+ const fs = yield* FileSystem.FileSystem;
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)}` })));
4429
4550
  });
4430
4551
 
4431
4552
  //#endregion
4432
- //#region src/lib/build-profile.ts
4433
- const asString$1 = (value) => typeof value === "string" ? value : void 0;
4434
- const deriveIosDistribution = (eas) => {
4435
- const override = eas.ios?.distribution;
4436
- if (override) return override;
4437
- if (eas.developmentClient === true) return "development";
4438
- if (eas.distribution === "internal") return "ad-hoc";
4439
- if (eas.distribution === "store") return "app-store";
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/>";
4556
+ /**
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`
4564
+ */
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");
4440
4587
  };
4441
- const deriveAndroidFormat = (eas) => {
4442
- if (eas.android?.format) return eas.android.format;
4443
- if (eas.distribution === "store") return "aab";
4444
- if (eas.distribution === "internal") return "apk";
4445
- if (eas.developmentClient === true) return "apk";
4588
+
4589
+ //#endregion
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
+ }
4446
4601
  };
4447
- const deriveAndroidDistribution = (eas, format) => {
4448
- if (eas.android?.distribution) return eas.android.distribution;
4449
- if (format === "aab") return "play-store";
4450
- return "direct";
4602
+ /**
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.
4607
+ */
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));
4451
4634
  };
4452
- const hasIosIntent = (eas) => eas.ios !== void 0 || eas.distribution !== void 0 || eas.developmentClient === true;
4453
- const hasAndroidIntent = (eas) => eas.android !== void 0 || eas.distribution !== void 0 || eas.developmentClient === true;
4454
- const resolveIosAutoIncrement = (eas) => {
4455
- const override = eas.ios?.autoIncrement;
4456
- if (override === false) return;
4457
- if (override === true) return "buildNumber";
4458
- if (override === "buildNumber" || override === "version") return override;
4459
- const top = eas.autoIncrement;
4460
- if (top === true || top === "buildNumber") return "buildNumber";
4461
- if (top === "version") return "version";
4635
+
4636
+ //#endregion
4637
+ //#region src/lib/plist.ts
4638
+ const plist = typeof plistMod.parse === "function" ? plistMod : plistMod.default;
4639
+ /**
4640
+ * Parse an XML plist string into a typed object.
4641
+ * Throws on malformed XML callers should wrap in Effect.try.
4642
+ */
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;
4462
4651
  };
4463
- const resolveAndroidAutoIncrement = (eas) => {
4464
- const override = eas.android?.autoIncrement;
4465
- if (override === false) return;
4466
- if (override === true) return "versionCode";
4467
- if (override === "versionCode" || override === "version") return override;
4468
- const top = eas.autoIncrement;
4469
- if (top === true || top === "versionCode") return "versionCode";
4470
- if (top === "version") return "version";
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"));
4657
+
4658
+ //#endregion
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;
4471
4663
  };
4472
- const toIosProfile = (eas) => {
4473
- if (!hasIosIntent(eas)) return;
4474
- const distribution = deriveIosDistribution(eas);
4475
- if (!distribution) return;
4476
- const ios = eas.ios ?? {};
4477
- const autoIncrement = resolveIosAutoIncrement(eas);
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* () {
4674
+ const parsed = yield* Effect.try({
4675
+ try: () => parsePlistXml(plistXml),
4676
+ catch: (error) => new ProvisioningError({ message: `Failed to parse provisioning profile plist: ${error instanceof Error ? error.message : String(error)}` })
4677
+ });
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() });
4478
4682
  return {
4479
- distribution,
4480
- ...ios.buildConfiguration === void 0 ? {} : { buildConfiguration: ios.buildConfiguration },
4481
- ...ios.scheme === void 0 ? {} : { scheme: ios.scheme },
4482
- ...ios.simulator === void 0 ? {} : { simulator: ios.simulator },
4483
- ...autoIncrement === void 0 ? {} : { autoIncrement }
4683
+ uuid,
4684
+ name,
4685
+ teamId
4484
4686
  };
4485
- };
4486
- const toAndroidProfile = (eas) => {
4487
- if (!hasAndroidIntent(eas)) return;
4488
- const format = deriveAndroidFormat(eas);
4489
- if (!format) return;
4490
- const android = eas.android ?? {};
4491
- const distribution = deriveAndroidDistribution(eas, format);
4492
- const autoIncrement = resolveAndroidAutoIncrement(eas);
4687
+ });
4688
+ const userProvisioningProfilesDir = () => path.join(os.homedir(), "Library", "MobileDevice", "Provisioning Profiles");
4689
+ /**
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.
4695
+ */
4696
+ const installProvisioningProfile = ({ profilePath }) => Effect.acquireRelease(Effect.gen(function* () {
4697
+ const fs = yield* FileSystem.FileSystem;
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)}` })));
4493
4708
  return {
4494
- format,
4495
- distribution,
4496
- ...android.buildType === void 0 ? {} : { buildType: android.buildType },
4497
- ...android.flavor === void 0 ? {} : { flavor: android.flavor },
4498
- ...android.gradleCommand === void 0 ? {} : { gradleCommand: android.gradleCommand },
4499
- ...autoIncrement === void 0 ? {} : { autoIncrement }
4709
+ ...info,
4710
+ installedPath,
4711
+ ownsInstallation: true
4500
4712
  };
4501
- };
4502
- const fromEasProfile = (eas, profileName) => {
4503
- const ios = toIosProfile(eas);
4504
- const android = toAndroidProfile(eas);
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
+ })));
4722
+
4723
+ //#endregion
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}`);
4756
+ }
4505
4757
  return {
4506
- name: profileName,
4507
- environment: eas.environment ?? "production",
4508
- ...eas.channel === void 0 ? {} : { channel: eas.channel },
4509
- ...eas.env === void 0 ? {} : { env: eas.env },
4510
- ...ios === void 0 ? {} : { ios },
4511
- ...android === void 0 ? {} : { android },
4512
- ...eas.credentialsSource === void 0 ? {} : { credentialsSource: eas.credentialsSource }
4758
+ passed: warnings.length === 0,
4759
+ warnings
4513
4760
  };
4514
- };
4515
- const readBuildProfile = (projectRoot, profileName) => Effect.gen(function* () {
4516
- return fromEasProfile(yield* resolveEasBuildProfile(yield* readEasJson(projectRoot), profileName), profileName);
4517
4761
  });
4518
- const readRuntimeVersionMeta = (config) => ({
4519
- appVersion: config.version,
4520
- rawRuntimeVersion: readRawRuntimeVersion(config.runtimeVersion)
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);
4521
4768
  });
4522
- const readRawRuntimeVersion = (value) => {
4523
- if (typeof value === "string") return value;
4524
- const policy = asString$1(asRecord(value)?.["policy"]);
4525
- if (policy) return { policy };
4526
- };
4527
-
4528
- //#endregion
4529
- //#region src/lib/clear-cache.ts
4530
4769
  /**
4531
- * Project-scoped build cache directories to remove when --clear-cache is passed.
4532
- * Intentionally avoids `~/.gradle/caches` (global) and `ios/Pods/` (requires
4533
- * pod install rebuild leave to the user).
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.
4534
4773
  */
4535
- const CACHE_DIRS = [
4536
- "android/.gradle",
4537
- "android/app/build",
4538
- "android/build",
4539
- "ios/build",
4540
- ".expo",
4541
- "node_modules/.cache"
4542
- ];
4543
- const clearBuildCaches = (projectRoot) => Effect.gen(function* () {
4774
+ const listSignedBundleDirs = (appDir) => Effect.gen(function* () {
4544
4775
  const fs = yield* FileSystem.FileSystem;
4545
- const removed = [];
4546
- yield* Effect.forEach(CACHE_DIRS, (rel) => Effect.gen(function* () {
4547
- const target = path.join(projectRoot, rel);
4548
- if (!(yield* fs.exists(target).pipe(Effect.catchAll(() => Effect.succeed(false))))) return;
4549
- yield* fs.remove(target, { recursive: true }).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
4550
- removed.push(rel);
4551
- }), { concurrency: 4 });
4552
- if (removed.length > 0) yield* Console.error(`Cleared caches: ${removed.join(", ")}`);
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;
4553
4801
  });
4554
4802
 
4555
4803
  //#endregion
4556
- //#region src/lib/env-exporter.ts
4557
- const coerceEnvironment = (raw) => raw === "development" || raw === "preview" || raw === "production" ? raw : void 0;
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");
4558
4815
  /**
4559
- * Pull environment variables for a project + environment and flatten them into
4560
- * a key/value map. Returns an empty map when the project has no variables.
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"`).
4561
4819
  */
4562
- const pullEnvVars = (api, { projectId, environment }) => {
4563
- const validated = coerceEnvironment(environment);
4564
- if (!validated) return Effect.fail(new EnvExportError({ message: `Invalid environment "${environment}". Must be one of: development, preview, production.` }));
4565
- return api["env-vars"].export({ urlParams: {
4566
- projectId,
4567
- environment: validated
4568
- } }).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)}` })));
4569
- };
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;
4883
+ });
4570
4884
 
4571
4885
  //#endregion
4572
- //#region src/lib/git-context.ts
4573
- const runString = (cmd, cwd) => Command.string(Command.workingDirectory(cmd, cwd));
4886
+ //#region src/lib/xcpretty-formatter.ts
4574
4887
  /**
4575
- * Best-effort git context extraction. If git is missing, the directory isn't
4576
- * a repo, or any command fails, we silently return undefined fields so the
4577
- * build can still proceed. This is intentional — git context is metadata,
4578
- * not a requirement.
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).
4579
4891
  */
4580
- const readGitContext = (projectRoot) => Effect.gen(function* () {
4581
- const [commit, ref, commitMessage, status] = yield* Effect.all([
4582
- runString(Command.make("git", "rev-parse", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
4583
- runString(Command.make("git", "symbolic-ref", "--short", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
4584
- runString(Command.make("git", "log", "-1", "--format=%s"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
4585
- runString(Command.make("git", "status", "--porcelain"), projectRoot).pipe(Effect.catchAll(() => Effect.succeed("")))
4586
- ], { concurrency: "unbounded" });
4892
+ const createXcodebuildFormatter = (projectRoot) => {
4893
+ const formatter = ExpoRunFormatter.create(projectRoot);
4587
4894
  return {
4588
- ref: ref.length > 0 ? ref : void 0,
4589
- commit: commit.length > 0 ? commit : void 0,
4590
- commitMessage: commitMessage.length > 0 ? commitMessage : void 0,
4591
- dirty: status.trim().length > 0
4895
+ pipe: (line) => formatter.pipe(line),
4896
+ getBuildSummary: () => formatter.getBuildSummary()
4592
4897
  };
4593
- });
4898
+ };
4594
4899
 
4595
4900
  //#endregion
4596
- //#region src/lib/gradle-config.ts
4597
- /**
4598
- * Parse Groovy `build.gradle` to extract key Android config values.
4599
- * Returns `undefined` if:
4600
- * - Only `build.gradle.kts` exists (Kotlin DSL not supported by gradle-to-js)
4601
- * - No build.gradle found at all
4602
- * - Parse fails
4603
- *
4604
- * Informational only — never blocks the build.
4605
- */
4606
- const readGradleConfig = (androidDir) => Effect.gen(function* () {
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?`
4908
+ });
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* () {
4607
4916
  const fs = yield* FileSystem.FileSystem;
4608
- const gradlePath = path.join(androidDir, "app", "build.gradle");
4609
- const ktsPath = path.join(androidDir, "app", "build.gradle.kts");
4610
- const hasGroovy = yield* fs.exists(gradlePath).pipe(Effect.orElseSucceed(() => false));
4611
- const hasKts = yield* fs.exists(ktsPath).pipe(Effect.orElseSucceed(() => false));
4612
- if (!hasGroovy && hasKts) return;
4613
- if (!hasGroovy) return;
4614
- const content = yield* fs.readFileString(gradlePath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
4615
- if (!content) return;
4616
- return yield* Effect.tryPromise({
4617
- try: async () => {
4618
- return __require("gradle-to-js").parseText(stripGroovyComments(content));
4619
- },
4620
- catch: () => void 0
4621
- }).pipe(Effect.map(extractGradleConfig), Effect.catchAll(() => Effect.succeed(void 0)));
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);
4929
+ }
4930
+ }
4931
+ }
4932
+ return yield* new ArtifactNotFoundError({ message: `No .app bundle found under "${root}".` });
4622
4933
  });
4623
- /**
4624
- * Log a warning if Gradle applicationId differs from app.json package name.
4625
- */
4626
- const warnOnGradleMismatch = (gradleConfig, expectedPackage) => {
4627
- if (!gradleConfig?.applicationId) return Effect.void;
4628
- if (gradleConfig.applicationId === expectedPackage) return Effect.void;
4629
- return Console.warn(`Gradle applicationId "${gradleConfig.applicationId}" differs from app.json package "${expectedPackage}". The Gradle value will be used in the built APK/AAB.`);
4630
- };
4631
- /**
4632
- * Strip Groovy single-line and block comments.
4633
- * gradle-to-js chokes on comments — EAS CLI does this same pre-processing.
4634
- */
4635
- const stripGroovyComments = (text) => text.replaceAll(/\/\/.*$/gmu, "").replaceAll(/\/\*[\s\S]*?\*\//gu, "");
4636
- const parseVersionCode = (raw) => {
4637
- if (typeof raw === "number") return raw;
4638
- if (typeof raw === "string") return Number.parseInt(raw, 10) || void 0;
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);
4956
+ return {
4957
+ artifactPath: archivePath,
4958
+ byteSize,
4959
+ sha256
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
+ })));
4639
4993
  };
4640
- const extractGradleConfig = (parsed) => {
4641
- const defaultConfig = asRecord(asRecord(parsed["android"])?.["defaultConfig"]);
4642
- const applicationId = typeof defaultConfig?.["applicationId"] === "string" ? unquote(defaultConfig["applicationId"]) : void 0;
4643
- const versionCode = parseVersionCode(defaultConfig?.["versionCode"]);
4644
- const versionName = typeof defaultConfig?.["versionName"] === "string" ? unquote(defaultConfig["versionName"]) : void 0;
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);
4645
5077
  return {
4646
- ...applicationId === void 0 ? {} : { applicationId },
4647
- ...versionCode === void 0 ? {} : { versionCode },
4648
- ...versionName === void 0 ? {} : { versionName }
5078
+ artifactPath,
5079
+ byteSize,
5080
+ sha256
4649
5081
  };
4650
- };
4651
- const unquote = (input) => input.startsWith("\"") && input.endsWith("\"") ? input.slice(1, -1) : input;
4652
-
4653
- //#endregion
4654
- //#region src/lib/platform-detect.ts
4655
- const PLATFORMS = ["ios", "android"];
4656
- const inferPlatforms = (config) => {
4657
- const fromConfig = config["platforms"];
4658
- if (Array.isArray(fromConfig)) return fromConfig.filter((entry) => entry === "ios" || entry === "android");
4659
- const present = [];
4660
- if (config.ios !== void 0) present.push("ios");
4661
- if (config.android !== void 0) present.push("android");
4662
- return present;
4663
- };
4664
- /**
4665
- * Resolve a build platform from an explicit flag, or fall back to the Expo
4666
- * config (`expo.platforms` or the presence of `ios`/`android` sections). Prompts
4667
- * when the config declares both platforms; fails when ambiguous and prompts are
4668
- * disallowed.
4669
- */
4670
- const detectPlatform = (explicit, config) => Effect.gen(function* () {
4671
- if (explicit !== void 0) return explicit;
4672
- const candidates = inferPlatforms(config);
4673
- 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." });
4674
- if (candidates.length === 1) {
4675
- const [only] = candidates;
4676
- if (only === void 0) return yield* new BuildProfileError({ message: "Internal: empty platform candidate list." });
4677
- return only;
4678
- }
4679
- if (!(yield* InteractiveMode).allow) return yield* new BuildProfileError({ message: `Multiple platforms detected (${candidates.join(", ")}). Pass --platform explicitly when running non-interactively.` });
4680
- return yield* promptSelect("Which platform to build?", PLATFORMS.filter((entry) => candidates.includes(entry)).map((entry) => ({
4681
- value: entry,
4682
- label: entry
4683
- })));
4684
5082
  });
5083
+ const runIosBuild = (input) => input.iosProfile.simulator === true ? runIosSimulatorBuild(input) : runIosDeviceBuild(input);
4685
5084
 
4686
5085
  //#endregion
4687
- //#region src/lib/repo-clean.ts
4688
- const MAX_FILES_SHOWN = 10;
4689
- 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([])));
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
+ };
4690
5126
  /**
4691
- * Refuse to proceed when the working tree has uncommitted changes. Skipped when
4692
- * `allowDirty` is true. In interactive mode, prompts the user to confirm; in
4693
- * non-interactive mode, fails with `DirtyRepoError`.
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.
4694
5129
  */
4695
- const ensureRepoClean = ({ projectRoot, allowDirty, label }) => Effect.gen(function* () {
4696
- if (allowDirty) return;
4697
- const dirty = yield* readPorcelain(projectRoot);
4698
- if (dirty.length === 0) return;
4699
- const preview = dirty.slice(0, MAX_FILES_SHOWN).join("\n ");
4700
- const overflow = dirty.length > MAX_FILES_SHOWN ? `\n ... and ${String(dirty.length - MAX_FILES_SHOWN)} more` : "";
4701
- yield* Console.error(`Uncommitted changes (${String(dirty.length)} file(s)):\n ${preview}${overflow}`);
4702
- if (!(yield* InteractiveMode).allow) {
4703
- yield* new DirtyRepoError({ message: `Refusing to ${label} with a dirty working tree. Commit your changes or pass --allow-dirty.` });
4704
- return;
4705
- }
4706
- if (!(yield* promptConfirm(`Continue ${label} with uncommitted changes?`, { initialValue: false }))) yield* new DirtyRepoError({ message: `${label} cancelled by user.` });
4707
- });
4708
-
4709
- //#endregion
4710
- //#region src/lib/fingerprint.ts
4711
- var FingerprintError = class extends Data.TaggedError("FingerprintError") {};
4712
- const runFingerprintFull = (projectRoot) => Effect.gen(function* () {
4713
- const cmd = Command.make("bunx", "@expo/fingerprint", projectRoot).pipe(Command.workingDirectory(projectRoot));
4714
- const stdout = yield* Command.string(cmd).pipe(Effect.mapError((cause) => new FingerprintError({ message: `Failed to run "@expo/fingerprint": ${cause.message}` })));
4715
- const parsed = yield* Effect.try({
4716
- try: () => JSON.parse(stdout),
4717
- catch: () => new FingerprintError({ message: "Failed to parse @expo/fingerprint output as JSON." })
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
4718
5139
  });
4719
- if (!isRecord(parsed)) return yield* new FingerprintError({ message: "@expo/fingerprint output was not a JSON object." });
4720
- const { hash } = parsed;
4721
- if (typeof hash !== "string" || hash.length === 0) return yield* new FingerprintError({ message: "@expo/fingerprint output did not contain a \"hash\" string field." });
4722
- const sourcesRaw = parsed["sources"];
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.` });
4723
5148
  return {
4724
- hash,
4725
- sources: Array.isArray(sourcesRaw) ? sourcesRaw : []
5149
+ id: completed.id,
5150
+ status: "uploaded"
4726
5151
  };
4727
5152
  });
4728
5153
 
4729
5154
  //#endregion
4730
- //#region src/lib/runtime-version.ts
4731
- const resolveRuntimeVersion = ({ raw, appVersion, projectRoot }) => Effect.gen(function* () {
4732
- if (typeof raw === "string") return raw;
4733
- if (raw === void 0) return yield* new RuntimeVersionError({ message: "No runtimeVersion configured in expo section of app.json." });
4734
- const { policy } = raw;
4735
- if (policy === "appVersion") {
4736
- if (appVersion === void 0) return yield* new RuntimeVersionError({ message: "runtimeVersion policy is \"appVersion\" but expo.version is missing in app.json." });
4737
- return appVersion;
4738
- }
4739
- if (policy === "fingerprint") return yield* runFingerprintFull(projectRoot).pipe(Effect.map((result) => result.hash), Effect.mapError((cause) => new RuntimeVersionError({ message: cause.message })));
4740
- if (policy === "nativeVersion") return yield* new RuntimeVersionError({ message: "runtimeVersion policy \"nativeVersion\" is not supported. Set a static runtimeVersion string in your Expo config." });
4741
- return yield* new RuntimeVersionError({ message: `Unsupported runtimeVersion policy "${policy}". Use a static string, "appVersion", or "fingerprint".` });
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);
4742
5161
  });
4743
-
4744
- //#endregion
4745
- //#region src/lib/temp-dir.ts
4746
- /**
4747
- * Create a scoped temp directory prefixed with "better-update-" and `chmod 0o700`
4748
- * it so only the current user can read its contents. The directory and all files
4749
- * inside it are removed when the enclosing scope closes.
4750
- */
4751
- const acquireBuildTempDir = Effect.gen(function* () {
4752
- const fs = yield* FileSystem.FileSystem;
4753
- const dir = yield* fs.makeTempDirectoryScoped({ prefix: "better-update-" });
4754
- yield* fs.chmod(dir, 448);
4755
- return dir;
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;
4756
5166
  });
4757
-
4758
- //#endregion
4759
- //#region src/lib/android-keystore.ts
4760
- const DEFAULT_KEYSTORE_VALIDITY_DAYS = 1e4;
4761
- const renderDistinguishedName = (params) => `CN=${params.commonName}, O=${params.organization}`;
4762
- 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({
4763
- commonName: input.commonName,
4764
- organization: input.organization
4765
- }), "-noprompt").pipe(Command.stdout("inherit"), Command.stderr("inherit"))).pipe(Effect.mapError((cause) => new BuildFailedError({
4766
- step: "generate android keystore",
4767
- exitCode: 1,
4768
- message: `generate android keystore failed to spawn: ${String(cause)}`
4769
- })), Effect.flatMap((code) => code === 0 ? Effect.void : Effect.fail(new BuildFailedError({
4770
- step: "generate android keystore",
4771
- exitCode: code,
4772
- message: `generate android keystore exited with code ${code}`
4773
- }))));
4774
-
4775
- //#endregion
4776
- //#region src/lib/apple-pem.ts
4777
- const PEM_HEADER = "-----BEGIN PRIVATE KEY-----";
4778
- const PEM_FOOTER = "-----END PRIVATE KEY-----";
4779
- const pemToPkcs8Der = (pem) => {
4780
- const normalized = pem.replaceAll("\r\n", "\n").trim();
4781
- const start = normalized.indexOf(PEM_HEADER);
4782
- const end = normalized.indexOf(PEM_FOOTER);
4783
- if (start === -1 || end === -1 || end <= start) return null;
4784
- const body = normalized.slice(start + 27, end).replaceAll(/\s+/gu, "").trim();
4785
- if (body.length === 0) return null;
4786
- try {
4787
- return fromBase64(body);
4788
- } catch {
4789
- return null;
4790
- }
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);
4791
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
+ });
4792
5227
 
4793
5228
  //#endregion
4794
- //#region src/lib/apple-asc-jwt.ts
4795
- var AppleAuthError = class extends Data.TaggedError("AppleAuthError") {};
4796
- const MAX_JWT_LIFETIME_SECONDS = 1200;
4797
- const asArrayBuffer = (bytes) => {
4798
- const buffer = new ArrayBuffer(bytes.byteLength);
4799
- new Uint8Array(buffer).set(bytes);
4800
- return buffer;
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;
4801
5239
  };
4802
- const signAscJwt = (credentials) => Effect.gen(function* () {
4803
- const der = pemToPkcs8Der(credentials.p8Pem);
4804
- if (der === null) return yield* Effect.fail(new AppleAuthError({ cause: /* @__PURE__ */ new Error("Invalid .p8 PEM") }));
4805
- const header = {
4806
- alg: "ES256",
4807
- kid: credentials.keyId,
4808
- typ: "JWT"
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 }
4809
5299
  };
4810
- const now = Math.floor(Date.now() / 1e3);
4811
- const payload = {
4812
- iss: credentials.issuerId,
4813
- iat: now,
4814
- exp: now + MAX_JWT_LIFETIME_SECONDS,
4815
- aud: "appstoreconnect-v1"
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 }
4816
5317
  };
4817
- const signingInput = `${toBase64Url(new TextEncoder().encode(JSON.stringify(header)))}.${toBase64Url(new TextEncoder().encode(JSON.stringify(payload)))}`;
4818
- const key = yield* Effect.tryPromise({
4819
- try: async () => crypto.subtle.importKey("pkcs8", asArrayBuffer(der), {
4820
- name: "ECDSA",
4821
- namedCurve: "P-256"
4822
- }, false, ["sign"]),
4823
- catch: (cause) => new AppleAuthError({ cause })
4824
- });
4825
- const signature = yield* Effect.tryPromise({
4826
- try: async () => crypto.subtle.sign({
4827
- name: "ECDSA",
4828
- hash: "SHA-256"
4829
- }, key, new TextEncoder().encode(signingInput)),
4830
- catch: (cause) => new AppleAuthError({ cause })
4831
- });
4832
- return `${signingInput}.${toBase64Url(new Uint8Array(signature))}`;
4833
- });
4834
-
4835
- //#endregion
4836
- //#region src/lib/apple-asc-client.ts
4837
- var AscApiError = class extends Data.TaggedError("AscApiError") {};
4838
- var AscNetworkError = class extends Data.TaggedError("AscNetworkError") {};
4839
- const API_BASE = "https://api.appstoreconnect.apple.com";
4840
- const extractErrors = (body) => {
4841
- if (!isRecord(body) || !Array.isArray(body["errors"])) return [];
4842
- return body["errors"].filter((value) => isRecord(value));
4843
5318
  };
4844
- const parseApiError = (response, body, raw) => {
4845
- const [first] = extractErrors(body);
4846
- return new AscApiError({
4847
- status: response.status,
4848
- message: first?.detail ?? first?.title ?? response.statusText,
4849
- code: first?.code,
4850
- raw
4851
- });
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
+ };
4852
5344
  };
4853
- const fetchRaw = (jwt, path, init) => Effect.gen(function* () {
4854
- const response = yield* Effect.tryPromise({
4855
- try: async () => fetch(`${API_BASE}${path}`, {
4856
- method: init?.method ?? "GET",
4857
- ...init?.body === void 0 ? {} : { body: init.body },
4858
- headers: {
4859
- authorization: `Bearer ${jwt}`,
4860
- "content-type": "application/json",
4861
- accept: "application/json"
4862
- }
4863
- }),
4864
- catch: (cause) => new AscNetworkError({ cause })
4865
- });
4866
- const text = yield* Effect.tryPromise({
4867
- try: async () => response.text(),
4868
- catch: (cause) => new AscNetworkError({ cause })
4869
- });
4870
- const body = text.length === 0 ? {} : JSON.parse(text);
4871
- if (!response.ok) return yield* Effect.fail(parseApiError(response, body, text));
4872
- return body;
4873
- });
4874
- const toAscCertificate = (value) => {
4875
- if (!isRecord(value)) return null;
4876
- const { id, attributes } = value;
4877
- if (typeof id !== "string" || !isRecord(attributes)) return null;
4878
- const { serialNumber, certificateType, expirationDate, certificateContent, displayName } = attributes;
4879
- if (typeof serialNumber !== "string" || typeof certificateType !== "string" || typeof expirationDate !== "string") return null;
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
+ }
4880
5358
  return {
4881
- id,
4882
- serialNumber,
4883
- certificateType,
4884
- expirationDate,
4885
- certificateContent: typeof certificateContent === "string" ? certificateContent : null,
4886
- displayName: typeof displayName === "string" ? displayName : null
5359
+ ...asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {},
5360
+ build: profiles
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 };
5368
+ };
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;
5380
+ return {
5381
+ ...base,
5382
+ ...overlay
4887
5383
  };
4888
5384
  };
4889
- const toAscBundleId = (value) => {
4890
- if (!isRecord(value)) return null;
4891
- const { id, attributes } = value;
4892
- if (typeof id !== "string" || !isRecord(attributes)) return null;
4893
- const { identifier, name } = attributes;
4894
- if (typeof identifier !== "string" || typeof name !== "string") return null;
5385
+ const mergeAndroid = (base, overlay) => {
5386
+ if (!base) return overlay;
5387
+ if (!overlay) return base;
4895
5388
  return {
4896
- id,
4897
- identifier,
4898
- name
5389
+ ...base,
5390
+ ...overlay
4899
5391
  };
4900
5392
  };
4901
- const PROFILE_TYPES = [
4902
- "IOS_APP_ADHOC",
4903
- "IOS_APP_DEVELOPMENT",
4904
- "IOS_APP_STORE",
4905
- "IOS_APP_INHOUSE"
4906
- ];
4907
- const asProfileType = (value) => {
4908
- const match = PROFILE_TYPES.find((entry) => entry === value);
4909
- return match === void 0 ? null : match;
4910
- };
4911
- const toAscProfile = (value) => {
4912
- if (!isRecord(value)) return null;
4913
- const { id, attributes } = value;
4914
- if (typeof id !== "string" || !isRecord(attributes)) return null;
4915
- const { name, uuid, expirationDate, profileContent } = attributes;
4916
- const profileType = asProfileType(attributes["profileType"]);
4917
- if (typeof name !== "string" || typeof uuid !== "string" || typeof expirationDate !== "string" || typeof profileContent !== "string" || profileType === null) return null;
5393
+ const mergeEnv = (base, overlay) => {
5394
+ if (!base) return overlay;
5395
+ if (!overlay) return base;
4918
5396
  return {
4919
- id,
4920
- name,
4921
- uuid,
4922
- expirationDate,
4923
- profileContent,
4924
- profileType
5397
+ ...base,
5398
+ ...overlay
4925
5399
  };
4926
5400
  };
4927
- const toAscDevice = (value) => {
4928
- if (!isRecord(value)) return null;
4929
- const { id, attributes } = value;
4930
- if (typeof id !== "string" || !isRecord(attributes)) return null;
4931
- const { udid, name } = attributes;
4932
- if (typeof udid !== "string" || typeof name !== "string") return null;
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;
4933
5411
  return {
4934
- id,
4935
- udid,
4936
- name
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 }
4937
5422
  };
4938
5423
  };
4939
- const extractList = (body, map) => {
4940
- if (!isRecord(body) || !Array.isArray(body["data"])) return [];
4941
- return body["data"].map(map).filter((value) => value !== null);
4942
- };
4943
- const extractSingle = (body, map) => {
4944
- if (!isRecord(body)) return null;
4945
- return map(body["data"]);
4946
- };
4947
- const malformed = (resource) => new AscApiError({
4948
- status: 500,
4949
- message: `Malformed ${resource} response`,
4950
- code: void 0,
4951
- raw: ""
4952
- });
4953
- const withJwt = (credentials, fn) => Effect.gen(function* () {
4954
- return yield* fn(yield* signAscJwt(credentials));
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;
4955
5440
  });
4956
- const listCertificates = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4957
- return extractList(yield* fetchRaw(jwt, `/v1/certificates${params?.certificateType ? `?filter[certificateType]=${params.certificateType}&limit=200` : "?limit=200"}`), toAscCertificate);
4958
- }));
4959
- const createCertificate = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4960
- const csrContent = params.csrPem.replaceAll("-----BEGIN CERTIFICATE REQUEST-----", "").replaceAll("-----END CERTIFICATE REQUEST-----", "").replaceAll(/\s+/gu, "");
4961
- const resource = extractSingle(yield* fetchRaw(jwt, "/v1/certificates", {
4962
- method: "POST",
4963
- body: JSON.stringify({ data: {
4964
- type: "certificates",
4965
- attributes: {
4966
- csrContent,
4967
- certificateType: params.certificateType
4968
- }
4969
- } })
4970
- }), toAscCertificate);
4971
- if (resource === null) return yield* Effect.fail(malformed("certificate"));
4972
- return resource;
4973
- }));
4974
- const deleteCertificate = (credentials, id) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4975
- yield* fetchRaw(jwt, `/v1/certificates/${encodeURIComponent(id)}`, { method: "DELETE" });
4976
- }));
4977
- const listBundleIds = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4978
- return extractList(yield* fetchRaw(jwt, "/v1/bundleIds?limit=200"), toAscBundleId);
4979
- }));
4980
- const createBundleId = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4981
- const resource = extractSingle(yield* fetchRaw(jwt, "/v1/bundleIds", {
4982
- method: "POST",
4983
- body: JSON.stringify({ data: {
4984
- type: "bundleIds",
4985
- attributes: {
4986
- identifier: params.identifier,
4987
- name: params.name,
4988
- platform: "IOS"
4989
- }
4990
- } })
4991
- }), toAscBundleId);
4992
- if (resource === null) return yield* Effect.fail(malformed("bundleId"));
4993
- return resource;
4994
- }));
4995
- const listDevices = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4996
- return extractList(yield* fetchRaw(jwt, "/v1/devices?limit=200"), toAscDevice);
4997
- }));
4998
- const createProvisioningProfile = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
4999
- const relationships = {
5000
- bundleId: { data: {
5001
- type: "bundleIds",
5002
- id: params.bundleIdAscId
5003
- } },
5004
- certificates: { data: params.certificateAscIds.map((id) => ({
5005
- type: "certificates",
5006
- id
5007
- })) },
5008
- ...params.deviceAscIds.length > 0 ? { devices: { data: params.deviceAscIds.map((id) => ({
5009
- type: "devices",
5010
- id
5011
- })) } } : {}
5012
- };
5013
- const resource = extractSingle(yield* fetchRaw(jwt, "/v1/profiles", {
5014
- method: "POST",
5015
- body: JSON.stringify({ data: {
5016
- type: "profiles",
5017
- attributes: {
5018
- name: params.profileName,
5019
- profileType: params.profileType
5020
- },
5021
- relationships
5022
- } })
5023
- }), toAscProfile);
5024
- if (resource === null) return yield* Effect.fail(malformed("profile"));
5025
- return resource;
5026
- }));
5027
- const isCertificateLimitError = (error) => {
5028
- if (error._tag !== "AscApiError") return false;
5029
- return /already have a current.*certificate|pending certificate request/iu.test(error.message);
5441
+ const stripExtends = (profile) => {
5442
+ if (profile.extends === void 0) return profile;
5443
+ const { extends: _omit, ...rest } = profile;
5444
+ return rest;
5030
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
+ });
5031
5451
 
5032
5452
  //#endregion
5033
- //#region src/lib/apple-cert-to-p12.ts
5034
- var CertParseError = class extends Data.TaggedError("CertParseError") {};
5035
- const APPLE_TEAM_ID_RE$1 = /^[A-Z0-9]{10}$/u;
5036
- const stringField = (cert, name) => {
5037
- const value = cert.subject.getField(name)?.value;
5038
- 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";
5039
5461
  };
5040
- const matchTeamFromCommonName = (cn) => {
5041
- const match = /\(([A-Z0-9]{10})\)/u.exec(cn);
5042
- if (match === null) return null;
5043
- const [, captured] = match;
5044
- 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";
5045
5467
  };
5046
- const extractTeamId$1 = (cert) => {
5047
- const ou = stringField(cert, "OU");
5048
- if (ou !== null && APPLE_TEAM_ID_RE$1.test(ou)) return ou;
5049
- const cn = stringField(cert, "CN");
5050
- if (cn === null) return null;
5051
- 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
+ };
5052
5506
  };
5053
- const parseCert = (certDerBytes) => {
5054
- const asn1 = forge.asn1.fromDer(certDerBytes);
5055
- 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
+ };
5056
5522
  };
5057
- const generatePassword = () => forge.util.encode64(forge.random.getBytesSync(16));
5058
- const extractCertMetadata = (cert) => Effect.gen(function* () {
5059
- const appleTeamId = extractTeamId$1(cert);
5060
- 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);
5061
5526
  return {
5062
- serialNumber: cert.serialNumber.toUpperCase(),
5063
- validFrom: cert.validity.notBefore.toISOString(),
5064
- validUntil: cert.validity.notAfter.toISOString(),
5065
- appleTeamId,
5066
- appleTeamName: stringField(cert, "O"),
5067
- developerIdIdentifier: stringField(cert, "UID"),
5068
- 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 }
5069
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)
5070
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
5071
5551
  /**
5072
- * Parse a PKCS#12 base64 bundle and extract certificate metadata. Used by the
5073
- * Apple-ID flow which receives a P12 directly from `createCertificateAndP12Async`
5074
- * 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).
5075
5555
  */
5076
- const extractMetadataFromP12 = (params) => Effect.gen(function* () {
5077
- const certBagOid = forge.pki.oids["certBag"];
5078
- if (certBagOid === void 0) return yield* Effect.fail(new CertParseError({ message: "PKCS#12 OID lookup for certBag failed" }));
5079
- const [first] = yield* Effect.try({
5080
- try: () => {
5081
- const p12Der = forge.util.decode64(params.p12Base64);
5082
- const p12Asn1 = forge.asn1.fromDer(p12Der);
5083
- return forge.pkcs12.pkcs12FromAsn1(p12Asn1, false, params.password).getBags({ bagType: certBagOid })[certBagOid] ?? [];
5084
- },
5085
- catch: (error) => new CertParseError({ message: `Failed to parse PKCS#12 bundle: ${error instanceof Error ? error.message : String(error)}` })
5086
- });
5087
- if (first?.cert === void 0) return yield* Effect.fail(new CertParseError({ message: "PKCS#12 bundle does not contain a certificate" }));
5088
- return yield* extractCertMetadata(first.cert);
5089
- });
5090
- const buildDistributionCertP12 = (params) => Effect.gen(function* () {
5091
- const result = yield* Effect.try({
5092
- try: () => {
5093
- const cert = parseCert(forge.util.decode64(params.certificateContentBase64));
5094
- const password = generatePassword();
5095
- const p12Asn1 = forge.pkcs12.toPkcs12Asn1(params.privateKey, [cert], password, {
5096
- friendlyName: "key",
5097
- algorithm: "3des"
5098
- });
5099
- return {
5100
- cert,
5101
- p12Base64: forge.util.encode64(forge.asn1.toDer(p12Asn1).getBytes()),
5102
- password
5103
- };
5104
- },
5105
- catch: (error) => new CertParseError({ message: `Failed to assemble .p12: ${error instanceof Error ? error.message : String(error)}` })
5106
- });
5107
- const metadata = yield* extractCertMetadata(result.cert);
5108
- return {
5109
- p12Base64: result.p12Base64,
5110
- password: result.password,
5111
- metadata
5112
- };
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(", ")}`);
5113
5574
  });
5114
5575
 
5115
5576
  //#endregion
5116
- //#region src/lib/apple-csr.ts
5117
- const generateRsaKeyPair = async () => new Promise((resolve) => {
5118
- forge.pki.rsa.generateKeyPair({
5119
- bits: 2048,
5120
- workers: 2
5121
- }, (_err, keyPair) => {
5122
- resolve(keyPair);
5123
- });
5124
- });
5125
- const generateCertificateSigningRequest = async () => {
5126
- const keyPair = await generateRsaKeyPair();
5127
- const csr = forge.pki.createCertificationRequest();
5128
- csr.publicKey = keyPair.publicKey;
5129
- csr.setSubject([{
5130
- name: "commonName",
5131
- shortName: "CN",
5132
- value: "PEM"
5133
- }]);
5134
- csr.sign(keyPair.privateKey, forge.md.sha1.create());
5135
- return {
5136
- csrPem: forge.pki.certificationRequestToPem(csr),
5137
- privateKeyPem: forge.pki.privateKeyToPem(keyPair.privateKey),
5138
- privateKey: keyPair.privateKey
5139
- };
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)}` })));
5140
5590
  };
5141
5591
 
5142
5592
  //#endregion
5143
- //#region src/lib/credentials-generator.ts
5144
- const DISTRIBUTION_TO_PROFILE_TYPE$1 = {
5145
- APP_STORE: "IOS_APP_STORE",
5146
- AD_HOC: "IOS_APP_ADHOC",
5147
- DEVELOPMENT: "IOS_APP_DEVELOPMENT",
5148
- ENTERPRISE: "IOS_APP_INHOUSE"
5149
- };
5150
- const computeDeviceRosterHashHex = (ascDeviceIds) => {
5151
- const sorted = [...ascDeviceIds].toSorted();
5152
- return createHash("sha256").update(sorted.join(","), "utf8").digest("hex");
5153
- };
5154
- var CertificateLimitError = class extends Data.TaggedError("CertificateLimitError") {};
5155
- var GenerateFailedError = class extends Data.TaggedError("GenerateFailedError") {};
5156
- const messageForAscCause = (cause) => {
5157
- if (cause._tag === "AscApiError") return cause.message;
5158
- if (cause._tag === "AppleAuthError") return "Apple JWT signing failed";
5159
- return "Network error talking to Apple";
5160
- };
5161
- const wrapAscError = (step) => (cause) => {
5162
- if (cause._tag === "AscApiError" && isCertificateLimitError(cause)) return new CertificateLimitError({ message: cause.message });
5163
- return new GenerateFailedError({
5164
- step,
5165
- message: messageForAscCause(cause)
5166
- });
5167
- };
5168
- const generateAndUploadKeystore = (api, input) => Effect.scoped(Effect.gen(function* () {
5169
- const fs = yield* FileSystem.FileSystem;
5170
- const tempDir = yield* acquireBuildTempDir;
5171
- const keystorePath = path.join(tempDir, "release.keystore");
5172
- yield* generateAndroidKeystore({
5173
- outputPath: keystorePath,
5174
- keyAlias: input.keyAlias,
5175
- storePassword: input.storePassword,
5176
- keyPassword: input.keyPassword,
5177
- commonName: input.commonName,
5178
- organization: input.organization,
5179
- ...input.validityDays === void 0 ? {} : { validityDays: input.validityDays }
5180
- });
5181
- const bytes = yield* fs.readFile(keystorePath);
5182
- const created = yield* api.androidUploadKeystores.upload({ payload: {
5183
- keystoreBase64: toBase64(bytes),
5184
- keyAlias: input.keyAlias,
5185
- keystorePassword: input.storePassword,
5186
- keyPassword: input.keyPassword
5187
- } });
5188
- return {
5189
- id: created.id,
5190
- keyAlias: created.keyAlias
5191
- };
5192
- }));
5193
- const fetchAscCredentials = (api, ascApiKeyId) => api.ascApiKeys.getCredentials({ path: { id: ascApiKeyId } });
5194
- const generateAndUploadDistributionCertificate = (api, input) => Effect.gen(function* () {
5195
- const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
5196
- const ascCreds = {
5197
- keyId: creds.keyId,
5198
- issuerId: creds.issuerId,
5199
- p8Pem: creds.p8Pem
5200
- };
5201
- const csrResult = yield* Effect.tryPromise({
5202
- try: generateCertificateSigningRequest,
5203
- catch: (cause) => new GenerateFailedError({
5204
- step: "csr",
5205
- message: `CSR generation failed: ${cause instanceof Error ? cause.message : String(cause)}`
5206
- })
5207
- });
5208
- const certificateType = input.certificateType ?? "IOS_DISTRIBUTION";
5209
- const apple = yield* createCertificate(ascCreds, {
5210
- csrPem: csrResult.csrPem,
5211
- certificateType
5212
- }).pipe(Effect.mapError(wrapAscError("apple-create-certificate")));
5213
- if (apple.certificateContent === null) return yield* Effect.fail(new GenerateFailedError({
5214
- step: "apple-create-certificate",
5215
- message: "Apple response missing certificateContent"
5216
- }));
5217
- const bundle = yield* buildDistributionCertP12({
5218
- certificateContentBase64: apple.certificateContent,
5219
- privateKey: csrResult.privateKey
5220
- }).pipe(Effect.mapError((cause) => new GenerateFailedError({
5221
- step: "p12-build",
5222
- message: cause.message
5223
- })));
5224
- const created = yield* api.appleDistributionCertificates.upload({ payload: {
5225
- p12Base64: bundle.p12Base64,
5226
- p12Password: bundle.password,
5227
- serialNumber: bundle.metadata.serialNumber,
5228
- appleTeamIdentifier: bundle.metadata.appleTeamId,
5229
- ...bundle.metadata.appleTeamName === null ? {} : { appleTeamName: bundle.metadata.appleTeamName },
5230
- ...bundle.metadata.developerIdIdentifier === null ? {} : { developerIdIdentifier: bundle.metadata.developerIdIdentifier },
5231
- validFrom: bundle.metadata.validFrom,
5232
- validUntil: bundle.metadata.validUntil
5233
- } });
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" });
5234
5608
  return {
5235
- id: created.id,
5236
- serialNumber: bundle.metadata.serialNumber,
5237
- appleTeamId: created.appleTeamId,
5238
- appleTeamIdentifier: bundle.metadata.appleTeamId,
5239
- 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
5240
5613
  };
5241
5614
  });
5242
- const revokeAppleCertificate = (api, input) => Effect.gen(function* () {
5243
- const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
5244
- yield* deleteCertificate({
5245
- keyId: creds.keyId,
5246
- issuerId: creds.issuerId,
5247
- p8Pem: creds.p8Pem
5248
- }, 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)));
5249
5643
  });
5250
- const revokeLocalDistributionCertificate = (api, input) => Effect.gen(function* () {
5251
- const local = (yield* api.appleDistributionCertificates.list()).items.find((entry) => entry.id === input.distributionCertificateId);
5252
- if (local === void 0) return yield* Effect.fail(new GenerateFailedError({
5253
- step: "load-distribution-certificate",
5254
- message: `Distribution certificate ${input.distributionCertificateId} not found on this account`
5255
- }));
5256
- const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
5257
- const ascCreds = {
5258
- keyId: creds.keyId,
5259
- issuerId: creds.issuerId,
5260
- p8Pem: creds.p8Pem
5261
- };
5262
- const targetSerial = local.serialNumber.toUpperCase();
5263
- const matching = yield* Effect.all([listCertificates(ascCreds, { certificateType: "IOS_DISTRIBUTION" }), listCertificates(ascCreds, { certificateType: "IOS_DEVELOPMENT" })], { concurrency: 2 }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
5264
- const ascMatch = [...matching[0], ...matching[1]].find((entry) => entry.serialNumber.toUpperCase() === targetSerial);
5265
- let revokedOnApple = false;
5266
- if (ascMatch !== void 0) {
5267
- yield* deleteCertificate(ascCreds, ascMatch.id).pipe(Effect.mapError(wrapAscError("apple-revoke-certificate")));
5268
- revokedOnApple = true;
5269
- }
5270
- let deletedLocally = false;
5271
- if (input.keepLocal !== true) {
5272
- yield* api.appleDistributionCertificates.delete({ path: { id: input.distributionCertificateId } });
5273
- deletedLocally = true;
5274
- }
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;
5275
5666
  return {
5276
- localId: input.distributionCertificateId,
5277
- serialNumber: local.serialNumber,
5278
- revokedOnApple,
5279
- deletedLocally
5667
+ ...applicationId === void 0 ? {} : { applicationId },
5668
+ ...versionCode === void 0 ? {} : { versionCode },
5669
+ ...versionName === void 0 ? {} : { versionName }
5280
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
+ })));
5281
5705
  });
5282
- const listAppleCertificates = (api, input) => Effect.gen(function* () {
5283
- const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
5284
- return yield* listCertificates({
5285
- keyId: creds.keyId,
5286
- issuerId: creds.issuerId,
5287
- p8Pem: creds.p8Pem
5288
- }, input.certificateType === void 0 ? {} : { certificateType: input.certificateType }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
5289
- });
5290
- const resolveCertAscId = (creds, serialNumber, certificateType) => Effect.gen(function* () {
5291
- const match = (yield* listCertificates(creds, { certificateType }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")))).find((entry) => entry.serialNumber.toUpperCase() === serialNumber);
5292
- if (match === void 0) return yield* Effect.fail(new GenerateFailedError({
5293
- step: "match-apple-certificate",
5294
- message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
5295
- }));
5296
- return match.id;
5297
- });
5298
- const ensureBundleId = (creds, bundleIdentifier) => Effect.gen(function* () {
5299
- const existing = (yield* listBundleIds(creds).pipe(Effect.mapError(wrapAscError("apple-list-bundle-ids")))).find((entry) => entry.identifier === bundleIdentifier);
5300
- if (existing !== void 0) return existing.id;
5301
- return (yield* createBundleId(creds, {
5302
- identifier: bundleIdentifier,
5303
- name: bundleIdentifier
5304
- }).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.` });
5305
5728
  });
5306
- const collectDeviceAscIds = (creds, appleTeamId, deviceIds) => Effect.gen(function* () {
5307
- 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"];
5308
5744
  return {
5309
- ids: deviceIds === void 0 ? devices.map((device) => device.id) : devices.filter((device) => new Set(deviceIds).has(device.id)).map((device) => device.id),
5310
- appleTeamId
5745
+ hash,
5746
+ sources: Array.isArray(sourcesRaw) ? sourcesRaw : []
5311
5747
  };
5312
5748
  });
5313
- const generateAndUploadProvisioningProfile = (api, input) => Effect.gen(function* () {
5314
- const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
5315
- const ascCreds = {
5316
- keyId: creds.keyId,
5317
- issuerId: creds.issuerId,
5318
- p8Pem: creds.p8Pem
5319
- };
5320
- 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({
5321
- step: "load-distribution-certificate",
5322
- message: `Distribution certificate ${input.distributionCertificateId} not found`
5323
- })) : Effect.succeed(match)));
5324
- const certificateType = input.distributionType === "DEVELOPMENT" ? "IOS_DEVELOPMENT" : "IOS_DISTRIBUTION";
5325
- const [certAscId, bundleIdAscId] = yield* Effect.all([resolveCertAscId(ascCreds, cert.serialNumber.toUpperCase(), certificateType), ensureBundleId(ascCreds, input.bundleIdentifier)], { concurrency: 2 });
5326
- const useDevices = input.distributionType === "AD_HOC" || input.distributionType === "DEVELOPMENT";
5327
- const { ids: deviceAscIds } = useDevices ? yield* collectDeviceAscIds(ascCreds, cert.appleTeamId, input.deviceIds) : { ids: [] };
5328
- if (useDevices && deviceAscIds.length === 0) return yield* Effect.fail(new GenerateFailedError({
5329
- step: "collect-devices",
5330
- message: "No registered devices to attach to the provisioning profile"
5331
- }));
5332
- const profileBytes = fromBase64((yield* createProvisioningProfile(ascCreds, {
5333
- profileName: `${input.bundleIdentifier} ${input.distributionType} ${Date.now()}`,
5334
- profileType: DISTRIBUTION_TO_PROFILE_TYPE$1[input.distributionType],
5335
- bundleIdAscId,
5336
- certificateAscIds: [certAscId],
5337
- deviceAscIds
5338
- }).pipe(Effect.mapError(wrapAscError("apple-create-profile")))).profileContent);
5339
- const rosterHash = useDevices ? computeDeviceRosterHashHex(deviceAscIds) : void 0;
5340
- const created = yield* api.appleProvisioningProfiles.upload({ payload: {
5341
- profileBase64: toBase64(profileBytes),
5342
- appleDistributionCertificateId: input.distributionCertificateId,
5343
- isManaged: true,
5344
- ...rosterHash === void 0 ? {} : { deviceRosterHash: rosterHash }
5345
- } });
5346
- return {
5347
- id: created.id,
5348
- bundleIdentifier: created.bundleIdentifier,
5349
- distributionType: created.distributionType,
5350
- profileName: created.profileName,
5351
- validUntil: created.validUntil,
5352
- developerPortalIdentifier: created.developerPortalIdentifier
5353
- };
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".` });
5354
5763
  });
5355
5764
 
5356
5765
  //#endregion