@better-update/cli 0.34.0 → 0.35.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
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { execFile, spawn, spawnSync } from "node:child_process";
4
4
  import { defineCommand, runMain } from "citty";
5
5
  import { Clock, Console, Context, Data, Deferred, Duration, Effect, Either, Layer, Match, Option, ParseResult, Schedule, Schema } from "effect";
6
- import { Command, FetchHttpClient, FileSystem, Headers as Headers$1, HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity, HttpClient, HttpClientRequest, OpenApi, Path } from "@effect/platform";
6
+ import { Command, FetchHttpClient, FileSystem, Headers as Headers$1, HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity, HttpClient, HttpClientRequest, OpenApi } from "@effect/platform";
7
7
  import { NodeContext } from "@effect/platform-node";
8
8
  import path from "node:path";
9
9
  import process$1 from "node:process";
@@ -17,10 +17,11 @@ import { once } from "node:events";
17
17
  import { createServer } from "node:http";
18
18
  import { maxBy, uniqBy } from "es-toolkit";
19
19
  import forge from "node-forge";
20
+ import { AndroidConfig } from "@expo/config-plugins";
21
+ import plist from "@expo/plist";
20
22
  import { spawn as spawn$1 } from "node-pty";
21
23
  import chalk from "chalk";
22
24
  import os, { tmpdir } from "node:os";
23
- import plistMod from "@expo/plist";
24
25
  import { ExpoRunFormatter } from "@expo/xcpretty";
25
26
  import { promisify } from "node:util";
26
27
  import ignore from "ignore";
@@ -34,7 +35,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
34
35
 
35
36
  //#endregion
36
37
  //#region package.json
37
- var version = "0.34.0";
38
+ var version = "0.35.0";
38
39
 
39
40
  //#endregion
40
41
  //#region src/lib/interactive-mode.ts
