@better-update/cli 0.24.2 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { execFile, spawn, spawnSync } from "node:child_process";
4
4
  import { defineCommand, runMain } from "citty";
5
- import { Console, Context, Data, Deferred, Duration, Effect, Either, Layer, Match, Option, ParseResult, Schedule, Schema } from "effect";
5
+ import { Clock, Console, Context, Data, Deferred, Duration, Effect, Either, Layer, Match, Option, ParseResult, Schedule, Schema } from "effect";
6
6
  import { Command, FetchHttpClient, FileSystem, Headers as Headers$1, HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity, HttpClient, HttpClientRequest, OpenApi, Path } from "@effect/platform";
7
7
  import { NodeContext } from "@effect/platform-node";
8
8
  import path from "node:path";
@@ -11,7 +11,8 @@ 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
+ import { Entry } from "@napi-rs/keyring";
15
16
  import { once } from "node:events";
16
17
  import { createServer } from "node:http";
17
18
  import { maxBy, uniqBy } from "es-toolkit";
@@ -33,7 +34,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
33
34
 
34
35
  //#endregion
35
36
  //#region package.json
36
- var version = "0.24.2";
37
+ var version = "0.26.1";
37
38
 
38
39
  //#endregion
39
40
  //#region src/lib/interactive-mode.ts
@@ -255,7 +256,7 @@ var AnalyticsGroup = class extends HttpApiGroup.make("analytics").add(HttpApiEnd
255
256
 
256
257
  //#endregion
257
258
  //#region ../../packages/api/src/domain/android-application-identifier.ts
258
- 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)" }));
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)" }));
259
260
  var AndroidApplicationIdentifier = class extends Schema.Class("AndroidApplicationIdentifier")({
260
261
  id: Id,
261
262
  organizationId: Id,
@@ -2929,6 +2930,81 @@ const UpdateAssetUploaderLive = Layer.effect(UpdateAssetUploader, Effect.gen(fun
2929
2930
  }) };
2930
2931
  }));
2931
2932
 
2933
+ //#endregion
2934
+ //#region src/services/vault-cache.ts
2935
+ /**
2936
+ * "Unlock once, reuse" for the credential vault — the analog of macOS
2937
+ * `security unlock-keychain`. The first vault operation in a session prompts for
2938
+ * the device passphrase, unwraps the vault key, and stows it in the OS keychain
2939
+ * (`@napi-rs/keyring`: macOS Keychain / Windows Credential Manager / Linux
2940
+ * libsecret) with a short TTL; subsequent commands read it back and skip the
2941
+ * prompt + Argon2id derivation entirely until it expires.
2942
+ *
2943
+ * What is cached is the unwrapped **vault key**, never the passphrase or the age
2944
+ * private key — so the blast radius of a leaked keychain entry is one vault
2945
+ * version's credentials, and only until the TTL lapses.
2946
+ */
2947
+ /** How long a cached vault key stays valid before a fresh passphrase is required. */
2948
+ const VAULT_CACHE_TTL_MS = 900 * 1e3;
2949
+ /** Keychain service name; the account is the recipient's public key. */
2950
+ const KEYCHAIN_SERVICE = "better-update-vault";
2951
+ const isCachedVaultEntry = (value) => isRecord(value) && typeof value["vaultKey"] === "string" && typeof value["vaultVersion"] === "number" && typeof value["keyId"] === "string" && typeof value["exp"] === "number";
2952
+ /** Serialize an unlocked vault into a keychain blob, stamping a TTL from `now`. */
2953
+ const encodeCacheEntry = (vault, now, ttlMs = VAULT_CACHE_TTL_MS) => JSON.stringify({
2954
+ vaultKey: toBase64(vault.vaultKey),
2955
+ vaultVersion: vault.vaultVersion,
2956
+ keyId: vault.keyId,
2957
+ exp: now + ttlMs
2958
+ });
2959
+ /**
2960
+ * Parse a keychain blob back into an unlocked vault, or `undefined` when it is
2961
+ * malformed or has expired as of `now` — so an expired entry reads exactly like
2962
+ * a missing one (and is evicted by the caller).
2963
+ */
2964
+ const decodeCacheEntry = (raw, now) => {
2965
+ const parsed = safeJsonParse(raw);
2966
+ if (!isCachedVaultEntry(parsed) || now >= parsed.exp) return;
2967
+ return {
2968
+ vault: {
2969
+ vaultKey: fromBase64(parsed.vaultKey),
2970
+ vaultVersion: parsed.vaultVersion,
2971
+ keyId: parsed.keyId
2972
+ },
2973
+ remainingMs: parsed.exp - now
2974
+ };
2975
+ };
2976
+ var VaultCache = class extends Context.Tag("cli/VaultCache")() {};
2977
+ const VaultCacheLive = Layer.effect(VaultCache, Effect.gen(function* () {
2978
+ const runtime = yield* CliRuntime;
2979
+ const cacheDisabled = Effect.gen(function* () {
2980
+ const flag = yield* runtime.getEnv("BETTER_UPDATE_NO_CACHE");
2981
+ return flag !== void 0 && flag.length > 0 && flag !== "0" && flag !== "false";
2982
+ });
2983
+ const readRaw = (publicKey) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, publicKey).getPassword()).pipe(Effect.catchAll(() => Effect.succeed(null)));
2984
+ const writeRaw = (publicKey, blob) => Effect.try(() => {
2985
+ new Entry(KEYCHAIN_SERVICE, publicKey).setPassword(blob);
2986
+ }).pipe(Effect.ignore);
2987
+ const deleteRaw = (publicKey) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, publicKey).deletePassword()).pipe(Effect.ignore);
2988
+ return {
2989
+ get: (publicKey) => Effect.gen(function* () {
2990
+ if (yield* cacheDisabled) return;
2991
+ const raw = yield* readRaw(publicKey);
2992
+ if (raw === null) return;
2993
+ const decoded = decodeCacheEntry(raw, yield* Clock.currentTimeMillis);
2994
+ if (decoded === void 0) {
2995
+ yield* deleteRaw(publicKey);
2996
+ return;
2997
+ }
2998
+ return decoded;
2999
+ }),
3000
+ set: (publicKey, vault) => Effect.gen(function* () {
3001
+ if (yield* cacheDisabled) return;
3002
+ yield* writeRaw(publicKey, encodeCacheEntry(vault, yield* Clock.currentTimeMillis));
3003
+ }),
3004
+ clear: (publicKey) => deleteRaw(publicKey)
3005
+ };
3006
+ }));
3007
+
2932
3008
  //#endregion
2933
3009
  //#region src/services/version-check.ts
