@callstack/brownfield-cli 6.0.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 +16 -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,3 +1,4 @@
1
+ import { type AppleSdk } from './destinationSdks.js';
1
2
  type Resolution = 'explicit' | 'detected' | 'not_found' | 'ambiguous';
2
3
  export interface ResolvePackagedFrameworkNameResult {
3
4
  frameworkName: string | null;
@@ -8,7 +9,9 @@ interface ResolvePackagedFrameworkNameOptions {
8
9
  explicitScheme?: string;
9
10
  productsPath: string;
10
11
  configuration: string;
12
+ /** Slices the build produced; defaults to both. */
13
+ sdks?: AppleSdk[];
11
14
  }
12
- export declare function resolvePackagedFrameworkName({ explicitScheme, productsPath, configuration, }: ResolvePackagedFrameworkNameOptions): ResolvePackagedFrameworkNameResult;
15
+ export declare function resolvePackagedFrameworkName({ explicitScheme, productsPath, configuration, sdks, }: ResolvePackagedFrameworkNameOptions): ResolvePackagedFrameworkNameResult;
13
16
  export {};
14
17
  //# sourceMappingURL=resolvePackagedFrameworkName.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"resolvePackagedFrameworkName.d.ts","sourceRoot":"","sources":["../../../src/brownfield/utils/resolvePackagedFrameworkName.ts"],"names":[],"mappings":"AAGA,KAAK,UAAU,GAAG,UAAU,GAAG,UAAU,GAAG,WAAW,GAAG,WAAW,CAAC;AAEtE,MAAM,WAAW,kCAAkC;IACjD,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,UAAU,EAAE,UAAU,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,UAAU,mCAAmC;IAC3C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;CACvB;AA4CD,wBAAgB,4BAA4B,CAAC,EAC3C,cAAc,EACd,YAAY,EACZ,aAAa,GACd,EAAE,mCAAmC,GAAG,kCAAkC,CAkC1E"}
1
+ {"version":3,"file":"resolvePackagedFrameworkName.d.ts","sourceRoot":"","sources":["../../../src/brownfield/utils/resolvePackagedFrameworkName.ts"],"names":[],"mappings":"AAGA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAErE,KAAK,UAAU,GAAG,UAAU,GAAG,UAAU,GAAG,WAAW,GAAG,WAAW,CAAC;AAEtE,MAAM,WAAW,kCAAkC;IACjD,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,UAAU,EAAE,UAAU,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,UAAU,mCAAmC;IAC3C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,mDAAmD;IACnD,IAAI,CAAC,EAAE,QAAQ,EAAE,CAAC;CACnB;AA4CD,wBAAgB,4BAA4B,CAAC,EAC3C,cAAc,EACd,YAAY,EACZ,aAAa,EACb,IAAqB,GACtB,EAAE,mCAAmC,GAAG,kCAAkC,CAwC1E"}
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { ALL_APPLE_SDKS } from './destinationSdks.js';
3
4
  function collectFrameworkCandidates(configurationProductsPath) {
4
5
  if (!fs.existsSync(configurationProductsPath)) {
5
6
  return [];
@@ -31,15 +32,18 @@ function collectFrameworkCandidates(configurationProductsPath) {
31
32
  }
32
33
  return [...discoveredFrameworks].sort();
33
34
  }
34
- export function resolvePackagedFrameworkName({ explicitScheme, productsPath, configuration, }) {
35
+ export function resolvePackagedFrameworkName({ explicitScheme, productsPath, configuration, sdks = ALL_APPLE_SDKS, }) {
35
36
  if (explicitScheme) {
36
37
  return {
37
38
  frameworkName: explicitScheme,
38
39
  resolution: 'explicit',
39
40
  };
40
41
  }
41
- const configurationProductsPath = path.join(productsPath, `${configuration}-iphoneos`);
42
- const candidates = collectFrameworkCandidates(configurationProductsPath);
42
+ // the JS bundle is emitted per slice, so scan every slice that was built rather
43
+ // than assuming the device one exists (`--destination simulator` skips it)
44
+ const candidates = [
45
+ ...new Set(sdks.flatMap((sdk) => collectFrameworkCandidates(path.join(productsPath, `${configuration}-${sdk}`)))),
46
+ ].sort();
43
47
  if (candidates.length === 1) {
44
48
  return {
45
49
  frameworkName: candidates[0] ?? null,
@@ -2,13 +2,33 @@ export declare const MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO = 56;
2
2
  export type PrebuiltExpoSupportResult = {
3
3
  supported: true;
4
4
  enabledByDefault: boolean;
5
+ /** Why prebuilts are off by default even though they are supported. */
6
+ defaultDisabledReason?: string;
5
7
  reason?: never;
6
8
  } | {
7
9
  supported: false;
8
10
  enabledByDefault?: never;
9
11
  reason: string;
10
12
  };
13
+ /**
14
+ * The React Native version the installed Expo SDK pins. Expo ships its
15
+ * precompiled modules built against this version.
16
+ */
17
+ export declare function getExpoReactNativeVersion(projectRoot: string): string | null;
18
+ /**
19
+ * Describes a React Native version the Expo prebuilts were not built for, or
20
+ * returns null when they match. Prebuilt Expo modules link against React
21
+ * Native's C++ API, which changes between minor versions, so a mismatch
22
+ * crashes at runtime.
23
+ */
24
+ export declare function getPrebuiltExpoReactNativeMismatch(projectRoot: string): string | null;
11
25
  export declare function supportsPrebuiltExpo({ projectRoot, }: {
12
26
  projectRoot: string;
13
27
  }): PrebuiltExpoSupportResult;
28
+ /**
29
+ * Whether the project's Podfile turns Expo's precompiled modules off. Expo
30
+ * Podfiles force `EXPO_USE_PRECOMPILED_MODULES` to '0' when
31
+ * `ios/Podfile.properties.json` sets it to "false", whatever the environment says.
32
+ */
33
+ export declare function isPrecompiledExpoDisabledByPodfile(projectRoot: string): boolean;
14
34
  //# sourceMappingURL=supportsPrebuiltExpo.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"supportsPrebuiltExpo.d.ts","sourceRoot":"","sources":["../../../src/brownfield/utils/supportsPrebuiltExpo.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,oCAAoC,KAAK,CAAC;AAEvD,MAAM,MAAM,yBAAyB,GACjC;IAAE,SAAS,EAAE,IAAI,CAAC;IAAC,gBAAgB,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,KAAK,CAAA;CAAE,GAC9D;IAAE,SAAS,EAAE,KAAK,CAAC;IAAC,gBAAgB,CAAC,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnE,wBAAgB,oBAAoB,CAAC,EACnC,WAAW,GACZ,EAAE;IACD,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,yBAAyB,CAqB5B"}
1
+ {"version":3,"file":"supportsPrebuiltExpo.d.ts","sourceRoot":"","sources":["../../../src/brownfield/utils/supportsPrebuiltExpo.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,oCAAoC,KAAK,CAAC;AAEvD,MAAM,MAAM,yBAAyB,GACjC;IACE,SAAS,EAAE,IAAI,CAAC;IAChB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,uEAAuE;IACvE,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,MAAM,CAAC,EAAE,KAAK,CAAC;CAChB,GACD;IAAE,SAAS,EAAE,KAAK,CAAC;IAAC,gBAAgB,CAAC,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAMnE;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc5E;AAED;;;;;GAKG;AACH,wBAAgB,kCAAkC,CAChD,WAAW,EAAE,MAAM,GAClB,MAAM,GAAG,IAAI,CAYf;AAED,wBAAgB,oBAAoB,CAAC,EACnC,WAAW,GACZ,EAAE;IACD,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,yBAAyB,CAsC5B;AAED;;;;GAIG;AACH,wBAAgB,kCAAkC,CAChD,WAAW,EAAE,MAAM,GAClB,OAAO,CAYT"}
@@ -1,5 +1,44 @@
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
+ import { getReactNativeVersion } from '@rock-js/tools';
5
+ import { getExpoIosUsePrecompiledModules, getExpoSdkMajor, isExpoProject } from './project.js';
2
6
  export const MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO = 56;
7
+ function majorMinor(version) {
8
+ return version.split('.').slice(0, 2).join('.');
9
+ }
10
+ /**
11
+ * The React Native version the installed Expo SDK pins. Expo ships its
12
+ * precompiled modules built against this version.
13
+ */
14
+ export function getExpoReactNativeVersion(projectRoot) {
15
+ try {
16
+ const require = createRequire(path.join(projectRoot, 'package.json'));
17
+ const expoPackageJson = require.resolve('expo/package.json');
18
+ const bundledNativeModules = JSON.parse(fs.readFileSync(path.join(path.dirname(expoPackageJson), 'bundledNativeModules.json'), 'utf8'));
19
+ return bundledNativeModules['react-native'] ?? null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /**
26
+ * Describes a React Native version the Expo prebuilts were not built for, or
27
+ * returns null when they match. Prebuilt Expo modules link against React
28
+ * Native's C++ API, which changes between minor versions, so a mismatch
29
+ * crashes at runtime.
30
+ */
31
+ export function getPrebuiltExpoReactNativeMismatch(projectRoot) {
32
+ const expected = getExpoReactNativeVersion(projectRoot);
33
+ const found = getReactNativeVersion(projectRoot);
34
+ if (!expected || found === 'unknown') {
35
+ return null;
36
+ }
37
+ if (majorMinor(expected) === majorMinor(found)) {
38
+ return null;
39
+ }
40
+ return `Expo's precompiled modules are built for React Native ${majorMinor(expected)}, but this project uses ${found}.`;
41
+ }
3
42
  export function supportsPrebuiltExpo({ projectRoot, }) {
4
43
  if (!isExpoProject(projectRoot)) {
5
44
  return { supported: true, enabledByDefault: false };
@@ -13,8 +52,36 @@ export function supportsPrebuiltExpo({ projectRoot, }) {
13
52
  reason: `--use-prebuilt-expo is unsupported in Expo SDK ${sdkLabel}: packaging brownfield with Expo prebuilts requires Expo SDK ${MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO} or newer.`,
14
53
  };
15
54
  }
55
+ const reactNativeMismatch = getPrebuiltExpoReactNativeMismatch(projectRoot);
56
+ if (reactNativeMismatch) {
57
+ return {
58
+ supported: true,
59
+ enabledByDefault: false,
60
+ defaultDisabledReason: reactNativeMismatch,
61
+ };
62
+ }
63
+ if (getExpoIosUsePrecompiledModules(projectRoot) === false) {
64
+ return {
65
+ supported: false,
66
+ reason: "--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.",
67
+ };
68
+ }
16
69
  return {
17
70
  supported: true,
18
71
  enabledByDefault: true,
19
72
  };
20
73
  }
74
+ /**
75
+ * Whether the project's Podfile turns Expo's precompiled modules off. Expo
76
+ * Podfiles force `EXPO_USE_PRECOMPILED_MODULES` to '0' when
77
+ * `ios/Podfile.properties.json` sets it to "false", whatever the environment says.
78
+ */
79
+ export function isPrecompiledExpoDisabledByPodfile(projectRoot) {
80
+ try {
81
+ const properties = JSON.parse(fs.readFileSync(path.join(projectRoot, 'ios', 'Podfile.properties.json'), 'utf8'));
82
+ return properties.EXPO_USE_PRECOMPILED_MODULES === 'false';
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAiFA,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAM3C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6FA,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAM3C"}
package/dist/index.js CHANGED
@@ -5,6 +5,16 @@ import { ExampleUsage } from './shared/index.js';
5
5
  import brownfieldCommands, { groupName as brownfieldCommandsGroupName, } from './brownfield/index.js';
6
6
  import brownieCommands, { groupName as brownieCommandsGroupName, } from './brownie/index.js';
7
7
  import navigationCommands, { groupName as navigationCommandsGroupName, } from './navigation/index.js';
8
+ // CocoaPods (spawned by Rock) raises Encoding::CompatibilityError under a non-UTF-8
9
+ // locale. Ruby resolves the character encoding as LC_ALL > LC_CTYPE > LANG, so check
10
+ // them in that precedence. Default to UTF-8 only when the effective value is missing
11
+ // or non-UTF-8.
12
+ const inheritedCharset = process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || '';
13
+ if (!/utf-?8/i.test(inheritedCharset)) {
14
+ process.env.LANG = 'en_US.UTF-8';
15
+ process.env.LC_CTYPE = 'en_US.UTF-8';
16
+ process.env.LC_ALL = 'en_US.UTF-8';
17
+ }
8
18
  const program = new Command();
9
19
  program
10
20
  .name(styleText('magenta', 'brownfield'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@callstack/brownfield-cli",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "license": "MIT",
5
5
  "author": "Artur Morys-Magiera <artus9033@gmail.com>",
6
6
  "bin": {
@@ -88,11 +88,11 @@
88
88
  "@expo/config": "^12.0.13",
89
89
  "@react-native-community/cli-config": "^20.0.0",
90
90
  "@react-native-community/cli-config-android": "^20.0.0",
91
- "@rock-js/platform-android": "^0.14.0",
92
- "@rock-js/platform-apple-helpers": "^0.14.0",
93
- "@rock-js/plugin-brownfield-android": "^0.14.0",
94
- "@rock-js/plugin-brownfield-ios": "^0.14.0",
95
- "@rock-js/tools": "^0.14.0",
91
+ "@rock-js/platform-android": "^0.15.4",
92
+ "@rock-js/platform-apple-helpers": "^0.15.4",
93
+ "@rock-js/plugin-brownfield-android": "^0.15.4",
94
+ "@rock-js/plugin-brownfield-ios": "^0.15.4",
95
+ "@rock-js/tools": "^0.15.4",
96
96
  "ajv": "^8.20.0",
97
97
  "commander": "^14.0.3",
98
98
  "quicktype-core": "^23.2.6",
@@ -18,8 +18,11 @@ import { Command, Option } from 'commander';
18
18
 
19
19
  import { runExpoPrebuildIfNeeded } from '../utils/expo.js';
20
20
  import { findProjectRoot } from '../utils/paths.js';
21
- import { getProjectInfo } from '../utils/project.js';
22
- import { supportsPrebuiltExpo } from '../utils/supportsPrebuiltExpo.js';
21
+ import { getProjectInfo, isExpoProject } from '../utils/project.js';
22
+ import {
23
+ isPrecompiledExpoDisabledByPodfile,
24
+ supportsPrebuiltExpo,
25
+ } from '../utils/supportsPrebuiltExpo.js';
23
26
  import { supportsPrebuiltRNCore } from '../utils/supportsPrebuiltRNCore.js';
24
27
  import {
25
28
  actionRunner,
@@ -29,6 +32,10 @@ import {
29
32
  import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieCodegenIfApplicable.js';
30
33
  import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js';
31
34
  import { copyDebugBundleToSimulatorSlice } from '../utils/copyDebugBundleToSimulatorSlice.js';
35
+ import {
36
+ collectFrameworkPaths,
37
+ resolveDestinationSdks,
38
+ } from '../utils/destinationSdks.js';
32
39
  import { resolvePackagedFrameworkName } from '../utils/resolvePackagedFrameworkName.js';
33
40
  import { stripFrameworkBinary } from '../utils/stripFrameworkBinary.js';
34
41
  import type { PackageIosOptions } from '../../types.js';
@@ -75,9 +82,7 @@ export function parseUsePrebuiltRnCoreArgument(
75
82
  return parseBooleanOptionArgument('--use-prebuilt-rn-core', value);
76
83
  }
77
84
 
78
- export function parseUsePrebuiltExpoArgument(
79
- value: string | boolean
80
- ): boolean {
85
+ export function parseUsePrebuiltExpoArgument(value: string | boolean): boolean {
81
86
  return parseBooleanOptionArgument('--use-prebuilt-expo', value);
82
87
  }
83
88
 
@@ -125,6 +130,11 @@ export const packageIosCommand = curryOptions(
125
130
  .action(
126
131
  actionRunner(async (cliOptions: PackageIosOptions) => {
127
132
  const options = mergeBrownfieldConfigWithOptions(cliOptions, 'ios');
133
+ // captured before the version-aware default below, so that a value from
134
+ // brownfield.config.json counts as explicit
135
+ const usePrebuiltExpoExplicit = isUsePrebuiltExpoExplicit(
136
+ options.usePrebuiltExpo
137
+ );
128
138
  const projectRoot = findProjectRoot();
129
139
 
130
140
  await runExpoPrebuildIfNeeded({ projectRoot, platform: 'ios' });
@@ -139,10 +149,68 @@ export const packageIosCommand = curryOptions(
139
149
  ? prebuiltRNCoreSupport.enabledByDefault
140
150
  : false;
141
151
 
142
- // version-aware default when the flag is omitted
143
- options.usePrebuiltExpo ??= prebuiltExpoSupport.supported
144
- ? prebuiltExpoSupport.enabledByDefault
145
- : false;
152
+ const usePrebuiltExpoWasPassed = options.usePrebuiltExpo !== undefined;
153
+ const precompiledExpoDisabledByPodfile =
154
+ isExpoProject(projectRoot) &&
155
+ isPrecompiledExpoDisabledByPodfile(projectRoot);
156
+
157
+ if (precompiledExpoDisabledByPodfile) {
158
+ if (options.usePrebuiltExpo) {
159
+ logger.warn(
160
+ 'ios/Podfile.properties.json sets EXPO_USE_PRECOMPILED_MODULES to "false", so pod install builds Expo modules from source and --use-prebuilt-expo will fail. Remove that property or pass --use-prebuilt-expo false.'
161
+ );
162
+ } else if (!usePrebuiltExpoWasPassed) {
163
+ logger.info(
164
+ 'ios/Podfile.properties.json turns Expo precompiled modules off. Building Expo modules from source.'
165
+ );
166
+ options.usePrebuiltExpo = false;
167
+ }
168
+ } else if (
169
+ !usePrebuiltExpoWasPassed &&
170
+ isExpoProject(projectRoot) &&
171
+ process.env.EXPO_USE_PRECOMPILED_MODULES === '0'
172
+ ) {
173
+ // Respect an exported opt-out, the same way Expo's Podfile does.
174
+ logger.info(
175
+ 'EXPO_USE_PRECOMPILED_MODULES is set to 0. Building Expo modules from source.'
176
+ );
177
+ options.usePrebuiltExpo = false;
178
+ }
179
+
180
+ const usePrebuiltExpoWasChosen =
181
+ usePrebuiltExpoWasPassed || options.usePrebuiltExpo !== undefined;
182
+
183
+ // version-aware default when the flag is omitted; Expo's precompiled
184
+ // modules also need the prebuilt React Native core
185
+ if (options.usePrebuiltExpo === undefined) {
186
+ options.usePrebuiltExpo = prebuiltExpoSupport.supported
187
+ ? prebuiltExpoSupport.enabledByDefault && !!options.usePrebuiltRnCore
188
+ : false;
189
+
190
+ if (
191
+ prebuiltExpoSupport.enabledByDefault &&
192
+ !options.usePrebuiltRnCore
193
+ ) {
194
+ logger.info(
195
+ 'Expo precompiled modules need the prebuilt React Native core, which is off. Building Expo modules from source.'
196
+ );
197
+ }
198
+ }
199
+
200
+ if (
201
+ prebuiltExpoSupport.supported &&
202
+ prebuiltExpoSupport.defaultDisabledReason
203
+ ) {
204
+ if (usePrebuiltExpoWasChosen && options.usePrebuiltExpo) {
205
+ logger.warn(
206
+ `${prebuiltExpoSupport.defaultDisabledReason} The packaged app will likely crash at runtime. Pass --use-prebuilt-expo false to build Expo modules from source.`
207
+ );
208
+ } else if (!usePrebuiltExpoWasChosen) {
209
+ logger.info(
210
+ `${prebuiltExpoSupport.defaultDisabledReason} Building Expo modules from source.`
211
+ );
212
+ }
213
+ }
146
214
 
147
215
  if (prebuiltRNCoreSupport) {
148
216
  logger.info(
@@ -167,6 +235,31 @@ export const packageIosCommand = curryOptions(
167
235
  throw new RockError(prebuiltExpoSupport.reason);
168
236
  }
169
237
 
238
+ if (isExpoProject(projectRoot)) {
239
+ // Read by Expo's autolinking during `pod install`. Expo-generated
240
+ // Podfiles default it to on, but a React Native CLI app's Podfile
241
+ // never sets it, so the flag has to drive it.
242
+ process.env.EXPO_USE_PRECOMPILED_MODULES = options.usePrebuiltExpo
243
+ ? '1'
244
+ : '0';
245
+ }
246
+
247
+ if (
248
+ options.usePrebuiltExpo &&
249
+ !options.extraParams?.some((param) =>
250
+ param.startsWith('SWIFT_ENABLE_EXPLICIT_MODULES=')
251
+ )
252
+ ) {
253
+ // Precompiled Expo builds the React Native pods as static libraries.
254
+ // With explicit modules off (a leftover older React Native versions
255
+ // wrote into app projects), Xcode cannot find their module maps when
256
+ // verifying the framework's Swift interface.
257
+ options.extraParams = [
258
+ ...(options.extraParams ?? []),
259
+ 'SWIFT_ENABLE_EXPLICIT_MODULES=YES',
260
+ ];
261
+ }
262
+
170
263
  if (!userConfig.project.ios) {
171
264
  throw new Error('iOS project not found.');
172
265
  }
@@ -197,6 +290,9 @@ export const packageIosCommand = curryOptions(
197
290
  // Reference: https://github.com/facebook/react-native/blob/490c5e8dcc6cdb19c334cc39e93a39a48ba71e96/packages/react-native/scripts/cocoapods/new_architecture.rb#L171
198
291
  const packageDir = path.join(dotBrownfieldDir, 'package', 'build');
199
292
  const configuration = options.configuration ?? 'Debug';
293
+ // `--destination` narrows which slices Xcode emits, so every path below has to
294
+ // look at those slices only instead of assuming a device + simulator pair
295
+ const sdks = resolveDestinationSdks(options.destination);
200
296
 
201
297
  const { hasBrownie } = await runBrownieCodegenIfApplicable(
202
298
  projectRoot,
@@ -225,6 +321,11 @@ export const packageIosCommand = curryOptions(
225
321
  projectRoot,
226
322
  packageDir,
227
323
  usePrebuiltExpo: options.usePrebuiltExpo,
324
+ usePrebuiltExpoExplicit,
325
+ // keep the generated SPM manifest consistent with the emitted XCFrameworks
326
+ onDegradeToSource: () => {
327
+ options.usePrebuiltExpo = false;
328
+ },
228
329
  });
229
330
 
230
331
  const productsPath = path.join(options.buildFolder, 'Build', 'Products');
@@ -233,6 +334,7 @@ export const packageIosCommand = curryOptions(
233
334
  explicitScheme: options.scheme,
234
335
  productsPath,
235
336
  configuration,
337
+ sdks,
236
338
  });
237
339
 
238
340
  if (!frameworkName && options.addSpmPackage) {
@@ -251,24 +353,19 @@ export const packageIosCommand = curryOptions(
251
353
  productsPath,
252
354
  configuration,
253
355
  frameworkName,
356
+ sdks,
254
357
  });
255
358
 
256
359
  if (configuration.includes('Debug')) {
257
360
  // Re-merge only Debug frameworks so the simulator slice includes main.jsbundle.
258
361
  await mergeFrameworks({
259
362
  sourceDir: userConfig.project.ios.sourceDir,
260
- frameworkPaths: [
261
- path.join(
262
- productsPath,
263
- `${configuration}-iphoneos`,
264
- `${frameworkName}.framework`
265
- ),
266
- path.join(
267
- productsPath,
268
- `${configuration}-iphonesimulator`,
269
- `${frameworkName}.framework`
270
- ),
271
- ],
363
+ frameworkPaths: collectFrameworkPaths({
364
+ productsPath,
365
+ configuration,
366
+ sdks,
367
+ frameworkName,
368
+ }),
272
369
  outputPath: path.join(packageDir, `${frameworkName}.xcframework`),
273
370
  });
274
371
  }
@@ -300,20 +397,13 @@ export const packageIosCommand = curryOptions(
300
397
 
301
398
  await mergeFrameworks({
302
399
  sourceDir: userConfig.project.ios.sourceDir,
303
- frameworkPaths: [
304
- path.join(
305
- productsPath,
306
- `${configuration}-iphoneos`,
307
- 'Brownie',
308
- 'Brownie.framework'
309
- ),
310
- path.join(
311
- productsPath,
312
- `${configuration}-iphonesimulator`,
313
- 'Brownie',
314
- 'Brownie.framework'
315
- ),
316
- ],
400
+ frameworkPaths: collectFrameworkPaths({
401
+ productsPath,
402
+ configuration,
403
+ sdks,
404
+ frameworkName: 'Brownie',
405
+ productSubDir: 'Brownie',
406
+ }),
317
407
  outputPath: brownieOutputPath,
318
408
  });
319
409
 
@@ -335,20 +425,13 @@ export const packageIosCommand = curryOptions(
335
425
 
336
426
  await mergeFrameworks({
337
427
  sourceDir: userConfig.project.ios.sourceDir,
338
- frameworkPaths: [
339
- path.join(
340
- productsPath,
341
- `${configuration}-iphoneos`,
342
- 'BrownfieldNavigation',
343
- 'BrownfieldNavigation.framework'
344
- ),
345
- path.join(
346
- productsPath,
347
- `${configuration}-iphonesimulator`,
348
- 'BrownfieldNavigation',
349
- 'BrownfieldNavigation.framework'
350
- ),
351
- ],
428
+ frameworkPaths: collectFrameworkPaths({
429
+ productsPath,
430
+ configuration,
431
+ sdks,
432
+ frameworkName: 'BrownfieldNavigation',
433
+ productSubDir: 'BrownfieldNavigation',
434
+ }),
352
435
  outputPath: brownfieldNavigationOutputPath,
353
436
  });
354
437
 
@@ -378,9 +461,20 @@ export const packageIosCommand = curryOptions(
378
461
  'In Xcode, choose File > Add Package Dependencies..., click Add Local..., and select that folder.'
379
462
  );
380
463
  }
464
+
465
+ // The dependency prints its own "Success" before the post-build steps above,
466
+ // so log a final line marking the true end of the run.
467
+ logger.success(
468
+ `package:ios finished. Artifacts are in ${colorLink(relativeToCwd(packageDir))}`
469
+ );
381
470
  })
382
471
  );
383
472
 
473
+ /** Whether `--use-prebuilt-expo` was chosen (CLI or brownfield.config.json) rather than inferred. */
474
+ export function isUsePrebuiltExpoExplicit(value: boolean | undefined) {
475
+ return value !== undefined;
476
+ }
477
+
384
478
  export const packageIosExample = new ExampleUsage(
385
479
  'package:ios --scheme BrownfieldLib --configuration Release',
386
480
  "Build iOS XCFramework for 'BrownfieldLib' scheme in Release configuration"
@@ -3,21 +3,31 @@ import path from 'node:path';
3
3
 
4
4
  import { colorLink, logger, relativeToCwd } from '@rock-js/tools';
5
5
 
6
+ import { ALL_APPLE_SDKS, type AppleSdk } from './destinationSdks.js';
7
+
6
8
  interface CopyDebugBundleToSimulatorSliceOptions {
7
9
  productsPath: string;
8
10
  configuration: string;
9
11
  frameworkName: string;
12
+ /** Slices the build produced; defaults to both. */
13
+ sdks?: AppleSdk[];
10
14
  }
11
15
 
12
16
  export function copyDebugBundleToSimulatorSlice({
13
17
  productsPath,
14
18
  configuration,
15
19
  frameworkName,
20
+ sdks = ALL_APPLE_SDKS,
16
21
  }: CopyDebugBundleToSimulatorSliceOptions) {
17
22
  if (!configuration.includes('Debug')) {
18
23
  return;
19
24
  }
20
25
 
26
+ // nothing to copy across when only one slice was built
27
+ if (!sdks.includes('iphoneos') || !sdks.includes('iphonesimulator')) {
28
+ return;
29
+ }
30
+
21
31
  const deviceBundlePath = path.join(
22
32
  productsPath,
23
33
  `${configuration}-iphoneos`,
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { RockError } from '@rock-js/tools';
5
+
6
+ /** Xcode SDK a build product directory belongs to (`Release-iphoneos`, `Release-iphonesimulator`). */
7
+ export type AppleSdk = 'iphoneos' | 'iphonesimulator';
8
+
9
+ /** Both slices, in the order Rock's `packageIosAction` builds them. */
10
+ export const ALL_APPLE_SDKS: AppleSdk[] = ['iphoneos', 'iphonesimulator'];
11
+
12
+ /**
13
+ * Map a single `--destination` value onto the SDK(s) its build products land in.
14
+ *
15
+ * Mirrors `resolveDestination` in `@rock-js/platform-apple-helpers`: the aliases
16
+ * `device`/`simulator` plus any raw `xcodebuild -destination` string. A value we
17
+ * cannot classify (`id=<udid>`, `name=iPhone 17`) may resolve to either SDK, so it
18
+ * widens to both rather than silently dropping a slice.
19
+ */
20
+ function resolveSdksForDestination(destination: string): AppleSdk[] {
21
+ const normalized = destination.trim().toLowerCase();
22
+
23
+ if (normalized === 'device') {
24
+ return ['iphoneos'];
25
+ }
26
+
27
+ if (normalized === 'simulator' || normalized.includes('simulator')) {
28
+ return ['iphonesimulator'];
29
+ }
30
+
31
+ if (normalized.includes('platform=ios')) {
32
+ return ['iphoneos'];
33
+ }
34
+
35
+ return ALL_APPLE_SDKS;
36
+ }
37
+
38
+ /**
39
+ * SDK slices a `package:ios` run produces for the given `--destination` values.
40
+ *
41
+ * Omitting `--destination` builds both slices, matching Rock's default.
42
+ */
43
+ export function resolveDestinationSdks(
44
+ destination?: string[] | null
45
+ ): AppleSdk[] {
46
+ if (!destination || destination.length === 0) {
47
+ return ALL_APPLE_SDKS;
48
+ }
49
+
50
+ const sdks = new Set(destination.flatMap(resolveSdksForDestination));
51
+
52
+ // keep a stable order regardless of the order the flags were passed in
53
+ return ALL_APPLE_SDKS.filter((sdk) => sdks.has(sdk));
54
+ }
55
+
56
+ /**
57
+ * Whether `directoryPath` holds a build product `mergeFrameworks` can consume:
58
+ * either a real `.framework` bundle or the static library it synthesizes one from.
59
+ */
60
+ export function hasFrameworkBuildProduct(
61
+ directoryPath: string,
62
+ frameworkName: string
63
+ ) {
64
+ return (
65
+ fs.existsSync(path.join(directoryPath, `${frameworkName}.framework`)) ||
66
+ fs.existsSync(path.join(directoryPath, `lib${frameworkName}.a`))
67
+ );
68
+ }
69
+
70
+ interface CollectFrameworkPathsOptions {
71
+ productsPath: string;
72
+ configuration: string;
73
+ sdks: AppleSdk[];
74
+ frameworkName: string;
75
+ /** Sub-directory of the configuration products dir, e.g. `Brownie` for a Pod target. */
76
+ productSubDir?: string;
77
+ }
78
+
79
+ /**
80
+ * `.framework` paths to merge into an XCFramework, limited to the slices that were
81
+ * actually built. Slices with no build product on disk are dropped, so a
82
+ * `--destination simulator` run merges the simulator slice alone instead of failing
83
+ * on a missing `-iphoneos` directory.
84
+ *
85
+ * Throws when no slice has a build product: passing an empty list to
86
+ * `mergeFrameworks` would surface as xcodebuild's opaque
87
+ * "at least one framework or library must be specified".
88
+ */
89
+ export function collectFrameworkPaths({
90
+ productsPath,
91
+ configuration,
92
+ sdks,
93
+ frameworkName,
94
+ productSubDir,
95
+ }: CollectFrameworkPathsOptions): string[] {
96
+ const searchedDirectories = sdks.map((sdk) =>
97
+ path.join(
98
+ productsPath,
99
+ `${configuration}-${sdk}`,
100
+ ...(productSubDir ? [productSubDir] : [])
101
+ )
102
+ );
103
+
104
+ const frameworkPaths = searchedDirectories
105
+ .filter((directoryPath) =>
106
+ hasFrameworkBuildProduct(directoryPath, frameworkName)
107
+ )
108
+ .map((directoryPath) =>
109
+ path.join(directoryPath, `${frameworkName}.framework`)
110
+ );
111
+
112
+ if (frameworkPaths.length === 0) {
113
+ throw new RockError(
114
+ `Could not find a build product for ${frameworkName} in the ${configuration} configuration. ` +
115
+ `Looked for ${frameworkName}.framework or lib${frameworkName}.a in:\n` +
116
+ searchedDirectories.map((dir) => ` - ${dir}`).join('\n') +
117
+ `\nIf the build produced an .app instead of a framework, the wrong scheme was built; pass --scheme with your brownfield framework scheme.`
118
+ );
119
+ }
120
+
121
+ return frameworkPaths;
122
+ }