@callstack/react-native-brownfield 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/lib/commonjs/expo-config-plugin/android/withAndroidModuleFiles.js +1 -1
  3. package/lib/commonjs/expo-config-plugin/android/withAndroidModuleFiles.js.map +1 -1
  4. package/lib/commonjs/expo-config-plugin/ios/podfileHelpers.js +1 -1
  5. package/lib/commonjs/expo-config-plugin/ios/podfileHelpers.js.map +1 -1
  6. package/lib/commonjs/expo-config-plugin/ios/withBrownfieldIos.js +1 -1
  7. package/lib/commonjs/expo-config-plugin/ios/withBrownfieldIos.js.map +1 -1
  8. package/lib/commonjs/expo-config-plugin/ios/withIosFrameworkFiles.js +1 -1
  9. package/lib/commonjs/expo-config-plugin/ios/withIosFrameworkFiles.js.map +1 -1
  10. package/lib/commonjs/expo-config-plugin/ios/xcodeHelpers.js +2 -2
  11. package/lib/commonjs/expo-config-plugin/ios/xcodeHelpers.js.map +1 -1
  12. package/lib/commonjs/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +35 -0
  13. package/lib/commonjs/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift +12 -0
  14. package/lib/commonjs/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb +5 -0
  15. package/lib/commonjs/index.js.map +1 -1
  16. package/lib/commonjs/scaffold/index.js +2 -0
  17. package/lib/commonjs/scaffold/index.js.map +1 -0
  18. package/lib/commonjs/scaffold/projectFiles.js +2 -0
  19. package/lib/commonjs/scaffold/projectFiles.js.map +1 -0
  20. package/lib/commonjs/scaffold/xcode.d.js +2 -0
  21. package/lib/commonjs/scaffold/xcode.d.js.map +1 -0
  22. package/lib/module/expo-config-plugin/android/withAndroidModuleFiles.js +1 -1
  23. package/lib/module/expo-config-plugin/android/withAndroidModuleFiles.js.map +1 -1
  24. package/lib/module/expo-config-plugin/ios/podfileHelpers.js +1 -1
  25. package/lib/module/expo-config-plugin/ios/podfileHelpers.js.map +1 -1
  26. package/lib/module/expo-config-plugin/ios/withBrownfieldIos.js +1 -1
  27. package/lib/module/expo-config-plugin/ios/withBrownfieldIos.js.map +1 -1
  28. package/lib/module/expo-config-plugin/ios/withIosFrameworkFiles.js +1 -1
  29. package/lib/module/expo-config-plugin/ios/withIosFrameworkFiles.js.map +1 -1
  30. package/lib/module/expo-config-plugin/ios/xcodeHelpers.js +2 -2
  31. package/lib/module/expo-config-plugin/ios/xcodeHelpers.js.map +1 -1
  32. package/lib/module/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +35 -0
  33. package/lib/module/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift +12 -0
  34. package/lib/module/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb +5 -0
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/module/scaffold/index.js +2 -0
  37. package/lib/module/scaffold/index.js.map +1 -0
  38. package/lib/module/scaffold/projectFiles.js +2 -0
  39. package/lib/module/scaffold/projectFiles.js.map +1 -0
  40. package/lib/module/scaffold/xcode.d.js +2 -0
  41. package/lib/module/scaffold/xcode.d.js.map +1 -0
  42. package/lib/typescript/commonjs/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts +10 -2
  43. package/lib/typescript/commonjs/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/src/expo-config-plugin/ios/podfileHelpers.d.ts +4 -2
  45. package/lib/typescript/commonjs/src/expo-config-plugin/ios/podfileHelpers.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/src/expo-config-plugin/ios/withBrownfieldIos.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts +11 -2
  48. package/lib/typescript/commonjs/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/src/expo-config-plugin/ios/xcodeHelpers.d.ts +6 -2
  50. package/lib/typescript/commonjs/src/expo-config-plugin/ios/xcodeHelpers.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/src/scaffold/index.d.ts +23 -0
  53. package/lib/typescript/commonjs/src/scaffold/index.d.ts.map +1 -0
  54. package/lib/typescript/commonjs/src/scaffold/projectFiles.d.ts +63 -0
  55. package/lib/typescript/commonjs/src/scaffold/projectFiles.d.ts.map +1 -0
  56. package/lib/typescript/module/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts +10 -2
  57. package/lib/typescript/module/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts.map +1 -1
  58. package/lib/typescript/module/src/expo-config-plugin/ios/podfileHelpers.d.ts +4 -2
  59. package/lib/typescript/module/src/expo-config-plugin/ios/podfileHelpers.d.ts.map +1 -1
  60. package/lib/typescript/module/src/expo-config-plugin/ios/withBrownfieldIos.d.ts.map +1 -1
  61. package/lib/typescript/module/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts +11 -2
  62. package/lib/typescript/module/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts.map +1 -1
  63. package/lib/typescript/module/src/expo-config-plugin/ios/xcodeHelpers.d.ts +6 -2
  64. package/lib/typescript/module/src/expo-config-plugin/ios/xcodeHelpers.d.ts.map +1 -1
  65. package/lib/typescript/module/src/index.d.ts.map +1 -1
  66. package/lib/typescript/module/src/scaffold/index.d.ts +23 -0
  67. package/lib/typescript/module/src/scaffold/index.d.ts.map +1 -0
  68. package/lib/typescript/module/src/scaffold/projectFiles.d.ts +63 -0
  69. package/lib/typescript/module/src/scaffold/projectFiles.d.ts.map +1 -0
  70. package/package.json +16 -2
  71. package/src/expo-config-plugin/android/withAndroidModuleFiles.ts +33 -5
  72. package/src/expo-config-plugin/ios/podfileHelpers.ts +24 -6
  73. package/src/expo-config-plugin/ios/withBrownfieldIos.ts +5 -2
  74. package/src/expo-config-plugin/ios/withIosFrameworkFiles.ts +21 -6
  75. package/src/expo-config-plugin/ios/xcodeHelpers.ts +77 -5
  76. package/src/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +35 -0
  77. package/src/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift +12 -0
  78. package/src/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb +5 -0
  79. package/src/index.ts +7 -0
  80. package/src/scaffold/index.ts +395 -0
  81. package/src/scaffold/projectFiles.ts +243 -0
  82. package/src/scaffold/xcode.d.ts +4 -0
