From e5b6ff6a64ea3470ca2f31ead2471761aee7e93e Mon Sep 17 00:00:00 2001 From: Luis Peixoto Date: Wed, 4 Nov 2026 18:06:12 -0300 Subject: [PATCH 1/4] Set minimum compatible community CLI version --- packages/community-cli-plugin/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/community-cli-plugin/package.json b/packages/community-cli-plugin/package.json index c424dcf434a..251c9ed9a0f 100644 --- a/packages/community-cli-plugin/package.json +++ b/packages/community-cli-plugin/package.json @@ -44,7 +44,7 @@ "metro-resolver": "^0.87.2" }, "peerDependencies": { - "@react-native-community/cli": "*", + "@react-native-community/cli": ">=21.0.0-0", "@react-native/metro-config": "0.87.0-main" }, "peerDependenciesMeta": { From 3775c4129ae6b6df356d50fa50a3f3c81b6264ef Mon Sep 17 00:00:00 2001 From: Luis Peixoto Date: Wed, 4 Nov 2026 18:17:50 -0300 Subject: [PATCH 2/4] Warn when the installed Community CLI is too old --- packages/community-cli-plugin/package.json | 2 +- packages/react-native/__tests__/cli-test.js | 73 +++++++++++++++++++++ packages/react-native/cli.js | 37 ++++++++--- 3 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 packages/react-native/__tests__/cli-test.js diff --git a/packages/community-cli-plugin/package.json b/packages/community-cli-plugin/package.json index 251c9ed9a0f..c424dcf434a 100644 --- a/packages/community-cli-plugin/package.json +++ b/packages/community-cli-plugin/package.json @@ -44,7 +44,7 @@ "metro-resolver": "^0.87.2" }, "peerDependencies": { - "@react-native-community/cli": ">=21.0.0-0", + "@react-native-community/cli": "*", "@react-native/metro-config": "0.87.0-main" }, "peerDependenciesMeta": { diff --git a/packages/react-native/__tests__/cli-test.js b/packages/react-native/__tests__/cli-test.js new file mode 100644 index 00000000000..5c4406ba7db --- /dev/null +++ b/packages/react-native/__tests__/cli-test.js @@ -0,0 +1,73 @@ +/** + * 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. + * + * @format + * @noflow + */ + +'use strict'; + +const {spawnSync} = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +describe('react-native CLI version warning', () => { + let projectDir; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-cli-test-')); + }); + + afterEach(() => { + fs.rmSync(projectDir, {recursive: true, force: true}); + }); + + function runConfig(version) { + const cliDir = path.join( + projectDir, + 'node_modules', + '@react-native-community', + 'cli', + ); + fs.mkdirSync(cliDir, {recursive: true}); + fs.writeFileSync( + path.join(cliDir, 'package.json'), + JSON.stringify({version}), + ); + fs.writeFileSync( + path.join(cliDir, 'index.js'), + 'exports.run = () => console.log(JSON.stringify({platforms: {}}));', + ); + return spawnSync( + process.execPath, + [path.join(__dirname, '..', 'cli.js'), 'config'], + {cwd: projectDir, encoding: 'utf8'}, + ); + } + + it('reports an incompatible CLI on stderr and preserves config output', () => { + const {status, stdout, stderr} = runConfig('20.2.0'); + + expect(status).toBe(0); + expect(JSON.parse(stdout)).toEqual({platforms: {}}); + expect(stderr).toContain('@react-native-community/cli@20.2.0'); + expect(stderr).toContain( + '"@react-native-community/cli": ">=21.0.0-alpha.1"', + ); + }); + + it.each(['21.0.0-alpha.1', '21.0.0', '22.0.0-alpha.1'])( + 'accepts CLI %s without a warning', + version => { + const {status, stdout, stderr} = runConfig(version); + + expect(status).toBe(0); + expect(JSON.parse(stdout)).toEqual({platforms: {}}); + expect(stderr).toBe(''); + }, + ); +}); diff --git a/packages/react-native/cli.js b/packages/react-native/cli.js index dfc5847c9d3..b77dc5bf1e7 100755 --- a/packages/react-native/cli.js +++ b/packages/react-native/cli.js @@ -14,6 +14,10 @@ // $FlowFixMe[untyped-import] const {name} = require('./package.json'); const {styleText} = require('node:util'); +const {satisfies} = require('semver'); + +const COMMUNITY_CLI_PACKAGE = '@react-native-community/cli'; +const COMMUNITY_CLI_RANGE = '>=21.0.0-alpha.1'; const deprecated = () => { throw new Error( @@ -21,12 +25,16 @@ const deprecated = () => { ); }; -function findCommunityCli(startDir /*: string */ = process.cwd()) { +function resolveFromProject(request /*: string */) { // With isolated node_modules (eg pnpm), we won't be able to find // `@react-native-community/cli` starting from the `react-native` directory. // Instead, we should use the project root, which we assume to be the cwd. - const options /*: {paths?: Array, ...} */ = {paths: [startDir]}; - const rncli = require.resolve('@react-native-community/cli', options); + const options /*: {paths?: Array, ...} */ = {paths: [process.cwd()]}; + return require.resolve(request, options); +} + +function findCommunityCli() { + const rncli = resolveFromProject(COMMUNITY_CLI_PACKAGE); // $FlowFixMe[unsupported-syntax] return require(rncli); } @@ -49,14 +57,14 @@ let cli /*: Readonly<{ run: deprecated, }; -function warnWithExplicitDependency(version /*: string */ = '*') { +function warnWithExplicitDependency(message /*: string */) { console.warn(` -${styleText('yellow', '⚠')}️ ${styleText(['dim'], 'react-native')} depends on ${styleText('dim', '@react-native-community/cli')} for cli commands. To fix update your ${styleText(['dim'], 'package.json')} to include: +${styleText('yellow', '⚠')}️ ${message} Update your ${styleText(['dim'], 'package.json')} to include: ${styleText( ['white', 'bold'], ` "devDependencies": { - "@react-native-community/cli": "latest", + "${COMMUNITY_CLI_PACKAGE}": "${COMMUNITY_CLI_RANGE}" } `, )} @@ -66,10 +74,23 @@ ${styleText( function main() { try { - return findCommunityCli().run(name); + const communityCli = findCommunityCli(); + const manifestPath = resolveFromProject( + `${COMMUNITY_CLI_PACKAGE}/package.json`, + ); + // $FlowFixMe[unsupported-syntax] + const {version} = require(manifestPath); + if (!satisfies(version, COMMUNITY_CLI_RANGE, {includePrerelease: true})) { + warnWithExplicitDependency( + `react-native found ${COMMUNITY_CLI_PACKAGE}@${version}, but requires ${COMMUNITY_CLI_RANGE} for CLI commands.`, + ); + } + return communityCli.run(name); } catch (e) { if (isMissingCliDependency(e)) { - warnWithExplicitDependency(); + warnWithExplicitDependency( + `react-native depends on ${COMMUNITY_CLI_PACKAGE} for CLI commands.`, + ); process.exit(1); } throw e; From 54e3a805ef87f8037e59b75c6b28925385151038 Mon Sep 17 00:00:00 2001 From: Luis Peixoto Date: Thu, 5 Nov 2026 10:21:53 -0300 Subject: [PATCH 3/4] Read Community CLI compatibility from optional peer metadata --- packages/react-native/__tests__/cli-test.js | 89 +++++++++++++++++---- packages/react-native/cli.js | 4 +- packages/react-native/package.json | 4 + 3 files changed, 80 insertions(+), 17 deletions(-) diff --git a/packages/react-native/__tests__/cli-test.js b/packages/react-native/__tests__/cli-test.js index 5c4406ba7db..148835f45c5 100644 --- a/packages/react-native/__tests__/cli-test.js +++ b/packages/react-native/__tests__/cli-test.js @@ -10,10 +10,17 @@ 'use strict'; +const {devDependencies} = require('../../rn-tester/package.json'); +const {peerDependencies} = require('../package.json'); const {spawnSync} = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const {minVersion} = require('semver'); + +const CLI_PACKAGE = '@react-native-community/cli'; +const CLI_RANGE = peerDependencies[CLI_PACKAGE]; +const MINIMUM_CLI_VERSION = minVersion(CLI_RANGE); describe('react-native CLI version warning', () => { let projectDir; @@ -26,7 +33,7 @@ describe('react-native CLI version warning', () => { fs.rmSync(projectDir, {recursive: true, force: true}); }); - function runConfig(version) { + function runConfig(version, range = CLI_RANGE) { const cliDir = path.join( projectDir, 'node_modules', @@ -42,32 +49,84 @@ describe('react-native CLI version warning', () => { path.join(cliDir, 'index.js'), 'exports.run = () => console.log(JSON.stringify({platforms: {}}));', ); + const reactNativeDir = path.join( + projectDir, + 'node_modules', + 'react-native', + ); + fs.mkdirSync(reactNativeDir, {recursive: true}); + fs.copyFileSync( + path.join(__dirname, '..', 'cli.js'), + path.join(reactNativeDir, 'cli.js'), + ); + fs.writeFileSync( + path.join(reactNativeDir, 'package.json'), + JSON.stringify({ + name: 'react-native', + peerDependencies: {[CLI_PACKAGE]: range}, + }), + ); return spawnSync( process.execPath, - [path.join(__dirname, '..', 'cli.js'), 'config'], - {cwd: projectDir, encoding: 'utf8'}, + [path.join(reactNativeDir, 'cli.js'), 'config'], + { + cwd: projectDir, + encoding: 'utf8', + env: { + ...process.env, + NODE_PATH: path.dirname( + path.dirname(require.resolve('semver/package.json')), + ), + }, + }, ); } + it('keeps the declared minimum on the CLI major exercised by RNTester', () => { + expect(MINIMUM_CLI_VERSION.major).toBe( + minVersion(devDependencies[CLI_PACKAGE]).major, + ); + }); + it('reports an incompatible CLI on stderr and preserves config output', () => { const {status, stdout, stderr} = runConfig('20.2.0'); expect(status).toBe(0); expect(JSON.parse(stdout)).toEqual({platforms: {}}); expect(stderr).toContain('@react-native-community/cli@20.2.0'); - expect(stderr).toContain( - '"@react-native-community/cli": ">=21.0.0-alpha.1"', - ); + expect(stderr).toContain(`"${CLI_PACKAGE}": "${CLI_RANGE}"`); }); - it.each(['21.0.0-alpha.1', '21.0.0', '22.0.0-alpha.1'])( - 'accepts CLI %s without a warning', - version => { - const {status, stdout, stderr} = runConfig(version); + it.each([ + MINIMUM_CLI_VERSION.version, + [ + MINIMUM_CLI_VERSION.major, + MINIMUM_CLI_VERSION.minor, + MINIMUM_CLI_VERSION.patch, + ].join('.'), + `${MINIMUM_CLI_VERSION.major + 1}.0.0-alpha.1`, + ])('accepts CLI %s without a warning', version => { + const {status, stdout, stderr} = runConfig(version); - expect(status).toBe(0); - expect(JSON.parse(stdout)).toEqual({platforms: {}}); - expect(stderr).toBe(''); - }, - ); + expect(status).toBe(0); + expect(JSON.parse(stdout)).toEqual({platforms: {}}); + expect(stderr).toBe(''); + }); + + it('uses an updated peer range for the warning and install suggestion', () => { + const range = '>=22.0.0'; + const {status, stdout, stderr} = runConfig('21.0.0', range); + + expect(status).toBe(0); + expect(JSON.parse(stdout)).toEqual({platforms: {}}); + expect(stderr).toContain(`requires ${range}`); + expect(stderr).toContain(`"${CLI_PACKAGE}": "${range}"`); + }); + + it('accepts the version allowed by an updated peer range', () => { + const {status, stderr} = runConfig('20.2.0', '>=20.0.0'); + + expect(status).toBe(0); + expect(stderr).toBe(''); + }); }); diff --git a/packages/react-native/cli.js b/packages/react-native/cli.js index b77dc5bf1e7..fa9167e350f 100755 --- a/packages/react-native/cli.js +++ b/packages/react-native/cli.js @@ -12,12 +12,12 @@ 'use strict'; // $FlowFixMe[untyped-import] -const {name} = require('./package.json'); +const {name, peerDependencies} = require('./package.json'); const {styleText} = require('node:util'); const {satisfies} = require('semver'); const COMMUNITY_CLI_PACKAGE = '@react-native-community/cli'; -const COMMUNITY_CLI_RANGE = '>=21.0.0-alpha.1'; +const COMMUNITY_CLI_RANGE = peerDependencies[COMMUNITY_CLI_PACKAGE]; const deprecated = () => { throw new Error( diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 5142e53d0a7..9e5971b70ef 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -141,10 +141,14 @@ "featureflags": "node ./scripts/featureflags/index.js" }, "peerDependencies": { + "@react-native-community/cli": ">=21.0.0-alpha.1", "@types/react": "^19.1.1", "react": "^19.3.0" }, "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, "@types/react": { "optional": true } From 78d6d2574308f0826dfcdceebc708e962c2ded22 Mon Sep 17 00:00:00 2001 From: Luis Peixoto Date: Fri, 6 Nov 2026 15:25:57 -0300 Subject: [PATCH 4/4] Reject incompatible Community CLI versions --- packages/react-native/__tests__/cli-test.js | 95 +++++++-------------- packages/react-native/cli.js | 56 ++++++------ packages/react-native/package.json | 4 - 3 files changed, 55 insertions(+), 100 deletions(-) diff --git a/packages/react-native/__tests__/cli-test.js b/packages/react-native/__tests__/cli-test.js index 148835f45c5..01b43a4c2b8 100644 --- a/packages/react-native/__tests__/cli-test.js +++ b/packages/react-native/__tests__/cli-test.js @@ -10,19 +10,12 @@ 'use strict'; -const {devDependencies} = require('../../rn-tester/package.json'); -const {peerDependencies} = require('../package.json'); const {spawnSync} = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const {minVersion} = require('semver'); -const CLI_PACKAGE = '@react-native-community/cli'; -const CLI_RANGE = peerDependencies[CLI_PACKAGE]; -const MINIMUM_CLI_VERSION = minVersion(CLI_RANGE); - -describe('react-native CLI version warning', () => { +describe('react-native CLI compatibility', () => { let projectDir; beforeEach(() => { @@ -33,7 +26,7 @@ describe('react-native CLI version warning', () => { fs.rmSync(projectDir, {recursive: true, force: true}); }); - function runConfig(version, range = CLI_RANGE) { + function runConfig(version) { const cliDir = path.join( projectDir, 'node_modules', @@ -55,18 +48,13 @@ describe('react-native CLI version warning', () => { 'react-native', ); fs.mkdirSync(reactNativeDir, {recursive: true}); - fs.copyFileSync( - path.join(__dirname, '..', 'cli.js'), - path.join(reactNativeDir, 'cli.js'), - ); - fs.writeFileSync( - path.join(reactNativeDir, 'package.json'), - JSON.stringify({ - name: 'react-native', - peerDependencies: {[CLI_PACKAGE]: range}, - }), - ); - return spawnSync( + for (const file of ['cli.js', 'package.json']) { + fs.copyFileSync( + path.join(__dirname, '..', file), + path.join(reactNativeDir, file), + ); + } + const {status, stdout, stderr} = spawnSync( process.execPath, [path.join(reactNativeDir, 'cli.js'), 'config'], { @@ -80,53 +68,28 @@ describe('react-native CLI version warning', () => { }, }, ); + return {status, stdout, stderr}; } - it('keeps the declared minimum on the CLI major exercised by RNTester', () => { - expect(MINIMUM_CLI_VERSION.major).toBe( - minVersion(devDependencies[CLI_PACKAGE]).major, - ); - }); - - it('reports an incompatible CLI on stderr and preserves config output', () => { - const {status, stdout, stderr} = runConfig('20.2.0'); - - expect(status).toBe(0); - expect(JSON.parse(stdout)).toEqual({platforms: {}}); - expect(stderr).toContain('@react-native-community/cli@20.2.0'); - expect(stderr).toContain(`"${CLI_PACKAGE}": "${CLI_RANGE}"`); + it('refuses to run an incompatible CLI with an upgrade error', () => { + expect(runConfig('20.2.0')).toMatchInlineSnapshot(` +Object { + "status": 1, + "stderr": "Error: Refusing to use @react-native-community/cli, as the installed version is incompatible with this version of React Native. Please upgrade @react-native-community/cli dependencies to 21.x.x. +", + "stdout": "", +} +`); }); - it.each([ - MINIMUM_CLI_VERSION.version, - [ - MINIMUM_CLI_VERSION.major, - MINIMUM_CLI_VERSION.minor, - MINIMUM_CLI_VERSION.patch, - ].join('.'), - `${MINIMUM_CLI_VERSION.major + 1}.0.0-alpha.1`, - ])('accepts CLI %s without a warning', version => { - const {status, stdout, stderr} = runConfig(version); - - expect(status).toBe(0); - expect(JSON.parse(stdout)).toEqual({platforms: {}}); - expect(stderr).toBe(''); - }); - - it('uses an updated peer range for the warning and install suggestion', () => { - const range = '>=22.0.0'; - const {status, stdout, stderr} = runConfig('21.0.0', range); - - expect(status).toBe(0); - expect(JSON.parse(stdout)).toEqual({platforms: {}}); - expect(stderr).toContain(`requires ${range}`); - expect(stderr).toContain(`"${CLI_PACKAGE}": "${range}"`); - }); - - it('accepts the version allowed by an updated peer range', () => { - const {status, stderr} = runConfig('20.2.0', '>=20.0.0'); - - expect(status).toBe(0); - expect(stderr).toBe(''); - }); + it.each(['21.0.0-alpha.1', '21.0.0', '22.0.0-alpha.1'])( + 'runs compatible CLI %s without an error', + version => { + expect(runConfig(version)).toEqual({ + status: 0, + stdout: '{"platforms":{}}\n', + stderr: '', + }); + }, + ); }); diff --git a/packages/react-native/cli.js b/packages/react-native/cli.js index fa9167e350f..609f647ff23 100755 --- a/packages/react-native/cli.js +++ b/packages/react-native/cli.js @@ -12,12 +12,12 @@ 'use strict'; // $FlowFixMe[untyped-import] -const {name, peerDependencies} = require('./package.json'); +const {name} = require('./package.json'); const {styleText} = require('node:util'); -const {satisfies} = require('semver'); +const {major} = require('semver'); const COMMUNITY_CLI_PACKAGE = '@react-native-community/cli'; -const COMMUNITY_CLI_RANGE = peerDependencies[COMMUNITY_CLI_PACKAGE]; +const MIN_SUPPORTED_CLI_MAJOR = 21; const deprecated = () => { throw new Error( @@ -25,16 +25,23 @@ const deprecated = () => { ); }; -function resolveFromProject(request /*: string */) { +function findCommunityCli(startDir /*: string */ = process.cwd()) { // With isolated node_modules (eg pnpm), we won't be able to find // `@react-native-community/cli` starting from the `react-native` directory. // Instead, we should use the project root, which we assume to be the cwd. - const options /*: {paths?: Array, ...} */ = {paths: [process.cwd()]}; - return require.resolve(request, options); -} - -function findCommunityCli() { - const rncli = resolveFromProject(COMMUNITY_CLI_PACKAGE); + const options /*: {paths?: Array, ...} */ = {paths: [startDir]}; + const rncli = require.resolve(COMMUNITY_CLI_PACKAGE, options); + const manifestPath = require.resolve( + `${COMMUNITY_CLI_PACKAGE}/package.json`, + options, + ); + // $FlowFixMe[unsupported-syntax] + const {version} = require(manifestPath); + if (major(version) < MIN_SUPPORTED_CLI_MAJOR) { + throw new Error( + `Refusing to use ${COMMUNITY_CLI_PACKAGE}, as the installed version is incompatible with this version of React Native. Please upgrade ${COMMUNITY_CLI_PACKAGE} dependencies to ${MIN_SUPPORTED_CLI_MAJOR}.x.x.`, + ); + } // $FlowFixMe[unsupported-syntax] return require(rncli); } @@ -57,14 +64,14 @@ let cli /*: Readonly<{ run: deprecated, }; -function warnWithExplicitDependency(message /*: string */) { +function warnWithExplicitDependency(version /*: string */ = '*') { console.warn(` -${styleText('yellow', '⚠')}️ ${message} Update your ${styleText(['dim'], 'package.json')} to include: +${styleText('yellow', '⚠')}️ ${styleText(['dim'], 'react-native')} depends on ${styleText('dim', '@react-native-community/cli')} for cli commands. To fix update your ${styleText(['dim'], 'package.json')} to include: ${styleText( ['white', 'bold'], ` "devDependencies": { - "${COMMUNITY_CLI_PACKAGE}": "${COMMUNITY_CLI_RANGE}" + "@react-native-community/cli": "latest", } `, )} @@ -74,26 +81,15 @@ ${styleText( function main() { try { - const communityCli = findCommunityCli(); - const manifestPath = resolveFromProject( - `${COMMUNITY_CLI_PACKAGE}/package.json`, - ); - // $FlowFixMe[unsupported-syntax] - const {version} = require(manifestPath); - if (!satisfies(version, COMMUNITY_CLI_RANGE, {includePrerelease: true})) { - warnWithExplicitDependency( - `react-native found ${COMMUNITY_CLI_PACKAGE}@${version}, but requires ${COMMUNITY_CLI_RANGE} for CLI commands.`, - ); - } - return communityCli.run(name); + return findCommunityCli().run(name); } catch (e) { if (isMissingCliDependency(e)) { - warnWithExplicitDependency( - `react-native depends on ${COMMUNITY_CLI_PACKAGE} for CLI commands.`, - ); - process.exit(1); + warnWithExplicitDependency(); + } else { + console.error(String(e)); } - throw e; + process.exitCode = 1; + return; } } diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 9e5971b70ef..5142e53d0a7 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -141,14 +141,10 @@ "featureflags": "node ./scripts/featureflags/index.js" }, "peerDependencies": { - "@react-native-community/cli": ">=21.0.0-alpha.1", "@types/react": "^19.1.1", "react": "^19.3.0" }, "peerDependenciesMeta": { - "@react-native-community/cli": { - "optional": true - }, "@types/react": { "optional": true }