@better-update/cli 0.47.5 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -35,7 +35,7 @@ var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
35
35
 
36
36
  //#endregion
37
37
  //#region package.json
38
- var version = "0.47.5";
38
+ var version = "0.48.0";
39
39
 
40
40
  //#endregion
41
41
  //#region src/lib/interactive-mode.ts
@@ -22111,6 +22111,109 @@ const renderSigningGradle = ({ keystorePath, storePassword, keyAlias, keyPasswor
22111
22111
  }
22112
22112
  `;
22113
22113
 
22114
+ //#endregion
22115
+ //#region src/lib/dotenv-file.ts
22116
+ /** Escape a string so it can be embedded as a literal inside a RegExp. */
22117
+ const escapeRegExp = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
22118
+ /**
22119
+ * Set or append `KEY=value` in a `.env` file body, mirroring the line-based
22120
+ * reader used by react-native-config (`dotenv.gradle` on Android,
22121
+ * `BuildDotenvConfig.rb` on iOS): values are written raw and single-line, an
22122
+ * existing key is replaced in place, a new key is appended at the end. Returns
22123
+ * the updated body.
22124
+ */
22125
+ const setEnvVar = (content, key, value) => {
22126
+ const re = new RegExp(String.raw`^${escapeRegExp(key)}=.*$`, "mu");
22127
+ return re.test(content) ? content.replace(re, `${key}=${value}`) : `${content.replace(/\n*$/u, "")}\n${key}=${value}\n`;
22128
+ };
22129
+ /** Apply a batch of `KEY=value` upserts in order (later entries win on a dup key). */
22130
+ const setEnvVars = (content, entries) => entries.reduce((acc, [key, value]) => setEnvVar(acc, key, value), content);
22131
+
22132
+ //#endregion
22133
+ //#region src/lib/android-version-sync.ts
22134
+ const CODE_LITERAL = /\bversionCode\s+(?<code>\d+)/u;
22135
+ const NAME_LITERAL = /\bversionName\s+(?<quote>["'])(?<name>[^"'\n]*)\k<quote>/u;
22136
+ const CODE_ENV = /\bversionCode\b[^\n]*?\.env\.get\(\s*["'](?<key>[^"']+)["']\s*\)/u;
22137
+ const NAME_ENV = /\bversionName\b[^\n]*?\.env\.get\(\s*["'](?<key>[^"']+)["']\s*\)/u;
22138
+ /**
22139
+ * Materialize one version field: patch a `build.gradle` literal in place, or
22140
+ * collect the referenced `.env` key when the value is `project.env.get(...)`.
22141
+ */
22142
+ const planField = (gradle, literal, env, value, rewriteLiteral) => {
22143
+ if (literal.test(gradle)) return {
22144
+ gradle: gradle.replace(literal, rewriteLiteral),
22145
+ matched: true
22146
+ };
22147
+ const key = env.exec(gradle)?.groups?.["key"];
22148
+ if (key !== void 0) return {
22149
+ gradle,
22150
+ envKey: [key, value],
22151
+ matched: true
22152
+ };
22153
+ return {
22154
+ gradle,
22155
+ matched: false
22156
+ };
22157
+ };
22158
+ /** Plan both version fields against the build.gradle content. */
22159
+ const planVersions = (gradle, params) => {
22160
+ const envKeys = [];
22161
+ const unresolved = [];
22162
+ let next = gradle;
22163
+ const apply = (value, literal, env, kind) => {
22164
+ if (value === void 0) return;
22165
+ const outcome = planField(next, literal, env, value, kind === "versionCode" ? () => `versionCode ${value}` : (match) => match.replace(NAME_LITERAL, (_full, quote) => `versionName ${quote}${value}${quote}`));
22166
+ next = outcome.gradle;
22167
+ if (outcome.envKey) envKeys.push(outcome.envKey);
22168
+ else if (!outcome.matched) unresolved.push(kind);
22169
+ };
22170
+ apply(params.versionCode, CODE_LITERAL, CODE_ENV, "versionCode");
22171
+ apply(params.versionName, NAME_LITERAL, NAME_ENV, "versionName");
22172
+ return {
22173
+ gradle: next,
22174
+ envKeys,
22175
+ unresolved
22176
+ };
22177
+ };
22178
+ const writeEnvKeys = (envPath, envKeys) => Effect.gen(function* () {
22179
+ const fs = yield* FileSystem.FileSystem;
22180
+ const current = yield* fs.readFileString(envPath).pipe(Effect.orElseSucceed(() => ""));
22181
+ const next = envKeys.reduce((acc, [key, value]) => setEnvVar(acc, key, value), current);
22182
+ yield* fs.writeFileString(envPath, next).pipe(Effect.orElseSucceed(() => void 0));
22183
+ });
22184
+ const reportResult = (params, patchedGradle, envKeys) => {
22185
+ return printHuman(`Applied eas.json version (${[...params.versionName === void 0 ? [] : [`versionName=${params.versionName}`], ...params.versionCode === void 0 ? [] : [`versionCode=${params.versionCode}`]].join(", ")}) to ${[...patchedGradle ? ["build.gradle"] : [], ...envKeys.length === 0 ? [] : [`.env (${envKeys.map(([key]) => key).join(", ")})`]].join(" + ")}`);
22186
+ };
22187
+ /**
22188
+ * Materialize an eas.json Android version / versionCode override into the staged
22189
+ * project so the built APK/AAB matches eas.json. Two shapes are supported:
22190
+ *
22191
+ * - Literal `versionCode` / `versionName` in `android/app/build.gradle` → the
22192
+ * literal is patched in place.
22193
+ * - react-native-config `project.env.get("KEY")` → the referenced key is written
22194
+ * into the staged `.env`, which the dotenv.gradle reader consumes at build time.
22195
+ *
22196
+ * Best-effort and non-fatal: a project whose version is wired some other way is
22197
+ * left untouched with a warning, never failing the build. Expo and custom builds
22198
+ * never reach here (the caller gates on non-Expo, native-strategy builds).
22199
+ */
22200
+ const applyAndroidVersion = (params) => Effect.gen(function* () {
22201
+ if (params.versionName === void 0 && params.versionCode === void 0) return;
22202
+ const fs = yield* FileSystem.FileSystem;
22203
+ const gradlePath = path.join(params.projectRoot, "android", "app", "build.gradle");
22204
+ const original = yield* fs.readFileString(gradlePath).pipe(Effect.orElseSucceed(() => void 0));
22205
+ if (original === void 0) {
22206
+ yield* printWarn(`Could not read ${gradlePath} to apply the eas.json version override — leaving native version as-is.`);
22207
+ return;
22208
+ }
22209
+ const plan = planVersions(original, params);
22210
+ const patchedGradle = plan.gradle !== original;
22211
+ if (patchedGradle) yield* fs.writeFileString(gradlePath, plan.gradle).pipe(Effect.orElseSucceed(() => void 0));
22212
+ if (plan.envKeys.length > 0) yield* writeEnvKeys(path.join(params.projectRoot, ".env"), plan.envKeys);
22213
+ if (patchedGradle || plan.envKeys.length > 0) yield* reportResult(params, patchedGradle, plan.envKeys);
22214
+ if (plan.unresolved.length > 0) yield* printWarn(`Could not locate ${plan.unresolved.join(" / ")} in android/app/build.gradle (no literal or react-native-config \`project.env.get(...)\` form found) — the built artifact may not reflect the eas.json version.`);
22215
+ });
22216
+
22114
22217
  //#endregion
22115
22218
  //#region src/lib/string-utils.ts
22116
22219
  const capitalize = (value) => {
@@ -23211,6 +23314,13 @@ const runGradleBuild = (input, commandEnv) => Effect.gen(function* () {
23211
23314
  const moduleName = input.androidProfile.module ?? "app";
23212
23315
  const androidDir = path.join(input.projectRoot, "android");
23213
23316
  yield* fixGradlew(androidDir, input.strategy !== "expo");
23317
+ if (input.nativeVersion !== void 0) yield* applyAndroidVersion({
23318
+ projectRoot: input.projectRoot,
23319
+ ...compact({
23320
+ versionName: input.nativeVersion.versionName,
23321
+ versionCode: input.nativeVersion.versionCode
23322
+ })
23323
+ });
23214
23324
  const credentials = yield* resolveAndroidCredentials(input);
23215
23325
  const gradleArgs = credentials === void 0 ? [] : yield* Effect.gen(function* () {
23216
23326
  const fs = yield* FileSystem.FileSystem;
@@ -23949,8 +24059,15 @@ const parseProject$1 = (pbxprojPath) => Effect.try({
23949
24059
  * spaces, brackets or non-identifier characters needs explicit quoting.
23950
24060
  */
23951
24061
  const quote = (value) => `"${value.replaceAll("\"", String.raw`\"`)}"`;
24062
+ /**
24063
+ * Version values (e.g. `6.0.4`, `17`) are normally emitted unquoted in pbxproj.
24064
+ * Keep them bare when they are a safe token to minimize diff noise versus the
24065
+ * committed project; fall back to quoting only for unusual values.
24066
+ */
24067
+ const SAFE_PBX_TOKEN = /^[A-Za-z0-9._-]+$/u;
24068
+ const pbxValue = (value) => SAFE_PBX_TOKEN.test(value) ? value : quote(value);
23952
24069
  const SDK_CONDITIONAL_IDENTITY_KEYS = ["\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\"", "CODE_SIGN_IDENTITY[sdk=iphoneos*]"];
23953
- const mutateConfig = (project, configUuid, settings) => {
24070
+ const mutateConfig = (project, configUuid, settings, versions) => {
23954
24071
  const cfg = project.pbxXCBuildConfigurationSection()[configUuid];
23955
24072
  if (!cfg || typeof cfg === "string") return false;
23956
24073
  cfg.buildSettings["CODE_SIGN_STYLE"] = "Manual";
@@ -23959,12 +24076,16 @@ const mutateConfig = (project, configUuid, settings) => {
23959
24076
  cfg.buildSettings["PROVISIONING_PROFILE_SPECIFIER"] = quote(settings.profileSpecifier);
23960
24077
  delete cfg.buildSettings["PROVISIONING_PROFILE"];
23961
24078
  for (const key of SDK_CONDITIONAL_IDENTITY_KEYS) delete cfg.buildSettings[key];
24079
+ if (versions?.marketingVersion !== void 0) cfg.buildSettings["MARKETING_VERSION"] = pbxValue(versions.marketingVersion);
24080
+ if (versions?.currentProjectVersion !== void 0) cfg.buildSettings["CURRENT_PROJECT_VERSION"] = pbxValue(versions.currentProjectVersion);
23962
24081
  return true;
23963
24082
  };
23964
24083
  /**
23965
24084
  * Write `CODE_SIGN_STYLE=Manual`, `DEVELOPMENT_TEAM`, `CODE_SIGN_IDENTITY`, and
23966
24085
  * `PROVISIONING_PROFILE_SPECIFIER` into the specified XCBuildConfiguration
23967
- * entries of the project under `iosDir`, then serialize back to disk.
24086
+ * entries of the project under `iosDir`, then serialize back to disk. When an
24087
+ * entry carries `versions`, `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` are
24088
+ * written into the same configuration(s) in the one pass.
23968
24089
  *
23969
24090
  * Only mutates the main app project — `Pods.xcodeproj` is left untouched. The
23970
24091
  * caller is responsible for ensuring each entry's `buildConfigurationUuids`
@@ -23976,7 +24097,7 @@ const applyTargetSigning = (options) => Effect.gen(function* () {
23976
24097
  const projectDir = yield* findXcodeProjectDir$1(options.iosDir);
23977
24098
  const pbxprojPath = path.join(projectDir, "project.pbxproj");
23978
24099
  const project = yield* parseProject$1(pbxprojPath);
23979
- for (const entry of options.entries) for (const configUuid of entry.buildConfigurationUuids) if (!mutateConfig(project, configUuid, entry.settings)) return yield* new XcodeProjectError({ message: `Build configuration ${configUuid} not found for target "${entry.targetName}" in ${pbxprojPath}.` });
24100
+ for (const entry of options.entries) for (const configUuid of entry.buildConfigurationUuids) if (!mutateConfig(project, configUuid, entry.settings, entry.versions)) return yield* new XcodeProjectError({ message: `Build configuration ${configUuid} not found for target "${entry.targetName}" in ${pbxprojPath}.` });
23980
24101
  const serialized = yield* Effect.try({
23981
24102
  try: () => project.writeSync(),
23982
24103
  catch: (cause) => new XcodeProjectError({ message: `Failed to serialize ${pbxprojPath}: ${cause instanceof Error ? cause.message : String(cause)}` })
@@ -24139,6 +24260,30 @@ const installProvisioningProfile = ({ profilePath }) => Effect.acquireRelease(Ef
24139
24260
  installedPath
24140
24261
  })));
24141
24262
 
24263
+ //#endregion
24264
+ //#region src/lib/ios-signing-entries.ts
24265
+ /**
24266
+ * Render the pbxproj signing entries for every installed target. When
24267
+ * `nativeVersion` is provided it is attached to ALL signed targets (app +
24268
+ * extensions): App Store validation rejects a bundled extension whose
24269
+ * CFBundleVersion / CFBundleShortVersionString differs from the host app, so the
24270
+ * version must move on every target together (matches `expo prebuild` and the
24271
+ * prior per-repo sync-version workaround).
24272
+ *
24273
+ * Pure and dependency-free so the build pipeline and its tests share one source
24274
+ * of truth for the entry shape without pulling in the credentials stack.
24275
+ */
24276
+ const buildSigningEntries = (params) => params.installedTargets.map(({ target, installed }) => ({
24277
+ targetName: target.targetName,
24278
+ buildConfigurationUuids: target.buildConfigurationUuids,
24279
+ settings: {
24280
+ teamId: installed.teamId,
24281
+ signingIdentity: params.signingIdentity,
24282
+ profileSpecifier: installed.name
24283
+ },
24284
+ ...params.nativeVersion ? { versions: params.nativeVersion } : {}
24285
+ }));
24286
+
24142
24287
  //#endregion
24143
24288
  //#region src/lib/post-build-validation.ts
24144
24289
  const validateOneBundle = (bundleDir, expectedByBundleId, expectedTeamId) => Effect.gen(function* () {
@@ -24601,15 +24746,11 @@ const runIosDeviceBuild = (input) => Effect.gen(function* () {
24601
24746
  const installedTargets = yield* installPerTarget(signedTargets, credentials, input.credentialsSource);
24602
24747
  yield* applyTargetSigning({
24603
24748
  iosDir,
24604
- entries: installedTargets.map(({ target, installed }) => ({
24605
- targetName: target.targetName,
24606
- buildConfigurationUuids: target.buildConfigurationUuids,
24607
- settings: {
24608
- teamId: installed.teamId,
24609
- signingIdentity: keychain.signingIdentity,
24610
- profileSpecifier: installed.name
24611
- }
24612
- }))
24749
+ entries: buildSigningEntries({
24750
+ installedTargets,
24751
+ signingIdentity: keychain.signingIdentity,
24752
+ nativeVersion: input.nativeVersion
24753
+ })
24613
24754
  });
24614
24755
  const archivePath = path.join(tempDir, "build.xcarchive");
24615
24756
  const archiveCmd = {
@@ -24748,6 +24889,59 @@ const resolveIosStrategy = (profile, projectType) => {
24748
24889
  return projectType === "expo" ? "expo" : "xcode";
24749
24890
  };
24750
24891
 
24892
+ //#endregion
24893
+ //#region src/lib/env-materialize.ts
24894
+ /**
24895
+ * Does the staged project consume a root `.env` at native-build time via
24896
+ * react-native-config? Only those projects need env materialization:
24897
+ *
24898
+ * - Expo reads `process.env` / app.config and regenerates native at prebuild.
24899
+ * - A bare project WITHOUT react-native-config reads its config another way
24900
+ * (process.env injection / hardcoded), so writing a stray `.env` there could
24901
+ * shadow unrelated config — leave it untouched.
24902
+ *
24903
+ * react-native-config is always a direct dependency when used, so package.json is
24904
+ * the reliable signal. Mirrors `hasExpoDependency` in detect-project-type.ts; a
24905
+ * missing/unparseable package.json is treated as "no".
24906
+ */
24907
+ const usesReactNativeConfig = (projectRoot) => Effect.gen(function* () {
24908
+ const text = yield* (yield* FileSystem.FileSystem).readFileString(path.join(projectRoot, "package.json")).pipe(Effect.orElseSucceed(() => ""));
24909
+ if (text.length === 0) return false;
24910
+ const pkg = asRecord(yield* Effect.try(() => JSON.parse(text)).pipe(Effect.orElseSucceed(() => void 0)));
24911
+ const deps = asRecord(pkg?.["dependencies"]);
24912
+ const devDeps = asRecord(pkg?.["devDependencies"]);
24913
+ return deps?.["react-native-config"] !== void 0 || devDeps?.["react-native-config"] !== void 0;
24914
+ });
24915
+ /**
24916
+ * Materialize the decrypted environment into the staged project's `.env` so a
24917
+ * bare react-native-config build reads the SAME values the Expo path gets via
24918
+ * `process.env`. react-native-config reads the `.env` FILE at build time (not
24919
+ * `process.env`), so without this a bare project would ship with whatever `.env`
24920
+ * was committed (or none) regardless of the server's environment.
24921
+ *
24922
+ * Supports both bare shapes:
24923
+ * - WITH react-native-config → values are merged into `.env` (server wins on a
24924
+ * key collision; any committed local-only keys are preserved).
24925
+ * - WITHOUT react-native-config → no-op; the build still gets the env via the
24926
+ * existing process.env injection, so nothing is lost and no stray file appears.
24927
+ *
24928
+ * Best-effort and non-fatal. Expo never reaches here (the caller gates on
24929
+ * non-Expo). An empty env set is a clean no-op, so this is safe to ship before
24930
+ * the server's env-vault is populated.
24931
+ */
24932
+ const materializeEnvFile = (params) => Effect.gen(function* () {
24933
+ const entries = Object.entries(params.envVars);
24934
+ if (entries.length === 0) return;
24935
+ if (!(yield* usesReactNativeConfig(params.projectRoot))) return;
24936
+ const fs = yield* FileSystem.FileSystem;
24937
+ const envPath = path.join(params.projectRoot, ".env");
24938
+ const current = yield* fs.readFileString(envPath).pipe(Effect.orElseSucceed(() => ""));
24939
+ const next = setEnvVars(current, entries);
24940
+ if (next === current) return;
24941
+ yield* fs.writeFileString(envPath, next).pipe(Effect.orElseSucceed(() => void 0));
24942
+ yield* printHuman(`Materialized ${String(entries.length)} environment variable(s) into .env for react-native-config.`);
24943
+ });
24944
+
24751
24945
  //#endregion
24752
24946
  //#region src/lib/gradle-config.ts
24753
24947
  const isValidAndroidPackageName = Schema.is(AndroidPackageName);
@@ -24835,6 +25029,11 @@ const runIosPlatformBuild = (input) => Effect.gen(function* () {
24835
25029
  bundleIdentifier: iosBundleId,
24836
25030
  distribution: iosProfile.distribution
24837
25031
  }, { freezeCredentials: input.freezeCredentials });
25032
+ const iosOverride = profile.ios.metaOverride;
25033
+ const nativeVersion = input.projectType !== "expo" && (iosOverride?.version !== void 0 || iosOverride?.buildNumber !== void 0) ? compact({
25034
+ marketingVersion: appMeta.appVersion,
25035
+ currentProjectVersion: appMeta.buildNumber
25036
+ }) : void 0;
24838
25037
  return {
24839
25038
  build: yield* runIosBuild({
24840
25039
  api,
@@ -24850,7 +25049,10 @@ const runIosPlatformBuild = (input) => Effect.gen(function* () {
24850
25049
  rawOutput: input.rawOutput,
24851
25050
  freezeCredentials: input.freezeCredentials,
24852
25051
  updateChannel: input.updateChannel,
24853
- ...compact({ customCommand: profile.customCommand?.ios })
25052
+ ...compact({
25053
+ customCommand: profile.customCommand?.ios,
25054
+ nativeVersion
25055
+ })
24854
25056
  }),
24855
25057
  target: isSimulator ? {
24856
25058
  platform: "ios",
@@ -24880,6 +25082,11 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
24880
25082
  projectId,
24881
25083
  applicationIdentifier
24882
25084
  }, { freezeCredentials: input.freezeCredentials });
25085
+ const androidOverride = profile.android.metaOverride;
25086
+ const nativeVersion = input.projectType !== "expo" && (androidOverride?.version !== void 0 || androidOverride?.versionCode !== void 0) ? compact({
25087
+ versionName: appMeta.appVersion,
25088
+ versionCode: appMeta.buildNumber
25089
+ }) : void 0;
24883
25090
  return {
24884
25091
  build: yield* runAndroidBuild({
24885
25092
  api,
@@ -24895,7 +25102,10 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
24895
25102
  strategy,
24896
25103
  packageManager: input.packageManager,
24897
25104
  updateChannel: input.updateChannel,
24898
- ...compact({ customCommand: profile.customCommand?.android })
25105
+ ...compact({
25106
+ customCommand: profile.customCommand?.android,
25107
+ nativeVersion
25108
+ })
24899
25109
  }),
24900
25110
  target: androidProfile.format === "aab" ? {
24901
25111
  platform: "android",
@@ -24910,6 +25120,10 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
24910
25120
  };
24911
25121
  });
24912
25122
  const runPlatformBuild = (input) => Effect.gen(function* () {
25123
+ if (input.projectType !== "expo") yield* materializeEnvFile({
25124
+ projectRoot: input.projectRoot,
25125
+ envVars: input.appEnvVars
25126
+ });
24913
25127
  return input.platform === "ios" ? yield* runIosPlatformBuild(input) : yield* runAndroidPlatformBuild(input);
24914
25128
  });
24915
25129
 
@@ -25245,6 +25459,7 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
25245
25459
  projectType,
25246
25460
  appMeta,
25247
25461
  envVars: buildEnv,
25462
+ appEnvVars: envVars,
25248
25463
  projectId,
25249
25464
  projectRoot: staging.projectRoot,
25250
25465
  tempDir,