@better-update/cli 0.15.2 → 0.15.4

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
@@ -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.15.2";
31
+ var version = "0.15.4";
32
32
 
33
33
  //#endregion
34
34
  //#region src/lib/interactive-mode.ts
@@ -4497,1846 +4497,1864 @@ const runAndroidBuild = (input) => Effect.gen(function* () {
4497
4497
  });
4498
4498
 
4499
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);
4500
+ //#region src/lib/credentials-generator-apple-id.ts
4501
+ const DISTRIBUTION_TO_PROFILE_TYPE = {
4502
+ APP_STORE: AppleUtils.ProfileType.IOS_APP_STORE,
4503
+ AD_HOC: AppleUtils.ProfileType.IOS_APP_ADHOC,
4504
+ DEVELOPMENT: AppleUtils.ProfileType.IOS_APP_DEVELOPMENT,
4505
+ ENTERPRISE: AppleUtils.ProfileType.IOS_APP_INHOUSE
4506
+ };
4507
+ const DISTRIBUTION_TO_CERTIFICATE_TYPE = {
4508
+ APP_STORE: AppleUtils.CertificateType.IOS_DISTRIBUTION,
4509
+ AD_HOC: AppleUtils.CertificateType.IOS_DISTRIBUTION,
4510
+ ENTERPRISE: AppleUtils.CertificateType.IOS_DISTRIBUTION,
4511
+ DEVELOPMENT: AppleUtils.CertificateType.IOS_DEVELOPMENT
4512
+ };
4513
+ var AppleIdGenerateFailedError = class extends Data.TaggedError("AppleIdGenerateFailedError") {};
4514
+ const CERT_LIMIT_PATTERN = /already have a current.*certificate|pending certificate request/iu;
4515
+ const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
4516
+ const wrap = (step, run) => Effect.tryPromise({
4517
+ try: run,
4518
+ catch: (cause) => new AppleIdGenerateFailedError({
4519
+ step,
4520
+ message: messageOf(cause)
4521
+ })
4506
4522
  });
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)}` })
4523
+ const wrapCertificateCreate = (run) => Effect.tryPromise({
4524
+ try: run,
4525
+ catch: (cause) => {
4526
+ const message = messageOf(cause);
4527
+ if (CERT_LIMIT_PATTERN.test(message)) return new CertificateLimitError({ message });
4528
+ return new AppleIdGenerateFailedError({
4529
+ step: "apple-create-certificate",
4530
+ message
4531
+ });
4532
+ }
4510
4533
  });
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;
4528
- };
4529
- /**
4530
- * Write `CODE_SIGN_STYLE=Manual`, `DEVELOPMENT_TEAM`, `CODE_SIGN_IDENTITY`, and
4531
- * `PROVISIONING_PROFILE_SPECIFIER` into the specified XCBuildConfiguration
4532
- * entries of the project under `iosDir`, then serialize back to disk.
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)}` })));
4534
+ const generateAndUploadDistributionCertificateViaAppleId = (api, input) => Effect.gen(function* () {
4535
+ const ctx = input.context;
4536
+ const certificateType = input.certificateType === "IOS_DEVELOPMENT" ? AppleUtils.CertificateType.IOS_DEVELOPMENT : AppleUtils.CertificateType.IOS_DISTRIBUTION;
4537
+ const result = yield* wrapCertificateCreate(async () => AppleUtils.createCertificateAndP12Async(ctx, { certificateType }));
4538
+ const metadata = yield* extractMetadataFromP12({
4539
+ p12Base64: result.certificateP12,
4540
+ password: result.password
4541
+ }).pipe(Effect.mapError((cause) => new AppleIdGenerateFailedError({
4542
+ step: "parse-p12",
4543
+ message: cause.message
4544
+ })));
4545
+ const created = yield* api.appleDistributionCertificates.upload({ payload: {
4546
+ p12Base64: result.certificateP12,
4547
+ p12Password: result.password,
4548
+ serialNumber: metadata.serialNumber,
4549
+ appleTeamIdentifier: metadata.appleTeamId,
4550
+ ...metadata.appleTeamName === null ? {} : { appleTeamName: metadata.appleTeamName },
4551
+ ...metadata.developerIdIdentifier === null ? {} : { developerIdIdentifier: metadata.developerIdIdentifier },
4552
+ validFrom: metadata.validFrom,
4553
+ validUntil: metadata.validUntil
4554
+ } });
4555
+ return {
4556
+ id: created.id,
4557
+ serialNumber: metadata.serialNumber,
4558
+ appleTeamId: created.appleTeamId,
4559
+ appleTeamIdentifier: metadata.appleTeamId,
4560
+ developerPortalIdentifier: result.certificate.id
4561
+ };
4562
+ });
4563
+ const listDistributionCertsViaAppleId = (ctx, certificateType = "IOS_DISTRIBUTION") => Effect.gen(function* () {
4564
+ const filter = certificateType === "IOS_DEVELOPMENT" ? AppleUtils.CertificateType.IOS_DEVELOPMENT : AppleUtils.CertificateType.IOS_DISTRIBUTION;
4565
+ return (yield* wrap("apple-list-certificates", async () => AppleUtils.Certificate.getAsync(ctx, { query: { filter: { certificateType: filter } } }))).map((entry) => ({
4566
+ developerPortalIdentifier: entry.id,
4567
+ serialNumber: entry.attributes.serialNumber,
4568
+ displayName: entry.attributes.displayName,
4569
+ expirationDate: entry.attributes.expirationDate
4570
+ }));
4571
+ });
4572
+ const revokeDistributionCertViaAppleId = (ctx, developerPortalIdentifier) => wrap("apple-revoke-certificate", async () => AppleUtils.Certificate.deleteAsync(ctx, { id: developerPortalIdentifier }));
4573
+ const findOrCreateBundleId = (ctx, bundleIdentifier) => Effect.gen(function* () {
4574
+ const existing = yield* wrap("apple-find-bundle-id", async () => AppleUtils.BundleId.findAsync(ctx, { identifier: bundleIdentifier }));
4575
+ if (existing !== null) return existing.id;
4576
+ return (yield* wrap("apple-create-bundle-id", async () => AppleUtils.BundleId.createAsync(ctx, {
4577
+ identifier: bundleIdentifier,
4578
+ name: bundleIdentifier,
4579
+ platform: AppleUtils.BundleIdPlatform.IOS
4580
+ }))).id;
4581
+ });
4582
+ const findAscCertificateId = (ctx, serialNumber, certificateType) => Effect.gen(function* () {
4583
+ const certs = yield* wrap("apple-list-certificates", async () => AppleUtils.Certificate.getAsync(ctx, { query: { filter: { certificateType } } }));
4584
+ const upper = serialNumber.toUpperCase();
4585
+ const match = certs.find((entry) => entry.attributes.serialNumber.toUpperCase() === upper);
4586
+ if (match === void 0) return yield* Effect.fail(new AppleIdGenerateFailedError({
4587
+ step: "match-apple-certificate",
4588
+ message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
4589
+ }));
4590
+ return match.id;
4591
+ });
4592
+ const collectIosDeviceIds = (ctx, deviceIds) => Effect.gen(function* () {
4593
+ const devices = yield* wrap("apple-list-devices", async () => AppleUtils.Device.getAllIOSProfileDevicesAsync(ctx));
4594
+ if (deviceIds === void 0) return devices.map((device) => device.id);
4595
+ const allowed = new Set(deviceIds);
4596
+ return devices.filter((device) => allowed.has(device.id)).map((device) => device.id);
4597
+ });
4598
+ const generateAndUploadProvisioningProfileViaAppleId = (api, input) => Effect.gen(function* () {
4599
+ const ctx = input.context;
4600
+ 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 AppleIdGenerateFailedError({
4601
+ step: "load-distribution-certificate",
4602
+ message: `Distribution certificate ${input.distributionCertificateId} not found`
4603
+ })) : Effect.succeed(match)));
4604
+ const certificateType = DISTRIBUTION_TO_CERTIFICATE_TYPE[input.distributionType];
4605
+ const [certAscId, bundleIdAscId] = yield* Effect.all([findAscCertificateId(ctx, cert.serialNumber, certificateType), findOrCreateBundleId(ctx, input.bundleIdentifier)], { concurrency: 2 });
4606
+ const useDevices = input.distributionType === "AD_HOC" || input.distributionType === "DEVELOPMENT";
4607
+ const deviceIds = useDevices ? yield* collectIosDeviceIds(ctx, input.deviceIds) : [];
4608
+ if (useDevices && deviceIds.length === 0) return yield* Effect.fail(new AppleIdGenerateFailedError({
4609
+ step: "collect-devices",
4610
+ message: "No registered devices to attach to the provisioning profile"
4611
+ }));
4612
+ const profileName = `${input.bundleIdentifier} ${input.distributionType} ${Date.now()}`;
4613
+ const { profileContent } = (yield* wrap("apple-create-profile", async () => AppleUtils.Profile.createAsync(ctx, {
4614
+ bundleId: bundleIdAscId,
4615
+ certificates: [certAscId],
4616
+ devices: deviceIds,
4617
+ name: profileName,
4618
+ profileType: DISTRIBUTION_TO_PROFILE_TYPE[input.distributionType]
4619
+ }))).attributes;
4620
+ if (profileContent === null) return yield* Effect.fail(new AppleIdGenerateFailedError({
4621
+ step: "extract-profile-content",
4622
+ message: "Apple returned a profile with no content (likely expired/invalid)"
4623
+ }));
4624
+ const profileBytes = fromBase64(profileContent);
4625
+ const rosterHash = useDevices ? computeDeviceRosterHashHex(deviceIds) : void 0;
4626
+ const created = yield* api.appleProvisioningProfiles.upload({ payload: {
4627
+ profileBase64: toBase64(profileBytes),
4628
+ appleDistributionCertificateId: input.distributionCertificateId,
4629
+ isManaged: true,
4630
+ ...rosterHash === void 0 ? {} : { deviceRosterHash: rosterHash }
4631
+ } });
4632
+ return {
4633
+ id: created.id,
4634
+ bundleIdentifier: created.bundleIdentifier,
4635
+ distributionType: created.distributionType,
4636
+ profileName: created.profileName,
4637
+ validUntil: created.validUntil,
4638
+ developerPortalIdentifier: created.developerPortalIdentifier
4639
+ };
4550
4640
  });
4551
4641
 
4552
4642
  //#endregion
4553
- //#region src/lib/ios-export-options.ts
4554
- const escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
4555
- const boolTag = (value) => value ? "<true/>" : "<false/>";
4643
+ //#region src/lib/ios-bundle-config-upsert.ts
4556
4644
  /**
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`
4645
+ * Idempotent bind for an iOS bundle configuration. When the row already exists
4646
+ * (e.g. orphaned after a cert was deleted, since the FK is `ON DELETE SET NULL`),
4647
+ * rebind cert + profile in place instead of failing on the unique constraint.
4648
+ * Mirrors EAS's setup behavior where the setup step is rerunnable.
4564
4649
  */
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");
4587
- };
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];
4650
+ const upsertIosBundleConfiguration = (api, input) => Effect.gen(function* () {
4651
+ const existing = (yield* api.iosBundleConfigurations.list({ path: { projectId: input.projectId } })).items.find((item) => item.bundleIdentifier === input.bundleIdentifier && item.distributionType === input.distributionType);
4652
+ if (existing === void 0) {
4653
+ yield* api.iosBundleConfigurations.create({
4654
+ path: { projectId: input.projectId },
4655
+ payload: {
4656
+ bundleIdentifier: input.bundleIdentifier,
4657
+ distributionType: input.distributionType,
4658
+ appleTeamId: input.appleTeamId,
4659
+ appleDistributionCertificateId: input.appleDistributionCertificateId,
4660
+ appleProvisioningProfileId: input.appleProvisioningProfileId,
4661
+ ...input.ascApiKeyId === void 0 ? {} : { ascApiKeyId: input.ascApiKeyId }
4662
+ }
4663
+ });
4664
+ yield* Console.log("iOS bundle configuration saved.");
4665
+ return { action: "created" };
4600
4666
  }
4601
- };
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));
4634
- };
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;
4651
- };
4652
- const BPLIST_MAGIC = Buffer.from("bplist00");
4653
- /**
4654
- * Auto-detect plist format (binary vs XML) and parse accordingly.
4655
- */
4656
- const parsePlist = (data) => data.subarray(0, 8).equals(BPLIST_MAGIC) ? parsePlistBinary(data) : parsePlistXml(data.toString("utf8"));
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;
4663
- };
4664
- const getFirstArrayString = (obj, key) => {
4665
- const value = obj[key];
4666
- if (Array.isArray(value) && typeof value[0] === "string") return value[0];
4667
- };
4668
- /**
4669
- * Extract `UUID`, `Name`, and the first `TeamIdentifier` from the XML plist
4670
- * output of `security cms -D -i <path>`. Returns `ProvisioningError` when any
4671
- * of the three fields are missing.
4672
- */
4673
- const extractProvisioningInfo = (plistXml) => Effect.gen(function* () {
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)}` })
4667
+ if (existing.appleTeamId !== input.appleTeamId) return yield* new MissingCredentialsError({
4668
+ message: `Bundle "${input.bundleIdentifier}" (${input.distributionType}) is already bound to a different Apple team than the new credentials.`,
4669
+ hint: "Delete the existing bundle configuration via the dashboard before retrying with a different team."
4677
4670
  });
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() });
4671
+ yield* api.iosBundleConfigurations.update({
4672
+ path: { id: existing.id },
4673
+ payload: {
4674
+ appleDistributionCertificateId: input.appleDistributionCertificateId,
4675
+ appleProvisioningProfileId: input.appleProvisioningProfileId,
4676
+ ...input.ascApiKeyId === void 0 ? {} : { ascApiKeyId: input.ascApiKeyId }
4677
+ }
4678
+ });
4679
+ yield* Console.log("iOS bundle configuration rebound.");
4682
4680
  return {
4683
- uuid,
4684
- name,
4685
- teamId
4681
+ action: "updated",
4682
+ id: existing.id
4686
4683
  };
4687
4684
  });
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)}` })));
4708
- return {
4709
- ...info,
4710
- installedPath,
4711
- ownsInstallation: true
4712
- };
4713
- }), (acquired) => Effect.gen(function* () {
4714
- if (!acquired.ownsInstallation) return;
4715
- yield* (yield* FileSystem.FileSystem).remove(acquired.installedPath).pipe(Effect.catchAll(() => Effect.void));
4716
- })).pipe(Effect.map(({ uuid, name, teamId, installedPath }) => ({
4717
- uuid,
4718
- name,
4719
- teamId,
4720
- installedPath
4721
- })));
4722
4685
 
4723
4686
  //#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
- };
4687
+ //#region src/application/credentials-interactive-apple-id.ts
4688
+ const chooseIosSetupPath = (api) => Effect.gen(function* () {
4689
+ if (!(yield* api.ascApiKeys.list()).items.some((key) => key.appleTeamId !== null)) return "apple-id";
4690
+ return yield* promptSelect("How would you like to provide your iOS credentials?", [{
4691
+ value: "apple-id",
4692
+ label: "Login with Apple ID (recommended for interactive use)"
4693
+ }, {
4694
+ value: "asc-key",
4695
+ label: "Use an App Store Connect API key"
4696
+ }]);
4740
4697
  });
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}`);
4698
+ const interactiveAppleIdCertLimitRecover = (ctx) => Effect.gen(function* () {
4699
+ yield* Console.log("");
4700
+ yield* Console.log("Apple reports the certificate limit was hit (max 3 distribution certs per team).");
4701
+ const certs = yield* listDistributionCertsViaAppleId(ctx, "IOS_DISTRIBUTION");
4702
+ if (certs.length === 0) return yield* new AppleIdGenerateFailedError({
4703
+ step: "limit-recover",
4704
+ message: "Apple says the certificate limit is hit but no existing certificates were returned."
4705
+ });
4706
+ const toRevoke = yield* promptMultiSelect("Select one or more certificates to revoke before retrying", certs.map((entry) => ({
4707
+ value: entry.developerPortalIdentifier,
4708
+ label: `${entry.serialNumber.slice(0, 12)}… (${entry.displayName}, exp ${entry.expirationDate.slice(0, 10)})`
4709
+ })), { required: true });
4710
+ yield* Effect.forEach(toRevoke, (id) => revokeDistributionCertViaAppleId(ctx, id), { concurrency: "inherit" });
4711
+ yield* Console.log(`Revoked ${toRevoke.length} certificate(s); retrying generation...`);
4712
+ });
4713
+ const generateDistributionCertViaAppleIdInteractive = (api, ctx) => Effect.gen(function* () {
4714
+ yield* Console.log("Generating distribution certificate via Apple ID...");
4715
+ const generate = generateAndUploadDistributionCertificateViaAppleId(api, { context: ctx });
4716
+ return yield* generate.pipe(Effect.catchTag("CertificateLimitError", () => interactiveAppleIdCertLimitRecover(ctx).pipe(Effect.flatMap(() => generate))));
4717
+ });
4718
+ const GENERATE_NEW = "__generate__";
4719
+ const chooseDistributionCertViaAppleId = (api, ctx, appleTeamIdentifier) => Effect.gen(function* () {
4720
+ const [teams, all] = yield* Effect.all([api.appleTeams.list(), api.appleDistributionCertificates.list()], { concurrency: 2 });
4721
+ const team = teams.items.find((entry) => entry.appleTeamId === appleTeamIdentifier);
4722
+ const items = team === void 0 ? [] : all.items.filter((cert) => cert.appleTeamId === team.id);
4723
+ if (items.length === 0) {
4724
+ const created = yield* generateDistributionCertViaAppleIdInteractive(api, ctx);
4725
+ return {
4726
+ id: created.id,
4727
+ appleTeamId: created.appleTeamId
4728
+ };
4729
+ }
4730
+ const choice = yield* promptSelect("Select a distribution certificate (or 'generate' for a fresh one)", [{
4731
+ value: GENERATE_NEW,
4732
+ label: "Generate a new distribution certificate"
4733
+ }, ...items.map((cert) => ({
4734
+ value: cert.id,
4735
+ label: `${cert.serialNumber.slice(0, 12)}… (team ${appleTeamIdentifier})`
4736
+ }))]);
4737
+ if (choice === GENERATE_NEW) {
4738
+ const created = yield* generateDistributionCertViaAppleIdInteractive(api, ctx);
4739
+ return {
4740
+ id: created.id,
4741
+ appleTeamId: created.appleTeamId
4742
+ };
4756
4743
  }
4744
+ const cert = items.find((entry) => entry.id === choice);
4745
+ if (cert === void 0) return yield* new AppleIdGenerateFailedError({
4746
+ step: "pick-certificate",
4747
+ message: `Selected certificate ${choice} not found after listing`
4748
+ });
4757
4749
  return {
4758
- passed: warnings.length === 0,
4759
- warnings
4750
+ id: cert.id,
4751
+ appleTeamId: cert.appleTeamId
4760
4752
  };
4761
4753
  });
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);
4754
+ const setupIosViaAppleId = (api, input) => Effect.gen(function* () {
4755
+ const auth = yield* AppleAuth;
4756
+ const session = yield* auth.ensureLoggedIn();
4757
+ const ctx = auth.buildRequestContext(session);
4758
+ yield* Console.log(`Logged in as ${session.username}. Team: ${session.teamName ?? session.teamId} (${session.teamId}).`);
4759
+ const cert = yield* chooseDistributionCertViaAppleId(api, ctx, session.teamId);
4760
+ const distributionType = IOS_DISTRIBUTION_TO_TYPE[input.distribution];
4761
+ yield* Console.log("Generating provisioning profile via Apple ID...");
4762
+ const profile = yield* generateAndUploadProvisioningProfileViaAppleId(api, {
4763
+ context: ctx,
4764
+ distributionCertificateId: cert.id,
4765
+ bundleIdentifier: input.bundleIdentifier,
4766
+ distributionType
4767
+ });
4768
+ yield* upsertIosBundleConfiguration(api, {
4769
+ projectId: input.projectId,
4770
+ bundleIdentifier: input.bundleIdentifier,
4771
+ distributionType,
4772
+ appleTeamId: cert.appleTeamId,
4773
+ appleDistributionCertificateId: cert.id,
4774
+ appleProvisioningProfileId: profile.id
4775
+ });
4768
4776
  });
4769
- /**
4770
- * Return the main `.app` plus every `.appex` extension under `<app>/PlugIns/`.
4771
- * Each returned path is a signed bundle that should carry its own embedded
4772
- * provisioning profile + Info.plist with CFBundleIdentifier.
4773
- */
4774
- const listSignedBundleDirs = (appDir) => Effect.gen(function* () {
4775
- const fs = yield* FileSystem.FileSystem;
4776
- const plugInsDir = path.join(appDir, "PlugIns");
4777
- if (!(yield* fs.exists(plugInsDir).pipe(Effect.catchAll(() => Effect.succeed(false))))) return [appDir];
4778
- return [appDir, ...(yield* fs.readDirectory(plugInsDir)).filter((entry) => entry.endsWith(".appex")).map((entry) => path.join(plugInsDir, entry))];
4779
- });
4780
- const readBundleId = (bundleDir) => Effect.gen(function* () {
4781
- const fs = yield* FileSystem.FileSystem;
4782
- const plistPath = path.join(bundleDir, "Info.plist");
4783
- const data = yield* fs.readFile(plistPath);
4784
- const bundleId = parsePlist(Buffer.from(data))["CFBundleIdentifier"];
4785
- return typeof bundleId === "string" ? bundleId : void 0;
4786
- });
4787
- const validateEmbeddedProfile = (bundleDir, expectedUuid, expectedTeamId, bundleId) => Effect.gen(function* () {
4788
- const warnings = [];
4789
- const profilePath = path.join(bundleDir, "embedded.mobileprovision");
4790
- const parsed = parsePlistXml(yield* Command.string(Command.make("security", "cms", "-D", "-i", profilePath)));
4791
- const actualUuid = parsed["UUID"];
4792
- if (typeof actualUuid === "string" && actualUuid !== expectedUuid) warnings.push(`[${bundleId}] Profile UUID mismatch: expected "${expectedUuid}", got "${actualUuid}"`);
4793
- const teamIdentifiers = parsed["TeamIdentifier"];
4794
- if (Array.isArray(teamIdentifiers)) {
4795
- const [actualTeamId] = teamIdentifiers;
4796
- if (typeof actualTeamId === "string" && actualTeamId !== expectedTeamId) warnings.push(`[${bundleId}] Team ID mismatch: expected "${expectedTeamId}", got "${actualTeamId}"`);
4797
- }
4798
- const expirationDate = parsed["ExpirationDate"];
4799
- if (expirationDate instanceof Date && expirationDate.getTime() < Date.now()) warnings.push(`[${bundleId}] Embedded provisioning profile expired on ${expirationDate.toISOString()}`);
4800
- return warnings;
4801
- });
4802
-
4803
- //#endregion
4804
- //#region src/lib/xcode-targets.ts
4805
- /** Product types whose targets require code-signing with a provisioning profile. */
4806
- const SIGNED_PRODUCT_TYPES = new Set([
4807
- "com.apple.product-type.application",
4808
- "com.apple.product-type.app-extension",
4809
- "com.apple.product-type.messages-extension",
4810
- "com.apple.product-type.tv-app-extension",
4811
- "com.apple.product-type.watchapp2",
4812
- "com.apple.product-type.watchkit2-extension"
4813
- ]);
4814
- const loadXcodeModule = () => __require("xcode");
4815
- /**
4816
- * Strip surrounding quotes from a pbxproj string value. `xcode` returns values
4817
- * verbatim from the project file, so identifiers like productType are usually
4818
- * wrapped in double quotes (e.g. `"com.apple.product-type.application"`).
4819
- */
4820
- const unquote$1 = (value) => value.length >= 2 && value.startsWith("\"") && value.endsWith("\"") ? value.slice(1, -1) : value;
4821
- const findXcodeProjectDir = (iosDir) => Effect.gen(function* () {
4822
- const projectDir = (yield* (yield* FileSystem.FileSystem).readDirectory(iosDir).pipe(Effect.mapError((cause) => new XcodeProjectError({ message: `Failed to read ${iosDir}: ${String(cause)}` })))).find((entry) => entry.endsWith(".xcodeproj"));
4823
- if (!projectDir) return yield* new XcodeProjectError({ message: `No .xcodeproj directory found under ${iosDir}. Did "expo prebuild" run?` });
4824
- return path.join(iosDir, projectDir);
4825
- });
4826
- const parseProject = (pbxprojPath) => Effect.try({
4827
- try: () => loadXcodeModule().project(pbxprojPath).parseSync(),
4828
- catch: (cause) => new XcodeProjectError({ message: `Failed to parse ${pbxprojPath}: ${cause instanceof Error ? cause.message : String(cause)}` })
4829
- });
4830
- const collectConfigUuidsForTarget = (project, target, configurationName) => {
4831
- const configList = project.pbxXCConfigurationList()[target.buildConfigurationList];
4832
- if (!configList || typeof configList === "string") return [];
4833
- const buildConfigSection = project.pbxXCBuildConfigurationSection();
4834
- return configList.buildConfigurations.map((entry) => entry.value).filter((uuid) => {
4835
- const cfg = buildConfigSection[uuid];
4836
- if (!cfg || typeof cfg === "string") return false;
4837
- return unquote$1(cfg.name) === configurationName;
4777
+ const regenerateProvisioningProfileViaAppleId = (api, input) => Effect.gen(function* () {
4778
+ const auth = yield* AppleAuth;
4779
+ const session = yield* auth.ensureLoggedIn();
4780
+ yield* Console.log("Regenerating provisioning profile via Apple ID...");
4781
+ const created = yield* generateAndUploadProvisioningProfileViaAppleId(api, {
4782
+ context: auth.buildRequestContext(session),
4783
+ distributionCertificateId: input.distributionCertificateId,
4784
+ bundleIdentifier: input.bundleIdentifier,
4785
+ distributionType: input.distributionType
4838
4786
  });
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;
4787
+ yield* api.iosBundleConfigurations.update({
4788
+ path: { id: input.bundleConfigurationId },
4789
+ payload: { appleProvisioningProfileId: created.id }
4790
+ });
4791
+ return created;
4883
4792
  });
4884
4793
 
4885
4794
  //#endregion
4886
- //#region src/lib/xcpretty-formatter.ts
4887
- /**
4888
- * Create a stateful xcodebuild output formatter backed by `@expo/xcpretty`.
4889
- * Each `pipe(line)` call may return zero or more formatted lines zero means
4890
- * the line was suppressed (e.g., intermediate compiler invocations).
4891
- */
4892
- const createXcodebuildFormatter = (projectRoot) => {
4893
- const formatter = ExpoRunFormatter.create(projectRoot);
4894
- return {
4895
- pipe: (line) => formatter.pipe(line),
4896
- getBuildSummary: () => formatter.getBuildSummary()
4897
- };
4898
- };
4899
-
4900
- //#endregion
4901
- //#region src/commands/build/ios.ts
4902
- const findXcworkspace = (iosDir) => Effect.gen(function* () {
4903
- const workspace = (yield* (yield* FileSystem.FileSystem).readDirectory(iosDir)).find((entry) => entry.endsWith(".xcworkspace"));
4904
- if (!workspace) return yield* new BuildFailedError({
4905
- step: "detect xcworkspace",
4906
- exitCode: 1,
4907
- message: `No .xcworkspace found under ${iosDir}. Did "pod install" run?`
4795
+ //#region src/application/credentials-interactive-ios-asc.ts
4796
+ const interactiveCertLimitRecover = (api, ascApiKeyId) => Effect.gen(function* () {
4797
+ yield* Console.log("");
4798
+ yield* Console.log("Apple reports the certificate limit was hit (max 3 distribution certs per team).");
4799
+ const certs = yield* listAppleCertificates(api, {
4800
+ ascApiKeyId,
4801
+ certificateType: "IOS_DISTRIBUTION"
4908
4802
  });
4909
- return workspace;
4803
+ if (certs.length === 0) return yield* Effect.fail(new MissingCredentialsError({
4804
+ message: "Apple says the certificate limit is hit but no existing certificates were returned.",
4805
+ hint: "Try again later or check the Apple Developer portal."
4806
+ }));
4807
+ const toRevoke = yield* promptMultiSelect("Select one or more certificates to revoke before retrying", certs.map((entry) => ({
4808
+ value: entry.id,
4809
+ label: `${entry.serialNumber.slice(0, 12)}… (${entry.displayName ?? entry.certificateType}, exp ${entry.expirationDate.slice(0, 10)})`
4810
+ })), { required: true });
4811
+ yield* Effect.forEach(toRevoke, (id) => revokeAppleCertificate(api, {
4812
+ ascApiKeyId,
4813
+ developerPortalIdentifier: id
4814
+ }), { concurrency: "inherit" });
4815
+ yield* Console.log(`Revoked ${toRevoke.length} certificate(s); retrying generation...`);
4910
4816
  });
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");
4817
+ const generateDistributionCertInteractive = (api) => Effect.gen(function* () {
4818
+ const teamAscKeys = (yield* api.ascApiKeys.list()).items.filter((key) => key.appleTeamId !== null);
4819
+ if (teamAscKeys.length === 0) return yield* Effect.fail(new MissingCredentialsError({
4820
+ message: "No ASC API key linked to an Apple team in this organization.",
4821
+ hint: "Upload an ASC API key with a team assignment via the dashboard, then retry."
4822
+ }));
4823
+ const ascKeyId = yield* promptSelect("Select an ASC API key to issue the certificate against", teamAscKeys.map((key) => ({
4824
+ value: key.id,
4825
+ label: `${key.name} (${key.keyId})`
4826
+ })));
4827
+ yield* Console.log("Generating CSR and requesting certificate from Apple...");
4828
+ const generate = generateAndUploadDistributionCertificate(api, { ascApiKeyId: ascKeyId });
4829
+ return yield* generate.pipe(Effect.catchTag("CertificateLimitError", () => interactiveCertLimitRecover(api, ascKeyId).pipe(Effect.flatMap(() => generate))));
4914
4830
  });
4915
- const findAppDirectory = (root) => Effect.gen(function* () {
4916
- const fs = yield* FileSystem.FileSystem;
4917
- const stack = [root];
4918
- let depth = 0;
4919
- while (stack.length > 0 && depth < 6) {
4920
- const layer = stack.splice(0);
4921
- depth += 1;
4922
- for (const dir of layer) {
4923
- const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => []));
4924
- for (const entry of entries) {
4925
- const full = path.join(dir, entry);
4926
- if (entry.endsWith(".app")) return full;
4927
- const stat = yield* fs.stat(full).pipe(Effect.option);
4928
- if (stat._tag === "Some" && stat.value.type === "Directory") stack.push(full);
4929
- }
4930
- }
4831
+ const chooseIosCertificateId = (api) => Effect.gen(function* () {
4832
+ const certs = yield* api.appleDistributionCertificates.list();
4833
+ if (certs.items.length === 0) {
4834
+ yield* Console.log("No distribution certificate found in this organization.");
4835
+ if ((yield* promptSelect("How would you like to proceed?", [{
4836
+ value: "generate",
4837
+ label: "Generate a new distribution certificate"
4838
+ }, {
4839
+ value: "abort",
4840
+ label: "Abort I'll upload one manually"
4841
+ }])) === "abort") return yield* Effect.fail(new MissingCredentialsError({
4842
+ message: "Build aborted — no distribution certificate available.",
4843
+ hint: "Run `better-update credentials generate distribution-certificate --asc-key-id <id>` or upload via the dashboard."
4844
+ }));
4845
+ return (yield* generateDistributionCertInteractive(api)).id;
4931
4846
  }
4932
- return yield* new ArtifactNotFoundError({ message: `No .app bundle found under "${root}".` });
4847
+ const choice = yield* promptSelect("Select a distribution certificate (or 'generate' for a fresh one)", [{
4848
+ value: "__generate__",
4849
+ label: "Generate a new distribution certificate"
4850
+ }, ...certs.items.map((cert) => ({
4851
+ value: cert.id,
4852
+ label: `${cert.serialNumber.slice(0, 12)}… (team ${cert.appleTeamId})`
4853
+ }))]);
4854
+ if (choice === "__generate__") return (yield* generateDistributionCertInteractive(api)).id;
4855
+ return choice;
4933
4856
  });
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);
4857
+ const pickIosCertificate = (api) => Effect.gen(function* () {
4858
+ const chosenId = yield* chooseIosCertificateId(api);
4859
+ const cert = (yield* api.appleDistributionCertificates.list()).items.find((entry) => entry.id === chosenId);
4860
+ if (cert === void 0) return yield* Effect.fail(new MissingCredentialsError({
4861
+ message: "Selected certificate not found after generation.",
4862
+ hint: "Retry."
4863
+ }));
4956
4864
  return {
4957
- artifactPath: archivePath,
4958
- byteSize,
4959
- sha256
4865
+ certId: chosenId,
4866
+ cert
4960
4867
  };
4961
4868
  });
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
4869
+ const pickIosAscKey = (api, appleTeamId) => Effect.gen(function* () {
4870
+ const teamAscKeys = (yield* api.ascApiKeys.list()).items.filter((key) => key.appleTeamId !== null && key.appleTeamId === appleTeamId);
4871
+ if (teamAscKeys.length === 0) return yield* Effect.fail(new MissingCredentialsError({
4872
+ message: `No ASC API key linked to Apple team ${appleTeamId}.`,
4873
+ hint: "Upload an ASC API key for that team via the dashboard, then retry."
4874
+ }));
4875
+ return yield* promptSelect("Select an ASC API key", teamAscKeys.map((key) => ({
4876
+ value: key.id,
4877
+ label: `${key.name} (${key.keyId})`
4878
+ })));
4971
4879
  });
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));
4880
+ const generateProvisioningProfileForBundle = (api, input, ctx) => Effect.gen(function* () {
4881
+ yield* Console.log("Generating provisioning profile via App Store Connect API...");
4882
+ return (yield* generateAndUploadProvisioningProfile(api, {
4883
+ ascApiKeyId: ctx.ascKeyId,
4884
+ distributionCertificateId: ctx.certId,
4885
+ bundleIdentifier: input.bundleIdentifier,
4886
+ distributionType: ctx.distributionType
4887
+ })).id;
4984
4888
  });
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
4889
+ const resolveIosProfileId = (api, input, ctx) => Effect.gen(function* () {
4890
+ const matching = (yield* api.appleProvisioningProfiles.list({ urlParams: {} })).items.filter((profile) => profile.bundleIdentifier === input.bundleIdentifier && profile.distributionType === ctx.distributionType && profile.appleTeamId === ctx.cert.appleTeamId);
4891
+ if (matching.length === 0) return yield* generateProvisioningProfileForBundle(api, input, ctx);
4892
+ if (!(yield* promptConfirm(`Reuse an existing ${input.distribution} profile for ${input.bundleIdentifier}?`, { initialValue: true }))) return yield* generateProvisioningProfileForBundle(api, input, ctx);
4893
+ return yield* promptSelect("Select a provisioning profile", matching.map((profile) => ({
4894
+ value: profile.id,
4895
+ label: profile.profileName ?? profile.developerPortalIdentifier ?? profile.id
4992
4896
  })));
4993
- };
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.`
4897
+ });
4898
+ const setupIosViaAscKey = (api, input) => Effect.gen(function* () {
4899
+ const { certId, cert } = yield* pickIosCertificate(api);
4900
+ const ascKeyId = yield* pickIosAscKey(api, cert.appleTeamId);
4901
+ const distributionType = IOS_DISTRIBUTION_TO_TYPE[input.distribution];
4902
+ const profileId = yield* resolveIosProfileId(api, input, {
4903
+ certId,
4904
+ cert,
4905
+ ascKeyId,
4906
+ distributionType
5054
4907
  });
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
- }))
4908
+ yield* upsertIosBundleConfiguration(api, {
4909
+ projectId: input.projectId,
4910
+ bundleIdentifier: input.bundleIdentifier,
4911
+ distributionType,
4912
+ appleTeamId: cert.appleTeamId,
4913
+ appleDistributionCertificateId: certId,
4914
+ appleProvisioningProfileId: profileId,
4915
+ ascApiKeyId: ascKeyId
5074
4916
  });
5075
- const artifactPath = yield* findIosArtifact({ exportPath });
5076
- const { sha256, byteSize } = yield* sha256File(artifactPath);
5077
- return {
5078
- artifactPath,
5079
- byteSize,
5080
- sha256
5081
- };
5082
4917
  });
5083
- const runIosBuild = (input) => input.iosProfile.simulator === true ? runIosSimulatorBuild(input) : runIosDeviceBuild(input);
5084
4918
 
5085
4919
  //#endregion
