From 6160bf2c6d10b16b83c5dd82cc584cdbe9a2c089 Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 11:47:03 +0200 Subject: [PATCH 01/14] docs: document setup for schema json in IDEs --- docs/docs/docs/api-reference/configuration.mdx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/docs/docs/api-reference/configuration.mdx b/docs/docs/docs/api-reference/configuration.mdx index b24783d2..a0045e04 100644 --- a/docs/docs/docs/api-reference/configuration.mdx +++ b/docs/docs/docs/api-reference/configuration.mdx @@ -46,6 +46,14 @@ module.exports = { If you want schema autocomplete and validation directly in the config file, use `react-native-brownfield.config.json`: +> [!TIP] +> +> The schema URL is not required, but it's recommended to include it for autocomplete and validation. If you want to use it, you may need to configure `oss.callstack.com` as a trusted domain in your IDE. +> +> In VS Code, you can do it by adding `oss.callstack.com` to `JSON › Schema Download: Trusted Domains` in your preferences. +> +> In Cursor, it is sufficient to enable `JSON › Schema Download: Enable` in preferences. + ```json { "$schema": "https://oss.callstack.com/react-native-brownfield/schema.json", From f6fa9463036884d266ef44030cb61bc72f7f13a7 Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 11:58:21 +0200 Subject: [PATCH 02/14] fix: cli to dev-depend on prettier to make binary available to scripts --- packages/cli/package.json | 1 + packages/cli/schema.json | 12 +++++++++--- yarn.lock | 1 + 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index e4ff5d50..ce8a20d3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -108,6 +108,7 @@ "eslint": "^9.39.3", "globals": "^17.3.0", "nodemon": "^3.1.14", + "prettier": "^3.8.1", "ts-json-schema-generator": "^2.9.0", "typescript": "5.9.3", "vitest": "^4.1.4" diff --git a/packages/cli/schema.json b/packages/cli/schema.json index 00ee5b71..addcf6fb 100644 --- a/packages/cli/schema.json +++ b/packages/cli/schema.json @@ -21,15 +21,19 @@ "type": "string" }, "android": { - "$ref": "#/definitions/BrownfieldAndroidConfig" + "$ref": "#/definitions/BrownfieldAndroidConfig", + "description": "Brownfield Android configuration." }, "brownie": { - "$ref": "#/definitions/BrownieConfig" + "$ref": "#/definitions/BrownieConfig", + "description": "Brownie (state library) configuration." }, "ios": { - "$ref": "#/definitions/BrownfieldIosConfig" + "$ref": "#/definitions/BrownfieldIosConfig", + "description": "Brownfield iOS configuration." }, "verbose": { + "description": "Enables verbose CLI logging.", "type": "boolean" } }, @@ -98,9 +102,11 @@ "additionalProperties": false, "properties": { "kotlin": { + "description": "The output path to generate Kotlin Brownie store files at.", "type": "string" }, "kotlinPackageName": { + "description": "The package name for the Kotlin source code.", "type": "string" } }, diff --git a/yarn.lock b/yarn.lock index 0071766a..908e5e7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1693,6 +1693,7 @@ __metadata: eslint: "npm:^9.39.3" globals: "npm:^17.3.0" nodemon: "npm:^3.1.14" + prettier: "npm:^3.8.1" quicktype-core: "npm:^23.2.6" quicktype-typescript-input: "npm:^23.2.6" ts-json-schema-generator: "npm:^2.9.0" From c977406be1369403f32eddbcb9a4ce2d90e68eea Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 13:49:49 +0200 Subject: [PATCH 03/14] feat: add missing schema typings comments --- .changeset/young-snails-lose.md | 5 +++++ packages/cli/src/types.ts | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 .changeset/young-snails-lose.md diff --git a/.changeset/young-snails-lose.md b/.changeset/young-snails-lose.md new file mode 100644 index 00000000..69abe525 --- /dev/null +++ b/.changeset/young-snails-lose.md @@ -0,0 +1,5 @@ +--- +'@callstack/brownfield-cli': patch +--- + +fix: generate config schema with descriptions, add more descriptions diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 33d04cd6..b27708a3 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -7,6 +7,9 @@ import type { BuildFlags as AppleBuildFlags } from '@rock-js/platform-apple-help export type Platform = 'swift' | 'kotlin'; export type BrownfieldCommonOptions = Partial<{ + /** + * Enables verbose CLI logging. + */ verbose: boolean; }>; @@ -15,13 +18,21 @@ export type BrownfieldConfigMetadata = Partial<{ }>; export type BrownieConfig = { + /** + * The output path to generate Kotlin Brownie store files at. + */ kotlin?: string; + + /** + * The package name for the Kotlin source code. + */ kotlinPackageName?: string; }; export type PackageIosOptions = AppleBuildFlags & { /** Set when `--use-prebuilt-rn-core` is passed; omitted when the flag is absent (Rock applies RN version defaults). */ usePrebuiltRnCore?: boolean; + /** When set, generate a local Swift Package Manager manifest next to the packaged XCFramework outputs. */ addSpmPackage?: boolean; }; @@ -45,7 +56,18 @@ export type BrownfieldIosConfig = Omit< export type BrownfieldConfig = BrownfieldConfigMetadata & BrownfieldCommonOptions & Partial<{ + /** + * Brownfield Android configuration. + */ android: BrownfieldAndroidConfig; + + /** + * Brownfield iOS configuration. + */ ios: BrownfieldIosConfig; + + /** + * Brownie (state library) configuration. + */ brownie: BrownieConfig; }>; From 3de0a2749b013a92c82ec6e347195d2bba7f15db Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 14:23:08 +0200 Subject: [PATCH 04/14] feat: drop obsolete options from config file --- docs/docs/docs/api-reference/configuration.mdx | 2 -- docs/docs/docs/cli/brownfield.mdx | 4 +--- packages/cli/schema.json | 6 ------ packages/cli/src/types.ts | 2 +- 4 files changed, 2 insertions(+), 12 deletions(-) diff --git a/docs/docs/docs/api-reference/configuration.mdx b/docs/docs/docs/api-reference/configuration.mdx index a0045e04..5577a06c 100644 --- a/docs/docs/docs/api-reference/configuration.mdx +++ b/docs/docs/docs/api-reference/configuration.mdx @@ -124,13 +124,11 @@ All file-based platform options mirror CLI flags, but they use camelCase propert | `ios.target` | `string` | Explicit Xcode target name. | | `ios.destination` | `string[]` | One or more Xcode destinations, such as `simulator`, `device`, or full destination strings. | | `ios.buildFolder` | `string` | Custom build output directory. By default, Brownfield uses the `.brownfield/build` path inside the iOS project. | -| `ios.archive` | `boolean` | Creates an archive build suitable for IPA export and distribution. | | `ios.extraParams` | `string[]` | Extra arguments passed to `xcodebuild`. | | `ios.exportExtraParams` | `string[]` | Extra arguments passed to the archive export step. | | `ios.exportOptionsPlist` | `string` | Export options plist filename used during archive export. | | `ios.installPods` | `boolean` | Controls automatic CocoaPods installation. Set `false` to match `--no-install-pods`. | | `ios.newArch` | `boolean` | Controls React Native new architecture support. Set `false` to match `--no-new-arch`. | -| `ios.local` | `boolean` | Forces a local `xcodebuild` flow. | | `ios.usePrebuiltRnCore` | `boolean` | Controls whether iOS packaging uses React Native Apple prebuilts. Omit it to keep Brownfield's version-aware defaults. | | `ios.addSpmPackage` | `boolean` | Generates a local Swift Package Manager manifest next to the packaged XCFramework outputs. | diff --git a/docs/docs/docs/cli/brownfield.mdx b/docs/docs/docs/cli/brownfield.mdx index 4fb142ba..2f30421a 100644 --- a/docs/docs/docs/cli/brownfield.mdx +++ b/docs/docs/docs/cli/brownfield.mdx @@ -36,12 +36,10 @@ Available arguments: | --export-options-plist | Name of the export options file for archiving. Defaults to: `ExportOptions.plist` | | --build-folder | Location for iOS build artifacts. Corresponds to Xcode's "-derivedDataPath". By default, the '\/.brownfield/build' path will be used. | | --destination | Define destination(s) for the build. You can pass multiple destinations as separate values or repeated use of the flag. Values: "simulator", "device", or xcodebuild destinations | -| --archive | Create an Xcode archive (IPA) of the build, required for uploading to App Store Connect or distributing to TestFlight | -| --add-spm-package | Generate a local `Package.swift` next to the packaged XCFramework outputs so the folder can be added to Xcode as a local Swift Package | +| --add-spm-package | Generate a local `Package.swift` next to the packaged XCFramework outputs so the folder can be added to Xcode as a local Swift Package | | --use-prebuilt-rn-core [bool] | Controls usage of React Native Apple prebuilt binaries for the packaging Xcode build. Omit for version-aware defaults (see [Getting Started — iOS — React Native Prebuilts](/docs/getting-started/ios#react-native-prebuilts)). Pass `true`, `false`, or use the flag without a value as shorthand for `true`. Supported only for Expo 55+ OR vanilla RN >= 0.81. | | --no-install-pods | Skip automatic CocoaPods installation | | --no-new-arch | Run React Native in legacy async architecture | -| --local | Force local build with xcodebuild | The build directory will be placed in the `/.brownfield/build` folder by default and the build outputs (XCFrameworks) will be created in the `/.brownfield/package/build` folder: diff --git a/packages/cli/schema.json b/packages/cli/schema.json index addcf6fb..e5a60f19 100644 --- a/packages/cli/schema.json +++ b/packages/cli/schema.json @@ -46,9 +46,6 @@ "description": "When set, generate a local Swift Package Manager manifest next to the packaged XCFramework outputs.", "type": "boolean" }, - "archive": { - "type": "boolean" - }, "buildFolder": { "type": "string" }, @@ -79,9 +76,6 @@ "installPods": { "type": "boolean" }, - "local": { - "type": "boolean" - }, "newArch": { "type": "boolean" }, diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index b27708a3..fa316512 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -29,7 +29,7 @@ export type BrownieConfig = { kotlinPackageName?: string; }; -export type PackageIosOptions = AppleBuildFlags & { +export type PackageIosOptions = Omit & { /** Set when `--use-prebuilt-rn-core` is passed; omitted when the flag is absent (Rock applies RN version defaults). */ usePrebuiltRnCore?: boolean; From 06d17529010263d142a2d057377457fe3dc4ff4c Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 17:11:25 +0200 Subject: [PATCH 05/14] chore: bump up Rock to downstream descriptions for schema --- apps/AppleApp/package.json | 2 +- packages/cli/package.json | 10 +++--- packages/cli/schema.json | 12 +++++++ yarn.lock | 64 +++++++++++++++++++------------------- 4 files changed, 50 insertions(+), 38 deletions(-) diff --git a/apps/AppleApp/package.json b/apps/AppleApp/package.json index 534b5501..4f732cb0 100644 --- a/apps/AppleApp/package.json +++ b/apps/AppleApp/package.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@callstack/brownfield-example-shared-tests": "workspace:^", - "@rock-js/tools": "^0.13.3", + "@rock-js/tools": "^0.13.5", "detox": "^20.27.0", "jest": "^29.7.0" } diff --git a/packages/cli/package.json b/packages/cli/package.json index ce8a20d3..a53fa2cd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -83,11 +83,11 @@ "@expo/config": "^12.0.13", "@react-native-community/cli-config": "^20.0.0", "@react-native-community/cli-config-android": "^20.0.0", - "@rock-js/platform-android": "^0.13.3", - "@rock-js/platform-apple-helpers": "^0.13.3", - "@rock-js/plugin-brownfield-android": "^0.13.3", - "@rock-js/plugin-brownfield-ios": "^0.13.3", - "@rock-js/tools": "^0.13.3", + "@rock-js/platform-android": "^0.13.5", + "@rock-js/platform-apple-helpers": "^0.13.5", + "@rock-js/plugin-brownfield-android": "^0.13.5", + "@rock-js/plugin-brownfield-ios": "^0.13.5", + "@rock-js/tools": "^0.13.5", "ajv": "^8.20.0", "commander": "^14.0.3", "quicktype-core": "^23.2.6", diff --git a/packages/cli/schema.json b/packages/cli/schema.json index e5a60f19..25e22adc 100644 --- a/packages/cli/schema.json +++ b/packages/cli/schema.json @@ -6,9 +6,11 @@ "additionalProperties": false, "properties": { "moduleName": { + "description": "AAR module name.", "type": "string" }, "variant": { + "description": "Your app's build variant, which is constructed from build type and product flavor, e.g. 'debug' or 'freeRelease'.", "type": "string" } }, @@ -47,42 +49,52 @@ "type": "boolean" }, "buildFolder": { + "description": "Location for iOS build artifacts.", "type": "string" }, "configuration": { + "description": "Xcode scheme configuration (case sensitive).", "type": "string" }, "destination": { + "description": "Destination(s) for the build. You can pass multiple destinations as separate values or repeated use of the flag. Values can be either: \"simulator\", \"device\" or destinations supported by \"xcodebuild -destination\" flag, e.g. \"generic/platform=iOS\".", "items": { "type": "string" }, "type": "array" }, "exportExtraParams": { + "description": "Custom xcodebuild export archive parameters.", "items": { "type": "string" }, "type": "array" }, "exportOptionsPlist": { + "description": "Export options file name for archiving (default: ExportOptions.plist).", "type": "string" }, "extraParams": { + "description": "Custom xcodebuild parameters.", "items": { "type": "string" }, "type": "array" }, "installPods": { + "description": "Whether to install CocoaPods.", "type": "boolean" }, "newArch": { + "description": "Whether to build in new architecture.", "type": "boolean" }, "scheme": { + "description": "Xcode scheme to use.", "type": "string" }, "target": { + "description": "Xcode target to use.", "type": "string" }, "usePrebuiltRnCore": { diff --git a/yarn.lock b/yarn.lock index 908e5e7c..bee3be11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1679,11 +1679,11 @@ __metadata: "@react-native-community/cli-types": "npm:^20.0.0" "@react-native/babel-preset": "npm:0.82.1" "@react-native/eslint-config": "npm:0.82.1" - "@rock-js/platform-android": "npm:^0.13.3" - "@rock-js/platform-apple-helpers": "npm:^0.13.3" - "@rock-js/plugin-brownfield-android": "npm:^0.13.3" - "@rock-js/plugin-brownfield-ios": "npm:^0.13.3" - "@rock-js/tools": "npm:^0.13.3" + "@rock-js/platform-android": "npm:^0.13.5" + "@rock-js/platform-apple-helpers": "npm:^0.13.5" + "@rock-js/plugin-brownfield-android": "npm:^0.13.5" + "@rock-js/plugin-brownfield-ios": "npm:^0.13.5" + "@rock-js/tools": "npm:^0.13.5" "@types/babel__core": "npm:^7.20.5" "@types/babel__preset-env": "npm:^7.10.0" "@types/node": "npm:^25.5.0" @@ -1810,7 +1810,7 @@ __metadata: resolution: "@callstack/brownfield-example-ios-app@workspace:apps/AppleApp" dependencies: "@callstack/brownfield-example-shared-tests": "workspace:^" - "@rock-js/tools": "npm:^0.13.3" + "@rock-js/tools": "npm:^0.13.5" detox: "npm:^20.27.0" jest: "npm:^29.7.0" languageName: unknown @@ -5837,59 +5837,59 @@ __metadata: languageName: node linkType: hard -"@rock-js/platform-android@npm:^0.13.3": - version: 0.13.3 - resolution: "@rock-js/platform-android@npm:0.13.3" +"@rock-js/platform-android@npm:^0.13.5": + version: 0.13.5 + resolution: "@rock-js/platform-android@npm:0.13.5" dependencies: "@react-native-community/cli-config-android": "npm:^20.0.0" - "@rock-js/tools": "npm:^0.13.3" + "@rock-js/tools": "npm:^0.13.5" tslib: "npm:^2.3.0" - checksum: 10/00c15b4364b8f168d0159529782604ce22d67fc1e0dd05b25168e5cf8feb3226f3190d1d8a3995fe2b3eb937d6b016a422a6d07f66a32f163a9ea1db3f85e97c + checksum: 10/21819b2d76ae8277ebcf3798598bdc89bc27c09c218ddae7ffb5498e4e9e597e7294aebc5b5381417991576e48ec273a7c21c24b088616eb2126addf70a1d7ae languageName: node linkType: hard -"@rock-js/platform-apple-helpers@npm:^0.13.3": - version: 0.13.3 - resolution: "@rock-js/platform-apple-helpers@npm:0.13.3" +"@rock-js/platform-apple-helpers@npm:^0.13.5": + version: 0.13.5 + resolution: "@rock-js/platform-apple-helpers@npm:0.13.5" dependencies: "@react-native-community/cli-config": "npm:^20.0.0" "@react-native-community/cli-config-apple": "npm:^20.0.0" - "@rock-js/tools": "npm:^0.13.3" + "@rock-js/tools": "npm:^0.13.5" adm-zip: "npm:^0.5.16" fast-xml-parser: "npm:^4.5.0" tslib: "npm:^2.3.0" - checksum: 10/3e66ce7833bbe85ea035540087d17047d69d455b4c25116eb88377cc589319e6cecd1bcaee525011f5ec85ce727029ac6f2b13760dfc6f174495ea95e7cc3fde + checksum: 10/e53171a46e2ad0db574009ba4d66fcb6b22f5243d334662cb506c9d2aaf60574bf30f9537204e84bdf486627b2796da47cd7592a5aaaaba72ea3ca345bb31b10 languageName: node linkType: hard -"@rock-js/plugin-brownfield-android@npm:^0.13.3": - version: 0.13.3 - resolution: "@rock-js/plugin-brownfield-android@npm:0.13.3" +"@rock-js/plugin-brownfield-android@npm:^0.13.5": + version: 0.13.5 + resolution: "@rock-js/plugin-brownfield-android@npm:0.13.5" dependencies: "@react-native-community/cli-config-android": "npm:^20.0.0" - "@rock-js/platform-android": "npm:^0.13.3" - "@rock-js/tools": "npm:^0.13.3" + "@rock-js/platform-android": "npm:^0.13.5" + "@rock-js/tools": "npm:^0.13.5" tslib: "npm:^2.3.0" - checksum: 10/ecb183c2fbeb1b989b2340e4abd20030500df74bdb629855800ed97cd1a83c2e4a671890d65f11e8f802d00b0e36619bef64c09a7a39f071403ba52d9d291791 + checksum: 10/77a32e463986b7f08919dfa54d6cb54868cc0f38de6ab381a5e9a07b549cc4c9b23f6e95135373d529b0619b0548172fa19cbc9affc98d0fabd79dff56ef8853 languageName: node linkType: hard -"@rock-js/plugin-brownfield-ios@npm:^0.13.3": - version: 0.13.3 - resolution: "@rock-js/plugin-brownfield-ios@npm:0.13.3" +"@rock-js/plugin-brownfield-ios@npm:^0.13.5": + version: 0.13.5 + resolution: "@rock-js/plugin-brownfield-ios@npm:0.13.5" dependencies: "@react-native-community/cli-config-apple": "npm:^20.0.0" "@react-native-community/cli-types": "npm:^20.0.0" - "@rock-js/platform-apple-helpers": "npm:^0.13.3" - "@rock-js/tools": "npm:^0.13.3" + "@rock-js/platform-apple-helpers": "npm:^0.13.5" + "@rock-js/tools": "npm:^0.13.5" tslib: "npm:^2.3.0" - checksum: 10/4dbd9c3f4a0985126c946f30d910fcdadf110e8f1ace3c188105128f007080dfd42b51d7bb18b65a55d1f7af8fff562e0113cffe8475bfd8398a2cc87cad951f + checksum: 10/9b39f350286b463575621efba9cd0a4496e8cc8ed601e1b29649afca73f2ed8529a6b7738ef7f351b815a2e3e068fedca6aa8fe0ab80c2521aae4d23da33e994 languageName: node linkType: hard -"@rock-js/tools@npm:^0.13.3": - version: 0.13.3 - resolution: "@rock-js/tools@npm:0.13.3" +"@rock-js/tools@npm:^0.13.5": + version: 0.13.5 + resolution: "@rock-js/tools@npm:0.13.5" dependencies: "@clack/prompts": "npm:^0.11.0" adm-zip: "npm:^0.5.16" @@ -5901,7 +5901,7 @@ __metadata: string-argv: "npm:^0.3.2" tar: "npm:^7.5.1" tslib: "npm:^2.3.0" - checksum: 10/5034f552e8dcaf3aedaa8f511cb08d073672ff76c37faeb07d35aacf2188082477d196065de8d6fc06f429eda64542d497797436a1179c19c26b72a71bbe6bc2 + checksum: 10/7731aee927e3895ac9ff265eccab7c62f388f54f91bd1f01ce9155a960172efb59598463c35ef5438990f51bcce00355b8ac877f0755612e689cf5ff0dd76f90 languageName: node linkType: hard From 4794f54bcf4498517c19c16a4b0733978410bd14 Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 17:50:09 +0200 Subject: [PATCH 06/14] feat: support brownfield unified config file in Expo --- .changeset/nine-mice-fall.md | 6 + apps/ExpoApp54/app.json | 7 +- .../docs/docs/api-reference/configuration.mdx | 90 ++++- docs/docs/docs/getting-started/expo.mdx | 24 +- packages/cli/package.json | 5 + packages/cli/schema.json | 69 ++++ .../src/__tests__/expoPluginConfig.test.ts | 309 ++++++++++++++++++ packages/cli/src/config.ts | 23 +- packages/cli/src/expoPluginConfig.ts | 189 +++++++++++ packages/cli/src/types.ts | 80 ++++- .../src/expo-config-plugin/withBrownfield.ts | 60 +--- 11 files changed, 795 insertions(+), 67 deletions(-) create mode 100644 .changeset/nine-mice-fall.md create mode 100644 packages/cli/src/__tests__/expoPluginConfig.test.ts create mode 100644 packages/cli/src/expoPluginConfig.ts diff --git a/.changeset/nine-mice-fall.md b/.changeset/nine-mice-fall.md new file mode 100644 index 00000000..473b5a7f --- /dev/null +++ b/.changeset/nine-mice-fall.md @@ -0,0 +1,6 @@ +--- +'@callstack/react-native-brownfield': patch +'@callstack/brownfield-cli': patch +--- + +feat: support brownfield unified config file in Expo diff --git a/apps/ExpoApp54/app.json b/apps/ExpoApp54/app.json index 3bde2efd..09022af7 100644 --- a/apps/ExpoApp54/app.json +++ b/apps/ExpoApp54/app.json @@ -41,12 +41,7 @@ } } ], - [ - "@callstack/react-native-brownfield", - { - "debug": true - } - ], + "@callstack/react-native-brownfield", "expo-web-browser" ], "experiments": { diff --git a/docs/docs/docs/api-reference/configuration.mdx b/docs/docs/docs/api-reference/configuration.mdx index 5577a06c..31b2160b 100644 --- a/docs/docs/docs/api-reference/configuration.mdx +++ b/docs/docs/docs/api-reference/configuration.mdx @@ -20,6 +20,65 @@ If the CLI finds multiple sources, it throws an error instead of guessing which When both a config value and a CLI flag are set for the same option, the CLI flag wins. The CLI also validates the file against the published schema and logs warnings for unknown or invalid keys. +## Expo projects + +For Expo apps, the Brownfield config file is also used by the `@callstack/react-native-brownfield` Expo config plugin during `expo prebuild`. + +Use **one configuration source** for Brownfield options: + +- `brownfield.config.js`, `brownfield.config.json`, or `package.json#brownfield` +- **or** plugin options in the `app.json` `plugins` tuple + +Do not define the same options in both places. If a Brownfield config file exists and `app.json` also passes plugin options, prebuild throws an error. + +### Shared keys between CLI and Expo plugin + +| Config file key | Expo plugin equivalent | Notes | +| -------------------- | ---------------------- | ---------------------------------------------------------- | +| `android.moduleName` | `android.moduleName` | Same key for packaging and prebuild scaffolding. | +| `ios.scheme` | `ios.frameworkName` | Same value; `ios.scheme` is the canonical config file key. | +| `verbose` | `debug` | Same intent; `verbose` is the canonical config file key. | + +### Expo-only keys + +Plugin-only options that do not map to CLI flags belong under nested `expo` objects: + +- `android.expo.*` for Android prebuild scaffolding +- `ios.expo.*` for iOS prebuild scaffolding + +Example: + +```json +{ + "$schema": "https://oss.callstack.com/react-native-brownfield/schema.json", + "verbose": true, + "android": { + "moduleName": "brownfieldlib", + "variant": "release", + "expo": { + "packageName": "com.example.app", + "minSdkVersion": 24 + } + }, + "ios": { + "scheme": "BrownfieldLib", + "configuration": "Release", + "expo": { + "bundleIdentifier": "com.example.app.brownfield", + "deploymentTarget": "15.0" + } + } +} +``` + +For Expo projects, register the plugin without options when using a config file: + +```json +{ + "plugins": ["@callstack/react-native-brownfield"] +} +``` + ## JavaScript config file If you prefer a JavaScript file, create `react-native-brownfield.config.js` and export a plain object with `module.exports`: @@ -110,16 +169,28 @@ All file-based platform options mirror CLI flags, but they use camelCase propert ### Android keys -| Key | Type | Description | -| -------------------- | -------- | -------------------------------------------------------------------- | -| `android.moduleName` | `string` | Android module name used for packaging and publishing AAR artifacts. | -| `android.variant` | `string` | Android build variant, for example `debug` or `freeRelease`. | +| Key | Type | Description | +| -------------------- | -------- | ---------------------------------------------------------------------------------- | +| `android.moduleName` | `string` | Android module name used for packaging, publishing, and Expo prebuild scaffolding. | +| `android.variant` | `string` | Android build variant, for example `debug` or `freeRelease`. | + +#### Android Expo keys + +| Key | Type | Description | +| -------------------------------- | -------- | ------------------------------------------------------ | +| `android.expo.packageName` | `string` | Package name for the generated Android library module. | +| `android.expo.minSdkVersion` | `number` | Minimum Android SDK supported by the library. | +| `android.expo.targetSdkVersion` | `number` | Target Android SDK version for the library. | +| `android.expo.compileSdkVersion` | `number` | Compile Android SDK version used to build the library. | +| `android.expo.groupId` | `string` | Maven group ID used when publishing the AAR. | +| `android.expo.artifactId` | `string` | Maven artifact ID used when publishing the AAR. | +| `android.expo.version` | `string` | Maven version used when publishing the AAR. | ### iOS keys | Key | Type | Description | | ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------- | -| `ios.scheme` | `string` | Xcode scheme used for packaging. | +| `ios.scheme` | `string` | Xcode scheme used for packaging. Also used as the generated framework name during Expo prebuild. | | `ios.configuration` | `string` | Xcode build configuration, for example `Debug` or `Release`. | | `ios.target` | `string` | Explicit Xcode target name. | | `ios.destination` | `string[]` | One or more Xcode destinations, such as `simulator`, `device`, or full destination strings. | @@ -132,6 +203,15 @@ All file-based platform options mirror CLI flags, but they use camelCase propert | `ios.usePrebuiltRnCore` | `boolean` | Controls whether iOS packaging uses React Native Apple prebuilts. Omit it to keep Brownfield's version-aware defaults. | | `ios.addSpmPackage` | `boolean` | Generates a local Swift Package Manager manifest next to the packaged XCFramework outputs. | +#### iOS Expo keys + +| Key | Type | Description | +| --------------------------- | -------- | --------------------------------------------------------------- | +| `ios.expo.bundleIdentifier` | `string` | Bundle identifier assigned to the generated framework. | +| `ios.expo.buildSettings` | `object` | Additional Xcode build settings applied to the framework build. | +| `ios.expo.deploymentTarget` | `string` | Minimum iOS version supported by the generated framework. | +| `ios.expo.frameworkVersion` | `string` | Framework version used for Apple build settings. | + ## Brownie configuration The Brownie configuration lives inside the main Brownfield config under the `brownie` key. diff --git a/docs/docs/docs/getting-started/expo.mdx b/docs/docs/docs/getting-started/expo.mdx index 3045029a..e7c7c8fc 100644 --- a/docs/docs/docs/getting-started/expo.mdx +++ b/docs/docs/docs/getting-started/expo.mdx @@ -176,7 +176,9 @@ ReactNativeView(moduleName: "ExpoRNApp") ## Plugin Options -You can pass plugin options through the second item in the `plugins` tuple in `app.json`: +For Expo projects, prefer a [`brownfield.config.json`](/docs/api-reference/configuration#expo-projects) file so the same settings drive both `expo prebuild` and Brownfield CLI commands. + +You can still pass plugin options through the second item in the `plugins` tuple in `app.json` when you do **not** use a Brownfield config file: ```json { @@ -186,13 +188,11 @@ You can pass plugin options through the second item in the `plugins` tuple in `a { "ios": { "frameworkName": "MyBrownfieldLib", - "bundleIdentifier": "com.example.app.brownfield", - ... + "bundleIdentifier": "com.example.app.brownfield" }, "android": { "moduleName": "mybrownfieldlib", - "packageName": "com.example.mybrownfieldlib", - ... + "packageName": "com.example.mybrownfieldlib" } } ] @@ -200,10 +200,22 @@ You can pass plugin options through the second item in the `plugins` tuple in `a } ``` +:::warning +If you use `brownfield.config.js`, `brownfield.config.json`, or `package.json#brownfield`, do not pass plugin options in `app.json`. Prebuild throws an error when both sources are used. +::: + +When using a Brownfield config file, register the plugin without options: + +```json +{ + "plugins": ["@callstack/react-native-brownfield"] +} +``` + ### iOS - `frameworkName` (`string`, default: `"BrownfieldLib"`) - - Name of the generated framework. This is also used as the XCFramework name. + - Name of the generated framework. This is also used as the XCFramework name. In `brownfield.config.*`, use `ios.scheme` instead. - `bundleIdentifier` (`string`, default: app bundle identifier + `.brownfield`) - Bundle identifier assigned to the generated framework. - `buildSettings` (`Record`, optional) diff --git a/packages/cli/package.json b/packages/cli/package.json index a53fa2cd..ded2a1b0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -42,6 +42,11 @@ "types": "./dist/types.d.ts", "default": "./dist/types.js" }, + "./expo-plugin-config": { + "source": "./src/expoPluginConfig.ts", + "types": "./dist/expoPluginConfig.d.ts", + "default": "./dist/expoPluginConfig.js" + }, "./package.json": "./package.json" }, "scripts": { diff --git a/packages/cli/schema.json b/packages/cli/schema.json index 25e22adc..c2e5b0c2 100644 --- a/packages/cli/schema.json +++ b/packages/cli/schema.json @@ -5,6 +5,10 @@ "BrownfieldAndroidConfig": { "additionalProperties": false, "properties": { + "expo": { + "$ref": "#/definitions/BrownfieldExpoAndroidConfig", + "description": "Expo config plugin options for Android Expo prebuild phase." + }, "moduleName": { "description": "AAR module name.", "type": "string" @@ -41,6 +45,67 @@ }, "type": "object" }, + "BrownfieldExpoAndroidConfig": { + "additionalProperties": false, + "description": "Expo config plugin options for Android prebuild scaffolding.", + "properties": { + "artifactId": { + "description": "Maven artifact ID used when publishing the AAR.", + "type": "string" + }, + "compileSdkVersion": { + "description": "Compile SDK version for the Android library.", + "type": "number" + }, + "groupId": { + "description": "Maven group ID used when publishing the AAR.", + "type": "string" + }, + "minSdkVersion": { + "description": "Minimum SDK version for the Android library.", + "type": "number" + }, + "packageName": { + "description": "The package name for the Android library module.", + "type": "string" + }, + "targetSdkVersion": { + "description": "Target SDK version for the Android library.", + "type": "number" + }, + "version": { + "description": "Maven version used when publishing the AAR.", + "type": "string" + } + }, + "type": "object" + }, + "BrownfieldExpoIosConfig": { + "additionalProperties": false, + "description": "Expo config plugin options for iOS prebuild scaffolding.", + "properties": { + "buildSettings": { + "additionalProperties": { + "type": ["string", "boolean", "number"] + }, + "description": "Custom build settings applied when building the framework.", + "type": "object" + }, + "bundleIdentifier": { + "description": "The bundle identifier for the framework.", + "type": "string" + }, + "deploymentTarget": { + "description": "Minimum iOS deployment target for the generated framework.", + "type": "string" + }, + "frameworkVersion": { + "description": "Framework version used for Apple build settings.", + "type": "string" + } + }, + "type": "object" + }, "BrownfieldIosConfig": { "additionalProperties": false, "properties": { @@ -63,6 +128,10 @@ }, "type": "array" }, + "expo": { + "$ref": "#/definitions/BrownfieldExpoIosConfig", + "description": "Expo config plugin options for iOS Expo prebuild phase." + }, "exportExtraParams": { "description": "Custom xcodebuild export archive parameters.", "items": { diff --git a/packages/cli/src/__tests__/expoPluginConfig.test.ts b/packages/cli/src/__tests__/expoPluginConfig.test.ts new file mode 100644 index 00000000..1f630345 --- /dev/null +++ b/packages/cli/src/__tests__/expoPluginConfig.test.ts @@ -0,0 +1,309 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../brownfield/utils/paths.js', () => ({ + findProjectRoot: vi.fn(() => process.cwd()), +})); + +import { hasBrownfieldConfigFile } from '../config.js'; +import { + assertNoConfigFilePluginOverlap, + resolveBrownfieldPluginConfig, +} from '../expoPluginConfig.js'; + +const originalCwd = process.cwd(); + +function createTempProject({ + packageJsonConfig, + jsConfig, + jsonConfig, +}: { + packageJsonConfig?: Record; + jsConfig?: Record; + jsonConfig?: Record; +} = {}): string { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'brownfield-expo-config-') + ); + + const packageJson: Record = { + name: 'temp-project', + version: '1.0.0', + }; + + if (packageJsonConfig !== undefined) { + packageJson['brownfield'] = packageJsonConfig; + } + + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify(packageJson, null, 2) + ); + + if (jsConfig !== undefined) { + fs.writeFileSync( + path.join(tempDir, 'brownfield.config.js'), + `module.exports = ${JSON.stringify(jsConfig, null, 2)};\n` + ); + } + + if (jsonConfig !== undefined) { + fs.writeFileSync( + path.join(tempDir, 'brownfield.config.json'), + JSON.stringify(jsonConfig, null, 2) + ); + } + + return tempDir; +} + +const baseExpoConfig = { + ios: { + bundleIdentifier: 'com.example.app', + }, + android: { + package: 'com.example.app', + }, +}; + +describe('hasBrownfieldConfigFile', () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + process.chdir(originalCwd); + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('returns true when brownfield.config.json exists', () => { + tempDir = createTempProject({ + jsonConfig: { verbose: true }, + }); + process.chdir(tempDir); + + expect(hasBrownfieldConfigFile()).toBe(true); + }); + + it('returns true when package.json contains a brownfield key', () => { + tempDir = createTempProject({ + packageJsonConfig: {}, + }); + process.chdir(tempDir); + + expect(hasBrownfieldConfigFile()).toBe(true); + }); + + it('returns false when no brownfield config source exists', () => { + tempDir = createTempProject(); + process.chdir(tempDir); + + expect(hasBrownfieldConfigFile()).toBe(false); + }); +}); + +describe('assertNoConfigFilePluginOverlap', () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + process.chdir(originalCwd); + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('allows empty plugin props when a config file exists', () => { + tempDir = createTempProject({ + jsonConfig: { android: { moduleName: 'brownfieldlib' } }, + }); + process.chdir(tempDir); + + expect(() => + assertNoConfigFilePluginOverlap( + { android: { moduleName: 'brownfieldlib' } }, + {} + ) + ).not.toThrow(); + }); + + it('throws when a config file exists and plugin props are non-empty', () => { + tempDir = createTempProject({ + jsonConfig: { android: { moduleName: 'brownfieldlib' } }, + }); + process.chdir(tempDir); + + expect(() => + assertNoConfigFilePluginOverlap( + { android: { moduleName: 'brownfieldlib' } }, + { android: { moduleName: 'brownfieldlib' } } + ) + ).toThrow(/both a brownfield config file and app.json plugin options/); + }); + + it('throws when a config file exists and debug is set in plugin props', () => { + tempDir = createTempProject({ + jsonConfig: { verbose: true }, + }); + process.chdir(tempDir); + + expect(() => + assertNoConfigFilePluginOverlap({ verbose: true }, { debug: true }) + ).toThrow(/both a brownfield config file and app.json plugin options/); + }); + + it('allows non-empty plugin props when no config file exists', () => { + tempDir = createTempProject(); + process.chdir(tempDir); + + expect(() => + assertNoConfigFilePluginOverlap( + {}, + { ios: { frameworkName: 'BrownfieldLib' } } + ) + ).not.toThrow(); + }); +}); + +describe('resolveBrownfieldPluginConfig', () => { + let tempDir: string | null = null; + + beforeEach(() => { + tempDir = createTempProject(); + process.chdir(tempDir); + }); + + afterEach(() => { + if (tempDir) { + process.chdir(originalCwd); + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('resolves defaults from Expo config when no file config exists', () => { + const resolved = resolveBrownfieldPluginConfig({}, {}, baseExpoConfig); + + expect(resolved).toEqual({ + debug: false, + ios: { + frameworkName: 'BrownfieldLib', + bundleIdentifier: 'com.example.app.brownfield', + buildSettings: {}, + deploymentTarget: '15.0', + frameworkVersion: '1', + }, + android: { + moduleName: 'brownfieldlib', + packageName: 'com.example.app', + minSdkVersion: 24, + targetSdkVersion: 35, + compileSdkVersion: 35, + groupId: 'com.example.app', + artifactId: 'brownfieldlib', + version: '0.0.1-SNAPSHOT', + }, + }); + }); + + it('uses legacy app.json plugin props when no config file exists', () => { + const resolved = resolveBrownfieldPluginConfig( + { + debug: true, + ios: { frameworkName: 'CustomLib' }, + android: { moduleName: 'customlib' }, + }, + {}, + baseExpoConfig + ); + + expect(resolved.debug).toBe(true); + expect(resolved.ios?.frameworkName).toBe('CustomLib'); + expect(resolved.android?.moduleName).toBe('customlib'); + }); + + it('maps ios.scheme to frameworkName and verbose to debug from file config', () => { + fs.writeFileSync( + path.join(tempDir!, 'brownfield.config.json'), + JSON.stringify({ + verbose: true, + android: { moduleName: 'mylib' }, + ios: { scheme: 'MyLib' }, + }) + ); + + const resolved = resolveBrownfieldPluginConfig( + {}, + { + verbose: true, + android: { moduleName: 'mylib' }, + ios: { scheme: 'MyLib' }, + }, + baseExpoConfig + ); + + expect(resolved.debug).toBe(true); + expect(resolved.ios?.frameworkName).toBe('MyLib'); + expect(resolved.android?.moduleName).toBe('mylib'); + }); + + it('merges android.expo and ios.expo from file config', () => { + fs.writeFileSync( + path.join(tempDir!, 'brownfield.config.json'), + JSON.stringify({ + android: { + moduleName: 'mylib', + expo: { + minSdkVersion: 26, + version: '2.0.0', + }, + }, + ios: { + scheme: 'MyLib', + expo: { + deploymentTarget: '16.0', + bundleIdentifier: 'com.example.framework', + }, + }, + }) + ); + + const fileConfig = { + android: { + moduleName: 'mylib', + expo: { + minSdkVersion: 26, + version: '2.0.0', + }, + }, + ios: { + scheme: 'MyLib', + expo: { + deploymentTarget: '16.0', + bundleIdentifier: 'com.example.framework', + }, + }, + }; + + const resolved = resolveBrownfieldPluginConfig( + {}, + fileConfig, + baseExpoConfig + ); + + expect(resolved.android).toMatchObject({ + moduleName: 'mylib', + minSdkVersion: 26, + version: '2.0.0', + }); + expect(resolved.ios).toMatchObject({ + frameworkName: 'MyLib', + deploymentTarget: '16.0', + bundleIdentifier: 'com.example.framework', + }); + }); +}); diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index b9f4ae12..e3f9fe4f 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -10,9 +10,9 @@ import { findProjectRoot } from './brownfield/utils/paths.js'; import BrownfieldSchema from '../schema.json' with { type: 'json' }; import { logger } from '@rock-js/tools'; -const CONFIG_BASE_NAME = 'brownfield'; -const JS_CONFIG_FILE_NAME = `${CONFIG_BASE_NAME}.config.js`; -const JSON_CONFIG_FILE_NAME = `${CONFIG_BASE_NAME}.config.json`; +export const CONFIG_BASE_NAME = 'brownfield'; +export const JS_CONFIG_FILE_NAME = `${CONFIG_BASE_NAME}.config.js`; +export const JSON_CONFIG_FILE_NAME = `${CONFIG_BASE_NAME}.config.json`; const SEPARATOR = '\n● '; @@ -58,6 +58,23 @@ export function loadBrownfieldConfig( return packageJson[CONFIG_BASE_NAME] || {}; } +export function hasBrownfieldConfigFile( + projectRoot: string = findProjectRoot() +): boolean { + const jsConfigFilePath = path.join(projectRoot, JS_CONFIG_FILE_NAME); + const jsonConfigFilePath = path.join(projectRoot, JSON_CONFIG_FILE_NAME); + + if (fs.existsSync(jsConfigFilePath) || fs.existsSync(jsonConfigFilePath)) { + return true; + } + + const require = createRequire(path.join(projectRoot, 'package.json')); + const packageJsonPath = path.join(projectRoot, 'package.json'); + const packageJson = require(packageJsonPath) as Record; + + return CONFIG_BASE_NAME in packageJson; +} + type BrownfieldPlatform = 'android' | 'ios'; type ConfigurableOptions = Record; diff --git a/packages/cli/src/expoPluginConfig.ts b/packages/cli/src/expoPluginConfig.ts new file mode 100644 index 00000000..704239b3 --- /dev/null +++ b/packages/cli/src/expoPluginConfig.ts @@ -0,0 +1,189 @@ +import type { BrownfieldConfig } from './types.js'; +import { hasBrownfieldConfigFile } from './config.js'; + +export { loadBrownfieldConfig } from './config.js'; + +/** + * Legacy Expo config plugin props shape accepted via app.json plugins tuple. + */ +export type BrownfieldPluginProps = { + debug?: boolean; + ios?: { + frameworkName?: string; + bundleIdentifier?: string; + buildSettings?: Record; + deploymentTarget?: string; + frameworkVersion?: string; + }; + android?: { + moduleName?: string; + packageName?: string; + minSdkVersion?: number; + targetSdkVersion?: number; + compileSdkVersion?: number; + groupId?: string; + artifactId?: string; + version?: string; + }; +}; + +export type ResolvedBrownfieldPluginAndroidConfig = { + moduleName: string; + packageName: string; + minSdkVersion: number; + targetSdkVersion: number; + compileSdkVersion: number; + groupId: string; + artifactId: string; + version: string; +}; + +export type ResolvedBrownfieldPluginIosConfig = { + frameworkName: string; + bundleIdentifier: string; + buildSettings: Record; + deploymentTarget: string; + frameworkVersion: string; +}; + +export type ResolvedBrownfieldPluginConfig = { + debug: boolean; + ios: ResolvedBrownfieldPluginIosConfig | null; + android: ResolvedBrownfieldPluginAndroidConfig | null; +}; + +type BrownfieldExpoConfig = { + ios?: { + bundleIdentifier?: string; + }; + android?: { + package?: string; + }; +}; + +const CONFIG_FILE_PLUGIN_OVERLAP_ERROR = + 'Brownfield configuration is defined in both a brownfield config file and app.json plugin options. ' + + 'Use only one source: either brownfield.config.js, brownfield.config.json, package.json#brownfield, or the plugin options in app.json.'; + +/** + * Checks if the plugin props are non-empty. + * @param props - The plugin props to check. + * @returns True if the plugin props are non-empty, false otherwise. + */ +function isPluginPropsNonEmpty(props: BrownfieldPluginProps): boolean { + if (props.debug !== undefined) { + return true; + } + + if (props.ios && Object.keys(props.ios).length > 0) { + return true; + } + + if (props.android && Object.keys(props.android).length > 0) { + return true; + } + + return false; +} + +/** + * Converts the file config to plugin props. + * @param fileConfig - The file config to convert to plugin props. + * @returns The plugin props. + */ +function fileConfigToPluginProps( + fileConfig: BrownfieldConfig +): BrownfieldPluginProps { + const props: BrownfieldPluginProps = {}; + + if (fileConfig.verbose !== undefined) { + props.debug = fileConfig.verbose; + } + + if (fileConfig.android) { + props.android = { + moduleName: fileConfig.android.moduleName, + ...fileConfig.android.expo, + }; + } + + if (fileConfig.ios) { + props.ios = { + frameworkName: fileConfig.ios.scheme, + ...fileConfig.ios.expo, + }; + } + + return props; +} + +/** + * Asserts that there is no overlap between the file config and the plugin props. + * @param _fileConfig - The file config to check for overlap. + * @param pluginProps - The plugin props to check for overlap. + * @param projectRoot - The project root in which to look for the config file + * @throws An error if there is overlap. + * @returns void + */ +export function assertNoConfigFilePluginOverlap( + _fileConfig: BrownfieldConfig, + pluginProps: BrownfieldPluginProps, + projectRoot?: string +): void { + if (!hasBrownfieldConfigFile(projectRoot)) { + return; + } + + if (isPluginPropsNonEmpty(pluginProps)) { + throw new Error(CONFIG_FILE_PLUGIN_OVERLAP_ERROR); + } +} + +/** + * Resolves the plugin config from the file config and the plugin props. + * @param pluginProps - The plugin props to resolve. + * @param fileConfig - The file config to resolve. + * @param expoConfig - The Expo plugin config to resolve. + * @returns The resolved plugin config. + */ +export function resolveBrownfieldPluginConfig( + pluginProps: BrownfieldPluginProps, + fileConfig: BrownfieldConfig, + expoConfig: BrownfieldExpoConfig +): ResolvedBrownfieldPluginConfig { + const useFileConfig = hasBrownfieldConfigFile(); + const effectiveProps = useFileConfig + ? fileConfigToPluginProps(fileConfig) + : pluginProps; + + const androidPackage = expoConfig.android?.package; + const androidModuleName = + effectiveProps.android?.moduleName ?? 'brownfieldlib'; + + return { + debug: effectiveProps.debug ?? false, + ios: expoConfig.ios + ? { + frameworkName: effectiveProps.ios?.frameworkName ?? 'BrownfieldLib', + bundleIdentifier: + effectiveProps.ios?.bundleIdentifier ?? + `${expoConfig.ios.bundleIdentifier}.brownfield`, + buildSettings: effectiveProps.ios?.buildSettings ?? {}, + deploymentTarget: effectiveProps.ios?.deploymentTarget ?? '15.0', + frameworkVersion: effectiveProps.ios?.frameworkVersion ?? '1', + } + : null, + android: androidPackage + ? { + moduleName: androidModuleName, + packageName: effectiveProps.android?.packageName ?? androidPackage, + minSdkVersion: effectiveProps.android?.minSdkVersion ?? 24, + targetSdkVersion: effectiveProps.android?.targetSdkVersion ?? 35, + compileSdkVersion: effectiveProps.android?.compileSdkVersion ?? 35, + groupId: effectiveProps.android?.groupId ?? androidPackage, + artifactId: effectiveProps.android?.artifactId ?? androidModuleName, + version: effectiveProps.android?.version ?? '0.0.1-SNAPSHOT', + } + : null, + }; +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index fa316512..42fe6231 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -44,14 +44,90 @@ export type BrownfieldPublishAndroidOptions = BrownfieldCommonOptions & export type BrownfieldPackageIosOptions = BrownfieldCommonOptions & Partial; +/** + * Expo config plugin options for Android prebuild scaffolding. + */ +export type BrownfieldExpoAndroidConfig = { + /** + * The package name for the Android library module. + */ + packageName?: string; + + /** + * Minimum SDK version for the Android library. + */ + minSdkVersion?: number; + + /** + * Target SDK version for the Android library. + */ + targetSdkVersion?: number; + + /** + * Compile SDK version for the Android library. + */ + compileSdkVersion?: number; + + /** + * Maven group ID used when publishing the AAR. + */ + groupId?: string; + + /** + * Maven artifact ID used when publishing the AAR. + */ + artifactId?: string; + + /** + * Maven version used when publishing the AAR. + */ + version?: string; +}; + +/** + * Expo config plugin options for iOS prebuild scaffolding. + */ +export type BrownfieldExpoIosConfig = { + /** + * The bundle identifier for the framework. + */ + bundleIdentifier?: string; + + /** + * Custom build settings applied when building the framework. + */ + buildSettings?: Record; + + /** + * Minimum iOS deployment target for the generated framework. + */ + deploymentTarget?: string; + + /** + * Framework version used for Apple build settings. + */ + frameworkVersion?: string; +}; + export type BrownfieldAndroidConfig = Omit< Partial & Partial, keyof BrownfieldCommonOptions ->; +> & { + /** + * Expo config plugin options for Android Expo prebuild phase. + */ + expo?: BrownfieldExpoAndroidConfig; +}; + export type BrownfieldIosConfig = Omit< Partial, keyof BrownfieldCommonOptions ->; +> & { + /** + * Expo config plugin options for iOS Expo prebuild phase. + */ + expo?: BrownfieldExpoIosConfig; +}; export type BrownfieldConfig = BrownfieldConfigMetadata & BrownfieldCommonOptions & diff --git a/packages/react-native-brownfield/src/expo-config-plugin/withBrownfield.ts b/packages/react-native-brownfield/src/expo-config-plugin/withBrownfield.ts index 9b9079be..3f093fe1 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/withBrownfield.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/withBrownfield.ts @@ -4,7 +4,12 @@ import { type ConfigPlugin, type StaticPlugin, } from '@expo/config-plugins'; -import type { ExpoConfig } from '@expo/config-types'; + +import { + assertNoConfigFilePluginOverlap, + loadBrownfieldConfig, + resolveBrownfieldPluginConfig, +} from '@callstack/brownfield-cli/expo-plugin-config'; import { withBrownfieldIos } from './ios/withBrownfieldIos'; import { withBrownfieldAndroid } from './android/withBrownfieldAndroid'; @@ -15,49 +20,6 @@ import type { import { Logger } from './logging'; -/** - * Resolves the plugin configuration using provided config and config defaults. - * @param config The user-provided Brownfield configuration. - * @param expoConfig The Expo configuration object. - * @returns The resolved Brownfield configuration. - */ -function resolveConfig( - config: BrownfieldPluginConfig = {}, - expoConfig: ExpoConfig -): ResolvedBrownfieldPluginConfig { - Logger.setIsDebug(config.debug ?? false); - - const androidPackage = expoConfig.android?.package; - const androidModuleName = config.android?.moduleName ?? 'brownfieldlib'; - - return { - ios: expoConfig.ios - ? { - frameworkName: config.ios?.frameworkName ?? 'BrownfieldLib', - bundleIdentifier: - config.ios?.bundleIdentifier ?? - `${expoConfig.ios.bundleIdentifier}.brownfield`, - buildSettings: config.ios?.buildSettings ?? {}, - deploymentTarget: config.ios?.deploymentTarget ?? '15.0', - frameworkVersion: config.ios?.frameworkVersion ?? '1', - } - : null, - android: androidPackage - ? { - moduleName: androidModuleName, - packageName: config.android?.packageName ?? androidPackage, - minSdkVersion: config.android?.minSdkVersion ?? 24, - targetSdkVersion: config.android?.targetSdkVersion ?? 35, - compileSdkVersion: config.android?.compileSdkVersion ?? 35, - groupId: config.android?.groupId ?? androidPackage, - artifactId: config.android?.artifactId ?? androidModuleName, - version: config.android?.version ?? '0.0.1-SNAPSHOT', - } - : null, - debug: config.debug ?? false, - }; -} - /** * React Native Brownfield - Expo Config Plugin. * @@ -90,7 +52,15 @@ const withBrownfield: ConfigPlugin = ( config, props = {} ) => { - const resolvedConfig = resolveConfig(props ?? {}, config); + const pluginProps = props ?? {}; + const fileConfig = loadBrownfieldConfig(); + + assertNoConfigFilePluginOverlap(fileConfig, pluginProps); + + const resolvedConfig: ResolvedBrownfieldPluginConfig = + resolveBrownfieldPluginConfig(pluginProps, fileConfig, config); + + Logger.setIsDebug(resolvedConfig.debug); const plugins: (ConfigPlugin | StaticPlugin)[] = []; From aad2cc3f6a0fb338808f72f0fa06818e670f72d1 Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 17:56:02 +0200 Subject: [PATCH 07/14] refactor: loadBrownfieldConfig to return null when no config file exists --- packages/cli/src/__tests__/config.test.ts | 10 ++++++- .../src/__tests__/expoPluginConfig.test.ts | 27 +++++++++---------- packages/cli/src/brownie/config.ts | 2 +- packages/cli/src/config.ts | 23 ++++------------ packages/cli/src/expoPluginConfig.ts | 19 +++++-------- 5 files changed, 35 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/__tests__/config.test.ts b/packages/cli/src/__tests__/config.test.ts index 4b0378e4..0c8d307d 100644 --- a/packages/cli/src/__tests__/config.test.ts +++ b/packages/cli/src/__tests__/config.test.ts @@ -146,9 +146,17 @@ describe('loadBrownfieldConfig', () => { }); }); - it('returns an empty config when no source exists', () => { + it('returns null when no source exists', () => { tempDir = createTempProject(); + expect(loadBrownfieldConfig(tempDir)).toBeNull(); + }); + + it('returns an empty config when package.json brownfield key is empty', () => { + tempDir = createTempProject({ + packageJsonConfig: {}, + }); + expect(loadBrownfieldConfig(tempDir)).toEqual({}); }); diff --git a/packages/cli/src/__tests__/expoPluginConfig.test.ts b/packages/cli/src/__tests__/expoPluginConfig.test.ts index 1f630345..177cc0e2 100644 --- a/packages/cli/src/__tests__/expoPluginConfig.test.ts +++ b/packages/cli/src/__tests__/expoPluginConfig.test.ts @@ -8,7 +8,7 @@ vi.mock('../brownfield/utils/paths.js', () => ({ findProjectRoot: vi.fn(() => process.cwd()), })); -import { hasBrownfieldConfigFile } from '../config.js'; +import { loadBrownfieldConfig } from '../config.js'; import { assertNoConfigFilePluginOverlap, resolveBrownfieldPluginConfig, @@ -69,7 +69,7 @@ const baseExpoConfig = { }, }; -describe('hasBrownfieldConfigFile', () => { +describe('loadBrownfieldConfig', () => { let tempDir: string | null = null; afterEach(() => { @@ -80,29 +80,29 @@ describe('hasBrownfieldConfigFile', () => { } }); - it('returns true when brownfield.config.json exists', () => { + it('returns config when brownfield.config.json exists', () => { tempDir = createTempProject({ jsonConfig: { verbose: true }, }); process.chdir(tempDir); - expect(hasBrownfieldConfigFile()).toBe(true); + expect(loadBrownfieldConfig()).toEqual({ verbose: true }); }); - it('returns true when package.json contains a brownfield key', () => { + it('returns config when package.json contains a brownfield key', () => { tempDir = createTempProject({ packageJsonConfig: {}, }); process.chdir(tempDir); - expect(hasBrownfieldConfigFile()).toBe(true); + expect(loadBrownfieldConfig()).toEqual({}); }); - it('returns false when no brownfield config source exists', () => { + it('returns null when no brownfield config source exists', () => { tempDir = createTempProject(); process.chdir(tempDir); - expect(hasBrownfieldConfigFile()).toBe(false); + expect(loadBrownfieldConfig()).toBeNull(); }); }); @@ -161,10 +161,9 @@ describe('assertNoConfigFilePluginOverlap', () => { process.chdir(tempDir); expect(() => - assertNoConfigFilePluginOverlap( - {}, - { ios: { frameworkName: 'BrownfieldLib' } } - ) + assertNoConfigFilePluginOverlap(null, { + ios: { frameworkName: 'BrownfieldLib' }, + }) ).not.toThrow(); }); }); @@ -186,7 +185,7 @@ describe('resolveBrownfieldPluginConfig', () => { }); it('resolves defaults from Expo config when no file config exists', () => { - const resolved = resolveBrownfieldPluginConfig({}, {}, baseExpoConfig); + const resolved = resolveBrownfieldPluginConfig({}, null, baseExpoConfig); expect(resolved).toEqual({ debug: false, @@ -217,7 +216,7 @@ describe('resolveBrownfieldPluginConfig', () => { ios: { frameworkName: 'CustomLib' }, android: { moduleName: 'customlib' }, }, - {}, + null, baseExpoConfig ); diff --git a/packages/cli/src/brownie/config.ts b/packages/cli/src/brownie/config.ts index 1224fcb8..59e6602a 100644 --- a/packages/cli/src/brownie/config.ts +++ b/packages/cli/src/brownie/config.ts @@ -108,7 +108,7 @@ export function resolveBrownieCodegenConfig({ const legacyConfig = hasLegacyConfig(projectRoot) ? loadConfig(projectRoot) : undefined; - const brownfieldBrownie = loadBrownfieldConfig(projectRoot).brownie; + const brownfieldBrownie = loadBrownfieldConfig(projectRoot)?.brownie; if (legacyConfig !== undefined && (brownie ?? brownfieldBrownie)) { throw new Error(LEGACY_AND_NEW_BROWNIE_CONFIG_ERROR); diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index e3f9fe4f..3ab94485 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -29,7 +29,7 @@ export function validateBrownfieldCLIConfig(config: unknown): void { export function loadBrownfieldConfig( projectRoot: string = findProjectRoot() -): BrownfieldConfig { +): BrownfieldConfig | null { const require = createRequire(path.join(projectRoot, 'package.json')); const jsConfigFilePath = path.join(projectRoot, JS_CONFIG_FILE_NAME); @@ -55,24 +55,11 @@ export function loadBrownfieldConfig( return require(jsonConfigFilePath) as BrownfieldConfig; } - return packageJson[CONFIG_BASE_NAME] || {}; -} - -export function hasBrownfieldConfigFile( - projectRoot: string = findProjectRoot() -): boolean { - const jsConfigFilePath = path.join(projectRoot, JS_CONFIG_FILE_NAME); - const jsonConfigFilePath = path.join(projectRoot, JSON_CONFIG_FILE_NAME); - - if (fs.existsSync(jsConfigFilePath) || fs.existsSync(jsonConfigFilePath)) { - return true; + if (CONFIG_BASE_NAME in packageJson) { + return (packageJson[CONFIG_BASE_NAME] as BrownfieldConfig) ?? {}; } - const require = createRequire(path.join(projectRoot, 'package.json')); - const packageJsonPath = path.join(projectRoot, 'package.json'); - const packageJson = require(packageJsonPath) as Record; - - return CONFIG_BASE_NAME in packageJson; + return null; } type BrownfieldPlatform = 'android' | 'ios'; @@ -94,7 +81,7 @@ export function mergeBrownfieldConfigWithOptions( options: T, platform: BrownfieldPlatform ): T { - const reactNativeBrownfieldConfig = loadBrownfieldConfig(); + const reactNativeBrownfieldConfig = loadBrownfieldConfig() ?? {}; validateBrownfieldCLIConfig(reactNativeBrownfieldConfig); diff --git a/packages/cli/src/expoPluginConfig.ts b/packages/cli/src/expoPluginConfig.ts index 704239b3..9a7832e5 100644 --- a/packages/cli/src/expoPluginConfig.ts +++ b/packages/cli/src/expoPluginConfig.ts @@ -1,5 +1,4 @@ import type { BrownfieldConfig } from './types.js'; -import { hasBrownfieldConfigFile } from './config.js'; export { loadBrownfieldConfig } from './config.js'; @@ -119,18 +118,16 @@ function fileConfigToPluginProps( /** * Asserts that there is no overlap between the file config and the plugin props. - * @param _fileConfig - The file config to check for overlap. + * @param fileConfig - The loaded file config, or null when no config source exists. * @param pluginProps - The plugin props to check for overlap. - * @param projectRoot - The project root in which to look for the config file * @throws An error if there is overlap. * @returns void */ export function assertNoConfigFilePluginOverlap( - _fileConfig: BrownfieldConfig, - pluginProps: BrownfieldPluginProps, - projectRoot?: string + fileConfig: BrownfieldConfig | null, + pluginProps: BrownfieldPluginProps ): void { - if (!hasBrownfieldConfigFile(projectRoot)) { + if (fileConfig === null) { return; } @@ -148,13 +145,11 @@ export function assertNoConfigFilePluginOverlap( */ export function resolveBrownfieldPluginConfig( pluginProps: BrownfieldPluginProps, - fileConfig: BrownfieldConfig, + fileConfig: BrownfieldConfig | null, expoConfig: BrownfieldExpoConfig ): ResolvedBrownfieldPluginConfig { - const useFileConfig = hasBrownfieldConfigFile(); - const effectiveProps = useFileConfig - ? fileConfigToPluginProps(fileConfig) - : pluginProps; + const effectiveProps = + fileConfig !== null ? fileConfigToPluginProps(fileConfig) : pluginProps; const androidPackage = expoConfig.android?.package; const androidModuleName = From 155e2be565c7dd8d7c2073a328e728a2cdb8c3b4 Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 18:07:48 +0200 Subject: [PATCH 08/14] fix(docs): obsolete, old config file name --- docs/docs/docs/api-reference/configuration.mdx | 10 +++++----- docs/docs/docs/getting-started/android.mdx | 2 +- docs/docs/docs/getting-started/ios.mdx | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/docs/docs/api-reference/configuration.mdx b/docs/docs/docs/api-reference/configuration.mdx index 31b2160b..c723fb10 100644 --- a/docs/docs/docs/api-reference/configuration.mdx +++ b/docs/docs/docs/api-reference/configuration.mdx @@ -10,8 +10,8 @@ For example, `--module-name` becomes `moduleName`, `--build-folder` becomes `bui The CLI supports exactly one configuration source per project: -- `react-native-brownfield.config.js` -- `react-native-brownfield.config.json` +- `brownfield.config.js` +- `brownfield.config.json` - `package.json` under the `react-native-brownfield` key Do not keep more than one of these at the same time. @@ -81,7 +81,7 @@ For Expo projects, register the plugin without options when using a config file: ## JavaScript config file -If you prefer a JavaScript file, create `react-native-brownfield.config.js` and export a plain object with `module.exports`: +If you prefer a JavaScript file, create `brownfield.config.js` and export a plain object with `module.exports`: ```js /** @type {import('@callstack/react-native-brownfield').BrownfieldConfig} */ @@ -103,7 +103,7 @@ module.exports = { ## JSON config file -If you want schema autocomplete and validation directly in the config file, use `react-native-brownfield.config.json`: +If you want schema autocomplete and validation directly in the config file, use `brownfield.config.json`: > [!TIP] > @@ -224,7 +224,7 @@ Currently supported Brownie keys are: | `kotlin` | `string` | Directory where generated Kotlin Brownie store files should be written. | | `kotlinPackageName` | `string` | Kotlin package name used in generated Brownie store files. | -Example inside `react-native-brownfield.config.json`: +Example inside `brownfield.config.json`: ```json { diff --git a/docs/docs/docs/getting-started/android.mdx b/docs/docs/docs/getting-started/android.mdx index 5ce0c33f..0f55091c 100644 --- a/docs/docs/docs/getting-started/android.mdx +++ b/docs/docs/docs/getting-started/android.mdx @@ -334,7 +334,7 @@ tasks.named("generateMetadataFileForMavenAarPublication") { ## 7. Create a Brownfield Configuration -Create `react-native-brownfield.config.json` in your project root: +Create `brownfield.config.json` in your project root: ```json { diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index 9ac0cf5b..a77cea21 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -107,7 +107,7 @@ class InternalClassForBundle {} ## 5. Create a Brownfield Configuration -Create `react-native-brownfield.config.json` in your project root: +Create `brownfield.config.json` in your project root: ```json { From b454b4d0300e1b5eb489dad6de278335b04e4a46 Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 18:41:29 +0200 Subject: [PATCH 09/14] fix: handling of edge cases --- packages/cli/src/__tests__/config.test.ts | 21 ++++++++++++++++++- .../src/__tests__/expoPluginConfig.test.ts | 10 +++++++++ packages/cli/src/expoPluginConfig.ts | 11 ++++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/__tests__/config.test.ts b/packages/cli/src/__tests__/config.test.ts index 0c8d307d..872ecfd0 100644 --- a/packages/cli/src/__tests__/config.test.ts +++ b/packages/cli/src/__tests__/config.test.ts @@ -39,7 +39,7 @@ function createTempProject({ jsConfig, jsonConfig, }: { - packageJsonConfig?: Record; + packageJsonConfig?: Record | null; jsConfig?: Record; jsonConfig?: Record; } = {}): string { @@ -160,6 +160,14 @@ describe('loadBrownfieldConfig', () => { expect(loadBrownfieldConfig(tempDir)).toEqual({}); }); + it('returns an empty config when package.json brownfield key is null', () => { + tempDir = createTempProject({ + packageJsonConfig: null, + }); + + expect(loadBrownfieldConfig(tempDir)).toEqual({}); + }); + it('throws when multiple config sources are present', () => { tempDir = createTempProject({ packageJsonConfig: { @@ -178,6 +186,17 @@ describe('loadBrownfieldConfig', () => { 'Project has multiple Brownfield configuration files' ); }); + + it('throws when package.json brownfield key is null and a config file exists', () => { + tempDir = createTempProject({ + packageJsonConfig: null, + jsonConfig: { verbose: true }, + }); + + expect(() => loadBrownfieldConfig(tempDir!)).toThrow( + 'Project has multiple Brownfield configuration files' + ); + }); }); describe('validateBrownfieldCLIConfig', () => { diff --git a/packages/cli/src/__tests__/expoPluginConfig.test.ts b/packages/cli/src/__tests__/expoPluginConfig.test.ts index 177cc0e2..8740bf63 100644 --- a/packages/cli/src/__tests__/expoPluginConfig.test.ts +++ b/packages/cli/src/__tests__/expoPluginConfig.test.ts @@ -225,6 +225,16 @@ describe('resolveBrownfieldPluginConfig', () => { expect(resolved.android?.moduleName).toBe('customlib'); }); + it('strips leading ":" from android.moduleName when resolving from file config', () => { + const resolved = resolveBrownfieldPluginConfig( + {}, + { android: { moduleName: ':mylib' } }, + baseExpoConfig + ); + expect(resolved.android?.moduleName).toBe('mylib'); + expect(resolved.android?.artifactId).toBe('mylib'); + }); + it('maps ios.scheme to frameworkName and verbose to debug from file config', () => { fs.writeFileSync( path.join(tempDir!, 'brownfield.config.json'), diff --git a/packages/cli/src/expoPluginConfig.ts b/packages/cli/src/expoPluginConfig.ts index 9a7832e5..830da182 100644 --- a/packages/cli/src/expoPluginConfig.ts +++ b/packages/cli/src/expoPluginConfig.ts @@ -152,8 +152,15 @@ export function resolveBrownfieldPluginConfig( fileConfig !== null ? fileConfigToPluginProps(fileConfig) : pluginProps; const androidPackage = expoConfig.android?.package; - const androidModuleName = - effectiveProps.android?.moduleName ?? 'brownfieldlib'; + /** + * Below: android.moduleName may be provided in the fully-qualified Gradle format + * (e.g. :BrownfieldLib — this is shown in the CLI example usage). + * The Expo prebuild side expects a raw module folder/name (it later + * prepends : itself and uses it as a path), so a leading : is removed. + */ + const androidModuleName = ( + effectiveProps.android?.moduleName ?? 'brownfieldlib' + ).replace(/^:/, ''); return { debug: effectiveProps.debug ?? false, From d4913df0a45fb1dba25e086595b8a5af0adb2dc0 Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 18:50:04 +0200 Subject: [PATCH 10/14] fix: allow union types in Ajv when parsing the config schema --- packages/cli/src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 3ab94485..6166db6b 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -16,7 +16,7 @@ export const JSON_CONFIG_FILE_NAME = `${CONFIG_BASE_NAME}.config.json`; const SEPARATOR = '\n● '; -const ajv = new Ajv({ allErrors: true }); +const ajv = new Ajv({ allErrors: true, allowUnionTypes: true }); const validateBrownfieldConfig = ajv.compile(BrownfieldSchema); export function validateBrownfieldCLIConfig(config: unknown): void { From 81f3ee0b28d3ab5a6ce01fd048b30d7ce48d74c5 Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 18:55:13 +0200 Subject: [PATCH 11/14] chore: migrate ExpoApp55 demo app to the brownfield config file workflow --- apps/ExpoApp55/app.json | 2 +- apps/ExpoApp55/brownfield.config.json | 14 ++++++++++++++ apps/ExpoApp55/package.json | 15 +-------------- 3 files changed, 16 insertions(+), 15 deletions(-) create mode 100644 apps/ExpoApp55/brownfield.config.json diff --git a/apps/ExpoApp55/app.json b/apps/ExpoApp55/app.json index 67b61868..22907a5e 100644 --- a/apps/ExpoApp55/app.json +++ b/apps/ExpoApp55/app.json @@ -37,7 +37,7 @@ } } ], - ["@callstack/react-native-brownfield", { "debug": true }], + "@callstack/react-native-brownfield", "expo-image", "expo-font", "expo-web-browser" diff --git a/apps/ExpoApp55/brownfield.config.json b/apps/ExpoApp55/brownfield.config.json new file mode 100644 index 00000000..eee651f9 --- /dev/null +++ b/apps/ExpoApp55/brownfield.config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://oss.callstack.com/react-native-brownfield/schema.json", + "android": { + "moduleName": "brownfieldlib" + }, + "ios": { + "scheme": "BrownfieldLib" + }, + "verbose": true, + "brownie": { + "kotlin": "./android/brownfieldlib/src/main/java/com/callstack/rnbrownfield/demo/expoapp55/Generated/", + "kotlinPackageName": "com.callstack.rnbrownfield.demo.expoapp55" + } +} diff --git a/apps/ExpoApp55/package.json b/apps/ExpoApp55/package.json index a18cb58b..dc4ca384 100644 --- a/apps/ExpoApp55/package.json +++ b/apps/ExpoApp55/package.json @@ -65,18 +65,5 @@ "react-test-renderer": "19.2.0", "typescript": "~5.9.2" }, - "private": true, - "brownfield": { - "brownie": { - "kotlin": "./android/brownfieldlib/src/main/java/com/callstack/rnbrownfield/demo/expoapp55/Generated/", - "kotlinPackageName": "com.callstack.rnbrownfield.demo.expoapp55" - }, - "android": { - "moduleName": "brownfieldlib" - }, - "ios": { - "scheme": "BrownfieldLib" - }, - "verbose": true - } + "private": true } From 9737a74b70d071542c9ee856d602f766dcb8ea66 Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 19:01:48 +0200 Subject: [PATCH 12/14] fix: ensure Expo prebuild failing with cryptic error under conflict of config files --- .../__tests__/packageIos.action.test.ts | 42 ++++++++++++----- .../src/brownfield/commands/packageAndroid.ts | 5 +- .../cli/src/brownfield/commands/packageIos.ts | 8 ++-- .../src/brownfield/commands/publishAndroid.ts | 5 +- packages/cli/src/brownfield/utils/project.ts | 47 +++++++++++++------ packages/cli/src/config.ts | 2 +- 6 files changed, 78 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts index a9751cf7..a5f79990 100644 --- a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts +++ b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts @@ -9,10 +9,7 @@ import { runNavigationCodegenIfApplicable } from '../../../navigation/helpers/ru import { packageIosCommand } from '../packageIos.js'; import { copyDebugBundleToSimulatorSlice } from '../../utils/copyDebugBundleToSimulatorSlice.js'; import { createLocalSpmPackage } from '../../utils/createLocalSpmPackage.js'; -import { runExpoPrebuildIfNeeded } from '../../utils/expo.js'; -import { getProjectInfo } from '../../utils/project.js'; import { resolvePackagedFrameworkName } from '../../utils/resolvePackagedFrameworkName.js'; -import { supportsPrebuiltRNCore } from '../../utils/supportsPrebuiltRNCore.js'; vi.mock('@rock-js/platform-apple-helpers', async (importOriginal) => { const actual = await importOriginal(); @@ -54,10 +51,18 @@ vi.mock('@rock-js/tools', async (importOriginal) => { }; }); +vi.mock('../../../config.js', () => ({ + mergeBrownfieldConfigWithOptions: vi.fn((options) => options), +})); + vi.mock('../../utils/expo.js', () => ({ runExpoPrebuildIfNeeded: vi.fn(), })); +vi.mock('../../utils/paths.js', () => ({ + findProjectRoot: vi.fn(() => '/repo'), +})); + vi.mock('../../utils/project.js', () => ({ getProjectInfo: vi.fn(() => ({ projectRoot: '/repo', @@ -90,11 +95,14 @@ vi.mock('../../../brownie/helpers/runBrownieCodegenIfApplicable.js', () => ({ })), })); -vi.mock('../../../navigation/helpers/runNavigationCodegenIfApplicable.js', () => ({ - runNavigationCodegenIfApplicable: vi.fn(async () => ({ - hasNavigation: false, - })), -})); +vi.mock( + '../../../navigation/helpers/runNavigationCodegenIfApplicable.js', + () => ({ + runNavigationCodegenIfApplicable: vi.fn(async () => ({ + hasNavigation: false, + })), + }) +); vi.mock('../../utils/copyDebugBundleToSimulatorSlice.js', () => ({ copyDebugBundleToSimulatorSlice: vi.fn(), @@ -145,7 +153,11 @@ describe('package:ios action --add-spm-package', () => { }); test('calls createLocalSpmPackage with the resolved framework name', async () => { - await invokePackageIosAction(['--add-spm-package', '--configuration', 'Release']); + await invokePackageIosAction([ + '--add-spm-package', + '--configuration', + 'Release', + ]); expect(packageIosAction).toHaveBeenCalledOnce(); expect(copyDebugBundleToSimulatorSlice).toHaveBeenCalledWith({ @@ -172,7 +184,11 @@ describe('package:ios action --add-spm-package', () => { candidates: [], }); - await invokePackageIosAction(['--add-spm-package', '--configuration', 'Release']); + await invokePackageIosAction([ + '--add-spm-package', + '--configuration', + 'Release', + ]); expect(mockCreateLocalSpmPackage).not.toHaveBeenCalled(); expect(mockLoggerWarn).not.toHaveBeenCalled(); @@ -189,7 +205,11 @@ describe('package:ios action --add-spm-package', () => { candidates: ['AppOne', 'AppTwo'], }); - await invokePackageIosAction(['--add-spm-package', '--configuration', 'Debug']); + await invokePackageIosAction([ + '--add-spm-package', + '--configuration', + 'Debug', + ]); expect(mockCreateLocalSpmPackage).not.toHaveBeenCalled(); expect(mockLoggerWarn).not.toHaveBeenCalled(); diff --git a/packages/cli/src/brownfield/commands/packageAndroid.ts b/packages/cli/src/brownfield/commands/packageAndroid.ts index abd69b07..a24031f6 100644 --- a/packages/cli/src/brownfield/commands/packageAndroid.ts +++ b/packages/cli/src/brownfield/commands/packageAndroid.ts @@ -12,6 +12,7 @@ import { curryOptions, } from '../../shared/index.js'; import { runExpoPrebuildIfNeeded } from '../utils/expo.js'; +import { findProjectRoot } from '../utils/paths.js'; import { getProjectInfo } from '../utils/project.js'; import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieCodegenIfApplicable.js'; import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js'; @@ -23,13 +24,15 @@ export const packageAndroidCommand = curryOptions( ).action( actionRunner(async (cliOptions: PackageAarFlags) => { const options = mergeBrownfieldConfigWithOptions(cliOptions, 'android'); + const projectRoot = findProjectRoot(); - const { projectRoot, platformConfig } = getProjectInfo('android'); await runExpoPrebuildIfNeeded({ projectRoot, platform: 'android', }); + const { platformConfig } = getProjectInfo('android'); + await runBrownieCodegenIfApplicable(projectRoot, 'kotlin'); await runNavigationCodegenIfApplicable(projectRoot); diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index cde328d7..f42f591c 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -17,6 +17,7 @@ import { import { Command, Option } from 'commander'; import { runExpoPrebuildIfNeeded } from '../utils/expo.js'; +import { findProjectRoot } from '../utils/paths.js'; import { getProjectInfo } from '../utils/project.js'; import { supportsPrebuiltRNCore } from '../utils/supportsPrebuiltRNCore.js'; import { @@ -97,8 +98,11 @@ export const packageIosCommand = curryOptions( .action( actionRunner(async (cliOptions: PackageIosOptions) => { const options = mergeBrownfieldConfigWithOptions(cliOptions, 'ios'); + const projectRoot = findProjectRoot(); - const { projectRoot, platformConfig, userConfig } = getProjectInfo('ios'); + await runExpoPrebuildIfNeeded({ projectRoot, platform: 'ios' }); + + const { platformConfig, userConfig } = getProjectInfo('ios'); const prebuiltRNCoreSupport = supportsPrebuiltRNCore({ projectRoot }); @@ -126,8 +130,6 @@ export const packageIosCommand = curryOptions( throw new RockError(prebuiltRNCoreSupport.reason); } - await runExpoPrebuildIfNeeded({ projectRoot, platform: 'ios' }); - if (!userConfig.project.ios) { throw new Error('iOS project not found.'); } diff --git a/packages/cli/src/brownfield/commands/publishAndroid.ts b/packages/cli/src/brownfield/commands/publishAndroid.ts index dee9d60e..f64bd2eb 100644 --- a/packages/cli/src/brownfield/commands/publishAndroid.ts +++ b/packages/cli/src/brownfield/commands/publishAndroid.ts @@ -12,6 +12,7 @@ import { ExampleUsage, } from '../../shared/index.js'; import { getProjectInfo } from '../utils/project.js'; +import { findProjectRoot } from '../utils/paths.js'; import { runExpoPrebuildIfNeeded } from '../utils/expo.js'; import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieCodegenIfApplicable.js'; import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js'; @@ -25,13 +26,15 @@ export const publishAndroidCommand = curryOptions( ).action( actionRunner(async (cliOptions: PublishLocalAarFlags) => { const options = mergeBrownfieldConfigWithOptions(cliOptions, 'android'); + const projectRoot = findProjectRoot(); - const { projectRoot, platformConfig } = getProjectInfo('android'); await runExpoPrebuildIfNeeded({ projectRoot, platform: 'android', }); + const { platformConfig } = getProjectInfo('android'); + await runBrownieCodegenIfApplicable(projectRoot, 'kotlin'); await runNavigationCodegenIfApplicable(projectRoot); diff --git a/packages/cli/src/brownfield/utils/project.ts b/packages/cli/src/brownfield/utils/project.ts index af3aea22..5b57f2f3 100644 --- a/packages/cli/src/brownfield/utils/project.ts +++ b/packages/cli/src/brownfield/utils/project.ts @@ -21,15 +21,46 @@ const cliConfig: typeof cliConfigImport = : // @ts-expect-error: interop default cliConfigImport.default; +function hasExpoAppConfig(projectRoot: string): boolean { + return ( + fs.existsSync(path.join(projectRoot, 'app.json')) || + fs.existsSync(path.join(projectRoot, 'app.config.js')) || + fs.existsSync(path.join(projectRoot, 'app.config.ts')) || + fs.existsSync(path.join(projectRoot, 'app.config.mjs')) + ); +} + +function projectDependsOnExpo(projectRoot: string): boolean { + const packageJsonPath = path.join(projectRoot, 'package.json'); + + if (!fs.existsSync(packageJsonPath)) { + return false; + } + + const packageJson = JSON.parse( + fs.readFileSync(packageJsonPath, 'utf-8') + ) as Record>; + + return ['dependencies', 'peerDependencies', 'devDependencies'].some( + (key) => packageJson[key]?.expo + ); +} + /** * Gets the Expo config if the project is an Expo project * @param projectRoot The project root path * @returns The Expo config if the project is an Expo project, null otherwise */ export function getExpoConfigIfIsExpo(projectRoot: string) { + const hasAppConfig = hasExpoAppConfig(projectRoot); + try { return getConfig(projectRoot, { skipSDKVersionRequirement: true }); - } catch { + } catch (error) { + if (hasAppConfig) { + throw error; + } + return null; } } @@ -41,19 +72,7 @@ export function getExpoConfigIfIsExpo(projectRoot: string) { * @returns Whether the project is an Expo project */ export function isExpoProject(projectRoot: string): boolean { - const hasExpoConfig = getExpoConfigIfIsExpo(projectRoot) !== null; - - // additionally, it is needed to check if the project depends on Expo packages explicitly - // to prevent false positives in a monorepo setup - const rnProjectRoot = findProjectRoot(); - const packageJsonPath = path.join(rnProjectRoot, 'package.json'); - const dependsOnExpo = - fs.existsSync(packageJsonPath) && - ['dependencies', 'peerDependencies', 'devDependencies'].some( - (key) => JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))[key]?.expo - ); - - return hasExpoConfig && dependsOnExpo; + return hasExpoAppConfig(projectRoot) && projectDependsOnExpo(projectRoot); } export function getExpoSdkMajor(projectRoot: string): number | null { diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 6166db6b..ce6ac422 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -41,7 +41,7 @@ export function loadBrownfieldConfig( [ fs.existsSync(jsConfigFilePath), fs.existsSync(jsonConfigFilePath), - packageJson[CONFIG_BASE_NAME], + CONFIG_BASE_NAME in packageJson, ].filter(Boolean).length > 1 ) { throw new Error('Project has multiple Brownfield configuration files'); From e9df539d4cf638bac04421174e25a7c0ca088128 Mon Sep 17 00:00:00 2001 From: artus9033 Date: Wed, 24 Jun 2026 19:07:33 +0200 Subject: [PATCH 13/14] test: adjust tests --- packages/cli/src/brownie/__tests__/config.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/brownie/__tests__/config.test.ts b/packages/cli/src/brownie/__tests__/config.test.ts index 32263c0e..8565e989 100644 --- a/packages/cli/src/brownie/__tests__/config.test.ts +++ b/packages/cli/src/brownie/__tests__/config.test.ts @@ -40,8 +40,9 @@ describe('loadConfig', () => { }); it('throws when package.json not found', () => { - mockCwd.mockReturnValue('/nonexistent/path'); - expect(() => loadConfig()).toThrow('package.json not found'); + expect(() => loadConfig('/nonexistent/path')).toThrow( + 'package.json not found' + ); }); it('returns empty config when brownie config missing', () => { From 4ce193e1f29f262144870c44b79297e0d640c5f6 Mon Sep 17 00:00:00 2001 From: artus9033 Date: Thu, 25 Jun 2026 23:18:38 +0200 Subject: [PATCH 14/14] fix(docs): invalid package.json key name in docs --- docs/docs/docs/api-reference/configuration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/docs/api-reference/configuration.mdx b/docs/docs/docs/api-reference/configuration.mdx index c723fb10..6757ca4b 100644 --- a/docs/docs/docs/api-reference/configuration.mdx +++ b/docs/docs/docs/api-reference/configuration.mdx @@ -12,7 +12,7 @@ The CLI supports exactly one configuration source per project: - `brownfield.config.js` - `brownfield.config.json` -- `package.json` under the `react-native-brownfield` key +- `package.json` under the `brownfield` key Do not keep more than one of these at the same time. If the CLI finds multiple sources, it throws an error instead of guessing which one should win.