@callstack/brownfield-cli 5.3.0 → 6.0.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/brownfield/commands/packageIos.d.ts +2 -0
  3. package/dist/brownfield/commands/packageIos.d.ts.map +1 -1
  4. package/dist/brownfield/commands/packageIos.js +102 -18
  5. package/dist/brownfield/utils/copyDebugBundleToSimulatorSlice.d.ts +4 -1
  6. package/dist/brownfield/utils/copyDebugBundleToSimulatorSlice.d.ts.map +1 -1
  7. package/dist/brownfield/utils/copyDebugBundleToSimulatorSlice.js +6 -1
  8. package/dist/brownfield/utils/destinationSdks.d.ts +36 -0
  9. package/dist/brownfield/utils/destinationSdks.d.ts.map +1 -0
  10. package/dist/brownfield/utils/destinationSdks.js +70 -0
  11. package/dist/brownfield/utils/emitExpoSupportXcframeworks.d.ts +9 -1
  12. package/dist/brownfield/utils/emitExpoSupportXcframeworks.d.ts.map +1 -1
  13. package/dist/brownfield/utils/emitExpoSupportXcframeworks.js +27 -5
  14. package/dist/brownfield/utils/expo.d.ts +7 -0
  15. package/dist/brownfield/utils/expo.d.ts.map +1 -1
  16. package/dist/brownfield/utils/expo.js +28 -1
  17. package/dist/brownfield/utils/project.d.ts +14 -0
  18. package/dist/brownfield/utils/project.d.ts.map +1 -1
  19. package/dist/brownfield/utils/project.js +47 -0
  20. package/dist/brownfield/utils/resolvePackagedFrameworkName.d.ts +4 -1
  21. package/dist/brownfield/utils/resolvePackagedFrameworkName.d.ts.map +1 -1
  22. package/dist/brownfield/utils/resolvePackagedFrameworkName.js +7 -3
  23. package/dist/brownfield/utils/supportsPrebuiltExpo.d.ts +20 -0
  24. package/dist/brownfield/utils/supportsPrebuiltExpo.d.ts.map +1 -1
  25. package/dist/brownfield/utils/supportsPrebuiltExpo.js +68 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +10 -0
  28. package/package.json +6 -6
  29. package/src/brownfield/commands/packageIos.ts +143 -49
  30. package/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts +10 -0
  31. package/src/brownfield/utils/destinationSdks.ts +122 -0
  32. package/src/brownfield/utils/emitExpoSupportXcframeworks.ts +48 -4
  33. package/src/brownfield/utils/expo.ts +38 -1
  34. package/src/brownfield/utils/project.ts +65 -0
  35. package/src/brownfield/utils/resolvePackagedFrameworkName.ts +16 -5
  36. package/src/brownfield/utils/supportsPrebuiltExpo.ts +98 -2
  37. package/src/index.ts +12 -0
@@ -1,10 +1,14 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
- import { RockError } from '@rock-js/tools';
4
+ import { logger, RockError } from '@rock-js/tools';
5
5
 
6
6
  import { normalizeCopiedXcframework } from './normalizeCopiedXcframework.js';
7
- import { getExpoSdkMajor, isExpoProject } from './project.js';
7
+ import {
8
+ getExpoIosUsePrecompiledModules,
9
+ getExpoSdkMajor,
10
+ isExpoProject,
11
+ } from './project.js';
8
12
 
9
13
  import { MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO } from './supportsPrebuiltExpo.js';
10
14
 
@@ -87,14 +91,32 @@ function resolveExpoFrameworkSourcePath(
87
91
  throw new RockError(`Unsupported Expo XCFramework: ${frameworkName}`);
88
92
  }
89
93
 
