diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 61104df1723..b6ce83ca5b3 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 4fe3d342fdc..1da3f62301f 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 4fe297cfc5e..8e506bb5615 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 b923577132b..6861e432b3b 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 e75af429ce2..6ca2a682f1c 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, };