diff --git a/packages/community-cli-plugin/README.md b/packages/community-cli-plugin/README.md index 030640fdc67b..109193ff9c81 100644 --- a/packages/community-cli-plugin/README.md +++ b/packages/community-cli-plugin/README.md @@ -75,6 +75,48 @@ npx @react-native-community/cli bundle --entry-file [options] | `--read-global-cache` | Attempt to fetch transformed JS code from the global cache, if configured. Defaults to `false`. | | `--config ` | Path to the CLI configuration file. | +### `codegen` + +Run the React Native codegen, generating native boilerplate from JS spec files. + +#### Usage + +```sh +npx @react-native-community/cli codegen [options] +``` + +#### Options + +| Option | Description | +| - | - | +| `--path ` | Path to the React Native project root. Defaults to the current working directory. | +| `--platform ` | Target platform. Supported values: `"android"`, `"ios"`, `"all"`. Defaults to `"all"`. | +| `--outputPath ` | Path where generated artifacts will be output to. | +| `--source ` | Whether the script is invoked from an `app` or a `library`. Defaults to `"app"`. | + +### `spm [action]` + +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). + +#### Usage + +```sh +npx @react-native-community/cli spm [action] [options] +``` + +#### Options + +| Option | Description | +| - | - | +| `--version ` | React Native version (e.g. `0.80.0`). Defaults to the version in `node_modules/react-native/package.json`. | +| `--yes` | Skip the dirty-pbxproj confirmation prompt. | +| `--xcodeproj ` | **[add]** Path to the `.xcodeproj` to inject SPM packages into (disambiguates when several exist). | +| `--productName ` | **[add]** App target to inject into (disambiguates when several exist). | +| `--deintegrate` | **[add]** Run `pod deintegrate` and strip React Native from the Podfile before injecting (CocoaPods → SwiftPM migration). | +| `--artifacts ` | **[advanced]** Local artifact root containing complete `debug/` and `release/` slots. | +| `--download ` | **[advanced]** Artifact download policy: `auto` (default), `skip`, or `force`. | +| `--skipCodegen` | **[advanced]** Skip the react-native codegen step. | + ## Contributing Changes to this package can be made locally and tested against the `rn-tester` app, per the [Contributing guide](https://reactnative.dev/contributing/overview#contributing-code). During development, this package is automatically run from source with no build step. diff --git a/packages/community-cli-plugin/package.json b/packages/community-cli-plugin/package.json index 1bf9ab2c8aa4..c780f698a964 100644 --- a/packages/community-cli-plugin/package.json +++ b/packages/community-cli-plugin/package.json @@ -33,6 +33,7 @@ "dependencies": { "@react-native/asset-utils": "0.87.0-main", "@react-native/dev-middleware": "0.87.0-main", + "commander": "^12.0.0", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.87.0", diff --git a/packages/community-cli-plugin/src/commands/bundle/index.js b/packages/community-cli-plugin/src/commands/bundle/index.js index 28073cd9fd99..7f82ef81d672 100644 --- a/packages/community-cli-plugin/src/commands/bundle/index.js +++ b/packages/community-cli-plugin/src/commands/bundle/index.js @@ -8,14 +8,22 @@ * @format */ -import type {Command} from '@react-native-community/cli-types'; +import type {Command as CommunityCommand} from '@react-native-community/cli-types'; import buildBundle from './buildBundle'; +import {Command} from 'commander'; import path from 'node:path'; export type {BundleCommandArgs} from './buildBundle'; -const bundleCommand: Command = { +type CommandOption = Readonly[number]>; + +type BundleCommandParser = { + parser: Command, + baseHelpInformation: string, +}; + +const bundleCommand: CommunityCommand = { name: 'bundle', description: 'Build the bundle for the provided JavaScript entry file.', func: buildBundle, @@ -123,4 +131,43 @@ const bundleCommand: Command = { ], }; +function addOptions( + command: Command, + options: ReadonlyArray, +): void { + for (const option of options) { + const description = option.description ?? ''; + const defaultValue = + typeof option.default === 'function' ? undefined : option.default; + + if (option.parse != null) { + command.option(option.name, description, option.parse, defaultValue); + } else if ( + typeof defaultValue === 'string' || + typeof defaultValue === 'boolean' || + Array.isArray(defaultValue) + ) { + command.option(option.name, description, defaultValue); + } else { + command.option(option.name, description); + } + } +} + +export function unstable_createBundleCommandParser( + additionalOptions: ReadonlyArray = [], +): BundleCommandParser { + const parser = new Command() + .name(bundleCommand.name) + .description(bundleCommand.description ?? '') + .helpOption('--help', 'Display help for command') + .allowUnknownOption(); + + addOptions(parser, bundleCommand.options ?? []); + const baseHelpInformation = parser.helpInformation(); + addOptions(parser, additionalOptions); + + return {parser, baseHelpInformation}; +} + export default bundleCommand; diff --git a/packages/community-cli-plugin/src/commands/codegen.js b/packages/community-cli-plugin/src/commands/codegen.js new file mode 100644 index 000000000000..cb158a1e8bb6 --- /dev/null +++ b/packages/community-cli-plugin/src/commands/codegen.js @@ -0,0 +1,63 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {Command, Config} from '@react-native-community/cli-types'; + +export type CodegenCommandArgs = { + path: string, + platform: string, + outputPath?: string, + source: string, +}; + +const codegenCommand: Command = { + name: 'codegen', + options: [ + { + name: '--path ', + description: 'Path to the React Native project root.', + default: process.cwd(), + }, + { + name: '--platform ', + description: + 'Target platform. Supported values: "android", "ios", "all".', + default: 'all', + }, + { + name: '--outputPath ', + description: 'Path where generated artifacts will be output to.', + }, + { + name: '--source ', + description: 'Whether the script is invoked from an `app` or a `library`', + default: 'app', + }, + ], + func: ( + argv: Array, + config: Config, + args: CodegenCommandArgs, + ): void => { + const generateArtifactsExecutor = require.resolve( + 'react-native/scripts/codegen/generate-artifacts-executor', + {paths: [config.root]}, + ); + // $FlowFixMe[unsupported-syntax] dynamic require of a resolved path + require(generateArtifactsExecutor).execute( + args.path, + args.platform, + args.outputPath, + args.source, + ); + }, +}; + +export default codegenCommand; diff --git a/packages/community-cli-plugin/src/commands/spm.js b/packages/community-cli-plugin/src/commands/spm.js new file mode 100644 index 000000000000..181ad4ff812e --- /dev/null +++ b/packages/community-cli-plugin/src/commands/spm.js @@ -0,0 +1,118 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {Command, Config} from '@react-native-community/cli-types'; + +export type SpmCommandArgs = { + version?: string, + yes?: boolean, + xcodeproj?: string, + productName?: string, + deintegrate?: boolean, + artifacts?: string, + download?: string, + skipCodegen?: boolean, +}; + +const spmCommand: Command = { + 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: '--version ', + description: + 'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json.', + }, + { + name: '--yes', + description: 'Skip the dirty-pbxproj confirmation prompt.', + }, + { + name: '--xcodeproj ', + description: + '[add] Path to the .xcodeproj to inject SPM packages into ' + + '(disambiguates when several exist).', + }, + { + name: '--productName ', + description: + '[add] App target to inject into (disambiguates when several exist).', + }, + { + name: '--deintegrate', + description: + '[add] Run `pod deintegrate` and strip React Native from the Podfile ' + + 'before injecting (CocoaPods → SwiftPM migration).', + }, + { + name: '--artifacts ', + description: + '[advanced] Local artifact root containing complete debug/ and release/ slots.', + }, + { + name: '--download ', + description: + '[advanced] Artifact download policy: auto (default), skip, or force.', + }, + { + name: '--skipCodegen', + description: '[advanced] Skip the react-native codegen step.', + }, + ], + func: async ( + argv: Array, + config: Config, + args: SpmCommandArgs, + ): Promise => { + const passthrough: Array = []; + if (argv[0] != null) { + passthrough.push(argv[0]); + } + const stringOpts: Array< + [ + 'version' | 'productName' | 'xcodeproj' | 'artifacts' | 'download', + string, + ], + > = [ + ['version', '--version'], + ['productName', '--product-name'], + ['xcodeproj', '--xcodeproj'], + ['artifacts', '--artifacts'], + ['download', '--download'], + ]; + for (const [key, flag] of stringOpts) { + const value = args[key]; + if (value != null) { + passthrough.push(flag, String(value)); + } + } + const boolOpts: Array<['skipCodegen' | 'deintegrate' | 'yes', string]> = [ + ['skipCodegen', '--skip-codegen'], + ['deintegrate', '--deintegrate'], + ['yes', '--yes'], + ]; + for (const [key, flag] of boolOpts) { + if (args[key] === true) { + passthrough.push(flag); + } + } + const setupAppleSpm = require.resolve( + 'react-native/scripts/setup-apple-spm', + {paths: [config.root]}, + ); + // $FlowFixMe[unsupported-syntax] dynamic require of a resolved path + await require(setupAppleSpm).main(passthrough); + }, +}; + +export default spmCommand; diff --git a/packages/community-cli-plugin/src/index.flow.js b/packages/community-cli-plugin/src/index.flow.js index 1f8177f03313..7b0fa97aceba 100644 --- a/packages/community-cli-plugin/src/index.flow.js +++ b/packages/community-cli-plugin/src/index.flow.js @@ -8,7 +8,12 @@ * @format */ -export {default as bundleCommand} from './commands/bundle'; +export { + default as bundleCommand, + unstable_createBundleCommandParser, +} from './commands/bundle'; +export {default as codegenCommand} from './commands/codegen'; +export {default as spmCommand} from './commands/spm'; export {default as startCommand} from './commands/start'; export {unstable_buildBundleWithConfig} from './commands/bundle/buildBundle'; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 19cc2133e023..091d8770a40b 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -159,7 +159,6 @@ "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.37.0", "base64-js": "^1.5.1", - "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "0.0.0", "invariant": "^2.2.4", diff --git a/packages/react-native/react-native.config.js b/packages/react-native/react-native.config.js index 44fb2a25229b..36cc09508d4a 100644 --- a/packages/react-native/react-native.config.js +++ b/packages/react-native/react-native.config.js @@ -14,14 +14,16 @@ import type {Command} from '@react-native-community/cli-types'; */ -// React Native shouldn't be exporting itself like this, the Community Template should be be directly -// depending on and injecting: +// IMPORTANT: This is a routing file only. Do NOT add new command +// definitions or implementations here. +// +// New CLI commands belong in @react-native/community-cli-plugin, and +// may (temporarily) be imported and registered here. +// +// Future state: The Community Template should directly depend on and inject: // - @react-native-community/cli-platform-android // - @react-native-community/cli-platform-ios // - @react-native/community-cli-plugin -// - codegen command should be inhoused into @react-native-community/cli -// -// This is a temporary workaround. const verbose = Boolean(process.env.DEBUG?.includes('react-native')); @@ -72,126 +74,12 @@ const commands /*: Array */ = []; const { bundleCommand, + codegenCommand, + spmCommand, startCommand, } = require('@react-native/community-cli-plugin'); -commands.push(bundleCommand, startCommand); - -const codegenCommand /*: Command */ = { - name: 'codegen', - options: [ - { - name: '--path ', - description: 'Path to the React Native project root.', - default: process.cwd(), - }, - { - name: '--platform ', - description: - 'Target platform. Supported values: "android", "ios", "all".', - default: 'all', - }, - { - name: '--outputPath ', - description: 'Path where generated artifacts will be output to.', - }, - { - name: '--source ', - description: 'Whether the script is invoked from an `app` or a `library`', - default: 'app', - }, - ], - func: (argv, config, args) => - require('./scripts/codegen/generate-artifacts-executor').execute( - args.path, - args.platform, - args.outputPath, - args.source, - ), -}; - -commands.push(codegenCommand); - -const spmCommand /*: Command */ = { - 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: '--version ', - description: - 'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json.', - }, - { - name: '--yes', - description: 'Skip the dirty-pbxproj confirmation prompt.', - }, - { - name: '--xcodeproj ', - description: - '[add] Path to the .xcodeproj to inject SPM packages into ' + - '(disambiguates when several exist).', - }, - { - name: '--productName ', - description: - '[add] App target to inject into (disambiguates when several exist).', - }, - { - name: '--deintegrate', - description: - '[add] Run `pod deintegrate` and strip React Native from the Podfile ' + - 'before injecting (CocoaPods → SwiftPM migration).', - }, - { - name: '--artifacts ', - description: - '[advanced] Local artifact root containing complete debug/ and release/ slots.', - }, - { - name: '--download ', - description: - '[advanced] Artifact download policy: auto (default), skip, or force.', - }, - { - name: '--skipCodegen', - description: '[advanced] Skip the react-native codegen step.', - }, - ], - func: async (argv, _config, args) => { - const passthrough /*: Array */ = []; - if (argv[0] != null) { - passthrough.push(argv[0]); - } - const stringOpts /*: Array<[string, string]> */ = [ - ['version', '--version'], - ['productName', '--product-name'], - ['xcodeproj', '--xcodeproj'], - ['artifacts', '--artifacts'], - ['download', '--download'], - ]; - for (const [key, flag] of stringOpts) { - if (args[key] != null) { - passthrough.push(flag, String(args[key])); - } - } - const boolOpts /*: Array<[string, string]> */ = [ - ['skipCodegen', '--skip-codegen'], - ['deintegrate', '--deintegrate'], - ['yes', '--yes'], - ]; - for (const [key, flag] of boolOpts) { - if (args[key]) { - passthrough.push(flag); - } - } - await require('./scripts/setup-apple-spm').main(passthrough); - }, -}; - -commands.push(spmCommand); +commands.push(bundleCommand, startCommand, spmCommand, codegenCommand); const config = { commands, diff --git a/packages/react-native/scripts/bundle.js b/packages/react-native/scripts/bundle.js index 8feb593cf3a6..75783b06919f 100644 --- a/packages/react-native/scripts/bundle.js +++ b/packages/react-native/scripts/bundle.js @@ -10,73 +10,79 @@ 'use strict'; -const {bundleCommand: bc} = require('@react-native/community-cli-plugin'); -const commander = require('commander'); +const { + bundleCommand: bundleCommandBase, + unstable_createBundleCommandParser, +} = require('@react-native/community-cli-plugin'); const {execSync} = require('node:child_process'); -const {readFileSync} = require('node:fs'); -const path = require('node:path'); -// Commander 12.0.0 changes from the global to named export -// $FlowFixMe[signature-verification-failure] -const program = commander.program ?? commander; +/*:: +type BundleOptions = { + configCmd?: string, + loadConfig?: string, + ... +}; +*/ -program.version( - JSON.parse( - readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'), - ).version, -); +const baseOptions = [ + { + name: '--config-cmd ', + description: 'Command to generate a JSON project config', + default: 'npx react-native config', + }, + { + name: '--load-config ', + description: 'JSON project config', + }, +]; -program - .name(bc.name) - .description(bc.description ?? '') - .option( - '--config-cmd ', - 'Command to generate a JSON project config', - 'npx react-native config', - ) - .option('--load-config ', 'JSON project config') - .option('--verbose', 'Additional logs', () => true, false) - .allowUnknownOption() - .action(async function handleAction() { - let config = null; - let options = program - .opts /*::<{ - configCmd?: string, - loadConfig?: string, - verbose: boolean, - ... - }>*/ - (); - if (options.loadConfig != null) { - config = JSON.parse( - options.loadConfig.replace(/^\W*'/, '').replace(/'\W*$/, ''), - ); - } else if (options.configCmd != null) { - config = JSON.parse( - execSync(options.configCmd.trim(), {encoding: 'utf8'}), - ); - } +const { + parser: bundleCommandParser, + baseHelpInformation: bundleCommandBaseHelp, +} = unstable_createBundleCommandParser(baseOptions); - if (config == null) { - throw new Error('No config provided'); - } +function formatOptions( + options /*: ReadonlyArray> */, +) { + return options + .map(option => ` ${option.name.padEnd(35)} ${option.description ?? ''}`) + .join('\n'); +} + +function printHelp() { + console.log(`${bundleCommandBaseHelp} +Additional options: +${formatOptions(baseOptions)} +`); +} + +async function main(argv /*: ReadonlyArray */ = process.argv.slice(2)) { + if (argv.includes('--help')) { + printHelp(); + return; + } - await bc.func(program.args, config, options); - }); + bundleCommandParser.parse(argv, {from: 'user'}); + const options = bundleCommandParser + .opts /*::*/ + (); -if (bc.options != null) { - for (const o of bc.options) { - program.option( - o.name, - o.description ?? '', - o.parse ?? (value => value), - o.default, + let config = null; + if (options.loadConfig != null) { + config = JSON.parse( + options.loadConfig.replace(/^\W*'/, '').replace(/'\W*$/, ''), ); + } else if (options.configCmd != null) { + config = JSON.parse(execSync(options.configCmd.trim(), {encoding: 'utf8'})); } + + if (config == null) { + throw new Error('No config provided'); + } + + await bundleCommandBase.func(bundleCommandParser.args, config, options); } if (require.main === module) { - program.parse(process.argv); + void main(); } - -module.exports = program;