@better-update/cli 0.15.2 → 0.15.3

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.3";
32
32
 
33
33
  //#endregion
34
34
  //#region src/lib/interactive-mode.ts
@@ -4497,1846 +4497,1858 @@ 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
- };
5506
- };
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
- };
5522
- };
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
- };
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;
5535
5104
  };
5536
- const readBuildProfile = (projectRoot, profileName) => Effect.gen(function* () {
5537
- return fromEasProfile(yield* resolveEasBuildProfile(yield* readEasJson(projectRoot), profileName), profileName);
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)}` })));
5538
5126
  });
5539
- const readRuntimeVersionMeta = (config) => ({
5540
- appVersion: config.version,
5541
- rawRuntimeVersion: readRawRuntimeVersion(config.runtimeVersion)
5127
+
5128
+ //#endregion
5129
+ //#region src/lib/ios-export-options.ts
5130
+ const escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
5131
+ const boolTag = (value) => value ? "<true/>" : "<false/>";
5132
+ /**
5133
+ * Render an Xcode `ExportOptions.plist` for `xcodebuild -exportArchive`.
5134
+ *
5135
+ * - `signingStyle` is always `manual` (ephemeral keychain + downloaded profile)
5136
+ * - `uploadSymbols` is emitted only for `app-store` exports
5137
+ * - `provisioningProfiles` dict maps each bundleId → profile name (one entry
5138
+ * per signed target: main app + any extensions like notification service)
5139
+ * - `compileBitcode` defaults to `false`
5140
+ */
5141
+ const renderExportOptionsPlist = ({ method, teamId, provisioningProfiles, compileBitcode = false }) => {
5142
+ const lines = [
5143
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
5144
+ "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
5145
+ "<plist version=\"1.0\">",
5146
+ "<dict>",
5147
+ " <key>method</key>",
5148
+ `\t<string>${escapeXml(method)}</string>`,
5149
+ " <key>teamID</key>",
5150
+ `\t<string>${escapeXml(teamId)}</string>`,
5151
+ " <key>signingStyle</key>",
5152
+ " <string>manual</string>",
5153
+ " <key>compileBitcode</key>",
5154
+ `\t${boolTag(compileBitcode)}`,
5155
+ " <key>provisioningProfiles</key>",
5156
+ " <dict>"
5157
+ ];
5158
+ for (const { bundleId, profileName } of provisioningProfiles) lines.push(`\t\t<key>${escapeXml(bundleId)}</key>`, `\t\t<string>${escapeXml(profileName)}</string>`);
5159
+ lines.push(" </dict>");
5160
+ if (method === "app-store") lines.push(" <key>uploadSymbols</key>", " <true/>");
5161
+ lines.push("</dict>", "</plist>", "");
5162
+ return lines.join("\n");
5163
+ };
5164
+
5165
+ //#endregion
5166
+ //#region src/lib/ios-keychain.ts
5167
+ const runOrFail = (cmd, step) => Command.string(cmd).pipe(Effect.mapError((cause) => new KeychainError({ message: `keychain ${step} failed: ${String(cause)}` })));
5168
+ const listCurrentKeychains = Effect.gen(function* () {
5169
+ 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
5170
  });
5543
- const readRawRuntimeVersion = (value) => {
5544
- if (typeof value === "string") return value;
5545
- const policy = asString$1(asRecord(value)?.["policy"]);
5546
- if (policy) return { policy };
5171
+ const parseSigningIdentity = (output) => {
5172
+ const lines = output.split("\n");
5173
+ for (const line of lines) {
5174
+ const match = /"([^"]+)"/u.exec(line);
5175
+ if (match?.[1]) return match[1];
5176
+ }
5177
+ };
5178
+ /**
5179
+ * Acquire an ephemeral macOS keychain, import a `.p12` into it, add it to the
5180
+ * user search list, and tear it all down on scope close. The keychain name is
5181
+ * namespaced as `better-update-<uuid>` and lives in `$tempDir`, so cleanup is
5182
+ * guaranteed under all termination paths.
5183
+ */
5184
+ const acquireKeychain = ({ tempDir, p12Path, p12Password }) => {
5185
+ const keychainName = `better-update-${randomUUID()}.keychain-db`;
5186
+ const keychainPath = path.join(tempDir, keychainName);
5187
+ const keychainPassword = randomBytes(32).toString("hex");
5188
+ return Effect.acquireRelease(Effect.gen(function* () {
5189
+ const priorKeychains = yield* listCurrentKeychains;
5190
+ yield* runOrFail(Command.make("security", "create-keychain", "-p", keychainPassword, keychainPath), "create-keychain");
5191
+ yield* runOrFail(Command.make("security", "unlock-keychain", "-p", keychainPassword, keychainPath), "unlock-keychain");
5192
+ yield* runOrFail(Command.make("security", "set-keychain-settings", "-t", "3600", "-l", keychainPath), "set-keychain-settings");
5193
+ yield* runOrFail(Command.make("security", "import", p12Path, "-k", keychainPath, "-P", p12Password, "-T", "/usr/bin/codesign"), "import");
5194
+ yield* runOrFail(Command.make("security", "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", keychainPassword, keychainPath), "set-key-partition-list");
5195
+ yield* runOrFail(Command.make("security", "list-keychains", "-d", "user", "-s", keychainPath, ...priorKeychains), "list-keychains -s (add)");
5196
+ const signingIdentity = parseSigningIdentity(yield* runOrFail(Command.make("security", "find-identity", "-v", "-p", "codesigning", keychainPath), "find-identity"));
5197
+ if (!signingIdentity) return yield* new KeychainError({ message: "No code signing identity found after importing .p12 into ephemeral keychain." });
5198
+ return {
5199
+ handle: {
5200
+ keychainName,
5201
+ keychainPath,
5202
+ signingIdentity
5203
+ },
5204
+ priorKeychains
5205
+ };
5206
+ }), ({ priorKeychains }) => Effect.gen(function* () {
5207
+ yield* Command.string(Command.make("security", "list-keychains", "-d", "user", "-s", ...priorKeychains)).pipe(Effect.catchAll(() => Effect.void));
5208
+ yield* Command.string(Command.make("security", "delete-keychain", keychainPath)).pipe(Effect.catchAll(() => Effect.void));
5209
+ })).pipe(Effect.map(({ handle }) => handle));
5547
5210
  };
5548
5211
 
5549
5212
  //#endregion
5550
- //#region src/lib/clear-cache.ts
5213
+ //#region src/lib/plist.ts
5214
+ const plist = typeof plistMod.parse === "function" ? plistMod : plistMod.default;
5551
5215
  /**
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).
5216
+ * Parse an XML plist string into a typed object.
5217
+ * Throws on malformed XML callers should wrap in Effect.try.
5555
5218
  */
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;
5219
+ const parsePlistXml = (xml) => plist.parse(xml);
5579
5220
  /**
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.
5221
+ * Parse a binary plist buffer into a typed object.
5222
+ * Uses bplist-parser for Apple's binary plist format.
5582
5223
  */
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)}` })));
5224
+ const parsePlistBinary = (buffer) => {
5225
+ const [result] = __require("bplist-parser").parseBuffer(buffer);
5226
+ return result;
5590
5227
  };
5228
+ const BPLIST_MAGIC = Buffer.from("bplist00");
5229
+ /**
5230
+ * Auto-detect plist format (binary vs XML) and parse accordingly.
5231
+ */
5232
+ const parsePlist = (data) => data.subarray(0, 8).equals(BPLIST_MAGIC) ? parsePlistBinary(data) : parsePlistXml(data.toString("utf8"));
5591
5233
 
5592
5234
  //#endregion
5593
- //#region src/lib/git-context.ts
5594
- const runString = (cmd, cwd) => Command.string(Command.workingDirectory(cmd, cwd));
5235
+ //#region src/lib/ios-provisioning.ts
5236
+ const getString = (obj, key) => {
5237
+ const value = obj[key];
5238
+ return typeof value === "string" ? value : void 0;
5239
+ };
5240
+ const getFirstArrayString = (obj, key) => {
5241
+ const value = obj[key];
5242
+ if (Array.isArray(value) && typeof value[0] === "string") return value[0];
5243
+ };
5595
5244
  /**
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.
5245
+ * Extract `UUID`, `Name`, and the first `TeamIdentifier` from the XML plist
5246
+ * output of `security cms -D -i <path>`. Returns `ProvisioningError` when any
5247
+ * of the three fields are missing.
5600
5248
  */
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" });
5249
+ const extractProvisioningInfo = (plistXml) => Effect.gen(function* () {
5250
+ const parsed = yield* Effect.try({
5251
+ try: () => parsePlistXml(plistXml),
5252
+ catch: (error) => new ProvisioningError({ message: `Failed to parse provisioning profile plist: ${error instanceof Error ? error.message : String(error)}` })
5253
+ });
5254
+ const uuid = getString(parsed, "UUID");
5255
+ const name = getString(parsed, "Name");
5256
+ const teamId = getFirstArrayString(parsed, "TeamIdentifier");
5257
+ if (!uuid || !name || !teamId) return yield* new ProvisioningError({ message: `Failed to parse provisioning profile: missing ${uuid ? "" : "UUID "}${name ? "" : "Name "}${teamId ? "" : "TeamIdentifier "}`.trim() });
5608
5258
  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
5259
+ uuid,
5260
+ name,
5261
+ teamId
5613
5262
  };
5614
5263
  });