@@ -0,0 +1,395 @@
1
+ // Emitted as a triple-slash reference into the generated scaffold .d.ts, so
2
+ // consumers (e.g. @callstack/create-react-native-brownfield) resolve the untyped `xcode`
3
+ // import through this single declaration instead of duplicating xcode.d.ts.
4
+ /// <reference path="./xcode.d.ts" />
5
+ import fs from 'node:fs';
6
+ import { createRequire } from 'node:module';
7
+ import path from 'node:path';
8
+
9
+ import type { ModProps, XcodeProject } from '@expo/config-plugins';
10
+ import type { UserConfig } from '@react-native-community/cli-types';
11
+ import cliConfigImport from '@react-native-community/cli-config';
12
+ import xcode from 'xcode';
13
+
14
+ import { Logger } from '../expo-config-plugin/logging';
15
+ import type {
16
+ ResolvedBrownfieldPluginConfigWithAndroid,
17
+ ResolvedBrownfieldPluginConfigWithIos,
18
+ } from '../expo-config-plugin/types';
19
+ import {
20
+ modifyRootBuildGradle,
21
+ modifySettingsGradle,
22
+ } from '../expo-config-plugin/android/utils/gradleHelpers';
23
+ import { createAndroidModule } from '../expo-config-plugin/android/withAndroidModuleFiles';
24
+ import { modifyPodfile } from '../expo-config-plugin/ios/podfileHelpers';
25
+ import {
26
+ addFrameworkTarget,
27
+ addSourceFilesBuildPhase,
28
+ copyBundleReactNativePhase,
29
+ } from '../expo-config-plugin/ios/xcodeHelpers';
30
+ import { createIosFramework } from '../expo-config-plugin/ios/withIosFrameworkFiles';
31
+ import {
32
+ addBrownfieldDependencies,
33
+ addBrownfieldPackageScripts,
34
+ writeBrownfieldFileConfig,
35
+ } from './projectFiles';
36
+
37
+ // ESM/CJS interop shim: `@react-native-community/cli-config` ships CJS whose
38
+ // default export is the function itself, but depending on how this module is
39
+ // loaded (bundler/ESM interop) the import may be the module namespace object
40
+ // with the function on `.default`. Unwrap to the callable either way —
41
+ // do not remove, otherwise the scaffold crashes with "cliConfig is not a function".
42
+ const cliConfig: typeof cliConfigImport =
43
+ typeof cliConfigImport === 'function'
44
+ ? cliConfigImport
45
+ : (cliConfigImport as any).default;
46
+
47
+ export type BrownfieldScaffoldOptions = {
48
+ /**
49
+ * React Native project root directory (contains package.json).
50
+ * Defaults to current working directory.
51
+ */
52
+ projectRoot?: string;
53
+
54
+ /**
55
+ * iOS framework target name (also framework directory name).
56
+ * Defaults to "BrownfieldLib".
57
+ */
58
+ iosFrameworkName?: string;
59
+
60
+ /**
61
+ * Android library module folder / Gradle module name.
62
+ * Defaults to "brownfieldlib".
63
+ */
64
+ androidModuleName?: string;
65
+
66
+ /**
67
+ * Enables verbose logging.
68
+ */
69
+ debug?: boolean;
70
+ };
71
+
72
+ function findProjectRoot(startDir: string): string {
73
+ let currentDir = startDir;
74
+ while (true) {
75
+ if (fs.existsSync(path.join(currentDir, 'package.json'))) {
76
+ return currentDir;
77
+ }
78
+ const parent = path.dirname(currentDir);
79
+ if (parent === currentDir) break;
80
+ currentDir = parent;
81
+ }
82
+ throw new Error('Could not find project root (no package.json found)');
83
+ }
84
+
85
+ function resolveUserConfig(projectRoot: string): UserConfig {
86
+ // Do not pass selectedPlatform: it restricts detection to a single platform
87
+ // and would leave project.android undefined.
88
+ return cliConfig({
89
+ projectRoot,
90
+ }) as UserConfig;
91
+ }
92
+
93
+ function readFileIfExists(filePath: string): string | null {
94
+ return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null;
95
+ }
96
+
97
+ function writeFileIfChanged(filePath: string, next: string) {
98
+ const prev = readFileIfExists(filePath);
99
+ if (prev === next) return;
100
+ fs.writeFileSync(filePath, next, 'utf8');
101
+ }
102
+
103
+ function firstXcodeprojPath(iosDir: string): string {
104
+ const entries = fs.readdirSync(iosDir, { withFileTypes: true });
105
+ const xcodeproj = entries.find(
106
+ (e) => e.isDirectory() && e.name.endsWith('.xcodeproj')
107
+ );
108
+ if (!xcodeproj) {
109
+ throw new Error(
110
+ `Could not find an .xcodeproj under ${iosDir}. Did you run iOS project generation?`
111
+ );
112
+ }
113
+ return path.join(iosDir, xcodeproj.name);
114
+ }
115
+
116
+ function unquote(value: string): string {
117
+ return value.replace(/^"+|"+$/g, '');
118
+ }
119
+
120
+ function resolveIosAppBundleId(pbxproj: any): string | null {
121
+ const nativeTargets = pbxproj.pbxNativeTargetSection?.() ?? {};
122
+ const configLists = pbxproj.pbxXCConfigurationList?.() ?? {};
123
+ const buildConfigs = pbxproj.pbxXCBuildConfigurationSection?.() ?? {};
124
+
125
+ for (const [key, target] of Object.entries<any>(nativeTargets)) {
126
+ if (key.endsWith('_comment')) continue;
127
+ // heuristic: application targets usually have productType including "application"
128
+ if (
129
+ typeof target?.productType === 'string' &&
130
+ !target.productType.includes('application')
131
+ ) {
132
+ continue;
133
+ }
134
+
135
+ const configListId = target?.buildConfigurationList;
136
+ const configList = configLists?.[configListId];
137
+ const debugConfigId = configList?.buildConfigurations?.find?.(
138
+ (c: any) => c?.comment === 'Debug'
139
+ )?.value;
140
+ const debugConfig = debugConfigId ? buildConfigs?.[debugConfigId] : null;
141
+ const bundleId = debugConfig?.buildSettings?.PRODUCT_BUNDLE_IDENTIFIER;
142
+ if (typeof bundleId === 'string' && bundleId.length > 0) {
143
+ return unquote(bundleId);
144
+ }
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ function resolveReactNativeVersion(projectRoot: string): string {
151
+ // The shipped build is CommonJS, where bare `require` works. If this module
152
+ // is ever loaded as true ESM, bare `require` is undefined; fall back to an
153
+ // explicit `createRequire`. Anchored at cwd (not `import.meta.url`) because
154
+ // Babel keeps `import.meta` verbatim in the CJS output, which is a parse
155
+ // SyntaxError there.
156
+ const localRequire: NodeRequire =
157
+ typeof require === 'undefined'
158
+ ? createRequire(path.join(process.cwd(), 'noop.js'))
159
+ : require;
160
+ const rnPkgPath = localRequire.resolve('react-native/package.json', {
161
+ paths: [projectRoot],
162
+ });
163
+ const rnPkg = localRequire(rnPkgPath);
164
+ if (!rnPkg?.version) {
165
+ throw new Error('Could not resolve react-native version from package.json');
166
+ }
167
+ return rnPkg.version;
168
+ }
169
+
170
+ export async function scaffoldBrownfieldInRncCliProject(
171
+ options: BrownfieldScaffoldOptions = {}
172
+ ): Promise<void> {
173
+ const projectRoot = findProjectRoot(
174
+ path.resolve(options.projectRoot ?? process.cwd())
175
+ );
176
+ Logger.setIsDebug(options.debug ?? false);
177
+
178
+ const userConfig = resolveUserConfig(projectRoot);
179
+ const android = userConfig.project.android;
180
+ const ios = userConfig.project.ios;
181
+
182
+ if (!android) {
183
+ throw new Error('Android project not found.');
184
+ }
185
+ if (!ios) {
186
+ throw new Error('iOS project not found.');
187
+ }
188
+
189
+ const androidDir = path.isAbsolute(android.sourceDir)
190
+ ? android.sourceDir
191
+ : path.join(projectRoot, android.sourceDir || 'android');
192
+ // Honour the CLI-resolved iOS source directory (symmetry with Android
193
+ // above): a project that sets project.ios.sourceDir to a custom relative
194
+ // path makes the Community CLI resolve it to an absolute directory, and
195
+ // hard-coding <root>/ios would then miss the .xcodeproj or mutate the
196
+ // wrong directory. Note: an absolute sourceDir authored by the user is
197
+ // already rejected upstream (cli-config-apple joins it onto the project
198
+ // root), so the isAbsolute branch mainly guards the resolved value.
199
+ const iosSourceDir = ios.sourceDir;
200
+ const iosDir = path.isAbsolute(iosSourceDir)
201
+ ? iosSourceDir
202
+ : path.join(projectRoot, iosSourceDir || 'ios');
203
+
204
+ const rnVersion = resolveReactNativeVersion(projectRoot);
205
+
206
+ const iosFrameworkName = options.iosFrameworkName ?? 'BrownfieldLib';
207
+ const androidModuleName = options.androidModuleName ?? 'brownfieldlib';
208
+ const androidPackageName = android.packageName ?? android.applicationId;
209
+ if (!androidPackageName) {
210
+ throw new Error(
211
+ 'Could not resolve Android package name from React Native CLI config.'
212
+ );
213
+ }
214
+
215
+ // Tracks the current mutation phase so a mid-way failure can name the step
216
+ // that failed and point users at fix-forward guidance (see catch below).
217
+ let currentStep = 'preconditions';
218
+
219
+ try {
220
+ currentStep = 'android-gradle';
221
+ // --- Android: root build.gradle + settings.gradle ---
222
+ const rootBuildGradlePath = path.join(androidDir, 'build.gradle');
223
+ const rootBuildGradle = readFileIfExists(rootBuildGradlePath);
224
+ if (!rootBuildGradle) {
225
+ throw new Error(`Missing ${rootBuildGradlePath}`);
226
+ }
227
+ writeFileIfChanged(
228
+ rootBuildGradlePath,
229
+ modifyRootBuildGradle(rootBuildGradle)
230
+ );
231
+
232
+ const settingsGradlePath = path.join(androidDir, 'settings.gradle');
233
+ const settingsGradle = readFileIfExists(settingsGradlePath);
234
+ if (!settingsGradle) {
235
+ throw new Error(`Missing ${settingsGradlePath}`);
236
+ }
237
+ writeFileIfChanged(
238
+ settingsGradlePath,
239
+ modifySettingsGradle(settingsGradle, androidModuleName)
240
+ );
241
+
242
+ const resolvedAndroidConfig: ResolvedBrownfieldPluginConfigWithAndroid = {
243
+ android: {
244
+ moduleName: androidModuleName,
245
+ packageName: androidPackageName,
246
+ // minSdk has no rootProject fallback in the shared build.gradle.kts
247
+ // template (it renders a literal), so keep the Expo plugin default.
248
+ // RN CLI rootProject.ext.minSdkVersion is 24 on current templates
249
+ // anyway; the generated build.gradle.kts stays readable.
250
+ minSdkVersion: 24,
251
+ // compile/target SDK are intentionally not hardcoded: leaving them
252
+ // unset makes the shared template fall back to
253
+ // resolveRootProjectInt("compileSdkVersion"/"targetSdkVersion"), i.e.
254
+ // the app's own rootProject.ext values (RN 0.87 needs compileSdk 37+;
255
+ // AGP 9 rejects library `targetSdk` entirely — see
256
+ // renderTargetSdkBlock). Proven against a real RN 0.87.1 app.
257
+ targetSdkVersion: undefined,
258
+ compileSdkVersion: undefined,
259
+ groupId: androidPackageName,
260
+ artifactId: androidModuleName,
261
+ version: '0.0.1-SNAPSHOT',
262
+ // Fields added to the resolved config after the original draft was
263
+ // written (BGP local-plugin wiring + flavor dimensions, #448/#458).
264
+ // Defaults match the Expo plugin defaults for RN CLI projects.
265
+ useLocalGradlePlugin: false,
266
+ useLocalMaven: false,
267
+ missingDimensionStrategies: [],
268
+ },
269
+ ios: null,
270
+ debug: options.debug ?? false,
271
+ };
272
+
273
+ currentStep = 'android-module';
274
+ createAndroidModule({
275
+ androidDir,
276
+ config: resolvedAndroidConfig,
277
+ rnVersion,
278
+ // 'vanilla' = non-Expo host (same axis as `useExpoHost: false` below).
279
+ templateVariant: 'vanilla',
280
+ });
281
+
282
+ // --- iOS: xcodeproj + Podfile + framework source files ---
283
+ currentStep = 'ios-project';
284
+ const xcodeprojPath = firstXcodeprojPath(iosDir);
285
+ const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
286
+ if (!fs.existsSync(pbxprojPath)) {
287
+ throw new Error(`Missing ${pbxprojPath}`);
288
+ }
289
+
290
+ const project = xcode.project(pbxprojPath);
291
+ project.parseSync();
292
+
293
+ const appBundleId = resolveIosAppBundleId(project);
294
+ const brownfieldBundleId = appBundleId
295
+ ? `${appBundleId}.brownfield`
296
+ : `com.brownfield.${iosFrameworkName.toLowerCase()}`;
297
+
298
+ const resolvedIosConfig: ResolvedBrownfieldPluginConfigWithIos = {
299
+ ios: {
300
+ frameworkName: iosFrameworkName,
301
+ bundleIdentifier: brownfieldBundleId,
302
+ buildSettings: {},
303
+ // Defaults aligned with the Expo plugin defaults (Expo's fallback
304
+ // when no deployment target can be derived; see
305
+ // resolveFrameworkDeploymentTarget). RN CLI config does not expose
306
+ // an iOS deployment target directly.
307
+ // TODO: derive from the Podfile `platform :ios` line or RN's
308
+ // min_ios_version_supported, or expose a flag.
309
+ deploymentTarget: '15.0',
310
+ // Standard CFBundleShortVersionString for the generated framework
311
+ // (matches the Expo plugin default); unrelated to package versioning.
312
+ frameworkVersion: '1',
313
+ },
314
+ android: null,
315
+ debug: options.debug ?? false,
316
+ };
317
+
318
+ // Contract: downstream helpers (xcodeHelpers.addFrameworkTarget /
319
+ // resolveAppTargetName) only read `platformProjectRoot` (framework group
320
+ // location) and `projectName` (app-target-name fallback), but the shared
321
+ // Expo-path signatures require a full ModProps, so we provide every field
322
+ // with scaffold-appropriate values (no mods are run here, so `introspect`
323
+ // is false and `nextMod` is omitted).
324
+ const modRequest: ModProps<XcodeProject> = {
325
+ projectRoot,
326
+ platformProjectRoot: iosDir,
327
+ modName: 'react-native-brownfield-scaffold',
328
+ platform: 'ios',
329
+ introspect: false,
330
+ projectName: path.basename(xcodeprojPath, '.xcodeproj'),
331
+ };
332
+
333
+ const { frameworkTargetUUID } = addFrameworkTarget(
334
+ project,
335
+ modRequest,
336
+ resolvedIosConfig.ios,
337
+ { useExpoHost: false }
338
+ );
339
+
340
+ copyBundleReactNativePhase(project, frameworkTargetUUID);
341
+ addSourceFilesBuildPhase(
342
+ project,
343
+ frameworkTargetUUID,
344
+ resolvedIosConfig.ios,
345
+ { useExpoHost: false }
346
+ );
347
+ // xcode@3.x writeSync() returns the serialized project but does not write it.
348
+ // This is the riskiest write: a failure here leaves a partially mutated
349
+ // pbxproj on disk (the in-memory changes are lost, not half-written).
350
+ fs.writeFileSync(pbxprojPath, project.writeSync());
351
+
352
+ currentStep = 'ios-podfile';
353
+ const podfilePath = path.join(iosDir, 'Podfile');
354
+ const podfile = readFileIfExists(podfilePath);
355
+ if (!podfile) {
356
+ throw new Error(`Missing ${podfilePath}`);
357
+ }
358
+ writeFileIfChanged(podfilePath, modifyPodfile(podfile, iosFrameworkName));
359
+
360
+ currentStep = 'ios-sources';
361
+ createIosFramework(iosDir, resolvedIosConfig, { useExpoHost: false });
362
+
363
+ // --- App package wiring: Brownfield deps + package:* scripts ---
364
+ // The scaffolded Kotlin host imports com.callstack.reactnativebrownfield.*,
365
+ // so the JS/runtime package must be an app dependency for native builds to
366
+ // resolve it. Idempotent: existing version specs are left untouched.
367
+ currentStep = 'package-dependencies';
368
+ addBrownfieldDependencies(projectRoot);
369
+
370
+ currentStep = 'package-scripts';
371
+ addBrownfieldPackageScripts(projectRoot);
372
+
373
+ // --- brownfield.config.json: packaging settings for `brownfield package:*` ---
374
+ // Deterministic content derived from the scaffolded names, so re-runs
375
+ // produce identical bytes (overwrite == no-op).
376
+ currentStep = 'brownfield-config';
377
+ writeBrownfieldFileConfig(projectRoot, {
378
+ iosFrameworkName,
379
+ androidModuleName,
380
+ });
381
+ } catch (error) {
382
+ const message = error instanceof Error ? error.message : String(error);
383
+ Logger.logInfo(
384
+ 'Scaffolding failed mid-way; fix-forward: re-running the CLI is idempotent for completed steps (gradle settings, Podfile, xcodeproj target detection, package.json deps/scripts, brownfield.config.json) — inspect partial changes with git diff.'
385
+ );
386
+ throw new Error(
387
+ `Brownfield scaffolding failed during step "${currentStep}": ${message}`,
388
+ {
389
+ cause: error,
390
+ }
391
+ );
392
+ }
393
+
394
+ Logger.logInfo('Brownfield scaffolding complete.');
395
+ }
@@ -0,0 +1,243 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ import { Logger } from '../expo-config-plugin/logging';
5
+
6
+ export const BROWNFIELD_RUNTIME_PACKAGE_NAME =
7
+ '@callstack/react-native-brownfield';
8
+ export const BROWNFIELD_CLI_PACKAGE_NAME = '@callstack/brownfield-cli';
9
+
10
+ /**
11
+ * Match the docs convention (getting-started/quick-start + expo.mdx): both
12
+ * packages are regular dependencies. The brownfield CLI ships as a dependency
13
+ * of the runtime package, so a consistent caret range pinned to this package's
14
+ * own published version keeps the two in lockstep (they are released together).
15
+ */
16
+ export function resolveBrownfieldPackageVersionRange(): string {
17
+ return `^${readOwnPackageVersion()}`;
18
+ }
19
+
20
+ function readOwnPackageVersion(): string {
21
+ // Walk up from this module to its own package.json. Works both in the
22
+ // published layout (lib/commonjs/scaffold -> package root) and in-repo
23
+ // (src/scaffold -> package root). `__dirname` is absent in true-ESM builds
24
+ // (and `import.meta` must not be used here — Babel keeps it verbatim in the
25
+ // CJS output, which is a parse SyntaxError there; see index.ts), so fall
26
+ // back to cwd, which is correct whenever the package is resolved from the
27
+ // app's own node_modules chain.
28
+ let dir = typeof __dirname === 'string' ? __dirname : process.cwd();
29
+ while (true) {
30
+ const packageJsonPath = path.join(dir, 'package.json');
31
+ if (fs.existsSync(packageJsonPath)) {
32
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
33
+ name?: string;
34
+ version?: string;
35
+ };
36
+ if (pkg.name === BROWNFIELD_RUNTIME_PACKAGE_NAME) {
37
+ if (!pkg.version) {
38
+ throw new Error(
39
+ `${BROWNFIELD_RUNTIME_PACKAGE_NAME} package.json has no version`
40
+ );
41
+ }
42
+ return pkg.version;
43
+ }
44
+ }
45
+ const parent = path.dirname(dir);
46
+ if (parent === dir) break;
47
+ dir = parent;
48
+ }
49
+ throw new Error(
50
+ `Could not locate the ${BROWNFIELD_RUNTIME_PACKAGE_NAME} package.json to resolve dependency versions.`
51
+ );
52
+ }
53
+
54
+ type PackageJson = {
55
+ dependencies?: Record<string, string>;
56
+ devDependencies?: Record<string, string>;
57
+ scripts?: Record<string, string>;
58
+ [key: string]: unknown;
59
+ };
60
+
61
+ function readPackageJson(projectRoot: string): PackageJson {
62
+ const packageJsonPath = path.join(projectRoot, 'package.json');
63
+ if (!fs.existsSync(packageJsonPath)) {
64
+ throw new Error(`Missing ${packageJsonPath}`);
65
+ }
66
+ return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJson;
67
+ }
68
+
69
+ function writePackageJson(projectRoot: string, pkg: PackageJson): void {
70
+ // Stable formatting (2-space indent, trailing newline), so repeated runs
71
+ // produce byte-identical output.
72
+ fs.writeFileSync(
73
+ path.join(projectRoot, 'package.json'),
74
+ JSON.stringify(pkg, null, 2) + '\n',
75
+ 'utf8'
76
+ );
77
+ }
78
+
79
+ function ensureDependency(
80
+ pkg: PackageJson,
81
+ packageName: string,
82
+ versionRange: string
83
+ ): boolean {
84
+ // Idempotency: if the package is declared anywhere already, leave the
85
+ // user's version spec untouched (same spirit as the Podfile/gradle guards).
86
+ if (pkg.dependencies?.[packageName] ?? pkg.devDependencies?.[packageName]) {
87
+ return false;
88
+ }
89
+ pkg.dependencies = pkg.dependencies ?? {};
90
+ pkg.dependencies[packageName] = versionRange;
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * Adds @callstack/react-native-brownfield (the Kotlin/Swift host code the
96
+ * scaffolded module imports) and @callstack/brownfield-cli (the `brownfield`
97
+ * bin used by the package:* scripts) to the app's dependencies.
98
+ * Existing declarations are never overwritten. Returns the names added.
99
+ */
100
+ export function addBrownfieldDependencies(projectRoot: string): string[] {
101
+ const pkg = readPackageJson(projectRoot);
102
+ const versionRange = resolveBrownfieldPackageVersionRange();
103
+
104
+ const added: string[] = [];
105
+ if (ensureDependency(pkg, BROWNFIELD_RUNTIME_PACKAGE_NAME, versionRange)) {
106
+ added.push(BROWNFIELD_RUNTIME_PACKAGE_NAME);
107
+ }
108
+ if (ensureDependency(pkg, BROWNFIELD_CLI_PACKAGE_NAME, versionRange)) {
109
+ added.push(BROWNFIELD_CLI_PACKAGE_NAME);
110
+ }
111
+
112
+ if (added.length > 0) {
113
+ writePackageJson(projectRoot, pkg);
114
+ Logger.logDebug(
115
+ `Added Brownfield dependencies: ${added.join(', ')} (${versionRange})`
116
+ );
117
+ }
118
+ return added;
119
+ }
120
+
121
+ /**
122
+ * Adds the `package:ios` / `package:android` scripts from the docs' manual
123
+ * steps (settings come from the generated brownfield.config.json, so no flags
124
+ * are duplicated here). Existing scripts with the same names are kept.
125
+ */
126
+ export function addBrownfieldPackageScripts(projectRoot: string): string[] {
127
+ const pkg = readPackageJson(projectRoot);
128
+ pkg.scripts = pkg.scripts ?? {};
129
+
130
+ const desiredScripts: Record<string, string> = {
131
+ 'package:ios': 'brownfield package:ios',
132
+ 'package:android': 'brownfield package:android',
133
+ };
134
+
135
+ const added: string[] = [];
136
+ for (const [name, command] of Object.entries(desiredScripts)) {
137
+ if (pkg.scripts[name] === undefined) {
138
+ pkg.scripts[name] = command;
139
+ added.push(name);
140
+ }
141
+ }
142
+
143
+ if (added.length > 0) {
144
+ writePackageJson(projectRoot, pkg);
145
+ Logger.logDebug(`Added Brownfield scripts: ${added.join(', ')}`);
146
+ }
147
+ return added;
148
+ }
149
+
150
+ export type BrownfieldFileConfig = {
151
+ $schema: string;
152
+ ios: {
153
+ scheme: string;
154
+ configuration: string;
155
+ };
156
+ android: {
157
+ moduleName: string;
158
+ variant: string;
159
+ };
160
+ };
161
+
162
+ /**
163
+ * Builds the brownfield.config.json content for the scaffolded targets,
164
+ * matching the manual steps in docs getting-started/ios.mdx (step 5) and
165
+ * android.mdx (step 7) and the CLI config schema (packages/cli/src/types.ts,
166
+ * strict additionalProperties: false).
167
+ *
168
+ * Idempotency strategy: the content is derived deterministically from the
169
+ * scaffolded project state (framework/module names), so re-running the
170
+ * scaffold overwrites with byte-identical content. Users customizing it are
171
+ * taking over responsibility; a re-scaffold with the same names is a no-op.
172
+ */
173
+ export function createBrownfieldFileConfig({
174
+ iosFrameworkName,
175
+ androidModuleName,
176
+ }: {
177
+ iosFrameworkName: string;
178
+ androidModuleName: string;
179
+ }): BrownfieldFileConfig {
180
+ return {
181
+ $schema: 'https://oss.callstack.com/react-native-brownfield/schema.json',
182
+ ios: {
183
+ // iOS packaging builds the generated framework target by scheme name.
184
+ scheme: iosFrameworkName,
185
+ configuration: 'Release',
186
+ },
187
+ android: {
188
+ // Gradle module name of the generated library module.
189
+ moduleName: androidModuleName,
190
+ variant: 'Release',
191
+ },
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Writes brownfield.config.json at the project root. Deterministic output:
197
+ * same inputs -> same bytes, so re-running the scaffold never duplicates or
198
+ * drifts the file.
199
+ *
200
+ * Skips writing (returns null) when another Brownfield config source already
201
+ * exists: packages/cli loadBrownfieldConfig fails hard on multiple config
202
+ * files, so an existing brownfield.config.js or a legacy `brownfield` key in
203
+ * package.json takes precedence over generating a JSON one.
204
+ */
205
+ export function writeBrownfieldFileConfig(
206
+ projectRoot: string,
207
+ names: { iosFrameworkName: string; androidModuleName: string }
208
+ ): string | null {
209
+ const configPath = path.join(projectRoot, 'brownfield.config.json');
210
+
211
+ const jsConfigPath = path.join(projectRoot, 'brownfield.config.js');
212
+ if (fs.existsSync(jsConfigPath)) {
213
+ Logger.logDebug(
214
+ `Skipping brownfield.config.json: ${jsConfigPath} already exists.`
215
+ );
216
+ return null;
217
+ }
218
+
219
+ const pkg = readPackageJson(projectRoot);
220
+ if ('brownfield' in pkg) {
221
+ Logger.logDebug(
222
+ 'Skipping brownfield.config.json: legacy "brownfield" key in package.json already exists.'
223
+ );
224
+ return null;
225
+ }
226
+
227
+ // Trailing newline, same as writePackageJson: the idempotency guard below
228
+ // compares bytes, so the written content must be exactly what a
229
+ // newline-normalizing toolchain (editorconfig, prettier, git text eol)
230
+ // leaves on disk. Content + '\n' is both the written bytes and the
231
+ // comparison value, so the guard cannot be broken by an external normalizer.
232
+ const content =
233
+ JSON.stringify(createBrownfieldFileConfig(names), null, 2) + '\n';
234
+ const prev = fs.existsSync(configPath)
235
+ ? fs.readFileSync(configPath, 'utf8')
236
+ : null;
237
+
238
+ if (prev !== content) {
239
+ fs.writeFileSync(configPath, content, 'utf8');
240
+ Logger.logDebug(`Wrote ${configPath}`);
241
+ }
242
+ return configPath;
243
+ }
@@ -0,0 +1,4 @@
1
+ declare module 'xcode' {
2
+ const xcode: any;
3
+ export default xcode;
4
+ }