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/apps/RNApp/react-native-brownfield.config.js b/apps/RNApp/react-native-brownfield.config.js new file mode 100644 index 00000000..c9f0a98f --- /dev/null +++ b/apps/RNApp/react-native-brownfield.config.js @@ -0,0 +1,7 @@ +/** + * @type {import('@callstack/react-native-brownfield').BrownfieldConfig} + */ +module.exports = { + moduleName: ':BrownfieldLib', + scheme: 'BrownfieldLib', +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index 141fd1bc..756eeca0 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": { @@ -62,7 +67,8 @@ "!**/__fixtures__", "!**/__mocks__", "!**/.*", - "README.md" + "README.md", + "schema.json" ], "publishConfig": { "access": "public" @@ -80,6 +86,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/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/__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 new file mode 100644 index 00000000..2ee9c481 --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,72 @@ +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 './brownfield/utils/paths.js'; + +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'; +const PACKAGE_JSON_CONFIG_KEY = 'react-native-brownfield'; + +const SEPARATOR = '\n● '; + +const ajv = new Ajv({ allErrors: true }); +const validateBrownfieldConfig = ajv.compile(BrownfieldSchema); + +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' })}.`); + } +} + +export function loadBrownfieldConfig( + projectRoot: string = findProjectRoot() +): BrownfieldConfig { + const require = createRequire(path.join(projectRoot, 'package.json')); + + const jsConfigFilePath = path.join(projectRoot, JS_CONFIG_FILE_NAME); + if (fs.existsSync(jsConfigFilePath)) { + return require(jsConfigFilePath) as BrownfieldConfig; + } + + const jsonConfigFilePath = path.join(projectRoot, JSON_CONFIG_FILE_NAME); + if (fs.existsSync(jsonConfigFilePath)) { + return require(jsonConfigFilePath) as BrownfieldConfig; + } + + const packageJsonPath = path.join(projectRoot, 'package.json'); + const packageJson = require(packageJsonPath) as Record< + string, + unknown + >; + + 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, + projectRoot?: string +): void { + const reactNativeBrownfieldConfig = loadBrownfieldConfig(projectRoot); + + logger.debug('Loaded Brownfield CLI config:', reactNativeBrownfieldConfig); + + validateBrownfieldCLIConfig(reactNativeBrownfieldConfig); + applyBrownfieldCLIConfig(program, reactNativeBrownfieldConfig); +} \ No newline at end of file diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 867a3dbb..682a48b3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -14,6 +14,7 @@ import brownieCommands, { import navigationCommands, { groupName as navigationCommandsGroupName, } from './navigation/index.js'; +import { loadAndApplyBrownfieldCLIConfig } from './config.js'; const program = new Command(); @@ -82,6 +83,8 @@ program.commandsGroup('Utility commands').helpCommand('help [command]'); export function runCLI(argv: string[]): void { program.parse(argv); + loadAndApplyBrownfieldCLIConfig(program); + if (!argv.slice(2).length) { program.outputHelp(); } diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts new file mode 100644 index 00000000..5cc7553b --- /dev/null +++ b/packages/cli/src/types.ts @@ -0,0 +1,27 @@ +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 BrownfieldConfigMetadata = Partial<{ + $schema: string; +}> + +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 = BrownfieldConfigMetadata & BrownfieldCommonOptions & BrownfieldAndroidConfig & BrownfieldIosConfig diff --git a/packages/react-native-brownfield/src/index.ts b/packages/react-native-brownfield/src/index.ts index 14024084..457f69b3 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/types'; + export interface MessageEvent { data: unknown; } 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"