Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 63 additions & 9 deletions packages/react-native/scripts/setup-apple-spm.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
* must contain debug/ and release/ cache slots.
* [advanced] --download <auto|skip|force> Artifact policy (default: auto).
* [advanced] --skip-codegen Skip the react-native codegen step.
* [advanced] --config-command <json> Override the autolinking config command.
*
* Steps performed (add/update):
* 1. react-native codegen → build/generated/ios/ + install SPM codegen template
Expand Down Expand Up @@ -83,6 +84,7 @@ const {
} = require('./spm/generate-spm-autolinking');
const {
generateAutolinkingConfig,
parseConfigCommandJson,
} = require('./spm/generate-spm-autolinking-config');
const {main: generatePackage} = require('./spm/generate-spm-package');
const {findSourcePath} = require('./spm/generate-spm-package');
Expand Down Expand Up @@ -187,6 +189,11 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
default: false,
describe: '[advanced] Skip the react-native codegen step',
})
.option('config-command', {
type: 'string',
describe:
'[advanced] JSON array of the argv used to generate autolinking.json, overriding the default @react-native-community/cli config command. Also settable via RCT_SPM_AUTOLINKING_CONFIG_COMMAND. Example: \'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]\'',
})
.usage(
'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.',
)
Expand Down Expand Up @@ -214,6 +221,10 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
version: parsed.version ?? null,
artifacts: parsed.artifacts ?? null,
skipCodegen: parsed['skip-codegen'],
configCommand:
parsed['config-command'] != null
? parseConfigCommandJson(parsed['config-command'], '--config-command')
: null,
downloadPolicy: parsed.download,
productName: parsed['product-name'] ?? null,
xcodeprojPath: parsed.xcodeproj ?? null,
Expand Down Expand Up @@ -892,6 +903,47 @@ function logNextSteps(
log('To remove SPM later: `npx react-native spm deinit`');
}

// Generate autolinking.json, failing closed on a config-command error.
//
// generateAutolinkingConfig throws ONLY when the config command itself fails —
// a non-zero exit, unparseable output, or a config missing
// project.ios.sourceDir. Swallowing that (the old behavior) let the run proceed
// and emit an empty Autolinked package, which only surfaced much later as an
// inscrutable `unable to resolve module dependency` at build time. Instead we
// set process.exitCode = 2 (a hard Xcode build-phase error, matching the
// RemoteVersionError path) and return null so the caller stops.
//
// A genuinely native-module-free app does NOT reach the error path: its command
// exits 0 with valid, empty-dependency JSON, so generateAutolinkingConfig
// returns normally and the empty-package path downstream stays valid.
function generateAutolinkingConfigOrFailClosed(
opts /*: {
projectRoot: string,
configCommand?: Array<string>,
generate?: typeof generateAutolinkingConfig,
} */,
) /*: ?AutolinkingConfigResult */ {
const generate = opts.generate ?? generateAutolinkingConfig;
try {
return generate({
projectRoot: opts.projectRoot,
configCommand: opts.configCommand,
});
} catch (e) {
logError(
`Failed to generate autolinking.json: ${e.message}\n` +
'The autolinking config command failed. If this app replaces ' +
'@react-native-community/cli autolinking (e.g. an Expo app), set ' +
'RCT_SPM_AUTOLINKING_CONFIG_COMMAND (or pass --config-command) to a ' +
'JSON argv array whose command prints the React Native CLI config, ' +
'e.g. \'["npx","expo-modules-autolinking","react-native-config",' +
'"--json","--platform","ios"]\'.',
);
process.exitCode = 2;
return null;
}
}

async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
let appRoot = process.cwd();
const projectRoot = findProjectRoot(appRoot);
Expand Down Expand Up @@ -981,16 +1033,16 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
let autolinkingConfigResult /*: ?AutolinkingConfigResult */ = null;
if (needsCliConfig) {
log('Generating autolinking.json (CLI config)...');
try {
autolinkingConfigResult = generateAutolinkingConfig({projectRoot});
log(
`Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`,
);
} catch (e) {
logError(
`generate-spm-autolinking-config failed: ${e.message}. External native modules may not be discovered.`,
);
autolinkingConfigResult = generateAutolinkingConfigOrFailClosed({
projectRoot,
configCommand: args.configCommand ?? undefined,
});
if (autolinkingConfigResult == null) {
// Fail closed: the config command errored and the helper already set
// process.exitCode = 2. Stop rather than emit an empty Autolinked package.
return;
}
log(`Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`);
}
const reactNativeRoot = resolveReactNativeRoot(
autolinkingConfigResult,
Expand Down Expand Up @@ -1159,6 +1211,8 @@ module.exports = {
main,
detectStandardRnLayoutRedirect,
findInjectedXcodeproj,
generateAutolinkingConfigOrFailClosed,
parseArgs,
resolveAction,
shouldAutoDeintegrate,
ensureBothArtifactFlavors,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ const os = require('node:os');
const path = require('node:path');

let tmpProjects = [];
let originalConfigCommandEnv;

beforeEach(() => {
originalConfigCommandEnv = process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND;
delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND;
});

afterEach(() => {
if (originalConfigCommandEnv == null) {
delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND;
} else {
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = originalConfigCommandEnv;
}
});

function makeTmpProject() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-autolink-config-'));
Expand Down Expand Up @@ -256,4 +270,95 @@ describe('generateAutolinkingConfig', () => {
rawJson: raw,
});
});

describe('config command override', () => {
it('uses the config command from RCT_SPM_AUTOLINKING_CONFIG_COMMAND', () => {
const {projectRoot, iosDir} = makeTmpProject();
const raw = JSON.stringify(fakeCliConfig(iosDir));
let receivedCommand = null;
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([
'my-cli',
'config',
]);

generateAutolinkingConfig({
projectRoot,
cliRunner: command => {
receivedCommand = command;
return {stdout: raw, stderr: '', exitCode: 0};
},
});

expect(receivedCommand).toEqual(['my-cli', 'config']);
});

it('prefers an explicit configCommand over the environment variable', () => {
const {projectRoot, iosDir} = makeTmpProject();
const raw = JSON.stringify(fakeCliConfig(iosDir));
let receivedCommand = null;
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([
'environment',
'config',
]);

generateAutolinkingConfig({
projectRoot,
configCommand: ['explicit', 'config'],
cliRunner: command => {
receivedCommand = command;
return {stdout: raw, stderr: '', exitCode: 0};
},
});

expect(receivedCommand).toEqual(['explicit', 'config']);
});

it('throws when the environment variable is not JSON', () => {
const {projectRoot} = makeTmpProject();
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = 'not json';

expect(() =>
generateAutolinkingConfig({
projectRoot,
cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}),
}),
).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/);
});