5086
- //#region src/commands/build/reserve-and-upload.ts
5087
- const buildReserveCommon = (input) => ({
5088
- projectId: input.projectId,
5089
- profile: input.profileName,
5090
- runtimeVersion: input.runtimeVersion,
5091
- bundleId: input.bundleId,
5092
- sha256: input.sha256,
5093
- byteSize: input.byteSize,
5094
- ...input.appVersion === void 0 ? {} : { appVersion: input.appVersion },
5095
- ...input.buildNumber === void 0 ? {} : { buildNumber: input.buildNumber },
5096
- ...input.gitContext.ref === void 0 ? {} : { gitRef: input.gitContext.ref },
5097
- ...input.gitContext.commit === void 0 ? {} : { gitCommit: input.gitContext.commit },
5098
- ...input.message === void 0 ? {} : { message: input.message }
4920
+ //#region src/application/credentials-interactive.ts
4921
+ const hasTag = (cause) => typeof cause === "object" && cause !== null && "_tag" in cause;
4922
+ const isMissingResolveError = (cause) => hasTag(cause) && (cause._tag === "NotFound" || cause._tag === "BadRequest");
4923
+ const generateKeystoreInteractive = (api) => Effect.gen(function* () {
4924
+ const alias = yield* promptText("Key alias", { placeholder: "upload-key" });
4925
+ const storePassword = yield* promptPassword("Keystore password");
4926
+ const keyPassword = yield* promptPassword("Key password");
4927
+ const commonName = yield* promptText("Common name (CN)", { placeholder: "Your App" });
4928
+ const organization = yield* promptText("Organization (O)", { placeholder: "Your Company" });
4929
+ yield* Console.log("Generating keystore with keytool...");
4930
+ return (yield* generateAndUploadKeystore(api, {
4931
+ keyAlias: alias,
4932
+ storePassword,
4933
+ keyPassword,
4934
+ commonName,
4935
+ organization
4936
+ })).id;
5099
4937
  });
5100
- const callReserve = (api, input) => {
5101
- const common = buildReserveCommon(input);
5102
- const { target } = input;
5103
- if (target.platform === "ios") return target.distribution === "simulator" ? api.builds.reserve({ payload: {
5104
- ...common,
5105
- platform: "ios",
5106
- distribution: "simulator",
5107
- artifactFormat: "tar.gz"
5108
- } }) : api.builds.reserve({ payload: {
5109
- ...common,
5110
- platform: "ios",
5111
- distribution: target.distribution,
5112
- artifactFormat: "ipa"
5113
- } });
5114
- return target.distribution === "play-store" ? api.builds.reserve({ payload: {
5115
- ...common,
5116
- platform: "android",
5117
- distribution: "play-store",
5118
- artifactFormat: "aab"
5119
- } }) : api.builds.reserve({ payload: {
5120
- ...common,
5121
- platform: "android",
5122
- distribution: "direct",
5123
- artifactFormat: "apk"
5124
- } });
5125
- };
5126
- /**
5127
- * Reserve a build record on the server, upload the artifact to the returned
5128
- * presigned URL, and finalize the build with its sha256 + byteSize.
5129
- */
5130
- const reserveAndUpload = (api, input) => Effect.gen(function* () {
5131
- const presignedUploadClient = yield* PresignedUploadClient;
5132
- const reserveResult = yield* callReserve(api, input).pipe(Effect.mapError((cause) => new ReserveError({ message: `Failed to reserve build: ${formatCause(cause)}` })));
5133
- yield* presignedUploadClient.putToPresignedUrl({
5134
- url: reserveResult.uploadUrl,
5135
- filePath: input.artifactPath,
5136
- byteSize: input.byteSize,
5137
- expiresAt: reserveResult.uploadExpiresAt,
5138
- headers: reserveResult.uploadHeaders
5139
- });
5140
- const completed = yield* api.builds.complete({
5141
- path: { id: reserveResult.id },
5142
- payload: {
5143
- sha256: input.sha256,
5144
- byteSize: input.byteSize
5145
- }
5146
- }).pipe(Effect.mapError((cause) => new CompleteError({ message: `Failed to complete build ${reserveResult.id}: ${formatCause(cause)}` })));
5147
- if (!completed.artifact) return yield* new CompleteError({ message: `Build ${completed.id} completed but server returned no artifact record.` });
5148
- return {
5149
- id: completed.id,
5150
- status: "uploaded"
5151
- };
4938
+ const pickExistingKeystore = (api) => Effect.gen(function* () {
4939
+ const keystores = yield* api.androidUploadKeystores.list();
4940
+ if (keystores.items.length === 0) return yield* Effect.fail(new MissingCredentialsError({
4941
+ message: "No existing keystores in this organization.",
4942
+ hint: "Re-run and choose 'Generate new keystore'."
4943
+ }));
4944
+ return yield* promptSelect("Select a keystore", keystores.items.map((item) => ({
4945
+ value: item.id,
4946
+ label: item.keyAlias
4947
+ })));
5152
4948
  });
5153
-
5154
- //#endregion
5155
- //#region src/lib/auto-increment.ts
5156
- const bumpBuildNumber = (current) => Effect.gen(function* () {
5157
- const raw = current ?? "0";
5158
- const parsed = Number.parseInt(raw, 10);
5159
- if (Number.isNaN(parsed)) return yield* new BuildProfileError({ message: `Cannot autoIncrement ios.buildNumber: current value "${raw}" is not a base-10 integer.` });
5160
- return String(parsed + 1);
5161
- });
5162
- const bumpVersionCode = (current) => Effect.gen(function* () {
5163
- const value = current ?? 0;
5164
- if (!Number.isInteger(value) || value < 0) return yield* new BuildProfileError({ message: `Cannot autoIncrement android.versionCode: current value ${String(value)} is not a non-negative integer.` });
5165
- return value + 1;
5166
- });
5167
- const SEMVER_PATCH = /^(\d+)\.(\d+)\.(\d+)(.*)$/u;
5168
- const bumpVersion = (current) => Effect.gen(function* () {
5169
- if (current === void 0) return yield* new BuildProfileError({ message: "Cannot autoIncrement version: no `version` field set in Expo config." });
5170
- const match = SEMVER_PATCH.exec(current);
5171
- if (!match) return yield* new BuildProfileError({ message: `Cannot autoIncrement version: "${current}" is not a semver string like "1.2.3".` });
5172
- const [, major, minor, patch, suffix] = match;
5173
- const nextPatch = Number.parseInt(patch ?? "0", 10) + 1;
5174
- return `${major ?? "0"}.${minor ?? "0"}.${String(nextPatch)}${suffix ?? ""}`;
5175
- });
5176
- const computeIosBumps = (config, mode) => Effect.gen(function* () {
5177
- if (mode === "buildNumber") return { nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber) };
5178
- return {
5179
- nextVersion: yield* bumpVersion(config.version),
5180
- nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber)
5181
- };
4949
+ const resolveAndroidAppId = (api, input) => Effect.gen(function* () {
4950
+ const existing = (yield* api.androidApplicationIdentifiers.list({ path: { projectId: input.projectId } })).items.find((item) => item.packageName === input.applicationIdentifier);
4951
+ if (existing !== void 0) return existing.id;
4952
+ return (yield* api.androidApplicationIdentifiers.create({
4953
+ path: { projectId: input.projectId },
4954
+ payload: { packageName: input.applicationIdentifier }
4955
+ })).id;
5182
4956
  });
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
- };
4957
+ const resolveAndroidKeystoreId = (api, choice) => choice === "generate" ? generateKeystoreInteractive(api) : pickExistingKeystore(api);
4958
+ const setupAndroidInteractive = (api, input) => Effect.gen(function* () {
4959
+ yield* Console.log("");
4960
+ yield* Console.log(`No Android build credentials configured for ${input.applicationIdentifier}.`);
4961
+ const appId = yield* resolveAndroidAppId(api, input);
4962
+ const choice = yield* promptSelect("How would you like to provide a keystore?", [
4963
+ {
4964
+ value: "generate",
4965
+ label: "Generate new keystore"
4966
+ },
4967
+ {
4968
+ value: "existing",
4969
+ label: "Pick an existing keystore"
4970
+ },
4971
+ {
4972
+ value: "abort",
4973
+ label: "Abort — I'll configure it in the dashboard"
4974
+ }
4975
+ ]);
4976
+ if (choice === "abort") return yield* Effect.fail(new MissingCredentialsError({
4977
+ message: `Build aborted — no keystore bound to ${input.applicationIdentifier}.`,
4978
+ hint: "Run `better-update credentials generate keystore` or upload via the dashboard."
4979
+ }));
4980
+ const keystoreId = yield* resolveAndroidKeystoreId(api, choice);
4981
+ yield* api.androidBuildCredentials.create({
4982
+ path: { applicationIdentifierId: appId },
4983
+ payload: {
4984
+ name: "Default",
4985
+ isDefault: true,
4986
+ androidUploadKeystoreId: keystoreId
4987
+ }
4988
+ });
4989
+ yield* Console.log("Android build credentials configured.");
5189
4990
  });
5190
- const buildPatch = (platform, bumps) => {
5191
- const patch = {};
5192
- if (bumps.nextVersion !== void 0) patch["version"] = bumps.nextVersion;
5193
- if (platform === "ios" && bumps.nextBuildNumber !== void 0) patch["ios"] = { buildNumber: bumps.nextBuildNumber };
5194
- if (platform === "android" && bumps.nextVersionCode !== void 0) patch["android"] = { versionCode: bumps.nextVersionCode };
5195
- return patch;
5196
- };
5197
- const describeBumps = (platform, bumps) => {
5198
- const parts = [];
5199
- if (bumps.nextVersion !== void 0) parts.push(`version=${bumps.nextVersion}`);
5200
- if (platform === "ios" && bumps.nextBuildNumber !== void 0) parts.push(`ios.buildNumber=${bumps.nextBuildNumber}`);
5201
- if (platform === "android" && bumps.nextVersionCode !== void 0) parts.push(`android.versionCode=${String(bumps.nextVersionCode)}`);
5202
- return parts.join(", ");
5203
- };
5204
- const computeBumps = (input) => {
5205
- if (input.platform === "ios") return input.iosMode === void 0 ? Effect.succeed({}) : computeIosBumps(input.config, input.iosMode);
5206
- return input.androidMode === void 0 ? Effect.succeed({}) : computeAndroidBumps(input.config, input.androidMode);
5207
- };
5208
- const hasAnyBump = (bumps) => bumps.nextVersion !== void 0 || bumps.nextBuildNumber !== void 0 || bumps.nextVersionCode !== void 0;
5209
- /**
5210
- * Bump `version` / `ios.buildNumber` / `android.versionCode` per the resolved
5211
- * autoIncrement mode, persist via `@expo/config.modifyConfigAsync`, and log a
5212
- * Human-readable summary. No-op when the mode is undefined. Returns the new
5213
- * Bumped values so callers can refresh their in-memory ExpoConfig.
5214
- */
5215
- const applyAutoIncrement = (input) => Effect.gen(function* () {
5216
- const bumps = yield* computeBumps(input);
5217
- if (!hasAnyBump(bumps)) return bumps;
5218
- const patch = buildPatch(input.platform, bumps);
5219
- const result = yield* writeExpoConfigPatch(input.projectRoot, patch).pipe(Effect.mapError((cause) => new BuildProfileError({ message: `Failed to persist autoIncrement: ${cause.message}` })));
5220
- if (result.type === "warn" && result.configPath === null) {
5221
- yield* Console.log(`autoIncrement: dynamic Expo config detected, cannot write back. Update manually: ${describeBumps(input.platform, bumps)}`);
5222
- return bumps;
4991
+ const ensureAndroidCredentialsAvailable = (api, input) => api.buildCredentials.resolve({
4992
+ path: { projectId: input.projectId },
4993
+ payload: {
4994
+ platform: "android",
4995
+ applicationIdentifier: input.applicationIdentifier
5223
4996
  }
5224
- yield* Console.log(`autoIncrement: bumped ${describeBumps(input.platform, bumps)}`);
5225
- return bumps;
5226
- });
5227
-
5228
- //#endregion
5229
- //#region src/lib/eas-config.ts
5230
- const MAX_EXTENDS_DEPTH = 10;
5231
- const asStringValue = (value) => typeof value === "string" ? value : void 0;
5232
- const asBooleanValue = (value) => typeof value === "boolean" ? value : void 0;
5233
- const asEnv = (value) => {
5234
- const record = asRecord(value);
5235
- if (!record) return;
5236
- const env = {};
5237
- for (const [key, raw] of Object.entries(record)) if (typeof raw === "string") env[key] = raw;
5238
- return Object.keys(env).length === 0 ? void 0 : env;
5239
- };
5240
- const asIosDistribution = (raw) => {
5241
- const value = asStringValue(raw);
5242
- if (value === "app-store" || value === "ad-hoc" || value === "development" || value === "enterprise") return value;
5243
- };
5244
- const asEnterpriseProvisioning = (raw) => {
5245
- const value = asStringValue(raw);
5246
- return value === "adhoc" || value === "universal" ? value : void 0;
5247
- };
5248
- const asAndroidBuildType = (raw) => {
5249
- const value = asStringValue(raw);
5250
- return value === "debug" || value === "release" ? value : void 0;
5251
- };
5252
- const asAndroidFormat = (raw) => {
5253
- const value = asStringValue(raw);
5254
- return value === "apk" || value === "aab" ? value : void 0;
5255
- };
5256
- const asAndroidDistribution = (raw) => {
5257
- const value = asStringValue(raw);
5258
- return value === "play-store" || value === "direct" ? value : void 0;
5259
- };
5260
- const asIosAutoIncrement = (raw) => {
5261
- if (typeof raw === "boolean") return raw;
5262
- const value = asStringValue(raw);
5263
- return value === "buildNumber" || value === "version" ? value : void 0;
5264
- };
5265
- const asAndroidAutoIncrement = (raw) => {
5266
- if (typeof raw === "boolean") return raw;
5267
- const value = asStringValue(raw);
5268
- return value === "versionCode" || value === "version" ? value : void 0;
5269
- };
5270
- const asAutoIncrement = (raw) => {
5271
- if (typeof raw === "boolean") return raw;
5272
- const value = asStringValue(raw);
5273
- return value === "buildNumber" || value === "versionCode" || value === "version" ? value : void 0;
5274
- };
5275
- const asEasDistribution = (raw) => {
5276
- const value = asStringValue(raw);
5277
- return value === "internal" || value === "store" ? value : void 0;
5278
- };
5279
- const asCredentialsSource = (raw) => {
5280
- const value = asStringValue(raw);
5281
- return value === "remote" || value === "local" ? value : void 0;
5282
- };
5283
- const parseIosProfile = (raw) => {
5284
- const record = asRecord(raw);
5285
- if (!record) return;
5286
- const distribution = asIosDistribution(record["distribution"]);
5287
- const buildConfiguration = asStringValue(record["buildConfiguration"]);
5288
- const scheme = asStringValue(record["scheme"]);
5289
- const simulator = asBooleanValue(record["simulator"]);
5290
- const enterpriseProvisioning = asEnterpriseProvisioning(record["enterpriseProvisioning"]);
5291
- const autoIncrement = asIosAutoIncrement(record["autoIncrement"]);
5292
- return {
5293
- ...distribution === void 0 ? {} : { distribution },
5294
- ...buildConfiguration === void 0 ? {} : { buildConfiguration },
5295
- ...scheme === void 0 ? {} : { scheme },
5296
- ...simulator === void 0 ? {} : { simulator },
5297
- ...enterpriseProvisioning === void 0 ? {} : { enterpriseProvisioning },
5298
- ...autoIncrement === void 0 ? {} : { autoIncrement }
5299
- };
5300
- };
5301
- const parseAndroidProfile = (raw) => {
5302
- const record = asRecord(raw);
5303
- if (!record) return;
5304
- const buildType = asAndroidBuildType(record["buildType"]);
5305
- const flavor = asStringValue(record["flavor"]);
5306
- const gradleCommand = asStringValue(record["gradleCommand"]);
5307
- const format = asAndroidFormat(record["format"]);
5308
- const distribution = asAndroidDistribution(record["distribution"]);
5309
- const autoIncrement = asAndroidAutoIncrement(record["autoIncrement"]);
5310
- return {
5311
- ...buildType === void 0 ? {} : { buildType },
5312
- ...flavor === void 0 ? {} : { flavor },
5313
- ...gradleCommand === void 0 ? {} : { gradleCommand },
5314
- ...format === void 0 ? {} : { format },
5315
- ...distribution === void 0 ? {} : { distribution },
5316
- ...autoIncrement === void 0 ? {} : { autoIncrement }
5317
- };
5318
- };
5319
- const parseBuildProfile = (raw) => {
5320
- const record = asRecord(raw);
5321
- if (!record) return;
5322
- const extendsName = asStringValue(record["extends"]);
5323
- const developmentClient = asBooleanValue(record["developmentClient"]);
5324
- const distribution = asEasDistribution(record["distribution"]);
5325
- const channel = asStringValue(record["channel"]);
5326
- const environment = asStringValue(record["environment"]);
5327
- const env = asEnv(record["env"]);
5328
- const ios = parseIosProfile(record["ios"]);
5329
- const android = parseAndroidProfile(record["android"]);
5330
- const credentialsSource = asCredentialsSource(record["credentialsSource"]);
5331
- const autoIncrement = asAutoIncrement(record["autoIncrement"]);
5332
- return {
5333
- ...extendsName === void 0 ? {} : { extends: extendsName },
5334
- ...developmentClient === void 0 ? {} : { developmentClient },
5335
- ...distribution === void 0 ? {} : { distribution },
5336
- ...channel === void 0 ? {} : { channel },
5337
- ...environment === void 0 ? {} : { environment },
5338
- ...env === void 0 ? {} : { env },
5339
- ...ios === void 0 ? {} : { ios },
5340
- ...android === void 0 ? {} : { android },
5341
- ...credentialsSource === void 0 ? {} : { credentialsSource },
5342
- ...autoIncrement === void 0 ? {} : { autoIncrement }
5343
- };
5344
- };
5345
- const parseEasConfig = (text) => Effect.gen(function* () {
5346
- const root = asRecord(yield* Effect.try({
5347
- try: () => JSON.parse(text),
5348
- catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
4997
+ }).pipe(Effect.asVoid);
4998
+ const ensureAndroidCredentials = (api, input, options) => ensureAndroidCredentialsAvailable(api, input).pipe(Effect.catchIf(isMissingResolveError, () => Effect.gen(function* () {
4999
+ const mode = yield* InteractiveMode;
5000
+ if (options.freezeCredentials || !mode.allow) return yield* Effect.fail(new MissingCredentialsError({
5001
+ message: `No Android build credentials for ${input.applicationIdentifier}.`,
5002
+ hint: options.freezeCredentials ? "Run `better-update credentials generate` first, or remove --freeze-credentials." : "Run `better-update credentials generate` first, or rerun with --interactive to configure now."
5349
5003
  }));
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
- }
5358
- return {
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}` })))));
5004
+ yield* setupAndroidInteractive(api, input);
5005
+ return yield* ensureAndroidCredentialsAvailable(api, input);
5006
+ })));
5007
+ const setupIosInteractive = (api, input) => Effect.gen(function* () {
5008
+ yield* Console.log("");
5009
+ yield* Console.log(`No iOS bundle configuration for ${input.bundleIdentifier} (${input.distribution}).`);
5010
+ if ((yield* chooseIosSetupPath(api)) === "apple-id") return yield* setupIosViaAppleId(api, input);
5011
+ return yield* setupIosViaAscKey(api, input);
5376
5012
  });
5377
- const mergeIos = (base, overlay) => {
5378
- if (!base) return overlay;
5379
- if (!overlay) return base;
5380
- return {
5381
- ...base,
5382
- ...overlay
5383
- };
5384
- };
5385
- const mergeAndroid = (base, overlay) => {
5386
- if (!base) return overlay;
5387
- if (!overlay) return base;
5388
- return {
5389
- ...base,
5390
- ...overlay
5391
- };
5392
- };
5393
- const mergeEnv = (base, overlay) => {
5394
- if (!base) return overlay;
5395
- if (!overlay) return base;
5396
- return {
5397
- ...base,
5398
- ...overlay
5399
- };
5400
- };
5401
- const mergeProfile = (base, overlay) => {
5402
- const ios = mergeIos(base.ios, overlay.ios);
5403
- const android = mergeAndroid(base.android, overlay.android);
5404
- const env = mergeEnv(base.env, overlay.env);
5405
- const developmentClient = overlay.developmentClient ?? base.developmentClient;
5406
- const distribution = overlay.distribution ?? base.distribution;
5407
- const channel = overlay.channel ?? base.channel;
5408
- const environment = overlay.environment ?? base.environment;
5409
- const credentialsSource = overlay.credentialsSource ?? base.credentialsSource;
5410
- const autoIncrement = overlay.autoIncrement ?? base.autoIncrement;
5411
- return {
5412
- ...overlay.extends === void 0 ? {} : { extends: overlay.extends },
5413
- ...developmentClient === void 0 ? {} : { developmentClient },
5414
- ...distribution === void 0 ? {} : { distribution },
5415
- ...channel === void 0 ? {} : { channel },
5416
- ...environment === void 0 ? {} : { environment },
5417
- ...env === void 0 ? {} : { env },
5418
- ...ios === void 0 ? {} : { ios },
5419
- ...android === void 0 ? {} : { android },
5420
- ...credentialsSource === void 0 ? {} : { credentialsSource },
5421
- ...autoIncrement === void 0 ? {} : { autoIncrement }
5422
- };
5423
- };
5424
- const collectExtendsChain = (profiles, profileName) => Effect.gen(function* () {
5425
- const chain = [];
5426
- const visited = /* @__PURE__ */ new Set();
5427
- let current = profileName;
5428
- let depth = 0;
5429
- while (current !== void 0) {
5430
- if (visited.has(current)) return yield* new BuildProfileError({ message: `Cycle detected in eas.json build.${profileName} extends chain at "${current}".` });
5431
- visited.add(current);
5432
- const profile = profiles[current];
5433
- if (!profile) return yield* new BuildProfileError({ message: current === profileName ? `Build profile "${profileName}" not found in eas.json.` : `Build profile "${profileName}" extends missing profile "${current}".` });
5434
- chain.unshift(profile);
5435
- current = profile.extends;
5436
- depth += 1;
5437
- if (depth > MAX_EXTENDS_DEPTH) return yield* new BuildProfileError({ message: `Too many "extends" levels (max ${String(MAX_EXTENDS_DEPTH)}) in eas.json build.${profileName}.` });
5013
+ const resolveIosBuildCredentials = (api, input) => api.buildCredentials.resolve({
5014
+ path: { projectId: input.projectId },
5015
+ payload: {
5016
+ platform: "ios",
5017
+ bundleIdentifier: input.bundleIdentifier,
5018
+ distributionType: IOS_DISTRIBUTION_TO_TYPE[input.distribution]
5438
5019
  }
5439
- return chain;
5440
5020
  });
5441
- const stripExtends = (profile) => {
5442
- if (profile.extends === void 0) return profile;
5443
- const { extends: _omit, ...rest } = profile;
5444
- return rest;
5445
- };
5446
- const resolveEasBuildProfile = (config, profileName) => Effect.gen(function* () {
5447
- const profiles = config.build;
5448
- if (!profiles) return yield* new BuildProfileError({ message: "eas.json has no \"build\" section. Add at least one profile." });
5449
- return stripExtends((yield* collectExtendsChain(profiles, profileName)).reduce((acc, next, index) => index === 0 ? next : mergeProfile(acc, next), {}));
5021
+ const findBoundIosConfig = (api, input) => Effect.gen(function* () {
5022
+ const distributionType = IOS_DISTRIBUTION_TO_TYPE[input.distribution];
5023
+ const match = (yield* api.iosBundleConfigurations.list({ path: { projectId: input.projectId } })).items.find((config) => config.bundleIdentifier === input.bundleIdentifier && config.distributionType === distributionType);
5024
+ if (match === void 0) return yield* Effect.fail(new MissingCredentialsError({
5025
+ message: `iOS bundle configuration vanished while regenerating stale profile for ${input.bundleIdentifier}`,
5026
+ hint: "Retry; the configuration must exist before regeneration"
5027
+ }));
5028
+ return match;
5029
+ });
5030
+ const regenerateProvisioningProfile = (api, input) => Effect.gen(function* () {
5031
+ const config = yield* findBoundIosConfig(api, input);
5032
+ if (config.appleDistributionCertificateId === null) return yield* new MissingCredentialsError({
5033
+ message: "Profile cannot be regenerated: bundle configuration is missing the distribution certificate",
5034
+ hint: "Re-bind credentials via `better-update credentials generate` or the dashboard"
5035
+ });
5036
+ const distributionType = IOS_DISTRIBUTION_TO_TYPE[input.distribution];
5037
+ if (config.ascApiKeyId === null) return yield* regenerateProvisioningProfileViaAppleId(api, {
5038
+ bundleIdentifier: input.bundleIdentifier,
5039
+ distributionCertificateId: config.appleDistributionCertificateId,
5040
+ distributionType,
5041
+ bundleConfigurationId: config.id
5042
+ });
5043
+ yield* Console.log("Regenerating provisioning profile via App Store Connect API...");
5044
+ const created = yield* generateAndUploadProvisioningProfile(api, {
5045
+ ascApiKeyId: config.ascApiKeyId,
5046
+ distributionCertificateId: config.appleDistributionCertificateId,
5047
+ bundleIdentifier: input.bundleIdentifier,
5048
+ distributionType
5049
+ });
5050
+ yield* api.iosBundleConfigurations.update({
5051
+ path: { id: config.id },
5052
+ payload: { appleProvisioningProfileId: created.id }
5053
+ });
5054
+ return created;
5450
5055
  });
5056
+ const ensureIosCredentials = (api, input, options) => resolveIosBuildCredentials(api, input).pipe(Effect.catchIf(isMissingResolveError, () => Effect.gen(function* () {
5057
+ const mode = yield* InteractiveMode;
5058
+ if (options.freezeCredentials || !mode.allow) return yield* Effect.fail(new MissingCredentialsError({
5059
+ message: `No iOS build credentials for ${input.bundleIdentifier} (${input.distribution}).`,
5060
+ hint: options.freezeCredentials ? "Run `better-update credentials generate` first, or remove --freeze-credentials." : "Run `better-update credentials generate` first, or rerun with --interactive to configure now."
5061
+ }));
5062
+ yield* setupIosInteractive(api, input);
5063
+ return yield* resolveIosBuildCredentials(api, input);
5064
+ })), Effect.flatMap((resolved) => Effect.gen(function* () {
5065
+ if (resolved.platform !== "ios" || !resolved.profileStale) return;
5066
+ const mode = yield* InteractiveMode;
5067
+ if (options.freezeCredentials || !mode.allow) return yield* Effect.fail(new MissingCredentialsError({
5068
+ message: `Stale provisioning profile for ${input.bundleIdentifier}; cannot regenerate without an interactive session.`,
5069
+ hint: options.freezeCredentials ? "Run a build without --freeze-credentials once to refresh the profile, or run `better-update credentials regenerate-profile`." : "Run `better-update credentials regenerate-profile --bundle <id> --distribution <type>` from an interactive terminal."
5070
+ }));
5071
+ yield* Console.log(`Stale provisioning profile for ${input.bundleIdentifier} (device roster changed). Regenerating...`);
5072
+ yield* regenerateProvisioningProfile(api, input);
5073
+ })));
5451
5074
 
5452
5075
  //#endregion
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";
5461
- };
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";
5467
- };
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
- };
5076
+ //#region src/lib/ios-codesign-pbxproj.ts
5077
+ const loadXcodeModule$1 = () => __require("xcode");
5078
+ const findXcodeProjectDir$1 = (iosDir) => Effect.gen(function* () {
5079
+ 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"));
5080
+ if (!projectDir) return yield* new XcodeProjectError({ message: `No .xcodeproj directory found under ${iosDir}.` });
5081
+ return path.join(iosDir, projectDir);
5082
+ });
5083
+ const parseProject$1 = (pbxprojPath) => Effect.try({
5084
+ try: () => loadXcodeModule$1().project(pbxprojPath).parseSync(),
5085
+ catch: (cause) => new XcodeProjectError({ message: `Failed to parse ${pbxprojPath}: ${cause instanceof Error ? cause.message : String(cause)}` })
5086
+ });
5087
+ /**
5088
+ * Always wrap a value in double quotes for safe pbxproj serialization. The
5089
+ * `xcode` writer emits values verbatim (e.g. `KEY = %s;`), so any string with
5090
+ * spaces, brackets or non-identifier characters needs explicit quoting.
5091
+ */
5092
+ const quote = (value) => `"${value.replaceAll("\"", String.raw`\"`)}"`;
5093
+ const SDK_CONDITIONAL_IDENTITY_KEYS = ["\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\"", "CODE_SIGN_IDENTITY[sdk=iphoneos*]"];
5094
+ const mutateConfig = (project, configUuid, settings) => {
5095
+ const cfg = project.pbxXCBuildConfigurationSection()[configUuid];
5096
+ if (!cfg || typeof cfg === "string") return false;
5097
+ cfg.buildSettings["CODE_SIGN_STYLE"] = "Manual";
5098
+ cfg.buildSettings["DEVELOPMENT_TEAM"] = quote(settings.teamId);
5099
+ cfg.buildSettings["CODE_SIGN_IDENTITY"] = quote(settings.signingIdentity);
5100
+ cfg.buildSettings["PROVISIONING_PROFILE_SPECIFIER"] = quote(settings.profileSpecifier);
5101
+ delete cfg.buildSettings["PROVISIONING_PROFILE"];
5102
+ for (const key of SDK_CONDITIONAL_IDENTITY_KEYS) delete cfg.buildSettings[key];
5103
+ return true;
5506
5104
  };
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
- };
5105
+ /**
5106
+ * Write `CODE_SIGN_STYLE=Manual`, `DEVELOPMENT_TEAM`, `CODE_SIGN_IDENTITY`, and
5107
+ * `PROVISIONING_PROFILE_SPECIFIER` into the specified XCBuildConfiguration
5108
+ * entries of the project under `iosDir`, then serialize back to disk.
5109
+ *
5110
+ * Only mutates the main app project — `Pods.xcodeproj` is left untouched. The
5111
+ * caller is responsible for ensuring each entry's `buildConfigurationUuids`
5112
+ * only includes configurations that belong to a signed target (see
5113
+ * `discoverSignedTargets`).
5114
+ */
5115
+ const applyTargetSigning = (options) => Effect.gen(function* () {
5116
+ const fs = yield* FileSystem.FileSystem;
5117
+ const projectDir = yield* findXcodeProjectDir$1(options.iosDir);
5118
+ const pbxprojPath = path.join(projectDir, "project.pbxproj");
5119
+ const project = yield* parseProject$1(pbxprojPath);
5120
+ 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}.` });
5121
+ const serialized = yield* Effect.try({
5122
+ try: () => project.writeSync(),
5123
+ catch: (cause) => new XcodeProjectError({ message: `Failed to serialize ${pbxprojPath}: ${cause instanceof Error ? cause.message : String(cause)}` })
5124
+ });
5125
+ yield* fs.writeFileString(pbxprojPath, serialized).pipe(Effect.mapError((cause) => new XcodeProjectError({ message: `Failed to write ${pbxprojPath}: ${String(cause)}` })));
5126
+ });
5127
+
5128
+ //#endregion
5129
+ //#region src/lib/ios-export-options.ts
5130
+ const XCODE_METHOD = {
5131
+ "app-store": "app-store-connect",
5132
+ "ad-hoc": "release-testing",
5133
+ development: "debugging",
5134
+ enterprise: "enterprise"
5522
5135
  };
5523
- const fromEasProfile = (eas, profileName) => {
5524
- const ios = toIosProfile(eas);
5525
- const android = toAndroidProfile(eas);
5526
- return {
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 }
5534
- };
5136
+ const escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
5137
+ const boolTag = (value) => value ? "<true/>" : "<false/>";
5138
+ /**
5139
+ * Render an Xcode `ExportOptions.plist` for `xcodebuild -exportArchive`.
5140
+ *
5141
+ * - `signingStyle` is always `manual` (ephemeral keychain + downloaded profile)
5142
+ * - `uploadSymbols` is emitted only for `app-store` exports
5143
+ * - `provisioningProfiles` dict maps each bundleId profile name (one entry
5144
+ * per signed target: main app + any extensions like notification service)
5145
+ * - `compileBitcode` defaults to `false`
5146
+ */
5147
+ const renderExportOptionsPlist = ({ method, teamId, provisioningProfiles, compileBitcode = false }) => {
5148
+ const lines = [
5149
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
5150
+ "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
5151
+ "<plist version=\"1.0\">",
5152
+ "<dict>",
5153
+ " <key>method</key>",
5154
+ `\t<string>${escapeXml(XCODE_METHOD[method])}</string>`,
5155
+ " <key>teamID</key>",
5156
+ `\t<string>${escapeXml(teamId)}</string>`,
5157
+ " <key>signingStyle</key>",
5158
+ " <string>manual</string>",
5159
+ " <key>compileBitcode</key>",
5160
+ `\t${boolTag(compileBitcode)}`,
5161
+ " <key>provisioningProfiles</key>",
5162
+ " <dict>"
5163
+ ];
5164
+ for (const { bundleId, profileName } of provisioningProfiles) lines.push(`\t\t<key>${escapeXml(bundleId)}</key>`, `\t\t<string>${escapeXml(profileName)}</string>`);
5165
+ lines.push(" </dict>");
5166
+ if (method === "app-store") lines.push(" <key>uploadSymbols</key>", " <true/>");
5167
+ lines.push("</dict>", "</plist>", "");
5168
+ return lines.join("\n");
5535
5169
  };
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)
5170
+
5171
+ //#endregion
5172
+ //#region src/lib/ios-keychain.ts
5173
+ const runOrFail = (cmd, step) => Command.string(cmd).pipe(Effect.mapError((cause) => new KeychainError({ message: `keychain ${step} failed: ${String(cause)}` })));
5174
+ const listCurrentKeychains = Effect.gen(function* () {
5175
+ 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);
5542
5176
  });
5543
- const readRawRuntimeVersion = (value) => {
5544
- if (typeof value === "string") return value;
5545
- const policy = asString$1(asRecord(value)?.["policy"]);
5546
- if (policy) return { policy };
5177
+ const parseSigningIdentity = (output) => {
5178
+ const lines = output.split("\n");
5179
+ for (const line of lines) {
5180
+ const match = /"([^"]+)"/u.exec(line);
5181
+ if (match?.[1]) return match[1];
5182
+ }
5183
+ };
5184
+ /**
5185
+ * Acquire an ephemeral macOS keychain, import a `.p12` into it, add it to the
5186
+ * user search list, and tear it all down on scope close. The keychain name is
5187
+ * namespaced as `better-update-<uuid>` and lives in `$tempDir`, so cleanup is
5188
+ * guaranteed under all termination paths.
5189
+ */
5190
+ const acquireKeychain = ({ tempDir, p12Path, p12Password }) => {
5191
+ const keychainName = `better-update-${randomUUID()}.keychain-db`;
5192
+ const keychainPath = path.join(tempDir, keychainName);
5193
+ const keychainPassword = randomBytes(32).toString("hex");
5194
+ return Effect.acquireRelease(Effect.gen(function* () {
5195
+ const priorKeychains = yield* listCurrentKeychains;
5196
+ yield* runOrFail(Command.make("security", "create-keychain", "-p", keychainPassword, keychainPath), "create-keychain");
5197
+ yield* runOrFail(Command.make("security", "unlock-keychain", "-p", keychainPassword, keychainPath), "unlock-keychain");
5198
+ yield* runOrFail(Command.make("security", "set-keychain-settings", "-t", "3600", "-l", keychainPath), "set-keychain-settings");
5199
+ yield* runOrFail(Command.make("security", "import", p12Path, "-k", keychainPath, "-P", p12Password, "-T", "/usr/bin/codesign"), "import");
5200
+ yield* runOrFail(Command.make("security", "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", keychainPassword, keychainPath), "set-key-partition-list");
5201
+ yield* runOrFail(Command.make("security", "list-keychains", "-d", "user", "-s", keychainPath, ...priorKeychains), "list-keychains -s (add)");
5202
+ const signingIdentity = parseSigningIdentity(yield* runOrFail(Command.make("security", "find-identity", "-v", "-p", "codesigning", keychainPath), "find-identity"));
5203
+ if (!signingIdentity) return yield* new KeychainError({ message: "No code signing identity found after importing .p12 into ephemeral keychain." });
5204
+ return {
5205
+ handle: {
5206
+ keychainName,
5207
+ keychainPath,
5208
+ signingIdentity
5209
+ },
5210
+ priorKeychains
5211
+ };
5212
+ }), ({ priorKeychains }) => Effect.gen(function* () {
5213
+ yield* Command.string(Command.make("security", "list-keychains", "-d", "user", "-s", ...priorKeychains)).pipe(Effect.catchAll(() => Effect.void));
5214
+ yield* Command.string(Command.make("security", "delete-keychain", keychainPath)).pipe(Effect.catchAll(() => Effect.void));
5215
+ })).pipe(Effect.map(({ handle }) => handle));
5547
5216
  };
5548
5217
 
5549
5218
  //#endregion
5550
- //#region src/lib/clear-cache.ts
5219
+ //#region src/lib/plist.ts
5220
+ const plist = typeof plistMod.parse === "function" ? plistMod : plistMod.default;
5551
5221
  /**
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).
5222
+ * Parse an XML plist string into a typed object.
5223
+ * Throws on malformed XML callers should wrap in Effect.try.
5555
5224
  */
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(", ")}`);
5574
- });
5575
-
5576
- //#endregion
5577
- //#region src/lib/env-exporter.ts
5578
- const coerceEnvironment = (raw) => raw === "development" || raw === "preview" || raw === "production" ? raw : void 0;
5225
+ const parsePlistXml = (xml) => plist.parse(xml);
5579
5226
  /**
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.
5227
+ * Parse a binary plist buffer into a typed object.
5228
+ * Uses bplist-parser for Apple's binary plist format.
5582
5229
  */
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)}` })));
5230
+ const parsePlistBinary = (buffer) => {
5231
+ const [result] = __require("bplist-parser").parseBuffer(buffer);
5232
+ return result;
5590
5233
  };
5234
+ const BPLIST_MAGIC = Buffer.from("bplist00");
5235
+ /**
5236
+ * Auto-detect plist format (binary vs XML) and parse accordingly.
5237
+ */
5238
+ const parsePlist = (data) => data.subarray(0, 8).equals(BPLIST_MAGIC) ? parsePlistBinary(data) : parsePlistXml(data.toString("utf8"));
5591
5239
 
5592
5240
  //#endregion
5593
- //#region src/lib/git-context.ts
5594
- const runString = (cmd, cwd) => Command.string(Command.workingDirectory(cmd, cwd));
5241
+ //#region src/lib/ios-provisioning.ts
5242
+ const getString = (obj, key) => {
5243
+ const value = obj[key];
5244
+ return typeof value === "string" ? value : void 0;
5245
+ };
5246
+ const getFirstArrayString = (obj, key) => {
5247
+ const value = obj[key];
5248
+ if (Array.isArray(value) && typeof value[0] === "string") return value[0];
5249
+ };
5595
5250
  /**
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.
5251
+ * Extract `UUID`, `Name`, and the first `TeamIdentifier` from the XML plist
5252
+ * output of `security cms -D -i <path>`. Returns `ProvisioningError` when any
5253
+ * of the three fields are missing.
5600
5254
  */
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" });
5255
+ const extractProvisioningInfo = (plistXml) => Effect.gen(function* () {
5256
+ const parsed = yield* Effect.try({
5257
+ try: () => parsePlistXml(plistXml),
5258
+ catch: (error) => new ProvisioningError({ message: `Failed to parse provisioning profile plist: ${error instanceof Error ? error.message : String(error)}` })
5259
+ });
5260
+ const uuid = getString(parsed, "UUID");
5261
+ const name = getString(parsed, "Name");
5262
+ const teamId = getFirstArrayString(parsed, "TeamIdentifier");
5263
+ if (!uuid || !name || !teamId) return yield* new ProvisioningError({ message: `Failed to parse provisioning profile: missing ${uuid ? "" : "UUID "}${name ? "" : "Name "}${teamId ? "" : "TeamIdentifier "}`.trim() });
5608
5264
  return {
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
5265
+ uuid,
5266
+ name,
5267
+ teamId
5613
5268
  };
5614
5269
  });
5615
-
5616
- //#endregion
5617
- //#region src/lib/gradle-config.ts
5270
+ const userProvisioningProfilesDir = () => path.join(os.homedir(), "Library", "MobileDevice", "Provisioning Profiles");
5618
5271
  /**
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.
5272
+ * Scoped installation of a provisioning profile: parses its metadata via
5273
+ * `security cms -D -i`, copies it into `~/Library/MobileDevice/Provisioning Profiles`
5274
+ * under `<uuid>.mobileprovision`, and removes the copy on scope close — but
5275
+ * only if we installed it. If the target file already existed when we arrived
5276
+ * (e.g., Xcode had it), we leave both the file and the contents untouched.
5626
5277
  */
5627
- const readGradleConfig = (androidDir) => Effect.gen(function* () {
5278
+ const installProvisioningProfile = ({ profilePath }) => Effect.acquireRelease(Effect.gen(function* () {
5628
5279
  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)));
5643
- });
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;
5280
+ 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)}` }))));
5281
+ const targetDir = userProvisioningProfilesDir();
5282
+ const installedPath = path.join(targetDir, `${info.uuid}.mobileprovision`);
5283
+ yield* fs.makeDirectory(targetDir, { recursive: true }).pipe(Effect.catchAll((cause) => new ProvisioningError({ message: `Failed to create provisioning profiles dir: ${String(cause)}` })));
5284
+ if (yield* fs.exists(installedPath).pipe(Effect.orElseSucceed(() => false))) return {
5285
+ ...info,
5286
+ installedPath,
5287
+ ownsInstallation: false
5288
+ };
5289
+ yield* fs.copyFile(profilePath, installedPath).pipe(Effect.catchAll((cause) => new ProvisioningError({ message: `Failed to copy provisioning profile into ${installedPath}: ${String(cause)}` })));
5666
5290
  return {
5667
- ...applicationId === void 0 ? {} : { applicationId },
5668
- ...versionCode === void 0 ? {} : { versionCode },
5669
- ...versionName === void 0 ? {} : { versionName }
5291
+ ...info,
5292
+ installedPath,
5293
+ ownsInstallation: true
5670
5294
  };