5615
-
5616
- //#endregion
5617
- //#region src/lib/gradle-config.ts
5264
+ const userProvisioningProfilesDir = () => path.join(os.homedir(), "Library", "MobileDevice", "Provisioning Profiles");
5618
5265
  /**
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.
5266
+ * Scoped installation of a provisioning profile: parses its metadata via
5267
+ * `security cms -D -i`, copies it into `~/Library/MobileDevice/Provisioning Profiles`
5268
+ * under `<uuid>.mobileprovision`, and removes the copy on scope close — but
5269
+ * only if we installed it. If the target file already existed when we arrived
5270
+ * (e.g., Xcode had it), we leave both the file and the contents untouched.
5626
5271
  */
5627
- const readGradleConfig = (androidDir) => Effect.gen(function* () {
5272
+ const installProvisioningProfile = ({ profilePath }) => Effect.acquireRelease(Effect.gen(function* () {
5628
5273
  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;
5274
+ 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)}` }))));
5275
+ const targetDir = userProvisioningProfilesDir();
5276
+ const installedPath = path.join(targetDir, `${info.uuid}.mobileprovision`);
5277
+ yield* fs.makeDirectory(targetDir, { recursive: true }).pipe(Effect.catchAll((cause) => new ProvisioningError({ message: `Failed to create provisioning profiles dir: ${String(cause)}` })));
5278
+ if (yield* fs.exists(installedPath).pipe(Effect.orElseSucceed(() => false))) return {
5279
+ ...info,
5280
+ installedPath,
5281
+ ownsInstallation: false
5282
+ };
5283
+ yield* fs.copyFile(profilePath, installedPath).pipe(Effect.catchAll((cause) => new ProvisioningError({ message: `Failed to copy provisioning profile into ${installedPath}: ${String(cause)}` })));
5666
5284
  return {
5667
- ...applicationId === void 0 ? {} : { applicationId },
5668
- ...versionCode === void 0 ? {} : { versionCode },
5669
- ...versionName === void 0 ? {} : { versionName }
5285
+ ...info,
5286
+ installedPath,
5287
+ ownsInstallation: true
5670
5288
  };
5671
- };
5672
- const unquote = (input) => input.startsWith("\"") && input.endsWith("\"") ? input.slice(1, -1) : input;
5289
+ }), (acquired) => Effect.gen(function* () {
5290
+ if (!acquired.ownsInstallation) return;
5291
+ yield* (yield* FileSystem.FileSystem).remove(acquired.installedPath).pipe(Effect.catchAll(() => Effect.void));
5292
+ })).pipe(Effect.map(({ uuid, name, teamId, installedPath }) => ({
5293
+ uuid,
5294
+ name,
5295
+ teamId,
5296
+ installedPath
5297
+ })));
5673
5298
 
5674
5299
  //#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
- };
5300
+ //#region src/lib/post-build-validation.ts
5301
+ const validateOneBundle = (bundleDir, expectedByBundleId, expectedTeamId) => Effect.gen(function* () {
5302
+ const bundleId = yield* readBundleId(bundleDir).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
5303
+ if (!bundleId) return {
5304
+ bundleId: void 0,
5305
+ warnings: [`Missing CFBundleIdentifier in Info.plist at ${bundleDir}`]
5306
+ };
5307
+ const expected = expectedByBundleId.get(bundleId);
5308
+ if (!expected) return {
5309
+ bundleId,
5310
+ warnings: [`Unexpected signed bundle "${bundleId}" found in archive at ${bundleDir}`]
5311
+ };
5312
+ return {
5313
+ bundleId,
5314
+ warnings: yield* validateEmbeddedProfile(bundleDir, expected.profileUuid, expectedTeamId, bundleId).pipe(Effect.catchAll(() => Effect.succeed([])))
5315
+ };
5316
+ });
5317
+ const validateIosBuild = (params) => Effect.gen(function* () {
5318
+ const appDir = yield* findAppDirectory$1(params.archivePath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
5319
+ if (!appDir) return {
5320
+ passed: false,
5321
+ warnings: ["Could not locate .app bundle in archive — skipping post-build validation"]
5322
+ };
5323
+ const bundleDirs = yield* listSignedBundleDirs(appDir).pipe(Effect.catchAll(() => Effect.succeed([appDir])));
5324
+ const expectedByBundleId = new Map(params.expectedTargets.map((target) => [target.bundleId, target]));
5325
+ const perBundle = yield* Effect.forEach(bundleDirs, (bundleDir) => validateOneBundle(bundleDir, expectedByBundleId, params.expectedTeamId));
5326
+ const warnings = perBundle.flatMap((entry) => [...entry.warnings]);
5327
+ const validatedBundleIds = new Set(perBundle.map((entry) => entry.bundleId).filter((id) => id !== void 0));
5328
+ for (const expected of params.expectedTargets) if (!validatedBundleIds.has(expected.bundleId)) warnings.push(`Expected signed target "${expected.bundleId}" was not found in the archive.`);
5329
+ if (warnings.length > 0) {
5330
+ yield* Console.warn("Post-build validation warnings:");
5331
+ for (const warning of warnings) yield* Console.warn(` - ${warning}`);
5332
+ }
5333
+ return {
5334
+ passed: warnings.length === 0,
5335
+ warnings
5336
+ };
5337
+ });
5338
+ const findAppDirectory$1 = (archivePath) => Effect.gen(function* () {
5339
+ const fs = yield* FileSystem.FileSystem;
5340
+ const productsDir = path.join(archivePath, "Products", "Applications");
5341
+ const appEntry = (yield* fs.readDirectory(productsDir)).find((entry) => entry.endsWith(".app"));
5342
+ if (!appEntry) return yield* Effect.fail("No .app found");
5343
+ return path.join(productsDir, appEntry);
5344
+ });
5685
5345
  /**
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.
5346
+ * Return the main `.app` plus every `.appex` extension under `<app>/PlugIns/`.
5347
+ * Each returned path is a signed bundle that should carry its own embedded
5348
+ * provisioning profile + Info.plist with CFBundleIdentifier.
5690
5349
  */
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;
5350
+ const listSignedBundleDirs = (appDir) => Effect.gen(function* () {
5351
+ const fs = yield* FileSystem.FileSystem;
5352
+ const plugInsDir = path.join(appDir, "PlugIns");
5353
+ if (!(yield* fs.exists(plugInsDir).pipe(Effect.catchAll(() => Effect.succeed(false))))) return [appDir];
5354
+ return [appDir, ...(yield* fs.readDirectory(plugInsDir)).filter((entry) => entry.endsWith(".appex")).map((entry) => path.join(plugInsDir, entry))];
5355
+ });
5356
+ const readBundleId = (bundleDir) => Effect.gen(function* () {
5357
+ const fs = yield* FileSystem.FileSystem;
5358
+ const plistPath = path.join(bundleDir, "Info.plist");
5359
+ const data = yield* fs.readFile(plistPath);
5360
+ const bundleId = parsePlist(Buffer.from(data))["CFBundleIdentifier"];
5361
+ return typeof bundleId === "string" ? bundleId : void 0;
5362
+ });
5363
+ const validateEmbeddedProfile = (bundleDir, expectedUuid, expectedTeamId, bundleId) => Effect.gen(function* () {
5364
+ const warnings = [];
5365
+ const profilePath = path.join(bundleDir, "embedded.mobileprovision");
5366
+ const parsed = parsePlistXml(yield* Command.string(Command.make("security", "cms", "-D", "-i", profilePath)));
5367
+ const actualUuid = parsed["UUID"];
5368
+ if (typeof actualUuid === "string" && actualUuid !== expectedUuid) warnings.push(`[${bundleId}] Profile UUID mismatch: expected "${expectedUuid}", got "${actualUuid}"`);
5369
+ const teamIdentifiers = parsed["TeamIdentifier"];
5370
+ if (Array.isArray(teamIdentifiers)) {
5371
+ const [actualTeamId] = teamIdentifiers;
5372
+ if (typeof actualTeamId === "string" && actualTeamId !== expectedTeamId) warnings.push(`[${bundleId}] Team ID mismatch: expected "${expectedTeamId}", got "${actualTeamId}"`);
5699
5373
  }
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
- })));
5374
+ const expirationDate = parsed["ExpirationDate"];
5375
+ if (expirationDate instanceof Date && expirationDate.getTime() < Date.now()) warnings.push(`[${bundleId}] Embedded provisioning profile expired on ${expirationDate.toISOString()}`);
5376
+ return warnings;
5705
5377
  });
5706
5378
 
5707
5379
  //#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([])));
5380
+ //#region src/lib/xcode-targets.ts
5381
+ /** Product types whose targets require code-signing with a provisioning profile. */
5382
+ const SIGNED_PRODUCT_TYPES = new Set([
5383
+ "com.apple.product-type.application",
5384
+ "com.apple.product-type.app-extension",
5385
+ "com.apple.product-type.messages-extension",
5386
+ "com.apple.product-type.tv-app-extension",
5387
+ "com.apple.product-type.watchapp2",
5388
+ "com.apple.product-type.watchkit2-extension"
5389
+ ]);
5390
+ const loadXcodeModule = () => __require("xcode");
5711
5391
  /**
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`.
5392
+ * Strip surrounding quotes from a pbxproj string value. `xcode` returns values
5393
+ * verbatim from the project file, so identifiers like productType are usually
5394
+ * wrapped in double quotes (e.g. `"com.apple.product-type.application"`).
5715
5395
  */
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;
5396
+ const unquote$1 = (value) => value.length >= 2 && value.startsWith("\"") && value.endsWith("\"") ? value.slice(1, -1) : value;
5397
+ const findXcodeProjectDir = (iosDir) => Effect.gen(function* () {
5398
+ 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"));
5399
+ if (!projectDir) return yield* new XcodeProjectError({ message: `No .xcodeproj directory found under ${iosDir}. Did "expo prebuild" run?` });
5400
+ return path.join(iosDir, projectDir);
5401
+ });
5402
+ const parseProject = (pbxprojPath) => Effect.try({
5403
+ try: () => loadXcodeModule().project(pbxprojPath).parseSync(),
5404
+ catch: (cause) => new XcodeProjectError({ message: `Failed to parse ${pbxprojPath}: ${cause instanceof Error ? cause.message : String(cause)}` })
5405
+ });
5406
+ const collectConfigUuidsForTarget = (project, target, configurationName) => {
5407
+ const configList = project.pbxXCConfigurationList()[target.buildConfigurationList];
5408
+ if (!configList || typeof configList === "string") return [];
5409
+ const buildConfigSection = project.pbxXCBuildConfigurationSection();
5410
+ return configList.buildConfigurations.map((entry) => entry.value).filter((uuid) => {
5411
+ const cfg = buildConfigSection[uuid];
5412
+ if (!cfg || typeof cfg === "string") return false;
5413
+ return unquote$1(cfg.name) === configurationName;
5414
+ });
5415
+ };
5416
+ const extractBundleIdForConfig = (project, configUuid) => {
5417
+ const cfg = project.pbxXCBuildConfigurationSection()[configUuid];
5418
+ if (!cfg || typeof cfg === "string") return;
5419
+ const raw = cfg.buildSettings["PRODUCT_BUNDLE_IDENTIFIER"];
5420
+ if (typeof raw !== "string") return;
5421
+ return unquote$1(raw);
5422
+ };
5423
+ const collectSignedTargets = (project, pbxprojPath, configurationName) => Effect.gen(function* () {
5424
+ const results = [];
5425
+ const nativeTargets = project.pbxNativeTargetSection();
5426
+ for (const [uuid, entry] of Object.entries(nativeTargets)) {
5427
+ if (uuid.endsWith("_comment") || typeof entry === "string") continue;
5428
+ const productType = unquote$1(entry.productType);
5429
+ if (!SIGNED_PRODUCT_TYPES.has(productType)) continue;
5430
+ const configUuids = collectConfigUuidsForTarget(project, entry, configurationName);
5431
+ const [firstConfigUuid] = configUuids;
5432
+ if (!firstConfigUuid) return yield* new XcodeProjectError({ message: `Target "${unquote$1(entry.name)}" has no "${configurationName}" build configuration in ${pbxprojPath}.` });
5433
+ const bundleId = extractBundleIdForConfig(project, firstConfigUuid);
5434
+ if (!bundleId) return yield* new XcodeProjectError({ message: `Target "${unquote$1(entry.name)}" is missing PRODUCT_BUNDLE_IDENTIFIER in the "${configurationName}" configuration.` });
5435
+ results.push({
5436
+ targetName: unquote$1(entry.name),
5437
+ bundleId,
5438
+ productType,
5439
+ buildConfigurationUuids: configUuids
5440
+ });
5726
5441
  }
5727
- if (!(yield* promptConfirm(`Continue ${label} with uncommitted changes?`, { initialValue: false }))) yield* new DirtyRepoError({ message: `${label} cancelled by user.` });
5442
+ return results;
5443
+ });
5444
+ /**
5445
+ * Enumerate code-signed native targets (main app + extensions) declared in the
5446
+ * single `.xcodeproj` under `iosDir`, restricted to a given build configuration
5447
+ * (e.g. "Release"). Pod targets and other library product types are excluded.
5448
+ *
5449
+ * The returned `buildConfigurationUuids` list is the set of XCBuildConfiguration
5450
+ * UUIDs that belong to this target *and* match `configurationName` — the
5451
+ * per-target signing mutator writes settings into exactly those configurations.
5452
+ */
5453
+ const discoverSignedTargets = (options) => Effect.gen(function* () {
5454
+ const projectDir = yield* findXcodeProjectDir(options.iosDir);
5455
+ const pbxprojPath = path.join(projectDir, "project.pbxproj");
5456
+ const results = yield* collectSignedTargets(yield* parseProject(pbxprojPath), pbxprojPath, options.configurationName);
5457
+ if (results.length === 0) return yield* new XcodeProjectError({ message: `No signed native targets found in ${pbxprojPath} for configuration "${options.configurationName}".` });
5458
+ return results;
5728
5459
  });
5729
5460
 
5730
5461
  //#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"];
5462
+ //#region src/lib/xcpretty-formatter.ts
5463
+ /**
5464
+ * Create a stateful xcodebuild output formatter backed by `@expo/xcpretty`.
5465
+ * Each `pipe(line)` call may return zero or more formatted lines — zero means
5466
+ * the line was suppressed (e.g., intermediate compiler invocations).
5467
+ */
5468
+ const createXcodebuildFormatter = (projectRoot) => {
5469
+ const formatter = ExpoRunFormatter.create(projectRoot);
5744
5470
  return {
5745
- hash,
5746
- sources: Array.isArray(sourcesRaw) ? sourcesRaw : []
5471
+ pipe: (line) => formatter.pipe(line),
5472
+ getBuildSummary: () => formatter.getBuildSummary()
5747
5473
  };
5748
- });
5474
+ };
5749
5475
 
5750
5476
  //#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;
5477
+ //#region src/commands/build/ios.ts
5478
+ const findXcworkspace = (iosDir) => Effect.gen(function* () {
5479
+ const workspace = (yield* (yield* FileSystem.FileSystem).readDirectory(iosDir)).find((entry) => entry.endsWith(".xcworkspace"));
5480
+ if (!workspace) return yield* new BuildFailedError({
5481
+ step: "detect xcworkspace",
5482
+ exitCode: 1,
5483
+ message: `No .xcworkspace found under ${iosDir}. Did "pod install" run?`
5484
+ });
5485
+ return workspace;
5486
+ });
5487
+ const prebuildAndPods = (params) => Effect.gen(function* () {
5488
+ yield* runStep(Command.make("bunx", "expo", "prebuild", "--platform", "ios", "--clean").pipe(Command.workingDirectory(params.projectRoot), Command.env(params.commandEnv)), "expo prebuild ios");
5489
+ yield* runStep(Command.make("pod", "install").pipe(Command.workingDirectory(params.iosDir), Command.env(params.commandEnv)), "pod install");
5490
+ });
5491
+ const findAppDirectory = (root) => Effect.gen(function* () {
5492
+ const fs = yield* FileSystem.FileSystem;
5493
+ const stack = [root];
5494
+ let depth = 0;
5495
+ while (stack.length > 0 && depth < 6) {
5496
+ const layer = stack.splice(0);
5497
+ depth += 1;
5498
+ for (const dir of layer) {
5499
+ const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => []));
5500
+ for (const entry of entries) {
5501
+ const full = path.join(dir, entry);
5502
+ if (entry.endsWith(".app")) return full;
5503
+ const stat = yield* fs.stat(full).pipe(Effect.option);
5504
+ if (stat._tag === "Some" && stat.value.type === "Directory") stack.push(full);
5505
+ }
5506
+ }
5759
5507
  }
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".` });
5508
+ return yield* new ArtifactNotFoundError({ message: `No .app bundle found under "${root}".` });
5763
5509
  });
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
- })
5510
+ const runIosSimulatorBuild = (input) => Effect.gen(function* () {
5511
+ const { projectRoot, iosProfile, envVars, tempDir } = input;
5512
+ const runtime = yield* CliRuntime;
5513
+ const iosDir = path.join(projectRoot, "ios");
5514
+ const commandEnv = yield* runtime.commandEnvironment(envVars);
5515
+ yield* prebuildAndPods({
5516
+ projectRoot,
5517
+ iosDir,
5518
+ commandEnv
5519
+ });
5520
+ const workspaceFilename = yield* findXcworkspace(iosDir);
5521
+ const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
5522
+ const configuration = iosProfile.buildConfiguration ?? "Release";
5523
+ const derivedDataPath = path.join(tempDir, "derived-data");
5524
+ 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));
5525
+ const formatter = input.rawOutput ? void 0 : createXcodebuildFormatter(projectRoot);
5526
+ yield* formatter ? runStepFormatted(buildCmd, "xcodebuild build (simulator)", formatter) : runStep(buildCmd, "xcodebuild build (simulator)");
5527
+ const appDir = yield* findAppDirectory(path.join(derivedDataPath, "Build", "Products", `${configuration}-iphonesimulator`));
5528
+ const archiveName = `${path.basename(appDir, ".app")}-simulator.tar.gz`;
5529
+ const archivePath = path.join(tempDir, archiveName);
5530
+ yield* runStep(Command.make("tar", "-czf", archivePath, "-C", path.dirname(appDir), path.basename(appDir)).pipe(Command.env(commandEnv)), "tar simulator .app");
5531
+ const { sha256, byteSize } = yield* sha256File(archivePath);
5532
+ return {
5533
+ artifactPath: archivePath,
5534
+ byteSize,
5535
+ sha256
5536
+ };
5788
5537
  });
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
5538
+ const ensurePerTargetCredentials = (params) => Effect.forEach(params.signedTargets, (target) => ensureIosCredentials(params.api, {
5539
+ projectId: params.projectId,
5540
+ bundleIdentifier: target.bundleId,
5541
+ distribution: params.distribution
5542
+ }, { freezeCredentials: params.freezeCredentials }), { concurrency: 1 });
5543
+ const fetchAllCredentials = (params) => params.input.credentialsSource === "local" ? loadLocalIosCredentials({
5544
+ projectRoot: params.input.projectRoot,
5545
+ mainBundleIdentifier: params.mainBundleIdentifier
5546
+ }) : downloadIosCredentials(params.api, {
5547
+ projectId: params.input.projectId,
5548
+ mainBundleIdentifier: params.mainBundleIdentifier,
5549
+ bundleIdentifiers: params.allBundleIdentifiers,
5550
+ distribution: params.input.iosProfile.distribution,
5551
+ tempDir: params.input.tempDir
5552
+ });
5553
+ const installPerTarget = (signedTargets, credentials, credentialsSource) => Effect.gen(function* () {
5554
+ const profileByBundle = new Map(credentials.profiles.map((profile) => [profile.bundleIdentifier, profile]));
5555
+ const missing = signedTargets.filter((target) => !profileByBundle.has(target.bundleId));
5556
+ if (missing.length > 0) {
5557
+ const list = missing.map((target) => `"${target.targetName}" (${target.bundleId})`).join(", ");
5558
+ 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.";
5559
+ return yield* new MissingCredentialsError({
5560
+ message: `Missing provisioning profile for signed target(s): ${list}.`,
5561
+ hint
5797
5562
  });
5798
5563
  }
5564
+ return yield* Effect.forEach(signedTargets, (target) => installProfileForTarget(target, profileByBundle));
5799
5565
  });
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
5566
+ const installProfileForTarget = (target, profileByBundle) => {
5567
+ const profile = profileByBundle.get(target.bundleId);
5568
+ if (!profile) return Effect.fail(new ProvisioningError({ message: `Internal: no profile for ${target.bundleId} after pre-check.` }));
5569
+ return installProvisioningProfile({ profilePath: profile.profilePath }).pipe(Effect.map((installed) => ({
5570
+ target,
5571
+ profile,
5572
+ installed
5810
5573
  })));
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
- } });
5574
+ };
5575
+ const pickMainTarget = (signedTargets) => signedTargets.find((target) => target.productType === "com.apple.product-type.application") ?? signedTargets[0];
5576
+ const runIosDeviceBuild = (input) => Effect.gen(function* () {
5577
+ const { api, tempDir, projectRoot, iosProfile, envVars } = input;
5578
+ const runtime = yield* CliRuntime;
5579
+ const fs = yield* FileSystem.FileSystem;
5580
+ const iosDir = path.join(projectRoot, "ios");
5581
+ const { distribution } = iosProfile;
5582
+ const commandEnv = yield* runtime.commandEnvironment(envVars);
5583
+ yield* prebuildAndPods({
5584
+ projectRoot,
5585
+ iosDir,
5586
+ commandEnv
5587
+ });
5588
+ const workspaceFilename = yield* findXcworkspace(iosDir);
5589
+ const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
5590
+ const configuration = iosProfile.buildConfiguration ?? "Release";
5591
+ const signedTargets = yield* discoverSignedTargets({
5592
+ iosDir,
5593
+ configurationName: configuration
5594
+ });
5595
+ const mainTarget = pickMainTarget(signedTargets);
5596
+ if (!mainTarget) return yield* new BuildFailedError({
5597
+ step: "discover signed targets",
5598
+ exitCode: 1,
5599
+ message: `No signed iOS targets found in the Xcode project for configuration "${configuration}".`
5600
+ });
5601
+ if (input.credentialsSource === "remote") yield* ensurePerTargetCredentials({
5602
+ api,
5603
+ projectId: input.projectId,
5604
+ distribution: iosProfile.distribution,
5605
+ signedTargets,
5606
+ freezeCredentials: input.freezeCredentials ?? false
5607
+ });
5608
+ const credentials = yield* fetchAllCredentials({
5609
+ api,
5610
+ input,
5611
+ mainBundleIdentifier: mainTarget.bundleId,
5612
+ allBundleIdentifiers: signedTargets.map((target) => target.bundleId)
5613
+ });
5614
+ const keychain = yield* acquireKeychain({
5615
+ tempDir,
5616
+ p12Path: credentials.p12Path,
5617
+ p12Password: credentials.p12Password
5618
+ });
5619
+ const installedTargets = yield* installPerTarget(signedTargets, credentials, input.credentialsSource);
5620
+ yield* applyTargetSigning({
5621
+ iosDir,
5622
+ entries: installedTargets.map(({ target, installed }) => ({
5623
+ targetName: target.targetName,
5624
+ buildConfigurationUuids: target.buildConfigurationUuids,
5625
+ settings: {
5626
+ teamId: installed.teamId,
5627
+ signingIdentity: keychain.signingIdentity,
5628
+ profileSpecifier: installed.name
5629
+ }
5630
+ }))
5631
+ });
5632
+ const archivePath = path.join(tempDir, "build.xcarchive");
5633
+ const archiveCmd = Command.make("xcodebuild", "-workspace", workspaceFilename, "-scheme", scheme, "-configuration", configuration, "-archivePath", archivePath, "-allowProvisioningUpdates", "archive").pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
5634
+ const formatter = input.rawOutput ? void 0 : createXcodebuildFormatter(projectRoot);
5635
+ yield* formatter ? runStepFormatted(archiveCmd, "xcodebuild archive", formatter) : runStep(archiveCmd, "xcodebuild archive");
5636
+ const exportOptionsPath = path.join(tempDir, "ExportOptions.plist");
5637
+ const mainInstall = installedTargets.find((entry) => entry.target.targetName === mainTarget.targetName);
5638
+ if (!mainInstall) return yield* new BuildFailedError({
5639
+ step: "resolve main target signing",
5640
+ exitCode: 1,
5641
+ message: `Internal: main target "${mainTarget.targetName}" was not in the installed list.`
5642
+ });
5643
+ const { teamId } = mainInstall.installed;
5644
+ yield* fs.writeFileString(exportOptionsPath, renderExportOptionsPlist({
5645
+ method: distribution,
5646
+ teamId,
5647
+ provisioningProfiles: installedTargets.map(({ target, installed }) => ({
5648
+ bundleId: target.bundleId,
5649
+ profileName: installed.name
5650
+ }))
5651
+ }));
5652
+ const exportPath = path.join(tempDir, "export");
5653
+ const exportCmd = Command.make("xcodebuild", "-exportArchive", "-archivePath", archivePath, "-exportPath", exportPath, "-exportOptionsPlist", exportOptionsPath, "-allowProvisioningUpdates").pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
5654
+ yield* formatter ? runStepFormatted(exportCmd, "xcodebuild exportArchive", formatter) : runStep(exportCmd, "xcodebuild exportArchive");
5655
+ yield* validateIosBuild({
5656
+ archivePath,
5657
+ expectedTeamId: teamId,
5658
+ expectedTargets: installedTargets.map(({ target, installed }) => ({
5659
+ bundleId: target.bundleId,
5660
+ profileUuid: installed.uuid
5661
+ }))
5662
+ });
5663
+ const artifactPath = yield* findIosArtifact({ exportPath });
5664
+ const { sha256, byteSize } = yield* sha256File(artifactPath);
5821
5665
  return {
5822
- id: created.id,
5823
- serialNumber: metadata.serialNumber,
5824
- appleTeamId: created.appleTeamId,
5825
- appleTeamIdentifier: metadata.appleTeamId,
5826
- developerPortalIdentifier: result.certificate.id
5666
+ artifactPath,
5667
+ byteSize,
5668
+ sha256
5827
5669
  };
5828
5670
  });
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);
5671
+ const runIosBuild = (input) => input.iosProfile.simulator === true ? runIosSimulatorBuild(input) : runIosDeviceBuild(input);
5672
+
5673
+ //#endregion
5674
+ //#region src/commands/build/reserve-and-upload.ts
5675
+ const buildReserveCommon = (input) => ({
5676
+ projectId: input.projectId,
5677
+ profile: input.profileName,
5678
+ runtimeVersion: input.runtimeVersion,
5679
+ bundleId: input.bundleId,
5680
+ sha256: input.sha256,
5681
+ byteSize: input.byteSize,
5682
+ ...input.appVersion === void 0 ? {} : { appVersion: input.appVersion },
5683
+ ...input.buildNumber === void 0 ? {} : { buildNumber: input.buildNumber },
5684
+ ...input.gitContext.ref === void 0 ? {} : { gitRef: input.gitContext.ref },
5685
+ ...input.gitContext.commit === void 0 ? {} : { gitCommit: input.gitContext.commit },
5686
+ ...input.message === void 0 ? {} : { message: input.message }
5863
5687
  });
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 }
5688
+ const callReserve = (api, input) => {
5689
+ const common = buildReserveCommon(input);
5690
+ const { target } = input;
5691
+ if (target.platform === "ios") return target.distribution === "simulator" ? api.builds.reserve({ payload: {
5692
+ ...common,
5693
+ platform: "ios",
5694
+ distribution: "simulator",
5695
+ artifactFormat: "tar.gz"
5696
+ } }) : api.builds.reserve({ payload: {
5697
+ ...common,
5698
+ platform: "ios",
5699
+ distribution: target.distribution,
5700
+ artifactFormat: "ipa"
5701
+ } });
5702
+ return target.distribution === "play-store" ? api.builds.reserve({ payload: {
5703
+ ...common,
5704
+ platform: "android",
5705
+ distribution: "play-store",
5706
+ artifactFormat: "aab"
5707
+ } }) : api.builds.reserve({ payload: {
5708
+ ...common,
5709
+ platform: "android",
5710
+ distribution: "direct",
5711
+ artifactFormat: "apk"
5897
5712
  } });
5713
+ };
5714
+ /**
5715
+ * Reserve a build record on the server, upload the artifact to the returned
5716
+ * presigned URL, and finalize the build with its sha256 + byteSize.
5717
+ */
5718
+ const reserveAndUpload = (api, input) => Effect.gen(function* () {
5719
+ const presignedUploadClient = yield* PresignedUploadClient;
5720
+ const reserveResult = yield* callReserve(api, input).pipe(Effect.mapError((cause) => new ReserveError({ message: `Failed to reserve build: ${formatCause(cause)}` })));
5721
+ yield* presignedUploadClient.putToPresignedUrl({
5722
+ url: reserveResult.uploadUrl,
5723
+ filePath: input.artifactPath,
5724
+ byteSize: input.byteSize,
5725
+ expiresAt: reserveResult.uploadExpiresAt,
5726
+ headers: reserveResult.uploadHeaders
5727
+ });
5728
+ const completed = yield* api.builds.complete({
5729
+ path: { id: reserveResult.id },
5730
+ payload: {
5731
+ sha256: input.sha256,
5732
+ byteSize: input.byteSize
5733
+ }
5734
+ }).pipe(Effect.mapError((cause) => new CompleteError({ message: `Failed to complete build ${reserveResult.id}: ${formatCause(cause)}` })));
5735
+ if (!completed.artifact) return yield* new CompleteError({ message: `Build ${completed.id} completed but server returned no artifact record.` });
5898
5736
  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
5737
+ id: completed.id,
5738
+ status: "uploaded"
5905
5739
  };
5906
5740
  });
