From ba1d60f85399e8e3b61093851d0de8683500b322 Mon Sep 17 00:00:00 2001 From: Daniel Potor Date: Wed, 28 Oct 2026 11:14:06 +0100 Subject: [PATCH] feat(cli): allow extra XCFrameworks in local SPM package --- .changeset/extra-spm-xcframeworks.md | 5 + .../docs/docs/api-reference/configuration.mdx | 1 + docs/docs/docs/cli/brownfield.mdx | 2 + packages/cli/schema.json | 23 ++ packages/cli/src/__tests__/config.test.ts | 29 +++ .../__tests__/packageIos.action.test.ts | 33 +++ .../cli/src/brownfield/commands/packageIos.ts | 2 + .../__tests__/createLocalSpmPackage.test.ts | 215 ++++++++++++++++++ .../brownfield/utils/createLocalSpmPackage.ts | 84 ++++++- packages/cli/src/types.ts | 22 ++ 10 files changed, 405 insertions(+), 11 deletions(-) create mode 100644 .changeset/extra-spm-xcframeworks.md diff --git a/.changeset/extra-spm-xcframeworks.md b/.changeset/extra-spm-xcframeworks.md new file mode 100644 index 000000000..5c96b5bba --- /dev/null +++ b/.changeset/extra-spm-xcframeworks.md @@ -0,0 +1,5 @@ +--- +'@callstack/brownfield-cli': minor +--- + +Allow `ios.extraSpmXcframeworks` in brownfield config so `package:ios --add-spm-package` can include project-declared XCFrameworks as additional SPM binary targets. diff --git a/docs/docs/docs/api-reference/configuration.mdx b/docs/docs/docs/api-reference/configuration.mdx index daa8b40d8..771831941 100644 --- a/docs/docs/docs/api-reference/configuration.mdx +++ b/docs/docs/docs/api-reference/configuration.mdx @@ -224,6 +224,7 @@ Example: | `ios.newArch` | `boolean` | Controls React Native new architecture support. Set `false` to match `--no-new-arch`. | | `ios.usePrebuiltRnCore` | `boolean` | Controls whether iOS packaging uses React Native Apple prebuilts. Omit it to keep Brownfield's version-aware defaults. | | `ios.addSpmPackage` | `boolean` | Generates a local Swift Package Manager manifest next to the packaged XCFramework outputs. | +| `ios.extraSpmXcframeworks` | `{ name: string, path: string }[]` | Extra XCFrameworks copied into the generated local SPM package as binary targets. Only used when `--add-spm-package` is set. Defaults to `[]`. | #### iOS Expo keys diff --git a/docs/docs/docs/cli/brownfield.mdx b/docs/docs/docs/cli/brownfield.mdx index 76a5ff712..1fb8fdbad 100644 --- a/docs/docs/docs/cli/brownfield.mdx +++ b/docs/docs/docs/cli/brownfield.mdx @@ -63,6 +63,8 @@ If you are using the brownfield-navigation package, you will also need to embed If you pass `--add-spm-package`, the CLI also writes `Package.swift` into the same `package/build` directory and prints the folder path in the terminal. In Xcode, use **File > Add Package Dependencies...**, click **Add Local...**, and select that `package/build` folder to consume the generated XCFrameworks as a local Swift package instead of dragging them in manually. +Projects that need the host to import additional XCFrameworks beside the packaged RN framework can declare them in `brownfield.config.js` as `ios.extraSpmXcframeworks`. Those entries are copied into the generated local SPM package as extra binary targets when `--add-spm-package` is set. + ## Android For Android, building happens in two steps: first, you build (`brownfield package:android`) the AAR artifact(s) with your module, in the appropriate build variant(s), and then you `brownfield publish:android` them to Maven local. diff --git a/packages/cli/schema.json b/packages/cli/schema.json index a25fb5ec9..c3c6d6bd5 100644 --- a/packages/cli/schema.json +++ b/packages/cli/schema.json @@ -176,6 +176,13 @@ }, "type": "array" }, + "extraSpmXcframeworks": { + "description": "Additional XCFrameworks copied into the generated local SPM package as binary targets. Only consumed when `--add-spm-package` is set. Defaults to [].", + "items": { + "$ref": "#/definitions/ExtraSpmXcframework" + }, + "type": "array" + }, "installPods": { "description": "Whether to install CocoaPods.", "type": "boolean" @@ -216,6 +223,22 @@ } }, "type": "object" + }, + "ExtraSpmXcframework": { + "additionalProperties": false, + "description": "Extra XCFramework declared for inclusion as a binary target in the local SPM package generated by `package:ios --add-spm-package`.", + "properties": { + "name": { + "description": "Binary target / module name used in Package.swift.", + "type": "string" + }, + "path": { + "description": "Path to the XCFramework directory, absolute or relative to the project root.", + "type": "string" + } + }, + "required": ["name", "path"], + "type": "object" } } } diff --git a/packages/cli/src/__tests__/config.test.ts b/packages/cli/src/__tests__/config.test.ts index 872ecfd0a..a2e5f7855 100644 --- a/packages/cli/src/__tests__/config.test.ts +++ b/packages/cli/src/__tests__/config.test.ts @@ -222,6 +222,22 @@ describe('validateBrownfieldCLIConfig', () => { expect(mockLoggerWarn).not.toHaveBeenCalled(); }); + it('does not warn for a valid ios.extraSpmXcframeworks config', () => { + validateBrownfieldCLIConfig({ + ios: { + scheme: 'AppScheme', + extraSpmXcframeworks: [ + { + name: 'MyHostModule', + path: './artifacts/MyHostModule.xcframework', + }, + ], + }, + }); + + expect(mockLoggerWarn).not.toHaveBeenCalled(); + }); + it('warns for a schema-invalid config', () => { validateBrownfieldCLIConfig({ unsupportedOption: true, @@ -232,6 +248,19 @@ describe('validateBrownfieldCLIConfig', () => { 'Brownfield configuration has some issues:' ); }); + + it('warns when ios.extraSpmXcframeworks entries are malformed', () => { + validateBrownfieldCLIConfig({ + ios: { + extraSpmXcframeworks: [{ name: 'MissingPath' }], + }, + }); + + expect(mockLoggerWarn).toHaveBeenCalledTimes(1); + expect(mockLoggerWarn.mock.calls[0]?.[0]).toContain( + 'Brownfield configuration has some issues:' + ); + }); }); describe('mergeBrownfieldConfigWithOptions', () => { diff --git a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts index 0fe63c48e..c43ba1e4b 100644 --- a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts +++ b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts @@ -240,6 +240,8 @@ describe('package:ios action --add-spm-package', () => { packageDir: '/repo/ios/.brownfield/package/build', frameworkName: 'BrownfieldLib', usePrebuiltExpo: true, + projectRoot: '/repo', + extraSpmXcframeworks: [], }); expect(mockLoggerSuccess).toHaveBeenCalledWith( 'Local SPM package manifest created at /repo/ios/.brownfield/package/build/Package.swift' @@ -270,6 +272,37 @@ describe('package:ios action --add-spm-package', () => { packageDir: '/repo/ios/.brownfield/package/build', frameworkName: 'BrownfieldLib', usePrebuiltExpo: false, + projectRoot: '/repo', + extraSpmXcframeworks: [], + }); + }); + + test('forwards ios.extraSpmXcframeworks from merged config to createLocalSpmPackage', async () => { + const { mergeBrownfieldConfigWithOptions } = await import( + '../../../config.js' + ); + const extraSpmXcframeworks = [ + { name: 'MyHostModule', path: './artifacts/MyHostModule.xcframework' }, + ]; + vi.mocked(mergeBrownfieldConfigWithOptions).mockImplementationOnce( + (options) => ({ + ...options, + extraSpmXcframeworks, + }) + ); + + await invokePackageIosAction([ + '--add-spm-package', + '--configuration', + 'Release', + ]); + + expect(mockCreateLocalSpmPackage).toHaveBeenCalledWith({ + packageDir: '/repo/ios/.brownfield/package/build', + frameworkName: 'BrownfieldLib', + usePrebuiltExpo: true, + projectRoot: '/repo', + extraSpmXcframeworks, }); }); diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index 00af02cc0..b70ff8db3 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -449,6 +449,8 @@ export const packageIosCommand = curryOptions( packageDir, frameworkName: frameworkName ?? undefined, usePrebuiltExpo: options.usePrebuiltExpo, + projectRoot, + extraSpmXcframeworks: options.extraSpmXcframeworks ?? [], }); logger.success( diff --git a/packages/cli/src/brownfield/utils/__tests__/createLocalSpmPackage.test.ts b/packages/cli/src/brownfield/utils/__tests__/createLocalSpmPackage.test.ts index a79bccc0a..b9748ce44 100644 --- a/packages/cli/src/brownfield/utils/__tests__/createLocalSpmPackage.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/createLocalSpmPackage.test.ts @@ -220,4 +220,219 @@ describe('createLocalSpmPackage', () => { expect(manifest).toContain('library(name: "BrownfieldLib"'); expect(manifest).toContain('binaryTarget(name: "BrownfieldLib"'); }); + + it('copies declared extra SPM XCFrameworks into the package as binary targets', () => { + createXcframework(tempDir, 'BrownfieldLib'); + createXcframework(tempDir, 'hermesvm'); + createXcframework(tempDir, 'ReactBrownfield'); + + const projectRoot = path.join(tempDir, 'project'); + const extraSourcePath = path.join( + projectRoot, + 'artifacts', + 'MyHostModule.xcframework' + ); + fs.mkdirSync(extraSourcePath, { recursive: true }); + fs.writeFileSync(path.join(extraSourcePath, 'Info.plist'), 'extra'); + + const result = createLocalSpmPackage({ + packageDir: tempDir, + frameworkName: 'BrownfieldLib', + projectRoot, + extraSpmXcframeworks: [ + { name: 'MyHostModule', path: './artifacts/MyHostModule.xcframework' }, + ], + }); + + const manifest = fs.readFileSync(result.packageManifestPath, 'utf8'); + + expect(manifest).toContain('binaryTarget(name: "MyHostModule"'); + expect(manifest).toContain( + 'path: "./spm-artifacts/MyHostModule.xcframework"' + ); + expect(manifest).toContain('"MyHostModule"'); + expect( + fs.existsSync( + path.join(tempDir, 'spm-artifacts', 'MyHostModule.xcframework', 'Info.plist') + ) + ).toBe(true); + expect(fs.existsSync(path.join(tempDir, 'MyHostModule.xcframework'))).toBe( + false + ); + expect( + fs.readFileSync( + path.join( + tempDir, + 'spm-artifacts', + 'MyHostModule.xcframework', + 'Info.plist' + ), + 'utf8' + ) + ).toBe('extra'); + }); + + it('reserves extra SPM XCFramework names during app-framework auto-detection', () => { + createXcframework(tempDir, 'BrownfieldLib'); + createXcframework(tempDir, 'hermesvm'); + createXcframework(tempDir, 'ReactBrownfield'); + createXcframework(tempDir, 'MyHostModule'); + + const projectRoot = path.join(tempDir, 'project'); + const extraSourcePath = path.join( + projectRoot, + 'artifacts', + 'MyHostModule.xcframework' + ); + fs.mkdirSync(extraSourcePath, { recursive: true }); + + const result = createLocalSpmPackage({ + packageDir: tempDir, + projectRoot, + extraSpmXcframeworks: [ + { name: 'MyHostModule', path: './artifacts/MyHostModule.xcframework' }, + ], + }); + + const manifest = fs.readFileSync(result.packageManifestPath, 'utf8'); + + expect(manifest).toContain('name: "BrownfieldLibPackage"'); + expect(manifest).toContain('library(name: "BrownfieldLib"'); + expect(manifest).toContain('binaryTarget(name: "MyHostModule"'); + }); + + it('throws a clear error when an extra SPM XCFramework path is missing', () => { + createXcframework(tempDir, 'BrownfieldLib'); + createXcframework(tempDir, 'hermesvm'); + createXcframework(tempDir, 'ReactBrownfield'); + + expect(() => + createLocalSpmPackage({ + packageDir: tempDir, + frameworkName: 'BrownfieldLib', + projectRoot: tempDir, + extraSpmXcframeworks: [ + { + name: 'MyHostModule', + path: './missing/MyHostModule.xcframework', + }, + ], + }) + ).toThrowError( + 'Missing extra SPM XCFramework "MyHostModule" at ./missing/MyHostModule.xcframework' + ); + }); + + it('keeps Package.swift identical when extraSpmXcframeworks is empty', () => { + const baselineDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'create-local-spm-package-baseline-') + ); + + try { + for (const dir of [tempDir, baselineDir]) { + createXcframework(dir, 'BrownfieldLib'); + createXcframework(dir, 'hermesvm'); + createXcframework(dir, 'ReactBrownfield'); + createXcframework(dir, 'Brownie'); + } + + const baseline = createLocalSpmPackage({ + packageDir: baselineDir, + frameworkName: 'BrownfieldLib', + }); + const withEmptyExtras = createLocalSpmPackage({ + packageDir: tempDir, + frameworkName: 'BrownfieldLib', + extraSpmXcframeworks: [], + }); + + expect(fs.readFileSync(withEmptyExtras.packageManifestPath, 'utf8')).toBe( + fs.readFileSync(baseline.packageManifestPath, 'utf8') + ); + expect(fs.readFileSync(path.join(tempDir, 'README.md'), 'utf8')).toBe( + fs.readFileSync(path.join(baselineDir, 'README.md'), 'utf8') + ); + } finally { + fs.rmSync(baselineDir, { recursive: true, force: true }); + } + }); + + it('includes multiple declared extra SPM XCFrameworks', () => { + createXcframework(tempDir, 'BrownfieldLib'); + createXcframework(tempDir, 'hermesvm'); + createXcframework(tempDir, 'ReactBrownfield'); + + const projectRoot = path.join(tempDir, 'project'); + for (const name of ['MyHostModule', 'DesignSystem']) { + fs.mkdirSync(path.join(projectRoot, 'artifacts', `${name}.xcframework`), { + recursive: true, + }); + } + + const result = createLocalSpmPackage({ + packageDir: tempDir, + frameworkName: 'BrownfieldLib', + projectRoot, + extraSpmXcframeworks: [ + { name: 'MyHostModule', path: './artifacts/MyHostModule.xcframework' }, + { name: 'DesignSystem', path: './artifacts/DesignSystem.xcframework' }, + ], + }); + + const manifest = fs.readFileSync(result.packageManifestPath, 'utf8'); + + expect(manifest).toContain('binaryTarget(name: "MyHostModule"'); + expect(manifest).toContain('binaryTarget(name: "DesignSystem"'); + expect(manifest).toContain( + 'targets: ["BrownfieldLib", "hermesvm", "ReactBrownfield", "MyHostModule", "DesignSystem"]' + ); + expect( + fs.existsSync( + path.join(tempDir, 'spm-artifacts', 'MyHostModule.xcframework') + ) + ).toBe(true); + expect( + fs.existsSync( + path.join(tempDir, 'spm-artifacts', 'DesignSystem.xcframework') + ) + ).toBe(true); + }); + + it('resolves absolute extra SPM XCFramework paths', () => { + createXcframework(tempDir, 'BrownfieldLib'); + createXcframework(tempDir, 'hermesvm'); + createXcframework(tempDir, 'ReactBrownfield'); + + const absoluteExtraPath = path.join( + tempDir, + 'absolute-artifacts', + 'MyHostModule.xcframework' + ); + fs.mkdirSync(absoluteExtraPath, { recursive: true }); + fs.writeFileSync(path.join(absoluteExtraPath, 'marker.txt'), 'absolute'); + + const result = createLocalSpmPackage({ + packageDir: tempDir, + frameworkName: 'BrownfieldLib', + projectRoot: path.join(tempDir, 'unused-project-root'), + extraSpmXcframeworks: [ + { name: 'MyHostModule', path: absoluteExtraPath }, + ], + }); + + const manifest = fs.readFileSync(result.packageManifestPath, 'utf8'); + + expect(manifest).toContain('binaryTarget(name: "MyHostModule"'); + expect( + fs.readFileSync( + path.join( + tempDir, + 'spm-artifacts', + 'MyHostModule.xcframework', + 'marker.txt' + ), + 'utf8' + ) + ).toBe('absolute'); + }); }); diff --git a/packages/cli/src/brownfield/utils/createLocalSpmPackage.ts b/packages/cli/src/brownfield/utils/createLocalSpmPackage.ts index 0dda1d837..0289e0917 100644 --- a/packages/cli/src/brownfield/utils/createLocalSpmPackage.ts +++ b/packages/cli/src/brownfield/utils/createLocalSpmPackage.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import type { ExtraSpmXcframework } from '../../types.js'; import { prepareLocalSpmArtifacts, SPM_ARTIFACTS_DIR_NAME, @@ -14,6 +15,8 @@ type CreateLocalSpmPackageOptions = { packageDir: string; frameworkName?: string; usePrebuiltExpo?: boolean; + projectRoot?: string; + extraSpmXcframeworks?: ExtraSpmXcframework[]; }; type CreateLocalSpmPackageResult = { @@ -56,6 +59,7 @@ function requireHermesXcframework(packageDir: string) { function resolveAppFrameworkName( packageDir: string, + reservedFrameworkNames: Set, explicitFrameworkName?: string ) { if (explicitFrameworkName) { @@ -68,7 +72,7 @@ function resolveAppFrameworkName( (entry) => entry.isDirectory() && entry.name.endsWith('.xcframework') && - !RESERVED_FRAMEWORK_NAMES.has(path.basename(entry.name, '.xcframework')) + !reservedFrameworkNames.has(path.basename(entry.name, '.xcframework')) ) .map((entry) => path.basename(entry.name, '.xcframework')) .sort(); @@ -88,6 +92,47 @@ function resolveAppFrameworkName( ); } +function resolveExtraSpmXcframeworkPath( + projectRoot: string, + entry: ExtraSpmXcframework +) { + return path.isAbsolute(entry.path) + ? entry.path + : path.resolve(projectRoot, entry.path); +} + +function stageExtraSpmXcframeworks({ + packageDir, + projectRoot, + extraSpmXcframeworks, +}: { + packageDir: string; + projectRoot: string; + extraSpmXcframeworks: ExtraSpmXcframework[]; +}) { + const stagedNames: string[] = []; + + for (const entry of extraSpmXcframeworks) { + const sourcePath = resolveExtraSpmXcframeworkPath(projectRoot, entry); + + if (!fs.existsSync(sourcePath)) { + throw new Error( + `Missing extra SPM XCFramework "${entry.name}" at ${entry.path}` + ); + } + + const destinationPath = path.join( + packageDir, + `${entry.name}.xcframework` + ); + fs.rmSync(destinationPath, { recursive: true, force: true }); + fs.cpSync(sourcePath, destinationPath, { recursive: true }); + stagedNames.push(entry.name); + } + + return stagedNames; +} + function renderPackageSwift({ packageName, libraryName, @@ -176,25 +221,42 @@ export function createLocalSpmPackage({ packageDir, frameworkName, usePrebuiltExpo = true, + projectRoot = process.cwd(), + extraSpmXcframeworks = [], }: CreateLocalSpmPackageOptions): CreateLocalSpmPackageResult { + const reservedFrameworkNames = new Set([ + ...RESERVED_FRAMEWORK_NAMES, + ...extraSpmXcframeworks.map((entry) => entry.name), + ]); + const extraTargetNames = stageExtraSpmXcframeworks({ + packageDir, + projectRoot, + extraSpmXcframeworks, + }); const resolvedFrameworkName = resolveAppFrameworkName( packageDir, + reservedFrameworkNames, frameworkName ); const expoSupportXcframeworkNames = getExpoSupportXcframeworkNames(usePrebuiltExpo); const targetNames = [ - resolvedFrameworkName, - requireHermesXcframework(packageDir), - requireXcframework(packageDir, 'ReactBrownfield'), - optionalXcframework(packageDir, 'Brownie'), - optionalXcframework(packageDir, 'BrownfieldNavigation'), - ...expoSupportXcframeworkNames.map((frameworkName) => - optionalXcframework(packageDir, frameworkName) + ...new Set( + [ + resolvedFrameworkName, + requireHermesXcframework(packageDir), + requireXcframework(packageDir, 'ReactBrownfield'), + optionalXcframework(packageDir, 'Brownie'), + optionalXcframework(packageDir, 'BrownfieldNavigation'), + ...expoSupportXcframeworkNames.map((expoFrameworkName) => + optionalXcframework(packageDir, expoFrameworkName) + ), + optionalXcframework(packageDir, 'React'), + optionalXcframework(packageDir, 'ReactNativeDependencies'), + ...extraTargetNames, + ].filter((targetName): targetName is string => targetName !== null) ), - optionalXcframework(packageDir, 'React'), - optionalXcframework(packageDir, 'ReactNativeDependencies'), - ].filter((targetName): targetName is string => targetName !== null); + ]; const packageManifestPath = path.join(packageDir, 'Package.swift'); const readmePath = path.join(packageDir, 'README.md'); diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index b11c5607b..dc7246072 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -29,6 +29,22 @@ export type BrownieConfig = { kotlinPackageName?: string; }; +/** + * Extra XCFramework declared for inclusion as a binary target in the + * local SPM package generated by `package:ios --add-spm-package`. + */ +export type ExtraSpmXcframework = { + /** + * Binary target / module name used in Package.swift. + */ + name: string; + + /** + * Path to the XCFramework directory, absolute or relative to the project root. + */ + path: string; +}; + export type PackageIosOptions = Omit & { /** Set when `--use-prebuilt-rn-core` is passed; omitted when the flag is absent (Rock applies RN version defaults). */ usePrebuiltRnCore?: boolean; @@ -38,6 +54,12 @@ export type PackageIosOptions = Omit & { /** When set, generate a local Swift Package Manager manifest next to the packaged XCFramework outputs. */ addSpmPackage?: boolean; + + /** + * Additional XCFrameworks copied into the generated local SPM package as + * binary targets. Only consumed when `--add-spm-package` is set. Defaults to []. + */ + extraSpmXcframeworks?: ExtraSpmXcframework[]; }; export type BrownfieldPackageAndroidOptions = BrownfieldCommonOptions &