94
+ function getPrebuiltExpoRemedy(projectRoot: string) {
95
+ const cause =
96
+ getExpoIosUsePrecompiledModules(projectRoot) === false
97
+ ? 'expo-build-properties sets ios.usePrecompiledModules to false, so prebuilt Expo XCFrameworks are never produced. '
98
+ : '';
99
+ return `${cause}Either re-run with --use-prebuilt-expo false to build Expo modules from source, or run \`pod install\` (with ios.usePrecompiledModules enabled) to materialize the prebuilts.`;
100
+ }
101
+
90
102
  export function emitExpoSupportXcframeworks({
91
103
  projectRoot,
92
104
  packageDir,
93
105
  usePrebuiltExpo = true,
106
+ usePrebuiltExpoExplicit = true,
107
+ onDegradeToSource,
94
108
  }: {
95
109
  projectRoot: string;
96
110
  packageDir: string;
97
111
  usePrebuiltExpo?: boolean;
112
+ /**
113
+ * Whether `usePrebuiltExpo` was chosen by the user (CLI flag or brownfield.config.json)
114
+ * rather than inferred from the Expo SDK version. When inferred, missing prebuilts
115
+ * degrade to building Expo modules from source instead of throwing.
116
+ */
117
+ usePrebuiltExpoExplicit?: boolean;
118
+ /** Called when an inferred `usePrebuiltExpo` was degraded to `false` because prebuilts are absent */
119
+ onDegradeToSource?: () => void;
98
120
  }) {
99
121
  if (!isExpoProject(projectRoot)) {
100
122
  return false;
@@ -108,6 +130,22 @@ export function emitExpoSupportXcframeworks({
108
130
  return false;
109
131
  }
110
132
 
133
+ if (usePrebuiltExpo && !usePrebuiltExpoExplicit) {
134
+ const missingPrebuilts = ALL_EXPO_SUPPORT_XCFRAMEWORK_NAMES.filter(
135
+ (frameworkName) =>
136
+ !fs.existsSync(resolveExpoFrameworkSourcePath(projectRoot, frameworkName))
137
+ );
138
+ // ExpoModulesJSI ships in node_modules and is not a Pods prebuilt; only degrade
139
+ // when Pods-provided prebuilts are absent.
140
+ if (missingPrebuilts.some((name) => name !== 'ExpoModulesJSI')) {
141
+ logger.warn(
142
+ `Prebuilt Expo XCFrameworks were not found (${missingPrebuilts.join(', ')}). ${getPrebuiltExpoRemedy(projectRoot)} Continuing without prebuilt Expo (--use-prebuilt-expo false); pass --use-prebuilt-expo true to make this an error.`
143
+ );
144
+ usePrebuiltExpo = false;
145
+ onDegradeToSource?.();
146
+ }
147
+ }
148
+
111
149
  for (const frameworkName of getExpoSupportXcframeworkNames(usePrebuiltExpo)) {
112
150
  const sourcePath = resolveExpoFrameworkSourcePath(
113
151
  projectRoot,
@@ -115,7 +153,7 @@ export function emitExpoSupportXcframeworks({
115
153
  );
116
154
  if (!fs.existsSync(sourcePath)) {
117
155
  throw new RockError(
118
- `Expected Expo SDK ${MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO}+ XCFramework not found: ${frameworkName}.xcframework at ${path.relative(projectRoot, sourcePath)}`
156
+ `Expected Expo SDK ${MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO}+ XCFramework not found: ${frameworkName}.xcframework at ${path.relative(projectRoot, sourcePath)}. ${getPrebuiltExpoRemedy(projectRoot)}`
119
157
  );
120
158
  }
121
159
 
@@ -124,7 +162,13 @@ export function emitExpoSupportXcframeworks({
124
162
  `${frameworkName}.xcframework`
125
163
  );
126
164
  fs.rmSync(destinationPath, { recursive: true, force: true });
127
- fs.cpSync(sourcePath, destinationPath, { recursive: true });
165
+ // With precompiled Expo, CocoaPods symlinks expo-image's dependencies into
166
+ // node_modules. Copy what the link points to, so the package is portable
167
+ // and normalizing it doesn't modify node_modules.
168
+ fs.cpSync(fs.realpathSync(sourcePath), destinationPath, {
169
+ recursive: true,
170
+ verbatimSymlinks: true,
171
+ });
128
172
  normalizeCopiedXcframework(destinationPath);
129
173
  }
130
174
 
@@ -1,4 +1,6 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
2
4
 
3
5
  import { logger } from '@rock-js/tools';
4
6
 
@@ -41,6 +43,34 @@ function getNpxCommand() {
41
43
  return process.platform === 'win32' ? 'npx.cmd' : 'npx';
42
44
  }
43
45
 
46
+ function isGitIgnored(targetPath: string): boolean {
47
+ // `git check-ignore` exits 0 when ignored, 1 when not, 128 outside a repo.
48
+ const result = spawnSync('git', ['check-ignore', '-q', targetPath], {
49
+ cwd: path.dirname(targetPath),
50
+ stdio: 'ignore',
51
+ });
52
+ if (result.status !== 0 && result.status !== 1) {
53
+ logger.warn(
54
+ `Couldn't check with git whether ${path.basename(targetPath)}/ is gitignored, so it's treated as committed and expo prebuild is skipped. If this project uses Continuous Native Generation, run \`npx expo prebuild\` before packaging.`
55
+ );
56
+ }
57
+ return result.status === 0;
58
+ }
59
+
60
+ /**
61
+ * Whether the platform's native project is generated by `expo prebuild` (Continuous Native
62
+ * Generation) rather than maintained by hand. A bare React Native app that installed Expo modules
63
+ * commits its native directories; running prebuild there would wipe them (including the
64
+ * brownfield library module), so prebuild only runs when the directory is missing or gitignored.
65
+ */
66
+ export function usesContinuousNativeGeneration(
67
+ projectRoot: string,
68
+ platform: ExpoPlatform
69
+ ): boolean {
70
+ const nativeDir = path.join(projectRoot, platform);
71
+ return !fs.existsSync(nativeDir) || isGitIgnored(nativeDir);
72
+ }
73
+
44
74
  export async function runExpoPrebuildIfNeeded({
45
75
  projectRoot,
46
76
  platform,
@@ -52,6 +82,13 @@ export async function runExpoPrebuildIfNeeded({
52
82
  return false;
53
83
  }
54
84
 
85
+ if (!usesContinuousNativeGeneration(projectRoot, platform)) {
86
+ logger.info(
87
+ `Expo modules detected in a project with a committed ${platform} directory. Skipping expo prebuild.`
88
+ );
89
+ return false;
90
+ }
91
+
55
92
  logger.info(
56
93
  `Expo project detected. Running expo prebuild for ${platform}...`
57
94
  );
@@ -81,6 +81,71 @@ export function isExpoProject(projectRoot: string): boolean {
81
81
  return hasExpoAppConfig(projectRoot) && projectDependsOnExpo(projectRoot);
82
82
  }
83
83
 
84
+ type ExpoPluginEntry = string | [string, ...unknown[]] | unknown;
85
+
86
+ /**
87
+ * Reads `ios.usePrecompiledModules` from the `expo-build-properties` plugin entry.
88
+ *
89
+ * `getExpoConfigIfIsExpo` uses `skipPlugins: true`, which makes `@expo/config`
90
+ * delete `exp.plugins`, so the plugins array is read from `rootConfig` (the raw
91
+ * static app.json / app.config.json) instead. `rootConfig` is the raw *static*
92
+ * config only, so a dynamic config (app.config.js/ts) exposes no plugins that
93
+ * way; for those we retry once with plugins enabled, which keeps the plugins
94
+ * array. That retry can throw for plugins that are not loadable as Node modules
95
+ * (the reason `skipPlugins` is set in the first place), so it is best-effort.
96
+ *
97
+ * @returns the explicit boolean value, or `undefined` if it is not set or not resolvable
98
+ */
99
+ export function getExpoIosUsePrecompiledModules(
100
+ projectRoot: string
101
+ ): boolean | undefined {
102
+ const config = getExpoConfigIfIsExpo(projectRoot);
103
+ if (!config) {
104
+ return undefined;
105
+ }
106
+
107
+ const rootConfig = config.rootConfig as
108
+ | { plugins?: ExpoPluginEntry[]; expo?: { plugins?: ExpoPluginEntry[] } }
109
+ | undefined;
110
+ let plugins =
111
+ (config.exp as { plugins?: ExpoPluginEntry[] }).plugins ??
112
+ rootConfig?.expo?.plugins ??
113
+ rootConfig?.plugins;
114
+
115
+ if (!Array.isArray(plugins)) {
116
+ // Dynamic config: no static plugins array exists. Retry with plugins
117
+ // enabled, which preserves `exp.plugins`. Best-effort — plugin resolution
118
+ // can throw, and an unreadable config is treated the same as an unset value.
119
+ try {
120
+ plugins = (
121
+ getConfig(projectRoot, { skipSDKVersionRequirement: true }).exp as {
122
+ plugins?: ExpoPluginEntry[];
123
+ }
124
+ ).plugins;
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ if (!Array.isArray(plugins)) {
131
+ return undefined;
132
+ }
133
+
134
+ for (const plugin of plugins) {
135
+ if (!Array.isArray(plugin) || plugin[0] !== 'expo-build-properties') {
136
+ continue;
137
+ }
138
+ const value = (
139
+ plugin[1] as { ios?: { usePrecompiledModules?: unknown } } | undefined
140
+ )?.ios?.usePrecompiledModules;
141
+ if (typeof value === 'boolean') {
142
+ return value;
143
+ }
144
+ }
145
+
146
+ return undefined;
147
+ }
148
+
84
149
  export function getExpoSdkMajor(projectRoot: string): number | null {
85
150
  const rawExpoVersion = getExpoConfigIfIsExpo(projectRoot)?.exp.sdkVersion;
86
151
  if (!rawExpoVersion) {
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
+ import { ALL_APPLE_SDKS, type AppleSdk } from './destinationSdks.js';
5
+
4
6
  type Resolution = 'explicit' | 'detected' | 'not_found' | 'ambiguous';
5
7
 
6
8
  export interface ResolvePackagedFrameworkNameResult {
@@ -13,6 +15,8 @@ interface ResolvePackagedFrameworkNameOptions {
13
15
  explicitScheme?: string;
14
16
  productsPath: string;
15
17
  configuration: string;
18
+ /** Slices the build produced; defaults to both. */
19
+ sdks?: AppleSdk[];
16
20
  }
17
21
 
18
22
  function collectFrameworkCandidates(configurationProductsPath: string): string[] {
@@ -61,6 +65,7 @@ export function resolvePackagedFrameworkName({
61
65
  explicitScheme,
62
66
  productsPath,
63
67
  configuration,
68
+ sdks = ALL_APPLE_SDKS,
64
69
  }: ResolvePackagedFrameworkNameOptions): ResolvePackagedFrameworkNameResult {
65
70
  if (explicitScheme) {
66
71
  return {
@@ -69,11 +74,17 @@ export function resolvePackagedFrameworkName({
69
74
  };
70
75
  }
71
76
 
72
- const configurationProductsPath = path.join(
73
- productsPath,
74
- `${configuration}-iphoneos`
75
- );
76
- const candidates = collectFrameworkCandidates(configurationProductsPath);
77
+ // the JS bundle is emitted per slice, so scan every slice that was built rather
78
+ // than assuming the device one exists (`--destination simulator` skips it)
79
+ const candidates = [
80
+ ...new Set(
81
+ sdks.flatMap((sdk) =>
82
+ collectFrameworkCandidates(
83
+ path.join(productsPath, `${configuration}-${sdk}`)
84
+ )
85
+ )
86
+ ),
87
+ ].sort();
77
88
 
78
89
  if (candidates.length === 1) {
79
90
  return {
@@ -1,11 +1,69 @@
1
- import { getExpoSdkMajor, isExpoProject } from './project.js';
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+
5
+ import { getReactNativeVersion } from '@rock-js/tools';
6
+
7
+ import { getExpoIosUsePrecompiledModules, getExpoSdkMajor, isExpoProject } from './project.js';
2
8
 
3
9
  export const MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO = 56;
4
10
 
5
11
  export type PrebuiltExpoSupportResult =
6
- | { supported: true; enabledByDefault: boolean; reason?: never }
12
+ | {
13
+ supported: true;
14
+ enabledByDefault: boolean;
15
+ /** Why prebuilts are off by default even though they are supported. */
16
+ defaultDisabledReason?: string;
17
+ reason?: never;
18
+ }
7
19
  | { supported: false; enabledByDefault?: never; reason: string };
8
20
 
21
+ function majorMinor(version: string): string {
22
+ return version.split('.').slice(0, 2).join('.');
23
+ }
24
+
25
+ /**
26
+ * The React Native version the installed Expo SDK pins. Expo ships its
27
+ * precompiled modules built against this version.
28
+ */
29
+ export function getExpoReactNativeVersion(projectRoot: string): string | null {
30
+ try {
31
+ const require = createRequire(path.join(projectRoot, 'package.json'));
32
+ const expoPackageJson = require.resolve('expo/package.json');
33
+ const bundledNativeModules = JSON.parse(
34
+ fs.readFileSync(
35
+ path.join(path.dirname(expoPackageJson), 'bundledNativeModules.json'),
36
+ 'utf8'
37
+ )
38
+ ) as Record<string, string>;
39
+ return bundledNativeModules['react-native'] ?? null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Describes a React Native version the Expo prebuilts were not built for, or
47
+ * returns null when they match. Prebuilt Expo modules link against React
48
+ * Native's C++ API, which changes between minor versions, so a mismatch
49
+ * crashes at runtime.
50
+ */
51
+ export function getPrebuiltExpoReactNativeMismatch(
52
+ projectRoot: string
53
+ ): string | null {
54
+ const expected = getExpoReactNativeVersion(projectRoot);
55
+ const found = getReactNativeVersion(projectRoot);
56
+ if (!expected || found === 'unknown') {
57
+ return null;
58
+ }
59
+
60
+ if (majorMinor(expected) === majorMinor(found)) {
61
+ return null;
62
+ }
63
+
64
+ return `Expo's precompiled modules are built for React Native ${majorMinor(expected)}, but this project uses ${found}.`;
65
+ }
66
+
9
67
  export function supportsPrebuiltExpo({
10
68
  projectRoot,
11
69
  }: {
@@ -27,8 +85,46 @@ export function supportsPrebuiltExpo({
27
85
  };
28
86
  }
29
87
 
88
+ const reactNativeMismatch = getPrebuiltExpoReactNativeMismatch(projectRoot);
89
+ if (reactNativeMismatch) {
90
+ return {
91
+ supported: true,
92
+ enabledByDefault: false,
93
+ defaultDisabledReason: reactNativeMismatch,
94
+ };
95
+ }
96
+
97
+ if (getExpoIosUsePrecompiledModules(projectRoot) === false) {
98
+ return {
99
+ supported: false,
100
+ reason:
101
+ "--use-prebuilt-expo is unsupported: expo-build-properties sets ios.usePrecompiledModules to false in your Expo config, so prebuilt Expo XCFrameworks are never produced. Set it to true (or remove it) and run 'pod install', or pass --use-prebuilt-expo false to build Expo modules from source.",
102
+ };
103
+ }
104
+
30
105
  return {
31
106
  supported: true,
32
107
  enabledByDefault: true,
33
108
  };
34
109
  }
110
+
111
+ /**
112
+ * Whether the project's Podfile turns Expo's precompiled modules off. Expo
113
+ * Podfiles force `EXPO_USE_PRECOMPILED_MODULES` to '0' when
114
+ * `ios/Podfile.properties.json` sets it to "false", whatever the environment says.
115
+ */
116
+ export function isPrecompiledExpoDisabledByPodfile(
117
+ projectRoot: string
118
+ ): boolean {
119
+ try {
120
+ const properties = JSON.parse(
121
+ fs.readFileSync(
122
+ path.join(projectRoot, 'ios', 'Podfile.properties.json'),
123
+ 'utf8'
124
+ )
125
+ );
126
+ return properties.EXPO_USE_PRECOMPILED_MODULES === 'false';
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
package/src/index.ts CHANGED
@@ -15,6 +15,18 @@ import navigationCommands, {
15
15
  groupName as navigationCommandsGroupName,
16
16
  } from './navigation/index.js';
17
17
 
18
+ // CocoaPods (spawned by Rock) raises Encoding::CompatibilityError under a non-UTF-8
19
+ // locale. Ruby resolves the character encoding as LC_ALL > LC_CTYPE > LANG, so check
20
+ // them in that precedence. Default to UTF-8 only when the effective value is missing
21
+ // or non-UTF-8.
22
+ const inheritedCharset =
23
+ process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || '';
24
+ if (!/utf-?8/i.test(inheritedCharset)) {
25
+ process.env.LANG = 'en_US.UTF-8';
26
+ process.env.LC_CTYPE = 'en_US.UTF-8';
27
+ process.env.LC_ALL = 'en_US.UTF-8';
28
+ }
29
+
18
30
  const program = new Command();
19
31
 
20
32
  program