5671
- };
5672
- const unquote = (input) => input.startsWith("\"") && input.endsWith("\"") ? input.slice(1, -1) : input;
5295
+ }), (acquired) => Effect.gen(function* () {
5296
+ if (!acquired.ownsInstallation) return;
5297
+ yield* (yield* FileSystem.FileSystem).remove(acquired.installedPath).pipe(Effect.catchAll(() => Effect.void));
5298
+ })).pipe(Effect.map(({ uuid, name, teamId, installedPath }) => ({
5299
+ uuid,
5300
+ name,
5301
+ teamId,
5302
+ installedPath
5303
+ })));
5673
5304
 
5674
5305
  //#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
- };
5306
+ //#region src/lib/post-build-validation.ts
5307
+ const validateOneBundle = (bundleDir, expectedByBundleId, expectedTeamId) => Effect.gen(function* () {
5308
+ const bundleId = yield* readBundleId(bundleDir).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
5309
+ if (!bundleId) return {
5310
+ bundleId: void 0,
5311
+ warnings: [`Missing CFBundleIdentifier in Info.plist at ${bundleDir}`]
5312
+ };
5313
+ const expected = expectedByBundleId.get(bundleId);
5314
+ if (!expected) return {
5315
+ bundleId,
5316
+ warnings: [`Unexpected signed bundle "${bundleId}" found in archive at ${bundleDir}`]
5317
+ };
5318
+ return {
5319
+ bundleId,
5320
+ warnings: yield* validateEmbeddedProfile(bundleDir, expected.profileUuid, expectedTeamId, bundleId).pipe(Effect.catchAll(() => Effect.succeed([])))
5321
+ };
5322
+ });
5323
+ const validateIosBuild = (params) => Effect.gen(function* () {
5324
+ const appDir = yield* findAppDirectory$1(params.archivePath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
5325
+ if (!appDir) return {
5326
+ passed: false,
5327
+ warnings: ["Could not locate .app bundle in archive — skipping post-build validation"]
5328
+ };
5329
+ const bundleDirs = yield* listSignedBundleDirs(appDir).pipe(Effect.catchAll(() => Effect.succeed([appDir])));
5330
+ const expectedByBundleId = new Map(params.expectedTargets.map((target) => [target.bundleId, target]));
5331
+ const perBundle = yield* Effect.forEach(bundleDirs, (bundleDir) => validateOneBundle(bundleDir, expectedByBundleId, params.expectedTeamId));
5332
+ const warnings = perBundle.flatMap((entry) => [...entry.warnings]);
5333
+ const validatedBundleIds = new Set(perBundle.map((entry) => entry.bundleId).filter((id) => id !== void 0));
5334
+ for (const expected of params.expectedTargets) if (!validatedBundleIds.has(expected.bundleId)) warnings.push(`Expected signed target "${expected.bundleId}" was not found in the archive.`);
5335
+ if (warnings.length > 0) {
5336
+ yield* Console.warn("Post-build validation warnings:");
5337
+ for (const warning of warnings) yield* Console.warn(` - ${warning}`);
5338
+ }
5339
+ return {
5340
+ passed: warnings.length === 0,
5341
+ warnings
5342
+ };
5343
+ });
5344
+ const findAppDirectory$1 = (archivePath) => Effect.gen(function* () {
5345
+ const fs = yield* FileSystem.FileSystem;
5346
+ const productsDir = path.join(archivePath, "Products", "Applications");
5347
+ const appEntry = (yield* fs.readDirectory(productsDir)).find((entry) => entry.endsWith(".app"));
5348
+ if (!appEntry) return yield* Effect.fail("No .app found");
5349
+ return path.join(productsDir, appEntry);
5350
+ });
5685
5351
  /**
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.
5352
+ * Return the main `.app` plus every `.appex` extension under `<app>/PlugIns/`.
5353
+ * Each returned path is a signed bundle that should carry its own embedded
5354
+ * provisioning profile + Info.plist with CFBundleIdentifier.
5690
5355
  */
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;
5356
+ const listSignedBundleDirs = (appDir) => Effect.gen(function* () {
5357
+ const fs = yield* FileSystem.FileSystem;
5358
+ const plugInsDir = path.join(appDir, "PlugIns");
5359
+ if (!(yield* fs.exists(plugInsDir).pipe(Effect.catchAll(() => Effect.succeed(false))))) return [appDir];
5360
+ return [appDir, ...(yield* fs.readDirectory(plugInsDir)).filter((entry) => entry.endsWith(".appex")).map((entry) => path.join(plugInsDir, entry))];
5361
+ });
5362
+ const readBundleId = (bundleDir) => Effect.gen(function* () {
5363
+ const fs = yield* FileSystem.FileSystem;
5364
+ const plistPath = path.join(bundleDir, "Info.plist");
5365
+ const data = yield* fs.readFile(plistPath);
5366
+ const bundleId = parsePlist(Buffer.from(data))["CFBundleIdentifier"];
5367
+ return typeof bundleId === "string" ? bundleId : void 0;
5368
+ });
5369
+ const validateEmbeddedProfile = (bundleDir, expectedUuid, expectedTeamId, bundleId) => Effect.gen(function* () {
5370
+ const warnings = [];
5371
+ const profilePath = path.join(bundleDir, "embedded.mobileprovision");
5372
+ const parsed = parsePlistXml(yield* Command.string(Command.make("security", "cms", "-D", "-i", profilePath)));
5373
+ const actualUuid = parsed["UUID"];
5374
+ if (typeof actualUuid === "string" && actualUuid !== expectedUuid) warnings.push(`[${bundleId}] Profile UUID mismatch: expected "${expectedUuid}", got "${actualUuid}"`);
5375
+ const teamIdentifiers = parsed["TeamIdentifier"];
5376
+ if (Array.isArray(teamIdentifiers)) {
5377
+ const [actualTeamId] = teamIdentifiers;
5378
+ if (typeof actualTeamId === "string" && actualTeamId !== expectedTeamId) warnings.push(`[${bundleId}] Team ID mismatch: expected "${expectedTeamId}", got "${actualTeamId}"`);
5699
5379
  }
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
- })));
5380
+ const expirationDate = parsed["ExpirationDate"];
5381
+ if (expirationDate instanceof Date && expirationDate.getTime() < Date.now()) warnings.push(`[${bundleId}] Embedded provisioning profile expired on ${expirationDate.toISOString()}`);
5382
+ return warnings;
5705
5383
  });
5706
5384
 
5707
5385
  //#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([])));