5907
5741
 
5908
5742
  //#endregion
5909
- //#region src/lib/ios-bundle-config-upsert.ts
5743
+ //#region src/lib/auto-increment.ts
5744
+ const bumpBuildNumber = (current) => Effect.gen(function* () {
5745
+ const raw = current ?? "0";
5746
+ const parsed = Number.parseInt(raw, 10);
5747
+ if (Number.isNaN(parsed)) return yield* new BuildProfileError({ message: `Cannot autoIncrement ios.buildNumber: current value "${raw}" is not a base-10 integer.` });
5748
+ return String(parsed + 1);
5749
+ });
5750
+ const bumpVersionCode = (current) => Effect.gen(function* () {
5751
+ const value = current ?? 0;
5752
+ 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.` });
5753
+ return value + 1;
5754
+ });
5755
+ const SEMVER_PATCH = /^(\d+)\.(\d+)\.(\d+)(.*)$/u;
5756
+ const bumpVersion = (current) => Effect.gen(function* () {
5757
+ if (current === void 0) return yield* new BuildProfileError({ message: "Cannot autoIncrement version: no `version` field set in Expo config." });
5758
+ const match = SEMVER_PATCH.exec(current);
5759
+ if (!match) return yield* new BuildProfileError({ message: `Cannot autoIncrement version: "${current}" is not a semver string like "1.2.3".` });
5760
+ const [, major, minor, patch, suffix] = match;
5761
+ const nextPatch = Number.parseInt(patch ?? "0", 10) + 1;
5762
+ return `${major ?? "0"}.${minor ?? "0"}.${String(nextPatch)}${suffix ?? ""}`;
5763
+ });
5764
+ const computeIosBumps = (config, mode) => Effect.gen(function* () {
5765
+ if (mode === "buildNumber") return { nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber) };
5766
+ return {
5767
+ nextVersion: yield* bumpVersion(config.version),
5768
+ nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber)
5769
+ };
5770
+ });
5771
+ const computeAndroidBumps = (config, mode) => Effect.gen(function* () {
5772
+ if (mode === "versionCode") return { nextVersionCode: yield* bumpVersionCode(config.android?.versionCode) };
5773
+ return {
5774
+ nextVersion: yield* bumpVersion(config.version),
5775
+ nextVersionCode: yield* bumpVersionCode(config.android?.versionCode)
5776
+ };
5777
+ });
5778
+ const buildPatch = (platform, bumps) => {
5779
+ const patch = {};
5780
+ if (bumps.nextVersion !== void 0) patch["version"] = bumps.nextVersion;
5781
+ if (platform === "ios" && bumps.nextBuildNumber !== void 0) patch["ios"] = { buildNumber: bumps.nextBuildNumber };
5782
+ if (platform === "android" && bumps.nextVersionCode !== void 0) patch["android"] = { versionCode: bumps.nextVersionCode };
5783
+ return patch;
5784
+ };
5785
+ const describeBumps = (platform, bumps) => {
5786
+ const parts = [];
5787
+ if (bumps.nextVersion !== void 0) parts.push(`version=${bumps.nextVersion}`);
5788
+ if (platform === "ios" && bumps.nextBuildNumber !== void 0) parts.push(`ios.buildNumber=${bumps.nextBuildNumber}`);
5789
+ if (platform === "android" && bumps.nextVersionCode !== void 0) parts.push(`android.versionCode=${String(bumps.nextVersionCode)}`);
5790
+ return parts.join(", ");
5791
+ };
5792
+ const computeBumps = (input) => {
5793
+ if (input.platform === "ios") return input.iosMode === void 0 ? Effect.succeed({}) : computeIosBumps(input.config, input.iosMode);
5794
+ return input.androidMode === void 0 ? Effect.succeed({}) : computeAndroidBumps(input.config, input.androidMode);
5795
+ };
5796
+ const hasAnyBump = (bumps) => bumps.nextVersion !== void 0 || bumps.nextBuildNumber !== void 0 || bumps.nextVersionCode !== void 0;
5910
5797
  /**
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.
5798
+ * Bump `version` / `ios.buildNumber` / `android.versionCode` per the resolved
5799
+ * autoIncrement mode, persist via `@expo/config.modifyConfigAsync`, and log a
5800
+ * Human-readable summary. No-op when the mode is undefined. Returns the new
5801
+ * Bumped values so callers can refresh their in-memory ExpoConfig.
5915
5802
  */
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" };
5803
+ const applyAutoIncrement = (input) => Effect.gen(function* () {
5804
+ const bumps = yield* computeBumps(input);
5805
+ if (!hasAnyBump(bumps)) return bumps;
5806
+ const patch = buildPatch(input.platform, bumps);
5807
+ const result = yield* writeExpoConfigPatch(input.projectRoot, patch).pipe(Effect.mapError((cause) => new BuildProfileError({ message: `Failed to persist autoIncrement: ${cause.message}` })));
5808
+ if (result.type === "warn" && result.configPath === null) {
5809
+ yield* Console.log(`autoIncrement: dynamic Expo config detected, cannot write back. Update manually: ${describeBumps(input.platform, bumps)}`);
5810
+ return bumps;
5811
+ }
5812
+ yield* Console.log(`autoIncrement: bumped ${describeBumps(input.platform, bumps)}`);
5813
+ return bumps;
5814
+ });
5815
+
5816
+ //#endregion
5817
+ //#region src/lib/eas-config.ts
5818
+ const MAX_EXTENDS_DEPTH = 10;
5819
+ const asStringValue = (value) => typeof value === "string" ? value : void 0;
5820
+ const asBooleanValue = (value) => typeof value === "boolean" ? value : void 0;
5821
+ const asEnv = (value) => {
5822
+ const record = asRecord(value);
5823
+ if (!record) return;
5824
+ const env = {};
5825
+ for (const [key, raw] of Object.entries(record)) if (typeof raw === "string") env[key] = raw;
5826
+ return Object.keys(env).length === 0 ? void 0 : env;
5827
+ };
5828
+ const asIosDistribution = (raw) => {
5829
+ const value = asStringValue(raw);
5830
+ if (value === "app-store" || value === "ad-hoc" || value === "development" || value === "enterprise") return value;
5831
+ };
5832
+ const asEnterpriseProvisioning = (raw) => {
5833
+ const value = asStringValue(raw);
5834
+ return value === "adhoc" || value === "universal" ? value : void 0;
5835
+ };
5836
+ const asAndroidBuildType = (raw) => {
5837
+ const value = asStringValue(raw);
5838
+ return value === "debug" || value === "release" ? value : void 0;
5839
+ };
5840
+ const asAndroidFormat = (raw) => {
5841
+ const value = asStringValue(raw);
5842
+ return value === "apk" || value === "aab" ? value : void 0;
5843
+ };
5844
+ const asAndroidDistribution = (raw) => {
5845
+ const value = asStringValue(raw);
5846
+ return value === "play-store" || value === "direct" ? value : void 0;
5847
+ };
5848
+ const asIosAutoIncrement = (raw) => {
5849
+ if (typeof raw === "boolean") return raw;
5850
+ const value = asStringValue(raw);
5851
+ return value === "buildNumber" || value === "version" ? value : void 0;
5852
+ };
5853
+ const asAndroidAutoIncrement = (raw) => {
5854
+ if (typeof raw === "boolean") return raw;
5855
+ const value = asStringValue(raw);
5856
+ return value === "versionCode" || value === "version" ? value : void 0;
5857
+ };
5858
+ const asAutoIncrement = (raw) => {
5859
+ if (typeof raw === "boolean") return raw;
5860
+ const value = asStringValue(raw);
5861
+ return value === "buildNumber" || value === "versionCode" || value === "version" ? value : void 0;
5862
+ };
5863
+ const asEasDistribution = (raw) => {
5864
+ const value = asStringValue(raw);
5865
+ return value === "internal" || value === "store" ? value : void 0;
5866
+ };
5867
+ const asCredentialsSource = (raw) => {
5868
+ const value = asStringValue(raw);
5869
+ return value === "remote" || value === "local" ? value : void 0;
5870
+ };
5871
+ const parseIosProfile = (raw) => {
5872
+ const record = asRecord(raw);
5873
+ if (!record) return;
5874
+ const distribution = asIosDistribution(record["distribution"]);
5875
+ const buildConfiguration = asStringValue(record["buildConfiguration"]);
5876
+ const scheme = asStringValue(record["scheme"]);
5877
+ const simulator = asBooleanValue(record["simulator"]);
5878
+ const enterpriseProvisioning = asEnterpriseProvisioning(record["enterpriseProvisioning"]);
5879
+ const autoIncrement = asIosAutoIncrement(record["autoIncrement"]);
5880
+ return {
5881
+ ...distribution === void 0 ? {} : { distribution },
5882
+ ...buildConfiguration === void 0 ? {} : { buildConfiguration },
5883
+ ...scheme === void 0 ? {} : { scheme },
5884
+ ...simulator === void 0 ? {} : { simulator },
5885
+ ...enterpriseProvisioning === void 0 ? {} : { enterpriseProvisioning },
5886
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
5887
+ };
5888
+ };
5889
+ const parseAndroidProfile = (raw) => {
5890
+ const record = asRecord(raw);
5891
+ if (!record) return;
5892
+ const buildType = asAndroidBuildType(record["buildType"]);
5893
+ const flavor = asStringValue(record["flavor"]);
5894
+ const gradleCommand = asStringValue(record["gradleCommand"]);
5895
+ const format = asAndroidFormat(record["format"]);
5896
+ const distribution = asAndroidDistribution(record["distribution"]);
5897
+ const autoIncrement = asAndroidAutoIncrement(record["autoIncrement"]);
5898
+ return {
5899
+ ...buildType === void 0 ? {} : { buildType },
5900
+ ...flavor === void 0 ? {} : { flavor },
5901
+ ...gradleCommand === void 0 ? {} : { gradleCommand },
5902
+ ...format === void 0 ? {} : { format },
5903
+ ...distribution === void 0 ? {} : { distribution },
5904
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
5905
+ };
5906
+ };
5907
+ const parseBuildProfile = (raw) => {
5908
+ const record = asRecord(raw);
5909
+ if (!record) return;
5910
+ const extendsName = asStringValue(record["extends"]);
5911
+ const developmentClient = asBooleanValue(record["developmentClient"]);
5912
+ const distribution = asEasDistribution(record["distribution"]);
5913
+ const channel = asStringValue(record["channel"]);
5914
+ const environment = asStringValue(record["environment"]);
5915
+ const env = asEnv(record["env"]);
5916
+ const ios = parseIosProfile(record["ios"]);
5917
+ const android = parseAndroidProfile(record["android"]);
5918
+ const credentialsSource = asCredentialsSource(record["credentialsSource"]);
5919
+ const autoIncrement = asAutoIncrement(record["autoIncrement"]);
5920
+ return {
5921
+ ...extendsName === void 0 ? {} : { extends: extendsName },
5922
+ ...developmentClient === void 0 ? {} : { developmentClient },
5923
+ ...distribution === void 0 ? {} : { distribution },
5924
+ ...channel === void 0 ? {} : { channel },
5925
+ ...environment === void 0 ? {} : { environment },
5926
+ ...env === void 0 ? {} : { env },
5927
+ ...ios === void 0 ? {} : { ios },
5928
+ ...android === void 0 ? {} : { android },
5929
+ ...credentialsSource === void 0 ? {} : { credentialsSource },
5930
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
5931
+ };
5932
+ };
5933
+ const parseEasConfig = (text) => Effect.gen(function* () {
5934
+ const root = asRecord(yield* Effect.try({
5935
+ try: () => JSON.parse(text),
5936
+ catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
5937
+ }));
5938
+ if (!root) return yield* new BuildProfileError({ message: "eas.json must be a JSON object at the top level." });
5939
+ const buildRecord = asRecord(root["build"]);
5940
+ if (!buildRecord) return asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {};
5941
+ const profiles = {};
5942
+ for (const [name, value] of Object.entries(buildRecord)) {
5943
+ const profile = parseBuildProfile(value);
5944
+ if (profile) profiles[name] = profile;
5932
5945
  }
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
5946
  return {
5947
- action: "updated",
5948
- id: existing.id
5947
+ ...asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {},
5948
+ build: profiles
5949
5949
  };
5950
5950
  });
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...`);
5951
+ const parseCli = (raw) => {
5952
+ const record = asRecord(raw);
5953
+ if (!record) return {};
5954
+ const version = asStringValue(record["version"]);
5955
+ return version === void 0 ? {} : { version };
5956
+ };
5957
+ const easJsonPath = (projectRoot) => Effect.gen(function* () {
5958
+ return (yield* Path.Path).join(projectRoot, "eas.json");
5978
5959
  });
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))));
5960
+ const readEasJson = (projectRoot) => Effect.gen(function* () {
5961
+ const fs = yield* FileSystem.FileSystem;
5962
+ const filePath = yield* easJsonPath(projectRoot);
5963
+ 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
5964
  });
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
- });
5965
+ const mergeIos = (base, overlay) => {
5966
+ if (!base) return overlay;
5967
+ if (!overlay) return base;
6015
5968
  return {
6016
- id: cert.id,
6017
- appleTeamId: cert.appleTeamId
5969
+ ...base,
5970
+ ...overlay
6018
5971
  };
5972
+ };
5973
+ const mergeAndroid = (base, overlay) => {
5974
+ if (!base) return overlay;
5975
+ if (!overlay) return base;
5976
+ return {
5977
+ ...base,
5978
+ ...overlay
5979
+ };
5980
+ };
5981
+ const mergeEnv = (base, overlay) => {
5982
+ if (!base) return overlay;
5983
+ if (!overlay) return base;
5984
+ return {
5985
+ ...base,
5986
+ ...overlay
5987
+ };
5988
+ };
5989
+ const mergeProfile = (base, overlay) => {
5990
+ const ios = mergeIos(base.ios, overlay.ios);
5991
+ const android = mergeAndroid(base.android, overlay.android);
5992
+ const env = mergeEnv(base.env, overlay.env);
5993
+ const developmentClient = overlay.developmentClient ?? base.developmentClient;
5994
+ const distribution = overlay.distribution ?? base.distribution;
5995
+ const channel = overlay.channel ?? base.channel;
5996
+ const environment = overlay.environment ?? base.environment;
5997
+ const credentialsSource = overlay.credentialsSource ?? base.credentialsSource;
5998
+ const autoIncrement = overlay.autoIncrement ?? base.autoIncrement;
5999
+ return {
6000
+ ...overlay.extends === void 0 ? {} : { extends: overlay.extends },
6001
+ ...developmentClient === void 0 ? {} : { developmentClient },
6002
+ ...distribution === void 0 ? {} : { distribution },
6003
+ ...channel === void 0 ? {} : { channel },
6004
+ ...environment === void 0 ? {} : { environment },
6005
+ ...env === void 0 ? {} : { env },
6006
+ ...ios === void 0 ? {} : { ios },
6007
+ ...android === void 0 ? {} : { android },
6008
+ ...credentialsSource === void 0 ? {} : { credentialsSource },
6009
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
6010
+ };
6011
+ };
6012
+ const collectExtendsChain = (profiles, profileName) => Effect.gen(function* () {
6013
+ const chain = [];
6014
+ const visited = /* @__PURE__ */ new Set();
6015
+ let current = profileName;
6016
+ let depth = 0;
6017
+ while (current !== void 0) {
6018
+ if (visited.has(current)) return yield* new BuildProfileError({ message: `Cycle detected in eas.json build.${profileName} extends chain at "${current}".` });
6019
+ visited.add(current);
6020
+ const profile = profiles[current];
6021
+ if (!profile) return yield* new BuildProfileError({ message: current === profileName ? `Build profile "${profileName}" not found in eas.json.` : `Build profile "${profileName}" extends missing profile "${current}".` });
6022
+ chain.unshift(profile);
6023
+ current = profile.extends;
6024
+ depth += 1;
6025
+ if (depth > MAX_EXTENDS_DEPTH) return yield* new BuildProfileError({ message: `Too many "extends" levels (max ${String(MAX_EXTENDS_DEPTH)}) in eas.json build.${profileName}.` });
6026
+ }
6027
+ return chain;
6019
6028
  });
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
- }));
6029
+ const stripExtends = (profile) => {
6030
+ if (profile.extends === void 0) return profile;
6031
+ const { extends: _omit, ...rest } = profile;
6032
+ return rest;
6033
+ };
6034
+ const resolveEasBuildProfile = (config, profileName) => Effect.gen(function* () {
6035
+ const profiles = config.build;
6036
+ if (!profiles) return yield* new BuildProfileError({ message: "eas.json has no \"build\" section. Add at least one profile." });
6037
+ return stripExtends((yield* collectExtendsChain(profiles, profileName)).reduce((acc, next, index) => index === 0 ? next : mergeProfile(acc, next), {}));
6038
+ });
6039
+
6040
+ //#endregion
6041
+ //#region src/lib/build-profile.ts
6042
+ const asString$1 = (value) => typeof value === "string" ? value : void 0;
6043
+ const deriveIosDistribution = (eas) => {
6044
+ const override = eas.ios?.distribution;
6045
+ if (override) return override;
6046
+ if (eas.developmentClient === true) return "development";
6047
+ if (eas.distribution === "internal") return "ad-hoc";
6048
+ if (eas.distribution === "store") return "app-store";
6049
+ };
6050
+ const deriveAndroidFormat = (eas) => {
6051
+ if (eas.android?.format) return eas.android.format;
6052
+ if (eas.distribution === "store") return "aab";
6053
+ if (eas.distribution === "internal") return "apk";
6054
+ if (eas.developmentClient === true) return "apk";
6055
+ };
6056
+ const deriveAndroidDistribution = (eas, format) => {
6057
+ if (eas.android?.distribution) return eas.android.distribution;
6058
+ if (format === "aab") return "play-store";
6059
+ return "direct";
6060
+ };
6061
+ const hasIosIntent = (eas) => eas.ios !== void 0 || eas.distribution !== void 0 || eas.developmentClient === true;
6062
+ const hasAndroidIntent = (eas) => eas.android !== void 0 || eas.distribution !== void 0 || eas.developmentClient === true;
6063
+ const resolveIosAutoIncrement = (eas) => {
6064
+ const override = eas.ios?.autoIncrement;
6065
+ if (override === false) return;
6066
+ if (override === true) return "buildNumber";
6067
+ if (override === "buildNumber" || override === "version") return override;
6068
+ const top = eas.autoIncrement;
6069
+ if (top === true || top === "buildNumber") return "buildNumber";
6070
+ if (top === "version") return "version";
6071
+ };
6072
+ const resolveAndroidAutoIncrement = (eas) => {
6073
+ const override = eas.android?.autoIncrement;
6074
+ if (override === false) return;
6075
+ if (override === true) return "versionCode";
6076
+ if (override === "versionCode" || override === "version") return override;
6077
+ const top = eas.autoIncrement;
6078
+ if (top === true || top === "versionCode") return "versionCode";
6079
+ if (top === "version") return "version";
6080
+ };
6081
+ const toIosProfile = (eas) => {
6082
+ if (!hasIosIntent(eas)) return;
6083
+ const distribution = deriveIosDistribution(eas);
6084
+ if (!distribution) return;
6085
+ const ios = eas.ios ?? {};
6086
+ const autoIncrement = resolveIosAutoIncrement(eas);
6130
6087
  return {
6131
- certId: chosenId,
6132
- cert
6088
+ distribution,
6089
+ ...ios.buildConfiguration === void 0 ? {} : { buildConfiguration: ios.buildConfiguration },
6090
+ ...ios.scheme === void 0 ? {} : { scheme: ios.scheme },
6091
+ ...ios.simulator === void 0 ? {} : { simulator: ios.simulator },
6092
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
6133
6093
  };
6094
+ };
6095
+ const toAndroidProfile = (eas) => {
6096
+ if (!hasAndroidIntent(eas)) return;
6097
+ const format = deriveAndroidFormat(eas);
6098
+ if (!format) return;
6099
+ const android = eas.android ?? {};
6100
+ const distribution = deriveAndroidDistribution(eas, format);
6101
+ const autoIncrement = resolveAndroidAutoIncrement(eas);
6102
+ return {
6103
+ format,
6104
+ distribution,
6105
+ ...android.buildType === void 0 ? {} : { buildType: android.buildType },
6106
+ ...android.flavor === void 0 ? {} : { flavor: android.flavor },
6107
+ ...android.gradleCommand === void 0 ? {} : { gradleCommand: android.gradleCommand },
6108
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
6109
+ };
6110
+ };
6111
+ const fromEasProfile = (eas, profileName) => {
6112
+ const ios = toIosProfile(eas);
6113
+ const android = toAndroidProfile(eas);
6114
+ return {
6115
+ name: profileName,
6116
+ environment: eas.environment ?? "production",
6117
+ ...eas.channel === void 0 ? {} : { channel: eas.channel },
6118
+ ...eas.env === void 0 ? {} : { env: eas.env },
6119
+ ...ios === void 0 ? {} : { ios },
6120
+ ...android === void 0 ? {} : { android },
6121
+ ...eas.credentialsSource === void 0 ? {} : { credentialsSource: eas.credentialsSource }
6122
+ };
6123
+ };
6124
+ const readBuildProfile = (projectRoot, profileName) => Effect.gen(function* () {
6125
+ return fromEasProfile(yield* resolveEasBuildProfile(yield* readEasJson(projectRoot), profileName), profileName);
6134
6126
  });
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
- });
6127
+ const readRuntimeVersionMeta = (config) => ({
6128
+ appVersion: config.version,
6129
+ rawRuntimeVersion: readRawRuntimeVersion(config.runtimeVersion)
6183
6130
  });
6131
+ const readRawRuntimeVersion = (value) => {
6132
+ if (typeof value === "string") return value;
6133
+ const policy = asString$1(asRecord(value)?.["policy"]);
6134
+ if (policy) return { policy };
6135
+ };
6184
6136
 
6185
6137
  //#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
- })));
6138
+ //#region src/lib/clear-cache.ts
6139
+ /**
6140
+ * Project-scoped build cache directories to remove when --clear-cache is passed.
6141
+ * Intentionally avoids `~/.gradle/caches` (global) and `ios/Pods/` (requires
6142
+ * pod install rebuild leave to the user).
6143
+ */
6144
+ const CACHE_DIRS = [
6145
+ "android/.gradle",
6146
+ "android/app/build",
6147
+ "android/build",
6148
+ "ios/build",
6149
+ ".expo",
6150
+ "node_modules/.cache"
6151
+ ];
6152
+ const clearBuildCaches = (projectRoot) => Effect.gen(function* () {
6153
+ const fs = yield* FileSystem.FileSystem;
6154
+ const removed = [];
6155
+ yield* Effect.forEach(CACHE_DIRS, (rel) => Effect.gen(function* () {
6156
+ const target = path.join(projectRoot, rel);
6157
+ if (!(yield* fs.exists(target).pipe(Effect.catchAll(() => Effect.succeed(false))))) return;
6158
+ yield* fs.remove(target, { recursive: true }).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
6159
+ removed.push(rel);
6160
+ }), { concurrency: 4 });
6161
+ if (removed.length > 0) yield* Console.error(`Cleared caches: ${removed.join(", ")}`);
6214
6162
  });
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;
6163
+
6164
+ //#endregion
6165
+ //#region src/lib/env-exporter.ts
6166
+ const coerceEnvironment = (raw) => raw === "development" || raw === "preview" || raw === "production" ? raw : void 0;
6167
+ /**
6168
+ * Pull environment variables for a project + environment and flatten them into
6169
+ * a key/value map. Returns an empty map when the project has no variables.
6170
+ */
6171
+ const pullEnvVars = (api, { projectId, environment }) => {
6172
+ const validated = coerceEnvironment(environment);
6173
+ if (!validated) return Effect.fail(new EnvExportError({ message: `Invalid environment "${environment}". Must be one of: development, preview, production.` }));
6174
+ return api["env-vars"].export({ urlParams: {
6175
+ projectId,
6176
+ environment: validated
6177
+ } }).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)}` })));
6178
+ };
6179
+
6180
+ //#endregion
6181
+ //#region src/lib/git-context.ts
6182
+ const runString = (cmd, cwd) => Command.string(Command.workingDirectory(cmd, cwd));
6183
+ /**
6184
+ * Best-effort git context extraction. If git is missing, the directory isn't
6185
+ * a repo, or any command fails, we silently return undefined fields so the
6186
+ * build can still proceed. This is intentional — git context is metadata,
6187
+ * not a requirement.
6188
+ */
6189
+ const readGitContext = (projectRoot) => Effect.gen(function* () {
6190
+ const [commit, ref, commitMessage, status] = yield* Effect.all([
6191
+ runString(Command.make("git", "rev-parse", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
6192
+ runString(Command.make("git", "symbolic-ref", "--short", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
6193
+ runString(Command.make("git", "log", "-1", "--format=%s"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
6194
+ runString(Command.make("git", "status", "--porcelain"), projectRoot).pipe(Effect.catchAll(() => Effect.succeed("")))
6195
+ ], { concurrency: "unbounded" });
6196
+ return {
6197
+ ref: ref.length > 0 ? ref : void 0,
6198
+ commit: commit.length > 0 ? commit : void 0,
6199
+ commitMessage: commitMessage.length > 0 ? commitMessage : void 0,
6200
+ dirty: status.trim().length > 0
6201
+ };
6222
6202
  });
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"
6203
+
6204
+ //#endregion
6205
+ //#region src/lib/gradle-config.ts
6206
+ /**
6207
+ * Parse Groovy `build.gradle` to extract key Android config values.
6208
+ * Returns `undefined` if:
6209
+ * - Only `build.gradle.kts` exists (Kotlin DSL not supported by gradle-to-js)
6210
+ * - No build.gradle found at all
6211
+ * - Parse fails
6212
+ *
6213
+ * Informational only — never blocks the build.
6214
+ */
6215
+ const readGradleConfig = (androidDir) => Effect.gen(function* () {
6216
+ const fs = yield* FileSystem.FileSystem;
6217
+ const gradlePath = path.join(androidDir, "app", "build.gradle");
6218
+ const ktsPath = path.join(androidDir, "app", "build.gradle.kts");
6219
+ const hasGroovy = yield* fs.exists(gradlePath).pipe(Effect.orElseSucceed(() => false));
6220
+ const hasKts = yield* fs.exists(ktsPath).pipe(Effect.orElseSucceed(() => false));
6221
+ if (!hasGroovy && hasKts) return;
6222
+ if (!hasGroovy) return;
6223
+ const content = yield* fs.readFileString(gradlePath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
6224
+ if (!content) return;
6225
+ return yield* Effect.tryPromise({
6226
+ try: async () => {
6227
+ return __require("gradle-to-js").parseText(stripGroovyComments(content));
6236
6228
  },
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.");
6229
+ catch: () => void 0
6230
+ }).pipe(Effect.map(extractGradleConfig), Effect.catchAll(() => Effect.succeed(void 0)));
6256
6231
  });
6257
- const ensureAndroidCredentialsAvailable = (api, input) => api.buildCredentials.resolve({
6258
- path: { projectId: input.projectId },
6259
- payload: {
6260
- platform: "android",
6261
- applicationIdentifier: input.applicationIdentifier
6232
+ /**
6233
+ * Log a warning if Gradle applicationId differs from app.json package name.
6234
+ */
6235
+ const warnOnGradleMismatch = (gradleConfig, expectedPackage) => {
6236
+ if (!gradleConfig?.applicationId) return Effect.void;
6237
+ if (gradleConfig.applicationId === expectedPackage) return Effect.void;
6238
+ return Console.warn(`Gradle applicationId "${gradleConfig.applicationId}" differs from app.json package "${expectedPackage}". The Gradle value will be used in the built APK/AAB.`);
6239
+ };
6240
+ /**
6241
+ * Strip Groovy single-line and block comments.
6242
+ * gradle-to-js chokes on comments — EAS CLI does this same pre-processing.
6243
+ */
6244
+ const stripGroovyComments = (text) => text.replaceAll(/\/\/.*$/gmu, "").replaceAll(/\/\*[\s\S]*?\*\//gu, "");
6245
+ const parseVersionCode = (raw) => {
6246
+ if (typeof raw === "number") return raw;
6247
+ if (typeof raw === "string") return Number.parseInt(raw, 10) || void 0;
6248
+ };
6249
+ const extractGradleConfig = (parsed) => {
6250
+ const defaultConfig = asRecord(asRecord(parsed["android"])?.["defaultConfig"]);
6251
+ const applicationId = typeof defaultConfig?.["applicationId"] === "string" ? unquote(defaultConfig["applicationId"]) : void 0;
6252
+ const versionCode = parseVersionCode(defaultConfig?.["versionCode"]);
6253
+ const versionName = typeof defaultConfig?.["versionName"] === "string" ? unquote(defaultConfig["versionName"]) : void 0;
6254
+ return {
6255
+ ...applicationId === void 0 ? {} : { applicationId },
6256
+ ...versionCode === void 0 ? {} : { versionCode },
6257
+ ...versionName === void 0 ? {} : { versionName }
6258
+ };
6259
+ };
6260
+ const unquote = (input) => input.startsWith("\"") && input.endsWith("\"") ? input.slice(1, -1) : input;
6261
+
6262
+ //#endregion
6263
+ //#region src/lib/platform-detect.ts
6264
+ const PLATFORMS = ["ios", "android"];
6265
+ const inferPlatforms = (config) => {
6266
+ const fromConfig = config["platforms"];
6267
+ if (Array.isArray(fromConfig)) return fromConfig.filter((entry) => entry === "ios" || entry === "android");
6268
+ const present = [];
6269
+ if (config.ios !== void 0) present.push("ios");
6270
+ if (config.android !== void 0) present.push("android");
6271
+ return present;
6272
+ };
6273
+ /**
6274
+ * Resolve a build platform from an explicit flag, or fall back to the Expo
6275
+ * config (`expo.platforms` or the presence of `ios`/`android` sections). Prompts
6276
+ * when the config declares both platforms; fails when ambiguous and prompts are
6277
+ * disallowed.
6278
+ */
6279
+ const detectPlatform = (explicit, config) => Effect.gen(function* () {
6280
+ if (explicit !== void 0) return explicit;
6281
+ const candidates = inferPlatforms(config);
6282
+ 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." });
6283
+ if (candidates.length === 1) {
6284
+ const [only] = candidates;
6285
+ if (only === void 0) return yield* new BuildProfileError({ message: "Internal: empty platform candidate list." });
6286
+ return only;
6262
6287
  }
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);
6288
+ if (!(yield* InteractiveMode).allow) return yield* new BuildProfileError({ message: `Multiple platforms detected (${candidates.join(", ")}). Pass --platform explicitly when running non-interactively.` });
6289
+ return yield* promptSelect("Which platform to build?", PLATFORMS.filter((entry) => candidates.includes(entry)).map((entry) => ({
6290
+ value: entry,
6291
+ label: entry
6292
+ })));
6278
6293
  });
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]
6294
+
6295
+ //#endregion
6296
+ //#region src/lib/repo-clean.ts
6297
+ const MAX_FILES_SHOWN = 10;
6298
+ 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([])));
6299
+ /**
6300
+ * Refuse to proceed when the working tree has uncommitted changes. Skipped when
6301
+ * `allowDirty` is true. In interactive mode, prompts the user to confirm; in
6302
+ * non-interactive mode, fails with `DirtyRepoError`.
6303
+ */
6304
+ const ensureRepoClean = ({ projectRoot, allowDirty, label }) => Effect.gen(function* () {
6305
+ if (allowDirty) return;
6306
+ const dirty = yield* readPorcelain(projectRoot);
6307
+ if (dirty.length === 0) return;
6308
+ const preview = dirty.slice(0, MAX_FILES_SHOWN).join("\n ");
6309
+ const overflow = dirty.length > MAX_FILES_SHOWN ? `\n ... and ${String(dirty.length - MAX_FILES_SHOWN)} more` : "";
6310
+ yield* Console.error(`Uncommitted changes (${String(dirty.length)} file(s)):\n ${preview}${overflow}`);
6311
+ if (!(yield* InteractiveMode).allow) {
6312
+ yield* new DirtyRepoError({ message: `Refusing to ${label} with a dirty working tree. Commit your changes or pass --allow-dirty.` });
6313
+ return;
6285
6314
  }
6315
+ if (!(yield* promptConfirm(`Continue ${label} with uncommitted changes?`, { initialValue: false }))) yield* new DirtyRepoError({ message: `${label} cancelled by user.` });
6286
6316
  });
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 }
6317
+
6318
+ //#endregion
6319
+ //#region src/lib/fingerprint.ts
6320
+ var FingerprintError = class extends Data.TaggedError("FingerprintError") {};
6321
+ const runFingerprintFull = (projectRoot) => Effect.gen(function* () {
6322
+ const cmd = Command.make("bunx", "@expo/fingerprint", projectRoot).pipe(Command.workingDirectory(projectRoot));
6323
+ const stdout = yield* Command.string(cmd).pipe(Effect.mapError((cause) => new FingerprintError({ message: `Failed to run "@expo/fingerprint": ${cause.message}` })));
6324
+ const parsed = yield* Effect.try({
6325
+ try: () => JSON.parse(stdout),
6326
+ catch: () => new FingerprintError({ message: "Failed to parse @expo/fingerprint output as JSON." })
6319
6327
  });
6320
- return created;
6328
+ if (!isRecord(parsed)) return yield* new FingerprintError({ message: "@expo/fingerprint output was not a JSON object." });
6329
+ const { hash } = parsed;
6330
+ if (typeof hash !== "string" || hash.length === 0) return yield* new FingerprintError({ message: "@expo/fingerprint output did not contain a \"hash\" string field." });
6331
+ const sourcesRaw = parsed["sources"];
6332
+ return {
6333
+ hash,
6334
+ sources: Array.isArray(sourcesRaw) ? sourcesRaw : []
6335
+ };
6336
+ });
6337
+
6338
+ //#endregion
6339
+ //#region src/lib/runtime-version.ts
6340
+ const resolveRuntimeVersion = ({ raw, appVersion, projectRoot }) => Effect.gen(function* () {
6341
+ if (typeof raw === "string") return raw;
6342
+ if (raw === void 0) return yield* new RuntimeVersionError({ message: "No runtimeVersion configured in expo section of app.json." });
6343
+ const { policy } = raw;
6344
+ if (policy === "appVersion") {
6345
+ if (appVersion === void 0) return yield* new RuntimeVersionError({ message: "runtimeVersion policy is \"appVersion\" but expo.version is missing in app.json." });
6346
+ return appVersion;
6347
+ }
6348
+ if (policy === "fingerprint") return yield* runFingerprintFull(projectRoot).pipe(Effect.map((result) => result.hash), Effect.mapError((cause) => new RuntimeVersionError({ message: cause.message })));
6349
+ if (policy === "nativeVersion") return yield* new RuntimeVersionError({ message: "runtimeVersion policy \"nativeVersion\" is not supported. Set a static runtimeVersion string in your Expo config." });
6350
+ return yield* new RuntimeVersionError({ message: `Unsupported runtimeVersion policy "${policy}". Use a static string, "appVersion", or "fingerprint".` });
6321
6351
  });
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
6352
 
6341
6353
  //#endregion
6342
6354
  //#region src/application/build-workflow.ts
@@ -6363,7 +6375,8 @@ const runIosPlatformBuild = (input) => Effect.gen(function* () {
6363
6375
  envVars,
6364
6376
  projectId,
6365
6377
  credentialsSource,
6366
- rawOutput: options.rawOutput
6378
+ rawOutput: options.rawOutput,
6379
+ freezeCredentials: options.freezeCredentials ?? false
6367
6380
  }),
6368
6381
  target: isSimulator ? {
6369
6382
  platform: "ios",