2934
3010
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/@better-update/cli/latest";
@@ -2979,7 +3055,7 @@ const VersionCheckLive = Layer.effect(VersionCheck, Effect.gen(function* () {
2979
3055
  //#endregion
2980
3056
  //#region src/app-layer.ts
2981
3057
  const CliPlatformLayer = Layer.mergeAll(CliRuntimeLive, NodeContext.layer, FetchHttpClient.layer);
2982
- const CliStoreLayer = Layer.mergeAll(AuthStoreLive, ConfigStoreLive, AppleSessionStoreLive, IdentityStoreLive).pipe(Layer.provide(CliPlatformLayer));
3058
+ const CliStoreLayer = Layer.mergeAll(AuthStoreLive, ConfigStoreLive, AppleSessionStoreLive, IdentityStoreLive, VaultCacheLive).pipe(Layer.provide(CliPlatformLayer));
2983
3059
  const CliAdapterDependencies = Layer.mergeAll(CliPlatformLayer, CliStoreLayer);
2984
3060
  const ApiClientLayer = ApiClientLive.pipe(Layer.provide(CliAdapterDependencies));
2985
3061
  const AppleAuthLayer = AppleAuthLive.pipe(Layer.provide(CliAdapterDependencies));
@@ -4675,8 +4751,31 @@ const findIosArtifact = ({ exportPath }) => Effect.gen(function* () {
4675
4751
  if (!picked) return yield* new ArtifactNotFoundError({ message: `No .ipa file found under "${exportPath}".` });
4676
4752
  return picked.path;
4677
4753
  });
4678
- const findAndroidArtifact = ({ projectRoot, format, flavor, buildType, minMtimeMs }) => Effect.gen(function* () {
4679
- const outputsRoot = path.join(projectRoot, "android", "app", "build", "outputs");
4754
+ /**
4755
+ * Resolve a custom-command build artifact from a user-supplied path. A pattern
4756
+ * without wildcards is treated as a literal path (relative to `baseDir`);
4757
+ * otherwise the fixed leading directory + file extension are extracted and the
4758
+ * newest matching file under that directory is returned.
4759
+ */
4760
+ const findArtifactByGlob = ({ baseDir, pattern, minMtimeMs }) => Effect.gen(function* () {
4761
+ const fs = yield* FileSystem.FileSystem;
4762
+ if (!/[*?[]/u.test(pattern)) {
4763
+ const full = path.isAbsolute(pattern) ? pattern : path.join(baseDir, pattern);
4764
+ if (yield* fs.exists(full).pipe(Effect.orElseSucceed(() => false))) return full;
4765
+ return yield* new ArtifactNotFoundError({ message: `No artifact found at "${full}".` });
4766
+ }
4767
+ const extension = path.extname(pattern).toLowerCase();
4768
+ if (extension === "") return yield* new ArtifactNotFoundError({ message: `artifactPath "${pattern}" must end in a file extension (e.g. **/*.aab).` });
4769
+ const wildcardIndex = pattern.search(/[*?[]/u);
4770
+ const fixedPrefix = pattern.slice(0, wildcardIndex);
4771
+ const prefixDir = fixedPrefix.includes("/") ? fixedPrefix.slice(0, fixedPrefix.lastIndexOf("/")) : "";
4772
+ const searchRoot = prefixDir === "" ? baseDir : path.join(baseDir, prefixDir);
4773
+ const picked = newest(yield* walkAndFind(searchRoot, extension), minMtimeMs);
4774
+ if (!picked) return yield* new ArtifactNotFoundError({ message: `No file matching "${pattern}" found under "${searchRoot}".` });
4775
+ return picked.path;
4776
+ });
4777
+ const findAndroidArtifact = ({ projectRoot, format, flavor, buildType, minMtimeMs, module: gradleModule = "app" }) => Effect.gen(function* () {
4778
+ const outputsRoot = path.join(projectRoot, "android", gradleModule, "build", "outputs");
4680
4779
  const subdir = format === "aab" ? "bundle" : "apk";
4681
4780
  const variantDir = flavor ? `${flavor}${capitalize(buildType)}` : buildType;
4682
4781
  const pickedDirect = newest(yield* walkAndFind(path.join(outputsRoot, subdir, variantDir), `.${format}`), minMtimeMs);
@@ -18373,6 +18472,25 @@ const grantRecipient = (args) => Effect.gen(function* () {
18373
18472
  const resolveVaultPassphrase = Effect.gen(function* () {
18374
18473
  return (yield* activeRecipient).source === "file" ? yield* promptPassword("Passphrase to unlock this device's identity:") : void 0;
18375
18474
  });
18475
+ /**
18476
+ * Unlock the org vault key for an interactive command, reusing a cached vault key
18477
+ * from the OS keychain when one is present and unexpired — so the device
18478
+ * passphrase is prompted at most once per cache TTL rather than on every command
18479
+ * (`better-update credentials unlock` / `lock` drive that session explicitly).
18480
+ * The CI `BETTER_UPDATE_IDENTITY` key carries no passphrase and is never cached:
18481
+ * it skips straight to the raw unwrap. On a cache miss the full unlock runs —
18482
+ * prompt, Argon2id, fetch + unwrap — and the result is cached for next time.
18483
+ */
18484
+ const unlockVaultKeyInteractive = (api) => Effect.gen(function* () {
18485
+ const recipient = yield* activeRecipient;
18486
+ if (recipient.source !== "file") return yield* unlockVaultKey(api, void 0);
18487
+ const cache = yield* VaultCache;
18488
+ const cached = yield* cache.get(recipient.publicKey);
18489
+ if (cached !== void 0) return cached.vault;
18490
+ const vault = yield* unlockVaultKey(api, yield* promptPassword("Passphrase to unlock this device's identity:"));
18491
+ yield* cache.set(recipient.publicKey, vault);
18492
+ return vault;
18493
+ }).pipe(Effect.provide(VaultCacheLive));
18376
18494
  /** Look up a registered recipient by its key id or full `SHA256:` fingerprint. */
18377
18495
  const findRecipient = (api, selector) => Effect.gen(function* () {
18378
18496
  const { items } = yield* api.userEncryptionKeys.list();
@@ -18420,11 +18538,15 @@ const openVaultSession = (api, passphrase) => Effect.gen(function* () {
18420
18538
  };
18421
18539
  });
18422
18540
  /**
18423
- * {@link openVaultSession} that first resolves the device passphraseprompting
18424
- * when the active identity is the on-disk file, none for the CI env key.
18541
+ * {@link openVaultSession} that unlocks the vault key interactively reusing the
18542
+ * OS-keychain-cached key when one is live (no prompt), prompting for the device
18543
+ * passphrase only on a cache miss, and none at all for the CI env key.
18425
18544
  */
18426
18545
  const openVaultSessionInteractive = (api) => Effect.gen(function* () {
18427
- return yield* openVaultSession(api, yield* resolveVaultPassphrase);
18546
+ return {
18547
+ orgId: yield* getActiveOrgId(api),
18548
+ vault: yield* unlockVaultKeyInteractive(api)
18549
+ };
18428
18550
  });
18429
18551
  /** Reshape a sealed envelope into the `{ id, …opaque fields }` an upload body carries. */
18430
18552
  const toUploadEnvelope = (envelope) => ({
@@ -18841,7 +18963,7 @@ const stringField = (cert, name) => {
18841
18963
  return typeof value === "string" ? value : null;
18842
18964
  };
18843
18965
  const matchTeamFromCommonName = (cn) => {
18844
- const match = /\(([A-Z0-9]{10})\)/u.exec(cn);
18966
+ const match = /\((?<team>[A-Z0-9]{10})\)/u.exec(cn);
18845
18967
  if (match === null) return null;
18846
18968
  const [, captured] = match;
18847
18969
  return captured === void 0 ? null : captured;
@@ -19926,56 +20048,44 @@ const gradleTaskName = (format, flavor, buildType) => {
19926
20048
  const verb = format === "aab" ? "bundle" : "assemble";
19927
20049
  return flavor ? `${verb}${capitalize(flavor)}${capitalize(buildType)}` : `${verb}${capitalize(buildType)}`;
19928
20050
  };
19929
- const runAndroidBuild = (input) => Effect.gen(function* () {
19930
- const { api, tempDir, projectRoot, androidProfile, applicationIdentifier, envVars, projectId } = input;
19931
- const runtime = yield* CliRuntime;
20051
+ /** Resolve the signing keystore (remote or local), or `undefined` when skipped. */
20052
+ const resolveAndroidCredentials = (input) => {
20053
+ if (input.skipCredentials) return Effect.succeed(void 0);
20054
+ return input.credentialsSource === "local" ? loadLocalAndroidCredentials({ projectRoot: input.projectRoot }) : downloadAndroidCredentials(input.api, {
20055
+ projectId: input.projectId,
20056
+ applicationIdentifier: input.applicationIdentifier,
20057
+ tempDir: input.tempDir,
20058
+ buildProfile: input.profileName
20059
+ });
20060
+ };
20061
+ /** Gradle build against the (already-prepared) `android/` dir. */
20062
+ const runGradleBuild = (input, commandEnv) => Effect.gen(function* () {
19932
20063
  const buildStartMs = Date.now();
19933
- const { format } = androidProfile;
19934
- const { flavor } = androidProfile;
19935
- const buildType = androidProfile.buildType ?? "release";
19936
- const androidDir = path.join(projectRoot, "android");
19937
- const commandEnv = yield* runtime.commandEnvironment(envVars);
19938
- yield* runStep({
19939
- command: "bunx",
19940
- args: [
19941
- "expo",
19942
- "prebuild",
19943
- "--platform",
19944
- "android",
19945
- "--clean"
19946
- ],
19947
- cwd: projectRoot,
19948
- env: commandEnv
19949
- }, "expo prebuild android");
19950
- const gradleArgs = yield* input.skipCredentials ? Effect.succeed([]) : Effect.gen(function* () {
19951
- const credentials = input.credentialsSource === "local" ? yield* loadLocalAndroidCredentials({ projectRoot }) : yield* downloadAndroidCredentials(api, {
19952
- projectId,
19953
- applicationIdentifier,
19954
- tempDir,
19955
- buildProfile: input.profileName
19956
- });
20064
+ const { format, flavor } = input.androidProfile;
20065
+ const buildType = input.androidProfile.buildType ?? "release";
20066
+ const moduleName = input.androidProfile.module ?? "app";
20067
+ const androidDir = path.join(input.projectRoot, "android");
20068
+ const credentials = yield* resolveAndroidCredentials(input);
20069
+ const gradleArgs = credentials === void 0 ? [] : yield* Effect.gen(function* () {
19957
20070
  const fs = yield* FileSystem.FileSystem;
19958
- const signingGradlePath = path.join(tempDir, "signing.gradle");
19959
- yield* fs.writeFileString(signingGradlePath, renderSigningGradle({
19960
- keystorePath: credentials.keystorePath,
19961
- storePassword: credentials.storePassword,
19962
- keyAlias: credentials.keyAlias,
19963
- keyPassword: credentials.keyPassword
19964
- }));
20071
+ const signingGradlePath = path.join(input.tempDir, "signing.gradle");
20072
+ yield* fs.writeFileString(signingGradlePath, renderSigningGradle(credentials));
19965
20073
  return ["--init-script", signingGradlePath];
19966
20074
  });
19967
- const taskName = gradleTaskName(format, flavor, buildType);
20075
+ const taskName = input.androidProfile.gradleTask ?? gradleTaskName(format, flavor, buildType);
20076
+ const taskArg = taskName.startsWith(":") ? taskName : `:${moduleName}:${taskName}`;
19968
20077
  yield* runStep({
19969
20078
  command: "./gradlew",
19970
- args: [...gradleArgs, `:app:${taskName}`],
20079
+ args: [...gradleArgs, taskArg],
19971
20080
  cwd: androidDir,
19972
20081
  env: commandEnv
19973
20082
  }, "gradlew");
19974
20083
  const artifactPath = yield* findAndroidArtifact({
19975
- projectRoot,
20084
+ projectRoot: input.projectRoot,
19976
20085
  format,
19977
20086
  buildType,
19978
20087
  minMtimeMs: buildStartMs,
20088
+ module: moduleName,
19979
20089
  ...compact({ flavor })
19980
20090
  });
19981
20091
  const { sha256, byteSize } = yield* sha256File(artifactPath);
@@ -19985,6 +20095,71 @@ const runAndroidBuild = (input) => Effect.gen(function* () {
19985
20095
  sha256
19986
20096
  };
19987
20097
  });
20098
+ /**
20099
+ * Custom-command build. We can't inject signing into an arbitrary build, so the
20100
+ * resolved keystore + passwords are exposed to the command as `BETTER_UPDATE_*`
20101
+ * env vars; the user's script consumes them. The artifact is located via the
20102
+ * profile's `artifactPath` glob.
20103
+ */
20104
+ const runAndroidCustom = (input, commandEnv) => Effect.gen(function* () {
20105
+ const buildStartMs = Date.now();
20106
+ const custom = input.customCommand;
20107
+ if (custom === void 0) return yield* new BuildFailedError({
20108
+ step: "custom android build",
20109
+ exitCode: 1,
20110
+ message: "Internal: custom Android strategy selected without a custom command."
20111
+ });
20112
+ if (custom.artifactPath === void 0) return yield* new BuildFailedError({
20113
+ step: "custom android build",
20114
+ exitCode: 1,
20115
+ message: "Custom Android build requires \"artifactPath\" (e.g. \"**/*.aab\") in better-update.json."
20116
+ });
20117
+ const credentials = yield* resolveAndroidCredentials(input);
20118
+ const credEnv = credentials === void 0 ? {} : {
20119
+ BETTER_UPDATE_ANDROID_KEYSTORE_PATH: credentials.keystorePath,
20120
+ BETTER_UPDATE_ANDROID_KEYSTORE_PASSWORD: credentials.storePassword,
20121
+ BETTER_UPDATE_ANDROID_KEY_ALIAS: credentials.keyAlias,
20122
+ BETTER_UPDATE_ANDROID_KEY_PASSWORD: credentials.keyPassword
20123
+ };
20124
+ const cwd = custom.cwd === void 0 ? input.projectRoot : path.join(input.projectRoot, custom.cwd);
20125
+ yield* runStep({
20126
+ command: "sh",
20127
+ args: ["-c", custom.command],
20128
+ cwd,
20129
+ env: {
20130
+ ...commandEnv,
20131
+ ...credEnv,
20132
+ ...custom.env
20133
+ }
20134
+ }, "custom android build");
20135
+ const artifactPath = yield* findArtifactByGlob({
20136
+ baseDir: cwd,
20137
+ pattern: custom.artifactPath,
20138
+ minMtimeMs: buildStartMs
20139
+ });
20140
+ const { sha256, byteSize } = yield* sha256File(artifactPath);
20141
+ return {
20142
+ artifactPath,
20143
+ byteSize,
20144
+ sha256
20145
+ };
20146
+ });
20147
+ const runAndroidBuild = (input) => Effect.gen(function* () {
20148
+ const commandEnv = yield* (yield* CliRuntime).commandEnvironment(input.envVars);
20149
+ if (input.strategy === "expo") yield* runStep({
20150
+ command: "bunx",
20151
+ args: [
20152
+ "expo",
20153
+ "prebuild",
20154
+ "--platform",
20155
+ "android",
20156
+ "--clean"
20157
+ ],
20158
+ cwd: input.projectRoot,
20159
+ env: commandEnv
20160
+ }, "expo prebuild android");
20161
+ return input.strategy === "custom" ? yield* runAndroidCustom(input, commandEnv) : yield* runGradleBuild(input, commandEnv);
20162
+ });
19988
20163
 
19989
20164
  //#endregion
19990
20165
  //#region src/lib/credentials-generator-apple-id.ts
@@ -20699,7 +20874,7 @@ const listCurrentKeychains = Effect.gen(function* () {
20699
20874
  const parseSigningIdentity = (output) => {
20700
20875
  const lines = output.split("\n");
20701
20876
  for (const line of lines) {
20702
- const match = /"([^"]+)"/u.exec(line);
20877
+ const match = /"(?<identity>[^"]+)"/u.exec(line);
20703
20878
  if (match?.[1]) return match[1];
20704
20879
  }
20705
20880
  };
@@ -21002,36 +21177,85 @@ const createXcodebuildFormatter = (projectRoot) => {
21002
21177
  };
21003
21178
 
21004
21179
  //#endregion
21005
- //#region src/commands/build/ios.ts
21006
- const findXcworkspace = (iosDir) => Effect.gen(function* () {
21007
- const workspace = (yield* (yield* FileSystem.FileSystem).readDirectory(iosDir)).find((entry) => entry.endsWith(".xcworkspace"));
21008
- if (!workspace) return yield* new BuildFailedError({
21009
- step: "detect xcworkspace",
21180
+ //#region src/commands/build/ios-prepare.ts
21181
+ const baseName = (entry) => entry.replace(/\.(?<ext>xcworkspace|xcodeproj)$/u, "");
21182
+ /**
21183
+ * Resolve the Xcode container to build: an explicit `workspace`/`project` from
21184
+ * the profile, else an auto-discovered `.xcworkspace` (CocoaPods), else the
21185
+ * `.xcodeproj` (pure-native apps without Pods).
21186
+ */
21187
+ const resolveXcodeContainer = (projectRoot, iosDir, iosProfile) => Effect.gen(function* () {
21188
+ if (iosProfile.workspace !== void 0) {
21189
+ const containerPath = path.resolve(projectRoot, iosProfile.workspace);
21190
+ return {
21191
+ flag: "-workspace",
21192
+ containerPath,
21193
+ schemeBase: baseName(path.basename(containerPath))
21194
+ };
21195
+ }
21196
+ if (iosProfile.project !== void 0) {
21197
+ const containerPath = path.resolve(projectRoot, iosProfile.project);
21198
+ return {
21199
+ flag: "-project",
21200
+ containerPath,
21201
+ schemeBase: baseName(path.basename(containerPath))
21202
+ };
21203
+ }
21204
+ const entries = yield* (yield* FileSystem.FileSystem).readDirectory(iosDir).pipe(Effect.orElseSucceed(() => []));
21205
+ const workspace = entries.find((entry) => entry.endsWith(".xcworkspace"));
21206
+ if (workspace !== void 0) return {
21207
+ flag: "-workspace",
21208
+ containerPath: path.join(iosDir, workspace),
21209
+ schemeBase: baseName(workspace)
21210
+ };
21211
+ const project = entries.find((entry) => entry.endsWith(".xcodeproj"));
21212
+ if (project !== void 0) return {
21213
+ flag: "-project",
21214
+ containerPath: path.join(iosDir, project),
21215
+ schemeBase: baseName(project)
21216
+ };
21217
+ return yield* new BuildFailedError({
21218
+ step: "resolve Xcode container",
21010
21219
  exitCode: 1,
21011
- message: `No .xcworkspace found under ${iosDir}. Did "pod install" run?`
21220
+ message: `No .xcworkspace or .xcodeproj found under ${iosDir}. Set ios.workspace / ios.project in better-update.json.`
21012
21221
  });
21013
- return workspace;
21014
21222
  });
21015
- const prebuildAndPods = (params) => Effect.gen(function* () {
21016
- yield* runStep({
21017
- command: "bunx",
21018
- args: [
21019
- "expo",
21020
- "prebuild",
21021
- "--platform",
21022
- "ios",
21023
- "--clean"
21024
- ],
21025
- cwd: params.projectRoot,
21026
- env: params.commandEnv
21027
- }, "expo prebuild ios");
21028
- yield* runStep({
21223
+ /**
21224
+ * Prepare the `ios/` dir for an xcodebuild. Expo regenerates it from app.json
21225
+ * via prebuild then runs `pod install`; bare/KMP/native build the committed dir
21226
+ * and only run `pod install` when a Podfile is present (unless disabled).
21227
+ */
21228
+ const prepareIosNative = (params) => Effect.gen(function* () {
21229
+ if (params.strategy === "expo") {
21230
+ yield* runStep({
21231
+ command: "bunx",
21232
+ args: [
21233
+ "expo",
21234
+ "prebuild",
21235
+ "--platform",
21236
+ "ios",
21237
+ "--clean"
21238
+ ],
21239
+ cwd: params.projectRoot,
21240
+ env: params.commandEnv
21241
+ }, "expo prebuild ios");
21242
+ yield* runStep({
21243
+ command: "pod",
21244
+ args: ["install"],
21245
+ cwd: params.iosDir,
21246
+ env: params.commandEnv
21247
+ }, "pod install");
21248
+ return;
21249
+ }
21250
+ if (params.iosProfile.podInstall === false) return;
21251
+ if (yield* (yield* FileSystem.FileSystem).exists(path.join(params.iosDir, "Podfile")).pipe(Effect.orElseSucceed(() => false))) yield* runStep({
21029
21252
  command: "pod",
21030
21253
  args: ["install"],
21031
21254
  cwd: params.iosDir,
21032
21255
  env: params.commandEnv
21033
21256
  }, "pod install");
21034
21257
  });
21258
+ /** Recursively locate the first `.app` bundle under `root` (simulator output). */
21035
21259
  const findAppDirectory = (root) => Effect.gen(function* () {
21036
21260
  const fs = yield* FileSystem.FileSystem;
21037
21261
  const stack = [root];
@@ -21051,25 +21275,30 @@ const findAppDirectory = (root) => Effect.gen(function* () {
21051
21275
  }
21052
21276
  return yield* new ArtifactNotFoundError({ message: `No .app bundle found under "${root}".` });
21053
21277
  });
21278
+
21279
+ //#endregion
21280
+ //#region src/commands/build/ios.ts
21054
21281
  const runIosSimulatorBuild = (input) => Effect.gen(function* () {
21055
21282
  const { projectRoot, iosProfile, envVars, tempDir } = input;
21056
21283
  const runtime = yield* CliRuntime;
21057
21284
  const iosDir = path.join(projectRoot, "ios");
21058
21285
  const commandEnv = yield* runtime.commandEnvironment(envVars);
21059
- yield* prebuildAndPods({
21286
+ yield* prepareIosNative({
21287
+ strategy: input.strategy,
21060
21288
  projectRoot,
21061
21289
  iosDir,
21290
+ iosProfile,
21062
21291
  commandEnv
21063
21292
  });
21064
- const workspaceFilename = yield* findXcworkspace(iosDir);
21065
- const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
21293
+ const container = yield* resolveXcodeContainer(projectRoot, iosDir, iosProfile);
21294
+ const scheme = iosProfile.scheme ?? container.schemeBase;
21066
21295
  const configuration = iosProfile.buildConfiguration ?? "Release";
21067
21296
  const derivedDataPath = path.join(tempDir, "derived-data");
21068
21297
  const buildCmd = {
21069
21298
  command: "xcodebuild",
21070
21299
  args: [
21071
- "-workspace",
21072
- workspaceFilename,
21300
+ container.flag,
21301
+ container.containerPath,
21073
21302
  "-scheme",
21074
21303
  scheme,
21075
21304
  "-configuration",
@@ -21157,13 +21386,15 @@ const runIosDeviceBuild = (input) => Effect.gen(function* () {
21157
21386
  const iosDir = path.join(projectRoot, "ios");
21158
21387
  const { distribution } = iosProfile;
21159
21388
  const commandEnv = yield* runtime.commandEnvironment(envVars);
21160
- yield* prebuildAndPods({
21389
+ yield* prepareIosNative({
21390
+ strategy: input.strategy,
21161
21391
  projectRoot,
21162
21392
  iosDir,
21393
+ iosProfile,
21163
21394
  commandEnv
21164
21395
  });
21165
- const workspaceFilename = yield* findXcworkspace(iosDir);
21166
- const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
21396
+ const container = yield* resolveXcodeContainer(projectRoot, iosDir, iosProfile);
21397
+ const scheme = iosProfile.scheme ?? container.schemeBase;
21167
21398
  const configuration = iosProfile.buildConfiguration ?? "Release";
21168
21399
  const signedTargets = yield* discoverSignedTargets({
21169
21400
  iosDir,
@@ -21210,8 +21441,8 @@ const runIosDeviceBuild = (input) => Effect.gen(function* () {
21210
21441
  const archiveCmd = {
21211
21442
  command: "xcodebuild",
21212
21443
  args: [
21213
- "-workspace",
21214
- workspaceFilename,
21444
+ container.flag,
21445
+ container.containerPath,
21215
21446
  "-scheme",
21216
21447
  scheme,
21217
21448
  "-configuration",
@@ -21275,19 +21506,76 @@ const runIosDeviceBuild = (input) => Effect.gen(function* () {
21275
21506
  sha256
21276
21507
  };
21277
21508
  });
21278
- const runIosBuild = (input) => input.iosProfile.simulator === true ? runIosSimulatorBuild(input) : runIosDeviceBuild(input);
21509
+ /**
21510
+ * Custom-command iOS build. The resolved p12 + provisioning profiles are written
21511
+ * to `tempDir` and their paths exposed via `BETTER_UPDATE_IOS_*` env vars; the
21512
+ * user's command performs the actual signing/archive. The artifact is located
21513
+ * via the profile's `artifactPath` glob.
21514
+ */
21515
+ const runIosCustom = (input) => Effect.gen(function* () {
21516
+ const commandEnv = yield* (yield* CliRuntime).commandEnvironment(input.envVars);
21517
+ const custom = input.customCommand;
21518
+ if (custom === void 0) return yield* new BuildFailedError({
21519
+ step: "custom ios build",
21520
+ exitCode: 1,
21521
+ message: "Internal: custom iOS strategy selected without a custom command."
21522
+ });
21523
+ if (custom.artifactPath === void 0) return yield* new BuildFailedError({
21524
+ step: "custom ios build",
21525
+ exitCode: 1,
21526
+ message: "Custom iOS build requires \"artifactPath\" (e.g. \"build/*.ipa\") in better-update.json."
21527
+ });
21528
+ const credentials = yield* fetchAllCredentials({
21529
+ api: input.api,
21530
+ input,
21531
+ mainBundleIdentifier: input.bundleId,
21532
+ allBundleIdentifiers: [input.bundleId]
21533
+ });
21534
+ const credEnv = {
21535
+ BETTER_UPDATE_IOS_P12_PATH: credentials.p12Path,
21536
+ BETTER_UPDATE_IOS_P12_PASSWORD: credentials.p12Password,
21537
+ BETTER_UPDATE_IOS_PROVISIONING_PROFILES: credentials.profiles.map((profile) => profile.profilePath).join(":")
21538
+ };
21539
+ const cwd = custom.cwd === void 0 ? input.projectRoot : path.join(input.projectRoot, custom.cwd);
21540
+ const buildStartMs = Date.now();
21541
+ yield* runStep({
21542
+ command: "sh",
21543
+ args: ["-c", custom.command],
21544
+ cwd,
21545
+ env: {
21546
+ ...commandEnv,
21547
+ ...credEnv,
21548
+ ...custom.env
21549
+ }
21550
+ }, "custom ios build");
21551
+ const artifactPath = yield* findArtifactByGlob({
21552
+ baseDir: cwd,
21553
+ pattern: custom.artifactPath,
21554
+ minMtimeMs: buildStartMs
21555
+ });
21556
+ const { sha256, byteSize } = yield* sha256File(artifactPath);
21557
+ return {
21558
+ artifactPath,
21559
+ byteSize,
21560
+ sha256
21561
+ };
21562
+ });
21563
+ const runIosBuild = (input) => {
21564
+ if (input.strategy === "custom") return runIosCustom(input);
21565
+ return input.iosProfile.simulator === true ? runIosSimulatorBuild(input) : runIosDeviceBuild(input);
21566
+ };
21279
21567
 
21280
21568
  //#endregion
21281
21569
  //#region src/commands/build/reserve-and-upload.ts
21282
21570
  const buildReserveCommon = (input) => ({
21283
21571
  projectId: input.projectId,
21284
21572
  profile: input.profileName,
21285
- runtimeVersion: input.runtimeVersion,
21286
21573
  bundleId: input.bundleId,
21287
21574
  sha256: input.sha256,
21288
21575
  byteSize: input.byteSize,
21289
21576
  gitDirty: input.gitContext.dirty,
21290
21577
  ...compact({
21578
+ runtimeVersion: input.runtimeVersion,
21291
21579
  appVersion: input.appVersion,
21292
21580
  buildNumber: input.buildNumber,
21293
21581
  gitRef: input.gitContext.ref,
@@ -21363,7 +21651,7 @@ const bumpVersionCode = (current) => Effect.gen(function* () {
21363
21651
  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.` });
21364
21652
  return value + 1;
21365
21653
  });
21366
- const SEMVER_PATCH = /^(\d+)\.(\d+)\.(\d+)(.*)$/u;
21654
+ const SEMVER_PATCH = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?<suffix>.*)$/u;
21367
21655
  const bumpVersion = (current) => Effect.gen(function* () {
21368
21656
  if (current === void 0) return yield* new BuildProfileError({ message: "Cannot autoIncrement version: no `version` field set in Expo config." });
21369
21657
  const match = SEMVER_PATCH.exec(current);
@@ -21444,20 +21732,21 @@ const stripExtends = (profile) => {
21444
21732
  };
21445
21733
  const resolveExtendsChain = (params) => Effect.gen(function* () {
21446
21734
  const { profiles, profileName, label, maxDepth, makeError } = params;
21735
+ const sourceLabel = params.sourceLabel ?? "eas.json";
21447
21736
  const noun = label === "build" ? "Build" : "Submit";
21448
21737
  const chain = [];
21449
21738
  const visited = /* @__PURE__ */ new Set();
21450
21739
  let current = profileName;
21451
21740
  let depth = 0;
21452
21741
  while (current !== void 0) {
21453
- if (visited.has(current)) return yield* Effect.fail(makeError(`Cycle detected in eas.json ${label}.${profileName} extends chain at "${current}".`));
21742
+ if (visited.has(current)) return yield* Effect.fail(makeError(`Cycle detected in ${sourceLabel} ${label}.${profileName} extends chain at "${current}".`));
21454
21743
  visited.add(current);
21455
21744
  const profile = profiles[current];
21456
- if (!profile) return yield* Effect.fail(makeError(current === profileName ? `${noun} profile "${profileName}" not found in eas.json.` : `${noun} profile "${profileName}" extends missing profile "${current}".`));
21745
+ if (!profile) return yield* Effect.fail(makeError(current === profileName ? `${noun} profile "${profileName}" not found in ${sourceLabel}.` : `${noun} profile "${profileName}" extends missing profile "${current}".`));
21457
21746
  chain.unshift(profile);
21458
21747
  current = profile.extends;
21459
21748
  depth += 1;
21460
- if (depth > maxDepth) return yield* Effect.fail(makeError(`Too many "extends" levels (max ${String(maxDepth)}) in eas.json ${label}.${profileName}.`));
21749
+ if (depth > maxDepth) return yield* Effect.fail(makeError(`Too many "extends" levels (max ${String(maxDepth)}) in ${sourceLabel} ${label}.${profileName}.`));
21461
21750
  }
21462
21751
  return chain;
21463
21752
  });
@@ -21524,13 +21813,14 @@ const mergeSubmitProfile = (base, overlay) => {
21524
21813
  android
21525
21814
  });
21526
21815
  };
21527
- const resolveEasSubmitProfile = (profiles, profileName) => Effect.gen(function* () {
21528
- if (!profiles) return yield* new BuildProfileError({ message: "eas.json has no \"submit\" section. Add at least one submit profile." });
21816
+ const resolveEasSubmitProfile = (profiles, profileName, sourceLabel = "eas.json") => Effect.gen(function* () {
21817
+ if (!profiles) return yield* new BuildProfileError({ message: `${sourceLabel} has no "submit" section. Add at least one submit profile.` });
21529
21818
  return stripExtends((yield* resolveExtendsChain({
21530
21819
  profiles,
21531
21820
  profileName,
21532
21821
  label: "submit",
21533
21822
  maxDepth: MAX_SUBMIT_EXTENDS_DEPTH,
21823
+ sourceLabel,
21534
21824
  makeError: (message) => new BuildProfileError({ message })
21535
21825
  })).reduce((acc, next, index) => index === 0 ? next : mergeSubmitProfile(acc, next), {}));
21536
21826
  });
@@ -21597,7 +21887,13 @@ const parseIosProfile = (raw) => {
21597
21887
  scheme: asStringValue(record["scheme"]),
21598
21888
  simulator: asBooleanValue(record["simulator"]),
21599
21889
  enterpriseProvisioning: asEnterpriseProvisioning(record["enterpriseProvisioning"]),
21600
- autoIncrement: asIosAutoIncrement(record["autoIncrement"])
21890
+ autoIncrement: asIosAutoIncrement(record["autoIncrement"]),
21891
+ workspace: asStringValue(record["workspace"]),
21892
+ project: asStringValue(record["project"]),
21893
+ podInstall: asBooleanValue(record["podInstall"]),
21894
+ bundleIdentifier: asStringValue(record["bundleIdentifier"]),
21895
+ version: asStringValue(record["version"]),
21896
+ buildNumber: asStringValue(record["buildNumber"])
21601
21897
  });
21602
21898
  };
21603
21899
  const parseAndroidProfile = (raw) => {
@@ -21609,9 +21905,35 @@ const parseAndroidProfile = (raw) => {
21609
21905
  gradleCommand: asStringValue(record["gradleCommand"]),
21610
21906
  format: asAndroidFormat(record["format"]),
21611
21907
  distribution: asAndroidDistribution(record["distribution"]),
21612
- autoIncrement: asAndroidAutoIncrement(record["autoIncrement"])
21908
+ autoIncrement: asAndroidAutoIncrement(record["autoIncrement"]),
21909
+ module: asStringValue(record["module"]),
21910
+ gradleTask: asStringValue(record["gradleTask"]),
21911
+ applicationId: asStringValue(record["applicationId"]),
21912
+ version: asStringValue(record["version"]),
21913
+ versionCode: asStringValue(record["versionCode"])
21613
21914
  });
21614
21915
  };
21916
+ const parseCustomCommandSpec = (raw) => {
21917
+ const record = asRecord(raw);
21918
+ if (!record) return;
21919
+ const command = asStringValue(record["command"]);
21920
+ if (command === void 0) return;
21921
+ return compact({
21922
+ command,
21923
+ cwd: asStringValue(record["cwd"]),
21924
+ env: asEnv(record["env"]),
21925
+ artifactPath: asStringValue(record["artifactPath"])
21926
+ });
21927
+ };
21928
+ const parseCustomCommandProfile = (raw) => {
21929
+ const record = asRecord(raw);
21930
+ if (!record) return;
21931
+ const result = compact({
21932
+ ios: parseCustomCommandSpec(record["ios"]),
21933
+ android: parseCustomCommandSpec(record["android"])
21934
+ });
21935
+ return Object.keys(result).length === 0 ? void 0 : result;
21936
+ };
21615
21937
  const parseBuildProfile = (raw) => {
21616
21938
  const record = asRecord(raw);
21617
21939
  if (!record) return;
@@ -21626,15 +21948,16 @@ const parseBuildProfile = (raw) => {
21626
21948
  android: parseAndroidProfile(record["android"]),
21627
21949
  credentialsSource: asCredentialsSource(record["credentialsSource"]),
21628
21950
  autoIncrement: asAutoIncrement(record["autoIncrement"]),
21629
- withoutCredentials: asBooleanValue(record["withoutCredentials"])
21951
+ withoutCredentials: asBooleanValue(record["withoutCredentials"]),
21952
+ custom: parseCustomCommandProfile(record["custom"])
21630
21953
  });
21631
21954
  };
21632
- const parseEasConfig = (text) => Effect.gen(function* () {
21633
- const root = asRecord(yield* Effect.try({
21634
- try: () => JSON.parse(text),
21635
- catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
21636
- }));
21637
- if (!root) return yield* new BuildProfileError({ message: "eas.json must be a JSON object at the top level." });
21955
+ /**
21956
+ * Parse an already-decoded JSON object into an {@link EasConfig}. Shared by the
21957
+ * `eas.json` reader and the `better-update.json` build-config reader — both hold
21958
+ * the same `build`/`submit`/`cli` shape, only the source file differs.
21959
+ */
21960
+ const parseConfigFromRecord = (root) => {
21638
21961
  const buildRecord = asRecord(root["build"]);
21639
21962
  if (!buildRecord) return asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {};
21640
21963
  const profiles = {};
@@ -21653,6 +21976,14 @@ const parseEasConfig = (text) => Effect.gen(function* () {
21653
21976
  build: profiles,
21654
21977
  ...Object.keys(submit).length === 0 ? {} : { submit }
21655
21978
  };
21979
+ };
21980
+ const parseEasConfig = (text) => Effect.gen(function* () {
21981
+ const root = asRecord(yield* Effect.try({
21982
+ try: () => JSON.parse(text),
21983
+ catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
21984
+ }));
21985
+ if (!root) return yield* new BuildProfileError({ message: "eas.json must be a JSON object at the top level." });
21986
+ return parseConfigFromRecord(root);
21656
21987
  });
21657
21988
  const parseCli = (raw) => {
21658
21989
  const record = asRecord(raw);
@@ -21667,10 +21998,15 @@ const readEasJson = (projectRoot) => Effect.gen(function* () {
21667
21998
  const filePath = yield* easJsonPath(projectRoot);
21668
21999
  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}` })))));
21669
22000
  });
22001
+ const mergeCustom = (base, overlay) => {
22002
+ const merged = shallowMerge(base, overlay);
22003
+ return merged === void 0 || Object.keys(merged).length === 0 ? void 0 : merged;
22004
+ };
21670
22005
  const mergeProfile = (base, overlay) => {
21671
22006
  const ios = shallowMerge(base.ios, overlay.ios);
21672
22007
  const android = shallowMerge(base.android, overlay.android);
21673
22008
  const env = shallowMerge(base.env, overlay.env);
22009
+ const custom = mergeCustom(base.custom, overlay.custom);
21674
22010
  const developmentClient = overlay.developmentClient ?? base.developmentClient;
21675
22011
  const distribution = overlay.distribution ?? base.distribution;
21676
22012
  const channel = overlay.channel ?? base.channel;
@@ -21689,21 +22025,41 @@ const mergeProfile = (base, overlay) => {
21689
22025
  android,
21690
22026
  credentialsSource,
21691
22027
  autoIncrement,
21692
- withoutCredentials
22028
+ withoutCredentials,
22029
+ custom
21693
22030
  });
21694
22031
  };
21695
- const resolveEasBuildProfile = (config, profileName) => Effect.gen(function* () {
22032
+ const resolveEasBuildProfile = (config, profileName, sourceLabel = "eas.json") => Effect.gen(function* () {
21696
22033
  const profiles = config.build;
21697
- if (!profiles) return yield* new BuildProfileError({ message: "eas.json has no \"build\" section. Add at least one profile." });
22034
+ if (!profiles) return yield* new BuildProfileError({ message: `${sourceLabel} has no "build" section. Add at least one profile.` });
21698
22035
  return stripExtends((yield* resolveExtendsChain({
21699
22036
  profiles,
21700
22037
  profileName,
21701
22038
  label: "build",
21702
22039
  maxDepth: MAX_EXTENDS_DEPTH,
22040
+ sourceLabel,
21703
22041
  makeError: (message) => new BuildProfileError({ message })
21704
22042
  })).reduce((acc, next, index) => index === 0 ? next : mergeProfile(acc, next), {}));
21705
22043
  });
21706
22044
 
22045
+ //#endregion
22046
+ //#region src/lib/better-update-build-config.ts
22047
+ /** Label used in profile-resolution error copy when config comes from this file. */
22048
+ const BETTER_UPDATE_SOURCE_LABEL = "better-update.json";
22049
+ /**
22050
+ * Read the `build`/`submit`/`cli` config from `better-update.json`. Returns an
22051
+ * empty config (no `build` key) when the file is absent or carries no build
22052
+ * section. Shares the parser with {@link file://./eas-config.ts}; only the
22053
+ * source file and the error `sourceLabel` differ.
22054
+ */
22055
+ const readBuildConfig = (projectRoot) => readBetterUpdateConfig(projectRoot).pipe(Effect.map((config) => config === void 0 ? {} : parseConfigFromRecord(config)));
22056
+ /** List available build-profile names declared in `better-update.json`. */
22057
+ const listBuildProfileNames = (projectRoot) => readBuildConfig(projectRoot).pipe(Effect.map((config) => Object.keys(config.build ?? {})));
22058
+ /** Resolve a submit profile from `better-update.json`'s `submit` section. */
22059
+ const readSubmitProfile = (projectRoot, profileName) => Effect.gen(function* () {
22060
+ return yield* resolveEasSubmitProfile((yield* readBuildConfig(projectRoot)).submit, profileName, BETTER_UPDATE_SOURCE_LABEL);
22061
+ });
22062
+
21707
22063
  //#endregion
21708
22064
  //#region src/lib/build-profile.ts
21709
22065
  const deriveIosDistribution = (eas) => {
@@ -21750,12 +22106,22 @@ const toIosProfile = (eas) => {
21750
22106
  if (!distribution) return;
21751
22107
  const ios = eas.ios ?? {};
21752
22108
  const autoIncrement = resolveIosAutoIncrement(eas);
22109
+ const buildConfiguration = ios.buildConfiguration ?? (eas.developmentClient === true ? "Debug" : void 0);
22110
+ const metaOverride = compact({
22111
+ bundleIdentifier: ios.bundleIdentifier,
22112
+ version: ios.version,
22113
+ buildNumber: ios.buildNumber
22114
+ });
21753
22115
  return compact({
21754
22116
  distribution,
21755
- buildConfiguration: ios.buildConfiguration ?? (eas.developmentClient === true ? "Debug" : void 0),
22117
+ buildConfiguration,
21756
22118
  scheme: ios.scheme,
21757
22119
  simulator: ios.simulator,
21758
- autoIncrement
22120
+ autoIncrement,
22121
+ workspace: ios.workspace,
22122
+ project: ios.project,
22123
+ podInstall: ios.podInstall,
22124
+ metaOverride: Object.keys(metaOverride).length === 0 ? void 0 : metaOverride
21759
22125
  });
21760
22126
  };
21761
22127
  const toAndroidProfile = (eas) => {
@@ -21765,16 +22131,25 @@ const toAndroidProfile = (eas) => {
21765
22131
  const android = eas.android ?? {};
21766
22132
  const distribution = deriveAndroidDistribution(eas, format);
21767
22133
  const autoIncrement = resolveAndroidAutoIncrement(eas);
22134
+ const buildType = android.buildType ?? (eas.developmentClient === true ? "debug" : void 0);
22135
+ const metaOverride = compact({
22136
+ applicationId: android.applicationId,
22137
+ version: android.version,
22138
+ versionCode: android.versionCode
22139
+ });
21768
22140
  return compact({
21769
22141
  format,
21770
22142
  distribution,
21771
- buildType: android.buildType ?? (eas.developmentClient === true ? "debug" : void 0),
22143
+ buildType,
21772
22144
  flavor: android.flavor,
21773
22145
  gradleCommand: android.gradleCommand,
21774
- autoIncrement
22146
+ autoIncrement,
22147
+ module: android.module,
22148
+ gradleTask: android.gradleTask,
22149
+ metaOverride: Object.keys(metaOverride).length === 0 ? void 0 : metaOverride
21775
22150
  });
21776
22151
  };
21777
- const fromEasProfile = (eas, profileName) => {
22152
+ const fromGenericProfile = (eas, profileName) => {
21778
22153
  const ios = toIosProfile(eas);
21779
22154
  const android = toAndroidProfile(eas);
21780
22155
  return compact({
@@ -21786,11 +22161,13 @@ const fromEasProfile = (eas, profileName) => {
21786
22161
  android,
21787
22162
  credentialsSource: eas.credentialsSource,
21788
22163
  developmentClient: eas.developmentClient,
21789
- withoutCredentials: eas.withoutCredentials
22164
+ withoutCredentials: eas.withoutCredentials,
22165
+ customCommand: eas.custom
21790
22166
  });
21791
22167
  };
22168
+ /** Resolve a build profile from `better-update.json`'s `build` section. */
21792
22169
  const readBuildProfile = (projectRoot, profileName) => Effect.gen(function* () {
21793
- return fromEasProfile(yield* resolveEasBuildProfile(yield* readEasJson(projectRoot), profileName), profileName);
22170
+ return fromGenericProfile(yield* resolveEasBuildProfile(yield* readBuildConfig(projectRoot), profileName, "better-update.json"), profileName);
21794
22171
  });
21795
22172
  const readRuntimeVersionMeta = (config, platform) => ({
21796
22173
  platform,
@@ -21800,6 +22177,17 @@ const readRuntimeVersionMeta = (config, platform) => ({
21800
22177
  rawRuntimeVersion: extractRawRuntimeVersion(config, platform)
21801
22178
  });
21802
22179
 
22180
+ //#endregion
22181
+ //#region src/lib/build-strategy.ts
22182
+ const resolveAndroidStrategy = (profile, projectType) => {
22183
+ if (profile.customCommand?.android !== void 0) return "custom";
22184
+ return projectType === "expo" ? "expo" : "gradle";
22185
+ };
22186
+ const resolveIosStrategy = (profile, projectType) => {
22187
+ if (profile.customCommand?.ios !== void 0) return "custom";
22188
+ return projectType === "expo" ? "expo" : "xcode";
22189
+ };
22190
+
21803
22191
  //#endregion
21804
22192
  //#region src/lib/clear-cache.ts
21805
22193
  /**
@@ -21827,6 +22215,64 @@ const clearBuildCaches = (projectRoot) => Effect.gen(function* () {
21827
22215
  if (removed.length > 0) yield* Console.error(`Cleared caches: ${removed.join(", ")}`);
21828
22216
  });
21829
22217
 
22218
+ //#endregion
22219
+ //#region src/lib/detect-project-type.ts
22220
+ const PROJECT_TYPES = [
22221
+ "expo",
22222
+ "bare",
22223
+ "kmp",
22224
+ "native",
22225
+ "custom"
22226
+ ];
22227
+ /** Narrow an arbitrary `projectType` override (e.g. from better-update.json) to a valid value. */
22228
+ const asProjectType = (raw) => PROJECT_TYPES.find((type) => type === raw);
22229
+ const exists = (filePath) => Effect.gen(function* () {
22230
+ return yield* (yield* FileSystem.FileSystem).exists(filePath).pipe(Effect.orElseSucceed(() => false));
22231
+ });
22232
+ const readText = (filePath) => Effect.gen(function* () {
22233
+ return yield* (yield* FileSystem.FileSystem).readFileString(filePath).pipe(Effect.orElseSucceed(() => ""));
22234
+ });
22235
+ const hasExpoDependency = (projectRoot) => Effect.gen(function* () {
22236
+ const text = yield* readText(path.join(projectRoot, "package.json"));
22237
+ if (text.length === 0) return false;
22238
+ const pkg = asRecord(yield* Effect.try(() => JSON.parse(text)).pipe(Effect.orElseSucceed(() => void 0)));
22239
+ const deps = asRecord(pkg?.["dependencies"]);
22240
+ const devDeps = asRecord(pkg?.["devDependencies"]);
22241
+ return deps?.["expo"] !== void 0 || devDeps?.["expo"] !== void 0;
22242
+ });
22243
+ const hasAnyExpoConfigFile = (projectRoot) => Effect.gen(function* () {
22244
+ for (const name of [
22245
+ "app.json",
22246
+ "app.config.js",
22247
+ "app.config.ts"
22248
+ ]) if (yield* exists(path.join(projectRoot, name))) return true;
22249
+ return false;
22250
+ });
22251
+ const looksKmp = (projectRoot) => Effect.gen(function* () {
22252
+ if (yield* exists(path.join(projectRoot, "composeApp"))) return true;
22253
+ for (const name of ["settings.gradle.kts", "settings.gradle"]) {
22254
+ const text = yield* readText(path.join(projectRoot, name));
22255
+ if (text.includes("composeApp") || text.includes(":shared")) return true;
22256
+ }
22257
+ return yield* exists(path.join(projectRoot, "android", "app", "build.gradle.kts"));
22258
+ });
22259
+ /**
22260
+ * Resolve a project's build-system family. An explicit override always wins;
22261
+ * otherwise the filesystem shape is inspected. `custom` is never auto-detected —
22262
+ * it is intent expressed via override or a profile `custom` block.
22263
+ */
22264
+ const detectProjectType = (params) => Effect.gen(function* () {
22265
+ if (params.override !== void 0) return params.override;
22266
+ const { projectRoot } = params;
22267
+ if (isExpoConfigInstalled() && ((yield* hasExpoDependency(projectRoot)) || (yield* hasAnyExpoConfigFile(projectRoot)))) return "expo";
22268
+ if (yield* looksKmp(projectRoot)) return "kmp";
22269
+ const hasAndroid = yield* exists(path.join(projectRoot, "android"));
22270
+ const hasIos = yield* exists(path.join(projectRoot, "ios"));
22271
+ const hasPackageJson = yield* exists(path.join(projectRoot, "package.json"));
22272
+ if (hasAndroid && hasIos && hasPackageJson) return "bare";
22273
+ return "native";
22274
+ });
22275
+
21830
22276
  //#endregion
21831
22277
  //#region src/lib/dev-client-check.ts
21832
22278
  const readDeps = (filePath) => Effect.gen(function* () {
@@ -22192,6 +22638,28 @@ const detectPlatform = (explicit, config) => Effect.gen(function* () {
22192
22638
  label: entry
22193
22639
  })));
22194
22640
  });
22641
+ /**
22642
+ * Resolve a build platform for non-Expo projects from an explicit flag, or by
22643
+ * intersecting the profile's declared platform sections with the native dirs
22644
+ * present on disk. Prompts when both remain; fails when ambiguous and prompts
22645
+ * are disallowed.
22646
+ */
22647
+ const detectPlatformGeneric = (explicit, context) => Effect.gen(function* () {
22648
+ if (explicit !== void 0) return explicit;
22649
+ const candidates = [];
22650
+ const wantsIos = context.profile.ios !== void 0 || context.profile.customCommand?.ios !== void 0;
22651
+ const wantsAndroid = context.profile.android !== void 0 || context.profile.customCommand?.android !== void 0;
22652
+ if (wantsIos && (context.hasIosDir || context.profile.customCommand?.ios !== void 0)) candidates.push("ios");
22653
+ if (wantsAndroid && (context.hasAndroidDir || context.profile.customCommand?.android !== void 0)) candidates.push("android");
22654
+ 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." });
22655
+ const [only] = candidates;
22656
+ if (candidates.length === 1 && only !== void 0) return only;
22657
+ if (!(yield* InteractiveMode).allow) return yield* new BuildProfileError({ message: `Multiple platforms available (${candidates.join(", ")}). Pass --platform explicitly when running non-interactively.` });
22658
+ return yield* promptSelect("Which platform to build?", candidates.map((entry) => ({
22659
+ value: entry,
22660
+ label: entry
22661
+ })));
22662
+ });
22195
22663
 
22196
22664
  //#endregion
22197
22665
  //#region src/lib/project-staging.ts
@@ -22204,24 +22672,30 @@ const LOCKFILES = [
22204
22672
  ["package-lock.json", "npm"]
22205
22673
  ];
22206
22674
  /**
22207
- * Paths never copied into staging covers generated native build outputs and
22208
- * dependency dirs that must be reinstalled fresh in staging.
22675
+ * Generated native build outputs / dependency dirs that must never be copied —
22676
+ * they are regenerated in staging (Pods via `pod install`, build/ via gradle).
22209
22677
  */
22210
- const ALWAYS_IGNORE = [
22211
- "node_modules",
22212
- ".git",
22678
+ const NATIVE_BUILD_OUTPUTS = [
22213
22679
  "ios/build",
22214
22680
  "ios/Pods",
22215
22681
  "ios/DerivedData",
22216
22682
  "android/build",
22217
22683
  "android/app/build",
22218
22684
  "android/.gradle",
22219
- "android/.kotlin",
22685
+ "android/.kotlin"
22686
+ ];
22687
+ /**
22688
+ * Paths never copied into staging — covers generated native build outputs and
22689
+ * dependency dirs that must be reinstalled fresh in staging.
22690
+ */
22691
+ const ALWAYS_IGNORE = [...[
22692
+ "node_modules",
22693
+ ".git",
22220
22694
  ".expo",
22221
22695
  ".gradle",
22222
22696
  ".turbo",
22223
22697
  "dist"
22224
- ];
22698
+ ], ...NATIVE_BUILD_OUTPUTS];
22225
22699
  const findLockfile = (fs, dir) => Effect.gen(function* () {
22226
22700
  for (const [name, pm] of LOCKFILES) if (yield* fs.exists(path.join(dir, name)).pipe(Effect.catchAll(() => Effect.succeed(false)))) return pm;
22227
22701
  });
@@ -22249,8 +22723,12 @@ const detectWorkspaceRoot = (cwd) => walkUpForLockfile(cwd, cwd);
22249
22723
  * Build an `Ignore` matcher for the workspace root. `.easignore` REPLACES
22250
22724
  * `.gitignore` when present (matches EAS semantics); otherwise `.gitignore`
22251
22725
  * is layered on top of the always-ignore baseline.
22726
+ *
22727
+ * When `includeNativeSource` is set, the native source dirs are re-included
22728
+ * after the ignore files are applied, then their build outputs re-excluded, so
22729
+ * a committed `ios/`/`android/` reaches staging intact.
22252
22730
  */
22253
- const buildIgnoreInstance = (workspaceRoot) => Effect.gen(function* () {
22731
+ const buildIgnoreInstance = (workspaceRoot, options = {}) => Effect.gen(function* () {
22254
22732
  const fs = yield* FileSystem.FileSystem;
22255
22733
  const ig = ignore();
22256
22734
  ig.add([...ALWAYS_IGNORE]);
@@ -22258,12 +22736,17 @@ const buildIgnoreInstance = (workspaceRoot) => Effect.gen(function* () {
22258
22736
  if (yield* fs.exists(easignorePath).pipe(Effect.catchAll(() => Effect.succeed(false)))) {
22259
22737
  const content = yield* fs.readFileString(easignorePath).pipe(Effect.catchAll(() => Effect.succeed("")));
22260
22738
  ig.add(content);
22261
- return ig;
22739
+ } else {
22740
+ const gitignorePath = path.join(workspaceRoot, ".gitignore");
22741
+ if (yield* fs.exists(gitignorePath).pipe(Effect.catchAll(() => Effect.succeed(false)))) {
22742
+ const content = yield* fs.readFileString(gitignorePath).pipe(Effect.catchAll(() => Effect.succeed("")));
22743
+ ig.add(content);
22744
+ }
22262
22745
  }
22263
- const gitignorePath = path.join(workspaceRoot, ".gitignore");
22264
- if (yield* fs.exists(gitignorePath).pipe(Effect.catchAll(() => Effect.succeed(false)))) {
22265
- const content = yield* fs.readFileString(gitignorePath).pipe(Effect.catchAll(() => Effect.succeed("")));
22266
- ig.add(content);
22746
+ if (options.includeNativeSource === true) {
22747
+ const base = options.appRelPath === void 0 || options.appRelPath === "" ? "" : `${options.appRelPath}/`;
22748
+ ig.add([`!${base}android`, `!${base}ios`]);
22749
+ ig.add(NATIVE_BUILD_OUTPUTS.map((entry) => `${base}${entry}`));
22267
22750
  }
22268
22751
  return ig;
22269
22752
  });
@@ -22311,6 +22794,7 @@ const runInstall = (params) => runStep({
22311
22794
  * regardless of what `expo prebuild`, `pod install`, or `gradlew` write.
22312
22795
  */
22313
22796
  const prepareStagingProject = (input) => Effect.gen(function* () {
22797
+ const fs = yield* FileSystem.FileSystem;
22314
22798
  const runtime = yield* CliRuntime;
22315
22799
  const { workspaceRoot, packageManager } = yield* detectWorkspaceRoot(input.userCwd);
22316
22800
  const relAppPath = path.relative(workspaceRoot, input.userCwd);
@@ -22320,14 +22804,18 @@ const prepareStagingProject = (input) => Effect.gen(function* () {
22320
22804
  yield* copyProjectTree({
22321
22805
  source: workspaceRoot,
22322
22806
  dest: stagingRoot,
22323
- ig: yield* buildIgnoreInstance(workspaceRoot)
22807
+ ig: yield* buildIgnoreInstance(workspaceRoot, {
22808
+ includeNativeSource: input.projectType !== void 0 && input.projectType !== "expo",
22809
+ appRelPath: relAppPath
22810
+ })
22324
22811
  });
22325
22812
  yield* initGitRepo(stagingRoot);
22326
- yield* runInstall({
22813
+ if (yield* fs.exists(path.join(workspaceRoot, "package.json")).pipe(Effect.catchAll(() => Effect.succeed(false)))) yield* runInstall({
22327
22814
  stagingRoot,
22328
22815
  packageManager,
22329
22816
  env: yield* runtime.commandEnvironment(input.envVars)
22330
22817
  });
22818
+ else yield* printHuman("No package.json at the staging root — skipping dependency install.");
22331
22819
  return {
22332
22820
  stagingRoot,
22333
22821
  projectRoot,
@@ -22927,7 +23415,7 @@ const runAndroidGooglePlayUpload = (inputs) => Effect.gen(function* () {
22927
23415
  });
22928
23416
 
22929
23417
  //#endregion
22930
- //#region src/application/build-workflow.ts
23418
+ //#region src/application/build-auto-submit.ts
22931
23419
  const buildAutoSubmitIosConfig = (iosProfile, whatToTest) => {
22932
23420
  if (iosProfile?.bundleIdentifier === void 0) return;
22933
23421
  return compact({
@@ -22953,9 +23441,10 @@ const buildAutoSubmitAndroidConfig = (androidProfile) => {
22953
23441
  rollout: androidProfile.rollout
22954
23442
  });
22955
23443
  };
23444
+ /** Submit a freshly-built artifact to the store using the profile's submit config. */
22956
23445
  const runAutoSubmit = (input) => Effect.gen(function* () {
22957
23446
  yield* printHuman(`\nAuto-submitting build ${input.buildId} (profile ${input.profileName})...`);
22958
- const easProfile = yield* resolveEasSubmitProfile((yield* readEasJson(process.cwd())).submit, input.profileName);
23447
+ const easProfile = yield* readSubmitProfile(yield* (yield* CliRuntime).cwd, input.profileName);
22959
23448
  const archiveUrl = (yield* input.api.builds.getInstallLink({ path: { id: input.buildId } })).artifactUrl;
22960
23449
  const iosConfig = input.platform === "ios" ? buildAutoSubmitIosConfig(easProfile.ios, input.whatToTest) : void 0;
22961
23450
  const androidConfig = input.platform === "android" ? buildAutoSubmitAndroidConfig(easProfile.android) : void 0;
@@ -22983,15 +23472,146 @@ const runAutoSubmit = (input) => Effect.gen(function* () {
22983
23472
  }
22984
23473
  yield* printHuman(`Submission final status: ${(yield* pollSubmissionUntilTerminal(input.api, submission.id)).status}`);
22985
23474
  });
23475
+
23476
+ //#endregion
23477
+ //#region src/lib/ios-native-meta.ts
23478
+ const APPLICATION_PRODUCT_TYPE = "com.apple.product-type.application";
23479
+ /**
23480
+ * A build setting that is an unresolved reference (`$(MARKETING_VERSION)`) or a
23481
+ * variable interpolation tells us nothing concrete — treat it as absent so the
23482
+ * profile `metaOverride` can supply a real value.
23483
+ */
23484
+ const concreteSetting = (raw) => {
23485
+ if (typeof raw === "number") return String(raw);
23486
+ if (typeof raw !== "string") return;
23487
+ const value = unquote$1(raw);
23488
+ return value.length === 0 || value.includes("$(") ? void 0 : value;
23489
+ };
23490
+ const findApplicationTarget = (project) => {
23491
+ const nativeTargets = project.pbxNativeTargetSection();
23492
+ for (const [uuid, entry] of Object.entries(nativeTargets)) {
23493
+ if (uuid.endsWith("_comment") || typeof entry === "string") continue;
23494
+ if (unquote$1(entry.productType) === APPLICATION_PRODUCT_TYPE) return entry;
23495
+ }
23496
+ };
23497
+ const configUuidForName = (project, target, configurationName) => {
23498
+ const configList = project.pbxXCConfigurationList()[target.buildConfigurationList];
23499
+ if (!configList || typeof configList === "string") return;
23500
+ const buildConfigSection = project.pbxXCBuildConfigurationSection();
23501
+ return configList.buildConfigurations.map((entry) => entry.value).find((uuid) => {
23502
+ const cfg = buildConfigSection[uuid];
23503
+ return cfg !== void 0 && typeof cfg !== "string" && unquote$1(cfg.name) === configurationName;
23504
+ });
23505
+ };
23506
+ /**
23507
+ * Read app metadata (bundle id, marketing version, build number) for the main
23508
+ * application target of the single `.xcodeproj` under `iosDir`, for a given build
23509
+ * configuration. Used for non-Expo (bare/native) projects where there is no
23510
+ * `app.json`. Missing or unresolved settings come back `undefined` so the caller
23511
+ * can fall back to profile `metaOverride`.
23512
+ */
23513
+ const readIosNativeMeta = (params) => Effect.gen(function* () {
23514
+ const projectDir = yield* findXcodeProjectDir(params.iosDir);
23515
+ const project = yield* parseProject(path.join(projectDir, "project.pbxproj"));
23516
+ const target = findApplicationTarget(project);
23517
+ if (target === void 0) return {};
23518
+ const configUuid = configUuidForName(project, target, params.configurationName);
23519
+ if (configUuid === void 0) return {};
23520
+ const cfg = project.pbxXCBuildConfigurationSection()[configUuid];
23521
+ if (cfg === void 0 || typeof cfg === "string") return {};
23522
+ const settings = cfg.buildSettings;
23523
+ return compact({
23524
+ bundleId: concreteSetting(settings["PRODUCT_BUNDLE_IDENTIFIER"]),
23525
+ marketingVersion: concreteSetting(settings["MARKETING_VERSION"]),
23526
+ currentProjectVersion: concreteSetting(settings["CURRENT_PROJECT_VERSION"])
23527
+ });
23528
+ });
23529
+
23530
+ //#endregion
23531
+ //#region src/application/resolve-app-meta.ts
23532
+ const EMPTY = {
23533
+ bundleId: void 0,
23534
+ androidPackage: void 0,
23535
+ appVersion: void 0,
23536
+ buildNumber: void 0,
23537
+ rawRuntimeVersion: void 0
23538
+ };
23539
+ 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;
23540
+ const resolveAndroidMeta = (projectRoot, profile) => Effect.gen(function* () {
23541
+ const gradle = yield* readGradleConfig(path.join(projectRoot, "android"));
23542
+ const override = profile.android?.metaOverride;
23543
+ yield* warnIfMismatch("android.applicationId", override?.applicationId, gradle?.applicationId);
23544
+ const androidPackage = override?.applicationId ?? gradle?.applicationId;
23545
+ 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." });
23546
+ const versionCode = override?.versionCode ?? (gradle?.versionCode === void 0 ? void 0 : String(gradle.versionCode));
23547
+ return {
23548
+ ...EMPTY,
23549
+ androidPackage,
23550
+ appVersion: override?.version ?? gradle?.versionName,
23551
+ buildNumber: versionCode
23552
+ };
23553
+ });
23554
+ const resolveIosMeta = (projectRoot, profile) => Effect.gen(function* () {
23555
+ const configurationName = profile.ios?.buildConfiguration ?? "Release";
23556
+ const native = yield* readIosNativeMeta({
23557
+ iosDir: path.join(projectRoot, "ios"),
23558
+ configurationName
23559
+ }).pipe(Effect.orElseSucceed(() => ({})));
23560
+ const override = profile.ios?.metaOverride;
23561
+ yield* warnIfMismatch("ios.bundleIdentifier", override?.bundleIdentifier, native.bundleId);
23562
+ const bundleId = override?.bundleIdentifier ?? native.bundleId;
23563
+ 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." });
23564
+ return {
23565
+ ...EMPTY,
23566
+ bundleId,
23567
+ appVersion: override?.version ?? native.marketingVersion,
23568
+ buildNumber: override?.buildNumber ?? native.currentProjectVersion
23569
+ };
23570
+ });
23571
+ const overlayExpoOverride = (meta, platform, profile) => {
23572
+ if (platform === "ios") {
23573
+ const override = profile.ios?.metaOverride;
23574
+ return {
23575
+ ...meta,
23576
+ bundleId: override?.bundleIdentifier ?? meta.bundleId,
23577
+ appVersion: override?.version ?? meta.appVersion,
23578
+ buildNumber: override?.buildNumber ?? meta.buildNumber
23579
+ };
23580
+ }
23581
+ const override = profile.android?.metaOverride;
23582
+ return {
23583
+ ...meta,
23584
+ androidPackage: override?.applicationId ?? meta.androidPackage,
23585
+ appVersion: override?.version ?? meta.appVersion,
23586
+ buildNumber: override?.versionCode ?? meta.buildNumber
23587
+ };
23588
+ };
23589
+ /**
23590
+ * Resolve app metadata (bundle id / package, version, build number) in a
23591
+ * project-type-aware way. Expo reads from app.json; bare/native read from native
23592
+ * files (build.gradle / pbxproj); KMP/custom rely on profile `metaOverride`,
23593
+ * which also overrides native values when both are present.
23594
+ */
23595
+ const resolveAppMeta = (params) => {
23596
+ if (params.projectType === "expo") {
23597
+ if (params.expoAppMeta === void 0) return Effect.fail(new BuildProfileError({ message: "Internal: missing Expo app metadata for expo project." }));
23598
+ return Effect.succeed(overlayExpoOverride(params.expoAppMeta, params.platform, params.profile));
23599
+ }
23600
+ return params.platform === "ios" ? resolveIosMeta(params.projectRoot, params.profile) : resolveAndroidMeta(params.projectRoot, params.profile);
23601
+ };
23602
+
23603
+ //#endregion
23604
+ //#region src/application/build-workflow.ts
22986
23605
  const runIosPlatformBuild = (input) => Effect.gen(function* () {
22987
23606
  const { api, appMeta, envVars, options, profile, projectId, projectRoot, tempDir } = input;
22988
23607
  if (!profile.ios) return yield* new BuildProfileError({ message: `Profile "${profile.name}" has no ios section.` });
22989
23608
  const iosProfile = profile.ios;
22990
23609
  const iosBundleId = appMeta.bundleId;
22991
- if (!iosBundleId) return yield* new BuildProfileError({ message: "Missing ios.bundleIdentifier in your Expo config." });
23610
+ if (!iosBundleId) return yield* new BuildProfileError({ message: "Missing iOS bundle identifier (set ios.bundleIdentifier or your Expo config)." });
23611
+ const strategy = resolveIosStrategy(profile, input.projectType);
22992
23612
  const isSimulator = iosProfile.simulator === true;
22993
23613
  const credentialsSource = profile.credentialsSource ?? "remote";
22994
- if (!isSimulator && credentialsSource === "remote") yield* ensureIosCredentials(api, {
23614
+ if (strategy !== "custom" && !isSimulator && credentialsSource === "remote") yield* ensureIosCredentials(api, {
22995
23615
  projectId,
22996
23616
  bundleIdentifier: iosBundleId,
22997
23617
  distribution: iosProfile.distribution
@@ -23006,8 +23626,10 @@ const runIosPlatformBuild = (input) => Effect.gen(function* () {
23006
23626
  envVars,
23007
23627
  projectId,
23008
23628
  credentialsSource,
23629
+ strategy,
23009
23630
  rawOutput: options.rawOutput,
23010
- freezeCredentials: options.freezeCredentials ?? false
23631
+ freezeCredentials: options.freezeCredentials ?? false,
23632
+ ...compact({ customCommand: profile.customCommand?.ios })
23011
23633
  }),
23012
23634
  target: isSimulator ? {
23013
23635
  platform: "ios",
@@ -23026,7 +23648,8 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
23026
23648
  if (!profile.android) return yield* new BuildProfileError({ message: `Profile "${profile.name}" has no android section.` });
23027
23649
  const androidProfile = profile.android;
23028
23650
  const androidBundleId = appMeta.androidPackage;
23029
- if (!androidBundleId) return yield* new BuildProfileError({ message: "Missing android.package in your Expo config." });
23651
+ if (!androidBundleId) return yield* new BuildProfileError({ message: "Missing Android applicationId (set android.applicationId or your Expo config)." });
23652
+ const strategy = resolveAndroidStrategy(profile, input.projectType);
23030
23653
  const gradleConfig = yield* readGradleConfig(`${projectRoot}/android`);
23031
23654
  yield* warnOnGradleMismatch(gradleConfig, androidBundleId);
23032
23655
  const applicationIdentifier = gradleConfig?.applicationId ?? androidBundleId;
@@ -23047,7 +23670,9 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
23047
23670
  projectId,
23048
23671
  credentialsSource,
23049
23672
  profileName: profile.name,
23050
- skipCredentials
23673
+ skipCredentials,
23674
+ strategy,
23675
+ ...compact({ customCommand: profile.customCommand?.android })
23051
23676
  }),
23052
23677
  target: androidProfile.format === "aab" ? {
23053
23678
  platform: "android",
@@ -23062,12 +23687,48 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
23062
23687
  };
23063
23688
  });
23064
23689
  const runPlatformBuild = (input) => input.platform === "ios" ? runIosPlatformBuild(input) : runAndroidPlatformBuild(input);
23690
+ const dirExists = (root, name) => Effect.gen(function* () {
23691
+ return yield* (yield* FileSystem.FileSystem).exists(path.join(root, name)).pipe(Effect.orElseSucceed(() => false));
23692
+ });
23693
+ /**
23694
+ * Expo metadata path: read app.json (with the env overlay so dynamic configs
23695
+ * resolve), apply autoIncrement to the user's tree, re-read, then derive the OTA
23696
+ * runtimeVersion. Mirrors the original managed flow.
23697
+ */
23698
+ const resolveExpoBuildMeta = (params) => Effect.gen(function* () {
23699
+ const { userCwd, platform, profile, envVars } = params;
23700
+ yield* applyAutoIncrement({
23701
+ projectRoot: userCwd,
23702
+ platform,
23703
+ config: yield* readExpoConfig(userCwd, envVars),
23704
+ ...platform === "ios" && profile.ios?.autoIncrement !== void 0 ? { iosMode: profile.ios.autoIncrement } : {},
23705
+ ...platform === "android" && profile.android?.autoIncrement !== void 0 ? { androidMode: profile.android.autoIncrement } : {}
23706
+ });
23707
+ const bumpedConfig = yield* readExpoConfig(userCwd, envVars);
23708
+ const appMeta = yield* resolveAppMeta({
23709
+ projectType: "expo",
23710
+ platform,
23711
+ projectRoot: userCwd,
23712
+ profile,
23713
+ expoAppMeta: yield* readAppMeta(bumpedConfig, platform)
23714
+ });
23715
+ return {
23716
+ appMeta,
23717
+ runtimeVersion: yield* resolveRuntimeVersion({
23718
+ raw: appMeta.rawRuntimeVersion,
23719
+ appVersion: appMeta.appVersion,
23720
+ projectRoot: userCwd,
23721
+ platform,
23722
+ buildNumber: appMeta.buildNumber,
23723
+ sdkVersion: bumpedConfig.sdkVersion
23724
+ })
23725
+ };
23726
+ });
23065
23727
  const resolveProfileName = (projectRoot, requested) => Effect.gen(function* () {
23066
- const easConfig = yield* readEasJson(projectRoot);
23067
- const available = Object.keys(easConfig.build ?? {});
23728
+ const available = yield* listBuildProfileNames(projectRoot);
23068
23729
  if (available.includes(requested)) return requested;
23069
23730
  if (!(yield* InteractiveMode).allow || available.length === 0) return requested;
23070
- yield* printHuman(`Build profile "${requested}" not found in eas.json.`);
23731
+ yield* printHuman(`Build profile "${requested}" not found in better-update.json.`);
23071
23732
  return yield* promptSelect("Pick a build profile:", available.map((name) => ({
23072
23733
  value: name,
23073
23734
  label: name
@@ -23081,11 +23742,19 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23081
23742
  allowDirty: options.allowDirty ?? false,
23082
23743
  label: "build"
23083
23744
  });
23084
- const baseConfig = yield* readExpoConfig(userCwd);
23085
- const projectId = yield* extractProjectId(baseConfig);
23086
- const platform = yield* detectPlatform(options.platform, baseConfig);
23745
+ const projectType = yield* detectProjectType({
23746
+ projectRoot: userCwd,
23747
+ override: asProjectType((yield* readBetterUpdateConfig(userCwd))?.["projectType"])
23748
+ });
23749
+ const isExpo = projectType === "expo";
23750
+ const projectId = yield* readProjectId;
23087
23751
  const profile = yield* readBuildProfile(userCwd, yield* resolveProfileName(userCwd, options.profileName));
23088
23752
  if (profile.developmentClient === true) yield* warnIfDevClientMissing(userCwd);
23753
+ const platform = isExpo ? yield* detectPlatform(options.platform, yield* readExpoConfig(userCwd)) : yield* detectPlatformGeneric(options.platform, {
23754
+ profile,
23755
+ hasAndroidDir: yield* dirExists(userCwd, "android"),
23756
+ hasIosDir: yield* dirExists(userCwd, "ios")
23757
+ });
23089
23758
  const envVars = {
23090
23759
  ...yield* pullEnvVars(api, {
23091
23760
  projectId,
@@ -23093,36 +23762,35 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23093
23762
  }),
23094
23763
  ...profile.env
23095
23764
  };
23096
- yield* applyAutoIncrement({
23097
- projectRoot: userCwd,
23098
- platform,
23099
- config: yield* readExpoConfig(userCwd, envVars),
23100
- ...platform === "ios" && profile.ios?.autoIncrement !== void 0 ? { iosMode: profile.ios.autoIncrement } : {},
23101
- ...platform === "android" && profile.android?.autoIncrement !== void 0 ? { androidMode: profile.android.autoIncrement } : {}
23102
- });
23103
- const bumpedConfig = yield* readExpoConfig(userCwd, envVars);
23104
- const appMeta = yield* readAppMeta(bumpedConfig, platform);
23105
- const runtimeVersion = yield* resolveRuntimeVersion({
23106
- raw: appMeta.rawRuntimeVersion,
23107
- appVersion: appMeta.appVersion,
23108
- projectRoot: userCwd,
23765
+ const { appMeta, runtimeVersion } = isExpo ? yield* resolveExpoBuildMeta({
23766
+ userCwd,
23109
23767
  platform,
23110
- buildNumber: appMeta.buildNumber,
23111
- sdkVersion: bumpedConfig.sdkVersion
23112
- });
23768
+ profile,
23769
+ envVars
23770
+ }) : {
23771
+ appMeta: yield* resolveAppMeta({
23772
+ projectType,
23773
+ platform,
23774
+ projectRoot: userCwd,
23775
+ profile
23776
+ }),
23777
+ runtimeVersion: void 0
23778
+ };
23113
23779
  if (options.clearCache) yield* clearBuildCaches(userCwd);
23114
23780
  const tempDir = yield* acquireBuildTempDir;
23115
23781
  const staging = yield* prepareStagingProject({
23116
23782
  userCwd,
23117
23783
  tempDir,
23118
- envVars
23784
+ envVars,
23785
+ projectType
23119
23786
  });
23120
- yield* printHuman(`Building ${platform} artifact for profile "${profile.name}" (runtimeVersion=${runtimeVersion})`);
23787
+ yield* printHuman(`Building ${platform} artifact for profile "${profile.name}"${runtimeVersion === void 0 ? "" : ` (runtimeVersion=${runtimeVersion})`}`);
23121
23788
  const { build, target, bundleId } = yield* runPlatformBuild({
23122
23789
  api,
23123
23790
  options,
23124
23791
  platform,
23125
23792
  profile,
23793
+ projectType,
23126
23794
  appMeta,
23127
23795
  envVars,
23128
23796
  projectId,
@@ -23156,18 +23824,18 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23156
23824
  commit: rawGitContext.commit,
23157
23825
  dirty: rawGitContext.dirty
23158
23826
  });
23159
- const fingerprintHash = yield* runFingerprintForPlatform(userCwd, platform).pipe(Effect.map((entry) => entry.hash), Effect.catchAll(() => Effect.succeed(void 0)));
23827
+ const fingerprintHash = isExpo ? yield* runFingerprintForPlatform(userCwd, platform).pipe(Effect.map((entry) => entry.hash), Effect.catchAll(() => Effect.succeed(void 0))) : void 0;
23160
23828
  const result = yield* reserveAndUpload(api, {
23161
23829
  target,
23162
23830
  projectId,
23163
23831
  profileName: profile.name,
23164
- runtimeVersion,
23165
23832
  bundleId,
23166
23833
  gitContext,
23167
23834
  artifactPath: build.artifactPath,
23168
23835
  sha256: build.sha256,
23169
23836
  byteSize: build.byteSize,
23170
23837
  ...compact({
23838
+ runtimeVersion,
23171
23839
  appVersion: appMeta.appVersion,
23172
23840
  buildNumber: appMeta.buildNumber,
23173
23841
  message: options.message,
@@ -23180,7 +23848,7 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23180
23848
  ["Status", result.status],
23181
23849
  ["Platform", platform],
23182
23850
  ["Profile", profile.name],
23183
- ["Runtime version", runtimeVersion],
23851
+ ...runtimeVersion === void 0 ? [] : [["Runtime version", runtimeVersion]],
23184
23852
  ["Artifact", build.artifactPath],
23185
23853
  ["SHA-256", build.sha256],
23186
23854
  ["Bytes", String(build.byteSize)]
@@ -23225,7 +23893,7 @@ const DEFAULT_PROFILES = [
23225
23893
  "preview",
23226
23894
  "production"
23227
23895
  ];
23228
- const writeEasJson$1 = (filePath, value) => Effect.gen(function* () {
23896
+ const writeEasJson = (filePath, value) => Effect.gen(function* () {
23229
23897
  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}` })));
23230
23898
  });
23231
23899
  const configureBuildCommand = defineCommand({
@@ -23243,7 +23911,7 @@ const configureBuildCommand = defineCommand({
23243
23911
  const easJsonPath = path.join(projectRoot, "eas.json");
23244
23912
  const fs = yield* FileSystem.FileSystem;
23245
23913
  if (!(yield* fs.exists(easJsonPath))) {
23246
- yield* writeEasJson$1(easJsonPath, DEFAULT_EAS_JSON);
23914
+ yield* writeEasJson(easJsonPath, DEFAULT_EAS_JSON);
23247
23915
  yield* printHuman(`Wrote eas.json with default profiles to ${easJsonPath}.`);
23248
23916
  yield* printHumanKeyValue([["Profiles", DEFAULT_PROFILES.join(", ")], ["Path", easJsonPath]]);
23249
23917
  return {
@@ -23260,7 +23928,7 @@ const configureBuildCommand = defineCommand({
23260
23928
  path: easJsonPath
23261
23929
  };
23262
23930
  }
23263
- yield* writeEasJson$1(easJsonPath, DEFAULT_EAS_JSON);
23931
+ yield* writeEasJson(easJsonPath, DEFAULT_EAS_JSON);
23264
23932
  yield* printHuman(`Overwrote eas.json with default profiles.`);
23265
23933
  return {
23266
23934
  action: "overwritten",
@@ -23288,7 +23956,7 @@ const configureBuildCommand = defineCommand({
23288
23956
  };
23289
23957
  }
23290
23958
  const additions = Object.fromEntries(missing.map((name) => [name, DEFAULT_EAS_JSON.build[name]]));
23291
- yield* writeEasJson$1(easJsonPath, {
23959
+ yield* writeEasJson(easJsonPath, {
23292
23960
  build: {
23293
23961
  ...config.build,
23294
23962
  ...additions
@@ -23888,7 +24556,7 @@ const tryReadApkPackageWith = (bin, apkPath) => Effect.gen(function* () {
23888
24556
  if (!(yield* which(bin).pipe(Effect.catchAll(() => Effect.succeed(null))))) return;
23889
24557
  const raw = yield* execCapture(`${bin} dump`, bin, "dump", "badging", apkPath).pipe(Effect.catchAll(() => Effect.succeed(null)));
23890
24558
  if (!raw) return;
23891
- return /package: name='([^']+)'/u.exec(raw)?.[1];
24559
+ return /package: name='(?<packageName>[^']+)'/u.exec(raw)?.[1];
23892
24560
  });
23893
24561
  const readApkPackageName = (apkPath) => Effect.gen(function* () {
23894
24562
  return yield* Effect.reduce(["aapt2", "aapt"], void 0, (acc, bin) => acc === void 0 ? tryReadApkPackageWith(bin, apkPath) : Effect.succeed(acc));
@@ -24101,7 +24769,7 @@ const runCommand$1 = defineCommand({
24101
24769
  //#region src/application/upload-workflow.ts
24102
24770
  const resolveIosTarget = (profile, appMeta) => Effect.gen(function* () {
24103
24771
  if (!profile.ios) return yield* new BuildProfileError({ message: `Profile "${profile.name}" has no ios section.` });
24104
- if (!appMeta.bundleId) return yield* new BuildProfileError({ message: "Missing ios.bundleIdentifier in your Expo config." });
24772
+ if (!appMeta.bundleId) return yield* new BuildProfileError({ message: "Missing iOS bundle identifier (set ios.bundleIdentifier or your Expo config)." });
24105
24773
  return {
24106
24774
  target: {
24107
24775
  platform: "ios",
@@ -24113,7 +24781,7 @@ const resolveIosTarget = (profile, appMeta) => Effect.gen(function* () {
24113
24781
  });
24114
24782
  const resolveAndroidTarget = (profile, appMeta, projectRoot) => Effect.gen(function* () {
24115
24783
  if (!profile.android) return yield* new BuildProfileError({ message: `Profile "${profile.name}" has no android section.` });
24116
- if (!appMeta.androidPackage) return yield* new BuildProfileError({ message: "Missing android.package in your Expo config." });
24784
+ if (!appMeta.androidPackage) return yield* new BuildProfileError({ message: "Missing Android applicationId (set android.applicationId or your Expo config)." });
24117
24785
  const gradleConfig = yield* readGradleConfig(`${projectRoot}/android`);
24118
24786
  yield* warnOnGradleMismatch(gradleConfig, appMeta.androidPackage);
24119
24787
  const bundleId = gradleConfig?.applicationId ?? appMeta.androidPackage;
@@ -24130,27 +24798,56 @@ const resolveAndroidTarget = (profile, appMeta, projectRoot) => Effect.gen(funct
24130
24798
  bundleId
24131
24799
  };
24132
24800
  });
24801
+ /** Resolve app metadata + OTA runtimeVersion for an upload (project-type aware). */
24802
+ const resolveUploadMeta = (params) => Effect.gen(function* () {
24803
+ const { projectType, platform, projectRoot, profile, envVars } = params;
24804
+ const expoConfig = projectType === "expo" ? yield* readExpoConfig(projectRoot, envVars) : void 0;
24805
+ const appMeta = yield* resolveAppMeta({
24806
+ projectType,
24807
+ platform,
24808
+ projectRoot,
24809
+ profile,
24810
+ ...compact({
24811
+ expoConfig,
24812
+ expoAppMeta: expoConfig === void 0 ? void 0 : yield* readAppMeta(expoConfig, platform)
24813
+ })
24814
+ });
24815
+ return {
24816
+ appMeta,
24817
+ runtimeVersion: expoConfig === void 0 ? void 0 : yield* resolveRuntimeVersion({
24818
+ raw: appMeta.rawRuntimeVersion,
24819
+ appVersion: appMeta.appVersion,
24820
+ projectRoot,
24821
+ platform,
24822
+ buildNumber: appMeta.buildNumber,
24823
+ sdkVersion: expoConfig.sdkVersion
24824
+ }),
24825
+ isExpo: expoConfig !== void 0
24826
+ };
24827
+ });
24133
24828
  const runUploadWorkflow = (options) => Effect.gen(function* () {
24134
24829
  const api = yield* apiClient;
24135
24830
  const projectRoot = yield* (yield* CliRuntime).cwd;
24136
24831
  if (!(yield* (yield* FileSystem.FileSystem).exists(options.artifactPath).pipe(Effect.orElseSucceed(() => false)))) yield* new ArtifactNotFoundError({ message: `Artifact not found at ${options.artifactPath}.` });
24137
- const projectId = yield* extractProjectId(yield* readExpoConfig(projectRoot));
24832
+ const projectType = yield* detectProjectType({
24833
+ projectRoot,
24834
+ override: asProjectType((yield* readBetterUpdateConfig(projectRoot))?.["projectType"])
24835
+ });
24836
+ const projectId = yield* readProjectId;
24138
24837
  const profile = yield* readBuildProfile(projectRoot, options.profileName);
24139
- const expoConfig = yield* readExpoConfig(projectRoot, {
24838
+ const envVars = {
24140
24839
  ...yield* pullEnvVars(api, {
24141
24840
  projectId,
24142
24841
  environment: profile.environment
24143
24842
  }),
24144
24843
  ...profile.env
24145
- });
24146
- const appMeta = yield* readAppMeta(expoConfig, options.platform);
24147
- const runtimeVersion = yield* resolveRuntimeVersion({
24148
- raw: appMeta.rawRuntimeVersion,
24149
- appVersion: appMeta.appVersion,
24150
- projectRoot,
24844
+ };
24845
+ const { appMeta, runtimeVersion, isExpo } = yield* resolveUploadMeta({
24846
+ projectType,
24151
24847
  platform: options.platform,
24152
- buildNumber: appMeta.buildNumber,
24153
- sdkVersion: expoConfig.sdkVersion
24848
+ projectRoot,
24849
+ profile,
24850
+ envVars
24154
24851
  });
24155
24852
  const { target, bundleId } = options.platform === "ios" ? yield* resolveIosTarget(profile, appMeta) : yield* resolveAndroidTarget(profile, appMeta, projectRoot);
24156
24853
  yield* printHuman(`Hashing ${options.artifactPath}...`);
@@ -24161,7 +24858,7 @@ const runUploadWorkflow = (options) => Effect.gen(function* () {
24161
24858
  commit: rawGitContext.commit,
24162
24859
  dirty: rawGitContext.dirty
24163
24860
  });
24164
- const fingerprintHash = yield* runFingerprintForPlatform(projectRoot, options.platform).pipe(Effect.map((entry) => entry.hash), Effect.catchAll(() => Effect.succeed(void 0)));
24861
+ const fingerprintHash = isExpo ? yield* runFingerprintForPlatform(projectRoot, options.platform).pipe(Effect.map((entry) => entry.hash), Effect.catchAll(() => Effect.succeed(void 0))) : void 0;
24165
24862
  const result = yield* reserveAndUpload(api, compact({
24166
24863
  target,
24167
24864
  projectId,
@@ -24183,7 +24880,7 @@ const runUploadWorkflow = (options) => Effect.gen(function* () {
24183
24880
  ["Status", result.status],
24184
24881
  ["Platform", options.platform],
24185
24882
  ["Profile", profile.name],
24186
- ["Runtime version", runtimeVersion],
24883
+ ...runtimeVersion === void 0 ? [] : [["Runtime version", runtimeVersion]],
24187
24884
  ["Artifact", options.artifactPath],
24188
24885
  ["SHA-256", sha256],
24189
24886
  ["Bytes", String(byteSize)]
@@ -24792,7 +25489,7 @@ const parseGoogleServiceAccountKey = (jsonText) => Effect.gen(function* () {
24792
25489
  const APPLE_TEAM_ID_RE = /^[A-Z0-9]{10}$/u;
24793
25490
  const extractTeamId = (params) => {
24794
25491
  if (params.orgUnit && APPLE_TEAM_ID_RE.test(params.orgUnit)) return params.orgUnit;
24795
- return /\(([A-Z0-9]{10})\)\s*$/u.exec(params.signingIdentity)?.[1];
25492
+ return /\((?<team>[A-Z0-9]{10})\)\s*$/u.exec(params.signingIdentity)?.[1];
24796
25493
  };
24797
25494
  /**
24798
25495
  * Parse a PKCS#12 (.p12) buffer and extract certificate metadata.
@@ -26102,13 +26799,11 @@ const currentRecipients = (api) => Effect.gen(function* () {
26102
26799
  //#endregion
26103
26800
  //#region src/commands/credentials/vault-session.ts
26104
26801
  /**
26105
- * Unlock the vault key, prompting for the device passphrase only when the active
26106
- * identity is the on-disk file the CI `BETTER_UPDATE_IDENTITY` env key is raw
26107
- * and needs none.
26802
+ * Unlock the vault key for an interactive command: reuse the OS-keychain-cached
26803
+ * key when live, prompt for the device passphrase only on a cache miss, and none
26804
+ * at all for the CI `BETTER_UPDATE_IDENTITY` env key.
26108
26805
  */
26109
- const unlockVaultInteractively = (api) => Effect.gen(function* () {
26110
- return yield* unlockVaultKey(api, yield* resolveVaultPassphrase);
26111
- });
26806
+ const unlockVaultInteractively = (api) => unlockVaultKeyInteractive(api);
26112
26807
  /** Resolve a recipient selector (key id or fingerprint) from a flag, prompting if absent. */
26113
26808
  const resolveSelector = (flag, message) => Effect.gen(function* () {
26114
26809
  if (flag && flag.trim().length > 0) return flag.trim();
@@ -27740,6 +28435,56 @@ const revokeCommand = defineCommand({
27740
28435
  subCommands: { "distribution-certificate": distributionCertificateCommand }
27741
28436
  });
27742
28437
 
28438
+ //#endregion
28439
+ //#region src/commands/credentials/session.ts
28440
+ /** Whole minutes left, rounded up so "<1 min remaining" still reads as 1. */
28441
+ const remainingMinutes = (remainingMs) => Math.max(1, Math.ceil(remainingMs / 6e4));
28442
+ const unlockCommand = defineCommand({
28443
+ meta: {
28444
+ name: "unlock",
28445
+ description: "Unlock the credential vault and cache the key in your OS keychain, so later commands don't re-prompt"
28446
+ },
28447
+ run: async () => runEffect(Effect.gen(function* () {
28448
+ const recipient = yield* activeRecipient;
28449
+ if (recipient.source !== "file") {
28450
+ yield* printHuman("Active identity is the BETTER_UPDATE_IDENTITY (CI) key — it has no passphrase and isn't cached.");
28451
+ return;
28452
+ }
28453
+ const api = yield* apiClient;
28454
+ const cache = yield* VaultCache;
28455
+ yield* cache.clear(recipient.publicKey);
28456
+ yield* unlockVaultKeyInteractive(api);
28457
+ const cached = yield* cache.get(recipient.publicKey);
28458
+ 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`}.`);
28459
+ }))
28460
+ });
28461
+ const lockCommand = defineCommand({
28462
+ meta: {
28463
+ name: "lock",
28464
+ description: "Forget the cached vault key — the next credential command will prompt again"
28465
+ },
28466
+ run: async () => runEffect(Effect.gen(function* () {
28467
+ const recipient = yield* activeRecipient;
28468
+ yield* (yield* VaultCache).clear(recipient.publicKey);
28469
+ yield* printHuman("Vault locked — the cached key was cleared from your OS keychain.");
28470
+ }))
28471
+ });
28472
+ const statusCommand$1 = defineCommand({
28473
+ meta: {
28474
+ name: "status",
28475
+ description: "Show whether the vault is currently unlocked (cached) and for how much longer"
28476
+ },
28477
+ run: async () => runEffect(Effect.gen(function* () {
28478
+ const recipient = yield* activeRecipient;
28479
+ if (recipient.source !== "file") {
28480
+ yield* printHuman("Active identity is the BETTER_UPDATE_IDENTITY (CI) key — caching not used.");
28481
+ return;
28482
+ }
28483
+ const cached = yield* (yield* VaultCache).get(recipient.publicKey);
28484
+ 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.`);
28485
+ }))
28486
+ });
28487
+
27743
28488
  //#endregion
27744
28489
  //#region src/commands/credentials/sync/helpers.ts
27745
28490
  const SYNC_EXIT_EXTRAS = {
@@ -28599,6 +29344,9 @@ const credentialsCommand = defineCommand({
28599
29344
  identity: identityCommand,
28600
29345
  access: accessCommand,
28601
29346
  device: deviceCommand,
29347
+ unlock: unlockCommand,
29348
+ lock: lockCommand,
29349
+ status: statusCommand$1,
28602
29350
  list: listCommand$3,
28603
29351
  view: viewCommand$1,
28604
29352
  download: downloadCommand,
@@ -28626,7 +29374,7 @@ const DEVICE_CLASS_VALUES = [
28626
29374
  const isDeviceClass = (value) => DEVICE_CLASS_VALUES.includes(value);
28627
29375
  const ttlHours = (value) => {
28628
29376
  if (value === void 0) return;
28629
- const match = /^([0-9]+)([hd])?$/u.exec(value);
29377
+ const match = /^(?<value>[0-9]+)(?<unit>[hd])?$/u.exec(value);
28630
29378
  if (!match?.[1]) return;
28631
29379
  const num = Number.parseInt(match[1], 10);
28632
29380
  return match[2] === "d" ? num * 24 : num;
@@ -29036,11 +29784,18 @@ const checkProjectLink = Effect.gen(function* () {
29036
29784
  const source = (yield* readLinkedProjectId(root)) === void 0 ? "Expo config" : "better-update.json";
29037
29785
  return pass("project-linked", "Project linked", `projectId=${resolved.right} (via ${source})`);
29038
29786
  });
29039
- const checkEasJson = Effect.gen(function* () {
29040
- const result = yield* readEasJson(yield* (yield* CliRuntime).cwd).pipe(Effect.either);
29041
- if (result._tag === "Left") return warn("eas-json", "eas.json", result.left.message);
29042
- const count = Object.keys(result.right.build ?? {}).length;
29043
- return pass("eas-json", "eas.json", `${count} profile(s) defined`);
29787
+ const checkProjectType = Effect.gen(function* () {
29788
+ const root = yield* (yield* CliRuntime).cwd;
29789
+ const override = asProjectType((yield* readBetterUpdateConfig(root))?.["projectType"]);
29790
+ return pass("project-type", "Project type", `${yield* detectProjectType({
29791
+ projectRoot: root,
29792
+ override
29793
+ })} (${override === void 0 ? "auto-detected" : "better-update.json override"})`);
29794
+ });
29795
+ const checkBuildConfig = Effect.gen(function* () {
29796
+ const names = yield* listBuildProfileNames(yield* (yield* CliRuntime).cwd);
29797
+ if (names.length === 0) return warn("build-config", "Build config", "No build profiles found. Add a \"build\" section to better-update.json.");
29798
+ return pass("build-config", "Build config", `${names.length} profile(s) defined`);
29044
29799
  });
29045
29800
  const runChecks = Effect.gen(function* () {
29046
29801
  const xcode = (yield* CliRuntime).platform === "darwin" ? [yield* checkCommand("xcode", "Xcode CLI tools", "xcode-select", ["-p"])] : [];
@@ -29051,7 +29806,8 @@ const runChecks = Effect.gen(function* () {
29051
29806
  yield* checkServerHealth,
29052
29807
  yield* checkAuth,
29053
29808
  yield* checkProjectLink,
29054
- yield* checkEasJson
29809
+ yield* checkProjectType,
29810
+ yield* checkBuildConfig
29055
29811
  ];
29056
29812
  });
29057
29813
  const statusIcon = (status) => {
@@ -29110,7 +29866,7 @@ const parseSingleEnvironmentArg = (raw) => Effect.gen(function* () {
29110
29866
  return raw;
29111
29867
  });
29112
29868
  const formatEnvironments = (environments) => [...environments].toSorted((left, right) => left.localeCompare(right)).join(",");
29113
- const DOTENV_LINE = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/u;
29869
+ const DOTENV_LINE = /^\s*(?:export\s+)?(?<key>[A-Z][A-Z0-9_]*)\s*=\s*(?<value>.*?)\s*$/u;
29114
29870
  const stripQuotes = (raw) => {
29115
29871
  if (raw.length < 2) return raw;
29116
29872
  const [first] = raw;
@@ -30219,49 +30975,40 @@ const logoutCommand = defineCommand({
30219
30975
 
30220
30976
  //#endregion
30221
30977
  //#region src/commands/migrate-config.ts
30222
- const readAppJson = (projectRoot) => {
30223
- const path$1 = path.join(projectRoot, "app.json");
30224
- if (!existsSync(path$1)) return null;
30225
- const raw = readFileSync(path$1, "utf8");
30226
- return JSON.parse(raw);
30227
- };
30228
- const writeAppJson = (projectRoot, content) => {
30229
- writeFileSync(path.join(projectRoot, "app.json"), `${JSON.stringify(content, null, 2)}\n`);
30230
- };
30231
- const writeEasJson = (projectRoot, profiles) => {
30232
- writeFileSync(path.join(projectRoot, "eas.json"), `${JSON.stringify({ build: profiles }, null, 2)}\n`);
30233
- };
30234
30978
  const migrateConfigCommand = defineCommand({
30235
30979
  meta: {
30236
30980
  name: "migrate-config",
30237
- description: "Migrate legacy `extra.betterUpdate.profiles` (in app.json) to a sibling `eas.json` file"
30981
+ description: "Migrate `build`/`submit` profiles from a legacy eas.json into better-update.json"
30238
30982
  },
30239
30983
  args: { yes: {
30240
30984
  type: "boolean",
30241
30985
  description: "Skip the confirmation prompt"
30242
30986
  } },
30243
30987
  run: async ({ args }) => runEffect(Effect.gen(function* () {
30244
- const root = yield* (yield* CliRuntime).cwd;
30245
- const appJson = readAppJson(root);
30246
- if (!appJson) return yield* new InvalidArgumentError({ message: `No app.json found at ${root}.` });
30247
- const profiles = appJson.expo?.extra?.betterUpdate?.profiles;
30248
- if (profiles === void 0) {
30249
- yield* printHuman("No legacy `extra.betterUpdate.profiles` found in app.json — nothing to migrate.");
30988
+ const runtime = yield* CliRuntime;
30989
+ const fs = yield* FileSystem.FileSystem;
30990
+ const root = yield* runtime.cwd;
30991
+ const easPath = path.join(root, "eas.json");
30992
+ if (!(yield* fs.exists(easPath).pipe(Effect.orElseSucceed(() => false)))) return yield* new InvalidArgumentError({ message: `No eas.json found at ${root}.` });
30993
+ const config = yield* readEasJson(root);
30994
+ const patch = compact({
30995
+ build: config.build,
30996
+ submit: config.submit,
30997
+ cli: config.cli
30998
+ });
30999
+ if (Object.keys(patch).length === 0) {
31000
+ yield* printHuman("eas.json has no build/submit/cli sections — nothing to migrate.");
30250
31001
  return;
30251
31002
  }
30252
- 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." });
30253
- if (!args.yes) {
30254
- if (!(yield* promptConfirm(`Move profiles to eas.json and strip from app.json?`, { initialValue: true }))) {
31003
+ const existingBuild = (yield* readBetterUpdateConfig(root))?.["build"];
31004
+ if (typeof existingBuild === "object" && existingBuild !== null && config.build !== void 0 && !args.yes) {
31005
+ if (!(yield* promptConfirm("better-update.json already has a build section — overwrite it from eas.json?", { initialValue: false }))) {
30255
31006
  yield* printHuman("Cancelled.");
30256
31007
  return;
30257
31008
  }
30258
31009
  }
30259
- writeEasJson(root, profiles);
30260
- const clone = structuredClone(appJson);
30261
- const betterUpdate = (clone.expo?.extra)?.betterUpdate;
30262
- if (betterUpdate) delete betterUpdate["profiles"];
30263
- writeAppJson(root, clone);
30264
- yield* printHuman("Migrated profiles into eas.json. Legacy field removed from app.json.");
31010
+ yield* writeBetterUpdateConfig(root, patch);
31011
+ yield* printHuman("Merged eas.json build/submit into better-update.json. You can now delete eas.json.");
30265
31012
  }))
30266
31013
  });
30267
31014
 
@@ -30706,7 +31453,7 @@ const submitCommand = defineCommand({
30706
31453
  }
30707
31454
  const projectId = yield* readProjectId;
30708
31455
  const api = yield* apiClient;
30709
- const easProfile = yield* resolveEasSubmitProfile((yield* readEasJson(process.cwd())).submit, args.profile);
31456
+ const easProfile = yield* readSubmitProfile(yield* (yield* CliRuntime).cwd, args.profile);
30710
31457
  const archive = yield* resolveArchive(api, projectId, platform, {
30711
31458
  id: args.id,
30712
31459
  path: args.path,