From 7e7602c63c8c5c21eff76e9a98f1d6629bafc0ce Mon Sep 17 00:00:00 2001 From: Kacper Wiszczuk Date: Mon, 18 May 2026 12:59:08 +0200 Subject: [PATCH 1/6] feat: brownfield config file --- apps/RNApp/react-native-brownfield.config.js | 6 ++ packages/cli/src/brownfield/config.ts | 98 ++++++++++++++++++++ packages/cli/src/brownfield/index.ts | 3 + packages/cli/src/brownfield/schema.json | 85 +++++++++++++++++ packages/cli/src/brownfield/types.ts | 23 +++++ packages/cli/src/index.ts | 42 ++++++++- 6 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 apps/RNApp/react-native-brownfield.config.js create mode 100644 packages/cli/src/brownfield/config.ts create mode 100644 packages/cli/src/brownfield/schema.json create mode 100644 packages/cli/src/brownfield/types.ts diff --git a/apps/RNApp/react-native-brownfield.config.js b/apps/RNApp/react-native-brownfield.config.js new file mode 100644 index 00000000..eac6f863 --- /dev/null +++ b/apps/RNApp/react-native-brownfield.config.js @@ -0,0 +1,6 @@ +/** + * @typedef {import('@callstack/').BrownfieldConfig} BrownfieldConfig + */ +module.exports = { + moduleName: 'SSS', +}; diff --git a/packages/cli/src/brownfield/config.ts b/packages/cli/src/brownfield/config.ts new file mode 100644 index 00000000..e60e8b84 --- /dev/null +++ b/packages/cli/src/brownfield/config.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import type { BrownfieldConfig } from './types.js'; +import { findProjectRoot } from './utils/paths.js'; + +const CONFIG_FILE_NAMES = [ + 'react-native-brownfield.config.js', + 'react-native-brownfield.config.json', +] as const; + +const PACKAGE_JSON_CONFIG_KEY = 'react-native-brownfield'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validateConfig(value: unknown, source: string): BrownfieldConfig { + if (!isRecord(value)) { + throw new Error( + `Brownfield config in ${source} must export an object.` + ); + } + + return value as BrownfieldConfig; +} + +function normalizeModuleValue( + moduleValue: unknown, + source: string +): BrownfieldConfig { + if ( + isRecord(moduleValue) && + 'default' in moduleValue && + moduleValue.default !== undefined + ) { + return validateConfig(moduleValue.default, source); + } + + return validateConfig(moduleValue, source); +} + +function loadModuleFromFile( + require: ReturnType, + filePath: string +) { + const resolvedPath = require.resolve(filePath); + delete require.cache[resolvedPath]; + return require(resolvedPath); +} + +function loadConfigFromFile( + require: ReturnType, + filePath: string +): BrownfieldConfig { + return normalizeModuleValue( + loadModuleFromFile(require, filePath), + path.basename(filePath) + ); +} + +/** + * Loads Brownfield CLI config from project root. + * Search order: + * 1. react-native-brownfield.config.js + * 2. react-native-brownfield.config.json + * 3. package.json#react-native-brownfield + */ +export function loadConfig( + projectRoot: string = findProjectRoot() +): BrownfieldConfig { + const require = createRequire(path.join(projectRoot, 'package.json')); + + for (const fileName of CONFIG_FILE_NAMES) { + const filePath = path.join(projectRoot, fileName); + if (fs.existsSync(filePath)) { + return loadConfigFromFile(require, filePath); + } + } + + const packageJsonPath = path.join(projectRoot, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return {}; + } + + const packageJson = loadModuleFromFile(require, packageJsonPath) as Record< + string, + unknown + >; + + const packageJsonConfig = packageJson[PACKAGE_JSON_CONFIG_KEY]; + if (packageJsonConfig === undefined) { + return {}; + } + + return validateConfig(packageJsonConfig, 'package.json'); +} \ No newline at end of file diff --git a/packages/cli/src/brownfield/index.ts b/packages/cli/src/brownfield/index.ts index 55606d3f..9f9d9297 100644 --- a/packages/cli/src/brownfield/index.ts +++ b/packages/cli/src/brownfield/index.ts @@ -9,6 +9,9 @@ import { } from './commands/publishAndroid.js'; import { packageIosCommand, packageIosExample } from './commands/packageIos.js'; +export type * from './types.js'; +export * from './config.js'; + export const groupName = `${styleText(['bold', 'blueBright'], '@callstack/react-native-brownfield')}${styleText('whiteBright', ' - utilities for React Native Brownfield projects')}`; export const Commands = { diff --git a/packages/cli/src/brownfield/schema.json b/packages/cli/src/brownfield/schema.json new file mode 100644 index 00000000..9cb64fa8 --- /dev/null +++ b/packages/cli/src/brownfield/schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://unpkg.com/@callstack/brownfield-cli/brownfield.schema.json", + "title": "React Native Brownfield CLI config", + "description": "Configuration for the Brownfield packaging commands.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference for editor tooling." + }, + "verbose": { + "type": "boolean", + "description": "Enable verbose logging." + }, + "variant": { + "type": "string", + "description": "Android build variant, for example debug or freeRelease.", + "default": "debug" + }, + "moduleName": { + "type": "string", + "description": "Android AAR module name." + }, + "configuration": { + "type": "string", + "description": "Explicit iOS scheme configuration to use. This value is case sensitive." + }, + "scheme": { + "type": "string", + "description": "Explicit iOS Xcode scheme to use." + }, + "target": { + "type": "string", + "description": "Explicit iOS Xcode target to use." + }, + "extraParams": { + "type": "array", + "description": "Custom parameters passed to xcodebuild.", + "items": { + "type": "string" + } + }, + "exportExtraParams": { + "type": "array", + "description": "Custom parameters passed to xcodebuild during archive export.", + "items": { + "type": "string" + } + }, + "exportOptionsPlist": { + "type": "string", + "description": "Export options plist file name used for archiving.", + "default": "ExportOptions.plist" + }, + "buildFolder": { + "type": "string", + "description": "Location for iOS build artifacts." + }, + "destination": { + "type": "array", + "description": "iOS build destinations, such as simulator, device, or custom xcodebuild destination strings.", + "items": { + "type": "string" + } + }, + "archive": { + "type": "boolean", + "description": "Create an Xcode archive for the iOS build." + }, + "installPods": { + "type": "boolean", + "description": "Whether CocoaPods should be installed automatically. Set to false to match --no-install-pods." + }, + "newArch": { + "type": "boolean", + "description": "Whether to use the new React Native architecture. Set to false to match --no-new-arch." + }, + "local": { + "type": "boolean", + "description": "Force a local iOS build with xcodebuild." + } + } +} diff --git a/packages/cli/src/brownfield/types.ts b/packages/cli/src/brownfield/types.ts new file mode 100644 index 00000000..bd9bd7e0 --- /dev/null +++ b/packages/cli/src/brownfield/types.ts @@ -0,0 +1,23 @@ +import { + type PackageAarFlags, +} from '@rock-js/platform-android'; + +import { + type PublishLocalAarFlags, +} from '@rock-js/platform-android'; +import { + type BuildFlags as AppleBuildFlags, +} from '@rock-js/platform-apple-helpers'; + +export type BrownfieldCommonOptions = Partial<{ + verbose: boolean; +}> + +export type BrownfieldPackageAndroidOptions = BrownfieldCommonOptions & Partial +export type BrownfieldPublishAndroidOptions = BrownfieldCommonOptions & Partial +export type BrownfieldPackageIosOptions = BrownfieldCommonOptions & Partial + +export type BrownfieldAndroidConfig = Partial & Partial +export type BrownfieldIosConfig = Partial + +export type BrownfieldConfig = BrownfieldAndroidConfig & BrownfieldIosConfig diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 867a3dbb..197e4091 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,11 +2,13 @@ import { styleText } from 'node:util'; import { logger } from '@rock-js/tools'; -import { Command } from 'commander'; +import { Command, type Option } from 'commander'; import { ExampleUsage } from './shared/index.js'; import brownfieldCommands, { groupName as brownfieldCommandsGroupName, + loadConfig, + type BrownfieldConfig, } from './brownfield/index.js'; import brownieCommands, { groupName as brownieCommandsGroupName, @@ -43,6 +45,38 @@ program.configureHelp({ styleSubcommandText: (str) => styleText('blue', str), }); +function getCommandOptions(command: Command): Option[] { + return (command as Command & { options: Option[] }).options; +} + +function applyConfigValueToCommand(command: Command, key: string, value: unknown) { + const option = getCommandOptions(command).find( + (candidate) => candidate.attributeName() === key + ); + + if (!option) { + return; + } + + command.setOptionValueWithSource(key, value, 'config'); +} + +function applyBrownfieldConfigToCommands(config: BrownfieldConfig) { + for (const [key, value] of Object.entries(config)) { + if (value === undefined) { + continue; + } + + applyConfigValueToCommand(program, key, value); + + for (const command of Object.values(brownfieldCommands)) { + if (command instanceof Command) { + applyConfigValueToCommand(command, key, value); + } + } + } +} + function registrationHelper( commandsRegistration: Record, groupName: string @@ -73,6 +107,12 @@ function registrationHelper( } } +const reactNativeBrownfieldConfig = loadConfig() + +console.log('Loaded Brownfield CLI config:', reactNativeBrownfieldConfig); + +applyBrownfieldConfigToCommands(reactNativeBrownfieldConfig); + registrationHelper(brownfieldCommands, brownfieldCommandsGroupName); registrationHelper(brownieCommands, brownieCommandsGroupName); registrationHelper(navigationCommands, navigationCommandsGroupName); From 2e498231d1cd910355cbed8c28072a86b02fa049 Mon Sep 17 00:00:00 2001 From: Kacper Wiszczuk Date: Mon, 18 May 2026 13:20:17 +0200 Subject: [PATCH 2/6] fix: rewire config types --- apps/RNApp/react-native-brownfield.config.js | 5 +++-- packages/react-native-brownfield/src/index.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/RNApp/react-native-brownfield.config.js b/apps/RNApp/react-native-brownfield.config.js index eac6f863..c9f0a98f 100644 --- a/apps/RNApp/react-native-brownfield.config.js +++ b/apps/RNApp/react-native-brownfield.config.js @@ -1,6 +1,7 @@ /** - * @typedef {import('@callstack/').BrownfieldConfig} BrownfieldConfig + * @type {import('@callstack/react-native-brownfield').BrownfieldConfig} */ module.exports = { - moduleName: 'SSS', + moduleName: ':BrownfieldLib', + scheme: 'BrownfieldLib', }; diff --git a/packages/react-native-brownfield/src/index.ts b/packages/react-native-brownfield/src/index.ts index 14024084..225a7aab 100644 --- a/packages/react-native-brownfield/src/index.ts +++ b/packages/react-native-brownfield/src/index.ts @@ -2,6 +2,8 @@ import { Platform } from 'react-native'; import ReactNativeBrownfieldModule from './NativeReactNativeBrownfieldModule'; +export type { BrownfieldConfig } from '@callstack/brownfield-cli/brownfield'; + export interface MessageEvent { data: unknown; } From de2fc0408f5fd1653a14ff83fe142eb7723725fb Mon Sep 17 00:00:00 2001 From: Kacper Wiszczuk Date: Mon, 18 May 2026 14:55:32 +0200 Subject: [PATCH 3/6] feat: schema validation --- apps/RNApp/package.json | 6 +- packages/cli/package.json | 1 + packages/cli/schema.json | 70 ++++++++++++++++++++ packages/cli/src/brownfield/config.ts | 83 ++++++------------------ packages/cli/src/brownfield/schema.json | 85 ------------------------- packages/cli/src/brownfield/types.ts | 6 +- packages/cli/src/index.ts | 19 ++---- yarn.lock | 1 + 8 files changed, 106 insertions(+), 165 deletions(-) create mode 100644 packages/cli/schema.json delete mode 100644 packages/cli/src/brownfield/schema.json diff --git a/apps/RNApp/package.json b/apps/RNApp/package.json index 8ec3f40e..20d8464c 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", - "brownfield:publish:android": "brownfield publish:android --module-name :BrownfieldLib", - "brownfield:package:ios": "brownfield package:ios --scheme BrownfieldLib --configuration Release", + "brownfield:package:android": "brownfield package:android --variant release", + "brownfield:publish:android": "brownfield publish:android", + "brownfield:package:ios": "brownfield package:ios --configuration Release", "lint": "eslint .", "start": "react-native start", "test": "jest", diff --git a/packages/cli/package.json b/packages/cli/package.json index 141fd1bc..a9998814 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -72,6 +72,7 @@ "@react-native-community/cli-config-android": "*" }, "dependencies": { + "ajv": "^6.14.0", "@expo/config": "^12.0.13", "@react-native-community/cli-config": "^20.0.0", "@react-native-community/cli-config-android": "^20.0.0", diff --git a/packages/cli/schema.json b/packages/cli/schema.json new file mode 100644 index 00000000..e601cf42 --- /dev/null +++ b/packages/cli/schema.json @@ -0,0 +1,70 @@ +{ + "$ref": "#/definitions/BrownfieldConfig", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "BrownfieldConfig": { + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "archive": { + "type": "boolean" + }, + "buildFolder": { + "type": "string" + }, + "configuration": { + "type": "string" + }, + "destination": { + "items": { + "type": "string" + }, + "type": "array" + }, + "exportExtraParams": { + "items": { + "type": "string" + }, + "type": "array" + }, + "exportOptionsPlist": { + "type": "string" + }, + "extraParams": { + "items": { + "type": "string" + }, + "type": "array" + }, + "installPods": { + "type": "boolean" + }, + "local": { + "type": "boolean" + }, + "moduleName": { + "type": "string" + }, + "newArch": { + "type": "boolean" + }, + "scheme": { + "type": "string" + }, + "target": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "verbose": { + "type": "boolean" + } + }, + "type": "object" + } + } +} + diff --git a/packages/cli/src/brownfield/config.ts b/packages/cli/src/brownfield/config.ts index e60e8b84..52224b62 100644 --- a/packages/cli/src/brownfield/config.ts +++ b/packages/cli/src/brownfield/config.ts @@ -2,62 +2,27 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; +import Ajv from 'ajv'; + import type { BrownfieldConfig } from './types.js'; import { findProjectRoot } from './utils/paths.js'; -const CONFIG_FILE_NAMES = [ - 'react-native-brownfield.config.js', - 'react-native-brownfield.config.json', -] as const; +import BrownfieldSchema from '../../schema.json' with { type: 'json' }; +import { logger } from '@rock-js/tools'; +const JS_CONFIG_FILE_NAME = 'react-native-brownfield.config.js'; +const JSON_CONFIG_FILE_NAME = 'react-native-brownfield.config.json'; const PACKAGE_JSON_CONFIG_KEY = 'react-native-brownfield'; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function validateConfig(value: unknown, source: string): BrownfieldConfig { - if (!isRecord(value)) { - throw new Error( - `Brownfield config in ${source} must export an object.` - ); - } +const SEPARATOR = '\n● '; - return value as BrownfieldConfig; -} +const ajv = new Ajv({ allErrors: true }); +const validateBrownfieldConfig = ajv.compile(BrownfieldSchema); -function normalizeModuleValue( - moduleValue: unknown, - source: string -): BrownfieldConfig { - if ( - isRecord(moduleValue) && - 'default' in moduleValue && - moduleValue.default !== undefined - ) { - return validateConfig(moduleValue.default, source); +export function validateConfig(config: unknown) { + if (!validateBrownfieldConfig(config)) { + logger.warn(`Brownfield configuration has some issues: ${SEPARATOR}${ajv.errorsText(validateBrownfieldConfig.errors, { separator: SEPARATOR, dataVar: 'config' })}.`); } - - return validateConfig(moduleValue, source); -} - -function loadModuleFromFile( - require: ReturnType, - filePath: string -) { - const resolvedPath = require.resolve(filePath); - delete require.cache[resolvedPath]; - return require(resolvedPath); -} - -function loadConfigFromFile( - require: ReturnType, - filePath: string -): BrownfieldConfig { - return normalizeModuleValue( - loadModuleFromFile(require, filePath), - path.basename(filePath) - ); } /** @@ -72,27 +37,21 @@ export function loadConfig( ): BrownfieldConfig { const require = createRequire(path.join(projectRoot, 'package.json')); - for (const fileName of CONFIG_FILE_NAMES) { - const filePath = path.join(projectRoot, fileName); - if (fs.existsSync(filePath)) { - return loadConfigFromFile(require, filePath); - } + const jsConfigFilePath = path.join(projectRoot, JS_CONFIG_FILE_NAME); + if (fs.existsSync(jsConfigFilePath)) { + return require(jsConfigFilePath) as BrownfieldConfig; } - const packageJsonPath = path.join(projectRoot, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return {}; + const jsonConfigFilePath = path.join(projectRoot, JSON_CONFIG_FILE_NAME); + if (fs.existsSync(jsonConfigFilePath)) { + return require(jsonConfigFilePath) as BrownfieldConfig; } - const packageJson = loadModuleFromFile(require, packageJsonPath) as Record< + const packageJsonPath = path.join(projectRoot, 'package.json'); + const packageJson = require(packageJsonPath) as Record< string, unknown >; - const packageJsonConfig = packageJson[PACKAGE_JSON_CONFIG_KEY]; - if (packageJsonConfig === undefined) { - return {}; - } - - return validateConfig(packageJsonConfig, 'package.json'); + return packageJson[PACKAGE_JSON_CONFIG_KEY] || {}; } \ No newline at end of file diff --git a/packages/cli/src/brownfield/schema.json b/packages/cli/src/brownfield/schema.json deleted file mode 100644 index 9cb64fa8..00000000 --- a/packages/cli/src/brownfield/schema.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://unpkg.com/@callstack/brownfield-cli/brownfield.schema.json", - "title": "React Native Brownfield CLI config", - "description": "Configuration for the Brownfield packaging commands.", - "type": "object", - "additionalProperties": false, - "properties": { - "$schema": { - "type": "string", - "description": "JSON Schema reference for editor tooling." - }, - "verbose": { - "type": "boolean", - "description": "Enable verbose logging." - }, - "variant": { - "type": "string", - "description": "Android build variant, for example debug or freeRelease.", - "default": "debug" - }, - "moduleName": { - "type": "string", - "description": "Android AAR module name." - }, - "configuration": { - "type": "string", - "description": "Explicit iOS scheme configuration to use. This value is case sensitive." - }, - "scheme": { - "type": "string", - "description": "Explicit iOS Xcode scheme to use." - }, - "target": { - "type": "string", - "description": "Explicit iOS Xcode target to use." - }, - "extraParams": { - "type": "array", - "description": "Custom parameters passed to xcodebuild.", - "items": { - "type": "string" - } - }, - "exportExtraParams": { - "type": "array", - "description": "Custom parameters passed to xcodebuild during archive export.", - "items": { - "type": "string" - } - }, - "exportOptionsPlist": { - "type": "string", - "description": "Export options plist file name used for archiving.", - "default": "ExportOptions.plist" - }, - "buildFolder": { - "type": "string", - "description": "Location for iOS build artifacts." - }, - "destination": { - "type": "array", - "description": "iOS build destinations, such as simulator, device, or custom xcodebuild destination strings.", - "items": { - "type": "string" - } - }, - "archive": { - "type": "boolean", - "description": "Create an Xcode archive for the iOS build." - }, - "installPods": { - "type": "boolean", - "description": "Whether CocoaPods should be installed automatically. Set to false to match --no-install-pods." - }, - "newArch": { - "type": "boolean", - "description": "Whether to use the new React Native architecture. Set to false to match --no-new-arch." - }, - "local": { - "type": "boolean", - "description": "Force a local iOS build with xcodebuild." - } - } -} diff --git a/packages/cli/src/brownfield/types.ts b/packages/cli/src/brownfield/types.ts index bd9bd7e0..5cc7553b 100644 --- a/packages/cli/src/brownfield/types.ts +++ b/packages/cli/src/brownfield/types.ts @@ -13,6 +13,10 @@ export type BrownfieldCommonOptions = Partial<{ verbose: boolean; }> +export type BrownfieldConfigMetadata = Partial<{ + $schema: string; +}> + export type BrownfieldPackageAndroidOptions = BrownfieldCommonOptions & Partial export type BrownfieldPublishAndroidOptions = BrownfieldCommonOptions & Partial export type BrownfieldPackageIosOptions = BrownfieldCommonOptions & Partial @@ -20,4 +24,4 @@ export type BrownfieldPackageIosOptions = BrownfieldCommonOptions & Partial & Partial export type BrownfieldIosConfig = Partial -export type BrownfieldConfig = BrownfieldAndroidConfig & BrownfieldIosConfig +export type BrownfieldConfig = BrownfieldConfigMetadata & BrownfieldCommonOptions & BrownfieldAndroidConfig & BrownfieldIosConfig diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 197e4091..e29ab843 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,12 +2,13 @@ import { styleText } from 'node:util'; import { logger } from '@rock-js/tools'; -import { Command, type Option } from 'commander'; +import { Command } from 'commander'; import { ExampleUsage } from './shared/index.js'; import brownfieldCommands, { groupName as brownfieldCommandsGroupName, loadConfig, + validateConfig, type BrownfieldConfig, } from './brownfield/index.js'; import brownieCommands, { @@ -45,19 +46,7 @@ program.configureHelp({ styleSubcommandText: (str) => styleText('blue', str), }); -function getCommandOptions(command: Command): Option[] { - return (command as Command & { options: Option[] }).options; -} - function applyConfigValueToCommand(command: Command, key: string, value: unknown) { - const option = getCommandOptions(command).find( - (candidate) => candidate.attributeName() === key - ); - - if (!option) { - return; - } - command.setOptionValueWithSource(key, value, 'config'); } @@ -109,7 +98,9 @@ function registrationHelper( const reactNativeBrownfieldConfig = loadConfig() -console.log('Loaded Brownfield CLI config:', reactNativeBrownfieldConfig); +validateConfig(reactNativeBrownfieldConfig); + +console.debug('Loaded Brownfield config:', reactNativeBrownfieldConfig); applyBrownfieldConfigToCommands(reactNativeBrownfieldConfig); diff --git a/yarn.lock b/yarn.lock index fb84082d..9934a05c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1766,6 +1766,7 @@ __metadata: "@types/babel__preset-env": "npm:^7.10.0" "@types/node": "npm:^25.5.0" "@vitest/coverage-v8": "npm:^4.1.0" + ajv: "npm:^6.14.0" commander: "npm:^14.0.3" eslint: "npm:^9.39.3" globals: "npm:^17.3.0" From a54bf4afcef4e934a08fa9ec491965b02ef5597f Mon Sep 17 00:00:00 2001 From: Kacper Wiszczuk Date: Mon, 18 May 2026 15:09:19 +0200 Subject: [PATCH 4/6] fix: apply and log configuration --- packages/cli/src/index.ts | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e29ab843..425df29a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,7 +9,6 @@ import brownfieldCommands, { groupName as brownfieldCommandsGroupName, loadConfig, validateConfig, - type BrownfieldConfig, } from './brownfield/index.js'; import brownieCommands, { groupName as brownieCommandsGroupName, @@ -46,23 +45,15 @@ program.configureHelp({ styleSubcommandText: (str) => styleText('blue', str), }); -function applyConfigValueToCommand(command: Command, key: string, value: unknown) { - command.setOptionValueWithSource(key, value, 'config'); -} +function applyBrownfieldCLIConfig() { + const reactNativeBrownfieldConfig = loadConfig() -function applyBrownfieldConfigToCommands(config: BrownfieldConfig) { - for (const [key, value] of Object.entries(config)) { - if (value === undefined) { - continue; - } + logger.debug('Loaded Brownfield CLI config:', reactNativeBrownfieldConfig); - applyConfigValueToCommand(program, key, value); + validateConfig(reactNativeBrownfieldConfig); - for (const command of Object.values(brownfieldCommands)) { - if (command instanceof Command) { - applyConfigValueToCommand(command, key, value); - } - } + for (const [key, value] of Object.entries(reactNativeBrownfieldConfig)) { + program.setOptionValueWithSource(key, value, 'config'); } } @@ -96,14 +87,6 @@ function registrationHelper( } } -const reactNativeBrownfieldConfig = loadConfig() - -validateConfig(reactNativeBrownfieldConfig); - -console.debug('Loaded Brownfield config:', reactNativeBrownfieldConfig); - -applyBrownfieldConfigToCommands(reactNativeBrownfieldConfig); - registrationHelper(brownfieldCommands, brownfieldCommandsGroupName); registrationHelper(brownieCommands, brownieCommandsGroupName); registrationHelper(navigationCommands, navigationCommandsGroupName); @@ -113,6 +96,8 @@ program.commandsGroup('Utility commands').helpCommand('help [command]'); export function runCLI(argv: string[]): void { program.parse(argv); + applyBrownfieldCLIConfig() + if (!argv.slice(2).length) { program.outputHelp(); } From 64fb67d71ffe6c61842137c5b566418d391e4351 Mon Sep 17 00:00:00 2001 From: Kacper Wiszczuk Date: Tue, 19 May 2026 11:10:25 +0200 Subject: [PATCH 5/6] chore: move files and export types --- packages/cli/package.json | 7 ++++- packages/cli/src/brownfield/index.ts | 3 -- packages/cli/src/{brownfield => }/config.ts | 29 ++++++++++++------- packages/cli/src/index.ts | 17 ++--------- packages/cli/src/{brownfield => }/types.ts | 0 packages/react-native-brownfield/src/index.ts | 2 +- 6 files changed, 27 insertions(+), 31 deletions(-) rename packages/cli/src/{brownfield => }/config.ts (69%) rename packages/cli/src/{brownfield => }/types.ts (100%) diff --git a/packages/cli/package.json b/packages/cli/package.json index a9998814..1704f2b1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,6 +37,11 @@ "types": "./dist/navigation/index.d.ts", "default": "./dist/navigation/index.js" }, + "./types": { + "source": "./src/types.ts", + "types": "./dist/types.d.ts", + "default": "./dist/types.js" + }, "./package.json": "./package.json" }, "scripts": { @@ -72,7 +77,6 @@ "@react-native-community/cli-config-android": "*" }, "dependencies": { - "ajv": "^6.14.0", "@expo/config": "^12.0.13", "@react-native-community/cli-config": "^20.0.0", "@react-native-community/cli-config-android": "^20.0.0", @@ -81,6 +85,7 @@ "@rock-js/plugin-brownfield-android": "^0.12.12", "@rock-js/plugin-brownfield-ios": "^0.12.12", "@rock-js/tools": "^0.12.12", + "ajv": "^6.14.0", "commander": "^14.0.3", "quicktype-core": "^23.2.6", "quicktype-typescript-input": "^23.2.6", diff --git a/packages/cli/src/brownfield/index.ts b/packages/cli/src/brownfield/index.ts index 9f9d9297..55606d3f 100644 --- a/packages/cli/src/brownfield/index.ts +++ b/packages/cli/src/brownfield/index.ts @@ -9,9 +9,6 @@ import { } from './commands/publishAndroid.js'; import { packageIosCommand, packageIosExample } from './commands/packageIos.js'; -export type * from './types.js'; -export * from './config.js'; - export const groupName = `${styleText(['bold', 'blueBright'], '@callstack/react-native-brownfield')}${styleText('whiteBright', ' - utilities for React Native Brownfield projects')}`; export const Commands = { diff --git a/packages/cli/src/brownfield/config.ts b/packages/cli/src/config.ts similarity index 69% rename from packages/cli/src/brownfield/config.ts rename to packages/cli/src/config.ts index 52224b62..4cbb2aca 100644 --- a/packages/cli/src/brownfield/config.ts +++ b/packages/cli/src/config.ts @@ -5,10 +5,11 @@ import path from 'node:path'; import Ajv from 'ajv'; import type { BrownfieldConfig } from './types.js'; -import { findProjectRoot } from './utils/paths.js'; +import { findProjectRoot } from './brownfield/utils/paths.js'; -import BrownfieldSchema from '../../schema.json' with { type: 'json' }; +import BrownfieldSchema from '../schema.json' with { type: 'json' }; import { logger } from '@rock-js/tools'; +import { Command } from 'commander'; const JS_CONFIG_FILE_NAME = 'react-native-brownfield.config.js'; const JSON_CONFIG_FILE_NAME = 'react-native-brownfield.config.json'; @@ -19,20 +20,13 @@ const SEPARATOR = '\n● '; const ajv = new Ajv({ allErrors: true }); const validateBrownfieldConfig = ajv.compile(BrownfieldSchema); -export function validateConfig(config: unknown) { +function validateConfig(config: unknown) { if (!validateBrownfieldConfig(config)) { logger.warn(`Brownfield configuration has some issues: ${SEPARATOR}${ajv.errorsText(validateBrownfieldConfig.errors, { separator: SEPARATOR, dataVar: 'config' })}.`); } } -/** - * Loads Brownfield CLI config from project root. - * Search order: - * 1. react-native-brownfield.config.js - * 2. react-native-brownfield.config.json - * 3. package.json#react-native-brownfield - */ -export function loadConfig( +function loadBrownfieldConfig( projectRoot: string = findProjectRoot() ): BrownfieldConfig { const require = createRequire(path.join(projectRoot, 'package.json')); @@ -54,4 +48,17 @@ export function loadConfig( >; return packageJson[PACKAGE_JSON_CONFIG_KEY] || {}; +} + + +export function loadAndApplyBrownfieldCLIConfig(program: Command) { + const reactNativeBrownfieldConfig = loadBrownfieldConfig() + + logger.debug('Loaded Brownfield CLI config:', reactNativeBrownfieldConfig); + + validateConfig(reactNativeBrownfieldConfig); + + for (const [key, value] of Object.entries(reactNativeBrownfieldConfig)) { + program.setOptionValueWithSource(key, value, 'config'); + } } \ No newline at end of file diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 425df29a..682a48b3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,8 +7,6 @@ import { Command } from 'commander'; import { ExampleUsage } from './shared/index.js'; import brownfieldCommands, { groupName as brownfieldCommandsGroupName, - loadConfig, - validateConfig, } from './brownfield/index.js'; import brownieCommands, { groupName as brownieCommandsGroupName, @@ -16,6 +14,7 @@ import brownieCommands, { import navigationCommands, { groupName as navigationCommandsGroupName, } from './navigation/index.js'; +import { loadAndApplyBrownfieldCLIConfig } from './config.js'; const program = new Command(); @@ -45,18 +44,6 @@ program.configureHelp({ styleSubcommandText: (str) => styleText('blue', str), }); -function applyBrownfieldCLIConfig() { - const reactNativeBrownfieldConfig = loadConfig() - - logger.debug('Loaded Brownfield CLI config:', reactNativeBrownfieldConfig); - - validateConfig(reactNativeBrownfieldConfig); - - for (const [key, value] of Object.entries(reactNativeBrownfieldConfig)) { - program.setOptionValueWithSource(key, value, 'config'); - } -} - function registrationHelper( commandsRegistration: Record, groupName: string @@ -96,7 +83,7 @@ program.commandsGroup('Utility commands').helpCommand('help [command]'); export function runCLI(argv: string[]): void { program.parse(argv); - applyBrownfieldCLIConfig() + loadAndApplyBrownfieldCLIConfig(program); if (!argv.slice(2).length) { program.outputHelp(); diff --git a/packages/cli/src/brownfield/types.ts b/packages/cli/src/types.ts similarity index 100% rename from packages/cli/src/brownfield/types.ts rename to packages/cli/src/types.ts diff --git a/packages/react-native-brownfield/src/index.ts b/packages/react-native-brownfield/src/index.ts index 225a7aab..457f69b3 100644 --- a/packages/react-native-brownfield/src/index.ts +++ b/packages/react-native-brownfield/src/index.ts @@ -2,7 +2,7 @@ import { Platform } from 'react-native'; import ReactNativeBrownfieldModule from './NativeReactNativeBrownfieldModule'; -export type { BrownfieldConfig } from '@callstack/brownfield-cli/brownfield'; +export type { BrownfieldConfig } from '@callstack/brownfield-cli/types'; export interface MessageEvent { data: unknown; From 04a9b05d8e0722d805907e7d7f1e1015b50037e3 Mon Sep 17 00:00:00 2001 From: Kacper Wiszczuk Date: Tue, 19 May 2026 11:35:30 +0200 Subject: [PATCH 6/6] chore: add unit tests and export schema.json --- packages/cli/package.json | 3 +- packages/cli/src/__tests__/config.test.ts | 190 ++++++++++++++++++++++ packages/cli/src/config.ts | 26 ++- 3 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/__tests__/config.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 1704f2b1..756eeca0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -67,7 +67,8 @@ "!**/__fixtures__", "!**/__mocks__", "!**/.*", - "README.md" + "README.md", + "schema.json" ], "publishConfig": { "access": "public" diff --git a/packages/cli/src/__tests__/config.test.ts b/packages/cli/src/__tests__/config.test.ts new file mode 100644 index 00000000..4c54f049 --- /dev/null +++ b/packages/cli/src/__tests__/config.test.ts @@ -0,0 +1,190 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import * as rockTools from '@rock-js/tools'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it, Mock, vi } from 'vitest'; + +import { + applyBrownfieldCLIConfig, + loadAndApplyBrownfieldCLIConfig, + loadBrownfieldConfig, + validateBrownfieldCLIConfig, +} from '../config.js'; + +vi.mock('@rock-js/tools', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + logger: { + ...actual.logger, + debug: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +const mockLoggerWarn = rockTools.logger.warn as Mock; + +function createTempProject(options?: { + packageJsonConfig?: Record; + jsConfig?: Record; + jsonConfig?: Record; +}): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'brownfield-cli-config-')); + + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify( + { + name: 'brownfield-config-test', + version: '1.0.0', + 'react-native-brownfield': options?.packageJsonConfig, + }, + null, + 2 + ) + ); + + if (options?.jsConfig) { + fs.writeFileSync( + path.join(tempDir, 'react-native-brownfield.config.js'), + `module.exports = ${JSON.stringify(options.jsConfig, null, 2)};\n` + ); + } + + if (options?.jsonConfig) { + fs.writeFileSync( + path.join(tempDir, 'react-native-brownfield.config.json'), + JSON.stringify(options.jsonConfig, null, 2) + ); + } + + return tempDir; +} + +function cleanupTempDir(directory: string): void { + fs.rmSync(directory, { recursive: true, force: true }); +} + +describe('loadBrownfieldConfig', () => { + let tempDir: string | null = null; + + afterEach(() => { + mockLoggerWarn.mockReset(); + + if (tempDir) { + cleanupTempDir(tempDir); + tempDir = null; + } + }); + + it('prefers js config over json and package.json', () => { + tempDir = createTempProject({ + packageJsonConfig: { verbose: false, variant: 'package-json' }, + jsonConfig: { verbose: false, variant: 'json' }, + jsConfig: { verbose: true, variant: 'js' }, + }); + + expect(loadBrownfieldConfig(tempDir)).toEqual({ + verbose: true, + variant: 'js', + }); + }); + + it('prefers json config over package.json when js config is missing', () => { + tempDir = createTempProject({ + packageJsonConfig: { verbose: false, variant: 'package-json' }, + jsonConfig: { verbose: true, variant: 'json' }, + }); + + expect(loadBrownfieldConfig(tempDir)).toEqual({ + verbose: true, + variant: 'json', + }); + }); + + it('falls back to package.json config when js and json configs are missing', () => { + tempDir = createTempProject({ + packageJsonConfig: { verbose: true, variant: 'package-json' }, + }); + + expect(loadBrownfieldConfig(tempDir)).toEqual({ + verbose: true, + variant: 'package-json', + }); + }); +}); + +describe('validateBrownfieldCLIConfig', () => { + afterEach(() => { + mockLoggerWarn.mockReset(); + }); + + it('does not warn for valid config', () => { + validateBrownfieldCLIConfig({ + verbose: true, + variant: 'release', + }); + + expect(mockLoggerWarn).not.toHaveBeenCalled(); + }); + + it('warns for schema violations', () => { + validateBrownfieldCLIConfig({ + unsupportedOption: true, + }); + + expect(mockLoggerWarn).toHaveBeenCalledOnce(); + expect(mockLoggerWarn.mock.calls[0]?.[0]).toContain( + 'Brownfield configuration has some issues:' + ); + expect(mockLoggerWarn.mock.calls[0]?.[0]).toContain( + 'should NOT have additional properties' + ); + }); +}); + +describe('config application', () => { + let tempDir: string | null = null; + + afterEach(() => { + mockLoggerWarn.mockReset(); + + if (tempDir) { + cleanupTempDir(tempDir); + tempDir = null; + } + }); + + it('applies config values to a commander program with config as the source', () => { + const program = new Command(); + + applyBrownfieldCLIConfig(program, { + verbose: true, + variant: 'release', + }); + + expect(program.getOptionValue('verbose')).toBe(true); + expect(program.getOptionValueSource('verbose')).toBe('config'); + expect(program.getOptionValue('variant')).toBe('release'); + expect(program.getOptionValueSource('variant')).toBe('config'); + }); + + it('loads config and attaches it to the commander program', () => { + tempDir = createTempProject({ + packageJsonConfig: { verbose: true, variant: 'release' }, + }); + + const program = new Command(); + + loadAndApplyBrownfieldCLIConfig(program, tempDir); + + expect(program.getOptionValue('verbose')).toBe(true); + expect(program.getOptionValueSource('verbose')).toBe('config'); + expect(program.getOptionValue('variant')).toBe('release'); + expect(program.getOptionValueSource('variant')).toBe('config'); + }); +}); \ No newline at end of file diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 4cbb2aca..2ee9c481 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -20,13 +20,13 @@ const SEPARATOR = '\n● '; const ajv = new Ajv({ allErrors: true }); const validateBrownfieldConfig = ajv.compile(BrownfieldSchema); -function validateConfig(config: unknown) { +export function validateBrownfieldCLIConfig(config: unknown): void { if (!validateBrownfieldConfig(config)) { logger.warn(`Brownfield configuration has some issues: ${SEPARATOR}${ajv.errorsText(validateBrownfieldConfig.errors, { separator: SEPARATOR, dataVar: 'config' })}.`); } } -function loadBrownfieldConfig( +export function loadBrownfieldConfig( projectRoot: string = findProjectRoot() ): BrownfieldConfig { const require = createRequire(path.join(projectRoot, 'package.json')); @@ -50,15 +50,23 @@ function loadBrownfieldConfig( return packageJson[PACKAGE_JSON_CONFIG_KEY] || {}; } +export function applyBrownfieldCLIConfig( + program: Command, + config: BrownfieldConfig +): void { + for (const [key, value] of Object.entries(config)) { + program.setOptionValueWithSource(key, value, 'config'); + } +} -export function loadAndApplyBrownfieldCLIConfig(program: Command) { - const reactNativeBrownfieldConfig = loadBrownfieldConfig() +export function loadAndApplyBrownfieldCLIConfig( + program: Command, + projectRoot?: string +): void { + const reactNativeBrownfieldConfig = loadBrownfieldConfig(projectRoot); logger.debug('Loaded Brownfield CLI config:', reactNativeBrownfieldConfig); - validateConfig(reactNativeBrownfieldConfig); - - for (const [key, value] of Object.entries(reactNativeBrownfieldConfig)) { - program.setOptionValueWithSource(key, value, 'config'); - } + validateBrownfieldCLIConfig(reactNativeBrownfieldConfig); + applyBrownfieldCLIConfig(program, reactNativeBrownfieldConfig); } \ No newline at end of file