diff --git a/.changeset/rich-turkeys-find.md b/.changeset/rich-turkeys-find.md new file mode 100644 index 00000000..42d79f3d --- /dev/null +++ b/.changeset/rich-turkeys-find.md @@ -0,0 +1,6 @@ +--- +'@callstack/react-native-brownfield': minor +'@callstack/brownfield-cli': minor +--- + +Added configuration file support for react-native-brownfield packages. diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..d0e78bad --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "json.schemas": [ + { + "fileMatch": ["**/package.json"], + "url": "https://oss.callstack.com/react-native-brownfield/package-json.schema.json" + } + ] +} \ No newline at end of file diff --git a/apps/ExpoApp54/brownfield.config.json b/apps/ExpoApp54/brownfield.config.json new file mode 100644 index 00000000..a836f915 --- /dev/null +++ b/apps/ExpoApp54/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/expoapp54/Generated/", + "kotlinPackageName": "com.callstack.rnbrownfield.demo.expoapp54" + } +} diff --git a/apps/ExpoApp54/package.json b/apps/ExpoApp54/package.json index 2cb01b4f..3d0e1bc1 100644 --- a/apps/ExpoApp54/package.json +++ b/apps/ExpoApp54/package.json @@ -15,9 +15,9 @@ "ci:local:e2e:ios": "bash ../../scripts/ci-local-expo54-ios-e2e.sh", "prebuild": "expo prebuild", "brownfield:prepare:android:ci": "cd .. && node --experimental-strip-types --no-warnings ./scripts/prepare-android-build-gradle-for-ci.ts ExpoApp54", - "brownfield:package:android": "brownfield package:android --module-name brownfieldlib --variant release --verbose", - "brownfield:publish:android": "brownfield publish:android --module-name brownfieldlib --verbose", - "brownfield:package:ios": "brownfield package:ios --scheme BrownfieldLib --configuration Release --verbose", + "brownfield:package:android": "brownfield package:android --variant release", + "brownfield:publish:android": "brownfield publish:android", + "brownfield:package:ios": "brownfield package:ios --configuration Release", "eas:stg": "EXPO_TOKEN=$EAS_TOKEN eas update --channel production --message 'testing 1st stg channel update' --platform android" }, "dependencies": { @@ -58,9 +58,5 @@ "jest-expo": "~54.0.16", "react-test-renderer": "19.1.0", "typescript": "~5.9.3" - }, - "brownie": { - "kotlin": "./android/brownfieldlib/src/main/java/com/callstack/rnbrownfield/demo/expoapp54/Generated/", - "kotlinPackageName": "com.callstack.rnbrownfield.demo.expoapp54" } } diff --git a/apps/ExpoApp55/package.json b/apps/ExpoApp55/package.json index 63375638..a18cb58b 100644 --- a/apps/ExpoApp55/package.json +++ b/apps/ExpoApp55/package.json @@ -14,9 +14,9 @@ "ci:local:e2e:ios": "bash ../../scripts/ci-local-expo55-ios-e2e.sh", "prebuild": "expo prebuild", "brownfield:prepare:android:ci": "cd .. && node --experimental-strip-types --no-warnings ./scripts/prepare-android-build-gradle-for-ci.ts ExpoApp55", - "brownfield:package:android": "brownfield package:android --module-name brownfieldlib --variant release --verbose", - "brownfield:publish:android": "brownfield publish:android --module-name brownfieldlib --verbose", - "brownfield:package:ios": "brownfield package:ios --scheme BrownfieldLib --configuration Release --verbose", + "brownfield:package:android": "brownfield package:android --variant release", + "brownfield:publish:android": "brownfield publish:android", + "brownfield:package:ios": "brownfield package:ios --configuration Release", "eas:stg": "EXPO_TOKEN=$EAS_TOKEN eas update --channel production --message 'testing 1st stg channel update' --platform ios --environment staging" }, "dependencies": { @@ -66,8 +66,17 @@ "typescript": "~5.9.2" }, "private": true, - "brownie": { - "kotlin": "./android/brownfieldlib/src/main/java/com/callstack/rnbrownfield/demo/expoapp55/Generated/", - "kotlinPackageName": "com.callstack.rnbrownfield.demo.expoapp55" + "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 } } diff --git a/apps/RNApp/brownfield.config.js b/apps/RNApp/brownfield.config.js new file mode 100644 index 00000000..2304c089 --- /dev/null +++ b/apps/RNApp/brownfield.config.js @@ -0,0 +1,12 @@ +/** + * @type {import('@callstack/react-native-brownfield').BrownfieldConfig} + */ +module.exports = { + android: { + moduleName: ':BrownfieldLib', + }, + ios: { + scheme: 'BrownfieldLib', + }, + verbose: true, +}; diff --git a/apps/RNApp/package.json b/apps/RNApp/package.json index aa4a40c4..48a2d82b 100644 --- a/apps/RNApp/package.json +++ b/apps/RNApp/package.json @@ -7,9 +7,9 @@ "ios": "yarn brownfield:package:ios && brownfield codegen && react-native run-ios", "build:example:android-rn": "react-native build-android", "build:example:ios-rn": "react-native build-ios", - "brownfield:package:android": "brownfield package:android --module-name :BrownfieldLib --variant release --verbose", - "brownfield:publish:android": "brownfield publish:android --module-name :BrownfieldLib --verbose", - "brownfield:package:ios": "brownfield package:ios --scheme BrownfieldLib --configuration Release --verbose", + "brownfield:package:android": "brownfield package:android --variant release", + "brownfield:publish:android": "brownfield publish:android", + "brownfield:package:ios": "brownfield package:ios --configuration Release", "lint": "eslint .", "start": "react-native start", "test": "jest --config jest.config.js", diff --git a/docs/.gitignore b/docs/.gitignore index 044373fb..4861b320 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -23,3 +23,6 @@ dist-ssr *.sln *.sw? doc_build + +# Copied schema file +docs/public/schema.json diff --git a/docs/docs/docs/api-reference/_meta.json b/docs/docs/docs/api-reference/_meta.json index 01a36bfd..cb0953b0 100644 --- a/docs/docs/docs/api-reference/_meta.json +++ b/docs/docs/docs/api-reference/_meta.json @@ -1,4 +1,9 @@ [ + { + "type": "file", + "name": "configuration", + "label": "Configuration files" + }, { "type": "dir", "name": "react-native-brownfield", diff --git a/docs/docs/docs/api-reference/configuration.mdx b/docs/docs/docs/api-reference/configuration.mdx new file mode 100644 index 00000000..b24783d2 --- /dev/null +++ b/docs/docs/docs/api-reference/configuration.mdx @@ -0,0 +1,215 @@ +# Configuration files + +The Brownfield CLI can load configuration from a file instead of repeating the same flags on every command. +That configuration covers both `@callstack/react-native-brownfield` and `@callstack/brownie` options. + +Configuration keys use camelCase names that match CLI flags inside their platform section. +For example, `--module-name` becomes `moduleName`, `--build-folder` becomes `buildFolder`, and `--use-prebuilt-rn-core` becomes `usePrebuiltRnCore`. + +## Choose one configuration source + +The CLI supports exactly one configuration source per project: + +- `react-native-brownfield.config.js` +- `react-native-brownfield.config.json` +- `package.json` under the `react-native-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. + +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. + +## JavaScript config file + +If you prefer a JavaScript file, create `react-native-brownfield.config.js` and export a plain object with `module.exports`: + +```js +/** @type {import('@callstack/react-native-brownfield').BrownfieldConfig} */ +module.exports = { + verbose: true, + android: { + moduleName: ':BrownfieldLib', + }, + ios: { + scheme: 'BrownfieldLib', + }, + brownie: { + kotlin: + './android/BrownfieldLib/src/main/java/com/example/brownfield/Generated/', + kotlinPackageName: 'com.example.brownfield', + }, +}; +``` + +## JSON config file + +If you want schema autocomplete and validation directly in the config file, use `react-native-brownfield.config.json`: + +```json +{ + "$schema": "https://oss.callstack.com/react-native-brownfield/schema.json", + "verbose": true, + "android": { + "moduleName": "brownfieldlib" + }, + "ios": { + "scheme": "BrownfieldLib", + "configuration": "Release", + "usePrebuiltRnCore": true + }, + "brownie": { + "kotlin": "./android/brownfieldlib/src/main/java/com/example/brownfield/Generated/", + "kotlinPackageName": "com.example.brownfield" + } +} +``` + +## package.json config + +If you prefer to keep everything in `package.json`, place the configuration under `react-native-brownfield`: + +```json +{ + "name": "my-app", + "brownfield": { + "verbose": true, + "android": { + "moduleName": ":BrownfieldLib" + }, + "ios": { + "scheme": "BrownfieldLib" + }, + "brownie": { + "kotlin": "./android/BrownfieldLib/src/main/java/com/example/brownfield/Generated/", + "kotlinPackageName": "com.example.brownfield" + } + } +} +``` + +## Configuration reference + +All file-based platform options mirror CLI flags, but they use camelCase property names under `android` or `ios`. + +### Shared keys + +| Key | Type | Description | +| --------- | --------- | --------------------------------------------------------------------- | +| `$schema` | `string` | JSON Schema URL used by editors for validation and autocomplete. | +| `verbose` | `boolean` | Enables verbose CLI logging. | +| `brownie` | `object` | Nested Brownie configuration used by `brownfield codegen`. See below. | + +### 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`. | + +### iOS keys + +| Key | Type | Description | +| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------- | +| `ios.scheme` | `string` | Xcode scheme used for packaging. | +| `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. | +| `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. | + +## Brownie configuration + +The Brownie configuration lives inside the main Brownfield config under the `brownie` key. +This is the preferred format for Brownie code generation. + +Currently supported Brownie keys are: + +| Key | Type | Description | +| ------------------- | -------- | ----------------------------------------------------------------------- | +| `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`: + +```json +{ + "$schema": "https://oss.callstack.com/react-native-brownfield/schema.json", + "brownie": { + "kotlin": "./android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/Generated/", + "kotlinPackageName": "com.rnapp.brownfieldlib" + } +} +``` + +Only the Kotlin output is configurable. +Swift Brownie files are always generated to `node_modules/@callstack/brownie/ios/Generated/`. + +## Migrating from legacy Brownie configuration + +Legacy Brownie configuration used a top-level `brownie` block in `package.json`: + +```json +{ + "brownie": { + "kotlin": "./android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/Generated/", + "kotlinPackageName": "com.rnapp.brownfieldlib" + } +} +``` + +The new format moves the same values under the main Brownfield config: + +```json +{ + "brownfield": { + "android": { + "moduleName": ":BrownfieldLib" + }, + "ios": { + "scheme": "BrownfieldLib" + }, + "brownie": { + "kotlin": "./android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/Generated/", + "kotlinPackageName": "com.rnapp.brownfieldlib" + } + } +} +``` + +You can also migrate to a standalone config file: + +```json +{ + "$schema": "https://oss.callstack.com/react-native-brownfield/schema.json", + "android": { + "moduleName": ":BrownfieldLib" + }, + "ios": { + "scheme": "BrownfieldLib" + }, + "brownie": { + "kotlin": "./android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/Generated/", + "kotlinPackageName": "com.rnapp.brownfieldlib" + } +} +``` + +Migration steps: + +1. Pick one main Brownfield config source. +2. Move the legacy `package.json#brownie` values into the nested `brownie` object in that source. +3. Remove the old top-level `brownie` block from `package.json`. +4. Run `brownfield codegen` again. + +Do not keep the legacy and new Brownie configuration at the same time. +If both are present, `brownfield codegen` throws an error. +If only the legacy format is present, the command still works for now, but it prints a migration warning. diff --git a/docs/docs/docs/cli/brownfield.mdx b/docs/docs/docs/cli/brownfield.mdx index 0febfb5e..4fb142ba 100644 --- a/docs/docs/docs/cli/brownfield.mdx +++ b/docs/docs/docs/cli/brownfield.mdx @@ -2,6 +2,11 @@ The `brownfield` CLI provides utilities for building & packaging artifacts for brownfield projects that use the `@callstack/react-native-brownfield` library. +:::tip Configuration file +You can store supported `brownfield` CLI options in a project configuration file instead of passing the same flags on every command. +See [Configuration files](/docs/api-reference/configuration) for supported config sources and option names. +::: + ## Usage ```bash @@ -37,7 +42,6 @@ Available arguments: | --no-install-pods | Skip automatic CocoaPods installation | | --no-new-arch | Run React Native in legacy async architecture | | --local | Force local build with xcodebuild | -| --verbose | Enable verbose logging | 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/docs/docs/docs/cli/brownie.mdx b/docs/docs/docs/cli/brownie.mdx index a2a01dab..201297f8 100644 --- a/docs/docs/docs/cli/brownie.mdx +++ b/docs/docs/docs/cli/brownie.mdx @@ -2,6 +2,11 @@ The `brownfield codegen` CLI command generates `@callstack/brownie` (Brownie) state management library native store types from TypeScript schema. +:::tip Configuration file +You can configure Brownie codegen from the main Brownfield config file by using the nested `brownie` object. +See [Configuration files](/docs/api-reference/configuration) for supported config sources and Brownie-specific settings. +::: + ## Usage ```bash diff --git a/docs/docs/docs/getting-started/android.mdx b/docs/docs/docs/getting-started/android.mdx index fa0fec2e..488b69da 100644 --- a/docs/docs/docs/getting-started/android.mdx +++ b/docs/docs/docs/getting-started/android.mdx @@ -281,21 +281,40 @@ tasks.named("generateMetadataFileForMavenAarPublication") { } ``` -## 7. Create the AAR +## 7. Create a Brownfield Configuration + +Create `react-native-brownfield.config.json` in your project root: + +```json +{ + "$schema": "https://oss.callstack.com/react-native-brownfield/schema.json", + "android": { + "moduleName": "reactnativeapp", + "variant": "Release" + } +} +``` + +This lets the CLI reuse your packaging settings without repeating the same flags on every command. +See [Configuration files](/docs/api-reference/configuration) for JavaScript and `package.json` variants and the full list of supported options. + +## 8. Create the AAR Use the brownfield CLI to package your React Native app: ```bash -npx brownfield package:android --variant Release --module-name reactnativeapp +npx brownfield package:android ``` Then publish to **local Maven**: ```bash -npx brownfield publish:android --module-name reactnativeapp +npx brownfield publish:android ``` -## 8. Add the AAR to Your Android App +If you prefer to keep the settings on the command line, you can still run `npx brownfield package:android --variant Release --module-name reactnativeapp` and `npx brownfield publish:android --module-name reactnativeapp` instead. + +## 9. Add the AAR to Your Android App Add **`mavenLocal()`** to your app's `settings.gradle.kts`: @@ -315,7 +334,7 @@ dependencies { } ``` -## 9. Initialize React Native +## 10. Initialize React Native In your **`MainActivity`**: @@ -333,7 +352,7 @@ class MainActivity : AppCompatActivity() { } ``` -## 10. Show the React Native UI +## 11. Show the React Native UI ### Using Fragment diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index d241c01e..9ac0cf5b 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -105,16 +105,35 @@ public let ReactNativeBundle = Bundle(for: InternalClassForBundle.self) class InternalClassForBundle {} ``` -## 5. Create the XCFramework +## 5. Create a Brownfield Configuration + +Create `react-native-brownfield.config.json` in your project root: + +```json +{ + "$schema": "https://oss.callstack.com/react-native-brownfield/schema.json", + "ios": { + "scheme": "", + "configuration": "Release" + } +} +``` + +This lets the CLI reuse your packaging settings without repeating the same flags on every command. +See [Configuration files](/docs/api-reference/configuration) for JavaScript and `package.json` variants and the full list of supported options. + +## 6. Create the XCFramework Use the brownfield CLI to package your React Native app: ```bash -npx brownfield package:ios --scheme --configuration Release +npx brownfield package:ios ``` This creates the XCFramework in **`ios/.brownfield/package/build/`** (relative to your project root). +If you prefer to keep the settings on the command line, you can still run `npx brownfield package:ios --scheme --configuration Release` instead. + If you also want a local Swift Package Manager wrapper around the generated XCFrameworks, add `--add-spm-package`: ```bash @@ -123,7 +142,7 @@ npx brownfield package:ios --scheme --configuration Rele That command writes `Package.swift` into the same `ios/.brownfield/package/build/` directory. -## 6. Add the Framework to Your iOS App +## 7. Add the Framework to Your iOS App 1. Open **`ios/.brownfield/package/build`** directory (relative to your React Native project root) 2. Drag these files into your native iOS app's Xcode project: @@ -161,7 +180,7 @@ For a successful validation of a Release package: 4. Build the host app 5. Run the host app without Metro and confirm the packaged React Native content loads correctly -## 7. Initialize React Native +## 8. Initialize React Native In your native iOS app's **`AppDelegate.swift`**: @@ -195,7 +214,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } ``` -## 8. Run Your App +## 9. Run Your App ### Debug Configuration diff --git a/docs/docs/docs/getting-started/quick-start.mdx b/docs/docs/docs/getting-started/quick-start.mdx index 13499245..e73f47af 100644 --- a/docs/docs/docs/getting-started/quick-start.mdx +++ b/docs/docs/docs/getting-started/quick-start.mdx @@ -77,6 +77,7 @@ Now that you have the library installed, follow the platform-specific guides to For detailed API documentation, see: +- [Configuration files](/docs/api-reference/configuration) - [Swift API](/docs/api-reference/react-native-brownfield/swift) - [Objective-C API](/docs/api-reference/react-native-brownfield/objective-c) - [Kotlin API](/docs/api-reference/react-native-brownfield/kotlin) diff --git a/docs/docs/public/package-json.schema.json b/docs/docs/public/package-json.schema.json new file mode 100644 index 00000000..28383c8d --- /dev/null +++ b/docs/docs/public/package-json.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "React Native Brownfield package.json extension", + "description": "Adds react-native-brownfield configuration completions to package.json.", + "type": "object", + "properties": { + "brownfield": { + "$ref": "https://oss.callstack.com/react-native-brownfield/schema.json" + } + } +} \ No newline at end of file diff --git a/docs/package.json b/docs/package.json index 11553bb2..c1ab49bb 100644 --- a/docs/package.json +++ b/docs/package.json @@ -7,8 +7,9 @@ "hoistingLimits": "workspaces" }, "scripts": { - "dev": "rspress dev", - "build": "rspress build", + "copy:schema": "node ./scripts/copy-schema.mjs", + "dev": "yarn run copy:schema && rspress dev", + "build": "yarn run copy:schema && rspress build", "build:docs": "yarn run build", "preview": "rspress preview" }, diff --git a/docs/scripts/copy-schema.mjs b/docs/scripts/copy-schema.mjs new file mode 100644 index 00000000..81dd3b59 --- /dev/null +++ b/docs/scripts/copy-schema.mjs @@ -0,0 +1,11 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..', '..'); + +const sourcePath = path.join(repoRoot, 'packages', 'cli', 'schema.json'); +const targetPath = path.join(repoRoot, 'docs', 'docs', 'public', 'schema.json'); + +await fs.copyFile(sourcePath, targetPath); diff --git a/lefthook.yml b/lefthook.yml index 5823dad2..3e8e1d20 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,6 +1,9 @@ pre-commit: parallel: true commands: + brownfield-config-schema: + run: yarn generate:schema && git add packages/cli/schema.json + brownfield-navigation-drift: run: node --experimental-strip-types --no-warnings ./scripts/check-brownfield-navigation-drift.ts diff --git a/package.json b/package.json index ca401be9..cb01337d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "brownfield:plugin:release-notes": "node --experimental-strip-types --no-warnings ./scripts/generate-brownfield-gradle-plugin-release-notes.ts", "brownfield:plugin:version:check": "node --experimental-strip-types --no-warnings ./scripts/sync-brownfield-gradle-plugin-version.ts --check", "brownfield:plugin:version:sync": "node --experimental-strip-types --no-warnings ./scripts/sync-brownfield-gradle-plugin-version.ts", + "generate:schema": "yarn workspace @callstack/brownfield-cli generate:schema", "generate:store": "node --experimental-strip-types --no-warnings ./scripts/generate-store.ts", "skillgym:brownie": "skillgym run skillgym/suites/brownie-suite.ts", "skillgym:navigation": "skillgym run skillgym/suites/brownfield-navigation-suite.ts", diff --git a/packages/cli/package.json b/packages/cli/package.json index dd048069..bd823f81 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,6 +37,11 @@ "types": "./dist/navigation/index.d.ts", "default": "./dist/navigation/index.js" }, + "./types": { + "source": "./src/types.ts", + "types": "./dist/types.d.ts", + "default": "./dist/types.js" + }, "./package.json": "./package.json" }, "scripts": { @@ -45,6 +50,7 @@ "build:brownfield": "yarn run build", "build": "node -e \"const fs=require('fs'),p=require('path');const d=p.join('dist','index.d.ts');if(!fs.existsSync(d)){try{fs.unlinkSync('tsconfig.tsbuildinfo')}catch{}}\" && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", + "generate:schema": "ts-json-schema-generator --path src/types.ts --type BrownfieldConfig --out schema.json && prettier --write schema.json", "test": "vitest run" }, "keywords": [ @@ -81,6 +87,7 @@ "@rock-js/plugin-brownfield-android": "^0.13.3", "@rock-js/plugin-brownfield-ios": "^0.13.3", "@rock-js/tools": "^0.13.3", + "ajv": "^8.20.0", "commander": "^14.0.3", "quicktype-core": "^23.2.6", "quicktype-typescript-input": "^23.2.6", @@ -100,6 +107,7 @@ "eslint": "^9.39.3", "globals": "^17.3.0", "nodemon": "^3.1.14", + "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 new file mode 100644 index 00000000..00ee5b71 --- /dev/null +++ b/packages/cli/schema.json @@ -0,0 +1,110 @@ +{ + "$ref": "#/definitions/BrownfieldConfig", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "BrownfieldAndroidConfig": { + "additionalProperties": false, + "properties": { + "moduleName": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "type": "object" + }, + "BrownfieldConfig": { + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "android": { + "$ref": "#/definitions/BrownfieldAndroidConfig" + }, + "brownie": { + "$ref": "#/definitions/BrownieConfig" + }, + "ios": { + "$ref": "#/definitions/BrownfieldIosConfig" + }, + "verbose": { + "type": "boolean" + } + }, + "type": "object" + }, + "BrownfieldIosConfig": { + "additionalProperties": false, + "properties": { + "addSpmPackage": { + "description": "When set, generate a local Swift Package Manager manifest next to the packaged XCFramework outputs.", + "type": "boolean" + }, + "archive": { + "type": "boolean" + }, + "buildFolder": { + "type": "string" + }, + "configuration": { + "type": "string" + }, + "destination": { + "items": { + "type": "string" + }, + "type": "array" + }, + "exportExtraParams": { + "items": { + "type": "string" + }, + "type": "array" + }, + "exportOptionsPlist": { + "type": "string" + }, + "extraParams": { + "items": { + "type": "string" + }, + "type": "array" + }, + "installPods": { + "type": "boolean" + }, + "local": { + "type": "boolean" + }, + "newArch": { + "type": "boolean" + }, + "scheme": { + "type": "string" + }, + "target": { + "type": "string" + }, + "usePrebuiltRnCore": { + "description": "Set when `--use-prebuilt-rn-core` is passed; omitted when the flag is absent (Rock applies RN version defaults).", + "type": "boolean" + } + }, + "type": "object" + }, + "BrownieConfig": { + "additionalProperties": false, + "properties": { + "kotlin": { + "type": "string" + }, + "kotlinPackageName": { + "type": "string" + } + }, + "type": "object" + } + } +} diff --git a/packages/cli/src/__tests__/config.test.ts b/packages/cli/src/__tests__/config.test.ts new file mode 100644 index 00000000..4b0378e4 --- /dev/null +++ b/packages/cli/src/__tests__/config.test.ts @@ -0,0 +1,363 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import * as rockTools from '@rock-js/tools'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@rock-js/tools', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { + ...actual.logger, + warn: vi.fn(), + debug: vi.fn(), + setVerbose: vi.fn(), + }, + }; +}); + +vi.mock('../brownfield/utils/paths.js', () => ({ + findProjectRoot: vi.fn(() => process.cwd()), +})); + +import { + loadBrownfieldConfig, + mergeBrownfieldConfigWithOptions, + validateBrownfieldCLIConfig, +} from '../config.js'; + +const mockLoggerWarn = rockTools.logger.warn as ReturnType; +const mockLoggerSetVerbose = rockTools.logger.setVerbose as ReturnType< + typeof vi.fn +>; +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-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; +} + +describe('loadBrownfieldConfig', () => { + let tempDir: string | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + process.chdir(originalCwd); + + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('loads config from package.json', () => { + tempDir = createTempProject({ + packageJsonConfig: { + ios: { + scheme: 'PackageScheme', + destination: ['simulator'], + }, + }, + }); + + expect(loadBrownfieldConfig(tempDir)).toEqual({ + ios: { + scheme: 'PackageScheme', + destination: ['simulator'], + }, + }); + }); + + it('loads config from a JavaScript config file', () => { + tempDir = createTempProject({ + jsConfig: { + ios: { + scheme: 'JsScheme', + installPods: true, + }, + }, + }); + + expect(loadBrownfieldConfig(tempDir)).toEqual({ + ios: { + scheme: 'JsScheme', + installPods: true, + }, + }); + }); + + it('loads config from a JSON config file', () => { + tempDir = createTempProject({ + jsonConfig: { + ios: { + scheme: 'JsonScheme', + }, + verbose: true, + }, + }); + + expect(loadBrownfieldConfig(tempDir)).toEqual({ + ios: { + scheme: 'JsonScheme', + }, + verbose: true, + }); + }); + + it('returns an empty config when no source exists', () => { + tempDir = createTempProject(); + + expect(loadBrownfieldConfig(tempDir)).toEqual({}); + }); + + it('throws when multiple config sources are present', () => { + tempDir = createTempProject({ + packageJsonConfig: { + ios: { + scheme: 'PackageScheme', + }, + }, + jsConfig: { + ios: { + scheme: 'JsScheme', + }, + }, + }); + + expect(() => loadBrownfieldConfig(tempDir!)).toThrow( + 'Project has multiple Brownfield configuration files' + ); + }); +}); + +describe('validateBrownfieldCLIConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not warn for a schema-valid config', () => { + validateBrownfieldCLIConfig({ + verbose: true, + ios: { + scheme: 'AppScheme', + destination: ['simulator'], + usePrebuiltRnCore: true, + }, + brownie: { + kotlin: + './android/BrownfieldLib/src/main/java/com/rnapp/brownfieldlib/Generated/', + kotlinPackageName: 'com.rnapp.brownfieldlib', + }, + }); + + expect(mockLoggerWarn).not.toHaveBeenCalled(); + }); + + it('warns for a schema-invalid config', () => { + validateBrownfieldCLIConfig({ + unsupportedOption: true, + }); + + expect(mockLoggerWarn).toHaveBeenCalledTimes(1); + expect(mockLoggerWarn.mock.calls[0]?.[0]).toContain( + 'Brownfield configuration has some issues:' + ); + }); +}); + +describe('mergeBrownfieldConfigWithOptions', () => { + let tempDir: string | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + process.chdir(originalCwd); + + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('applies platform config values to undefined CLI options', () => { + tempDir = createTempProject({ + packageJsonConfig: { + ios: { + scheme: 'ConfigScheme', + installPods: true, + destination: ['simulator'], + }, + }, + }); + process.chdir(tempDir); + + const options = { + target: 'MyApp', + scheme: undefined, + }; + + const mergedOptions = mergeBrownfieldConfigWithOptions(options, 'ios'); + + expect(mergedOptions).toMatchObject({ + scheme: 'ConfigScheme', + installPods: true, + destination: ['simulator'], + target: 'MyApp', + }); + expect(mockLoggerWarn).not.toHaveBeenCalled(); + }); + + it('preserves CLI options when they override platform config', () => { + tempDir = createTempProject({ + packageJsonConfig: { + ios: { + scheme: 'ConfigScheme', + }, + }, + }); + process.chdir(tempDir); + + const options = { + scheme: 'CliScheme', + }; + + const mergedOptions = mergeBrownfieldConfigWithOptions(options, 'ios'); + + expect(mergedOptions.scheme).toBe('CliScheme'); + expect(mockLoggerWarn).toHaveBeenCalledWith( + 'CLI option "%s" is overriding the react-native-brownfield config value: %s -> %s.', + 'scheme', + 'ConfigScheme', + 'CliScheme' + ); + }); + + it('logs array config values overridden by CLI options', () => { + tempDir = createTempProject({ + packageJsonConfig: { + ios: { + destination: ['simulator'], + }, + }, + }); + process.chdir(tempDir); + + mergeBrownfieldConfigWithOptions( + { + destination: ['device'], + }, + 'ios' + ); + + expect(mockLoggerWarn).toHaveBeenCalledWith( + 'CLI option "%s" is overriding the react-native-brownfield config value: %s -> %s.', + 'destination', + '["simulator"]', + '["device"]' + ); + }); + + it('does not allow undefined options to override platform config', () => { + tempDir = createTempProject({ + packageJsonConfig: { + android: { + variant: 'release', + }, + }, + }); + process.chdir(tempDir); + + const options = { + variant: undefined, + }; + + const mergedOptions = mergeBrownfieldConfigWithOptions(options, 'android'); + + expect(mergedOptions.variant).toBe('release'); + }); + + it('applies shared config values to platform commands', () => { + tempDir = createTempProject({ + packageJsonConfig: { + verbose: true, + android: { + moduleName: ':BrownfieldLib', + }, + }, + }); + process.chdir(tempDir); + + const options = {}; + + const mergedOptions = mergeBrownfieldConfigWithOptions(options, 'android'); + + expect(mergedOptions).toMatchObject({ + verbose: true, + moduleName: ':BrownfieldLib', + }); + expect(mockLoggerSetVerbose).toHaveBeenCalledWith(true); + }); + + it('applies verbose after CLI options override shared config', () => { + tempDir = createTempProject({ + packageJsonConfig: { + verbose: true, + }, + }); + process.chdir(tempDir); + + const mergedOptions = mergeBrownfieldConfigWithOptions( + { + verbose: false, + }, + 'android' + ); + + expect(mergedOptions.verbose).toBe(false); + expect(mockLoggerSetVerbose).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/cli/src/brownfield/commands/packageAndroid.ts b/packages/cli/src/brownfield/commands/packageAndroid.ts index 6852f3c5..abd69b07 100644 --- a/packages/cli/src/brownfield/commands/packageAndroid.ts +++ b/packages/cli/src/brownfield/commands/packageAndroid.ts @@ -15,16 +15,15 @@ import { runExpoPrebuildIfNeeded } from '../utils/expo.js'; import { getProjectInfo } from '../utils/project.js'; import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieCodegenIfApplicable.js'; import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js'; +import { mergeBrownfieldConfigWithOptions } from '../../config.js'; export const packageAndroidCommand = curryOptions( new Command('package:android').description('Build Android AAR'), - packageAarOptions.map((option) => - option.name.startsWith('--variant') - ? { ...option, default: 'debug' } - : option - ) + packageAarOptions ).action( - actionRunner(async (options: PackageAarFlags) => { + actionRunner(async (cliOptions: PackageAarFlags) => { + const options = mergeBrownfieldConfigWithOptions(cliOptions, 'android'); + const { projectRoot, platformConfig } = getProjectInfo('android'); await runExpoPrebuildIfNeeded({ projectRoot, diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index ea2b866f..cde328d7 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -4,7 +4,6 @@ import path from 'node:path'; import { getBuildOptions, mergeFrameworks, - type BuildFlags as AppleBuildFlags, } from '@rock-js/platform-apple-helpers'; import { packageIosAction } from '@rock-js/plugin-brownfield-ios'; import { @@ -30,7 +29,9 @@ import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNa import { copyDebugBundleToSimulatorSlice } from '../utils/copyDebugBundleToSimulatorSlice.js'; import { resolvePackagedFrameworkName } from '../utils/resolvePackagedFrameworkName.js'; import { stripFrameworkBinary } from '../utils/stripFrameworkBinary.js'; +import type { PackageIosOptions } from '../../types.js'; import { createLocalSpmPackage } from '../utils/createLocalSpmPackage.js'; +import { mergeBrownfieldConfigWithOptions } from '../../config.js'; /** Help text for `--use-prebuilt-rn-core` (keep in sync with docs/docs/docs/getting-started/ios.mdx, "React Native Prebuilts" section). */ const USE_PREBUILT_RN_CORE_HELP = @@ -69,13 +70,6 @@ function getPackagedFrameworkResolutionFailureMessage({ : 'could not resolve the packaged framework output automatically; pass --scheme explicitly'; } -type PackageIosCliFlags = 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; -}; - export const packageIosCommand = curryOptions( new Command('package:ios').description('Build iOS XCFramework'), getBuildOptions({ platformName: 'ios' }).map((option) => @@ -101,7 +95,9 @@ export const packageIosCommand = curryOptions( ) ) .action( - actionRunner(async (options: PackageIosCliFlags) => { + actionRunner(async (cliOptions: PackageIosOptions) => { + const options = mergeBrownfieldConfigWithOptions(cliOptions, 'ios'); + const { projectRoot, platformConfig, userConfig } = getProjectInfo('ios'); const prebuiltRNCoreSupport = supportsPrebuiltRNCore({ projectRoot }); @@ -196,10 +192,12 @@ export const packageIosCommand = curryOptions( if (!frameworkName && options.addSpmPackage) { throw new RockError( - `Cannot generate local SPM package: ${getPackagedFrameworkResolutionFailureMessage({ - resolution, - candidates, - })}` + `Cannot generate local SPM package: ${getPackagedFrameworkResolutionFailureMessage( + { + resolution, + candidates, + } + )}` ); } @@ -231,10 +229,12 @@ export const packageIosCommand = curryOptions( } } else if (configuration.includes('Debug')) { logger.warn( - `Skipping Debug simulator JS bundle copy: ${getPackagedFrameworkResolutionFailureMessage({ - resolution, - candidates, - })}` + `Skipping Debug simulator JS bundle copy: ${getPackagedFrameworkResolutionFailureMessage( + { + resolution, + candidates, + } + )}` ); } @@ -326,7 +326,7 @@ export const packageIosCommand = curryOptions( `Add the local package folder in Xcode: ${colorLink(relativeToCwd(packageDir))}` ); logger.info( - "In Xcode, choose File > Add Package Dependencies..., click Add Local..., and select that folder." + 'In Xcode, choose File > Add Package Dependencies..., click Add Local..., and select that folder.' ); } }) diff --git a/packages/cli/src/brownfield/commands/publishAndroid.ts b/packages/cli/src/brownfield/commands/publishAndroid.ts index 6edb6746..dee9d60e 100644 --- a/packages/cli/src/brownfield/commands/publishAndroid.ts +++ b/packages/cli/src/brownfield/commands/publishAndroid.ts @@ -15,6 +15,7 @@ import { getProjectInfo } from '../utils/project.js'; import { runExpoPrebuildIfNeeded } from '../utils/expo.js'; import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieCodegenIfApplicable.js'; import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js'; +import { mergeBrownfieldConfigWithOptions } from '../../config.js'; export const publishAndroidCommand = curryOptions( new Command('publish:android').description( @@ -22,7 +23,9 @@ export const publishAndroidCommand = curryOptions( ), publishLocalAarOptions ).action( - actionRunner(async (options: PublishLocalAarFlags) => { + actionRunner(async (cliOptions: PublishLocalAarFlags) => { + const options = mergeBrownfieldConfigWithOptions(cliOptions, 'android'); + const { projectRoot, platformConfig } = getProjectInfo('android'); await runExpoPrebuildIfNeeded({ projectRoot, diff --git a/packages/cli/src/brownie/__tests__/commands/codegen.test.ts b/packages/cli/src/brownie/__tests__/commands/codegen.test.ts index deb5473d..76fb0b5d 100644 --- a/packages/cli/src/brownie/__tests__/commands/codegen.test.ts +++ b/packages/cli/src/brownie/__tests__/commands/codegen.test.ts @@ -113,6 +113,27 @@ describe('runCodegen', () => { expect(mockGenerateSwift).not.toHaveBeenCalled(); }); + it('throws when legacy and new brownie configs are both provided', async () => { + tempDir = createTempPackageJson({ + brownie: { + kotlin: './LegacyGenerated', + }, + }); + mockCwd.mockReturnValue(tempDir); + + await expect( + runCodegen({ + brownie: { + kotlin: './NewGenerated', + }, + }) + ).rejects.toThrow( + 'Cannot use both legacy and new Brownie configuration formats simultaneously.' + ); + + expect(mockDiscoverStores).not.toHaveBeenCalled(); + }); + it('generates swift and kotlin by default when kotlin is configured', async () => { tempDir = createTempPackageJson({ brownie: { diff --git a/packages/cli/src/brownie/commands/codegen.ts b/packages/cli/src/brownie/commands/codegen.ts index 28d2a5bc..1571b8e9 100644 --- a/packages/cli/src/brownie/commands/codegen.ts +++ b/packages/cli/src/brownie/commands/codegen.ts @@ -6,16 +6,12 @@ import { Command, Option } from 'commander'; import { intro, logger, outro } from '@rock-js/tools'; import { QuickTypeError } from 'quicktype-core'; import { actionRunner } from '../../shared/index.js'; -import { - loadConfig, - getSwiftOutputPath, - type BrownieConfig, -} from '../config.js'; +import { hasLegacyConfig, loadConfig, getSwiftOutputPath } from '../config.js'; import { generateSwift } from '../generators/swift.js'; import { generateKotlin } from '../generators/kotlin.js'; import { discoverStores, type DiscoveredStore } from '../store-discovery.js'; -import type { Platform } from '../types.js'; import { NoBrownieStoresError } from '../errors/NoBrownieStoresError.js'; +import type { BrownieConfig, Platform } from '../../types.js'; function getOutputPath(dir: string, name: string, ext: string): string { return path.join(dir, `${name}.${ext}`); @@ -84,17 +80,34 @@ async function generateForStore( } } -export type RunCodegenOptions = { platform?: Platform }; +export type RunCodegenOptions = { + platform?: Platform; + brownie?: BrownieConfig; +}; /** * Runs the codegen command with the given arguments. */ -export async function runCodegen({ platform }: RunCodegenOptions) { +export async function runCodegen({ platform, brownie }: RunCodegenOptions) { intro( `Running Brownie codegen for ${platform ? `platform ${platform}` : 'all platforms'}` ); - const config = loadConfig(); + const legacyConfig = hasLegacyConfig() ? loadConfig() : undefined; + + if (legacyConfig && brownie) { + throw new Error( + 'Cannot use both legacy and new Brownie configuration formats simultaneously. Please migrate to the new configuration format and remove legacy configuration files: https://oss.callstack.com/react-native-brownfield/docs/api-reference/configuration#migrating-from-legacy-brownie-configuration' + ); + } + + if (legacyConfig) { + logger.warn( + 'You are using legacy Brownie configuration. Please migrate to the new configuration format. See the documentation for more details: https://oss.callstack.com/react-native-brownfield/docs/api-reference/configuration#migrating-from-legacy-brownie-configuration' + ); + } + + const config = brownie || legacyConfig || {}; if (platform && !['swift', 'kotlin'].includes(platform)) { logger.error(`Invalid platform: ${platform}. Must be 'swift' or 'kotlin'`); diff --git a/packages/cli/src/brownie/config.ts b/packages/cli/src/brownie/config.ts index 7b3d2192..2945f3fe 100644 --- a/packages/cli/src/brownie/config.ts +++ b/packages/cli/src/brownie/config.ts @@ -1,21 +1,28 @@ import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; - -export interface BrownieConfig { - kotlin?: string; - kotlinPackageName?: string; -} +import { findProjectRoot } from '../brownfield/utils/paths.js'; +import type { BrownieConfig } from '../types.js'; interface PackageJson { brownie?: BrownieConfig; } +function loadPackageJson(projectRoot: string = findProjectRoot()): PackageJson { + const packageJsonPath = path.resolve(projectRoot, 'package.json'); + + if (!fs.existsSync(packageJsonPath)) { + throw new Error('package.json not found'); + } + + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as PackageJson; +} + /** * Checks if @callstack/brownie package is installed. */ export function isBrownieInstalled( - projectRoot: string = process.cwd() + projectRoot: string = findProjectRoot() ): boolean { const require = createRequire(path.join(projectRoot, 'package.json')); try { @@ -30,7 +37,7 @@ export function isBrownieInstalled( * Resolves the path to the @callstack/brownie package. */ export function getBrowniePackagePath( - projectRoot: string = process.cwd() + projectRoot: string = findProjectRoot() ): string { const require = createRequire(path.join(projectRoot, 'package.json')); try { @@ -48,24 +55,28 @@ export function getBrowniePackagePath( * Returns the output path for generated Swift files. */ export function getSwiftOutputPath( - projectRoot: string = process.cwd() + projectRoot: string = findProjectRoot() ): string { const browniePath = getBrowniePackagePath(projectRoot); return path.join(browniePath, 'ios', 'Generated'); } +/** + * Returns whether package.json contains legacy brownie config. + */ +export function hasLegacyConfig( + projectRoot: string = findProjectRoot() +): boolean { + const packageJson = loadPackageJson(projectRoot); + + return Object.prototype.hasOwnProperty.call(packageJson, 'brownie'); +} + /** * Loads brownie config from package.json in the current working directory. */ export function loadConfig(): BrownieConfig { - const packageJsonPath = path.resolve(process.cwd(), 'package.json'); - - if (!fs.existsSync(packageJsonPath)) { - throw new Error('package.json not found'); - } + const packageJson = loadPackageJson(); - const packageJson: PackageJson = JSON.parse( - fs.readFileSync(packageJsonPath, 'utf-8') - ); return packageJson.brownie ?? {}; } diff --git a/packages/cli/src/brownie/helpers/runBrownieCodegenIfApplicable.ts b/packages/cli/src/brownie/helpers/runBrownieCodegenIfApplicable.ts index 86b947e9..fd92e8b6 100644 --- a/packages/cli/src/brownie/helpers/runBrownieCodegenIfApplicable.ts +++ b/packages/cli/src/brownie/helpers/runBrownieCodegenIfApplicable.ts @@ -1,7 +1,7 @@ import { runCodegen } from '../commands/codegen.js'; import { isBrownieInstalled } from '../config.js'; -import type { Platform } from '../types.js'; +import type { Platform } from '../../types.js'; export async function runBrownieCodegenIfApplicable( projectRoot: string, diff --git a/packages/cli/src/brownie/index.ts b/packages/cli/src/brownie/index.ts index fd4b48c5..c7dfece7 100644 --- a/packages/cli/src/brownie/index.ts +++ b/packages/cli/src/brownie/index.ts @@ -2,7 +2,6 @@ import { styleText } from 'node:util'; import * as Commands from './commands/index.js'; -export type * from './types.js'; export * from './store-discovery.js'; export const groupName = `${styleText(['bold', 'blueBright'], '@callstack/brownie')}${styleText('whiteBright', ' - Shared state management CLI for React Native Brownfield')}`; diff --git a/packages/cli/src/brownie/types.ts b/packages/cli/src/brownie/types.ts deleted file mode 100644 index 7bafef24..00000000 --- a/packages/cli/src/brownie/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type Platform = 'swift' | 'kotlin'; diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 00000000..b9f4ae12 --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,119 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import Ajv from 'ajv'; + +import type { BrownfieldConfig } from './types.js'; +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`; + +const SEPARATOR = '\n● '; + +const ajv = new Ajv({ allErrors: true }); +const validateBrownfieldConfig = ajv.compile(BrownfieldSchema); + +export function validateBrownfieldCLIConfig(config: unknown): void { + if (!validateBrownfieldConfig(config)) { + logger.warn( + `Brownfield configuration has some issues: ${SEPARATOR}${ajv.errorsText(validateBrownfieldConfig.errors, { separator: SEPARATOR, dataVar: 'config' })}.` + ); + } +} + +export function loadBrownfieldConfig( + projectRoot: string = findProjectRoot() +): BrownfieldConfig { + const require = createRequire(path.join(projectRoot, 'package.json')); + + const jsConfigFilePath = path.join(projectRoot, JS_CONFIG_FILE_NAME); + const jsonConfigFilePath = path.join(projectRoot, JSON_CONFIG_FILE_NAME); + const packageJsonPath = path.join(projectRoot, 'package.json'); + const packageJson = require(packageJsonPath) as Record; + + if ( + [ + fs.existsSync(jsConfigFilePath), + fs.existsSync(jsonConfigFilePath), + packageJson[CONFIG_BASE_NAME], + ].filter(Boolean).length > 1 + ) { + throw new Error('Project has multiple Brownfield configuration files'); + } + + if (fs.existsSync(jsConfigFilePath)) { + return require(jsConfigFilePath) as BrownfieldConfig; + } + + if (fs.existsSync(jsonConfigFilePath)) { + return require(jsonConfigFilePath) as BrownfieldConfig; + } + + return packageJson[CONFIG_BASE_NAME] || {}; +} + +type BrownfieldPlatform = 'android' | 'ios'; +type ConfigurableOptions = Record; + +function getSharedConfig(config: BrownfieldConfig): ConfigurableOptions { + return config.verbose === undefined ? {} : { verbose: config.verbose }; +} + +function formatConfigValue(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +function areConfigValuesEqual(configValue: unknown, optionValue: unknown) { + return JSON.stringify(configValue) === JSON.stringify(optionValue); +} + +export function mergeBrownfieldConfigWithOptions( + options: T, + platform: BrownfieldPlatform +): T { + const reactNativeBrownfieldConfig = loadBrownfieldConfig(); + + validateBrownfieldCLIConfig(reactNativeBrownfieldConfig); + + const platformConfig: ConfigurableOptions = { + ...getSharedConfig(reactNativeBrownfieldConfig), + ...reactNativeBrownfieldConfig[platform], + }; + + const cliOptions = Object.fromEntries( + Object.entries(options).filter(([, value]) => value !== undefined) + ); + + for (const [key, value] of Object.entries(cliOptions)) { + const configValue = platformConfig[key]; + + if ( + configValue !== undefined && + !areConfigValuesEqual(configValue, value) + ) { + logger.warn( + 'CLI option "%s" is overriding the react-native-brownfield config value: %s -> %s.', + key, + formatConfigValue(configValue), + formatConfigValue(value) + ); + } + } + + const mergedOptions = { + ...platformConfig, + ...cliOptions, + } as T & { verbose?: unknown }; + + if (typeof mergedOptions.verbose === 'boolean') { + logger.setVerbose(mergedOptions.verbose); + } + + return mergedOptions; +} diff --git a/packages/cli/src/navigation/commands/codegen.ts b/packages/cli/src/navigation/commands/codegen.ts index ce0c935a..8a4b988e 100644 --- a/packages/cli/src/navigation/commands/codegen.ts +++ b/packages/cli/src/navigation/commands/codegen.ts @@ -42,9 +42,7 @@ export const navigationCodegenCommand = new Command('navigation:codegen') const specPath = typeof args[0] === 'string' ? args[0] : undefined; const options = args.find( - ( - arg - ): arg is RunNavigationCodegenCommandOptions => + (arg): arg is RunNavigationCodegenCommandOptions => typeof arg === 'object' && arg !== null && 'dryRun' in arg ) ?? {}; diff --git a/packages/cli/src/shared/utils/__tests__/cli.test.ts b/packages/cli/src/shared/utils/__tests__/cli.test.ts index b9d91cd3..c69432e8 100644 --- a/packages/cli/src/shared/utils/__tests__/cli.test.ts +++ b/packages/cli/src/shared/utils/__tests__/cli.test.ts @@ -1,6 +1,6 @@ import * as rockTools from '@rock-js/tools'; -import { expect, Mock, test, vi } from 'vitest'; +import { beforeEach, expect, Mock, test, vi } from 'vitest'; import { actionRunner } from '../cli.js'; @@ -32,6 +32,10 @@ const createWrappedFailingAction = (ErrorCls: new (message: string) => Error) => throw new ErrorCls(FAILING_ACTION_ERROR_MESSAGE); }); +beforeEach(() => { + vi.clearAllMocks(); +}); + test('actionRunner should call the wrapped function', async () => { const mockAction = vi.fn(async () => Promise.resolve()); const wrappedAction = actionRunner(mockAction); diff --git a/packages/cli/src/shared/utils/cli.ts b/packages/cli/src/shared/utils/cli.ts index aa1b2975..850a2ebf 100644 --- a/packages/cli/src/shared/utils/cli.ts +++ b/packages/cli/src/shared/utils/cli.ts @@ -23,8 +23,10 @@ export function curryOptions(programCommand: Command, options: RockCLIOptions) { return programCommand; } -export function actionRunner(fn: (...args: T[]) => Promise) { - return async function wrappedCLIAction(...args: T[]) { +export function actionRunner( + fn: (...args: Args) => Promise +) { + return async function wrappedCLIAction(...args: Args) { try { await fn(...args); } catch (error) { diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts new file mode 100644 index 00000000..33d04cd6 --- /dev/null +++ b/packages/cli/src/types.ts @@ -0,0 +1,51 @@ +import type { + PublishLocalAarFlags, + PackageAarFlags, +} from '@rock-js/platform-android'; +import type { BuildFlags as AppleBuildFlags } from '@rock-js/platform-apple-helpers'; + +export type Platform = 'swift' | 'kotlin'; + +export type BrownfieldCommonOptions = Partial<{ + verbose: boolean; +}>; + +export type BrownfieldConfigMetadata = Partial<{ + $schema: string; +}>; + +export type BrownieConfig = { + kotlin?: string; + 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; +}; + +export type BrownfieldPackageAndroidOptions = BrownfieldCommonOptions & + Partial; +export type BrownfieldPublishAndroidOptions = BrownfieldCommonOptions & + Partial; +export type BrownfieldPackageIosOptions = BrownfieldCommonOptions & + Partial; + +export type BrownfieldAndroidConfig = Omit< + Partial & Partial, + keyof BrownfieldCommonOptions +>; +export type BrownfieldIosConfig = Omit< + Partial, + keyof BrownfieldCommonOptions +>; + +export type BrownfieldConfig = BrownfieldConfigMetadata & + BrownfieldCommonOptions & + Partial<{ + android: BrownfieldAndroidConfig; + ios: BrownfieldIosConfig; + brownie: BrownieConfig; + }>; diff --git a/packages/react-native-brownfield/src/index.ts b/packages/react-native-brownfield/src/index.ts index 14024084..457f69b3 100644 --- a/packages/react-native-brownfield/src/index.ts +++ b/packages/react-native-brownfield/src/index.ts @@ -2,6 +2,8 @@ import { Platform } from 'react-native'; import ReactNativeBrownfieldModule from './NativeReactNativeBrownfieldModule'; +export type { BrownfieldConfig } from '@callstack/brownfield-cli/types'; + export interface MessageEvent { data: unknown; } diff --git a/yarn.lock b/yarn.lock index 4e6dbdf0..0071766a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1688,12 +1688,14 @@ __metadata: "@types/babel__preset-env": "npm:^7.10.0" "@types/node": "npm:^25.5.0" "@vitest/coverage-v8": "npm:^4.1.0" + ajv: "npm:^8.20.0" commander: "npm:^14.0.3" eslint: "npm:^9.39.3" globals: "npm:^17.3.0" nodemon: "npm:^3.1.14" quicktype-core: "npm:^23.2.6" quicktype-typescript-input: "npm:^23.2.6" + ts-json-schema-generator: "npm:^2.9.0" ts-morph: "npm:^27.0.2" typescript: "npm:5.9.3" vitest: "npm:^4.1.4" @@ -7485,7 +7487,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.11.0, ajv@npm:^8.6.3": +"ajv@npm:^8.11.0, ajv@npm:^8.20.0, ajv@npm:^8.6.3": version: 8.20.0 resolution: "ajv@npm:8.20.0" dependencies: @@ -19425,7 +19427,7 @@ __metadata: languageName: node linkType: hard -"safe-stable-stringify@npm:^2.2.0, safe-stable-stringify@npm:^2.3.1": +"safe-stable-stringify@npm:^2.2.0, safe-stable-stringify@npm:^2.3.1, safe-stable-stringify@npm:^2.5.0": version: 2.5.0 resolution: "safe-stable-stringify@npm:2.5.0" checksum: 10/2697fa186c17c38c3ca5309637b4ac6de2f1c3d282da27cd5e1e3c88eca0fb1f9aea568a6aabdf284111592c8782b94ee07176f17126031be72ab1313ed46c5c @@ -20708,6 +20710,24 @@ __metadata: languageName: node linkType: hard +"ts-json-schema-generator@npm:^2.9.0": + version: 2.9.0 + resolution: "ts-json-schema-generator@npm:2.9.0" + dependencies: + "@types/json-schema": "npm:^7.0.15" + commander: "npm:^14.0.3" + glob: "npm:^13.0.6" + json5: "npm:^2.2.3" + normalize-path: "npm:^3.0.0" + safe-stable-stringify: "npm:^2.5.0" + tslib: "npm:^2.8.1" + typescript: "npm:^5.9.3" + bin: + ts-json-schema-generator: bin/ts-json-schema-generator.js + checksum: 10/50a18cb6a1171e495af16a4fe8165000d0155ef693cec9345bacb8a5b6cc559dcc730443ebdc025590cd29ffd2019564267a8222c7f6389bdf5558856fbe479b + languageName: node + linkType: hard + "ts-morph@npm:^27.0.2": version: 27.0.2 resolution: "ts-morph@npm:27.0.2" @@ -20768,7 +20788,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.5.3, tslib@npm:^2.8.0": +"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.5.3, tslib@npm:^2.8.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7