5386
+ //#region src/lib/xcode-targets.ts
5387
+ /** Product types whose targets require code-signing with a provisioning profile. */
5388
+ const SIGNED_PRODUCT_TYPES = new Set([
5389
+ "com.apple.product-type.application",
5390
+ "com.apple.product-type.app-extension",
5391
+ "com.apple.product-type.messages-extension",
5392
+ "com.apple.product-type.tv-app-extension",
5393
+ "com.apple.product-type.watchapp2",
5394
+ "com.apple.product-type.watchkit2-extension"
5395
+ ]);
5396
+ const loadXcodeModule = () => __require("xcode");
5711
5397
  /**
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`.
5398
+ * Strip surrounding quotes from a pbxproj string value. `xcode` returns values
5399
+ * verbatim from the project file, so identifiers like productType are usually
5400
+ * wrapped in double quotes (e.g. `"com.apple.product-type.application"`).
5715
5401
  */
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;
5402
+ const unquote$1 = (value) => value.length >= 2 && value.startsWith("\"") && value.endsWith("\"") ? value.slice(1, -1) : value;
5403
+ const findXcodeProjectDir = (iosDir) => Effect.gen(function* () {
5404
+ 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"));
5405
+ if (!projectDir) return yield* new XcodeProjectError({ message: `No .xcodeproj directory found under ${iosDir}. Did "expo prebuild" run?` });
5406
+ return path.join(iosDir, projectDir);
5407
+ });
5408
+ const parseProject = (pbxprojPath) => Effect.try({
5409
+ try: () => loadXcodeModule().project(pbxprojPath).parseSync(),
5410
+ catch: (cause) => new XcodeProjectError({ message: `Failed to parse ${pbxprojPath}: ${cause instanceof Error ? cause.message : String(cause)}` })
5411
+ });
5412
+ const collectConfigUuidsForTarget = (project, target, configurationName) => {
5413
+ const configList = project.pbxXCConfigurationList()[target.buildConfigurationList];
5414
+ if (!configList || typeof configList === "string") return [];
5415
+ const buildConfigSection = project.pbxXCBuildConfigurationSection();
5416
+ return configList.buildConfigurations.map((entry) => entry.value).filter((uuid) => {
5417
+ const cfg = buildConfigSection[uuid];
5418
+ if (!cfg || typeof cfg === "string") return false;
5419
+ return unquote$1(cfg.name) === configurationName;
5420
+ });
5421
+ };
5422
+ const extractBundleIdForConfig = (project, configUuid) => {
5423
+ const cfg = project.pbxXCBuildConfigurationSection()[configUuid];
5424
+ if (!cfg || typeof cfg === "string") return;
5425
+ const raw = cfg.buildSettings["PRODUCT_BUNDLE_IDENTIFIER"];
5426
+ if (typeof raw !== "string") return;
5427
+ return unquote$1(raw);
5428
+ };
5429
+ const collectSignedTargets = (project, pbxprojPath, configurationName) => Effect.gen(function* () {
5430
+ const results = [];
5431
+ const nativeTargets = project.pbxNativeTargetSection();
5432
+ for (const [uuid, entry] of Object.entries(nativeTargets)) {
5433
+ if (uuid.endsWith("_comment") || typeof entry === "string") continue;
5434
+ const productType = unquote$1(entry.productType);
5435
+ if (!SIGNED_PRODUCT_TYPES.has(productType)) continue;
5436
+ const configUuids = collectConfigUuidsForTarget(project, entry, configurationName);
5437
+ const [firstConfigUuid] = configUuids;
5438
+ if (!firstConfigUuid) return yield* new XcodeProjectError({ message: `Target "${unquote$1(entry.name)}" has no "${configurationName}" build configuration in ${pbxprojPath}.` });
5439
+ const bundleId = extractBundleIdForConfig(project, firstConfigUuid);
5440
+ if (!bundleId) return yield* new XcodeProjectError({ message: `Target "${unquote$1(entry.name)}" is missing PRODUCT_BUNDLE_IDENTIFIER in the "${configurationName}" configuration.` });
5441
+ results.push({
5442
+ targetName: unquote$1(entry.name),
5443
+ bundleId,
5444
+ productType,
5445
+ buildConfigurationUuids: configUuids
5446
+ });
5726
5447
  }
5727
- if (!(yield* promptConfirm(`Continue ${label} with uncommitted changes?`, { initialValue: false }))) yield* new DirtyRepoError({ message: `${label} cancelled by user.` });
5448
+ return results;
5449
+ });
5450
+ /**
5451
+ * Enumerate code-signed native targets (main app + extensions) declared in the
5452
+ * single `.xcodeproj` under `iosDir`, restricted to a given build configuration
5453
+ * (e.g. "Release"). Pod targets and other library product types are excluded.
5454
+ *
5455
+ * The returned `buildConfigurationUuids` list is the set of XCBuildConfiguration
5456
+ * UUIDs that belong to this target *and* match `configurationName` — the
5457
+ * per-target signing mutator writes settings into exactly those configurations.
5458
+ */
5459
+ const discoverSignedTargets = (options) => Effect.gen(function* () {
5460
+ const projectDir = yield* findXcodeProjectDir(options.iosDir);
5461
+ const pbxprojPath = path.join(projectDir, "project.pbxproj");
5462
+ const results = yield* collectSignedTargets(yield* parseProject(pbxprojPath), pbxprojPath, options.configurationName);
5463
+ if (results.length === 0) return yield* new XcodeProjectError({ message: `No signed native targets found in ${pbxprojPath} for configuration "${options.configurationName}".` });
5464
+ return results;
5728
5465
  });
5729
5466
 
5730
5467
  //#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"];
5468
+ //#region src/lib/xcpretty-formatter.ts
5469
+ /**
5470
+ * Create a stateful xcodebuild output formatter backed by `@expo/xcpretty`.
5471
+ * Each `pipe(line)` call may return zero or more formatted lines — zero means
5472
+ * the line was suppressed (e.g., intermediate compiler invocations).
5473
+ */
5474
+ const createXcodebuildFormatter = (projectRoot) => {
5475
+ const formatter = ExpoRunFormatter.create(projectRoot);
5744
5476
  return {
5745
- hash,
5746
- sources: Array.isArray(sourcesRaw) ? sourcesRaw : []
5477
+ pipe: (line) => formatter.pipe(line),
5478
+ getBuildSummary: () => formatter.getBuildSummary()
5747
5479
  };
5748
- });
5480
+ };
5749
5481
 
5750
5482
  //#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;
5483
+ //#region src/commands/build/ios.ts
5484
+ const findXcworkspace = (iosDir) => Effect.gen(function* () {
5485
+ const workspace = (yield* (yield* FileSystem.FileSystem).readDirectory(iosDir)).find((entry) => entry.endsWith(".xcworkspace"));
5486
+ if (!workspace) return yield* new BuildFailedError({
5487
+ step: "detect xcworkspace",
5488
+ exitCode: 1,
5489
+ message: `No .xcworkspace found under ${iosDir}. Did "pod install" run?`
5490
+ });
5491
+ return workspace;
5492
+ });
5493
+ const prebuildAndPods = (params) => Effect.gen(function* () {
5494
+ yield* runStep(Command.make("bunx", "expo", "prebuild", "--platform", "ios", "--clean").pipe(Command.workingDirectory(params.projectRoot), Command.env(params.commandEnv)), "expo prebuild ios");
5495
+ yield* runStep(Command.make("pod", "install").pipe(Command.workingDirectory(params.iosDir), Command.env(params.commandEnv)), "pod install");
5496
+ });
5497
+ const findAppDirectory = (root) => Effect.gen(function* () {
5498
+ const fs = yield* FileSystem.FileSystem;
5499
+ const stack = [root];
5500
+ let depth = 0;
5501
+ while (stack.length > 0 && depth < 6) {
5502
+ const layer = stack.splice(0);
5503
+ depth += 1;
5504
+ for (const dir of layer) {
5505
+ const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => []));
5506
+ for (const entry of entries) {
5507
+ const full = path.join(dir, entry);
5508
+ if (entry.endsWith(".app")) return full;
5509
+ const stat = yield* fs.stat(full).pipe(Effect.option);
5510
+ if (stat._tag === "Some" && stat.value.type === "Directory") stack.push(full);
5511
+ }
5512
+ }
5759
5513
  }
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".` });
5514
+ return yield* new ArtifactNotFoundError({ message: `No .app bundle found under "${root}".` });
5763
5515
  });
5764
-
5765
- //#endregion
5766
- //#region src/lib/credentials-generator-apple-id.ts
5767
- const DISTRIBUTION_TO_PROFILE_TYPE = {
5768
- APP_STORE: AppleUtils.ProfileType.IOS_APP_STORE,
5769
- AD_HOC: AppleUtils.ProfileType.IOS_APP_ADHOC,
5770
- DEVELOPMENT: AppleUtils.ProfileType.IOS_APP_DEVELOPMENT,
5771
- ENTERPRISE: AppleUtils.ProfileType.IOS_APP_INHOUSE
5772
- };
5773
- const DISTRIBUTION_TO_CERTIFICATE_TYPE = {
5774
- APP_STORE: AppleUtils.CertificateType.IOS_DISTRIBUTION,
5775
- AD_HOC: AppleUtils.CertificateType.IOS_DISTRIBUTION,
5776
- ENTERPRISE: AppleUtils.CertificateType.IOS_DISTRIBUTION,
5777
- DEVELOPMENT: AppleUtils.CertificateType.IOS_DEVELOPMENT
5778
- };
5779
- var AppleIdGenerateFailedError = class extends Data.TaggedError("AppleIdGenerateFailedError") {};
5780
- const CERT_LIMIT_PATTERN = /already have a current.*certificate|pending certificate request/iu;
5781
- const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
5782
- const wrap = (step, run) => Effect.tryPromise({
5783
- try: run,
5784
- catch: (cause) => new AppleIdGenerateFailedError({
5785
- step,
5786
- message: messageOf(cause)
5787
- })
5516
+ const runIosSimulatorBuild = (input) => Effect.gen(function* () {
5517
+ const { projectRoot, iosProfile, envVars, tempDir } = input;
5518
+ const runtime = yield* CliRuntime;
5519
+ const iosDir = path.join(projectRoot, "ios");
5520
+ const commandEnv = yield* runtime.commandEnvironment(envVars);
5521
+ yield* prebuildAndPods({
5522
+ projectRoot,
5523
+ iosDir,
5524
+ commandEnv
5525
+ });
5526
+ const workspaceFilename = yield* findXcworkspace(iosDir);
5527
+ const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
5528
+ const configuration = iosProfile.buildConfiguration ?? "Release";
5529
+ const derivedDataPath = path.join(tempDir, "derived-data");
5530
+ 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));
5531
+ const formatter = input.rawOutput ? void 0 : createXcodebuildFormatter(projectRoot);
5532
+ yield* formatter ? runStepFormatted(buildCmd, "xcodebuild build (simulator)", formatter) : runStep(buildCmd, "xcodebuild build (simulator)");
5533
+ const appDir = yield* findAppDirectory(path.join(derivedDataPath, "Build", "Products", `${configuration}-iphonesimulator`));
5534
+ const archiveName = `${path.basename(appDir, ".app")}-simulator.tar.gz`;
5535
+ const archivePath = path.join(tempDir, archiveName);
5536
+ yield* runStep(Command.make("tar", "-czf", archivePath, "-C", path.dirname(appDir), path.basename(appDir)).pipe(Command.env(commandEnv)), "tar simulator .app");
5537
+ const { sha256, byteSize } = yield* sha256File(archivePath);
5538
+ return {
5539
+ artifactPath: archivePath,
5540
+ byteSize,
5541
+ sha256
5542
+ };
5788
5543
  });
