Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/RNApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions apps/RNApp/react-native-brownfield.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* @type {import('@callstack/react-native-brownfield').BrownfieldConfig}
*/
module.exports = {
moduleName: ':BrownfieldLib',
scheme: 'BrownfieldLib',
};
9 changes: 8 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -62,7 +67,8 @@
"!**/__fixtures__",
"!**/__mocks__",
"!**/.*",
"README.md"
"README.md",
"schema.json"
],
"publishConfig": {
"access": "public"
Expand All @@ -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",
Expand Down
70 changes: 70 additions & 0 deletions packages/cli/schema.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}

190 changes: 190 additions & 0 deletions packages/cli/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof rockTools>();

return {
...actual,
logger: {
...actual.logger,
debug: vi.fn(),
warn: vi.fn(),
},
};
});

const mockLoggerWarn = rockTools.logger.warn as Mock;

function createTempProject(options?: {
packageJsonConfig?: Record<string, unknown>;
jsConfig?: Record<string, unknown>;
jsonConfig?: Record<string, unknown>;
}): 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');
});
});
72 changes: 72 additions & 0 deletions packages/cli/src/config.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading