diff --git a/packages/cli/package.json b/packages/cli/package.json index fb4e5aca0..a8c4e693a 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 e7779fca4..1b69ccee7 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 000000000..51fd05243 --- /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 04d882097..c3fc82f76 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 9683acbae..7b19753b0 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 3cc2e1af2..7983691c2 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 9172f012b..d3b40c169 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 333185dac..9ba413763 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 2ae9d7df9..aa1569a17 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 000000000..aaa2e0cb7 --- /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 000000000..be2173b51 --- /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 000000000..7fda35955 --- /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 000000000..f6920a8c5 --- /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 000000000..11ebc7915 --- /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 000000000..422a10249 --- /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 000000000..93f058cb9 --- /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 000000000..01901b934 --- /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 278d35cc2..82bd9090c 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 2b5386f8e..000000000 --- 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 286b5bce1..608b73c48 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 495c26190..32682a3e2 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 9adb4ff67..5c7505665 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"