5789
- const wrapCertificateCreate = (run) => Effect.tryPromise({
5790
- try: run,
5791
- catch: (cause) => {
5792
- const message = messageOf(cause);
5793
- if (CERT_LIMIT_PATTERN.test(message)) return new CertificateLimitError({ message });
5794
- return new AppleIdGenerateFailedError({
5795
- step: "apple-create-certificate",
5796
- message
5544
+ const ensurePerTargetCredentials = (params) => Effect.forEach(params.signedTargets, (target) => ensureIosCredentials(params.api, {
5545
+ projectId: params.projectId,
5546
+ bundleIdentifier: target.bundleId,
5547
+ distribution: params.distribution
5548
+ }, { freezeCredentials: params.freezeCredentials }), { concurrency: 1 });
5549
+ const fetchAllCredentials = (params) => params.input.credentialsSource === "local" ? loadLocalIosCredentials({
5550
+ projectRoot: params.input.projectRoot,
5551
+ mainBundleIdentifier: params.mainBundleIdentifier
5552
+ }) : downloadIosCredentials(params.api, {
5553
+ projectId: params.input.projectId,
5554
+ mainBundleIdentifier: params.mainBundleIdentifier,
5555
+ bundleIdentifiers: params.allBundleIdentifiers,
5556
+ distribution: params.input.iosProfile.distribution,
5557
+ tempDir: params.input.tempDir
5558
+ });
5559
+ const installPerTarget = (signedTargets, credentials, credentialsSource) => Effect.gen(function* () {
5560
+ const profileByBundle = new Map(credentials.profiles.map((profile) => [profile.bundleIdentifier, profile]));
5561
+ const missing = signedTargets.filter((target) => !profileByBundle.has(target.bundleId));
5562
+ if (missing.length > 0) {
5563
+ const list = missing.map((target) => `"${target.targetName}" (${target.bundleId})`).join(", ");
5564
+ 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.";
5565
+ return yield* new MissingCredentialsError({
5566
+ message: `Missing provisioning profile for signed target(s): ${list}.`,
5567
+ hint
5797
5568
  });
5798
5569
  }
5570
+ return yield* Effect.forEach(signedTargets, (target) => installProfileForTarget(target, profileByBundle));
5799
5571
  });
5800
- const generateAndUploadDistributionCertificateViaAppleId = (api, input) => Effect.gen(function* () {
5801
- const ctx = input.context;
5802
- const certificateType = input.certificateType === "IOS_DEVELOPMENT" ? AppleUtils.CertificateType.IOS_DEVELOPMENT : AppleUtils.CertificateType.IOS_DISTRIBUTION;
5803
- const result = yield* wrapCertificateCreate(async () => AppleUtils.createCertificateAndP12Async(ctx, { certificateType }));
5804
- const metadata = yield* extractMetadataFromP12({
5805
- p12Base64: result.certificateP12,
5806
- password: result.password
5807
- }).pipe(Effect.mapError((cause) => new AppleIdGenerateFailedError({
5808
- step: "parse-p12",
5809
- message: cause.message
5572
+ const installProfileForTarget = (target, profileByBundle) => {
5573
+ const profile = profileByBundle.get(target.bundleId);
5574
+ if (!profile) return Effect.fail(new ProvisioningError({ message: `Internal: no profile for ${target.bundleId} after pre-check.` }));
5575
+ return installProvisioningProfile({ profilePath: profile.profilePath }).pipe(Effect.map((installed) => ({
5576
+ target,
5577
+ profile,
5578
+ installed
5810
5579
  })));
5811
- const created = yield* api.appleDistributionCertificates.upload({ payload: {
5812
- p12Base64: result.certificateP12,
5813
- p12Password: result.password,
5814
- serialNumber: metadata.serialNumber,
5815
- appleTeamIdentifier: metadata.appleTeamId,
5816
- ...metadata.appleTeamName === null ? {} : { appleTeamName: metadata.appleTeamName },
5817
- ...metadata.developerIdIdentifier === null ? {} : { developerIdIdentifier: metadata.developerIdIdentifier },
5818
- validFrom: metadata.validFrom,
5819
- validUntil: metadata.validUntil
5820
- } });
5580
+ };
5581
+ const pickMainTarget = (signedTargets) => signedTargets.find((target) => target.productType === "com.apple.product-type.application") ?? signedTargets[0];
5582
+ const runIosDeviceBuild = (input) => Effect.gen(function* () {
5583
+ const { api, tempDir, projectRoot, iosProfile, envVars } = input;
5584
+ const runtime = yield* CliRuntime;
5585
+ const fs = yield* FileSystem.FileSystem;
5586
+ const iosDir = path.join(projectRoot, "ios");
5587
+ const { distribution } = iosProfile;
5588
+ const commandEnv = yield* runtime.commandEnvironment(envVars);
5589
+ yield* prebuildAndPods({
5590
+ projectRoot,
5591
+ iosDir,
5592
+ commandEnv
5593
+ });
5594
+ const workspaceFilename = yield* findXcworkspace(iosDir);
5595
+ const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
5596
+ const configuration = iosProfile.buildConfiguration ?? "Release";
5597
+ const signedTargets = yield* discoverSignedTargets({
5598
+ iosDir,
5599
+ configurationName: configuration
5600
+ });
5601
+ const mainTarget = pickMainTarget(signedTargets);
5602
+ if (!mainTarget) return yield* new BuildFailedError({
5603
+ step: "discover signed targets",
5604
+ exitCode: 1,
5605
+ message: `No signed iOS targets found in the Xcode project for configuration "${configuration}".`
5606
+ });
5607
+ if (input.credentialsSource === "remote") yield* ensurePerTargetCredentials({
5608
+ api,
5609
+ projectId: input.projectId,
5610
+ distribution: iosProfile.distribution,
5611
+ signedTargets,
5612
+ freezeCredentials: input.freezeCredentials ?? false
5613
+ });
5614
+ const credentials = yield* fetchAllCredentials({
5615
+ api,
5616
+ input,
5617
+ mainBundleIdentifier: mainTarget.bundleId,
5618
+ allBundleIdentifiers: signedTargets.map((target) => target.bundleId)
5619
+ });
5620
+ const keychain = yield* acquireKeychain({
5621
+ tempDir,
5622
+ p12Path: credentials.p12Path,
5623
+ p12Password: credentials.p12Password
5624
+ });
5625
+ const installedTargets = yield* installPerTarget(signedTargets, credentials, input.credentialsSource);
5626
+ yield* applyTargetSigning({
5627
+ iosDir,
5628
+ entries: installedTargets.map(({ target, installed }) => ({
5629
+ targetName: target.targetName,
5630
+ buildConfigurationUuids: target.buildConfigurationUuids,
5631
+ settings: {
5632
+ teamId: installed.teamId,
5633
+ signingIdentity: keychain.signingIdentity,
5634
+ profileSpecifier: installed.name
5635
+ }
5636
+ }))
5637
+ });
5638
+ const archivePath = path.join(tempDir, "build.xcarchive");
5639
+ const archiveCmd = Command.make("xcodebuild", "-workspace", workspaceFilename, "-scheme", scheme, "-configuration", configuration, "-archivePath", archivePath, "-allowProvisioningUpdates", "archive").pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
5640
+ const formatter = input.rawOutput ? void 0 : createXcodebuildFormatter(projectRoot);
5641
+ yield* formatter ? runStepFormatted(archiveCmd, "xcodebuild archive", formatter) : runStep(archiveCmd, "xcodebuild archive");
5642
+ const exportOptionsPath = path.join(tempDir, "ExportOptions.plist");
5643
+ const mainInstall = installedTargets.find((entry) => entry.target.targetName === mainTarget.targetName);
5644
+ if (!mainInstall) return yield* new BuildFailedError({
5645
+ step: "resolve main target signing",
5646
+ exitCode: 1,
5647
+ message: `Internal: main target "${mainTarget.targetName}" was not in the installed list.`
5648
+ });
5649
+ const { teamId } = mainInstall.installed;
5650
+ yield* fs.writeFileString(exportOptionsPath, renderExportOptionsPlist({
5651
+ method: distribution,
5652
+ teamId,
5653
+ provisioningProfiles: installedTargets.map(({ target, installed }) => ({
5654
+ bundleId: target.bundleId,
5655
+ profileName: installed.name
5656
+ }))
5657
+ }));
5658
+ const exportPath = path.join(tempDir, "export");
5659
+ const exportCmd = Command.make("xcodebuild", "-exportArchive", "-archivePath", archivePath, "-exportPath", exportPath, "-exportOptionsPlist", exportOptionsPath, "-allowProvisioningUpdates").pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
5660
+ yield* formatter ? runStepFormatted(exportCmd, "xcodebuild exportArchive", formatter) : runStep(exportCmd, "xcodebuild exportArchive");
5661
+ yield* validateIosBuild({
5662
+ archivePath,
5663
+ expectedTeamId: teamId,
5664
+ expectedTargets: installedTargets.map(({ target, installed }) => ({
5665
+ bundleId: target.bundleId,
5666
+ profileUuid: installed.uuid
5667
+ }))
5668
+ });
5669
+ const artifactPath = yield* findIosArtifact({ exportPath });
5670
+ const { sha256, byteSize } = yield* sha256File(artifactPath);
5821
5671
  return {
5822
- id: created.id,
5823
- serialNumber: metadata.serialNumber,
5824
- appleTeamId: created.appleTeamId,
5825
- appleTeamIdentifier: metadata.appleTeamId,
5826
- developerPortalIdentifier: result.certificate.id
5672
+ artifactPath,
5673
+ byteSize,
5674
+ sha256
5827
5675
  };
5828
5676
  });
5829
- const listDistributionCertsViaAppleId = (ctx, certificateType = "IOS_DISTRIBUTION") => Effect.gen(function* () {
5830
- const filter = certificateType === "IOS_DEVELOPMENT" ? AppleUtils.CertificateType.IOS_DEVELOPMENT : AppleUtils.CertificateType.IOS_DISTRIBUTION;
5831
- return (yield* wrap("apple-list-certificates", async () => AppleUtils.Certificate.getAsync(ctx, { query: { filter: { certificateType: filter } } }))).map((entry) => ({
5832
- developerPortalIdentifier: entry.id,
5833
- serialNumber: entry.attributes.serialNumber,
5834
- displayName: entry.attributes.displayName,
5835
- expirationDate: entry.attributes.expirationDate
5836
- }));
5837
- });
5838
- const revokeDistributionCertViaAppleId = (ctx, developerPortalIdentifier) => wrap("apple-revoke-certificate", async () => AppleUtils.Certificate.deleteAsync(ctx, { id: developerPortalIdentifier }));
5839
- const findOrCreateBundleId = (ctx, bundleIdentifier) => Effect.gen(function* () {
5840
- const existing = yield* wrap("apple-find-bundle-id", async () => AppleUtils.BundleId.findAsync(ctx, { identifier: bundleIdentifier }));
5841
- if (existing !== null) return existing.id;
5842
- return (yield* wrap("apple-create-bundle-id", async () => AppleUtils.BundleId.createAsync(ctx, {
5843
- identifier: bundleIdentifier,
5844
- name: bundleIdentifier,
5845
- platform: AppleUtils.BundleIdPlatform.IOS
5846
- }))).id;
5847
- });
5848
- const findAscCertificateId = (ctx, serialNumber, certificateType) => Effect.gen(function* () {
5849
- const certs = yield* wrap("apple-list-certificates", async () => AppleUtils.Certificate.getAsync(ctx, { query: { filter: { certificateType } } }));
5850
- const upper = serialNumber.toUpperCase();
5851
- const match = certs.find((entry) => entry.attributes.serialNumber.toUpperCase() === upper);
5852
- if (match === void 0) return yield* Effect.fail(new AppleIdGenerateFailedError({
5853
- step: "match-apple-certificate",
5854
- message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
5855
- }));
5856
- return match.id;
5857
- });
5858
- const collectIosDeviceIds = (ctx, deviceIds) => Effect.gen(function* () {
5859
- const devices = yield* wrap("apple-list-devices", async () => AppleUtils.Device.getAllIOSProfileDevicesAsync(ctx));
5860
- if (deviceIds === void 0) return devices.map((device) => device.id);
5861
- const allowed = new Set(deviceIds);
5862
- return devices.filter((device) => allowed.has(device.id)).map((device) => device.id);
5677
+ const runIosBuild = (input) => input.iosProfile.simulator === true ? runIosSimulatorBuild(input) : runIosDeviceBuild(input);
5678
+
5679
+ //#endregion
5680
+ //#region src/commands/build/reserve-and-upload.ts
5681
+ const buildReserveCommon = (input) => ({
5682
+ projectId: input.projectId,
5683
+ profile: input.profileName,
5684
+ runtimeVersion: input.runtimeVersion,
5685
+ bundleId: input.bundleId,
5686
+ sha256: input.sha256,
5687
+ byteSize: input.byteSize,
5688
+ ...input.appVersion === void 0 ? {} : { appVersion: input.appVersion },
5689
+ ...input.buildNumber === void 0 ? {} : { buildNumber: input.buildNumber },
5690
+ ...input.gitContext.ref === void 0 ? {} : { gitRef: input.gitContext.ref },
5691
+ ...input.gitContext.commit === void 0 ? {} : { gitCommit: input.gitContext.commit },
5692
+ ...input.message === void 0 ? {} : { message: input.message }
5863
5693
  });
5864
- const generateAndUploadProvisioningProfileViaAppleId = (api, input) => Effect.gen(function* () {
5865
- const ctx = input.context;
5866
- 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 AppleIdGenerateFailedError({
5867
- step: "load-distribution-certificate",
5868
- message: `Distribution certificate ${input.distributionCertificateId} not found`
5869
- })) : Effect.succeed(match)));
5870
- const certificateType = DISTRIBUTION_TO_CERTIFICATE_TYPE[input.distributionType];
5871
- const [certAscId, bundleIdAscId] = yield* Effect.all([findAscCertificateId(ctx, cert.serialNumber, certificateType), findOrCreateBundleId(ctx, input.bundleIdentifier)], { concurrency: 2 });
5872
- const useDevices = input.distributionType === "AD_HOC" || input.distributionType === "DEVELOPMENT";
5873
- const deviceIds = useDevices ? yield* collectIosDeviceIds(ctx, input.deviceIds) : [];
5874
- if (useDevices && deviceIds.length === 0) return yield* Effect.fail(new AppleIdGenerateFailedError({
5875
- step: "collect-devices",
5876
- message: "No registered devices to attach to the provisioning profile"
5877
- }));
5878
- const profileName = `${input.bundleIdentifier} ${input.distributionType} ${Date.now()}`;
5879
- const { profileContent } = (yield* wrap("apple-create-profile", async () => AppleUtils.Profile.createAsync(ctx, {
5880
- bundleId: bundleIdAscId,
5881
- certificates: [certAscId],
5882
- devices: deviceIds,
5883
- name: profileName,
5884
- profileType: DISTRIBUTION_TO_PROFILE_TYPE[input.distributionType]
5885
- }))).attributes;
5886
- if (profileContent === null) return yield* Effect.fail(new AppleIdGenerateFailedError({
5887
- step: "extract-profile-content",
5888
- message: "Apple returned a profile with no content (likely expired/invalid)"
5889
- }));
5890
- const profileBytes = fromBase64(profileContent);
5891
- const rosterHash = useDevices ? computeDeviceRosterHashHex(deviceIds) : void 0;
5892
- const created = yield* api.appleProvisioningProfiles.upload({ payload: {
5893
- profileBase64: toBase64(profileBytes),
5894
- appleDistributionCertificateId: input.distributionCertificateId,
5895
- isManaged: true,
5896
- ...rosterHash === void 0 ? {} : { deviceRosterHash: rosterHash }
5694
+ const callReserve = (api, input) => {
5695
+ const common = buildReserveCommon(input);
5696
+ const { target } = input;
5697
+ if (target.platform === "ios") return target.distribution === "simulator" ? api.builds.reserve({ payload: {
5698
+ ...common,
5699
+ platform: "ios",
5700
+ distribution: "simulator",
5701
+ artifactFormat: "tar.gz"
5702
+ } }) : api.builds.reserve({ payload: {
5703
+ ...common,
5704
+ platform: "ios",
5705
+ distribution: target.distribution,
5706
+ artifactFormat: "ipa"
5707
+ } });
5708
+ return target.distribution === "play-store" ? api.builds.reserve({ payload: {
5709
+ ...common,
5710
+ platform: "android",
5711
+ distribution: "play-store",
5712
+ artifactFormat: "aab"
5713
+ } }) : api.builds.reserve({ payload: {
5714
+ ...common,
5715
+ platform: "android",
5716
+ distribution: "direct",
5717
+ artifactFormat: "apk"
5897
5718
  } });
5719
+ };
5720
+ /**
5721
+ * Reserve a build record on the server, upload the artifact to the returned
5722
+ * presigned URL, and finalize the build with its sha256 + byteSize.
5723
+ */
5724
+ const reserveAndUpload = (api, input) => Effect.gen(function* () {
5725
+ const presignedUploadClient = yield* PresignedUploadClient;
5726
+ const reserveResult = yield* callReserve(api, input).pipe(Effect.mapError((cause) => new ReserveError({ message: `Failed to reserve build: ${formatCause(cause)}` })));
5727
+ yield* presignedUploadClient.putToPresignedUrl({
5728
+ url: reserveResult.uploadUrl,
5729
+ filePath: input.artifactPath,
5730
+ byteSize: input.byteSize,
5731
+ expiresAt: reserveResult.uploadExpiresAt,
5732
+ headers: reserveResult.uploadHeaders
5733
+ });
5734
+ const completed = yield* api.builds.complete({
5735
+ path: { id: reserveResult.id },
5736
+ payload: {
5737
+ sha256: input.sha256,
5738
+ byteSize: input.byteSize
5739
+ }
5740
+ }).pipe(Effect.mapError((cause) => new CompleteError({ message: `Failed to complete build ${reserveResult.id}: ${formatCause(cause)}` })));
5741
+ if (!completed.artifact) return yield* new CompleteError({ message: `Build ${completed.id} completed but server returned no artifact record.` });
5898
5742
  return {
5899
- id: created.id,
5900
- bundleIdentifier: created.bundleIdentifier,
5901
- distributionType: created.distributionType,
5902
- profileName: created.profileName,
5903
- validUntil: created.validUntil,
5904
- developerPortalIdentifier: created.developerPortalIdentifier
5743
+ id: completed.id,
5744
+ status: "uploaded"
5905
5745
  };
5906
5746
  });
5907
5747
 
5908
5748
  //#endregion
5909
- //#region src/lib/ios-bundle-config-upsert.ts
5749
+ //#region src/lib/auto-increment.ts
5750
+ const bumpBuildNumber = (current) => Effect.gen(function* () {
5751
+ const raw = current ?? "0";
5752
+ const parsed = Number.parseInt(raw, 10);
5753
+ if (Number.isNaN(parsed)) return yield* new BuildProfileError({ message: `Cannot autoIncrement ios.buildNumber: current value "${raw}" is not a base-10 integer.` });
5754
+ return String(parsed + 1);
5755
+ });
5756
+ const bumpVersionCode = (current) => Effect.gen(function* () {
5757
+ const value = current ?? 0;
5758
+ 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.` });
5759
+ return value + 1;
5760
+ });
5761
+ const SEMVER_PATCH = /^(\d+)\.(\d+)\.(\d+)(.*)$/u;
5762
+ const bumpVersion = (current) => Effect.gen(function* () {
5763
+ if (current === void 0) return yield* new BuildProfileError({ message: "Cannot autoIncrement version: no `version` field set in Expo config." });
5764
+ const match = SEMVER_PATCH.exec(current);
5765
+ if (!match) return yield* new BuildProfileError({ message: `Cannot autoIncrement version: "${current}" is not a semver string like "1.2.3".` });
5766
+ const [, major, minor, patch, suffix] = match;
5767
+ const nextPatch = Number.parseInt(patch ?? "0", 10) + 1;
5768
+ return `${major ?? "0"}.${minor ?? "0"}.${String(nextPatch)}${suffix ?? ""}`;
5769
+ });
5770
+ const computeIosBumps = (config, mode) => Effect.gen(function* () {
5771
+ if (mode === "buildNumber") return { nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber) };
5772
+ return {
5773
+ nextVersion: yield* bumpVersion(config.version),
5774
+ nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber)
5775
+ };
5776
+ });
5777
+ const computeAndroidBumps = (config, mode) => Effect.gen(function* () {
5778
+ if (mode === "versionCode") return { nextVersionCode: yield* bumpVersionCode(config.android?.versionCode) };
5779
+ return {
5780
+ nextVersion: yield* bumpVersion(config.version),
5781
+ nextVersionCode: yield* bumpVersionCode(config.android?.versionCode)
5782
+ };
5783
+ });
5784
+ const buildPatch = (platform, bumps) => {
5785
+ const patch = {};
5786
+ if (bumps.nextVersion !== void 0) patch["version"] = bumps.nextVersion;
5787
+ if (platform === "ios" && bumps.nextBuildNumber !== void 0) patch["ios"] = { buildNumber: bumps.nextBuildNumber };
5788
+ if (platform === "android" && bumps.nextVersionCode !== void 0) patch["android"] = { versionCode: bumps.nextVersionCode };
5789
+ return patch;
5790
+ };
5791
+ const describeBumps = (platform, bumps) => {
5792
+ const parts = [];
5793
+ if (bumps.nextVersion !== void 0) parts.push(`version=${bumps.nextVersion}`);
5794
+ if (platform === "ios" && bumps.nextBuildNumber !== void 0) parts.push(`ios.buildNumber=${bumps.nextBuildNumber}`);
5795
+ if (platform === "android" && bumps.nextVersionCode !== void 0) parts.push(`android.versionCode=${String(bumps.nextVersionCode)}`);
5796
+ return parts.join(", ");
5797
+ };
5798
+ const computeBumps = (input) => {
5799
+ if (input.platform === "ios") return input.iosMode === void 0 ? Effect.succeed({}) : computeIosBumps(input.config, input.iosMode);
5800
+ return input.androidMode === void 0 ? Effect.succeed({}) : computeAndroidBumps(input.config, input.androidMode);
5801
+ };
5802
+ const hasAnyBump = (bumps) => bumps.nextVersion !== void 0 || bumps.nextBuildNumber !== void 0 || bumps.nextVersionCode !== void 0;
5910
5803
  /**
5911
- * Idempotent bind for an iOS bundle configuration. When the row already exists
5912
- * (e.g. orphaned after a cert was deleted, since the FK is `ON DELETE SET NULL`),
5913
- * rebind cert + profile in place instead of failing on the unique constraint.
5914
- * Mirrors EAS's setup behavior where the setup step is rerunnable.
5804
+ * Bump `version` / `ios.buildNumber` / `android.versionCode` per the resolved
5805
+ * autoIncrement mode, persist via `@expo/config.modifyConfigAsync`, and log a
5806
+ * Human-readable summary. No-op when the mode is undefined. Returns the new
5807
+ * Bumped values so callers can refresh their in-memory ExpoConfig.
5915
5808
  */
5916
- const upsertIosBundleConfiguration = (api, input) => Effect.gen(function* () {
5917
- const existing = (yield* api.iosBundleConfigurations.list({ path: { projectId: input.projectId } })).items.find((item) => item.bundleIdentifier === input.bundleIdentifier && item.distributionType === input.distributionType);
5918
- if (existing === void 0) {
5919
- yield* api.iosBundleConfigurations.create({
5920
- path: { projectId: input.projectId },
5921
- payload: {
5922
- bundleIdentifier: input.bundleIdentifier,
5923
- distributionType: input.distributionType,
5924
- appleTeamId: input.appleTeamId,
5925
- appleDistributionCertificateId: input.appleDistributionCertificateId,
5926
- appleProvisioningProfileId: input.appleProvisioningProfileId,
5927
- ...input.ascApiKeyId === void 0 ? {} : { ascApiKeyId: input.ascApiKeyId }
5928
- }
5929
- });
5930
- yield* Console.log("iOS bundle configuration saved.");
5931
- return { action: "created" };
5809
+ const applyAutoIncrement = (input) => Effect.gen(function* () {
5810
+ const bumps = yield* computeBumps(input);
5811
+ if (!hasAnyBump(bumps)) return bumps;
5812
+ const patch = buildPatch(input.platform, bumps);
5813
+ const result = yield* writeExpoConfigPatch(input.projectRoot, patch).pipe(Effect.mapError((cause) => new BuildProfileError({ message: `Failed to persist autoIncrement: ${cause.message}` })));
5814
+ if (result.type === "warn" && result.configPath === null) {
5815
+ yield* Console.log(`autoIncrement: dynamic Expo config detected, cannot write back. Update manually: ${describeBumps(input.platform, bumps)}`);
5816
+ return bumps;
5817
+ }
5818
+ yield* Console.log(`autoIncrement: bumped ${describeBumps(input.platform, bumps)}`);
5819
+ return bumps;
5820
+ });
5821
+
5822
+ //#endregion
5823
+ //#region src/lib/eas-config.ts
5824
+ const MAX_EXTENDS_DEPTH = 10;
5825
+ const asStringValue = (value) => typeof value === "string" ? value : void 0;
5826
+ const asBooleanValue = (value) => typeof value === "boolean" ? value : void 0;
5827
+ const asEnv = (value) => {
5828
+ const record = asRecord(value);
5829
+ if (!record) return;
5830
+ const env = {};
5831
+ for (const [key, raw] of Object.entries(record)) if (typeof raw === "string") env[key] = raw;
5832
+ return Object.keys(env).length === 0 ? void 0 : env;
5833
+ };
5834
+ const asIosDistribution = (raw) => {
5835
+ const value = asStringValue(raw);
5836
+ if (value === "app-store" || value === "ad-hoc" || value === "development" || value === "enterprise") return value;
5837
+ };
5838
+ const asEnterpriseProvisioning = (raw) => {
5839
+ const value = asStringValue(raw);
5840
+ return value === "adhoc" || value === "universal" ? value : void 0;
5841
+ };
5842
+ const asAndroidBuildType = (raw) => {
5843
+ const value = asStringValue(raw);
5844
+ return value === "debug" || value === "release" ? value : void 0;
5845
+ };
5846
+ const asAndroidFormat = (raw) => {
5847
+ const value = asStringValue(raw);
5848
+ return value === "apk" || value === "aab" ? value : void 0;
5849
+ };
5850
+ const asAndroidDistribution = (raw) => {
5851
+ const value = asStringValue(raw);
5852
+ return value === "play-store" || value === "direct" ? value : void 0;
5853
+ };
5854
+ const asIosAutoIncrement = (raw) => {
5855
+ if (typeof raw === "boolean") return raw;
5856
+ const value = asStringValue(raw);
5857
+ return value === "buildNumber" || value === "version" ? value : void 0;
5858
+ };
5859
+ const asAndroidAutoIncrement = (raw) => {
5860
+ if (typeof raw === "boolean") return raw;
5861
+ const value = asStringValue(raw);
5862
+ return value === "versionCode" || value === "version" ? value : void 0;
5863
+ };
5864
+ const asAutoIncrement = (raw) => {
5865
+ if (typeof raw === "boolean") return raw;
5866
+ const value = asStringValue(raw);
5867
+ return value === "buildNumber" || value === "versionCode" || value === "version" ? value : void 0;
5868
+ };
5869
+ const asEasDistribution = (raw) => {
5870
+ const value = asStringValue(raw);
5871
+ return value === "internal" || value === "store" ? value : void 0;
5872
+ };
5873
+ const asCredentialsSource = (raw) => {
5874
+ const value = asStringValue(raw);
5875
+ return value === "remote" || value === "local" ? value : void 0;
5876
+ };
5877
+ const parseIosProfile = (raw) => {
5878
+ const record = asRecord(raw);
5879
+ if (!record) return;
5880
+ const distribution = asIosDistribution(record["distribution"]);
5881
+ const buildConfiguration = asStringValue(record["buildConfiguration"]);
5882
+ const scheme = asStringValue(record["scheme"]);
5883
+ const simulator = asBooleanValue(record["simulator"]);
5884
+ const enterpriseProvisioning = asEnterpriseProvisioning(record["enterpriseProvisioning"]);
5885
+ const autoIncrement = asIosAutoIncrement(record["autoIncrement"]);
5886
+ return {
5887
+ ...distribution === void 0 ? {} : { distribution },
5888
+ ...buildConfiguration === void 0 ? {} : { buildConfiguration },
5889
+ ...scheme === void 0 ? {} : { scheme },
5890
+ ...simulator === void 0 ? {} : { simulator },
5891
+ ...enterpriseProvisioning === void 0 ? {} : { enterpriseProvisioning },
5892
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
5893
+ };
5894
+ };
5895
+ const parseAndroidProfile = (raw) => {
5896
+ const record = asRecord(raw);
5897
+ if (!record) return;
5898
+ const buildType = asAndroidBuildType(record["buildType"]);
5899
+ const flavor = asStringValue(record["flavor"]);
5900
+ const gradleCommand = asStringValue(record["gradleCommand"]);
5901
+ const format = asAndroidFormat(record["format"]);
5902
+ const distribution = asAndroidDistribution(record["distribution"]);
5903
+ const autoIncrement = asAndroidAutoIncrement(record["autoIncrement"]);
5904
+ return {
5905
+ ...buildType === void 0 ? {} : { buildType },
5906
+ ...flavor === void 0 ? {} : { flavor },
5907
+ ...gradleCommand === void 0 ? {} : { gradleCommand },
5908
+ ...format === void 0 ? {} : { format },
5909
+ ...distribution === void 0 ? {} : { distribution },
5910
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
5911
+ };
5912
+ };
5913
+ const parseBuildProfile = (raw) => {
5914
+ const record = asRecord(raw);
5915
+ if (!record) return;
5916
+ const extendsName = asStringValue(record["extends"]);
5917
+ const developmentClient = asBooleanValue(record["developmentClient"]);
5918
+ const distribution = asEasDistribution(record["distribution"]);
5919
+ const channel = asStringValue(record["channel"]);
5920
+ const environment = asStringValue(record["environment"]);
5921
+ const env = asEnv(record["env"]);
5922
+ const ios = parseIosProfile(record["ios"]);
5923
+ const android = parseAndroidProfile(record["android"]);
5924
+ const credentialsSource = asCredentialsSource(record["credentialsSource"]);
5925
+ const autoIncrement = asAutoIncrement(record["autoIncrement"]);
5926
+ return {
5927
+ ...extendsName === void 0 ? {} : { extends: extendsName },
5928
+ ...developmentClient === void 0 ? {} : { developmentClient },
5929
+ ...distribution === void 0 ? {} : { distribution },
5930
+ ...channel === void 0 ? {} : { channel },
5931
+ ...environment === void 0 ? {} : { environment },
5932
+ ...env === void 0 ? {} : { env },
5933
+ ...ios === void 0 ? {} : { ios },
5934
+ ...android === void 0 ? {} : { android },
5935
+ ...credentialsSource === void 0 ? {} : { credentialsSource },
5936
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
5937
+ };
5938
+ };
5939
+ const parseEasConfig = (text) => Effect.gen(function* () {
5940
+ const root = asRecord(yield* Effect.try({
5941
+ try: () => JSON.parse(text),
5942
+ catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
5943
+ }));
5944
+ if (!root) return yield* new BuildProfileError({ message: "eas.json must be a JSON object at the top level." });
5945
+ const buildRecord = asRecord(root["build"]);
5946
+ if (!buildRecord) return asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {};
5947
+ const profiles = {};
5948
+ for (const [name, value] of Object.entries(buildRecord)) {
5949
+ const profile = parseBuildProfile(value);
5950
+ if (profile) profiles[name] = profile;
5932
5951
  }
5933
- if (existing.appleTeamId !== input.appleTeamId) return yield* new MissingCredentialsError({
5934
- message: `Bundle "${input.bundleIdentifier}" (${input.distributionType}) is already bound to a different Apple team than the new credentials.`,
5935
- hint: "Delete the existing bundle configuration via the dashboard before retrying with a different team."
5936
- });
5937
- yield* api.iosBundleConfigurations.update({
5938
- path: { id: existing.id },
5939
- payload: {
5940
- appleDistributionCertificateId: input.appleDistributionCertificateId,
5941
- appleProvisioningProfileId: input.appleProvisioningProfileId,
5942
- ...input.ascApiKeyId === void 0 ? {} : { ascApiKeyId: input.ascApiKeyId }
5943
- }
5944
- });
5945
- yield* Console.log("iOS bundle configuration rebound.");
5946
5952
  return {
5947
- action: "updated",
5948
- id: existing.id
5953
+ ...asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {},
5954
+ build: profiles
5949
5955
  };
5950
5956
  });
5951
-
5952
- //#endregion
5953
- //#region src/application/credentials-interactive-apple-id.ts
5954
- const chooseIosSetupPath = (api) => Effect.gen(function* () {
5955
- if (!(yield* api.ascApiKeys.list()).items.some((key) => key.appleTeamId !== null)) return "apple-id";
5956
- return yield* promptSelect("How would you like to provide your iOS credentials?", [{
5957
- value: "apple-id",
5958
- label: "Login with Apple ID (recommended for interactive use)"
5959
- }, {
5960
- value: "asc-key",
5961
- label: "Use an App Store Connect API key"
5962
- }]);
5963
- });
5964
- const interactiveAppleIdCertLimitRecover = (ctx) => Effect.gen(function* () {
5965
- yield* Console.log("");
5966
- yield* Console.log("Apple reports the certificate limit was hit (max 3 distribution certs per team).");
5967
- const certs = yield* listDistributionCertsViaAppleId(ctx, "IOS_DISTRIBUTION");
5968
- if (certs.length === 0) return yield* new AppleIdGenerateFailedError({
5969
- step: "limit-recover",
5970
- message: "Apple says the certificate limit is hit but no existing certificates were returned."
5971
- });
5972
- const toRevoke = yield* promptMultiSelect("Select one or more certificates to revoke before retrying", certs.map((entry) => ({
5973
- value: entry.developerPortalIdentifier,
5974
- label: `${entry.serialNumber.slice(0, 12)}… (${entry.displayName}, exp ${entry.expirationDate.slice(0, 10)})`
5975
- })), { required: true });
5976
- yield* Effect.forEach(toRevoke, (id) => revokeDistributionCertViaAppleId(ctx, id), { concurrency: "inherit" });
5977
- yield* Console.log(`Revoked ${toRevoke.length} certificate(s); retrying generation...`);
5957
+ const parseCli = (raw) => {
5958
+ const record = asRecord(raw);
5959
+ if (!record) return {};
5960
+ const version = asStringValue(record["version"]);
5961
+ return version === void 0 ? {} : { version };
5962
+ };
5963
+ const easJsonPath = (projectRoot) => Effect.gen(function* () {
5964
+ return (yield* Path.Path).join(projectRoot, "eas.json");
5978
5965
  });
5979
- const generateDistributionCertViaAppleIdInteractive = (api, ctx) => Effect.gen(function* () {
5980
- yield* Console.log("Generating distribution certificate via Apple ID...");
5981
- const generate = generateAndUploadDistributionCertificateViaAppleId(api, { context: ctx });
5982
- return yield* generate.pipe(Effect.catchTag("CertificateLimitError", () => interactiveAppleIdCertLimitRecover(ctx).pipe(Effect.flatMap(() => generate))));
5966
+ const readEasJson = (projectRoot) => Effect.gen(function* () {
5967
+ const fs = yield* FileSystem.FileSystem;
5968
+ const filePath = yield* easJsonPath(projectRoot);
5969
+ 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}` })))));
5983
5970
  });
5984
- const GENERATE_NEW = "__generate__";
5985
- const chooseDistributionCertViaAppleId = (api, ctx, appleTeamIdentifier) => Effect.gen(function* () {
5986
- const [teams, all] = yield* Effect.all([api.appleTeams.list(), api.appleDistributionCertificates.list()], { concurrency: 2 });
5987
- const team = teams.items.find((entry) => entry.appleTeamId === appleTeamIdentifier);
5988
- const items = team === void 0 ? [] : all.items.filter((cert) => cert.appleTeamId === team.id);
5989
- if (items.length === 0) {
5990
- const created = yield* generateDistributionCertViaAppleIdInteractive(api, ctx);
5991
- return {
5992
- id: created.id,
5993
- appleTeamId: created.appleTeamId
5994
- };
5995
- }
5996
- const choice = yield* promptSelect("Select a distribution certificate (or 'generate' for a fresh one)", [{
5997
- value: GENERATE_NEW,
5998
- label: "Generate a new distribution certificate"
5999
- }, ...items.map((cert) => ({
6000
- value: cert.id,
6001
- label: `${cert.serialNumber.slice(0, 12)}… (team ${appleTeamIdentifier})`
6002
- }))]);
6003
- if (choice === GENERATE_NEW) {
6004
- const created = yield* generateDistributionCertViaAppleIdInteractive(api, ctx);
6005
- return {
6006
- id: created.id,
6007
- appleTeamId: created.appleTeamId
6008
- };
6009
- }
6010
- const cert = items.find((entry) => entry.id === choice);
6011
- if (cert === void 0) return yield* new AppleIdGenerateFailedError({
6012
- step: "pick-certificate",
6013
- message: `Selected certificate ${choice} not found after listing`
6014
- });
5971
+ const mergeIos = (base, overlay) => {
5972
+ if (!base) return overlay;
5973
+ if (!overlay) return base;
6015
5974
  return {
6016
- id: cert.id,
6017
- appleTeamId: cert.appleTeamId
5975
+ ...base,
5976
+ ...overlay
6018
5977
  };
5978
+ };
5979
+ const mergeAndroid = (base, overlay) => {
5980
+ if (!base) return overlay;
5981
+ if (!overlay) return base;
5982
+ return {
5983
+ ...base,
5984
+ ...overlay
5985
+ };
5986
+ };
5987
+ const mergeEnv = (base, overlay) => {
5988
+ if (!base) return overlay;
5989
+ if (!overlay) return base;
5990
+ return {
5991
+ ...base,
5992
+ ...overlay
5993
+ };
5994
+ };
5995
+ const mergeProfile = (base, overlay) => {
5996
+ const ios = mergeIos(base.ios, overlay.ios);
5997
+ const android = mergeAndroid(base.android, overlay.android);
5998
+ const env = mergeEnv(base.env, overlay.env);
5999
+ const developmentClient = overlay.developmentClient ?? base.developmentClient;
6000
+ const distribution = overlay.distribution ?? base.distribution;
6001
+ const channel = overlay.channel ?? base.channel;
6002
+ const environment = overlay.environment ?? base.environment;
6003
+ const credentialsSource = overlay.credentialsSource ?? base.credentialsSource;
6004
+ const autoIncrement = overlay.autoIncrement ?? base.autoIncrement;
6005
+ return {
6006
+ ...overlay.extends === void 0 ? {} : { extends: overlay.extends },
6007
+ ...developmentClient === void 0 ? {} : { developmentClient },
6008
+ ...distribution === void 0 ? {} : { distribution },
6009
+ ...channel === void 0 ? {} : { channel },
6010
+ ...environment === void 0 ? {} : { environment },
6011
+ ...env === void 0 ? {} : { env },
6012
+ ...ios === void 0 ? {} : { ios },
6013
+ ...android === void 0 ? {} : { android },
6014
+ ...credentialsSource === void 0 ? {} : { credentialsSource },
6015
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
6016
+ };
6017
+ };
6018
+ const collectExtendsChain = (profiles, profileName) => Effect.gen(function* () {
6019
+ const chain = [];
6020
+ const visited = /* @__PURE__ */ new Set();
6021
+ let current = profileName;
6022
+ let depth = 0;
6023
+ while (current !== void 0) {
6024
+ if (visited.has(current)) return yield* new BuildProfileError({ message: `Cycle detected in eas.json build.${profileName} extends chain at "${current}".` });
6025
+ visited.add(current);
6026
+ const profile = profiles[current];
6027
+ if (!profile) return yield* new BuildProfileError({ message: current === profileName ? `Build profile "${profileName}" not found in eas.json.` : `Build profile "${profileName}" extends missing profile "${current}".` });
6028
+ chain.unshift(profile);
6029
+ current = profile.extends;
6030
+ depth += 1;
6031
+ if (depth > MAX_EXTENDS_DEPTH) return yield* new BuildProfileError({ message: `Too many "extends" levels (max ${String(MAX_EXTENDS_DEPTH)}) in eas.json build.${profileName}.` });
6032
+ }
6033
+ return chain;
6019
6034
  });
6020
- const setupIosViaAppleId = (api, input) => Effect.gen(function* () {
6021
- const auth = yield* AppleAuth;
6022
- const session = yield* auth.ensureLoggedIn();
6023
- const ctx = auth.buildRequestContext(session);
6024
- yield* Console.log(`Logged in as ${session.username}. Team: ${session.teamName ?? session.teamId} (${session.teamId}).`);
6025
- const cert = yield* chooseDistributionCertViaAppleId(api, ctx, session.teamId);
6026
- const distributionType = IOS_DISTRIBUTION_TO_TYPE[input.distribution];
6027
- yield* Console.log("Generating provisioning profile via Apple ID...");
6028
- const profile = yield* generateAndUploadProvisioningProfileViaAppleId(api, {
6029
- context: ctx,
6030
- distributionCertificateId: cert.id,
6031
- bundleIdentifier: input.bundleIdentifier,
6032
- distributionType
6033
- });
6034
- yield* upsertIosBundleConfiguration(api, {
6035
- projectId: input.projectId,
6036
- bundleIdentifier: input.bundleIdentifier,
6037
- distributionType,
6038
- appleTeamId: cert.appleTeamId,
6039
- appleDistributionCertificateId: cert.id,
6040
- appleProvisioningProfileId: profile.id
6041
- });
6042
- });
6043
- const regenerateProvisioningProfileViaAppleId = (api, input) => Effect.gen(function* () {
6044
- const auth = yield* AppleAuth;
6045
- const session = yield* auth.ensureLoggedIn();
6046
- yield* Console.log("Regenerating provisioning profile via Apple ID...");
6047
- const created = yield* generateAndUploadProvisioningProfileViaAppleId(api, {
6048
- context: auth.buildRequestContext(session),
6049
- distributionCertificateId: input.distributionCertificateId,
6050
- bundleIdentifier: input.bundleIdentifier,
6051
- distributionType: input.distributionType
6052
- });
6053
- yield* api.iosBundleConfigurations.update({
6054
- path: { id: input.bundleConfigurationId },
6055
- payload: { appleProvisioningProfileId: created.id }
6056
- });
6057
- return created;
6058
- });
6059
-
6060
- //#endregion
6061
- //#region src/application/credentials-interactive-ios-asc.ts
6062
- const interactiveCertLimitRecover = (api, ascApiKeyId) => Effect.gen(function* () {
6063
- yield* Console.log("");
6064
- yield* Console.log("Apple reports the certificate limit was hit (max 3 distribution certs per team).");
6065
- const certs = yield* listAppleCertificates(api, {
6066
- ascApiKeyId,
6067
- certificateType: "IOS_DISTRIBUTION"
6068
- });
6069
- if (certs.length === 0) return yield* Effect.fail(new MissingCredentialsError({
6070
- message: "Apple says the certificate limit is hit but no existing certificates were returned.",
6071
- hint: "Try again later or check the Apple Developer portal."
6072
- }));
6073
- const toRevoke = yield* promptMultiSelect("Select one or more certificates to revoke before retrying", certs.map((entry) => ({
6074
- value: entry.id,
6075
- label: `${entry.serialNumber.slice(0, 12)}… (${entry.displayName ?? entry.certificateType}, exp ${entry.expirationDate.slice(0, 10)})`
6076
- })), { required: true });
6077
- yield* Effect.forEach(toRevoke, (id) => revokeAppleCertificate(api, {
6078
- ascApiKeyId,
6079
- developerPortalIdentifier: id
6080
- }), { concurrency: "inherit" });
6081
- yield* Console.log(`Revoked ${toRevoke.length} certificate(s); retrying generation...`);
6082
- });
6083
- const generateDistributionCertInteractive = (api) => Effect.gen(function* () {
6084
- const teamAscKeys = (yield* api.ascApiKeys.list()).items.filter((key) => key.appleTeamId !== null);
6085
- if (teamAscKeys.length === 0) return yield* Effect.fail(new MissingCredentialsError({
6086
- message: "No ASC API key linked to an Apple team in this organization.",
6087
- hint: "Upload an ASC API key with a team assignment via the dashboard, then retry."
6088
- }));
6089
- const ascKeyId = yield* promptSelect("Select an ASC API key to issue the certificate against", teamAscKeys.map((key) => ({
6090
- value: key.id,
6091
- label: `${key.name} (${key.keyId})`
6092
- })));
6093
- yield* Console.log("Generating CSR and requesting certificate from Apple...");
6094
- const generate = generateAndUploadDistributionCertificate(api, { ascApiKeyId: ascKeyId });
6095
- return yield* generate.pipe(Effect.catchTag("CertificateLimitError", () => interactiveCertLimitRecover(api, ascKeyId).pipe(Effect.flatMap(() => generate))));
6096
- });
6097
- const chooseIosCertificateId = (api) => Effect.gen(function* () {
6098
- const certs = yield* api.appleDistributionCertificates.list();
6099
- if (certs.items.length === 0) {
6100
- yield* Console.log("No distribution certificate found in this organization.");
6101
- if ((yield* promptSelect("How would you like to proceed?", [{
6102
- value: "generate",
6103
- label: "Generate a new distribution certificate"
6104
- }, {
6105
- value: "abort",
6106
- label: "Abort — I'll upload one manually"
6107
- }])) === "abort") return yield* Effect.fail(new MissingCredentialsError({
6108
- message: "Build aborted — no distribution certificate available.",
6109
- hint: "Run `better-update credentials generate distribution-certificate --asc-key-id <id>` or upload via the dashboard."
6110
- }));
6111
- return (yield* generateDistributionCertInteractive(api)).id;
6112
- }
6113
- const choice = yield* promptSelect("Select a distribution certificate (or 'generate' for a fresh one)", [{
6114
- value: "__generate__",
6115
- label: "Generate a new distribution certificate"
6116
- }, ...certs.items.map((cert) => ({
6117
- value: cert.id,
6118
- label: `${cert.serialNumber.slice(0, 12)}… (team ${cert.appleTeamId})`
6119
- }))]);
6120
- if (choice === "__generate__") return (yield* generateDistributionCertInteractive(api)).id;
6121
- return choice;
6122
- });
6123
- const pickIosCertificate = (api) => Effect.gen(function* () {
6124
- const chosenId = yield* chooseIosCertificateId(api);
6125
- const cert = (yield* api.appleDistributionCertificates.list()).items.find((entry) => entry.id === chosenId);
6126
- if (cert === void 0) return yield* Effect.fail(new MissingCredentialsError({
6127
- message: "Selected certificate not found after generation.",
6128
- hint: "Retry."
6129
- }));
6035
+ const stripExtends = (profile) => {
6036
+ if (profile.extends === void 0) return profile;
6037
+ const { extends: _omit, ...rest } = profile;
6038
+ return rest;
6039
+ };
6040
+ const resolveEasBuildProfile = (config, profileName) => Effect.gen(function* () {
6041
+ const profiles = config.build;
6042
+ if (!profiles) return yield* new BuildProfileError({ message: "eas.json has no \"build\" section. Add at least one profile." });
6043
+ return stripExtends((yield* collectExtendsChain(profiles, profileName)).reduce((acc, next, index) => index === 0 ? next : mergeProfile(acc, next), {}));
6044
+ });
6045
+
6046
+ //#endregion
6047
+ //#region src/lib/build-profile.ts
6048
+ const asString$1 = (value) => typeof value === "string" ? value : void 0;
6049
+ const deriveIosDistribution = (eas) => {
6050
+ const override = eas.ios?.distribution;
6051
+ if (override) return override;
6052
+ if (eas.developmentClient === true) return "development";
6053
+ if (eas.distribution === "internal") return "ad-hoc";
6054
+ if (eas.distribution === "store") return "app-store";
6055
+ };
6056
+ const deriveAndroidFormat = (eas) => {
6057
+ if (eas.android?.format) return eas.android.format;
6058
+ if (eas.distribution === "store") return "aab";
6059
+ if (eas.distribution === "internal") return "apk";
6060
+ if (eas.developmentClient === true) return "apk";
6061
+ };
6062
+ const deriveAndroidDistribution = (eas, format) => {
6063
+ if (eas.android?.distribution) return eas.android.distribution;
6064
+ if (format === "aab") return "play-store";
6065
+ return "direct";
6066
+ };
6067
+ const hasIosIntent = (eas) => eas.ios !== void 0 || eas.distribution !== void 0 || eas.developmentClient === true;
6068
+ const hasAndroidIntent = (eas) => eas.android !== void 0 || eas.distribution !== void 0 || eas.developmentClient === true;
6069
+ const resolveIosAutoIncrement = (eas) => {
6070
+ const override = eas.ios?.autoIncrement;
6071
+ if (override === false) return;
6072
+ if (override === true) return "buildNumber";
6073
+ if (override === "buildNumber" || override === "version") return override;
6074
+ const top = eas.autoIncrement;
6075
+ if (top === true || top === "buildNumber") return "buildNumber";
6076
+ if (top === "version") return "version";
6077
+ };
6078
+ const resolveAndroidAutoIncrement = (eas) => {
6079
+ const override = eas.android?.autoIncrement;
6080
+ if (override === false) return;
6081
+ if (override === true) return "versionCode";
6082
+ if (override === "versionCode" || override === "version") return override;
6083
+ const top = eas.autoIncrement;
6084
+ if (top === true || top === "versionCode") return "versionCode";
6085
+ if (top === "version") return "version";
6086
+ };
6087
+ const toIosProfile = (eas) => {
6088
+ if (!hasIosIntent(eas)) return;
6089
+ const distribution = deriveIosDistribution(eas);
6090
+ if (!distribution) return;
6091
+ const ios = eas.ios ?? {};
6092
+ const autoIncrement = resolveIosAutoIncrement(eas);
6130
6093
  return {
6131
- certId: chosenId,
6132
- cert
6094
+ distribution,
6095
+ ...ios.buildConfiguration === void 0 ? {} : { buildConfiguration: ios.buildConfiguration },
6096
+ ...ios.scheme === void 0 ? {} : { scheme: ios.scheme },
6097
+ ...ios.simulator === void 0 ? {} : { simulator: ios.simulator },
6098
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
6133
6099
  };
6100
+ };
6101
+ const toAndroidProfile = (eas) => {
6102
+ if (!hasAndroidIntent(eas)) return;
6103
+ const format = deriveAndroidFormat(eas);
6104
+ if (!format) return;
6105
+ const android = eas.android ?? {};
6106
+ const distribution = deriveAndroidDistribution(eas, format);
6107
+ const autoIncrement = resolveAndroidAutoIncrement(eas);
6108
+ return {
6109
+ format,
6110
+ distribution,
6111
+ ...android.buildType === void 0 ? {} : { buildType: android.buildType },
6112
+ ...android.flavor === void 0 ? {} : { flavor: android.flavor },
6113
+ ...android.gradleCommand === void 0 ? {} : { gradleCommand: android.gradleCommand },
6114
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
6115
+ };
6116
+ };
6117
+ const fromEasProfile = (eas, profileName) => {
6118
+ const ios = toIosProfile(eas);
6119
+ const android = toAndroidProfile(eas);
6120
+ return {
6121
+ name: profileName,
6122
+ environment: eas.environment ?? "production",
6123
+ ...eas.channel === void 0 ? {} : { channel: eas.channel },
6124
+ ...eas.env === void 0 ? {} : { env: eas.env },
6125
+ ...ios === void 0 ? {} : { ios },
6126
+ ...android === void 0 ? {} : { android },
6127
+ ...eas.credentialsSource === void 0 ? {} : { credentialsSource: eas.credentialsSource }
6128
+ };
6129
+ };
6130
+ const readBuildProfile = (projectRoot, profileName) => Effect.gen(function* () {
6131
+ return fromEasProfile(yield* resolveEasBuildProfile(yield* readEasJson(projectRoot), profileName), profileName);
6134
6132
  });
6135
- const pickIosAscKey = (api, appleTeamId) => Effect.gen(function* () {
6136
- const teamAscKeys = (yield* api.ascApiKeys.list()).items.filter((key) => key.appleTeamId !== null && key.appleTeamId === appleTeamId);
6137
- if (teamAscKeys.length === 0) return yield* Effect.fail(new MissingCredentialsError({
6138
- message: `No ASC API key linked to Apple team ${appleTeamId}.`,
6139
- hint: "Upload an ASC API key for that team via the dashboard, then retry."
6140
- }));
6141
- return yield* promptSelect("Select an ASC API key", teamAscKeys.map((key) => ({
6142
- value: key.id,
6143
- label: `${key.name} (${key.keyId})`
6144
- })));
6145
- });
6146
- const generateProvisioningProfileForBundle = (api, input, ctx) => Effect.gen(function* () {
6147
- yield* Console.log("Generating provisioning profile via App Store Connect API...");
6148
- return (yield* generateAndUploadProvisioningProfile(api, {
6149
- ascApiKeyId: ctx.ascKeyId,
6150
- distributionCertificateId: ctx.certId,
6151
- bundleIdentifier: input.bundleIdentifier,
6152
- distributionType: ctx.distributionType
6153
- })).id;
6154
- });
6155
- const resolveIosProfileId = (api, input, ctx) => Effect.gen(function* () {
6156
- const matching = (yield* api.appleProvisioningProfiles.list({ urlParams: {} })).items.filter((profile) => profile.bundleIdentifier === input.bundleIdentifier && profile.distributionType === ctx.distributionType && profile.appleTeamId === ctx.cert.appleTeamId);
6157
- if (matching.length === 0) return yield* generateProvisioningProfileForBundle(api, input, ctx);
6158
- if (!(yield* promptConfirm(`Reuse an existing ${input.distribution} profile for ${input.bundleIdentifier}?`, { initialValue: true }))) return yield* generateProvisioningProfileForBundle(api, input, ctx);
6159
- return yield* promptSelect("Select a provisioning profile", matching.map((profile) => ({
6160
- value: profile.id,
6161
- label: profile.profileName ?? profile.developerPortalIdentifier ?? profile.id
6162
- })));
6163
- });
6164
- const setupIosViaAscKey = (api, input) => Effect.gen(function* () {
6165
- const { certId, cert } = yield* pickIosCertificate(api);
6166
- const ascKeyId = yield* pickIosAscKey(api, cert.appleTeamId);
6167
- const distributionType = IOS_DISTRIBUTION_TO_TYPE[input.distribution];
6168
- const profileId = yield* resolveIosProfileId(api, input, {
6169
- certId,
6170
- cert,
6171
- ascKeyId,
6172
- distributionType
6173
- });
6174
- yield* upsertIosBundleConfiguration(api, {
6175
- projectId: input.projectId,
6176
- bundleIdentifier: input.bundleIdentifier,
6177
- distributionType,
6178
- appleTeamId: cert.appleTeamId,
6179
- appleDistributionCertificateId: certId,
6180
- appleProvisioningProfileId: profileId,
6181
- ascApiKeyId: ascKeyId
6182
- });
6133
+ const readRuntimeVersionMeta = (config) => ({
6134
+ appVersion: config.version,
6135
+ rawRuntimeVersion: readRawRuntimeVersion(config.runtimeVersion)
6183
6136
  });
6137
+ const readRawRuntimeVersion = (value) => {
6138
+ if (typeof value === "string") return value;
6139
+ const policy = asString$1(asRecord(value)?.["policy"]);
6140
+ if (policy) return { policy };
6141
+ };
6184
6142
 
6185
6143
  //#endregion
6186
- //#region src/application/credentials-interactive.ts
6187
- const hasTag = (cause) => typeof cause === "object" && cause !== null && "_tag" in cause;
6188
- const isMissingResolveError = (cause) => hasTag(cause) && (cause._tag === "NotFound" || cause._tag === "BadRequest");
6189
- const generateKeystoreInteractive = (api) => Effect.gen(function* () {
6190
- const alias = yield* promptText("Key alias", { placeholder: "upload-key" });
6191
- const storePassword = yield* promptPassword("Keystore password");
6192
- const keyPassword = yield* promptPassword("Key password");
6193
- const commonName = yield* promptText("Common name (CN)", { placeholder: "Your App" });
6194
- const organization = yield* promptText("Organization (O)", { placeholder: "Your Company" });
6195
- yield* Console.log("Generating keystore with keytool...");
6196
- return (yield* generateAndUploadKeystore(api, {
6197
- keyAlias: alias,
6198
- storePassword,
6199
- keyPassword,
6200
- commonName,
6201
- organization
6202
- })).id;
6203
- });
6204
- const pickExistingKeystore = (api) => Effect.gen(function* () {
6205
- const keystores = yield* api.androidUploadKeystores.list();
6206
- if (keystores.items.length === 0) return yield* Effect.fail(new MissingCredentialsError({
6207
- message: "No existing keystores in this organization.",
6208
- hint: "Re-run and choose 'Generate new keystore'."
6209
- }));
6210
- return yield* promptSelect("Select a keystore", keystores.items.map((item) => ({
6211
- value: item.id,
6212
- label: item.keyAlias
6213
- })));
6144
+ //#region src/lib/clear-cache.ts
6145
+ /**
6146
+ * Project-scoped build cache directories to remove when --clear-cache is passed.
6147
+ * Intentionally avoids `~/.gradle/caches` (global) and `ios/Pods/` (requires
6148
+ * pod install rebuild leave to the user).
6149
+ */
6150
+ const CACHE_DIRS = [
6151
+ "android/.gradle",
6152
+ "android/app/build",
6153
+ "android/build",
6154
+ "ios/build",
6155
+ ".expo",
6156
+ "node_modules/.cache"
6157
+ ];
6158
+ const clearBuildCaches = (projectRoot) => Effect.gen(function* () {
6159
+ const fs = yield* FileSystem.FileSystem;
6160
+ const removed = [];
6161
+ yield* Effect.forEach(CACHE_DIRS, (rel) => Effect.gen(function* () {
6162
+ const target = path.join(projectRoot, rel);
6163
+ if (!(yield* fs.exists(target).pipe(Effect.catchAll(() => Effect.succeed(false))))) return;
6164
+ yield* fs.remove(target, { recursive: true }).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
6165
+ removed.push(rel);
6166
+ }), { concurrency: 4 });
6167
+ if (removed.length > 0) yield* Console.error(`Cleared caches: ${removed.join(", ")}`);
6214
6168
  });
6215
- const resolveAndroidAppId = (api, input) => Effect.gen(function* () {
6216
- const existing = (yield* api.androidApplicationIdentifiers.list({ path: { projectId: input.projectId } })).items.find((item) => item.packageName === input.applicationIdentifier);
6217
- if (existing !== void 0) return existing.id;
6218
- return (yield* api.androidApplicationIdentifiers.create({
6219
- path: { projectId: input.projectId },
6220
- payload: { packageName: input.applicationIdentifier }
6221
- })).id;
6169
+
6170
+ //#endregion
6171
+ //#region src/lib/env-exporter.ts
6172
+ const coerceEnvironment = (raw) => raw === "development" || raw === "preview" || raw === "production" ? raw : void 0;
6173
+ /**
6174
+ * Pull environment variables for a project + environment and flatten them into
6175
+ * a key/value map. Returns an empty map when the project has no variables.
6176
+ */
6177
+ const pullEnvVars = (api, { projectId, environment }) => {
6178
+ const validated = coerceEnvironment(environment);
6179
+ if (!validated) return Effect.fail(new EnvExportError({ message: `Invalid environment "${environment}". Must be one of: development, preview, production.` }));
6180
+ return api["env-vars"].export({ urlParams: {
6181
+ projectId,
6182
+ environment: validated
6183
+ } }).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)}` })));
6184
+ };
6185
+
6186
+ //#endregion
6187
+ //#region src/lib/git-context.ts
6188
+ const runString = (cmd, cwd) => Command.string(Command.workingDirectory(cmd, cwd));
6189
+ /**
6190
+ * Best-effort git context extraction. If git is missing, the directory isn't
6191
+ * a repo, or any command fails, we silently return undefined fields so the
6192
+ * build can still proceed. This is intentional — git context is metadata,
6193
+ * not a requirement.
6194
+ */
6195
+ const readGitContext = (projectRoot) => Effect.gen(function* () {
6196
+ const [commit, ref, commitMessage, status] = yield* Effect.all([
6197
+ runString(Command.make("git", "rev-parse", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
6198
+ runString(Command.make("git", "symbolic-ref", "--short", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
6199
+ runString(Command.make("git", "log", "-1", "--format=%s"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
6200
+ runString(Command.make("git", "status", "--porcelain"), projectRoot).pipe(Effect.catchAll(() => Effect.succeed("")))
6201
+ ], { concurrency: "unbounded" });
6202
+ return {
6203
+ ref: ref.length > 0 ? ref : void 0,
6204
+ commit: commit.length > 0 ? commit : void 0,
6205
+ commitMessage: commitMessage.length > 0 ? commitMessage : void 0,
6206
+ dirty: status.trim().length > 0
6207
+ };
6222
6208
  });
6223
- const resolveAndroidKeystoreId = (api, choice) => choice === "generate" ? generateKeystoreInteractive(api) : pickExistingKeystore(api);
6224
- const setupAndroidInteractive = (api, input) => Effect.gen(function* () {
6225
- yield* Console.log("");
6226
- yield* Console.log(`No Android build credentials configured for ${input.applicationIdentifier}.`);
6227
- const appId = yield* resolveAndroidAppId(api, input);
6228
- const choice = yield* promptSelect("How would you like to provide a keystore?", [
6229
- {
6230
- value: "generate",
6231
- label: "Generate new keystore"
6232
- },
6233
- {
6234
- value: "existing",
6235
- label: "Pick an existing keystore"
6209
+
6210
+ //#endregion
6211
+ //#region src/lib/gradle-config.ts
6212
+ /**
6213
+ * Parse Groovy `build.gradle` to extract key Android config values.
6214
+ * Returns `undefined` if:
6215
+ * - Only `build.gradle.kts` exists (Kotlin DSL not supported by gradle-to-js)
6216
+ * - No build.gradle found at all
6217
+ * - Parse fails
6218
+ *
6219
+ * Informational only — never blocks the build.
6220
+ */
6221
+ const readGradleConfig = (androidDir) => Effect.gen(function* () {
6222
+ const fs = yield* FileSystem.FileSystem;
6223
+ const gradlePath = path.join(androidDir, "app", "build.gradle");
6224
+ const ktsPath = path.join(androidDir, "app", "build.gradle.kts");
6225
+ const hasGroovy = yield* fs.exists(gradlePath).pipe(Effect.orElseSucceed(() => false));
6226
+ const hasKts = yield* fs.exists(ktsPath).pipe(Effect.orElseSucceed(() => false));
6227
+ if (!hasGroovy && hasKts) return;
6228
+ if (!hasGroovy) return;
6229
+ const content = yield* fs.readFileString(gradlePath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
6230
+ if (!content) return;
6231
+ return yield* Effect.tryPromise({
6232
+ try: async () => {
6233
+ return __require("gradle-to-js").parseText(stripGroovyComments(content));
6236
6234
  },
6237
- {
6238
- value: "abort",
6239
- label: "Abort — I'll configure it in the dashboard"
6240
- }
6241
- ]);
6242
- if (choice === "abort") return yield* Effect.fail(new MissingCredentialsError({
6243
- message: `Build aborted — no keystore bound to ${input.applicationIdentifier}.`,
6244
- hint: "Run `better-update credentials generate keystore` or upload via the dashboard."
6245
- }));
6246
- const keystoreId = yield* resolveAndroidKeystoreId(api, choice);
6247
- yield* api.androidBuildCredentials.create({
6248
- path: { applicationIdentifierId: appId },
6249
- payload: {
6250
- name: "Default",
6251
- isDefault: true,
6252
- androidUploadKeystoreId: keystoreId
6253
- }
6254
- });
6255
- yield* Console.log("Android build credentials configured.");
6235
+ catch: () => void 0
6236
+ }).pipe(Effect.map(extractGradleConfig), Effect.catchAll(() => Effect.succeed(void 0)));
6256
6237
  });
6257
- const ensureAndroidCredentialsAvailable = (api, input) => api.buildCredentials.resolve({
6258
- path: { projectId: input.projectId },
6259
- payload: {
6260
- platform: "android",
6261
- applicationIdentifier: input.applicationIdentifier
6238
+ /**
6239
+ * Log a warning if Gradle applicationId differs from app.json package name.
6240
+ */
6241
+ const warnOnGradleMismatch = (gradleConfig, expectedPackage) => {
6242
+ if (!gradleConfig?.applicationId) return Effect.void;
6243
+ if (gradleConfig.applicationId === expectedPackage) return Effect.void;
6244
+ return Console.warn(`Gradle applicationId "${gradleConfig.applicationId}" differs from app.json package "${expectedPackage}". The Gradle value will be used in the built APK/AAB.`);
6245
+ };
6246
+ /**
6247
+ * Strip Groovy single-line and block comments.
6248
+ * gradle-to-js chokes on comments — EAS CLI does this same pre-processing.
6249
+ */
6250
+ const stripGroovyComments = (text) => text.replaceAll(/\/\/.*$/gmu, "").replaceAll(/\/\*[\s\S]*?\*\//gu, "");
6251
+ const parseVersionCode = (raw) => {
6252
+ if (typeof raw === "number") return raw;
6253
+ if (typeof raw === "string") return Number.parseInt(raw, 10) || void 0;
6254
+ };
6255
+ const extractGradleConfig = (parsed) => {
6256
+ const defaultConfig = asRecord(asRecord(parsed["android"])?.["defaultConfig"]);
6257
+ const applicationId = typeof defaultConfig?.["applicationId"] === "string" ? unquote(defaultConfig["applicationId"]) : void 0;
6258
+ const versionCode = parseVersionCode(defaultConfig?.["versionCode"]);
6259
+ const versionName = typeof defaultConfig?.["versionName"] === "string" ? unquote(defaultConfig["versionName"]) : void 0;
6260
+ return {
6261
+ ...applicationId === void 0 ? {} : { applicationId },
6262
+ ...versionCode === void 0 ? {} : { versionCode },
6263
+ ...versionName === void 0 ? {} : { versionName }
6264
+ };
6265
+ };
6266
+ const unquote = (input) => input.startsWith("\"") && input.endsWith("\"") ? input.slice(1, -1) : input;
6267
+
6268
+ //#endregion
6269
+ //#region src/lib/platform-detect.ts
6270
+ const PLATFORMS = ["ios", "android"];
6271
+ const inferPlatforms = (config) => {
6272
+ const fromConfig = config["platforms"];
6273
+ if (Array.isArray(fromConfig)) return fromConfig.filter((entry) => entry === "ios" || entry === "android");
6274
+ const present = [];
6275
+ if (config.ios !== void 0) present.push("ios");
6276
+ if (config.android !== void 0) present.push("android");
6277
+ return present;
6278
+ };
6279
+ /**
6280
+ * Resolve a build platform from an explicit flag, or fall back to the Expo
6281
+ * config (`expo.platforms` or the presence of `ios`/`android` sections). Prompts
6282
+ * when the config declares both platforms; fails when ambiguous and prompts are
6283
+ * disallowed.
6284
+ */
6285
+ const detectPlatform = (explicit, config) => Effect.gen(function* () {
6286
+ if (explicit !== void 0) return explicit;
6287
+ const candidates = inferPlatforms(config);
6288
+ 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." });
6289
+ if (candidates.length === 1) {
6290
+ const [only] = candidates;
6291
+ if (only === void 0) return yield* new BuildProfileError({ message: "Internal: empty platform candidate list." });
6292
+ return only;
6262
6293
  }
6263
- }).pipe(Effect.asVoid);
6264
- const ensureAndroidCredentials = (api, input, options) => ensureAndroidCredentialsAvailable(api, input).pipe(Effect.catchIf(isMissingResolveError, () => Effect.gen(function* () {
6265
- const mode = yield* InteractiveMode;
6266
- if (options.freezeCredentials || !mode.allow) return yield* Effect.fail(new MissingCredentialsError({
6267
- message: `No Android build credentials for ${input.applicationIdentifier}.`,
6268
- hint: options.freezeCredentials ? "Run `better-update credentials generate` first, or remove --freeze-credentials." : "Run `better-update credentials generate` first, or rerun with --interactive to configure now."
6269
- }));
6270
- yield* setupAndroidInteractive(api, input);
6271
- return yield* ensureAndroidCredentialsAvailable(api, input);
6272
- })));
6273
- const setupIosInteractive = (api, input) => Effect.gen(function* () {
6274
- yield* Console.log("");
6275
- yield* Console.log(`No iOS bundle configuration for ${input.bundleIdentifier} (${input.distribution}).`);
6276
- if ((yield* chooseIosSetupPath(api)) === "apple-id") return yield* setupIosViaAppleId(api, input);
6277
- return yield* setupIosViaAscKey(api, input);
6294
+ if (!(yield* InteractiveMode).allow) return yield* new BuildProfileError({ message: `Multiple platforms detected (${candidates.join(", ")}). Pass --platform explicitly when running non-interactively.` });
6295
+ return yield* promptSelect("Which platform to build?", PLATFORMS.filter((entry) => candidates.includes(entry)).map((entry) => ({
6296
+ value: entry,
6297
+ label: entry
6298
+ })));
6278
6299
  });
6279
- const resolveIosBuildCredentials = (api, input) => api.buildCredentials.resolve({
6280
- path: { projectId: input.projectId },
6281
- payload: {
6282
- platform: "ios",
6283
- bundleIdentifier: input.bundleIdentifier,
6284
- distributionType: IOS_DISTRIBUTION_TO_TYPE[input.distribution]
6300
+
6301
+ //#endregion
6302
+ //#region src/lib/repo-clean.ts
6303
+ const MAX_FILES_SHOWN = 10;
6304
+ 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([])));
6305
+ /**
6306
+ * Refuse to proceed when the working tree has uncommitted changes. Skipped when
6307
+ * `allowDirty` is true. In interactive mode, prompts the user to confirm; in
6308
+ * non-interactive mode, fails with `DirtyRepoError`.
6309
+ */
6310
+ const ensureRepoClean = ({ projectRoot, allowDirty, label }) => Effect.gen(function* () {
6311
+ if (allowDirty) return;
6312
+ const dirty = yield* readPorcelain(projectRoot);
6313
+ if (dirty.length === 0) return;
6314
+ const preview = dirty.slice(0, MAX_FILES_SHOWN).join("\n ");
6315
+ const overflow = dirty.length > MAX_FILES_SHOWN ? `\n ... and ${String(dirty.length - MAX_FILES_SHOWN)} more` : "";
6316
+ yield* Console.error(`Uncommitted changes (${String(dirty.length)} file(s)):\n ${preview}${overflow}`);
6317
+ if (!(yield* InteractiveMode).allow) {
6318
+ yield* new DirtyRepoError({ message: `Refusing to ${label} with a dirty working tree. Commit your changes or pass --allow-dirty.` });
6319
+ return;
6285
6320
  }
6321
+ if (!(yield* promptConfirm(`Continue ${label} with uncommitted changes?`, { initialValue: false }))) yield* new DirtyRepoError({ message: `${label} cancelled by user.` });
6286
6322
  });
6287
- const findBoundIosConfig = (api, input) => Effect.gen(function* () {
6288
- const distributionType = IOS_DISTRIBUTION_TO_TYPE[input.distribution];
6289
- const match = (yield* api.iosBundleConfigurations.list({ path: { projectId: input.projectId } })).items.find((config) => config.bundleIdentifier === input.bundleIdentifier && config.distributionType === distributionType);
6290
- if (match === void 0) return yield* Effect.fail(new MissingCredentialsError({
6291
- message: `iOS bundle configuration vanished while regenerating stale profile for ${input.bundleIdentifier}`,
6292
- hint: "Retry; the configuration must exist before regeneration"
6293
- }));
6294
- return match;
6295
- });
6296
- const regenerateProvisioningProfile = (api, input) => Effect.gen(function* () {
6297
- const config = yield* findBoundIosConfig(api, input);
6298
- if (config.appleDistributionCertificateId === null) return yield* new MissingCredentialsError({
6299
- message: "Profile cannot be regenerated: bundle configuration is missing the distribution certificate",
6300
- hint: "Re-bind credentials via `better-update credentials generate` or the dashboard"
6301
- });
6302
- const distributionType = IOS_DISTRIBUTION_TO_TYPE[input.distribution];
6303
- if (config.ascApiKeyId === null) return yield* regenerateProvisioningProfileViaAppleId(api, {
6304
- bundleIdentifier: input.bundleIdentifier,
6305
- distributionCertificateId: config.appleDistributionCertificateId,
6306
- distributionType,
6307
- bundleConfigurationId: config.id
6308
- });
6309
- yield* Console.log("Regenerating provisioning profile via App Store Connect API...");
6310
- const created = yield* generateAndUploadProvisioningProfile(api, {
6311
- ascApiKeyId: config.ascApiKeyId,
6312
- distributionCertificateId: config.appleDistributionCertificateId,
6313
- bundleIdentifier: input.bundleIdentifier,
6314
- distributionType
6315
- });
6316
- yield* api.iosBundleConfigurations.update({
6317
- path: { id: config.id },
6318
- payload: { appleProvisioningProfileId: created.id }
6323
+
6324
+ //#endregion
6325
+ //#region src/lib/fingerprint.ts
6326
+ var FingerprintError = class extends Data.TaggedError("FingerprintError") {};
6327
+ const runFingerprintFull = (projectRoot) => Effect.gen(function* () {
6328
+ const cmd = Command.make("bunx", "@expo/fingerprint", projectRoot).pipe(Command.workingDirectory(projectRoot));
6329
+ const stdout = yield* Command.string(cmd).pipe(Effect.mapError((cause) => new FingerprintError({ message: `Failed to run "@expo/fingerprint": ${cause.message}` })));
6330
+ const parsed = yield* Effect.try({
6331
+ try: () => JSON.parse(stdout),
6332
+ catch: () => new FingerprintError({ message: "Failed to parse @expo/fingerprint output as JSON." })
6319
6333
  });
6320
- return created;
6334
+ if (!isRecord(parsed)) return yield* new FingerprintError({ message: "@expo/fingerprint output was not a JSON object." });
6335
+ const { hash } = parsed;
6336
+ if (typeof hash !== "string" || hash.length === 0) return yield* new FingerprintError({ message: "@expo/fingerprint output did not contain a \"hash\" string field." });
6337
+ const sourcesRaw = parsed["sources"];
6338
+ return {
6339
+ hash,
6340
+ sources: Array.isArray(sourcesRaw) ? sourcesRaw : []
6341
+ };
6342
+ });
6343
+
6344
+ //#endregion
6345
+ //#region src/lib/runtime-version.ts
6346
+ const resolveRuntimeVersion = ({ raw, appVersion, projectRoot }) => Effect.gen(function* () {
6347
+ if (typeof raw === "string") return raw;
6348
+ if (raw === void 0) return yield* new RuntimeVersionError({ message: "No runtimeVersion configured in expo section of app.json." });
6349
+ const { policy } = raw;
6350
+ if (policy === "appVersion") {
6351
+ if (appVersion === void 0) return yield* new RuntimeVersionError({ message: "runtimeVersion policy is \"appVersion\" but expo.version is missing in app.json." });
6352
+ return appVersion;
6353
+ }
6354
+ if (policy === "fingerprint") return yield* runFingerprintFull(projectRoot).pipe(Effect.map((result) => result.hash), Effect.mapError((cause) => new RuntimeVersionError({ message: cause.message })));
6355
+ if (policy === "nativeVersion") return yield* new RuntimeVersionError({ message: "runtimeVersion policy \"nativeVersion\" is not supported. Set a static runtimeVersion string in your Expo config." });
6356
+ return yield* new RuntimeVersionError({ message: `Unsupported runtimeVersion policy "${policy}". Use a static string, "appVersion", or "fingerprint".` });
6321
6357
  });
6322
- const ensureIosCredentials = (api, input, options) => resolveIosBuildCredentials(api, input).pipe(Effect.catchIf(isMissingResolveError, () => Effect.gen(function* () {
6323
- const mode = yield* InteractiveMode;
6324
- if (options.freezeCredentials || !mode.allow) return yield* Effect.fail(new MissingCredentialsError({
6325
- message: `No iOS build credentials for ${input.bundleIdentifier} (${input.distribution}).`,
6326
- hint: options.freezeCredentials ? "Run `better-update credentials generate` first, or remove --freeze-credentials." : "Run `better-update credentials generate` first, or rerun with --interactive to configure now."
6327
- }));
6328
- yield* setupIosInteractive(api, input);
6329
- return yield* resolveIosBuildCredentials(api, input);
6330
- })), Effect.flatMap((resolved) => Effect.gen(function* () {
6331
- if (resolved.platform !== "ios" || !resolved.profileStale) return;
6332
- const mode = yield* InteractiveMode;
6333
- if (options.freezeCredentials || !mode.allow) return yield* Effect.fail(new MissingCredentialsError({
6334
- message: `Stale provisioning profile for ${input.bundleIdentifier}; cannot regenerate without an interactive session.`,
6335
- hint: options.freezeCredentials ? "Run a build without --freeze-credentials once to refresh the profile, or run `better-update credentials regenerate-profile`." : "Run `better-update credentials regenerate-profile --bundle <id> --distribution <type>` from an interactive terminal."
6336
- }));
6337
- yield* Console.log(`Stale provisioning profile for ${input.bundleIdentifier} (device roster changed). Regenerating...`);
6338
- yield* regenerateProvisioningProfile(api, input);
6339
- })));
6340
6358
 
6341
6359
  //#endregion
6342
6360
  //#region src/application/build-workflow.ts
@@ -6363,7 +6381,8 @@ const runIosPlatformBuild = (input) => Effect.gen(function* () {
6363
6381
  envVars,
6364
6382
  projectId,
6365
6383
  credentialsSource,
6366
- rawOutput: options.rawOutput
6384
+ rawOutput: options.rawOutput,
6385
+ freezeCredentials: options.freezeCredentials ?? false
6367
6386
  }),
6368
6387
  target: isSimulator ? {
6369
6388
  platform: "ios",