@@ -3471,8 +3472,11 @@ const UpdateAssetUploaderLive = Layer.effect(UpdateAssetUploader, Effect.gen(fun
3471
3472
  * private key — so the blast radius of a leaked keychain entry is one vault
3472
3473
  * version's credentials, and only until the TTL lapses.
3473
3474
  */
3474
- /** How long a cached vault key stays valid before a fresh passphrase is required. */
3475
+ /** Default for how long a cached vault key stays valid before a fresh passphrase is required. */
3475
3476
  const VAULT_CACHE_TTL_MS = 900 * 1e3;
3477
+ /** Bounds for a user-chosen TTL (`credentials unlock --duration`). */
3478
+ const VAULT_CACHE_TTL_MIN_MS = 60 * 1e3;
3479
+ const VAULT_CACHE_TTL_MAX_MS = 1440 * 60 * 1e3;
3476
3480
  /** Keychain service name; the account is the recipient's public key. */
3477
3481
  const KEYCHAIN_SERVICE = "better-update-vault";
3478
3482
  const isCachedVaultEntry = (value) => isRecord$1(value) && typeof value["vaultKey"] === "string" && typeof value["vaultVersion"] === "number" && typeof value["keyId"] === "string" && typeof value["exp"] === "number";
@@ -3524,9 +3528,9 @@ const VaultCacheLive = Layer.effect(VaultCache, Effect.gen(function* () {
3524
3528
  }
3525
3529
  return decoded;
3526
3530
  }),
3527
- set: (publicKey, vault) => Effect.gen(function* () {
3531
+ set: (publicKey, vault, ttlMs) => Effect.gen(function* () {
3528
3532
  if (yield* cacheDisabled) return;
3529
- yield* writeRaw(publicKey, encodeCacheEntry(vault, yield* Clock.currentTimeMillis));
3533
+ yield* writeRaw(publicKey, encodeCacheEntry(vault, yield* Clock.currentTimeMillis, ttlMs));
3530
3534
  }),
3531
3535
  clear: (publicKey) => deleteRaw(publicKey)
3532
3536
  };
@@ -4130,53 +4134,401 @@ const printHumanList = (headers, rows, emptyMessage) => Effect.gen(function* ()
4130
4134
  });
4131
4135
 
4132
4136
  //#endregion
4133
- //#region src/lib/better-update-config.ts
4137
+ //#region src/lib/eas-profile-extends.ts
4138
+ const asStringValue = (value) => typeof value === "string" ? value : void 0;
4139
+ const asBooleanValue = (value) => typeof value === "boolean" ? value : void 0;
4140
+ const asNumberValue = (raw) => typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
4141
+ const shallowMerge = (base, overlay) => {
4142
+ if (!base) return overlay;
4143
+ if (!overlay) return base;
4144
+ return {
4145
+ ...base,
4146
+ ...overlay
4147
+ };
4148
+ };
4149
+ const stripExtends = (profile) => {
4150
+ if (profile.extends === void 0) return profile;
4151
+ const { extends: _omit, ...rest } = profile;
4152
+ return rest;
4153
+ };
4154
+ const resolveExtendsChain = (params) => Effect.gen(function* () {
4155
+ const { profiles, profileName, label, maxDepth, makeError } = params;
4156
+ const sourceLabel = params.sourceLabel ?? "eas.json";
4157
+ const noun = label === "build" ? "Build" : "Submit";
4158
+ const chain = [];
4159
+ const visited = /* @__PURE__ */ new Set();
4160
+ let current = profileName;
4161
+ let depth = 0;
4162
+ while (current !== void 0) {
4163
+ if (visited.has(current)) return yield* Effect.fail(makeError(`Cycle detected in ${sourceLabel} ${label}.${profileName} extends chain at "${current}".`));
4164
+ visited.add(current);
4165
+ const profile = profiles[current];
4166
+ if (!profile) return yield* Effect.fail(makeError(current === profileName ? `${noun} profile "${profileName}" not found in ${sourceLabel}.` : `${noun} profile "${profileName}" extends missing profile "${current}".`));
4167
+ chain.unshift(profile);
4168
+ current = profile.extends;
4169
+ depth += 1;
4170
+ if (depth > maxDepth) return yield* Effect.fail(makeError(`Too many "extends" levels (max ${String(maxDepth)}) in ${sourceLabel} ${label}.${profileName}.`));
4171
+ }
4172
+ return chain;
4173
+ });
4174
+
4175
+ //#endregion
4176
+ //#region src/lib/eas-submit-config.ts
4177
+ const MAX_SUBMIT_EXTENDS_DEPTH = 10;
4178
+ const asStringArray = (raw) => {
4179
+ if (!Array.isArray(raw)) return;
4180
+ const items = raw.filter((item) => typeof item === "string");
4181
+ return items.length === 0 ? void 0 : items;
4182
+ };
4183
+ const asAndroidReleaseStatus = (raw) => {
4184
+ const value = asStringValue(raw);
4185
+ return value === "completed" || value === "draft" || value === "halted" || value === "inProgress" ? value : void 0;
4186
+ };
4187
+ const parseIosSubmitProfile = (raw) => {
4188
+ const record = asRecord(raw);
4189
+ if (!record) return;
4190
+ return compact({
4191
+ appleId: asStringValue(record["appleId"]),
4192
+ ascAppId: asStringValue(record["ascAppId"]),
4193
+ appleTeamId: asStringValue(record["appleTeamId"]),
4194
+ ascApiKeyPath: asStringValue(record["ascApiKeyPath"]),
4195
+ ascApiKeyId: asStringValue(record["ascApiKeyId"]),
4196
+ ascApiKeyIssuerId: asStringValue(record["ascApiKeyIssuerId"]),
4197
+ sku: asStringValue(record["sku"]),
4198
+ language: asStringValue(record["language"]),
4199
+ companyName: asStringValue(record["companyName"]),
4200
+ appName: asStringValue(record["appName"]),
4201
+ bundleIdentifier: asStringValue(record["bundleIdentifier"]),
4202
+ metadataPath: asStringValue(record["metadataPath"]),
4203
+ groups: asStringArray(record["groups"])
4204
+ });
4205
+ };
4206
+ const parseAndroidSubmitProfile = (raw) => {
4207
+ const record = asRecord(raw);
4208
+ if (!record) return;
4209
+ return compact({
4210
+ serviceAccountKeyPath: asStringValue(record["serviceAccountKeyPath"]),
4211
+ serviceAccountKeyId: asStringValue(record["serviceAccountKeyId"]),
4212
+ track: asStringValue(record["track"]),
4213
+ releaseStatus: asAndroidReleaseStatus(record["releaseStatus"]),
4214
+ changesNotSentForReview: asBooleanValue(record["changesNotSentForReview"]),
4215
+ rollout: asNumberValue(record["rollout"]),
4216
+ applicationId: asStringValue(record["applicationId"])
4217
+ });
4218
+ };
4219
+ const parseSubmitProfile = (raw) => {
4220
+ const record = asRecord(raw);
4221
+ if (!record) return;
4222
+ return compact({
4223
+ extends: asStringValue(record["extends"]),
4224
+ ios: parseIosSubmitProfile(record["ios"]),
4225
+ android: parseAndroidSubmitProfile(record["android"])
4226
+ });
4227
+ };
4228
+ const mergeSubmitProfile = (base, overlay) => {
4229
+ const ios = shallowMerge(base.ios, overlay.ios);
4230
+ const android = shallowMerge(base.android, overlay.android);
4231
+ return compact({
4232
+ extends: overlay.extends,
4233
+ ios,
4234
+ android
4235
+ });
4236
+ };
4237
+ const resolveEasSubmitProfile = (profiles, profileName, sourceLabel = "eas.json") => Effect.gen(function* () {
4238
+ if (!profiles) return yield* new BuildProfileError({ message: `${sourceLabel} has no "submit" section. Add at least one submit profile.` });
4239
+ return stripExtends((yield* resolveExtendsChain({
4240
+ profiles,
4241
+ profileName,
4242
+ label: "submit",
4243
+ maxDepth: MAX_SUBMIT_EXTENDS_DEPTH,
4244
+ sourceLabel,
4245
+ makeError: (message) => new BuildProfileError({ message })
4246
+ })).reduce((acc, next, index) => index === 0 ? next : mergeSubmitProfile(acc, next), {}));
4247
+ });
4248
+
4249
+ //#endregion
4250
+ //#region src/lib/eas-config.ts
4251
+ const MAX_EXTENDS_DEPTH = 10;
4252
+ const asEnv = (value) => {
4253
+ const record = asRecord(value);
4254
+ if (!record) return;
4255
+ const env = {};
4256
+ for (const [key, raw] of Object.entries(record)) if (typeof raw === "string") env[key] = raw;
4257
+ return Object.keys(env).length === 0 ? void 0 : env;
4258
+ };
4259
+ const asIosDistribution = (raw) => {
4260
+ const value = asStringValue(raw);
4261
+ if (value === "app-store" || value === "ad-hoc" || value === "development" || value === "enterprise") return value;
4262
+ };
4263
+ const asEnterpriseProvisioning = (raw) => {
4264
+ const value = asStringValue(raw);
4265
+ return value === "adhoc" || value === "universal" ? value : void 0;
4266
+ };
4267
+ const asAndroidBuildType = (raw) => {
4268
+ const value = asStringValue(raw);
4269
+ return value === "debug" || value === "release" ? value : void 0;
4270
+ };
4271
+ const asAndroidFormat = (raw) => {
4272
+ const value = asStringValue(raw);
4273
+ return value === "apk" || value === "aab" ? value : void 0;
4274
+ };
4275
+ const asAndroidDistribution = (raw) => {
4276
+ const value = asStringValue(raw);
4277
+ return value === "play-store" || value === "direct" ? value : void 0;
4278
+ };
4279
+ const asIosAutoIncrement = (raw) => {
4280
+ if (typeof raw === "boolean") return raw;
4281
+ const value = asStringValue(raw);
4282
+ return value === "buildNumber" || value === "version" ? value : void 0;
4283
+ };
4284
+ const asAndroidAutoIncrement = (raw) => {
4285
+ if (typeof raw === "boolean") return raw;
4286
+ const value = asStringValue(raw);
4287
+ return value === "versionCode" || value === "version" ? value : void 0;
4288
+ };
4289
+ const asAutoIncrement = (raw) => {
4290
+ if (typeof raw === "boolean") return raw;
4291
+ const value = asStringValue(raw);
4292
+ return value === "buildNumber" || value === "versionCode" || value === "version" ? value : void 0;
4293
+ };
4294
+ const asEasDistribution = (raw) => {
4295
+ const value = asStringValue(raw);
4296
+ return value === "internal" || value === "store" ? value : void 0;
4297
+ };
4298
+ const asCredentialsSource = (raw) => {
4299
+ const value = asStringValue(raw);
4300
+ return value === "remote" || value === "local" ? value : void 0;
4301
+ };
4302
+ const parseIosProfile = (raw) => {
4303
+ const record = asRecord(raw);
4304
+ if (!record) return;
4305
+ return compact({
4306
+ distribution: asIosDistribution(record["distribution"]),
4307
+ buildConfiguration: asStringValue(record["buildConfiguration"]),
4308
+ scheme: asStringValue(record["scheme"]),
4309
+ simulator: asBooleanValue(record["simulator"]),
4310
+ enterpriseProvisioning: asEnterpriseProvisioning(record["enterpriseProvisioning"]),
4311
+ autoIncrement: asIosAutoIncrement(record["autoIncrement"]),
4312
+ workspace: asStringValue(record["workspace"]),
4313
+ project: asStringValue(record["project"]),
4314
+ podInstall: asBooleanValue(record["podInstall"]),
4315
+ bundleIdentifier: asStringValue(record["bundleIdentifier"]),
4316
+ version: asStringValue(record["version"]),
4317
+ buildNumber: asStringValue(record["buildNumber"])
4318
+ });
4319
+ };
4320
+ const parseAndroidProfile = (raw) => {
4321
+ const record = asRecord(raw);
4322
+ if (!record) return;
4323
+ return compact({
4324
+ buildType: asAndroidBuildType(record["buildType"]),
4325
+ flavor: asStringValue(record["flavor"]),
4326
+ gradleCommand: asStringValue(record["gradleCommand"]),
4327
+ format: asAndroidFormat(record["format"]),
4328
+ distribution: asAndroidDistribution(record["distribution"]),
4329
+ autoIncrement: asAndroidAutoIncrement(record["autoIncrement"]),
4330
+ module: asStringValue(record["module"]),
4331
+ gradleTask: asStringValue(record["gradleTask"]),
4332
+ applicationId: asStringValue(record["applicationId"]),
4333
+ version: asStringValue(record["version"]),
4334
+ versionCode: asStringValue(record["versionCode"])
4335
+ });
4336
+ };
4337
+ const parseCustomCommandSpec = (raw) => {
4338
+ const record = asRecord(raw);
4339
+ if (!record) return;
4340
+ const command = asStringValue(record["command"]);
4341
+ if (command === void 0) return;
4342
+ return compact({
4343
+ command,
4344
+ cwd: asStringValue(record["cwd"]),
4345
+ env: asEnv(record["env"]),
4346
+ artifactPath: asStringValue(record["artifactPath"])
4347
+ });
4348
+ };
4349
+ const parseCustomCommandProfile = (raw) => {
4350
+ const record = asRecord(raw);
4351
+ if (!record) return;
4352
+ const result = compact({
4353
+ ios: parseCustomCommandSpec(record["ios"]),
4354
+ android: parseCustomCommandSpec(record["android"])
4355
+ });
4356
+ return Object.keys(result).length === 0 ? void 0 : result;
4357
+ };
4358
+ const parseBuildProfile = (raw) => {
4359
+ const record = asRecord(raw);
4360
+ if (!record) return;
4361
+ return compact({
4362
+ extends: asStringValue(record["extends"]),
4363
+ developmentClient: asBooleanValue(record["developmentClient"]),
4364
+ distribution: asEasDistribution(record["distribution"]),
4365
+ channel: asStringValue(record["channel"]),
4366
+ environment: asStringValue(record["environment"]),
4367
+ env: asEnv(record["env"]),
4368
+ ios: parseIosProfile(record["ios"]),
4369
+ android: parseAndroidProfile(record["android"]),
4370
+ credentialsSource: asCredentialsSource(record["credentialsSource"]),
4371
+ autoIncrement: asAutoIncrement(record["autoIncrement"]),
4372
+ withoutCredentials: asBooleanValue(record["withoutCredentials"]),
4373
+ custom: parseCustomCommandProfile(record["custom"])
4374
+ });
4375
+ };
4376
+ /**
4377
+ * Parse an already-decoded JSON object into an {@link EasConfig}. Shared by the
4378
+ * `eas.json` reader and any legacy build-config reader — both hold
4379
+ * the same `build`/`submit`/`cli` shape, only the source file differs.
4380
+ */
4381
+ const parseConfigFromRecord = (root) => {
4382
+ const buildRecord = asRecord(root["build"]);
4383
+ if (!buildRecord) return asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {};
4384
+ const profiles = {};
4385
+ for (const [name, value] of Object.entries(buildRecord)) {
4386
+ const profile = parseBuildProfile(value);
4387
+ if (profile) profiles[name] = profile;
4388
+ }
4389
+ const submitRecord = asRecord(root["submit"]);
4390
+ const submit = {};
4391
+ if (submitRecord) for (const [name, value] of Object.entries(submitRecord)) {
4392
+ const profile = parseSubmitProfile(value);
4393
+ if (profile !== void 0) submit[name] = profile;
4394
+ }
4395
+ return {
4396
+ ...asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {},
4397
+ build: profiles,
4398
+ ...Object.keys(submit).length === 0 ? {} : { submit }
4399
+ };
4400
+ };
4401
+ const parseEasConfig = (text) => Effect.gen(function* () {
4402
+ const root = asRecord(yield* Effect.try({
4403
+ try: () => JSON.parse(text),
4404
+ catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
4405
+ }));
4406
+ if (!root) return yield* new BuildProfileError({ message: "eas.json must be a JSON object at the top level." });
4407
+ return parseConfigFromRecord(root);
4408
+ });
4409
+ const parseCli = (raw) => {
4410
+ const record = asRecord(raw);
4411
+ if (!record) return {};
4412
+ return compact({ version: asStringValue(record["version"]) });
4413
+ };
4414
+ const easJsonPath = (projectRoot) => path.join(projectRoot, "eas.json");
4415
+ const readEasJson = (projectRoot) => Effect.gen(function* () {
4416
+ const fs = yield* FileSystem.FileSystem;
4417
+ const filePath = easJsonPath(projectRoot);
4418
+ 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}` }))));
4419
+ });
4420
+ const mergeCustom = (base, overlay) => {
4421
+ const merged = shallowMerge(base, overlay);
4422
+ return merged === void 0 || Object.keys(merged).length === 0 ? void 0 : merged;
4423
+ };
4424
+ const mergeProfile = (base, overlay) => {
4425
+ const ios = shallowMerge(base.ios, overlay.ios);
4426
+ const android = shallowMerge(base.android, overlay.android);
4427
+ const env = shallowMerge(base.env, overlay.env);
4428
+ const custom = mergeCustom(base.custom, overlay.custom);
4429
+ const developmentClient = overlay.developmentClient ?? base.developmentClient;
4430
+ const distribution = overlay.distribution ?? base.distribution;
4431
+ const channel = overlay.channel ?? base.channel;
4432
+ const environment = overlay.environment ?? base.environment;
4433
+ const credentialsSource = overlay.credentialsSource ?? base.credentialsSource;
4434
+ const autoIncrement = overlay.autoIncrement ?? base.autoIncrement;
4435
+ const withoutCredentials = overlay.withoutCredentials ?? base.withoutCredentials;
4436
+ return compact({
4437
+ extends: overlay.extends,
4438
+ developmentClient,
4439
+ distribution,
4440
+ channel,
4441
+ environment,
4442
+ env,
4443
+ ios,
4444
+ android,
4445
+ credentialsSource,
4446
+ autoIncrement,
4447
+ withoutCredentials,
4448
+ custom
4449
+ });
4450
+ };
4451
+ const resolveEasBuildProfile = (config, profileName, sourceLabel = "eas.json") => Effect.gen(function* () {
4452
+ const profiles = config.build;
4453
+ if (!profiles) return yield* new BuildProfileError({ message: `${sourceLabel} has no "build" section. Add at least one profile.` });
4454
+ return stripExtends((yield* resolveExtendsChain({
4455
+ profiles,
4456
+ profileName,
4457
+ label: "build",
4458
+ maxDepth: MAX_EXTENDS_DEPTH,
4459
+ sourceLabel,
4460
+ makeError: (message) => new BuildProfileError({ message })
4461
+ })).reduce((acc, next, index) => index === 0 ? next : mergeProfile(acc, next), {}));
4462
+ });
4463
+
4464
+ //#endregion
4465
+ //#region src/lib/eas-json.ts
4466
+ /**
4467
+ * `eas.json` is better-update's single project config file — for every build
4468
+ * system, not just Expo. Besides the EAS-shaped `cli`/`build`/`submit`
4469
+ * sections it carries two CLI-owned top-level extension keys:
4470
+ *
4471
+ * - `projectId` — the better-update project link (non-Expo projects; Expo
4472
+ * projects may keep it in app.json `extra.betterUpdate`).
4473
+ * - `projectType` — build-system override ("expo" | "bare" | "kmp" | "native"
4474
+ * | "custom") for projects auto-detection gets wrong.
4475
+ *
4476
+ * Helpers here read/write the file as a raw record so unknown keys (and the
4477
+ * extension keys) survive round-trips untouched.
4478
+ */
4134
4479
  /**
4135
4480
  * Environment variable that overrides project-id resolution. Highest precedence
4136
4481
  * so CI and ephemeral checkouts can target a project without writing any file.
4137
4482
  */
4138
4483
  const BETTER_UPDATE_PROJECT_ID_ENV = "BETTER_UPDATE_PROJECT_ID";
4139
4484
  /**
4140
- * Build-system-neutral project link file. Lives at the project root and lets a
4141
- * non-Expo project (KMP, Flutter, native, …) carry a better-update project id
4142
- * without an `app.json` / `@expo/config`.
4143
- */
4144
- const BETTER_UPDATE_CONFIG_FILENAME = "better-update.json";
4145
- const configPath = (projectRoot) => path.join(projectRoot, BETTER_UPDATE_CONFIG_FILENAME);
4146
- /**
4147
- * Read the project-local `better-update.json` if present. Returns `undefined`
4148
- * when the file is missing or holds invalid JSON — an unlinked project is a
4149
- * normal state, not an error. Mirrors {@link file://./../services/config-store.ts}'s
4150
- * graceful `readConfig`.
4485
+ * Read `eas.json` as a raw record if present. Returns `undefined` when the
4486
+ * file is missing or holds invalid JSON an unlinked project is a normal
4487
+ * state, not an error (profile readers surface parse errors separately).
4151
4488
  */
4152
- const readBetterUpdateConfig = (projectRoot) => Effect.gen(function* () {
4153
- const content = yield* (yield* FileSystem.FileSystem).readFileString(configPath(projectRoot)).pipe(Effect.orElseSucceed(() => ""));
4489
+ const readEasJsonRaw = (projectRoot) => Effect.gen(function* () {
4490
+ const content = yield* (yield* FileSystem.FileSystem).readFileString(easJsonPath(projectRoot)).pipe(Effect.orElseSucceed(() => ""));
4154
4491
  if (content.length === 0) return;
4155
4492
  return yield* Effect.try(() => JSON.parse(content)).pipe(Effect.map((parsed) => isRecord$1(parsed) ? parsed : void 0), Effect.orElseSucceed(() => void 0));
4156
4493
  });
4157
4494
  /**
4158
- * Resolve the linked project id from `better-update.json`, or `undefined` when
4159
- * the file is absent / has no usable `projectId`.
4160
- */
4161
- const readLinkedProjectId = (projectRoot) => readBetterUpdateConfig(projectRoot).pipe(Effect.map((config) => {
4162
- const id = config?.["projectId"];
4163
- return typeof id === "string" && id.length > 0 ? id : void 0;
4164
- }));
4165
- /**
4166
- * Merge `patch` into the existing `better-update.json` (creating it if absent)
4167
- * and write it back, returning the absolute file path. Used by `init` to persist
4168
- * a project link for build-system-neutral projects.
4495
+ * Merge `patch` into the existing `eas.json` (creating it if absent) and write
4496
+ * it back, returning the absolute file path. Shallow merge: patched keys win,
4497
+ * all other keys are preserved verbatim.
4169
4498
  */
4170
- const writeBetterUpdateConfig = (projectRoot, patch) => Effect.gen(function* () {
4499
+ const writeEasJsonPatch = (projectRoot, patch) => Effect.gen(function* () {
4171
4500
  const fs = yield* FileSystem.FileSystem;
4172
- const filePath = configPath(projectRoot);
4501
+ const filePath = easJsonPath(projectRoot);
4173
4502
  const merged = {
4174
- ...yield* readBetterUpdateConfig(projectRoot),
4503
+ ...yield* readEasJsonRaw(projectRoot),
4175
4504
  ...patch
4176
4505
  };
4177
- yield* fs.writeFileString(filePath, `${JSON.stringify(merged, null, 2)}\n`).pipe(Effect.mapError((cause) => new ProjectNotLinkedError({ message: `Failed to write ${BETTER_UPDATE_CONFIG_FILENAME}: ${formatCause(cause)}` })));
4506
+ yield* fs.writeFileString(filePath, `${JSON.stringify(merged, null, 2)}\n`).pipe(Effect.mapError((cause) => new ProjectNotLinkedError({ message: `Failed to write eas.json: ${formatCause(cause)}` })));
4178
4507
  return filePath;
4179
4508
  });
4509
+ /**
4510
+ * Resolve the linked project id from `eas.json`'s top-level `projectId`, or
4511
+ * `undefined` when the file is absent / has no usable value.
4512
+ */
4513
+ const readEasLinkedProjectId = (projectRoot) => readEasJsonRaw(projectRoot).pipe(Effect.map((config) => {
4514
+ const id = config?.["projectId"];
4515
+ return typeof id === "string" && id.length > 0 ? id : void 0;
4516
+ }));
4517
+ /**
4518
+ * Raw `projectType` override from `eas.json`, for `detectProjectType`.
4519
+ * Callers narrow it via `asProjectType`.
4520
+ */
4521
+ const readEasProjectType = (projectRoot) => readEasJsonRaw(projectRoot).pipe(Effect.map((config) => config?.["projectType"]));
4522
+ /** List available build-profile names; `[]` when no eas.json exists. */
4523
+ const listBuildProfileNames = (projectRoot) => Effect.gen(function* () {
4524
+ if (!(yield* (yield* FileSystem.FileSystem).exists(easJsonPath(projectRoot)).pipe(Effect.orElseSucceed(() => false)))) return [];
4525
+ const config = yield* readEasJson(projectRoot);
4526
+ return Object.keys(config.build ?? {});
4527
+ });
4528
+ /** Resolve a submit profile from `eas.json`'s `submit` section. */
4529
+ const readSubmitProfile = (projectRoot, profileName) => Effect.gen(function* () {
4530
+ return yield* resolveEasSubmitProfile((yield* readEasJson(projectRoot)).submit, profileName, "eas.json");
4531
+ });
4180
4532
 
4181
4533
  //#endregion
4182
4534
  //#region ../../node_modules/.bun/structured-headers@2.0.2/node_modules/structured-headers/dist/util.js
@@ -4341,10 +4693,10 @@ const isExpoConfigInstalled = () => {
4341
4693
  };
4342
4694
  /**
4343
4695
  * Build-system-neutral "project not linked" guidance, listing every supported
4344
- * project-id source (env override, better-update.json, Expo config) so the
4696
+ * project-id source (env override, eas.json, Expo config) so the
4345
4697
  * message is correct for Expo and non-Expo projects alike.
4346
4698
  */
4347
- const PROJECT_NOT_LINKED_MESSAGE = `Project not linked. Run \`better-update init\` to link this project, set the ${BETTER_UPDATE_PROJECT_ID_ENV} environment variable, add a "projectId" to better-update.json, or set extra.betterUpdate.projectId in your Expo config.`;
4699
+ const PROJECT_NOT_LINKED_MESSAGE = `Project not linked. Run \`better-update init\` to link this project, set the ${BETTER_UPDATE_PROJECT_ID_ENV} environment variable, add a top-level "projectId" to eas.json, or set extra.betterUpdate.projectId in your Expo config.`;
4348
4700
  const clearDynamicConfigCache = (projectRoot) => {
4349
4701
  const { dynamicConfigPath } = loadExpoConfigModule().getConfigFilePaths(projectRoot);
4350
4702
  if (dynamicConfigPath) delete __require.cache[dynamicConfigPath];
@@ -4553,19 +4905,19 @@ const readAppMeta = (config, platform) => Effect.gen(function* () {
4553
4905
  /**
4554
4906
  * Resolve the active better-update project id, build-system-agnostically.
4555
4907
  *
4556
- * Precedence: `BETTER_UPDATE_PROJECT_ID` env > `better-update.json` >
4557
- * `@expo/config` (`extra.betterUpdate.projectId`). The Expo branch is taken only
4558
- * when `@expo/config` is installed, so a non-Expo project (no app.json, Expo not
4559
- * installed) never crashes at module load — it simply falls through to the
4560
- * `ProjectNotLinkedError`. Every `env`/`credentials`/`status` command reaches the
4561
- * vault through this single resolver.
4908
+ * Precedence: `BETTER_UPDATE_PROJECT_ID` env > `eas.json` top-level `projectId`
4909
+ * > `@expo/config` (`extra.betterUpdate.projectId`). The Expo branch is taken
4910
+ * only when `@expo/config` is installed, so a non-Expo project (no app.json,
4911
+ * Expo not installed) never crashes at module load — it simply falls through to
4912
+ * the `ProjectNotLinkedError`. Every `env`/`credentials`/`status` command
4913
+ * reaches the vault through this single resolver.
4562
4914
  */
4563
4915
  const readProjectId = Effect.gen(function* () {
4564
4916
  const runtime = yield* CliRuntime;
4565
4917
  const fromEnv = yield* runtime.getEnv(BETTER_UPDATE_PROJECT_ID_ENV);
4566
4918
  if (fromEnv !== void 0 && fromEnv.length > 0) return fromEnv;
4567
4919
  const projectRoot = yield* runtime.cwd;
4568
- const fromConfig = yield* readLinkedProjectId(projectRoot);
4920
+ const fromConfig = yield* readEasLinkedProjectId(projectRoot);
4569
4921
  if (fromConfig !== void 0) return fromConfig;
4570
4922
  if (isExpoConfigInstalled()) {
4571
4923
  const fromExpo = yield* readExpoConfig(projectRoot).pipe(Effect.flatMap(extractProjectId), Effect.option);
@@ -4577,7 +4929,7 @@ const readProjectId = Effect.gen(function* () {
4577
4929
  * Read the Expo config without failing: `Option.some(config)` when an Expo
4578
4930
  * project is present and loads, otherwise `Option.none()` (no `@expo/config`
4579
4931
  * installed, no config file, or a config that errors). Lets `init` decide
4580
- * whether to write the link into the Expo config vs `better-update.json`.
4932
+ * whether to write the link into the Expo config vs `eas.json`.
4581
4933
  */
4582
4934
  const readExpoConfigOptional = (projectRoot) => Effect.suspend(() => {
4583
4935
  if (!isExpoConfigInstalled()) return Effect.succeed(Option.none());
@@ -18212,15 +18564,18 @@ const grantRecipient = (args) => Effect.gen(function* () {
18212
18564
  * operation: download/build-resolve reads, seal-for-upload + generate writes, and
18213
18565
  * rotation. There is no read-only cache: an unlock makes the next write seamless
18214
18566
  * too.
18567
+ *
18568
+ * `cacheTtlMs` overrides how long the unlocked key stays cached (default 15 min)
18569
+ * — `credentials unlock --duration` is the one caller that sets it.
18215
18570
  */
18216
- const unlockVaultKeyInteractive = (api) => Effect.gen(function* () {
18571
+ const unlockVaultKeyInteractive = (api, options) => Effect.gen(function* () {
18217
18572
  const recipient = yield* activeRecipient;
18218
18573
  if (recipient.source !== "file") return yield* unlockVaultKey(api, void 0);
18219
18574
  const cache = yield* VaultCache;
18220
18575
  const cached = yield* cache.get(recipient.publicKey);
18221
18576
  if (cached !== void 0) return cached.vault;
18222
18577
  const vault = yield* unlockVaultKey(api, yield* promptPassword("Passphrase to unlock this device's identity:"));
18223
- yield* cache.set(recipient.publicKey, vault);
18578
+ yield* cache.set(recipient.publicKey, vault, options?.cacheTtlMs);
18224
18579
  return vault;
18225
18580
  }).pipe(Effect.provide(VaultCacheLive));
18226
18581
  /**
@@ -19649,6 +20004,106 @@ const loadLocalAndroidCredentials = (options) => Effect.gen(function* () {
19649
20004
  };
19650
20005
  });
19651
20006
 
20007
+ //#endregion
20008
+ //#region src/lib/update-channel-native.ts
20009
+ /**
20010
+ * EAS parity: after `expo prebuild`, bake the build profile's `channel` into
20011
+ * the generated native projects as the `expo-channel-name` request header —
20012
+ * exactly what EAS Build does (`androidSetChannelNativelyAsync` /
20013
+ * `iosSetChannelNativelyAsync` in eas-build). Writing the native files instead
20014
+ * of app.json works for dynamic configs (`app.config.ts`) too, which
20015
+ * `modifyConfigAsync` cannot patch.
20016
+ */
20017
+ /** AndroidManifest meta-data key expo-updates reads extra request headers from. */
20018
+ const ANDROID_REQUEST_HEADERS_META_KEY = "expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY";
20019
+ /** Expo.plist key holding the same request-header map on iOS. */
20020
+ const IOS_REQUEST_HEADERS_PLIST_KEY = "EXUpdatesRequestHeaders";
20021
+ /** Request header expo-updates sends to select the OTA channel. */
20022
+ const EXPO_CHANNEL_HEADER = "expo-channel-name";
20023
+ const asStringRecord = (value) => {
20024
+ if (!isRecord$1(value)) return {};
20025
+ const out = {};
20026
+ for (const [key, raw] of Object.entries(value)) if (typeof raw === "string") out[key] = raw;
20027
+ return out;
20028
+ };
20029
+ /** Merge the channel header into an existing request-header map (channel wins). */
20030
+ const withChannelHeader = (existing, channel) => ({
20031
+ ...asStringRecord(existing),
20032
+ [EXPO_CHANNEL_HEADER]: channel
20033
+ });
20034
+ /**
20035
+ * Whether the app declares `expo-updates` as a (dev)dependency. Channel
20036
+ * injection only makes sense when the updates module ships in the binary —
20037
+ * mirrors eas-build's `isExpoUpdatesInstalledAsync` gate.
20038
+ */
20039
+ const isExpoUpdatesInstalled = (projectRoot) => Effect.gen(function* () {
20040
+ const parsed = safeJsonParse(yield* (yield* FileSystem.FileSystem).readFileString(path.join(projectRoot, "package.json")).pipe(Effect.orElseSucceed(() => "")));
20041
+ if (!isRecord$1(parsed)) return false;
20042
+ const dependencies = isRecord$1(parsed["dependencies"]) ? parsed["dependencies"] : {};
20043
+ const devDependencies = isRecord$1(parsed["devDependencies"]) ? parsed["devDependencies"] : {};
20044
+ return "expo-updates" in dependencies || "expo-updates" in devDependencies;
20045
+ });
20046
+ /**
20047
+ * Write the channel into the prebuilt `android/` project: merge it into the
20048
+ * request-headers JSON carried by the manifest meta-data entry (creating the
20049
+ * entry when prebuild emitted none).
20050
+ */
20051
+ const setAndroidUpdateChannel = (params) => Effect.tryPromise({
20052
+ try: async () => {
20053
+ const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(params.projectRoot);
20054
+ const manifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
20055
+ const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest);
20056
+ const existing = AndroidConfig.Manifest.getMainApplicationMetaDataValue(manifest, ANDROID_REQUEST_HEADERS_META_KEY);
20057
+ const headers = withChannelHeader(existing === null ? void 0 : safeJsonParse(existing), params.channel);
20058
+ AndroidConfig.Manifest.addMetaDataItemToMainApplication(mainApplication, ANDROID_REQUEST_HEADERS_META_KEY, JSON.stringify(headers));
20059
+ await AndroidConfig.Manifest.writeAndroidManifestAsync(manifestPath, manifest);
20060
+ },
20061
+ catch: (cause) => new BuildFailedError({
20062
+ step: "set android update channel",
20063
+ exitCode: 1,
20064
+ message: `Failed to write the update channel into AndroidManifest.xml: ${formatCause(cause)}`
20065
+ })
20066
+ });
20067
+ /** Locate `ios/<target>/Supporting/Expo.plist` in a prebuilt iOS project. */
20068
+ const findExpoPlist = (iosDir) => Effect.gen(function* () {
20069
+ const fs = yield* FileSystem.FileSystem;
20070
+ const entries = yield* fs.readDirectory(iosDir).pipe(Effect.orElseSucceed(() => []));
20071
+ for (const entry of entries) {
20072
+ const candidate = path.join(iosDir, entry, "Supporting", "Expo.plist");
20073
+ if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) return candidate;
20074
+ }
20075
+ return yield* new BuildFailedError({
20076
+ step: "set ios update channel",
20077
+ exitCode: 1,
20078
+ message: `No Supporting/Expo.plist found under ${iosDir} after prebuild — is "expo-updates" installed?`
20079
+ });
20080
+ });
20081
+ /**
20082
+ * Write the channel into the prebuilt `ios/` project: merge it into the
20083
+ * `EXUpdatesRequestHeaders` dict of the generated Expo.plist.
20084
+ */
20085
+ const setIosUpdateChannel = (params) => Effect.gen(function* () {
20086
+ const fs = yield* FileSystem.FileSystem;
20087
+ const plistPath = yield* findExpoPlist(params.iosDir);
20088
+ const failure = (cause) => new BuildFailedError({
20089
+ step: "set ios update channel",
20090
+ exitCode: 1,
20091
+ message: `Failed to write the update channel into ${plistPath}: ${formatCause(cause)}`
20092
+ });
20093
+ const content = yield* fs.readFileString(plistPath).pipe(Effect.mapError(failure));
20094
+ const parsed = yield* Effect.try({
20095
+ try: () => plist.parse(content),
20096
+ catch: failure
20097
+ });
20098
+ if (!isRecord$1(parsed)) return yield* failure("Expo.plist is not a plist dictionary.");
20099
+ const next = {
20100
+ ...parsed,
20101
+ [IOS_REQUEST_HEADERS_PLIST_KEY]: withChannelHeader(parsed[IOS_REQUEST_HEADERS_PLIST_KEY], params.channel)
20102
+ };
20103
+ const rendered = plist.build(next);
20104
+ yield* fs.writeFileString(plistPath, `${rendered}\n`).pipe(Effect.mapError(failure));
20105
+ });
20106
+
19652
20107
  //#endregion
19653
20108
  //#region src/lib/pty-runner.ts
19654
20109
  const ptyDimensions = () => {
@@ -19929,7 +20384,7 @@ const runAndroidCustom = (input, commandEnv) => Effect.gen(function* () {
19929
20384
  if (custom.artifactPath === void 0) return yield* new BuildFailedError({
19930
20385
  step: "custom android build",
19931
20386
  exitCode: 1,
19932
- message: "Custom Android build requires \"artifactPath\" (e.g. \"**/*.aab\") in better-update.json."
20387
+ message: "Custom Android build requires \"artifactPath\" (e.g. \"**/*.aab\") in eas.json."
19933
20388
  });
19934
20389
  const credentials = yield* resolveAndroidCredentials(input);
19935
20390
  const credEnv = credentials === void 0 ? {} : {
@@ -19963,18 +20418,24 @@ const runAndroidCustom = (input, commandEnv) => Effect.gen(function* () {
19963
20418
  });
19964
20419
  const runAndroidBuild = (input) => Effect.gen(function* () {
19965
20420
  const commandEnv = yield* (yield* CliRuntime).commandEnvironment(input.envVars);
19966
- if (input.strategy === "expo") yield* runStep({
19967
- command: "bunx",
19968
- args: [
19969
- "expo",
19970
- "prebuild",
19971
- "--platform",
19972
- "android",
19973
- "--clean"
19974
- ],
19975
- cwd: input.projectRoot,
19976
- env: commandEnv
19977
- }, "expo prebuild android");
20421
+ if (input.strategy === "expo") {
20422
+ yield* runStep({
20423
+ command: "bunx",
20424
+ args: [
20425
+ "expo",
20426
+ "prebuild",
20427
+ "--platform",
20428
+ "android",
20429
+ "--clean"
20430
+ ],
20431
+ cwd: input.projectRoot,
20432
+ env: commandEnv
20433
+ }, "expo prebuild android");
20434
+ if (input.updateChannel !== void 0) yield* setAndroidUpdateChannel({
20435
+ projectRoot: input.projectRoot,
20436
+ channel: input.updateChannel
20437
+ });
20438
+ }
19978
20439
  return input.strategy === "custom" ? yield* runAndroidCustom(input, commandEnv) : yield* runGradleBuild(input, commandEnv);
19979
20440
  });
19980
20441
 
@@ -20854,12 +21315,12 @@ const acquireKeychain = ({ tempDir, p12Path, p12Password }) => {
20854
21315
 
20855
21316
  //#endregion
20856
21317
  //#region src/lib/plist.ts
20857
- const plist = typeof plistMod.parse === "function" ? plistMod : plistMod.default;
21318
+ const plist$1 = typeof plist.parse === "function" ? plist : plist.default;
20858
21319
  /**
20859
21320
  * Parse an XML plist string into a typed object.
20860
21321
  * Throws on malformed XML — callers should wrap in Effect.try.
20861
21322
  */
20862
- const parsePlistXml = (xml) => plist.parse(xml);
21323
+ const parsePlistXml = (xml) => plist$1.parse(xml);
20863
21324
  /**
20864
21325
  * Parse a binary plist buffer into a typed object.
20865
21326
  * Uses bplist-parser for Apple's binary plist format.
@@ -21157,7 +21618,7 @@ const resolveXcodeContainer = (projectRoot, iosDir, iosProfile) => Effect.gen(fu
21157
21618
  return yield* new BuildFailedError({
21158
21619
  step: "resolve Xcode container",
21159
21620
  exitCode: 1,
21160
- message: `No .xcworkspace or .xcodeproj found under ${iosDir}. Set ios.workspace / ios.project in better-update.json.`
21621
+ message: `No .xcworkspace or .xcodeproj found under ${iosDir}. Set ios.workspace / ios.project in eas.json.`
21161
21622
  });
21162
21623
  });
21163
21624
  /**
@@ -21179,6 +21640,10 @@ const prepareIosNative = (params) => Effect.gen(function* () {
21179
21640
  cwd: params.projectRoot,
21180
21641
  env: params.commandEnv
21181
21642
  }, "expo prebuild ios");
21643
+ if (params.updateChannel !== void 0) yield* setIosUpdateChannel({
21644
+ iosDir: params.iosDir,
21645
+ channel: params.updateChannel
21646
+ });
21182
21647
  yield* runStep({
21183
21648
  command: "pod",
21184
21649
  args: ["install"],
@@ -21228,7 +21693,8 @@ const runIosSimulatorBuild = (input) => Effect.gen(function* () {
21228
21693
  projectRoot,
21229
21694
  iosDir,
21230
21695
  iosProfile,
21231
- commandEnv
21696
+ commandEnv,
21697
+ updateChannel: input.updateChannel
21232
21698
  });
21233
21699
  const container = yield* resolveXcodeContainer(projectRoot, iosDir, iosProfile);
21234
21700
  const scheme = iosProfile.scheme ?? container.schemeBase;
@@ -21331,7 +21797,8 @@ const runIosDeviceBuild = (input) => Effect.gen(function* () {
21331
21797
  projectRoot,
21332
21798
  iosDir,
21333
21799
  iosProfile,
21334
- commandEnv
21800
+ commandEnv,
21801
+ updateChannel: input.updateChannel
21335
21802
  });
21336
21803
  const container = yield* resolveXcodeContainer(projectRoot, iosDir, iosProfile);
21337
21804
  const scheme = iosProfile.scheme ?? container.schemeBase;
@@ -21463,7 +21930,7 @@ const runIosCustom = (input) => Effect.gen(function* () {
21463
21930
  if (custom.artifactPath === void 0) return yield* new BuildFailedError({
21464
21931
  step: "custom ios build",
21465
21932
  exitCode: 1,
21466
- message: "Custom iOS build requires \"artifactPath\" (e.g. \"build/*.ipa\") in better-update.json."
21933
+ message: "Custom iOS build requires \"artifactPath\" (e.g. \"build/*.ipa\") in eas.json."
21467
21934
  });
21468
21935
  const credentials = yield* fetchAllCredentials({
21469
21936
  api: input.api,
@@ -21652,354 +22119,6 @@ const applyAutoIncrement = (input) => Effect.gen(function* () {
21652
22119
  return bumps;
21653
22120
  });
21654
22121
 
21655
- //#endregion
21656
- //#region src/lib/eas-profile-extends.ts
21657
- const asStringValue = (value) => typeof value === "string" ? value : void 0;
21658
- const asBooleanValue = (value) => typeof value === "boolean" ? value : void 0;
21659
- const asNumberValue = (raw) => typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
21660
- const shallowMerge = (base, overlay) => {
21661
- if (!base) return overlay;
21662
- if (!overlay) return base;
21663
- return {
21664
- ...base,
21665
- ...overlay
21666
- };
21667
- };
21668
- const stripExtends = (profile) => {
21669
- if (profile.extends === void 0) return profile;
21670
- const { extends: _omit, ...rest } = profile;
21671
- return rest;
21672
- };
21673
- const resolveExtendsChain = (params) => Effect.gen(function* () {
21674
- const { profiles, profileName, label, maxDepth, makeError } = params;
21675
- const sourceLabel = params.sourceLabel ?? "eas.json";
21676
- const noun = label === "build" ? "Build" : "Submit";
21677
- const chain = [];
21678
- const visited = /* @__PURE__ */ new Set();
21679
- let current = profileName;
21680
- let depth = 0;
21681
- while (current !== void 0) {
21682
- if (visited.has(current)) return yield* Effect.fail(makeError(`Cycle detected in ${sourceLabel} ${label}.${profileName} extends chain at "${current}".`));
21683
- visited.add(current);
21684
- const profile = profiles[current];
21685
- if (!profile) return yield* Effect.fail(makeError(current === profileName ? `${noun} profile "${profileName}" not found in ${sourceLabel}.` : `${noun} profile "${profileName}" extends missing profile "${current}".`));
21686
- chain.unshift(profile);
21687
- current = profile.extends;
21688
- depth += 1;
21689
- if (depth > maxDepth) return yield* Effect.fail(makeError(`Too many "extends" levels (max ${String(maxDepth)}) in ${sourceLabel} ${label}.${profileName}.`));
21690
- }
21691
- return chain;
21692
- });
21693
-
21694
- //#endregion
21695
- //#region src/lib/eas-submit-config.ts
21696
- const MAX_SUBMIT_EXTENDS_DEPTH = 10;
21697
- const asStringArray = (raw) => {
21698
- if (!Array.isArray(raw)) return;
21699
- const items = raw.filter((item) => typeof item === "string");
21700
- return items.length === 0 ? void 0 : items;
21701
- };
21702
- const asAndroidReleaseStatus = (raw) => {
21703
- const value = asStringValue(raw);
21704
- return value === "completed" || value === "draft" || value === "halted" || value === "inProgress" ? value : void 0;
21705
- };
21706
- const parseIosSubmitProfile = (raw) => {
21707
- const record = asRecord(raw);
21708
- if (!record) return;
21709
- return compact({
21710
- appleId: asStringValue(record["appleId"]),
21711
- ascAppId: asStringValue(record["ascAppId"]),
21712
- appleTeamId: asStringValue(record["appleTeamId"]),
21713
- ascApiKeyPath: asStringValue(record["ascApiKeyPath"]),
21714
- ascApiKeyId: asStringValue(record["ascApiKeyId"]),
21715
- ascApiKeyIssuerId: asStringValue(record["ascApiKeyIssuerId"]),
21716
- sku: asStringValue(record["sku"]),
21717
- language: asStringValue(record["language"]),
21718
- companyName: asStringValue(record["companyName"]),
21719
- appName: asStringValue(record["appName"]),
21720
- bundleIdentifier: asStringValue(record["bundleIdentifier"]),
21721
- metadataPath: asStringValue(record["metadataPath"]),
21722
- groups: asStringArray(record["groups"])
21723
- });
21724
- };
21725
- const parseAndroidSubmitProfile = (raw) => {
21726
- const record = asRecord(raw);
21727
- if (!record) return;
21728
- return compact({
21729
- serviceAccountKeyPath: asStringValue(record["serviceAccountKeyPath"]),
21730
- serviceAccountKeyId: asStringValue(record["serviceAccountKeyId"]),
21731
- track: asStringValue(record["track"]),
21732
- releaseStatus: asAndroidReleaseStatus(record["releaseStatus"]),
21733
- changesNotSentForReview: asBooleanValue(record["changesNotSentForReview"]),
21734
- rollout: asNumberValue(record["rollout"]),
21735
- applicationId: asStringValue(record["applicationId"])
21736
- });
21737
- };
21738
- const parseSubmitProfile = (raw) => {
21739
- const record = asRecord(raw);
21740
- if (!record) return;
21741
- return compact({
21742
- extends: asStringValue(record["extends"]),
21743
- ios: parseIosSubmitProfile(record["ios"]),
21744
- android: parseAndroidSubmitProfile(record["android"])
21745
- });
21746
- };
21747
- const mergeSubmitProfile = (base, overlay) => {
21748
- const ios = shallowMerge(base.ios, overlay.ios);
21749
- const android = shallowMerge(base.android, overlay.android);
21750
- return compact({
21751
- extends: overlay.extends,
21752
- ios,
21753
- android
21754
- });
21755
- };
21756
- const resolveEasSubmitProfile = (profiles, profileName, sourceLabel = "eas.json") => Effect.gen(function* () {
21757
- if (!profiles) return yield* new BuildProfileError({ message: `${sourceLabel} has no "submit" section. Add at least one submit profile.` });
21758
- return stripExtends((yield* resolveExtendsChain({
21759
- profiles,
21760
- profileName,
21761
- label: "submit",
21762
- maxDepth: MAX_SUBMIT_EXTENDS_DEPTH,
21763
- sourceLabel,
21764
- makeError: (message) => new BuildProfileError({ message })
21765
- })).reduce((acc, next, index) => index === 0 ? next : mergeSubmitProfile(acc, next), {}));
21766
- });
21767
-
21768
- //#endregion
21769
- //#region src/lib/eas-config.ts
21770
- const MAX_EXTENDS_DEPTH = 10;
21771
- const asEnv = (value) => {
21772
- const record = asRecord(value);
21773
- if (!record) return;
21774
- const env = {};
21775
- for (const [key, raw] of Object.entries(record)) if (typeof raw === "string") env[key] = raw;
21776
- return Object.keys(env).length === 0 ? void 0 : env;
21777
- };
21778
- const asIosDistribution = (raw) => {
21779
- const value = asStringValue(raw);
21780
- if (value === "app-store" || value === "ad-hoc" || value === "development" || value === "enterprise") return value;
21781
- };
21782
- const asEnterpriseProvisioning = (raw) => {
21783
- const value = asStringValue(raw);
21784
- return value === "adhoc" || value === "universal" ? value : void 0;
21785
- };
21786
- const asAndroidBuildType = (raw) => {
21787
- const value = asStringValue(raw);
21788
- return value === "debug" || value === "release" ? value : void 0;
21789
- };
21790
- const asAndroidFormat = (raw) => {
21791
- const value = asStringValue(raw);
21792
- return value === "apk" || value === "aab" ? value : void 0;
21793
- };
21794
- const asAndroidDistribution = (raw) => {
21795
- const value = asStringValue(raw);
21796
- return value === "play-store" || value === "direct" ? value : void 0;
21797
- };
21798
- const asIosAutoIncrement = (raw) => {
21799
- if (typeof raw === "boolean") return raw;
21800
- const value = asStringValue(raw);
21801
- return value === "buildNumber" || value === "version" ? value : void 0;
21802
- };
21803
- const asAndroidAutoIncrement = (raw) => {
21804
- if (typeof raw === "boolean") return raw;
21805
- const value = asStringValue(raw);
21806
- return value === "versionCode" || value === "version" ? value : void 0;
21807
- };
21808
- const asAutoIncrement = (raw) => {
21809
- if (typeof raw === "boolean") return raw;
21810
- const value = asStringValue(raw);
21811
- return value === "buildNumber" || value === "versionCode" || value === "version" ? value : void 0;
21812
- };
21813
- const asEasDistribution = (raw) => {
21814
- const value = asStringValue(raw);
21815
- return value === "internal" || value === "store" ? value : void 0;
21816
- };
21817
- const asCredentialsSource = (raw) => {
21818
- const value = asStringValue(raw);
21819
- return value === "remote" || value === "local" ? value : void 0;
21820
- };
21821
- const parseIosProfile = (raw) => {
21822
- const record = asRecord(raw);
21823
- if (!record) return;
21824
- return compact({
21825
- distribution: asIosDistribution(record["distribution"]),
21826
- buildConfiguration: asStringValue(record["buildConfiguration"]),
21827
- scheme: asStringValue(record["scheme"]),
21828
- simulator: asBooleanValue(record["simulator"]),
21829
- enterpriseProvisioning: asEnterpriseProvisioning(record["enterpriseProvisioning"]),
21830
- autoIncrement: asIosAutoIncrement(record["autoIncrement"]),
21831
- workspace: asStringValue(record["workspace"]),
21832
- project: asStringValue(record["project"]),
21833
- podInstall: asBooleanValue(record["podInstall"]),
21834
- bundleIdentifier: asStringValue(record["bundleIdentifier"]),
21835
- version: asStringValue(record["version"]),
21836
- buildNumber: asStringValue(record["buildNumber"])
21837
- });
21838
- };
21839
- const parseAndroidProfile = (raw) => {
21840
- const record = asRecord(raw);
21841
- if (!record) return;
21842
- return compact({
21843
- buildType: asAndroidBuildType(record["buildType"]),
21844
- flavor: asStringValue(record["flavor"]),
21845
- gradleCommand: asStringValue(record["gradleCommand"]),
21846
- format: asAndroidFormat(record["format"]),
21847
- distribution: asAndroidDistribution(record["distribution"]),
21848
- autoIncrement: asAndroidAutoIncrement(record["autoIncrement"]),
21849
- module: asStringValue(record["module"]),
21850
- gradleTask: asStringValue(record["gradleTask"]),
21851
- applicationId: asStringValue(record["applicationId"]),
21852
- version: asStringValue(record["version"]),
21853
- versionCode: asStringValue(record["versionCode"])
21854
- });
21855
- };
21856
- const parseCustomCommandSpec = (raw) => {
21857
- const record = asRecord(raw);
21858
- if (!record) return;
21859
- const command = asStringValue(record["command"]);
21860
- if (command === void 0) return;
21861
- return compact({
21862
- command,
21863
- cwd: asStringValue(record["cwd"]),
21864
- env: asEnv(record["env"]),
21865
- artifactPath: asStringValue(record["artifactPath"])
21866
- });
21867
- };
21868
- const parseCustomCommandProfile = (raw) => {
21869
- const record = asRecord(raw);
21870
- if (!record) return;
21871
- const result = compact({
21872
- ios: parseCustomCommandSpec(record["ios"]),
21873
- android: parseCustomCommandSpec(record["android"])
21874
- });
21875
- return Object.keys(result).length === 0 ? void 0 : result;
21876
- };
21877
- const parseBuildProfile = (raw) => {
21878
- const record = asRecord(raw);
21879
- if (!record) return;
21880
- return compact({
21881
- extends: asStringValue(record["extends"]),
21882
- developmentClient: asBooleanValue(record["developmentClient"]),
21883
- distribution: asEasDistribution(record["distribution"]),
21884
- channel: asStringValue(record["channel"]),
21885
- environment: asStringValue(record["environment"]),
21886
- env: asEnv(record["env"]),
21887
- ios: parseIosProfile(record["ios"]),
21888
- android: parseAndroidProfile(record["android"]),
21889
- credentialsSource: asCredentialsSource(record["credentialsSource"]),
21890
- autoIncrement: asAutoIncrement(record["autoIncrement"]),
21891
- withoutCredentials: asBooleanValue(record["withoutCredentials"]),
21892
- custom: parseCustomCommandProfile(record["custom"])
21893
- });
21894
- };
21895
- /**
21896
- * Parse an already-decoded JSON object into an {@link EasConfig}. Shared by the
21897
- * `eas.json` reader and the `better-update.json` build-config reader — both hold
21898
- * the same `build`/`submit`/`cli` shape, only the source file differs.
21899
- */
21900
- const parseConfigFromRecord = (root) => {
21901
- const buildRecord = asRecord(root["build"]);
21902
- if (!buildRecord) return asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {};
21903
- const profiles = {};
21904
- for (const [name, value] of Object.entries(buildRecord)) {
21905
- const profile = parseBuildProfile(value);
21906
- if (profile) profiles[name] = profile;
21907
- }
21908
- const submitRecord = asRecord(root["submit"]);
21909
- const submit = {};
21910
- if (submitRecord) for (const [name, value] of Object.entries(submitRecord)) {
21911
- const profile = parseSubmitProfile(value);
21912
- if (profile !== void 0) submit[name] = profile;
21913
- }
21914
- return {
21915
- ...asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {},
21916
- build: profiles,
21917
- ...Object.keys(submit).length === 0 ? {} : { submit }
21918
- };
21919
- };
21920
- const parseEasConfig = (text) => Effect.gen(function* () {
21921
- const root = asRecord(yield* Effect.try({
21922
- try: () => JSON.parse(text),
21923
- catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
21924
- }));
21925
- if (!root) return yield* new BuildProfileError({ message: "eas.json must be a JSON object at the top level." });
21926
- return parseConfigFromRecord(root);
21927
- });
21928
- const parseCli = (raw) => {
21929
- const record = asRecord(raw);
21930
- if (!record) return {};
21931
- return compact({ version: asStringValue(record["version"]) });
21932
- };
21933
- const easJsonPath = (projectRoot) => Effect.gen(function* () {
21934
- return (yield* Path.Path).join(projectRoot, "eas.json");
21935
- });
21936
- const readEasJson = (projectRoot) => Effect.gen(function* () {
21937
- const fs = yield* FileSystem.FileSystem;
21938
- const filePath = yield* easJsonPath(projectRoot);
21939
- 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}` }))));
21940
- });
21941
- const mergeCustom = (base, overlay) => {
21942
- const merged = shallowMerge(base, overlay);
21943
- return merged === void 0 || Object.keys(merged).length === 0 ? void 0 : merged;
21944
- };
21945
- const mergeProfile = (base, overlay) => {
21946
- const ios = shallowMerge(base.ios, overlay.ios);
21947
- const android = shallowMerge(base.android, overlay.android);
21948
- const env = shallowMerge(base.env, overlay.env);
21949
- const custom = mergeCustom(base.custom, overlay.custom);
21950
- const developmentClient = overlay.developmentClient ?? base.developmentClient;
21951
- const distribution = overlay.distribution ?? base.distribution;
21952
- const channel = overlay.channel ?? base.channel;
21953
- const environment = overlay.environment ?? base.environment;
21954
- const credentialsSource = overlay.credentialsSource ?? base.credentialsSource;
21955
- const autoIncrement = overlay.autoIncrement ?? base.autoIncrement;
21956
- const withoutCredentials = overlay.withoutCredentials ?? base.withoutCredentials;
21957
- return compact({
21958
- extends: overlay.extends,
21959
- developmentClient,
21960
- distribution,
21961
- channel,
21962
- environment,
21963
- env,
21964
- ios,
21965
- android,
21966
- credentialsSource,
21967
- autoIncrement,
21968
- withoutCredentials,
21969
- custom
21970
- });
21971
- };
21972
- const resolveEasBuildProfile = (config, profileName, sourceLabel = "eas.json") => Effect.gen(function* () {
21973
- const profiles = config.build;
21974
- if (!profiles) return yield* new BuildProfileError({ message: `${sourceLabel} has no "build" section. Add at least one profile.` });
21975
- return stripExtends((yield* resolveExtendsChain({
21976
- profiles,
21977
- profileName,
21978
- label: "build",
21979
- maxDepth: MAX_EXTENDS_DEPTH,
21980
- sourceLabel,
21981
- makeError: (message) => new BuildProfileError({ message })
21982
- })).reduce((acc, next, index) => index === 0 ? next : mergeProfile(acc, next), {}));
21983
- });
21984
-
21985
- //#endregion
21986
- //#region src/lib/better-update-build-config.ts
21987
- /** Label used in profile-resolution error copy when config comes from this file. */
21988
- const BETTER_UPDATE_SOURCE_LABEL = "better-update.json";
21989
- /**
21990
- * Read the `build`/`submit`/`cli` config from `better-update.json`. Returns an
21991
- * empty config (no `build` key) when the file is absent or carries no build
21992
- * section. Shares the parser with {@link file://./eas-config.ts}; only the
21993
- * source file and the error `sourceLabel` differ.
21994
- */
21995
- const readBuildConfig = (projectRoot) => readBetterUpdateConfig(projectRoot).pipe(Effect.map((config) => config === void 0 ? {} : parseConfigFromRecord(config)));
21996
- /** List available build-profile names declared in `better-update.json`. */
21997
- const listBuildProfileNames = (projectRoot) => readBuildConfig(projectRoot).pipe(Effect.map((config) => Object.keys(config.build ?? {})));
21998
- /** Resolve a submit profile from `better-update.json`'s `submit` section. */
21999
- const readSubmitProfile = (projectRoot, profileName) => Effect.gen(function* () {
22000
- return yield* resolveEasSubmitProfile((yield* readBuildConfig(projectRoot)).submit, profileName, BETTER_UPDATE_SOURCE_LABEL);
22001
- });
22002
-
22003
22122
  //#endregion
22004
22123
  //#region src/lib/build-profile.ts
22005
22124
  const deriveIosDistribution = (eas) => {
@@ -22105,9 +22224,9 @@ const fromGenericProfile = (eas, profileName) => {
22105
22224
  customCommand: eas.custom
22106
22225
  });
22107
22226
  };
22108
- /** Resolve a build profile from `better-update.json`'s `build` section. */
22227
+ /** Resolve a build profile from `eas.json`'s `build` section (all build systems). */
22109
22228
  const readBuildProfile = (projectRoot, profileName) => Effect.gen(function* () {
22110
- return fromGenericProfile(yield* resolveEasBuildProfile(yield* readBuildConfig(projectRoot), profileName, "better-update.json"), profileName);
22229
+ return fromGenericProfile(yield* resolveEasBuildProfile(yield* readEasJson(projectRoot), profileName, "eas.json"), profileName);
22111
22230
  });
22112
22231
  const readRuntimeVersionMeta = (config, platform) => ({
22113
22232
  platform,
@@ -22164,7 +22283,7 @@ const PROJECT_TYPES = [
22164
22283
  "native",
22165
22284
  "custom"
22166
22285
  ];
22167
- /** Narrow an arbitrary `projectType` override (e.g. from better-update.json) to a valid value. */
22286
+ /** Narrow an arbitrary `projectType` override (e.g. from eas.json) to a valid value. */
22168
22287
  const asProjectType = (raw) => PROJECT_TYPES.find((type) => type === raw);
22169
22288
  const exists = (filePath) => Effect.gen(function* () {
22170
22289
  return yield* (yield* FileSystem.FileSystem).exists(filePath).pipe(Effect.orElseSucceed(() => false));
@@ -22595,7 +22714,7 @@ const detectPlatformGeneric = (explicit, context) => Effect.gen(function* () {
22595
22714
  const wantsAndroid = context.profile.android !== void 0 || context.profile.customCommand?.android !== void 0;
22596
22715
  if (wantsIos && (context.hasIosDir || context.profile.customCommand?.ios !== void 0)) candidates.push("ios");
22597
22716
  if (wantsAndroid && (context.hasAndroidDir || context.profile.customCommand?.android !== void 0)) candidates.push("android");
22598
- 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." });
22717
+ if (candidates.length === 0) return yield* new BuildProfileError({ message: "Cannot infer build platform. Add an `ios` or `android` section to the build profile in eas.json, or pass --platform." });
22599
22718
  const [only] = candidates;
22600
22719
  if (candidates.length === 1 && only !== void 0) return only;
22601
22720
  if (!(yield* InteractiveMode).allow) return yield* new BuildProfileError({ message: `Multiple platforms available (${candidates.join(", ")}). Pass --platform explicitly when running non-interactively.` });
@@ -23477,13 +23596,13 @@ const EMPTY = {
23477
23596
  buildNumber: void 0,
23478
23597
  rawRuntimeVersion: void 0
23479
23598
  };
23480
- 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;
23599
+ const warnIfMismatch = (label, override, native) => override !== void 0 && native !== void 0 && override !== native ? printWarn(`${label} override "${override}" differs from the native value "${native}". The eas.json value will be used for build metadata.`) : Effect.void;
23481
23600
  const resolveAndroidMeta = (projectRoot, profile) => Effect.gen(function* () {
23482
23601
  const gradle = yield* readGradleConfig(path.join(projectRoot, "android"));
23483
23602
  const override = profile.android?.metaOverride;
23484
23603
  yield* warnIfMismatch("android.applicationId", override?.applicationId, gradle?.applicationId);
23485
23604
  const androidPackage = override?.applicationId ?? gradle?.applicationId;
23486
- 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." });
23605
+ if (androidPackage === void 0) return yield* new BuildProfileError({ message: "Could not determine the Android applicationId. Set android.applicationId under this build profile in eas.json, or ensure android/app/build.gradle defines it." });
23487
23606
  const versionCode = override?.versionCode ?? (gradle?.versionCode === void 0 ? void 0 : String(gradle.versionCode));
23488
23607
  return {
23489
23608
  ...EMPTY,
@@ -23501,7 +23620,7 @@ const resolveIosMeta = (projectRoot, profile) => Effect.gen(function* () {
23501
23620
  const override = profile.ios?.metaOverride;
23502
23621
  yield* warnIfMismatch("ios.bundleIdentifier", override?.bundleIdentifier, native.bundleId);
23503
23622
  const bundleId = override?.bundleIdentifier ?? native.bundleId;
23504
- 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." });
23623
+ if (bundleId === void 0) return yield* new BuildProfileError({ message: "Could not determine the iOS bundle identifier. Set ios.bundleIdentifier under this build profile in eas.json, or ensure the Xcode project defines PRODUCT_BUNDLE_IDENTIFIER for the build configuration." });
23505
23624
  return {
23506
23625
  ...EMPTY,
23507
23626
  bundleId,
@@ -23541,6 +23660,27 @@ const resolveAppMeta = (params) => {
23541
23660
  return params.platform === "ios" ? resolveIosMeta(params.projectRoot, params.profile) : resolveAndroidMeta(params.projectRoot, params.profile);
23542
23661
  };
23543
23662
 
23663
+ //#endregion
23664
+ //#region src/application/resolve-update-channel.ts
23665
+ /**
23666
+ * EAS parity: bake the profile's channel into the native app as the
23667
+ * expo-channel-name request header during prebuild. Only the managed ("expo")
23668
+ * strategy prebuilds, and only apps shipping expo-updates read the header — a
23669
+ * binary built without it silently falls back to the server's default channel,
23670
+ * so a missing `channel` on an updates-enabled profile fails fast instead.
23671
+ * Returns the channel to inject, or `undefined` to skip injection.
23672
+ */
23673
+ const resolveUpdateChannel = (params) => Effect.gen(function* () {
23674
+ const { userCwd, platform, profile, projectType } = params;
23675
+ if ((platform === "ios" ? resolveIosStrategy(profile, projectType) : resolveAndroidStrategy(profile, projectType)) !== "expo") return;
23676
+ if (!(yield* isExpoUpdatesInstalled(userCwd))) {
23677
+ yield* printHuman("expo-updates is not installed — skipping update-channel injection.");
23678
+ return;
23679
+ }
23680
+ if (profile.channel === void 0) return yield* new BuildProfileError({ message: `Build profile "${profile.name}" has no "channel". The channel is baked into the binary as the expo-channel-name update request header; add e.g. "channel": "production" to the profile.` });
23681
+ return profile.channel;
23682
+ });
23683
+
23544
23684
  //#endregion
23545
23685
  //#region src/application/build-workflow.ts
23546
23686
  const runIosPlatformBuild = (input) => Effect.gen(function* () {
@@ -23570,6 +23710,7 @@ const runIosPlatformBuild = (input) => Effect.gen(function* () {
23570
23710
  strategy,
23571
23711
  rawOutput: options.rawOutput,
23572
23712
  freezeCredentials: options.freezeCredentials ?? false,
23713
+ updateChannel: input.updateChannel,
23573
23714
  ...compact({ customCommand: profile.customCommand?.ios })
23574
23715
  }),
23575
23716
  target: isSimulator ? {
@@ -23613,6 +23754,7 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
23613
23754
  profileName: profile.name,
23614
23755
  skipCredentials,
23615
23756
  strategy,
23757
+ updateChannel: input.updateChannel,
23616
23758
  ...compact({ customCommand: profile.customCommand?.android })
23617
23759
  }),
23618
23760
  target: androidProfile.format === "aab" ? {
@@ -23669,7 +23811,7 @@ const resolveProfileName = (projectRoot, requested) => Effect.gen(function* () {
23669
23811
  const available = yield* listBuildProfileNames(projectRoot);
23670
23812
  if (available.includes(requested)) return requested;
23671
23813
  if (!(yield* InteractiveMode).allow || available.length === 0) return requested;
23672
- yield* printHuman(`Build profile "${requested}" not found in better-update.json.`);
23814
+ yield* printHuman(`Build profile "${requested}" not found in eas.json.`);
23673
23815
  return yield* promptSelect("Pick a build profile:", available.map((name) => ({
23674
23816
  value: name,
23675
23817
  label: name
@@ -23685,7 +23827,7 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23685
23827
  });
23686
23828
  const projectType = yield* detectProjectType({
23687
23829
  projectRoot: userCwd,
23688
- override: asProjectType((yield* readBetterUpdateConfig(userCwd))?.["projectType"])
23830
+ override: asProjectType(yield* readEasProjectType(userCwd))
23689
23831
  });
23690
23832
  const isExpo = projectType === "expo";
23691
23833
  const projectId = yield* readProjectId;
@@ -23703,6 +23845,12 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23703
23845
  hasAndroidDir: yield* dirExists(userCwd, "android"),
23704
23846
  hasIosDir: yield* dirExists(userCwd, "ios")
23705
23847
  });
23848
+ const updateChannel = yield* resolveUpdateChannel({
23849
+ userCwd,
23850
+ platform,
23851
+ profile,
23852
+ projectType
23853
+ });
23706
23854
  const { appMeta, runtimeVersion } = isExpo ? yield* resolveExpoBuildMeta({
23707
23855
  userCwd,
23708
23856
  platform,
@@ -23725,7 +23873,8 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23725
23873
  envVars,
23726
23874
  projectType
23727
23875
  });
23728
- yield* printHuman(`Building ${platform} artifact for profile "${profile.name}"${runtimeVersion === void 0 ? "" : ` (runtimeVersion=${runtimeVersion})`}`);
23876
+ const buildDetails = [...runtimeVersion === void 0 ? [] : [`runtimeVersion=${runtimeVersion}`], ...updateChannel === void 0 ? [] : [`channel=${updateChannel}`]];
23877
+ yield* printHuman(`Building ${platform} artifact for profile "${profile.name}"${buildDetails.length === 0 ? "" : ` (${buildDetails.join(", ")})`}`);
23729
23878
  const { build, target, bundleId } = yield* runPlatformBuild({
23730
23879
  api,
23731
23880
  options,
@@ -23736,7 +23885,8 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23736
23885
  envVars,
23737
23886
  projectId,
23738
23887
  projectRoot: staging.projectRoot,
23739
- tempDir
23888
+ tempDir,
23889
+ updateChannel
23740
23890
  });
23741
23891
  yield* printHuman(`Artifact produced: ${build.artifactPath}`);
23742
23892
  let exportedArtifactPath = void 0;
@@ -24757,7 +24907,7 @@ const runUploadWorkflow = (options) => Effect.gen(function* () {
24757
24907
  if (!(yield* (yield* FileSystem.FileSystem).exists(options.artifactPath).pipe(Effect.orElseSucceed(() => false)))) return yield* new ArtifactNotFoundError({ message: `Artifact not found at ${options.artifactPath}.` });
24758
24908
  const projectType = yield* detectProjectType({
24759
24909
  projectRoot,
24760
- override: asProjectType((yield* readBetterUpdateConfig(projectRoot))?.["projectType"])
24910
+ override: asProjectType(yield* readEasProjectType(projectRoot))
24761
24911
  });
24762
24912
  const projectId = yield* readProjectId;
24763
24913
  const profile = yield* readBuildProfile(projectRoot, options.profileName);
@@ -28547,16 +28697,54 @@ const revokeCommand = defineCommand({
28547
28697
  }
28548
28698
  });
28549
28699
 
28700
+ //#endregion
28701
+ //#region src/lib/duration.ts
28702
+ /**
28703
+ * Parse a human-readable duration flag ("90", "45m", "2h", "1h30m") into
28704
+ * milliseconds. A bare number means minutes. Returns `undefined` for anything
28705
+ * unparseable or non-positive — range policy stays with the caller.
28706
+ */
28707
+ const parseDurationMs = (input) => {
28708
+ const groups = /^(?:(?<hours>\d+)h)?(?:(?<minutes>\d+)m?)?$/u.exec(input.trim().toLowerCase())?.groups;
28709
+ if (!groups || groups["hours"] === void 0 && groups["minutes"] === void 0) return;
28710
+ const hours = Number(groups["hours"] ?? 0);
28711
+ const minutes = Number(groups["minutes"] ?? 0);
28712
+ const ms = (hours * 60 + minutes) * 6e4;
28713
+ return ms > 0 ? ms : void 0;
28714
+ };
28715
+ /**
28716
+ * Approximate human form of a duration, rounded up to whole minutes so
28717
+ * "<1 min remaining" still reads as 1 — "45 min", "2 h", "1 h 30 min".
28718
+ */
28719
+ const formatDurationApprox = (ms) => {
28720
+ const totalMinutes = Math.max(1, Math.ceil(ms / 6e4));
28721
+ const hours = Math.floor(totalMinutes / 60);
28722
+ const minutes = totalMinutes % 60;
28723
+ if (hours === 0) return `${minutes} min`;
28724
+ return minutes === 0 ? `${hours} h` : `${hours} h ${minutes} min`;
28725
+ };
28726
+
28550
28727
  //#endregion
28551
28728
  //#region src/commands/credentials/session.ts
28552
- /** Whole minutes left, rounded up so "<1 min remaining" still reads as 1. */
28553
- const remainingMinutes = (remainingMs) => Math.max(1, Math.ceil(remainingMs / 6e4));
28729
+ /** Parse + bound the `--duration` flag; `undefined` (flag absent) keeps the 15-minute default. */
28730
+ const resolveUnlockTtlMs = (flag) => Effect.gen(function* () {
28731
+ if (flag === void 0) return;
28732
+ const ms = parseDurationMs(flag);
28733
+ if (ms === void 0) return yield* new InvalidArgumentError({ message: `Could not parse --duration "${flag}" — use minutes ("90") or h/m units ("45m", "2h", "1h30m").` });
28734
+ if (ms < 6e4 || ms > 864e5) return yield* new InvalidArgumentError({ message: `--duration must be between ${formatDurationApprox(VAULT_CACHE_TTL_MIN_MS)} and ${formatDurationApprox(VAULT_CACHE_TTL_MAX_MS)}, got "${flag}".` });
28735
+ return ms;
28736
+ });
28554
28737
  const unlockCommand = defineCommand({
28555
28738
  meta: {
28556
28739
  name: "unlock",
28557
28740
  description: "Unlock the credential vault and cache the key in your OS keychain, so later commands don't re-prompt"
28558
28741
  },
28559
- run: async () => runEffect(Effect.gen(function* () {
28742
+ args: { duration: {
28743
+ type: "string",
28744
+ description: "How long to stay unlocked — minutes (\"90\") or h/m units (\"45m\", \"2h\", \"1h30m\"); default 15m, max 24h"
28745
+ } },
28746
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
28747
+ const cacheTtlMs = yield* resolveUnlockTtlMs(args.duration);
28560
28748
  const recipient = yield* activeRecipient;
28561
28749
  if (recipient.source !== "file") {
28562
28750
  yield* printHuman("Active identity is the BETTER_UPDATE_IDENTITY (CI) key — it has no passphrase and isn't cached.");
@@ -28565,9 +28753,9 @@ const unlockCommand = defineCommand({
28565
28753
  const api = yield* apiClient;
28566
28754
  const cache = yield* VaultCache;
28567
28755
  yield* cache.clear(recipient.publicKey);
28568
- yield* unlockVaultKeyInteractive(api);
28756
+ yield* unlockVaultKeyInteractive(api, { cacheTtlMs });
28569
28757
  const cached = yield* cache.get(recipient.publicKey);
28570
- yield* printHuman(`Vault unlocked${cached === void 0 ? " (no OS keychain available — commands will keep prompting)" : ` for ~${remainingMinutes(cached.remainingMs)} min; run \`better-update credentials lock\` to clear it`}.`);
28758
+ yield* printHuman(`Vault unlocked${cached === void 0 ? " (no OS keychain available — commands will keep prompting)" : ` for ~${formatDurationApprox(cached.remainingMs)}; run \`better-update credentials lock\` to clear it`}.`);
28571
28759
  }))
28572
28760
  });
28573
28761
  const lockCommand = defineCommand({
@@ -28593,7 +28781,7 @@ const statusCommand$1 = defineCommand({
28593
28781
  return;
28594
28782
  }
28595
28783
  const cached = yield* (yield* VaultCache).get(recipient.publicKey);
28596
- yield* printHuman(cached === void 0 ? "Locked — the next credential command will prompt for your passphrase." : `Unlocked — cached vault key expires in ~${remainingMinutes(cached.remainingMs)} min.`);
28784
+ yield* printHuman(cached === void 0 ? "Locked — the next credential command will prompt for your passphrase." : `Unlocked — cached vault key expires in ~${formatDurationApprox(cached.remainingMs)}.`);
28597
28785
  }))
28598
28786
  });
28599
28787
 
@@ -30044,21 +30232,22 @@ const checkProjectLink = Effect.gen(function* () {
30044
30232
  if (fromEnv !== void 0 && fromEnv.length > 0) return pass("project-linked", "Project linked", `projectId=${fromEnv} (via ${BETTER_UPDATE_PROJECT_ID_ENV})`);
30045
30233
  const resolved = yield* readProjectId.pipe(Effect.either);
30046
30234
  if (resolved._tag === "Left") return warn("project-linked", "Project linked", resolved.left.message);
30047
- const source = (yield* readLinkedProjectId(root)) === void 0 ? "Expo config" : "better-update.json";
30235
+ const source = (yield* readEasLinkedProjectId(root)) === void 0 ? "Expo config" : "eas.json";
30048
30236
  return pass("project-linked", "Project linked", `projectId=${resolved.right} (via ${source})`);
30049
30237
  });
30050
30238
  const checkProjectType = Effect.gen(function* () {
30051
30239
  const root = yield* (yield* CliRuntime).cwd;
30052
- const override = asProjectType((yield* readBetterUpdateConfig(root))?.["projectType"]);
30240
+ const override = asProjectType(yield* readEasProjectType(root));
30053
30241
  return pass("project-type", "Project type", `${yield* detectProjectType({
30054
30242
  projectRoot: root,
30055
30243
  override
30056
- })} (${override === void 0 ? "auto-detected" : "better-update.json override"})`);
30244
+ })} (${override === void 0 ? "auto-detected" : "eas.json override"})`);
30057
30245
  });
30058
30246
  const checkBuildConfig = Effect.gen(function* () {
30059
- const names = yield* listBuildProfileNames(yield* (yield* CliRuntime).cwd);
30060
- if (names.length === 0) return warn("build-config", "Build config", "No build profiles found. Add a \"build\" section to better-update.json.");
30061
- return pass("build-config", "Build config", `${names.length} profile(s) defined`);
30247
+ const names = yield* listBuildProfileNames(yield* (yield* CliRuntime).cwd).pipe(Effect.either);
30248
+ if (names._tag === "Left") return warn("build-config", "Build config", names.left.message);
30249
+ if (names.right.length === 0) return warn("build-config", "Build config", "No build profiles found. Add a \"build\" section to eas.json.");
30250
+ return pass("build-config", "Build config", `${names.right.length} profile(s) defined`);
30062
30251
  });
30063
30252
  const runChecks = Effect.gen(function* () {
30064
30253
  const xcode = (yield* CliRuntime).platform === "darwin" ? [yield* checkCommand("xcode", "Xcode CLI tools", "xcode-select", ["-p"])] : [];
@@ -31573,7 +31762,8 @@ const resolveNameAndSlug = (args, projectRoot, expoConfig) => Effect.gen(functio
31573
31762
  });
31574
31763
  /**
31575
31764
  * Persist the resolved project id: into the Expo config (`extra.betterUpdate`)
31576
- * when an Expo project is present, otherwise into `better-update.json`.
31765
+ * when an Expo project is present, otherwise as `eas.json`'s top-level
31766
+ * `projectId` extension key.
31577
31767
  */
31578
31768
  const persistLink = (projectRoot, projectId, hasExpoConfig) => Effect.gen(function* () {
31579
31769
  if (hasExpoConfig) {
@@ -31585,7 +31775,7 @@ const persistLink = (projectRoot, projectId, hasExpoConfig) => Effect.gen(functi
31585
31775
  ...compact({ configPath: writeResult.configPath })
31586
31776
  };
31587
31777
  }
31588
- const filePath = yield* writeBetterUpdateConfig(projectRoot, { projectId });
31778
+ const filePath = yield* writeEasJsonPatch(projectRoot, { projectId });
31589
31779
  yield* printHuman(`Project linked successfully. ID saved to ${path.relative(projectRoot, filePath)}.`);
31590
31780
  return {
31591
31781
  projectId,
@@ -31694,45 +31884,6 @@ const logoutCommand = defineCommand({
31694
31884
  }), { json: "value" })
31695
31885
  });
31696
31886
 
31697
- //#endregion
31698
- //#region src/commands/migrate-config.ts
31699
- const migrateConfigCommand = defineCommand({
31700
- meta: {
31701
- name: "migrate-config",
31702
- description: "Migrate `build`/`submit` profiles from a legacy eas.json into better-update.json"
31703
- },
31704
- args: { yes: {
31705
- type: "boolean",
31706
- description: "Skip the confirmation prompt"
31707
- } },
31708
- run: async ({ args }) => runEffect(Effect.gen(function* () {
31709
- const runtime = yield* CliRuntime;
31710
- const fs = yield* FileSystem.FileSystem;
31711
- const root = yield* runtime.cwd;
31712
- const easPath = path.join(root, "eas.json");
31713
- if (!(yield* fs.exists(easPath).pipe(Effect.orElseSucceed(() => false)))) return yield* new InvalidArgumentError({ message: `No eas.json found at ${root}.` });
31714
- const config = yield* readEasJson(root);
31715
- const patch = compact({
31716
- build: config.build,
31717
- submit: config.submit,
31718
- cli: config.cli
31719
- });
31720
- if (Object.keys(patch).length === 0) {
31721
- yield* printHuman("eas.json has no build/submit/cli sections — nothing to migrate.");
31722
- return;
31723
- }
31724
- const existingBuild = (yield* readBetterUpdateConfig(root))?.["build"];
31725
- if (typeof existingBuild === "object" && existingBuild !== null && config.build !== void 0 && !args.yes) {
31726
- if (!(yield* promptConfirm("better-update.json already has a build section — overwrite it from eas.json?", { initialValue: false }))) {
31727
- yield* printHuman("Cancelled.");
31728
- return;
31729
- }
31730
- }
31731
- yield* writeBetterUpdateConfig(root, patch);
31732
- yield* printHuman("Merged eas.json build/submit into better-update.json. You can now delete eas.json.");
31733
- }))
31734
- });
31735
-
31736
31887
  //#endregion
31737
31888
  //#region src/commands/open.ts
31738
31889
  const RESOURCE_PATHS = {
@@ -35432,7 +35583,6 @@ const commandRegistry = {
35432
35583
  devices: devicesCommand,
35433
35584
  webhooks: webhooksCommand,
35434
35585
  autocomplete: autocompleteCommand,
35435
- "migrate-config": migrateConfigCommand,
35436
35586
  apple: appleCommand,
35437
35587
  submit: submitCommand
35438
35588
  };