From 2a4bdc5d0199c959ee72dea796d256a6deb0b783 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Sat, 31 Oct 2026 09:44:06 +0100 Subject: [PATCH] feat(init): set up iOS with Swift Package Manager by default On macOS, for React Native 0.87 and later, `init` now runs React Native's Swift Package Manager setup (`setup-apple-spm add --deintegrate --yes`) after dependencies are installed, instead of installing CocoaPods. `--skip-spm` keeps the CocoaPods setup. `--install-pods true` without `--skip-spm` is an error where the setup would run. A failed setup prints a warning and a retry command; init still finishes. --- docs/commands.md | 6 +- .../src/commands/init/__tests__/init.test.ts | 291 +++++++++++++++++ .../__tests__/printRunInstructions.test.ts | 107 +++++++ .../src/commands/init/__tests__/spm.test.ts | 297 ++++++++++++++++++ packages/cli/src/commands/init/index.ts | 7 +- packages/cli/src/commands/init/init.ts | 41 ++- .../src/commands/init/printRunInstructions.ts | 41 ++- packages/cli/src/commands/init/spm.ts | 116 +++++++ packages/cli/src/commands/init/types.ts | 1 + 9 files changed, 892 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/commands/init/__tests__/init.test.ts create mode 100644 packages/cli/src/commands/init/__tests__/printRunInstructions.test.ts create mode 100644 packages/cli/src/commands/init/__tests__/spm.test.ts create mode 100644 packages/cli/src/commands/init/spm.ts diff --git a/docs/commands.md b/docs/commands.md index 18c9af230..a605a1b8b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -98,7 +98,11 @@ Skip dependencies installation #### `--install-pods [boolean]` -Determine if CocoaPods should be installed when initializing a project. If set to `true` it will install pods, if set to `false`, it will skip the step entirely. If not used, prompt will be displayed +Determine if CocoaPods should be installed when initializing a project. If set to `true` it will install pods, if set to `false`, it will skip the step entirely. If not used, prompt will be displayed. On macOS with React Native 0.87+, `init` uses Swift Package Manager instead; add `--skip-spm` to use CocoaPods. With `--template` or an out-of-tree `--platform-name`, `--install-pods true` keeps CocoaPods. + +#### `--skip-spm` + +Use CocoaPods instead of Swift Package Manager for iOS dependencies. On macOS, starting with React Native 0.87, `init` sets up iOS dependencies with Swift Package Manager, the same as `npx react-native spm add --deintegrate`. With this flag, `init` keeps the CocoaPods setup, and `--install-pods` decides whether pods are installed. Passing `--install-pods true` without this flag is an error for React Native 0.87 and later on macOS. The error applies only when `init` can look up the React Native version; if the lookup fails, for example offline, there is no error and `init` uses CocoaPods. It also applies only without `--skip-install` and with the default template. It does not apply with `--template` or with an out-of-tree `--platform-name`. #### `--pm ` diff --git a/packages/cli/src/commands/init/__tests__/init.test.ts b/packages/cli/src/commands/init/__tests__/init.test.ts new file mode 100644 index 000000000..42c2771c7 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/init.test.ts @@ -0,0 +1,291 @@ +import fs from 'fs'; +import path from 'path'; +import execa from 'execa'; +import * as cliTools from '@react-native-community/cli-tools'; +import {installPods} from '@react-native-community/cli-platform-apple'; +import {runCodegen} from '@react-native-community/cli-config-apple'; +import { + cleanup, + getTempDirectory, + writeFiles, +} from '../../../../../../jest/helpers'; +import * as PackageManager from '../../../tools/packageManager'; +import {npmResolveConcreteVersion} from '../../../tools/npm'; +import {installTemplatePackage, getTemplateConfig} from '../template'; +import {createTemplateUri} from '../version'; +import {checkGitInstallation} from '../git'; +import printRunInstructions from '../printRunInstructions'; +import initialize from '../init'; +import type {Options} from '../types'; + +jest.mock('execa', () => jest.fn()); +jest.mock('@react-native-community/cli-tools', () => ({ + ...jest.requireActual('@react-native-community/cli-tools'), + logger: { + log: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + isVerbose: jest.fn(), + }, + prompt: jest.fn(), + getLoader: jest.fn(), + cacheManager: {removeProjectCache: jest.fn(), set: jest.fn()}, +})); +jest.mock('@react-native-community/cli-platform-apple', () => ({ + installPods: jest.fn(), +})); +jest.mock('@react-native-community/cli-config-apple', () => ({ + runCodegen: jest.fn(), +})); +jest.mock('../../../tools/npm', () => ({ + npmResolveConcreteVersion: jest.fn(), +})); +jest.mock('../version', () => ({ + createTemplateUri: jest.fn(), +})); +jest.mock('../git', () => ({ + checkGitInstallation: jest.fn(), +})); +jest.mock('../template'); +jest.mock('../editTemplate'); +jest.mock('../printRunInstructions'); + +const PROJECT_NAME = 'TestApp'; +const POD_PROMPT = 'Do you want to install CocoaPods now?'; + +const originalPlatform = process.platform; +const originalCwd = process.cwd(); + +// A directory per test, because module resolution results are cached per path. +let rootCount = 0; +let ROOT: string; +let PROJECT_DIR: string; + +function promptMessages() { + return (cliTools.prompt as jest.Mock).mock.calls.map(([{message}]) => + String(message), + ); +} + +function installReactNative({withSpmScript}: {withSpmScript: boolean}) { + jest + .spyOn(PackageManager, 'installAll') + .mockImplementation(async ({root}) => { + writeFiles(root, { + 'node_modules/react-native/package.json': JSON.stringify({ + name: 'react-native', + main: './index.js', + exports: { + '.': './index.js', + './scripts/*': './scripts/*.js', + './package.json': './package.json', + }, + }), + 'node_modules/react-native/index.js': '', + ...(withSpmScript + ? {'node_modules/react-native/scripts/setup-apple-spm.js': ''} + : {}), + }); + return undefined as unknown as execa.ExecaReturnValue; + }); +} + +function init(options: Partial = {}) { + return initialize([PROJECT_NAME], options as Options); +} + +beforeEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + Object.defineProperty(process, 'platform', {value: 'darwin'}); + + const directory = getTempDirectory(`init-wiring-${++rootCount}`); + fs.mkdirSync(directory, {recursive: true}); + ROOT = fs.realpathSync(directory); + PROJECT_DIR = path.join(ROOT, PROJECT_NAME); + process.chdir(ROOT); + + (npmResolveConcreteVersion as jest.Mock).mockResolvedValue('0.88.0'); + (createTemplateUri as jest.Mock).mockResolvedValue('template-uri'); + (checkGitInstallation as jest.Mock).mockResolvedValue(false); + (installTemplatePackage as jest.Mock).mockImplementation( + async (_uri: string, templateSourceDir: string) => { + writeFiles(templateSourceDir, { + 'package.json': JSON.stringify({dependencies: {template: '*'}}), + }); + }, + ); + (getTemplateConfig as jest.Mock).mockReturnValue({ + placeholderName: 'HelloWorld', + templateDir: 'template', + }); + (execa as unknown as jest.Mock).mockResolvedValue({exitCode: 0}); + (cliTools.getLoader as jest.Mock).mockReturnValue(new cliTools.NoopLoader()); + (cliTools.prompt as jest.Mock).mockResolvedValue({installCocoapods: true}); + jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); +}); + +afterEach(() => { + process.chdir(originalCwd); + cleanup(ROOT); +}); + +afterAll(() => { + Object.defineProperty(process, 'platform', {value: originalPlatform}); +}); + +describe('init on macOS', () => { + it('sets up SwiftPM instead of CocoaPods when react-native ships the script', async () => { + installReactNative({withSpmScript: true}); + + await init(); + + expect(execa).toHaveBeenCalledWith( + process.execPath, + [ + path.join( + PROJECT_DIR, + 'node_modules/react-native/scripts/setup-apple-spm.js', + ), + 'add', + '--deintegrate', + '--yes', + ], + expect.objectContaining({cwd: PROJECT_DIR}), + ); + expect(installPods).not.toHaveBeenCalled(); + expect(runCodegen).not.toHaveBeenCalled(); + expect(promptMessages()).not.toContainEqual( + expect.stringContaining(POD_PROMPT), + ); + expect(printRunInstructions).toHaveBeenCalledWith( + PROJECT_DIR, + PROJECT_NAME, + expect.objectContaining({spmSetup: 'done'}), + ); + }); + + it('asks the user to retry SwiftPM when the setup fails', async () => { + installReactNative({withSpmScript: true}); + (execa as unknown as jest.Mock).mockImplementation(async (command) => { + if (command === process.execPath) { + throw new Error('Command failed with exit code 1'); + } + return {exitCode: 0}; + }); + + await init(); + + expect(printRunInstructions).toHaveBeenCalledWith( + PROJECT_DIR, + PROJECT_NAME, + expect.objectContaining({spmSetup: 'failed'}), + ); + }); + + it('leaves SwiftPM pending with --skip-install', async () => { + installReactNative({withSpmScript: true}); + + await init({skipInstall: true}); + + expect(PackageManager.installAll).not.toHaveBeenCalled(); + expect(execa).not.toHaveBeenCalledWith( + process.execPath, + expect.anything(), + expect.anything(), + ); + expect(printRunInstructions).toHaveBeenCalledWith( + PROJECT_DIR, + PROJECT_NAME, + expect.objectContaining({spmSetup: 'pending'}), + ); + }); + + it.each([ + ['react-native does not ship the script', false, {}], + ['--skip-spm is given', true, {skipSpm: true}], + ])( + 'offers CocoaPods when %s', + async (_label, withSpmScript, options: Partial) => { + installReactNative({withSpmScript}); + + await init(options); + + expect(promptMessages()).toContainEqual( + expect.stringContaining(POD_PROMPT), + ); + expect(runCodegen).toHaveBeenCalled(); + expect(installPods).toHaveBeenCalled(); + expect(execa).not.toHaveBeenCalledWith( + process.execPath, + expect.anything(), + expect.anything(), + ); + expect(printRunInstructions).toHaveBeenCalledWith( + PROJECT_DIR, + PROJECT_NAME, + expect.objectContaining({spmSetup: undefined}), + ); + }, + ); + + it.each([true, 'true'])( + 'rejects --install-pods %p for React Native 0.87+ before creating the project', + async (installPodsFlag) => { + installReactNative({withSpmScript: true}); + + await expect(init({installPods: installPodsFlag})).rejects.toThrow( + '--skip-spm', + ); + + expect(fs.existsSync(PROJECT_DIR)).toBe(false); + expect(installTemplatePackage).not.toHaveBeenCalled(); + expect(PackageManager.installAll).not.toHaveBeenCalled(); + }, + ); + + describe.each([ + ['--template', {template: 'my-template'}], + ['an out-of-tree --platform-name', {platformName: 'react-native-macos'}], + ])('with %s', (_label, versionUnknownOptions: Partial) => { + it('keeps CocoaPods for --install-pods true', async () => { + installReactNative({withSpmScript: true}); + + await init({...versionUnknownOptions, installPods: true}); + + expect(installPods).toHaveBeenCalled(); + expect(execa).not.toHaveBeenCalledWith( + process.execPath, + expect.anything(), + expect.anything(), + ); + expect(printRunInstructions).toHaveBeenCalledWith( + PROJECT_DIR, + PROJECT_NAME, + expect.objectContaining({spmSetup: undefined}), + ); + }); + + it('sets up SwiftPM when react-native ships the script', async () => { + installReactNative({withSpmScript: true}); + + await init(versionUnknownOptions); + + expect(execa).toHaveBeenCalledWith( + process.execPath, + expect.arrayContaining(['add', '--deintegrate', '--yes']), + expect.objectContaining({cwd: PROJECT_DIR}), + ); + expect(installPods).not.toHaveBeenCalled(); + expect(printRunInstructions).toHaveBeenCalledWith( + PROJECT_DIR, + PROJECT_NAME, + expect.objectContaining({spmSetup: 'done'}), + ); + }); + }); +}); diff --git a/packages/cli/src/commands/init/__tests__/printRunInstructions.test.ts b/packages/cli/src/commands/init/__tests__/printRunInstructions.test.ts new file mode 100644 index 000000000..c600d005c --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/printRunInstructions.test.ts @@ -0,0 +1,107 @@ +import fs from 'fs'; +import {logger} from '@react-native-community/cli-tools'; +import printRunInstructions from '../printRunInstructions'; + +const PROJECT_DIR = '/projects/MyApp'; +const SPM_COMMAND = 'npx react-native spm add --deintegrate'; + +const originalPlatform = process.platform; + +function printedOutput() { + return (logger.log as jest.Mock).mock.calls.flat().join('\n'); +} + +function mockWorkspaceExists(exists: boolean) { + jest + .spyOn(fs, 'existsSync') + .mockImplementation( + (file) => exists && String(file).endsWith('.xcworkspace'), + ); +} + +beforeEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + Object.defineProperty(process, 'platform', {value: 'darwin'}); +}); + +afterAll(() => { + Object.defineProperty(process, 'platform', {value: originalPlatform}); +}); + +describe('printRunInstructions', () => { + it('points at the Xcode project without pod steps when SwiftPM was set up', () => { + mockWorkspaceExists(true); + + printRunInstructions(PROJECT_DIR, 'MyApp', { + showPodsInstructions: true, + spmSetup: 'done', + }); + + const output = printedOutput(); + expect(output).toContain('MyApp.xcodeproj'); + expect(output).not.toContain('MyApp.xcworkspace'); + expect(output).not.toContain('pod install'); + expect(output).not.toContain('Cocoapods'); + expect(output).not.toContain(SPM_COMMAND); + expect(output).toContain(`cd "${PROJECT_DIR}"\n`); + }); + + it('tells the user to set up SwiftPM when it is still pending', () => { + mockWorkspaceExists(false); + + printRunInstructions(PROJECT_DIR, 'MyApp', { + showPodsInstructions: true, + spmSetup: 'pending', + }); + + const output = printedOutput(); + expect(output).toContain(SPM_COMMAND); + expect(output).toContain('once dependencies are installed'); + expect(output).toContain('MyApp.xcodeproj'); + expect(output).not.toContain('pod install'); + expect(output).toContain(`cd "${PROJECT_DIR}"\n`); + }); + + it('tells the user to retry the SwiftPM setup when it failed', () => { + mockWorkspaceExists(false); + + printRunInstructions(PROJECT_DIR, 'MyApp', { + showPodsInstructions: true, + spmSetup: 'failed', + }); + + const output = printedOutput(); + expect(output).toContain( + `Retry the Swift Package Manager setup\n • ${SPM_COMMAND}`, + ); + expect(output).not.toContain('once dependencies are installed'); + expect(output).toContain('Open the iOS project in Xcode with "xed -b ios"'); + expect(output).not.toContain('MyApp.xcodeproj'); + expect(output).not.toContain('MyApp.xcworkspace'); + expect(output).not.toContain('pod install'); + expect(output).toContain(`cd "${PROJECT_DIR}"\n`); + }); + + it('keeps the CocoaPods instructions when pods were not installed', () => { + mockWorkspaceExists(false); + + printRunInstructions(PROJECT_DIR, 'MyApp', {showPodsInstructions: true}); + + const output = printedOutput(); + expect(output).toContain('bundle exec pod install'); + expect(output).toContain(`cd "${PROJECT_DIR}/ios"`); + expect(output).not.toContain(SPM_COMMAND); + }); + + it('points at the workspace when CocoaPods were installed', () => { + mockWorkspaceExists(true); + + printRunInstructions(PROJECT_DIR, 'MyApp', {showPodsInstructions: false}); + + const output = printedOutput(); + expect(output).toContain('MyApp.xcworkspace'); + expect(output).not.toContain('pod install'); + expect(output).not.toContain(SPM_COMMAND); + }); +}); diff --git a/packages/cli/src/commands/init/__tests__/spm.test.ts b/packages/cli/src/commands/init/__tests__/spm.test.ts new file mode 100644 index 000000000..12c3ce6a4 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/spm.test.ts @@ -0,0 +1,297 @@ +import fs from 'fs'; +import path from 'path'; +import execa from 'execa'; +import {CLIError, logger, NoopLoader} from '@react-native-community/cli-tools'; +import { + cleanup, + getTempDirectory, + writeFiles, +} from '../../../../../../jest/helpers'; +import { + assertNoSpmConflict, + findSpmSetupScript, + getTemplateReactNativeVersion, + isSpmExpected, + runSpmSetup, +} from '../spm'; + +jest.mock('execa', () => jest.fn()); + +// A directory per test, because module resolution results are cached per path. +let projectCount = 0; +let PROJECT_DIR: string; +let SCRIPT_PATH: string; + +const originalPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform) { + Object.defineProperty(process, 'platform', {value: platform}); +} + +function writeReactNative({withSpmScript}: {withSpmScript: boolean}) { + writeFiles(PROJECT_DIR, { + 'node_modules/react-native/package.json': JSON.stringify({ + name: 'react-native', + main: './index.js', + exports: { + '.': './index.js', + './scripts/*': './scripts/*.js', + './package.json': './package.json', + }, + }), + 'node_modules/react-native/index.js': '', + ...(withSpmScript + ? {'node_modules/react-native/scripts/setup-apple-spm.js': ''} + : {}), + }); +} + +beforeEach(() => { + jest.resetAllMocks(); + setPlatform('darwin'); + const directory = getTempDirectory(`init-spm-project-${++projectCount}`); + fs.mkdirSync(directory, {recursive: true}); + PROJECT_DIR = fs.realpathSync(directory); + SCRIPT_PATH = path.join( + PROJECT_DIR, + 'node_modules', + 'react-native', + 'scripts', + 'setup-apple-spm.js', + ); +}); + +afterEach(() => { + cleanup(PROJECT_DIR); +}); + +afterAll(() => { + setPlatform(originalPlatform); +}); + +describe('findSpmSetupScript', () => { + it('returns the script shipped with react-native on macOS', () => { + writeReactNative({withSpmScript: true}); + + expect(findSpmSetupScript(PROJECT_DIR, {})).toBe(SCRIPT_PATH); + }); + + it('returns undefined when react-native does not ship the script', () => { + writeReactNative({withSpmScript: false}); + + expect(findSpmSetupScript(PROJECT_DIR, {})).toBeUndefined(); + }); + + it('returns undefined when react-native is not installed', () => { + writeFiles(PROJECT_DIR, {'package.json': '{}'}); + + expect(findSpmSetupScript(PROJECT_DIR, {})).toBeUndefined(); + }); + + it('returns undefined outside macOS', () => { + writeReactNative({withSpmScript: true}); + setPlatform('linux'); + + expect(findSpmSetupScript(PROJECT_DIR, {})).toBeUndefined(); + }); + + it('returns undefined with --skip-spm', () => { + writeReactNative({withSpmScript: true}); + + expect(findSpmSetupScript(PROJECT_DIR, {skipSpm: true})).toBeUndefined(); + }); + + it('returns undefined with --skip-install', () => { + writeReactNative({withSpmScript: true}); + + expect( + findSpmSetupScript(PROJECT_DIR, {skipInstall: true}), + ).toBeUndefined(); + }); + + it.each([true, 'true'])( + 'returns undefined with --install-pods %p', + (installPods) => { + writeReactNative({withSpmScript: true}); + + expect(findSpmSetupScript(PROJECT_DIR, {installPods})).toBeUndefined(); + }, + ); + + it.each([false, 'false'])( + 'returns the script with --install-pods %p', + (installPods) => { + writeReactNative({withSpmScript: true}); + + expect(findSpmSetupScript(PROJECT_DIR, {installPods})).toBe(SCRIPT_PATH); + }, + ); +}); + +describe('runSpmSetup', () => { + it('runs the script as a child process in the project directory', async () => { + (execa as unknown as jest.Mock).mockResolvedValue({exitCode: 0}); + + const didSetUp = await runSpmSetup( + SCRIPT_PATH, + PROJECT_DIR, + new NoopLoader(), + ); + + expect(didSetUp).toBe(true); + expect(execa).toHaveBeenCalledWith( + process.execPath, + [SCRIPT_PATH, 'add', '--deintegrate', '--yes'], + expect.objectContaining({cwd: PROJECT_DIR}), + ); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('warns with the retry command and does not throw when the script fails', async () => { + (execa as unknown as jest.Mock).mockRejectedValue( + new Error('Command failed with exit code 1'), + ); + + const didSetUp = await runSpmSetup( + SCRIPT_PATH, + PROJECT_DIR, + new NoopLoader(), + ); + + expect(didSetUp).toBe(false); + const warning = (logger.warn as jest.Mock).mock.calls.flat().join('\n'); + expect(warning).toContain('Command failed with exit code 1'); + expect(warning).toContain('npx react-native spm add --deintegrate'); + expect(warning).toContain( + 'Project initialization continues, but the iOS project may be partly converted.', + ); + }); + + it('tells the user that the setup needs CocoaPods before the retry command', async () => { + (execa as unknown as jest.Mock).mockRejectedValue( + new Error('Command failed with exit code 1'), + ); + + await runSpmSetup(SCRIPT_PATH, PROJECT_DIR, new NoopLoader()); + + const warning = (logger.warn as jest.Mock).mock.calls.flat().join('\n'); + const podHint = warning.indexOf( + "If CocoaPods is not installed, install it first: the setup uses it to remove the template's CocoaPods setup.", + ); + expect(podHint).toBeGreaterThan(-1); + expect(podHint).toBeLessThan( + warning.indexOf('npx react-native spm add --deintegrate'), + ); + }); +}); + +describe('getTemplateReactNativeVersion', () => { + it('returns the resolved version for the default template', () => { + expect(getTemplateReactNativeVersion({}, '0.88.1')).toBe('0.88.1'); + }); + + it('returns undefined when a custom template picks the version', () => { + expect( + getTemplateReactNativeVersion({template: 'my-template'}, '0.88.1'), + ).toBeUndefined(); + }); + + it('returns undefined for an out-of-tree platform', () => { + expect( + getTemplateReactNativeVersion( + {platformName: 'react-native-macos'}, + '0.88.1', + ), + ).toBeUndefined(); + }); + + it('returns undefined when the version could not be resolved', () => { + expect(getTemplateReactNativeVersion({}, 'latest')).toBeUndefined(); + }); +}); + +describe('isSpmExpected', () => { + it.each([ + ['0.87.0', true], + ['0.87.0-rc.1', true], + ['0.88.1', true], + ['0.86.2', false], + [undefined, false], + ])('for React Native %p returns %p', (reactNativeVersion, expected) => { + expect(isSpmExpected(reactNativeVersion, {})).toBe(expected); + }); + + it('returns false with --skip-spm', () => { + expect(isSpmExpected('0.88.1', {skipSpm: true})).toBe(false); + }); + + it('returns false outside macOS', () => { + setPlatform('linux'); + + expect(isSpmExpected('0.88.1', {})).toBe(false); + }); +}); + +describe('assertNoSpmConflict', () => { + const versions: Array<[string, string | undefined, boolean]> = [ + ['>= 0.87', '0.87.0', true], + ['>= 0.87 (nightly)', '0.88.0-nightly-20261001-abcdef', true], + ['< 0.87', '0.86.2', false], + ['unknown', undefined, false], + ]; + + describe.each(versions)( + 'with React Native %s', + (_label, reactNativeVersion, spmExpected) => { + it.each([true, 'true'])( + `${spmExpected ? 'throws' : 'does not throw'} with --install-pods %p`, + (installPods) => { + const check = () => + assertNoSpmConflict(reactNativeVersion, {installPods}); + if (spmExpected) { + expect(check).toThrow(CLIError); + expect(check).toThrow('--skip-spm'); + expect(check).toThrow('or remove --install-pods'); + } else { + expect(check).not.toThrow(); + } + }, + ); + + it.each([false, 'false', undefined])( + 'does not throw with --install-pods %p', + (installPods) => { + expect(() => + assertNoSpmConflict(reactNativeVersion, {installPods}), + ).not.toThrow(); + }, + ); + + it.each([true, 'true'])( + 'does not throw with --install-pods %p and --skip-spm', + (installPods) => { + expect(() => + assertNoSpmConflict(reactNativeVersion, { + installPods, + skipSpm: true, + }), + ).not.toThrow(); + }, + ); + }, + ); + + it('does not throw with --install-pods true and --skip-install', () => { + expect(() => + assertNoSpmConflict('0.88.1', {installPods: true, skipInstall: true}), + ).not.toThrow(); + }); + + it('does not throw outside macOS', () => { + setPlatform('linux'); + + expect(() => + assertNoSpmConflict('0.88.1', {installPods: true}), + ).not.toThrow(); + }); +}); diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index f89397840..19f61f0cb 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -36,7 +36,12 @@ export default { { name: '--install-pods [boolean]', description: - 'Determine if CocoaPods should be installed when initializing a project', + 'Determine if CocoaPods should be installed. On macOS with React Native 0.87+, init uses Swift Package Manager instead; add --skip-spm to use CocoaPods.', + }, + { + name: '--skip-spm', + description: + 'Use CocoaPods instead of Swift Package Manager for iOS dependencies', }, { name: '--package-name ', diff --git a/packages/cli/src/commands/init/init.ts b/packages/cli/src/commands/init/init.ts index 20be2d731..08e361624 100644 --- a/packages/cli/src/commands/init/init.ts +++ b/packages/cli/src/commands/init/init.ts @@ -42,6 +42,14 @@ import {createTemplateUri} from './version'; import {TEMPLATE_COMMUNITY_REACT_NATIVE_VERSION} from './constants'; import type {Options} from './types'; import {runCodegen} from '@react-native-community/cli-config-apple'; +import { + assertNoSpmConflict, + findSpmSetupScript, + getTemplateReactNativeVersion, + isSpmExpected, + runSpmSetup, + SpmSetup, +} from './spm'; const DEFAULT_VERSION = 'latest'; @@ -55,6 +63,7 @@ interface TemplateOptions { skipInstall?: boolean; packageName?: string; installCocoaPods?: string | boolean; + skipSpm?: boolean; version: string; replaceDirectory?: string | boolean; yarnConfigOptions?: Record; @@ -62,6 +71,7 @@ interface TemplateOptions { interface TemplateReturnType { didInstallPods?: boolean; + spmSetup?: SpmSetup; replaceDirectory?: string | boolean; } @@ -192,6 +202,7 @@ async function createFromTemplate({ skipInstall, packageName, installCocoaPods, + skipSpm, replaceDirectory, yarnConfigOptions, version, @@ -202,6 +213,7 @@ async function createFromTemplate({ logger.log(banner(version !== DEFAULT_VERSION ? version : undefined)); } let didInstallPods = String(installCocoaPods) === 'true'; + let spmSetup: SpmSetup | undefined; let packageManager = pm; if (pm) { @@ -279,7 +291,19 @@ async function createFromTemplate({ root: projectDirectory, }); - if (process.platform === 'darwin') { + const spmSetupScript = findSpmSetupScript(projectDirectory, { + skipSpm, + installPods: installCocoaPods, + }); + + if (spmSetupScript) { + const didSetUpSpm = await runSpmSetup( + spmSetupScript, + projectDirectory, + loader, + ); + spmSetup = didSetUpSpm ? 'done' : 'failed'; + } else if (process.platform === 'darwin') { const installPodsValue = String(installCocoaPods); try { @@ -348,7 +372,7 @@ async function createFromTemplate({ process.exit(1); } - return {didInstallPods}; + return {didInstallPods, spmSetup}; } async function installDependencies({ @@ -428,6 +452,7 @@ async function createProject( skipInstall: options.skipInstall, packageName: options.packageName, installCocoaPods: options.installPods, + skipSpm: options.skipSpm, version, replaceDirectory: options.replaceDirectory, yarnConfigOptions: options.yarnConfigOptions, @@ -492,6 +517,12 @@ export default (async function initialize( } } + const templateReactNativeVersion = getTemplateReactNativeVersion( + options, + version, + ); + assertNoSpmConflict(templateReactNativeVersion, options); + const root = process.cwd(); const directoryName = path.relative(root, options.directory || projectName); @@ -523,7 +554,7 @@ export default (async function initialize( ); } - const {didInstallPods} = await createProject( + const {didInstallPods, spmSetup} = await createProject( projectName, directoryName, version, @@ -535,7 +566,11 @@ export default (async function initialize( await createGitRepository(projectFolder); } + const isSpmSetupPending = + options.skipInstall && isSpmExpected(templateReactNativeVersion, options); + printRunInstructions(projectFolder, projectName, { showPodsInstructions: !didInstallPods, + spmSetup: isSpmSetupPending ? 'pending' : spmSetup, }); }); diff --git a/packages/cli/src/commands/init/printRunInstructions.ts b/packages/cli/src/commands/init/printRunInstructions.ts index d26315815..1ac22f728 100644 --- a/packages/cli/src/commands/init/printRunInstructions.ts +++ b/packages/cli/src/commands/init/printRunInstructions.ts @@ -12,9 +12,11 @@ import fs from 'fs'; import process from 'process'; import pico from 'picocolors'; import {logger} from '@react-native-community/cli-tools'; +import {SPM_SETUP_COMMAND, SpmSetup} from './spm'; interface Options { showPodsInstructions?: boolean; + spmSetup?: SpmSetup; } function printRunInstructions( @@ -33,7 +35,9 @@ function printRunInstructions( iosProjectDir, `${projectName}.xcworkspace`, ); - const isUsingPods = fs.existsSync(iosPodsFile); + const isUsingSpm = !!options.spmSetup; + const isUsingPods = !isUsingSpm && fs.existsSync(iosPodsFile); + const showPodsInstructions = !isUsingSpm && options.showPodsInstructions; const relativeXcodeProjectPath = path.relative( '..', @@ -42,22 +46,39 @@ function printRunInstructions( : path.resolve(iosProjectDir, `${projectName}.xcodeproj`), ); - iosInstructions = ` - ${pico.cyan(`Run instructions for ${pico.bold('iOS')}`)}: - • cd "${projectDir}${options.showPodsInstructions ? '/ios' : ''}" - ${ - options.showPodsInstructions - ? ` + // A failed setup can leave either the .xcodeproj or the .xcworkspace as the file to open. + const xcodeInstruction = + options.spmSetup === 'failed' + ? 'Open the iOS project in Xcode with "xed -b ios"' + : `Open ${relativeXcodeProjectPath} in Xcode or run "xed -b ios"`; + + let dependencyInstructions = ''; + if (showPodsInstructions) { + dependencyInstructions = ` • Install Cocoapods • bundle install # you need to run this only once in your project. • bundle exec pod install • cd .. - ` - : '' + `; + } else if (options.spmSetup === 'pending') { + dependencyInstructions = ` + • Set up Swift Package Manager once dependencies are installed + • ${SPM_SETUP_COMMAND} + `; + } else if (options.spmSetup === 'failed') { + dependencyInstructions = ` + • Retry the Swift Package Manager setup + • ${SPM_SETUP_COMMAND} + `; } + + iosInstructions = ` + ${pico.cyan(`Run instructions for ${pico.bold('iOS')}`)}: + • cd "${projectDir}${showPodsInstructions ? '/ios' : ''}" + ${dependencyInstructions} • npx react-native run-ios ${pico.dim('- or -')} - • Open ${relativeXcodeProjectPath} in Xcode or run "xed -b ios" + • ${xcodeInstruction} • Hit the Run button `; diff --git a/packages/cli/src/commands/init/spm.ts b/packages/cli/src/commands/init/spm.ts new file mode 100644 index 000000000..c42e6abf7 --- /dev/null +++ b/packages/cli/src/commands/init/spm.ts @@ -0,0 +1,116 @@ +import semver from 'semver'; +import pico from 'picocolors'; +import {CLIError, logger, Loader} from '@react-native-community/cli-tools'; +import {executeCommand} from '../../tools/executeCommand'; +import type {Options} from './types'; + +export const SPM_SETUP_COMMAND = 'npx react-native spm add --deintegrate'; + +const SPM_MINIMUM_REACT_NATIVE_VERSION = '0.87.0'; + +export type SpmSetup = 'done' | 'pending' | 'failed'; + +type SpmOptions = Pick; + +function isInstallPodsRequested(installPods: Options['installPods']) { + return String(installPods) === 'true'; +} + +export function getTemplateReactNativeVersion( + options: Pick, + resolvedVersion: string, +): string | undefined { + const isOutOfTreePlatform = + !!options.platformName && options.platformName !== 'react-native'; + if (options.template || isOutOfTreePlatform) { + return undefined; + } + return semver.coerce(resolvedVersion) ? resolvedVersion : undefined; +} + +export function isSpmExpected( + reactNativeVersion: string | undefined, + {skipSpm}: SpmOptions, +): boolean { + const version = semver.coerce(reactNativeVersion); + return ( + process.platform === 'darwin' && + !skipSpm && + version != null && + semver.gte(version, SPM_MINIMUM_REACT_NATIVE_VERSION) + ); +} + +export function assertNoSpmConflict( + reactNativeVersion: string | undefined, + options: SpmOptions, +) { + if ( + !options.skipInstall && + isInstallPodsRequested(options.installPods) && + isSpmExpected(reactNativeVersion, options) + ) { + throw new CLIError( + `${pico.bold( + '--install-pods true', + )} installs CocoaPods, but React Native ${reactNativeVersion} sets up iOS dependencies with Swift Package Manager. Add ${pico.bold( + '--skip-spm', + )} to use CocoaPods, or remove ${pico.bold('--install-pods')}.`, + ); + } +} + +// An explicit `--install-pods true` keeps CocoaPods for templates whose React +// Native version was not known up front. +export function findSpmSetupScript( + projectDirectory: string, + options: SpmOptions, +): string | undefined { + if ( + process.platform !== 'darwin' || + options.skipSpm || + options.skipInstall || + isInstallPodsRequested(options.installPods) + ) { + return undefined; + } + + try { + // Extensionless, because react-native's `exports` map appends `.js` to `./scripts/*`. + return require.resolve('react-native/scripts/setup-apple-spm', { + paths: [projectDirectory], + }); + } catch { + return undefined; + } +} + +export async function runSpmSetup( + scriptPath: string, + projectDirectory: string, + loader: Loader, +): Promise { + loader.start('Setting up iOS dependencies with Swift Package Manager'); + + try { + // A child process, because the script reports failure through `process.exitCode`. + // `process.execPath`, because nvm or Volta can put a different Node first on PATH. + await executeCommand( + process.execPath, + [scriptPath, 'add', '--deintegrate', '--yes'], + {root: projectDirectory, silent: true}, + ); + loader.succeed(); + return true; + } catch (error) { + loader.fail(); + logger.warn( + `Setting up Swift Package Manager failed. Project initialization continues, but the iOS project may be partly converted.\nIf CocoaPods is not installed, install it first: the setup uses it to remove the template's CocoaPods setup.\nYou need to run ${pico.bold( + SPM_SETUP_COMMAND, + )} in the project directory before running iOS.\n${ + (error as Error).message + }`, + ); + return false; + } +} diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index 57ff1e54a..f44949987 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -11,6 +11,7 @@ export type Options = { version: string; packageName?: string; installPods?: string | boolean; + skipSpm?: boolean; platformName?: string; skipGitInit?: boolean; replaceDirectory?: string | boolean;