@better-update/cli 0.25.0 → 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
@@ -11,7 +11,7 @@ import AppleUtils from "@expo/apple-utils";
11
11
  import { autocomplete, cancel, confirm, isCancel, multiselect, password, select, text } from "@clack/prompts";
12
12
  import { open, readFile, writeFile } from "node:fs/promises";
13
13
  import { X509Certificate, createHash, createSign, createVerify, randomBytes, randomUUID } from "node:crypto";
14
- import { accessSync, chmodSync, constants, createReadStream, existsSync, promises, readFileSync, writeFileSync } from "node:fs";
14
+ import { accessSync, chmodSync, constants, createReadStream, promises } from "node:fs";
15
15
  import { Entry } from "@napi-rs/keyring";
16
16
  import { once } from "node:events";
17
17
  import { createServer } from "node:http";
@@ -34,7 +34,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
34
34
 
35
35
  //#endregion
36
36
  //#region package.json
37
- var version = "0.25.0";
37
+ var version = "0.26.1";
38
38
 
39
39
  //#endregion
40
40
  //#region src/lib/interactive-mode.ts
@@ -256,7 +256,7 @@ var AnalyticsGroup = class extends HttpApiGroup.make("analytics").add(HttpApiEnd
256
256
 
257
257
  //#endregion
258
258
  //#region ../../packages/api/src/domain/android-application-identifier.ts
259
- const AndroidPackageName = Schema.String.pipe(Schema.pattern(/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/u, { message: () => "Package name must be reverse-domain style (e.g., com.acme.app)" }));
259
+ const AndroidPackageName = Schema.String.pipe(Schema.pattern(/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/u, { message: () => "Package name must be reverse-domain style (e.g., com.acme.app)" }));
260
260
  var AndroidApplicationIdentifier = class extends Schema.Class("AndroidApplicationIdentifier")({
261
261
  id: Id,
262
262
  organizationId: Id,
@@ -4751,8 +4751,31 @@ const findIosArtifact = ({ exportPath }) => Effect.gen(function* () {
4751
4751
  if (!picked) return yield* new ArtifactNotFoundError({ message: `No .ipa file found under "${exportPath}".` });
4752
4752
  return picked.path;
4753
4753
  });
4754
- const findAndroidArtifact = ({ projectRoot, format, flavor, buildType, minMtimeMs }) => Effect.gen(function* () {
4755
- 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");
4756
4779
  const subdir = format === "aab" ? "bundle" : "apk";
4757
4780
  const variantDir = flavor ? `${flavor}${capitalize(buildType)}` : buildType;
4758
4781
  const pickedDirect = newest(yield* walkAndFind(path.join(outputsRoot, subdir, variantDir), `.${format}`), minMtimeMs);
@@ -18940,7 +18963,7 @@ const stringField = (cert, name) => {
18940
18963
  return typeof value === "string" ? value : null;
18941
18964
  };
18942
18965
  const matchTeamFromCommonName = (cn) => {
18943
- const match = /\(([A-Z0-9]{10})\)/u.exec(cn);
18966
+ const match = /\((?<team>[A-Z0-9]{10})\)/u.exec(cn);
18944
18967
  if (match === null) return null;
18945
18968
  const [, captured] = match;
18946
18969
  return captured === void 0 ? null : captured;
@@ -20025,56 +20048,44 @@ const gradleTaskName = (format, flavor, buildType) => {
20025
20048
  const verb = format === "aab" ? "bundle" : "assemble";
20026
20049
  return flavor ? `${verb}${capitalize(flavor)}${capitalize(buildType)}` : `${verb}${capitalize(buildType)}`;
20027
20050
  };
20028
- const runAndroidBuild = (input) => Effect.gen(function* () {
20029
- const { api, tempDir, projectRoot, androidProfile, applicationIdentifier, envVars, projectId } = input;
20030
- 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* () {
20031
20063
  const buildStartMs = Date.now();
20032
- const { format } = androidProfile;
20033
- const { flavor } = androidProfile;
20034
- const buildType = androidProfile.buildType ?? "release";
20035
- const androidDir = path.join(projectRoot, "android");
20036
- const commandEnv = yield* runtime.commandEnvironment(envVars);
20037
- yield* runStep({
20038
- command: "bunx",
20039
- args: [
20040
- "expo",
20041
- "prebuild",
20042
- "--platform",
20043
- "android",
20044
- "--clean"
20045
- ],
20046
- cwd: projectRoot,
20047
- env: commandEnv
20048
- }, "expo prebuild android");
20049
- const gradleArgs = yield* input.skipCredentials ? Effect.succeed([]) : Effect.gen(function* () {
20050
- const credentials = input.credentialsSource === "local" ? yield* loadLocalAndroidCredentials({ projectRoot }) : yield* downloadAndroidCredentials(api, {
20051
- projectId,
20052
- applicationIdentifier,
20053
- tempDir,
20054
- buildProfile: input.profileName
20055
- });
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* () {
20056
20070
  const fs = yield* FileSystem.FileSystem;
20057
- const signingGradlePath = path.join(tempDir, "signing.gradle");
20058
- yield* fs.writeFileString(signingGradlePath, renderSigningGradle({
20059
- keystorePath: credentials.keystorePath,
20060
- storePassword: credentials.storePassword,
20061
- keyAlias: credentials.keyAlias,
20062
- keyPassword: credentials.keyPassword
20063
- }));
20071
+ const signingGradlePath = path.join(input.tempDir, "signing.gradle");
20072
+ yield* fs.writeFileString(signingGradlePath, renderSigningGradle(credentials));
20064
20073
  return ["--init-script", signingGradlePath];
20065
20074
  });
20066
- const taskName = gradleTaskName(format, flavor, buildType);
20075
+ const taskName = input.androidProfile.gradleTask ?? gradleTaskName(format, flavor, buildType);
20076
+ const taskArg = taskName.startsWith(":") ? taskName : `:${moduleName}:${taskName}`;
20067
20077
  yield* runStep({
20068
20078
  command: "./gradlew",
20069
- args: [...gradleArgs, `:app:${taskName}`],
20079
+ args: [...gradleArgs, taskArg],
20070
20080
  cwd: androidDir,
20071
20081
  env: commandEnv
20072
20082
  }, "gradlew");
20073
20083
  const artifactPath = yield* findAndroidArtifact({
20074
- projectRoot,
20084
+ projectRoot: input.projectRoot,
20075
20085
  format,
20076
20086
  buildType,
20077
20087
  minMtimeMs: buildStartMs,
20088
+ module: moduleName,
20078
20089
  ...compact({ flavor })
20079
20090
  });
20080
20091
  const { sha256, byteSize } = yield* sha256File(artifactPath);
@@ -20084,6 +20095,71 @@ const runAndroidBuild = (input) => Effect.gen(function* () {
20084
20095
  sha256
20085
20096
  };
20086
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
+ });
20087
20163
 
20088
20164
  //#endregion
20089
20165
  //#region src/lib/credentials-generator-apple-id.ts
@@ -20798,7 +20874,7 @@ const listCurrentKeychains = Effect.gen(function* () {
20798
20874
  const parseSigningIdentity = (output) => {
20799
20875
  const lines = output.split("\n");
20800
20876
  for (const line of lines) {
20801
- const match = /"([^"]+)"/u.exec(line);
20877
+ const match = /"(?<identity>[^"]+)"/u.exec(line);
20802
20878
  if (match?.[1]) return match[1];
20803
20879
  }
20804
20880
  };
@@ -21101,36 +21177,85 @@ const createXcodebuildFormatter = (projectRoot) => {
21101
21177
  };
21102
21178
 
21103
21179
  //#endregion
21104
- //#region src/commands/build/ios.ts
21105
- const findXcworkspace = (iosDir) => Effect.gen(function* () {
21106
- const workspace = (yield* (yield* FileSystem.FileSystem).readDirectory(iosDir)).find((entry) => entry.endsWith(".xcworkspace"));
21107
- if (!workspace) return yield* new BuildFailedError({
21108
- step: "detect xcworkspace",
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",
21109
21219
  exitCode: 1,
21110
- 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.`
21111
21221
  });
21112
- return workspace;
21113
21222
  });
21114
- const prebuildAndPods = (params) => Effect.gen(function* () {
21115
- yield* runStep({
21116
- command: "bunx",
21117
- args: [
21118
- "expo",
21119
- "prebuild",
21120
- "--platform",
21121
- "ios",
21122
- "--clean"
21123
- ],
21124
- cwd: params.projectRoot,
21125
- env: params.commandEnv
21126
- }, "expo prebuild ios");
21127
- yield* runStep({
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({
21128
21252
  command: "pod",
21129
21253
  args: ["install"],
21130
21254
  cwd: params.iosDir,
21131
21255
  env: params.commandEnv
21132
21256
  }, "pod install");
21133
21257
  });
21258
+ /** Recursively locate the first `.app` bundle under `root` (simulator output). */
21134
21259
  const findAppDirectory = (root) => Effect.gen(function* () {
21135
21260
  const fs = yield* FileSystem.FileSystem;
21136
21261
  const stack = [root];
@@ -21150,25 +21275,30 @@ const findAppDirectory = (root) => Effect.gen(function* () {
21150
21275
  }
21151
21276
  return yield* new ArtifactNotFoundError({ message: `No .app bundle found under "${root}".` });
21152
21277
  });
21278
+
21279
+ //#endregion
21280
+ //#region src/commands/build/ios.ts
21153
21281
  const runIosSimulatorBuild = (input) => Effect.gen(function* () {
21154
21282
  const { projectRoot, iosProfile, envVars, tempDir } = input;
21155
21283
  const runtime = yield* CliRuntime;
21156
21284
  const iosDir = path.join(projectRoot, "ios");
21157
21285
  const commandEnv = yield* runtime.commandEnvironment(envVars);
21158
- yield* prebuildAndPods({
21286
+ yield* prepareIosNative({
21287
+ strategy: input.strategy,
21159
21288
  projectRoot,
21160
21289
  iosDir,
21290
+ iosProfile,
21161
21291
  commandEnv
21162
21292
  });
21163
- const workspaceFilename = yield* findXcworkspace(iosDir);
21164
- const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
21293
+ const container = yield* resolveXcodeContainer(projectRoot, iosDir, iosProfile);
21294
+ const scheme = iosProfile.scheme ?? container.schemeBase;
21165
21295
  const configuration = iosProfile.buildConfiguration ?? "Release";
21166
21296
  const derivedDataPath = path.join(tempDir, "derived-data");
21167
21297
  const buildCmd = {
21168
21298
  command: "xcodebuild",
21169
21299
  args: [
21170
- "-workspace",
21171
- workspaceFilename,
21300
+ container.flag,
21301
+ container.containerPath,
21172
21302
  "-scheme",
21173
21303
  scheme,
21174
21304
  "-configuration",
@@ -21256,13 +21386,15 @@ const runIosDeviceBuild = (input) => Effect.gen(function* () {
21256
21386
  const iosDir = path.join(projectRoot, "ios");
21257
21387
  const { distribution } = iosProfile;
21258
21388
  const commandEnv = yield* runtime.commandEnvironment(envVars);
21259
- yield* prebuildAndPods({
21389
+ yield* prepareIosNative({
21390
+ strategy: input.strategy,
21260
21391
  projectRoot,
21261
21392
  iosDir,
21393
+ iosProfile,
21262
21394
  commandEnv
21263
21395
  });
21264
- const workspaceFilename = yield* findXcworkspace(iosDir);
21265
- const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/u, "");
21396
+ const container = yield* resolveXcodeContainer(projectRoot, iosDir, iosProfile);
21397
+ const scheme = iosProfile.scheme ?? container.schemeBase;
21266
21398
  const configuration = iosProfile.buildConfiguration ?? "Release";
21267
21399
  const signedTargets = yield* discoverSignedTargets({
21268
21400
  iosDir,
@@ -21309,8 +21441,8 @@ const runIosDeviceBuild = (input) => Effect.gen(function* () {
21309
21441
  const archiveCmd = {
21310
21442
  command: "xcodebuild",
21311
21443
  args: [
21312
- "-workspace",
21313
- workspaceFilename,
21444
+ container.flag,
21445
+ container.containerPath,
21314
21446
  "-scheme",
21315
21447
  scheme,
21316
21448
  "-configuration",
@@ -21374,19 +21506,76 @@ const runIosDeviceBuild = (input) => Effect.gen(function* () {
21374
21506
  sha256
21375
21507
  };
21376
21508
  });
21377
- 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
+ };
21378
21567
 
21379
21568
  //#endregion
21380
21569
  //#region src/commands/build/reserve-and-upload.ts
21381
21570
  const buildReserveCommon = (input) => ({
21382
21571
  projectId: input.projectId,
21383
21572
  profile: input.profileName,
21384
- runtimeVersion: input.runtimeVersion,
21385
21573
  bundleId: input.bundleId,
21386
21574
  sha256: input.sha256,
21387
21575
  byteSize: input.byteSize,
21388
21576
  gitDirty: input.gitContext.dirty,
21389
21577
  ...compact({
21578
+ runtimeVersion: input.runtimeVersion,
21390
21579
  appVersion: input.appVersion,
21391
21580
  buildNumber: input.buildNumber,
21392
21581
  gitRef: input.gitContext.ref,
@@ -21462,7 +21651,7 @@ const bumpVersionCode = (current) => Effect.gen(function* () {
21462
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.` });
21463
21652
  return value + 1;
21464
21653
  });
21465
- const SEMVER_PATCH = /^(\d+)\.(\d+)\.(\d+)(.*)$/u;
21654
+ const SEMVER_PATCH = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?<suffix>.*)$/u;
21466
21655
  const bumpVersion = (current) => Effect.gen(function* () {
21467
21656
  if (current === void 0) return yield* new BuildProfileError({ message: "Cannot autoIncrement version: no `version` field set in Expo config." });
21468
21657
  const match = SEMVER_PATCH.exec(current);
@@ -21543,20 +21732,21 @@ const stripExtends = (profile) => {
21543
21732
  };
21544
21733
  const resolveExtendsChain = (params) => Effect.gen(function* () {
21545
21734
  const { profiles, profileName, label, maxDepth, makeError } = params;
21735
+ const sourceLabel = params.sourceLabel ?? "eas.json";
21546
21736
  const noun = label === "build" ? "Build" : "Submit";
21547
21737
  const chain = [];
21548
21738
  const visited = /* @__PURE__ */ new Set();
21549
21739
  let current = profileName;
21550
21740
  let depth = 0;
21551
21741
  while (current !== void 0) {
21552
- if (visited.has(current)) return yield* Effect.fail(makeError(`Cycle detected in eas.json ${label}.${profileName} extends chain at "${current}".`));
21742
+ if (visited.has(current)) return yield* Effect.fail(makeError(`Cycle detected in ${sourceLabel} ${label}.${profileName} extends chain at "${current}".`));
21553
21743
  visited.add(current);
21554
21744
  const profile = profiles[current];
21555
- if (!profile) return yield* Effect.fail(makeError(current === profileName ? `${noun} profile "${profileName}" not found in eas.json.` : `${noun} profile "${profileName}" extends missing profile "${current}".`));
21745
+ if (!profile) return yield* Effect.fail(makeError(current === profileName ? `${noun} profile "${profileName}" not found in ${sourceLabel}.` : `${noun} profile "${profileName}" extends missing profile "${current}".`));
21556
21746
  chain.unshift(profile);
21557
21747
  current = profile.extends;
21558
21748
  depth += 1;
21559
- 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}.`));
21560
21750
  }
21561
21751
  return chain;
21562
21752
  });
@@ -21623,13 +21813,14 @@ const mergeSubmitProfile = (base, overlay) => {
21623
21813
  android
21624
21814
  });
21625
21815
  };
21626
- const resolveEasSubmitProfile = (profiles, profileName) => Effect.gen(function* () {
21627
- if (!profiles) return yield* new BuildProfileError({ message: "eas.json has no \"submit\" section. Add at least one submit profile." });
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.` });
21628
21818
  return stripExtends((yield* resolveExtendsChain({
21629
21819
  profiles,
21630
21820
  profileName,
21631
21821
  label: "submit",
21632
21822
  maxDepth: MAX_SUBMIT_EXTENDS_DEPTH,
21823
+ sourceLabel,
21633
21824
  makeError: (message) => new BuildProfileError({ message })
21634
21825
  })).reduce((acc, next, index) => index === 0 ? next : mergeSubmitProfile(acc, next), {}));
21635
21826
  });
@@ -21696,7 +21887,13 @@ const parseIosProfile = (raw) => {
21696
21887
  scheme: asStringValue(record["scheme"]),
21697
21888
  simulator: asBooleanValue(record["simulator"]),
21698
21889
  enterpriseProvisioning: asEnterpriseProvisioning(record["enterpriseProvisioning"]),
21699
- 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"])
21700
21897
  });
21701
21898
  };
21702
21899
  const parseAndroidProfile = (raw) => {
@@ -21708,9 +21905,35 @@ const parseAndroidProfile = (raw) => {
21708
21905
  gradleCommand: asStringValue(record["gradleCommand"]),
21709
21906
  format: asAndroidFormat(record["format"]),
21710
21907
  distribution: asAndroidDistribution(record["distribution"]),
21711
- 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"])
21914
+ });
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"])
21712
21926
  });
21713
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
+ };
21714
21937
  const parseBuildProfile = (raw) => {
21715
21938
  const record = asRecord(raw);
21716
21939
  if (!record) return;
@@ -21725,15 +21948,16 @@ const parseBuildProfile = (raw) => {
21725
21948
  android: parseAndroidProfile(record["android"]),
21726
21949
  credentialsSource: asCredentialsSource(record["credentialsSource"]),
21727
21950
  autoIncrement: asAutoIncrement(record["autoIncrement"]),
21728
- withoutCredentials: asBooleanValue(record["withoutCredentials"])
21951
+ withoutCredentials: asBooleanValue(record["withoutCredentials"]),
21952
+ custom: parseCustomCommandProfile(record["custom"])
21729
21953
  });
21730
21954
  };
