@crumbsdk/react-native 0.0.1-rc.4 → 0.0.1-rc.5

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.
@@ -0,0 +1,63 @@
1
+ // Apply in the application module after the React Native plugin.
2
+ import groovy.json.JsonOutput
3
+
4
+ def crumbPackage = new File(["node", "--print", "require.resolve('@crumbsdk/react-native/package.json')"].execute(null, rootDir).text.trim()).parentFile
5
+ def crumbTool = new File(crumbPackage, 'tools/cli.cjs')
6
+ def crumbNodeWrapper = new File(crumbPackage, 'tools/node.cjs')
7
+ def runCrumb = { args, directory ->
8
+ def execution = providers.exec {
9
+ workingDir directory
10
+ commandLine args
11
+ ignoreExitValue = true
12
+ }
13
+ def stdout = execution.standardOutput.asText.get()
14
+ def stderr = execution.standardError.asText.get()
15
+ if (stdout.trim()) logger.lifecycle(stdout.trim())
16
+ if (stderr.trim()) logger.warn(stderr.trim())
17
+ execution.result.get().assertNormalExitValue()
18
+ }
19
+
20
+ plugins.withId('com.facebook.react') {
21
+ androidComponents.onVariants(androidComponents.selector().all()) { variant ->
22
+ def suffix = variant.name.capitalize()
23
+ def bundleName = "createBundle${suffix}JsAndAssets"
24
+ def releaseDir = layout.buildDirectory.dir("crumb/${variant.name}")
25
+ def manifest = releaseDir.map { new File(it.asFile, 'release.json') }
26
+ def uploadTask = tasks.register("uploadCrumb${suffix}SourceMaps") {
27
+ group = 'react'
28
+ description = 'Upload exact Crumb release artifacts after JavaScript bundling.'
29
+ onlyIf {
30
+ def bundleTask = tasks.findByName(bundleName)
31
+ bundleTask != null && bundleTask.state.executed && bundleTask.state.failure == null && manifest.get().exists()
32
+ }
33
+ doLast {
34
+ def bundleTask = tasks.getByName(bundleName)
35
+ def originalNode = bundleTask.ext.crumbOriginalNode
36
+ def outputs = variant.outputs.collect { output ->
37
+ [appVersion: output.versionName.get(), nativeBuild: output.versionCode.get().toString()]
38
+ }.unique()
39
+ outputs.each { target ->
40
+ def bundle = new File(bundleTask.jsBundleDir.get().asFile, bundleTask.bundleAssetName.get())
41
+ def sourceMap = new File(bundleTask.jsSourceMapsDir.get().asFile, bundleTask.bundleAssetName.get() + '.map')
42
+ def input = new File(releaseDir.get().asFile, "input-${target.nativeBuild}.json")
43
+ input.text = JsonOutput.toJson(target + [root: bundleTask.root.get().asFile.absolutePath,
44
+ manifest: manifest.get().absolutePath, platform: 'android',
45
+ bundle: bundle.absolutePath, sourceMap: sourceMap.absolutePath])
46
+ runCrumb(originalNode + [crumbTool.absolutePath, 'upload-build', '--input', input.absolutePath], bundleTask.root.get().asFile)
47
+ }
48
+ }
49
+ }
50
+ tasks.matching { it.name == bundleName }.configureEach { bundleTask ->
51
+ def originalNode = new ArrayList(bundleTask.nodeExecutableAndArgs.get())
52
+ bundleTask.nodeExecutableAndArgs.set(originalNode + [crumbNodeWrapper.absolutePath, manifest.get().absolutePath])
53
+ bundleTask.outputs.file(manifest)
54
+ bundleTask.outputs.file(releaseDir.map { new File(it.asFile, 'crumb-release.js') })
55
+ bundleTask.inputs.file(new File(bundleTask.root.get().asFile, 'crumb.config.json')).optional()
56
+ bundleTask.doFirst {
57
+ runCrumb(originalNode + [crumbTool.absolutePath, 'prepare', '--directory', releaseDir.get().asFile.absolutePath], bundleTask.root.get().asFile)
58
+ }
59
+ bundleTask.ext.crumbOriginalNode = originalNode
60
+ bundleTask.finalizedBy(uploadTask)
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,122 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { spawnSync } = require('node:child_process');
4
+ const { randomUUID } = require('node:crypto');
5
+ const {
6
+ prepareRelease,
7
+ readRelease,
8
+ verifyMapIdentity,
9
+ } = require('./release.cjs');
10
+ const { uploadRelease } = require('./upload.cjs');
11
+ const { readConfig } = require('./config.cjs');
12
+
13
+ function validateTargets(value) {
14
+ if (!Array.isArray(value) || value.length === 0 || value.length > 100)
15
+ throw new Error(
16
+ 'Supply between 1 and 100 explicit native release targets.'
17
+ );
18
+ const seen = new Set();
19
+ for (const target of value) {
20
+ if (
21
+ !target ||
22
+ !['ios', 'android'].includes(target.platform) ||
23
+ Object.keys(target).some(
24
+ (key) => !['platform', 'appVersion', 'nativeBuild'].includes(key)
25
+ ) ||
26
+ ![target.appVersion, target.nativeBuild].every(
27
+ (item) =>
28
+ typeof item === 'string' &&
29
+ /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/.test(item)
30
+ )
31
+ ) {
32
+ throw new Error(
33
+ 'Each target must contain platform (ios/android), appVersion and nativeBuild from an eligible built binary.'
34
+ );
35
+ }
36
+ const key = JSON.stringify([
37
+ target.platform,
38
+ target.appVersion,
39
+ target.nativeBuild,
40
+ ]);
41
+ if (seen.has(key)) throw new Error('Duplicate native release target.');
42
+ seen.add(key);
43
+ }
44
+ return value;
45
+ }
46
+
47
+ function exportArtifacts(directory, targets) {
48
+ const metadata = JSON.parse(
49
+ fs.readFileSync(path.join(directory, 'metadata.json'), 'utf8')
50
+ );
51
+ if (
52
+ metadata.version !== 0 ||
53
+ metadata.bundler !== 'metro' ||
54
+ !metadata.fileMetadata
55
+ )
56
+ throw new Error(
57
+ 'Unsupported Expo export metadata. Use the advanced uploader for this export format.'
58
+ );
59
+ return targets.map((target) => {
60
+ const name = metadata.fileMetadata[target.platform]?.bundle;
61
+ if (typeof name !== 'string' || path.isAbsolute(name))
62
+ throw new Error('The export is missing a native platform bundle.');
63
+ const bundle = path.resolve(directory, name);
64
+ if (!bundle.startsWith(path.resolve(directory) + path.sep))
65
+ throw new Error('The export bundle must be inside the export directory.');
66
+ const sourceMap = bundle + '.map';
67
+ for (const file of [bundle, sourceMap]) {
68
+ if (
69
+ !fs.statSync(file).isFile() ||
70
+ !fs.realpathSync(file).startsWith(fs.realpathSync(directory) + path.sep)
71
+ )
72
+ throw new Error(
73
+ 'Export artifacts must be regular files inside the export directory.'
74
+ );
75
+ }
76
+ return { ...target, bundle, sourceMap };
77
+ });
78
+ }
79
+
80
+ function exportRelease(root, output, targetsFile, options = {}) {
81
+ if (!readConfig(root).sourceMaps.enabled)
82
+ throw new Error(
83
+ 'Enable source maps with crumb setup before preparing an export.'
84
+ );
85
+ const targets = validateTargets(
86
+ JSON.parse(fs.readFileSync(targetsFile, 'utf8'))
87
+ );
88
+ const directory = path.resolve(root, output);
89
+ if (fs.existsSync(directory))
90
+ throw new Error(
91
+ 'Choose a new export directory. Crumb does not overwrite an existing export.'
92
+ );
93
+ const manifest = prepareRelease(
94
+ path.join(root, '.crumb', 'exports', randomUUID())
95
+ );
96
+ const expo = require.resolve('expo/bin/cli', { paths: [root] });
97
+ const args = [expo, 'export', '--source-maps', '--output-dir', directory];
98
+ for (const platform of new Set(targets.map((target) => target.platform)))
99
+ args.push('--platform', platform);
100
+ const result = spawnSync(process.execPath, args, {
101
+ cwd: root,
102
+ stdio: 'inherit',
103
+ env: { ...process.env, CRUMB_RELEASE_MANIFEST: manifest },
104
+ });
105
+ if (result.error || result.status !== 0)
106
+ throw new Error(
107
+ 'Expo export failed. Fix the build error before uploading or publishing an update.'
108
+ );
109
+ const artifacts = exportArtifacts(directory, targets);
110
+ for (const artifact of artifacts)
111
+ verifyMapIdentity(artifact.sourceMap, readRelease(manifest).bundleVersion);
112
+ for (const artifact of artifacts)
113
+ uploadRelease(
114
+ { root, manifest, ...artifact },
115
+ { dryRun: options.dryRun, strict: true }
116
+ );
117
+ console.log(
118
+ 'Crumb: export retained. Publish this exact directory with EAS --input-dir and --skip-bundler; do not rebuild it.'
119
+ );
120
+ }
121
+
122
+ module.exports = { validateTargets, exportArtifacts, exportRelease };
package/tools/ios.cjs ADDED
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ const path = require('node:path');
3
+ const fs = require('node:fs');
4
+ const { spawnSync, execFileSync } = require('node:child_process');
5
+ const { readConfig, isStrict } = require('./config.cjs');
6
+ const { prepareRelease } = require('./release.cjs');
7
+ const { uploadRelease } = require('./upload.cjs');
8
+
9
+ function plistValue(env, key) {
10
+ const candidates = [
11
+ env.TARGET_BUILD_DIR && env.INFOPLIST_PATH
12
+ ? path.resolve(env.TARGET_BUILD_DIR, env.INFOPLIST_PATH)
13
+ : undefined,
14
+ env.INFOPLIST_FILE
15
+ ? path.resolve(env.PROJECT_DIR, env.INFOPLIST_FILE)
16
+ : undefined,
17
+ ];
18
+ for (const file of candidates) {
19
+ if (!file || !fs.existsSync(file)) continue;
20
+ try {
21
+ const value = execFileSync(
22
+ '/usr/libexec/PlistBuddy',
23
+ ['-c', `Print :${key}`, file],
24
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
25
+ ).trim();
26
+ return value.replace(
27
+ /\$\(([^)]+)\)|\$\{([^}]+)\}/g,
28
+ (expression, a, b) => env[a || b] || expression
29
+ );
30
+ } catch {
31
+ /* Try the build's other plist source. */
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ function main() {
38
+ const env = { ...process.env };
39
+ const root = path.resolve(
40
+ env.PROJECT_ROOT || path.join(env.PROJECT_DIR || process.cwd(), '..')
41
+ );
42
+ const config = readConfig(root);
43
+ const enabled =
44
+ config.sourceMaps.enabled &&
45
+ !/Debug/i.test(env.CONFIGURATION || '') &&
46
+ env.SKIP_BUNDLING !== '1';
47
+ let manifest;
48
+ if (enabled) {
49
+ if (!env.DERIVED_FILE_DIR)
50
+ throw new Error('Run the Crumb iOS hook from an Xcode build.');
51
+ manifest = prepareRelease(
52
+ path.join(env.DERIVED_FILE_DIR, 'crumb', env.TARGET_NAME || 'app')
53
+ );
54
+ env.CRUMB_RELEASE_MANIFEST = manifest;
55
+ env.SOURCEMAP_FILE = path.join(path.dirname(manifest), 'bundle.map');
56
+ }
57
+ const [command, ...args] = process.argv.slice(2);
58
+ if (!command)
59
+ throw new Error('The original Xcode bundle command is required.');
60
+ const build = spawnSync(command, args, { env, stdio: 'inherit' });
61
+ if (build.error || build.status !== 0) {
62
+ process.exitCode = build.status || 1;
63
+ return;
64
+ }
65
+ if (!manifest) return;
66
+ const bundle = path.join(
67
+ env.CONFIGURATION_BUILD_DIR,
68
+ env.UNLOCALIZED_RESOURCES_FOLDER_PATH,
69
+ `${env.BUNDLE_NAME || 'main'}.jsbundle`
70
+ );
71
+ uploadRelease(
72
+ {
73
+ root,
74
+ manifest,
75
+ platform: 'ios',
76
+ appVersion: plistValue(env, 'CFBundleShortVersionString'),
77
+ nativeBuild: plistValue(env, 'CFBundleVersion'),
78
+ bundle,
79
+ sourceMap: env.SOURCEMAP_FILE,
80
+ },
81
+ { strict: isStrict(config) }
82
+ );
83
+ }
84
+
85
+ if (require.main === module) {
86
+ try {
87
+ main();
88
+ } catch (error) {
89
+ console.error(`Crumb: ${error.message}`);
90
+ process.exitCode = 1;
91
+ }
92
+ }
93
+ module.exports = { plistValue };
package/tools/node.cjs ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ const { spawnSync } = require('node:child_process');
3
+ const { readConfig } = require('./config.cjs');
4
+ const [manifest, ...args] = process.argv.slice(2);
5
+ if (!manifest || !args.length) {
6
+ console.error(
7
+ 'Crumb build wrapper requires release metadata and the original Node command.'
8
+ );
9
+ process.exitCode = 1;
10
+ } else {
11
+ const result = spawnSync(process.execPath, [...process.execArgv, ...args], {
12
+ stdio: 'inherit',
13
+ env: {
14
+ ...process.env,
15
+ CRUMB_RELEASE_MANIFEST: readConfig(process.cwd()).sourceMaps.enabled
16
+ ? manifest
17
+ : '',
18
+ },
19
+ });
20
+ process.exitCode = result.status === null ? 1 : result.status;
21
+ }
@@ -0,0 +1,65 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { randomUUID, createHash } = require('node:crypto');
4
+
5
+ function prepareRelease(directory) {
6
+ fs.mkdirSync(directory, { recursive: true });
7
+ const bundleVersion = randomUUID();
8
+ const polyfill = path.join(directory, 'crumb-release.js');
9
+ fs.writeFileSync(
10
+ polyfill,
11
+ `globalThis.__CRUMB_BUNDLE_VERSION__ = ${JSON.stringify(bundleVersion)};\n`
12
+ );
13
+ const manifest = path.join(directory, 'release.json');
14
+ fs.writeFileSync(
15
+ manifest,
16
+ JSON.stringify({ version: 1, bundleVersion, polyfill })
17
+ );
18
+ return manifest;
19
+ }
20
+
21
+ function readRelease(file) {
22
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
23
+ if (
24
+ value.version !== 1 ||
25
+ typeof value.bundleVersion !== 'string' ||
26
+ !/^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/.test(value.bundleVersion) ||
27
+ typeof value.polyfill !== 'string' ||
28
+ !path.isAbsolute(value.polyfill)
29
+ ) {
30
+ throw new Error(
31
+ 'Crumb release metadata is invalid. Rebuild using the configured Crumb build hook.'
32
+ );
33
+ }
34
+ return value;
35
+ }
36
+
37
+ function hashFile(file) {
38
+ return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
39
+ }
40
+
41
+ function verifyMapIdentity(file, bundleVersion) {
42
+ if (fs.statSync(file).size > 25 * 1024 * 1024)
43
+ throw new Error('Source map exceeds the 25 MiB upload limit.');
44
+ const map = JSON.parse(fs.readFileSync(file, 'utf8'));
45
+ function contains(value, depth = 0) {
46
+ if (!value || depth > 8) return false;
47
+ return (
48
+ (Array.isArray(value.sourcesContent) &&
49
+ value.sourcesContent.some(
50
+ (source) =>
51
+ typeof source === 'string' &&
52
+ source.includes('__CRUMB_BUNDLE_VERSION__') &&
53
+ source.includes(JSON.stringify(bundleVersion))
54
+ )) ||
55
+ (Array.isArray(value.sections) &&
56
+ value.sections.some((section) => contains(section.map, depth + 1)))
57
+ );
58
+ }
59
+ if (!contains(map))
60
+ throw new Error(
61
+ 'The final map does not contain this build’s Crumb identity. Check the Metro integration and rebuild; no artifacts were uploaded.'
62
+ );
63
+ }
64
+
65
+ module.exports = { prepareRelease, readRelease, hashFile, verifyMapIdentity };
@@ -0,0 +1,291 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { readConfig, validateOrigin } = require('./config.cjs');
4
+ const {
5
+ configureXcodeProject,
6
+ configureGradle,
7
+ } = require('../plugin/build-hooks.cjs');
8
+
9
+ const metroMarker = '// crumb-metro';
10
+
11
+ function setupPlan(root, { sourceMaps, uploadUrl } = {}) {
12
+ const pkg = JSON.parse(
13
+ fs.readFileSync(path.join(root, 'package.json'), 'utf8')
14
+ );
15
+ const dependencies = { ...pkg.dependencies, ...pkg.devDependencies };
16
+ if (!dependencies['react-native'])
17
+ throw new Error('Run crumb setup from the React Native app directory.');
18
+ const expo = Boolean(dependencies.expo);
19
+ const manager = [
20
+ 'yarn.lock',
21
+ 'pnpm-lock.yaml',
22
+ 'bun.lock',
23
+ 'bun.lockb',
24
+ 'package-lock.json',
25
+ ].find((name) => fs.existsSync(path.join(root, name)));
26
+ const changes = [];
27
+ const instructions = [];
28
+ const edit = (name, content) => {
29
+ const file = path.join(root, name);
30
+ const before = fs.existsSync(file)
31
+ ? fs.readFileSync(file, 'utf8')
32
+ : undefined;
33
+ if (before !== content) changes.push({ name, file, before, content });
34
+ };
35
+ const config = readConfig(root);
36
+ if (sourceMaps !== undefined) config.sourceMaps.enabled = sourceMaps;
37
+ if (config.sourceMaps.enabled)
38
+ config.sourceMaps.uploadUrl = validateOrigin(
39
+ uploadUrl || config.sourceMaps.uploadUrl
40
+ );
41
+ edit('crumb.config.json', JSON.stringify(config, null, 2) + '\n');
42
+ if (!dependencies['react-native-nitro-modules']) {
43
+ const version =
44
+ require('../package.json').peerDependencies['react-native-nitro-modules'];
45
+ const add =
46
+ manager === 'yarn.lock'
47
+ ? 'yarn add'
48
+ : manager === 'pnpm-lock.yaml'
49
+ ? 'pnpm add'
50
+ : manager?.startsWith('bun')
51
+ ? 'bun add'
52
+ : 'npm install';
53
+ instructions.push(
54
+ `Install the required native peer: ${add} react-native-nitro-modules@${JSON.stringify(version)}`
55
+ );
56
+ }
57
+ if (expo) {
58
+ if (
59
+ ['app.config.ts', 'app.config.js', 'app.config.mjs'].some((name) =>
60
+ fs.existsSync(path.join(root, name))
61
+ )
62
+ ) {
63
+ instructions.push(
64
+ 'Add @crumbsdk/react-native to plugins in your dynamic Expo config. Existing dynamic configuration was preserved.'
65
+ );
66
+ } else {
67
+ const appFile = path.join(root, 'app.json');
68
+ const app = fs.existsSync(appFile)
69
+ ? JSON.parse(fs.readFileSync(appFile, 'utf8'))
70
+ : { expo: {} };
71
+ if (!app.expo)
72
+ throw new Error(
73
+ 'app.json does not contain an Expo configuration. Add the Crumb plugin manually.'
74
+ );
75
+ const plugins = app.expo.plugins || [];
76
+ if (
77
+ !plugins.some(
78
+ (item) =>
79
+ (Array.isArray(item) ? item[0] : item) === '@crumbsdk/react-native'
80
+ )
81
+ ) {
82
+ app.expo.plugins = [...plugins, '@crumbsdk/react-native'];
83
+ edit('app.json', JSON.stringify(app, null, 2) + '\n');
84
+ }
85
+ }
86
+ instructions.push(
87
+ 'Run your normal Expo prebuild/development build to apply native configuration. Expo Go cannot load the native SDK.'
88
+ );
89
+ } else {
90
+ const podfile = path.join(root, 'ios/Podfile');
91
+ if (fs.existsSync(podfile)) {
92
+ let contents = fs.readFileSync(podfile, 'utf8');
93
+ if (!contents.includes('crumb_native_pods!')) {
94
+ if (
95
+ /pod\s+['"](?:CrumbSDK(?:Core|UI)?|PLCrashReporter)['"]/.test(
96
+ contents
97
+ )
98
+ ) {
99
+ throw new Error(
100
+ 'Remove manual Crumb/PLCrashReporter pod declarations before using bundled native dependencies.'
101
+ );
102
+ }
103
+ if (/^.*use_native_modules!.*$/m.test(contents)) {
104
+ contents = contents.replace(
105
+ /^.*use_native_modules!.*$/m,
106
+ (line) =>
107
+ `${line}\n require File.join(File.dirname(\`node --print "require.resolve('@crumbsdk/react-native/package.json')"\`.strip), 'scripts', 'ios')\n crumb_native_pods!`
108
+ );
109
+ edit('ios/Podfile', contents);
110
+ } else
111
+ instructions.push(
112
+ 'Register crumb_native_pods! inside your app target using the manual iOS instructions.'
113
+ );
114
+ }
115
+ instructions.push(
116
+ 'Install the iOS pods using your project’s usual command.'
117
+ );
118
+ }
119
+ }
120
+ if (config.sourceMaps.enabled) {
121
+ const ignoreFile = path.join(root, '.gitignore');
122
+ const ignore = fs.existsSync(ignoreFile)
123
+ ? fs.readFileSync(ignoreFile, 'utf8')
124
+ : '';
125
+ if (!ignore.split(/\r?\n/).includes('/.crumb/'))
126
+ edit(
127
+ '.gitignore',
128
+ `${ignore}\n# Crumb local release artifacts (never commit source maps)\n/.crumb/\n`
129
+ );
130
+ const metroFiles = [
131
+ 'metro.config.js',
132
+ 'metro.config.cjs',
133
+ 'metro.config.mjs',
134
+ 'metro.config.ts',
135
+ ].filter((name) => fs.existsSync(path.join(root, name)));
136
+ if (metroFiles.length > 1)
137
+ throw new Error(
138
+ 'Multiple Metro configurations found. Wrap the active configuration with withCrumb manually.'
139
+ );
140
+ const metroName =
141
+ metroFiles[0] ||
142
+ (pkg.type === 'module' ? 'metro.config.cjs' : 'metro.config.js');
143
+ const before = metroFiles[0]
144
+ ? fs.readFileSync(path.join(root, metroName), 'utf8')
145
+ : undefined;
146
+ if (
147
+ before &&
148
+ !before.includes(metroMarker) &&
149
+ !before.includes('@crumbsdk/react-native/metro')
150
+ ) {
151
+ if (
152
+ metroName.endsWith('.mjs') ||
153
+ metroName.endsWith('.ts') ||
154
+ (pkg.type === 'module' && metroName.endsWith('.js')) ||
155
+ !before.includes('module.exports')
156
+ ) {
157
+ instructions.push(
158
+ 'Wrap your existing Metro config with withCrumb from @crumbsdk/react-native/metro. This configuration format was preserved.'
159
+ );
160
+ } else
161
+ edit(
162
+ metroName,
163
+ `${before}\n${metroMarker}\nmodule.exports = require('@crumbsdk/react-native/metro').withCrumb(module.exports);\n`
164
+ );
165
+ } else if (!before) {
166
+ edit(
167
+ metroName,
168
+ `const { getDefaultConfig } = require('${expo ? 'expo/metro-config' : '@react-native/metro-config'}');\n${metroMarker}\nmodule.exports = require('@crumbsdk/react-native/metro').withCrumb(getDefaultConfig(__dirname));\n`
169
+ );
170
+ }
171
+ if (!expo) {
172
+ const gradle = path.join(root, 'android/app/build.gradle');
173
+ if (fs.existsSync(gradle))
174
+ edit(
175
+ 'android/app/build.gradle',
176
+ configureGradle(fs.readFileSync(gradle, 'utf8'))
177
+ );
178
+ else
179
+ instructions.push(
180
+ 'Apply tools/crumb.gradle in your Android application module using the manual instructions.'
181
+ );
182
+ const ios = path.join(root, 'ios');
183
+ const projects = fs.existsSync(ios)
184
+ ? fs.readdirSync(ios).filter((name) => name.endsWith('.xcodeproj'))
185
+ : [];
186
+ if (projects.length === 1) {
187
+ const name = `ios/${projects[0]}/project.pbxproj`;
188
+ const project = require('xcode').project(path.join(root, name));
189
+ project.parseSync();
190
+ configureXcodeProject(project);
191
+ // Avoid formatting unrelated project sections on repeat setup.
192
+ if (
193
+ !fs
194
+ .readFileSync(path.join(root, name), 'utf8')
195
+ .includes('crumb-release-build')
196
+ )
197
+ edit(name, project.writeSync());
198
+ } else
199
+ instructions.push(
200
+ 'Configure the app’s Xcode bundle phase using the manual instructions; no unique project was selected.'
201
+ );
202
+ }
203
+ instructions.push(
204
+ 'Set CRUMB_SOURCE_MAP_TOKEN as a build secret. Never put it in this config, app initialization, or EXPO_PUBLIC_* variables.'
205
+ );
206
+ instructions.push(
207
+ 'Enable diagnostics.javascriptCrashCapture.enabled in Crumb.start. Remove manual release.bundleVersion when using generated build identity.'
208
+ );
209
+ }
210
+ instructions.push(
211
+ 'Keep the native minimum targets at iOS 15.1 and Android API 26 or higher; retain any higher framework requirement.'
212
+ );
213
+ instructions.push(
214
+ 'Initialize Crumb with the SDK configuration from your dashboard, then send a test report.'
215
+ );
216
+ return { framework: expo ? 'Expo' : 'React Native', changes, instructions };
217
+ }
218
+
219
+ function applyPlan(plan) {
220
+ // Check every file before changing any: a preview must not overwrite later user edits.
221
+ for (const change of plan.changes) {
222
+ const current = fs.existsSync(change.file)
223
+ ? fs.readFileSync(change.file, 'utf8')
224
+ : undefined;
225
+ if (current !== change.before)
226
+ throw new Error(
227
+ `Setup stopped because ${change.name} changed after preview. Run setup again.`
228
+ );
229
+ }
230
+ for (const change of plan.changes) {
231
+ fs.mkdirSync(path.dirname(change.file), { recursive: true });
232
+ fs.writeFileSync(change.file, change.content);
233
+ }
234
+ }
235
+
236
+ function doctor(root) {
237
+ const config = readConfig(root);
238
+ const checks = [];
239
+ const pkg = JSON.parse(
240
+ fs.readFileSync(path.join(root, 'package.json'), 'utf8')
241
+ );
242
+ for (const name of ['@crumbsdk/react-native', 'react-native-nitro-modules']) {
243
+ try {
244
+ require.resolve(`${name}/package.json`, { paths: [root] });
245
+ checks.push({ check: name, status: 'ready' });
246
+ } catch {
247
+ checks.push({
248
+ check: name,
249
+ status: 'missing',
250
+ action: 'Install the dependency in this app.',
251
+ });
252
+ }
253
+ }
254
+ if (config.sourceMaps.enabled) {
255
+ const metro = [
256
+ 'metro.config.js',
257
+ 'metro.config.cjs',
258
+ 'metro.config.mjs',
259
+ 'metro.config.ts',
260
+ ].some((name) => {
261
+ const file = path.join(root, name);
262
+ return (
263
+ fs.existsSync(file) &&
264
+ fs.readFileSync(file, 'utf8').includes('@crumbsdk/react-native/metro')
265
+ );
266
+ });
267
+ checks.push({
268
+ check: 'Metro release integration',
269
+ status: metro ? 'configured' : 'missing',
270
+ action:
271
+ 'Run crumb setup or wrap your existing Metro config with withCrumb.',
272
+ });
273
+ checks.push({
274
+ check: 'Upload credential in this environment',
275
+ status: process.env.CRUMB_SOURCE_MAP_TOKEN ? 'present' : 'missing',
276
+ action:
277
+ 'Set the build secret in the environment that runs release builds.',
278
+ });
279
+ }
280
+ return {
281
+ framework:
282
+ pkg.dependencies?.expo || pkg.devDependencies?.expo
283
+ ? 'Expo'
284
+ : 'React Native',
285
+ sourceMaps: config.sourceMaps.enabled,
286
+ checks,
287
+ note: 'Local checks do not confirm uploaded maps or readable crashes. Build a release and verify a test crash in the dashboard.',
288
+ };
289
+ }
290
+
291
+ module.exports = { setupPlan, applyPlan, doctor };