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
5 changes: 5 additions & 0 deletions .changeset/extra-spm-xcframeworks.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/docs/docs/api-reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/docs/docs/cli/brownfield.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
}
}
}
29 changes: 29 additions & 0 deletions packages/cli/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
});
});

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/brownfield/commands/packageIos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,8 @@ export const packageIosCommand = curryOptions(
packageDir,
frameworkName: frameworkName ?? undefined,
usePrebuiltExpo: options.usePrebuiltExpo,
projectRoot,
extraSpmXcframeworks: options.extraSpmXcframeworks ?? [],
});

logger.success(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading
Loading