21731
- const parseEasConfig = (text) => Effect.gen(function* () {
21732
- const root = asRecord(yield* Effect.try({
21733
- try: () => JSON.parse(text),
21734
- catch: (cause) => new BuildProfileError({ message: `eas.json is not valid JSON: ${formatCause(cause)}` })
21735
- }));
21736
- if (!root) return yield* new BuildProfileError({ message: "eas.json must be a JSON object at the top level." });
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) => {
21737
21961
  const buildRecord = asRecord(root["build"]);
21738
21962
  if (!buildRecord) return asRecord(root["cli"]) ? { cli: parseCli(root["cli"]) } : {};
21739
21963
  const profiles = {};
@@ -21752,6 +21976,14 @@ const parseEasConfig = (text) => Effect.gen(function* () {
21752
21976
  build: profiles,
21753
21977
  ...Object.keys(submit).length === 0 ? {} : { submit }
21754
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);
21755
21987
  });
21756
21988
  const parseCli = (raw) => {
21757
21989
  const record = asRecord(raw);
@@ -21766,10 +21998,15 @@ const readEasJson = (projectRoot) => Effect.gen(function* () {
21766
21998
  const filePath = yield* easJsonPath(projectRoot);
21767
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}` })))));
21768
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
+ };
21769
22005
  const mergeProfile = (base, overlay) => {
21770
22006
  const ios = shallowMerge(base.ios, overlay.ios);
21771
22007
  const android = shallowMerge(base.android, overlay.android);
21772
22008
  const env = shallowMerge(base.env, overlay.env);
22009
+ const custom = mergeCustom(base.custom, overlay.custom);
21773
22010
  const developmentClient = overlay.developmentClient ?? base.developmentClient;
21774
22011
  const distribution = overlay.distribution ?? base.distribution;
21775
22012
  const channel = overlay.channel ?? base.channel;
@@ -21788,21 +22025,41 @@ const mergeProfile = (base, overlay) => {
21788
22025
  android,
21789
22026
  credentialsSource,
21790
22027
  autoIncrement,
21791
- withoutCredentials
22028
+ withoutCredentials,
22029
+ custom
21792
22030
  });
21793
22031
  };
21794
- const resolveEasBuildProfile = (config, profileName) => Effect.gen(function* () {
22032
+ const resolveEasBuildProfile = (config, profileName, sourceLabel = "eas.json") => Effect.gen(function* () {
21795
22033
  const profiles = config.build;
21796
- 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.` });
21797
22035
  return stripExtends((yield* resolveExtendsChain({
21798
22036
  profiles,
21799
22037
  profileName,
21800
22038
  label: "build",
21801
22039
  maxDepth: MAX_EXTENDS_DEPTH,
22040
+ sourceLabel,
21802
22041
  makeError: (message) => new BuildProfileError({ message })
21803
22042
  })).reduce((acc, next, index) => index === 0 ? next : mergeProfile(acc, next), {}));
21804
22043
  });
