From 31e68a65168ddc5faf72b8f56f5235c54e625c2f Mon Sep 17 00:00:00 2001 From: Mike Grabowski Date: Mon, 1 Apr 2019 18:10:37 +0200 Subject: [PATCH] Standardise configuration mechanism (#254) Summary: --------- This pull requests brings a new way of configuring the CLI and drops never properly standardised and documented "rnpm" configuration. This is done in order to support new "auto-linking" feature and consolidate the configuration into a single place, that is easy to customise by developers. ### Highlights Given the scope of this PR, it's hard to write down every tiny detail. I've tried to leave as many comments as possible throughout the code to make it easier for you to navigate and understand some of the code patterns. Please see the highlighted changes below: - We now use `cosmiconfig` to load user preferences. We do that by taking "react-native" out of "package.json" and reading "react-native.config.js". We still read "rnpm" for legacy purposes and print appropriate deprecation messages along the instructions on what to do in order to upgrade. Jest validation library makes this kind of things super easy. - We validate the provided configuration by user using Jest validation library. This gives instant feedback whether the configuration is correct or not. - We now read configuration in a single place (happens in main CLI file) and pass it down to commands. Previously, we used to call `findPlugins` multiple times w/o cache, causing expensive globs to be performed. - Project configuration is lazy. We won't glob node_modules and/or look for native files unless you explicitly read for that package configuration. - Better support for out-of-tree-platforms - no need to define "haste" yourself, we are able to infer it from other properties. The files are also better organised now, causing less of maintenance pain with "linkConfig" - We consider our automatically generated configuration a set of defaults. Users can override settings for the project and each of the dependencies. Useful, especially with auto-linking feature to disable certain packages from it or when you don't want to link particular platform automatically. This has been historically really hard to implement - Global flags (e.g. "reactNativePath") can now be defined in configuration, instead of passing every time around. This fixes issues with "packager.sh" script (starting automatically Metro when running from Xcode) when run from RNTester.xcodeproj ### Next steps Once this PR is merged, we can concurrently start working/merging other "auto-linking" PRs. In the meantime, I'll submit a PR to move regular "link" (soon to be considered a legacy) to use the new configuration format as well. The new configuration has been designed in the way to still include previous configuration keys to support "link" and other community packages. For now, we print handy deprecation messages to help migrate the community from "rnpm" to "react-native" configuration. When "link" gets deprecated/removed forever in favour of "auto-linking", we should revisit the configuration and eventually, remove extraneous keys out of it. With "auto-linking", we don't need majority of it. Test Plan: ---------- Run `react-native config` to output the configuration. --- packages/cli/package.json | 3 + packages/cli/src/cliEntry.js | 52 +---- packages/cli/src/commands/config/config.js | 11 + packages/cli/src/commands/index.js | 28 +-- .../src/commands/info/__tests__/info.test.js | 12 +- .../src/commands/link/getDependencyConfig.js | 1 + .../cli/src/commands/link/getProjectConfig.js | 1 + packages/cli/src/commands/link/linkAll.js | 8 +- .../upgrade/__tests__/upgrade.test.js | 7 + .../__tests__/__snapshots__/index.js.snap | 152 ++++++++++++++ .../cli/src/tools/config/__tests__/index.js | 197 ++++++++++++++++++ .../cli/src/tools/config/findDependencies.js | 34 +++ packages/cli/src/tools/config/index.js | 118 +++++++++++ .../src/tools/config/readConfigFromDisk.js | 105 ++++++++++ .../tools/config/resolveReactNativePath.js | 21 ++ packages/cli/src/tools/config/schema.js | 122 +++++++++++ packages/cli/src/tools/config/types.flow.js | 76 +++++++ packages/cli/src/tools/errors.js | 43 ++++ packages/cli/src/tools/getLegacyConfig.js | 62 ------ packages/cli/src/tools/loadMetroConfig.js | 9 +- packages/cli/src/tools/types.flow.js | 31 ++- yarn.lock | 38 +++- 22 files changed, 984 insertions(+), 147 deletions(-) create mode 100644 packages/cli/src/commands/config/config.js create mode 100644 packages/cli/src/tools/config/__tests__/__snapshots__/index.js.snap create mode 100644 packages/cli/src/tools/config/__tests__/index.js create mode 100644 packages/cli/src/tools/config/findDependencies.js create mode 100644 packages/cli/src/tools/config/index.js create mode 100644 packages/cli/src/tools/config/readConfigFromDisk.js create mode 100644 packages/cli/src/tools/config/resolveReactNativePath.js create mode 100644 packages/cli/src/tools/config/schema.js create mode 100644 packages/cli/src/tools/config/types.flow.js delete mode 100644 packages/cli/src/tools/getLegacyConfig.js diff --git a/packages/cli/package.json b/packages/cli/package.json index fb4e5aca02..a8c4e693a3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,6 +23,8 @@ "commander": "^2.19.0", "compression": "^1.7.1", "connect": "^3.6.5", + "cosmiconfig": "^5.1.0", + "deepmerge": "^3.2.0", "denodeify": "^1.2.1", "envinfo": "^7.1.0", "errorhandler": "^1.5.0", @@ -32,6 +34,7 @@ "glob": "^7.1.1", "graceful-fs": "^4.1.3", "inquirer": "^3.0.6", + "joi": "^14.3.1", "lodash": "^4.17.5", "metro": "^0.53.1", "metro-config": "^0.53.1", diff --git a/packages/cli/src/cliEntry.js b/packages/cli/src/cliEntry.js index e7779fca4e..1b69ccee79 100644 --- a/packages/cli/src/cliEntry.js +++ b/packages/cli/src/cliEntry.js @@ -10,10 +10,10 @@ import chalk from 'chalk'; import childProcess from 'child_process'; import commander from 'commander'; -import minimist from 'minimist'; import path from 'path'; + import type {CommandT, ContextT} from './tools/types.flow'; -import getLegacyConfig from './tools/getLegacyConfig'; + import {getCommands} from './commands'; import init from './commands/init/init'; import assertRequiredOptions from './tools/assertRequiredOptions'; @@ -21,11 +21,10 @@ import logger from './tools/logger'; import findPlugins from './tools/findPlugins'; import {setProjectDir} from './tools/PackageManager'; import pkgJson from '../package.json'; +import loadConfig from './tools/config'; commander .option('--version', 'Print CLI version') - .option('--projectRoot [string]', 'Path to the root of the project') - .option('--reactNativePath [string]', 'Path to React Native') .option('--verbose', 'Increase logging verbosity'); commander.on('command:*', () => { @@ -117,15 +116,6 @@ const addCommand = (command: CommandT, ctx: ContextT) => { opt.default, ), ); - - /** - * We want every command (like "start", "link") to accept below options. - * To achieve that we append them to regular options of each command here. - * This way they'll be displayed in the commands --help menus. - */ - cmd - .option('--projectRoot [string]', 'Path to the root of the project') - .option('--reactNativePath [string]', 'Path to React Native'); }; async function run() { @@ -156,43 +146,11 @@ async function setupAndRun() { } } - /** - * At this point, commander arguments are not parsed yet because we need to - * add all the commands and their options. That's why we resort to using - * minimist for parsing some global options. - */ - const options = minimist(process.argv.slice(2)); - - const root = options.projectRoot - ? path.resolve(options.projectRoot) - : process.cwd(); - - const reactNativePath = options.reactNativePath - ? path.resolve(options.reactNativePath) - : (() => { - try { - return path.dirname( - // $FlowIssue: Wrong `require.resolve` type definition - require.resolve('react-native/package.json', { - paths: [root], - }), - ); - } catch (_ignored) { - throw new Error( - 'Unable to find React Native files. Make sure "react-native" module is installed in your project dependencies.', - ); - } - })(); - - const ctx = { - ...getLegacyConfig(root), - reactNativePath, - root, - }; + const ctx = loadConfig(); setProjectDir(ctx.root); - const commands = getCommands(ctx.root); + const commands = getCommands(ctx); commands.forEach(command => addCommand(command, ctx)); diff --git a/packages/cli/src/commands/config/config.js b/packages/cli/src/commands/config/config.js new file mode 100644 index 0000000000..51fd052436 --- /dev/null +++ b/packages/cli/src/commands/config/config.js @@ -0,0 +1,11 @@ +/** + * @flow + */ +import {type ContextT} from '../../tools/types.flow'; +export default { + name: 'config', + description: 'Print CLI configuration', + func: async (argv: string[], ctx: ContextT) => { + console.log(JSON.stringify(ctx, null, 2)); + }, +}; diff --git a/packages/cli/src/commands/index.js b/packages/cli/src/commands/index.js index 04d8820979..c3fc82f760 100644 --- a/packages/cli/src/commands/index.js +++ b/packages/cli/src/commands/index.js @@ -4,7 +4,6 @@ import path from 'path'; -import findPlugins from '../tools/findPlugins'; import logger from '../tools/logger'; import type { @@ -13,6 +12,8 @@ import type { LocalCommandT, } from '../tools/types.flow'; +import {type ContextT} from '../tools/types.flow'; + import server from './server/server'; import runIOS from './runIOS/runIOS'; import runAndroid from './runAndroid/runAndroid'; @@ -27,6 +28,7 @@ import upgrade from './upgrade/upgrade'; import logAndroid from './logAndroid/logAndroid'; import logIOS from './logIOS/logIOS'; import info from './info/info'; +import config from './config/config'; /** * List of built-in commands @@ -47,6 +49,7 @@ const loadLocalCommands: Array = [ logAndroid, logIOS, info, + config, ]; /** @@ -55,10 +58,11 @@ const loadLocalCommands: Array = [ * This checks all CLI plugins for presence of 3rd party packages that define commands * and loads them */ -const loadProjectCommands = (root: string): Array => { - const plugins = findPlugins(root); - - return plugins.commands.reduce((acc: Array, pathToCommands) => { +const loadProjectCommands = ({ + root, + commands, +}: ContextT): Array => { + return commands.reduce((acc: Array, cmdPath: string) => { /** * `pathToCommand` is a path to a file where commands are defined, relative to `node_modules` * folder. @@ -67,12 +71,12 @@ const loadProjectCommands = (root: string): Array => { * into consideration. */ const name = - pathToCommands[0] === '@' - ? pathToCommands + cmdPath[0] === '@' + ? cmdPath .split(path.sep) .slice(0, 2) .join(path.sep) - : pathToCommands.split(path.sep)[0]; + : cmdPath.split(path.sep)[0]; const pkg = require(path.join(root, 'node_modules', name, 'package.json')); @@ -81,7 +85,7 @@ const loadProjectCommands = (root: string): Array => { | Array = require(path.join( root, 'node_modules', - pathToCommands, + cmdPath, )); if (Array.isArray(requiredCommands)) { @@ -90,14 +94,14 @@ const loadProjectCommands = (root: string): Array => { ); } - return acc.concat({...requiredCommands}); + return acc.concat({...requiredCommands, pkg}); }, []); }; /** * Loads all the commands inside a given `root` folder */ -export function getCommands(root: string): Array { +export function getCommands(ctx: ContextT): Array { return [ ...loadLocalCommands, { @@ -111,6 +115,6 @@ export function getCommands(root: string): Array { ); }, }, - ...loadProjectCommands(root), + ...loadProjectCommands(ctx), ]; } diff --git a/packages/cli/src/commands/info/__tests__/info.test.js b/packages/cli/src/commands/info/__tests__/info.test.js index 9683acbae5..7b19753b0b 100644 --- a/packages/cli/src/commands/info/__tests__/info.test.js +++ b/packages/cli/src/commands/info/__tests__/info.test.js @@ -8,7 +8,17 @@ jest.mock('../../../tools/logger', () => ({ log: jest.fn(), })); -const ctx = {reactNativePath: '', root: ''}; +const ctx = { + root: '', + reactNativePath: '', + dependencies: {}, + platforms: {}, + commands: [], + haste: { + platforms: [], + providesModuleNodeModules: [], + }, +}; beforeEach(() => { jest.resetAllMocks(); diff --git a/packages/cli/src/commands/link/getDependencyConfig.js b/packages/cli/src/commands/link/getDependencyConfig.js index 3cc2e1af24..7983691c2d 100644 --- a/packages/cli/src/commands/link/getDependencyConfig.js +++ b/packages/cli/src/commands/link/getDependencyConfig.js @@ -28,6 +28,7 @@ export default function getDependencyConfig( Object.keys(availablePlatforms).forEach(platform => { platformConfigs[platform] = availablePlatforms[platform].dependencyConfig( folder, + // $FlowIssue: Flow can't match platform config with its appropriate config function config[platform] || {}, ); }); diff --git a/packages/cli/src/commands/link/getProjectConfig.js b/packages/cli/src/commands/link/getProjectConfig.js index 9172f012bf..d3b40c1695 100644 --- a/packages/cli/src/commands/link/getProjectConfig.js +++ b/packages/cli/src/commands/link/getProjectConfig.js @@ -24,6 +24,7 @@ export default function getProjectConfig( logger.debug(`Getting project config for ${getPlatformName(platform)}...`); platformConfigs[platform] = availablePlatforms[platform].projectConfig( ctx.root, + // $FlowIssue: Flow can't match platform config with its appropriate config function config[platform] || {}, ); }); diff --git a/packages/cli/src/commands/link/linkAll.js b/packages/cli/src/commands/link/linkAll.js index 333185dace..9ba4137636 100644 --- a/packages/cli/src/commands/link/linkAll.js +++ b/packages/cli/src/commands/link/linkAll.js @@ -33,19 +33,19 @@ function linkAll( const projectAssets = getAssets(context.root); const dependencies = getProjectDependencies(context.root); - const depenendenciesConfig = dependencies.map(dependnecy => - getDependencyConfig(context, platforms, dependnecy), + const dependenciesConfig = dependencies.map(dependency => + getDependencyConfig(context, platforms, dependency), ); const assets = dedupeAssets( - depenendenciesConfig.reduce( + dependenciesConfig.reduce( (acc, dependency) => acc.concat(dependency.assets), projectAssets, ), ); const tasks = flatten( - depenendenciesConfig.map(config => [ + dependenciesConfig.map(config => [ () => promisify(config.commands.prelink || commandStub), () => linkDependency(platforms, project, config), () => promisify(config.commands.postlink || commandStub), diff --git a/packages/cli/src/commands/upgrade/__tests__/upgrade.test.js b/packages/cli/src/commands/upgrade/__tests__/upgrade.test.js index 2ae9d7df9a..aa1569a17e 100644 --- a/packages/cli/src/commands/upgrade/__tests__/upgrade.test.js +++ b/packages/cli/src/commands/upgrade/__tests__/upgrade.test.js @@ -55,6 +55,13 @@ const olderVersion = '0.56.0'; const ctx = { root: '/project/root', reactNativePath: '', + commands: [], + platforms: {}, + dependencies: {}, + haste: { + providesModuleNodeModules: [], + platforms: [], + }, }; const opts = { legacy: false, diff --git a/packages/cli/src/tools/config/__tests__/__snapshots__/index.js.snap b/packages/cli/src/tools/config/__tests__/__snapshots__/index.js.snap new file mode 100644 index 0000000000..aaa2e0cb72 --- /dev/null +++ b/packages/cli/src/tools/config/__tests__/__snapshots__/index.js.snap @@ -0,0 +1,152 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should deep merge project configuration with default values 1`] = ` +Object { + "commands": Array [], + "dependencies": Object { + "react-native-test": Object { + "assets": Array [], + "hooks": Object {}, + "params": Array [], + "platforms": Object { + "android": null, + "ios": Object { + "folder": "<>/node_modules/react-native-test", + "libraryFolder": "Libraries", + "pbxprojPath": "<>/node_modules/react-native-test/ios/HelloWorld.xcodeproj/project.pbxproj", + "plist": Array [], + "podfile": null, + "podspec": null, + "projectName": "HelloWorld.xcodeproj", + "projectPath": "<>/node_modules/react-native-test/ios/HelloWorld.xcodeproj", + "sharedLibraries": Array [], + "sourceDir": "./abc", + }, + }, + }, + }, + "haste": Object { + "platforms": Array [], + "providesModuleNodeModules": Array [], + }, + "platforms": Object { + "android": Object {}, + "ios": Object {}, + }, + "reactNativePath": ".", + "root": "<>", +} +`; + +exports[`should have a valid structure by default 1`] = ` +Object { + "commands": Array [], + "dependencies": Object {}, + "haste": Object { + "platforms": Array [], + "providesModuleNodeModules": Array [], + }, + "platforms": Object { + "android": Object {}, + "ios": Object {}, + }, + "reactNativePath": ".", + "root": "<>", +} +`; + +exports[`should load an out-of-tree "windows" platform that ships with a dependency 1`] = ` +Object { + "haste": Object { + "platforms": Array [ + "windows", + ], + "providesModuleNodeModules": Array [ + "react-native-windows", + ], + }, + "platforms": Object { + "android": Object {}, + "ios": Object {}, + "windows": Object {}, + }, +} +`; + +exports[`should load commands from "react-native-foo" and "react-native-bar" packages 1`] = ` +Array [ + "react-native-foo/command-foo.js", + "react-native-bar/command-bar.js", +] +`; + +exports[`should read \`rnpm\` config from a dependency and transform it to a new format 1`] = ` +Object { + "assets": Array [], + "hooks": Object {}, + "params": Array [], + "platforms": Object { + "android": null, + "ios": Object { + "folder": "<>/node_modules/react-native-foo", + "libraryFolder": "Libraries", + "pbxprojPath": "<>/node_modules/react-native-foo/customLocation/customProject.xcodeproj/project.pbxproj", + "plist": Array [], + "podfile": null, + "podspec": null, + "projectName": "customProject.xcodeproj", + "projectPath": "<>/node_modules/react-native-foo/customLocation/customProject.xcodeproj", + "sharedLibraries": Array [], + "sourceDir": "<>/node_modules/react-native-foo/customLocation", + }, + }, +} +`; + +exports[`should read a config of a dependency and use it to load other settings 1`] = ` +Object { + "assets": Array [], + "hooks": Object {}, + "params": Array [], + "platforms": Object { + "android": null, + "ios": Object { + "folder": "<>/node_modules/react-native-test", + "libraryFolder": "Libraries", + "pbxprojPath": "<>/node_modules/react-native-test/customLocation/customProject.xcodeproj/project.pbxproj", + "plist": Array [], + "podfile": null, + "podspec": null, + "projectName": "customProject.xcodeproj", + "projectPath": "<>/node_modules/react-native-test/customLocation/customProject.xcodeproj", + "sharedLibraries": Array [], + "sourceDir": "<>/node_modules/react-native-test/customLocation", + }, + }, +} +`; + +exports[`should return dependencies from package.json 1`] = ` +Object { + "react-native-test": Object { + "assets": Array [], + "hooks": Object {}, + "params": Array [], + "platforms": Object { + "android": null, + "ios": Object { + "folder": "<>/node_modules/react-native-test", + "libraryFolder": "Libraries", + "pbxprojPath": "<>/node_modules/react-native-test/ios/HelloWorld.xcodeproj/project.pbxproj", + "plist": Array [], + "podfile": null, + "podspec": null, + "projectName": "HelloWorld.xcodeproj", + "projectPath": "<>/node_modules/react-native-test/ios/HelloWorld.xcodeproj", + "sharedLibraries": Array [], + "sourceDir": "<>/node_modules/react-native-test/ios", + }, + }, + }, +} +`; diff --git a/packages/cli/src/tools/config/__tests__/index.js b/packages/cli/src/tools/config/__tests__/index.js new file mode 100644 index 0000000000..be2173b51e --- /dev/null +++ b/packages/cli/src/tools/config/__tests__/index.js @@ -0,0 +1,197 @@ +/** + * @flow + */ + +import loadConfig from '../'; + +import { + cleanup, + writeFiles, + getTempDirectory, +} from '../../../../../../e2e/helpers'; + +const DIR = getTempDirectory('resolve_config_path_test'); + +// Removes string from all key/values within an object +const removeString = (config, str) => + JSON.parse( + JSON.stringify(config).replace(new RegExp(str, 'g'), '<>'), + ); + +beforeEach(() => { + cleanup(DIR); + jest.resetModules(); +}); + +afterEach(() => cleanup(DIR)); + +test('should have a valid structure by default', () => { + writeFiles(DIR, { + 'package.json': `{ + "react-native": { + "reactNativePath": "." + } + }`, + }); + const config = loadConfig(DIR); + expect(removeString(config, DIR)).toMatchSnapshot(); +}); + +test('should return dependencies from package.json', () => { + writeFiles(DIR, { + 'node_modules/react-native-test/package.json': '{}', + 'node_modules/react-native-test/ios/HelloWorld.xcodeproj/project.pbxproj': + '', + 'package.json': `{ + "dependencies": { + "react-native-test": "0.0.1" + }, + "react-native": { + "reactNativePath": "." + } + }`, + }); + const {dependencies} = loadConfig(DIR); + expect(removeString(dependencies, DIR)).toMatchSnapshot(); +}); + +test('should read a config of a dependency and use it to load other settings', () => { + writeFiles(DIR, { + 'node_modules/react-native-test/package.json': `{ + "react-native": { + "dependency": { + "platforms": { + "ios": { + "project": "./customLocation/customProject.xcodeproj" + } + } + } + } + }`, + 'package.json': `{ + "dependencies": { + "react-native-test": "0.0.1" + }, + "react-native": { + "reactNativePath": "." + } + }`, + }); + const {dependencies} = loadConfig(DIR); + expect( + removeString(dependencies['react-native-test'], DIR), + ).toMatchSnapshot(); +}); + +test('should deep merge project configuration with default values', () => { + writeFiles(DIR, { + 'node_modules/react-native-test/package.json': '{}', + 'node_modules/react-native-test/ios/HelloWorld.xcodeproj/project.pbxproj': + '', + 'package.json': `{ + "dependencies": { + "react-native-test": "0.0.1" + }, + "react-native": { + "reactNativePath": ".", + "dependencies": { + "react-native-test": { + "platforms": { + "ios": { + "sourceDir": "./abc" + } + } + } + } + } + }`, + }); + const config = loadConfig(DIR); + expect(removeString(config, DIR)).toMatchSnapshot(); +}); + +test('should read `rnpm` config from a dependency and transform it to a new format', () => { + writeFiles(DIR, { + 'node_modules/react-native-foo/package.json': `{ + "name": "react-native-foo", + "rnpm": { + "ios": { + "project": "./customLocation/customProject.xcodeproj" + } + } + }`, + 'package.json': `{ + "dependencies": { + "react-native-foo": "0.0.1" + }, + "react-native": { + "reactNativePath": "." + } + }`, + }); + const {dependencies} = loadConfig(DIR); + expect(removeString(dependencies['react-native-foo'], DIR)).toMatchSnapshot(); +}); + +test('should load commands from "react-native-foo" and "react-native-bar" packages', () => { + writeFiles(DIR, { + 'node_modules/react-native-foo/package.json': `{ + "react-native": { + "commands": [ + "./command-foo.js" + ] + } + }`, + 'node_modules/react-native-bar/package.json': `{ + "react-native": { + "commands": [ + "./command-bar.js" + ] + } + }`, + 'package.json': `{ + "dependencies": { + "react-native-foo": "0.0.1", + "react-native-bar": "0.0.1" + }, + "react-native": { + "reactNativePath": "." + } + }`, + }); + const {commands} = loadConfig(DIR); + expect(removeString(commands, DIR)).toMatchSnapshot(); +}); + +test('should load an out-of-tree "windows" platform that ships with a dependency', () => { + writeFiles(DIR, { + 'node_modules/react-native-windows/platform.js': ` + module.exports = {"windows": {}}; + `, + 'node_modules/react-native-windows/package.json': `{ + "name": "react-native-windows", + "rnpm": { + "haste": { + "platforms": [ + "windows" + ], + "providesModuleNodeModules": [ + "react-native-windows" + ] + }, + "plugin": "./plugin.js", + "platform": "./platform.js" + } + }`, + 'package.json': `{ + "dependencies": { + "react-native-windows": "0.0.1" + }, + "react-native": { + "reactNativePath": "." + } + }`, + }); + const {haste, platforms} = loadConfig(DIR); + expect(removeString({haste, platforms}, DIR)).toMatchSnapshot(); +}); diff --git a/packages/cli/src/tools/config/findDependencies.js b/packages/cli/src/tools/config/findDependencies.js new file mode 100644 index 0000000000..7fda35955d --- /dev/null +++ b/packages/cli/src/tools/config/findDependencies.js @@ -0,0 +1,34 @@ +/** + * @flow + */ + +import path from 'path'; + +const pluginRe = new RegExp( + [ + '^react-native-', + '^@(.*)/react-native-', + '^@react-native(.*)/(?!rnpm-plugin-)', + ].join('|'), +); + +/** + * Returns an array of dependencies from project's package.json that + * are likely to be React Native packages (see regular expression above) + */ +export default function findDependencies(root: string): Array { + let pjson; + + try { + pjson = require(path.join(root, 'package.json')); + } catch (e) { + return []; + } + + const deps = [ + ...Object.keys(pjson.dependencies || {}), + ...Object.keys(pjson.devDependencies || {}), + ]; + + return deps.filter(dependency => pluginRe.test(dependency)); +} diff --git a/packages/cli/src/tools/config/index.js b/packages/cli/src/tools/config/index.js new file mode 100644 index 0000000000..f6920a8c5d --- /dev/null +++ b/packages/cli/src/tools/config/index.js @@ -0,0 +1,118 @@ +/** + * @flow + */ +import dedent from 'dedent'; +import path from 'path'; +import merge from 'deepmerge'; + +import findDependencies from './findDependencies'; +import { + readProjectConfigFromDisk, + readDependencyConfigFromDisk, + readLegacyDependencyConfigFromDisk, +} from './readConfigFromDisk'; + +import {type ProjectConfigT, type RawProjectConfigT} from './types.flow'; + +/** + * Built-in platforms + */ +import * as ios from '../ios'; +import * as android from '../android'; +import resolveReactNativePath from './resolveReactNativePath'; + +/** + * Loads CLI configuration + */ +function loadConfig(projectRoot: string = process.cwd()): ProjectConfigT { + const inferredProjectConfig = findDependencies(projectRoot).reduce( + (acc: RawProjectConfigT, dependencyName) => { + const root = path.join(projectRoot, 'node_modules', dependencyName); + + const config = + readLegacyDependencyConfigFromDisk(root) || + readDependencyConfigFromDisk(root); + + return { + ...acc, + dependencies: { + ...acc.dependencies, + // $FlowIssue: Computed getters are not yet supported. + get [dependencyName]() { + return { + platforms: Object.keys(acc.platforms).reduce( + (dependency, platform) => { + dependency[platform] = acc.platforms[ + platform + ].dependencyConfig( + root, + config.dependency.platforms[platform], + ); + return dependency; + }, + {}, + ), + assets: config.dependency.assets, + hooks: config.dependency.hooks, + params: config.dependency.params, + }; + }, + }, + commands: acc.commands.concat( + config.commands.map(pathToCommand => + path.join(dependencyName, pathToCommand), + ), + ), + platforms: { + ...acc.platforms, + ...config.platforms, + }, + haste: { + providesModuleNodeModules: acc.haste.providesModuleNodeModules.concat( + Object.keys(config.platforms).length > 0 ? dependencyName : [], + ), + platforms: [...acc.haste.platforms, ...Object.keys(config.platforms)], + }, + }; + }, + ({ + root: projectRoot, + reactNativePath: resolveReactNativePath(projectRoot), + dependencies: {}, + commands: [], + platforms: { + ios, + android, + }, + haste: { + providesModuleNodeModules: [], + platforms: [], + }, + }: RawProjectConfigT), + ); + + const config: RawProjectConfigT = merge( + inferredProjectConfig, + readProjectConfigFromDisk(projectRoot), + ); + + if (config.reactNativePath === null) { + throw new Error(dedent` + Unable to find React Native files. Make sure "react-native" module is installed + in your project dependencies. + + If you are using React Native from a non-standard location, consider setting: + { + "react-native": { + "reactNativePath": "./path/to/react-native" + } + } + in your \`package.json\`. + `); + } + + // $FlowIssue: `reactNativePath: null` is never null at this point + return config; +} + +export default loadConfig; diff --git a/packages/cli/src/tools/config/readConfigFromDisk.js b/packages/cli/src/tools/config/readConfigFromDisk.js new file mode 100644 index 0000000000..11ebc7915e --- /dev/null +++ b/packages/cli/src/tools/config/readConfigFromDisk.js @@ -0,0 +1,105 @@ +/** + * @flow + * + * Loads and validates a project configuration + */ +import Joi from 'joi'; +import cosmiconfig from 'cosmiconfig'; +import path from 'path'; + +import {type DependencyConfigT, type ProjectConfigT} from './types.flow'; + +import {JoiError} from '../errors'; + +import * as schema from './schema'; +import logger from '../logger'; + +/** + * Places to look for the new configuration + */ +const searchPlaces = ['react-native.config.js', 'package.json']; + +/** + * Reads a project configuration as defined by the user in the current + * workspace. + */ +export function readProjectConfigFromDisk(rootFolder: string): ProjectConfigT { + const explorer = cosmiconfig('react-native', {searchPlaces}); + + const {config} = explorer.searchSync(rootFolder) || {config: undefined}; + + const result = Joi.validate(config, schema.projectConfig); + + if (result.error) { + throw new JoiError(result.error); + } + + return result.value; +} + +/** + * Reads a dependency configuration as defined by the developer + * inside `node_modules`. + */ +export function readDependencyConfigFromDisk( + rootFolder: string, +): DependencyConfigT { + const explorer = cosmiconfig('react-native', { + stopDir: rootFolder, + searchPlaces, + }); + + const {config} = explorer.searchSync(rootFolder) || {config: undefined}; + + const result = Joi.validate(config, schema.dependencyConfig); + + if (result.error) { + throw new JoiError(result.error); + } + + return result.value; +} + +/** + * Reads a legacy configuaration from a `package.json` "rnpm" key. + */ +export function readLegacyDependencyConfigFromDisk( + rootFolder: string, +): ?DependencyConfigT { + const {rnpm: config, name} = require(path.join(rootFolder, 'package.json')); + + if (!config) { + return undefined; + } + + const transformedConfig = { + dependency: { + platforms: { + ios: config.ios, + android: config.android, + }, + assets: config.assets, + hooks: config.commands, + params: config.params, + }, + commands: [].concat(config.plugin || []), + platforms: config.platform + ? require(path.join(rootFolder, config.platform)) + : undefined, + }; + + // @todo: paste a link to documentation that explains the migration steps + logger.warn( + `Package '${path.basename( + name, + )}' is using deprecated "rnpm" config that will stop working from next release. Consider upgrading to the new config format.`, + ); + + const result = Joi.validate(transformedConfig, schema.dependencyConfig); + + if (result.error) { + throw new JoiError(result.error); + } + + return result.value; +} diff --git a/packages/cli/src/tools/config/resolveReactNativePath.js b/packages/cli/src/tools/config/resolveReactNativePath.js new file mode 100644 index 0000000000..422a10249f --- /dev/null +++ b/packages/cli/src/tools/config/resolveReactNativePath.js @@ -0,0 +1,21 @@ +/** + * @flow + */ +import path from 'path'; + +/** + * Finds path to React Native inside `node_modules` or throws + * an error otherwise. + */ +export default function resolveReactNativePath(root: string) { + try { + return path.dirname( + // $FlowIssue: Wrong `require.resolve` type definition + require.resolve('react-native/package.json', { + paths: [root], + }), + ); + } catch (_ignored) { + return null; + } +} diff --git a/packages/cli/src/tools/config/schema.js b/packages/cli/src/tools/config/schema.js new file mode 100644 index 0000000000..93f058cb92 --- /dev/null +++ b/packages/cli/src/tools/config/schema.js @@ -0,0 +1,122 @@ +/** + * @flow + */ +import t from 'joi'; + +const map = (key, value) => + t + .object() + .unknown(true) + .pattern(key, value); + +/** + * Schema for DependencyConfigT + */ +export const dependencyConfig = t + .object({ + dependency: t + .object({ + platforms: map(t.string(), t.any()) + .keys({ + ios: t + .object({ + project: t.string(), + sharedLibraries: t.array().items(t.string()), + libraryFolder: t.string(), + }) + .default({}), + android: t + .object({ + sourceDir: t.string(), + manifestPath: t.string(), + packageImportPath: t.string(), + packageInstance: t.string(), + }) + .default({}), + }) + .default(), + assets: t + .array() + .items(t.string()) + .default([]), + hooks: map(t.string(), t.string()).default(), + params: t + .array() + .items( + t.object({ + name: t.string(), + type: t.string(), + message: t.string(), + }), + ) + .default([]), + }) + .default(), + platforms: map( + t.string(), + t.object({ + dependencyConfig: t.func(), + projectConfig: t.func(), + linkConfig: t.func(), + }), + ).default(), + commands: t + .array() + .items(t.string()) + .default([]), + }) + .default(); + +/** + * Schema for ProjectConfigT + */ +export const projectConfig = t + .object({ + dependencies: map( + t.string(), + t + .object({ + platforms: map(t.string(), t.any()).keys({ + ios: t + .object({ + sourceDir: t.string(), + folder: t.string(), + pbxprojPath: t.string(), + podfile: t.string(), + podspec: t.string(), + projectPath: t.string(), + projectName: t.string(), + libraryFolder: t.string(), + sharedLibraries: t.array().items(t.string()), + }) + .allow(null), + android: t + .object({ + sourceDir: t.string(), + folder: t.string(), + packageImportPath: t.string(), + packageInstance: t.string(), + }) + .allow(null), + }), + assets: t.array().items(t.string()), + hooks: map(t.string(), t.string()), + params: t.array().items( + t.object({ + name: t.string(), + type: t.string(), + message: t.string(), + }), + ), + }) + .allow(null), + ), + commands: t.array().items(t.string()), + haste: t.object({ + providesModuleNodeModules: t.array().items(t.string()), + platforms: t.array().items(t.string()), + }), + reactNativePath: t.string(), + root: t.string(), + }) + .default({}); diff --git a/packages/cli/src/tools/config/types.flow.js b/packages/cli/src/tools/config/types.flow.js new file mode 100644 index 0000000000..01901b9342 --- /dev/null +++ b/packages/cli/src/tools/config/types.flow.js @@ -0,0 +1,76 @@ +/** + * @flow + */ + +import type { + AndroidConfigParamsT, + IOSConfigParamsT, + InquirerPromptT, + DependencyConfigAndroidT, + DependencyConfigIOST, +} from '../types.flow'; + +/** + * A map of hooks to run pre/post some of the CLI actions + */ +type HooksT = { + [key: string]: string, + prelink?: string, + postlink?: string, +}; + +/** + * A map with additional platforms that ship with a dependency. + */ +export type PlatformsT = { + [key: string]: { + dependencyConfig?: Function, + projectConfig?: Function, + linkConfig?: Function, + }, +}; + +export type DependencyConfigT = { + dependency: { + platforms: { + android?: AndroidConfigParamsT, + ios?: IOSConfigParamsT, + [key: string]: any, + }, + assets: string[], + hooks: HooksT, + params: InquirerPromptT[], + }, + commands: string[], + platforms: PlatformsT, +}; + +type _ProjectConfigT = { + root: string, + dependencies: { + [key: string]: { + platforms: { + android: DependencyConfigAndroidT | null, + ios: DependencyConfigIOST | null, + [key: string]: any, + }, + assets: string[], + hooks: HooksT, + params: InquirerPromptT[], + }, + }, + platforms: PlatformsT, + commands: string[], + haste: { + platforms: Array, + providesModuleNodeModules: Array, + }, +}; + +export type RawProjectConfigT = _ProjectConfigT & { + reactNativePath: string | null, +}; + +export type ProjectConfigT = _ProjectConfigT & { + reactNativePath: string, +}; diff --git a/packages/cli/src/tools/errors.js b/packages/cli/src/tools/errors.js index 278d35cc22..82bd9090c7 100644 --- a/packages/cli/src/tools/errors.js +++ b/packages/cli/src/tools/errors.js @@ -2,6 +2,7 @@ * @flow */ import chalk from 'chalk'; +import dedent from 'dedent'; export class ProcessError extends Error { constructor(msg: string, processError: string) { @@ -9,3 +10,45 @@ export class ProcessError extends Error { Error.captureStackTrace(this, ProcessError); } } + +type JoiErrorT = { + details: Array<{ + message: string, + path: string[], + type: string, + context: { + key: string, + label: string, + value: Object, + }, + }>, +}; + +export class JoiError extends Error { + constructor(joiError: JoiErrorT) { + super( + joiError.details + .map(error => { + const name = error.path.join('.'); + const value = JSON.stringify(error.context.value); + switch (error.type) { + case 'object.allowUnknown': + return dedent` + Unknown option ${name} with value "${value}" was found. + This is either a typing error or a user mistake. Fixing it will remove this message. + `; + case 'object.base': + case 'string.base': + const expectedType = error.type.replace('.base', ''); + const actualType = typeof error.context.value; + return dedent` + Option ${name} must be a ${expectedType}, instead got ${actualType} + `; + default: + return error.message; + } + }) + .join(), + ); + } +} diff --git a/packages/cli/src/tools/getLegacyConfig.js b/packages/cli/src/tools/getLegacyConfig.js deleted file mode 100644 index 2b5386f8ef..0000000000 --- a/packages/cli/src/tools/getLegacyConfig.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @flow - */ -import path from 'path'; -import util from 'util'; - -import getPlatforms from './getPlatforms'; -import getPackageConfiguration from './getPackageConfiguration'; -import getHooks from './getHooks'; -import getAssets from './getAssets'; -import getParams from './getParams'; - -const generateDeprecationMessage = api => - `${api} is deprecated and will be removed soon. Please check release notes on how to upgrade`; - -/** - * Gets legacy configuration to support existing plugins while they migrate - * to the new API - * - * This file will be removed from the next version. - */ -export default (root: string) => ({ - getPlatformConfig: util.deprecate( - () => getPlatforms(root), - generateDeprecationMessage('getPlatformConfig()'), - ), - getProjectConfig: util.deprecate(() => { - const platforms = getPlatforms(root); - - const rnpm = getPackageConfiguration(root); - - const config = { - ...rnpm, - assets: getAssets(root), - }; - - Object.keys(platforms).forEach(key => { - config[key] = platforms[key].projectConfig(root, rnpm[key] || {}); - }); - - return config; - }, generateDeprecationMessage('getProjectConfig()')), - getDependencyConfig: util.deprecate((packageName: string) => { - const platforms = getPlatforms(root); - const folder = path.join(process.cwd(), 'node_modules', packageName); - - const rnpm = getPackageConfiguration(folder); - - const config = { - ...rnpm, - assets: getAssets(folder), - commands: getHooks(folder), - params: getParams(folder), - }; - - Object.keys(platforms).forEach(key => { - config[key] = platforms[key].dependencyConfig(folder, rnpm[key] || {}); - }); - - return config; - }, generateDeprecationMessage('getDependencyConfig()')), -}); diff --git a/packages/cli/src/tools/loadMetroConfig.js b/packages/cli/src/tools/loadMetroConfig.js index 286b5bce1c..608b73c48d 100644 --- a/packages/cli/src/tools/loadMetroConfig.js +++ b/packages/cli/src/tools/loadMetroConfig.js @@ -5,8 +5,7 @@ import path from 'path'; import {createBlacklist} from 'metro'; import {loadConfig} from 'metro-config'; -import type {ContextT} from './types.flow'; -import findPlugins from './findPlugins'; +import {type ContextT} from './types.flow'; import findSymlinkedModules from './findSymlinkedModules'; const resolveSymlinksForRoots = roots => @@ -32,16 +31,14 @@ const getBlacklistRE = () => createBlacklist([/.*\/__fixtures__\/.*/]); * Otherwise, a.native.js will not load on Windows or other platforms */ export const getDefaultConfig = (ctx: ContextT) => { - const plugins = findPlugins(ctx.root); - return { resolver: { resolverMainFields: ['react-native', 'browser', 'main'], blacklistRE: getBlacklistRE(), - platforms: ['ios', 'android', 'native', ...plugins.haste.platforms], + platforms: ['ios', 'android', 'native', ...ctx.haste.platforms], providesModuleNodeModules: [ 'react-native', - ...plugins.haste.providesModuleNodeModules, + ...ctx.haste.providesModuleNodeModules, ], hasteImplModulePath: path.join(ctx.reactNativePath, 'jest/hasteImpl'), }, diff --git a/packages/cli/src/tools/types.flow.js b/packages/cli/src/tools/types.flow.js index 495c26190e..32682a3e2a 100644 --- a/packages/cli/src/tools/types.flow.js +++ b/packages/cli/src/tools/types.flow.js @@ -2,10 +2,9 @@ * @flow */ -export type ContextT = { - root: string, - reactNativePath: string, -}; +import {type ProjectConfigT as ConfigT} from './config/types.flow'; + +export type ContextT = ConfigT; export type LocalCommandT = { name: string, @@ -69,13 +68,18 @@ export type PlatformConfigT = { }, }; -/** - * The following types will be useful when we type `link` itself. For now, - * they can be treated as aliases. - */ -export type AndroidConfigParamsT = {}; +export type AndroidConfigParamsT = { + sourceDir?: string, + manifestPath?: string, + packageImportPath?: string, + packageInstance?: string, +}; -export type IOSConfigParamsT = {}; +export type IOSConfigParamsT = { + project?: string, + sharedLibraries?: string[], + libraryFolder?: string, +}; export type ProjectConfigIOST = {}; @@ -142,4 +146,11 @@ export type PackageConfigurationT = { params?: InquirerPromptT[], android: AndroidConfigParamsT, ios: IOSConfigParamsT, + + plugin?: string | Array, + platform?: string, + haste?: { + platforms?: Array, + providesModuleNodeModules?: Array, + }, }; diff --git a/yarn.lock b/yarn.lock index 9adb4ff67e..5c75056653 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3175,7 +3175,7 @@ deep-is@~0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" -deepmerge@3.2.0: +deepmerge@3.2.0, deepmerge@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-3.2.0.tgz#58ef463a57c08d376547f8869fdc5bcee957f44e" integrity sha512-6+LuZGU7QCNUnAJyX8cIrlzoEgggTM6B7mm+znKOX4t5ltluT9KLjN6g61ECMS0LTsLW7yDpNoxhix5FZcrIow== @@ -4334,6 +4334,11 @@ has@^1.0.1, has@^1.0.3: dependencies: function-bind "^1.1.1" +hoek@6.x.x: + version "6.1.3" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-6.1.3.tgz#73b7d33952e01fe27a38b0457294b79dd8da242c" + integrity sha512-YXXAAhmF9zpQbC7LEcREFtXfGq5K1fmd+4PHkBq8NUqmzW3G+Dq10bI/i0KucLRwss3YYFQ0fSfoxBZYiGUqtQ== + home-or-tmp@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-3.0.0.tgz#57a8fe24cf33cdd524860a15821ddc25c86671fb" @@ -4811,6 +4816,13 @@ isarray@1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" +isemail@3.x.x: + version "3.2.0" + resolved "https://registry.yarnpkg.com/isemail/-/isemail-3.2.0.tgz#59310a021931a9fb06bbb51e155ce0b3f236832c" + integrity sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg== + dependencies: + punycode "2.x.x" + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -5419,6 +5431,15 @@ jest@^24.5.0: import-local "^2.0.0" jest-cli "^24.5.0" +joi@^14.3.1: + version "14.3.1" + resolved "https://registry.yarnpkg.com/joi/-/joi-14.3.1.tgz#164a262ec0b855466e0c35eea2a885ae8b6c703c" + integrity sha512-LQDdM+pkOrpAn4Lp+neNIFV3axv1Vna3j38bisbQhETPMANYRbFJFUyOZcOClYvM/hppMhGWuKSFEK9vjrB+bQ== + dependencies: + hoek "6.x.x" + isemail "3.x.x" + topo "3.x.x" + js-levenshtein@^1.1.3: version "1.1.4" resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.4.tgz#3a56e3cbf589ca0081eb22cd9ba0b1290a16d26e" @@ -7228,14 +7249,14 @@ pumpify@^1.3.3: inherits "^2.0.3" pump "^2.0.0" +punycode@2.x.x, punycode@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" -punycode@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" - q@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" @@ -8373,6 +8394,13 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" +topo@3.x.x: + version "3.0.3" + resolved "https://registry.yarnpkg.com/topo/-/topo-3.0.3.tgz#d5a67fb2e69307ebeeb08402ec2a2a6f5f7ad95c" + integrity sha512-IgpPtvD4kjrJ7CRA3ov2FhWQADwv+Tdqbsf1ZnPUSAtCJ9e1Z44MmoSGDXGk4IppoZA7jd/QRkNddlLJWlUZsQ== + dependencies: + hoek "6.x.x" + tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"