SPM: allow overriding the autolinking config command (#57662)

Summary:
The SwiftPM autolinking flow hardcodes `react-native-community/cli config` to generate `autolinking.json` (`generate-spm-autolinking-config.js`). Apps that replace community autolinking — most notably **Expo**, which ships `expo-modules-autolinking` instead of `react-native-community/cli` — had no way to override that command, and any failure was swallowed. The result: the config command fails, `autolinking.json` is never written, the `Autolinked` SwiftPM package comes out empty, and `import Expo` (and every Expo module) fails to resolve — surfacing much later as an inscrutable `unable to resolve module dependency: 'Expo'`.

CocoaPods already solves the injection half: `use_native_modules!(config_command = $default_command)` accepts the command as a parameter, so an Expo `Podfile` passes `expo-modules-autolinking react-native-config` in place of the `rncli` default. This PR adds the equivalent hook to the SwiftPM path **and** closes the silent-failure trap.

### 1. Allow overriding the config command

`generateAutolinkingConfig` already accepted a `configCommand` option internally; it was just never reachable. Two ways to supply it, mirroring the CocoaPods hook:

- **`--config-command '<json>'`** — CLI flag taking a JSON array of the argv.
- **`RCT_SPM_AUTOLINKING_CONFIG_COMMAND`** — env var in the same JSON-array format. This is the vehicle for the injected Xcode build phase, which usually can't rewrite the script's argv but can read env.

Both go through one `parseConfigCommandJson` validator (rejects non-JSON, non-arrays, empty arrays, and non-string / empty-string elements, with a `source`-named error). Precedence: **`--config-command` > `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` > default** (local `rncli` → `npx --no-install` fallback, unchanged). JSON (rather than whitespace-splitting) because real commands contain dashed flags and quoted script strings.

The value is the **command to execute** — its stdout is captured as the config JSON and written verbatim — exactly matching CocoaPods, not a precomputed result. An Expo app feeds the same argv it already builds for `use_native_modules!`:

```jsonc
RCT_SPM_AUTOLINKING_CONFIG_COMMAND='["node","--no-warnings","--eval","require('expo/bin/autolinking')","expo-modules-autolinking","react-native-config","--json","--platform","ios","--source-dir","/abs/path"]'
```

(Use `--platform ios`: the generator requires `project.ios.sourceDir` and everything downstream is iOS-only.)

### 2. Fail closed when the config command errors

Previously `main()` swallowed a config-command failure as a warning and continued, which is what let the empty package be produced silently. That policy is now extracted 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 existing `RemoteVersionError` path), and stops.

The guard is deliberately narrow: 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. Only an *erroring* command fails the build.

## Changelog:

[IOS] [ADDED] - Allow overriding the SwiftPM autolinking config command via `--config-command` / `RCT_SPM_AUTOLINKING_CONFIG_COMMAND`
[IOS] [CHANGED] - Fail closed with an actionable error when the SwiftPM autolinking config command fails, instead of silently emitting an empty Autolinked package

Pull Request resolved: https://github.com/react/react-native/pull/57662

Test Plan:
New unit tests, developed red → green:

- `generate-spm-autolinking-config-test.js` — env var honored; explicit `configCommand` beats env; invalid-JSON and invalid-shape (`[]`, `[1,2]`) throw with the source name; env unset falls back to the default command; env state saved/restored per test.
- `setup-apple-spm-test.js` — `parseArgs` parses `--config-command` into an argv array, defaults to `null` when omitted, and throws on an invalid value; `generateAutolinkingConfigOrFailClosed` returns the result on success (exit code untouched), passes `projectRoot`/`configCommand` through, and on a config-command error returns `null`, sets exit 2, and logs an actionable error that names the env var and preserves the underlying cause.

```
$ yarn jest --no-cache -i \
    packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js \
    packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js
Test Suites: 2 passed, 2 total
Tests:       39 passed, 39 total
```

`prettier` and `eslint` clean on all changed files.

Reviewed By: zeyap

Differential Revision: D113554857

Pulled By: cipolleschi

fbshipit-source-id: d0baeeefc91aed144cf9e405e198531ff88088a7
This commit is contained in:
Christian Falch
2026-07-27 07:06:42 -07:00
committed by meta-codesync[bot]
parent e9eac585e2
commit a7ba4ce522
5 changed files with 317 additions and 11 deletions
+63 -9
View File
@@ -55,6 +55,7 @@
* must contain debug/ and release/ cache slots.
* [advanced] --download <auto|skip|force> Artifact policy (default: auto).
* [advanced] --skip-codegen Skip the react-native codegen step.
* [advanced] --config-command <json> 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<string> */) /*: 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<string> */) /*: 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,
@@ -892,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<string>,
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<string> */) /*: Promise<void> */ {
let appRoot = process.cwd();
const projectRoot = findProjectRoot(appRoot);
@@ -981,16 +1033,16 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
let autolinkingConfigResult /*: ?AutolinkingConfigResult */ = null;
if (needsCliConfig) {
log('Generating autolinking.json (CLI config)...');
try {
autolinkingConfigResult = generateAutolinkingConfig({projectRoot});
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,
@@ -1159,6 +1211,8 @@ module.exports = {
main,
detectStandardRnLayoutRedirect,
findInjectedXcodeproj,
generateAutolinkingConfigOrFailClosed,
parseArgs,
resolveAction,
shouldAutoDeintegrate,
ensureBothArtifactFlavors,
@@ -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',
]);
});
});
});
@@ -14,6 +14,8 @@ const {
detectStandardRnLayoutRedirect,
ensureBothArtifactFlavors,
findInjectedXcodeproj,
generateAutolinkingConfigOrFailClosed,
parseArgs,
resolveAction,
shouldAutoDeintegrate,
} = require('../../setup-apple-spm');
@@ -59,6 +61,102 @@ 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/,
);
});
});
// ---------------------------------------------------------------------------
// 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).
@@ -16,6 +16,8 @@
*
* Invokes the React Native community CLI to produce its config and writes the
* raw JSON to <project.ios.sourceDir>/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<string> */ {
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<string> */ {
@@ -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<string> */ {
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<string> */,
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,
};
+3
View File
@@ -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<string> | null,
// Artifact download policy: 'auto' fetches when missing, 'skip' never
// fetches, 'force' clears the cache slot and re-downloads.
downloadPolicy: 'auto' | 'skip' | 'force',