21805
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
+
21806
22063
  //#endregion
21807
22064
  //#region src/lib/build-profile.ts
21808
22065
  const deriveIosDistribution = (eas) => {
@@ -21849,12 +22106,22 @@ const toIosProfile = (eas) => {
21849
22106
  if (!distribution) return;
21850
22107
  const ios = eas.ios ?? {};
21851
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
+ });
21852
22115
  return compact({
21853
22116
  distribution,
21854
- buildConfiguration: ios.buildConfiguration ?? (eas.developmentClient === true ? "Debug" : void 0),
22117
+ buildConfiguration,
21855
22118
  scheme: ios.scheme,
21856
22119
  simulator: ios.simulator,
21857
- 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
21858
22125
  });
21859
22126
  };
21860
22127
  const toAndroidProfile = (eas) => {
@@ -21864,16 +22131,25 @@ const toAndroidProfile = (eas) => {
21864
22131
  const android = eas.android ?? {};
21865
22132
  const distribution = deriveAndroidDistribution(eas, format);
21866
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
+ });
21867
22140
  return compact({
21868
22141
  format,
21869
22142
  distribution,
21870
- buildType: android.buildType ?? (eas.developmentClient === true ? "debug" : void 0),
22143
+ buildType,
21871
22144
  flavor: android.flavor,
21872
22145
  gradleCommand: android.gradleCommand,
21873
- autoIncrement
22146
+ autoIncrement,
22147
+ module: android.module,
22148
+ gradleTask: android.gradleTask,
22149
+ metaOverride: Object.keys(metaOverride).length === 0 ? void 0 : metaOverride
21874
22150
  });
