From b4db5dfb8efebf08a25a6c06051bc2c7a11558c6 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 29 Jul 2026 19:24:21 +0200 Subject: [PATCH] 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, };