Skip to content

Commit c9427ad

Browse files
chrfalchclaude
andcommitted
SPM: allow overriding the autolinking config command
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 '<json argv array>'` 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 <noreply@anthropic.com>
1 parent 07dc003 commit c9427ad

5 files changed

Lines changed: 196 additions & 3 deletions

File tree

packages/react-native/scripts/setup-apple-spm.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
* must contain debug/ and release/ cache slots.
5656
* [advanced] --download <auto|skip|force> Artifact policy (default: auto).
5757
* [advanced] --skip-codegen Skip the react-native codegen step.
58+
* [advanced] --config-command <json> Override the autolinking config command.
5859
*
5960
* Steps performed (add/update):
6061
* 1. react-native codegen → build/generated/ios/ + install SPM codegen template
@@ -83,6 +84,7 @@ const {
8384
} = require('./spm/generate-spm-autolinking');
8485
const {
8586
generateAutolinkingConfig,
87+
parseConfigCommandJson,
8688
} = require('./spm/generate-spm-autolinking-config');
8789
const {main: generatePackage} = require('./spm/generate-spm-package');
8890
const {findSourcePath} = require('./spm/generate-spm-package');
@@ -187,6 +189,11 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
187189
default: false,
188190
describe: '[advanced] Skip the react-native codegen step',
189191
})
192+
.option('config-command', {
193+
type: 'string',
194+
describe:
195+
'[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"]\'',
196+
})
190197
.usage(
191198
'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.',
192199
)
@@ -214,6 +221,10 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
214221
version: parsed.version ?? null,
215222
artifacts: parsed.artifacts ?? null,
216223
skipCodegen: parsed['skip-codegen'],
224+
configCommand:
225+
parsed['config-command'] != null
226+
? parseConfigCommandJson(parsed['config-command'], '--config-command')
227+
: null,
217228
downloadPolicy: parsed.download,
218229
productName: parsed['product-name'] ?? null,
219230
xcodeprojPath: parsed.xcodeproj ?? null,
@@ -982,7 +993,10 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
982993
if (needsCliConfig) {
983994
log('Generating autolinking.json (CLI config)...');
984995
try {
985-
autolinkingConfigResult = generateAutolinkingConfig({projectRoot});
996+
autolinkingConfigResult = generateAutolinkingConfig({
997+
projectRoot,
998+
configCommand: args.configCommand ?? undefined,
999+
});
9861000
log(
9871001
`Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`,
9881002
);
@@ -1159,6 +1173,7 @@ module.exports = {
11591173
main,
11601174
detectStandardRnLayoutRedirect,
11611175
findInjectedXcodeproj,
1176+
parseArgs,
11621177
resolveAction,
11631178
shouldAutoDeintegrate,
11641179
ensureBothArtifactFlavors,

packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ const os = require('node:os');
3636
const path = require('node:path');
3737

3838
let tmpProjects = [];
39+
let originalConfigCommandEnv;
40+
41+
beforeEach(() => {
42+
originalConfigCommandEnv = process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND;
43+
delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND;
44+
});
45+
46+
afterEach(() => {
47+
if (originalConfigCommandEnv == null) {
48+
delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND;
49+
} else {
50+
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = originalConfigCommandEnv;
51+
}
52+
});
3953

4054
function makeTmpProject() {
4155
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-autolink-config-'));
@@ -256,4 +270,95 @@ describe('generateAutolinkingConfig', () => {
256270
rawJson: raw,
257271
});
258272
});
273+
274+
describe('config command override', () => {
275+
it('uses the config command from RCT_SPM_AUTOLINKING_CONFIG_COMMAND', () => {
276+
const {projectRoot, iosDir} = makeTmpProject();
277+
const raw = JSON.stringify(fakeCliConfig(iosDir));
278+
let receivedCommand = null;
279+
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([
280+
'my-cli',
281+
'config',
282+
]);
283+
284+
generateAutolinkingConfig({
285+
projectRoot,
286+
cliRunner: command => {
287+
receivedCommand = command;
288+
return {stdout: raw, stderr: '', exitCode: 0};
289+
},
290+
});
291+
292+
expect(receivedCommand).toEqual(['my-cli', 'config']);
293+
});
294+
295+
it('prefers an explicit configCommand over the environment variable', () => {
296+
const {projectRoot, iosDir} = makeTmpProject();
297+
const raw = JSON.stringify(fakeCliConfig(iosDir));
298+
let receivedCommand = null;
299+
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([
300+
'environment',
301+
'config',
302+
]);
303+
304+
generateAutolinkingConfig({
305+
projectRoot,
306+
configCommand: ['explicit', 'config'],
307+
cliRunner: command => {
308+
receivedCommand = command;
309+
return {stdout: raw, stderr: '', exitCode: 0};
310+
},
311+
});
312+
313+
expect(receivedCommand).toEqual(['explicit', 'config']);
314+
});
315+
316+
it('throws when the environment variable is not JSON', () => {
317+
const {projectRoot} = makeTmpProject();
318+
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = 'not json';
319+
320+
expect(() =>
321+
generateAutolinkingConfig({
322+
projectRoot,
323+
cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}),
324+
}),
325+
).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/);
326+
});
327+
328+
it.each(['[]', '[1,2]'])(
329+
'throws when the environment variable is not a non-empty string array: %s',
330+
rawConfigCommand => {
331+
const {projectRoot} = makeTmpProject();
332+
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = rawConfigCommand;
333+
334+
expect(() =>
335+
generateAutolinkingConfig({
336+
projectRoot,
337+
cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}),
338+
}),
339+
).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/);
340+
},
341+
);
342+
343+
it('falls back to the default command when the environment variable is unset', () => {
344+
const {projectRoot, iosDir} = makeTmpProject();
345+
const raw = JSON.stringify(fakeCliConfig(iosDir));
346+
let receivedCommand = null;
347+
348+
generateAutolinkingConfig({
349+
projectRoot,
350+
cliRunner: command => {
351+
receivedCommand = command;
352+
return {stdout: raw, stderr: '', exitCode: 0};
353+
},
354+
});
355+
356+
expect(receivedCommand).toEqual([
357+
'npx',
358+
'--no-install',
359+
'@react-native-community/cli',
360+
'config',
361+
]);
362+
});
363+
});
259364
});

packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
detectStandardRnLayoutRedirect,
1515
ensureBothArtifactFlavors,
1616
findInjectedXcodeproj,
17+
parseArgs,
1718
resolveAction,
1819
shouldAutoDeintegrate,
1920
} = require('../../setup-apple-spm');
@@ -59,6 +60,29 @@ function gitInitAndCommit(dir) {
5960
execFileSync('git', ['commit', '-m', 'init'], opts);
6061
}
6162

63+
describe('parseArgs', () => {
64+
it('parses --config-command as a JSON argv array', () => {
65+
const args = parseArgs([
66+
'update',
67+
'--config-command',
68+
'["a","b","config"]',
69+
]);
70+
71+
expect(args.action).toBe('update');
72+
expect(args.configCommand).toEqual(['a', 'b', 'config']);
73+
});
74+
75+
it('sets configCommand to null when --config-command is omitted', () => {
76+
expect(parseArgs(['update']).configCommand).toBeNull();
77+
});
78+
79+
it('throws for an invalid --config-command value', () => {
80+
expect(() => parseArgs(['update', '--config-command', 'not json'])).toThrow(
81+
/--config-command/,
82+
);
83+
});
84+
});
85+
6286
// ---------------------------------------------------------------------------
6387
// resolveAction — zero-arg default. Explicit action wins; otherwise `update`
6488
// when an injection marker exists, else `add` (first run).

packages/react-native/scripts/spm/generate-spm-autolinking-config.js

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
*
1717
* Invokes the React Native community CLI to produce its config and writes the
1818
* raw JSON to <project.ios.sourceDir>/build/generated/autolinking/autolinking.json.
19+
* The config command can be overridden by `--config-command` or
20+
* `RCT_SPM_AUTOLINKING_CONFIG_COMMAND`, in that order, before the default.
1921
*
2022
* No filtering or reshaping happens here — the downstream consumer
2123
* (generate-spm-autolinking.js) does its own iOS-only filtering when reading
@@ -60,6 +62,32 @@ const FALLBACK_CONFIG_COMMAND = [
6062
'config',
6163
];
6264

65+
function parseConfigCommandJson(
66+
raw /*: string */,
67+
source /*: string */,
68+
) /*: Array<string> */ {
69+
let parsed;
70+
try {
71+
parsed = JSON.parse(raw);
72+
} catch {
73+
throw new Error(
74+
`${source}: config command must be a JSON array of strings. Example: '["npx","@react-native-community/cli","config"]'`,
75+
);
76+
}
77+
78+
if (
79+
!Array.isArray(parsed) ||
80+
parsed.length === 0 ||
81+
!parsed.every(value => typeof value === 'string' && value.length > 0)
82+
) {
83+
throw new Error(
84+
`${source}: config command must be a non-empty JSON array of non-empty strings`,
85+
);
86+
}
87+
88+
return parsed;
89+
}
90+
6391
function resolveDefaultConfigCommand(
6492
projectRoot /*: string */,
6593
) /*: Array<string> */ {
@@ -94,6 +122,19 @@ function resolveDefaultConfigCommand(
94122
return FALLBACK_CONFIG_COMMAND;
95123
}
96124

125+
// Env-var / default resolution for the autolinking config command. An explicit
126+
// `configCommand` (e.g. from `--config-command`) is handled upstream by
127+
// generateAutolinkingConfig's destructuring default, so it never reaches here —
128+
// this only decides between the env-var override and the built-in default.
129+
function resolveConfigCommand(projectRoot /*: string */) /*: Array<string> */ {
130+
const raw = process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND;
131+
if (typeof raw === 'string' && raw.trim().length > 0) {
132+
return parseConfigCommandJson(raw, 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND');
133+
}
134+
135+
return resolveDefaultConfigCommand(projectRoot);
136+
}
137+
97138
function defaultCliRunner(
98139
command /*: Array<string> */,
99140
opts /*: {cwd: string} */,
@@ -116,7 +157,7 @@ function generateAutolinkingConfig(
116157
) /*: GenerateAutolinkingConfigResult */ {
117158
const {
118159
projectRoot,
119-
configCommand = resolveDefaultConfigCommand(projectRoot),
160+
configCommand = resolveConfigCommand(projectRoot),
120161
cliRunner = defaultCliRunner,
121162
} = opts;
122163

@@ -158,4 +199,9 @@ function generateAutolinkingConfig(
158199
return {config, outputPath: outPath, rawJson};
159200
}
160201

161-
module.exports = {generateAutolinkingConfig, resolveDefaultConfigCommand};
202+
module.exports = {
203+
generateAutolinkingConfig,
204+
parseConfigCommandJson,
205+
resolveConfigCommand,
206+
resolveDefaultConfigCommand,
207+
};

packages/react-native/scripts/spm/spm-types.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export type SetupArgs = {
1616
// `debug/` and `release/` cache slots, each with artifacts.json.
1717
artifacts: string | null,
1818
skipCodegen: boolean,
19+
// Overrides the autolinking config command; also settable via
20+
// RCT_SPM_AUTOLINKING_CONFIG_COMMAND.
21+
configCommand: Array<string> | null,
1922
// Artifact download policy: 'auto' fetches when missing, 'skip' never
2023
// fetches, 'force' clears the cache slot and re-downloads.
2124
downloadPolicy: 'auto' | 'skip' | 'force',

0 commit comments

Comments
 (0)