21875
22151
  };
21876
- const fromEasProfile = (eas, profileName) => {
22152
+ const fromGenericProfile = (eas, profileName) => {
21877
22153
  const ios = toIosProfile(eas);
21878
22154
  const android = toAndroidProfile(eas);
21879
22155
  return compact({
@@ -21885,11 +22161,13 @@ const fromEasProfile = (eas, profileName) => {
21885
22161
  android,
21886
22162
  credentialsSource: eas.credentialsSource,
21887
22163
  developmentClient: eas.developmentClient,
21888
- withoutCredentials: eas.withoutCredentials
22164
+ withoutCredentials: eas.withoutCredentials,
22165
+ customCommand: eas.custom
21889
22166
  });
21890
22167
  };
22168
+ /** Resolve a build profile from `better-update.json`'s `build` section. */
21891
22169
  const readBuildProfile = (projectRoot, profileName) => Effect.gen(function* () {
21892
- return fromEasProfile(yield* resolveEasBuildProfile(yield* readEasJson(projectRoot), profileName), profileName);
22170
+ return fromGenericProfile(yield* resolveEasBuildProfile(yield* readBuildConfig(projectRoot), profileName, "better-update.json"), profileName);
21893
22171
  });
21894
22172
  const readRuntimeVersionMeta = (config, platform) => ({
21895
22173
  platform,
@@ -21899,6 +22177,17 @@ const readRuntimeVersionMeta = (config, platform) => ({
21899
22177
  rawRuntimeVersion: extractRawRuntimeVersion(config, platform)
21900
22178
  });
21901
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
+
21902
22191
  //#endregion
21903
22192
  //#region src/lib/clear-cache.ts
21904
22193
  /**
@@ -21926,6 +22215,64 @@ const clearBuildCaches = (projectRoot) => Effect.gen(function* () {
21926
22215
  if (removed.length > 0) yield* Console.error(`Cleared caches: ${removed.join(", ")}`);
21927
22216
  });
21928
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
+
21929
22276
  //#endregion
21930
22277
  //#region src/lib/dev-client-check.ts
21931
22278
  const readDeps = (filePath) => Effect.gen(function* () {
@@ -22291,6 +22638,28 @@ const detectPlatform = (explicit, config) => Effect.gen(function* () {
22291
22638
  label: entry
22292
22639
  })));
22293
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
+ });
22294
22663
 
22295
22664
  //#endregion
22296
22665
  //#region src/lib/project-staging.ts
@@ -22303,24 +22672,30 @@ const LOCKFILES = [
22303
22672
  ["package-lock.json", "npm"]
22304
22673
  ];
22305
22674
  /**
22306
- * Paths never copied into staging covers generated native build outputs and
22307
- * 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).
22308
22677
  */
22309
- const ALWAYS_IGNORE = [
22310
- "node_modules",
22311
- ".git",
22678
+ const NATIVE_BUILD_OUTPUTS = [
22312
22679
  "ios/build",
22313
22680
  "ios/Pods",
22314
22681
  "ios/DerivedData",
22315
22682
  "android/build",
22316
22683
  "android/app/build",
22317
22684
  "android/.gradle",
22318
- "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",
22319
22694
  ".expo",
22320
22695
  ".gradle",
22321
22696
  ".turbo",
22322
22697
  "dist"
22323
- ];
22698
+ ], ...NATIVE_BUILD_OUTPUTS];
22324
22699
  const findLockfile = (fs, dir) => Effect.gen(function* () {
22325
22700
  for (const [name, pm] of LOCKFILES) if (yield* fs.exists(path.join(dir, name)).pipe(Effect.catchAll(() => Effect.succeed(false)))) return pm;
22326
22701
  });
@@ -22348,8 +22723,12 @@ const detectWorkspaceRoot = (cwd) => walkUpForLockfile(cwd, cwd);
22348
22723
  * Build an `Ignore` matcher for the workspace root. `.easignore` REPLACES
22349
22724
  * `.gitignore` when present (matches EAS semantics); otherwise `.gitignore`
22350
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.
22351
22730
  */
22352
- const buildIgnoreInstance = (workspaceRoot) => Effect.gen(function* () {
22731
+ const buildIgnoreInstance = (workspaceRoot, options = {}) => Effect.gen(function* () {
22353
22732
  const fs = yield* FileSystem.FileSystem;
22354
22733
  const ig = ignore();
22355
22734
  ig.add([...ALWAYS_IGNORE]);
@@ -22357,12 +22736,17 @@ const buildIgnoreInstance = (workspaceRoot) => Effect.gen(function* () {
22357
22736
  if (yield* fs.exists(easignorePath).pipe(Effect.catchAll(() => Effect.succeed(false)))) {
22358
22737
  const content = yield* fs.readFileString(easignorePath).pipe(Effect.catchAll(() => Effect.succeed("")));
22359
22738
  ig.add(content);
22360
- 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
+ }
22361
22745
  }
22362
- const gitignorePath = path.join(workspaceRoot, ".gitignore");
22363
- if (yield* fs.exists(gitignorePath).pipe(Effect.catchAll(() => Effect.succeed(false)))) {
22364
- const content = yield* fs.readFileString(gitignorePath).pipe(Effect.catchAll(() => Effect.succeed("")));
22365
- ig.add(content);
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}`));
22366
22750
  }
22367
22751
  return ig;
22368
22752
  });
@@ -22410,6 +22794,7 @@ const runInstall = (params) => runStep({
22410
22794
  * regardless of what `expo prebuild`, `pod install`, or `gradlew` write.
22411
22795
  */
22412
22796
  const prepareStagingProject = (input) => Effect.gen(function* () {
22797
+ const fs = yield* FileSystem.FileSystem;
22413
22798
  const runtime = yield* CliRuntime;
22414
22799
  const { workspaceRoot, packageManager } = yield* detectWorkspaceRoot(input.userCwd);
22415
22800
  const relAppPath = path.relative(workspaceRoot, input.userCwd);
@@ -22419,14 +22804,18 @@ const prepareStagingProject = (input) => Effect.gen(function* () {
22419
22804
  yield* copyProjectTree({
22420
22805
  source: workspaceRoot,
22421
22806
  dest: stagingRoot,
22422
- ig: yield* buildIgnoreInstance(workspaceRoot)
22807
+ ig: yield* buildIgnoreInstance(workspaceRoot, {
22808
+ includeNativeSource: input.projectType !== void 0 && input.projectType !== "expo",
22809
+ appRelPath: relAppPath
22810
+ })
22423
22811
  });
22424
22812
  yield* initGitRepo(stagingRoot);
22425
- yield* runInstall({
22813
+ if (yield* fs.exists(path.join(workspaceRoot, "package.json")).pipe(Effect.catchAll(() => Effect.succeed(false)))) yield* runInstall({
22426
22814
  stagingRoot,
22427
22815
  packageManager,
22428
22816
  env: yield* runtime.commandEnvironment(input.envVars)
22429
22817
  });
22818
+ else yield* printHuman("No package.json at the staging root — skipping dependency install.");
22430
22819
  return {
22431
22820
  stagingRoot,
22432
22821
  projectRoot,
@@ -23026,7 +23415,7 @@ const runAndroidGooglePlayUpload = (inputs) => Effect.gen(function* () {
23026
23415
  });
23027
23416
 
23028
23417
  //#endregion
23029
- //#region src/application/build-workflow.ts
23418
+ //#region src/application/build-auto-submit.ts
23030
23419
  const buildAutoSubmitIosConfig = (iosProfile, whatToTest) => {
23031
23420
  if (iosProfile?.bundleIdentifier === void 0) return;
23032
23421
  return compact({
@@ -23052,9 +23441,10 @@ const buildAutoSubmitAndroidConfig = (androidProfile) => {
23052
23441
  rollout: androidProfile.rollout
23053
23442
  });
23054
23443
  };
23444
+ /** Submit a freshly-built artifact to the store using the profile's submit config. */
23055
23445
  const runAutoSubmit = (input) => Effect.gen(function* () {
23056
23446
  yield* printHuman(`\nAuto-submitting build ${input.buildId} (profile ${input.profileName})...`);
23057
- const easProfile = yield* resolveEasSubmitProfile((yield* readEasJson(process.cwd())).submit, input.profileName);
23447
+ const easProfile = yield* readSubmitProfile(yield* (yield* CliRuntime).cwd, input.profileName);
23058
23448
  const archiveUrl = (yield* input.api.builds.getInstallLink({ path: { id: input.buildId } })).artifactUrl;
23059
23449
  const iosConfig = input.platform === "ios" ? buildAutoSubmitIosConfig(easProfile.ios, input.whatToTest) : void 0;
23060
23450
  const androidConfig = input.platform === "android" ? buildAutoSubmitAndroidConfig(easProfile.android) : void 0;
@@ -23082,15 +23472,146 @@ const runAutoSubmit = (input) => Effect.gen(function* () {
23082
23472
  }
23083
23473
  yield* printHuman(`Submission final status: ${(yield* pollSubmissionUntilTerminal(input.api, submission.id)).status}`);
23084
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
23085
23605
  const runIosPlatformBuild = (input) => Effect.gen(function* () {
23086
23606
  const { api, appMeta, envVars, options, profile, projectId, projectRoot, tempDir } = input;
23087
23607
  if (!profile.ios) return yield* new BuildProfileError({ message: `Profile "${profile.name}" has no ios section.` });
23088
23608
  const iosProfile = profile.ios;
23089
23609
  const iosBundleId = appMeta.bundleId;
23090
- 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);
23091
23612
  const isSimulator = iosProfile.simulator === true;
23092
23613
  const credentialsSource = profile.credentialsSource ?? "remote";
23093
- if (!isSimulator && credentialsSource === "remote") yield* ensureIosCredentials(api, {
23614
+ if (strategy !== "custom" && !isSimulator && credentialsSource === "remote") yield* ensureIosCredentials(api, {
23094
23615
  projectId,
23095
23616
  bundleIdentifier: iosBundleId,
23096
23617
  distribution: iosProfile.distribution
@@ -23105,8 +23626,10 @@ const runIosPlatformBuild = (input) => Effect.gen(function* () {
23105
23626
  envVars,
23106
23627
  projectId,
23107
23628
  credentialsSource,
23629
+ strategy,
23108
23630
  rawOutput: options.rawOutput,
23109
- freezeCredentials: options.freezeCredentials ?? false
23631
+ freezeCredentials: options.freezeCredentials ?? false,
23632
+ ...compact({ customCommand: profile.customCommand?.ios })
23110
23633
  }),
23111
23634
  target: isSimulator ? {
23112
23635
  platform: "ios",
@@ -23125,7 +23648,8 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
23125
23648
  if (!profile.android) return yield* new BuildProfileError({ message: `Profile "${profile.name}" has no android section.` });
23126
23649
  const androidProfile = profile.android;
23127
23650
  const androidBundleId = appMeta.androidPackage;
23128
- 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);
23129
23653
  const gradleConfig = yield* readGradleConfig(`${projectRoot}/android`);
23130
23654
  yield* warnOnGradleMismatch(gradleConfig, androidBundleId);
23131
23655
  const applicationIdentifier = gradleConfig?.applicationId ?? androidBundleId;
@@ -23146,7 +23670,9 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
23146
23670
  projectId,
23147
23671
  credentialsSource,
23148
23672
  profileName: profile.name,
23149
- skipCredentials
23673
+ skipCredentials,
23674
+ strategy,
23675
+ ...compact({ customCommand: profile.customCommand?.android })
23150
23676
  }),
23151
23677
  target: androidProfile.format === "aab" ? {
23152
23678
  platform: "android",
@@ -23161,12 +23687,48 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
23161
23687
  };
23162
23688
  });
23163
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
+ });
23164
23727
  const resolveProfileName = (projectRoot, requested) => Effect.gen(function* () {
23165
- const easConfig = yield* readEasJson(projectRoot);
23166
- const available = Object.keys(easConfig.build ?? {});
23728
+ const available = yield* listBuildProfileNames(projectRoot);
23167
23729
  if (available.includes(requested)) return requested;
23168
23730
  if (!(yield* InteractiveMode).allow || available.length === 0) return requested;
23169
- yield* printHuman(`Build profile "${requested}" not found in eas.json.`);
23731
+ yield* printHuman(`Build profile "${requested}" not found in better-update.json.`);
23170
23732
  return yield* promptSelect("Pick a build profile:", available.map((name) => ({
23171
23733
  value: name,
23172
23734
  label: name
@@ -23180,11 +23742,19 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23180
23742
  allowDirty: options.allowDirty ?? false,
23181
23743
  label: "build"
23182
23744
  });
23183
- const baseConfig = yield* readExpoConfig(userCwd);
23184
- const projectId = yield* extractProjectId(baseConfig);
23185
- 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;
23186
23751
  const profile = yield* readBuildProfile(userCwd, yield* resolveProfileName(userCwd, options.profileName));
23187
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
+ });
23188
23758
  const envVars = {
23189
23759
  ...yield* pullEnvVars(api, {
23190
23760
  projectId,
@@ -23192,36 +23762,35 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23192
23762
  }),
23193
23763
  ...profile.env
23194
23764
  };
23195
- yield* applyAutoIncrement({
23196
- projectRoot: userCwd,
23197
- platform,
23198
- config: yield* readExpoConfig(userCwd, envVars),
23199
- ...platform === "ios" && profile.ios?.autoIncrement !== void 0 ? { iosMode: profile.ios.autoIncrement } : {},
23200
- ...platform === "android" && profile.android?.autoIncrement !== void 0 ? { androidMode: profile.android.autoIncrement } : {}
23201
- });
23202
- const bumpedConfig = yield* readExpoConfig(userCwd, envVars);
23203
- const appMeta = yield* readAppMeta(bumpedConfig, platform);
23204
- const runtimeVersion = yield* resolveRuntimeVersion({
23205
- raw: appMeta.rawRuntimeVersion,
23206
- appVersion: appMeta.appVersion,
23207
- projectRoot: userCwd,
23765
+ const { appMeta, runtimeVersion } = isExpo ? yield* resolveExpoBuildMeta({
23766
+ userCwd,
23208
23767
  platform,
23209
- buildNumber: appMeta.buildNumber,
23210
- sdkVersion: bumpedConfig.sdkVersion
23211
- });
23768
+ profile,
23769
+ envVars
23770
+ }) : {
23771
+ appMeta: yield* resolveAppMeta({
23772
+ projectType,
23773
+ platform,
23774
+ projectRoot: userCwd,
23775
+ profile
23776
+ }),
23777
+ runtimeVersion: void 0
23778
+ };
23212
23779
  if (options.clearCache) yield* clearBuildCaches(userCwd);
23213
23780
  const tempDir = yield* acquireBuildTempDir;
23214
23781
  const staging = yield* prepareStagingProject({
23215
23782
  userCwd,
23216
23783
  tempDir,
23217
- envVars
23784
+ envVars,
23785
+ projectType
23218
23786
  });
23219
- 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})`}`);
23220
23788
  const { build, target, bundleId } = yield* runPlatformBuild({
23221
23789
  api,
23222
23790
  options,
23223
23791
  platform,
23224
23792
  profile,
23793
+ projectType,
23225
23794
  appMeta,
23226
23795
  envVars,
23227
23796
  projectId,
@@ -23255,18 +23824,18 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23255
23824
  commit: rawGitContext.commit,
23256
23825
  dirty: rawGitContext.dirty
23257
23826
  });
23258
- 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;
23259
23828
  const result = yield* reserveAndUpload(api, {
23260
23829
  target,
23261
23830
  projectId,
23262
23831
  profileName: profile.name,
23263
- runtimeVersion,
23264
23832
  bundleId,
23265
23833
  gitContext,
23266
23834
  artifactPath: build.artifactPath,
23267
23835
  sha256: build.sha256,
23268
23836
  byteSize: build.byteSize,
23269
23837
  ...compact({
23838
+ runtimeVersion,
23270
23839
  appVersion: appMeta.appVersion,
23271
23840
  buildNumber: appMeta.buildNumber,
23272
23841
  message: options.message,
@@ -23279,7 +23848,7 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
23279
23848
  ["Status", result.status],
23280
23849
  ["Platform", platform],
23281
23850
  ["Profile", profile.name],
23282
- ["Runtime version", runtimeVersion],
23851
+ ...runtimeVersion === void 0 ? [] : [["Runtime version", runtimeVersion]],
23283
23852
  ["Artifact", build.artifactPath],
23284
23853
  ["SHA-256", build.sha256],
23285
23854
  ["Bytes", String(build.byteSize)]
@@ -23324,7 +23893,7 @@ const DEFAULT_PROFILES = [
23324
23893
  "preview",
23325
23894
  "production"
23326
23895
  ];
23327
- const writeEasJson$1 = (filePath, value) => Effect.gen(function* () {
23896
+ const writeEasJson = (filePath, value) => Effect.gen(function* () {
23328
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}` })));
23329
23898
  });
23330
23899
  const configureBuildCommand = defineCommand({
@@ -23342,7 +23911,7 @@ const configureBuildCommand = defineCommand({
23342
23911
  const easJsonPath = path.join(projectRoot, "eas.json");
23343
23912
  const fs = yield* FileSystem.FileSystem;
23344
23913
  if (!(yield* fs.exists(easJsonPath))) {
23345
- yield* writeEasJson$1(easJsonPath, DEFAULT_EAS_JSON);
23914
+ yield* writeEasJson(easJsonPath, DEFAULT_EAS_JSON);
23346
23915
  yield* printHuman(`Wrote eas.json with default profiles to ${easJsonPath}.`);
23347
23916
  yield* printHumanKeyValue([["Profiles", DEFAULT_PROFILES.join(", ")], ["Path", easJsonPath]]);
23348
23917
  return {
@@ -23359,7 +23928,7 @@ const configureBuildCommand = defineCommand({
23359
23928
  path: easJsonPath
23360
23929
  };
23361
23930
  }
23362
- yield* writeEasJson$1(easJsonPath, DEFAULT_EAS_JSON);
23931
+ yield* writeEasJson(easJsonPath, DEFAULT_EAS_JSON);
23363
23932
  yield* printHuman(`Overwrote eas.json with default profiles.`);
23364
23933
  return {
23365
23934
  action: "overwritten",
@@ -23387,7 +23956,7 @@ const configureBuildCommand = defineCommand({
23387
23956
  };
23388
23957
  }
23389
23958
  const additions = Object.fromEntries(missing.map((name) => [name, DEFAULT_EAS_JSON.build[name]]));
23390
- yield* writeEasJson$1(easJsonPath, {
23959
+ yield* writeEasJson(easJsonPath, {
23391
23960
  build: {
23392
23961
  ...config.build,
23393
23962
  ...additions
@@ -23987,7 +24556,7 @@ const tryReadApkPackageWith = (bin, apkPath) => Effect.gen(function* () {
23987
24556
  if (!(yield* which(bin).pipe(Effect.catchAll(() => Effect.succeed(null))))) return;
23988
24557
  const raw = yield* execCapture(`${bin} dump`, bin, "dump", "badging", apkPath).pipe(Effect.catchAll(() => Effect.succeed(null)));
23989
24558
  if (!raw) return;
23990
- return /package: name='([^']+)'/u.exec(raw)?.[1];
24559
+ return /package: name='(?<packageName>[^']+)'/u.exec(raw)?.[1];
23991
24560
  });
23992
24561
  const readApkPackageName = (apkPath) => Effect.gen(function* () {
23993
24562
  return yield* Effect.reduce(["aapt2", "aapt"], void 0, (acc, bin) => acc === void 0 ? tryReadApkPackageWith(bin, apkPath) : Effect.succeed(acc));
@@ -24200,7 +24769,7 @@ const runCommand$1 = defineCommand({
24200
24769
  //#region src/application/upload-workflow.ts
24201
24770
  const resolveIosTarget = (profile, appMeta) => Effect.gen(function* () {
24202
24771
  if (!profile.ios) return yield* new BuildProfileError({ message: `Profile "${profile.name}" has no ios section.` });
24203
- if (!appMeta.bundleId) return yield* new BuildProfileError({ message: "Missing ios.bundleIdentifier in your Expo config." });
24772
+ if (!appMeta.bundleId) return yield* new BuildProfileError({ message: "Missing iOS bundle identifier (set ios.bundleIdentifier or your Expo config)." });
24204
24773
  return {
24205
24774
  target: {
24206
24775
  platform: "ios",
@@ -24212,7 +24781,7 @@ const resolveIosTarget = (profile, appMeta) => Effect.gen(function* () {
24212
24781
  });
24213
24782
  const resolveAndroidTarget = (profile, appMeta, projectRoot) => Effect.gen(function* () {
24214
24783
  if (!profile.android) return yield* new BuildProfileError({ message: `Profile "${profile.name}" has no android section.` });
24215
- if (!appMeta.androidPackage) return yield* new BuildProfileError({ message: "Missing android.package in your Expo config." });
24784
+ if (!appMeta.androidPackage) return yield* new BuildProfileError({ message: "Missing Android applicationId (set android.applicationId or your Expo config)." });
24216
24785
  const gradleConfig = yield* readGradleConfig(`${projectRoot}/android`);
24217
24786
  yield* warnOnGradleMismatch(gradleConfig, appMeta.androidPackage);
24218
24787
  const bundleId = gradleConfig?.applicationId ?? appMeta.androidPackage;
@@ -24229,27 +24798,56 @@ const resolveAndroidTarget = (profile, appMeta, projectRoot) => Effect.gen(funct
24229
24798
  bundleId
24230
24799
  };
24231
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
+ });
24232
24828
  const runUploadWorkflow = (options) => Effect.gen(function* () {
24233
24829
  const api = yield* apiClient;
24234
24830
  const projectRoot = yield* (yield* CliRuntime).cwd;
24235
24831
  if (!(yield* (yield* FileSystem.FileSystem).exists(options.artifactPath).pipe(Effect.orElseSucceed(() => false)))) yield* new ArtifactNotFoundError({ message: `Artifact not found at ${options.artifactPath}.` });
24236
- const projectId = yield* extractProjectId(yield* readExpoConfig(projectRoot));
24832
+ const projectType = yield* detectProjectType({
24833
+ projectRoot,
24834
+ override: asProjectType((yield* readBetterUpdateConfig(projectRoot))?.["projectType"])
24835
+ });
24836
+ const projectId = yield* readProjectId;
24237
24837
  const profile = yield* readBuildProfile(projectRoot, options.profileName);
24238
- const expoConfig = yield* readExpoConfig(projectRoot, {
24838
+ const envVars = {
24239
24839
  ...yield* pullEnvVars(api, {
24240
24840
  projectId,
24241
24841
  environment: profile.environment
24242
24842
  }),
24243
24843
  ...profile.env
24244
- });
24245
- const appMeta = yield* readAppMeta(expoConfig, options.platform);
24246
- const runtimeVersion = yield* resolveRuntimeVersion({
24247
- raw: appMeta.rawRuntimeVersion,
24248
- appVersion: appMeta.appVersion,
24249
- projectRoot,
24844
+ };
24845
+ const { appMeta, runtimeVersion, isExpo } = yield* resolveUploadMeta({
24846
+ projectType,
24250
24847
  platform: options.platform,
24251
- buildNumber: appMeta.buildNumber,
24252
- sdkVersion: expoConfig.sdkVersion
24848
+ projectRoot,
24849
+ profile,
24850
+ envVars
24253
24851
  });
24254
24852
  const { target, bundleId } = options.platform === "ios" ? yield* resolveIosTarget(profile, appMeta) : yield* resolveAndroidTarget(profile, appMeta, projectRoot);
24255
24853
  yield* printHuman(`Hashing ${options.artifactPath}...`);
@@ -24260,7 +24858,7 @@ const runUploadWorkflow = (options) => Effect.gen(function* () {
24260
24858
  commit: rawGitContext.commit,
24261
24859
  dirty: rawGitContext.dirty
24262
24860
  });
24263
- 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;
24264
24862
  const result = yield* reserveAndUpload(api, compact({
24265
24863
  target,
24266
24864
  projectId,
@@ -24282,7 +24880,7 @@ const runUploadWorkflow = (options) => Effect.gen(function* () {
24282
24880
  ["Status", result.status],
24283
24881
  ["Platform", options.platform],
24284
24882
  ["Profile", profile.name],
24285
- ["Runtime version", runtimeVersion],
24883
+ ...runtimeVersion === void 0 ? [] : [["Runtime version", runtimeVersion]],
24286
24884
  ["Artifact", options.artifactPath],
24287
24885
  ["SHA-256", sha256],
24288
24886
  ["Bytes", String(byteSize)]
@@ -24891,7 +25489,7 @@ const parseGoogleServiceAccountKey = (jsonText) => Effect.gen(function* () {
24891
25489
  const APPLE_TEAM_ID_RE = /^[A-Z0-9]{10}$/u;
24892
25490
  const extractTeamId = (params) => {
24893
25491
  if (params.orgUnit && APPLE_TEAM_ID_RE.test(params.orgUnit)) return params.orgUnit;
24894
- return /\(([A-Z0-9]{10})\)\s*$/u.exec(params.signingIdentity)?.[1];
25492
+ return /\((?<team>[A-Z0-9]{10})\)\s*$/u.exec(params.signingIdentity)?.[1];
24895
25493
  };
24896
25494
  /**
24897
25495
  * Parse a PKCS#12 (.p12) buffer and extract certificate metadata.
@@ -28776,7 +29374,7 @@ const DEVICE_CLASS_VALUES = [
28776
29374
  const isDeviceClass = (value) => DEVICE_CLASS_VALUES.includes(value);
28777
29375
  const ttlHours = (value) => {
28778
29376
  if (value === void 0) return;
28779
- const match = /^([0-9]+)([hd])?$/u.exec(value);
29377
+ const match = /^(?<value>[0-9]+)(?<unit>[hd])?$/u.exec(value);
28780
29378
  if (!match?.[1]) return;
28781
29379
  const num = Number.parseInt(match[1], 10);
28782
29380
  return match[2] === "d" ? num * 24 : num;
@@ -29186,11 +29784,18 @@ const checkProjectLink = Effect.gen(function* () {
29186
29784
  const source = (yield* readLinkedProjectId(root)) === void 0 ? "Expo config" : "better-update.json";
29187
29785
  return pass("project-linked", "Project linked", `projectId=${resolved.right} (via ${source})`);
29188
29786
  });
29189
- const checkEasJson = Effect.gen(function* () {
29190
- const result = yield* readEasJson(yield* (yield* CliRuntime).cwd).pipe(Effect.either);
29191
- if (result._tag === "Left") return warn("eas-json", "eas.json", result.left.message);
29192
- const count = Object.keys(result.right.build ?? {}).length;
29193
- return pass("eas-json", "eas.json", `${count} profile(s) defined`);
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`);
29194
29799
  });
29195
29800
  const runChecks = Effect.gen(function* () {
29196
29801
  const xcode = (yield* CliRuntime).platform === "darwin" ? [yield* checkCommand("xcode", "Xcode CLI tools", "xcode-select", ["-p"])] : [];
@@ -29201,7 +29806,8 @@ const runChecks = Effect.gen(function* () {
29201
29806
  yield* checkServerHealth,
29202
29807
  yield* checkAuth,
29203
29808
  yield* checkProjectLink,
29204
- yield* checkEasJson
29809
+ yield* checkProjectType,
29810
+ yield* checkBuildConfig
29205
29811
  ];
29206
29812
  });
29207
29813
  const statusIcon = (status) => {
@@ -29260,7 +29866,7 @@ const parseSingleEnvironmentArg = (raw) => Effect.gen(function* () {
29260
29866
  return raw;
29261
29867
  });
29262
29868
  const formatEnvironments = (environments) => [...environments].toSorted((left, right) => left.localeCompare(right)).join(",");
29263
- const DOTENV_LINE = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/u;
29869
+ const DOTENV_LINE = /^\s*(?:export\s+)?(?<key>[A-Z][A-Z0-9_]*)\s*=\s*(?<value>.*?)\s*$/u;
29264
29870
  const stripQuotes = (raw) => {
29265
29871
  if (raw.length < 2) return raw;
29266
29872
  const [first] = raw;
@@ -30369,49 +30975,40 @@ const logoutCommand = defineCommand({
30369
30975
 
30370
30976
  //#endregion
30371
30977
  //#region src/commands/migrate-config.ts
30372
- const readAppJson = (projectRoot) => {
30373
- const path$1 = path.join(projectRoot, "app.json");
30374
- if (!existsSync(path$1)) return null;
30375
- const raw = readFileSync(path$1, "utf8");
30376
- return JSON.parse(raw);
30377
- };
30378
- const writeAppJson = (projectRoot, content) => {
30379
- writeFileSync(path.join(projectRoot, "app.json"), `${JSON.stringify(content, null, 2)}\n`);
30380
- };
30381
- const writeEasJson = (projectRoot, profiles) => {
30382
- writeFileSync(path.join(projectRoot, "eas.json"), `${JSON.stringify({ build: profiles }, null, 2)}\n`);
30383
- };
30384
30978
  const migrateConfigCommand = defineCommand({
30385
30979
  meta: {
30386
30980
  name: "migrate-config",
30387
- 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"
30388
30982
  },
30389
30983
  args: { yes: {
30390
30984
  type: "boolean",
30391
30985
  description: "Skip the confirmation prompt"
30392
30986
  } },
30393
30987
  run: async ({ args }) => runEffect(Effect.gen(function* () {
30394
- const root = yield* (yield* CliRuntime).cwd;
30395
- const appJson = readAppJson(root);
30396
- if (!appJson) return yield* new InvalidArgumentError({ message: `No app.json found at ${root}.` });
30397
- const profiles = appJson.expo?.extra?.betterUpdate?.profiles;
30398
- if (profiles === void 0) {
30399
- yield* printHuman("No legacy `extra.betterUpdate.profiles` found in app.json — nothing to migrate.");
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.");
30400
31001
  return;
30401
31002
  }
30402
- if (existsSync(path.join(root, "eas.json"))) return yield* new InvalidArgumentError({ message: "eas.json already exists. Manual review required — refusing to overwrite. Remove eas.json first if you want to regenerate." });
30403
- if (!args.yes) {
30404
- if (!(yield* promptConfirm(`Move profiles to eas.json and strip from app.json?`, { initialValue: true }))) {
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 }))) {
30405
31006
  yield* printHuman("Cancelled.");
30406
31007
  return;
30407
31008
  }
30408
31009
  }
30409
- writeEasJson(root, profiles);
30410
- const clone = structuredClone(appJson);
30411
- const betterUpdate = (clone.expo?.extra)?.betterUpdate;
30412
- if (betterUpdate) delete betterUpdate["profiles"];
30413
- writeAppJson(root, clone);
30414
- yield* printHuman("Migrated profiles into eas.json. Legacy field removed from app.json.");
31010
+ yield* writeBetterUpdateConfig(root, patch);
31011
+ yield* printHuman("Merged eas.json build/submit into better-update.json. You can now delete eas.json.");
30415
31012
  }))
30416
31013
  });
30417
31014
 
@@ -30856,7 +31453,7 @@ const submitCommand = defineCommand({
30856
31453
  }
30857
31454
  const projectId = yield* readProjectId;
30858
31455
  const api = yield* apiClient;
30859
- const easProfile = yield* resolveEasSubmitProfile((yield* readEasJson(process.cwd())).submit, args.profile);
31456
+ const easProfile = yield* readSubmitProfile(yield* (yield* CliRuntime).cwd, args.profile);
30860
31457
  const archive = yield* resolveArchive(api, projectId, platform, {
30861
31458
  id: args.id,
30862
31459
  path: args.path,