diff --git a/.gitignore b/.gitignore index df918d12..d87b2c50 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,7 @@ packages/react-native-brownfield/ios/swiftpm/.build/ .skillgym-results/ .cursor + +# Brownfield +**/.brownfield/ +**/.brownfield_navigation_override/ \ No newline at end of file diff --git a/apps/RNApp/ios/Podfile.lock b/apps/RNApp/ios/Podfile.lock index 90fc0f1b..bfae0403 100644 --- a/apps/RNApp/ios/Podfile.lock +++ b/apps/RNApp/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - BrownfieldNavigation (3.8.1): + - BrownfieldNavigation (3.10.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -21,7 +21,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - Brownie (3.8.1): + - Brownie (3.10.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1803,7 +1803,7 @@ PODS: - ReactNativeDependencies - ReactAppDependencyProvider (0.85.0): - ReactCodegen - - ReactBrownfield (3.8.1): + - ReactBrownfield (3.10.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2174,10 +2174,10 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - BrownfieldNavigation: 6bab41701e536267009b77cf6dc6a8a509db1f4a - Brownie: c0b61f2fd916a0bcb6b4918b49aa3eebec5e1cd6 + BrownfieldNavigation: 7dd35f5096bf15980e49152cadd2c6ca0137f13a + Brownie: bb49a5d914b1e728617f7a845644a6d3a048dec4 FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b - hermes-engine: 891a8d77b6705a5c71992a8f3eaf2bfc2cfb1dda + hermes-engine: 133acc7688f66a6db232bff7de874c7129b01e1e RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257 RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64 RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a @@ -2186,7 +2186,7 @@ SPEC CHECKSUMS: React: 1b1536b9099195944034e65b1830f463caaa8390 React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5 React-Core: 00faa4d038298089a1d5a5b21dde8660c4f0820d - React-Core-prebuilt: ca4f822f6813ee31e20a965602ed5bbe611a003d + React-Core-prebuilt: 46fad9d85d53619a69cbd91fa8703ce085c73c29 React-CoreModules: a17807f849bfd86045b0b9a75ec8c19373b482f6 React-cxxreact: c7b53ace5827be54048288bce5c55f337c41e95f React-debug: 1f20c32441d0090cf67e6b966895f4ccd929d84c @@ -2246,10 +2246,10 @@ SPEC CHECKSUMS: React-utils: f747ea9fa3f4b293533ec4ef7976d1e37f004ef8 React-webperformancenativemodule: cf676ba871cc4b6ae175f75b92e8c689960c4141 ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2 - ReactBrownfield: 9acfec561aae66f2adbf6faa451a0712b17a38b2 + ReactBrownfield: efb9825ec1f9ad6fbfccc24aedf3ebd46b2e4cda ReactCodegen: d9ba64702c846111b3eeb157ea2e15aa5bb2ea55 ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360 - ReactNativeDependencies: 94375812b438d76ddfa8937a75fbb59cc0cd8fff + ReactNativeDependencies: bf5d16ce034aaabeeb0f7102f7db739529a3b910 RNScreens: 85c533985720c563272c6f6dd19e1278dfd0f5d4 Yoga: ce94692032f0a4e4ca7ed9e52a284cb208fcdbbb diff --git a/apps/RNApp/package.json b/apps/RNApp/package.json index ee760f79..b5676c80 100644 --- a/apps/RNApp/package.json +++ b/apps/RNApp/package.json @@ -7,9 +7,9 @@ "ios": "react-native run-ios", "build:example:android-rn": "react-native build-android", "build:example:ios-rn": "react-native build-ios", - "brownfield:package:android": "brownfield package:android --module-name :BrownfieldLib --variant release --verbose", - "brownfield:publish:android": "brownfield publish:android --module-name :BrownfieldLib --verbose", - "brownfield:package:ios": "brownfield package:ios --scheme BrownfieldLib --configuration Release --verbose", + "brownfield:package:android": "brownfield package:android --module-name :BrownfieldLib --variant release --verbose --output-dir ./android/.brownfield/navigation", + "brownfield:publish:android": "brownfield publish:android --module-name :BrownfieldLib --verbose --output-dir ./android/.brownfield/navigation", + "brownfield:package:ios": "brownfield package:ios --scheme BrownfieldLib --configuration Release --verbose --output-dir ./ios/.brownfield/navigation", "lint": "eslint .", "start": "react-native start", "test": "jest --config jest.config.js", diff --git a/docs/docs/docs/api-reference/brownfield-navigation/setup-and-codegen.mdx b/docs/docs/docs/api-reference/brownfield-navigation/setup-and-codegen.mdx index 6f899801..87195389 100644 --- a/docs/docs/docs/api-reference/brownfield-navigation/setup-and-codegen.mdx +++ b/docs/docs/docs/api-reference/brownfield-navigation/setup-and-codegen.mdx @@ -53,6 +53,12 @@ From your app root: npx brownfield navigation:codegen ``` +Optionally, write generated files to a custom app-local directory: + +```bash +npx brownfield navigation:codegen --output-dir .brownfield/navigation-generated +``` + ## 3) What gets generated Codegen updates `@callstack/brownfield-navigation` with your contract: @@ -74,6 +80,19 @@ npx brownfield navigation:codegen Then recompile native apps. +## 5) Maintainer workflow for packaging without dirtying package files + +If you maintain this repo (or any monorepo where `@callstack/brownfield-navigation` sources are checked in), use a custom output directory and pass it to packaging commands: + +```bash +npx brownfield navigation:codegen --output-dir .brownfield/navigation-generated +npx brownfield package:ios --scheme BrownfieldLib --output-dir .brownfield/navigation-generated +npx brownfield package:android --module-name :BrownfieldLib --output-dir .brownfield/navigation-generated +npx brownfield publish:android --module-name :BrownfieldLib --output-dir .brownfield/navigation-generated +``` + +Brownfield resolves `--output-dir` relative to your project root. If you omit it, Brownfield falls back to the default package-based generated paths. + ## Next - Continue with [Native Integration](/docs/api-reference/brownfield-navigation/native-integration) diff --git a/docs/docs/docs/cli/brownfield.mdx b/docs/docs/docs/cli/brownfield.mdx index 1e029a30..49786ae1 100644 --- a/docs/docs/docs/cli/brownfield.mdx +++ b/docs/docs/docs/cli/brownfield.mdx @@ -33,6 +33,7 @@ Available arguments: | --destination | Define destination(s) for the build. You can pass multiple destinations as separate values or repeated use of the flag. Values: "simulator", "device", or xcodebuild destinations | | --archive | Create an Xcode archive (IPA) of the build, required for uploading to App Store Connect or distributing to TestFlight | | --use-prebuilt-rn-core [bool] | Controls usage of React Native Apple prebuilt binaries for the packaging Xcode build. Omit for version-aware defaults (see [Getting Started — iOS — React Native Prebuilts](/docs/getting-started/ios#react-native-prebuilts)). Pass `true`, `false`, or use the flag without a value as shorthand for `true`. Supported only for Expo 55+ OR vanilla RN >= 0.81. | +| --output-dir [path] | Custom output directory for generated Brownfield Navigation files used during packaging. Use this to keep generated files app-local instead of mutating `packages/brownfield-navigation`. | | --no-install-pods | Skip automatic CocoaPods installation | | --no-new-arch | Run React Native in legacy async architecture | | --local | Force local build with xcodebuild | @@ -70,6 +71,7 @@ Available arguments: | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | --variant | Specify your app's build variant, which is constructed from build type and product flavor, e.g. 'debug' or 'freeRelease'. (default: "debug") | | --module-name | AAR module name | +| --output-dir [path] | Custom output directory for generated Brownfield Navigation files used during packaging. | | --verbose | Enable verbose logging | ### Publish locally for Android @@ -81,4 +83,18 @@ To publish the `.aar`(s) built beforehand with `npx brownfield publish:android` | Argument | Description | | ------------- | ---------------------- | | --module-name | AAR module name | +| --output-dir [path] | Custom output directory for generated Brownfield Navigation files used during packaging. | | --verbose | Enable verbose logging | + +## Maintainer workflow for app-local navigation output + +When packaging from a monorepo checkout, maintainers can keep `packages/brownfield-navigation` clean by generating navigation files into an app-local directory and passing the same directory to package commands. + +```bash +npx brownfield navigation:codegen --output-dir .brownfield/navigation-generated +npx brownfield package:ios --scheme BrownfieldLib --output-dir .brownfield/navigation-generated +npx brownfield package:android --module-name :BrownfieldLib --output-dir .brownfield/navigation-generated +npx brownfield publish:android --module-name :BrownfieldLib --output-dir .brownfield/navigation-generated +``` + +`--output-dir` is resolved relative to your app project root. If omitted, Brownfield keeps default behavior and writes/reads generated navigation files from `@callstack/brownfield-navigation`. diff --git a/packages/brownfield-navigation/BrownfieldNavigation.podspec b/packages/brownfield-navigation/BrownfieldNavigation.podspec index ec135e4a..37d29cd0 100644 --- a/packages/brownfield-navigation/BrownfieldNavigation.podspec +++ b/packages/brownfield-navigation/BrownfieldNavigation.podspec @@ -1,4 +1,5 @@ require 'json' +require 'shellwords' package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) @@ -13,7 +14,52 @@ Pod::Spec.new do |spec| spec.platform = :ios, "14.0" spec.source = { :git => "git@github.com:callstack/react-native-brownfield.git", :tag => "#{spec.version}" } - spec.source_files = "ios/**/*.{h,m,mm,swift}" + + navigation_ios_source_root = ENV['BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT'] + if navigation_ios_source_root && !navigation_ios_source_root.strip.empty? + resolved_navigation_ios_source_root = File.expand_path(navigation_ios_source_root, __dir__) + required_generated_source_files = [ + File.join(resolved_navigation_ios_source_root, 'NativeBrownfieldNavigation.mm'), + File.join(resolved_navigation_ios_source_root, 'BrownfieldNavigationDelegate.swift'), + ] + + missing_generated_source_files = required_generated_source_files.reject { |file| File.exist?(file) } + if missing_generated_source_files.any? + raise "[BrownfieldNavigation] Missing generated iOS source files for override: #{missing_generated_source_files.join(', ')}" + end + + override_source_files = required_generated_source_files.dup + optional_models_file = File.join( + resolved_navigation_ios_source_root, + 'BrownfieldNavigationModels.swift' + ) + override_source_files << optional_models_file if File.exist?(optional_models_file) + + override_source_dir = File.join(__dir__, 'ios', '.brownfield_navigation_override') + linked_override_source_files = override_source_files.map do |source_file| + File.join(override_source_dir, File.basename(source_file)) + end + + symlink_commands = override_source_files.zip(linked_override_source_files).map do |source_file, destination_file| + "ln -sf #{Shellwords.escape(source_file)} #{Shellwords.escape(destination_file)}" + end + + spec.prepare_command = <<-CMD + set -e + rm -rf #{Shellwords.escape(override_source_dir)} + mkdir -p #{Shellwords.escape(override_source_dir)} + #{symlink_commands.join("\n ")} + CMD + + spec.source_files = [ + 'ios/NativeBrownfieldNavigation.h', + 'ios/BrownfieldNavigationManager.swift', + *linked_override_source_files.map { |file| file.sub("#{__dir__}/", '') }, + ] + else + spec.source_files = "ios/**/*.{h,m,mm,swift}" + end + spec.pod_target_xcconfig = { # below: needed to build the XCFramework with `.swiftinterface` files, required by xcodebuild -create-xcframework to succeed 'DEFINES_MODULE' => 'YES', diff --git a/packages/brownfield-navigation/android/build.gradle b/packages/brownfield-navigation/android/build.gradle index 00e7ba33..3f17441a 100644 --- a/packages/brownfield-navigation/android/build.gradle +++ b/packages/brownfield-navigation/android/build.gradle @@ -32,6 +32,35 @@ apply plugin: "kotlin-android" apply plugin: "com.facebook.react" +def navigationAndroidGeneratedSourceDirPropertyName = + "brownfieldNavigationAndroidGeneratedSourceDir" +def navigationAndroidGeneratedSourceDir = + project.findProperty(navigationAndroidGeneratedSourceDirPropertyName)?.toString()?.trim() +def navigationGeneratedOutputRoot +def navigationGeneratedJsSourceDir + +if (navigationAndroidGeneratedSourceDir) { + navigationAndroidGeneratedSourceDir = file(navigationAndroidGeneratedSourceDir).absolutePath + if (!file(navigationAndroidGeneratedSourceDir).exists()) { + throw new GradleException( + "Configured ${navigationAndroidGeneratedSourceDirPropertyName} path does not exist: ${navigationAndroidGeneratedSourceDir}" + ) + } + + navigationGeneratedOutputRoot = file(navigationAndroidGeneratedSourceDir) + .toPath() + .resolve("../../../../") + .normalize() + .toFile() + .absolutePath + navigationGeneratedJsSourceDir = file(navigationGeneratedOutputRoot).toPath().resolve("src").normalize().toFile().absolutePath + if (!file(navigationGeneratedJsSourceDir).exists()) { + throw new GradleException( + "Configured ${navigationAndroidGeneratedSourceDirPropertyName} implies JS codegen source dir that does not exist: ${navigationGeneratedJsSourceDir}" + ) + } +} + android { namespace "com.callstack.nativebrownfieldnavigation" @@ -60,6 +89,59 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + sourceSets { + main { + if (navigationAndroidGeneratedSourceDir) { + java.srcDirs += [navigationAndroidGeneratedSourceDir] + if (hasProperty('kotlin')) { + kotlin.srcDirs += [navigationAndroidGeneratedSourceDir] + } + } + } + } +} + +if (navigationAndroidGeneratedSourceDir) { + // Keep RN codegen schema generation in sync with overridden generated artifacts. + tasks.matching { task -> + task.name == "generateCodegenSchemaFromJavaScript" + }.configureEach { task -> + task.jsRootDir.set(file(navigationGeneratedJsSourceDir)) + task.jsInputFiles.set( + fileTree(task.jsRootDir) { tree -> + tree.include("**/*.js") + tree.include("**/*.jsx") + tree.include("**/*.ts") + tree.include("**/*.tsx") + + tree.exclude("node_modules/**/*") + tree.exclude("**/*.d.ts") + tree.exclude("**/build/**/*") + } + ) + } + + def generatedFileNames = [ + "BrownfieldNavigationDelegate.kt", + "NativeBrownfieldNavigationModule.kt", + "BrownfieldNavigationModels.kt" + ] + def defaultGeneratedFilePaths = generatedFileNames.collectEntries { fileName -> + [(file("src/main/java/com/callstack/nativebrownfieldnavigation/${fileName}").absolutePath): + file("${navigationAndroidGeneratedSourceDir}/com/callstack/nativebrownfieldnavigation/${fileName}").absolutePath] + } + + // Only exclude checked-in generated sources when the override counterpart is present. + // This preserves a safe fallback when override files are missing (e.g. cleaned build dir). + tasks.matching { task -> + task.name.startsWith("compile") && task.name.endsWith("Kotlin") + }.configureEach { task -> + task.exclude { details -> + def overrideFilePath = defaultGeneratedFilePaths[details.file.absolutePath] + overrideFilePath != null && file(overrideFilePath).exists() + } + } } dependencies { diff --git a/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts b/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts index 7739d6dc..2a16857c 100644 --- a/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts +++ b/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts @@ -1,8 +1,16 @@ import { Command, Option } from 'commander'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import * as rockTools from '@rock-js/tools'; import { describe, expect, test } from 'vitest'; -import { parseUsePrebuiltRnCoreArgument } from '../packageIos.js'; +import { + BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR, + parseUsePrebuiltRnCoreArgument, + resolveNavigationIosSourceRoot, + withNavigationIosSourceRootEnv, +} from '../packageIos.js'; /** Mirrors `--use-prebuilt-rn-core` on `packageIosCommand` (preset + parser). */ function parsePackageIosArgv(argv: string[]) { @@ -64,3 +72,52 @@ describe('--use-prebuilt-rn-core (Commander)', () => { ).toBe(false); }); }); + +describe('resolveNavigationIosSourceRoot', () => { + test('returns undefined when outputDir is absent', () => { + expect(resolveNavigationIosSourceRoot('/tmp/project')).toBeUndefined(); + }); + + test('resolves outputDir relative to project root and appends ios', () => { + expect( + resolveNavigationIosSourceRoot('/tmp/project', '.brownfield/navigation') + ).toBe(path.join('/tmp/project', '.brownfield', 'navigation', 'ios')); + }); +}); + +describe('withNavigationIosSourceRootEnv', () => { + test('sets and restores env var around execution', async () => { + const tempProjectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'brownfield-ios-env-test-') + ); + const outputDir = '.brownfield/navigation'; + const iosSourceRoot = path.join(tempProjectRoot, outputDir, 'ios'); + fs.mkdirSync(iosSourceRoot, { recursive: true }); + delete process.env[BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR]; + + const seenInside = await withNavigationIosSourceRootEnv({ + projectRoot: tempProjectRoot, + outputDir, + run: async () => process.env[BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR], + }); + + expect(seenInside).toBe(iosSourceRoot); + expect( + process.env[BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR] + ).toBeUndefined(); + }); + + test('throws when custom output does not provide ios generated sources', async () => { + const tempProjectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'brownfield-ios-env-test-') + ); + + await expect( + withNavigationIosSourceRootEnv({ + projectRoot: tempProjectRoot, + outputDir: '.brownfield/missing-navigation-output', + run: async () => undefined, + }) + ).rejects.toThrow(rockTools.RockError); + }); +}); diff --git a/packages/cli/src/brownfield/commands/packageAndroid.ts b/packages/cli/src/brownfield/commands/packageAndroid.ts index 6852f3c5..10bea0b0 100644 --- a/packages/cli/src/brownfield/commands/packageAndroid.ts +++ b/packages/cli/src/brownfield/commands/packageAndroid.ts @@ -15,6 +15,11 @@ import { runExpoPrebuildIfNeeded } from '../utils/expo.js'; import { getProjectInfo } from '../utils/project.js'; import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieCodegenIfApplicable.js'; import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js'; +import { withNavigationAndroidSourceDirProperty } from '../utils/navigationAndroidSourceOverride.js'; + +type PackageAndroidCliFlags = PackageAarFlags & { + outputDir?: string; +}; export const packageAndroidCommand = curryOptions( new Command('package:android').description('Build Android AAR'), @@ -23,25 +28,37 @@ export const packageAndroidCommand = curryOptions( ? { ...option, default: 'debug' } : option ) -).action( - actionRunner(async (options: PackageAarFlags) => { - const { projectRoot, platformConfig } = getProjectInfo('android'); - await runExpoPrebuildIfNeeded({ - projectRoot, - platform: 'android', - }); +) + .option( + '--output-dir ', + 'Custom output directory for generated navigation files used during packaging' + ) + .action( + actionRunner(async (options: PackageAndroidCliFlags) => { + const { projectRoot, platformConfig } = getProjectInfo('android'); + await runExpoPrebuildIfNeeded({ + projectRoot, + platform: 'android', + }); - await runBrownieCodegenIfApplicable(projectRoot, 'kotlin'); - await runNavigationCodegenIfApplicable(projectRoot); + await runBrownieCodegenIfApplicable(projectRoot, 'kotlin'); + await runNavigationCodegenIfApplicable(projectRoot, { + outputDir: options.outputDir, + }); - await packageAarAction({ - projectRoot, - pluginConfig: platformConfig, - moduleName: options.moduleName, - variant: options.variant, - }); - }) -); + await withNavigationAndroidSourceDirProperty({ + projectRoot, + outputDir: options.outputDir, + run: async () => + packageAarAction({ + projectRoot, + pluginConfig: platformConfig, + moduleName: options.moduleName, + variant: options.variant, + }), + }); + }) + ); export const packageAndroidExample = new ExampleUsage( 'package:android --module-name :BrownfieldLib --variant release', diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index 3f8eb75c..88076f61 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -59,8 +59,64 @@ export function parseUsePrebuiltRnCoreArgument( type PackageIosCliFlags = AppleBuildFlags & { /** Set when `--use-prebuilt-rn-core` is passed; omitted when the flag is absent (Rock applies RN version defaults). */ usePrebuiltRnCore?: boolean; + /** Custom output directory for generated navigation files used during packaging. */ + outputDir?: string; }; +export const BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR = + 'BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT'; + +export function resolveNavigationIosSourceRoot( + projectRoot: string, + outputDir?: string +): string | undefined { + if (!outputDir) { + return undefined; + } + + return path.join(path.resolve(projectRoot, outputDir), 'ios'); +} + +export async function withNavigationIosSourceRootEnv({ + projectRoot, + outputDir, + run, +}: { + projectRoot: string; + outputDir?: string; + run: () => Promise; +}): Promise { + const navigationIosSourceRoot = resolveNavigationIosSourceRoot( + projectRoot, + outputDir + ); + + if (!navigationIosSourceRoot) { + return run(); + } + + if (!fs.existsSync(navigationIosSourceRoot)) { + throw new RockError( + `Navigation iOS generated sources not found at ${navigationIosSourceRoot}. Verify --output-dir points to a valid navigation codegen output root.` + ); + } + + const previousValue = process.env[BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR]; + process.env[BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR] = + navigationIosSourceRoot; + + try { + return await run(); + } finally { + if (previousValue === undefined) { + delete process.env[BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR]; + } else { + process.env[BROWNFIELD_NAVIGATION_IOS_SOURCE_ROOT_ENV_VAR] = + previousValue; + } + } +} + export const packageIosCommand = curryOptions( new Command('package:ios').description('Build iOS XCFramework'), getBuildOptions({ platformName: 'ios' }).map((option) => @@ -79,6 +135,10 @@ export const packageIosCommand = curryOptions( .preset(true) .argParser(parseUsePrebuiltRnCoreArgument) ) + .option( + '--output-dir ', + 'Custom output directory for generated navigation files used during packaging' + ) .action( actionRunner(async (options: PackageIosCliFlags) => { const { projectRoot, platformConfig, userConfig } = getProjectInfo('ios'); @@ -147,23 +207,30 @@ export const packageIosCommand = curryOptions( 'swift' ); const { hasNavigation } = - await runNavigationCodegenIfApplicable(projectRoot); - - await packageIosAction( - options, - { - projectRoot, - reactNativePath: userConfig.reactNativePath, - // below: the userConfig.reactNativeVersion may be a non-semver-format string, - // e.g. '0.82' (note the missing patch component), - // therefore we resolve it manually from RN's package.json using Rock's utils - reactNativeVersion: getReactNativeVersion(projectRoot), - packageDir, // the output directory for artifacts - skipCache: true, // cache is dependent on existence of Rock config file - usePrebuiltRNCore: options.usePrebuiltRnCore, - }, - platformConfig - ); + await runNavigationCodegenIfApplicable(projectRoot, { + outputDir: options.outputDir, + }); + + await withNavigationIosSourceRootEnv({ + projectRoot, + outputDir: options.outputDir, + run: async () => + packageIosAction( + options, + { + projectRoot, + reactNativePath: userConfig.reactNativePath, + // below: the userConfig.reactNativeVersion may be a non-semver-format string, + // e.g. '0.82' (note the missing patch component), + // therefore we resolve it manually from RN's package.json using Rock's utils + reactNativeVersion: getReactNativeVersion(projectRoot), + packageDir, // the output directory for artifacts + skipCache: true, // cache is dependent on existence of Rock config file + usePrebuiltRNCore: options.usePrebuiltRnCore, + }, + platformConfig + ), + }); const productsPath = path.join(options.buildFolder, 'Build', 'Products'); const { frameworkName, resolution, candidates } = diff --git a/packages/cli/src/brownfield/commands/publishAndroid.ts b/packages/cli/src/brownfield/commands/publishAndroid.ts index 6edb6746..e35231d7 100644 --- a/packages/cli/src/brownfield/commands/publishAndroid.ts +++ b/packages/cli/src/brownfield/commands/publishAndroid.ts @@ -15,30 +15,47 @@ import { getProjectInfo } from '../utils/project.js'; import { runExpoPrebuildIfNeeded } from '../utils/expo.js'; import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieCodegenIfApplicable.js'; import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js'; +import { withNavigationAndroidSourceDirProperty } from '../utils/navigationAndroidSourceOverride.js'; + +type PublishAndroidCliFlags = PublishLocalAarFlags & { + outputDir?: string; +}; export const publishAndroidCommand = curryOptions( new Command('publish:android').description( 'Publish Android package to Maven local' ), publishLocalAarOptions -).action( - actionRunner(async (options: PublishLocalAarFlags) => { - const { projectRoot, platformConfig } = getProjectInfo('android'); - await runExpoPrebuildIfNeeded({ - projectRoot, - platform: 'android', - }); +) + .option( + '--output-dir ', + 'Custom output directory for generated navigation files used during packaging' + ) + .action( + actionRunner(async (options: PublishAndroidCliFlags) => { + const { projectRoot, platformConfig } = getProjectInfo('android'); + await runExpoPrebuildIfNeeded({ + projectRoot, + platform: 'android', + }); - await runBrownieCodegenIfApplicable(projectRoot, 'kotlin'); - await runNavigationCodegenIfApplicable(projectRoot); + await runBrownieCodegenIfApplicable(projectRoot, 'kotlin'); + await runNavigationCodegenIfApplicable(projectRoot, { + outputDir: options.outputDir, + }); - await publishLocalAarAction({ - projectRoot, - pluginConfig: platformConfig, - moduleName: options.moduleName, - }); - }) -); + await withNavigationAndroidSourceDirProperty({ + projectRoot, + outputDir: options.outputDir, + run: async () => + publishLocalAarAction({ + projectRoot, + pluginConfig: platformConfig, + moduleName: options.moduleName, + }), + }); + }) + ); export const publishAndroidExample = new ExampleUsage( 'publish:android --module-name :BrownfieldLib', diff --git a/packages/cli/src/brownfield/utils/__tests__/navigationAndroidSourceOverride.test.ts b/packages/cli/src/brownfield/utils/__tests__/navigationAndroidSourceOverride.test.ts new file mode 100644 index 00000000..1e09db6b --- /dev/null +++ b/packages/cli/src/brownfield/utils/__tests__/navigationAndroidSourceOverride.test.ts @@ -0,0 +1,126 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import * as rockTools from '@rock-js/tools'; +import { describe, expect, test } from 'vitest'; + +import { + BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_GRADLE_PROPERTY, + resolveNavigationAndroidSourceDir, + withNavigationAndroidSourceDirProperty, +} from '../navigationAndroidSourceOverride.js'; + +const BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR = `ORG_GRADLE_PROJECT_${BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_GRADLE_PROPERTY}`; + +describe('resolveNavigationAndroidSourceDir', () => { + test('returns undefined when outputDir is absent', () => { + expect(resolveNavigationAndroidSourceDir('/tmp/project')).toBeUndefined(); + }); + + test('resolves outputDir relative to project root and appends android source path', () => { + expect( + resolveNavigationAndroidSourceDir( + '/tmp/project', + '.brownfield/navigation-output' + ) + ).toBe( + path.join( + '/tmp/project', + '.brownfield', + 'navigation-output', + 'android', + 'src', + 'main', + 'java' + ) + ); + }); +}); + +describe('withNavigationAndroidSourceDirProperty', () => { + test('runs without env override when outputDir is absent', async () => { + delete process.env[BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR]; + + const seenInside = await withNavigationAndroidSourceDirProperty({ + projectRoot: '/tmp/project', + run: async () => + process.env[BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR], + }); + + expect(seenInside).toBeUndefined(); + expect( + process.env[BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR] + ).toBeUndefined(); + }); + + test('sets and restores env var around execution', async () => { + const tempProjectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'brownfield-android-env-test-') + ); + const outputDir = '.brownfield/navigation'; + const androidSourceDir = path.join( + tempProjectRoot, + outputDir, + 'android', + 'src', + 'main', + 'java' + ); + fs.mkdirSync(androidSourceDir, { recursive: true }); + delete process.env[BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR]; + + const seenInside = await withNavigationAndroidSourceDirProperty({ + projectRoot: tempProjectRoot, + outputDir, + run: async () => + process.env[BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR], + }); + + expect(seenInside).toBe(androidSourceDir); + expect( + process.env[BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR] + ).toBeUndefined(); + }); + + test('restores previous env var value after execution', async () => { + const tempProjectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'brownfield-android-env-test-') + ); + const outputDir = '.brownfield/navigation'; + const androidSourceDir = path.join( + tempProjectRoot, + outputDir, + 'android', + 'src', + 'main', + 'java' + ); + fs.mkdirSync(androidSourceDir, { recursive: true }); + process.env[BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR] = + '/tmp/previous-value'; + + await withNavigationAndroidSourceDirProperty({ + projectRoot: tempProjectRoot, + outputDir, + run: async () => undefined, + }); + + expect(process.env[BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR]).toBe( + '/tmp/previous-value' + ); + }); + + test('throws when custom output does not provide android generated sources', async () => { + const tempProjectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'brownfield-android-env-test-') + ); + + await expect( + withNavigationAndroidSourceDirProperty({ + projectRoot: tempProjectRoot, + outputDir: '.brownfield/missing-navigation-output', + run: async () => undefined, + }) + ).rejects.toThrow(rockTools.RockError); + }); +}); diff --git a/packages/cli/src/brownfield/utils/navigationAndroidSourceOverride.ts b/packages/cli/src/brownfield/utils/navigationAndroidSourceOverride.ts new file mode 100644 index 00000000..4c3efe61 --- /dev/null +++ b/packages/cli/src/brownfield/utils/navigationAndroidSourceOverride.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { RockError } from '@rock-js/tools'; + +export const BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_GRADLE_PROPERTY = + 'brownfieldNavigationAndroidGeneratedSourceDir'; + +const BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR = `ORG_GRADLE_PROJECT_${BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_GRADLE_PROPERTY}`; + +export function resolveNavigationAndroidSourceDir( + projectRoot: string, + outputDir?: string +): string | undefined { + if (!outputDir) { + return undefined; + } + + return path.join( + path.resolve(projectRoot, outputDir), + 'android', + 'src', + 'main', + 'java' + ); +} + +export async function withNavigationAndroidSourceDirProperty({ + projectRoot, + outputDir, + run, +}: { + projectRoot: string; + outputDir?: string; + run: () => Promise; +}): Promise { + const navigationAndroidSourceDir = resolveNavigationAndroidSourceDir( + projectRoot, + outputDir + ); + + if (!navigationAndroidSourceDir) { + return run(); + } + + if (!fs.existsSync(navigationAndroidSourceDir)) { + throw new RockError( + `Navigation Android generated sources not found at ${navigationAndroidSourceDir}. Verify --output-dir points to a valid navigation codegen output root.` + ); + } + + const previousValue = + process.env[BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR]; + process.env[BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR] = + navigationAndroidSourceDir; + + try { + return await run(); + } finally { + if (previousValue === undefined) { + delete process.env[BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR]; + } else { + process.env[BROWNFIELD_NAVIGATION_ANDROID_SOURCE_DIR_ENV_VAR] = + previousValue; + } + } +} diff --git a/packages/cli/src/navigation/__tests__/runner.integration.test.ts b/packages/cli/src/navigation/__tests__/runner.integration.test.ts index a29209d7..c0efe201 100644 --- a/packages/cli/src/navigation/__tests__/runner.integration.test.ts +++ b/packages/cli/src/navigation/__tests__/runner.integration.test.ts @@ -138,4 +138,75 @@ describe('runNavigationCodegen integration', () => { 'class NativeBrownfieldNavigationModule' ); }); + + it('writes generated artifacts to custom output directory', async () => { + const { projectRoot } = createTempProject(); + tempProjectRoots.push(projectRoot); + mockedNavigationPackagePath = path.join(projectRoot, 'unused-package-root'); + + fs.writeFileSync( + path.join(projectRoot, 'brownfield.navigation.ts'), + ` + export interface BrownfieldNavigationSpec { + openScreen(route: string, params?: Object): void; + } + ` + ); + + const outputDir = 'tmp/generated-navigation'; + const outputRoot = path.join(projectRoot, outputDir); + + fs.mkdirSync(path.join(outputRoot, 'src'), { recursive: true }); + fs.mkdirSync(path.join(outputRoot, 'lib', 'commonjs'), { recursive: true }); + fs.mkdirSync(path.join(outputRoot, 'lib', 'module'), { recursive: true }); + fs.mkdirSync(path.join(outputRoot, 'lib', 'typescript', 'commonjs', 'src'), { + recursive: true, + }); + fs.mkdirSync(path.join(outputRoot, 'lib', 'typescript', 'module', 'src'), { + recursive: true, + }); + fs.mkdirSync(path.join(outputRoot, 'ios'), { recursive: true }); + fs.mkdirSync( + path.join( + outputRoot, + 'android', + 'src', + 'main', + 'java', + 'com', + 'callstack', + 'nativebrownfieldnavigation' + ), + { recursive: true } + ); + + await runNavigationCodegen({ projectRoot, outputDir }); + + expect( + fs.existsSync( + path.join(outputRoot, 'src', 'NativeBrownfieldNavigation.ts') + ) + ).toBe(true); + expect(fs.existsSync(path.join(outputRoot, 'src', 'index.ts'))).toBe(true); + expect( + fs.existsSync( + path.join(outputRoot, 'ios', 'BrownfieldNavigationDelegate.swift') + ) + ).toBe(true); + expect( + fs.existsSync( + path.join( + outputRoot, + 'android', + 'src', + 'main', + 'java', + 'com', + 'callstack', + 'nativebrownfieldnavigation', + 'NativeBrownfieldNavigationModule.kt' + ) + ) + ).toBe(true); + }); }); diff --git a/packages/cli/src/navigation/commands/codegen.ts b/packages/cli/src/navigation/commands/codegen.ts index ce0c935a..b5986067 100644 --- a/packages/cli/src/navigation/commands/codegen.ts +++ b/packages/cli/src/navigation/commands/codegen.ts @@ -6,21 +6,25 @@ import { runNavigationCodegen } from '../runner.js'; interface RunNavigationCodegenCommandOptions { dryRun?: boolean; + outputDir?: string; } interface NavigationCodegenActionOptions { specPath?: string; dryRun?: boolean; + outputDir?: string; } export async function runNavigationCodegenCommand({ specPath, dryRun = false, + outputDir, }: NavigationCodegenActionOptions): Promise { intro('Running Brownfield Navigation codegen'); await runNavigationCodegen({ specPath, dryRun, + outputDir, }); outro('Done!'); } @@ -34,6 +38,7 @@ export const navigationCodegenCommand = new Command('navigation:codegen') 'Path to navigation spec file (defaults to brownfield.navigation.ts)' ) .option('--dry-run', 'Print generated code without writing files') + .option('--output-dir ', 'Custom output directory for generated files') .action( actionRunner( async ( @@ -45,12 +50,15 @@ export const navigationCodegenCommand = new Command('navigation:codegen') ( arg ): arg is RunNavigationCodegenCommandOptions => - typeof arg === 'object' && arg !== null && 'dryRun' in arg + typeof arg === 'object' && + arg !== null && + ('dryRun' in arg || 'outputDir' in arg) ) ?? {}; await runNavigationCodegenCommand({ specPath, dryRun: Boolean(options.dryRun), + outputDir: options.outputDir, }); } ) diff --git a/packages/cli/src/navigation/helpers/runNavigationCodegenIfApplicable.ts b/packages/cli/src/navigation/helpers/runNavigationCodegenIfApplicable.ts index e0a97cc8..1a572410 100644 --- a/packages/cli/src/navigation/helpers/runNavigationCodegenIfApplicable.ts +++ b/packages/cli/src/navigation/helpers/runNavigationCodegenIfApplicable.ts @@ -2,15 +2,20 @@ import { isNavigationInstalled } from '../config.js'; import { isNavigationSpecPresent } from '../spec-discovery.js'; import { runNavigationCodegen } from '../runner.js'; +interface RunNavigationCodegenIfApplicableOptions { + specPath?: string; + outputDir?: string; +} + export async function runNavigationCodegenIfApplicable( projectRoot: string, - specPath?: string + { specPath, outputDir }: RunNavigationCodegenIfApplicableOptions = {} ): Promise<{ hasNavigation: boolean; hasSpec: boolean }> { const hasNavigation = isNavigationInstalled(projectRoot); const hasSpec = hasNavigation && isNavigationSpecPresent(specPath, projectRoot); if (hasSpec) { - await runNavigationCodegen({ specPath, projectRoot }); + await runNavigationCodegen({ specPath, projectRoot, outputDir }); } return { hasNavigation, hasSpec }; diff --git a/packages/cli/src/navigation/runner.ts b/packages/cli/src/navigation/runner.ts index 6ff76836..becb8b6c 100644 --- a/packages/cli/src/navigation/runner.ts +++ b/packages/cli/src/navigation/runner.ts @@ -29,6 +29,7 @@ interface RunNavigationCodegenOptions { specPath?: string; dryRun?: boolean; projectRoot?: string; + outputDir?: string; } interface NavigationOutputPaths { @@ -209,6 +210,7 @@ export async function runNavigationCodegen({ specPath, dryRun = false, projectRoot = process.cwd(), + outputDir, }: RunNavigationCodegenOptions): Promise { const resolvedSpecPath = resolveNavigationSpecPath(specPath, projectRoot); if (!fs.existsSync(resolvedSpecPath)) { @@ -228,7 +230,9 @@ export async function runNavigationCodegen({ `Found ${methods.length} method${methods.length === 1 ? '' : 's'}: ${methods.map((method) => method.name).join(', ')}` ); - const packageRoot = getNavigationPackagePath(projectRoot); + const packageRoot = outputDir + ? path.resolve(projectRoot, outputDir) + : getNavigationPackagePath(projectRoot); const androidJavaPackageName = DEFAULT_ANDROID_JAVA_PACKAGE; const indexTs = generateIndexTs(methods, referencedTypeDeclarations); const models = await generateNavigationModels({