@better-update/cli 0.40.2 → 0.41.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
@@ -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.40.2";
38
+ var version = "0.41.1";
39
39
 
40
40
  //#endregion
41
41
  //#region src/lib/interactive-mode.ts
@@ -19942,20 +19942,98 @@ const walkUpForLockfile = (startCwd, dir) => Effect.gen(function* () {
19942
19942
  */
19943
19943
  const detectWorkspaceRoot = (cwd) => walkUpForLockfile(cwd, cwd);
19944
19944
  /**
19945
- * Build an `Ignore` matcher for the workspace root. `.easignore` REPLACES
19946
- * `.gitignore` when present (matches EAS semantics); otherwise `.gitignore`
19947
- * is layered on top of the always-ignore baseline.
19945
+ * Rebase a single nested `.gitignore` line so it applies only within `prefix`
19946
+ * (the posix dir path + trailing slash) when folded into the workspace-root
19947
+ * matcher. Returns `undefined` for blanks/comments. Anchored patterns (a
19948
+ * leading or interior `/`) are prefixed as-is; unanchored ones (which match at
19949
+ * any depth) become `prefix**\/pat`. Negation and directory-only trailing
19950
+ * slashes are preserved.
19951
+ */
19952
+ const rebaseGitignoreLine = (prefix, rawLine) => {
19953
+ const line = rawLine.replace(/(?<!\\)\s+$/u, "");
19954
+ if (line === "" || line.startsWith("#")) return;
19955
+ const negate = line.startsWith("!");
19956
+ const unescaped = negate || line.startsWith(String.raw`\#`) || line.startsWith(String.raw`\!`) ? line.slice(1) : line;
19957
+ const hadLeadingSlash = unescaped.startsWith("/");
19958
+ const body = hadLeadingSlash ? unescaped.slice(1) : unescaped;
19959
+ if (body === "") return;
19960
+ const withoutTrailingSlash = body.endsWith("/") ? body.slice(0, -1) : body;
19961
+ const rebased = hadLeadingSlash || withoutTrailingSlash.includes("/") ? `${prefix}${body}` : `${prefix}**/${body}`;
19962
+ return negate ? `!${rebased}` : rebased;
19963
+ };
19964
+ /**
19965
+ * Rebase every line of a nested `.gitignore` to its own directory (`relDir`,
19966
+ * posix, relative to the workspace root) — mirroring how git scopes a
19967
+ * `.gitignore` to the dir it lives in.
19968
+ */
19969
+ const rebaseGitignore = (relDir, content) => {
19970
+ const prefix = `${relDir}/`;
19971
+ return content.split(/\r?\n/u).map((rawLine) => rebaseGitignoreLine(prefix, rawLine)).filter((pattern) => pattern !== void 0);
19972
+ };
19973
+ const safeReadDir = async (dir) => {
19974
+ try {
19975
+ return await promises.readdir(dir, { withFileTypes: true });
19976
+ } catch {
19977
+ return [];
19978
+ }
19979
+ };
19980
+ const safeReadText = async (file) => {
19981
+ try {
19982
+ return await promises.readFile(file, "utf8");
19983
+ } catch {
19984
+ return "";
19985
+ }
19986
+ };
19987
+ const isDirectory = async (file) => {
19988
+ try {
19989
+ return (await promises.lstat(file)).isDirectory();
19990
+ } catch {
19991
+ return false;
19992
+ }
19993
+ };
19994
+ /**
19995
+ * Fold every NESTED `.gitignore` under `workspaceRoot` into `ig`, each rebased
19996
+ * to its own directory (git scopes a `.gitignore` to the dir it lives in, and
19997
+ * EAS — which stages via git — honors that). The walk prunes any directory the
19998
+ * matcher-so-far already ignores, so it never descends into `node_modules`,
19999
+ * build outputs, or a subtree excluded by a shallower `.gitignore` — including
20000
+ * the entries a just-loaded nested `.gitignore` adds. The root `.gitignore` is
20001
+ * added by the caller, so the walk skips it.
20002
+ */
20003
+ const addNestedGitignores = (workspaceRoot, ig) => Effect.promise(async () => {
20004
+ const walk = async (absDir, relDir) => {
20005
+ const entries = await safeReadDir(absDir);
20006
+ if (relDir !== "" && entries.some((entry) => entry.isFile() && entry.name === ".gitignore")) {
20007
+ const content = await safeReadText(path.join(absDir, ".gitignore"));
20008
+ if (content !== "") ig.add(rebaseGitignore(relDir, content));
20009
+ }
20010
+ const subdirs = entries.filter((entry) => entry.isDirectory());
20011
+ for (const entry of subdirs) {
20012
+ const childRel = relDir === "" ? entry.name : `${relDir}/${entry.name}`;
20013
+ if (!ig.ignores(childRel) && !ig.ignores(`${childRel}/`)) await walk(path.join(absDir, entry.name), childRel);
20014
+ }
20015
+ };
20016
+ await walk(workspaceRoot, "");
20017
+ });
20018
+ /**
20019
+ * Build an `Ignore` matcher for the workspace root. `.easignore` REPLACES every
20020
+ * `.gitignore` when present (matches EAS semantics); otherwise the root
20021
+ * `.gitignore` plus every NESTED `.gitignore` (git semantics) is layered on top
20022
+ * of the always-ignore baseline.
19948
20023
  *
19949
20024
  * When `includeNativeSource` is set, the native source dirs are re-included
19950
- * after the ignore files are applied, then their build outputs re-excluded, so
19951
- * a committed `ios/`/`android/` reaches staging intact.
20025
+ * before the nested scan (so the scan descends into committed `android/`/`ios/`
20026
+ * and folds in their nested ignores), then their build outputs re-excluded last,
20027
+ * so a committed `ios/`/`android/` reaches staging intact.
19952
20028
  */
19953
20029
  const buildIgnoreInstance = (workspaceRoot, options = {}) => Effect.gen(function* () {
19954
20030
  const fs = yield* FileSystem.FileSystem;
19955
20031
  const ig = ignore();
19956
20032
  ig.add([...ALWAYS_IGNORE]);
20033
+ const base = options.appRelPath === void 0 || options.appRelPath === "" ? "" : `${options.appRelPath}/`;
19957
20034
  const easignorePath = path.join(workspaceRoot, ".easignore");
19958
- if (yield* fs.exists(easignorePath).pipe(Effect.orElseSucceed(() => false))) {
20035
+ const hasEasignore = yield* fs.exists(easignorePath).pipe(Effect.orElseSucceed(() => false));
20036
+ if (hasEasignore) {
19959
20037
  const content = yield* fs.readFileString(easignorePath).pipe(Effect.orElseSucceed(() => ""));
19960
20038
  ig.add(content);
19961
20039
  } else {
@@ -19965,11 +20043,9 @@ const buildIgnoreInstance = (workspaceRoot, options = {}) => Effect.gen(function
19965
20043
  ig.add(content);
19966
20044
  }
19967
20045
  }
19968
- if (options.includeNativeSource === true) {
19969
- const base = options.appRelPath === void 0 || options.appRelPath === "" ? "" : `${options.appRelPath}/`;
19970
- ig.add([`!${base}android`, `!${base}ios`]);
19971
- ig.add(NATIVE_BUILD_OUTPUTS.map((entry) => `${base}${entry}`));
19972
- }
20046
+ if (options.includeNativeSource === true) ig.add([`!${base}android`, `!${base}ios`]);
20047
+ if (!hasEasignore) yield* addNestedGitignores(workspaceRoot, ig);
20048
+ if (options.includeNativeSource === true) ig.add(NATIVE_BUILD_OUTPUTS.map((entry) => `${base}${entry}`));
19973
20049
  return ig;
19974
20050
  });
19975
20051
  const copyProjectTree = (params) => Effect.tryPromise({
@@ -19977,11 +20053,12 @@ const copyProjectTree = (params) => Effect.tryPromise({
19977
20053
  await promises.cp(params.source, params.dest, {
19978
20054
  recursive: true,
19979
20055
  dereference: false,
19980
- filter: (src) => {
20056
+ filter: async (src) => {
19981
20057
  const rel = path.relative(params.source, src);
19982
20058
  if (rel === "") return true;
19983
20059
  const posixRel = rel.split(path.sep).join("/");
19984
- return !params.ig.ignores(posixRel);
20060
+ const isDir = await isDirectory(src);
20061
+ return !params.ig.ignores(isDir ? `${posixRel}/` : posixRel);
19985
20062
  }
19986
20063
  });
19987
20064
  },
@@ -20004,6 +20081,50 @@ const initGitRepo = (stagingRoot) => Effect.tryPromise({
20004
20081
  catch: (cause) => new StagingError({ message: `Failed to init git repo in staging dir: ${formatCause(cause)}` })
20005
20082
  }).pipe(Effect.asVoid);
20006
20083
  /**
20084
+ * Snapshot the staged tree as a single commit so its working tree reads CLEAN.
20085
+ *
20086
+ * EAS stages via `git clone` + checkout, so the tree it hands to
20087
+ * `expo prebuild --clean` is a clean checkout. Our `cp` + `git init` leaves
20088
+ * every staged file UNTRACKED, which `expo prebuild`'s git check reads as
20089
+ * "dirty" (`git status --porcelain` is non-empty). Because the native build
20090
+ * runs inside a PTY, Expo's `isInteractive()` is true even with no real
20091
+ * controlling TTY, so it prompts `Continue with uncommitted changes?` and then
20092
+ * blocks on stdin we never write — hanging CI / backgrounded / piped builds.
20093
+ *
20094
+ * Committing once here makes `git status` clean, so Expo's check passes on
20095
+ * EVERY Expo version — the `EXPO_NO_GIT_STATUS` env gate the build sets only
20096
+ * exists in newer Expo — with no global `CI=1` side effects. The real
20097
+ * dirty-tree decision already ran against the user's *actual* working tree in
20098
+ * `ensureRepoClean` (honoring `--allow-dirty`). Best-effort: hooks are disabled
20099
+ * and a failure is non-fatal — the build proceeds and `EXPO_NO_GIT_STATUS`
20100
+ * still covers newer Expo.
20101
+ */
20102
+ const commitStagingSnapshot = (stagingRoot) => Effect.tryPromise(async () => {
20103
+ const run = async (args) => execFileAsync$1("git", [...args], {
20104
+ cwd: stagingRoot,
20105
+ env: {
20106
+ ...process.env,
20107
+ LEFTHOOK: "0",
20108
+ HUSKY: "0"
20109
+ }
20110
+ });
20111
+ await run(["add", "-A"]);
20112
+ await run([
20113
+ "-c",
20114
+ "user.name=better-update",
20115
+ "-c",
20116
+ "user.email=build@better-update.dev",
20117
+ "-c",
20118
+ "commit.gpgsign=false",
20119
+ "commit",
20120
+ "--no-verify",
20121
+ "--allow-empty",
20122
+ "-q",
20123
+ "-m",
20124
+ "better-update staging snapshot"
20125
+ ]);
20126
+ }).pipe(Effect.ignore);
20127
+ /**
20007
20128
  * Install args per package manager, frozen-lockfile variants matching EAS
20008
20129
  * (`bun install --frozen-lockfile` / `npm ci --include=dev` / etc.) so the
20009
20130
  * staged install resolves exactly what the user's lockfile pins.
@@ -20059,6 +20180,7 @@ const prepareStagingProject = (input) => Effect.gen(function* () {
20059
20180
  env: commandEnv
20060
20181
  });
20061
20182
  } else yield* printHuman("No package.json at the staging root — skipping dependency install.");
20183
+ yield* commitStagingSnapshot(stagingRoot);
20062
20184
  return {
20063
20185
  stagingRoot,
20064
20186
  projectRoot,
@@ -23929,6 +24051,26 @@ const discoverSignedTargets = (options) => Effect.gen(function* () {
23929
24051
  if (results.length === 0) return yield* new XcodeProjectError({ message: `No signed native targets found in ${pbxprojPath} for configuration "${options.configurationName}".` });
23930
24052
  return results;
23931
24053
  });
24054
+ /**
24055
+ * Like {@link discoverSignedTargets}, but tolerant of a project that hasn't been
24056
+ * prebuilt: returns `undefined` when `iosDir` is absent or contains no
24057
+ * `.xcodeproj` (a managed Expo app before `expo prebuild`). A genuine parse
24058
+ * error in an existing project still surfaces. Used by `credentials configure`,
24059
+ * which runs outside a build and must degrade to the main bundle id from the
24060
+ * Expo config when the native project isn't generated yet.
24061
+ */
24062
+ const discoverSignedTargetsIfPresent = (options) => Effect.gen(function* () {
24063
+ const fs = yield* FileSystem.FileSystem;
24064
+ if (!(yield* fs.exists(options.iosDir).pipe(Effect.orElseSucceed(() => false)))) return;
24065
+ if (!(yield* fs.readDirectory(options.iosDir).pipe(Effect.orElseSucceed(() => []))).some((entry) => entry.endsWith(".xcodeproj"))) return;
24066
+ return yield* discoverSignedTargets(options);
24067
+ });
24068
+ /**
24069
+ * Pick the "main" target from a discovered set: the application product type if
24070
+ * present, otherwise the first signed target. Shared by the build pipeline and
24071
+ * `credentials configure` so both agree on which bundle id is primary.
24072
+ */
24073
+ const pickMainTarget = (signedTargets) => signedTargets.find((target) => target.productType === "com.apple.product-type.application") ?? signedTargets[0];
23932
24074
 
23933
24075
  //#endregion
23934
24076
  //#region src/lib/xcpretty-formatter.ts
@@ -24160,7 +24302,6 @@ const installProfileForTarget = (target, profileByBundle) => {
24160
24302
  installed
24161
24303
  })));
24162
24304
  };
24163
- const pickMainTarget = (signedTargets) => signedTargets.find((target) => target.productType === "com.apple.product-type.application") ?? signedTargets[0];
24164
24305
  const runIosDeviceBuild = (input) => Effect.gen(function* () {
24165
24306
  const { api, tempDir, projectRoot, iosProfile, envVars } = input;
24166
24307
  const runtime = yield* CliRuntime;
@@ -28347,6 +28488,12 @@ const accessCommand = defineCommand({
28347
28488
 
28348
28489
  //#endregion
28349
28490
  //#region src/commands/credentials/configure.ts
28491
+ /**
28492
+ * Xcode build configuration whose `PRODUCT_BUNDLE_IDENTIFIER`s the configure
28493
+ * wizard reads when discovering signed targets. Distribution signing always
28494
+ * runs against the Release configuration.
28495
+ */
28496
+ const IOS_DISCOVERY_CONFIGURATION = "Release";
28350
28497
  const bindAndroidFcmGsa = (api, input) => Effect.gen(function* () {
28351
28498
  const app = (yield* api.androidApplicationIdentifiers.list({ path: { projectId: input.projectId } })).items.find((entry) => entry.packageName === input.applicationIdentifier);
28352
28499
  if (app === void 0) return yield* new MissingCredentialsError({
@@ -28441,6 +28588,68 @@ const configureIos = (args) => Effect.gen(function* () {
28441
28588
  yield* printHuman("Run with --rebind to switch certificate, profile, or ASC key.");
28442
28589
  yield* printHuman("Run with --bind-push-key <id> / --bind-asc-key <id> to update a single binding.");
28443
28590
  });
28591
+ const configureIosTargets = (args) => Effect.gen(function* () {
28592
+ yield* printHuman(`Configuring iOS credentials for ${args.targets.length} signed target(s) (${args.distribution})...`);
28593
+ yield* Effect.forEach(args.targets, (target) => Effect.gen(function* () {
28594
+ const input = {
28595
+ projectId: args.projectId,
28596
+ bundleIdentifier: target.bundleId,
28597
+ distribution: args.distribution
28598
+ };
28599
+ yield* printHuman("");
28600
+ yield* printHuman(`${target.targetName} (${target.bundleId})`);
28601
+ yield* ensureIosCredentials(args.api, input, { freezeCredentials: false });
28602
+ yield* showIosBinding(args.api, input);
28603
+ }), { concurrency: 1 });
28604
+ yield* printHuman("");
28605
+ yield* printHuman("Run with --bundle <id> --rebind to switch a target's certificate, profile, or ASC key.");
28606
+ yield* printHuman("Run with --bundle <id> --bind-push-key <id> / --bind-asc-key <id> to update a single binding.");
28607
+ });
28608
+ const runConfigureIos = (args) => Effect.gen(function* () {
28609
+ const { api, projectId, root, distribution } = args;
28610
+ const singleBundleOnly = args.bundle !== void 0 || args.rebind || args.bindPushKey !== void 0 || args.bindAscKey !== void 0;
28611
+ const iosMeta = yield* readAppMetaOptional(root, "ios");
28612
+ if (!singleBundleOnly) {
28613
+ const targets = yield* discoverSignedTargetsIfPresent({
28614
+ iosDir: path.join(root, "ios"),
28615
+ configurationName: IOS_DISCOVERY_CONFIGURATION
28616
+ });
28617
+ const main = targets === void 0 ? void 0 : pickMainTarget(targets);
28618
+ if (targets !== void 0 && main !== void 0) {
28619
+ yield* configureIosTargets({
28620
+ api,
28621
+ projectId,
28622
+ distribution,
28623
+ targets
28624
+ });
28625
+ return {
28626
+ platform: "ios",
28627
+ projectId,
28628
+ distribution,
28629
+ bundleIdentifier: main.bundleId,
28630
+ bundleIdentifiers: targets.map((target) => target.bundleId)
28631
+ };
28632
+ }
28633
+ if (iosMeta.bundleId !== void 0) yield* printHuman("No prebuilt iOS project found — configuring the main bundle only. Run `expo prebuild` (or a build) once so app-extension targets are discovered and configured.");
28634
+ }
28635
+ const bundleIdentifier = args.bundle ?? iosMeta.bundleId ?? (yield* promptText("iOS bundle identifier"));
28636
+ yield* configureIos({
28637
+ api,
28638
+ projectId,
28639
+ bundleIdentifier,
28640
+ distribution,
28641
+ rebind: args.rebind,
28642
+ bindPushKey: args.bindPushKey,
28643
+ bindAscKey: args.bindAscKey
28644
+ });
28645
+ return {
28646
+ platform: "ios",
28647
+ projectId,
28648
+ distribution,
28649
+ bundleIdentifier,
28650
+ bundleIdentifiers: [bundleIdentifier]
28651
+ };
28652
+ });
28444
28653
  const configureCommand$1 = defineCommand({
28445
28654
  meta: {
28446
28655
  name: "configure",
@@ -28454,7 +28663,7 @@ const configureCommand$1 = defineCommand({
28454
28663
  },
28455
28664
  bundle: {
28456
28665
  type: "string",
28457
- description: "iOS bundle identifier (defaults to app.json)"
28666
+ description: "iOS bundle identifier to scope to a single target (defaults to configuring every signed target — main app + extensions — discovered from the Xcode project)"
28458
28667
  },
28459
28668
  "android-package": {
28460
28669
  type: "string",
@@ -28498,25 +28707,16 @@ const configureCommand$1 = defineCommand({
28498
28707
  }, {
28499
28708
  value: "android",
28500
28709
  label: "Android"
28501
- }]))) === "ios") {
28502
- const iosMeta = yield* readAppMetaOptional(root, "ios");
28503
- const bundleIdentifier = args.bundle ?? iosMeta.bundleId ?? (yield* promptText("iOS bundle identifier"));
28504
- yield* configureIos({
28505
- api,
28506
- projectId,
28507
- bundleIdentifier,
28508
- distribution: args.distribution,
28509
- rebind: args.rebind ?? false,
28510
- bindPushKey: args["bind-push-key"],
28511
- bindAscKey: args["bind-asc-key"]
28512
- });
28513
- return {
28514
- platform: "ios",
28515
- projectId,
28516
- bundleIdentifier,
28517
- distribution: args.distribution
28518
- };
28519
- }
28710
+ }]))) === "ios") return yield* runConfigureIos({
28711
+ api,
28712
+ projectId,
28713
+ root,
28714
+ bundle: args.bundle,
28715
+ distribution: args.distribution,
28716
+ rebind: args.rebind ?? false,
28717
+ bindPushKey: args["bind-push-key"],
28718
+ bindAscKey: args["bind-asc-key"]
28719
+ });
28520
28720
  const androidMeta = yield* readAppMetaOptional(root, "android");
28521
28721
  const applicationIdentifier = args["android-package"] ?? androidMeta.androidPackage ?? (yield* promptText("Android application identifier"));
28522
28722
  yield* configureAndroid({