From c9427ad8cd04c7d1a66c0e4224ae67dd79ecb4d9 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Fri, 24 Jul 2026 11:15:33 +0200 Subject: [PATCH 1/2] SPM: allow overriding the autolinking config command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SwiftPM autolinking flow hardcodes `@react-native-community/cli config` to generate autolinking.json. Apps that replace community autolinking — most notably Expo, which ships expo-modules-autolinking instead of @rncli — have no injection point, so the command fails, autolinking.json is never written, and the Autolinked SwiftPM package is emitted empty (external native modules can't be imported). CocoaPods already solves this: `use_native_modules!(config_command)` takes the command as a parameter, letting an Expo Podfile pass its own. This adds the equivalent hook to the SPM path: - `--config-command ''` CLI flag, and - the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var (same JSON-array format), which the injected Xcode build phase can read even when it can't rewrite argv. Both go through one validator; precedence is flag > env > default. The value is the command to run (its stdout is captured as the config JSON), mirroring CocoaPods — not a precomputed result. ## Changelog: [IOS] [ADDED] - Allow overriding the SwiftPM autolinking config command via --config-command / RCT_SPM_AUTOLINKING_CONFIG_COMMAND Co-Authored-By: Claude Opus 4.8 --- .../react-native/scripts/setup-apple-spm.js | 17 ++- .../generate-spm-autolinking-config-test.js | 105 ++++++++++++++++++ .../spm/__tests__/setup-apple-spm-test.js | 24 ++++ .../spm/generate-spm-autolinking-config.js | 50 ++++++++- .../react-native/scripts/spm/spm-types.js | 3 + 5 files changed, 196 insertions(+), 3 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 3c493f3c8858..fc3f8a5e2279 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -55,6 +55,7 @@ * must contain debug/ and release/ cache slots. * [advanced] --download Artifact policy (default: auto). * [advanced] --skip-codegen Skip the react-native codegen step. + * [advanced] --config-command Override the autolinking config command. * * Steps performed (add/update): * 1. react-native codegen → build/generated/ios/ + install SPM codegen template @@ -83,6 +84,7 @@ const { } = require('./spm/generate-spm-autolinking'); const { generateAutolinkingConfig, + parseConfigCommandJson, } = require('./spm/generate-spm-autolinking-config'); const {main: generatePackage} = require('./spm/generate-spm-package'); const {findSourcePath} = require('./spm/generate-spm-package'); @@ -187,6 +189,11 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { default: false, describe: '[advanced] Skip the react-native codegen step', }) + .option('config-command', { + type: 'string', + describe: + '[advanced] JSON array of the argv used to generate autolinking.json, overriding the default @react-native-community/cli config command. Also settable via RCT_SPM_AUTOLINKING_CONFIG_COMMAND. Example: \'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]\'', + }) .usage( 'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.', ) @@ -214,6 +221,10 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { version: parsed.version ?? null, artifacts: parsed.artifacts ?? null, skipCodegen: parsed['skip-codegen'], + configCommand: + parsed['config-command'] != null + ? parseConfigCommandJson(parsed['config-command'], '--config-command') + : null, downloadPolicy: parsed.download, productName: parsed['product-name'] ?? null, xcodeprojPath: parsed.xcodeproj ?? null, @@ -982,7 +993,10 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { if (needsCliConfig) { log('Generating autolinking.json (CLI config)...'); try { - autolinkingConfigResult = generateAutolinkingConfig({projectRoot}); + autolinkingConfigResult = generateAutolinkingConfig({ + projectRoot, + configCommand: args.configCommand ?? undefined, + }); log( `Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`, ); @@ -1159,6 +1173,7 @@ module.exports = { main, detectStandardRnLayoutRedirect, findInjectedXcodeproj, + parseArgs, resolveAction, shouldAutoDeintegrate, ensureBothArtifactFlavors, diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js index 81b2e97ea31c..3b89786cb0cf 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js @@ -36,6 +36,20 @@ const os = require('node:os'); const path = require('node:path'); let tmpProjects = []; +let originalConfigCommandEnv; + +beforeEach(() => { + originalConfigCommandEnv = process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; + delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; +}); + +afterEach(() => { + if (originalConfigCommandEnv == null) { + delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; + } else { + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = originalConfigCommandEnv; + } +}); function makeTmpProject() { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-autolink-config-')); @@ -256,4 +270,95 @@ describe('generateAutolinkingConfig', () => { rawJson: raw, }); }); + + describe('config command override', () => { + it('uses the config command from RCT_SPM_AUTOLINKING_CONFIG_COMMAND', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand = null; + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([ + 'my-cli', + 'config', + ]); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: command => { + receivedCommand = command; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual(['my-cli', 'config']); + }); + + it('prefers an explicit configCommand over the environment variable', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand = null; + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([ + 'environment', + 'config', + ]); + + generateAutolinkingConfig({ + projectRoot, + configCommand: ['explicit', 'config'], + cliRunner: command => { + receivedCommand = command; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual(['explicit', 'config']); + }); + + it('throws when the environment variable is not JSON', () => { + const {projectRoot} = makeTmpProject(); + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = 'not json'; + + expect(() => + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}), + }), + ).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/); + }); + + it.each(['[]', '[1,2]'])( + 'throws when the environment variable is not a non-empty string array: %s', + rawConfigCommand => { + const {projectRoot} = makeTmpProject(); + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = rawConfigCommand; + + expect(() => + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}), + }), + ).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/); + }, + ); + + it('falls back to the default command when the environment variable is unset', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand = null; + + generateAutolinkingConfig({ + projectRoot, + cliRunner: command => { + receivedCommand = command; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual([ + 'npx', + '--no-install', + '@react-native-community/cli', + 'config', + ]); + }); + }); }); diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index 2413c8e24d9a..19acfab2fe83 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -14,6 +14,7 @@ const { detectStandardRnLayoutRedirect, ensureBothArtifactFlavors, findInjectedXcodeproj, + parseArgs, resolveAction, shouldAutoDeintegrate, } = require('../../setup-apple-spm'); @@ -59,6 +60,29 @@ function gitInitAndCommit(dir) { execFileSync('git', ['commit', '-m', 'init'], opts); } +describe('parseArgs', () => { + it('parses --config-command as a JSON argv array', () => { + const args = parseArgs([ + 'update', + '--config-command', + '["a","b","config"]', + ]); + + expect(args.action).toBe('update'); + expect(args.configCommand).toEqual(['a', 'b', 'config']); + }); + + it('sets configCommand to null when --config-command is omitted', () => { + expect(parseArgs(['update']).configCommand).toBeNull(); + }); + + it('throws for an invalid --config-command value', () => { + expect(() => parseArgs(['update', '--config-command', 'not json'])).toThrow( + /--config-command/, + ); + }); +}); + // --------------------------------------------------------------------------- // resolveAction — zero-arg default. Explicit action wins; otherwise `update` // when an injection marker exists, else `add` (first run). diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking-config.js b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js index 915c16e51565..b923577132b1 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking-config.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js @@ -16,6 +16,8 @@ * * Invokes the React Native community CLI to produce its config and writes the * raw JSON to /build/generated/autolinking/autolinking.json. + * The config command can be overridden by `--config-command` or + * `RCT_SPM_AUTOLINKING_CONFIG_COMMAND`, in that order, before the default. * * No filtering or reshaping happens here — the downstream consumer * (generate-spm-autolinking.js) does its own iOS-only filtering when reading @@ -60,6 +62,32 @@ const FALLBACK_CONFIG_COMMAND = [ 'config', ]; +function parseConfigCommandJson( + raw /*: string */, + source /*: string */, +) /*: Array */ { + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error( + `${source}: config command must be a JSON array of strings. Example: '["npx","@react-native-community/cli","config"]'`, + ); + } + + if ( + !Array.isArray(parsed) || + parsed.length === 0 || + !parsed.every(value => typeof value === 'string' && value.length > 0) + ) { + throw new Error( + `${source}: config command must be a non-empty JSON array of non-empty strings`, + ); + } + + return parsed; +} + function resolveDefaultConfigCommand( projectRoot /*: string */, ) /*: Array */ { @@ -94,6 +122,19 @@ function resolveDefaultConfigCommand( return FALLBACK_CONFIG_COMMAND; } +// Env-var / default resolution for the autolinking config command. An explicit +// `configCommand` (e.g. from `--config-command`) is handled upstream by +// generateAutolinkingConfig's destructuring default, so it never reaches here — +// this only decides between the env-var override and the built-in default. +function resolveConfigCommand(projectRoot /*: string */) /*: Array */ { + const raw = process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; + if (typeof raw === 'string' && raw.trim().length > 0) { + return parseConfigCommandJson(raw, 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'); + } + + return resolveDefaultConfigCommand(projectRoot); +} + function defaultCliRunner( command /*: Array */, opts /*: {cwd: string} */, @@ -116,7 +157,7 @@ function generateAutolinkingConfig( ) /*: GenerateAutolinkingConfigResult */ { const { projectRoot, - configCommand = resolveDefaultConfigCommand(projectRoot), + configCommand = resolveConfigCommand(projectRoot), cliRunner = defaultCliRunner, } = opts; @@ -158,4 +199,9 @@ function generateAutolinkingConfig( return {config, outputPath: outPath, rawJson}; } -module.exports = {generateAutolinkingConfig, resolveDefaultConfigCommand}; +module.exports = { + generateAutolinkingConfig, + parseConfigCommandJson, + resolveConfigCommand, + resolveDefaultConfigCommand, +}; diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index cc30c8823b65..75d0ba26b6aa 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -16,6 +16,9 @@ export type SetupArgs = { // `debug/` and `release/` cache slots, each with artifacts.json. artifacts: string | null, skipCodegen: boolean, + // Overrides the autolinking config command; also settable via + // RCT_SPM_AUTOLINKING_CONFIG_COMMAND. + configCommand: Array | null, // Artifact download policy: 'auto' fetches when missing, 'skip' never // fetches, 'force' clears the cache slot and re-downloads. downloadPolicy: 'auto' | 'skip' | 'force', From 88746cfe29656f9ee4d3eacd0622ffc51d4a920f Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Fri, 24 Jul 2026 11:35:28 +0200 Subject: [PATCH 2/2] SPM: fail closed when the autolinking config command errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-apple-spm previously swallowed a config-command failure as a warning and continued, emitting an empty Autolinked package that only surfaced much later as an inscrutable `unable to resolve module dependency` at build time — with nothing pointing at the config command. Extract the policy into generateAutolinkingConfigOrFailClosed: on a config-command error (non-zero exit, unparseable output, or a config missing project.ios.sourceDir) it logs an actionable message naming RCT_SPM_AUTOLINKING_CONFIG_COMMAND / --config-command, sets process.exitCode = 2 (a hard Xcode build-phase error, matching the RemoteVersionError path), and stops instead of proceeding. The guard is narrow by construction: a genuinely native-module-free app never reaches the error path — its command exits 0 with valid, empty-dependency JSON, so the generator returns normally and the legitimate empty-package path stays valid. ## Changelog: [IOS] [CHANGED] - Fail closed with an actionable error when the SwiftPM autolinking config command fails, instead of silently emitting an empty Autolinked package Co-Authored-By: Claude Opus 4.8 --- .../react-native/scripts/setup-apple-spm.js | 63 +++++++++++++--- .../spm/__tests__/setup-apple-spm-test.js | 74 +++++++++++++++++++ 2 files changed, 125 insertions(+), 12 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index fc3f8a5e2279..61104df17235 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -903,6 +903,47 @@ function logNextSteps( log('To remove SPM later: `npx react-native spm deinit`'); } +// Generate autolinking.json, failing closed on a config-command error. +// +// generateAutolinkingConfig throws ONLY when the config command itself fails — +// a non-zero exit, unparseable output, or a config missing +// project.ios.sourceDir. Swallowing that (the old behavior) let the run proceed +// and emit an empty Autolinked package, which only surfaced much later as an +// inscrutable `unable to resolve module dependency` at build time. Instead we +// set process.exitCode = 2 (a hard Xcode build-phase error, matching the +// RemoteVersionError path) and return null so the caller stops. +// +// A genuinely native-module-free app does NOT reach the error path: its command +// exits 0 with valid, empty-dependency JSON, so generateAutolinkingConfig +// returns normally and the empty-package path downstream stays valid. +function generateAutolinkingConfigOrFailClosed( + opts /*: { + projectRoot: string, + configCommand?: Array, + generate?: typeof generateAutolinkingConfig, + } */, +) /*: ?AutolinkingConfigResult */ { + const generate = opts.generate ?? generateAutolinkingConfig; + try { + return generate({ + projectRoot: opts.projectRoot, + configCommand: opts.configCommand, + }); + } catch (e) { + logError( + `Failed to generate autolinking.json: ${e.message}\n` + + 'The autolinking config command failed. If this app replaces ' + + '@react-native-community/cli autolinking (e.g. an Expo app), set ' + + 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND (or pass --config-command) to a ' + + 'JSON argv array whose command prints the React Native CLI config, ' + + 'e.g. \'["npx","expo-modules-autolinking","react-native-config",' + + '"--json","--platform","ios"]\'.', + ); + process.exitCode = 2; + return null; + } +} + async function main(argv /*:: ?: Array */) /*: Promise */ { let appRoot = process.cwd(); const projectRoot = findProjectRoot(appRoot); @@ -992,19 +1033,16 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { let autolinkingConfigResult /*: ?AutolinkingConfigResult */ = null; if (needsCliConfig) { log('Generating autolinking.json (CLI config)...'); - try { - autolinkingConfigResult = generateAutolinkingConfig({ - projectRoot, - configCommand: args.configCommand ?? undefined, - }); - log( - `Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`, - ); - } catch (e) { - logError( - `generate-spm-autolinking-config failed: ${e.message}. External native modules may not be discovered.`, - ); + autolinkingConfigResult = generateAutolinkingConfigOrFailClosed({ + projectRoot, + configCommand: args.configCommand ?? undefined, + }); + if (autolinkingConfigResult == null) { + // Fail closed: the config command errored and the helper already set + // process.exitCode = 2. Stop rather than emit an empty Autolinked package. + return; } + log(`Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`); } const reactNativeRoot = resolveReactNativeRoot( autolinkingConfigResult, @@ -1173,6 +1211,7 @@ module.exports = { main, detectStandardRnLayoutRedirect, findInjectedXcodeproj, + generateAutolinkingConfigOrFailClosed, parseArgs, resolveAction, shouldAutoDeintegrate, diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index 19acfab2fe83..4fe297cfc5e1 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -14,6 +14,7 @@ const { detectStandardRnLayoutRedirect, ensureBothArtifactFlavors, findInjectedXcodeproj, + generateAutolinkingConfigOrFailClosed, parseArgs, resolveAction, shouldAutoDeintegrate, @@ -83,6 +84,79 @@ describe('parseArgs', () => { }); }); +// --------------------------------------------------------------------------- +// generateAutolinkingConfigOrFailClosed — the fail-closed policy main() applies +// to the autolinking config step. Swallowing a config-command error (the old +// behavior) let the build proceed with a silently-empty Autolinked package that +// only surfaced later as `unable to resolve module dependency`. A native- +// module-free app does NOT hit the error path: its command exits 0 with valid +// empty-dependency JSON and the generator returns normally. +// --------------------------------------------------------------------------- + +describe('generateAutolinkingConfigOrFailClosed', () => { + let prevExitCode; + let warnSpy; + + beforeEach(() => { + prevExitCode = process.exitCode; + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + process.exitCode = prevExitCode; + jest.restoreAllMocks(); + }); + + it('returns the config result and leaves the exit code untouched on success', () => { + const result = { + config: {}, + outputPath: '/app/ios/autolinking.json', + rawJson: '{}', + }; + const out = generateAutolinkingConfigOrFailClosed({ + projectRoot: '/app', + generate: () => result, + }); + + expect(out).toBe(result); + expect(process.exitCode).not.toBe(2); + }); + + it('passes projectRoot and configCommand through to the generator', () => { + let received; + generateAutolinkingConfigOrFailClosed({ + projectRoot: '/proj', + configCommand: ['my-cli', 'config'], + generate: opts => { + received = opts; + return {config: {}, outputPath: '', rawJson: ''}; + }, + }); + + expect(received).toEqual({ + projectRoot: '/proj', + configCommand: ['my-cli', 'config'], + }); + }); + + it('fails closed (null, exit 2, actionable error) when the config command errors', () => { + const out = generateAutolinkingConfigOrFailClosed({ + projectRoot: '/app', + generate: () => { + throw new Error("'my-cli config' exited with status 1"); + }, + }); + + expect(out).toBeNull(); + expect(process.exitCode).toBe(2); + const warnings = warnSpy.mock.calls.map(c => c.join(' ')).join('\n'); + // Names the override so the next person can fix it... + expect(warnings).toMatch(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/); + // ...and preserves the underlying cause. + expect(warnings).toMatch(/exited with status 1/); + }); +}); + // --------------------------------------------------------------------------- // resolveAction — zero-arg default. Explicit action wins; otherwise `update` // when an injection marker exists, else `add` (first run).