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..ff6e265b --- /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 + }, + "include": ["src/**/*.ts"] +} + diff --git a/packages/react-native-brownfield/package.json b/packages/react-native-brownfield/package.json index 0bdafe04..8f868441 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": { @@ -86,7 +97,10 @@ "@expo/config-plugins": "^54.0.4" }, "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.25.2", 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 95873e2c..5447efa2 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 @@ -19,12 +19,9 @@ export function createAndroidModule({ androidDir, config, rnVersion, - isExpoPre55, + templateVariant, }: { - /** - * Whether the Expo project is pre-55 - */ - isExpoPre55: boolean; + templateVariant: 'expo-pre55' | 'expo-post55' | 'vanilla'; /** * The root Android directory path @@ -78,9 +75,11 @@ export function createAndroidModule({ relativePath: `src/main/java/${config.android.packageName.replace(/\./g, '/')}/ReactNativeHostManager.kt`, content: renderTemplate( 'android', - isExpoPre55 + templateVariant === 'expo-pre55' ? 'ReactNativeHostManager.pre55.kt' - : 'ReactNativeHostManager.post55.kt', + : templateVariant === 'expo-post55' + ? 'ReactNativeHostManager.post55.kt' + : 'ReactNativeHostManager.vanilla.kt', { '{{PACKAGE_NAME}}': android.packageName, } @@ -148,12 +147,13 @@ export const withAndroidModuleFiles: ConfigPlugin< } const { isExpoPre55 } = getExpoInfo(config); + const templateVariant = isExpoPre55 ? 'expo-pre55' : 'expo-post55'; createAndroidModule({ androidDir, config: props, rnVersion, - isExpoPre55, + templateVariant, }); return dangerousConfig; 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 0580ca6c..bf0c42c9 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 @@ -98,7 +98,7 @@ function ensureExpoDefinesForSDK55AndAbove(podfile: string): string { 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}'`)) { @@ -111,9 +111,14 @@ 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, - }); + const useExpoHost = typeof expoMajor === 'number' && expoMajor >= 0; + 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( @@ -139,11 +144,13 @@ export function modifyPodfile( Logger.logDebug(`Added framework target "${frameworkName}" to Podfile`); - if (expoMajor < 55) { - modifiedPodfile = ensureExpoPhaseOrderingHook(modifiedPodfile); - } else { - // Expo SDK >= 55 - modifiedPodfile = ensureExpoDefinesForSDK55AndAbove(modifiedPodfile); + if (useExpoHost) { + if ((expoMajor as number) < 55) { + modifiedPodfile = ensureExpoPhaseOrderingHook(modifiedPodfile); + } else { + // Expo SDK >= 55 + modifiedPodfile = ensureExpoDefinesForSDK55AndAbove(modifiedPodfile); + } } 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 3402f05b..20cf293e 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 @@ -38,7 +38,8 @@ export const withBrownfieldIos: ConfigPlugin< const { frameworkTargetUUID, targetAlreadyExists } = addFrameworkTarget( project, modRequest, - props.ios + props.ios, + { useExpoHost: true } ); if (targetAlreadyExists) { @@ -68,7 +69,9 @@ export const withBrownfieldIos: ConfigPlugin< ); } - addSourceFilesBuildPhase(project, frameworkTargetUUID, props.ios); + addSourceFilesBuildPhase(project, frameworkTargetUUID, props.ios, { + 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 46823be4..e451b345 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,12 +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', {}), + content: renderTemplate( + 'ios', + useExpoHost ? 'FrameworkInterface.swift' : 'FrameworkInterface.vanilla.swift', + {} + ), }, { relativePath: 'Info.plist', @@ -39,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 +75,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 82f577eb..ed93e6fc 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 @@ -21,7 +21,10 @@ import { renderTemplate } from '../template/engine'; export function addFrameworkTarget( project: XcodeProject, modRequest: ModProps, - options: ResolvedBrownfieldPluginIosConfig + options: ResolvedBrownfieldPluginIosConfig, + brownfieldOptions?: { + useExpoHost?: boolean; + } ): { frameworkTargetUUID: string; targetAlreadyExists: boolean; @@ -132,7 +135,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); @@ -162,9 +165,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 14024084..c0e4af56 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 interface MessageEvent { data: unknown; 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..68911614 --- /dev/null +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -0,0 +1,278 @@ +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', + }, + 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..824e3156 --- /dev/null +++ b/packages/react-native-brownfield/src/scaffold/xcode.d.ts @@ -0,0 +1,5 @@ +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 fb84082d..ed382d36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2005,6 +2005,8 @@ __metadata: "@babel/runtime": "npm:^7.25.0" "@callstack/brownfield-cli": "workspace:^" "@expo/config-plugins": "npm:^54.0.4" + "@react-native-community/cli-config": "npm:^20.0.0" + "@react-native-community/cli-types": "npm:^20.0.0" "@react-native/babel-preset": "npm:0.82.1" "@types/jest": "npm:^30.0.0" "@types/react": "npm:^19.1.1" @@ -2018,6 +2020,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.0.4 bin: @@ -8945,6 +8948,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"