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/.changeset/vast-waves-itch.md b/.changeset/vast-waves-itch.md new file mode 100644 index 00000000..88238eab --- /dev/null +++ b/.changeset/vast-waves-itch.md @@ -0,0 +1,6 @@ +--- +'@callstack/react-native-brownfield': patch +'@callstack/brownfield-cli': patch +--- + +feat: support local BGP in Expo config plugin diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3c68d2c..6c9c020c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,11 @@ jobs: CLANG_MODULE_CACHE_PATH="$RUNNER_TEMP/swift-cache/clang" \ swift test --scratch-path "$RUNNER_TEMP/swift-cache/swiftpm" + - name: Run brownfield tests including MacOS-only tests + run: | + cd packages/react-native-brownfield + yarn test + android-androidapp-expo: name: Android road test (AndroidApp - Expo ${{ matrix.version }}) runs-on: ubuntu-latest 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/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 } diff --git a/docs/docs/docs/api-reference/configuration.mdx b/docs/docs/docs/api-reference/configuration.mdx index 5577a06c..b26f819c 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. @@ -20,9 +20,69 @@ 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, + "useLocalGradlePlugin": true + } + }, + "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`: +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} */ @@ -44,7 +104,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] > @@ -110,16 +170,29 @@ 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. | +| `android.expo.useLocalGradlePlugin` | `boolean` | Load the Brownfield Gradle plugin from `node_modules` via `includeBuild` instead of the Maven classpath dependency. Disabled by default. | ### 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 +205,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. @@ -144,7 +226,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/expo.mdx b/docs/docs/docs/getting-started/expo.mdx index 3045029a..4fd2dfca 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) @@ -239,3 +251,5 @@ You can pass plugin options through the second item in the `plugins` tuple in `a - Maven artifact ID used when publishing the AAR. - `version` (`string`, default: `"0.0.1-SNAPSHOT"`) - Maven version used when publishing the AAR. +- `useLocalGradlePlugin` (`boolean`, default: `false`) + - Load the Brownfield Gradle plugin from `@callstack/react-native-brownfield/gradle-plugin/brownfield` in `node_modules` via `includeBuild` instead of adding the Maven classpath dependency. In `brownfield.config.*`, use `android.expo.useLocalGradlePlugin` instead. Useful when patching the plugin locally or working with an unreleased version. See [Load the Plugin from Node Modules](/docs/getting-started/android#advanced-load-the-plugin-from-node-modules) for details. 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 { 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..bbb9246d 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,71 @@ }, "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" + }, + "useLocalGradlePlugin": { + "description": "When true, load the Brownfield Gradle plugin from `@callstack/react-native-brownfield/gradle-plugin/brownfield` via `includeBuild` instead of adding the Maven classpath dependency. Disabled by default.", + "type": "boolean" + }, + "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 +132,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__/config.test.ts b/packages/cli/src/__tests__/config.test.ts index 4b0378e4..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 { @@ -146,9 +146,25 @@ 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({}); + }); + + it('returns an empty config when package.json brownfield key is null', () => { + tempDir = createTempProject({ + packageJsonConfig: null, + }); + expect(loadBrownfieldConfig(tempDir)).toEqual({}); }); @@ -170,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 new file mode 100644 index 00000000..6745c71e --- /dev/null +++ b/packages/cli/src/__tests__/expoPluginConfig.test.ts @@ -0,0 +1,351 @@ +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 { loadBrownfieldConfig } 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('loadBrownfieldConfig', () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + process.chdir(originalCwd); + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('returns config when brownfield.config.json exists', () => { + tempDir = createTempProject({ + jsonConfig: { verbose: true }, + }); + process.chdir(tempDir); + + expect(loadBrownfieldConfig()).toEqual({ verbose: true }); + }); + + it('returns config when package.json contains a brownfield key', () => { + tempDir = createTempProject({ + packageJsonConfig: {}, + }); + process.chdir(tempDir); + + expect(loadBrownfieldConfig()).toEqual({}); + }); + + it('returns null when no brownfield config source exists', () => { + tempDir = createTempProject(); + process.chdir(tempDir); + + expect(loadBrownfieldConfig()).toBeNull(); + }); +}); + +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(null, { + 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({}, null, 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', + useLocalGradlePlugin: false, + }, + }); + }); + + it('uses legacy app.json plugin props when no config file exists', () => { + const resolved = resolveBrownfieldPluginConfig( + { + debug: true, + ios: { frameworkName: 'CustomLib' }, + android: { moduleName: 'customlib' }, + }, + null, + baseExpoConfig + ); + + expect(resolved.debug).toBe(true); + expect(resolved.ios?.frameworkName).toBe('CustomLib'); + 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'), + 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', + }); + }); + + it('maps android.expo.useLocalGradlePlugin from file config', () => { + const resolved = resolveBrownfieldPluginConfig( + {}, + { + android: { + moduleName: 'mylib', + expo: { + useLocalGradlePlugin: true, + }, + }, + }, + baseExpoConfig + ); + + expect(resolved.android?.useLocalGradlePlugin).toBe(true); + }); + + it('maps android.useLocalGradlePlugin from legacy app.json plugin props', () => { + const resolved = resolveBrownfieldPluginConfig( + { + android: { + moduleName: 'mylib', + useLocalGradlePlugin: true, + }, + }, + null, + baseExpoConfig + ); + + expect(resolved.android?.useLocalGradlePlugin).toBe(true); + }); +}); 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/__tests__/strip-framework-binary.test.ts b/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts index ec9d4178..aada964b 100644 --- a/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/strip-framework-binary.test.ts @@ -65,76 +65,91 @@ describe('stripFrameworkBinary', () => { ); }); - it('strips binary from ios-arm64 slice', () => { - const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ - 'ios-arm64', - ]); - const binaryPath = path.join( - xcframeworkPath, - 'ios-arm64', - 'TestFramework.framework', - 'TestFramework' - ); - const originalContent = fs.readFileSync(binaryPath, 'utf-8'); - - stripFrameworkBinary(xcframeworkPath); - - const newContent = fs.readFileSync(binaryPath); - expect(newContent.toString()).not.toBe(originalContent); - expect(fs.existsSync(binaryPath)).toBe(true); - expect(mockLoggerSuccess).toHaveBeenCalledWith( - 'TestFramework.xcframework is now interface-only' - ); - }); - - it('strips binary from simulator slice with fat binary', () => { - const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ - 'ios-arm64_x86_64-simulator', - ]); - const binaryPath = path.join( - xcframeworkPath, - 'ios-arm64_x86_64-simulator', - 'TestFramework.framework', - 'TestFramework' - ); - const originalContent = fs.readFileSync(binaryPath, 'utf-8'); - - stripFrameworkBinary(xcframeworkPath); - - const newContent = fs.readFileSync(binaryPath); - expect(newContent.toString()).not.toBe(originalContent); + describe.skipIf(process.platform !== 'darwin')('with Xcode toolchain', () => { + it('strips binary from ios-arm64 slice', () => { + const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ + 'ios-arm64', + ]); + const binaryPath = path.join( + xcframeworkPath, + 'ios-arm64', + 'TestFramework.framework', + 'TestFramework' + ); + const originalContent = fs.readFileSync(binaryPath, 'utf-8'); + + stripFrameworkBinary(xcframeworkPath); + + const newContent = fs.readFileSync(binaryPath); + expect(newContent.toString()).not.toBe(originalContent); + expect(fs.existsSync(binaryPath)).toBe(true); + expect(mockLoggerSuccess).toHaveBeenCalledWith( + 'TestFramework.xcframework is now interface-only' + ); + }); - const archInfo = execSync(`xcrun lipo -info "${binaryPath}"`, { - encoding: 'utf-8', + it('strips binary from simulator slice with fat binary', () => { + const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ + 'ios-arm64_x86_64-simulator', + ]); + const binaryPath = path.join( + xcframeworkPath, + 'ios-arm64_x86_64-simulator', + 'TestFramework.framework', + 'TestFramework' + ); + const originalContent = fs.readFileSync(binaryPath, 'utf-8'); + + stripFrameworkBinary(xcframeworkPath); + + const newContent = fs.readFileSync(binaryPath); + expect(newContent.toString()).not.toBe(originalContent); + + const archInfo = execSync(`xcrun lipo -info "${binaryPath}"`, { + encoding: 'utf-8', + }); + expect(archInfo).toContain('arm64'); + expect(archInfo).toContain('x86_64'); }); - expect(archInfo).toContain('arm64'); - expect(archInfo).toContain('x86_64'); - }); - it('handles multiple slices', () => { - const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ - 'ios-arm64', - 'ios-arm64_x86_64-simulator', - ]); + it('handles multiple slices', () => { + const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ + 'ios-arm64', + 'ios-arm64_x86_64-simulator', + ]); + + stripFrameworkBinary(xcframeworkPath); + + const deviceBinary = path.join( + xcframeworkPath, + 'ios-arm64', + 'TestFramework.framework', + 'TestFramework' + ); + const simBinary = path.join( + xcframeworkPath, + 'ios-arm64_x86_64-simulator', + 'TestFramework.framework', + 'TestFramework' + ); + + expect(fs.existsSync(deviceBinary)).toBe(true); + expect(fs.existsSync(simBinary)).toBe(true); + expect(mockLoggerSuccess).toHaveBeenCalledOnce(); + }); - stripFrameworkBinary(xcframeworkPath); + it('ignores non-ios directories', () => { + const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ + 'ios-arm64', + ]); + fs.mkdirSync(path.join(xcframeworkPath, 'macos-arm64'), { + recursive: true, + }); - const deviceBinary = path.join( - xcframeworkPath, - 'ios-arm64', - 'TestFramework.framework', - 'TestFramework' - ); - const simBinary = path.join( - xcframeworkPath, - 'ios-arm64_x86_64-simulator', - 'TestFramework.framework', - 'TestFramework' - ); + stripFrameworkBinary(xcframeworkPath); - expect(fs.existsSync(deviceBinary)).toBe(true); - expect(fs.existsSync(simBinary)).toBe(true); - expect(mockLoggerSuccess).toHaveBeenCalledOnce(); + expect(mockLoggerSuccess).toHaveBeenCalledOnce(); + }); }); it('warns and skips unknown slice types', () => { @@ -164,17 +179,4 @@ describe('stripFrameworkBinary', () => { expect.stringContaining('No binary found at') ); }); - - it('ignores non-ios directories', () => { - const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [ - 'ios-arm64', - ]); - fs.mkdirSync(path.join(xcframeworkPath, 'macos-arm64'), { - recursive: true, - }); - - stripFrameworkBinary(xcframeworkPath); - - expect(mockLoggerSuccess).toHaveBeenCalledOnce(); - }); }); 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/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', () => { 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 b9f4ae12..ce6ac422 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -10,13 +10,13 @@ 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ā— '; -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 { @@ -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); @@ -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'); @@ -55,7 +55,11 @@ export function loadBrownfieldConfig( return require(jsonConfigFilePath) as BrownfieldConfig; } - return packageJson[CONFIG_BASE_NAME] || {}; + if (CONFIG_BASE_NAME in packageJson) { + return (packageJson[CONFIG_BASE_NAME] as BrownfieldConfig) ?? {}; + } + + return null; } type BrownfieldPlatform = 'android' | 'ios'; @@ -77,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 new file mode 100644 index 00000000..9b455636 --- /dev/null +++ b/packages/cli/src/expoPluginConfig.ts @@ -0,0 +1,195 @@ +import type { BrownfieldConfig } from './types.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; + useLocalGradlePlugin?: boolean; + }; +}; + +export type ResolvedBrownfieldPluginAndroidConfig = { + moduleName: string; + packageName: string; + minSdkVersion: number; + targetSdkVersion: number; + compileSdkVersion: number; + groupId: string; + artifactId: string; + version: string; + useLocalGradlePlugin: boolean; +}; + +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 loaded file config, or null when no config source exists. + * @param pluginProps - The plugin props to check for overlap. + * @throws An error if there is overlap. + * @returns void + */ +export function assertNoConfigFilePluginOverlap( + fileConfig: BrownfieldConfig | null, + pluginProps: BrownfieldPluginProps +): void { + if (fileConfig === null) { + 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 | null, + expoConfig: BrownfieldExpoConfig +): ResolvedBrownfieldPluginConfig { + const effectiveProps = + fileConfig !== null ? fileConfigToPluginProps(fileConfig) : pluginProps; + + const androidPackage = expoConfig.android?.package; + /** + * 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, + 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', + useLocalGradlePlugin: + effectiveProps.android?.useLocalGradlePlugin ?? false, + } + : null, + }; +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index fa316512..4acc4a1c 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -44,14 +44,98 @@ 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; + + /** + * When true, load the Brownfield Gradle plugin from + * `@callstack/react-native-brownfield/gradle-plugin/brownfield` via + * `includeBuild` instead of adding the Maven classpath dependency. + * Disabled by default. + */ + useLocalGradlePlugin?: boolean; +}; + +/** + * 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/android/__tests__/withAndroidModuleFiles.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/withAndroidModuleFiles.test.ts index 8dc72de2..2aa06f97 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/withAndroidModuleFiles.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/withAndroidModuleFiles.test.ts @@ -183,6 +183,7 @@ describe('createAndroidModule', () => { groupId: 'com.example', artifactId: 'brownfieldlib', version: '1.0.0', + useLocalGradlePlugin: false, }, }; } diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/utils/__tests__/gradleHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/utils/__tests__/gradleHelpers.test.ts new file mode 100644 index 00000000..4c65b25b --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/utils/__tests__/gradleHelpers.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +import { modifyRootBuildGradle, modifySettingsGradle } from '../gradleHelpers'; + +const rootBuildGradle = ` +buildscript { + ext { + buildToolsVersion = "35.0.0" + } + dependencies { + classpath("com.android.tools.build:gradle:8.6.0") + } +} +`; + +const settingsGradle = `pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +plugins { id("com.facebook.react.settings") } +rootProject.name = 'MyApp' +include ':app' +`; + +describe('modifyRootBuildGradle', () => { + it('adds the Maven Brownfield Gradle plugin classpath by default', () => { + const result = modifyRootBuildGradle(rootBuildGradle); + + expect(result).toContain('brownfield-gradle-plugin'); + }); + + it('skips the Maven classpath when useLocalGradlePlugin is enabled', () => { + const result = modifyRootBuildGradle(rootBuildGradle, { + useLocalGradlePlugin: true, + }); + + expect(result).toBe(rootBuildGradle); + expect(result).not.toContain('brownfield-gradle-plugin'); + }); +}); + +describe('modifySettingsGradle', () => { + it('includes the brownfield module', () => { + const result = modifySettingsGradle(settingsGradle, 'brownfieldlib'); + + expect(result).toContain("include ':brownfieldlib'"); + }); + + it('adds includeBuild for the local Brownfield Gradle plugin when enabled', () => { + const result = modifySettingsGradle(settingsGradle, 'brownfieldlib', { + useLocalGradlePlugin: true, + }); + + expect(result).toContain( + 'includeBuild("../node_modules/@callstack/react-native-brownfield/gradle-plugin/brownfield")' + ); + expect(result).toContain("include ':brownfieldlib'"); + }); + + it('prepends pluginManagement when settings.gradle has no pluginManagement block', () => { + const settingsWithoutPluginManagement = `rootProject.name = 'MyApp' +include ':app' +`; + + const result = modifySettingsGradle( + settingsWithoutPluginManagement, + 'brownfieldlib', + { useLocalGradlePlugin: true } + ); + + expect(result.startsWith('pluginManagement {')).toBe(true); + expect(result).toContain( + 'includeBuild("../node_modules/@callstack/react-native-brownfield/gradle-plugin/brownfield")' + ); + }); + + it('does not duplicate the local Brownfield Gradle plugin includeBuild', () => { + const settingsWithLocalPlugin = `${settingsGradle} +includeBuild("../node_modules/@callstack/react-native-brownfield/gradle-plugin/brownfield") +`; + + const result = modifySettingsGradle( + settingsWithLocalPlugin, + 'brownfieldlib', + { + useLocalGradlePlugin: true, + } + ); + + expect( + result.match( + /includeBuild\("\.\.\/node_modules\/@callstack\/react-native-brownfield\/gradle-plugin\/brownfield"\)/g + ) + ).toHaveLength(1); + }); +}); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/utils/gradleHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/utils/gradleHelpers.ts index e6a2f05b..990a0f65 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/android/utils/gradleHelpers.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/utils/gradleHelpers.ts @@ -1,12 +1,29 @@ import { brownfieldGradlePluginDependency } from './constants'; import { Logger } from '../../logging'; +const LOCAL_GRADLE_PLUGIN_INCLUDE_BUILD = + 'includeBuild("../node_modules/@callstack/react-native-brownfield/gradle-plugin/brownfield")'; + +type GradleModificationOptions = { + useLocalGradlePlugin?: boolean; +}; + /** * Modifies the root build.gradle to add the Brownfield Gradle plugin dependency * @param contents The original build.gradle content * @returns The modified build.gradle content */ -export function modifyRootBuildGradle(contents: string): string { +export function modifyRootBuildGradle( + contents: string, + { useLocalGradlePlugin = false }: GradleModificationOptions = {} +): string { + if (useLocalGradlePlugin) { + Logger.logDebug( + 'Skipping Maven Brownfield Gradle plugin classpath because useLocalGradlePlugin is enabled' + ); + return contents; + } + // check if already added if (contents.includes('brownfield-gradle-plugin')) { Logger.logDebug( @@ -39,6 +56,33 @@ export function modifyRootBuildGradle(contents: string): string { return modifiedContents; } +function addLocalGradlePluginIncludeBuild(contents: string): string { + if (contents.includes('gradle-plugin/brownfield')) { + Logger.logDebug( + 'Local Brownfield Gradle plugin includeBuild already present, skipping' + ); + return contents; + } + + Logger.logDebug( + 'Modifying settings.gradle to include local Brownfield Gradle plugin' + ); + + const pluginManagementMatch = contents.match(/pluginManagement\s*\{/); + + if (pluginManagementMatch?.index !== undefined) { + const insertIndex = + pluginManagementMatch.index + pluginManagementMatch[0].length; + const insertion = `\n\t${LOCAL_GRADLE_PLUGIN_INCLUDE_BUILD}`; + + return ( + contents.slice(0, insertIndex) + insertion + contents.slice(insertIndex) + ); + } + + return `pluginManagement {\n\t${LOCAL_GRADLE_PLUGIN_INCLUDE_BUILD}\n}\n\n${contents}`; +} + /** * Modifies settings.gradle to include the Brownfield module * @param contents The original settings.gradle content @@ -47,16 +91,23 @@ export function modifyRootBuildGradle(contents: string): string { */ export function modifySettingsGradle( contents: string, - moduleName: string + moduleName: string, + { useLocalGradlePlugin = false }: GradleModificationOptions = {} ): string { + let modifiedContents = contents; + + if (useLocalGradlePlugin) { + modifiedContents = addLocalGradlePluginIncludeBuild(modifiedContents); + } + const includeStatement = `include ':${moduleName}'`; // check if already included - if (contents.includes(includeStatement)) { + if (modifiedContents.includes(includeStatement)) { Logger.logDebug( `Module "${moduleName}" already in settings.gradle, skipping` ); - return contents; + return modifiedContents; } Logger.logDebug( @@ -64,7 +115,7 @@ export function modifySettingsGradle( ); // add the include statement at the end - const modifiedContents = contents + `\n${includeStatement}\n`; + modifiedContents = modifiedContents + `\n${includeStatement}\n`; Logger.logDebug(`Added module "${moduleName}" to settings.gradle`); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/withBrownfieldAndroid.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/withBrownfieldAndroid.ts index 9ff3c2d9..68aa8a6e 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/android/withBrownfieldAndroid.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/withBrownfieldAndroid.ts @@ -32,7 +32,8 @@ export const withBrownfieldAndroid: ConfigPlugin< // Step 1: modify root build.gradle to add Brownfield Gradle plugin dependency config = withProjectBuildGradle(config, (gradleConfig) => { gradleConfig.modResults.contents = modifyRootBuildGradle( - gradleConfig.modResults.contents + gradleConfig.modResults.contents, + { useLocalGradlePlugin: androidConfig.useLocalGradlePlugin } ); return gradleConfig; @@ -42,7 +43,8 @@ export const withBrownfieldAndroid: ConfigPlugin< config = withSettingsGradle(config, (settingsConfig) => { settingsConfig.modResults.contents = modifySettingsGradle( settingsConfig.modResults.contents, - androidConfig.moduleName + androidConfig.moduleName, + { useLocalGradlePlugin: androidConfig.useLocalGradlePlugin } ); return settingsConfig; diff --git a/packages/react-native-brownfield/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.ts b/packages/react-native-brownfield/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.ts index cd9f9f4d..68f1b622 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.ts @@ -49,6 +49,13 @@ export interface BrownfieldPluginAndroidConfig { * @default "0.0.1-SNAPSHOT" */ version?: string; + + /** + * Load the Brownfield Gradle plugin from node_modules via includeBuild + * instead of the Maven classpath dependency. + * @default false + */ + useLocalGradlePlugin?: boolean; } /** 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)[] = [];