From 9a515e570cd802481170f79a8b5ed006eecdd846 Mon Sep 17 00:00:00 2001 From: Daniel Potor Date: Fri, 22 May 2026 11:06:10 +0200 Subject: [PATCH 01/20] feat: add create-react-native-brownfield scaffold for RN CLI --- .../create-react-native-brownfield/README.md | 29 ++ .../eslint.config.mjs | 4 + .../package.json | 50 +++ .../src/main.ts | 33 ++ .../src/xcode.d.ts | 4 + .../tsconfig.json | 17 ++ packages/react-native-brownfield/package.json | 16 +- .../android/withAndroidModuleFiles.ts | 20 +- .../expo-config-plugin/ios/podfileHelpers.ts | 32 +- .../ios/withBrownfieldIos.ts | 7 +- .../ios/withIosFrameworkFiles.ts | 24 +- .../expo-config-plugin/ios/xcodeHelpers.ts | 14 +- .../android/ReactNativeHostManager.vanilla.kt | 35 +++ .../ios/FrameworkInterface.vanilla.swift | 7 + .../ios/PodfileTargetBlock.vanilla.rb | 5 + packages/react-native-brownfield/src/index.ts | 2 + .../src/scaffold/index.ts | 284 ++++++++++++++++++ .../src/scaffold/xcode.d.ts | 4 + .../tsconfig.build.json | 2 +- .../react-native-brownfield/tsconfig.json | 5 +- yarn.lock | 18 ++ 21 files changed, 585 insertions(+), 27 deletions(-) create mode 100644 packages/create-react-native-brownfield/README.md create mode 100644 packages/create-react-native-brownfield/eslint.config.mjs create mode 100644 packages/create-react-native-brownfield/package.json create mode 100644 packages/create-react-native-brownfield/src/main.ts create mode 100644 packages/create-react-native-brownfield/src/xcode.d.ts create mode 100644 packages/create-react-native-brownfield/tsconfig.json create mode 100644 packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt create mode 100644 packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift create mode 100644 packages/react-native-brownfield/src/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb create mode 100644 packages/react-native-brownfield/src/scaffold/index.ts create mode 100644 packages/react-native-brownfield/src/scaffold/xcode.d.ts diff --git a/packages/create-react-native-brownfield/README.md b/packages/create-react-native-brownfield/README.md new file mode 100644 index 00000000..30c1bba6 --- /dev/null +++ b/packages/create-react-native-brownfield/README.md @@ -0,0 +1,29 @@ +# create-react-native-brownfield + +Scaffolds React Native Brownfield packaging targets in an **existing** React Native Community CLI project (non-Expo). + +## Usage + +From inside your React Native app directory: + +```bash +npx create-react-native-brownfield@latest +``` + +Or: + +```bash +pnpm create react-native-brownfield +``` + +```bash +yarn create react-native-brownfield +``` + +## Options + +- `--path `: project root (default: `.`) +- `--ios-framework-name `: iOS framework target name (default: `BrownfieldLib`) +- `--android-module-name `: Android module name (default: `brownfieldlib`) +- `--debug`: verbose logs + diff --git a/packages/create-react-native-brownfield/eslint.config.mjs b/packages/create-react-native-brownfield/eslint.config.mjs new file mode 100644 index 00000000..79e239c3 --- /dev/null +++ b/packages/create-react-native-brownfield/eslint.config.mjs @@ -0,0 +1,4 @@ +import eslintRnConfig from '../../eslint.config.rn.mjs'; + +/** @type {import('eslint').Linter.Config[]} */ +export default eslintRnConfig; diff --git a/packages/create-react-native-brownfield/package.json b/packages/create-react-native-brownfield/package.json new file mode 100644 index 00000000..31dd0003 --- /dev/null +++ b/packages/create-react-native-brownfield/package.json @@ -0,0 +1,50 @@ +{ + "name": "create-react-native-brownfield", + "version": "0.0.0", + "license": "MIT", + "author": "Callstack", + "bin": "dist/main.js", + "type": "module", + "homepage": "https://github.com/callstack/react-native-brownfield", + "repository": { + "url": "git+https://github.com/callstack/react-native-brownfield.git" + }, + "description": "Scaffold React Native Brownfield in an existing React Native CLI project", + "scripts": { + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch" + }, + "keywords": [ + "react-native", + "brownfield", + "create", + "scaffold" + ], + "files": [ + "src", + "dist", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@callstack/react-native-brownfield": "workspace:^", + "commander": "^14.0.3" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "eslint": "^9.39.3", + "globals": "^17.3.0", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/create-react-native-brownfield/src/main.ts b/packages/create-react-native-brownfield/src/main.ts new file mode 100644 index 00000000..c8e58d3c --- /dev/null +++ b/packages/create-react-native-brownfield/src/main.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +import { Command } from 'commander'; + +import { scaffoldBrownfieldInRncCliProject } from '@callstack/react-native-brownfield/scaffold'; + +const program = new Command(); + +program + .name('create-react-native-brownfield') + .description( + 'Scaffold React Native Brownfield packaging targets in an existing React Native CLI project.' + ) + .option('-p, --path ', 'path to the React Native project', '.') + .option( + '--ios-framework-name ', + 'iOS framework target name (default: BrownfieldLib)' + ) + .option( + '--android-module-name ', + 'Android library module name (default: brownfieldlib)' + ) + .option('--debug', 'enable verbose logging', false) + .action(async (opts) => { + await scaffoldBrownfieldInRncCliProject({ + projectRoot: opts.path, + iosFrameworkName: opts.iosFrameworkName, + androidModuleName: opts.androidModuleName, + debug: !!opts.debug, + }); + }); + +program.parse(process.argv); diff --git a/packages/create-react-native-brownfield/src/xcode.d.ts b/packages/create-react-native-brownfield/src/xcode.d.ts new file mode 100644 index 00000000..c8034dc5 --- /dev/null +++ b/packages/create-react-native-brownfield/src/xcode.d.ts @@ -0,0 +1,4 @@ +declare module 'xcode' { + const xcode: any; + export default xcode; +} diff --git a/packages/create-react-native-brownfield/tsconfig.json b/packages/create-react-native-brownfield/tsconfig.json new file mode 100644 index 00000000..d4ae1810 --- /dev/null +++ b/packages/create-react-native-brownfield/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node"], + "declaration": true, + "sourceMap": true, + "verbatimModuleSyntax": false, + "allowImportingTsExtensions": false + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/react-native-brownfield/package.json b/packages/react-native-brownfield/package.json index ddfa4630..6e2d78d1 100644 --- a/packages/react-native-brownfield/package.json +++ b/packages/react-native-brownfield/package.json @@ -41,6 +41,17 @@ "default": "./lib/commonjs/expo-config-plugin/app.plugin.js" } }, + "./scaffold": { + "source": "./src/scaffold/index.ts", + "import": { + "types": "./lib/typescript/module/src/scaffold/index.d.ts", + "default": "./lib/module/scaffold/index.js" + }, + "require": { + "types": "./lib/typescript/commonjs/src/scaffold/index.d.ts", + "default": "./lib/commonjs/scaffold/index.js" + } + }, "./package.json": "./package.json" }, "scripts": { @@ -89,7 +100,10 @@ "@expo/config-plugins": ">=54 <100" }, "dependencies": { - "@callstack/brownfield-cli": "workspace:^" + "@callstack/brownfield-cli": "workspace:^", + "@react-native-community/cli-config": "^20.0.0", + "@react-native-community/cli-types": "^20.0.0", + "xcode": "^3.0.1" }, "devDependencies": { "@babel/core": "^7.29.6", diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts index 12f2e0cb..bef15302 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts @@ -83,11 +83,19 @@ export function createAndroidModule({ rnVersion, projectRoot, expoMajor, + templateVariant = 'expo', }: { /** * Expo app root (used to detect optional dependencies such as expo-updates) */ projectRoot?: string; + + /** + * Source template flavor. 'vanilla' is used by the RN CLI scaffold for + * non-Expo projects; Expo keeps the default 'expo' variant. + */ + templateVariant?: 'expo' | 'vanilla'; + /** * The root Android directory path */ @@ -157,9 +165,15 @@ export function createAndroidModule({ }, { relativePath: `src/main/java/${config.android.packageName.replace(/\./g, '/')}/ReactNativeHostManager.kt`, - content: renderTemplate('android', 'ReactNativeHostManager.post55.kt', { - '{{PACKAGE_NAME}}': android.packageName, - }), + content: renderTemplate( + 'android', + templateVariant === 'vanilla' + ? 'ReactNativeHostManager.vanilla.kt' + : 'ReactNativeHostManager.post55.kt', + { + '{{PACKAGE_NAME}}': android.packageName, + } + ), }, { relativePath: 'consumer-rules.pro', diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts index 04f4aa87..6ad41a04 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts @@ -54,13 +54,15 @@ function ensureExpoDefinesForSDK55AndAbove( * Modifies the Podfile to include the Brownfield framework target * @param podfile The original Podfile content * @param frameworkName The name of the framework target to add - * @param expoMajor The major version of the Expo SDK + * @param expoMajor The major version of the Expo SDK. Omit for vanilla + * (non-Expo) projects: the vanilla target block is inserted and no + * Expo-only hooks are applied. * @returns The modified Podfile content */ export function modifyPodfile( podfile: string, frameworkName: string, - expoMajor: number + expoMajor?: number ): string { // check if the framework target is already included if (podfile.includes(`target '${frameworkName}'`)) { @@ -73,9 +75,17 @@ export function modifyPodfile( Logger.logDebug(`Modifying Podfile for framework: ${frameworkName}`); // insert the framework target after the main target's "do" - const frameworkTargetBlock = renderTemplate('ios', 'PodfileTargetBlock.rb', { - '{{FRAMEWORK_NAME}}': frameworkName, - }); + // Vanilla means the caller passed no Expo SDK version at all (undefined). + // Negative values mean "Expo detected but unknown version" and must still + // hit the unsupported-version error path below, not the vanilla path. + const useExpoHost = typeof expoMajor === 'number'; + const frameworkTargetBlock = renderTemplate( + 'ios', + useExpoHost ? 'PodfileTargetBlock.rb' : 'PodfileTargetBlock.vanilla.rb', + { + '{{FRAMEWORK_NAME}}': frameworkName, + } + ); // find insertion point after the first target's content begins, before the end of the target block const mainTargetMatch = podfile.match( @@ -101,8 +111,14 @@ export function modifyPodfile( Logger.logDebug(`Added framework target "${frameworkName}" to Podfile`); - if (expoMajor < MIN_SUPPORTED_EXPO_SDK_MAJOR_VERSION) { - const versionLabel = expoMajor < 0 ? 'unknown' : expoMajor.toString(); + // Vanilla projects (expoMajor === undefined) get no Expo-only hooks. + if (!useExpoHost) { + return modifiedPodfile; + } + + if ((expoMajor as number) < MIN_SUPPORTED_EXPO_SDK_MAJOR_VERSION) { + const versionLabel = + (expoMajor as number) < 0 ? 'unknown' : String(expoMajor); throw new SourceModificationError( `Expo SDK ${versionLabel} is not supported. Please use Expo SDK ${MIN_SUPPORTED_EXPO_SDK_MAJOR_VERSION} or newer. For older versions, please see the matrix of supported versions: https://oss.callstack.com/react-native-brownfield/docs/getting-started/introduction#expo-version-compatibility` ); @@ -110,7 +126,7 @@ export function modifyPodfile( modifiedPodfile = ensureExpoDefinesForSDK55AndAbove( modifiedPodfile, - expoMajor + expoMajor as number ); return modifiedPodfile; diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts index ea03837e..e4fbb781 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts @@ -47,7 +47,8 @@ export const withBrownfieldIos: ConfigPlugin< const { frameworkTargetUUID, targetAlreadyExists } = addFrameworkTarget( project, modRequest, - iosProps + iosProps, + { useExpoHost: true } ); // Ensure Expo.plist is present in the framework resources phase when @@ -73,7 +74,9 @@ export const withBrownfieldIos: ConfigPlugin< // copy the "Bundle React Native code and images" build phase from the main target to the framework target copyBundleReactNativePhase(project, frameworkTargetUUID); - addSourceFilesBuildPhase(project, frameworkTargetUUID, iosProps); + addSourceFilesBuildPhase(project, frameworkTargetUUID, iosProps, { + useExpoHost: true, + }); return xcodeConfig; }); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts index 3c652ab1..edbb20e4 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts @@ -16,14 +16,25 @@ import { renderTemplate } from '../template/engine'; * @returns The list of framework source files */ export function getFrameworkSourceFiles( - ios: ResolvedBrownfieldPluginConfigWithIos['ios'] + ios: ResolvedBrownfieldPluginConfigWithIos['ios'], + options?: { + /** + * Whether the packaged framework is expected to use the Expo host. + * This influences template selection for the generated framework sources. + */ + useExpoHost?: boolean; + } ): RenderedTemplateFile[] { + const useExpoHost = options?.useExpoHost ?? true; + return [ { relativePath: `${ios.frameworkName}.swift`, - content: renderTemplate('ios', 'FrameworkInterface.swift', { - '{{BUNDLE_IDENTIFIER}}': ios.bundleIdentifier, - }), + content: useExpoHost + ? renderTemplate('ios', 'FrameworkInterface.swift', { + '{{BUNDLE_IDENTIFIER}}': ios.bundleIdentifier, + }) + : renderTemplate('ios', 'FrameworkInterface.vanilla.swift', {}), }, { relativePath: 'Info.plist', @@ -41,7 +52,8 @@ export function getFrameworkSourceFiles( */ export function createIosFramework( iosDir: string, - config: ResolvedBrownfieldPluginConfigWithIos + config: ResolvedBrownfieldPluginConfigWithIos, + options?: Parameters[1] ) { const { ios } = config; const frameworkDir = path.join(iosDir, ios.frameworkName); @@ -61,7 +73,7 @@ export function createIosFramework( } // write files - for (const file of getFrameworkSourceFiles(ios)) { + for (const file of getFrameworkSourceFiles(ios, options)) { const filePath = path.join(frameworkDir, file.relativePath); fs.writeFileSync(filePath, file.content, 'utf8'); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts index f0450312..f3d7f554 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts @@ -20,7 +20,10 @@ import { getFrameworkSourceFiles } from './withIosFrameworkFiles'; export function addFrameworkTarget( project: XcodeProject, modRequest: ModProps, - options: ResolvedBrownfieldPluginIosConfig + options: ResolvedBrownfieldPluginIosConfig, + brownfieldOptions?: { + useExpoHost?: boolean; + } ): { frameworkTargetUUID: string; targetAlreadyExists: boolean; @@ -131,7 +134,7 @@ export function addFrameworkTarget( }); // create the framework group in the project - const filePaths = getFrameworkSourceFiles(options).map( + const filePaths = getFrameworkSourceFiles(options, brownfieldOptions).map( (file) => file.relativePath ); const groupPath = path.join(modRequest.platformProjectRoot, frameworkName); @@ -161,9 +164,12 @@ export function addFrameworkTarget( export function addSourceFilesBuildPhase( project: XcodeProject, frameworkTargetUUID: string, - options: ResolvedBrownfieldPluginIosConfig + options: ResolvedBrownfieldPluginIosConfig, + brownfieldOptions?: { + useExpoHost?: boolean; + } ) { - const filePaths = getFrameworkSourceFiles(options).map( + const filePaths = getFrameworkSourceFiles(options, brownfieldOptions).map( (file) => file.relativePath ); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt b/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt new file mode 100644 index 00000000..b3059725 --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt @@ -0,0 +1,35 @@ +package {{PACKAGE_NAME}} + +import android.app.Application +import android.content.res.Configuration +import com.callstack.reactnativebrownfield.OnJSBundleLoaded +import com.callstack.reactnativebrownfield.ReactNativeBrownfield +import com.facebook.react.PackageList +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost + +object ReactNativeHostManager { + fun initialize(application: Application, onJSBundleLoaded: OnJSBundleLoaded? = null) { + loadReactNative(application) + + val reactHost: ReactHost by lazy { + getDefaultReactHost( + context = application, + packageList = PackageList(application).packages, + jsMainModulePath = "index", + jsBundleAssetPath = "index.android.bundle", + jsBundleFilePath = null, + useDevSupport = BuildConfig.DEBUG, + jsRuntimeFactory = null + ) + } + + ReactNativeBrownfield.initialize(application, reactHost, onJSBundleLoaded) + } + + fun onConfigurationChanged(application: Application, newConfig: Configuration) { + // no-op (kept for API symmetry with Expo variant) + } +} + diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift new file mode 100644 index 00000000..f3f473f3 --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift @@ -0,0 +1,7 @@ +import Foundation + +// Initializes a Bundle instance that points at the framework target. +public let ReactNativeBundle = Bundle(for: InternalClassForBundle.self) + +class InternalClassForBundle {} + diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb new file mode 100644 index 00000000..1846d8bb --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb @@ -0,0 +1,5 @@ + # Brownfield framework target for packaging as XCFramework + target '{{FRAMEWORK_NAME}}' do + inherit! :complete + end + diff --git a/packages/react-native-brownfield/src/index.ts b/packages/react-native-brownfield/src/index.ts index 457f69b3..c1c69acc 100644 --- a/packages/react-native-brownfield/src/index.ts +++ b/packages/react-native-brownfield/src/index.ts @@ -1,6 +1,8 @@ import { Platform } from 'react-native'; import ReactNativeBrownfieldModule from './NativeReactNativeBrownfieldModule'; +export { scaffoldBrownfieldInRncCliProject } from './scaffold'; +export type { BrownfieldScaffoldOptions } from './scaffold'; export type { BrownfieldConfig } from '@callstack/brownfield-cli/types'; diff --git a/packages/react-native-brownfield/src/scaffold/index.ts b/packages/react-native-brownfield/src/scaffold/index.ts new file mode 100644 index 00000000..b065b784 --- /dev/null +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -0,0 +1,284 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { UserConfig } from '@react-native-community/cli-types'; +import cliConfigImport from '@react-native-community/cli-config'; +import xcode from 'xcode'; + +import { Logger } from '../expo-config-plugin/logging'; +import type { + ResolvedBrownfieldPluginConfigWithAndroid, + ResolvedBrownfieldPluginConfigWithIos, +} from '../expo-config-plugin/types'; +import { + modifyRootBuildGradle, + modifySettingsGradle, +} from '../expo-config-plugin/android/utils/gradleHelpers'; +import { createAndroidModule } from '../expo-config-plugin/android/withAndroidModuleFiles'; +import { modifyPodfile } from '../expo-config-plugin/ios/podfileHelpers'; +import { + addFrameworkTarget, + addSourceFilesBuildPhase, + copyBundleReactNativePhase, +} from '../expo-config-plugin/ios/xcodeHelpers'; +import { createIosFramework } from '../expo-config-plugin/ios/withIosFrameworkFiles'; + +const cliConfig: typeof cliConfigImport = + typeof cliConfigImport === 'function' + ? cliConfigImport + : (cliConfigImport as any).default; + +export type BrownfieldScaffoldOptions = { + /** + * React Native project root directory (contains package.json). + * Defaults to current working directory. + */ + projectRoot?: string; + + /** + * iOS framework target name (also framework directory name). + * Defaults to "BrownfieldLib". + */ + iosFrameworkName?: string; + + /** + * Android library module folder / Gradle module name. + * Defaults to "brownfieldlib". + */ + androidModuleName?: string; + + /** + * Enables verbose logging. + */ + debug?: boolean; +}; + +function findProjectRoot(startDir: string): string { + let currentDir = startDir; + while (currentDir !== '/') { + if (fs.existsSync(path.join(currentDir, 'package.json'))) { + return currentDir; + } + currentDir = path.dirname(currentDir); + } + throw new Error('Could not find project root (no package.json found)'); +} + +function resolveUserConfig(projectRoot: string): UserConfig { + return cliConfig({ + projectRoot, + selectedPlatform: 'ios', + }) as UserConfig; +} + +function readFileIfExists(filePath: string): string | null { + return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null; +} + +function writeFileIfChanged(filePath: string, next: string) { + const prev = readFileIfExists(filePath); + if (prev === next) return; + fs.writeFileSync(filePath, next, 'utf8'); +} + +function firstXcodeprojPath(iosDir: string): string { + const entries = fs.readdirSync(iosDir, { withFileTypes: true }); + const xcodeproj = entries.find( + (e) => e.isDirectory() && e.name.endsWith('.xcodeproj') + ); + if (!xcodeproj) { + throw new Error( + `Could not find an .xcodeproj under ${iosDir}. Did you run iOS project generation?` + ); + } + return path.join(iosDir, xcodeproj.name); +} + +function unquote(value: string): string { + return value.replace(/^"+|"+$/g, ''); +} + +function resolveIosAppBundleId(pbxproj: any): string | null { + const nativeTargets = pbxproj.pbxNativeTargetSection?.() ?? {}; + const configLists = pbxproj.pbxXCConfigurationList?.() ?? {}; + const buildConfigs = pbxproj.pbxXCBuildConfigurationSection?.() ?? {}; + + for (const [key, target] of Object.entries(nativeTargets)) { + if (key.endsWith('_comment')) continue; + // heuristic: application targets usually have productType including "application" + if ( + typeof target?.productType === 'string' && + !target.productType.includes('application') + ) { + continue; + } + + const configListId = target?.buildConfigurationList; + const configList = configLists?.[configListId]; + const debugConfigId = configList?.buildConfigurations?.find?.( + (c: any) => c?.comment === 'Debug' + )?.value; + const debugConfig = debugConfigId ? buildConfigs?.[debugConfigId] : null; + const bundleId = debugConfig?.buildSettings?.PRODUCT_BUNDLE_IDENTIFIER; + if (typeof bundleId === 'string' && bundleId.length > 0) { + return unquote(bundleId); + } + } + + return null; +} + +function resolveReactNativeVersion(projectRoot: string): string { + const rnPkgPath = require.resolve('react-native/package.json', { + paths: [projectRoot], + }); + const rnPkg = require(rnPkgPath); + if (!rnPkg?.version) { + throw new Error('Could not resolve react-native version from package.json'); + } + return rnPkg.version; +} + +export async function scaffoldBrownfieldInRncCliProject( + options: BrownfieldScaffoldOptions = {} +): Promise { + const projectRoot = findProjectRoot( + path.resolve(options.projectRoot ?? process.cwd()) + ); + Logger.setIsDebug(options.debug ?? false); + + const userConfig = resolveUserConfig(projectRoot); + const android = userConfig.project.android; + const ios = userConfig.project.ios; + + if (!android) { + throw new Error('Android project not found.'); + } + if (!ios) { + throw new Error('iOS project not found.'); + } + + const androidDir = path.isAbsolute(android.sourceDir) + ? android.sourceDir + : path.join(projectRoot, android.sourceDir || 'android'); + const iosDir = path.join(projectRoot, 'ios'); + + const rnVersion = resolveReactNativeVersion(projectRoot); + + const iosFrameworkName = options.iosFrameworkName ?? 'BrownfieldLib'; + const androidModuleName = options.androidModuleName ?? 'brownfieldlib'; + const androidPackageName = android.packageName ?? android.applicationId; + if (!androidPackageName) { + throw new Error( + 'Could not resolve Android package name from React Native CLI config.' + ); + } + + // --- Android: root build.gradle + settings.gradle + module files --- + const rootBuildGradlePath = path.join(androidDir, 'build.gradle'); + const rootBuildGradle = readFileIfExists(rootBuildGradlePath); + if (!rootBuildGradle) { + throw new Error(`Missing ${rootBuildGradlePath}`); + } + writeFileIfChanged( + rootBuildGradlePath, + modifyRootBuildGradle(rootBuildGradle) + ); + + const settingsGradlePath = path.join(androidDir, 'settings.gradle'); + const settingsGradle = readFileIfExists(settingsGradlePath); + if (!settingsGradle) { + throw new Error(`Missing ${settingsGradlePath}`); + } + writeFileIfChanged( + settingsGradlePath, + modifySettingsGradle(settingsGradle, androidModuleName) + ); + + const resolvedAndroidConfig: ResolvedBrownfieldPluginConfigWithAndroid = { + android: { + moduleName: androidModuleName, + packageName: androidPackageName, + minSdkVersion: 24, + targetSdkVersion: 35, + compileSdkVersion: 35, + groupId: androidPackageName, + artifactId: androidModuleName, + version: '0.0.1-SNAPSHOT', + // Fields added to the resolved config after the original draft was + // written (BGP local-plugin wiring + flavor dimensions, #448/#458). + // Defaults match the Expo plugin defaults for RN CLI projects. + useLocalGradlePlugin: false, + useLocalMaven: false, + missingDimensionStrategies: [], + }, + ios: null, + debug: options.debug ?? false, + }; + + createAndroidModule({ + androidDir, + config: resolvedAndroidConfig, + rnVersion, + templateVariant: 'vanilla', + }); + + // --- iOS: xcodeproj + Podfile + framework source files --- + const xcodeprojPath = firstXcodeprojPath(iosDir); + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + throw new Error(`Missing ${pbxprojPath}`); + } + + const project = xcode.project(pbxprojPath); + project.parseSync(); + + const appBundleId = resolveIosAppBundleId(project); + const brownfieldBundleId = appBundleId + ? `${appBundleId}.brownfield` + : `com.brownfield.${iosFrameworkName.toLowerCase()}`; + + const resolvedIosConfig: ResolvedBrownfieldPluginConfigWithIos = { + ios: { + frameworkName: iosFrameworkName, + bundleIdentifier: brownfieldBundleId, + buildSettings: {}, + deploymentTarget: '15.0', + frameworkVersion: '1', + }, + android: null, + debug: options.debug ?? false, + }; + + const modRequest = { + platformProjectRoot: iosDir, + projectName: path.basename(xcodeprojPath, '.xcodeproj'), + } as any; + + const { frameworkTargetUUID } = addFrameworkTarget( + project, + modRequest, + resolvedIosConfig.ios, + { useExpoHost: false } + ); + + copyBundleReactNativePhase(project, frameworkTargetUUID); + addSourceFilesBuildPhase( + project, + frameworkTargetUUID, + resolvedIosConfig.ios, + { useExpoHost: false } + ); + project.writeSync(); + + const podfilePath = path.join(iosDir, 'Podfile'); + const podfile = readFileIfExists(podfilePath); + if (!podfile) { + throw new Error(`Missing ${podfilePath}`); + } + writeFileIfChanged(podfilePath, modifyPodfile(podfile, iosFrameworkName)); + + createIosFramework(iosDir, resolvedIosConfig, { useExpoHost: false }); + + Logger.logInfo('Brownfield scaffolding complete.'); +} diff --git a/packages/react-native-brownfield/src/scaffold/xcode.d.ts b/packages/react-native-brownfield/src/scaffold/xcode.d.ts new file mode 100644 index 00000000..c8034dc5 --- /dev/null +++ b/packages/react-native-brownfield/src/scaffold/xcode.d.ts @@ -0,0 +1,4 @@ +declare module 'xcode' { + const xcode: any; + export default xcode; +} diff --git a/packages/react-native-brownfield/tsconfig.build.json b/packages/react-native-brownfield/tsconfig.build.json index 1c66acf6..fc8520e7 100644 --- a/packages/react-native-brownfield/tsconfig.build.json +++ b/packages/react-native-brownfield/tsconfig.build.json @@ -1,3 +1,3 @@ { - "extends": "./tsconfig" + "extends": "./tsconfig.json" } diff --git a/packages/react-native-brownfield/tsconfig.json b/packages/react-native-brownfield/tsconfig.json index 9aa8b679..3d3b52eb 100644 --- a/packages/react-native-brownfield/tsconfig.json +++ b/packages/react-native-brownfield/tsconfig.json @@ -1,8 +1,9 @@ { - "extends": "../../tsconfig", + "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": ".", - "outDir": "./lib/typescript" + "outDir": "./lib/typescript", + "verbatimModuleSyntax": false }, "include": ["package.json", "src"] } diff --git a/yarn.lock b/yarn.lock index 61002589..c2f2d36c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2136,6 +2136,8 @@ __metadata: "@babel/runtime": "npm:^7.25.0" "@callstack/brownfield-cli": "workspace:^" "@expo/config-plugins": "npm:^56.0.8" + "@react-native-community/cli-config": "npm:^20.0.0" + "@react-native-community/cli-types": "npm:^20.0.0" "@react-native/babel-preset": "npm:0.83.2" "@types/jest": "npm:^30.0.0" "@types/react": "npm:^19.1.1" @@ -2149,6 +2151,7 @@ __metadata: react-native-builder-bob: "npm:^0.41.0" typescript: "npm:5.9.3" vitest: "npm:^4.1.4" + xcode: "npm:^3.0.1" peerDependencies: "@expo/config-plugins": ">=54 <100" bin: @@ -10408,6 +10411,21 @@ __metadata: languageName: node linkType: hard +"create-react-native-brownfield@workspace:packages/create-react-native-brownfield": + version: 0.0.0-use.local + resolution: "create-react-native-brownfield@workspace:packages/create-react-native-brownfield" + dependencies: + "@callstack/react-native-brownfield": "workspace:^" + "@types/node": "npm:^25.5.0" + commander: "npm:^14.0.3" + eslint: "npm:^9.39.3" + globals: "npm:^17.3.0" + typescript: "npm:5.9.3" + bin: + create-react-native-brownfield: dist/main.js + languageName: unknown + linkType: soft + "create-require@npm:^1.1.0": version: 1.1.1 resolution: "create-require@npm:1.1.1" From b168ad8f53ca56e5868730da0dd519d53c7e97fa Mon Sep 17 00:00:00 2001 From: Edu Date: Thu, 1 Oct 2026 13:24:20 +0200 Subject: [PATCH 02/20] fix: make scaffold RN version resolution ESM-safe and project-root scan portable --- .../src/scaffold/index.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/react-native-brownfield/src/scaffold/index.ts b/packages/react-native-brownfield/src/scaffold/index.ts index b065b784..65aa0db2 100644 --- a/packages/react-native-brownfield/src/scaffold/index.ts +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { createRequire } from 'node:module'; import path from 'node:path'; import type { UserConfig } from '@react-native-community/cli-types'; @@ -55,11 +56,13 @@ export type BrownfieldScaffoldOptions = { function findProjectRoot(startDir: string): string { let currentDir = startDir; - while (currentDir !== '/') { + while (true) { if (fs.existsSync(path.join(currentDir, 'package.json'))) { return currentDir; } - currentDir = path.dirname(currentDir); + const parent = path.dirname(currentDir); + if (parent === currentDir) break; + currentDir = parent; } throw new Error('Could not find project root (no package.json found)'); } @@ -129,10 +132,19 @@ function resolveIosAppBundleId(pbxproj: any): string | null { } function resolveReactNativeVersion(projectRoot: string): string { - const rnPkgPath = require.resolve('react-native/package.json', { + // The shipped build is CommonJS, where bare `require` works. If this module + // is ever loaded as true ESM, bare `require` is undefined; fall back to an + // explicit `createRequire`. Anchored at cwd (not `import.meta.url`) because + // Babel keeps `import.meta` verbatim in the CJS output, which is a parse + // SyntaxError there. + const localRequire: NodeRequire = + typeof require === 'undefined' + ? createRequire(path.join(process.cwd(), 'noop.js')) + : require; + const rnPkgPath = localRequire.resolve('react-native/package.json', { paths: [projectRoot], }); - const rnPkg = require(rnPkgPath); + const rnPkg = localRequire(rnPkgPath); if (!rnPkg?.version) { throw new Error('Could not resolve react-native version from package.json'); } From c60796ebdc38ec985ba962805810bd22cd016837 Mon Sep 17 00:00:00 2001 From: Edu Date: Thu, 1 Oct 2026 13:31:05 +0200 Subject: [PATCH 03/20] test: cover vanilla scaffold paths in podfile, framework files, and android module helpers --- .../__tests__/withAndroidModuleFiles.test.ts | 49 ++++++++ .../ios/__tests__/podfileHelpers.test.ts | 110 +++++++++++++++++ .../__tests__/withIosFrameworkFiles.test.ts | 47 ++++++++ .../ios/__tests__/xcodeHelpers.test.ts | 114 +++++++++++++++++- 4 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts 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 32311c4a..29dafc39 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 @@ -221,6 +221,44 @@ describe('createAndroidModule', () => { ).toBe(true); }); + it('renders the vanilla host manager without Expo imports for the vanilla template variant', () => { + const androidDir = createAndroidDir(); + + createAndroidModule({ + androidDir, + config: createConfig(), + rnVersion: '0.81.0', + templateVariant: 'vanilla', + }); + + const hostManager = readLibraryHostManager(androidDir); + expect(hostManager).toContain('package com.example.brownfield'); + expect(hostManager).toContain( + 'ReactNativeBrownfield.initialize(application, reactHost, onJSBundleLoaded)' + ); + expect(hostManager).toContain('loadReactNative(application)'); + expect(hostManager).not.toContain('{{PACKAGE_NAME}}'); + + // Expo-only wiring must not leak into the RN CLI variant. + expect(hostManager).not.toContain('expo.modules'); + expect(hostManager).not.toContain('ExpoReactHostFactory'); + expect(hostManager).not.toContain('ApplicationLifecycleDispatcher'); + }); + + it('renders the Expo host manager by default when no template variant is given', () => { + const androidDir = createAndroidDir(); + + createAndroidModule({ + androidDir, + config: createConfig(), + rnVersion: '0.81.0', + }); + + const hostManager = readLibraryHostManager(androidDir); + expect(hostManager).toContain('import expo.modules.ExpoReactHostFactory'); + expect(hostManager).not.toContain('loadReactNative(application)'); + }); + it('emits targetSdk for Expo SDK versions below 58 when inherited from the app project', () => { const androidDir = createAndroidDir(); @@ -459,4 +497,15 @@ HERMES_V1_VERSION_NAME=250829098.0.10 'utf8' ); } + + function readLibraryHostManager(androidDir: string): string { + return fs.readFileSync( + path.join( + androidDir, + 'brownfieldlib', + 'src/main/java/com/example/brownfield/ReactNativeHostManager.kt' + ), + 'utf8' + ); + } }); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts new file mode 100644 index 00000000..d5336cec --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; + +import { modifyPodfile } from '../podfileHelpers'; + +// Marker emitted by the Expo-only `ensureExpoDefinesForSDK55AndAbove` hook. +const EXPO_DEFINES_MARKER = + '# >>> react-native-brownfield Expo SDK 55+ swift defines >>>'; + +const MAIN_TARGET = 'ExpoApp56'; + +function createPodfile(): string { + return [ + "require File.join(File.dirname(`node --print \"require.resolve('expo/package.json')\"`), 'scripts/autolinking')", + '', + `target '${MAIN_TARGET}' do`, + ' use_expo_modules!', + ' config = use_native_modules!', + '', + ' post_install do |installer|', + ' react_native_post_install(', + ' installer,', + ' config[:reactNativePath],', + ' :mac_catalyst_enabled => false,', + ' )', + ' end', + 'end', + '', + ].join('\n'); +} + +describe('modifyPodfile', () => { + it('renders the vanilla target block without Expo hooks when no Expo SDK version is given', () => { + const modified = modifyPodfile(createPodfile(), 'BrownfieldLib'); + + expect(modified).toContain("target 'BrownfieldLib' do"); + expect(modified).toContain('inherit! :complete'); + + // The Expo target block marks the target as hosted by an Expo app; the + // vanilla template must not. + expect(modified).not.toContain('REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'); + + // No Expo-only post_install hook is added for vanilla projects. + expect(modified).not.toContain(EXPO_DEFINES_MARKER); + expect(modified).not.toContain('EXPO_SDK_GTE_55'); + expect(modified.match(/post_install do \|installer\|/g)).toHaveLength(1); + }); + + it('does not require an Expo post_install block when the host is vanilla', () => { + // RN CLI Podfiles have no Expo post_install hooks, so the vanilla path must + // not depend on them and must not raise any version-related error. + const plainPodfile = `target 'BrownTestApp' do + config = use_native_modules! + use_react_native! +end +`; + + expect(() => modifyPodfile(plainPodfile, 'BrownfieldLib')).not.toThrow(); + expect(modifyPodfile(plainPodfile, 'BrownfieldLib')).toContain( + "target 'BrownfieldLib' do" + ); + }); + + it('throws for an unknown Expo SDK version detected as negative', () => { + expect(() => modifyPodfile(createPodfile(), 'BrownfieldLib', -1)).toThrow( + /Expo SDK unknown is not supported/ + ); + }); + + it('throws for Expo SDK versions below the minimum supported major version', () => { + expect(() => modifyPodfile(createPodfile(), 'BrownfieldLib', 55)).toThrow( + /Expo SDK 55 is not supported/ + ); + }); + + it('applies the Expo defines hook for supported Expo SDK versions', () => { + const modified = modifyPodfile(createPodfile(), 'BrownfieldLib', 56); + + expect(modified).toContain("target 'BrownfieldLib' do"); + expect(modified).toContain('REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'); + expect(modified).toContain(EXPO_DEFINES_MARKER); + expect(modified).toContain('EXPO_SDK_GTE_55'); + + // SDK 56+ raises the default deployment target used by the hook. + expect(modified).toContain( + "podfile_properties['ios.deploymentTarget'] || '16.4'" + ); + }); + + it('adds the framework target only once across repeated runs', () => { + const once = modifyPodfile(createPodfile(), 'BrownfieldLib'); + const twice = modifyPodfile(once, 'BrownfieldLib'); + + expect(twice).toBe(once); + expect(twice.match(/target 'BrownfieldLib'/g)).toHaveLength(1); + }); + + it('adds the Expo defines hook only once across repeated runs', () => { + const once = modifyPodfile(createPodfile(), 'BrownfieldLib', 56); + const twice = modifyPodfile(once, 'BrownfieldLib', 56); + + // The marker contains a `+`, so count occurrences without regex semantics. + expect(twice.split(EXPO_DEFINES_MARKER)).toHaveLength(2); + }); + + it('throws when the Podfile has no main target to insert after', () => { + expect(() => modifyPodfile('# empty Podfile\n', 'BrownfieldLib')).toThrow( + /Could not find main target in Podfile/ + ); + }); +}); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts index 9876ea54..9908b9f7 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts @@ -27,5 +27,52 @@ describe('getFrameworkSourceFiles', () => { expect(frameworkInterface?.content).toContain( 'Bundle(for: InternalClassForBundle.self)' ); + expect(frameworkInterface?.content).toContain( + 'extension ReactNativeBrownfield' + ); + expect(frameworkInterface?.content).not.toContain('{{BUNDLE_IDENTIFIER}}'); + }); + + it('renders the Expo interface with the bundle identifier when the Expo host is requested explicitly', () => { + const files = getFrameworkSourceFiles(iosConfig, { useExpoHost: true }); + const frameworkInterface = files.find( + (file) => file.relativePath === 'BrownfieldLib.swift' + ); + + expect(frameworkInterface?.content).toBe( + getFrameworkSourceFiles(iosConfig)[0].content + ); + }); + + it('renders the vanilla framework interface without bundle identifier lookups for non-Expo hosts', () => { + const files = getFrameworkSourceFiles(iosConfig, { useExpoHost: false }); + const frameworkInterface = files.find( + (file) => file.relativePath === 'BrownfieldLib.swift' + ); + + expect(frameworkInterface?.content).toContain( + 'public let ReactNativeBundle = Bundle(for: InternalClassForBundle.self)' + ); + expect(frameworkInterface?.content).toContain( + 'class InternalClassForBundle {}' + ); + + // Vanilla frameworks cannot resolve an Expo bundle, so neither the + // identifier lookups nor the Expo modules provider may be emitted. + expect(frameworkInterface?.content).not.toContain( + iosConfig.bundleIdentifier + ); + expect(frameworkInterface?.content).not.toContain('{{BUNDLE_IDENTIFIER}}'); + expect(frameworkInterface?.content).not.toContain('Bundle(identifier:'); + expect(frameworkInterface?.content).not.toContain('ReactBrownfield'); + expect(frameworkInterface?.content).not.toContain('ExpoModulesProvider'); + }); + + it('keeps the Info.plist bundle identifier for vanilla hosts', () => { + const files = getFrameworkSourceFiles(iosConfig, { useExpoHost: false }); + const infoPlist = files.find((file) => file.relativePath === 'Info.plist'); + + expect(infoPlist?.content).toContain(iosConfig.bundleIdentifier); + expect(infoPlist?.content).not.toContain('{{BUNDLE_IDENTIFIER}}'); }); }); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts index 446efd3a..32e543d8 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts @@ -1,6 +1,17 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../withIosFrameworkFiles', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getFrameworkSourceFiles: vi.fn(actual.getFrameworkSourceFiles), + }; +}); import { + addFrameworkTarget, + addSourceFilesBuildPhase, getAppTargetDeploymentTarget, getFrameworkBuildSettings, rewriteBundleReactNativePhaseScriptForFrameworkTarget, @@ -46,6 +57,107 @@ describe('getFrameworkBuildSettings', () => { }); }); +describe('addFrameworkTarget', () => { + function createProjectStub() { + const addedGroups: { filePaths: string[] }[] = []; + return { + addedGroups, + pbxTargetByName: () => undefined, + addTarget: () => ({ + uuid: 'FRAMEWORK_UUID', + pbxNativeTarget: { buildConfigurationList: 'CONFIG_LIST' }, + }), + pbxXCConfigurationList: () => ({ + CONFIG_LIST: { + buildConfigurations: [ + { comment: 'Debug', value: 'DEBUG_CONFIG' }, + { comment: 'Release', value: 'RELEASE_CONFIG' }, + ], + }, + }), + pbxXCBuildConfigurationSection: () => ({ + DEBUG_CONFIG: { buildSettings: {} }, + RELEASE_CONFIG: { buildSettings: {} }, + }), + updateBuildProperty: () => undefined, + addPbxGroup: (filePaths: string[]) => { + addedGroups.push({ filePaths }); + return { uuid: 'GROUP_UUID' }; + }, + getFirstProject: () => ({ firstProject: { mainGroup: 'MAIN_GROUP' } }), + addToPbxGroup: () => undefined, + } as any; + } + + const modRequest = { + platformProjectRoot: '/app/ios', + projectRoot: '/app', + } as any; + + it('forwards the useExpoHost option to the framework source file group', async () => { + const { getFrameworkSourceFiles } = + await import('../withIosFrameworkFiles'); + const project = createProjectStub(); + + addFrameworkTarget(project, modRequest, baseOptions, { + useExpoHost: false, + }); + + expect(getFrameworkSourceFiles).toHaveBeenCalledWith(baseOptions, { + useExpoHost: false, + }); + expect(project.addedGroups[0]?.filePaths).toEqual([ + 'BrownfieldLib.swift', + 'Info.plist', + ]); + }); +}); + +describe('addSourceFilesBuildPhase', () => { + function createProjectStub() { + const calls: unknown[][] = []; + return { + calls, + addBuildPhase(...args: unknown[]) { + calls.push(args); + }, + } as any; + } + + it('passes the useExpoHost option through to the rendered source files', async () => { + const { getFrameworkSourceFiles } = + await import('../withIosFrameworkFiles'); + const project = createProjectStub(); + + addSourceFilesBuildPhase(project, 'FRAMEWORK_UUID', baseOptions, { + useExpoHost: false, + }); + + expect(getFrameworkSourceFiles).toHaveBeenCalledWith(baseOptions, { + useExpoHost: false, + }); + // Only the Swift sources are added; Info.plist is filtered out. + expect(project.calls).toHaveLength(1); + expect(project.calls[0][0]).toEqual(['BrownfieldLib.swift']); + }); + + it('renders Expo-hosted source files when no brownfield options are given', async () => { + const { getFrameworkSourceFiles } = + await import('../withIosFrameworkFiles'); + const project = createProjectStub(); + + addSourceFilesBuildPhase(project, 'FRAMEWORK_UUID', baseOptions); + + // The undefined options defer to getFrameworkSourceFiles' Expo default. + expect(getFrameworkSourceFiles).toHaveBeenCalledWith( + baseOptions, + undefined + ); + expect(project.calls).toHaveLength(1); + expect(project.calls[0][0]).toEqual(['BrownfieldLib.swift']); + }); +}); + describe('getAppTargetDeploymentTarget', () => { it('prefers the release deployment target and strips quotes', () => { const project = { From 9746682f8ba5d8f31e59681a310fb7ca90df37fc Mon Sep 17 00:00:00 2001 From: Edu Date: Thu, 1 Oct 2026 14:24:02 +0200 Subject: [PATCH 04/20] fix: make scaffold CLI run end-to-end on a real RN CLI app - ship the create CLI as CJS; bob emits CJS for lib/module under type:module, breaking ESM import of the scaffold subpath - resolve the RN CLI config without selectedPlatform so the Android project is detected - persist project.pbxproj from the writeSync return value; xcode@3 never writes the file itself - match framework targets by quoted name and dedupe sources phases so re-runs stay idempotent - escape newlines in the shell script of the copied bundle phase; otherwise Xcode reports the project as damaged --- .../package.json | 1 - .../ios/__tests__/xcodeHelpers.test.ts | 73 +++++++++++++++++++ .../expo-config-plugin/ios/xcodeHelpers.ts | 68 ++++++++++++++++- .../src/scaffold/index.ts | 6 +- 4 files changed, 144 insertions(+), 4 deletions(-) diff --git a/packages/create-react-native-brownfield/package.json b/packages/create-react-native-brownfield/package.json index 31dd0003..3e3014e2 100644 --- a/packages/create-react-native-brownfield/package.json +++ b/packages/create-react-native-brownfield/package.json @@ -4,7 +4,6 @@ "license": "MIT", "author": "Callstack", "bin": "dist/main.js", - "type": "module", "homepage": "https://github.com/callstack/react-native-brownfield", "repository": { "url": "git+https://github.com/callstack/react-native-brownfield.git" diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts index 32e543d8..d1828d27 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts @@ -111,6 +111,46 @@ describe('addFrameworkTarget', () => { 'Info.plist', ]); }); + + it('detects previously added targets stored with quoted names', () => { + // xcode@3.x addTarget stores names/comments quoted and the parser keeps + // the quotes on re-parse, so pbxTargetByName misses them. Without the + // quoted-name fallback this run would create a duplicate target. + const project = { + pbxTargetByName: () => undefined, + addTarget: vi.fn(), + hash: { + project: { + objects: { + PBXNativeTarget: { + FRAMEWORK_UUID: { + isa: 'PBXNativeTarget', + name: '"BrownfieldLib"', + productReference: 'PRODUCT_REF', + }, + }, + }, + }, + }, + pbxNativeTargetSection: () => ({ + FRAMEWORK_UUID: { + isa: 'PBXNativeTarget', + name: '"BrownfieldLib"', + productReference: 'PRODUCT_REF', + }, + }), + } as any; + + const result = addFrameworkTarget(project, modRequest, baseOptions, { + useExpoHost: false, + }); + + expect(project.addTarget).not.toHaveBeenCalled(); + expect(result).toEqual({ + frameworkTargetUUID: 'FRAMEWORK_UUID', + targetAlreadyExists: true, + }); + }); }); describe('addSourceFilesBuildPhase', () => { @@ -141,6 +181,39 @@ describe('addSourceFilesBuildPhase', () => { expect(project.calls[0][0]).toEqual(['BrownfieldLib.swift']); }); + it('skips adding a duplicate sources phase when the target already has one', () => { + const project = { + calls: [] as unknown[][], + hash: { + project: { + objects: { + PBXNativeTarget: { + FRAMEWORK_UUID: { + isa: 'PBXNativeTarget', + name: '"BrownfieldLib"', + buildPhases: [ + { + value: 'SOURCES_PHASE_UUID', + comment: 'BrownfieldLib', + }, + ], + }, + }, + }, + }, + }, + addBuildPhase(...args: unknown[]) { + (this as any).calls.push(args); + }, + } as any; + + addSourceFilesBuildPhase(project, 'FRAMEWORK_UUID', baseOptions, { + useExpoHost: false, + }); + + expect(project.calls).toHaveLength(0); + }); + it('renders Expo-hosted source files when no brownfield options are given', async () => { const { getFrameworkSourceFiles } = await import('../withIosFrameworkFiles'); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts index f3d7f554..fd9cd5a9 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts @@ -11,6 +11,43 @@ import type { ResolvedBrownfieldPluginIosConfig } from '../types'; import { SourceModificationError } from '../errors/SourceModificationError'; import { getFrameworkSourceFiles } from './withIosFrameworkFiles'; +/** + * xcode@3.x addTarget() stores target names/comments quoted (e.g. name = + * "BrownfieldLib"), and the pbxproj parser keeps the quote characters on + * re-parse. That makes pbxTargetByName() (comment-based, unquoted) miss + * targets we previously wrote, so a second run would create a duplicate + * target. Match by unquoting the stored name as a fallback. + */ +function findFrameworkTargetByQuotedName( + project: XcodeProject, + frameworkName: string +): ReturnType { + const nativeTargets = getRawProjectObjects(project).PBXNativeTarget as + | Record + | undefined; + + for (const [key, target] of Object.entries(nativeTargets ?? {})) { + if (key.endsWith('_comment') || target?.isa !== 'PBXNativeTarget') { + continue; + } + const name = unquotePbxString(target.name); + if (name === frameworkName) { + return target as any; + } + } + + return null; +} + +function unquotePbxString(value: string | undefined): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + return value.startsWith('"') && value.endsWith('"') + ? value.slice(1, -1) + : value; +} + /** * Adds a new Framework target to the Xcode project for Brownfield packaging * @throws If target creation fails @@ -31,7 +68,9 @@ export function addFrameworkTarget( const { frameworkName, bundleIdentifier } = options; // check if target already exists - const existingTarget = project.pbxTargetByName(frameworkName); + const existingTarget = + project.pbxTargetByName(frameworkName) ?? + findFrameworkTargetByQuotedName(project, frameworkName); if (existingTarget) { Logger.logDebug( `Framework target "${frameworkName}" already exists, skipping creation` @@ -177,6 +216,26 @@ export function addSourceFilesBuildPhase( (filePath) => !filePath.endsWith('.plist') ); + // Idempotency: skip when the target already has a sources phase with this + // comment, so re-running scaffolding does not duplicate the build phase. + const nativeTargets = getRawProjectObjects(project).PBXNativeTarget as + | Record + | undefined; + const targetBuildPhases = + nativeTargets?.[frameworkTargetUUID]?.buildPhases ?? []; + const alreadyHasSourcePhase = targetBuildPhases.some((phase) => + hasBuildPhaseComment( + { comment: typeof phase === 'string' ? undefined : phase.comment }, + options.frameworkName + ) + ); + if (alreadyHasSourcePhase) { + Logger.logDebug( + `Sources build phase "${options.frameworkName}" already present on target ${frameworkTargetUUID}, skipping` + ); + return; + } + project.addBuildPhase( sourceFiles, 'PBXSourcesBuildPhase', @@ -619,6 +678,13 @@ export function copyBundleReactNativePhase( } ); + // xcode's pbxShellScriptBuildPhaseObj only escapes quotes, leaving real + // newlines in the shellScript value — that produces an invalid pbxproj + // once the project is written. Encode both fields the same way the + // update path above does. + addedPhase.buildPhase.shellPath = encodePbxString(frameworkShellPath); + addedPhase.buildPhase.shellScript = encodePbxString(frameworkShellScript); + if (existingPhase.showEnvVarsInLog !== undefined) { addedPhase.buildPhase.showEnvVarsInLog = existingPhase.showEnvVarsInLog; } diff --git a/packages/react-native-brownfield/src/scaffold/index.ts b/packages/react-native-brownfield/src/scaffold/index.ts index 65aa0db2..13a809e4 100644 --- a/packages/react-native-brownfield/src/scaffold/index.ts +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -68,9 +68,10 @@ function findProjectRoot(startDir: string): string { } function resolveUserConfig(projectRoot: string): UserConfig { + // Do not pass selectedPlatform: it restricts detection to a single platform + // and would leave project.android undefined. return cliConfig({ projectRoot, - selectedPlatform: 'ios', }) as UserConfig; } @@ -281,7 +282,8 @@ export async function scaffoldBrownfieldInRncCliProject( resolvedIosConfig.ios, { useExpoHost: false } ); - project.writeSync(); + // xcode@3.x writeSync() returns the serialized project but does not write it. + fs.writeFileSync(pbxprojPath, project.writeSync()); const podfilePath = path.join(iosDir, 'Podfile'); const podfile = readFileIfExists(podfilePath); From 03142680642e8c3a3ad5c98b421772f97d7c3265 Mon Sep 17 00:00:00 2001 From: Edu Date: Thu, 1 Oct 2026 15:05:58 +0200 Subject: [PATCH 05/20] refactor: harden the RN CLI scaffold against review follow-ups - narrow expoMajor via early return instead of a boolean and casts - comment the cli-config interop shim and the SDK version defaults - keep one xcode ambient declaration, referenced from the scaffold - build a complete ModProps object instead of casting modRequest - name the failing step and log a fix-forward hint when scaffolding fails mid-way - cross-reference the templateVariant/useExpoHost polarity in docs - ignore the create CLI build output --- .gitignore | 3 + .../src/xcode.d.ts | 4 - .../android/withAndroidModuleFiles.ts | 3 + .../expo-config-plugin/ios/podfileHelpers.ts | 16 +- .../ios/withIosFrameworkFiles.ts | 3 + .../src/scaffold/index.ts | 261 +++++++++++------- 6 files changed, 178 insertions(+), 112 deletions(-) delete mode 100644 packages/create-react-native-brownfield/src/xcode.d.ts diff --git a/.gitignore b/.gitignore index 873cd0ec..c7a33cc3 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,9 @@ buck-out/ # generated by bob lib/ +# CLI build output +packages/create-react-native-brownfield/dist/ + # Turbo .turbo diff --git a/packages/create-react-native-brownfield/src/xcode.d.ts b/packages/create-react-native-brownfield/src/xcode.d.ts deleted file mode 100644 index c8034dc5..00000000 --- a/packages/create-react-native-brownfield/src/xcode.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module 'xcode' { - const xcode: any; - export default xcode; -} diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts index bef15302..5b17a078 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts @@ -93,6 +93,9 @@ export function createAndroidModule({ /** * Source template flavor. 'vanilla' is used by the RN CLI scaffold for * non-Expo projects; Expo keeps the default 'expo' variant. + * Note: expresses the same Expo-vs-vanilla axis as `useExpoHost` in the + * iOS helpers (withIosFrameworkFiles/xcodeHelpers), with opposite + * polarity: 'vanilla' here === `useExpoHost: false` there. */ templateVariant?: 'expo' | 'vanilla'; diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts index 6ad41a04..096a6fd4 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts @@ -78,10 +78,11 @@ export function modifyPodfile( // Vanilla means the caller passed no Expo SDK version at all (undefined). // Negative values mean "Expo detected but unknown version" and must still // hit the unsupported-version error path below, not the vanilla path. - const useExpoHost = typeof expoMajor === 'number'; const frameworkTargetBlock = renderTemplate( 'ios', - useExpoHost ? 'PodfileTargetBlock.rb' : 'PodfileTargetBlock.vanilla.rb', + typeof expoMajor === 'number' + ? 'PodfileTargetBlock.rb' + : 'PodfileTargetBlock.vanilla.rb', { '{{FRAMEWORK_NAME}}': frameworkName, } @@ -112,13 +113,14 @@ export function modifyPodfile( Logger.logDebug(`Added framework target "${frameworkName}" to Podfile`); // Vanilla projects (expoMajor === undefined) get no Expo-only hooks. - if (!useExpoHost) { + // Early-returning here lets TypeScript narrow `expoMajor` to `number` + // for the rest of the function (no casts needed below). + if (typeof expoMajor !== 'number') { return modifiedPodfile; } - if ((expoMajor as number) < MIN_SUPPORTED_EXPO_SDK_MAJOR_VERSION) { - const versionLabel = - (expoMajor as number) < 0 ? 'unknown' : String(expoMajor); + if (expoMajor < MIN_SUPPORTED_EXPO_SDK_MAJOR_VERSION) { + const versionLabel = expoMajor < 0 ? 'unknown' : String(expoMajor); throw new SourceModificationError( `Expo SDK ${versionLabel} is not supported. Please use Expo SDK ${MIN_SUPPORTED_EXPO_SDK_MAJOR_VERSION} or newer. For older versions, please see the matrix of supported versions: https://oss.callstack.com/react-native-brownfield/docs/getting-started/introduction#expo-version-compatibility` ); @@ -126,7 +128,7 @@ export function modifyPodfile( modifiedPodfile = ensureExpoDefinesForSDK55AndAbove( modifiedPodfile, - expoMajor as number + expoMajor ); return modifiedPodfile; diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts index edbb20e4..1e22d92a 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts @@ -21,6 +21,9 @@ export function getFrameworkSourceFiles( /** * Whether the packaged framework is expected to use the Expo host. * This influences template selection for the generated framework sources. + * Note: expresses the same Expo-vs-vanilla axis as `templateVariant` in + * the Android helper (withAndroidModuleFiles.createAndroidModule), with + * opposite polarity: `false` here === 'vanilla' there. */ useExpoHost?: boolean; } diff --git a/packages/react-native-brownfield/src/scaffold/index.ts b/packages/react-native-brownfield/src/scaffold/index.ts index 13a809e4..ba018994 100644 --- a/packages/react-native-brownfield/src/scaffold/index.ts +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -1,7 +1,12 @@ +// Emitted as a triple-slash reference into the generated scaffold .d.ts, so +// consumers (e.g. create-react-native-brownfield) resolve the untyped `xcode` +// import through this single declaration instead of duplicating xcode.d.ts. +/// import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; +import type { ModProps, XcodeProject } from '@expo/config-plugins'; import type { UserConfig } from '@react-native-community/cli-types'; import cliConfigImport from '@react-native-community/cli-config'; import xcode from 'xcode'; @@ -24,6 +29,11 @@ import { } from '../expo-config-plugin/ios/xcodeHelpers'; import { createIosFramework } from '../expo-config-plugin/ios/withIosFrameworkFiles'; +// ESM/CJS interop shim: `@react-native-community/cli-config` ships CJS whose +// default export is the function itself, but depending on how this module is +// loaded (bundler/ESM interop) the import may be the module namespace object +// with the function on `.default`. Unwrap to the callable either way — +// do not remove, otherwise the scaffold crashes with "cliConfig is not a function". const cliConfig: typeof cliConfigImport = typeof cliConfigImport === 'function' ? cliConfigImport @@ -187,112 +197,161 @@ export async function scaffoldBrownfieldInRncCliProject( ); } - // --- Android: root build.gradle + settings.gradle + module files --- - const rootBuildGradlePath = path.join(androidDir, 'build.gradle'); - const rootBuildGradle = readFileIfExists(rootBuildGradlePath); - if (!rootBuildGradle) { - throw new Error(`Missing ${rootBuildGradlePath}`); - } - writeFileIfChanged( - rootBuildGradlePath, - modifyRootBuildGradle(rootBuildGradle) - ); - - const settingsGradlePath = path.join(androidDir, 'settings.gradle'); - const settingsGradle = readFileIfExists(settingsGradlePath); - if (!settingsGradle) { - throw new Error(`Missing ${settingsGradlePath}`); - } - writeFileIfChanged( - settingsGradlePath, - modifySettingsGradle(settingsGradle, androidModuleName) - ); - - const resolvedAndroidConfig: ResolvedBrownfieldPluginConfigWithAndroid = { - android: { - moduleName: androidModuleName, - packageName: androidPackageName, - minSdkVersion: 24, - targetSdkVersion: 35, - compileSdkVersion: 35, - groupId: androidPackageName, - artifactId: androidModuleName, - version: '0.0.1-SNAPSHOT', - // Fields added to the resolved config after the original draft was - // written (BGP local-plugin wiring + flavor dimensions, #448/#458). - // Defaults match the Expo plugin defaults for RN CLI projects. - useLocalGradlePlugin: false, - useLocalMaven: false, - missingDimensionStrategies: [], - }, - ios: null, - debug: options.debug ?? false, - }; - - createAndroidModule({ - androidDir, - config: resolvedAndroidConfig, - rnVersion, - templateVariant: 'vanilla', - }); + // Tracks the current mutation phase so a mid-way failure can name the step + // that failed and point users at fix-forward guidance (see catch below). + let currentStep = 'preconditions'; + + try { + currentStep = 'android-gradle'; + // --- Android: root build.gradle + settings.gradle --- + const rootBuildGradlePath = path.join(androidDir, 'build.gradle'); + const rootBuildGradle = readFileIfExists(rootBuildGradlePath); + if (!rootBuildGradle) { + throw new Error(`Missing ${rootBuildGradlePath}`); + } + writeFileIfChanged( + rootBuildGradlePath, + modifyRootBuildGradle(rootBuildGradle) + ); - // --- iOS: xcodeproj + Podfile + framework source files --- - const xcodeprojPath = firstXcodeprojPath(iosDir); - const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); - if (!fs.existsSync(pbxprojPath)) { - throw new Error(`Missing ${pbxprojPath}`); - } + const settingsGradlePath = path.join(androidDir, 'settings.gradle'); + const settingsGradle = readFileIfExists(settingsGradlePath); + if (!settingsGradle) { + throw new Error(`Missing ${settingsGradlePath}`); + } + writeFileIfChanged( + settingsGradlePath, + modifySettingsGradle(settingsGradle, androidModuleName) + ); - const project = xcode.project(pbxprojPath); - project.parseSync(); - - const appBundleId = resolveIosAppBundleId(project); - const brownfieldBundleId = appBundleId - ? `${appBundleId}.brownfield` - : `com.brownfield.${iosFrameworkName.toLowerCase()}`; - - const resolvedIosConfig: ResolvedBrownfieldPluginConfigWithIos = { - ios: { - frameworkName: iosFrameworkName, - bundleIdentifier: brownfieldBundleId, - buildSettings: {}, - deploymentTarget: '15.0', - frameworkVersion: '1', - }, - android: null, - debug: options.debug ?? false, - }; - - const modRequest = { - platformProjectRoot: iosDir, - projectName: path.basename(xcodeprojPath, '.xcodeproj'), - } as any; - - const { frameworkTargetUUID } = addFrameworkTarget( - project, - modRequest, - resolvedIosConfig.ios, - { useExpoHost: false } - ); + const resolvedAndroidConfig: ResolvedBrownfieldPluginConfigWithAndroid = { + android: { + moduleName: androidModuleName, + packageName: androidPackageName, + // Defaults aligned with the Expo plugin defaults for RN CLI projects. + // RN CLI config does not reliably expose SDK levels today, so there + // is no cheap derivation available here. + // TODO: derive from the app's rootProject.ext SDK values (the shared + // build.gradle.kts template already falls back to + // rootProject.ext.compileSdkVersion/targetSdkVersion) or expose flags. + minSdkVersion: 24, + targetSdkVersion: 35, + compileSdkVersion: 35, + groupId: androidPackageName, + artifactId: androidModuleName, + version: '0.0.1-SNAPSHOT', + // Fields added to the resolved config after the original draft was + // written (BGP local-plugin wiring + flavor dimensions, #448/#458). + // Defaults match the Expo plugin defaults for RN CLI projects. + useLocalGradlePlugin: false, + useLocalMaven: false, + missingDimensionStrategies: [], + }, + ios: null, + debug: options.debug ?? false, + }; + + currentStep = 'android-module'; + createAndroidModule({ + androidDir, + config: resolvedAndroidConfig, + rnVersion, + // 'vanilla' = non-Expo host (same axis as `useExpoHost: false` below). + templateVariant: 'vanilla', + }); + + // --- iOS: xcodeproj + Podfile + framework source files --- + currentStep = 'ios-project'; + const xcodeprojPath = firstXcodeprojPath(iosDir); + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + throw new Error(`Missing ${pbxprojPath}`); + } - copyBundleReactNativePhase(project, frameworkTargetUUID); - addSourceFilesBuildPhase( - project, - frameworkTargetUUID, - resolvedIosConfig.ios, - { useExpoHost: false } - ); - // xcode@3.x writeSync() returns the serialized project but does not write it. - fs.writeFileSync(pbxprojPath, project.writeSync()); + const project = xcode.project(pbxprojPath); + project.parseSync(); + + const appBundleId = resolveIosAppBundleId(project); + const brownfieldBundleId = appBundleId + ? `${appBundleId}.brownfield` + : `com.brownfield.${iosFrameworkName.toLowerCase()}`; + + const resolvedIosConfig: ResolvedBrownfieldPluginConfigWithIos = { + ios: { + frameworkName: iosFrameworkName, + bundleIdentifier: brownfieldBundleId, + buildSettings: {}, + // Defaults aligned with the Expo plugin defaults (Expo's fallback + // when no deployment target can be derived; see + // resolveFrameworkDeploymentTarget). RN CLI config does not expose + // an iOS deployment target directly. + // TODO: derive from the Podfile `platform :ios` line or RN's + // min_ios_version_supported, or expose a flag. + deploymentTarget: '15.0', + // Standard CFBundleShortVersionString for the generated framework + // (matches the Expo plugin default); unrelated to package versioning. + frameworkVersion: '1', + }, + android: null, + debug: options.debug ?? false, + }; + + // Contract: downstream helpers (xcodeHelpers.addFrameworkTarget / + // resolveAppTargetName) only read `platformProjectRoot` (framework group + // location) and `projectName` (app-target-name fallback), but the shared + // Expo-path signatures require a full ModProps, so we provide every field + // with scaffold-appropriate values (no mods are run here, so `introspect` + // is false and `nextMod` is omitted). + const modRequest: ModProps = { + projectRoot, + platformProjectRoot: iosDir, + modName: 'react-native-brownfield-scaffold', + platform: 'ios', + introspect: false, + projectName: path.basename(xcodeprojPath, '.xcodeproj'), + }; + + const { frameworkTargetUUID } = addFrameworkTarget( + project, + modRequest, + resolvedIosConfig.ios, + { useExpoHost: false } + ); - const podfilePath = path.join(iosDir, 'Podfile'); - const podfile = readFileIfExists(podfilePath); - if (!podfile) { - throw new Error(`Missing ${podfilePath}`); + copyBundleReactNativePhase(project, frameworkTargetUUID); + addSourceFilesBuildPhase( + project, + frameworkTargetUUID, + resolvedIosConfig.ios, + { useExpoHost: false } + ); + // xcode@3.x writeSync() returns the serialized project but does not write it. + // This is the riskiest write: a failure here leaves a partially mutated + // pbxproj on disk (the in-memory changes are lost, not half-written). + fs.writeFileSync(pbxprojPath, project.writeSync()); + + currentStep = 'ios-podfile'; + const podfilePath = path.join(iosDir, 'Podfile'); + const podfile = readFileIfExists(podfilePath); + if (!podfile) { + throw new Error(`Missing ${podfilePath}`); + } + writeFileIfChanged(podfilePath, modifyPodfile(podfile, iosFrameworkName)); + + currentStep = 'ios-sources'; + createIosFramework(iosDir, resolvedIosConfig, { useExpoHost: false }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + Logger.logInfo( + 'Scaffolding failed mid-way; fix-forward: re-running the CLI is idempotent for completed steps (gradle settings, Podfile, xcodeproj target detection) — inspect partial changes with git diff.' + ); + throw new Error( + `Brownfield scaffolding failed during step "${currentStep}": ${message}`, + { + cause: error, + } + ); } - writeFileIfChanged(podfilePath, modifyPodfile(podfile, iosFrameworkName)); - - createIosFramework(iosDir, resolvedIosConfig, { useExpoHost: false }); Logger.logInfo('Brownfield scaffolding complete.'); } From 4d14c1b1dda4ba4087b7eef798e809c7838fe464 Mon Sep 17 00:00:00 2001 From: Edu Date: Thu, 1 Oct 2026 15:44:44 +0200 Subject: [PATCH 06/20] fix: emit AGP9-safe gradle from the RN CLI scaffold AGP 9 (bundled by RN 0.87) removed `targetSdk` from library defaultConfig, so the vanilla template variant must omit the block exactly like the Expo SDK 58+ path does. Passing templateVariant into renderTargetSdkBlock keeps Expo behavior intact (<58 still emits, 58+ omits). Also stop hardcoding compile/target SDK 35 in the scaffold: leaving them unset routes the shared template through resolveRootProjectInt, inheriting the app's own rootProject.ext values (RN 0.87 needs compileSdk 37+). minSdk stays 24: the shared template renders it as a literal with no rootProject fallback. --- .../__tests__/withAndroidModuleFiles.test.ts | 26 +++++++++++++++++++ .../android/withAndroidModuleFiles.ts | 15 +++++++++-- .../src/scaffold/index.ts | 20 ++++++++------ 3 files changed, 51 insertions(+), 10 deletions(-) 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 29dafc39..ea640cbe 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 @@ -259,6 +259,32 @@ describe('createAndroidModule', () => { expect(hostManager).not.toContain('loadReactNative(application)'); }); + it('omits targetSdk for the vanilla template variant (AGP 9 rejects the field)', () => { + const androidDir = createAndroidDir(); + + createAndroidModule({ + androidDir, + config: createConfig(), + rnVersion: '0.87.1', + templateVariant: 'vanilla', + }); + + // AGP 9 + RN 0.87 fails compilation with "Unresolved reference 'targetSdk'". + expect(readLibraryBuildGradle(androidDir)).not.toContain('targetSdk'); + }); + + it('still emits targetSdk on the Expo variant when no Expo SDK major is given', () => { + const androidDir = createAndroidDir(); + + createAndroidModule({ + androidDir, + config: createConfig(), + rnVersion: '0.87.1', + }); + + expect(readLibraryBuildGradle(androidDir)).toContain('targetSdk = 35'); + }); + it('emits targetSdk for Expo SDK versions below 58 when inherited from the app project', () => { const androidDir = createAndroidDir(); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts index 5b17a078..02fbc268 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts @@ -62,8 +62,15 @@ export function resolveTargetSdkVersionExpression( export function renderTargetSdkBlock( config: ResolvedBrownfieldPluginConfigWithAndroid, - expoMajor?: number + expoMajor?: number, + templateVariant: 'expo' | 'vanilla' = 'expo' ): string { + // RN CLI (vanilla) projects target AGP 9, where library modules must not + // set `targetSdk` (compilation fails with "Unresolved reference 'targetSdk'"). + if (templateVariant === 'vanilla') { + return ''; + } + if ( expoMajor !== undefined && expoMajor >= EXPO_SDK_OMIT_TARGET_SDK_FROM_MAJOR @@ -127,7 +134,11 @@ export function createAndroidModule({ const hermesArtifact = getHermesArtifact(rnVersion, projectRoot); const compileSdkVersionExpression = resolveCompileSdkVersionExpression(config); - const targetSdkBlock = renderTargetSdkBlock(config, expoMajor); + const targetSdkBlock = renderTargetSdkBlock( + config, + expoMajor, + templateVariant + ); const minifyEnabled = (android as { minifyEnabled?: boolean }).minifyEnabled ?? false; const extraProguardRules = diff --git a/packages/react-native-brownfield/src/scaffold/index.ts b/packages/react-native-brownfield/src/scaffold/index.ts index ba018994..e1881055 100644 --- a/packages/react-native-brownfield/src/scaffold/index.ts +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -228,15 +228,19 @@ export async function scaffoldBrownfieldInRncCliProject( android: { moduleName: androidModuleName, packageName: androidPackageName, - // Defaults aligned with the Expo plugin defaults for RN CLI projects. - // RN CLI config does not reliably expose SDK levels today, so there - // is no cheap derivation available here. - // TODO: derive from the app's rootProject.ext SDK values (the shared - // build.gradle.kts template already falls back to - // rootProject.ext.compileSdkVersion/targetSdkVersion) or expose flags. + // minSdk has no rootProject fallback in the shared build.gradle.kts + // template (it renders a literal), so keep the Expo plugin default. + // RN CLI rootProject.ext.minSdkVersion is 24 on current templates + // anyway; the generated build.gradle.kts stays readable. minSdkVersion: 24, - targetSdkVersion: 35, - compileSdkVersion: 35, + // compile/target SDK are intentionally not hardcoded: leaving them + // unset makes the shared template fall back to + // resolveRootProjectInt("compileSdkVersion"/"targetSdkVersion"), i.e. + // the app's own rootProject.ext values (RN 0.87 needs compileSdk 37+; + // AGP 9 rejects library `targetSdk` entirely — see + // renderTargetSdkBlock). Proven against a real RN 0.87.1 app. + targetSdkVersion: undefined, + compileSdkVersion: undefined, groupId: androidPackageName, artifactId: androidModuleName, version: '0.0.1-SNAPSHOT', From fe83b08af42cc349edb530ccc29d8ee520958df3 Mon Sep 17 00:00:00 2001 From: Edu Date: Thu, 1 Oct 2026 15:45:01 +0200 Subject: [PATCH 07/20] feat: install brownfield deps and generate brownfield.config.json in the scaffold Done-means scope: after the init, `brownfield package:ios` / `package:android` must work with zero manual steps. - add @callstack/react-native-brownfield (the Kotlin/Swift host code the scaffolded module imports) and @callstack/brownfield-cli (the brownfield bin) as dependencies, caret-pinned to this package's own published version; existing specs are never overwritten - add the package:ios / package:android scripts from the docs manual steps, without clobbering same-named user scripts - write brownfield.config.json matching the docs ios.mdx step 5 / android.mdx step 7 shape and validated against the CLI's own ajv schema; skipped when a brownfield.config.js or legacy package.json `brownfield` key already exists (the CLI loader errors on multiple config sources) Config content is derived deterministically from the scaffolded names, so re-running the scaffold is byte-identical rather than duplicating anything. --- .../scaffold/__tests__/projectFiles.test.ts | 289 ++++++++++++++++++ .../src/scaffold/index.ts | 26 +- .../src/scaffold/projectFiles.ts | 237 ++++++++++++++ 3 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-brownfield/src/scaffold/__tests__/projectFiles.test.ts create mode 100644 packages/react-native-brownfield/src/scaffold/projectFiles.ts diff --git a/packages/react-native-brownfield/src/scaffold/__tests__/projectFiles.test.ts b/packages/react-native-brownfield/src/scaffold/__tests__/projectFiles.test.ts new file mode 100644 index 00000000..7b0c209b --- /dev/null +++ b/packages/react-native-brownfield/src/scaffold/__tests__/projectFiles.test.ts @@ -0,0 +1,289 @@ +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + BROWNFIELD_CLI_PACKAGE_NAME, + BROWNFIELD_RUNTIME_PACKAGE_NAME, + addBrownfieldDependencies, + addBrownfieldPackageScripts, + createBrownfieldFileConfig, + writeBrownfieldFileConfig, +} from '../projectFiles'; + +// Mirrors packages/cli/src/config.ts: the emitted config must validate against +// the schema the CLI itself enforces (schema.json is generated from +// packages/cli/src/types.ts via `yarn generate:schema`). ajv is resolved from +// packages/cli, where it is a declared dependency and the same version the CLI +// validates with, so this test adds no dependency of its own. +const cliPackageRoot = path.resolve(__dirname, '../../../../cli'); +const cliRequire = createRequire(path.join(cliPackageRoot, 'package.json')); +// ajv v8 CJS: module.exports is the class itself. Typed loosely on purpose: +// the hoisted `ajv` type declarations in this package may differ from the v8 +// copy packages/cli actually validates with; only the compiled validator +// surface is used here. +const Ajv = cliRequire('ajv') as new (options?: Record) => { + compile(schema: unknown): (data: unknown) => boolean; +}; +const cliSchema = cliRequire('./schema.json'); + +const ajv = new Ajv({ allErrors: true, allowUnionTypes: true }); +const validateConfig = ajv.compile(cliSchema); + +describe('scaffold project.json file helpers', () => { + let projectRoot: string; + + beforeEach(() => { + projectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'react-native-brownfield-scaffold-') + ); + }); + + afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); + }); + + function writePackageJson(pkg: Record) { + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify(pkg, null, 2) + '\n', + 'utf8' + ); + } + + function readPackageJson(): any { + return JSON.parse( + fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8') + ); + } + + describe('addBrownfieldDependencies', () => { + it('adds the runtime package as a dependency when absent', () => { + writePackageJson({ name: 'app', dependencies: { react: '19.2.3' } }); + + addBrownfieldDependencies(projectRoot); + + const pkg = readPackageJson(); + expect(pkg.dependencies[BROWNFIELD_RUNTIME_PACKAGE_NAME]).toBe('^5.1.1'); + expect(pkg.dependencies.react).toBe('19.2.3'); + }); + + it('adds the brownfield CLI as a dependency when absent', () => { + writePackageJson({ name: 'app' }); + + addBrownfieldDependencies(projectRoot); + + const pkg = readPackageJson(); + expect(pkg.dependencies[BROWNFIELD_CLI_PACKAGE_NAME]).toBe('^5.1.1'); + }); + + it('leaves existing versions untouched', () => { + writePackageJson({ + name: 'app', + dependencies: { + [BROWNFIELD_RUNTIME_PACKAGE_NAME]: 'file:../local-fork', + }, + devDependencies: { + [BROWNFIELD_CLI_PACKAGE_NAME]: 'workspace:^', + }, + }); + + addBrownfieldDependencies(projectRoot); + + const pkg = readPackageJson(); + expect(pkg.dependencies[BROWNFIELD_RUNTIME_PACKAGE_NAME]).toBe( + 'file:../local-fork' + ); + expect(pkg.devDependencies[BROWNFIELD_CLI_PACKAGE_NAME]).toBe( + 'workspace:^' + ); + // Must not also appear in the other section. + expect( + pkg.devDependencies[BROWNFIELD_RUNTIME_PACKAGE_NAME] + ).toBeUndefined(); + expect(pkg.dependencies[BROWNFIELD_CLI_PACKAGE_NAME]).toBeUndefined(); + }); + + it('is idempotent and byte-stable across runs', () => { + writePackageJson({ name: 'app', dependencies: { react: '19.2.3' } }); + + addBrownfieldDependencies(projectRoot); + const first = fs.readFileSync( + path.join(projectRoot, 'package.json'), + 'utf8' + ); + + addBrownfieldDependencies(projectRoot); + const second = fs.readFileSync( + path.join(projectRoot, 'package.json'), + 'utf8' + ); + + expect(second).toBe(first); + }); + + it('creates the dependencies section when the package.json has none', () => { + writePackageJson({ name: 'app', version: '1.0.0' }); + + addBrownfieldDependencies(projectRoot); + + const pkg = readPackageJson(); + expect(Object.keys(pkg.dependencies).sort()).toEqual( + [BROWNFIELD_CLI_PACKAGE_NAME, BROWNFIELD_RUNTIME_PACKAGE_NAME].sort() + ); + }); + }); + + describe('addBrownfieldPackageScripts', () => { + it('adds package:ios and package:android scripts', () => { + writePackageJson({ + name: 'app', + scripts: { start: 'react-native start' }, + }); + + addBrownfieldPackageScripts(projectRoot); + + const pkg = readPackageJson(); + expect(pkg.scripts['package:ios']).toBe('brownfield package:ios'); + expect(pkg.scripts['package:android']).toBe('brownfield package:android'); + expect(pkg.scripts.start).toBe('react-native start'); + }); + + it('does not clobber existing scripts with the same name', () => { + writePackageJson({ + name: 'app', + scripts: { 'package:ios': 'my-custom-packaging' }, + }); + + addBrownfieldPackageScripts(projectRoot); + + expect(readPackageJson().scripts['package:ios']).toBe( + 'my-custom-packaging' + ); + }); + + it('is idempotent and byte-stable across runs', () => { + writePackageJson({ name: 'app' }); + + addBrownfieldPackageScripts(projectRoot); + const first = fs.readFileSync( + path.join(projectRoot, 'package.json'), + 'utf8' + ); + + addBrownfieldPackageScripts(projectRoot); + expect( + fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8') + ).toBe(first); + }); + }); + + describe('createBrownfieldFileConfig', () => { + it('matches the docs manual-step shape and passes the CLI schema', () => { + const config = createBrownfieldFileConfig({ + iosFrameworkName: 'BrownfieldLib', + androidModuleName: 'brownfieldlib', + }); + + expect(config).toEqual({ + $schema: + 'https://oss.callstack.com/react-native-brownfield/schema.json', + ios: { + scheme: 'BrownfieldLib', + configuration: 'Release', + }, + android: { + moduleName: 'brownfieldlib', + variant: 'Release', + }, + }); + + expect(validateConfig(config)).toBe(true); + }); + + it('reflects custom framework and module names', () => { + const config = createBrownfieldFileConfig({ + iosFrameworkName: 'MyLib', + androidModuleName: 'mylib', + }); + + expect(validateConfig(config)).toBe(true); + expect(config.ios?.scheme).toBe('MyLib'); + expect(config.android?.moduleName).toBe('mylib'); + }); + }); + + describe('writeBrownfieldFileConfig', () => { + it('writes brownfield.config.json deterministically at the project root', () => { + writePackageJson({ name: 'app' }); + const configPath = path.join(projectRoot, 'brownfield.config.json'); + const target = writeBrownfieldFileConfig(projectRoot, { + iosFrameworkName: 'BrownfieldLib', + androidModuleName: 'brownfieldlib', + }); + + expect(target).toBe(configPath); + const first = fs.readFileSync(configPath, 'utf8'); + expect(JSON.parse(first)).toEqual( + createBrownfieldFileConfig({ + iosFrameworkName: 'BrownfieldLib', + androidModuleName: 'brownfieldlib', + }) + ); + + const again = writeBrownfieldFileConfig(projectRoot, { + iosFrameworkName: 'BrownfieldLib', + androidModuleName: 'brownfieldlib', + }); + expect(again).toBe(configPath); + expect(fs.readFileSync(configPath, 'utf8')).toBe(first); + }); + + it('overwrites a stale config with the deterministic derived content', () => { + writePackageJson({ name: 'app' }); + const target = path.join(projectRoot, 'brownfield.config.json'); + fs.writeFileSync(target, '{"ios":{"scheme":"OldName"}}', 'utf8'); + + writeBrownfieldFileConfig(projectRoot, { + iosFrameworkName: 'BrownfieldLib', + androidModuleName: 'brownfieldlib', + }); + + expect(JSON.parse(fs.readFileSync(target, 'utf8')).ios.scheme).toBe( + 'BrownfieldLib' + ); + }); + + it('does not write a json config when a js config already exists', () => { + // packages/cli loadBrownfieldConfig errors on multiple config files. + fs.writeFileSync( + path.join(projectRoot, 'brownfield.config.js'), + 'module.exports = {};', + 'utf8' + ); + + writeBrownfieldFileConfig(projectRoot, { + iosFrameworkName: 'BrownfieldLib', + androidModuleName: 'brownfieldlib', + }); + + expect( + fs.existsSync(path.join(projectRoot, 'brownfield.config.json')) + ).toBe(false); + }); + + it('does not write a json config when package.json carries a legacy brownfield key', () => { + writePackageJson({ name: 'app', brownfield: { ios: { scheme: 'Lib' } } }); + + writeBrownfieldFileConfig(projectRoot, { + iosFrameworkName: 'BrownfieldLib', + androidModuleName: 'brownfieldlib', + }); + + expect( + fs.existsSync(path.join(projectRoot, 'brownfield.config.json')) + ).toBe(false); + }); + }); +}); diff --git a/packages/react-native-brownfield/src/scaffold/index.ts b/packages/react-native-brownfield/src/scaffold/index.ts index e1881055..3911e4dc 100644 --- a/packages/react-native-brownfield/src/scaffold/index.ts +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -28,6 +28,11 @@ import { copyBundleReactNativePhase, } from '../expo-config-plugin/ios/xcodeHelpers'; import { createIosFramework } from '../expo-config-plugin/ios/withIosFrameworkFiles'; +import { + addBrownfieldDependencies, + addBrownfieldPackageScripts, + writeBrownfieldFileConfig, +} from './projectFiles'; // ESM/CJS interop shim: `@react-native-community/cli-config` ships CJS whose // default export is the function itself, but depending on how this module is @@ -344,10 +349,29 @@ export async function scaffoldBrownfieldInRncCliProject( currentStep = 'ios-sources'; createIosFramework(iosDir, resolvedIosConfig, { useExpoHost: false }); + + // --- App package wiring: Brownfield deps + package:* scripts --- + // The scaffolded Kotlin host imports com.callstack.reactnativebrownfield.*, + // so the JS/runtime package must be an app dependency for native builds to + // resolve it. Idempotent: existing version specs are left untouched. + currentStep = 'package-dependencies'; + addBrownfieldDependencies(projectRoot); + + currentStep = 'package-scripts'; + addBrownfieldPackageScripts(projectRoot); + + // --- brownfield.config.json: packaging settings for `brownfield package:*` --- + // Deterministic content derived from the scaffolded names, so re-runs + // produce identical bytes (overwrite == no-op). + currentStep = 'brownfield-config'; + writeBrownfieldFileConfig(projectRoot, { + iosFrameworkName, + androidModuleName, + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); Logger.logInfo( - 'Scaffolding failed mid-way; fix-forward: re-running the CLI is idempotent for completed steps (gradle settings, Podfile, xcodeproj target detection) — inspect partial changes with git diff.' + 'Scaffolding failed mid-way; fix-forward: re-running the CLI is idempotent for completed steps (gradle settings, Podfile, xcodeproj target detection, package.json deps/scripts, brownfield.config.json) — inspect partial changes with git diff.' ); throw new Error( `Brownfield scaffolding failed during step "${currentStep}": ${message}`, diff --git a/packages/react-native-brownfield/src/scaffold/projectFiles.ts b/packages/react-native-brownfield/src/scaffold/projectFiles.ts new file mode 100644 index 00000000..c09b3fa5 --- /dev/null +++ b/packages/react-native-brownfield/src/scaffold/projectFiles.ts @@ -0,0 +1,237 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { Logger } from '../expo-config-plugin/logging'; + +export const BROWNFIELD_RUNTIME_PACKAGE_NAME = + '@callstack/react-native-brownfield'; +export const BROWNFIELD_CLI_PACKAGE_NAME = '@callstack/brownfield-cli'; + +/** + * Match the docs convention (getting-started/quick-start + expo.mdx): both + * packages are regular dependencies. The brownfield CLI ships as a dependency + * of the runtime package, so a consistent caret range pinned to this package's + * own published version keeps the two in lockstep (they are released together). + */ +export function resolveBrownfieldPackageVersionRange(): string { + return `^${readOwnPackageVersion()}`; +} + +function readOwnPackageVersion(): string { + // Walk up from this module to its own package.json. Works both in the + // published layout (lib/commonjs/scaffold -> package root) and in-repo + // (src/scaffold -> package root). `__dirname` is absent in true-ESM builds + // (and `import.meta` must not be used here — Babel keeps it verbatim in the + // CJS output, which is a parse SyntaxError there; see index.ts), so fall + // back to cwd, which is correct whenever the package is resolved from the + // app's own node_modules chain. + let dir = typeof __dirname === 'string' ? __dirname : process.cwd(); + while (true) { + const packageJsonPath = path.join(dir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + name?: string; + version?: string; + }; + if (pkg.name === BROWNFIELD_RUNTIME_PACKAGE_NAME) { + if (!pkg.version) { + throw new Error( + `${BROWNFIELD_RUNTIME_PACKAGE_NAME} package.json has no version` + ); + } + return pkg.version; + } + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error( + `Could not locate the ${BROWNFIELD_RUNTIME_PACKAGE_NAME} package.json to resolve dependency versions.` + ); +} + +type PackageJson = { + dependencies?: Record; + devDependencies?: Record; + scripts?: Record; + [key: string]: unknown; +}; + +function readPackageJson(projectRoot: string): PackageJson { + const packageJsonPath = path.join(projectRoot, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`Missing ${packageJsonPath}`); + } + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJson; +} + +function writePackageJson(projectRoot: string, pkg: PackageJson): void { + // Stable formatting (2-space indent, trailing newline), so repeated runs + // produce byte-identical output. + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify(pkg, null, 2) + '\n', + 'utf8' + ); +} + +function ensureDependency( + pkg: PackageJson, + packageName: string, + versionRange: string +): boolean { + // Idempotency: if the package is declared anywhere already, leave the + // user's version spec untouched (same spirit as the Podfile/gradle guards). + if (pkg.dependencies?.[packageName] ?? pkg.devDependencies?.[packageName]) { + return false; + } + pkg.dependencies = pkg.dependencies ?? {}; + pkg.dependencies[packageName] = versionRange; + return true; +} + +/** + * Adds @callstack/react-native-brownfield (the Kotlin/Swift host code the + * scaffolded module imports) and @callstack/brownfield-cli (the `brownfield` + * bin used by the package:* scripts) to the app's dependencies. + * Existing declarations are never overwritten. Returns the names added. + */ +export function addBrownfieldDependencies(projectRoot: string): string[] { + const pkg = readPackageJson(projectRoot); + const versionRange = resolveBrownfieldPackageVersionRange(); + + const added: string[] = []; + if (ensureDependency(pkg, BROWNFIELD_RUNTIME_PACKAGE_NAME, versionRange)) { + added.push(BROWNFIELD_RUNTIME_PACKAGE_NAME); + } + if (ensureDependency(pkg, BROWNFIELD_CLI_PACKAGE_NAME, versionRange)) { + added.push(BROWNFIELD_CLI_PACKAGE_NAME); + } + + if (added.length > 0) { + writePackageJson(projectRoot, pkg); + Logger.logDebug( + `Added Brownfield dependencies: ${added.join(', ')} (${versionRange})` + ); + } + return added; +} + +/** + * Adds the `package:ios` / `package:android` scripts from the docs' manual + * steps (settings come from the generated brownfield.config.json, so no flags + * are duplicated here). Existing scripts with the same names are kept. + */ +export function addBrownfieldPackageScripts(projectRoot: string): string[] { + const pkg = readPackageJson(projectRoot); + pkg.scripts = pkg.scripts ?? {}; + + const desiredScripts: Record = { + 'package:ios': 'brownfield package:ios', + 'package:android': 'brownfield package:android', + }; + + const added: string[] = []; + for (const [name, command] of Object.entries(desiredScripts)) { + if (pkg.scripts[name] === undefined) { + pkg.scripts[name] = command; + added.push(name); + } + } + + if (added.length > 0) { + writePackageJson(projectRoot, pkg); + Logger.logDebug(`Added Brownfield scripts: ${added.join(', ')}`); + } + return added; +} + +export type BrownfieldFileConfig = { + $schema: string; + ios: { + scheme: string; + configuration: string; + }; + android: { + moduleName: string; + variant: string; + }; +}; + +/** + * Builds the brownfield.config.json content for the scaffolded targets, + * matching the manual steps in docs getting-started/ios.mdx (step 5) and + * android.mdx (step 7) and the CLI config schema (packages/cli/src/types.ts, + * strict additionalProperties: false). + * + * Idempotency strategy: the content is derived deterministically from the + * scaffolded project state (framework/module names), so re-running the + * scaffold overwrites with byte-identical content. Users customizing it are + * taking over responsibility; a re-scaffold with the same names is a no-op. + */ +export function createBrownfieldFileConfig({ + iosFrameworkName, + androidModuleName, +}: { + iosFrameworkName: string; + androidModuleName: string; +}): BrownfieldFileConfig { + return { + $schema: 'https://oss.callstack.com/react-native-brownfield/schema.json', + ios: { + // iOS packaging builds the generated framework target by scheme name. + scheme: iosFrameworkName, + configuration: 'Release', + }, + android: { + // Gradle module name of the generated library module. + moduleName: androidModuleName, + variant: 'Release', + }, + }; +} + +/** + * Writes brownfield.config.json at the project root. Deterministic output: + * same inputs -> same bytes, so re-running the scaffold never duplicates or + * drifts the file. + * + * Skips writing (returns null) when another Brownfield config source already + * exists: packages/cli loadBrownfieldConfig fails hard on multiple config + * files, so an existing brownfield.config.js or a legacy `brownfield` key in + * package.json takes precedence over generating a JSON one. + */ +export function writeBrownfieldFileConfig( + projectRoot: string, + names: { iosFrameworkName: string; androidModuleName: string } +): string | null { + const configPath = path.join(projectRoot, 'brownfield.config.json'); + + const jsConfigPath = path.join(projectRoot, 'brownfield.config.js'); + if (fs.existsSync(jsConfigPath)) { + Logger.logDebug( + `Skipping brownfield.config.json: ${jsConfigPath} already exists.` + ); + return null; + } + + const pkg = readPackageJson(projectRoot); + if ('brownfield' in pkg) { + Logger.logDebug( + 'Skipping brownfield.config.json: legacy "brownfield" key in package.json already exists.' + ); + return null; + } + + const content = JSON.stringify(createBrownfieldFileConfig(names), null, 2); + const prev = fs.existsSync(configPath) + ? fs.readFileSync(configPath, 'utf8') + : null; + + if (prev !== content) { + fs.writeFileSync(configPath, content, 'utf8'); + Logger.logDebug(`Wrote ${configPath}`); + } + return configPath; +} From 53cec09f55d295247d9d2d6382c6140ce6e6a6be Mon Sep 17 00:00:00 2001 From: Edu Date: Thu, 1 Oct 2026 16:14:13 +0200 Subject: [PATCH 08/20] fix: pin scaffolded gradle plugin to a published version The scaffold emitted BROWNFIELD_PLUGIN_VERSION (2.0.0-alpha10), which is the in-development plugin version tracked from gradle.properties and is not on Maven Central yet (404; Central tops at 2.0.0-alpha09), so every fresh scaffolded app would fail buildscript resolution. Scaffold now passes an explicit pluginVersion (PUBLISHED_BROWNFIELD_ PLUGIN_VERSION = 2.0.0-alpha09) through modifyRootBuildGradle; the Expo path keeps the old default untouched. Verified on the RN 0.87.1 smoke app (AGP 9.2.1, Gradle 9.4.1): fresh ':brownfieldlib:assembleDebug' against a Maven Central alpha09 classpath (no mavenLocal) -> BUILD SUCCESSFUL. --- .../android/__tests__/gradleHelpers.test.ts | 16 ++++++++++++ .../android/utils/constants.ts | 25 ++++++++++++++++++- .../android/utils/gradleHelpers.ts | 13 +++++++--- .../src/scaffold/index.ts | 8 +++++- 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/gradleHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/gradleHelpers.test.ts index 9100d096..5384917b 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/gradleHelpers.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/gradleHelpers.test.ts @@ -20,6 +20,22 @@ describe('gradleHelpers', () => { ); }); + it('emits an explicit pluginVersion when provided (RN CLI scaffold path)', () => { + // The scaffold must pin a Maven Central-published version; the default + // (above) tracks the in-development plugin and is for the Expo path. + const contents = `buildscript { + dependencies { + classpath('com.android.tools.build:gradle') + } +}`; + + expect( + modifyRootBuildGradle(contents, { pluginVersion: '2.0.0-alpha09' }) + ).toContain( + 'classpath("com.callstack.react:brownfield-gradle-plugin:2.0.0-alpha09")' + ); + }); + it('adds the Brownfield module include without mutating pluginManagement', () => { const contents = `pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts index c4637a05..4359c8af 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts @@ -1,2 +1,25 @@ export const BROWNFIELD_PLUGIN_VERSION = '2.0.0-alpha10'; -export const brownfieldGradlePluginDependency = `classpath("com.callstack.react:brownfield-gradle-plugin:${BROWNFIELD_PLUGIN_VERSION}")`; + +/** + * Newest brownfield-gradle-plugin version actually published to Maven Central + * (verified against repo1.maven.org maven-metadata.xml). The version above is + * kept in sync with gradle-plugins/react/brownfield/gradle.properties + * (`yarn brownfield:plugin:version:sync`) and tracks the plugin under + * development, whose release may lag behind this package. The RN CLI scaffold + * emits this published pin instead, because a scaffolded app resolves the + * classpath from Maven Central only and an unreleased version would 404. + * Proven against a real RN 0.87.1 app: `:brownfieldlib:assembleDebug` with a + * fresh Maven Central resolution of 2.0.0-alpha09. + * TODO(release): once 2.0.0-alpha10 (or later) is on Maven Central, point this + * at it — or better, make it track BROWNFIELD_PLUGIN_VERSION again. + */ +export const PUBLISHED_BROWNFIELD_PLUGIN_VERSION = '2.0.0-alpha09'; + +export function brownfieldGradlePluginClasspath( + version: string = BROWNFIELD_PLUGIN_VERSION +): string { + return `classpath("com.callstack.react:brownfield-gradle-plugin:${version}")`; +} + +export const brownfieldGradlePluginDependency = + brownfieldGradlePluginClasspath(); 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 cd273720..2503300e 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,6 +1,6 @@ import { BROWNFIELD_PLUGIN_VERSION, - brownfieldGradlePluginDependency, + brownfieldGradlePluginClasspath, } from './constants'; import { Logger } from '../../logging'; import { formatMissingDimensionStrategies } from './formatHelpers'; @@ -12,6 +12,12 @@ const LOCAL_MAVEN_REPOSITORY = 'mavenLocal()'; type GradleModificationOptions = { useLocalGradlePlugin?: boolean; useLocalMaven?: boolean; + /** + * Explicit brownfield-gradle-plugin version for the emitted classpath. + * Defaults to BROWNFIELD_PLUGIN_VERSION (Expo plugin behavior, unchanged); + * the RN CLI scaffold passes a Maven-Central-published version instead. + */ + pluginVersion?: string; }; const BUILD_SCRIPT_BLOCK_NAME = 'buildscript'; @@ -71,6 +77,7 @@ export function modifyRootBuildGradle( { useLocalGradlePlugin = false, useLocalMaven = false, + pluginVersion, }: GradleModificationOptions = {} ): string { let updatedContents = contents; @@ -108,8 +115,8 @@ export function modifyRootBuildGradle( } const gradlePluginDependency = useLocalMaven - ? `classpath("com.callstack.react:brownfield-gradle-plugin:${BROWNFIELD_PLUGIN_VERSION}-SNAPSHOT")` - : brownfieldGradlePluginDependency; + ? brownfieldGradlePluginClasspath(`${BROWNFIELD_PLUGIN_VERSION}-SNAPSHOT`) + : brownfieldGradlePluginClasspath(pluginVersion); // insert before the closing brace of dependencies const insertion = `\t${gradlePluginDependency}\n\t`; diff --git a/packages/react-native-brownfield/src/scaffold/index.ts b/packages/react-native-brownfield/src/scaffold/index.ts index 3911e4dc..d38a2466 100644 --- a/packages/react-native-brownfield/src/scaffold/index.ts +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -20,6 +20,7 @@ import { modifyRootBuildGradle, modifySettingsGradle, } from '../expo-config-plugin/android/utils/gradleHelpers'; +import { PUBLISHED_BROWNFIELD_PLUGIN_VERSION } from '../expo-config-plugin/android/utils/constants'; import { createAndroidModule } from '../expo-config-plugin/android/withAndroidModuleFiles'; import { modifyPodfile } from '../expo-config-plugin/ios/podfileHelpers'; import { @@ -216,7 +217,12 @@ export async function scaffoldBrownfieldInRncCliProject( } writeFileIfChanged( rootBuildGradlePath, - modifyRootBuildGradle(rootBuildGradle) + // Pin the Maven Central-published plugin version: a scaffolded RN CLI + // app resolves this classpath from Central only, so the in-development + // BROWNFIELD_PLUGIN_VERSION (often unreleased) would 404 the build. + modifyRootBuildGradle(rootBuildGradle, { + pluginVersion: PUBLISHED_BROWNFIELD_PLUGIN_VERSION, + }) ); const settingsGradlePath = path.join(androidDir, 'settings.gradle'); From a60f3de190c5a72e2deb3651f9de3a79d40fc291 Mon Sep 17 00:00:00 2001 From: Edu Date: Thu, 1 Oct 2026 16:42:33 +0200 Subject: [PATCH 09/20] chore: wire create-react-native-brownfield into release and CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - changesets: declare create-react-native-brownfield at 1.0.0 and add it to the fixed release group, mirroring how @callstack/brownfield-cli was introduced (verified with a local `changeset version` dry run: the whole fixed group bumps together). Add a minor changeset for the new package and the scaffold subpath export. - CI: add scaffold-smoke-android job — inits a fresh RN CLI app, runs the built create CLI against it and assembles the Android packaging module (assembleRelease, no pods/Emulator). Script validated locally: fresh app, scaffold run, deps from npm, BUILD SUCCESSFUL, AAR produced. --- .changeset/config.json | 3 +- .changeset/plenty-owls-smoke.md | 6 ++ .github/workflows/ci.yml | 26 +++++++ .../package.json | 2 +- scripts/ci-scaffold-smoke-android.sh | 67 +++++++++++++++++++ 5 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 .changeset/plenty-owls-smoke.md create mode 100644 scripts/ci-scaffold-smoke-android.sh diff --git a/.changeset/config.json b/.changeset/config.json index c3531772..e14d355e 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -11,7 +11,8 @@ "@callstack/brownfield-navigation", "@callstack/brownie", "@callstack/brownfield-cli", - "@callstack/react-native-brownfield" + "@callstack/react-native-brownfield", + "create-react-native-brownfield" ] ], "linked": [], diff --git a/.changeset/plenty-owls-smoke.md b/.changeset/plenty-owls-smoke.md new file mode 100644 index 00000000..8f63f7ba --- /dev/null +++ b/.changeset/plenty-owls-smoke.md @@ -0,0 +1,6 @@ +--- +'@callstack/react-native-brownfield': minor +'create-react-native-brownfield': minor +--- + +add create-react-native-brownfield: scaffold Brownfield packaging targets in an existing React Native CLI project (`npx create-react-native-brownfield`), plus the `@callstack/react-native-brownfield/scaffold` subpath export it uses diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8682592..e2872d51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,32 @@ jobs: run: | yarn workspace @callstack/react-native-brownfield brownfield --version + scaffold-smoke-android: + name: Scaffold smoke (RNC CLI app - Android) + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: [filter, build-lint] + if: | + always() && + needs.filter.outputs.packages == 'true' && + (needs.build-lint.result == 'success' || needs.build-lint.result == 'skipped') + + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Setup + uses: ./.github/actions/setup + + - name: Prepare Android + uses: ./.github/actions/prepare-android + + - name: Build packages + run: yarn build + + - name: Scaffold a fresh RN CLI app and assemble the packaging module + run: bash ./scripts/ci-scaffold-smoke-android.sh + ios-native-tests: name: iOS native tests runs-on: macos-26 diff --git a/packages/create-react-native-brownfield/package.json b/packages/create-react-native-brownfield/package.json index 3e3014e2..c962922b 100644 --- a/packages/create-react-native-brownfield/package.json +++ b/packages/create-react-native-brownfield/package.json @@ -1,6 +1,6 @@ { "name": "create-react-native-brownfield", - "version": "0.0.0", + "version": "1.0.0", "license": "MIT", "author": "Callstack", "bin": "dist/main.js", diff --git a/scripts/ci-scaffold-smoke-android.sh b/scripts/ci-scaffold-smoke-android.sh new file mode 100644 index 00000000..70ac76c1 --- /dev/null +++ b/scripts/ci-scaffold-smoke-android.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Scaffold smoke: run the built create-react-native-brownfield CLI against a +# fresh `@react-native-community/cli init` app and build the generated Android +# packaging module (Android only — no pods/xcodebuild, to stay cheap). +# Catches pbxproj/gradle/Kotlin-template mistakes that unit tests cannot. +# +# Usage (from repo root, after `yarn build`): +# bash ./scripts/ci-scaffold-smoke-android.sh +# +# Env: +# SMOKE_WORKDIR where to create the app (default: mktemp -d) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKDIR="${SMOKE_WORKDIR:-$(mktemp -d "${TMPDIR:-/tmp}/brownfield-scaffold-smoke.XXXXXX")}" +APP_NAME="BFSmoke" + +echo "::group::Create RN CLI app" +mkdir -p "$WORKDIR" +cd "$WORKDIR" +# --install-pods false: iOS pods are out of scope for this Android-only smoke. +npx --yes "@react-native-community/cli@latest" init "$APP_NAME" \ + --directory "$APP_NAME" \ + --pm npm \ + --install-pods false +cd "$APP_NAME" +echo "react-native version: $(node -p "require('./package.json').dependencies['react-native']")" +echo "::endgroup::" + +echo "::group::Run scaffold CLI" +node "$REPO_ROOT/packages/create-react-native-brownfield/dist/main.js" --debug +echo "::endgroup::" + +echo "::group::Install scaffolded dependencies" +# The scaffold adds `^` ranges; those may not be published +# yet mid-cycle. The point of this smoke is the scaffolded project wiring, so +# resolve both Brownfield packages to whatever is published on npm (same +# artifact a real user installs). The scaffolded Kotlin compiles against them. +npm pkg set "dependencies.@callstack/react-native-brownfield=latest" \ + "dependencies.@callstack/brownfield-cli=latest" +npm install --no-audit --no-fund +echo "::endgroup::" + +echo "::group::Verify scaffold output" +test -f android/brownfieldlib/build.gradle.kts || { echo "missing brownfieldlib/build.gradle.kts"; exit 1; } +test -f brownfield.config.json || { echo "missing brownfield.config.json"; exit 1; } +grep -q "brownfield-gradle-plugin" android/build.gradle || { echo "root build.gradle lacks the Brownfield Gradle plugin classpath"; exit 1; } +grep -q "include ':brownfieldlib'" android/settings.gradle || { echo "settings.gradle lacks ':brownfieldlib'"; exit 1; } +echo "::endgroup::" + +echo "::group::Assemble Android packaging module" +# The freshly generated app pins its own ndkVersion in buildscript ext; install +# it if missing (CI runners carry a different preinstalled set). No-op locally +# when the NDK is already present. +NDK_VERSION="$(sed -nE 's/.*ndkVersion = "([^"]+)".*/\1/p' android/build.gradle | head -1)" +SDKMANAGER="${ANDROID_HOME:-}/cmdline-tools/latest/bin/sdkmanager" +if [ -n "$NDK_VERSION" ] && [ -n "${ANDROID_HOME:-}" ] && [ ! -d "$ANDROID_HOME/ndk/$NDK_VERSION" ]; then + if [ -x "$SDKMANAGER" ]; then + yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true + "$SDKMANAGER" --install "ndk;$NDK_VERSION" + fi +fi +./android/gradlew -p android :brownfieldlib:assembleRelease --no-daemon +test -n "$(find android/brownfieldlib/build/outputs/aar -name '*.aar' 2>/dev/null)" || { echo "no AAR produced"; exit 1; } +echo "::endgroup::" + +echo "Scaffold smoke PASSED" From cf64f419855e27bb314c058a36756ff353fe3866 Mon Sep 17 00:00:00 2001 From: Edu Date: Thu, 1 Oct 2026 18:24:02 +0200 Subject: [PATCH 10/20] fix: address review advisories in scaffold file writes and CI smoke brownfield.config.json was written without a trailing newline while the sibling package.json writer appends one. Since the idempotency guard compares file bytes, an external newline normalizer (editorconfig, prettier, git text eol) silently rewrote the file and made every re-run rewrite it again. Write content + '\n' and compare against the same bytes, so drift is normalized back instead of breaking the guard. Also: - assert the scaffolded caret range structurally instead of pinning the current version literal, which failed on every release bump - cover compileSdkVersion: undefined on the vanilla template variant, the exact path the RN CLI scaffold takes - pin the CI smoke assertion to the published Gradle plugin version, so drift between the constant and Maven Central fails in CI - introduce GRADLE_DIR in the smoke script to disambiguate the ./android/gradlew -p android invocation --- .../__tests__/withAndroidModuleFiles.test.ts | 22 ++++++++++++ .../scaffold/__tests__/projectFiles.test.ts | 35 +++++++++++++++++-- .../src/scaffold/projectFiles.ts | 8 ++++- scripts/ci-scaffold-smoke-android.sh | 23 ++++++++---- 4 files changed, 79 insertions(+), 9 deletions(-) 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 ea640cbe..5aa9b7a7 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 @@ -176,6 +176,28 @@ describe('createAndroidModule', () => { expect(readLibraryBuildGradle(androidDir)).toContain('targetSdk = 35'); }); + it('inherits compileSdk from the app project on the vanilla variant used by the RN CLI scaffold', () => { + const androidDir = createAndroidDir(); + + // The RN CLI scaffold (scaffold/index.ts) passes compileSdkVersion: + // undefined on purpose, so the shared template must fall back to the + // app's own rootProject.ext value instead of a hardcoded level. + createAndroidModule({ + androidDir, + config: createConfig({ + android: { + compileSdkVersion: undefined, + }, + }), + rnVersion: '0.87.1', + templateVariant: 'vanilla', + }); + + expect(readLibraryBuildGradle(androidDir)).toContain( + 'compileSdk = resolveRootProjectInt("compileSdkVersion")' + ); + }); + it('keeps targetSdk when the optional Expo SDK version is omitted', () => { const androidDir = createAndroidDir(); diff --git a/packages/react-native-brownfield/src/scaffold/__tests__/projectFiles.test.ts b/packages/react-native-brownfield/src/scaffold/__tests__/projectFiles.test.ts index 7b0c209b..55e62447 100644 --- a/packages/react-native-brownfield/src/scaffold/__tests__/projectFiles.test.ts +++ b/packages/react-native-brownfield/src/scaffold/__tests__/projectFiles.test.ts @@ -31,6 +31,18 @@ const cliSchema = cliRequire('./schema.json'); const ajv = new Ajv({ allErrors: true, allowUnionTypes: true }); const validateConfig = ajv.compile(cliSchema); +// The scaffold derives the range from this package's own version, so pinning a +// literal (e.g. '^5.1.1') would fail on every release bump. Assert the caret +// shape plus the derivation itself, using an expected value read straight from +// package.json (independent of the code under test, so this is not tautological). +const CARET_VERSION_RANGE = /^\^\d+\.\d+\.\d+/; +const ownPackageVersion = ( + JSON.parse( + fs.readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8') + ) as { version: string } +).version; +const expectedVersionRange = `^${ownPackageVersion}`; + describe('scaffold project.json file helpers', () => { let projectRoot: string; @@ -65,7 +77,14 @@ describe('scaffold project.json file helpers', () => { addBrownfieldDependencies(projectRoot); const pkg = readPackageJson(); - expect(pkg.dependencies[BROWNFIELD_RUNTIME_PACKAGE_NAME]).toBe('^5.1.1'); + expect(pkg.dependencies[BROWNFIELD_RUNTIME_PACKAGE_NAME]).toMatch( + CARET_VERSION_RANGE + ); + // Still pins the real invariant: the range is derived from this + // package's own version, so it survives release bumps. + expect(pkg.dependencies[BROWNFIELD_RUNTIME_PACKAGE_NAME]).toBe( + expectedVersionRange + ); expect(pkg.dependencies.react).toBe('19.2.3'); }); @@ -75,7 +94,14 @@ describe('scaffold project.json file helpers', () => { addBrownfieldDependencies(projectRoot); const pkg = readPackageJson(); - expect(pkg.dependencies[BROWNFIELD_CLI_PACKAGE_NAME]).toBe('^5.1.1'); + // Same derived range as the runtime package: the two are released + // together (fixed changeset group), so they must stay in lockstep. + expect(pkg.dependencies[BROWNFIELD_CLI_PACKAGE_NAME]).toMatch( + CARET_VERSION_RANGE + ); + expect(pkg.dependencies[BROWNFIELD_CLI_PACKAGE_NAME]).toBe( + pkg.dependencies[BROWNFIELD_RUNTIME_PACKAGE_NAME] + ); }); it('leaves existing versions untouched', () => { @@ -225,6 +251,11 @@ describe('scaffold project.json file helpers', () => { expect(target).toBe(configPath); const first = fs.readFileSync(configPath, 'utf8'); + // POSIX text-file convention (and what writePackageJson already does): + // external newline-normalizing tooling (editorconfig, prettier, git + // text eol) would otherwise rewrite the file and break the byte-equality + // idempotency guard below. + expect(first.endsWith('\n')).toBe(true); expect(JSON.parse(first)).toEqual( createBrownfieldFileConfig({ iosFrameworkName: 'BrownfieldLib', diff --git a/packages/react-native-brownfield/src/scaffold/projectFiles.ts b/packages/react-native-brownfield/src/scaffold/projectFiles.ts index c09b3fa5..d75568b1 100644 --- a/packages/react-native-brownfield/src/scaffold/projectFiles.ts +++ b/packages/react-native-brownfield/src/scaffold/projectFiles.ts @@ -224,7 +224,13 @@ export function writeBrownfieldFileConfig( return null; } - const content = JSON.stringify(createBrownfieldFileConfig(names), null, 2); + // Trailing newline, same as writePackageJson: the idempotency guard below + // compares bytes, so the written content must be exactly what a + // newline-normalizing toolchain (editorconfig, prettier, git text eol) + // leaves on disk. Content + '\n' is both the written bytes and the + // comparison value, so the guard cannot be broken by an external normalizer. + const content = + JSON.stringify(createBrownfieldFileConfig(names), null, 2) + '\n'; const prev = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : null; diff --git a/scripts/ci-scaffold-smoke-android.sh b/scripts/ci-scaffold-smoke-android.sh index 70ac76c1..62bdcef5 100644 --- a/scripts/ci-scaffold-smoke-android.sh +++ b/scripts/ci-scaffold-smoke-android.sh @@ -14,6 +14,8 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORKDIR="${SMOKE_WORKDIR:-$(mktemp -d "${TMPDIR:-/tmp}/brownfield-scaffold-smoke.XXXXXX")}" APP_NAME="BFSmoke" +# Android project subdirectory of the generated app (RN CLI template layout). +GRADLE_DIR="android" echo "::group::Create RN CLI app" mkdir -p "$WORKDIR" @@ -42,17 +44,23 @@ npm install --no-audit --no-fund echo "::endgroup::" echo "::group::Verify scaffold output" -test -f android/brownfieldlib/build.gradle.kts || { echo "missing brownfieldlib/build.gradle.kts"; exit 1; } +# Keep in sync with PUBLISHED_BROWNFIELD_PLUGIN_VERSION in +# packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts. +# Asserted verbatim (not just the plugin artifact name) so a drift between the +# constant and what is actually resolvable from Maven Central fails here +# instead of in a user's build. +PUBLISHED_PLUGIN_VERSION="2.0.0-alpha09" +test -f "$GRADLE_DIR/brownfieldlib/build.gradle.kts" || { echo "missing brownfieldlib/build.gradle.kts"; exit 1; } test -f brownfield.config.json || { echo "missing brownfield.config.json"; exit 1; } -grep -q "brownfield-gradle-plugin" android/build.gradle || { echo "root build.gradle lacks the Brownfield Gradle plugin classpath"; exit 1; } -grep -q "include ':brownfieldlib'" android/settings.gradle || { echo "settings.gradle lacks ':brownfieldlib'"; exit 1; } +grep -qF "brownfield-gradle-plugin:$PUBLISHED_PLUGIN_VERSION" "$GRADLE_DIR/build.gradle" || { echo "root build.gradle lacks the Brownfield Gradle plugin classpath pinned to $PUBLISHED_PLUGIN_VERSION"; exit 1; } +grep -q "include ':brownfieldlib'" "$GRADLE_DIR/settings.gradle" || { echo "settings.gradle lacks ':brownfieldlib'"; exit 1; } echo "::endgroup::" echo "::group::Assemble Android packaging module" # The freshly generated app pins its own ndkVersion in buildscript ext; install # it if missing (CI runners carry a different preinstalled set). No-op locally # when the NDK is already present. -NDK_VERSION="$(sed -nE 's/.*ndkVersion = "([^"]+)".*/\1/p' android/build.gradle | head -1)" +NDK_VERSION="$(sed -nE 's/.*ndkVersion = "([^"]+)".*/\1/p' "$GRADLE_DIR/build.gradle" | head -1)" SDKMANAGER="${ANDROID_HOME:-}/cmdline-tools/latest/bin/sdkmanager" if [ -n "$NDK_VERSION" ] && [ -n "${ANDROID_HOME:-}" ] && [ ! -d "$ANDROID_HOME/ndk/$NDK_VERSION" ]; then if [ -x "$SDKMANAGER" ]; then @@ -60,8 +68,11 @@ if [ -n "$NDK_VERSION" ] && [ -n "${ANDROID_HOME:-}" ] && [ ! -d "$ANDROID_HOME/ "$SDKMANAGER" --install "ndk;$NDK_VERSION" fi fi -./android/gradlew -p android :brownfieldlib:assembleRelease --no-daemon -test -n "$(find android/brownfieldlib/build/outputs/aar -name '*.aar' 2>/dev/null)" || { echo "no AAR produced"; exit 1; } +# `-p "$GRADLE_DIR"` sets the project dir; the `./$GRADLE_DIR/gradlew` prefix is +# the wrapper's location relative to the app root (we stay in the app root, so +# the two "android" occurrences are not a typo). +"./$GRADLE_DIR/gradlew" -p "$GRADLE_DIR" :brownfieldlib:assembleRelease --no-daemon +test -n "$(find "$GRADLE_DIR/brownfieldlib/build/outputs/aar" -name '*.aar' 2>/dev/null)" || { echo "no AAR produced"; exit 1; } echo "::endgroup::" echo "Scaffold smoke PASSED" From d402795b99cb227276cf3b24b3229d810c735ffa Mon Sep 17 00:00:00 2001 From: Edu Date: Thu, 1 Oct 2026 18:29:36 +0200 Subject: [PATCH 11/20] docs: document the automated RN CLI setup path Add a React Native CLI Integration page presenting `npx create-react-native-brownfield` as the primary path for Community CLI apps: what it changes file by file, the flag table, idempotency and fix-forward guidance, and where the platform guides resume afterwards (iOS step 6, Android step 8). The manual guides stay the fallback and now link to the automated path. Also expand the create-react-native-brownfield README from a stub into the package's own reference: usage per package manager, the full list of project mutations, options, idempotency, requirements, and the current relationship to `brownfield init` (which does not exist yet, so the wrapper is the command today). --- docs/docs/docs/getting-started/_meta.json | 2 +- docs/docs/docs/getting-started/android.mdx | 4 + docs/docs/docs/getting-started/ios.mdx | 4 + .../docs/docs/getting-started/quick-start.mdx | 11 +++ docs/docs/docs/getting-started/rnc-cli.mdx | 96 +++++++++++++++++++ .../create-react-native-brownfield/README.md | 61 ++++++++++-- 6 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 docs/docs/docs/getting-started/rnc-cli.mdx diff --git a/docs/docs/docs/getting-started/_meta.json b/docs/docs/docs/getting-started/_meta.json index f4aebd53..763f83d9 100644 --- a/docs/docs/docs/getting-started/_meta.json +++ b/docs/docs/docs/getting-started/_meta.json @@ -1 +1 @@ -["introduction", "quick-start", "ios", "android", "expo", "examples"] +["introduction", "quick-start", "rnc-cli", "ios", "android", "expo", "examples"] diff --git a/docs/docs/docs/getting-started/android.mdx b/docs/docs/docs/getting-started/android.mdx index 818fb2a6..fc7555cc 100644 --- a/docs/docs/docs/getting-started/android.mdx +++ b/docs/docs/docs/getting-started/android.mdx @@ -4,6 +4,10 @@ import { PackageManagerTabs, Tabs, Tab } from '@theme'; This guide walks you through packaging your React Native app as an **AAR** and integrating it into your native Android app. +:::tip Community CLI apps can skip steps 1-7 +For a React Native Community CLI app, `npx create-react-native-brownfield@latest` creates the library module, wires the Gradle plugin and the React Native dependencies, writes the host manager, adds the build config fields and Maven publishing, and generates `brownfield.config.json`. See [React Native CLI Integration](/docs/getting-started/rnc-cli) and continue from step 8. +::: + ## Prerequisites - React Native app with `@callstack/react-native-brownfield` installed diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index 8091c095..bffd7c1d 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -4,6 +4,10 @@ import { PackageManagerTabs } from '@theme'; This guide walks you through packaging your React Native app as an **XCFramework** and integrating it into your native iOS app. +:::tip Community CLI apps can skip steps 1-5 +For a React Native Community CLI app, `npx create-react-native-brownfield@latest` creates the framework target and its build settings, updates the `Podfile`, copies the bundle script, writes the framework interface and generates `brownfield.config.json`. See [React Native CLI Integration](/docs/getting-started/rnc-cli), then check the [static linking requirement](#static-linking-requirement) in step 2 before you run `pod install`, and continue from step 6. +::: + ## Prerequisites - React Native app with `@callstack/react-native-brownfield` installed diff --git a/docs/docs/docs/getting-started/quick-start.mdx b/docs/docs/docs/getting-started/quick-start.mdx index e73f47af..80d6a4a6 100644 --- a/docs/docs/docs/getting-started/quick-start.mdx +++ b/docs/docs/docs/getting-started/quick-start.mdx @@ -16,6 +16,16 @@ import { PackageManagerTabs } from '@theme'; The React Native Brownfield library is intended to be installed in a **React Native app** that is later consumed as a **framework artifact** by your native iOS or Android app. +:::tip React Native CLI app? +If your app uses the React Native Community CLI, one command performs this installation **and** sets up the packaging targets: + +```bash +npx create-react-native-brownfield@latest +``` + +See [React Native CLI Integration](/docs/getting-started/rnc-cli) for what it changes and how to re-run it safely. The manual steps below apply to any setup, including Expo (see [Expo Integration](/docs/getting-started/expo)). +::: + In your React Native project run: @@ -70,6 +80,7 @@ The **Brownfield CLI automates all of this** with simple commands. Now that you have the library installed, follow the platform-specific guides to package your React Native app and integrate it with your native app: +- [**React Native CLI Integration**](/docs/getting-started/rnc-cli) - Set up packaging targets automatically in a Community CLI app - [**iOS Integration**](/docs/getting-started/ios) - Package as XCFramework and integrate with your iOS app - [**Android Integration**](/docs/getting-started/android) - Package as AAR and integrate with your Android app diff --git a/docs/docs/docs/getting-started/rnc-cli.mdx b/docs/docs/docs/getting-started/rnc-cli.mdx new file mode 100644 index 00000000..6df52f79 --- /dev/null +++ b/docs/docs/docs/getting-started/rnc-cli.mdx @@ -0,0 +1,96 @@ +import { PackageManagerTabs } from '@theme'; + +# React Native CLI Integration + +This guide walks you through packaging a **React Native Community CLI** app (non-Expo) as an **XCFramework** or an **AAR**. + +If you use Expo, see [Expo Integration](/docs/getting-started/expo) instead — the Expo config plugin does this setup for you on every prebuild. + +## Automated Setup + +Run this command from the root of your React Native project: + +```bash +npx create-react-native-brownfield@latest +``` + +With a different package manager: + +```bash +yarn create react-native-brownfield +``` + +```bash +pnpm create react-native-brownfield +``` + +The command wires up everything the [Brownfield CLI](/docs/cli/brownfield) needs to produce your artifacts, so you don't have to configure the packaging targets by hand. + +### What it changes + +| Area | Change | +| --- | --- | +| `android/build.gradle` | Adds the `brownfield-gradle-plugin` classpath, pinned to the published version | +| `android/settings.gradle` | Includes the new Android library module | +| `android//` | New packaging module with `build.gradle.kts`, autolinking setup, and a `ReactNativeHostManager.kt` written for a non-Expo host | +| `ios/.xcodeproj` | New framework target with the **Bundle React Native code and images** phase copied from your app target | +| `ios/Podfile` | Nested `target ''` block with `inherit! :complete` | +| `ios//` | Framework sources (`.swift`, `Info.plist`) | +| `package.json` | Adds `@callstack/react-native-brownfield` and `@callstack/brownfield-cli` dependencies and the `package:ios` / `package:android` scripts | +| `brownfield.config.json` | Generated packaging settings: iOS scheme and configuration, Android module name and variant | + +### Options + +| Flag | Default | Purpose | +| --- | --- | --- | +| `--path ` | `.` | Project root to scaffold into | +| `--ios-framework-name ` | `BrownfieldLib` | iOS framework target name (also the framework directory name) | +| `--android-module-name ` | `brownfieldlib` | Android library module name | +| `--debug` | `false` | Verbose logging | + +:::note `brownfield init` is not available yet +The same setup will eventually be exposed as an `init` subcommand of `@callstack/brownfield-cli`. Until then, `create-react-native-brownfield` is the command to run. +::: + +### Re-running the command + +The command is **idempotent** — run it as many times as you like. It detects work it already did, so it does not duplicate targets, build phases, Podfile blocks, dependencies, or scripts. It overwrites `brownfield.config.json` with content derived from your framework and module names, so an unchanged re-run leaves the file byte-identical. + +It does edit files you own, so review the result with `git diff` before committing. Existing dependency versions and existing `package:*` scripts are never overwritten. + +:::tip If a run fails halfway +Re-run it after fixing the cause. Completed steps are detected and skipped, so you do not need to revert anything — inspect the partial state with `git diff`. +::: + +### Prerequisites + +- A React Native Community CLI project (Expo projects use the [config plugin](/docs/getting-started/expo) instead) +- Node.js 20 or newer +- CocoaPods and Xcode for iOS, Android SDK for Android + +## Package Your App + +After the setup command, install dependencies and run the packaging scripts it added: + + + + + + + +`package:ios` writes the XCFramework to `ios/.brownfield/package/build/`; `package:android` builds the Fat-AAR. Both read their settings from `brownfield.config.json`, so no flags are needed. See [Configuration files](/docs/api-reference/configuration) to customize them. + +## Integrate With Your Native App + +Embedding the artifact in your native app is a manual step, and the guides below cover it in detail — including the packaging setup itself, if you prefer to configure it by hand instead of running the command: + +- [**iOS Integration**](/docs/getting-started/ios) — package as XCFramework and integrate into your iOS app. If you ran the automated setup, steps 1, 3, 4 and 5 are already done for you (framework target with the required build settings, bundle script, framework interface, and configuration file). Step 2 is done except for the optional static-linking snippet — check whether your `Podfile` needs it before you `pod install`. Then continue from step 6, **Create the XCFramework**. +- [**Android Integration**](/docs/getting-started/android) — package as AAR and integrate into your Android app. If you ran the automated setup, steps 1–7 are already done (library module, Gradle plugin, React Native dependencies, host manager, build config fields, Maven publishing, and configuration file). Continue from step 8, **Create the AAR**. + +Both guides also describe the manual alternative in full, so you can use them to check the generated wiring or to customize it beyond what the command offers. + +## Next Steps + +- [iOS API Reference](/docs/api-reference/react-native-brownfield/swift) +- [Android API Reference](/docs/api-reference/react-native-brownfield/kotlin) +- [Guidelines](/docs/guides/guidelines) diff --git a/packages/create-react-native-brownfield/README.md b/packages/create-react-native-brownfield/README.md index 30c1bba6..1c182b7f 100644 --- a/packages/create-react-native-brownfield/README.md +++ b/packages/create-react-native-brownfield/README.md @@ -1,29 +1,74 @@ # create-react-native-brownfield -Scaffolds React Native Brownfield packaging targets in an **existing** React Native Community CLI project (non-Expo). +Scaffolds [React Native Brownfield](https://oss.callstack.com/react-native-brownfield/) packaging targets into an **existing** React Native Community CLI project (non-Expo), so that `brownfield package:ios` and `brownfield package:android` work with no manual setup. + +For Expo projects, use the [Expo config plugin](https://oss.callstack.com/react-native-brownfield/docs/getting-started/expo) instead — it performs the equivalent setup on every prebuild. ## Usage -From inside your React Native app directory: +From the root of your React Native project: ```bash npx create-react-native-brownfield@latest ``` -Or: +Or with your package manager's `create` alias: + +```bash +yarn create react-native-brownfield +``` ```bash pnpm create react-native-brownfield ``` +## What it does + +The command mutates your project in place: + +- **Android** + - `android/build.gradle` — adds the `com.callstack.react:brownfield-gradle-plugin` classpath, pinned to the latest version published on Maven Central + - `android/settings.gradle` — includes the new library module + - `android//` — creates the packaging module: `build.gradle.kts` (Brownfield plugin, autolinking, RN dependencies, build config fields, `maven-publish`) and a `ReactNativeHostManager.kt` written for a non-Expo host +- **iOS** + - `ios/.xcodeproj` — adds a framework target with the required build settings and the `Bundle React Native code and images` phase copied from your app target + - `ios/Podfile` — adds a nested `target ''` block with `inherit! :complete` + - `ios//` — creates the framework sources (`.swift`, `Info.plist`) +- **Project wiring** + - `package.json` — adds `@callstack/react-native-brownfield` and `@callstack/brownfield-cli` as dependencies and the `package:ios` / `package:android` scripts (existing versions and scripts are never overwritten) + - `brownfield.config.json` — generates the packaging settings (iOS scheme and configuration, Android module name and variant); skipped if a `brownfield.config.js` or a legacy `brownfield` key in `package.json` already exists, since the CLI accepts only one config source + +Afterwards, install dependencies and produce your artifacts: + ```bash -yarn create react-native-brownfield +npm install +npm run package:ios # XCFramework into ios/.brownfield/package/build/ +npm run package:android # Fat-AAR ``` +Integrating the produced artifacts into your native app is covered by the [iOS](https://oss.callstack.com/react-native-brownfield/docs/getting-started/ios) and [Android](https://oss.callstack.com/react-native-brownfield/docs/getting-started/android) guides (for this scaffolded path, see [React Native CLI Integration](https://oss.callstack.com/react-native-brownfield/docs/getting-started/rnc-cli)). + ## Options -- `--path `: project root (default: `.`) -- `--ios-framework-name `: iOS framework target name (default: `BrownfieldLib`) -- `--android-module-name `: Android module name (default: `brownfieldlib`) -- `--debug`: verbose logs +| Flag | Default | Purpose | +| --------------------------------- | --------------- | -------------------------------------------------------------- | +| `-p, --path ` | `.` | Path to the React Native project root | +| `--ios-framework-name ` | `BrownfieldLib` | iOS framework target name (also the framework directory name) | +| `--android-module-name ` | `brownfieldlib` | Android library module name | +| `--debug` | `false` | Verbose logging | + +## Idempotency + +The command is safe to re-run. Already-applied mutations are detected and skipped: no duplicate Xcode targets, build phases, Podfile blocks, gradle lines, dependencies, or scripts. `brownfield.config.json` is rewritten with content derived from the framework/module names, so an unchanged re-run leaves the bytes untouched. + +It edits files you own, though — review the outcome with `git diff` before committing. If a run fails halfway, fix the cause and re-run: completed steps are recognized as done, so no manual rollback is needed. + +## Requirements + +- A React Native Community CLI project (Expo projects use the config plugin instead) +- Node.js `>=20` +- iOS packaging: Xcode + CocoaPods. Android packaging: Android SDK + +## Relationship to `brownfield init` +This package is currently the full implementation, not just a wrapper: the `brownfield` CLI does not expose an `init` subcommand yet. When it does, this package will forward to it and the CLI command will become the canonical entry point. From 9b15b0d64896d0848508d426111d263a78813689 Mon Sep 17 00:00:00 2001 From: Edu Date: Fri, 2 Oct 2026 13:57:40 +0200 Subject: [PATCH 12/20] fix: keep the scaffold API off the react-native package entry The scaffold re-exports made every Metro bundle of @callstack/react-native-brownfield traverse ./scaffold and its node-only imports (node:fs, node:path, xcode, Community CLI), which can fail app bundling even when the scaffold API is never used. Node-side consumers already import it through the dedicated ./scaffold subpath export. Addresses Copilot feedback on #480. --- packages/react-native-brownfield/src/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/react-native-brownfield/src/index.ts b/packages/react-native-brownfield/src/index.ts index c1c69acc..8515861e 100644 --- a/packages/react-native-brownfield/src/index.ts +++ b/packages/react-native-brownfield/src/index.ts @@ -1,8 +1,13 @@ import { Platform } from 'react-native'; import ReactNativeBrownfieldModule from './NativeReactNativeBrownfieldModule'; -export { scaffoldBrownfieldInRncCliProject } from './scaffold'; -export type { BrownfieldScaffoldOptions } from './scaffold'; + +// NOTE: the scaffold API is intentionally NOT re-exported here. This module is +// the package's `react-native`/`source` entry point, so anything reachable from +// it is traversed by Metro when an app imports the package. The scaffold pulls +// in node:fs, node:path, xcode and the Community CLI, which cannot bundle. +// Node-side consumers import it from the dedicated subpath instead: +// `@callstack/react-native-brownfield/scaffold`. export type { BrownfieldConfig } from '@callstack/brownfield-cli/types'; From d7d3c1a403c20589a73a7ccd0af4e6a81cf0a0a8 Mon Sep 17 00:00:00 2001 From: Edu Date: Fri, 2 Oct 2026 13:57:44 +0200 Subject: [PATCH 13/20] fix: re-export ReactBrownfield from vanilla interface and honor ios.sourceDir Two iOS scaffold gaps from Copilot feedback on #480: - The generated vanilla framework interface no longer re-exported ReactBrownfield, so a host app importing only the XCFramework could not resolve ReactNativeBrownfield.shared (the manual guide's interface does export it). Restore @_exported import and pin it in the template test. - The scaffold hard-coded /ios while Android honored the CLI config. Resolve project.ios.sourceDir with the same absolute/relative handling; a custom relative sourceDir now scaffolds the right directory (verified end-to-end on a smoke app with sourceDir: ios-custom, originals byte-identical, no duplicate framework target). --- .../ios/__tests__/withIosFrameworkFiles.test.ts | 8 +++++++- .../template/ios/FrameworkInterface.vanilla.swift | 5 +++++ .../react-native-brownfield/src/scaffold/index.ts | 12 +++++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts index 9908b9f7..d7aeaee1 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts @@ -57,6 +57,13 @@ describe('getFrameworkSourceFiles', () => { 'class InternalClassForBundle {}' ); + // The native host app imports only the generated framework and then calls + // ReactNativeBrownfield.shared, so the framework must re-export the + // library's public API (same as the manual guide's interface file). + expect(frameworkInterface?.content).toContain( + '@_exported import ReactBrownfield' + ); + // Vanilla frameworks cannot resolve an Expo bundle, so neither the // identifier lookups nor the Expo modules provider may be emitted. expect(frameworkInterface?.content).not.toContain( @@ -64,7 +71,6 @@ describe('getFrameworkSourceFiles', () => { ); expect(frameworkInterface?.content).not.toContain('{{BUNDLE_IDENTIFIER}}'); expect(frameworkInterface?.content).not.toContain('Bundle(identifier:'); - expect(frameworkInterface?.content).not.toContain('ReactBrownfield'); expect(frameworkInterface?.content).not.toContain('ExpoModulesProvider'); }); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift index f3f473f3..fe4a83a3 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift +++ b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift @@ -1,5 +1,10 @@ import Foundation +// Re-export the Brownfield runtime so the native host app resolves +// ReactNativeBrownfield.shared by importing only this framework, matching the +// manual guide's interface file (docs getting-started/ios.mdx). +@_exported import ReactBrownfield + // Initializes a Bundle instance that points at the framework target. public let ReactNativeBundle = Bundle(for: InternalClassForBundle.self) diff --git a/packages/react-native-brownfield/src/scaffold/index.ts b/packages/react-native-brownfield/src/scaffold/index.ts index d38a2466..b0600778 100644 --- a/packages/react-native-brownfield/src/scaffold/index.ts +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -190,7 +190,17 @@ export async function scaffoldBrownfieldInRncCliProject( const androidDir = path.isAbsolute(android.sourceDir) ? android.sourceDir : path.join(projectRoot, android.sourceDir || 'android'); - const iosDir = path.join(projectRoot, 'ios'); + // Honour the CLI-resolved iOS source directory (symmetry with Android + // above): a project that sets project.ios.sourceDir to a custom relative + // path makes the Community CLI resolve it to an absolute directory, and + // hard-coding /ios would then miss the .xcodeproj or mutate the + // wrong directory. Note: an absolute sourceDir authored by the user is + // already rejected upstream (cli-config-apple joins it onto the project + // root), so the isAbsolute branch mainly guards the resolved value. + const iosSourceDir = ios.sourceDir; + const iosDir = path.isAbsolute(iosSourceDir) + ? iosSourceDir + : path.join(projectRoot, iosSourceDir || 'ios'); const rnVersion = resolveReactNativeVersion(projectRoot); From b0a331620e34c372525391692c3fdc8d3a6b5eb7 Mon Sep 17 00:00:00 2001 From: Edu Date: Fri, 2 Oct 2026 15:04:24 +0200 Subject: [PATCH 14/20] fix(ci): declare build outputs for create-react-native-brownfield The scaffold smoke job failed with 'Cannot find module .../dist/main.js' because the package had no turbo.json. Every other built package declares its task outputs locally; without them the task hash carries no outputs, so a restored Turbo cache hit replays logs but never restores dist/. The Setup action keys the Turbo cache on packages/*/turbo.json, so adding this file both restores dist/ on hits and invalidates the poisoned cache entries written without outputs. --- packages/create-react-native-brownfield/turbo.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 packages/create-react-native-brownfield/turbo.json diff --git a/packages/create-react-native-brownfield/turbo.json b/packages/create-react-native-brownfield/turbo.json new file mode 100644 index 00000000..4f4de66b --- /dev/null +++ b/packages/create-react-native-brownfield/turbo.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "inputs": [ + "src/**/*", + "tsconfig*.json", + "package.json" + ], + "outputs": ["dist/**"] + } + } +} From 39f4a9003cbf217d7ae3607f2b5ebd5aa34e8635 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 5 Oct 2026 10:23:04 +0200 Subject: [PATCH 15/20] revert: drop published gradle plugin version pin 2.0.0-alpha10 is now on Maven Central, so the scaffold emits BROWNFIELD_PLUGIN_VERSION again and the CI smoke reads it from constants.ts. --- docs/docs/docs/getting-started/rnc-cli.mdx | 2 +- .../create-react-native-brownfield/README.md | 2 +- .../android/__tests__/gradleHelpers.test.ts | 16 ------------ .../android/utils/constants.ts | 25 +------------------ .../android/utils/gradleHelpers.ts | 13 +++------- .../src/scaffold/index.ts | 8 +----- scripts/ci-scaffold-smoke-android.sh | 12 ++++----- 7 files changed, 12 insertions(+), 66 deletions(-) diff --git a/docs/docs/docs/getting-started/rnc-cli.mdx b/docs/docs/docs/getting-started/rnc-cli.mdx index 6df52f79..9db58258 100644 --- a/docs/docs/docs/getting-started/rnc-cli.mdx +++ b/docs/docs/docs/getting-started/rnc-cli.mdx @@ -30,7 +30,7 @@ The command wires up everything the [Brownfield CLI](/docs/cli/brownfield) needs | Area | Change | | --- | --- | -| `android/build.gradle` | Adds the `brownfield-gradle-plugin` classpath, pinned to the published version | +| `android/build.gradle` | Adds the `brownfield-gradle-plugin` classpath | | `android/settings.gradle` | Includes the new Android library module | | `android//` | New packaging module with `build.gradle.kts`, autolinking setup, and a `ReactNativeHostManager.kt` written for a non-Expo host | | `ios/.xcodeproj` | New framework target with the **Bundle React Native code and images** phase copied from your app target | diff --git a/packages/create-react-native-brownfield/README.md b/packages/create-react-native-brownfield/README.md index 1c182b7f..e2d136ba 100644 --- a/packages/create-react-native-brownfield/README.md +++ b/packages/create-react-native-brownfield/README.md @@ -27,7 +27,7 @@ pnpm create react-native-brownfield The command mutates your project in place: - **Android** - - `android/build.gradle` — adds the `com.callstack.react:brownfield-gradle-plugin` classpath, pinned to the latest version published on Maven Central + - `android/build.gradle` — adds the `com.callstack.react:brownfield-gradle-plugin` classpath - `android/settings.gradle` — includes the new library module - `android//` — creates the packaging module: `build.gradle.kts` (Brownfield plugin, autolinking, RN dependencies, build config fields, `maven-publish`) and a `ReactNativeHostManager.kt` written for a non-Expo host - **iOS** diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/gradleHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/gradleHelpers.test.ts index 5384917b..9100d096 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/gradleHelpers.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/gradleHelpers.test.ts @@ -20,22 +20,6 @@ describe('gradleHelpers', () => { ); }); - it('emits an explicit pluginVersion when provided (RN CLI scaffold path)', () => { - // The scaffold must pin a Maven Central-published version; the default - // (above) tracks the in-development plugin and is for the Expo path. - const contents = `buildscript { - dependencies { - classpath('com.android.tools.build:gradle') - } -}`; - - expect( - modifyRootBuildGradle(contents, { pluginVersion: '2.0.0-alpha09' }) - ).toContain( - 'classpath("com.callstack.react:brownfield-gradle-plugin:2.0.0-alpha09")' - ); - }); - it('adds the Brownfield module include without mutating pluginManagement', () => { const contents = `pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts index 4359c8af..c4637a05 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts @@ -1,25 +1,2 @@ export const BROWNFIELD_PLUGIN_VERSION = '2.0.0-alpha10'; - -/** - * Newest brownfield-gradle-plugin version actually published to Maven Central - * (verified against repo1.maven.org maven-metadata.xml). The version above is - * kept in sync with gradle-plugins/react/brownfield/gradle.properties - * (`yarn brownfield:plugin:version:sync`) and tracks the plugin under - * development, whose release may lag behind this package. The RN CLI scaffold - * emits this published pin instead, because a scaffolded app resolves the - * classpath from Maven Central only and an unreleased version would 404. - * Proven against a real RN 0.87.1 app: `:brownfieldlib:assembleDebug` with a - * fresh Maven Central resolution of 2.0.0-alpha09. - * TODO(release): once 2.0.0-alpha10 (or later) is on Maven Central, point this - * at it — or better, make it track BROWNFIELD_PLUGIN_VERSION again. - */ -export const PUBLISHED_BROWNFIELD_PLUGIN_VERSION = '2.0.0-alpha09'; - -export function brownfieldGradlePluginClasspath( - version: string = BROWNFIELD_PLUGIN_VERSION -): string { - return `classpath("com.callstack.react:brownfield-gradle-plugin:${version}")`; -} - -export const brownfieldGradlePluginDependency = - brownfieldGradlePluginClasspath(); +export const brownfieldGradlePluginDependency = `classpath("com.callstack.react:brownfield-gradle-plugin:${BROWNFIELD_PLUGIN_VERSION}")`; 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 2503300e..cd273720 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,6 +1,6 @@ import { BROWNFIELD_PLUGIN_VERSION, - brownfieldGradlePluginClasspath, + brownfieldGradlePluginDependency, } from './constants'; import { Logger } from '../../logging'; import { formatMissingDimensionStrategies } from './formatHelpers'; @@ -12,12 +12,6 @@ const LOCAL_MAVEN_REPOSITORY = 'mavenLocal()'; type GradleModificationOptions = { useLocalGradlePlugin?: boolean; useLocalMaven?: boolean; - /** - * Explicit brownfield-gradle-plugin version for the emitted classpath. - * Defaults to BROWNFIELD_PLUGIN_VERSION (Expo plugin behavior, unchanged); - * the RN CLI scaffold passes a Maven-Central-published version instead. - */ - pluginVersion?: string; }; const BUILD_SCRIPT_BLOCK_NAME = 'buildscript'; @@ -77,7 +71,6 @@ export function modifyRootBuildGradle( { useLocalGradlePlugin = false, useLocalMaven = false, - pluginVersion, }: GradleModificationOptions = {} ): string { let updatedContents = contents; @@ -115,8 +108,8 @@ export function modifyRootBuildGradle( } const gradlePluginDependency = useLocalMaven - ? brownfieldGradlePluginClasspath(`${BROWNFIELD_PLUGIN_VERSION}-SNAPSHOT`) - : brownfieldGradlePluginClasspath(pluginVersion); + ? `classpath("com.callstack.react:brownfield-gradle-plugin:${BROWNFIELD_PLUGIN_VERSION}-SNAPSHOT")` + : brownfieldGradlePluginDependency; // insert before the closing brace of dependencies const insertion = `\t${gradlePluginDependency}\n\t`; diff --git a/packages/react-native-brownfield/src/scaffold/index.ts b/packages/react-native-brownfield/src/scaffold/index.ts index b0600778..abb3d7fc 100644 --- a/packages/react-native-brownfield/src/scaffold/index.ts +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -20,7 +20,6 @@ import { modifyRootBuildGradle, modifySettingsGradle, } from '../expo-config-plugin/android/utils/gradleHelpers'; -import { PUBLISHED_BROWNFIELD_PLUGIN_VERSION } from '../expo-config-plugin/android/utils/constants'; import { createAndroidModule } from '../expo-config-plugin/android/withAndroidModuleFiles'; import { modifyPodfile } from '../expo-config-plugin/ios/podfileHelpers'; import { @@ -227,12 +226,7 @@ export async function scaffoldBrownfieldInRncCliProject( } writeFileIfChanged( rootBuildGradlePath, - // Pin the Maven Central-published plugin version: a scaffolded RN CLI - // app resolves this classpath from Central only, so the in-development - // BROWNFIELD_PLUGIN_VERSION (often unreleased) would 404 the build. - modifyRootBuildGradle(rootBuildGradle, { - pluginVersion: PUBLISHED_BROWNFIELD_PLUGIN_VERSION, - }) + modifyRootBuildGradle(rootBuildGradle) ); const settingsGradlePath = path.join(androidDir, 'settings.gradle'); diff --git a/scripts/ci-scaffold-smoke-android.sh b/scripts/ci-scaffold-smoke-android.sh index 62bdcef5..941e971f 100644 --- a/scripts/ci-scaffold-smoke-android.sh +++ b/scripts/ci-scaffold-smoke-android.sh @@ -44,15 +44,13 @@ npm install --no-audit --no-fund echo "::endgroup::" echo "::group::Verify scaffold output" -# Keep in sync with PUBLISHED_BROWNFIELD_PLUGIN_VERSION in -# packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts. -# Asserted verbatim (not just the plugin artifact name) so a drift between the -# constant and what is actually resolvable from Maven Central fails here -# instead of in a user's build. -PUBLISHED_PLUGIN_VERSION="2.0.0-alpha09" +# Read the expected plugin version from the same constant the scaffold emits. +PLUGIN_VERSION="$(sed -nE "s/^export const BROWNFIELD_PLUGIN_VERSION = '([^']+)';/\1/p" \ + "$REPO_ROOT/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts")" +test -n "$PLUGIN_VERSION" || { echo "could not read BROWNFIELD_PLUGIN_VERSION"; exit 1; } test -f "$GRADLE_DIR/brownfieldlib/build.gradle.kts" || { echo "missing brownfieldlib/build.gradle.kts"; exit 1; } test -f brownfield.config.json || { echo "missing brownfield.config.json"; exit 1; } -grep -qF "brownfield-gradle-plugin:$PUBLISHED_PLUGIN_VERSION" "$GRADLE_DIR/build.gradle" || { echo "root build.gradle lacks the Brownfield Gradle plugin classpath pinned to $PUBLISHED_PLUGIN_VERSION"; exit 1; } +grep -qF "brownfield-gradle-plugin:$PLUGIN_VERSION" "$GRADLE_DIR/build.gradle" || { echo "root build.gradle lacks the Brownfield Gradle plugin classpath for $PLUGIN_VERSION"; exit 1; } grep -q "include ':brownfieldlib'" "$GRADLE_DIR/settings.gradle" || { echo "settings.gradle lacks ':brownfieldlib'"; exit 1; } echo "::endgroup::" From 24c329d535360e7e0a03b3d7bf34459788dfc937 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 5 Oct 2026 10:23:33 +0200 Subject: [PATCH 16/20] refactor: scope create CLI package under @callstack Rename to @callstack/create-react-native-brownfield to match the other packages; the bin name stays create-react-native-brownfield. --- .changeset/config.json | 2 +- .changeset/plenty-owls-smoke.md | 4 +-- docs/docs/docs/getting-started/android.mdx | 2 +- docs/docs/docs/getting-started/ios.mdx | 2 +- .../docs/docs/getting-started/quick-start.mdx | 2 +- docs/docs/docs/getting-started/rnc-cli.mdx | 8 ++--- .../create-react-native-brownfield/README.md | 8 ++--- .../package.json | 2 +- .../src/scaffold/index.ts | 2 +- scripts/ci-scaffold-smoke-android.sh | 2 +- yarn.lock | 30 +++++++++---------- 11 files changed, 32 insertions(+), 32 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index e14d355e..b7bba91c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -12,7 +12,7 @@ "@callstack/brownie", "@callstack/brownfield-cli", "@callstack/react-native-brownfield", - "create-react-native-brownfield" + "@callstack/create-react-native-brownfield" ] ], "linked": [], diff --git a/.changeset/plenty-owls-smoke.md b/.changeset/plenty-owls-smoke.md index 8f63f7ba..54dd1fb5 100644 --- a/.changeset/plenty-owls-smoke.md +++ b/.changeset/plenty-owls-smoke.md @@ -1,6 +1,6 @@ --- '@callstack/react-native-brownfield': minor -'create-react-native-brownfield': minor +'@callstack/create-react-native-brownfield': minor --- -add create-react-native-brownfield: scaffold Brownfield packaging targets in an existing React Native CLI project (`npx create-react-native-brownfield`), plus the `@callstack/react-native-brownfield/scaffold` subpath export it uses +add @callstack/create-react-native-brownfield: scaffold Brownfield packaging targets in an existing React Native CLI project (`npm create @callstack/react-native-brownfield`), plus the `@callstack/react-native-brownfield/scaffold` subpath export it uses diff --git a/docs/docs/docs/getting-started/android.mdx b/docs/docs/docs/getting-started/android.mdx index fc7555cc..1cf7f5f5 100644 --- a/docs/docs/docs/getting-started/android.mdx +++ b/docs/docs/docs/getting-started/android.mdx @@ -5,7 +5,7 @@ import { PackageManagerTabs, Tabs, Tab } from '@theme'; This guide walks you through packaging your React Native app as an **AAR** and integrating it into your native Android app. :::tip Community CLI apps can skip steps 1-7 -For a React Native Community CLI app, `npx create-react-native-brownfield@latest` creates the library module, wires the Gradle plugin and the React Native dependencies, writes the host manager, adds the build config fields and Maven publishing, and generates `brownfield.config.json`. See [React Native CLI Integration](/docs/getting-started/rnc-cli) and continue from step 8. +For a React Native Community CLI app, `npm create @callstack/react-native-brownfield@latest` creates the library module, wires the Gradle plugin and the React Native dependencies, writes the host manager, adds the build config fields and Maven publishing, and generates `brownfield.config.json`. See [React Native CLI Integration](/docs/getting-started/rnc-cli) and continue from step 8. ::: ## Prerequisites diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index bffd7c1d..e2fdf96b 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -5,7 +5,7 @@ import { PackageManagerTabs } from '@theme'; This guide walks you through packaging your React Native app as an **XCFramework** and integrating it into your native iOS app. :::tip Community CLI apps can skip steps 1-5 -For a React Native Community CLI app, `npx create-react-native-brownfield@latest` creates the framework target and its build settings, updates the `Podfile`, copies the bundle script, writes the framework interface and generates `brownfield.config.json`. See [React Native CLI Integration](/docs/getting-started/rnc-cli), then check the [static linking requirement](#static-linking-requirement) in step 2 before you run `pod install`, and continue from step 6. +For a React Native Community CLI app, `npm create @callstack/react-native-brownfield@latest` creates the framework target and its build settings, updates the `Podfile`, copies the bundle script, writes the framework interface and generates `brownfield.config.json`. See [React Native CLI Integration](/docs/getting-started/rnc-cli), then check the [static linking requirement](#static-linking-requirement) in step 2 before you run `pod install`, and continue from step 6. ::: ## Prerequisites diff --git a/docs/docs/docs/getting-started/quick-start.mdx b/docs/docs/docs/getting-started/quick-start.mdx index 80d6a4a6..c35f1fa6 100644 --- a/docs/docs/docs/getting-started/quick-start.mdx +++ b/docs/docs/docs/getting-started/quick-start.mdx @@ -20,7 +20,7 @@ The React Native Brownfield library is intended to be installed in a **React Nat If your app uses the React Native Community CLI, one command performs this installation **and** sets up the packaging targets: ```bash -npx create-react-native-brownfield@latest +npm create @callstack/react-native-brownfield@latest ``` See [React Native CLI Integration](/docs/getting-started/rnc-cli) for what it changes and how to re-run it safely. The manual steps below apply to any setup, including Expo (see [Expo Integration](/docs/getting-started/expo)). diff --git a/docs/docs/docs/getting-started/rnc-cli.mdx b/docs/docs/docs/getting-started/rnc-cli.mdx index 9db58258..e605c52a 100644 --- a/docs/docs/docs/getting-started/rnc-cli.mdx +++ b/docs/docs/docs/getting-started/rnc-cli.mdx @@ -11,17 +11,17 @@ If you use Expo, see [Expo Integration](/docs/getting-started/expo) instead — Run this command from the root of your React Native project: ```bash -npx create-react-native-brownfield@latest +npm create @callstack/react-native-brownfield@latest ``` With a different package manager: ```bash -yarn create react-native-brownfield +yarn create @callstack/react-native-brownfield ``` ```bash -pnpm create react-native-brownfield +pnpm create @callstack/react-native-brownfield ``` The command wires up everything the [Brownfield CLI](/docs/cli/brownfield) needs to produce your artifacts, so you don't have to configure the packaging targets by hand. @@ -49,7 +49,7 @@ The command wires up everything the [Brownfield CLI](/docs/cli/brownfield) needs | `--debug` | `false` | Verbose logging | :::note `brownfield init` is not available yet -The same setup will eventually be exposed as an `init` subcommand of `@callstack/brownfield-cli`. Until then, `create-react-native-brownfield` is the command to run. +The same setup will eventually be exposed as an `init` subcommand of `@callstack/brownfield-cli`. Until then, `@callstack/create-react-native-brownfield` is the command to run. ::: ### Re-running the command diff --git a/packages/create-react-native-brownfield/README.md b/packages/create-react-native-brownfield/README.md index e2d136ba..ee7f0df1 100644 --- a/packages/create-react-native-brownfield/README.md +++ b/packages/create-react-native-brownfield/README.md @@ -1,4 +1,4 @@ -# create-react-native-brownfield +# @callstack/create-react-native-brownfield Scaffolds [React Native Brownfield](https://oss.callstack.com/react-native-brownfield/) packaging targets into an **existing** React Native Community CLI project (non-Expo), so that `brownfield package:ios` and `brownfield package:android` work with no manual setup. @@ -9,17 +9,17 @@ For Expo projects, use the [Expo config plugin](https://oss.callstack.com/react- From the root of your React Native project: ```bash -npx create-react-native-brownfield@latest +npm create @callstack/react-native-brownfield@latest ``` Or with your package manager's `create` alias: ```bash -yarn create react-native-brownfield +yarn create @callstack/react-native-brownfield ``` ```bash -pnpm create react-native-brownfield +pnpm create @callstack/react-native-brownfield ``` ## What it does diff --git a/packages/create-react-native-brownfield/package.json b/packages/create-react-native-brownfield/package.json index c962922b..ea2194bf 100644 --- a/packages/create-react-native-brownfield/package.json +++ b/packages/create-react-native-brownfield/package.json @@ -1,5 +1,5 @@ { - "name": "create-react-native-brownfield", + "name": "@callstack/create-react-native-brownfield", "version": "1.0.0", "license": "MIT", "author": "Callstack", diff --git a/packages/react-native-brownfield/src/scaffold/index.ts b/packages/react-native-brownfield/src/scaffold/index.ts index abb3d7fc..d312b2d8 100644 --- a/packages/react-native-brownfield/src/scaffold/index.ts +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -1,5 +1,5 @@ // Emitted as a triple-slash reference into the generated scaffold .d.ts, so -// consumers (e.g. create-react-native-brownfield) resolve the untyped `xcode` +// consumers (e.g. @callstack/create-react-native-brownfield) resolve the untyped `xcode` // import through this single declaration instead of duplicating xcode.d.ts. /// import fs from 'node:fs'; diff --git a/scripts/ci-scaffold-smoke-android.sh b/scripts/ci-scaffold-smoke-android.sh index 941e971f..0ed199be 100644 --- a/scripts/ci-scaffold-smoke-android.sh +++ b/scripts/ci-scaffold-smoke-android.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Scaffold smoke: run the built create-react-native-brownfield CLI against a +# Scaffold smoke: run the built @callstack/create-react-native-brownfield CLI against a # fresh `@react-native-community/cli init` app and build the generated Android # packaging module (Android only — no pods/xcodebuild, to stay cheap). # Catches pbxproj/gradle/Kotlin-template mistakes that unit tests cannot. diff --git a/yarn.lock b/yarn.lock index c2f2d36c..60d11728 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2127,6 +2127,21 @@ __metadata: languageName: unknown linkType: soft +"@callstack/create-react-native-brownfield@workspace:packages/create-react-native-brownfield": + version: 0.0.0-use.local + resolution: "@callstack/create-react-native-brownfield@workspace:packages/create-react-native-brownfield" + dependencies: + "@callstack/react-native-brownfield": "workspace:^" + "@types/node": "npm:^25.5.0" + commander: "npm:^14.0.3" + eslint: "npm:^9.39.3" + globals: "npm:^17.3.0" + typescript: "npm:5.9.3" + bin: + create-react-native-brownfield: dist/main.js + languageName: unknown + linkType: soft + "@callstack/react-native-brownfield@workspace:^, @callstack/react-native-brownfield@workspace:packages/react-native-brownfield": version: 0.0.0-use.local resolution: "@callstack/react-native-brownfield@workspace:packages/react-native-brownfield" @@ -10411,21 +10426,6 @@ __metadata: languageName: node linkType: hard -"create-react-native-brownfield@workspace:packages/create-react-native-brownfield": - version: 0.0.0-use.local - resolution: "create-react-native-brownfield@workspace:packages/create-react-native-brownfield" - dependencies: - "@callstack/react-native-brownfield": "workspace:^" - "@types/node": "npm:^25.5.0" - commander: "npm:^14.0.3" - eslint: "npm:^9.39.3" - globals: "npm:^17.3.0" - typescript: "npm:5.9.3" - bin: - create-react-native-brownfield: dist/main.js - languageName: unknown - linkType: soft - "create-require@npm:^1.1.0": version: 1.1.1 resolution: "create-require@npm:1.1.1" From 0c5b2bb7716c398e38e2045f0de2521dffc1c198 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 5 Oct 2026 10:24:05 +0200 Subject: [PATCH 17/20] docs: use PackageManagerTabs for the create command --- docs/docs/docs/getting-started/rnc-cli.mdx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/docs/docs/docs/getting-started/rnc-cli.mdx b/docs/docs/docs/getting-started/rnc-cli.mdx index e605c52a..9d292381 100644 --- a/docs/docs/docs/getting-started/rnc-cli.mdx +++ b/docs/docs/docs/getting-started/rnc-cli.mdx @@ -10,19 +10,7 @@ If you use Expo, see [Expo Integration](/docs/getting-started/expo) instead — Run this command from the root of your React Native project: -```bash -npm create @callstack/react-native-brownfield@latest -``` - -With a different package manager: - -```bash -yarn create @callstack/react-native-brownfield -``` - -```bash -pnpm create @callstack/react-native-brownfield -``` + The command wires up everything the [Brownfield CLI](/docs/cli/brownfield) needs to produce your artifacts, so you don't have to configure the packaging targets by hand. From 3983e1bebedbf9b804bd9b3e6a0012713aad0bef Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 5 Oct 2026 10:24:07 +0200 Subject: [PATCH 18/20] docs: trim create CLI README to point at the website --- .../create-react-native-brownfield/README.md | 65 ++----------------- 1 file changed, 4 insertions(+), 61 deletions(-) diff --git a/packages/create-react-native-brownfield/README.md b/packages/create-react-native-brownfield/README.md index ee7f0df1..5bb2badf 100644 --- a/packages/create-react-native-brownfield/README.md +++ b/packages/create-react-native-brownfield/README.md @@ -1,8 +1,6 @@ # @callstack/create-react-native-brownfield -Scaffolds [React Native Brownfield](https://oss.callstack.com/react-native-brownfield/) packaging targets into an **existing** React Native Community CLI project (non-Expo), so that `brownfield package:ios` and `brownfield package:android` work with no manual setup. - -For Expo projects, use the [Expo config plugin](https://oss.callstack.com/react-native-brownfield/docs/getting-started/expo) instead — it performs the equivalent setup on every prebuild. +Scaffolds [React Native Brownfield](https://oss.callstack.com/react-native-brownfield/) packaging targets into an existing React Native Community CLI project (non-Expo). ## Usage @@ -12,63 +10,8 @@ From the root of your React Native project: npm create @callstack/react-native-brownfield@latest ``` -Or with your package manager's `create` alias: - -```bash -yarn create @callstack/react-native-brownfield -``` - -```bash -pnpm create @callstack/react-native-brownfield -``` - -## What it does - -The command mutates your project in place: - -- **Android** - - `android/build.gradle` — adds the `com.callstack.react:brownfield-gradle-plugin` classpath - - `android/settings.gradle` — includes the new library module - - `android//` — creates the packaging module: `build.gradle.kts` (Brownfield plugin, autolinking, RN dependencies, build config fields, `maven-publish`) and a `ReactNativeHostManager.kt` written for a non-Expo host -- **iOS** - - `ios/.xcodeproj` — adds a framework target with the required build settings and the `Bundle React Native code and images` phase copied from your app target - - `ios/Podfile` — adds a nested `target ''` block with `inherit! :complete` - - `ios//` — creates the framework sources (`.swift`, `Info.plist`) -- **Project wiring** - - `package.json` — adds `@callstack/react-native-brownfield` and `@callstack/brownfield-cli` as dependencies and the `package:ios` / `package:android` scripts (existing versions and scripts are never overwritten) - - `brownfield.config.json` — generates the packaging settings (iOS scheme and configuration, Android module name and variant); skipped if a `brownfield.config.js` or a legacy `brownfield` key in `package.json` already exists, since the CLI accepts only one config source - -Afterwards, install dependencies and produce your artifacts: - -```bash -npm install -npm run package:ios # XCFramework into ios/.brownfield/package/build/ -npm run package:android # Fat-AAR -``` - -Integrating the produced artifacts into your native app is covered by the [iOS](https://oss.callstack.com/react-native-brownfield/docs/getting-started/ios) and [Android](https://oss.callstack.com/react-native-brownfield/docs/getting-started/android) guides (for this scaffolded path, see [React Native CLI Integration](https://oss.callstack.com/react-native-brownfield/docs/getting-started/rnc-cli)). - -## Options - -| Flag | Default | Purpose | -| --------------------------------- | --------------- | -------------------------------------------------------------- | -| `-p, --path ` | `.` | Path to the React Native project root | -| `--ios-framework-name ` | `BrownfieldLib` | iOS framework target name (also the framework directory name) | -| `--android-module-name ` | `brownfieldlib` | Android library module name | -| `--debug` | `false` | Verbose logging | - -## Idempotency - -The command is safe to re-run. Already-applied mutations are detected and skipped: no duplicate Xcode targets, build phases, Podfile blocks, gradle lines, dependencies, or scripts. `brownfield.config.json` is rewritten with content derived from the framework/module names, so an unchanged re-run leaves the bytes untouched. - -It edits files you own, though — review the outcome with `git diff` before committing. If a run fails halfway, fix the cause and re-run: completed steps are recognized as done, so no manual rollback is needed. - -## Requirements - -- A React Native Community CLI project (Expo projects use the config plugin instead) -- Node.js `>=20` -- iOS packaging: Xcode + CocoaPods. Android packaging: Android SDK +Expo projects use the [Expo config plugin](https://oss.callstack.com/react-native-brownfield/docs/getting-started/expo) instead. -## Relationship to `brownfield init` +## Documentation -This package is currently the full implementation, not just a wrapper: the `brownfield` CLI does not expose an `init` subcommand yet. When it does, this package will forward to it and the CLI command will become the canonical entry point. +For what the command changes, its options, and the next steps, visit [our documentation](https://oss.callstack.com/react-native-brownfield/docs/getting-started/rnc-cli). From bcaefc9e04be89c7505f6ab8cacc4a0e8a1c4a4d Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 5 Oct 2026 13:06:24 +0200 Subject: [PATCH 19/20] chore: align create-react-native-brownfield version with fixed group (5.1.1) --- packages/create-react-native-brownfield/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-react-native-brownfield/package.json b/packages/create-react-native-brownfield/package.json index ea2194bf..22896673 100644 --- a/packages/create-react-native-brownfield/package.json +++ b/packages/create-react-native-brownfield/package.json @@ -1,6 +1,6 @@ { "name": "@callstack/create-react-native-brownfield", - "version": "1.0.0", + "version": "5.1.1", "license": "MIT", "author": "Callstack", "bin": "dist/main.js", From f047f0ed19fe79e0d8c9166ed56b2af37268f058 Mon Sep 17 00:00:00 2001 From: Edu Date: Mon, 5 Oct 2026 16:42:47 +0200 Subject: [PATCH 20/20] chore: align create-react-native-brownfield version with fixed group (5.2.0) --- packages/create-react-native-brownfield/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-react-native-brownfield/package.json b/packages/create-react-native-brownfield/package.json index 22896673..af3f69c4 100644 --- a/packages/create-react-native-brownfield/package.json +++ b/packages/create-react-native-brownfield/package.json @@ -1,6 +1,6 @@ { "name": "@callstack/create-react-native-brownfield", - "version": "5.1.1", + "version": "5.2.0", "license": "MIT", "author": "Callstack", "bin": "dist/main.js",