it.each(['[]', '[1,2]'])(
'throws when the environment variable is not a non-empty string array: %s',
rawConfigCommand => {
const {projectRoot} = makeTmpProject();
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = rawConfigCommand;

expect(() =>
generateAutolinkingConfig({
projectRoot,
cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}),
}),
).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/);
},
);

it('falls back to the default command when the environment variable is unset', () => {
const {projectRoot, iosDir} = makeTmpProject();
const raw = JSON.stringify(fakeCliConfig(iosDir));
let receivedCommand = null;

generateAutolinkingConfig({
projectRoot,
cliRunner: command => {
receivedCommand = command;
return {stdout: raw, stderr: '', exitCode: 0};
},
});

expect(receivedCommand).toEqual([
'npx',
'--no-install',
'@react-native-community/cli',
'config',
]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const {
detectStandardRnLayoutRedirect,
ensureBothArtifactFlavors,
findInjectedXcodeproj,
generateAutolinkingConfigOrFailClosed,
parseArgs,
resolveAction,
shouldAutoDeintegrate,
} = require('../../setup-apple-spm');
Expand Down Expand Up @@ -59,6 +61,102 @@ function gitInitAndCommit(dir) {
execFileSync('git', ['commit', '-m', 'init'], opts);
}

describe('parseArgs', () => {
it('parses --config-command as a JSON argv array', () => {
const args = parseArgs([
'update',
'--config-command',
'["a","b","config"]',
]);

expect(args.action).toBe('update');
expect(args.configCommand).toEqual(['a', 'b', 'config']);
});

it('sets configCommand to null when --config-command is omitted', () => {
expect(parseArgs(['update']).configCommand).toBeNull();
});

it('throws for an invalid --config-command value', () => {
expect(() => parseArgs(['update', '--config-command', 'not json'])).toThrow(
/--config-command/,
);
});
});

// ---------------------------------------------------------------------------
// generateAutolinkingConfigOrFailClosed — the fail-closed policy main() applies
// to the autolinking config step. Swallowing a config-command error (the old
// behavior) let the build proceed with a silently-empty Autolinked package that
// only surfaced later as `unable to resolve module dependency`. A native-
// module-free app does NOT hit the error path: its command exits 0 with valid
// empty-dependency JSON and the generator returns normally.
// ---------------------------------------------------------------------------

describe('generateAutolinkingConfigOrFailClosed', () => {
let prevExitCode;
let warnSpy;

beforeEach(() => {
prevExitCode = process.exitCode;
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
process.exitCode = prevExitCode;
jest.restoreAllMocks();
});

it('returns the config result and leaves the exit code untouched on success', () => {
const result = {
config: {},
outputPath: '/app/ios/autolinking.json',
rawJson: '{}',
};
const out = generateAutolinkingConfigOrFailClosed({
projectRoot: '/app',
generate: () => result,
});

expect(out).toBe(result);
expect(process.exitCode).not.toBe(2);
});

it('passes projectRoot and configCommand through to the generator', () => {
let received;
generateAutolinkingConfigOrFailClosed({
projectRoot: '/proj',
configCommand: ['my-cli', 'config'],
generate: opts => {
received = opts;
return {config: {}, outputPath: '', rawJson: ''};
},
});

expect(received).toEqual({
projectRoot: '/proj',
configCommand: ['my-cli', 'config'],
});
});

it('fails closed (null, exit 2, actionable error) when the config command errors', () => {
const out = generateAutolinkingConfigOrFailClosed({
projectRoot: '/app',
generate: () => {
throw new Error("'my-cli config' exited with status 1");
},
});

expect(out).toBeNull();
expect(process.exitCode).toBe(2);
const warnings = warnSpy.mock.calls.map(c => c.join(' ')).join('\n');
// Names the override so the next person can fix it...
expect(warnings).toMatch(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/);
// ...and preserves the underlying cause.
expect(warnings).toMatch(/exited with status 1/);
});
});

// ---------------------------------------------------------------------------
// resolveAction — zero-arg default. Explicit action wins; otherwise `update`
// when an injection marker exists, else `add` (first run).
Expand Down
Loading
Loading