@better-update/cli 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -11,7 +11,7 @@ import AppleUtils from "@expo/apple-utils";
11
11
  import { autocomplete, cancel, confirm, isCancel, multiselect, password, select, text } from "@clack/prompts";
12
12
  import { open, readFile, writeFile } from "node:fs/promises";
13
13
  import { X509Certificate, createHash, createSign, createVerify, randomBytes, randomUUID } from "node:crypto";
14
- import { accessSync, chmodSync, constants, createReadStream, existsSync, promises, readFileSync, writeFileSync } from "node:fs";
14
+ import { accessSync, chmodSync, constants, createReadStream, promises } from "node:fs";
15
15
  import { Entry } from "@napi-rs/keyring";
16
16
  import { once } from "node:events";
17
17
  import { createServer } from "node:http";
@@ -34,7 +34,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
34
34
 
35
35
  //#endregion
36
36
  //#region package.json
37
- var version = "0.25.0";
37
+ var version = "0.27.0";
38
38
 
39
39
  //#endregion
40
40
  //#region src/lib/interactive-mode.ts
@@ -67,6 +67,7 @@ const cookieSecurity = HttpApiSecurity.apiKey({
67
67
  key: "__Secure-better-auth.session_token",
68
68
  in: "cookie"
69
69
  });
