From b4db5dfb8efebf08a25a6c06051bc2c7a11558c6 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 29 Jul 2026 19:24:21 +0200 Subject: [PATCH 1/2] SPM: persist --config-command so the in-build sync keeps using it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spm add --config-command ''` succeeded and wrote a valid project, then every subsequent Xcode build failed: the injected "Sync SPM Autolinking" phase re-derives autolinking.json on each build, had no knowledge of the flag, and fell back to `npx --no-install @react-native-community/cli config` — which an app that replaces CLI autolinking (an Expo app, say) does not have. A successful `spm add` therefore produced an unbuildable project. Reported by the Expo team while verifying SwiftPM. Pin the command into the `.spm-injected.json` marker at add/update time and read it back on later runs, mirroring the neighbouring `artifactsVersionOverride` set-or-preserve pin. Resolution order is unchanged at the front and only extended at the back: --config-command -> RCT_SPM_AUTOLINKING_CONFIG_COMMAND -> pin -> default Both input routes persist. The help text advertises the env var as an equivalent way to supply the command, so pinning only the flag would have left half the documented interface broken the same way; the pin stores the resolved command from either route. Two details worth knowing: - generateAutolinkingConfig resolves the env var internally when no explicit command is passed, so handing it the pin would silently outrank a developer's env override. The read path withholds the pin while the env var is set and lets the existing precedence apply. A whitespace-only env var pins nothing and falls through, using the same blankness predicate as the resolver so the two cannot drift. - A pinned value is re-validated through the same parseConfigCommandJson the flag goes through, so a hand-edited or corrupt marker degrades to the env/default command rather than injecting a bogus argv into a build. Because this is persistent state, add/update logs one line when the command comes from the pin, naming .spm-injected.json, so a stale pin is diagnosable from build output instead of invisible. There is no "clear" verb short of `deinit`, as with the version pin. Also corrects two comments asserting that the build-time sync reads `readArtifactsVersionOverride`. It does not — that reader has no production caller, so only the write half of the version pin is wired. Wiring it up is a separate change; the comments are fixed here because they are actively misleading. Co-Authored-By: Claude Opus 5 --- .../react-native/scripts/setup-apple-spm.js | 54 +++++- .../__tests__/remove-spm-injection-test.js | 146 +++++++++++++++ .../spm/__tests__/setup-apple-spm-test.js | 177 +++++++++++++++++- .../spm/generate-spm-autolinking-config.js | 26 ++- .../scripts/spm/generate-spm-xcodeproj.js | 60 +++++- 5 files changed, 443 insertions(+), 20 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 61104df17235..b6ce83ca5b38 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -85,14 +85,18 @@ const { const { generateAutolinkingConfig, parseConfigCommandJson, + readEnvConfigCommand, + resolveEnvConfigCommand, } = require('./spm/generate-spm-autolinking-config'); const {main: generatePackage} = require('./spm/generate-spm-package'); const {findSourcePath} = require('./spm/generate-spm-package'); const { + SPM_INJECTED_MARKER, cleanupDanglingJavaScriptCoreRef, cleanupLeftoverPodsGroup, findInjectedXcodeproj, injectSpmIntoExistingXcodeproj, + readPinnedConfigCommand, removeSpmInjection, } = require('./spm/generate-spm-xcodeproj'); const {scaffoldAll} = require('./spm/scaffold-package-swift'); @@ -192,7 +196,7 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { .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"]\'', + '[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. Either way `add`/`update` remembers the value in .spm-injected.json, so later runs and Xcode builds reuse it. 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.', @@ -844,6 +848,7 @@ async function setupXcodeproj( // (injectSpmIntoExistingXcodeproj preserves it — see // generate-spm-xcodeproj.js). artifactsVersionOverride: args.version ?? null, + configCommand: resolveConfigCommandToPin(args), }); if (result.status !== 'injected') { logError(`SPM injection failed: ${result.reason}`); @@ -903,6 +908,45 @@ function logNextSteps( log('To remove SPM later: `npx react-native spm deinit`'); } +// The autolinking config command for this run: an explicit `--config-command` +// first, then the value a previous `add`/`update` pinned into the injection +// marker. undefined means "no explicit command", which is what makes +// generateAutolinkingConfig fall back to RCT_SPM_AUTOLINKING_CONFIG_COMMAND and +// then to the built-in default — so the pin has to be WITHHELD while the env +// var is set, or a stale pin would outrank a developer's env override. +function resolveExplicitConfigCommand( + args /*: SetupArgs */, + appRoot /*: string */, +) /*: Array | void */ { + if (args.configCommand != null) { + return args.configCommand; + } + if (readEnvConfigCommand() != null) { + return undefined; + } + const pinned = readPinnedConfigCommand(appRoot); + if (pinned == null) { + return undefined; + } + log( + `Autolinking config command (pinned in ${SPM_INJECTED_MARKER}): ` + + pinned.join(' '), + ); + return pinned; +} + +// The command to record in the injection marker. The env var is resolved here +// too, because the Xcode build phase inherits neither the flag nor the shell +// that set it — an env-only override that went unpinned would leave the build +// re-deriving autolinking.json with the default command. null pins nothing and +// preserves any earlier pin. An invalid env value throws, as the flag does, +// though `add` has already failed closed on it by this point. +function resolveConfigCommandToPin( + args /*: SetupArgs */, +) /*: ?Array */ { + return args.configCommand ?? resolveEnvConfigCommand(); +} + // Generate autolinking.json, failing closed on a config-command error. // // generateAutolinkingConfig throws ONLY when the config command itself fails — @@ -937,7 +981,9 @@ function generateAutolinkingConfigOrFailClosed( '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"]\'.', + '"--json","--platform","ios"]\'. An earlier `add`/`update` may also ' + + `have pinned a command in ${SPM_INJECTED_MARKER}; re-run with ` + + '--config-command to replace a stale one.', ); process.exitCode = 2; return null; @@ -1035,7 +1081,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { log('Generating autolinking.json (CLI config)...'); autolinkingConfigResult = generateAutolinkingConfigOrFailClosed({ projectRoot, - configCommand: args.configCommand ?? undefined, + configCommand: resolveExplicitConfigCommand(args, appRoot), }); if (autolinkingConfigResult == null) { // Fail closed: the config command errored and the helper already set @@ -1214,6 +1260,8 @@ module.exports = { generateAutolinkingConfigOrFailClosed, parseArgs, resolveAction, + resolveConfigCommandToPin, + resolveExplicitConfigCommand, shouldAutoDeintegrate, ensureBothArtifactFlavors, }; diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index 4fe3d342fdc7..1da3f62301f4 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -14,6 +14,7 @@ const { SPM_INJECTED_MARKER, injectSpmIntoExistingXcodeproj, readArtifactsVersionOverride, + readPinnedConfigCommand, removeSpmInjection, } = require('../generate-spm-xcodeproj'); const fs = require('node:fs'); @@ -393,3 +394,148 @@ describe('readArtifactsVersionOverride', () => { expect(readArtifactsVersionOverride(appRoot)).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// configCommand — the marker field persisting an explicit `spm add/update +// --config-command ''`. Without the pin, the build-time `sync` +// re-derived autolinking.json with the default @react-native-community/cli +// command and failed the "Sync SPM Autolinking" build phase in apps (e.g. Expo +// apps) that replace it. +// --------------------------------------------------------------------------- +describe('configCommand marker field', () => { + const EXPO_COMMAND = [ + 'npx', + 'expo-modules-autolinking', + 'react-native-config', + '--json', + '--platform', + 'ios', + ]; + + it('records an explicit config command into the marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + expect(readMarker(xcodeprojPath).configCommand).toEqual(EXPO_COMMAND); + expect(readPinnedConfigCommand(appRoot)).toEqual(EXPO_COMMAND); + }); + + it('defaults to null when --config-command has never been given', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).configCommand).toBeNull(); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); + + it('preserves the pin on a later run without --config-command', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).configCommand).toEqual(EXPO_COMMAND); + expect(readPinnedConfigCommand(appRoot)).toEqual(EXPO_COMMAND); + }); + + it('a later explicit --config-command overwrites the previous pin', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: ['my-cli', 'config'], + }); + expect(readMarker(xcodeprojPath).configCommand).toEqual([ + 'my-cli', + 'config', + ]); + expect(readPinnedConfigCommand(appRoot)).toEqual(['my-cli', 'config']); + }); + + it('deinit drops the pin along with the whole marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + removeSpmInjection({appRoot, xcodeprojPath}); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// readPinnedConfigCommand — pure fs read, used by setup-apple-spm.js (including +// the build-time `sync`) to reuse the config command an earlier `add`/`update` +// pinned. A hand-edited or corrupt marker must degrade to the env/default path +// instead of injecting a bogus argv or throwing mid-build. +// --------------------------------------------------------------------------- +describe('readPinnedConfigCommand', () => { + it('returns null when no xcodeproj has been injected yet', () => { + const {appRoot} = scaffoldApp(); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); + + it('returns null (never throws) on a malformed marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: ['my-cli', 'config'], + }); + fs.writeFileSync( + path.join(xcodeprojPath, SPM_INJECTED_MARKER), + '{ not valid json', + 'utf8', + ); + expect(() => readPinnedConfigCommand(appRoot)).not.toThrow(); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); + + it.each([ + ['a bare string', '"npx expo-modules-autolinking"'], + ['an empty array', '[]'], + ['a non-string member', '["npx", 7]'], + ['an empty-string member', '["npx", ""]'], + ['an object', '{"command": "npx"}'], + ])('returns null for a pinned value that is %s', (_label, pinned) => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: ['my-cli', 'config'], + }); + const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); + const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + marker.configCommand = JSON.parse(pinned); + fs.writeFileSync(markerPath, JSON.stringify(marker), 'utf8'); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); +}); 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 4fe297cfc5e1..8e506bb5615e 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 @@ -17,6 +17,8 @@ const { generateAutolinkingConfigOrFailClosed, parseArgs, resolveAction, + resolveConfigCommandToPin, + resolveExplicitConfigCommand, shouldAutoDeintegrate, } = require('../../setup-apple-spm'); const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); @@ -28,12 +30,17 @@ const path = require('node:path'); // Create an in-place-injected xcodeproj fixture: a directory carrying the // `.spm-injected.json` marker (what injectSpmIntoExistingXcodeproj writes). -function mkInjectedXcodeproj(appRoot, name) { +function mkInjectedXcodeproj(appRoot, name, markerFields = {}) { const dir = path.join(appRoot, name); fs.mkdirSync(dir, {recursive: true}); fs.writeFileSync( path.join(dir, SPM_INJECTED_MARKER), - JSON.stringify({rootUuid: 'X', target: 'MyApp', injectedUuids: []}), + JSON.stringify({ + rootUuid: 'X', + target: 'MyApp', + injectedUuids: [], + ...markerFields, + }), ); return dir; } @@ -157,6 +164,172 @@ describe('generateAutolinkingConfigOrFailClosed', () => { }); }); +// --------------------------------------------------------------------------- +// resolveExplicitConfigCommand — the autolinking config command every action +// (add/update/sync/scaffold) runs with: `--config-command` → +// RCT_SPM_AUTOLINKING_CONFIG_COMMAND → the value pinned in `.spm-injected.json` +// → the built-in default. undefined means "let generateAutolinkingConfig pick +// the env var or the default". +// --------------------------------------------------------------------------- + +describe('resolveExplicitConfigCommand', () => { + const ENV = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; + const PINNED = ['npx', 'expo-modules-autolinking', 'react-native-config']; + let tempDir; + let prevEnv; + let logSpy; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-config-command-')); + prevEnv = process.env[ENV]; + delete process.env[ENV]; + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + if (prevEnv === undefined) { + delete process.env[ENV]; + } else { + process.env[ENV] = prevEnv; + } + jest.restoreAllMocks(); + }); + + function pin(configCommand) { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj', {configCommand}); + } + + it('prefers an explicit --config-command over the env var and the pin', () => { + process.env[ENV] = '["from-env","config"]'; + pin(PINNED); + expect( + resolveExplicitConfigCommand( + {configCommand: ['flag', 'config']}, + tempDir, + ), + ).toEqual(['flag', 'config']); + }); + + it('lets the env var win over the pin (a stale pin must not shadow it)', () => { + process.env[ENV] = '["from-env","config"]'; + pin(PINNED); + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); + + it('uses the pin when neither the flag nor the env var is set', () => { + pin(PINNED); + expect( + resolveExplicitConfigCommand({configCommand: null}, tempDir), + ).toEqual(PINNED); + // Names the source, so a stale pin is diagnosable from the build log. + expect(logSpy.mock.calls.map(c => c.join(' ')).join('\n')).toMatch( + /\.spm-injected\.json/, + ); + }); + + it('ignores a blank env var and falls through to the pin', () => { + process.env[ENV] = ' '; + pin(PINNED); + expect( + resolveExplicitConfigCommand({configCommand: null}, tempDir), + ).toEqual(PINNED); + }); + + it('falls back to the default (undefined) with no flag, env var or pin', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); + + it('falls back to the default when the pinned value is malformed', () => { + pin('npx expo-modules-autolinking'); + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); + + it('falls back to the default when no project is injected yet', () => { + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); +}); + +// --------------------------------------------------------------------------- +// resolveConfigCommandToPin — what `add`/`update` records in the injection +// marker: the explicit `--config-command`, else the env override, since the +// Xcode build phase inherits neither. null pins nothing (and preserves any +// earlier pin). +// --------------------------------------------------------------------------- + +describe('resolveConfigCommandToPin', () => { + const ENV = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; + const FROM_ENV = ['npx', 'expo-modules-autolinking', 'react-native-config']; + let tempDir; + let prevEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-config-command-pin-')); + prevEnv = process.env[ENV]; + delete process.env[ENV]; + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + if (prevEnv === undefined) { + delete process.env[ENV]; + } else { + process.env[ENV] = prevEnv; + } + jest.restoreAllMocks(); + }); + + it('pins the env-derived command when only the env var is set', () => { + process.env[ENV] = JSON.stringify(FROM_ENV); + expect(resolveConfigCommandToPin({configCommand: null})).toEqual(FROM_ENV); + }); + + it('pins the explicit --config-command over the env var', () => { + process.env[ENV] = JSON.stringify(FROM_ENV); + expect( + resolveConfigCommandToPin({configCommand: ['flag', 'config']}), + ).toEqual(['flag', 'config']); + }); + + it('pins nothing when the env var is blank', () => { + process.env[ENV] = ' \t '; + expect(resolveConfigCommandToPin({configCommand: null})).toBeNull(); + }); + + it('pins nothing when neither the flag nor the env var is set', () => { + expect(resolveConfigCommandToPin({configCommand: null})).toBeNull(); + }); + + it('fails loud rather than pinning garbage from an invalid env var', () => { + process.env[ENV] = 'npx expo-modules-autolinking'; + expect(() => resolveConfigCommandToPin({configCommand: null})).toThrow( + /RCT_SPM_AUTOLINKING_CONFIG_COMMAND/, + ); + }); + + it('is resolved back by a later run with neither flag nor env var', () => { + process.env[ENV] = JSON.stringify(FROM_ENV); + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj', { + configCommand: resolveConfigCommandToPin({configCommand: null}), + }); + delete process.env[ENV]; + + expect( + resolveExplicitConfigCommand({configCommand: null}, tempDir), + ).toEqual(FROM_ENV); + }); +}); + // --------------------------------------------------------------------------- // 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 b923577132b1..6861e432b3b0 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking-config.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js @@ -122,17 +122,29 @@ function resolveDefaultConfigCommand( return FALLBACK_CONFIG_COMMAND; } +const ENV_CONFIG_COMMAND = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; + +// The raw env override, or null when unset or blank. Exported so callers that +// only need to know whether the override is in play (setup-apple-spm.js) share +// this blankness rule instead of re-deriving it. +function readEnvConfigCommand() /*: ?string */ { + const raw = process.env[ENV_CONFIG_COMMAND]; + return typeof raw === 'string' && raw.trim().length > 0 ? raw : null; +} + +// The env override, parsed and validated, or null when unset or blank. Throws +// on a set-but-invalid value — never silently degrades to the default. +function resolveEnvConfigCommand() /*: ?Array */ { + const raw = readEnvConfigCommand(); + return raw == null ? null : parseConfigCommandJson(raw, ENV_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); + return resolveEnvConfigCommand() ?? resolveDefaultConfigCommand(projectRoot); } function defaultCliRunner( @@ -202,6 +214,8 @@ function generateAutolinkingConfig( module.exports = { generateAutolinkingConfig, parseConfigCommandJson, + readEnvConfigCommand, resolveConfigCommand, resolveDefaultConfigCommand, + resolveEnvConfigCommand, }; diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index e75af429ce29..6ca2a682f1c9 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -22,6 +22,7 @@ */ const {readFlavoredFrameworksManifest} = require('./flavored-frameworks'); +const {parseConfigCommandJson} = require('./generate-spm-autolinking-config'); const { addArrayMembers, addArrayStringValues, @@ -1844,7 +1845,7 @@ function readGeneratedSourcesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{generatedSources?: {[string]: Array}, artifactsVersionOverride?: ?string, buildSettingChanges?: Array, ...} */ { +) /*: ?{generatedSources?: {[string]: Array}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -1857,8 +1858,9 @@ function readMarker( // Returns the `*.xcodeproj` under `appRoot` carrying a `.spm-injected.json` // marker (the user-owned project SPM packages were injected into in place), // or null when none has been injected yet. Pure fs reads — safe for the -// build-time sync (sync-spm-autolinking.js, via readArtifactsVersionOverride -// below) to call without pulling in any pbxproj-editing machinery at runtime. +// marker readers below, and for callers that only locate the project (setup- +// apple-spm.js's action defaulting and `deinit`), to call without exercising +// any pbxproj-editing machinery. function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; try { @@ -1884,11 +1886,13 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { * update --version` pinned into the injected xcodeproj's `.spm-injected.json` * marker (see the field's doc comment in injectSpmIntoExistingXcodeproj * below), or null when no project is injected yet, no override is pinned, or - * the marker can't be read (never throws). Pure fs reads — the build-time - * sync (sync-spm-autolinking.js) calls this to prefer the pinned version over - * the one derived from node_modules/react-native/package.json, so a - * version-mismatched setup keeps healing against the SAME artifact slot the - * explicit `--version` selected. + * the marker can't be read (never throws). Pure fs reads. + * + * Nothing in production calls this yet: the pin is written but never read + * back, so the build-time sync (sync-spm-autolinking.js) still derives the + * version from node_modules/react-native/package.json and can heal against a + * different artifact slot than the explicit `--version` selected. Only the + * tests cover it. */ function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ { const xcodeprojPath = findInjectedXcodeproj(appRoot); @@ -1899,13 +1903,39 @@ function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ { return typeof override === 'string' && override.length > 0 ? override : null; } +/** + * Read the autolinking config command a previous `spm add`/`update` pinned into + * the injected xcodeproj's `.spm-injected.json` marker, or null when nothing + * usable is pinned. Pure fs reads, like readArtifactsVersionOverride above, but + * this one IS wired: setup-apple-spm.js's resolveExplicitConfigCommand reads it + * on add/update/scaffold and on the build-time `sync`. Re-validated through the + * same parseConfigCommandJson the flag goes through, and never throws, so a + * hand-edited marker degrades to the env-var/default command instead of + * injecting a bogus argv into the build. + */ +function readPinnedConfigCommand(appRoot /*: string */) /*: ?Array */ { + const xcodeprojPath = findInjectedXcodeproj(appRoot); + if (xcodeprojPath == null) { + return null; + } + const pinned = readMarker(xcodeprojPath)?.configCommand; + if (pinned == null) { + return null; + } + try { + return parseConfigCommandJson(JSON.stringify(pinned), SPM_INJECTED_MARKER); + } catch { + return null; + } +} + /** * Add SPM packages to a user's EXISTING xcodeproj in place. Returns * {status: 'injected', target} on success, or {status: 'refused', reason} * when the project can't be safely edited (caller surfaces it; fail-loud). */ function injectSpmIntoExistingXcodeproj( - opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string} */, + opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array} */, ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { const {appRoot, reactNativeRoot, xcodeprojPath} = opts; const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); @@ -2022,6 +2052,16 @@ function injectSpmIntoExistingXcodeproj( prevMarker?.artifactsVersionOverride ?? null; + // Same set-or-preserve contract as the version pin above, for the autolinking + // config command `add`/`update` resolved (`--config-command` or + // RCT_SPM_AUTOLINKING_CONFIG_COMMAND) — the build-time sync sees neither the + // flag nor the developer's shell environment, so without the pin it + // re-derives autolinking.json with the default @react-native-community/cli + // command and breaks apps that replace it. Read back by + // readPinnedConfigCommand (above). No "clear" verb yet either; `deinit` drops + // the whole marker, this field with it. + const configCommand = opts.configCommand ?? prevMarker?.configCommand ?? null; + // Marker: idempotency signal + the exact, reversible record of every edit so // `deinit` (removeSpmInjection) can undo precisely what was added. writeIfChanged( @@ -2038,6 +2078,7 @@ function injectSpmIntoExistingXcodeproj( // `update` to reconcile away entries that left the manifest. generatedSources: generatedSourceUuids, artifactsVersionOverride, + configCommand, scheme: { file: schemeResult.file, created: schemeResult.status === 'created', @@ -2220,5 +2261,6 @@ module.exports = { removePreActionFromScheme, findInjectedXcodeproj, readArtifactsVersionOverride, + readPinnedConfigCommand, SPM_INJECTED_MARKER, }; From 312dec87b682e2d48ae74133f9ab9c08482537cb Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Thu, 30 Jul 2026 16:22:17 +0200 Subject: [PATCH 2/2] SPM docs: document --configCommand and its persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: The `--config-command` flag has never been documented, and the pin that keeps the in-build sync using it was added without a docs change. Cover both in spm-scripts.md: - a `--configCommand ` row in CLI Options, naming `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` as the equivalent env var, - a short section giving the resolution order (flag -> RCT_SPM_AUTOLINKING_CONFIG_COMMAND -> the `configCommand` pinned in `.spm-injected.json` -> the default `@react-native-community/cli config`) and why the pin has to exist: the Sync SPM Autolinking phase inherits neither the flag nor the shell that exported the env var, so an unpinned command turns a successful `add` into failing builds. Also states that `deinit` drops the marker and the pin with it, - the marker's dual role in the "What to commit" table: reversal record *and* pinned configuration, - a Troubleshooting row keyed on the symptom people will search for — the build phase failing with `@react-native-community/cli config` exiting non-zero. Docs only; no behavior change. Deliberately not reformatted: this file is not Prettier-formatted on this branch, and reformatting would bury the change. ## Changelog: [Internal] - Document `spm --configCommand` and how the autolinking config command is persisted ## Test Plan: Docs only — nothing to run. Every statement was checked against the code on this branch: the marker field name (`configCommand` in `generate-spm-xcodeproj.js`), both input routes persisting (`resolveConfigCommandToPin` = flag ?? env), the pin never shadowing the env var (`resolveExplicitConfigCommand`), the actions that read it (`needsCliConfig` covers add/update/sync/scaffold), and the failure being a hard build error (config-command failure sets exit 2, which the generated build phase turns into `exit 1`). Co-Authored-By: Claude Opus 5 --- .../scripts/spm/__doc__/spm-scripts.md | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/react-native/scripts/spm/__doc__/spm-scripts.md b/packages/react-native/scripts/spm/__doc__/spm-scripts.md index d0d0f9cbf7a0..abe6df11fc71 100644 --- a/packages/react-native/scripts/spm/__doc__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__doc__/spm-scripts.md @@ -137,6 +137,40 @@ accepts kebab-case equivalents (e.g. `--skip-codegen`). | `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | | `--download ` | [advanced] Artifact download policy (default: auto) | | `--skipCodegen` | [advanced] Skip the codegen step | +| `--configCommand ` | [advanced] JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Also settable via the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` | + +### The autolinking config command is remembered + +An app that replaces `@react-native-community/cli` autolinking (an Expo app, +for example) has to tell `spm` how to produce `autolinking.json`. Pass the +command once, on `add` or `update`: + +```bash +npx react-native spm add --configCommand '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' +``` + +Every action that needs `autolinking.json` — `add`, `update`, `scaffold`, and +the build-time `sync` — resolves the command in this order: + +1. `--configCommand` +2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` +3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an + earlier `add`/`update` +4. the default `@react-native-community/cli config` + +`add`/`update` pin whichever of the first two routes supplied the command, +validated as an argv array; a later run that passes neither keeps the existing +pin, and passing `--configCommand` again replaces it. The pin exists because +the **Sync SPM Autolinking** build phase inherits neither your flag nor the +shell that exported the env var — without it, a successful `add` is followed by +failing builds, because the phase re-derives `autolinking.json` with the +default command. A pin never shadows the env var, so an override in your shell +still takes effect, and a pin that no longer parses is ignored in favor of the +default. + +`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add` +therefore falls back to the default command unless you pass `--configCommand` +(or export the env var) again. ### Debug/Release flavor is automatic @@ -161,7 +195,7 @@ package graph, or require a second build. | Path | Commit? | Why | |------|---------|-----| | `MyApp.xcodeproj/` | Yes | Your project, with SwiftPM injected in place. Holds your signing, capabilities, Build Phases — `add` only adds SwiftPM refs/settings, additively. | -| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. | +| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. Also pins settings later runs and Xcode builds must reuse, such as the [autolinking config command](#the-autolinking-config-command-is-remembered). | | `build/generated/` | No | Codegen/autolinking output; regenerated | | `build/xcframeworks/` | No | Symlinks to the machine-local artifact cache | | `Package.resolved` | No | SwiftPM resolution file; machine-specific | @@ -335,6 +369,7 @@ across apps; refresh it with `react-native spm update --download force`. | "not contained in target" | Re-run setup (regenerates file-level symlinks) | | Codegen fails | Use `--skipCodegen` to iterate on other parts | | "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | +| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `--configCommand` (or with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | | Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | | Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | | Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` |