diff --git a/__e2e__/__snapshots__/config.test.ts.snap b/__e2e__/__snapshots__/config.test.ts.snap index 1d8bb1c6d..ac2afe61b 100644 --- a/__e2e__/__snapshots__/config.test.ts.snap +++ b/__e2e__/__snapshots__/config.test.ts.snap @@ -56,6 +56,13 @@ exports[`shows up current config without unnecessary output 1`] = ` "<>" ] }, + { + "name": "spm [action]", + "description": "Set up or maintain Swift Package Manager support for the iOS/macOS app. Actions: add, update, deinit, scaffold. With no action: add (or update if SPM is already set up).", + "options": [ + "<>" + ] + }, { "name": "log-ios", "description": "starts iOS device syslog tail", diff --git a/__e2e__/unknown.test.ts b/__e2e__/unknown.test.ts index a9c581514..ef552ccc9 100644 --- a/__e2e__/unknown.test.ts +++ b/__e2e__/unknown.test.ts @@ -24,3 +24,12 @@ test('suggest matching command', () => { (Did you mean init?)`, ); }); + +test('explain missing @react-native/community-cli-plugin commands', () => { + writeFiles(DIR, {'package.json': '{}'}); + const {exitCode, stderr} = runCLI(DIR, ['start'], {expectedFailure: true}); + expect(exitCode).toBe(1); + expect(stderr).toContain( + 'The "start" command is provided by @react-native/community-cli-plugin, which is not installed in this project.', + ); +}); diff --git a/packages/cli-config/src/__tests__/__snapshots__/index-test.ts.snap b/packages/cli-config/src/__tests__/__snapshots__/index-test.ts.snap index d5720cfa2..d08a5635a 100644 --- a/packages/cli-config/src/__tests__/__snapshots__/index-test.ts.snap +++ b/packages/cli-config/src/__tests__/__snapshots__/index-test.ts.snap @@ -85,15 +85,7 @@ Object { } `; -exports[`should not skip packages that have invalid configuration (to avoid breaking users): dependencies config 1`] = ` -Object { - "react-native": Object { - "name": "react-native", - "platforms": Object {}, - "root": "<>/node_modules/react-native", - }, -} -`; +exports[`should not skip packages that have invalid configuration (to avoid breaking users): dependencies config 1`] = `Object {}`; exports[`should not skip packages that have invalid configuration (to avoid breaking users): logged warning 1`] = `"warn Package react-native contains invalid configuration: \\"dependency.invalidProperty\\" is not allowed. Please verify it's properly linked using \\"npx react-native config\\" command and contact the package maintainers about this."`; @@ -138,14 +130,6 @@ Object { exports[`should return dependencies from package.json 1`] = ` Object { - "react-native": Object { - "name": "react-native", - "platforms": Object { - "android": null, - "ios": null, - }, - "root": "<>/node_modules/react-native", - }, "react-native-test": Object { "name": "react-native-test", "platforms": Object { diff --git a/packages/cli-config/src/__tests__/index-test.ts b/packages/cli-config/src/__tests__/index-test.ts index 49b915e7c..b40b1de76 100644 --- a/packages/cli-config/src/__tests__/index-test.ts +++ b/packages/cli-config/src/__tests__/index-test.ts @@ -1,6 +1,6 @@ import path from 'path'; import slash from 'slash'; -import {loadConfigAsync} from '..'; +import loadConfig, {loadConfigAsync} from '..'; import {cleanup, writeFiles, getTempDirectory} from '../../../../jest/helpers'; let DIR = getTempDirectory('config_test'); @@ -100,6 +100,33 @@ test('should return dependencies from package.json', async () => { expect(removeString(dependencies, DIR)).toMatchSnapshot(); }); +test('does not link "react-native" as a dependency', async () => { + DIR = getTempDirectory('config_test_react_native_not_linked'); + writeFiles(DIR, { + 'node_modules/react-native/package.json': '{}', + 'node_modules/react-native/React-Core-prebuilt.podspec': '', + 'node_modules/react-native-test/package.json': '{}', + 'node_modules/react-native-test/ReactNativeTest.podspec': '', + 'package.json': `{ + "dependencies": { + "react-native": "0.0.1", + "react-native-test": "0.0.1" + } + }`, + }); + const platforms = { + ios: { + projectConfig: require(iosPath).projectConfig, + dependencyConfig: require(iosPath).dependencyConfig, + }, + }; + + const asyncConfig = await loadConfigAsync({projectRoot: DIR, platforms}); + const syncConfig = loadConfig({projectRoot: DIR, platforms}); + expect(Object.keys(asyncConfig.dependencies)).toEqual(['react-native-test']); + expect(Object.keys(syncConfig.dependencies)).toEqual(['react-native-test']); +}); + test('should read a config of a dependency and use it to load other settings', async () => { DIR = getTempDirectory('config_test_settings'); writeFiles(DIR, { diff --git a/packages/cli-config/src/loadConfig.ts b/packages/cli-config/src/loadConfig.ts index b01c2d03b..644d92b51 100644 --- a/packages/cli-config/src/loadConfig.ts +++ b/packages/cli-config/src/loadConfig.ts @@ -84,15 +84,44 @@ const removeDuplicateCommands = (commands: Command[]) => { return Array.from(uniqueCommandsMap.values()); }; +/** + * `react-native` provides the platforms that dependencies are linked into, so + * it is never linked as a dependency itself. + */ +const isLinkableDependency = (dependencyName: string) => + dependencyName !== 'react-native'; + +/** + * Returns the built-in platforms to seed the config with, honoring the + * `selectedPlatform` filter the same way dependency platforms are filtered. The + * project's own and dependencies' platforms are layered on top of these. + */ +const getBasePlatforms = ( + platforms: Config['platforms'] | undefined, + selectedPlatform: string | undefined, +): {[platform: string]: Config['platforms'][string]} => { + if (!platforms) { + return {}; + } + if (selectedPlatform != null) { + return platforms[selectedPlatform] + ? {[selectedPlatform]: platforms[selectedPlatform]} + : {}; + } + return platforms; +}; + /** * Loads CLI configuration */ export default function loadConfig({ projectRoot = findProjectRoot(), selectedPlatform, + platforms, }: { projectRoot?: string; selectedPlatform?: string; + platforms?: Config['platforms']; }): Config { let lazyProject: ProjectConfig; const userConfig = readConfigFromDisk(projectRoot); @@ -110,7 +139,10 @@ export default function loadConfig({ dependencies: userConfig.dependencies, commands: userConfig.commands, healthChecks: userConfig.healthChecks || [], - platforms: userConfig.platforms, + platforms: { + ...getBasePlatforms(platforms, selectedPlatform), + ...userConfig.platforms, + }, assets: userConfig.assets, get project() { if (lazyProject) { @@ -148,17 +180,19 @@ export default function loadConfig({ let config = readDependencyConfigFromDisk(root, dependencyName); return assign({}, acc, { - dependencies: assign({}, acc.dependencies, { - get [dependencyName](): DependencyConfig { - return getDependencyConfig( - root, - dependencyName, - finalConfig, - config, - userConfig, - ); - }, - }), + dependencies: isLinkableDependency(dependencyName) + ? assign({}, acc.dependencies, { + get [dependencyName](): DependencyConfig { + return getDependencyConfig( + root, + dependencyName, + finalConfig, + config, + userConfig, + ); + }, + }) + : acc.dependencies, commands: removeDuplicateCommands([ ...config.commands, ...acc.commands, @@ -188,9 +222,11 @@ export default function loadConfig({ export async function loadConfigAsync({ projectRoot = findProjectRoot(), selectedPlatform, + platforms, }: { projectRoot?: string; selectedPlatform?: string; + platforms?: Config['platforms']; }): Promise { let lazyProject: ProjectConfig; const userConfig = await readConfigFromDiskAsync(projectRoot); @@ -208,7 +244,10 @@ export async function loadConfigAsync({ dependencies: userConfig.dependencies, commands: userConfig.commands, healthChecks: userConfig.healthChecks || [], - platforms: userConfig.platforms, + platforms: { + ...getBasePlatforms(platforms, selectedPlatform), + ...userConfig.platforms, + }, assets: userConfig.assets, get project() { if (lazyProject) { @@ -250,17 +289,19 @@ export async function loadConfigAsync({ ); return assign({}, acc, { - dependencies: assign({}, acc.dependencies, { - get [dependencyName](): DependencyConfig { - return getDependencyConfig( - root, - dependencyName, - finalConfig, - config, - userConfig, - ); - }, - }), + dependencies: isLinkableDependency(dependencyName) + ? assign({}, acc.dependencies, { + get [dependencyName](): DependencyConfig { + return getDependencyConfig( + root, + dependencyName, + finalConfig, + config, + userConfig, + ); + }, + }) + : acc.dependencies, commands: removeDuplicateCommands([ ...config.commands, ...acc.commands, diff --git a/packages/cli/package.json b/packages/cli/package.json index 1e849da07..a5aed9848 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,6 +27,8 @@ "@react-native-community/cli-clean": "20.2.0", "@react-native-community/cli-config": "20.2.0", "@react-native-community/cli-doctor": "20.2.0", + "@react-native-community/cli-platform-android": "20.2.0", + "@react-native-community/cli-platform-ios": "20.2.0", "@react-native-community/cli-server-api": "20.2.0", "@react-native-community/cli-tools": "20.2.0", "@react-native-community/cli-types": "20.2.0", diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 306fd5449..b609cc5e0 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -2,12 +2,16 @@ import {Command, DetachedCommand} from '@react-native-community/cli-types'; import {commands as cleanCommands} from '@react-native-community/cli-clean'; import {commands as doctorCommands} from '@react-native-community/cli-doctor'; import {commands as configCommands} from '@react-native-community/cli-config'; +import {commands as androidCommands} from '@react-native-community/cli-platform-android'; +import {commands as iosCommands} from '@react-native-community/cli-platform-ios'; import init from './init'; export const projectCommands = [ ...configCommands, cleanCommands.clean, doctorCommands.info, + ...iosCommands, + ...androidCommands, ] as Command[]; export const detachedCommands = [ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index dd8fd2223..26781e0d9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,14 @@ import type { Config, DetachedCommand, } from '@react-native-community/cli-types'; +import { + projectConfig as androidProjectConfig, + dependencyConfig as androidDependencyConfig, +} from '@react-native-community/cli-platform-android'; +import { + projectConfig as iosProjectConfig, + dependencyConfig as iosDependencyConfig, +} from '@react-native-community/cli-platform-ios'; import childProcess from 'child_process'; import {Command as CommanderCommand} from 'commander'; import path from 'path'; @@ -149,6 +157,31 @@ const isCommandPassed = (commandName: string) => { return process.argv.filter((arg) => arg === commandName).length > 0; }; +/** + * Commands that `@react-native/community-cli-plugin` registers, which projects + * must list in their own dependencies. + */ +const communityCliPluginCommands = ['bundle', 'codegen', 'spm', 'start']; + +function exitIfCommunityCliPluginMissing(argv: string[]) { + const commandName = argv.slice(2).find((arg) => !arg.startsWith('-')); + + if ( + commandName == null || + !communityCliPluginCommands.includes(commandName) || + program.commands.some((cmd) => cmd.name() === commandName) + ) { + return; + } + + logger.error( + `The "${commandName}" command is provided by ${pico.bold( + '@react-native/community-cli-plugin', + )}, which is not installed in this project. Add it to your devDependencies, at the same version as react-native, then run your package manager's install.`, + ); + process.exit(1); +} + async function setupAndRun(platformName?: string) { // Commander is not available yet @@ -197,6 +230,24 @@ async function setupAndRun(platformName?: string) { config = await loadConfigAsync({ selectedPlatform, + // iOS and Android are core platforms bundled with the CLI, so they are + // always registered. The project's own and dependencies' configs can + // still override them. + // + // The cast is needed because the platform packages' `projectConfig`/ + // `dependencyConfig` return `null` and take required params, whereas the + // `PlatformConfig` interface models these as `void`. These functions are + // the canonical implementations the CLI already calls via the config scan. + platforms: { + ios: { + projectConfig: iosProjectConfig, + dependencyConfig: iosDependencyConfig, + }, + android: { + projectConfig: androidProjectConfig, + dependencyConfig: androidDependencyConfig, + }, + } as Config['platforms'], }); logger.enable(); @@ -211,6 +262,8 @@ async function setupAndRun(platformName?: string) { for (const command of Object.values(commands)) { attachCommand(command, config); } + + exitIfCommunityCliPluginMissing(process.argv); } catch (error) { /** * When there is no `package.json` found, the CLI will enter `detached` mode and a subset diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 3848b08e3..e6d066349 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -9,6 +9,8 @@ {"path": "../cli-config"}, {"path": "../cli-doctor"}, {"path": "../cli-link-assets"}, + {"path": "../cli-platform-android"}, + {"path": "../cli-platform-ios"}, {"path": "../cli-server-api"}, {"path": "../cli-types"}, {"path": "../cli-tools"}