70
+ /** @effect-expect-leaking HttpServerRequest | ParsedSearchParams | RouteContext */
70
71
  var Authentication = class extends HttpApiMiddleware.Tag()("api/Authentication", {
71
72
  failure: Schema.Union(Unauthorized, Forbidden),
72
73
  provides: AuthContext,
@@ -256,7 +257,7 @@ var AnalyticsGroup = class extends HttpApiGroup.make("analytics").add(HttpApiEnd
256
257
 
257
258
  //#endregion
258
259
  //#region ../../packages/api/src/domain/android-application-identifier.ts
259
- const AndroidPackageName = Schema.String.pipe(Schema.pattern(/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/u, { message: () => "Package name must be reverse-domain style (e.g., com.acme.app)" }));
260
+ const AndroidPackageName = Schema.String.pipe(Schema.pattern(/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/u, { message: () => "Package name must be reverse-domain style (e.g., com.acme.app)" }));
260
261
  var AndroidApplicationIdentifier = class extends Schema.Class("AndroidApplicationIdentifier")({
261
262
  id: Id,
262
263
  organizationId: Id,
@@ -1177,6 +1178,44 @@ var BuildsGroup = class extends HttpApiGroup.make("builds").add(HttpApiEndpoint.
1177
1178
  description: "Build artifact upload, tracking, and download endpoints"
1178
1179
  })) {};
1179
1180
 
1181
+ //#endregion
1182
+ //#region ../../packages/api/src/domain/channel-grant.ts
1183
+ const GrantEffectSchema = Schema.Literal("allow", "deny");
1184
+ /** One member's allow/deny set on a channel; `actions` are "resource:action". */
1185
+ var ChannelGrant = class extends Schema.Class("ChannelGrant")({
1186
+ id: Id,
1187
+ memberId: Id,
1188
+ scopeKind: Schema.Literal("channel"),
1189
+ scopeId: Id,
1190
+ effect: GrantEffectSchema,
1191
+ actions: Schema.Array(Schema.String),
1192
+ createdAt: DateTimeString
1193
+ }) {};
1194
+ /** Upsert one (member, channel, effect) grant. effect defaults to "allow". */
1195
+ const UpsertChannelGrantBody = Schema.Struct({
1196
+ effect: Schema.optionalWith(GrantEffectSchema, { default: () => "allow" }),
1197
+ actions: Schema.Array(Schema.String).pipe(Schema.minItems(1))
1198
+ });
1199
+ const ListChannelGrantsParams = Schema.Struct({});
1200
+ const DeleteChannelGrantResult = Schema.Struct({ deleted: Schema.Number });
1201
+
1202
+ //#endregion
1203
+ //#region ../../packages/api/src/groups/channel-grants.ts
1204
+ const memberIdParam = HttpApiSchema.param("memberId", Schema.String);
1205
+ var ChannelGrantsGroup = class extends HttpApiGroup.make("channelGrants").add(HttpApiEndpoint.get("list")`/api/channels/${idParam}/grants`.setUrlParams(ListChannelGrantsParams).addSuccess(Schema.Array(ChannelGrant)).annotateContext(OpenApi.annotations({
1206
+ title: "List channel grants",
1207
+ description: "List all per-member allow/deny grants on a channel"
1208
+ }))).add(HttpApiEndpoint.put("upsert")`/api/channels/${idParam}/grants/${memberIdParam}`.setPayload(UpsertChannelGrantBody).addSuccess(ChannelGrant).annotateContext(OpenApi.annotations({
1209
+ title: "Upsert channel grant",
1210
+ description: "Create or replace a member's allow/deny grant on a channel"
1211
+ }))).add(HttpApiEndpoint.del("delete")`/api/channels/${idParam}/grants/${memberIdParam}`.addSuccess(DeleteChannelGrantResult).annotateContext(OpenApi.annotations({
1212
+ title: "Delete channel grant",
1213
+ description: "Revoke a member's grants on a channel"
1214
+ }))).addError(NotFound).addError(Forbidden).annotateContext(OpenApi.annotations({
1215
+ title: "Channel grants",
1216
+ description: "Per-channel ABAC permission grants (allow/deny by member)"
1217
+ })) {};
1218
+
1180
1219
  //#endregion
1181
1220
  //#region ../../packages/api/src/domain/channel.ts
1182
1221
  var Channel = class extends Schema.Class("Channel")({
@@ -1432,6 +1471,71 @@ const EnvVarRevision = Schema.Struct({
1432
1471
  const EnvVarRevisionsResult = Schema.Struct({ items: Schema.Array(EnvVarRevision) });
1433
1472
  const RollbackEnvVarBody = Schema.Struct({ toRevisionId: Id });
1434
1473
 
1474
+ //#endregion
1475
+ //#region ../../packages/api/src/domain/env-grant.ts
1476
+ /**
1477
+ * One member's allow/deny set on a (project × environment) env-var scope.
1478
+ * `scopeKind` is fixed to "env_var_environment". `scopeId` is the encoded
1479
+ * `<projectId|global>:<environment>` token (server-built). `actions` are
1480
+ * "resource:action" tokens (here always envVar:*).
1481
+ */
1482
+ var EnvGrant = class extends Schema.Class("EnvGrant")({
1483
+ id: Id,
1484
+ memberId: Id,
1485
+ scopeKind: Schema.Literal("env_var_environment"),
1486
+ scopeId: Schema.String,
1487
+ effect: GrantEffectSchema,
1488
+ actions: Schema.Array(Schema.String),
1489
+ createdAt: DateTimeString
1490
+ }) {};
1491
+ /** A flattened row for the list UI: one member × environment cell. */
1492
+ var EnvGrantRow = class extends Schema.Class("EnvGrantRow")({
1493
+ memberId: Id,
1494
+ environment: EnvVarEnvironment,
1495
+ effect: GrantEffectSchema,
1496
+ actions: Schema.Array(Schema.String)
1497
+ }) {};
1498
+ /**
1499
+ * URL params for listing grants on a project-or-global scope. `projectId` is the
1500
+ * sentinel "global" or a real project id (the server resolves null vs the
1501
+ * sentinel). Carried as a query param.
1502
+ */
1503
+ const ListEnvGrantsParams = Schema.Struct({ projectId: Schema.String });
1504
+ /**
1505
+ * Upsert one (member, project-or-global, environment) grant. `projectId` null =
1506
+ * org-global vault. effect defaults to "allow". actions are envVar:* tokens.
1507
+ */
1508
+ const UpsertEnvGrantBody = Schema.Struct({
1509
+ memberId: Id,
1510
+ projectId: Schema.NullOr(Id),
1511
+ environment: EnvVarEnvironment,
1512
+ effect: Schema.optionalWith(GrantEffectSchema, { default: () => "allow" }),
1513
+ actions: Schema.Array(Schema.String).pipe(Schema.minItems(1))
1514
+ });
1515
+ /** Delete both effects for (member, project-or-global, environment). */
1516
+ const DeleteEnvGrantBody = Schema.Struct({
1517
+ memberId: Id,
1518
+ projectId: Schema.NullOr(Id),
1519
+ environment: EnvVarEnvironment
1520
+ });
1521
+ const DeleteEnvGrantResult = Schema.Struct({ deleted: Schema.Number });
1522
+
1523
+ //#endregion
1524
+ //#region ../../packages/api/src/groups/env-grants.ts
1525
+ var EnvGrantsGroup = class extends HttpApiGroup.make("envGrants").add(HttpApiEndpoint.get("list", "/api/env-grants").setUrlParams(ListEnvGrantsParams).addSuccess(Schema.Array(EnvGrantRow)).annotateContext(OpenApi.annotations({
1526
+ title: "List env-var environment grants",
1527
+ description: "List per-member allow/deny env-var grants on a project-or-global scope across all environments. projectId is a real id or the sentinel 'global'."
1528
+ }))).add(HttpApiEndpoint.put("upsert", "/api/env-grants").setPayload(UpsertEnvGrantBody).addSuccess(EnvGrant).annotateContext(OpenApi.annotations({
1529
+ title: "Upsert env-var environment grant",
1530
+ description: "Create or replace a member's allow/deny env-var grant on one (project-or-global × environment) scope. projectId null = org-global."
1531
+ }))).add(HttpApiEndpoint.del("delete", "/api/env-grants").setPayload(DeleteEnvGrantBody).addSuccess(DeleteEnvGrantResult).annotateContext(OpenApi.annotations({
1532
+ title: "Delete env-var environment grants",
1533
+ description: "Revoke both allow and deny grants for a member on one (project-or-global × environment) scope."
1534
+ }))).addError(NotFound).addError(Forbidden).addError(BadRequest).annotateContext(OpenApi.annotations({
1535
+ title: "Env-var environment grants",
1536
+ description: "Per (project × environment) ABAC permission grants for env vars (allow/deny by member)"
1537
+ })) {};
1538
+
1435
1539
  //#endregion
1436
1540
  //#region ../../packages/api/src/groups/env-vars.ts
1437
1541
  var EnvVarsGroup = class extends HttpApiGroup.make("env-vars").add(HttpApiEndpoint.post("create", "/api/env-vars").setPayload(CreateEnvVarBody).addSuccess(EnvVar, { status: 201 }).annotateContext(OpenApi.annotations({
@@ -1805,6 +1909,54 @@ var MeGroup = class extends HttpApiGroup.make("me").add(HttpApiEndpoint.get("get
1805
1909
  description: "Current authenticated actor information"
1806
1910
  })) {};
1807
1911
 
1912
+ //#endregion
1913
+ //#region ../../packages/api/src/domain/org-role.ts
1914
+ /** One resource→actions grant inside a role's permission set. */
1915
+ const PermissionGrantSchema = Schema.Struct({
1916
+ resource: Schema.String,
1917
+ actions: Schema.Array(Schema.String)
1918
+ });
1919
+ var OrgRole = class extends Schema.Class("OrgRole")({
1920
+ id: Id,
1921
+ organizationId: Id,
1922
+ role: Schema.String,
1923
+ permissions: Schema.Array(PermissionGrantSchema),
1924
+ createdAt: DateTimeString,
1925
+ updatedAt: Schema.NullOr(DateTimeString)
1926
+ }) {};
1927
+ const CreateOrgRoleBody = Schema.Struct({
1928
+ name: Schema.String.pipe(Schema.minLength(1)),
1929
+ permissions: Schema.Array(PermissionGrantSchema)
1930
+ });
1931
+ const UpdateOrgRoleBody = Schema.Struct({
1932
+ permissions: Schema.optional(Schema.Array(PermissionGrantSchema)),
1933
+ name: Schema.optional(Schema.String.pipe(Schema.minLength(1)))
1934
+ });
1935
+ const ListOrgRolesParams = Schema.Struct({ organizationId: Id });
1936
+ const DeleteOrgRoleResult = Schema.Struct({ deleted: Schema.Number });
1937
+
1938
+ //#endregion
1939
+ //#region ../../packages/api/src/groups/org-roles.ts
1940
+ var OrgRolesGroup = class extends HttpApiGroup.make("roles").add(HttpApiEndpoint.get("list", "/api/roles").setUrlParams(ListOrgRolesParams).addSuccess(Schema.Array(OrgRole)).annotateContext(OpenApi.annotations({
1941
+ title: "List custom roles",
1942
+ description: "List all custom roles defined for an organization"
1943
+ }))).add(HttpApiEndpoint.post("create", "/api/roles").setPayload(CreateOrgRoleBody).addSuccess(OrgRole, { status: 201 }).annotateContext(OpenApi.annotations({
1944
+ title: "Create custom role",
1945
+ description: "Create a new custom role with a permission set"
1946
+ }))).add(HttpApiEndpoint.get("get")`/api/roles/${idParam}`.addSuccess(OrgRole).annotateContext(OpenApi.annotations({
1947
+ title: "Get custom role",
1948
+ description: "Fetch a single custom role by id"
1949
+ }))).add(HttpApiEndpoint.patch("update")`/api/roles/${idParam}`.setPayload(UpdateOrgRoleBody).addSuccess(OrgRole).annotateContext(OpenApi.annotations({
1950
+ title: "Update custom role",
1951
+ description: "Rename a custom role or replace its permission set"
1952
+ }))).add(HttpApiEndpoint.del("delete")`/api/roles/${idParam}`.addSuccess(DeleteOrgRoleResult).annotateContext(OpenApi.annotations({
1953
+ title: "Delete custom role",
1954
+ description: "Delete a custom role"
1955
+ }))).addError(NotFound).addError(Conflict).addError(Forbidden).annotateContext(OpenApi.annotations({
1956
+ title: "Roles",
1957
+ description: "Custom organization role management (dynamic access control)"
1958
+ })) {};
1959
+
1808
1960
  //#endregion
1809
1961
  //#region ../../packages/api/src/groups/org-vault.ts
1810
1962
  /** `:keyId` path parameter — a registered recipient's `user_encryption_keys.id`. */
@@ -2155,7 +2307,7 @@ var WebhooksGroup = class extends HttpApiGroup.make("webhooks").add(HttpApiEndpo
2155
2307
 
2156
2308
  //#endregion
2157
2309
  //#region ../../packages/api/src/api.ts
2158
- var ManagementApi = class extends HttpApi.make("management-api").add(ProjectsGroup).add(BranchesGroup).add(ChannelsGroup).add(UpdatesGroup).add(AssetsGroup).add(AnalyticsGroup).add(BuildsGroup).add(EnvVarsGroup).add(FingerprintsGroup).add(AuditLogsGroup).add(DevicesGroup).add(AppleTeamsGroup).add(AppleDistributionCertificatesGroup).add(ApplePushKeysGroup).add(AscApiKeysGroup).add(AppleProvisioningProfilesGroup).add(GoogleServiceAccountKeysGroup).add(IosBundleConfigurationsGroup).add(IosAppMetadataGroup).add(SubmissionsGroup).add(AndroidApplicationIdentifiersGroup).add(AndroidUploadKeystoresGroup).add(AndroidBuildCredentialsGroup).add(BuildCredentialsGroup).add(UserEncryptionKeysGroup).add(OrgVaultGroup).add(MeGroup).add(WebhooksGroup).add(AdminGroup).middleware(Authentication).annotateContext(OpenApi.annotations({
2310
+ var ManagementApi = class extends HttpApi.make("management-api").add(ProjectsGroup).add(BranchesGroup).add(ChannelsGroup).add(UpdatesGroup).add(AssetsGroup).add(AnalyticsGroup).add(BuildsGroup).add(EnvVarsGroup).add(FingerprintsGroup).add(AuditLogsGroup).add(DevicesGroup).add(AppleTeamsGroup).add(AppleDistributionCertificatesGroup).add(ApplePushKeysGroup).add(AscApiKeysGroup).add(AppleProvisioningProfilesGroup).add(GoogleServiceAccountKeysGroup).add(IosBundleConfigurationsGroup).add(IosAppMetadataGroup).add(SubmissionsGroup).add(AndroidApplicationIdentifiersGroup).add(AndroidUploadKeystoresGroup).add(AndroidBuildCredentialsGroup).add(BuildCredentialsGroup).add(UserEncryptionKeysGroup).add(OrgVaultGroup).add(MeGroup).add(WebhooksGroup).add(AdminGroup).add(OrgRolesGroup).add(ChannelGrantsGroup).add(EnvGrantsGroup).middleware(Authentication).annotateContext(OpenApi.annotations({
2159
2311
  title: "Better Update Management API",
2160
2312
  version: "1.0.0",
2161
2313
  description: "Management API for OTA update publishing, deployment, and analytics"
@@ -2308,13 +2460,13 @@ const ConfigStoreLive = Layer.effect(ConfigStore, Effect.gen(function* () {
2308
2460
  const runtime = yield* CliRuntime;
2309
2461
  const homeDirectory = yield* runtime.homeDirectory;
2310
2462
  const configFile = path.join(homeDirectory, ".better-update", "config.json");
2311
- const readConfig = fs.readFileString(configFile).pipe(Effect.catchAll(() => Effect.succeed("")), Effect.flatMap((content) => content.length === 0 ? Effect.succeed(void 0) : Effect.try({
2463
+ const readConfig = fs.readFileString(configFile).pipe(Effect.orElseSucceed(() => ""), Effect.flatMap((content) => content.length === 0 ? Effect.void : Effect.try({
2312
2464
  try: () => JSON.parse(content),
2313
2465
  catch: (cause) => new ConfigStoreParseError({
2314
2466
  message: "Config file contains invalid JSON",
2315
2467
  cause
2316
2468
  })
2317
- }).pipe(Effect.map((parsed) => isRecord(parsed) ? parsed : void 0), Effect.catchAll(() => Effect.succeed(void 0)))));
2469
+ }).pipe(Effect.map((parsed) => isRecord(parsed) ? parsed : void 0), Effect.orElseSucceed(() => void 0))));
2318
2470
  return {
2319
2471
  getBaseUrl: Effect.gen(function* () {
2320
2472
  const envUrl = yield* runtime.getEnv("BETTER_UPDATE_URL");
@@ -2537,7 +2689,7 @@ const AppleSessionStoreLive = Layer.effect(AppleSessionStore, Effect.gen(functio
2537
2689
  const usernameFile = path.join(sessionDir, "apple-username.json");
2538
2690
  return {
2539
2691
  loadSession: Effect.gen(function* () {
2540
- const content = yield* fs.readFileString(sessionFile).pipe(Effect.catchAll(() => Effect.succeed(null)));
2692
+ const content = yield* fs.readFileString(sessionFile).pipe(Effect.orElseSucceed(() => null));
2541
2693
  if (!content) return null;
2542
2694
  const parsed = safeJsonParse(content);
2543
2695
  if (!isRecord(parsed)) return null;
@@ -2555,7 +2707,7 @@ const AppleSessionStoreLive = Layer.effect(AppleSessionStore, Effect.gen(functio
2555
2707
  }).pipe(Effect.mapError((cause) => new AppleAuthError$1({ message: `Failed to save Apple session: ${formatCause(cause)}` }))),
2556
2708
  clearSession: fs.remove(sessionFile).pipe(Effect.catchAll(() => Effect.void)),
2557
2709
  loadLastUsername: Effect.gen(function* () {
2558
- const content = yield* fs.readFileString(usernameFile).pipe(Effect.catchAll(() => Effect.succeed(null)));
2710
+ const content = yield* fs.readFileString(usernameFile).pipe(Effect.orElseSucceed(() => null));
2559
2711
  if (!content) return null;
2560
2712
  const parsed = safeJsonParse(content);
2561
2713
  if (!isRecord(parsed) || typeof parsed["username"] !== "string") return null;
@@ -2651,7 +2803,7 @@ const interactiveLogin = (appleUtils, options, cachedUsername) => Effect.gen(fun
2651
2803
  const tryRestore = (appleUtils, store) => Effect.gen(function* () {
2652
2804
  const stored = yield* store.loadSession;
2653
2805
  if (stored === null) return null;
2654
- const restored = yield* restoreFromCookies(appleUtils, stored.cookies).pipe(Effect.catchAll(() => Effect.succeed(null)));
2806
+ const restored = yield* restoreFromCookies(appleUtils, stored.cookies).pipe(Effect.orElseSucceed(() => null));
2655
2807
  if (restored === null) return null;
2656
2808
  return yield* resolveSessionTeam(appleUtils, restored);
2657
2809
  });
@@ -2670,7 +2822,7 @@ const makeAppleAuthLive = (appleUtils = defaultAppleUtils) => Layer.effect(Apple
2670
2822
  whoami: Effect.gen(function* () {
2671
2823
  const stored = yield* store.loadSession;
2672
2824
  if (stored === null) return null;
2673
- const restored = yield* restoreFromCookies(appleUtils, stored.cookies).pipe(Effect.catchAll(() => Effect.succeed(null)));
2825
+ const restored = yield* restoreFromCookies(appleUtils, stored.cookies).pipe(Effect.orElseSucceed(() => null));
2674
2826
  if (restored !== null) return sessionFromAuthState(restored);
2675
2827
  const info = appleUtils.Session.getAnySessionInfo();
2676
2828
  return info === null ? null : sessionFromInfo(stored.username, info);
@@ -2756,7 +2908,7 @@ const IdentityStoreLive = Layer.effect(IdentityStore, Effect.gen(function* () {
2756
2908
  const identityFile = path.join(identityDir, "identity.json");
2757
2909
  return {
2758
2910
  load: Effect.gen(function* () {
2759
- const content = yield* fs.readFileString(identityFile).pipe(Effect.catchAll(() => Effect.succeed("")));
2911
+ const content = yield* fs.readFileString(identityFile).pipe(Effect.orElseSucceed(() => ""));
2760
2912
  if (content.length === 0) return null;
2761
2913
  const parsed = safeJsonParse(content);
2762
2914
  return isIdentityFile(parsed) ? parsed : null;
@@ -2980,7 +3132,7 @@ const VaultCacheLive = Layer.effect(VaultCache, Effect.gen(function* () {
2980
3132
  const flag = yield* runtime.getEnv("BETTER_UPDATE_NO_CACHE");
2981
3133
  return flag !== void 0 && flag.length > 0 && flag !== "0" && flag !== "false";
2982
3134
  });
2983
- const readRaw = (publicKey) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, publicKey).getPassword()).pipe(Effect.catchAll(() => Effect.succeed(null)));
3135
+ const readRaw = (publicKey) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, publicKey).getPassword()).pipe(Effect.orElseSucceed(() => null));
2984
3136
  const writeRaw = (publicKey, blob) => Effect.try(() => {
2985
3137
  new Entry(KEYCHAIN_SERVICE, publicKey).setPassword(blob);
2986
3138
  }).pipe(Effect.ignore);
@@ -3018,12 +3170,12 @@ const VersionCheckLive = Layer.effect(VersionCheck, Effect.gen(function* () {
3018
3170
  const cacheDir = path.join(homeDirectory, ".better-update");
3019
3171
  const cacheFile = path.join(cacheDir, "version-check.json");
3020
3172
  const readCache = Effect.gen(function* () {
3021
- const content = yield* fs.readFileString(cacheFile).pipe(Effect.catchAll(() => Effect.succeed("")));
3173
+ const content = yield* fs.readFileString(cacheFile).pipe(Effect.orElseSucceed(() => ""));
3022
3174
  if (content.length === 0) return;
3023
3175
  const parsed = yield* Effect.try({
3024
3176
  try: () => JSON.parse(content),
3025
3177
  catch: () => "parse-error"
3026
- }).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
3178
+ }).pipe(Effect.orElseSucceed(() => void 0));
3027
3179
  if (isRecord(parsed) && typeof parsed["latest"] === "string" && typeof parsed["checkedAt"] === "number") return {
3028
3180
  latest: parsed["latest"],
3029
3181
  checkedAt: parsed["checkedAt"]
@@ -3224,7 +3376,7 @@ const buildOpenBrowserCommand = (platform, url) => {
3224
3376
  };
3225
3377
  const openBrowser = (url) => Effect.gen(function* () {
3226
3378
  const command = buildOpenBrowserCommand((yield* CliRuntime).platform, url);
3227
- if (!(yield* Command.exitCode(command).pipe(Effect.map((code) => code === 0), Effect.catchAll(() => Effect.succeed(false))))) yield* Console.log(`Open this URL manually:\n${url}`);
3379
+ if (!(yield* Command.exitCode(command).pipe(Effect.map((code) => code === 0), Effect.orElseSucceed(() => false)))) yield* Console.log(`Open this URL manually:\n${url}`);
3228
3380
  });
3229
3381
  const browserLogin = Effect.scoped(Effect.gen(function* () {
3230
3382
  const configStore = yield* ConfigStore;
@@ -3623,9 +3775,9 @@ const configPath = (projectRoot) => path.join(projectRoot, BETTER_UPDATE_CONFIG_
3623
3775
  * graceful `readConfig`.
3624
3776
  */
3625
3777
  const readBetterUpdateConfig = (projectRoot) => Effect.gen(function* () {
3626
- const content = yield* (yield* FileSystem.FileSystem).readFileString(configPath(projectRoot)).pipe(Effect.catchAll(() => Effect.succeed("")));
3778
+ const content = yield* (yield* FileSystem.FileSystem).readFileString(configPath(projectRoot)).pipe(Effect.orElseSucceed(() => ""));
3627
3779
  if (content.length === 0) return;
3628
- return yield* Effect.try(() => JSON.parse(content)).pipe(Effect.map((parsed) => isRecord(parsed) ? parsed : void 0), Effect.catchAll(() => Effect.succeed(void 0)));
3780
+ return yield* Effect.try(() => JSON.parse(content)).pipe(Effect.map((parsed) => isRecord(parsed) ? parsed : void 0), Effect.orElseSucceed(() => void 0));
3629
3781
  });
3630
3782
  /**
3631
3783
  * Resolve the linked project id from `better-update.json`, or `undefined` when
@@ -4354,14 +4506,8 @@ const KeyValueFromString = Schema.transformOrFail(Schema.String, KeyValuePair, {
4354
4506
  },
4355
4507
  encode: ({ key, value }) => ParseResult.succeed(`${key}=${value}`)
4356
4508
  });
4357
- const parseRolloutPercentage = (raw, flag) => Effect.try({
4358
- try: () => Schema.decodeUnknownSync(RolloutPercentage)(Number(raw)),
4359
- catch: () => new InvalidArgumentError({ message: `--${flag} must be an integer between 1 and 100, got "${raw}".` })
4360
- });
4361
- const parseKeyValue = (raw) => Effect.try({
4362
- try: () => Schema.decodeUnknownSync(KeyValueFromString)(raw),
4363
- catch: () => new InvalidArgumentError({ message: "Invalid format. Use KEY=VALUE (e.g. API_KEY=abc123)" })
4364
- });
4509
+ const parseRolloutPercentage = (raw, flag) => Schema.decodeUnknown(RolloutPercentage)(Number(raw)).pipe(Effect.mapError(() => new InvalidArgumentError({ message: `--${flag} must be an integer between 1 and 100, got "${raw}".` })));
4510
+ const parseKeyValue = (raw) => Schema.decodeUnknown(KeyValueFromString)(raw).pipe(Effect.mapError(() => new InvalidArgumentError({ message: "Invalid format. Use KEY=VALUE (e.g. API_KEY=abc123)" })));
4365
4511
  const parseLimit = (raw, defaultValue) => {
4366
4512
  if (raw === void 0) return Effect.succeed(defaultValue);
4367
4513
  const parsed = Number(raw);
@@ -4371,7 +4517,7 @@ const parseLimit = (raw, defaultValue) => {
4371
4517
 
4372
4518
  //#endregion
4373
4519
  //#region src/commands/audit-logs/list.ts
4374
- const listCommand$9 = defineCommand({
4520
+ const listCommand$12 = defineCommand({
4375
4521
  meta: {
4376
4522
  name: "list",
4377
4523
  description: "List audit log entries"
@@ -4433,7 +4579,7 @@ const auditLogsCommand = defineCommand({
4433
4579
  name: "audit-logs",
4434
4580
  description: "View audit logs"
4435
4581
  },
4436
- subCommands: { list: listCommand$9 }
4582
+ subCommands: { list: listCommand$12 }
4437
4583
  });
4438
4584
 
4439
4585
  //#endregion
@@ -4537,7 +4683,7 @@ const drainPages = (fetchPage) => {
4537
4683
 
4538
4684
  //#endregion
4539
4685
  //#region src/commands/branches.ts
4540
- const listCommand$8 = defineCommand({
4686
+ const listCommand$11 = defineCommand({
4541
4687
  meta: {
4542
4688
  name: "list",
4543
4689
  description: "List branches for the linked project"
@@ -4560,7 +4706,7 @@ const listCommand$8 = defineCommand({
4560
4706
  ]), "No branches found.");
4561
4707
  }))
4562
4708
  });
4563
- const createCommand$4 = defineCommand({
4709
+ const createCommand$5 = defineCommand({
4564
4710
  meta: {
4565
4711
  name: "create",
4566
4712
  description: "Create a branch"
@@ -4583,7 +4729,7 @@ const createCommand$4 = defineCommand({
4583
4729
  ]);
4584
4730
  }))
4585
4731
  });
4586
- const viewCommand$3 = defineCommand({
4732
+ const viewCommand$4 = defineCommand({
4587
4733
  meta: {
4588
4734
  name: "view",
4589
4735
  description: "Show a branch by ID or name"
@@ -4641,7 +4787,7 @@ const renameCommand$1 = defineCommand({
4641
4787
  return branch;
4642
4788
  }), { json: "value" })
4643
4789
  });
4644
- const deleteCommand$6 = defineCommand({
4790
+ const deleteCommand$7 = defineCommand({
4645
4791
  meta: {
4646
4792
  name: "delete",
4647
4793
  description: "Delete a branch"
@@ -4666,11 +4812,11 @@ const branchesCommand = defineCommand({
4666
4812
  description: "Manage branches"
4667
4813
  },
4668
4814
  subCommands: {
4669
- list: listCommand$8,
4670
- view: viewCommand$3,
4671
- create: createCommand$4,
4815
+ list: listCommand$11,
4816
+ view: viewCommand$4,
4817
+ create: createCommand$5,
4672
4818
  rename: renameCommand$1,
4673
- delete: deleteCommand$6
4819
+ delete: deleteCommand$7
4674
4820
  }
4675
4821
  });
4676
4822
 
@@ -4751,8 +4897,31 @@ const findIosArtifact = ({ exportPath }) => Effect.gen(function* () {
4751
4897
  if (!picked) return yield* new ArtifactNotFoundError({ message: `No .ipa file found under "${exportPath}".` });
4752
4898
  return picked.path;
4753
4899
  });
4754
- const findAndroidArtifact = ({ projectRoot, format, flavor, buildType, minMtimeMs }) => Effect.gen(function* () {
4755
- const outputsRoot = path.join(projectRoot, "android", "app", "build", "outputs");
4900
+ /**
4901
+ * Resolve a custom-command build artifact from a user-supplied path. A pattern
4902
+ * without wildcards is treated as a literal path (relative to `baseDir`);
4903
+ * otherwise the fixed leading directory + file extension are extracted and the
4904
+ * newest matching file under that directory is returned.
4905
+ */
4906
+ const findArtifactByGlob = ({ baseDir, pattern, minMtimeMs }) => Effect.gen(function* () {
4907
+ const fs = yield* FileSystem.FileSystem;
4908
+ if (!/[*?[]/u.test(pattern)) {
4909
+ const full = path.isAbsolute(pattern) ? pattern : path.join(baseDir, pattern);
4910
+ if (yield* fs.exists(full).pipe(Effect.orElseSucceed(() => false))) return full;
4911
+ return yield* new ArtifactNotFoundError({ message: `No artifact found at "${full}".` });
4912
+ }
4913
+ const extension = path.extname(pattern).toLowerCase();
4914
+ if (extension === "") return yield* new ArtifactNotFoundError({ message: `artifactPath "${pattern}" must end in a file extension (e.g. **/*.aab).` });
4915
+ const wildcardIndex = pattern.search(/[*?[]/u);
4916
+ const fixedPrefix = pattern.slice(0, wildcardIndex);
4917
+ const prefixDir = fixedPrefix.includes("/") ? fixedPrefix.slice(0, fixedPrefix.lastIndexOf("/")) : "";
4918
+ const searchRoot = prefixDir === "" ? baseDir : path.join(baseDir, prefixDir);
4919
+ const picked = newest(yield* walkAndFind(searchRoot, extension), minMtimeMs);
4920
+ if (!picked) return yield* new ArtifactNotFoundError({ message: `No file matching "${pattern}" found under "${searchRoot}".` });
4921
+ return picked.path;
4922
+ });
4923
+ const findAndroidArtifact = ({ projectRoot, format, flavor, buildType, minMtimeMs, module: gradleModule = "app" }) => Effect.gen(function* () {
4924
+ const outputsRoot = path.join(projectRoot, "android", gradleModule, "build", "outputs");
4756
4925
  const subdir = format === "aab" ? "bundle" : "apk";
4757
4926
  const variantDir = flavor ? `${flavor}${capitalize(buildType)}` : buildType;
4758
4927
  const pickedDirect = newest(yield* walkAndFind(path.join(outputsRoot, subdir, variantDir), `.${format}`), minMtimeMs);
@@ -18686,7 +18855,7 @@ const asArrayBuffer$1 = (bytes) => {
18686
18855
  };
18687
18856
  const signAscJwt = (credentials) => Effect.gen(function* () {
18688
18857
  const der = pemToPkcs8Der(credentials.p8Pem);
18689
- if (der === null) return yield* Effect.fail(new AppleAuthError({ cause: /* @__PURE__ */ new Error("Invalid .p8 PEM") }));
18858
+ if (der === null) return yield* new AppleAuthError({ cause: /* @__PURE__ */ new Error("Invalid .p8 PEM") });
18690
18859
  const header = {
18691
18860
  alg: "ES256",
18692
18861
  kid: credentials.keyId,
@@ -18770,7 +18939,7 @@ const fetchRaw = (jwt, path, init) => Effect.gen(function* () {
18770
18939
  try: () => text.length === 0 ? {} : JSON.parse(text),
18771
18940
  catch: (cause) => new AscNetworkError({ cause })
18772
18941
  });
18773
- if (!response.ok) return yield* Effect.fail(parseApiError(response, body, text));
18942
+ if (!response.ok) return yield* parseApiError(response, body, text);
18774
18943
  return body;
18775
18944
  });
18776
18945
  const toAscCertificate = (value) => {
@@ -18870,12 +19039,10 @@ const createCertificate = (credentials, params) => withJwt(credentials, (jwt) =>
18870
19039
  }
18871
19040
  } })
18872
19041
  }), toAscCertificate);
18873
- if (resource === null) return yield* Effect.fail(malformed("certificate"));
19042
+ if (resource === null) return yield* malformed("certificate");
18874
19043
  return resource;
18875
19044
  }));
18876
- const deleteCertificate = (credentials, id) => withJwt(credentials, (jwt) => Effect.gen(function* () {
18877
- yield* fetchRaw(jwt, `/v1/certificates/${encodeURIComponent(id)}`, { method: "DELETE" });
18878
- }));
19045
+ const deleteCertificate = (credentials, id) => withJwt(credentials, (jwt) => Effect.asVoid(fetchRaw(jwt, `/v1/certificates/${encodeURIComponent(id)}`, { method: "DELETE" })));
18879
19046
  const listBundleIds = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
18880
19047
  return extractList(yield* fetchRaw(jwt, "/v1/bundleIds?limit=200"), toAscBundleId);
18881
19048
  }));
@@ -18891,7 +19058,7 @@ const createBundleId = (credentials, params) => withJwt(credentials, (jwt) => Ef
18891
19058
  }
18892
19059
  } })
18893
19060
  }), toAscBundleId);
18894
- if (resource === null) return yield* Effect.fail(malformed("bundleId"));
19061
+ if (resource === null) return yield* malformed("bundleId");
18895
19062
  return resource;
18896
19063
  }));
18897
19064
  const listDevices = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
@@ -18923,7 +19090,7 @@ const createProvisioningProfile = (credentials, params) => withJwt(credentials,
18923
19090
  relationships
18924
19091
  } })
18925
19092
  }), toAscProfile);
18926
- if (resource === null) return yield* Effect.fail(malformed("profile"));
19093
+ if (resource === null) return yield* malformed("profile");
18927
19094
  return resource;
18928
19095
  }));
18929
19096
  const isCertificateLimitError = (error) => {
@@ -18940,7 +19107,7 @@ const stringField = (cert, name) => {
18940
19107
  return typeof value === "string" ? value : null;
18941
19108
  };
18942
19109
  const matchTeamFromCommonName = (cn) => {
18943
- const match = /\(([A-Z0-9]{10})\)/u.exec(cn);
19110
+ const match = /\((?<team>[A-Z0-9]{10})\)/u.exec(cn);
18944
19111
  if (match === null) return null;
18945
19112
  const [, captured] = match;
18946
19113
  return captured === void 0 ? null : captured;
@@ -18959,7 +19126,7 @@ const parseCert = (certDerBytes) => {
18959
19126
  const generatePassword = () => forge.util.encode64(forge.random.getBytesSync(16));
18960
19127
  const extractCertMetadata = (cert) => Effect.gen(function* () {
18961
19128
  const appleTeamId = extractTeamId$1(cert);
18962
- if (appleTeamId === null) return yield* Effect.fail(new CertParseError({ message: "Could not extract Apple team identifier from certificate subject" }));
19129
+ if (appleTeamId === null) return yield* new CertParseError({ message: "Could not extract Apple team identifier from certificate subject" });
18963
19130
  return {
18964
19131
  serialNumber: cert.serialNumber.toUpperCase(),
18965
19132
  validFrom: cert.validity.notBefore.toISOString(),
@@ -18977,7 +19144,7 @@ const extractCertMetadata = (cert) => Effect.gen(function* () {
18977
19144
  */
18978
19145
  const extractMetadataFromP12 = (params) => Effect.gen(function* () {
18979
19146
  const certBagOid = forge.pki.oids["certBag"];
18980
- if (certBagOid === void 0) return yield* Effect.fail(new CertParseError({ message: "PKCS#12 OID lookup for certBag failed" }));
19147
+ if (certBagOid === void 0) return yield* new CertParseError({ message: "PKCS#12 OID lookup for certBag failed" });
18981
19148
  const [first] = yield* Effect.try({
18982
19149
  try: () => {
18983
19150
  const p12Der = forge.util.decode64(params.p12Base64);
@@ -18986,7 +19153,7 @@ const extractMetadataFromP12 = (params) => Effect.gen(function* () {
18986
19153
  },
18987
19154
  catch: (error) => new CertParseError({ message: `Failed to parse PKCS#12 bundle: ${error instanceof Error ? error.message : String(error)}` })
18988
19155
  });
18989
- if (first?.cert === void 0) return yield* Effect.fail(new CertParseError({ message: "PKCS#12 bundle does not contain a certificate" }));
19156
+ if (first?.cert === void 0) return yield* new CertParseError({ message: "PKCS#12 bundle does not contain a certificate" });
18990
19157
  return yield* extractCertMetadata(first.cert);
18991
19158
  });
18992
19159
  const buildDistributionCertP12 = (params) => Effect.gen(function* () {
@@ -19167,10 +19334,10 @@ const generateAndUploadDistributionCertificate = (api, input) => Effect.gen(func
19167
19334
  csrPem: csrResult.csrPem,
19168
19335
  certificateType
19169
19336
  }).pipe(Effect.mapError(wrapAscError("apple-create-certificate")));
19170
- if (apple.certificateContent === null) return yield* Effect.fail(new GenerateFailedError({
19337
+ if (apple.certificateContent === null) return yield* new GenerateFailedError({
19171
19338
  step: "apple-create-certificate",
19172
19339
  message: "Apple response missing certificateContent"
19173
- }));
19340
+ });
19174
19341
  const bundle = yield* buildDistributionCertP12({
19175
19342
  certificateContentBase64: apple.certificateContent,
19176
19343
  privateKey: csrResult.privateKey
@@ -19220,10 +19387,10 @@ const revokeAppleCertificate = (api, input) => Effect.gen(function* () {
19220
19387
  });
19221
19388
  const revokeLocalDistributionCertificate = (api, input) => Effect.gen(function* () {
19222
19389
  const local = (yield* api.appleDistributionCertificates.list()).items.find((entry) => entry.id === input.distributionCertificateId);
19223
- if (local === void 0) return yield* Effect.fail(new GenerateFailedError({
19390
+ if (local === void 0) return yield* new GenerateFailedError({
19224
19391
  step: "load-distribution-certificate",
19225
19392
  message: `Distribution certificate ${input.distributionCertificateId} not found on this account`
19226
- }));
19393
+ });
19227
19394
  const creds = yield* fetchAscCredentials(api, input.ascApiKeyId);
19228
19395
  const ascCreds = {
19229
19396
  keyId: creds.keyId,
@@ -19260,10 +19427,10 @@ const listAppleCertificates = (api, input) => Effect.gen(function* () {
19260
19427
  });
19261
19428
  const resolveCertAscId = (creds, serialNumber, certificateType) => Effect.gen(function* () {
19262
19429
  const match = (yield* listCertificates(creds, { certificateType }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")))).find((entry) => entry.serialNumber.toUpperCase() === serialNumber);
19263
- if (match === void 0) return yield* Effect.fail(new GenerateFailedError({
19430
+ if (match === void 0) return yield* new GenerateFailedError({
19264
19431
  step: "match-apple-certificate",
19265
19432
  message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
19266
- }));
19433
+ });
19267
19434
  return match.id;
19268
19435
  });
19269
19436
  const ensureBundleId = (creds, bundleIdentifier) => Effect.gen(function* () {
@@ -19296,10 +19463,10 @@ const generateAndUploadProvisioningProfile = (api, input) => Effect.gen(function
19296
19463
  const [certAscId, bundleIdAscId] = yield* Effect.all([resolveCertAscId(ascCreds, cert.serialNumber.toUpperCase(), certificateType), ensureBundleId(ascCreds, input.bundleIdentifier)], { concurrency: 2 });
19297
19464
  const useDevices = input.distributionType === "AD_HOC" || input.distributionType === "DEVELOPMENT";
19298
19465
  const { ids: deviceAscIds } = useDevices ? yield* collectDeviceAscIds(ascCreds, cert.appleTeamId, input.deviceIds) : { ids: [] };
19299
- if (useDevices && deviceAscIds.length === 0) return yield* Effect.fail(new GenerateFailedError({
19466
+ if (useDevices && deviceAscIds.length === 0) return yield* new GenerateFailedError({
19300
19467
  step: "collect-devices",
19301
19468
  message: "No registered devices to attach to the provisioning profile"
19302
- }));
19469
+ });
19303
19470
  const profileBytes = fromBase64((yield* createProvisioningProfile(ascCreds, {
19304
19471
  profileName: `${input.bundleIdentifier} ${input.distributionType} ${Date.now()}`,
19305
19472
  profileType: DISTRIBUTION_TO_PROFILE_TYPE$1[input.distributionType],
@@ -19610,10 +19777,10 @@ const downloadAndroidCredentials = (api, options) => Effect.gen(function* () {
19610
19777
  ...compact({ buildProfile: options.buildProfile })
19611
19778
  }
19612
19779
  }).pipe(Effect.mapError((cause) => resolveErrorToMissingCredentials(cause, "android")));
19613
- if (resolved.platform !== "android") return yield* Effect.fail(new MissingCredentialsError({
19780
+ if (resolved.platform !== "android") return yield* new MissingCredentialsError({
19614
19781
  message: "Server returned non-Android credentials for an Android build request",
19615
19782
  hint: androidBindHint
19616
- }));
19783
+ });
19617
19784
  const secret = yield* decryptResolveSecret({
19618
19785
  session: yield* openVaultSessionForBuild(api, androidBindHint),
19619
19786
  credentialType: "keystore",
@@ -19734,7 +19901,7 @@ const credentialsJsonPath = (projectRoot) => path.join(projectRoot, CREDENTIALS_
19734
19901
  const readCredentialsJson = (projectRoot) => Effect.gen(function* () {
19735
19902
  const fs = yield* FileSystem.FileSystem;
19736
19903
  const filePath = credentialsJsonPath(projectRoot);
19737
- if (!(yield* fs.exists(filePath).pipe(Effect.catchAll(() => Effect.succeed(false))))) return yield* new CredentialsJsonError({ message: `credentials.json not found at ${filePath}.` });
19904
+ if (!(yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)))) return yield* new CredentialsJsonError({ message: `credentials.json not found at ${filePath}.` });
19738
19905
  return yield* parseCredentialsJson(yield* fs.readFileString(filePath).pipe(Effect.mapError((cause) => new CredentialsJsonError({ message: `Failed to read credentials.json: ${String(cause)}` }))));
19739
19906
  });
19740
19907
  const writeCredentialsJson = (projectRoot, data) => Effect.gen(function* () {
@@ -19751,7 +19918,7 @@ const resolveCredentialPath = (projectRoot, candidate) => path.isAbsolute(candid
19751
19918
 
19752
19919
  //#endregion
19753
19920
  //#region src/lib/local-credentials.ts
19754
- const requirePath = (fs, absolutePath, label) => fs.exists(absolutePath).pipe(Effect.catchAll(() => Effect.succeed(false)), Effect.flatMap((exists) => exists ? Effect.void : Effect.fail(new MissingCredentialsError({
19921
+ const requirePath = (fs, absolutePath, label) => fs.exists(absolutePath).pipe(Effect.orElseSucceed(() => false), Effect.flatMap((exists) => exists ? Effect.void : Effect.fail(new MissingCredentialsError({
19755
19922
  message: `Local credentials.json: ${label} not found at ${absolutePath}.`,
19756
19923
  hint: "Run `better-update credentials sync pull` to materialize files, or fix the path in credentials.json."
19757
19924
  }))));
@@ -20006,7 +20173,7 @@ const runStepFormatted = (cmd, step, formatter) => Effect.gen(function* () {
20006
20173
  if (code !== 0) {
20007
20174
  const summary = formatter.getBuildSummary();
20008
20175
  if (summary.length > 0) process$1.stderr.write(`${summary}\n`);
20009
- return yield* Effect.fail(buildFailed(step, code, `${step} exited with code ${code}`));
20176
+ return yield* buildFailed(step, code, `${step} exited with code ${code}`);
20010
20177
  }
20011
20178
  });
20012
20179
 
@@ -20025,56 +20192,44 @@ const gradleTaskName = (format, flavor, buildType) => {
20025
20192
  const verb = format === "aab" ? "bundle" : "assemble";
20026
20193
  return flavor ? `${verb}${capitalize(flavor)}${capitalize(buildType)}` : `${verb}${capitalize(buildType)}`;
20027
20194
  };
20028
- const runAndroidBuild = (input) => Effect.gen(function* () {
20029
- const { api, tempDir, projectRoot, androidProfile, applicationIdentifier, envVars, projectId } = input;
20030
- const runtime = yield* CliRuntime;
20195
+ /** Resolve the signing keystore (remote or local), or `undefined` when skipped. */
20196
+ const resolveAndroidCredentials = (input) => {
20197
+ if (input.skipCredentials) return Effect.succeed(void 0);
20198
+ return input.credentialsSource === "local" ? loadLocalAndroidCredentials({ projectRoot: input.projectRoot }) : downloadAndroidCredentials(input.api, {
20199
+ projectId: input.projectId,
20200
+ applicationIdentifier: input.applicationIdentifier,
20201
+ tempDir: input.tempDir,
20202
+ buildProfile: input.profileName
20203
+ });
20204
+ };
20205
+ /** Gradle build against the (already-prepared) `android/` dir. */
20206
+ const runGradleBuild = (input, commandEnv) => Effect.gen(function* () {
20031
20207
  const buildStartMs = Date.now();
20032
- const { format } = androidProfile;
20033
- const { flavor } = androidProfile;
20034
- const buildType = androidProfile.buildType ?? "release";
20035
- const androidDir = path.join(projectRoot, "android");
20036
- const commandEnv = yield* runtime.commandEnvironment(envVars);
20037
- yield* runStep({
20038
- command: "bunx",
20039
- args: [
20040
- "expo",
20041
- "prebuild",
20042
- "--platform",
20043
- "android",
20044
- "--clean"
20045
- ],
20046
- cwd: projectRoot,
20047
- env: commandEnv
20048
- }, "expo prebuild android");
20049
- const gradleArgs = yield* input.skipCredentials ? Effect.succeed([]) : Effect.gen(function* () {
20050
- const credentials = input.credentialsSource === "local" ? yield* loadLocalAndroidCredentials({ projectRoot }) : yield* downloadAndroidCredentials(api, {
20051
- projectId,
20052
- applicationIdentifier,
20053
- tempDir,
20054
- buildProfile: input.profileName
20055
- });
20208
+ const { format, flavor } = input.androidProfile;
20209
+ const buildType = input.androidProfile.buildType ?? "release";
20210
+ const moduleName = input.androidProfile.module ?? "app";
20211
+ const androidDir = path.join(input.projectRoot, "android");
20212
+ const credentials = yield* resolveAndroidCredentials(input);
20213
+ const gradleArgs = credentials === void 0 ? [] : yield* Effect.gen(function* () {
20056
20214
  const fs = yield* FileSystem.FileSystem;
20057
- const signingGradlePath = path.join(tempDir, "signing.gradle");
20058
- yield* fs.writeFileString(signingGradlePath, renderSigningGradle({
20059
- keystorePath: credentials.keystorePath,
20060
- storePassword: credentials.storePassword,
20061
- keyAlias: credentials.keyAlias,
20062
- keyPassword: credentials.keyPassword
20063
- }));
20215
+ const signingGradlePath = path.join(input.tempDir, "signing.gradle");
20216
+ yield* fs.writeFileString(signingGradlePath, renderSigningGradle(credentials));
20064
20217
  return ["--init-script", signingGradlePath];
20065
20218
  });
20066
- const taskName = gradleTaskName(format, flavor, buildType);
20219
+ const taskName = input.androidProfile.gradleTask ?? gradleTaskName(format, flavor, buildType);
20220
+ const taskArg = taskName.startsWith(":") ? taskName : `:${moduleName}:${taskName}`;
20067
20221
  yield* runStep({
20068
20222
  command: "./gradlew",
20069
- args: [...gradleArgs, `:app:${taskName}`],
20223
+ args: [...gradleArgs, taskArg],
20070
20224
  cwd: androidDir,
20071
20225
  env: commandEnv
20072
20226
  }, "gradlew");
20073
20227
  const artifactPath = yield* findAndroidArtifact({
20074
- projectRoot,
20228
+ projectRoot: input.projectRoot,
20075
20229
  format,
20076
20230
  buildType,
20077
20231
  minMtimeMs: buildStartMs,
20232
+ module: moduleName,
20078
20233
  ...compact({ flavor })
20079
20234
  });
20080
20235
  const { sha256, byteSize } = yield* sha256File(artifactPath);
@@ -20084,6 +20239,71 @@ const runAndroidBuild = (input) => Effect.gen(function* () {
20084
20239
  sha256
20085
20240
  };
20086
20241
  });
20242
+ /**
20243
+ * Custom-command build. We can't inject signing into an arbitrary build, so the
20244
+ * resolved keystore + passwords are exposed to the command as `BETTER_UPDATE_*`
20245
+ * env vars; the user's script consumes them. The artifact is located via the
20246
+ * profile's `artifactPath` glob.
20247
+ */
20248
+ const runAndroidCustom = (input, commandEnv) => Effect.gen(function* () {
20249
+ const buildStartMs = Date.now();
20250
+ const custom = input.customCommand;
20251
+ if (custom === void 0) return yield* new BuildFailedError({
20252
+ step: "custom android build",
20253
+ exitCode: 1,
20254
+ message: "Internal: custom Android strategy selected without a custom command."
20255
+ });
20256
+ if (custom.artifactPath === void 0) return yield* new BuildFailedError({
20257
+ step: "custom android build",
20258
+ exitCode: 1,
20259
+ message: "Custom Android build requires \"artifactPath\" (e.g. \"**/*.aab\") in better-update.json."
20260
+ });
20261
+ const credentials = yield* resolveAndroidCredentials(input);
20262
+ const credEnv = credentials === void 0 ? {} : {
20263
+ BETTER_UPDATE_ANDROID_KEYSTORE_PATH: credentials.keystorePath,
20264
+ BETTER_UPDATE_ANDROID_KEYSTORE_PASSWORD: credentials.storePassword,
20265
+ BETTER_UPDATE_ANDROID_KEY_ALIAS: credentials.keyAlias,
20266
+ BETTER_UPDATE_ANDROID_KEY_PASSWORD: credentials.keyPassword
20267
+ };
20268
+ const cwd = custom.cwd === void 0 ? input.projectRoot : path.join(input.projectRoot, custom.cwd);
20269
+ yield* runStep({
20270
+ command: "sh",
20271
+ args: ["-c", custom.command],
20272
+ cwd,
20273
+ env: {
20274
+ ...commandEnv,
20275
+ ...credEnv,
20276
+ ...custom.env
20277
+ }
20278
+ }, "custom android build");
20279
+ const artifactPath = yield* findArtifactByGlob({
20280
+ baseDir: cwd,
20281
+ pattern: custom.artifactPath,
20282
+ minMtimeMs: buildStartMs
20283
+ });
20284
+ const { sha256, byteSize } = yield* sha256File(artifactPath);
20285
+ return {
20286
+ artifactPath,
20287
+ byteSize,
20288
+ sha256
20289
+ };
20290
+ });
20291
+ const runAndroidBuild = (input) => Effect.gen(function* () {
20292
+ const commandEnv = yield* (yield* CliRuntime).commandEnvironment(input.envVars);
20293
+ if (input.strategy === "expo") yield* runStep({
20294
+ command: "bunx",
20295
+ args: [
20296
+ "expo",
20297
+ "prebuild",
20298
+ "--platform",
20299
+ "android",
20300
+ "--clean"
20301
+ ],
20302
+ cwd: input.projectRoot,
20303
+ env: commandEnv
20304
+ }, "expo prebuild android");
20305
+ return input.strategy === "custom" ? yield* runAndroidCustom(input, commandEnv) : yield* runGradleBuild(input, commandEnv);
20306
+ });
20087
20307
 
20088
20308
  //#endregion
20089
20309
  //#region src/lib/credentials-generator-apple-id.ts
@@ -20189,10 +20409,10 @@ const findAscCertificateId = (ctx, serialNumber, certificateType) => Effect.gen(
20189
20409
  const certs = yield* wrap("apple-list-certificates", async () => AppleUtils.Certificate.getAsync(ctx, { query: { filter: { certificateType } } }));
20190
20410
  const upper = serialNumber.toUpperCase();
20191
20411
  const match = certs.find((entry) => entry.attributes.serialNumber.toUpperCase() === upper);
20192
- if (match === void 0) return yield* Effect.fail(new AppleIdGenerateFailedError({
20412
+ if (match === void 0) return yield* new AppleIdGenerateFailedError({
20193
20413
  step: "match-apple-certificate",
20194
20414
  message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
20195
- }));
20415
+ });
20196
20416
  return match.id;
20197
20417
  });
20198
20418
  const collectIosDeviceIds = (ctx, deviceIds) => Effect.gen(function* () {
@@ -20211,10 +20431,10 @@ const generateAndUploadProvisioningProfileViaAppleId = (api, input) => Effect.ge
20211
20431
  const [certAscId, bundleIdAscId] = yield* Effect.all([findAscCertificateId(ctx, cert.serialNumber, certificateType), findOrCreateBundleId(ctx, input.bundleIdentifier)], { concurrency: 2 });
20212
20432
  const useDevices = input.distributionType === "AD_HOC" || input.distributionType === "DEVELOPMENT";
20213
20433
  const deviceIds = useDevices ? yield* collectIosDeviceIds(ctx, input.deviceIds) : [];
20214
- if (useDevices && deviceIds.length === 0) return yield* Effect.fail(new AppleIdGenerateFailedError({
20434
+ if (useDevices && deviceIds.length === 0) return yield* new AppleIdGenerateFailedError({
20215
20435
  step: "collect-devices",
20216
20436
  message: "No registered devices to attach to the provisioning profile"
20217
- }));
20437
+ });
20218
20438
  const profileName = `${input.bundleIdentifier} ${input.distributionType} ${Date.now()}`;
20219
20439
  const { profileContent } = (yield* wrap("apple-create-profile", async () => AppleUtils.Profile.createAsync(ctx, {
20220
20440
  bundleId: bundleIdAscId,
@@ -20223,10 +20443,10 @@ const generateAndUploadProvisioningProfileViaAppleId = (api, input) => Effect.ge
20223
20443
  name: profileName,
20224
20444
  profileType: DISTRIBUTION_TO_PROFILE_TYPE[input.distributionType]
20225
20445
  }))).attributes;
20226
- if (profileContent === null) return yield* Effect.fail(new AppleIdGenerateFailedError({
20446
+ if (profileContent === null) return yield* new AppleIdGenerateFailedError({
20227
20447
  step: "extract-profile-content",
20228
20448
  message: "Apple returned a profile with no content (likely expired/invalid)"
20229
- }));
20449
+ });
20230
20450
  const profileBytes = fromBase64(profileContent);
20231
20451
  const rosterHash = useDevices ? computeDeviceRosterHashHex(deviceIds) : void 0;
20232
20452
  const created = yield* api.appleProvisioningProfiles.upload({ payload: {
@@ -20406,10 +20626,10 @@ const interactiveCertLimitRecover = (api, ascApiKeyId) => Effect.gen(function* (
20406
20626
  ascApiKeyId,
20407
20627
  certificateType: "IOS_DISTRIBUTION"
20408
20628
  });
20409
- if (certs.length === 0) return yield* Effect.fail(new MissingCredentialsError({
20629
+ if (certs.length === 0) return yield* new MissingCredentialsError({
20410
20630
  message: "Apple says the certificate limit is hit but no existing certificates were returned.",
20411
20631
  hint: "Try again later or check the Apple Developer portal."
20412
- }));
20632
+ });
20413
20633
  const toRevoke = yield* promptMultiSelect("Select one or more certificates to revoke before retrying", certs.map((entry) => ({
20414
20634
  value: entry.id,
20415
20635
  label: `${entry.serialNumber.slice(0, 12)}… (${entry.displayName ?? entry.certificateType}, exp ${entry.expirationDate.slice(0, 10)})`
@@ -20422,10 +20642,10 @@ const interactiveCertLimitRecover = (api, ascApiKeyId) => Effect.gen(function* (
20422
20642
  });
20423
20643
  const generateDistributionCertInteractive = (api) => Effect.gen(function* () {
20424
20644
  const teamAscKeys = (yield* api.ascApiKeys.list()).items.filter((key) => key.appleTeamId !== null);
20425
- if (teamAscKeys.length === 0) return yield* Effect.fail(new MissingCredentialsError({
20645
+ if (teamAscKeys.length === 0) return yield* new MissingCredentialsError({
20426
20646
  message: "No ASC API key linked to an Apple team in this organization.",
20427
20647
  hint: "Upload an ASC API key with a team assignment via the dashboard, then retry."
20428
- }));
20648
+ });
20429
20649
  const ascKeyId = yield* promptSelect("Select an ASC API key to issue the certificate against", teamAscKeys.map((key) => ({
20430
20650
  value: key.id,
20431
20651
  label: `${key.name} (${key.keyId})`
@@ -20444,10 +20664,10 @@ const chooseIosCertificateId = (api) => Effect.gen(function* () {
20444
20664
  }, {
20445
20665
  value: "abort",
20446
20666
  label: "Abort — I'll upload one manually"
20447
- }])) === "abort") return yield* Effect.fail(new MissingCredentialsError({
20667
+ }])) === "abort") return yield* new MissingCredentialsError({
20448
20668
  message: "Build aborted — no distribution certificate available.",
20449
20669
  hint: "Run `better-update credentials generate distribution-certificate --asc-key-id <id>` or upload via the dashboard."
20450
- }));
20670
+ });
20451
20671
  return (yield* generateDistributionCertInteractive(api)).id;
20452
20672
  }
20453
20673
  const choice = yield* promptSelect("Select a distribution certificate (or 'generate' for a fresh one)", [{
@@ -20463,10 +20683,10 @@ const chooseIosCertificateId = (api) => Effect.gen(function* () {
20463
20683
  const pickIosCertificate = (api) => Effect.gen(function* () {
20464
20684
  const chosenId = yield* chooseIosCertificateId(api);
20465
20685
  const cert = (yield* api.appleDistributionCertificates.list()).items.find((entry) => entry.id === chosenId);
20466
- if (cert === void 0) return yield* Effect.fail(new MissingCredentialsError({
20686
+ if (cert === void 0) return yield* new MissingCredentialsError({
20467
20687
  message: "Selected certificate not found after generation.",
20468
20688
  hint: "Retry."
20469
- }));
20689
+ });
20470
20690
  return {
20471
20691
  certId: chosenId,
20472
20692
  cert
@@ -20474,10 +20694,10 @@ const pickIosCertificate = (api) => Effect.gen(function* () {
20474
20694
  });
20475
20695
  const pickIosAscKey = (api, appleTeamId) => Effect.gen(function* () {
20476
20696
  const teamAscKeys = (yield* api.ascApiKeys.list()).items.filter((key) => key.appleTeamId !== null && key.appleTeamId === appleTeamId);
20477
- if (teamAscKeys.length === 0) return yield* Effect.fail(new MissingCredentialsError({
20697
+ if (teamAscKeys.length === 0) return yield* new MissingCredentialsError({
20478
20698
  message: `No ASC API key linked to Apple team ${appleTeamId}.`,
20479
20699
  hint: "Upload an ASC API key for that team via the dashboard, then retry."
20480
- }));
20700
+ });
20481
20701
  return yield* promptSelect("Select an ASC API key", teamAscKeys.map((key) => ({
20482
20702
  value: key.id,
20483
20703
  label: `${key.name} (${key.keyId})`
@@ -20558,10 +20778,10 @@ const generateKeystoreInteractive = (api) => Effect.gen(function* () {
20558
20778
  });
20559
20779
  const pickExistingKeystore = (api) => Effect.gen(function* () {
20560
20780
  const keystores = yield* api.androidUploadKeystores.list();
20561
- if (keystores.items.length === 0) return yield* Effect.fail(new MissingCredentialsError({
20781
+ if (keystores.items.length === 0) return yield* new MissingCredentialsError({
20562
20782
  message: "No existing keystores in this organization.",
20563
20783
  hint: "Re-run and choose 'Generate new keystore'."
20564
- }));
20784
+ });
20565
20785
  return yield* promptSelect("Select a keystore", keystores.items.map((item) => ({
20566
20786
  value: item.id,
20567
20787
  label: item.keyAlias
@@ -20594,10 +20814,10 @@ const setupAndroidInteractive = (api, input) => Effect.gen(function* () {
20594
20814
  label: "Abort — I'll configure it in the dashboard"
20595
20815
  }
20596
20816
  ]);
20597
- if (choice === "abort") return yield* Effect.fail(new MissingCredentialsError({
20817
+ if (choice === "abort") return yield* new MissingCredentialsError({
20598
20818
  message: `Build aborted — no keystore bound to ${input.applicationIdentifier}.`,
20599
20819
  hint: "Run `better-update credentials generate keystore` or upload via the dashboard."
20600
- }));
20820
+ });
20601
20821
  const keystoreId = yield* choice === "generate" ? generateKeystoreAuto(api, input.applicationIdentifier) : pickExistingKeystore(api);
20602
20822
  yield* api.androidBuildCredentials.create({
20603
20823
  path: { applicationIdentifierId: appId },
@@ -20618,10 +20838,10 @@ const ensureAndroidCredentialsAvailable = (api, input) => api.buildCredentials.r
20618
20838
  }).pipe(Effect.asVoid);
20619
20839
  const ensureAndroidCredentials = (api, input, options) => ensureAndroidCredentialsAvailable(api, input).pipe(Effect.catchIf(isMissingResolveError, () => Effect.gen(function* () {
20620
20840
  const mode = yield* InteractiveMode;
20621
- if (options.freezeCredentials || !mode.allow) return yield* Effect.fail(new MissingCredentialsError({
20841
+ if (options.freezeCredentials || !mode.allow) return yield* new MissingCredentialsError({
20622
20842
  message: `No Android build credentials for ${input.applicationIdentifier}.`,
20623
20843
  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."
20624
- }));
20844
+ });
20625
20845
  yield* setupAndroidInteractive(api, input);
20626
20846
  return yield* ensureAndroidCredentialsAvailable(api, input);
20627
20847
  })));
@@ -20642,10 +20862,10 @@ const resolveIosBuildCredentials = (api, input) => api.buildCredentials.resolve(
20642
20862
  const findBoundIosConfig = (api, input) => Effect.gen(function* () {
20643
20863
  const distributionType = IOS_DISTRIBUTION_TO_TYPE[input.distribution];
20644
20864
  const match = (yield* api.iosBundleConfigurations.list({ path: { projectId: input.projectId } })).items.find((config) => config.bundleIdentifier === input.bundleIdentifier && config.distributionType === distributionType);
20645
- if (match === void 0) return yield* Effect.fail(new MissingCredentialsError({
20865
+ if (match === void 0) return yield* new MissingCredentialsError({
20646
20866
  message: `iOS bundle configuration vanished while regenerating stale profile for ${input.bundleIdentifier}`,
20647
20867
  hint: "Retry; the configuration must exist before regeneration"
20648
- }));
20868
+ });
20649
20869
  return match;
20650
20870
  });
20651
20871
  const regenerateProvisioningProfile = (api, input) => Effect.gen(function* () {
@@ -20676,19 +20896,19 @@ const regenerateProvisioningProfile = (api, input) => Effect.gen(function* () {
20676
20896
  });
20677
20897
  const ensureIosCredentials = (api, input, options) => resolveIosBuildCredentials(api, input).pipe(Effect.catchIf(isMissingResolveError, () => Effect.gen(function* () {
20678
20898
  const mode = yield* InteractiveMode;
20679
- if (options.freezeCredentials || !mode.allow) return yield* Effect.fail(new MissingCredentialsError({
20899
+ if (options.freezeCredentials || !mode.allow) return yield* new MissingCredentialsError({
20680
20900
  message: `No iOS build credentials for ${input.bundleIdentifier} (${input.distribution}).`,
20681
20901
  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."
20682
- }));
20902
+ });
20683
20903
  yield* setupIosInteractive(api, input);
20684
20904
  return yield* resolveIosBuildCredentials(api, input);
20685
20905
  })), Effect.flatMap((resolved) => Effect.gen(function* () {
20686
20906
  if (resolved.platform !== "ios" || !resolved.profileStale) return;
20687
20907
  const mode = yield* InteractiveMode;
20688
- if (options.freezeCredentials || !mode.allow) return yield* Effect.fail(new MissingCredentialsError({
20908
+ if (options.freezeCredentials || !mode.allow) return yield* new MissingCredentialsError({
20689
20909
  message: `Stale provisioning profile for ${input.bundleIdentifier}; cannot regenerate without an interactive session.`,
20690
20910
  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."
20691
- }));
20911
+ });
20692
20912
  yield* Console.log(`Stale provisioning profile for ${input.bundleIdentifier} (device roster changed). Regenerating...`);
20693
20913
  yield* regenerateProvisioningProfile(api, input);
20694
20914
  })));
@@ -20738,7 +20958,7 @@ const applyTargetSigning = (options) => Effect.gen(function* () {
20738
20958
  const projectDir = yield* findXcodeProjectDir$1(options.iosDir);
20739
20959
  const pbxprojPath = path.join(projectDir, "project.pbxproj");
20740
20960
  const project = yield* parseProject$1(pbxprojPath);
20741
- 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}.` });
20961
+ for (const entry of options.entries) for (const configUuid of entry.buildConfigurationUuids) if (!mutateConfig(project, configUuid, entry.settings)) return yield* new XcodeProjectError({ message: `Build configuration ${configUuid} not found for target "${entry.targetName}" in ${pbxprojPath}.` });
20742
20962
  const serialized = yield* Effect.try({
20743
20963
  try: () => project.writeSync(),
20744
20964
  catch: (cause) => new XcodeProjectError({ message: `Failed to serialize ${pbxprojPath}: ${cause instanceof Error ? cause.message : String(cause)}` })
@@ -20798,7 +21018,7 @@ const listCurrentKeychains = Effect.gen(function* () {
20798
21018
  const parseSigningIdentity = (output) => {
20799
21019
  const lines = output.split("\n");
20800
21020
  for (const line of lines) {
20801
- const match = /"([^"]+)"/u.exec(line);
21021
+ const match = /"(?<identity>[^"]+)"/u.exec(line);
20802
21022
  if (match?.[1]) return match[1];
20803
21023
  }
20804
21024
  };
@@ -20926,7 +21146,7 @@ const installProvisioningProfile = ({ profilePath }) => Effect.acquireRelease(Ef
20926
21146
  //#endregion
20927
21147
  //#region src/lib/post-build-validation.ts
20928
21148
  const validateOneBundle = (bundleDir, expectedByBundleId, expectedTeamId) => Effect.gen(function* () {
20929
- const bundleId = yield* readBundleId(bundleDir).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
21149
+ const bundleId = yield* readBundleId(bundleDir).pipe(Effect.orElseSucceed(() => void 0));
20930
21150
  if (!bundleId) return {
20931
21151
  bundleId: void 0,
20932
21152
  warnings: [`Missing CFBundleIdentifier in Info.plist at ${bundleDir}`]
@@ -20938,16 +21158,16 @@ const validateOneBundle = (bundleDir, expectedByBundleId, expectedTeamId) => Eff
20938
21158
  };
20939
21159
  return {
20940
21160
  bundleId,
20941
- warnings: yield* validateEmbeddedProfile(bundleDir, expected.profileUuid, expectedTeamId, bundleId).pipe(Effect.catchAll(() => Effect.succeed([])))
21161
+ warnings: yield* validateEmbeddedProfile(bundleDir, expected.profileUuid, expectedTeamId, bundleId).pipe(Effect.orElseSucceed(() => []))
20942
21162
  };
20943
21163
  });
20944
21164
  const validateIosBuild = (params) => Effect.gen(function* () {
20945
- const appDir = yield* findAppDirectory$1(params.archivePath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
21165
+ const appDir = yield* findAppDirectory$1(params.archivePath).pipe(Effect.orElseSucceed(() => void 0));
20946
21166
  if (!appDir) return {
20947
21167
  passed: false,
20948
21168
  warnings: ["Could not locate .app bundle in archive — skipping post-build validation"]
20949
21169
  };
20950
- const bundleDirs = yield* listSignedBundleDirs(appDir).pipe(Effect.catchAll(() => Effect.succeed([appDir])));
21170
+ const bundleDirs = yield* listSignedBundleDirs(appDir).pipe(Effect.orElseSucceed(() => [appDir]));
20951
21171
  const expectedByBundleId = new Map(params.expectedTargets.map((target) => [target.bundleId, target]));
20952
21172
  const perBundle = yield* Effect.forEach(bundleDirs, (bundleDir) => validateOneBundle(bundleDir, expectedByBundleId, params.expectedTeamId));
20953
21173
  const warnings = perBundle.flatMap((entry) => [...entry.warnings]);
@@ -20977,7 +21197,7 @@ const findAppDirectory$1 = (archivePath) => Effect.gen(function* () {
20977
21197
  const listSignedBundleDirs = (appDir) => Effect.gen(function* () {
20978
21198
  const fs = yield* FileSystem.FileSystem;
20979
21199
  const plugInsDir = path.join(appDir, "PlugIns");
20980
- if (!(yield* fs.exists(plugInsDir).pipe(Effect.catchAll(() => Effect.succeed(false))))) return [appDir];
21200
+ if (!(yield* fs.exists(plugInsDir).pipe(Effect.orElseSucceed(() => false)))) return [appDir];
20981
21201
  return [appDir, ...(yield* fs.readDirectory(plugInsDir)).filter((entry) => entry.endsWith(".appex")).map((entry) => path.join(plugInsDir, entry))];
20982
21202
  });
20983
21203
  const readBundleId = (bundleDir) => Effect.gen(function* () {
@@ -21101,36 +21321,85 @@ const createXcodebuildFormatter = (projectRoot) => {
21101
21321
  };
21102
21322
 
21103
21323
  //#endregion
21104
- //#region src/commands/build/ios.ts
21105
- const findXcworkspace = (iosDir) => Effect.gen(function* () {
21106
- const workspace = (yield* (yield* FileSystem.FileSystem).readDirectory(iosDir)).find((entry) => entry.endsWith(".xcworkspace"));
21107
- if (!workspace) return yield* new BuildFailedError({
21108
- step: "detect xcworkspace",
21324
+ //#region src/commands/build/ios-prepare.ts
21325
+ const baseName = (entry) => entry.replace(/\.(?<ext>xcworkspace|xcodeproj)$/u, "");
21326
+ /**
21327
+ * Resolve the Xcode container to build: an explicit `workspace`/`project` from
21328
+ * the profile, else an auto-discovered `.xcworkspace` (CocoaPods), else the
21329
+ * `.xcodeproj` (pure-native apps without Pods).
21330
+ */
21331
+ const resolveXcodeContainer = (projectRoot, iosDir, iosProfile) => Effect.gen(function* () {
21332
+ if (iosProfile.workspace !== void 0) {
21333
+ const containerPath = path.resolve(projectRoot, iosProfile.workspace);
21334
+ return {
21335
+ flag: "-workspace",
21336
+ containerPath,
21337
+ schemeBase: baseName(path.basename(containerPath))
21338
+ };
21339
+ }
21340
+ if (iosProfile.project !== void 0) {
21341
+ const containerPath = path.resolve(projectRoot, iosProfile.project);
21342
+ return {
21343
+ flag: "-project",
21344
+ containerPath,
21345
+ schemeBase: baseName(path.basename(containerPath))
21346
+ };
21347
+ }
21348
+ const entries = yield* (yield* FileSystem.FileSystem).readDirectory(iosDir).pipe(Effect.orElseSucceed(() => []));
21349
+ const workspace = entries.find((entry) => entry.endsWith(".xcworkspace"));
21350
+ if (workspace !== void 0) return {
21351
+ flag: "-workspace",
21352
+ containerPath: path.join(iosDir, workspace),
21353
+ schemeBase: baseName(workspace)
21354
+ };
21355
+ const project = entries.find((entry) => entry.endsWith(".xcodeproj"));
21356
+ if (project !== void 0) return {
21357
+ flag: "-project",
21358
+ containerPath: path.join(iosDir, project),
21359
+ schemeBase: baseName(project)
21360
+ };
21361
+ return yield* new BuildFailedError({
21362
+ step: "resolve Xcode container",
21109
21363
  exitCode: 1,
21110
- message: `No .xcworkspace found under ${iosDir}. Did "pod install" run?`
21364
+ message: `No .xcworkspace or .xcodeproj found under ${iosDir}. Set ios.workspace / ios.project in better-update.json.`
21111
21365
  });
21112
- return workspace;
21113
21366
  });
21114
- const prebuildAndPods = (params) => Effect.gen(function* () {
21115
- yield* runStep({
21116
- command: "bunx",
21117
- args: [
21118
- "expo",
21119
- "prebuild",
21120
- "--platform",
21121
- "ios",
21122
- "--clean"
21123
- ],
21124
- cwd: params.projectRoot,
21125
- env: params.commandEnv
21126
- }, "expo prebuild ios");
21127
- yield* runStep({
21367
+ /**
21368
+ * Prepare the `ios/` dir for an xcodebuild. Expo regenerates it from app.json
21369
+ * via prebuild then runs `pod install`; bare/KMP/native build the committed dir
21370
+ * and only run `pod install` when a Podfile is present (unless disabled).
21371
+ */
21372
+ const prepareIosNative = (params) => Effect.gen(function* () {
21373
+ if (params.strategy === "expo") {
21374
+ yield* runStep({
21375
+ command: "bunx",
21376
+ args: [
21377
+ "expo",
21378
+ "prebuild",
21379
+ "--platform",
21380
+ "ios",
21381
+ "--clean"
21382
+ ],
21383
+ cwd: params.projectRoot,
21384
+ env: params.commandEnv
21385
+ }, "expo prebuild ios");
21386
+ yield* runStep({
21387
+ command: "pod",
21388
+ args: ["install"],
21389
+ cwd: params.iosDir,
21390
+ env: params.commandEnv
21391
+ }, "pod install");
21392
+ return;
21393
+ }
21394
+ if (params.iosProfile.podInstall === false) return;
21395
+ if (yield* (yield* FileSystem.FileSystem).exists(path.join(params.iosDir, "Podfile")).pipe(Effect.orElseSucceed(() => false))) yield* runStep({
21128
21396
  command: "pod",
21129
21397
  args: ["install"],
21130
21398
  cwd: params.iosDir,
21131
21399
  env: params.commandEnv
21132
21400
  }, "pod install");
21133
21401
  });
21402
+ /** Recursively locate the first `.app` bundle under `root` (simulator output). */
21134
21403
  const findAppDirectory = (root) => Effect.gen(function* () {
21135
21404
  const fs = yield* FileSystem.FileSystem;
21136
21405
  const stack = [root];
@@ -21150,25 +21419,30 @@ const findAppDirectory = (root) => Effect.gen(function* () {
21150
21419
  }
21151
21420
  return yield* new ArtifactNotFoundError({ message: `No .app bundle found under "${root}".` });
21152
21421
  });
21422
+
21423
+ //#endregion
21424
+ //#region src/commands/build/ios.ts
21153
21425
  const runIosSimulatorBuild = (input) => Effect.gen(function* () {
21154
21426
  const { projectRoot, iosProfile, envVars, tempDir } = input;
21155
21427
  const runtime = yield* CliRuntime;
21156
21428
  const iosDir = path.join(projectRoot, "ios");
21157
21429
  const commandEnv = yield* runtime.commandEnvironment(envVars);
21158
- yield* prebuildAndPods({
21430
+ yield* prepareIosNative({
21431
+ strategy: input.strategy,
21159
21432
  projectRoot,
21160
21433
  iosDir,
21434
+ iosProfile,
21161
21435
  commandEnv
21162
21436
  });
21163
- const workspaceFilename = yield* findXcworkspace(iosDir);
21164
- const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
21437
+ const container = yield* resolveXcodeContainer(projectRoot, iosDir, iosProfile);
21438
+ const scheme = iosProfile.scheme ?? container.schemeBase;
21165
21439
  const configuration = iosProfile.buildConfiguration ?? "Release";
21166
21440
  const derivedDataPath = path.join(tempDir, "derived-data");
21167
21441
  const buildCmd = {
21168
21442
  command: "xcodebuild",
21169
21443
  args: [
21170
- "-workspace",
21171
- workspaceFilename,
21444
+ container.flag,
21445
+ container.containerPath,
21172
21446
  "-scheme",
21173
21447
  scheme,
21174
21448
  "-configuration",
@@ -21256,13 +21530,15 @@ const runIosDeviceBuild = (input) => Effect.gen(function* () {
21256
21530
  const iosDir = path.join(projectRoot, "ios");
21257
21531
  const { distribution } = iosProfile;
21258
21532
  const commandEnv = yield* runtime.commandEnvironment(envVars);
21259
- yield* prebuildAndPods({
21533
+ yield* prepareIosNative({
21534
+ strategy: input.strategy,
21260
21535
  projectRoot,
21261
21536
  iosDir,
21537
+ iosProfile,
21262
21538
  commandEnv
21263
21539
  });
21264
- const workspaceFilename = yield* findXcworkspace(iosDir);
21265
- const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
21540
+ const container = yield* resolveXcodeContainer(projectRoot, iosDir, iosProfile);
21541
+ const scheme = iosProfile.scheme ?? container.schemeBase;
21266
21542
  const configuration = iosProfile.buildConfiguration ?? "Release";
21267
21543
  const signedTargets = yield* discoverSignedTargets({
21268
21544
  iosDir,
@@ -21309,8 +21585,8 @@ const runIosDeviceBuild = (input) => Effect.gen(function* () {
21309
21585
  const archiveCmd = {
21310
21586
  command: "xcodebuild",
21311
21587
  args: [
21312
- "-workspace",
21313
- workspaceFilename,
21588
+ container.flag,
21589
+ container.containerPath,
21314
21590
  "-scheme",
21315
21591
  scheme,
21316
21592
  "-configuration",
@@ -21374,19 +21650,76 @@ const runIosDeviceBuild = (input) => Effect.gen(function* () {
21374
21650
  sha256
21375
21651
  };
21376
21652
  });
21377
- const runIosBuild = (input) => input.iosProfile.simulator === true ? runIosSimulatorBuild(input) : runIosDeviceBuild(input);
21653
+ /**
21654
+ * Custom-command iOS build. The resolved p12 + provisioning profiles are written
21655
+ * to `tempDir` and their paths exposed via `BETTER_UPDATE_IOS_*` env vars; the
21656
+ * user's command performs the actual signing/archive. The artifact is located
21657
+ * via the profile's `artifactPath` glob.
21658
+ */
21659
+ const runIosCustom = (input) => Effect.gen(function* () {
21660
+ const commandEnv = yield* (yield* CliRuntime).commandEnvironment(input.envVars);
21661
+ const custom = input.customCommand;
21662
+ if (custom === void 0) return yield* new BuildFailedError({
21663
+ step: "custom ios build",
21664
+ exitCode: 1,
21665
+ message: "Internal: custom iOS strategy selected without a custom command."
21666
+ });
21667
+ if (custom.artifactPath === void 0) return yield* new BuildFailedError({
21668
+ step: "custom ios build",
21669
+ exitCode: 1,
21670
+ message: "Custom iOS build requires \"artifactPath\" (e.g. \"build/*.ipa\") in better-update.json."
21671
+ });
21672
+ const credentials = yield* fetchAllCredentials({
21673
+ api: input.api,
21674
+ input,
21675
+ mainBundleIdentifier: input.bundleId,
21676
+ allBundleIdentifiers: [input.bundleId]
21677
+ });
21678
+ const credEnv = {
21679
+ BETTER_UPDATE_IOS_P12_PATH: credentials.p12Path,
21680
+ BETTER_UPDATE_IOS_P12_PASSWORD: credentials.p12Password,
21681
+ BETTER_UPDATE_IOS_PROVISIONING_PROFILES: credentials.profiles.map((profile) => profile.profilePath).join(":")
21682
+ };
21683
+ const cwd = custom.cwd === void 0 ? input.projectRoot : path.join(input.projectRoot, custom.cwd);
21684
+ const buildStartMs = Date.now();
21685
+ yield* runStep({
21686
+ command: "sh",
21687
+ args: ["-c", custom.command],
21688
+ cwd,
21689
+ env: {
21690
+ ...commandEnv,
21691
+ ...credEnv,
21692
+ ...custom.env
21693
+ }
21694
+ }, "custom ios build");
21695
+ const artifactPath = yield* findArtifactByGlob({
21696
+ baseDir: cwd,
21697
+ pattern: custom.artifactPath,
21698
+ minMtimeMs: buildStartMs
21699
+ });
21700
+ const { sha256, byteSize } = yield* sha256File(artifactPath);
21701
+ return {
21702
+ artifactPath,
21703
+ byteSize,
21704
+ sha256
21705
+ };
21706
+ });
21707
+ const runIosBuild = (input) => {
21708
+ if (input.strategy === "custom") return runIosCustom(input);
21709
+ return input.iosProfile.simulator === true ? runIosSimulatorBuild(input) : runIosDeviceBuild(input);
21710
+ };
21378
21711
 
21379
21712
  //#endregion
21380
21713
  //#region src/commands/build/reserve-and-upload.ts
21381
21714
  const buildReserveCommon = (input) => ({
21382
21715
  projectId: input.projectId,
21383
21716
  profile: input.profileName,
21384
- runtimeVersion: input.runtimeVersion,
21385
21717
  bundleId: input.bundleId,
21386
21718
  sha256: input.sha256,
21387
21719
  byteSize: input.byteSize,
21388
21720
  gitDirty: input.gitContext.dirty,
21389
21721
  ...compact({
21722
+ runtimeVersion: input.runtimeVersion,
21390
21723
  appVersion: input.appVersion,
21391
21724
  buildNumber: input.buildNumber,
21392
21725
  gitRef: input.gitContext.ref,
@@ -21462,7 +21795,7 @@ const bumpVersionCode = (current) => Effect.gen(function* () {
21462
21795
  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.` });
21463
21796
  return value + 1;
21464
21797
  });
21465
- const SEMVER_PATCH = /^(\d+)\.(\d+)\.(\d+)(.*)$/u;
21798
+ const SEMVER_PATCH = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?<suffix>.*)$/u;
21466
21799
  const bumpVersion = (current) => Effect.gen(function* () {
21467
21800
  if (current === void 0) return yield* new BuildProfileError({ message: "Cannot autoIncrement version: no `version` field set in Expo config." });
21468
21801
  const match = SEMVER_PATCH.exec(current);
@@ -21543,20 +21876,21 @@ const stripExtends = (profile) => {
21543
21876
  };
21544
21877
  const resolveExtendsChain = (params) => Effect.gen(function* () {
21545
21878
  const { profiles, profileName, label, maxDepth, makeError } = params;
21879
+ const sourceLabel = params.sourceLabel ?? "eas.json";
21546
21880
  const noun = label === "build" ? "Build" : "Submit";
21547
21881
  const chain = [];
21548
21882
  const visited = /* @__PURE__ */ new Set();
21549
21883
  let current = profileName;
21550
21884
  let depth = 0;
21551
21885
  while (current !== void 0) {
21552
- if (visited.has(current)) return yield* Effect.fail(makeError(`Cycle detected in eas.json ${label}.${profileName} extends chain at "${current}".`));
21886
+ if (visited.has(current)) return yield* Effect.fail(makeError(`Cycle detected in ${sourceLabel} ${label}.${profileName} extends chain at "${current}".`));
21553
21887
  visited.add(current);
21554
21888
  const profile = profiles[current];
21555
- if (!profile) return yield* Effect.fail(makeError(current === profileName ? `${noun} profile "${profileName}" not found in eas.json.` : `${noun} profile "${profileName}" extends missing profile "${current}".`));
21889
+ if (!profile) return yield* Effect.fail(makeError(current === profileName ? `${noun} profile "${profileName}" not found in ${sourceLabel}.` : `${noun} profile "${profileName}" extends missing profile "${current}".`));
21556
21890
  chain.unshift(profile);
21557
21891
  current = profile.extends;
21558
21892
  depth += 1;
21559
- if (depth > maxDepth) return yield* Effect.fail(makeError(`Too many "extends" levels (max ${String(maxDepth)}) in eas.json ${label}.${profileName}.`));
21893
+ if (depth > maxDepth) return yield* Effect.fail(makeError(`Too many "extends" levels (max ${String(maxDepth)}) in ${sourceLabel} ${label}.${profileName}.`));
21560
21894
  }
21561
21895
  return chain;
21562
21896
  });
@@ -21623,13 +21957,14 @@ const mergeSubmitProfile = (base, overlay) => {
21623
21957
  android
21624
21958
  });
21625
21959
  };
21626
- const resolveEasSubmitProfile = (profiles, profileName) => Effect.gen(function* () {
21627
- if (!profiles) return yield* new BuildProfileError({ message: "eas.json has no \"submit\" section. Add at least one submit profile." });
21960
+ const resolveEasSubmitProfile = (profiles, profileName, sourceLabel = "eas.json") => Effect.gen(function* () {
21961
+ if (!profiles) return yield* new BuildProfileError({ message: `${sourceLabel} has no "submit" section. Add at least one submit profile.` });
21628
21962
  return stripExtends((yield* resolveExtendsChain({
21629
21963
  profiles,
21630
21964
  profileName,
21631
21965
  label: "submit",
21632
21966
  maxDepth: MAX_SUBMIT_EXTENDS_DEPTH,
21967
+ sourceLabel,
21633
21968
  makeError: (message) => new BuildProfileError({ message })
21634
21969
  })).reduce((acc, next, index) => index === 0 ? next : mergeSubmitProfile(acc, next), {}));
21635
21970
  });
@@ -21696,7 +22031,13 @@ const parseIosProfile = (raw) => {
21696
22031
  scheme: asStringValue(record["scheme"]),
21697
22032
  simulator: asBooleanValue(record["simulator"]),
21698
22033
  enterpriseProvisioning: asEnterpriseProvisioning(record["enterpriseProvisioning"]),
21699
- autoIncrement: asIosAutoIncrement(record["autoIncrement"])
22034
+ autoIncrement: asIosAutoIncrement(record["autoIncrement"]),
22035
+ workspace: asStringValue(record["workspace"]),
22036
+ project: asStringValue(record["project"]),
22037
+ podInstall: asBooleanValue(record["podInstall"]),
22038
+ bundleIdentifier: asStringValue(record["bundleIdentifier"]),
22039
+ version: asStringValue(record["version"]),
22040
+ buildNumber: asStringValue(record["buildNumber"])
21700
22041
  });
21701
22042
  };
21702
22043
  const parseAndroidProfile = (raw) => {
@@ -21708,9 +22049,35 @@ const parseAndroidProfile = (raw) => {
21708
22049
  gradleCommand: asStringValue(record["gradleCommand"]),
21709
22050
  format: asAndroidFormat(record["format"]),
21710
22051
  distribution: asAndroidDistribution(record["distribution"]),
21711
- autoIncrement: asAndroidAutoIncrement(record["autoIncrement"])
22052
+ autoIncrement: asAndroidAutoIncrement(record["autoIncrement"]),
22053
+ module: asStringValue(record["module"]),
22054
+ gradleTask: asStringValue(record["gradleTask"]),
22055
+ applicationId: asStringValue(record["applicationId"]),
22056
+ version: asStringValue(record["version"]),
22057
+ versionCode: asStringValue(record["versionCode"])
21712
22058
  });
21713
22059
  };
22060
+ const parseCustomCommandSpec = (raw) => {
22061
+ const record = asRecord(raw);
22062
+ if (!record) return;
22063
+ const command = asStringValue(record["command"]);
22064
+ if (command === void 0) return;
22065
+ return compact({
22066
+ command,
22067
+ cwd: asStringValue(record["cwd"]),
22068
+ env: asEnv(record["env"]),
22069
+ artifactPath: asStringValue(record["artifactPath"])
22070
+ });
22071
+ };
22072
+ const parseCustomCommandProfile = (raw) => {
22073
+ const record = asRecord(raw);
22074
+ if (!record) return;
22075
+ const result = compact({
22076
+ ios: parseCustomCommandSpec(record["ios"]),
22077
+ android: parseCustomCommandSpec(record["android"])
22078
+ });
22079
+ return Object.keys(result).length === 0 ? void 0 : result;
22080
+ };
21714
22081
  const parseBuildProfile = (raw) => {
21715
22082
  const record = asRecord(raw);
21716
22083
  if (!record) return;
@@ -21725,15 +22092,16 @@ const parseBuildProfile = (raw) => {
21725
22092
  android: parseAndroidProfile(record["android"]),
21726
22093
  credentialsSource: asCredentialsSource(record["credentialsSource"]),
21727
22094
  autoIncrement: asAutoIncrement(record["autoIncrement"]),
21728
- withoutCredentials: asBooleanValue(record["withoutCredentials"])
22095
+ withoutCredentials: asBooleanValue(record["withoutCredentials"]),
22096
+ custom: parseCustomCommandProfile(record["custom"])
21729
22097
  });
21730
22098
  };
21731
- const parseEasConfig = (text) => Effect.gen(function* () {
21732
- const root = asRecord(yield* Effect.try({
21733
- try: () => JSON.parse(text),
21734
- catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
21735
- }));
21736
- if (!root) return yield* new BuildProfileError({ message: "eas.json must be a JSON object at the top level." });
22099
+ /**
22100
+ * Parse an already-decoded JSON object into an {@link EasConfig}. Shared by the
22101
+ * `eas.json` reader and the `better-update.json` build-config reader — both hold
22102
+ * the same `build`/`submit`/`cli` shape, only the source file differs.
22103
+ */
22104
+ const parseConfigFromRecord = (root) => {
21737
22105
  const buildRecord = asRecord(root["build"]);
21738
22106
  if (!buildRecord) return asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {};
21739
22107
  const profiles = {};
@@ -21752,6 +22120,14 @@ const parseEasConfig = (text) => Effect.gen(function* () {
21752
22120
  build: profiles,
21753
22121
  ...Object.keys(submit).length === 0 ? {} : { submit }
21754
22122
  };
22123
+ };
22124
+ const parseEasConfig = (text) => Effect.gen(function* () {
22125
+ const root = asRecord(yield* Effect.try({
22126
+ try: () => JSON.parse(text),
22127
+ catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
22128
+ }));
22129
+ if (!root) return yield* new BuildProfileError({ message: "eas.json must be a JSON object at the top level." });
22130
+ return parseConfigFromRecord(root);
21755
22131
  });
21756
22132
  const parseCli = (raw) => {
21757
22133
  const record = asRecord(raw);
@@ -21764,12 +22140,17 @@ const easJsonPath = (projectRoot) => Effect.gen(function* () {
21764
22140
  const readEasJson = (projectRoot) => Effect.gen(function* () {
21765
22141
  const fs = yield* FileSystem.FileSystem;
21766
22142
  const filePath = yield* easJsonPath(projectRoot);
21767
- 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}` })))));
22143
+ return yield* parseEasConfig(yield* fs.readFileString(filePath).pipe(Effect.mapError((cause) => 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}` }))));
21768
22144
  });
22145
+ const mergeCustom = (base, overlay) => {
22146
+ const merged = shallowMerge(base, overlay);
22147
+ return merged === void 0 || Object.keys(merged).length === 0 ? void 0 : merged;
22148
+ };
21769
22149
  const mergeProfile = (base, overlay) => {
21770
22150
  const ios = shallowMerge(base.ios, overlay.ios);
21771
22151
  const android = shallowMerge(base.android, overlay.android);
21772
22152
  const env = shallowMerge(base.env, overlay.env);
22153
+ const custom = mergeCustom(base.custom, overlay.custom);
21773
22154
  const developmentClient = overlay.developmentClient ?? base.developmentClient;
21774
22155
  const distribution = overlay.distribution ?? base.distribution;
21775
22156
  const channel = overlay.channel ?? base.channel;
@@ -21788,21 +22169,41 @@ const mergeProfile = (base, overlay) => {
21788
22169
  android,
21789
22170
  credentialsSource,
21790
22171
  autoIncrement,
21791
- withoutCredentials
22172
+ withoutCredentials,
22173
+ custom
21792
22174
  });
21793
22175
  };
21794
- const resolveEasBuildProfile = (config, profileName) => Effect.gen(function* () {
22176
+ const resolveEasBuildProfile = (config, profileName, sourceLabel = "eas.json") => Effect.gen(function* () {
21795
22177
  const profiles = config.build;
21796
- if (!profiles) return yield* new BuildProfileError({ message: "eas.json has no \"build\" section. Add at least one profile." });
22178
+ if (!profiles) return yield* new BuildProfileError({ message: `${sourceLabel} has no "build" section. Add at least one profile.` });
21797
22179
  return stripExtends((yield* resolveExtendsChain({
21798
22180
  profiles,
21799
22181
  profileName,
21800
22182
  label: "build",
21801
22183
  maxDepth: MAX_EXTENDS_DEPTH,
22184
+ sourceLabel,
21802
22185
  makeError: (message) => new BuildProfileError({ message })
21803
22186
  })).reduce((acc, next, index) => index === 0 ? next : mergeProfile(acc, next), {}));
21804
22187
  });
21805
22188
 
22189
+ //#endregion
22190
+ //#region src/lib/better-update-build-config.ts
22191
+ /** Label used in profile-resolution error copy when config comes from this file. */
22192
+ const BETTER_UPDATE_SOURCE_LABEL = "better-update.json";
22193
+ /**
22194
+ * Read the `build`/`submit`/`cli` config from `better-update.json`. Returns an
22195
+ * empty config (no `build` key) when the file is absent or carries no build
22196
+ * section. Shares the parser with {@link file://./eas-config.ts}; only the
22197
+ * source file and the error `sourceLabel` differ.
22198
+ */
22199
+ const readBuildConfig = (projectRoot) => readBetterUpdateConfig(projectRoot).pipe(Effect.map((config) => config === void 0 ? {} : parseConfigFromRecord(config)));
22200
+ /** List available build-profile names declared in `better-update.json`. */
22201
+ const listBuildProfileNames = (projectRoot) => readBuildConfig(projectRoot).pipe(Effect.map((config) => Object.keys(config.build ?? {})));
22202
+ /** Resolve a submit profile from `better-update.json`'s `submit` section. */
22203
+ const readSubmitProfile = (projectRoot, profileName) => Effect.gen(function* () {
22204
+ return yield* resolveEasSubmitProfile((yield* readBuildConfig(projectRoot)).submit, profileName, BETTER_UPDATE_SOURCE_LABEL);
22205
+ });
22206
+
21806
22207
  //#endregion
21807
22208
  //#region src/lib/build-profile.ts
21808
22209
  const deriveIosDistribution = (eas) => {
@@ -21849,12 +22250,22 @@ const toIosProfile = (eas) => {
21849
22250
  if (!distribution) return;
21850
22251
  const ios = eas.ios ?? {};
21851
22252
  const autoIncrement = resolveIosAutoIncrement(eas);
22253
+ const buildConfiguration = ios.buildConfiguration ?? (eas.developmentClient === true ? "Debug" : void 0);
22254
+ const metaOverride = compact({
22255
+ bundleIdentifier: ios.bundleIdentifier,
22256
+ version: ios.version,
22257
+ buildNumber: ios.buildNumber
22258
+ });
21852
22259
  return compact({
21853
22260
  distribution,
21854
- buildConfiguration: ios.buildConfiguration ?? (eas.developmentClient === true ? "Debug" : void 0),
22261
+ buildConfiguration,
21855
22262
  scheme: ios.scheme,
21856
22263
  simulator: ios.simulator,
21857
- autoIncrement
22264
+ autoIncrement,
22265
+ workspace: ios.workspace,
22266
+ project: ios.project,
22267
+ podInstall: ios.podInstall,
22268
+ metaOverride: Object.keys(metaOverride).length === 0 ? void 0 : metaOverride
21858
22269
  });
21859
22270
  };
21860
22271
  const toAndroidProfile = (eas) => {
@@ -21864,16 +22275,25 @@ const toAndroidProfile = (eas) => {
21864
22275
  const android = eas.android ?? {};
21865
22276
  const distribution = deriveAndroidDistribution(eas, format);
21866
22277
  const autoIncrement = resolveAndroidAutoIncrement(eas);
22278
+ const buildType = android.buildType ?? (eas.developmentClient === true ? "debug" : void 0);
22279
+ const metaOverride = compact({
22280
+ applicationId: android.applicationId,
22281
+ version: android.version,
22282
+ versionCode: android.versionCode
22283
+ });
21867
22284
  return compact({
21868
22285
  format,
21869
22286
  distribution,
21870
- buildType: android.buildType ?? (eas.developmentClient === true ? "debug" : void 0),
22287
+ buildType,
21871
22288
  flavor: android.flavor,
21872
22289
  gradleCommand: android.gradleCommand,
21873
- autoIncrement
22290
+ autoIncrement,
22291
+ module: android.module,
22292
+ gradleTask: android.gradleTask,
22293
+ metaOverride: Object.keys(metaOverride).length === 0 ? void 0 : metaOverride
21874
22294
  });
21875
22295
  };
21876
- const fromEasProfile = (eas, profileName) => {
22296
+ const fromGenericProfile = (eas, profileName) => {
21877
22297
  const ios = toIosProfile(eas);
21878
22298
  const android = toAndroidProfile(eas);
21879
22299
  return compact({
@@ -21885,11 +22305,13 @@ const fromEasProfile = (eas, profileName) => {
21885
22305
  android,
21886
22306
  credentialsSource: eas.credentialsSource,
21887
22307
  developmentClient: eas.developmentClient,
21888
- withoutCredentials: eas.withoutCredentials
22308
+ withoutCredentials: eas.withoutCredentials,
22309
+ customCommand: eas.custom
21889
22310
  });
21890
22311
  };
22312
+ /** Resolve a build profile from `better-update.json`'s `build` section. */
21891
22313
  const readBuildProfile = (projectRoot, profileName) => Effect.gen(function* () {
21892
- return fromEasProfile(yield* resolveEasBuildProfile(yield* readEasJson(projectRoot), profileName), profileName);
22314
+ return fromGenericProfile(yield* resolveEasBuildProfile(yield* readBuildConfig(projectRoot), profileName, "better-update.json"), profileName);
21893
22315
  });
21894
22316
  const readRuntimeVersionMeta = (config, platform) => ({
21895
22317
  platform,
@@ -21899,6 +22321,17 @@ const readRuntimeVersionMeta = (config, platform) => ({
21899
22321
  rawRuntimeVersion: extractRawRuntimeVersion(config, platform)
21900
22322
  });
21901
22323
 
22324
+ //#endregion
22325
+ //#region src/lib/build-strategy.ts
22326
+ const resolveAndroidStrategy = (profile, projectType) => {
22327
+ if (profile.customCommand?.android !== void 0) return "custom";
22328
+ return projectType === "expo" ? "expo" : "gradle";
22329
+ };
22330
+ const resolveIosStrategy = (profile, projectType) => {
22331
+ if (profile.customCommand?.ios !== void 0) return "custom";
22332
+ return projectType === "expo" ? "expo" : "xcode";
22333
+ };
22334
+
21902
22335
  //#endregion
21903
22336
  //#region src/lib/clear-cache.ts
21904
22337
  /**
@@ -21919,13 +22352,71 @@ const clearBuildCaches = (projectRoot) => Effect.gen(function* () {
21919
22352
  const removed = [];
21920
22353
  yield* Effect.forEach(CACHE_DIRS, (rel) => Effect.gen(function* () {
21921
22354
  const target = path.join(projectRoot, rel);
21922
- if (!(yield* fs.exists(target).pipe(Effect.catchAll(() => Effect.succeed(false))))) return;
21923
- yield* fs.remove(target, { recursive: true }).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
22355
+ if (!(yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)))) return;
22356
+ yield* fs.remove(target, { recursive: true }).pipe(Effect.orElseSucceed(() => void 0));
21924
22357
  removed.push(rel);
21925
22358
  }), { concurrency: 4 });
21926
22359
  if (removed.length > 0) yield* Console.error(`Cleared caches: ${removed.join(", ")}`);
21927
22360
  });
21928
22361
 
22362
+ //#endregion
22363
+ //#region src/lib/detect-project-type.ts
22364
+ const PROJECT_TYPES = [
22365
+ "expo",
22366
+ "bare",
22367
+ "kmp",
22368
+ "native",
22369
+ "custom"
22370
+ ];
22371
+ /** Narrow an arbitrary `projectType` override (e.g. from better-update.json) to a valid value. */
22372
+ const asProjectType = (raw) => PROJECT_TYPES.find((type) => type === raw);
22373
+ const exists = (filePath) => Effect.gen(function* () {
22374
+ return yield* (yield* FileSystem.FileSystem).exists(filePath).pipe(Effect.orElseSucceed(() => false));
22375
+ });
22376
+ const readText = (filePath) => Effect.gen(function* () {
22377
+ return yield* (yield* FileSystem.FileSystem).readFileString(filePath).pipe(Effect.orElseSucceed(() => ""));
22378
+ });
22379
+ const hasExpoDependency = (projectRoot) => Effect.gen(function* () {
22380
+ const text = yield* readText(path.join(projectRoot, "package.json"));
22381
+ if (text.length === 0) return false;
22382
+ const pkg = asRecord(yield* Effect.try(() => JSON.parse(text)).pipe(Effect.orElseSucceed(() => void 0)));
22383
+ const deps = asRecord(pkg?.["dependencies"]);
22384
+ const devDeps = asRecord(pkg?.["devDependencies"]);
22385
+ return deps?.["expo"] !== void 0 || devDeps?.["expo"] !== void 0;
22386
+ });
22387
+ const hasAnyExpoConfigFile = (projectRoot) => Effect.gen(function* () {
22388
+ for (const name of [
22389
+ "app.json",
22390
+ "app.config.js",
22391
+ "app.config.ts"
22392
+ ]) if (yield* exists(path.join(projectRoot, name))) return true;
22393
+ return false;
22394
+ });
22395
+ const looksKmp = (projectRoot) => Effect.gen(function* () {
22396
+ if (yield* exists(path.join(projectRoot, "composeApp"))) return true;
22397
+ for (const name of ["settings.gradle.kts", "settings.gradle"]) {
22398
+ const text = yield* readText(path.join(projectRoot, name));
22399
+ if (text.includes("composeApp") || text.includes(":shared")) return true;
22400
+ }
22401
+ return yield* exists(path.join(projectRoot, "android", "app", "build.gradle.kts"));
22402
+ });
22403
+ /**
22404
+ * Resolve a project's build-system family. An explicit override always wins;
22405
+ * otherwise the filesystem shape is inspected. `custom` is never auto-detected —
22406
+ * it is intent expressed via override or a profile `custom` block.
22407
+ */
22408
+ const detectProjectType = (params) => Effect.gen(function* () {
22409
+ if (params.override !== void 0) return params.override;
22410
+ const { projectRoot } = params;
22411
+ if (isExpoConfigInstalled() && ((yield* hasExpoDependency(projectRoot)) || (yield* hasAnyExpoConfigFile(projectRoot)))) return "expo";
22412
+ if (yield* looksKmp(projectRoot)) return "kmp";
22413
+ const hasAndroid = yield* exists(path.join(projectRoot, "android"));
22414
+ const hasIos = yield* exists(path.join(projectRoot, "ios"));
22415
+ const hasPackageJson = yield* exists(path.join(projectRoot, "package.json"));
22416
+ if (hasAndroid && hasIos && hasPackageJson) return "bare";
22417
+ return "native";
22418
+ });
22419
+
21929
22420
  //#endregion
21930
22421
  //#region src/lib/dev-client-check.ts
21931
22422
  const readDeps = (filePath) => Effect.gen(function* () {
@@ -22191,10 +22682,10 @@ const runString = (cmd, cwd) => Command.string(Command.workingDirectory(cmd, cwd
22191
22682
  */
22192
22683
  const readGitContext = (projectRoot) => Effect.gen(function* () {
22193
22684
  const [commit, ref, commitMessage, status] = yield* Effect.all([
22194
- runString(Command.make("git", "rev-parse", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
22195
- runString(Command.make("git", "symbolic-ref", "--short", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
22196
- runString(Command.make("git", "log", "-1", "--format=%s"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.catchAll(() => Effect.succeed(""))),
22197
- runString(Command.make("git", "status", "--porcelain"), projectRoot).pipe(Effect.catchAll(() => Effect.succeed("")))
22685
+ runString(Command.make("git", "rev-parse", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.orElseSucceed(() => "")),
22686
+ runString(Command.make("git", "symbolic-ref", "--short", "HEAD"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.orElseSucceed(() => "")),
22687
+ runString(Command.make("git", "log", "-1", "--format=%s"), projectRoot).pipe(Effect.map((output) => output.trim()), Effect.orElseSucceed(() => "")),
22688
+ runString(Command.make("git", "status", "--porcelain"), projectRoot).pipe(Effect.orElseSucceed(() => ""))
22198
22689
  ], { concurrency: "unbounded" });
22199
22690
  return {
22200
22691
  ref: ref.length > 0 ? ref : void 0,
@@ -22223,14 +22714,14 @@ const readGradleConfig = (androidDir) => Effect.gen(function* () {
22223
22714
  const hasKts = yield* fs.exists(ktsPath).pipe(Effect.orElseSucceed(() => false));
22224
22715
  if (!hasGroovy && hasKts) return;
22225
22716
  if (!hasGroovy) return;
22226
- const content = yield* fs.readFileString(gradlePath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
22717
+ const content = yield* fs.readFileString(gradlePath).pipe(Effect.orElseSucceed(() => void 0));
22227
22718
  if (!content) return;
22228
22719
  return yield* Effect.tryPromise({
22229
22720
  try: async () => {
22230
22721
  return __require("gradle-to-js").parseText(stripGroovyComments(content));
22231
22722
  },
22232
22723
  catch: () => void 0
22233
- }).pipe(Effect.map(extractGradleConfig), Effect.catchAll(() => Effect.succeed(void 0)));
22724
+ }).pipe(Effect.map(extractGradleConfig), Effect.orElseSucceed(() => void 0));
22234
22725
  });
22235
22726
  /**
22236
22727
  * Log a warning if Gradle applicationId differs from app.json package name.
@@ -22291,6 +22782,28 @@ const detectPlatform = (explicit, config) => Effect.gen(function* () {
22291
22782
  label: entry
22292
22783
  })));
22293
22784
  });
22785
+ /**
22786
+ * Resolve a build platform for non-Expo projects from an explicit flag, or by
22787
+ * intersecting the profile's declared platform sections with the native dirs
22788
+ * present on disk. Prompts when both remain; fails when ambiguous and prompts
22789
+ * are disallowed.
22790
+ */
22791
+ const detectPlatformGeneric = (explicit, context) => Effect.gen(function* () {
22792
+ if (explicit !== void 0) return explicit;
22793
+ const candidates = [];
22794
+ const wantsIos = context.profile.ios !== void 0 || context.profile.customCommand?.ios !== void 0;
22795
+ const wantsAndroid = context.profile.android !== void 0 || context.profile.customCommand?.android !== void 0;
22796
+ if (wantsIos && (context.hasIosDir || context.profile.customCommand?.ios !== void 0)) candidates.push("ios");
22797
+ if (wantsAndroid && (context.hasAndroidDir || context.profile.customCommand?.android !== void 0)) candidates.push("android");
22798
+ if (candidates.length === 0) return yield* new BuildProfileError({ message: "Cannot infer build platform. Add an `ios` or `android` section to the build profile in better-update.json, or pass --platform." });
22799
+ const [only] = candidates;
22800
+ if (candidates.length === 1 && only !== void 0) return only;
22801
+ if (!(yield* InteractiveMode).allow) return yield* new BuildProfileError({ message: `Multiple platforms available (${candidates.join(", ")}). Pass --platform explicitly when running non-interactively.` });
22802
+ return yield* promptSelect("Which platform to build?", candidates.map((entry) => ({
22803
+ value: entry,
22804
+ label: entry
22805
+ })));
22806
+ });
22294
22807
 
22295
22808
  //#endregion
22296
22809
  //#region src/lib/project-staging.ts
@@ -22303,26 +22816,32 @@ const LOCKFILES = [
22303
22816
  ["package-lock.json", "npm"]
22304
22817
  ];
22305
22818
  /**
22306
- * Paths never copied into staging covers generated native build outputs and
22307
- * dependency dirs that must be reinstalled fresh in staging.
22819
+ * Generated native build outputs / dependency dirs that must never be copied —
22820
+ * they are regenerated in staging (Pods via `pod install`, build/ via gradle).
22308
22821
  */
22309
- const ALWAYS_IGNORE = [
22310
- "node_modules",
22311
- ".git",
22822
+ const NATIVE_BUILD_OUTPUTS = [
22312
22823
  "ios/build",
22313
22824
  "ios/Pods",
22314
22825
  "ios/DerivedData",
22315
22826
  "android/build",
22316
22827
  "android/app/build",
22317
22828
  "android/.gradle",
22318
- "android/.kotlin",
22829
+ "android/.kotlin"
22830
+ ];
22831
+ /**
22832
+ * Paths never copied into staging — covers generated native build outputs and
22833
+ * dependency dirs that must be reinstalled fresh in staging.
22834
+ */
22835
+ const ALWAYS_IGNORE = [...[
22836
+ "node_modules",
22837
+ ".git",
22319
22838
  ".expo",
22320
22839
  ".gradle",
22321
22840
  ".turbo",
22322
22841
  "dist"
22323
- ];
22842
+ ], ...NATIVE_BUILD_OUTPUTS];
22324
22843
  const findLockfile = (fs, dir) => Effect.gen(function* () {
22325
- for (const [name, pm] of LOCKFILES) if (yield* fs.exists(path.join(dir, name)).pipe(Effect.catchAll(() => Effect.succeed(false)))) return pm;
22844
+ for (const [name, pm] of LOCKFILES) if (yield* fs.exists(path.join(dir, name)).pipe(Effect.orElseSucceed(() => false))) return pm;
22326
22845
  });
22327
22846
  const walkUpForLockfile = (startCwd, dir) => Effect.gen(function* () {
22328
22847
  const pm = yield* findLockfile(yield* FileSystem.FileSystem, dir);
@@ -22348,21 +22867,30 @@ const detectWorkspaceRoot = (cwd) => walkUpForLockfile(cwd, cwd);
22348
22867
  * Build an `Ignore` matcher for the workspace root. `.easignore` REPLACES
22349
22868
  * `.gitignore` when present (matches EAS semantics); otherwise `.gitignore`
22350
22869
  * is layered on top of the always-ignore baseline.
22870
+ *
22871
+ * When `includeNativeSource` is set, the native source dirs are re-included
22872
+ * after the ignore files are applied, then their build outputs re-excluded, so
22873
+ * a committed `ios/`/`android/` reaches staging intact.
22351
22874
  */
22352
- const buildIgnoreInstance = (workspaceRoot) => Effect.gen(function* () {
22875
+ const buildIgnoreInstance = (workspaceRoot, options = {}) => Effect.gen(function* () {
22353
22876
  const fs = yield* FileSystem.FileSystem;
22354
22877
  const ig = ignore();
22355
22878
  ig.add([...ALWAYS_IGNORE]);
22356
22879
  const easignorePath = path.join(workspaceRoot, ".easignore");
22357
- if (yield* fs.exists(easignorePath).pipe(Effect.catchAll(() => Effect.succeed(false)))) {
22358
- const content = yield* fs.readFileString(easignorePath).pipe(Effect.catchAll(() => Effect.succeed("")));
22880
+ if (yield* fs.exists(easignorePath).pipe(Effect.orElseSucceed(() => false))) {
22881
+ const content = yield* fs.readFileString(easignorePath).pipe(Effect.orElseSucceed(() => ""));
22359
22882
  ig.add(content);
22360
- return ig;
22883
+ } else {
22884
+ const gitignorePath = path.join(workspaceRoot, ".gitignore");
22885
+ if (yield* fs.exists(gitignorePath).pipe(Effect.orElseSucceed(() => false))) {
22886
+ const content = yield* fs.readFileString(gitignorePath).pipe(Effect.orElseSucceed(() => ""));
22887
+ ig.add(content);
22888
+ }
22361
22889
  }
22362
- const gitignorePath = path.join(workspaceRoot, ".gitignore");
22363
- if (yield* fs.exists(gitignorePath).pipe(Effect.catchAll(() => Effect.succeed(false)))) {
22364
- const content = yield* fs.readFileString(gitignorePath).pipe(Effect.catchAll(() => Effect.succeed("")));
22365
- ig.add(content);
22890
+ if (options.includeNativeSource === true) {
22891
+ const base = options.appRelPath === void 0 || options.appRelPath === "" ? "" : `${options.appRelPath}/`;
22892
+ ig.add([`!${base}android`, `!${base}ios`]);
22893
+ ig.add(NATIVE_BUILD_OUTPUTS.map((entry) => `${base}${entry}`));
22366
22894
  }
22367
22895
  return ig;
22368
22896
  });
@@ -22410,6 +22938,7 @@ const runInstall = (params) => runStep({
22410
22938
  * regardless of what `expo prebuild`, `pod install`, or `gradlew` write.
22411
22939
  */
22412
22940
  const prepareStagingProject = (input) => Effect.gen(function* () {
22941
+ const fs = yield* FileSystem.FileSystem;
22413
22942
  const runtime = yield* CliRuntime;
22414
22943
  const { workspaceRoot, packageManager } = yield* detectWorkspaceRoot(input.userCwd);
22415
22944
  const relAppPath = path.relative(workspaceRoot, input.userCwd);
@@ -22419,14 +22948,18 @@ const prepareStagingProject = (input) => Effect.gen(function* () {
22419
22948
  yield* copyProjectTree({
22420
22949
  source: workspaceRoot,
22421
22950
  dest: stagingRoot,
22422
- ig: yield* buildIgnoreInstance(workspaceRoot)
22951
+ ig: yield* buildIgnoreInstance(workspaceRoot, {
22952
+ includeNativeSource: input.projectType !== void 0 && input.projectType !== "expo",
22953
+ appRelPath: relAppPath
22954
+ })
22423
22955
  });
22424
22956
  yield* initGitRepo(stagingRoot);
22425
- yield* runInstall({
22957
+ if (yield* fs.exists(path.join(workspaceRoot, "package.json")).pipe(Effect.orElseSucceed(() => false))) yield* runInstall({
22426
22958
  stagingRoot,
22427
22959
  packageManager,
22428
22960
  env: yield* runtime.commandEnvironment(input.envVars)
22429
22961
  });
22962
+ else yield* printHuman("No package.json at the staging root — skipping dependency install.");
22430
22963
  return {
22431
22964
  stagingRoot,
22432
22965
  projectRoot,
@@ -22438,7 +22971,7 @@ const prepareStagingProject = (input) => Effect.gen(function* () {
22438
22971
  //#endregion
22439
22972
  //#region src/lib/repo-clean.ts
22440
22973
  const MAX_FILES_SHOWN = 10;
22441
- 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([])));
22974
+ 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.orElseSucceed(() => []));
22442
22975
  /**
22443
22976
  * Refuse to proceed when the working tree has uncommitted changes. Skipped when
22444
22977
  * `allowDirty` is true. In interactive mode, prompts the user to confirm; in
@@ -22451,11 +22984,8 @@ const ensureRepoClean = ({ projectRoot, allowDirty, label }) => Effect.gen(funct
22451
22984
  const preview = dirty.slice(0, MAX_FILES_SHOWN).join("\n ");
22452
22985
  const overflow = dirty.length > MAX_FILES_SHOWN ? `\n ... and ${String(dirty.length - MAX_FILES_SHOWN)} more` : "";
22453
22986
  yield* Console.error(`Uncommitted changes (${String(dirty.length)} file(s)):\n ${preview}${overflow}`);
22454
- if (!(yield* InteractiveMode).allow) {
22455
- yield* new DirtyRepoError({ message: `Refusing to ${label} with a dirty working tree. Commit your changes or pass --allow-dirty.` });
22456
- return;
22457
- }
22458
- if (!(yield* promptConfirm(`Continue ${label} with uncommitted changes?`, { initialValue: false }))) yield* new DirtyRepoError({ message: `${label} cancelled by user.` });
22987
+ if (!(yield* InteractiveMode).allow) return yield* new DirtyRepoError({ message: `Refusing to ${label} with a dirty working tree. Commit your changes or pass --allow-dirty.` });
22988
+ if (!(yield* promptConfirm(`Continue ${label} with uncommitted changes?`, { initialValue: false }))) return yield* new DirtyRepoError({ message: `${label} cancelled by user.` });
22459
22989
  });
22460
22990
 
22461
22991
  //#endregion
@@ -22595,7 +23125,7 @@ const postTokenRequest = (tokenUri, jwt) => Effect.tryPromise({
22595
23125
  });
22596
23126
  const exchangeJwtForAccessToken = (tokenUri, jwt) => Effect.gen(function* () {
22597
23127
  const result = yield* postTokenRequest(tokenUri, jwt);
22598
- if (!result.ok) return yield* Effect.fail(new GooglePlayAuthError({ message: `OAuth token exchange failed: ${String(result.status)} ${result.text}` }));
23128
+ if (!result.ok) return yield* new GooglePlayAuthError({ message: `OAuth token exchange failed: ${String(result.status)} ${result.text}` });
22599
23129
  const json = yield* Effect.try({
22600
23130
  try: () => JSON.parse(result.text),
22601
23131
  catch: (cause) => new GooglePlayAuthError({
@@ -22620,7 +23150,7 @@ const acquireGooglePlayAccessToken = (serviceAccountJson) => Effect.gen(function
22620
23150
  message: "Service account JSON missing required fields (type, client_email, private_key)",
22621
23151
  cause
22622
23152
  })));
22623
- if (parsed.type !== "service_account") return yield* Effect.fail(new GooglePlayAuthError({ message: `Service account JSON has wrong type: ${parsed.type}` }));
23153
+ if (parsed.type !== "service_account") return yield* new GooglePlayAuthError({ message: `Service account JSON has wrong type: ${parsed.type}` });
22624
23154
  const tokenUri = parsed.token_uri ?? GOOGLE_OAUTH_TOKEN_URL;
22625
23155
  return {
22626
23156
  accessToken: yield* exchangeJwtForAccessToken(tokenUri, yield* signJwt(yield* importPrivateKey(parsed.private_key), buildJwtAssertion({
@@ -22660,10 +23190,10 @@ const performFetch = (params) => Effect.tryPromise({
22660
23190
  });
22661
23191
  const callJsonRaw = (params) => Effect.gen(function* () {
22662
23192
  const result = yield* performFetch(params);
22663
- if (!result.ok) return yield* Effect.fail(new GooglePlayApiError({
23193
+ if (!result.ok) return yield* new GooglePlayApiError({
22664
23194
  message: `${params.label} failed: ${String(result.status)} ${result.text}`,
22665
23195
  httpStatus: result.status
22666
- }));
23196
+ });
22667
23197
  return yield* Effect.try({
22668
23198
  try: () => result.text === "" ? {} : JSON.parse(result.text),
22669
23199
  catch: (cause) => new GooglePlayApiError({
@@ -22718,10 +23248,10 @@ const performBundleUpload = (params) => Effect.tryPromise({
22718
23248
  });
22719
23249
  const uploadBundle = (params) => Effect.gen(function* () {
22720
23250
  const result = yield* performBundleUpload(params);
22721
- if (!result.ok) return yield* Effect.fail(new GooglePlayApiError({
23251
+ if (!result.ok) return yield* new GooglePlayApiError({
22722
23252
  message: `edits.bundles.upload failed: ${String(result.status)} ${result.text}`,
22723
23253
  httpStatus: result.status
22724
- }));
23254
+ });
22725
23255
  const raw = yield* Effect.try({
22726
23256
  try: () => JSON.parse(result.text),
22727
23257
  catch: (cause) => new GooglePlayApiError({
@@ -22912,10 +23442,10 @@ const fetchArchiveOverHttp = (url) => Effect.gen(function* () {
22912
23442
  message: `Failed to download AAB from ${url}: ${cause instanceof Error ? cause.message : String(cause)}`
22913
23443
  })
22914
23444
  });
22915
- if (!result.ok || result.bytes === null) return yield* Effect.fail(new CliSubmitError({
23445
+ if (!result.ok || result.bytes === null) return yield* new CliSubmitError({
22916
23446
  code: "SUBMISSION_ARCHIVE_DOWNLOAD_FAILED",
22917
23447
  message: `HTTP ${String(result.status)} fetching archive at ${url}`
22918
- }));
23448
+ });
22919
23449
  return result.bytes;
22920
23450
  });
22921
23451
  const readArchiveBytes = (archive) => archive.source === "path" ? Effect.map(readLocalFile(archive.value, "SUBMISSION_ARCHIVE_READ_FAILED", (cause) => `Failed to read AAB at ${archive.value}: ${cause instanceof Error ? cause.message : String(cause)}`), (buf) => new Uint8Array(buf)) : fetchArchiveOverHttp(archive.value);
@@ -22990,10 +23520,10 @@ const runGooglePlayPipeline = (params) => Effect.gen(function* () {
22990
23520
  });
22991
23521
  const runAndroidGooglePlayUpload = (inputs) => Effect.gen(function* () {
22992
23522
  const { applicationId } = inputs.androidProfile;
22993
- if (applicationId === void 0) return yield* Effect.fail(new CliSubmitError({
23523
+ if (applicationId === void 0) return yield* new CliSubmitError({
22994
23524
  code: "SUBMISSION_ANDROID_APP_ID_MISSING",
22995
23525
  message: "Android submit profile requires applicationId — set submit.<profile>.android.applicationId in eas.json"
22996
- }));
23526
+ });
22997
23527
  const serviceAccountJson = yield* resolveServiceAccountJson({
22998
23528
  api: inputs.api,
22999
23529
  serviceAccountKeyId: inputs.serviceAccountKeyId,
@@ -23018,7 +23548,7 @@ const runAndroidGooglePlayUpload = (inputs) => Effect.gen(function* () {
23018
23548
  errorCode: engineError.code,
23019
23549
  errorMessage: engineError.message
23020
23550
  });
23021
- return yield* Effect.fail(engineError);
23551
+ return yield* engineError;
23022
23552
  })));
23023
23553
  yield* patchSubmissionStatus(inputs.api, inputs.submissionId, { status: "FINISHED" });
23024
23554
  yield* printHuman(`Google Play bundle uploaded (versionCode ${String(result.versionCode)})`);
@@ -23026,7 +23556,7 @@ const runAndroidGooglePlayUpload = (inputs) => Effect.gen(function* () {
23026
23556
  });
23027
23557
 
23028
23558
  //#endregion
23029
- //#region src/application/build-workflow.ts
23559
+ //#region src/application/build-auto-submit.ts
23030
23560
  const buildAutoSubmitIosConfig = (iosProfile, whatToTest) => {
23031
23561
  if (iosProfile?.bundleIdentifier === void 0) return;
23032
23562
  return compact({
@@ -23052,9 +23582,10 @@ const buildAutoSubmitAndroidConfig = (androidProfile) => {
23052
23582
  rollout: androidProfile.rollout
23053
23583
  });
23054
23584
  };
23585
+ /** Submit a freshly-built artifact to the store using the profile's submit config. */
23055
23586
  const runAutoSubmit = (input) => Effect.gen(function* () {
23056
23587
  yield* printHuman(`\nAuto-submitting build ${input.buildId} (profile ${input.profileName})...`);
23057
- const easProfile = yield* resolveEasSubmitProfile((yield* readEasJson(process.cwd())).submit, input.profileName);
23588
+ const easProfile = yield* readSubmitProfile(yield* (yield* CliRuntime).cwd, input.profileName);
23058
23589
  const archiveUrl = (yield* input.api.builds.getInstallLink({ path: { id: input.buildId } })).artifactUrl;
23059
23590
  const iosConfig = input.platform === "ios" ? buildAutoSubmitIosConfig(easProfile.ios, input.whatToTest) : void 0;
23060
23591
  const androidConfig = input.platform === "android" ? buildAutoSubmitAndroidConfig(easProfile.android) : void 0;
@@ -23082,15 +23613,146 @@ const runAutoSubmit = (input) => Effect.gen(function* () {
23082
23613
  }
23083
23614
  yield* printHuman(`Submission final status: ${(yield* pollSubmissionUntilTerminal(input.api, submission.id)).status}`);
23084
23615
  });
23616
+
23617
+ //#endregion
23618
+ //#region src/lib/ios-native-meta.ts
23619
+ const APPLICATION_PRODUCT_TYPE = "com.apple.product-type.application";
23620
+ /**
23621
+ * A build setting that is an unresolved reference (`$(MARKETING_VERSION)`) or a
23622
+ * variable interpolation tells us nothing concrete — treat it as absent so the
23623
+ * profile `metaOverride` can supply a real value.
23624
+ */
23625
+ const concreteSetting = (raw) => {
23626
+ if (typeof raw === "number") return String(raw);
23627
+ if (typeof raw !== "string") return;
23628
+ const value = unquote$1(raw);
23629
+ return value.length === 0 || value.includes("$(") ? void 0 : value;
23630
+ };
23631
+ const findApplicationTarget = (project) => {
23632
+ const nativeTargets = project.pbxNativeTargetSection();
23633
+ for (const [uuid, entry] of Object.entries(nativeTargets)) {
23634
+ if (uuid.endsWith("_comment") || typeof entry === "string") continue;
23635
+ if (unquote$1(entry.productType) === APPLICATION_PRODUCT_TYPE) return entry;
23636
+ }
23637
+ };
23638
+ const configUuidForName = (project, target, configurationName) => {
23639
+ const configList = project.pbxXCConfigurationList()[target.buildConfigurationList];
23640
+ if (!configList || typeof configList === "string") return;
23641
+ const buildConfigSection = project.pbxXCBuildConfigurationSection();
23642
+ return configList.buildConfigurations.map((entry) => entry.value).find((uuid) => {
23643
+ const cfg = buildConfigSection[uuid];
23644
+ return cfg !== void 0 && typeof cfg !== "string" && unquote$1(cfg.name) === configurationName;
23645
+ });
23646
+ };
23647
+ /**
23648
+ * Read app metadata (bundle id, marketing version, build number) for the main
23649
+ * application target of the single `.xcodeproj` under `iosDir`, for a given build
23650
+ * configuration. Used for non-Expo (bare/native) projects where there is no
23651
+ * `app.json`. Missing or unresolved settings come back `undefined` so the caller
23652
+ * can fall back to profile `metaOverride`.
23653
+ */
23654
+ const readIosNativeMeta = (params) => Effect.gen(function* () {
23655
+ const projectDir = yield* findXcodeProjectDir(params.iosDir);
23656
+ const project = yield* parseProject(path.join(projectDir, "project.pbxproj"));
23657
+ const target = findApplicationTarget(project);
23658
+ if (target === void 0) return {};
23659
+ const configUuid = configUuidForName(project, target, params.configurationName);
23660
+ if (configUuid === void 0) return {};
23661
+ const cfg = project.pbxXCBuildConfigurationSection()[configUuid];
23662
+ if (cfg === void 0 || typeof cfg === "string") return {};
23663
+ const settings = cfg.buildSettings;
23664
+ return compact({
23665
+ bundleId: concreteSetting(settings["PRODUCT_BUNDLE_IDENTIFIER"]),
23666
+ marketingVersion: concreteSetting(settings["MARKETING_VERSION"]),
23667
+ currentProjectVersion: concreteSetting(settings["CURRENT_PROJECT_VERSION"])
23668
+ });
23669
+ });
23670
+
23671
+ //#endregion
23672
+ //#region src/application/resolve-app-meta.ts
23673
+ const EMPTY = {
23674
+ bundleId: void 0,
23675
+ androidPackage: void 0,
23676
+ appVersion: void 0,
23677
+ buildNumber: void 0,
23678
+ rawRuntimeVersion: void 0
23679
+ };
23680
+ const warnIfMismatch = (label, override, native) => override !== void 0 && native !== void 0 && override !== native ? printWarn(`${label} override "${override}" differs from the native value "${native}". The better-update.json value will be used for build metadata.`) : Effect.void;
23681
+ const resolveAndroidMeta = (projectRoot, profile) => Effect.gen(function* () {
23682
+ const gradle = yield* readGradleConfig(path.join(projectRoot, "android"));
23683
+ const override = profile.android?.metaOverride;
23684
+ yield* warnIfMismatch("android.applicationId", override?.applicationId, gradle?.applicationId);
23685
+ const androidPackage = override?.applicationId ?? gradle?.applicationId;
23686
+ if (androidPackage === void 0) return yield* new BuildProfileError({ message: "Could not determine the Android applicationId. Set android.applicationId under this build profile in better-update.json, or ensure android/app/build.gradle defines it." });
23687
+ const versionCode = override?.versionCode ?? (gradle?.versionCode === void 0 ? void 0 : String(gradle.versionCode));
23688
+ return {
23689
+ ...EMPTY,
23690
+ androidPackage,
23691
+ appVersion: override?.version ?? gradle?.versionName,
23692
+ buildNumber: versionCode
23693
+ };
23694
+ });
23695
+ const resolveIosMeta = (projectRoot, profile) => Effect.gen(function* () {
23696
+ const configurationName = profile.ios?.buildConfiguration ?? "Release";
23697
+ const native = yield* readIosNativeMeta({
23698
+ iosDir: path.join(projectRoot, "ios"),
23699
+ configurationName
23700
+ }).pipe(Effect.orElseSucceed(() => ({})));
23701
+ const override = profile.ios?.metaOverride;
23702
+ yield* warnIfMismatch("ios.bundleIdentifier", override?.bundleIdentifier, native.bundleId);
23703
+ const bundleId = override?.bundleIdentifier ?? native.bundleId;
23704
+ if (bundleId === void 0) return yield* new BuildProfileError({ message: "Could not determine the iOS bundle identifier. Set ios.bundleIdentifier under this build profile in better-update.json, or ensure the Xcode project defines PRODUCT_BUNDLE_IDENTIFIER for the build configuration." });
23705
+ return {
23706
+ ...EMPTY,
23707
+ bundleId,
23708
+ appVersion: override?.version ?? native.marketingVersion,
23709
+ buildNumber: override?.buildNumber ?? native.currentProjectVersion
23710
+ };
23711
+ });
23712
+ const overlayExpoOverride = (meta, platform, profile) => {
23713
+ if (platform === "ios") {
23714
+ const override = profile.ios?.metaOverride;
23715
+ return {
23716
+ ...meta,
23717
+ bundleId: override?.bundleIdentifier ?? meta.bundleId,
23718
+ appVersion: override?.version ?? meta.appVersion,
23719
+ buildNumber: override?.buildNumber ?? meta.buildNumber
23720
+ };
23721
+ }
23722
+ const override = profile.android?.metaOverride;
23723
+ return {
23724
+ ...meta,
23725
+ androidPackage: override?.applicationId ?? meta.androidPackage,
23726
+ appVersion: override?.version ?? meta.appVersion,
23727
+ buildNumber: override?.versionCode ?? meta.buildNumber
23728
+ };
23729
+ };
23730
+ /**
23731
+ * Resolve app metadata (bundle id / package, version, build number) in a
23732
+ * project-type-aware way. Expo reads from app.json; bare/native read from native
23733
+ * files (build.gradle / pbxproj); KMP/custom rely on profile `metaOverride`,
23734
+ * which also overrides native values when both are present.
23735
+ */
23736
+ const resolveAppMeta = (params) => {
23737
+ if (params.projectType === "expo") {
23738
+ if (params.expoAppMeta === void 0) return Effect.fail(new BuildProfileError({ message: "Internal: missing Expo app metadata for expo project." }));
23739
+ return Effect.succeed(overlayExpoOverride(params.expoAppMeta, params.platform, params.profile));
23740
+ }
23741
+ return params.platform === "ios" ? resolveIosMeta(params.projectRoot, params.profile) : resolveAndroidMeta(params.projectRoot, params.profile);
23742
+ };
23743
+
23744
+ //#endregion
23745
+ //#region src/application/build-workflow.ts
23085
23746
  const runIosPlatformBuild = (input) => Effect.gen(function* () {
23086
23747
  const { api, appMeta, envVars, options, profile, projectId, projectRoot, tempDir } = input;
23087
23748
  if (!profile.ios) return yield* new BuildProfileError({ message: `Profile "${profile.name}" has no ios section.` });
23088
23749
  const iosProfile = profile.ios;
23089
23750
  const iosBundleId = appMeta.bundleId;
23090
- if (!iosBundleId) return yield* new BuildProfileError({ message: "Missing ios.bundleIdentifier in your Expo config." });
23751
+ if (!iosBundleId) return yield* new BuildProfileError({ message: "Missing iOS bundle identifier (set ios.bundleIdentifier or your Expo config)." });
23752
+ const strategy = resolveIosStrategy(profile, input.projectType);
23091
23753
  const isSimulator = iosProfile.simulator === true;
23092
23754
  const credentialsSource = profile.credentialsSource ?? "remote";
23093
- if (!isSimulator && credentialsSource === "remote") yield* ensureIosCredentials(api, {
23755
+ if (strategy !== "custom" && !isSimulator && credentialsSource === "remote") yield* ensureIosCredentials(api, {
23094
23756
  projectId,
23095
23757
  bundleIdentifier: iosBundleId,
23096
23758
  distribution: iosProfile.distribution
@@ -23105,8 +23767,10 @@ const runIosPlatformBuild = (input) => Effect.gen(function* () {
23105
23767
  envVars,
23106
23768
  projectId,
23107
23769
  credentialsSource,
23770
+ strategy,
23108
23771
  rawOutput: options.rawOutput,
23109
- freezeCredentials: options.freezeCredentials ?? false
23772
+ freezeCredentials: options.freezeCredentials ?? false,
23773
+ ...compact({ customCommand: profile.customCommand?.ios })
23110
23774
  }),
23111
23775
  target: isSimulator ? {
23112
23776
  platform: "ios",
@@ -23125,7 +23789,8 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
23125
23789
  if (!profile.android) return yield* new BuildProfileError({ message: `Profile "${profile.name}" has no android section.` });
23126
23790
  const androidProfile = profile.android;
23127
23791
  const androidBundleId = appMeta.androidPackage;
23128
- if (!androidBundleId) return yield* new BuildProfileError({ message: "Missing android.package in your Expo config." });
23792
+ if (!androidBundleId) return yield* new BuildProfileError({ message: "Missing Android applicationId (set android.applicationId or your Expo config)." });
23793
+ const strategy = resolveAndroidStrategy(profile, input.projectType);
23129
23794
  const gradleConfig = yield* readGradleConfig(`${projectRoot}/android`);
23130
23795
  yield* warnOnGradleMismatch(gradleConfig, androidBundleId);
23131
23796
  const applicationIdentifier = gradleConfig?.applicationId ?? androidBundleId;
@@ -23146,7 +23811,9 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
23146
23811
  projectId,
23147
23812
  credentialsSource,
23148
23813
  profileName: profile.name,
23149
- skipCredentials
23814
+ skipCredentials,
23815
+ strategy,
23816
+ ...compact({ customCommand: profile.customCommand?.android })
23150
23817
  }),
23151
23818
  target: androidProfile.format === "aab" ? {
23152
23819
  platform: "android",
@@ -23161,12 +23828,48 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
23161
23828
  };
23162
23829
  });
23163
23830
  const runPlatformBuild = (input) => input.platform === "ios" ? runIosPlatformBuild(input) : runAndroidPlatformBuild(input);
23831
+ const dirExists = (root, name) => Effect.gen(function* () {
23832
+ return yield* (yield* FileSystem.FileSystem).exists(path.join(root, name)).pipe(Effect.orElseSucceed(() => false));
23833
+ });
23834
+ /**
23835
+ * Expo metadata path: read app.json (with the env overlay so dynamic configs
23836
+ * resolve), apply autoIncrement to the user's tree, re-read, then derive the OTA
23837
+ * runtimeVersion. Mirrors the original managed flow.
23838
+ */
23839
+ const resolveExpoBuildMeta = (params) => Effect.gen(function* () {
23840
+ const { userCwd, platform, profile, envVars } = params;
23841
+ yield* applyAutoIncrement({
23842
+ projectRoot: userCwd,
23843
+ platform,
23844
+ config: yield* readExpoConfig(userCwd, envVars),
23845
+ ...platform === "ios" && profile.ios?.autoIncrement !== void 0 ? { iosMode: profile.ios.autoIncrement } : {},
23846
+ ...platform === "android" && profile.android?.autoIncrement !== void 0 ? { androidMode: profile.android.autoIncrement } : {}
23847
+ });
23848
+ const bumpedConfig = yield* readExpoConfig(userCwd, envVars);
23849
+ const appMeta = yield* resolveAppMeta({
23850
+ projectType: "expo",
23851
+ platform,
23852
+ projectRoot: userCwd,
23853
+ profile,
23854
+ expoAppMeta: yield* readAppMeta(bumpedConfig, platform)
23855
+ });
23856
+ return {
23857
+ appMeta,
23858
+ runtimeVersion: yield* resolveRuntimeVersion({
23859
+ raw: appMeta.rawRuntimeVersion,
23860
+ appVersion: appMeta.appVersion,
23861
+ projectRoot: userCwd,
23862
+ platform,
23863
+ buildNumber: appMeta.buildNumber,
23864
+ sdkVersion: bumpedConfig.sdkVersion
23865
+ })
23866
+ };
23867
+ });
23164
23868
  const resolveProfileName = (projectRoot, requested) => Effect.gen(function* () {
23165
- const easConfig = yield* readEasJson(projectRoot);
23166
- const available = Object.keys(easConfig.build ?? {});
23869
+ const available = yield* listBuildProfileNames(projectRoot);
23167
23870
  if (available.includes(requested)) return requested;
23168
23871
  if (!(yield* InteractiveMode).allow || available.length === 0) return requested;
23169
- yield* printHuman(`Build profile "${requested}" not found in eas.json.`);
23872
+ yield* printHuman(`Build profile "${requested}" not found in better-update.json.`);
23170
23873
  return yield* promptSelect("Pick a build profile:", available.map((name) => ({
23171
23874
  value: name,
23172
23875
  label: name
@@ -23180,11 +23883,19 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23180
23883
  allowDirty: options.allowDirty ?? false,
23181
23884
  label: "build"
23182
23885
  });
23183
- const baseConfig = yield* readExpoConfig(userCwd);
23184
- const projectId = yield* extractProjectId(baseConfig);
23185
- const platform = yield* detectPlatform(options.platform, baseConfig);
23886
+ const projectType = yield* detectProjectType({
23887
+ projectRoot: userCwd,
23888
+ override: asProjectType((yield* readBetterUpdateConfig(userCwd))?.["projectType"])
23889
+ });
23890
+ const isExpo = projectType === "expo";
23891
+ const projectId = yield* readProjectId;
23186
23892
  const profile = yield* readBuildProfile(userCwd, yield* resolveProfileName(userCwd, options.profileName));
23187
23893
  if (profile.developmentClient === true) yield* warnIfDevClientMissing(userCwd);
23894
+ const platform = isExpo ? yield* detectPlatform(options.platform, yield* readExpoConfig(userCwd)) : yield* detectPlatformGeneric(options.platform, {
23895
+ profile,
23896
+ hasAndroidDir: yield* dirExists(userCwd, "android"),
23897
+ hasIosDir: yield* dirExists(userCwd, "ios")
23898
+ });
23188
23899
  const envVars = {
23189
23900
  ...yield* pullEnvVars(api, {
23190
23901
  projectId,
@@ -23192,36 +23903,35 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23192
23903
  }),
23193
23904
  ...profile.env
23194
23905
  };
23195
- yield* applyAutoIncrement({
23196
- projectRoot: userCwd,
23197
- platform,
23198
- config: yield* readExpoConfig(userCwd, envVars),
23199
- ...platform === "ios" && profile.ios?.autoIncrement !== void 0 ? { iosMode: profile.ios.autoIncrement } : {},
23200
- ...platform === "android" && profile.android?.autoIncrement !== void 0 ? { androidMode: profile.android.autoIncrement } : {}
23201
- });
23202
- const bumpedConfig = yield* readExpoConfig(userCwd, envVars);
23203
- const appMeta = yield* readAppMeta(bumpedConfig, platform);
23204
- const runtimeVersion = yield* resolveRuntimeVersion({
23205
- raw: appMeta.rawRuntimeVersion,
23206
- appVersion: appMeta.appVersion,
23207
- projectRoot: userCwd,
23906
+ const { appMeta, runtimeVersion } = isExpo ? yield* resolveExpoBuildMeta({
23907
+ userCwd,
23208
23908
  platform,
23209
- buildNumber: appMeta.buildNumber,
23210
- sdkVersion: bumpedConfig.sdkVersion
23211
- });
23909
+ profile,
23910
+ envVars
23911
+ }) : {
23912
+ appMeta: yield* resolveAppMeta({
23913
+ projectType,
23914
+ platform,
23915
+ projectRoot: userCwd,
23916
+ profile
23917
+ }),
23918
+ runtimeVersion: void 0
23919
+ };
23212
23920
  if (options.clearCache) yield* clearBuildCaches(userCwd);
23213
23921
  const tempDir = yield* acquireBuildTempDir;
23214
23922
  const staging = yield* prepareStagingProject({
23215
23923
  userCwd,
23216
23924
  tempDir,
23217
- envVars
23925
+ envVars,
23926
+ projectType
23218
23927
  });
23219
- yield* printHuman(`Building ${platform} artifact for profile "${profile.name}" (runtimeVersion=${runtimeVersion})`);
23928
+ yield* printHuman(`Building ${platform} artifact for profile "${profile.name}"${runtimeVersion === void 0 ? "" : ` (runtimeVersion=${runtimeVersion})`}`);
23220
23929
  const { build, target, bundleId } = yield* runPlatformBuild({
23221
23930
  api,
23222
23931
  options,
23223
23932
  platform,
23224
23933
  profile,
23934
+ projectType,
23225
23935
  appMeta,
23226
23936
  envVars,
23227
23937
  projectId,
@@ -23255,18 +23965,18 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23255
23965
  commit: rawGitContext.commit,
23256
23966
  dirty: rawGitContext.dirty
23257
23967
  });
23258
- const fingerprintHash = yield* runFingerprintForPlatform(userCwd, platform).pipe(Effect.map((entry) => entry.hash), Effect.catchAll(() => Effect.succeed(void 0)));
23968
+ const fingerprintHash = isExpo ? yield* runFingerprintForPlatform(userCwd, platform).pipe(Effect.map((entry) => entry.hash), Effect.orElseSucceed(() => void 0)) : void 0;
23259
23969
  const result = yield* reserveAndUpload(api, {
23260
23970
  target,
23261
23971
  projectId,
23262
23972
  profileName: profile.name,
23263
- runtimeVersion,
23264
23973
  bundleId,
23265
23974
  gitContext,
23266
23975
  artifactPath: build.artifactPath,
23267
23976
  sha256: build.sha256,
23268
23977
  byteSize: build.byteSize,
23269
23978
  ...compact({
23979
+ runtimeVersion,
23270
23980
  appVersion: appMeta.appVersion,
23271
23981
  buildNumber: appMeta.buildNumber,
23272
23982
  message: options.message,
@@ -23279,7 +23989,7 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23279
23989
  ["Status", result.status],
23280
23990
  ["Platform", platform],
23281
23991
  ["Profile", profile.name],
23282
- ["Runtime version", runtimeVersion],
23992
+ ...runtimeVersion === void 0 ? [] : [["Runtime version", runtimeVersion]],
23283
23993
  ["Artifact", build.artifactPath],
23284
23994
  ["SHA-256", build.sha256],
23285
23995
  ["Bytes", String(build.byteSize)]
@@ -23324,7 +24034,7 @@ const DEFAULT_PROFILES = [
23324
24034
  "preview",
23325
24035
  "production"
23326
24036
  ];
23327
- const writeEasJson$1 = (filePath, value) => Effect.gen(function* () {
24037
+ const writeEasJson = (filePath, value) => Effect.gen(function* () {
23328
24038
  yield* (yield* FileSystem.FileSystem).writeFileString(filePath, `${JSON.stringify(value, null, 2)}\n`).pipe(Effect.mapError((cause) => new BuildProfileError({ message: `Failed to write eas.json: ${cause.message}` })));
23329
24039
  });
23330
24040
  const configureBuildCommand = defineCommand({
@@ -23342,7 +24052,7 @@ const configureBuildCommand = defineCommand({
23342
24052
  const easJsonPath = path.join(projectRoot, "eas.json");
23343
24053
  const fs = yield* FileSystem.FileSystem;
23344
24054
  if (!(yield* fs.exists(easJsonPath))) {
23345
- yield* writeEasJson$1(easJsonPath, DEFAULT_EAS_JSON);
24055
+ yield* writeEasJson(easJsonPath, DEFAULT_EAS_JSON);
23346
24056
  yield* printHuman(`Wrote eas.json with default profiles to ${easJsonPath}.`);
23347
24057
  yield* printHumanKeyValue([["Profiles", DEFAULT_PROFILES.join(", ")], ["Path", easJsonPath]]);
23348
24058
  return {
@@ -23359,7 +24069,7 @@ const configureBuildCommand = defineCommand({
23359
24069
  path: easJsonPath
23360
24070
  };
23361
24071
  }
23362
- yield* writeEasJson$1(easJsonPath, DEFAULT_EAS_JSON);
24072
+ yield* writeEasJson(easJsonPath, DEFAULT_EAS_JSON);
23363
24073
  yield* printHuman(`Overwrote eas.json with default profiles.`);
23364
24074
  return {
23365
24075
  action: "overwritten",
@@ -23387,7 +24097,7 @@ const configureBuildCommand = defineCommand({
23387
24097
  };
23388
24098
  }
23389
24099
  const additions = Object.fromEntries(missing.map((name) => [name, DEFAULT_EAS_JSON.build[name]]));
23390
- yield* writeEasJson$1(easJsonPath, {
24100
+ yield* writeEasJson(easJsonPath, {
23391
24101
  build: {
23392
24102
  ...config.build,
23393
24103
  ...additions
@@ -23554,7 +24264,7 @@ const compatibilityMatrixCommand = defineCommand({
23554
24264
 
23555
24265
  //#endregion
23556
24266
  //#region src/commands/builds/delete.ts
23557
- const deleteCommand$5 = defineCommand({
24267
+ const deleteCommand$6 = defineCommand({
23558
24268
  meta: {
23559
24269
  name: "delete",
23560
24270
  description: "Delete a build"
@@ -23610,10 +24320,7 @@ const downloadCommand$1 = defineCommand({
23610
24320
  const fs = yield* FileSystem.FileSystem;
23611
24321
  const cwd = yield* (yield* CliRuntime).cwd;
23612
24322
  const { artifact } = yield* api.builds.get({ path: { id: args.id } });
23613
- if (!artifact) {
23614
- yield* Effect.fail(new UploadFailedError({ message: `Build ${args.id} has no artifact yet.` }));
23615
- return;
23616
- }
24323
+ if (!artifact) return yield* new UploadFailedError({ message: `Build ${args.id} has no artifact yet.` });
23617
24324
  const link = yield* api.builds.getInstallLink({ path: { id: args.id } });
23618
24325
  const ext = artifact.format;
23619
24326
  const outputPath = args.output ?? path.join(cwd, `${args.id}.${ext}`);
@@ -23703,7 +24410,7 @@ const DISTRIBUTION_OPTIONS$1 = [
23703
24410
  "play-store",
23704
24411
  "direct"
23705
24412
  ];
23706
- const listCommand$7 = defineCommand({
24413
+ const listCommand$10 = defineCommand({
23707
24414
  meta: {
23708
24415
  name: "list",
23709
24416
  description: "List builds for the linked project"
@@ -23886,7 +24593,7 @@ const runInherit = (step, bin, ...args) => Command.exitCode(Command.make(bin, ..
23886
24593
  * Locate a tool on PATH. Returns the absolute path or fails with NativeRunError.
23887
24594
  */
23888
24595
  const which = (bin) => Effect.gen(function* () {
23889
- const trimmed = (yield* Command.string(Command.make("which", bin)).pipe(Effect.catchAll(() => Effect.fail(new NativeRunError({ message: `${bin} not found in PATH` }))))).trim();
24596
+ const trimmed = (yield* Command.string(Command.make("which", bin)).pipe(Effect.mapError(() => new NativeRunError({ message: `${bin} not found in PATH` })))).trim();
23890
24597
  if (trimmed === "") return yield* new NativeRunError({ message: `${bin} not found in PATH` });
23891
24598
  return trimmed;
23892
24599
  });
@@ -23905,7 +24612,7 @@ const findAppBundle = (root) => Effect.gen(function* () {
23905
24612
  const fs = yield* FileSystem.FileSystem;
23906
24613
  const candidates = [root, path.join(root, "Payload")];
23907
24614
  for (const candidate of candidates) {
23908
- const app = (yield* fs.readDirectory(candidate).pipe(Effect.catchAll(() => Effect.succeed([])))).find((entry) => entry.endsWith(".app"));
24615
+ const app = (yield* fs.readDirectory(candidate).pipe(Effect.orElseSucceed(() => []))).find((entry) => entry.endsWith(".app"));
23909
24616
  if (app) return path.join(candidate, app);
23910
24617
  }
23911
24618
  return yield* new NativeRunError({ message: `No .app bundle found inside ${root} or ${path.join(root, "Payload")}.` });
@@ -23984,10 +24691,10 @@ const installAndLaunchIosDevice = (params) => Effect.gen(function* () {
23984
24691
  * fall back to a CLI flag.
23985
24692
  */
23986
24693
  const tryReadApkPackageWith = (bin, apkPath) => Effect.gen(function* () {
23987
- if (!(yield* which(bin).pipe(Effect.catchAll(() => Effect.succeed(null))))) return;
23988
- const raw = yield* execCapture(`${bin} dump`, bin, "dump", "badging", apkPath).pipe(Effect.catchAll(() => Effect.succeed(null)));
24694
+ if (!(yield* which(bin).pipe(Effect.orElseSucceed(() => null)))) return;
24695
+ const raw = yield* execCapture(`${bin} dump`, bin, "dump", "badging", apkPath).pipe(Effect.orElseSucceed(() => null));
23989
24696
  if (!raw) return;
23990
- return /package: name='([^']+)'/u.exec(raw)?.[1];
24697
+ return /package: name='(?<packageName>[^']+)'/u.exec(raw)?.[1];
23991
24698
  });
23992
24699
  const readApkPackageName = (apkPath) => Effect.gen(function* () {
23993
24700
  return yield* Effect.reduce(["aapt2", "aapt"], void 0, (acc, bin) => acc === void 0 ? tryReadApkPackageWith(bin, apkPath) : Effect.succeed(acc));
@@ -24057,10 +24764,7 @@ const runIosSimulator = (params) => Effect.gen(function* () {
24057
24764
  });
24058
24765
  const runIosDevice = (params) => Effect.gen(function* () {
24059
24766
  const { deviceSelector } = params;
24060
- if (deviceSelector === void 0) {
24061
- yield* Effect.fail(new InvalidArgumentError({ message: "Pass --device-id <udid>. Run `xcrun devicectl list devices` to list connected devices." }));
24062
- return;
24063
- }
24767
+ if (deviceSelector === void 0) return yield* new InvalidArgumentError({ message: "Pass --device-id <udid>. Run `xcrun devicectl list devices` to list connected devices." });
24064
24768
  const bundleId = yield* readBundleIdFromApp(yield* findAppBundle(yield* extractIosArtifact({
24065
24769
  tempDir: params.tempDir,
24066
24770
  artifactPath: params.artifactPath,
@@ -24085,21 +24789,12 @@ const runIos = (params) => {
24085
24789
  return Effect.fail(new NativeRunError({ message: `Cannot install ${params.format} on iOS; only tar.gz (simulator) or ipa are supported.` }));
24086
24790
  };
24087
24791
  const runAndroid = (params) => Effect.gen(function* () {
24088
- if (params.format === "aab") {
24089
- yield* Effect.fail(new InvalidArgumentError({ message: ".aab artifacts cannot be installed directly. Use bundletool to convert to apks, or download the play-store APK." }));
24090
- return;
24091
- }
24092
- if (params.format !== "apk") {
24093
- yield* Effect.fail(new NativeRunError({ message: `Cannot install ${params.format} on Android; only apk is supported.` }));
24094
- return;
24095
- }
24792
+ if (params.format === "aab") return yield* new InvalidArgumentError({ message: ".aab artifacts cannot be installed directly. Use bundletool to convert to apks, or download the play-store APK." });
24793
+ if (params.format !== "apk") return yield* new NativeRunError({ message: `Cannot install ${params.format} on Android; only apk is supported.` });
24096
24794
  const device = yield* pickAndroidDevice(params.emulatorSelector);
24097
24795
  const detected = yield* readApkPackageName(params.artifactPath);
24098
24796
  const packageName = params.packageOverride ?? detected;
24099
- if (!packageName) {
24100
- yield* Effect.fail(new InvalidArgumentError({ message: "Could not detect APK package name (aapt/aapt2 not on PATH). Pass --package <name> explicitly." }));
24101
- return;
24102
- }
24797
+ if (!packageName) return yield* new InvalidArgumentError({ message: "Could not detect APK package name (aapt/aapt2 not on PATH). Pass --package <name> explicitly." });
24103
24798
  yield* printHuman(`Installing on Android device ${device.serial}...`);
24104
24799
  yield* installAndLaunchAndroid({
24105
24800
  serial: device.serial,
@@ -24164,7 +24859,7 @@ const runCommand$1 = defineCommand({
24164
24859
  projectId
24165
24860
  });
24166
24861
  const { artifact } = build;
24167
- if (!artifact) return yield* Effect.fail(new UploadFailedError({ message: `Build ${build.id} has no artifact yet.` }));
24862
+ if (!artifact) return yield* new UploadFailedError({ message: `Build ${build.id} has no artifact yet.` });
24168
24863
  const link = yield* api.builds.getInstallLink({ path: { id: build.id } });
24169
24864
  const tempDir = yield* acquireBuildTempDir;
24170
24865
  const artifactPath = path.join(tempDir, `artifact.${artifact.format}`);
@@ -24200,7 +24895,7 @@ const runCommand$1 = defineCommand({
24200
24895
  //#region src/application/upload-workflow.ts
24201
24896
  const resolveIosTarget = (profile, appMeta) => Effect.gen(function* () {
24202
24897
  if (!profile.ios) return yield* new BuildProfileError({ message: `Profile "${profile.name}" has no ios section.` });
24203
- if (!appMeta.bundleId) return yield* new BuildProfileError({ message: "Missing ios.bundleIdentifier in your Expo config." });
24898
+ if (!appMeta.bundleId) return yield* new BuildProfileError({ message: "Missing iOS bundle identifier (set ios.bundleIdentifier or your Expo config)." });
24204
24899
  return {
24205
24900
  target: {
24206
24901
  platform: "ios",
@@ -24212,7 +24907,7 @@ const resolveIosTarget = (profile, appMeta) => Effect.gen(function* () {
24212
24907
  });
24213
24908
  const resolveAndroidTarget = (profile, appMeta, projectRoot) => Effect.gen(function* () {
24214
24909
  if (!profile.android) return yield* new BuildProfileError({ message: `Profile "${profile.name}" has no android section.` });
24215
- if (!appMeta.androidPackage) return yield* new BuildProfileError({ message: "Missing android.package in your Expo config." });
24910
+ if (!appMeta.androidPackage) return yield* new BuildProfileError({ message: "Missing Android applicationId (set android.applicationId or your Expo config)." });
24216
24911
  const gradleConfig = yield* readGradleConfig(`${projectRoot}/android`);
24217
24912
  yield* warnOnGradleMismatch(gradleConfig, appMeta.androidPackage);
24218
24913
  const bundleId = gradleConfig?.applicationId ?? appMeta.androidPackage;
@@ -24229,27 +24924,56 @@ const resolveAndroidTarget = (profile, appMeta, projectRoot) => Effect.gen(funct
24229
24924
  bundleId
24230
24925
  };
24231
24926
  });
24927
+ /** Resolve app metadata + OTA runtimeVersion for an upload (project-type aware). */
24928
+ const resolveUploadMeta = (params) => Effect.gen(function* () {
24929
+ const { projectType, platform, projectRoot, profile, envVars } = params;
24930
+ const expoConfig = projectType === "expo" ? yield* readExpoConfig(projectRoot, envVars) : void 0;
24931
+ const appMeta = yield* resolveAppMeta({
24932
+ projectType,
24933
+ platform,
24934
+ projectRoot,
24935
+ profile,
24936
+ ...compact({
24937
+ expoConfig,
24938
+ expoAppMeta: expoConfig === void 0 ? void 0 : yield* readAppMeta(expoConfig, platform)
24939
+ })
24940
+ });
24941
+ return {
24942
+ appMeta,
24943
+ runtimeVersion: expoConfig === void 0 ? void 0 : yield* resolveRuntimeVersion({
24944
+ raw: appMeta.rawRuntimeVersion,
24945
+ appVersion: appMeta.appVersion,
24946
+ projectRoot,
24947
+ platform,
24948
+ buildNumber: appMeta.buildNumber,
24949
+ sdkVersion: expoConfig.sdkVersion
24950
+ }),
24951
+ isExpo: expoConfig !== void 0
24952
+ };
24953
+ });
24232
24954
  const runUploadWorkflow = (options) => Effect.gen(function* () {
24233
24955
  const api = yield* apiClient;
24234
24956
  const projectRoot = yield* (yield* CliRuntime).cwd;
24235
- if (!(yield* (yield* FileSystem.FileSystem).exists(options.artifactPath).pipe(Effect.orElseSucceed(() => false)))) yield* new ArtifactNotFoundError({ message: `Artifact not found at ${options.artifactPath}.` });
24236
- const projectId = yield* extractProjectId(yield* readExpoConfig(projectRoot));
24957
+ if (!(yield* (yield* FileSystem.FileSystem).exists(options.artifactPath).pipe(Effect.orElseSucceed(() => false)))) return yield* new ArtifactNotFoundError({ message: `Artifact not found at ${options.artifactPath}.` });
24958
+ const projectType = yield* detectProjectType({
24959
+ projectRoot,
24960
+ override: asProjectType((yield* readBetterUpdateConfig(projectRoot))?.["projectType"])
24961
+ });
24962
+ const projectId = yield* readProjectId;
24237
24963
  const profile = yield* readBuildProfile(projectRoot, options.profileName);
24238
- const expoConfig = yield* readExpoConfig(projectRoot, {
24964
+ const envVars = {
24239
24965
  ...yield* pullEnvVars(api, {
24240
24966
  projectId,
24241
24967
  environment: profile.environment
24242
24968
  }),
24243
24969
  ...profile.env
24244
- });
24245
- const appMeta = yield* readAppMeta(expoConfig, options.platform);
24246
- const runtimeVersion = yield* resolveRuntimeVersion({
24247
- raw: appMeta.rawRuntimeVersion,
24248
- appVersion: appMeta.appVersion,
24249
- projectRoot,
24970
+ };
24971
+ const { appMeta, runtimeVersion, isExpo } = yield* resolveUploadMeta({
24972
+ projectType,
24250
24973
  platform: options.platform,
24251
- buildNumber: appMeta.buildNumber,
24252
- sdkVersion: expoConfig.sdkVersion
24974
+ projectRoot,
24975
+ profile,
24976
+ envVars
24253
24977
  });
24254
24978
  const { target, bundleId } = options.platform === "ios" ? yield* resolveIosTarget(profile, appMeta) : yield* resolveAndroidTarget(profile, appMeta, projectRoot);
24255
24979
  yield* printHuman(`Hashing ${options.artifactPath}...`);
@@ -24260,7 +24984,7 @@ const runUploadWorkflow = (options) => Effect.gen(function* () {
24260
24984
  commit: rawGitContext.commit,
24261
24985
  dirty: rawGitContext.dirty
24262
24986
  });
24263
- const fingerprintHash = yield* runFingerprintForPlatform(projectRoot, options.platform).pipe(Effect.map((entry) => entry.hash), Effect.catchAll(() => Effect.succeed(void 0)));
24987
+ const fingerprintHash = isExpo ? yield* runFingerprintForPlatform(projectRoot, options.platform).pipe(Effect.map((entry) => entry.hash), Effect.orElseSucceed(() => void 0)) : void 0;
24264
24988
  const result = yield* reserveAndUpload(api, compact({
24265
24989
  target,
24266
24990
  projectId,
@@ -24282,7 +25006,7 @@ const runUploadWorkflow = (options) => Effect.gen(function* () {
24282
25006
  ["Status", result.status],
24283
25007
  ["Platform", options.platform],
24284
25008
  ["Profile", profile.name],
24285
- ["Runtime version", runtimeVersion],
25009
+ ...runtimeVersion === void 0 ? [] : [["Runtime version", runtimeVersion]],
24286
25010
  ["Artifact", options.artifactPath],
24287
25011
  ["SHA-256", sha256],
24288
25012
  ["Bytes", String(byteSize)]
@@ -24344,9 +25068,9 @@ const buildsCommand = defineCommand({
24344
25068
  description: "Manage builds"
24345
25069
  },
24346
25070
  subCommands: {
24347
- list: listCommand$7,
25071
+ list: listCommand$10,
24348
25072
  get: getCommand$2,
24349
- delete: deleteCommand$5,
25073
+ delete: deleteCommand$6,
24350
25074
  download: downloadCommand$1,
24351
25075
  run: runCommand$1,
24352
25076
  "install-link": installLinkCommand,
@@ -24372,7 +25096,7 @@ const resolveNamedResourceId$1 = (params) => resolveNamedResourceId$2(params, (m
24372
25096
 
24373
25097
  //#endregion
24374
25098
  //#region src/commands/channels/create.ts
24375
- const createCommand$3 = defineCommand({
25099
+ const createCommand$4 = defineCommand({
24376
25100
  meta: {
24377
25101
  name: "create",
24378
25102
  description: "Create a channel"
@@ -24417,7 +25141,7 @@ const createCommand$3 = defineCommand({
24417
25141
 
24418
25142
  //#endregion
24419
25143
  //#region src/commands/channels/delete.ts
24420
- const deleteCommand$4 = defineCommand({
25144
+ const deleteCommand$5 = defineCommand({
24421
25145
  meta: {
24422
25146
  name: "delete",
24423
25147
  description: "Delete a channel"
@@ -24440,6 +25164,193 @@ const deleteCommand$4 = defineCommand({
24440
25164
  })
24441
25165
  });
24442
25166
 
25167
+ //#endregion
25168
+ //#region src/commands/channels/grants/helpers.ts
25169
+ var GrantCommandError = class extends Data.TaggedError("GrantCommandError") {};
25170
+ const grantErrorExtras = { GrantCommandError: 2 };
25171
+
25172
+ //#endregion
25173
+ //#region src/commands/channels/grants/list.ts
25174
+ const resolveChannel = (channels, target) => Effect.gen(function* () {
25175
+ const channel = channels.find((ch) => ch.id === target) ?? channels.find((ch) => ch.name === target);
25176
+ if (!channel) return yield* new ChannelCommandError({ message: `Channel "${target}" not found by ID or name.` });
25177
+ return channel;
25178
+ });
25179
+ const listCommand$9 = defineCommand({
25180
+ meta: {
25181
+ name: "list",
25182
+ description: "List per-member grants on a channel"
25183
+ },
25184
+ args: { channel: {
25185
+ type: "positional",
25186
+ required: true,
25187
+ description: "Channel ID or channel name"
25188
+ } },
25189
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
25190
+ const projectId = yield* readProjectId;
25191
+ const api = yield* apiClient;
25192
+ const channel = yield* resolveChannel(yield* drainPages((page) => api.channels.list({ urlParams: {
25193
+ projectId,
25194
+ limit: 100,
25195
+ page
25196
+ } })), args.channel);
25197
+ yield* printList([
25198
+ "ID",
25199
+ "Member ID",
25200
+ "Effect",
25201
+ "Actions",
25202
+ "Created"
25203
+ ], (yield* api.channelGrants.list({
25204
+ path: { id: channel.id },
25205
+ urlParams: {}
25206
+ })).map((grant) => [
25207
+ grant.id,
25208
+ grant.memberId,
25209
+ grant.effect,
25210
+ grant.actions.join(", "),
25211
+ grant.createdAt
25212
+ ]), "No grants found for this channel.");
25213
+ }), { exits: {
25214
+ ...channelErrorExtras,
25215
+ ...grantErrorExtras
25216
+ } })
25217
+ });
25218
+
25219
+ //#endregion
25220
+ //#region src/commands/channels/grants/revoke.ts
25221
+ const revokeCommand$2 = defineCommand({
25222
+ meta: {
25223
+ name: "revoke",
25224
+ description: "Revoke all grants for a member on a channel"
25225
+ },
25226
+ args: {
25227
+ channel: {
25228
+ type: "positional",
25229
+ required: true,
25230
+ description: "Channel ID or channel name"
25231
+ },
25232
+ member: {
25233
+ type: "string",
25234
+ required: true,
25235
+ description: "Member ID whose grants to revoke"
25236
+ },
25237
+ yes: {
25238
+ type: "boolean",
25239
+ description: "Skip confirmation prompt"
25240
+ }
25241
+ },
25242
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
25243
+ if (!args.yes) {
25244
+ if (!(yield* promptConfirm(`Revoke all grants for member ${args.member} on channel ${args.channel}?`, { initialValue: false }))) {
25245
+ yield* printHuman("Cancelled.");
25246
+ return { deleted: 0 };
25247
+ }
25248
+ }
25249
+ const projectId = yield* readProjectId;
25250
+ const api = yield* apiClient;
25251
+ const channels = yield* drainPages((page) => api.channels.list({ urlParams: {
25252
+ projectId,
25253
+ limit: 100,
25254
+ page
25255
+ } }));
25256
+ const channel = channels.find((ch) => ch.id === args.channel) ?? channels.find((ch) => ch.name === args.channel);
25257
+ if (!channel) return yield* new ChannelCommandError({ message: `Channel "${args.channel}" not found by ID or name.` });
25258
+ const result = yield* api.channelGrants.delete({ path: {
25259
+ id: channel.id,
25260
+ memberId: args.member
25261
+ } });
25262
+ yield* printHuman(`Revoked grants for member ${args.member} on channel "${channel.name}".`);
25263
+ return result;
25264
+ }), {
25265
+ exits: {
25266
+ ...channelErrorExtras,
25267
+ ...grantErrorExtras
25268
+ },
25269
+ json: "value"
25270
+ })
25271
+ });
25272
+
25273
+ //#endregion
25274
+ //#region src/commands/channels/grants/set.ts
25275
+ const setCommand$3 = defineCommand({
25276
+ meta: {
25277
+ name: "set",
25278
+ description: "Create or replace a member's grant on a channel"
25279
+ },
25280
+ args: {
25281
+ channel: {
25282
+ type: "positional",
25283
+ required: true,
25284
+ description: "Channel ID or channel name"
25285
+ },
25286
+ member: {
25287
+ type: "string",
25288
+ required: true,
25289
+ description: "Member ID to grant permissions to"
25290
+ },
25291
+ actions: {
25292
+ type: "string",
25293
+ required: true,
25294
+ description: "Permission action tokens in resource:action format, comma-separated (e.g. update:create,rollout:update)"
25295
+ },
25296
+ effect: {
25297
+ type: "string",
25298
+ description: "Grant effect: allow (default) or deny"
25299
+ }
25300
+ },
25301
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
25302
+ const effectValue = args.effect ?? "allow";
25303
+ if (effectValue !== "allow" && effectValue !== "deny") return yield* new GrantCommandError({ message: `Invalid effect "${effectValue}" — must be "allow" or "deny".` });
25304
+ const actionTokens = args.actions.split(",").map((tok) => tok.trim()).filter((tok) => tok.length > 0);
25305
+ if (actionTokens.length === 0) return yield* new GrantCommandError({ message: "At least one action token is required." });
25306
+ const projectId = yield* readProjectId;
25307
+ const api = yield* apiClient;
25308
+ const channels = yield* drainPages((page) => api.channels.list({ urlParams: {
25309
+ projectId,
25310
+ limit: 100,
25311
+ page
25312
+ } }));
25313
+ const channel = channels.find((ch) => ch.id === args.channel) ?? channels.find((ch) => ch.name === args.channel);
25314
+ if (!channel) return yield* new ChannelCommandError({ message: `Channel "${args.channel}" not found by ID or name.` });
25315
+ const grant = yield* api.channelGrants.upsert({
25316
+ path: {
25317
+ id: channel.id,
25318
+ memberId: args.member
25319
+ },
25320
+ payload: {
25321
+ effect: effectValue,
25322
+ actions: actionTokens
25323
+ }
25324
+ });
25325
+ yield* printHumanKeyValue([
25326
+ ["ID", grant.id],
25327
+ ["Member ID", grant.memberId],
25328
+ ["Channel ID", grant.scopeId],
25329
+ ["Effect", grant.effect],
25330
+ ["Actions", grant.actions.join(", ")],
25331
+ ["Created", grant.createdAt]
25332
+ ]);
25333
+ return grant;
25334
+ }), { exits: {
25335
+ ...channelErrorExtras,
25336
+ ...grantErrorExtras
25337
+ } })
25338
+ });
25339
+
25340
+ //#endregion
25341
+ //#region src/commands/channels/grants/index.ts
25342
+ const grantsCommand$1 = defineCommand({
25343
+ meta: {
25344
+ name: "grants",
25345
+ description: "Manage per-member permission grants on a channel"
25346
+ },
25347
+ subCommands: {
25348
+ list: listCommand$9,
25349
+ set: setCommand$3,
25350
+ revoke: revokeCommand$2
25351
+ }
25352
+ });
25353
+
24443
25354
  //#endregion
24444
25355
  //#region src/commands/channels/insights.ts
24445
25356
  const insightsCommand$1 = defineCommand({
@@ -24486,7 +25397,7 @@ const insightsCommand$1 = defineCommand({
24486
25397
 
24487
25398
  //#endregion
24488
25399
  //#region src/commands/channels/list.ts
24489
- const listCommand$6 = defineCommand({
25400
+ const listCommand$8 = defineCommand({
24490
25401
  meta: {
24491
25402
  name: "list",
24492
25403
  description: "List channels for the linked project"
@@ -24590,7 +25501,7 @@ const completeCommand$1 = defineCommand({
24590
25501
 
24591
25502
  //#endregion
24592
25503
  //#region src/commands/channels/rollout/create.ts
24593
- const createCommand$2 = defineCommand({
25504
+ const createCommand$3 = defineCommand({
24594
25505
  meta: {
24595
25506
  name: "create",
24596
25507
  description: "Start a branch rollout on a channel"
@@ -24671,7 +25582,7 @@ const revertCommand$2 = defineCommand({
24671
25582
 
24672
25583
  //#endregion
24673
25584
  //#region src/commands/channels/rollout/update.ts
24674
- const updateCommand$3 = defineCommand({
25585
+ const updateCommand$4 = defineCommand({
24675
25586
  meta: {
24676
25587
  name: "update",
24677
25588
  description: "Update the rollout percentage on a channel"
@@ -24710,8 +25621,8 @@ const rolloutCommand$1 = defineCommand({
24710
25621
  description: "Manage channel branch rollouts"
24711
25622
  },
24712
25623
  subCommands: {
24713
- create: createCommand$2,
24714
- update: updateCommand$3,
25624
+ create: createCommand$3,
25625
+ update: updateCommand$4,
24715
25626
  complete: completeCommand$1,
24716
25627
  revert: revertCommand$2
24717
25628
  }
@@ -24719,7 +25630,7 @@ const rolloutCommand$1 = defineCommand({
24719
25630
 
24720
25631
  //#endregion
24721
25632
  //#region src/commands/channels/update.ts
24722
- const updateCommand$2 = defineCommand({
25633
+ const updateCommand$3 = defineCommand({
24723
25634
  meta: {
24724
25635
  name: "update",
24725
25636
  description: "Relink a channel to a different branch"
@@ -24762,7 +25673,7 @@ const updateCommand$2 = defineCommand({
24762
25673
 
24763
25674
  //#endregion
24764
25675
  //#region src/commands/channels/view.ts
24765
- const viewCommand$2 = defineCommand({
25676
+ const viewCommand$3 = defineCommand({
24766
25677
  meta: {
24767
25678
  name: "view",
24768
25679
  description: "Show a channel by ID or name"
@@ -24785,7 +25696,7 @@ const viewCommand$2 = defineCommand({
24785
25696
  page
24786
25697
  } }))]);
24787
25698
  const channel = channels.find((entry) => entry.id === args.target) ?? channels.find((entry) => entry.name === args.target);
24788
- if (!channel) return yield* Effect.fail(new ChannelCommandError({ message: `Channel "${args.target}" not found by ID or name.` }));
25699
+ if (!channel) return yield* new ChannelCommandError({ message: `Channel "${args.target}" not found by ID or name.` });
24789
25700
  const branchName = new Map(branches.map((branch) => [branch.id, branch.name])).get(channel.branchId) ?? channel.branchId;
24790
25701
  yield* printHumanKeyValue([
24791
25702
  ["ID", channel.id],
@@ -24822,14 +25733,15 @@ const channelsCommand = defineCommand({
24822
25733
  description: "Manage channels"
24823
25734
  },
24824
25735
  subCommands: {
24825
- list: listCommand$6,
24826
- view: viewCommand$2,
24827
- create: createCommand$3,
24828
- update: updateCommand$2,
25736
+ list: listCommand$8,
25737
+ view: viewCommand$3,
25738
+ create: createCommand$4,
25739
+ update: updateCommand$3,
24829
25740
  pause: pauseCommand,
24830
25741
  resume: resumeCommand,
24831
- delete: deleteCommand$4,
25742
+ delete: deleteCommand$5,
24832
25743
  rollout: rolloutCommand$1,
25744
+ grants: grantsCommand$1,
24833
25745
  insights: insightsCommand$1
24834
25746
  }
24835
25747
  });
@@ -24891,7 +25803,7 @@ const parseGoogleServiceAccountKey = (jsonText) => Effect.gen(function* () {
24891
25803
  const APPLE_TEAM_ID_RE = /^[A-Z0-9]{10}$/u;
24892
25804
  const extractTeamId = (params) => {
24893
25805
  if (params.orgUnit && APPLE_TEAM_ID_RE.test(params.orgUnit)) return params.orgUnit;
24894
- return /\(([A-Z0-9]{10})\)\s*$/u.exec(params.signingIdentity)?.[1];
25806
+ return /\((?<team>[A-Z0-9]{10})\)\s*$/u.exec(params.signingIdentity)?.[1];
24895
25807
  };
24896
25808
  /**
24897
25809
  * Parse a PKCS#12 (.p12) buffer and extract certificate metadata.
@@ -25210,7 +26122,7 @@ const announce = (heading) => Effect.gen(function* () {
25210
26122
  });
25211
26123
  const reportError = (label, cause) => Console.log(`✗ ${label}: ${cause instanceof Error ? cause.message : String(cause)}`);
25212
26124
  const safely = (label, effect) => effect.pipe(Effect.catchAll((cause) => reportError(label, cause)), Effect.asVoid);
25213
- const safePrompt = (effect) => effect.pipe(Effect.catchAll(() => Effect.succeed(BACK)));
26125
+ const safePrompt = (effect) => effect.pipe(Effect.orElseSucceed(() => BACK));
25214
26126
  const promptForBundleConfig = (ctx) => Effect.gen(function* () {
25215
26127
  const list = yield* ctx.api.iosBundleConfigurations.list({ path: { projectId: ctx.projectId } });
25216
26128
  if (list.items.length === 0) return yield* new MissingCredentialsError({
@@ -26243,7 +27155,7 @@ const toRecipientView = (userEncryptionKeyId, key) => ({
26243
27155
  fingerprint: key?.fingerprint
26244
27156
  })
26245
27157
  });
26246
- const listCommand$5 = defineCommand({
27158
+ const listCommand$7 = defineCommand({
26247
27159
  meta: {
26248
27160
  name: "list",
26249
27161
  description: "List recipients that currently hold the org vault key"
@@ -26470,7 +27382,7 @@ const accessCommand = defineCommand({
26470
27382
  description: "Inspect, grant, rotate, revoke, and recover access to the org credential vault"
26471
27383
  },
26472
27384
  subCommands: {
26473
- list: listCommand$5,
27385
+ list: listCommand$7,
26474
27386
  grant: grantCommand,
26475
27387
  rotate: rotateCommand,
26476
27388
  revoke: revokeCommand$1,
@@ -26679,7 +27591,7 @@ const CREDENTIAL_TYPES$3 = [
26679
27591
  "keystore",
26680
27592
  "google-service-account-key"
26681
27593
  ];
26682
- const deleteCommand$3 = defineCommand({
27594
+ const deleteCommand$4 = defineCommand({
26683
27595
  meta: {
26684
27596
  name: "delete",
26685
27597
  description: "Delete a credential"
@@ -26721,7 +27633,7 @@ const deleteCommand$3 = defineCommand({
26721
27633
  //#region src/commands/credentials/device.ts
26722
27634
  /** Self-linking is for your own device keys; recovery/machine keys go through `access grant`. */
26723
27635
  const requireDeviceKind = (target) => target.kind === "device" ? Effect.void : new IdentityError({ message: `Key ${target.id} is a ${target.kind} key, not a device. Use \`better-update credentials access grant\` for recovery/machine keys.` });
26724
- const listCommand$4 = defineCommand({
27636
+ const listCommand$6 = defineCommand({
26725
27637
  meta: {
26726
27638
  name: "list",
26727
27639
  description: "List your registered device keys (the active one is marked)"
@@ -26779,7 +27691,7 @@ const deviceCommand = defineCommand({
26779
27691
  description: "Manage your vault device keys"
26780
27692
  },
26781
27693
  subCommands: {
26782
- list: listCommand$4,
27694
+ list: listCommand$6,
26783
27695
  link: linkCommand
26784
27696
  },
26785
27697
  default: "list"
@@ -27141,7 +28053,7 @@ const handleCertLimitInteractive = (api, ascApiKeyId, certificateType) => Effect
27141
28053
  ascApiKeyId,
27142
28054
  certificateType
27143
28055
  });
27144
- if (certs.length === 0) return yield* Effect.fail(new CertificateLimitError({ message: "Apple says the certificate limit is hit but no existing certificates were returned — try again later." }));
28056
+ if (certs.length === 0) return yield* new CertificateLimitError({ message: "Apple says the certificate limit is hit but no existing certificates were returned — try again later." });
27145
28057
  const toRevoke = yield* promptMultiSelect("Select one or more certificates to revoke before retrying", certs.map((entry) => ({
27146
28058
  value: entry.id,
27147
28059
  label: `${entry.serialNumber.slice(0, 12)}… (${entry.displayName ?? entry.certificateType}, exp ${entry.expirationDate.slice(0, 10)})`
@@ -27434,7 +28346,7 @@ const printRecipient = (key) => printKeyValue([
27434
28346
  ["Recipient (public key)", key.publicKey],
27435
28347
  ["Fingerprint", key.fingerprint]
27436
28348
  ]);
27437
- const createCommand$1 = defineCommand({
28349
+ const createCommand$2 = defineCommand({
27438
28350
  meta: {
27439
28351
  name: "create",
27440
28352
  description: "Create this device's encryption identity and register it as a recipient"
@@ -27537,7 +28449,7 @@ const identityCommand = defineCommand({
27537
28449
  description: "Manage this device's end-to-end encryption identity"
27538
28450
  },
27539
28451
  subCommands: {
27540
- create: createCommand$1,
28452
+ create: createCommand$2,
27541
28453
  init: initCommand$1,
27542
28454
  register: registerCommand,
27543
28455
  show: showCommand
@@ -27547,7 +28459,7 @@ const identityCommand = defineCommand({
27547
28459
 
27548
28460
  //#endregion
27549
28461
  //#region src/commands/credentials/list.ts
27550
- const listCommand$3 = defineCommand({
28462
+ const listCommand$5 = defineCommand({
27551
28463
  meta: {
27552
28464
  name: "list",
27553
28465
  description: "List credentials across platforms"
@@ -27903,7 +28815,7 @@ const writeArtifact = (fs, projectRoot, relPath, bytes) => Effect.gen(function*
27903
28815
  const writeText = (fs, projectRoot, relPath, text) => writeArtifact(fs, projectRoot, relPath, new TextEncoder().encode(text));
27904
28816
  const ensureGitignoreEntries = (fs, projectRoot, paths) => Effect.gen(function* () {
27905
28817
  const filePath = path.join(projectRoot, ".gitignore");
27906
- const lines = ((yield* fs.exists(filePath).pipe(Effect.catchAll(() => Effect.succeed(false)))) ? yield* fs.readFileString(filePath).pipe(Effect.catchAll(() => Effect.succeed(""))) : "").split("\n");
28818
+ const lines = ((yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false))) ? yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")) : "").split("\n");
27907
28819
  const added = [];
27908
28820
  const next = [...lines];
27909
28821
  for (const entry of paths) if (!lines.includes(entry)) {
@@ -28702,7 +29614,7 @@ const lookupByType = (api, id, type) => {
28702
29614
  default: return Effect.fail(new CredentialValidationError({ message: `Unsupported credential type: ${String(type)}` }));
28703
29615
  }
28704
29616
  };
28705
- const viewCommand$1 = defineCommand({
29617
+ const viewCommand$2 = defineCommand({
28706
29618
  meta: {
28707
29619
  name: "view",
28708
29620
  description: "Show details for a single credential (without secrets)"
@@ -28749,14 +29661,14 @@ const credentialsCommand = defineCommand({
28749
29661
  unlock: unlockCommand,
28750
29662
  lock: lockCommand,
28751
29663
  status: statusCommand$1,
28752
- list: listCommand$3,
28753
- view: viewCommand$1,
29664
+ list: listCommand$5,
29665
+ view: viewCommand$2,
28754
29666
  download: downloadCommand,
28755
29667
  upload: uploadCommand,
28756
29668
  "upload-asc-key": uploadAscKeyCommand,
28757
29669
  generate: generateCommand$1,
28758
29670
  "regenerate-profile": regenerateProfileCommand,
28759
- delete: deleteCommand$3,
29671
+ delete: deleteCommand$4,
28760
29672
  remove: removeCommand,
28761
29673
  revoke: revokeCommand,
28762
29674
  configure: configureCommand$1,
@@ -28776,7 +29688,7 @@ const DEVICE_CLASS_VALUES = [
28776
29688
  const isDeviceClass = (value) => DEVICE_CLASS_VALUES.includes(value);
28777
29689
  const ttlHours = (value) => {
28778
29690
  if (value === void 0) return;
28779
- const match = /^([0-9]+)([hd])?$/u.exec(value);
29691
+ const match = /^(?<value>[0-9]+)(?<unit>[hd])?$/u.exec(value);
28780
29692
  if (!match?.[1]) return;
28781
29693
  const num = Number.parseInt(match[1], 10);
28782
29694
  return match[2] === "d" ? num * 24 : num;
@@ -29123,6 +30035,7 @@ const devicesCommand = defineCommand({
29123
30035
 
29124
30036
  //#endregion
29125
30037
  //#region src/commands/doctor.ts
30038
+ var HealthCheckError = class extends Data.TaggedError("HealthCheckError") {};
29126
30039
  const pass = (id, name, message) => ({
29127
30040
  id,
29128
30041
  name,
@@ -29161,7 +30074,10 @@ const checkServerHealth = Effect.gen(function* () {
29161
30074
  const url = `${yield* (yield* ConfigStore).getBaseUrl}/api/health`;
29162
30075
  const response = yield* Effect.tryPromise({
29163
30076
  try: async () => fetch(url, { signal: AbortSignal.timeout(3e3) }),
29164
- catch: (cause) => new Error(String(cause))
30077
+ catch: (cause) => new HealthCheckError({
30078
+ message: String(cause),
30079
+ cause
30080
+ })
29165
30081
  }).pipe(Effect.either);
29166
30082
  if (response._tag === "Left") return fail("health", "Server reachable", `${url} unreachable: ${response.left.message}`);
29167
30083
  const res = response.right;
@@ -29186,11 +30102,18 @@ const checkProjectLink = Effect.gen(function* () {
29186
30102
  const source = (yield* readLinkedProjectId(root)) === void 0 ? "Expo config" : "better-update.json";
29187
30103
  return pass("project-linked", "Project linked", `projectId=${resolved.right} (via ${source})`);
29188
30104
  });
29189
- const checkEasJson = Effect.gen(function* () {
29190
- const result = yield* readEasJson(yield* (yield* CliRuntime).cwd).pipe(Effect.either);
29191
- if (result._tag === "Left") return warn("eas-json", "eas.json", result.left.message);
29192
- const count = Object.keys(result.right.build ?? {}).length;
29193
- return pass("eas-json", "eas.json", `${count} profile(s) defined`);
30105
+ const checkProjectType = Effect.gen(function* () {
30106
+ const root = yield* (yield* CliRuntime).cwd;
30107
+ const override = asProjectType((yield* readBetterUpdateConfig(root))?.["projectType"]);
30108
+ return pass("project-type", "Project type", `${yield* detectProjectType({
30109
+ projectRoot: root,
30110
+ override
30111
+ })} (${override === void 0 ? "auto-detected" : "better-update.json override"})`);
30112
+ });
30113
+ const checkBuildConfig = Effect.gen(function* () {
30114
+ const names = yield* listBuildProfileNames(yield* (yield* CliRuntime).cwd);
30115
+ if (names.length === 0) return warn("build-config", "Build config", "No build profiles found. Add a \"build\" section to better-update.json.");
30116
+ return pass("build-config", "Build config", `${names.length} profile(s) defined`);
29194
30117
  });
29195
30118
  const runChecks = Effect.gen(function* () {
29196
30119
  const xcode = (yield* CliRuntime).platform === "darwin" ? [yield* checkCommand("xcode", "Xcode CLI tools", "xcode-select", ["-p"])] : [];
@@ -29201,7 +30124,8 @@ const runChecks = Effect.gen(function* () {
29201
30124
  yield* checkServerHealth,
29202
30125
  yield* checkAuth,
29203
30126
  yield* checkProjectLink,
29204
- yield* checkEasJson
30127
+ yield* checkProjectType,
30128
+ yield* checkBuildConfig
29205
30129
  ];
29206
30130
  });
29207
30131
  const statusIcon = (status) => {
@@ -29244,23 +30168,23 @@ const envErrorExtras = {
29244
30168
  SystemError: 6,
29245
30169
  BadArgument: 6
29246
30170
  };
29247
- const isEnvironmentName = (value) => value === "development" || value === "preview" || value === "production";
30171
+ const isEnvironmentName$1 = (value) => value === "development" || value === "preview" || value === "production";
29248
30172
  const parseEnvironmentsArg = (raw) => Effect.gen(function* () {
29249
30173
  const tokens = raw.split(",").map((token) => token.trim()).filter((token) => token.length > 0);
29250
30174
  if (tokens.length === 0) return yield* new InvalidArgumentError({ message: "Provide at least one environment (development, preview, production)." });
29251
30175
  const seen = /* @__PURE__ */ new Set();
29252
30176
  yield* Effect.forEach(tokens, (token) => Effect.gen(function* () {
29253
- if (!isEnvironmentName(token)) return yield* new InvalidArgumentError({ message: `Invalid environment "${token}". Must be one of: development, preview, production.` });
30177
+ if (!isEnvironmentName$1(token)) return yield* new InvalidArgumentError({ message: `Invalid environment "${token}". Must be one of: development, preview, production.` });
29254
30178
  seen.add(token);
29255
30179
  }), { discard: true });
29256
30180
  return [...seen];
29257
30181
  });
29258
30182
  const parseSingleEnvironmentArg = (raw) => Effect.gen(function* () {
29259
- if (!isEnvironmentName(raw)) return yield* new InvalidArgumentError({ message: `Invalid environment "${raw}". Must be one of: development, preview, production.` });
30183
+ if (!isEnvironmentName$1(raw)) return yield* new InvalidArgumentError({ message: `Invalid environment "${raw}". Must be one of: development, preview, production.` });
29260
30184
  return raw;
29261
30185
  });
29262
30186
  const formatEnvironments = (environments) => [...environments].toSorted((left, right) => left.localeCompare(right)).join(",");
29263
- const DOTENV_LINE = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/u;
30187
+ const DOTENV_LINE = /^\s*(?:export\s+)?(?<key>[A-Z][A-Z0-9_]*)\s*=\s*(?<value>.*?)\s*$/u;
29264
30188
  const stripQuotes = (raw) => {
29265
30189
  if (raw.length < 2) return raw;
29266
30190
  const [first] = raw;
@@ -29295,7 +30219,7 @@ const findProjectEnvVar = (api, projectId, key, environment) => Effect.gen(funct
29295
30219
 
29296
30220
  //#endregion
29297
30221
  //#region src/commands/env/delete.ts
29298
- const deleteCommand$2 = defineCommand({
30222
+ const deleteCommand$3 = defineCommand({
29299
30223
  meta: {
29300
30224
  name: "delete",
29301
30225
  description: "Delete a project env var (one environment, or every environment by default)"
@@ -29364,7 +30288,7 @@ const getExecTrailingArgv = () => trailing$1;
29364
30288
  const pullForExec = (api, projectId, environment) => pullEnvVars(api, {
29365
30289
  projectId,
29366
30290
  environment
29367
- }).pipe(Effect.catchAll(() => Effect.succeed({})));
30291
+ }).pipe(Effect.orElseSucceed(() => ({})));
29368
30292
  const splitTrailing = (trailing) => {
29369
30293
  if (!trailing || trailing.length === 0) return Effect.fail(new InvalidArgumentError({ message: "Pass the command after `--`. Example: `better-update env exec production -- bun run dev`." }));
29370
30294
  const [bin, ...rest] = trailing;
@@ -29467,6 +30391,191 @@ const getCommand$1 = defineCommand({
29467
30391
  }), envErrorExtras)
29468
30392
  });
29469
30393
 
30394
+ //#endregion
30395
+ //#region src/commands/env/grants/helpers.ts
30396
+ var EnvGrantCommandError = class extends Data.TaggedError("EnvGrantCommandError") {};
30397
+ const envGrantErrorExtras = { EnvGrantCommandError: 2 };
30398
+ /** Sentinel project token for the org-global env-var scope (mirrors server). */
30399
+ const ENV_GRANT_GLOBAL = "global";
30400
+ const ENVIRONMENTS = [
30401
+ "development",
30402
+ "preview",
30403
+ "production"
30404
+ ];
30405
+ /**
30406
+ * Type guard narrowing a raw arg to an {@link EnvironmentName}. Lets set/unset
30407
+ * validate `args.environment` AND narrow it without an unsafe `as` assertion.
30408
+ */
30409
+ const isEnvironmentName = (value) => ENVIRONMENTS.includes(value);
30410
+
30411
+ //#endregion
30412
+ //#region src/commands/env/grants/list.ts
30413
+ const listCommand$4 = defineCommand({
30414
+ meta: {
30415
+ name: "list",
30416
+ description: "List env-var grants on a (project × environment) scope"
30417
+ },
30418
+ args: {
30419
+ project: {
30420
+ type: "string",
30421
+ description: `Project id, or "${ENV_GRANT_GLOBAL}" for the org-global scope (default: linked project)`
30422
+ },
30423
+ global: {
30424
+ type: "boolean",
30425
+ default: false,
30426
+ description: "Target the org-global env-var scope instead of a project"
30427
+ }
30428
+ },
30429
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
30430
+ const projectId = args.global ? ENV_GRANT_GLOBAL : args.project ?? (yield* readProjectId);
30431
+ yield* printList([
30432
+ "Member ID",
30433
+ "Environment",
30434
+ "Effect",
30435
+ "Actions"
30436
+ ], (yield* (yield* apiClient).envGrants.list({ urlParams: { projectId } })).map((row) => [
30437
+ row.memberId,
30438
+ row.environment,
30439
+ row.effect,
30440
+ row.actions.join(", ")
30441
+ ]), "No env-var grants found for this scope.");
30442
+ }), { exits: { ...envGrantErrorExtras } })
30443
+ });
30444
+
30445
+ //#endregion
30446
+ //#region src/commands/env/grants/set.ts
30447
+ const setCommand$2 = defineCommand({
30448
+ meta: {
30449
+ name: "set",
30450
+ description: "Create or replace a member's env-var grant on a scope"
30451
+ },
30452
+ args: {
30453
+ member: {
30454
+ type: "string",
30455
+ required: true,
30456
+ description: "Member ID to grant"
30457
+ },
30458
+ environment: {
30459
+ type: "string",
30460
+ required: true,
30461
+ description: "Environment: development | preview | production"
30462
+ },
30463
+ actions: {
30464
+ type: "string",
30465
+ description: "Comma-separated envVar:* tokens (default: envVar:read)"
30466
+ },
30467
+ effect: {
30468
+ type: "string",
30469
+ description: "allow (default) or deny"
30470
+ },
30471
+ project: {
30472
+ type: "string",
30473
+ description: `Project id (default: linked project)`
30474
+ },
30475
+ global: {
30476
+ type: "boolean",
30477
+ default: false,
30478
+ description: "Target the org-global env-var scope"
30479
+ }
30480
+ },
30481
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
30482
+ const effectValue = args.effect ?? "allow";
30483
+ if (effectValue !== "allow" && effectValue !== "deny") return yield* new EnvGrantCommandError({ message: `Invalid effect "${effectValue}".` });
30484
+ const { environment } = args;
30485
+ if (!isEnvironmentName(environment)) return yield* new EnvGrantCommandError({ message: `Invalid environment "${environment}". One of: ${ENVIRONMENTS.join(", ")}.` });
30486
+ const actionTokens = (args.actions ?? "envVar:read").split(",").map((tok) => tok.trim()).filter((tok) => tok.length > 0);
30487
+ if (actionTokens.length === 0) return yield* new EnvGrantCommandError({ message: "At least one action token is required." });
30488
+ const projectId = args.global ? null : args.project ?? (yield* readProjectId);
30489
+ const grant = yield* (yield* apiClient).envGrants.upsert({ payload: {
30490
+ memberId: args.member,
30491
+ projectId,
30492
+ environment,
30493
+ effect: effectValue,
30494
+ actions: actionTokens
30495
+ } });
30496
+ yield* printHumanKeyValue([
30497
+ ["ID", grant.id],
30498
+ ["Member ID", grant.memberId],
30499
+ ["Scope", grant.scopeId],
30500
+ ["Effect", grant.effect],
30501
+ ["Actions", grant.actions.join(", ")],
30502
+ ["Created", grant.createdAt]
30503
+ ]);
30504
+ return grant;
30505
+ }), { exits: { ...envGrantErrorExtras } })
30506
+ });
30507
+
30508
+ //#endregion
30509
+ //#region src/commands/env/grants/unset.ts
30510
+ const unsetCommand = defineCommand({
30511
+ meta: {
30512
+ name: "unset",
30513
+ description: "Revoke a member's env-var grants on a scope"
30514
+ },
30515
+ args: {
30516
+ member: {
30517
+ type: "string",
30518
+ required: true,
30519
+ description: "Member ID whose grants to revoke"
30520
+ },
30521
+ environment: {
30522
+ type: "string",
30523
+ required: true,
30524
+ description: "Environment"
30525
+ },
30526
+ project: {
30527
+ type: "string",
30528
+ description: "Project id (default: linked project)"
30529
+ },
30530
+ global: {
30531
+ type: "boolean",
30532
+ default: false,
30533
+ description: "Target the org-global scope"
30534
+ },
30535
+ yes: {
30536
+ type: "boolean",
30537
+ default: false,
30538
+ description: "Skip confirmation prompt"
30539
+ }
30540
+ },
30541
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
30542
+ const { environment } = args;
30543
+ if (!isEnvironmentName(environment)) return yield* new EnvGrantCommandError({ message: `Invalid environment "${environment}".` });
30544
+ if (!args.yes) {
30545
+ const scopeLabel = args.global ? ENV_GRANT_GLOBAL : args.project ?? "linked project";
30546
+ if (!(yield* promptConfirm(`Revoke env-var grants for member ${args.member} on ${scopeLabel}/${args.environment}?`, { initialValue: false }))) {
30547
+ yield* printHuman("Cancelled.");
30548
+ return { deleted: 0 };
30549
+ }
30550
+ }
30551
+ const projectId = args.global ? null : args.project ?? (yield* readProjectId);
30552
+ const result = yield* (yield* apiClient).envGrants.delete({ payload: {
30553
+ memberId: args.member,
30554
+ projectId,
30555
+ environment
30556
+ } });
30557
+ yield* printHuman(`Revoked env-var grants for member ${args.member}.`);
30558
+ return result;
30559
+ }), {
30560
+ exits: { ...envGrantErrorExtras },
30561
+ json: "value"
30562
+ })
30563
+ });
30564
+
30565
+ //#endregion
30566
+ //#region src/commands/env/grants/index.ts
30567
+ const grantsCommand = defineCommand({
30568
+ meta: {
30569
+ name: "grants",
30570
+ description: "Manage per-member env-var access grants on a (project × environment) scope"
30571
+ },
30572
+ subCommands: {
30573
+ list: listCommand$4,
30574
+ set: setCommand$2,
30575
+ unset: unsetCommand
30576
+ }
30577
+ });
30578
+
29470
30579
  //#endregion
29471
30580
  //#region src/commands/env/history.ts
29472
30581
  const historyCommand = defineCommand({
@@ -29565,7 +30674,7 @@ const importCommand = defineCommand({
29565
30674
 
29566
30675
  //#endregion
29567
30676
  //#region src/commands/env/list.ts
29568
- const listCommand$2 = defineCommand({
30677
+ const listCommand$3 = defineCommand({
29569
30678
  meta: {
29570
30679
  name: "list",
29571
30680
  description: "List environment variable metadata. Values are end-to-end encrypted — read them with `env pull`, `env export`, or `env get`."
@@ -29831,7 +30940,7 @@ const setCommand$1 = defineCommand({
29831
30940
 
29832
30941
  //#endregion
29833
30942
  //#region src/commands/env/update.ts
29834
- const updateCommand$1 = defineCommand({
30943
+ const updateCommand$2 = defineCommand({
29835
30944
  meta: {
29836
30945
  name: "update",
29837
30946
  description: "Update a project env var's value or visibility for an environment"
@@ -29912,18 +31021,19 @@ const envCommand = defineCommand({
29912
31021
  description: "Manage environment variables"
29913
31022
  },
29914
31023
  subCommands: {
29915
- list: listCommand$2,
31024
+ list: listCommand$3,
29916
31025
  get: getCommand$1,
29917
31026
  set: setCommand$1,
29918
- update: updateCommand$1,
29919
- delete: deleteCommand$2,
31027
+ update: updateCommand$2,
31028
+ delete: deleteCommand$3,
29920
31029
  history: historyCommand,
29921
31030
  rollback: rollbackCommand$1,
29922
31031
  import: importCommand,
29923
31032
  push: pushCommand,
29924
31033
  export: exportCommand,
29925
31034
  pull: pullCommand,
29926
- exec: execCommand
31035
+ exec: execCommand,
31036
+ grants: grantsCommand
29927
31037
  }
29928
31038
  });
29929
31039
 
@@ -30194,7 +31304,7 @@ const fingerprintCommand = defineCommand({
30194
31304
  const checkExistingLink = (api, config, localSlug) => Effect.gen(function* () {
30195
31305
  const existingId = config.extra?.betterUpdate?.projectId;
30196
31306
  if (typeof existingId !== "string" || existingId.length === 0) return "no-link";
30197
- const project = yield* api.projects.get({ path: { id: existingId } }).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
31307
+ const project = yield* api.projects.get({ path: { id: existingId } }).pipe(Effect.orElseSucceed(() => void 0));
30198
31308
  if (project === void 0) {
30199
31309
  yield* printHuman(`Existing projectId "${existingId}" not found on server. Re-linking by local slug "${localSlug}".`);
30200
31310
  return "stale";
@@ -30214,9 +31324,9 @@ const checkExistingLink = (api, config, localSlug) => Effect.gen(function* () {
30214
31324
  const slugify = (value) => value.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-").replaceAll(/^-+|-+$/gu, "");
30215
31325
  /** Best-effort `name` from the local package.json, or undefined when absent. */
30216
31326
  const readPackageJsonName = (projectRoot) => Effect.gen(function* () {
30217
- const content = yield* (yield* FileSystem.FileSystem).readFileString(path.join(projectRoot, "package.json")).pipe(Effect.catchAll(() => Effect.succeed("")));
31327
+ const content = yield* (yield* FileSystem.FileSystem).readFileString(path.join(projectRoot, "package.json")).pipe(Effect.orElseSucceed(() => ""));
30218
31328
  if (content.length === 0) return;
30219
- const parsed = yield* Effect.try(() => JSON.parse(content)).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
31329
+ const parsed = yield* Effect.try(() => JSON.parse(content)).pipe(Effect.orElseSucceed(() => void 0));
30220
31330
  const name = isRecord(parsed) ? parsed["name"] : void 0;
30221
31331
  return typeof name === "string" && name.length > 0 ? name : void 0;
30222
31332
  });
@@ -30369,49 +31479,40 @@ const logoutCommand = defineCommand({
30369
31479
 
30370
31480
  //#endregion
30371
31481
  //#region src/commands/migrate-config.ts
30372
- const readAppJson = (projectRoot) => {
30373
- const path$1 = path.join(projectRoot, "app.json");
30374
- if (!existsSync(path$1)) return null;
30375
- const raw = readFileSync(path$1, "utf8");
30376
- return JSON.parse(raw);
30377
- };
30378
- const writeAppJson = (projectRoot, content) => {
30379
- writeFileSync(path.join(projectRoot, "app.json"), `${JSON.stringify(content, null, 2)}\n`);
30380
- };
30381
- const writeEasJson = (projectRoot, profiles) => {
30382
- writeFileSync(path.join(projectRoot, "eas.json"), `${JSON.stringify({ build: profiles }, null, 2)}\n`);
30383
- };
30384
31482
  const migrateConfigCommand = defineCommand({
30385
31483
  meta: {
30386
31484
  name: "migrate-config",
30387
- description: "Migrate legacy `extra.betterUpdate.profiles` (in app.json) to a sibling `eas.json` file"
31485
+ description: "Migrate `build`/`submit` profiles from a legacy eas.json into better-update.json"
30388
31486
  },
30389
31487
  args: { yes: {
30390
31488
  type: "boolean",
30391
31489
  description: "Skip the confirmation prompt"
30392
31490
  } },
30393
31491
  run: async ({ args }) => runEffect(Effect.gen(function* () {
30394
- const root = yield* (yield* CliRuntime).cwd;
30395
- const appJson = readAppJson(root);
30396
- if (!appJson) return yield* new InvalidArgumentError({ message: `No app.json found at ${root}.` });
30397
- const profiles = appJson.expo?.extra?.betterUpdate?.profiles;
30398
- if (profiles === void 0) {
30399
- yield* printHuman("No legacy `extra.betterUpdate.profiles` found in app.json — nothing to migrate.");
31492
+ const runtime = yield* CliRuntime;
31493
+ const fs = yield* FileSystem.FileSystem;
31494
+ const root = yield* runtime.cwd;
31495
+ const easPath = path.join(root, "eas.json");
31496
+ if (!(yield* fs.exists(easPath).pipe(Effect.orElseSucceed(() => false)))) return yield* new InvalidArgumentError({ message: `No eas.json found at ${root}.` });
31497
+ const config = yield* readEasJson(root);
31498
+ const patch = compact({
31499
+ build: config.build,
31500
+ submit: config.submit,
31501
+ cli: config.cli
31502
+ });
31503
+ if (Object.keys(patch).length === 0) {
31504
+ yield* printHuman("eas.json has no build/submit/cli sections — nothing to migrate.");
30400
31505
  return;
30401
31506
  }
30402
- if (existsSync(path.join(root, "eas.json"))) return yield* new InvalidArgumentError({ message: "eas.json already exists. Manual review required — refusing to overwrite. Remove eas.json first if you want to regenerate." });
30403
- if (!args.yes) {
30404
- if (!(yield* promptConfirm(`Move profiles to eas.json and strip from app.json?`, { initialValue: true }))) {
31507
+ const existingBuild = (yield* readBetterUpdateConfig(root))?.["build"];
31508
+ if (typeof existingBuild === "object" && existingBuild !== null && config.build !== void 0 && !args.yes) {
31509
+ if (!(yield* promptConfirm("better-update.json already has a build section — overwrite it from eas.json?", { initialValue: false }))) {
30405
31510
  yield* printHuman("Cancelled.");
30406
31511
  return;
30407
31512
  }
30408
31513
  }
30409
- writeEasJson(root, profiles);
30410
- const clone = structuredClone(appJson);
30411
- const betterUpdate = (clone.expo?.extra)?.betterUpdate;
30412
- if (betterUpdate) delete betterUpdate["profiles"];
30413
- writeAppJson(root, clone);
30414
- yield* printHuman("Migrated profiles into eas.json. Legacy field removed from app.json.");
31514
+ yield* writeBetterUpdateConfig(root, patch);
31515
+ yield* printHuman("Merged eas.json build/submit into better-update.json. You can now delete eas.json.");
30415
31516
  }))
30416
31517
  });
30417
31518
 
@@ -30469,7 +31570,7 @@ const openCommand = defineCommand({
30469
31570
 
30470
31571
  //#endregion
30471
31572
  //#region src/commands/projects.ts
30472
- const listCommand$1 = defineCommand({
31573
+ const listCommand$2 = defineCommand({
30473
31574
  meta: {
30474
31575
  name: "list",
30475
31576
  description: "List projects (most recently active first)"
@@ -30520,7 +31621,7 @@ const listCommand$1 = defineCommand({
30520
31621
  yield* printHuman(`Page ${result.page} · ${result.items.length} of ${result.total} project(s)`);
30521
31622
  }))
30522
31623
  });
30523
- const createCommand = defineCommand({
31624
+ const createCommand$1 = defineCommand({
30524
31625
  meta: {
30525
31626
  name: "create",
30526
31627
  description: "Create a new project"
@@ -30598,7 +31699,7 @@ const renameCommand = defineCommand({
30598
31699
  return project;
30599
31700
  }), { json: "value" })
30600
31701
  });
30601
- const deleteCommand$1 = defineCommand({
31702
+ const deleteCommand$2 = defineCommand({
30602
31703
  meta: {
30603
31704
  name: "delete",
30604
31705
  description: "Delete a project"
@@ -30623,10 +31724,229 @@ const projectsCommand = defineCommand({
30623
31724
  description: "Manage projects"
30624
31725
  },
30625
31726
  subCommands: {
30626
- list: listCommand$1,
30627
- create: createCommand,
31727
+ list: listCommand$2,
31728
+ create: createCommand$1,
30628
31729
  get: getCommand,
30629
31730
  rename: renameCommand,
31731
+ delete: deleteCommand$2
31732
+ }
31733
+ });
31734
+
31735
+ //#endregion
31736
+ //#region src/commands/roles/helpers.ts
31737
+ var RoleCommandError = class extends Data.TaggedError("RoleCommandError") {};
31738
+ const roleErrorExtras = { RoleCommandError: 2 };
31739
+ /**
31740
+ * Parse comma-separated "resource:action" tokens into the PermissionGrant array
31741
+ * the API expects. Multiple actions for the same resource are grouped.
31742
+ *
31743
+ * Input examples:
31744
+ * "channel:read,channel:update"
31745
+ * "channel:read, rollout:create, rollout:update"
31746
+ */
31747
+ const parsePermissionTokens = (raw) => Effect.gen(function* () {
31748
+ const tokens = raw.split(",").map((tok) => tok.trim()).filter((tok) => tok.length > 0);
31749
+ const grouped = /* @__PURE__ */ new Map();
31750
+ for (const token of tokens) {
31751
+ const colonIdx = token.indexOf(":");
31752
+ if (colonIdx === -1) return yield* new RoleCommandError({ message: `Invalid permission token "${token}" — expected "resource:action" format.` });
31753
+ const resource = token.slice(0, colonIdx).trim();
31754
+ const action = token.slice(colonIdx + 1).trim();
31755
+ if (!resource || !action) return yield* new RoleCommandError({ message: `Invalid permission token "${token}" — resource and action must be non-empty.` });
31756
+ const actions = grouped.get(resource) ?? /* @__PURE__ */ new Set();
31757
+ actions.add(action);
31758
+ grouped.set(resource, actions);
31759
+ }
31760
+ return [...grouped.entries()].map(([resource, actions]) => ({
31761
+ resource,
31762
+ actions: [...actions]
31763
+ }));
31764
+ });
31765
+
31766
+ //#endregion
31767
+ //#region src/commands/roles/create.ts
31768
+ const createCommand = defineCommand({
31769
+ meta: {
31770
+ name: "create",
31771
+ description: "Create a custom role"
31772
+ },
31773
+ args: {
31774
+ name: {
31775
+ type: "string",
31776
+ required: true,
31777
+ description: "Role name (unique per organization)"
31778
+ },
31779
+ permission: {
31780
+ type: "string",
31781
+ required: true,
31782
+ description: "Permission tokens in resource:action format, comma-separated (e.g. channel:read,channel:update)"
31783
+ }
31784
+ },
31785
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
31786
+ const permissions = yield* parsePermissionTokens(args.permission);
31787
+ const role = yield* (yield* apiClient).roles.create({ payload: {
31788
+ name: args.name,
31789
+ permissions
31790
+ } });
31791
+ yield* printHumanKeyValue([
31792
+ ["ID", role.id],
31793
+ ["Name", role.role],
31794
+ ["Permissions", role.permissions.map((perm) => `${perm.resource}:[${perm.actions.join(",")}]`).join("; ")],
31795
+ ["Created", role.createdAt]
31796
+ ]);
31797
+ return role;
31798
+ }), {
31799
+ exits: roleErrorExtras,
31800
+ json: "value"
31801
+ })
31802
+ });
31803
+
31804
+ //#endregion
31805
+ //#region src/commands/roles/delete.ts
31806
+ const deleteCommand$1 = defineCommand({
31807
+ meta: {
31808
+ name: "delete",
31809
+ description: "Delete a custom role"
31810
+ },
31811
+ args: {
31812
+ id: {
31813
+ type: "positional",
31814
+ required: true,
31815
+ description: "Role ID"
31816
+ },
31817
+ yes: {
31818
+ type: "boolean",
31819
+ description: "Skip confirmation prompt"
31820
+ }
31821
+ },
31822
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
31823
+ if (!args.yes) {
31824
+ if (!(yield* promptConfirm(`Delete role ${args.id}?`, { initialValue: false }))) {
31825
+ yield* printHuman("Cancelled.");
31826
+ return { deleted: 0 };
31827
+ }
31828
+ }
31829
+ const result = yield* (yield* apiClient).roles.delete({ path: { id: args.id } });
31830
+ yield* printHuman(`Role ${args.id} deleted.`);
31831
+ return result;
31832
+ }), {
31833
+ exits: roleErrorExtras,
31834
+ json: "value"
31835
+ })
31836
+ });
31837
+
31838
+ //#endregion
31839
+ //#region src/commands/roles/list.ts
31840
+ const listCommand$1 = defineCommand({
31841
+ meta: {
31842
+ name: "list",
31843
+ description: "List custom roles for the active organization"
31844
+ },
31845
+ args: { "organization-id": {
31846
+ type: "string",
31847
+ required: true,
31848
+ description: "Organization ID to list roles for"
31849
+ } },
31850
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
31851
+ yield* printList([
31852
+ "ID",
31853
+ "Name",
31854
+ "Permissions",
31855
+ "Created"
31856
+ ], (yield* (yield* apiClient).roles.list({ urlParams: { organizationId: args["organization-id"] } })).map((role) => [
31857
+ role.id,
31858
+ role.role,
31859
+ role.permissions.map((perm) => `${perm.resource}:[${perm.actions.join(",")}]`).join("; "),
31860
+ role.createdAt
31861
+ ]), "No custom roles found.");
31862
+ }), roleErrorExtras)
31863
+ });
31864
+
31865
+ //#endregion
31866
+ //#region src/commands/roles/update.ts
31867
+ const updateCommand$1 = defineCommand({
31868
+ meta: {
31869
+ name: "update",
31870
+ description: "Update a custom role's name or permissions"
31871
+ },
31872
+ args: {
31873
+ id: {
31874
+ type: "positional",
31875
+ required: true,
31876
+ description: "Role ID"
31877
+ },
31878
+ name: {
31879
+ type: "string",
31880
+ description: "New role name"
31881
+ },
31882
+ permission: {
31883
+ type: "string",
31884
+ description: "Replacement permission tokens in resource:action format, comma-separated (e.g. channel:read,channel:update)"
31885
+ }
31886
+ },
31887
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
31888
+ const permissions = args.permission === void 0 ? void 0 : yield* parsePermissionTokens(args.permission);
31889
+ const role = yield* (yield* apiClient).roles.update({
31890
+ path: { id: args.id },
31891
+ payload: compact({
31892
+ name: args.name,
31893
+ permissions
31894
+ })
31895
+ });
31896
+ yield* printHumanKeyValue([
31897
+ ["ID", role.id],
31898
+ ["Name", role.role],
31899
+ ["Permissions", role.permissions.map((perm) => `${perm.resource}:[${perm.actions.join(",")}]`).join("; ")],
31900
+ ["Updated", role.updatedAt ?? "-"]
31901
+ ]);
31902
+ return role;
31903
+ }), {
31904
+ exits: roleErrorExtras,
31905
+ json: "value"
31906
+ })
31907
+ });
31908
+
31909
+ //#endregion
31910
+ //#region src/commands/roles/view.ts
31911
+ const viewCommand$1 = defineCommand({
31912
+ meta: {
31913
+ name: "view",
31914
+ description: "Show a custom role by ID"
31915
+ },
31916
+ args: { id: {
31917
+ type: "positional",
31918
+ required: true,
31919
+ description: "Role ID"
31920
+ } },
31921
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
31922
+ const role = yield* (yield* apiClient).roles.get({ path: { id: args.id } });
31923
+ yield* printHumanKeyValue([
31924
+ ["ID", role.id],
31925
+ ["Name", role.role],
31926
+ ["Organization ID", role.organizationId],
31927
+ ["Permissions", role.permissions.map((perm) => `${perm.resource}:[${perm.actions.join(",")}]`).join("; ")],
31928
+ ["Created", role.createdAt],
31929
+ ["Updated", role.updatedAt ?? "-"]
31930
+ ]);
31931
+ return role;
31932
+ }), {
31933
+ exits: roleErrorExtras,
31934
+ json: "value"
31935
+ })
31936
+ });
31937
+
31938
+ //#endregion
31939
+ //#region src/commands/roles/index.ts
31940
+ const rolesCommand = defineCommand({
31941
+ meta: {
31942
+ name: "roles",
31943
+ description: "Manage custom organization roles"
31944
+ },
31945
+ subCommands: {
31946
+ list: listCommand$1,
31947
+ view: viewCommand$1,
31948
+ create: createCommand,
31949
+ update: updateCommand$1,
30630
31950
  delete: deleteCommand$1
30631
31951
  }
30632
31952
  });
@@ -30856,7 +32176,7 @@ const submitCommand = defineCommand({
30856
32176
  }
30857
32177
  const projectId = yield* readProjectId;
30858
32178
  const api = yield* apiClient;
30859
- const easProfile = yield* resolveEasSubmitProfile((yield* readEasJson(process.cwd())).submit, args.profile);
32179
+ const easProfile = yield* readSubmitProfile(yield* (yield* CliRuntime).cwd, args.profile);
30860
32180
  const archive = yield* resolveArchive(api, projectId, platform, {
30861
32181
  id: args.id,
30862
32182
  path: args.path,
@@ -32275,7 +33595,7 @@ const runPatchPhase = (input) => Effect.gen(function* () {
32275
33595
  runtimeVersion: input.runtimeVersion,
32276
33596
  platform: input.platform,
32277
33597
  limit: Math.max(1, input.baseWindow)
32278
- } }).pipe(Effect.catchAll(() => Effect.succeed([])));
33598
+ } }).pipe(Effect.orElseSucceed(() => []));
32279
33599
  const bases = selectBaseWindow(candidates, {
32280
33600
  newUpdateId: input.newUpdateId,
32281
33601
  maxRecent: input.baseWindow
@@ -32291,7 +33611,7 @@ const runPatchPhase = (input) => Effect.gen(function* () {
32291
33611
  bestSavingsPct: void 0
32292
33612
  };
32293
33613
  }
32294
- const newBundleBytes = yield* sha256File(input.newLaunchPath).pipe(Effect.map((result) => result.byteSize), Effect.catchAll(() => Effect.succeed(void 0)));
33614
+ const newBundleBytes = yield* sha256File(input.newLaunchPath).pipe(Effect.map((result) => result.byteSize), Effect.orElseSucceed(() => void 0));
32295
33615
  yield* printHuman(`Diffing against ${bases.length} base(s) (window=${input.baseWindow}; ${candidates.length} candidate(s) available).`);
32296
33616
  const uploadedOutcomes = (yield* Effect.forEach(bases, (base, index) => Effect.gen(function* () {
32297
33617
  const basePath = path.join(input.workDir, `base-${index}.bundle`);
@@ -32386,7 +33706,7 @@ const dedupeAssetsByHash = (assets) => uniqBy(assets, (asset) => asset.hash);
32386
33706
  * is informational, so a fingerprint failure resolves to `undefined` rather than
32387
33707
  * failing the publish.
32388
33708
  */
32389
- const resolvePlatformFingerprintHash = (projectRoot, platform) => runFingerprintForPlatform(projectRoot, platform).pipe(Effect.map((result) => result.hash), Effect.catchAll(() => Effect.succeed(void 0)));
33709
+ const resolvePlatformFingerprintHash = (projectRoot, platform) => runFingerprintForPlatform(projectRoot, platform).pipe(Effect.map((result) => result.hash), Effect.orElseSucceed(() => void 0));
32390
33710
  const preparePlatformAssets = ({ exportDir, platform }) => Effect.gen(function* () {
32391
33711
  const exportedAssets = yield* readExpoExportAssets({
32392
33712
  exportDir,
@@ -32482,7 +33802,7 @@ const publishPlatform = (params) => Effect.gen(function* () {
32482
33802
  const uploadDetailsByHash = new Map(assetRegistration.uploaded.map((asset) => [asset.hash, asset]));
32483
33803
  yield* Effect.forEach(uniqueAssets.filter((asset) => uploadDetailsByHash.has(asset.hash)), (asset) => Effect.gen(function* () {
32484
33804
  const detail = uploadDetailsByHash.get(asset.hash);
32485
- if (!detail) return yield* Effect.fail(new UpdatePublishError({ message: `Missing upload details for asset ${asset.hash}` }));
33805
+ if (!detail) return yield* new UpdatePublishError({ message: `Missing upload details for asset ${asset.hash}` });
32486
33806
  return yield* assetUploader.uploadAssetBinary({
32487
33807
  path: asset.path,
32488
33808
  hash: asset.hash,
@@ -33867,6 +35187,7 @@ const commandRegistry = {
33867
35187
  projects: projectsCommand,
33868
35188
  branches: branchesCommand,
33869
35189
  channels: channelsCommand,
35190
+ roles: rolesCommand,
33870
35191
  build: buildCommand,
33871
35192
  builds: buildsCommand,
33872
35193
  credentials: credentialsCommand,