diff --git a/.changeset/config.json b/.changeset/config.json index c3531772..b7bba91c 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", + "@callstack/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..54dd1fb5 --- /dev/null +++ b/.changeset/plenty-owls-smoke.md @@ -0,0 +1,6 @@ +--- +'@callstack/react-native-brownfield': minor +'@callstack/create-react-native-brownfield': minor +--- + +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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4be24aea..e1b5d77c 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/.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/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..1cf7f5f5 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, `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 - 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..e2fdf96b 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, `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 - 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..c35f1fa6 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 +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)). +::: + 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..9d292381 --- /dev/null +++ b/docs/docs/docs/getting-started/rnc-cli.mdx @@ -0,0 +1,84 @@ +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: + + + +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 | +| `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, `@callstack/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 new file mode 100644 index 00000000..5bb2badf --- /dev/null +++ b/packages/create-react-native-brownfield/README.md @@ -0,0 +1,17 @@ +# @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). + +## Usage + +From the root of your React Native project: + +```bash +npm create @callstack/react-native-brownfield@latest +``` + +Expo projects use the [Expo config plugin](https://oss.callstack.com/react-native-brownfield/docs/getting-started/expo) instead. + +## Documentation + +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). 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..af3f69c4 --- /dev/null +++ b/packages/create-react-native-brownfield/package.json @@ -0,0 +1,49 @@ +{ + "name": "@callstack/create-react-native-brownfield", + "version": "5.2.0", + "license": "MIT", + "author": "Callstack", + "bin": "dist/main.js", + "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/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/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/**"] + } + } +} diff --git a/packages/react-native-brownfield/package.json b/packages/react-native-brownfield/package.json index 786857e0..ce960044 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/__tests__/withAndroidModuleFiles.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/withAndroidModuleFiles.test.ts index 32311c4a..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(); @@ -221,6 +243,70 @@ 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('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(); @@ -459,4 +545,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/android/withAndroidModuleFiles.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts index 12f2e0cb..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 @@ -83,11 +90,22 @@ 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. + * 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'; + /** * The root Android directory path */ @@ -116,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 = @@ -157,9 +179,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/__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..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 @@ -27,5 +27,58 @@ 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 {}' + ); + + // 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( + iosConfig.bundleIdentifier + ); + expect(frameworkInterface?.content).not.toContain('{{BUNDLE_IDENTIFIER}}'); + expect(frameworkInterface?.content).not.toContain('Bundle(identifier:'); + 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..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 @@ -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,180 @@ 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', + ]); + }); + + 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', () => { + 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('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'); + 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 = { 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..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 @@ -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,18 @@ 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 frameworkTargetBlock = renderTemplate( + 'ios', + typeof expoMajor === 'number' + ? '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 +112,15 @@ export function modifyPodfile( Logger.logDebug(`Added framework target "${frameworkName}" to Podfile`); + // Vanilla projects (expoMajor === undefined) get no Expo-only hooks. + // 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 < MIN_SUPPORTED_EXPO_SDK_MAJOR_VERSION) { - const versionLabel = expoMajor < 0 ? 'unknown' : expoMajor.toString(); + 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` ); 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..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 @@ -16,14 +16,28 @@ 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. + * 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; + } ): 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 +55,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 +76,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..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 @@ -20,7 +57,10 @@ import { getFrameworkSourceFiles } from './withIosFrameworkFiles'; export function addFrameworkTarget( project: XcodeProject, modRequest: ModProps, - options: ResolvedBrownfieldPluginIosConfig + options: ResolvedBrownfieldPluginIosConfig, + brownfieldOptions?: { + useExpoHost?: boolean; + } ): { frameworkTargetUUID: string; targetAlreadyExists: boolean; @@ -28,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` @@ -131,7 +173,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 +203,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 ); @@ -171,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', @@ -613,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/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..fe4a83a3 --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift @@ -0,0 +1,12 @@ +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) + +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..8515861e 100644 --- a/packages/react-native-brownfield/src/index.ts +++ b/packages/react-native-brownfield/src/index.ts @@ -2,6 +2,13 @@ import { Platform } from 'react-native'; import ReactNativeBrownfieldModule from './NativeReactNativeBrownfieldModule'; +// 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'; export interface MessageEvent { 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..55e62447 --- /dev/null +++ b/packages/react-native-brownfield/src/scaffold/__tests__/projectFiles.test.ts @@ -0,0 +1,320 @@ +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); + +// 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; + + 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]).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'); + }); + + it('adds the brownfield CLI as a dependency when absent', () => { + writePackageJson({ name: 'app' }); + + addBrownfieldDependencies(projectRoot); + + const pkg = readPackageJson(); + // 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', () => { + 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'); + // 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', + 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 new file mode 100644 index 00000000..d312b2d8 --- /dev/null +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -0,0 +1,395 @@ +// Emitted as a triple-slash reference into the generated scaffold .d.ts, so +// 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'; +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'; + +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'; +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 +// 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 + : (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 (true) { + if (fs.existsSync(path.join(currentDir, 'package.json'))) { + return currentDir; + } + const parent = path.dirname(currentDir); + if (parent === currentDir) break; + currentDir = parent; + } + throw new Error('Could not find project root (no package.json found)'); +} + +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, + }) 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 { + // 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 = localRequire(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'); + // 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); + + 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.' + ); + } + + // 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) + ); + + 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, + // 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, + // 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', + // 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}`); + } + + 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 } + ); + + 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 }); + + // --- 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, package.json deps/scripts, brownfield.config.json) — inspect partial changes with git diff.' + ); + throw new Error( + `Brownfield scaffolding failed during step "${currentStep}": ${message}`, + { + cause: error, + } + ); + } + + Logger.logInfo('Brownfield scaffolding complete.'); +} 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..d75568b1 --- /dev/null +++ b/packages/react-native-brownfield/src/scaffold/projectFiles.ts @@ -0,0 +1,243 @@ +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; + } + + // 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; + + if (prev !== content) { + fs.writeFileSync(configPath, content, 'utf8'); + Logger.logDebug(`Wrote ${configPath}`); + } + return configPath; +} 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/scripts/ci-scaffold-smoke-android.sh b/scripts/ci-scaffold-smoke-android.sh new file mode 100644 index 00000000..0ed199be --- /dev/null +++ b/scripts/ci-scaffold-smoke-android.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# 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. +# +# 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" +# Android project subdirectory of the generated app (RN CLI template layout). +GRADLE_DIR="android" + +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" +# 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:$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::" + +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' "$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 + yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true + "$SDKMANAGER" --install "ndk;$NDK_VERSION" + fi +fi +# `-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" diff --git a/yarn.lock b/yarn.lock index 25082336..546eede0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2128,6 +2128,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" @@ -2137,6 +2152,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" @@ -2150,6 +2167,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: