@scalebun/react-native 1.5.0 → 1.6.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.
package/README.md CHANGED
@@ -18,6 +18,39 @@ yarn add @scalebun/react-native
18
18
  cd ios && pod install
19
19
  ```
20
20
 
21
+ To wire the AppDelegate for Direct APNs push (optional):
22
+
23
+ ```sh
24
+ npx scalebun init ios # patch the AppDelegate (ObjC auto-patched; Swift prints steps)
25
+ npx scalebun init ios --check # verify hooks exist (exit 1 if missing)
26
+ ```
27
+
28
+ ### Android
29
+
30
+ OTA updates need one wiring step in `MainApplication` so a downloaded bundle
31
+ actually loads — without it an update installs, reports success, and the app
32
+ silently keeps running the bundle baked into the APK. Run:
33
+
34
+ ```sh
35
+ npx scalebun init android # wire MainApplication for OTA bundle loading
36
+ npx scalebun init android --check # verify (exit 1 if missing)
37
+ npx scalebun init android --dry-run # preview changes without writing
38
+ ```
39
+
40
+ This adds `ScaleBunOtaModule.getJSBundleFile(...)` in the form matching your
41
+ React Native version (Kotlin is auto-patched; Java prints manual instructions).
42
+ **Rebuild the native app afterwards** — a JS reload does not load the newly
43
+ wired native path.
44
+
45
+ For push on Android (optional): add `android/app/google-services.json`, apply
46
+ the Google Services Gradle plugin with `@react-native-firebase/messaging`, and
47
+ declare the `POST_NOTIFICATIONS` permission. Run `npx scalebun doctor` to see
48
+ exactly what's missing.
49
+
50
+ > Run `npx scalebun -h` to list every command — SDK setup (`doctor`, `init ios`,
51
+ > `init android`) plus the OTA publishing commands (`login`, `ota publish`,
52
+ > `rollout`, …).
53
+
21
54
  ### Optional peer dependencies
22
55
 
23
56
  Some features activate only when their peer package is installed:
@@ -0,0 +1,370 @@
1
+ 'use strict';
2
+ /**
3
+ * Android MainApplication codemod for `npx scalebun init android`.
4
+ *
5
+ * Pure, dependency-free string transforms (no fs / process / RN imports) so they
6
+ * are unit-testable with plain fixtures and safe to run anywhere. The CLI
7
+ * (bin/scalebun.js) handles disk IO, flags, and reporting; this module only
8
+ * decides WHAT the patched source should be.
9
+ *
10
+ * It wires OTA bundle resolution into the host app's MainApplication so a
11
+ * downloaded update actually LOADS instead of the app silently running the
12
+ * bundle baked into the APK. The required call is the static
13
+ * `ScaleBunOtaModule.getJSBundleFile(context)`, wired in one of two shapes
14
+ * depending on the React Native version:
15
+ *
16
+ * - RN < 0.82 (ReactNativeHost still exists): add
17
+ * override fun getJSBundleFile(): String? =
18
+ * ScaleBunOtaModule.getJSBundleFile(this@MainApplication) ?: super.getJSBundleFile()
19
+ * inside the `object : *ReactNativeHost { … }` block.
20
+ *
21
+ * - RN >= 0.82 (ReactNativeHost removed): pass the path to getDefaultReactHost:
22
+ * getDefaultReactHost(
23
+ * …,
24
+ * jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(applicationContext),
25
+ * )
26
+ *
27
+ * The version decides the shape — mirroring the check in
28
+ * @scalebun/cli src/commands/doctor.ts. Every transform is idempotent (running
29
+ * twice is a no-op). Java MainApplication and unrecognized structures are NOT
30
+ * auto-patched — they return an exact manual snippet instead of a risky edit.
31
+ */
32
+
33
+ const OTA_IMPORT = 'import com.scalebun.rn.ota.ScaleBunOtaModule';
34
+ const BUNDLE_CALL = 'ScaleBunOtaModule.getJSBundleFile';
35
+ const MANAGED_TAG = 'ScaleBun OTA bundle resolution';
36
+
37
+ // ── MainApplication discovery ────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Find the host MainApplication under <androidDir>. Returns { path, kind } where
41
+ * kind is 'kotlin' (.kt) or 'java' (.java), or null when none is found. Kotlin
42
+ * is preferred. `fsLike`/`pathLike` are injected for testability.
43
+ */
44
+ function findMainApplication(androidDir, fsLike, pathLike) {
45
+ const fs = fsLike;
46
+ const path = pathLike;
47
+ const root = path.join(androidDir, 'app', 'src', 'main');
48
+
49
+ // Walk src/main/{java,kotlin}/** for MainApplication.kt/.java.
50
+ const found = { kotlin: null, java: null };
51
+ const walk = (dir) => {
52
+ let entries = [];
53
+ try {
54
+ entries = fs.readdirSync(dir, { withFileTypes: true });
55
+ } catch {
56
+ return;
57
+ }
58
+ for (const entry of entries) {
59
+ const p = path.join(dir, entry.name);
60
+ if (entry.isDirectory()) {
61
+ walk(p);
62
+ } else if (entry.name === 'MainApplication.kt' && !found.kotlin) {
63
+ found.kotlin = p;
64
+ } else if (entry.name === 'MainApplication.java' && !found.java) {
65
+ found.java = p;
66
+ }
67
+ }
68
+ };
69
+ walk(root);
70
+
71
+ if (found.kotlin) return { path: found.kotlin, kind: 'kotlin' };
72
+ if (found.java) return { path: found.java, kind: 'java' };
73
+ return null;
74
+ }
75
+
76
+ // ── version helper ────────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * True when RN >= 0.82 (ReactNativeHost removed → must pass jsBundleFilePath to
80
+ * getDefaultReactHost). `minor` is [major, minor] or null when unknown; when
81
+ * unknown we assume the modern shape.
82
+ */
83
+ function isReactHostEra(minor) {
84
+ if (!minor) return true;
85
+ return minor[0] > 0 || minor[1] >= 82;
86
+ }
87
+
88
+ // ── analysis ────────────────────────────────────────────────────────────────
89
+
90
+ /** Report which OTA wiring pieces are already present in a Kotlin MainApplication. */
91
+ function analyzeKotlin(source) {
92
+ const s = String(source);
93
+ return {
94
+ hasImport: s.includes(OTA_IMPORT),
95
+ hasBundleWiring: s.includes(BUNDLE_CALL),
96
+ hasReactNativeHostObject: /object\s*:\s*\w*ReactNativeHost\s*\(/.test(s),
97
+ hasGetDefaultReactHost: s.includes('getDefaultReactHost('),
98
+ hasJsBundleFilePathArg: s.includes('jsBundleFilePath'),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * True when OTA bundle resolution is fully wired for this RN version (for
104
+ * --check). On RN >= 0.82 the wiring must go through the jsBundleFilePath arg;
105
+ * before that any getJSBundleFile call is enough.
106
+ */
107
+ function isFullyWiredKotlin(source, minor) {
108
+ const a = analyzeKotlin(source);
109
+ if (!a.hasImport || !a.hasBundleWiring) return false;
110
+ if (isReactHostEra(minor)) return a.hasJsBundleFilePathArg;
111
+ return true;
112
+ }
113
+
114
+ // ── helpers ─────────────────────────────────────────────────────────────────
115
+
116
+ /** Insert `lineToAdd` immediately after the last top-of-file Kotlin import. */
117
+ function insertAfterLastImport(source, lineToAdd) {
118
+ const lines = source.split('\n');
119
+ let lastImport = -1;
120
+ for (let i = 0; i < lines.length; i++) {
121
+ if (/^\s*import\b/.test(lines[i])) lastImport = i;
122
+ // Stop scanning once the class/object declaration begins.
123
+ if (/^\s*(class|object)\b/.test(lines[i])) break;
124
+ }
125
+ if (lastImport === -1) {
126
+ // No imports — put it after the package line, else prepend.
127
+ for (let i = 0; i < lines.length; i++) {
128
+ if (/^\s*package\b/.test(lines[i])) {
129
+ lines.splice(i + 1, 0, '', lineToAdd);
130
+ return lines.join('\n');
131
+ }
132
+ }
133
+ return lineToAdd + '\n' + source;
134
+ }
135
+ lines.splice(lastImport + 1, 0, lineToAdd);
136
+ return lines.join('\n');
137
+ }
138
+
139
+ /** Leading whitespace of the first non-empty line at/after `fromIdx`. */
140
+ function bodyIndentAfter(source, fromIdx) {
141
+ const rest = source.slice(fromIdx).split('\n');
142
+ for (const line of rest) {
143
+ if (line.trim() === '') continue;
144
+ return (line.match(/^\s*/) || [''])[0];
145
+ }
146
+ return ' ';
147
+ }
148
+
149
+ /**
150
+ * Insert the getJSBundleFile() override into the `object : *ReactNativeHost(…) {`
151
+ * block (RN < 0.82). Returns { source, inserted, reason }.
152
+ */
153
+ function insertIntoReactNativeHostObject(source, methodText) {
154
+ const objRe = /object\s*:\s*\w*ReactNativeHost\s*\([^)]*\)\s*\{/;
155
+ const m = objRe.exec(source);
156
+ if (!m) return { source, inserted: false, reason: 'no-object' };
157
+ const insertAt = m.index + m[0].length; // right after the opening `{`
158
+ const indent = bodyIndentAfter(source, insertAt);
159
+ const block = '\n' + methodText(indent);
160
+ return {
161
+ source: source.slice(0, insertAt) + block + source.slice(insertAt),
162
+ inserted: true,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Insert a named argument into a call, respecting single-line vs multi-line
168
+ * Kotlin argument style. Returns { source, inserted, reason }.
169
+ */
170
+ function insertArgIntoCall(source, callName, argText) {
171
+ const callIdx = source.indexOf(callName + '(');
172
+ if (callIdx === -1) return { source, inserted: false, reason: 'no-call' };
173
+ const openIdx = source.indexOf('(', callIdx);
174
+
175
+ // Find the matching close paren (calls contain nested parens like
176
+ // PackageList(this).packages, so a naive indexOf(')') is wrong).
177
+ let depth = 0;
178
+ let closeIdx = -1;
179
+ for (let i = openIdx; i < source.length; i++) {
180
+ const c = source[i];
181
+ if (c === '(') depth++;
182
+ else if (c === ')') {
183
+ depth--;
184
+ if (depth === 0) {
185
+ closeIdx = i;
186
+ break;
187
+ }
188
+ }
189
+ }
190
+ if (closeIdx === -1) return { source, inserted: false, reason: 'no-close' };
191
+
192
+ const inner = source.slice(openIdx + 1, closeIdx);
193
+ const isMultiline = inner.includes('\n');
194
+
195
+ // Last meaningful (non-whitespace) char before the close paren.
196
+ let j = closeIdx - 1;
197
+ while (j > openIdx && /\s/.test(source[j])) j--;
198
+ const lastChar = source[j];
199
+
200
+ let insertText;
201
+ if (inner.trim() === '') {
202
+ insertText = argText;
203
+ } else if (isMultiline) {
204
+ // Align the new argument with the last existing argument line (the line
205
+ // where `j` sits) so the call reads consistently, not with the closing
206
+ // paren's indentation.
207
+ const argLineStart = source.lastIndexOf('\n', j) + 1;
208
+ const argIndent = (source.slice(argLineStart, j).match(/^\s*/) || [''])[0];
209
+ insertText =
210
+ lastChar === ','
211
+ ? `\n${argIndent}${argText},`
212
+ : `,\n${argIndent}${argText}`;
213
+ } else {
214
+ insertText = lastChar === ',' ? ` ${argText},` : `, ${argText}`;
215
+ }
216
+
217
+ return {
218
+ source: source.slice(0, j + 1) + insertText + source.slice(j + 1),
219
+ inserted: true,
220
+ };
221
+ }
222
+
223
+ // ── the patch ─────────────────────────────────────────────────────────────────
224
+
225
+ /**
226
+ * Patch a Kotlin MainApplication for OTA bundle resolution. `opts.minor` is the
227
+ * [major, minor] React Native version (or null). Returns:
228
+ * { changed, source, summary[], manual[] }
229
+ */
230
+ function patchKotlin(source, opts = {}) {
231
+ let out = String(source);
232
+ const summary = [];
233
+ const manual = [];
234
+ const a = analyzeKotlin(out);
235
+ const reactHostEra = isReactHostEra(opts.minor);
236
+
237
+ // Already fully wired for this era → no-op.
238
+ if (isFullyWiredKotlin(out, opts.minor)) {
239
+ return { changed: false, source: out, summary, manual };
240
+ }
241
+
242
+ if (reactHostEra) {
243
+ // RN >= 0.82: pass jsBundleFilePath to getDefaultReactHost(...).
244
+ if (!a.hasGetDefaultReactHost) {
245
+ manual.push(reactHostManual());
246
+ return { changed: false, source: out, summary, manual };
247
+ }
248
+ if (!a.hasImport) {
249
+ out = insertAfterLastImport(out, OTA_IMPORT);
250
+ summary.push(`Added import: ${OTA_IMPORT}`);
251
+ }
252
+ if (!a.hasJsBundleFilePathArg) {
253
+ const r = insertArgIntoCall(
254
+ out,
255
+ 'getDefaultReactHost',
256
+ 'jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(applicationContext)',
257
+ );
258
+ if (r.inserted) {
259
+ out = r.source;
260
+ summary.push(
261
+ 'Patched getDefaultReactHost(...) → added jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(applicationContext)',
262
+ );
263
+ } else {
264
+ manual.push(reactHostManual());
265
+ }
266
+ }
267
+ } else {
268
+ // RN < 0.82: add getJSBundleFile() override inside the ReactNativeHost object.
269
+ if (!a.hasReactNativeHostObject) {
270
+ manual.push(legacyManual());
271
+ return { changed: false, source: out, summary, manual };
272
+ }
273
+ if (!a.hasImport) {
274
+ out = insertAfterLastImport(out, OTA_IMPORT);
275
+ summary.push(`Added import: ${OTA_IMPORT}`);
276
+ }
277
+ if (!a.hasBundleWiring) {
278
+ const r = insertIntoReactNativeHostObject(
279
+ out,
280
+ (indent) =>
281
+ `${indent}// ${MANAGED_TAG} (added by \`npx scalebun init android\`)\n` +
282
+ `${indent}override fun getJSBundleFile(): String? =\n` +
283
+ `${indent} ScaleBunOtaModule.getJSBundleFile(this@MainApplication) ?: super.getJSBundleFile()\n`,
284
+ );
285
+ if (r.inserted) {
286
+ out = r.source;
287
+ summary.push(
288
+ 'Added getJSBundleFile() override → ScaleBunOtaModule.getJSBundleFile(this@MainApplication)',
289
+ );
290
+ } else {
291
+ manual.push(legacyManual());
292
+ }
293
+ }
294
+ }
295
+
296
+ return { changed: out !== String(source), source: out, summary, manual };
297
+ }
298
+
299
+ // ── manual instructions ─────────────────────────────────────────────────────
300
+
301
+ /** Manual snippet for RN >= 0.82 when the getDefaultReactHost call can't be found. */
302
+ function reactHostManual() {
303
+ return [
304
+ 'Could not locate getDefaultReactHost(...) in MainApplication. Wire OTA bundle',
305
+ 'resolution manually (RN >= 0.82):',
306
+ '',
307
+ ` ${OTA_IMPORT}`,
308
+ '',
309
+ ' override val reactHost: ReactHost by lazy {',
310
+ ' getDefaultReactHost(',
311
+ ' context = applicationContext,',
312
+ ' packageList = PackageList(this).packages,',
313
+ ' jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(applicationContext),',
314
+ ' )',
315
+ ' }',
316
+ ].join('\n');
317
+ }
318
+
319
+ /** Manual snippet for RN < 0.82 when the ReactNativeHost object can't be found. */
320
+ function legacyManual() {
321
+ return [
322
+ 'Could not locate the ReactNativeHost object in MainApplication. Add the OTA',
323
+ 'bundle-resolution override manually (RN < 0.82), inside your ReactNativeHost:',
324
+ '',
325
+ ` ${OTA_IMPORT}`,
326
+ '',
327
+ ' override fun getJSBundleFile(): String? =',
328
+ ' ScaleBunOtaModule.getJSBundleFile(this@MainApplication) ?: super.getJSBundleFile()',
329
+ ].join('\n');
330
+ }
331
+
332
+ /** Manual instructions for a Java MainApplication (auto-patch not supported). */
333
+ function javaInstructions(minor) {
334
+ if (isReactHostEra(minor)) {
335
+ return [
336
+ 'A Java MainApplication was detected. Automatic patching of Java is not',
337
+ 'supported — wire OTA bundle resolution manually (RN >= 0.82):',
338
+ '',
339
+ ' import com.scalebun.rn.ota.ScaleBunOtaModule;',
340
+ '',
341
+ ' // where you build the ReactHost via DefaultReactHost.getDefaultReactHost(...),',
342
+ ' // pass the OTA bundle path:',
343
+ ' ScaleBunOtaModule.getJSBundleFile(getApplicationContext())',
344
+ ].join('\n');
345
+ }
346
+ return [
347
+ 'A Java MainApplication was detected. Automatic patching of Java is not',
348
+ 'supported — add this override inside your ReactNativeHost (RN < 0.82):',
349
+ '',
350
+ ' import com.scalebun.rn.ota.ScaleBunOtaModule;',
351
+ '',
352
+ ' @Override',
353
+ ' protected String getJSBundleFile() {',
354
+ ' String ota = ScaleBunOtaModule.getJSBundleFile(getApplicationContext());',
355
+ ' return ota != null ? ota : super.getJSBundleFile();',
356
+ ' }',
357
+ ].join('\n');
358
+ }
359
+
360
+ module.exports = {
361
+ OTA_IMPORT,
362
+ BUNDLE_CALL,
363
+ MANAGED_TAG,
364
+ findMainApplication,
365
+ isReactHostEra,
366
+ analyzeKotlin,
367
+ isFullyWiredKotlin,
368
+ patchKotlin,
369
+ javaInstructions,
370
+ };
package/bin/scalebun.js CHANGED
@@ -22,6 +22,7 @@
22
22
  const fs = require('fs');
23
23
  const path = require('path');
24
24
  const codemod = require('./lib/iosCodemod');
25
+ const androidCodemod = require('./lib/androidCodemod');
25
26
 
26
27
  const MIN_BRIDGELESS_RNFIREBASE_MAJOR = 18;
27
28
 
@@ -597,6 +598,215 @@ function runInitIos(argv) {
597
598
  process.exit(0);
598
599
  }
599
600
 
601
+ // ─── `init android` — MainApplication OTA wiring + checklist ──────────────────
602
+
603
+ function findAndroidDir() {
604
+ const androidDir = path.join(CWD, 'android');
605
+ return exists(androidDir) ? androidDir : null;
606
+ }
607
+
608
+ /** [major, minor] of the installed react-native, or null. */
609
+ function rnMinor() {
610
+ const v = pkgVersion('react-native');
611
+ if (!v) return null;
612
+ const parts = v.split('.').map((n) => parseInt(n, 10));
613
+ return [parts[0] || 0, parts[1] || 0];
614
+ }
615
+
616
+ /**
617
+ * Detect Android setup that the codemod cannot apply — the checklist items
618
+ * (Firebase config, gradle plugin, manifest permissions, the New-Arch OTA
619
+ * version gate). Mirrors detectIosCapabilities.
620
+ */
621
+ function detectAndroidCapabilities(androidDir) {
622
+ const rnVersion = pkgVersion('react-native');
623
+ const minor = rnMinor();
624
+
625
+ const gradleProps = readText(path.join(androidDir, 'gradle.properties')) || '';
626
+ const newArch = /(^|\n)\s*newArchEnabled\s*=\s*true/.test(gradleProps);
627
+
628
+ // Bridgeless ignored getJSBundleFile() until RN 0.76.1 — an OTA update installs
629
+ // and silently never runs on 0.74/0.75/0.76.0 with New Arch on.
630
+ const otaDeadZone =
631
+ newArch &&
632
+ minor != null &&
633
+ minor[0] === 0 &&
634
+ (minor[1] === 74 || minor[1] === 75 || (rnVersion || '').startsWith('0.76.0'));
635
+
636
+ const hasGoogleServicesJson = exists(path.join(androidDir, 'app', 'google-services.json'));
637
+
638
+ const manifestPath = path.join(androidDir, 'app', 'src', 'main', 'AndroidManifest.xml');
639
+ const manifest = readText(manifestPath) || '';
640
+ const hasPostNotifications = /android\.permission\.POST_NOTIFICATIONS/.test(manifest);
641
+ const hasChannelMeta = /default_notification_channel_id/.test(manifest);
642
+
643
+ return {
644
+ rnVersion,
645
+ newArch,
646
+ otaDeadZone,
647
+ hasGoogleServicesJson,
648
+ manifestExists: !!readText(manifestPath),
649
+ hasPostNotifications,
650
+ hasChannelMeta,
651
+ };
652
+ }
653
+
654
+ function printAndroidChecklist(caps) {
655
+ console.log('');
656
+ console.log(paint('Android setup (verify / apply by hand — cannot be safely automated)', C.bold));
657
+
658
+ if (caps.otaDeadZone) {
659
+ console.log(
660
+ ' ' + FAIL() + ' ' +
661
+ paint(
662
+ `React Native ${caps.rnVersion} + New Architecture: OTA updates CANNOT LOAD.`,
663
+ C.red,
664
+ ),
665
+ );
666
+ console.log(
667
+ paint(
668
+ ' Bridgeless ignored getJSBundleFile() until RN 0.76.1. Upgrade to >= 0.76.1,\n' +
669
+ ' or set newArchEnabled=false. Until then updates install but never run.',
670
+ C.dim,
671
+ ),
672
+ );
673
+ }
674
+
675
+ line(caps.hasGoogleServicesJson ? OK() : WARN(), 'google-services.json', caps.hasGoogleServicesJson ? 'present' : null);
676
+ line(caps.hasPostNotifications ? OK() : WARN(), 'POST_NOTIFICATIONS perm', caps.hasPostNotifications ? 'declared' : null);
677
+ line(caps.hasChannelMeta ? OK() : WARN(), 'default channel meta', caps.hasChannelMeta ? 'declared' : null);
678
+
679
+ const todo = [];
680
+ if (!caps.hasGoogleServicesJson) {
681
+ todo.push(
682
+ 'For push: add ' + paint('android/app/google-services.json', C.cyan) +
683
+ ' (Firebase console), apply the Google Services gradle plugin, and add ' +
684
+ paint('@react-native-firebase/messaging', C.cyan) + ' for real tokens.',
685
+ );
686
+ }
687
+ if (caps.manifestExists && !caps.hasPostNotifications) {
688
+ todo.push(
689
+ 'Declare the Android 13+ permission in AndroidManifest.xml: ' +
690
+ paint('<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />', C.cyan),
691
+ );
692
+ }
693
+ if (caps.manifestExists && !caps.hasChannelMeta) {
694
+ todo.push(
695
+ 'Declare a default FCM channel meta-data ' +
696
+ '(com.google.firebase.messaging.default_notification_channel_id) so backgrounded ' +
697
+ 'notifications have a channel.',
698
+ );
699
+ }
700
+
701
+ if (todo.length) {
702
+ console.log('');
703
+ console.log(paint('Push follow-ups (skip if you only use OTA)', C.bold + C.yellow));
704
+ todo.forEach((t, i) => console.log(` ${i + 1}. ${t}`));
705
+ }
706
+ }
707
+
708
+ function runInitAndroid(argv) {
709
+ const flags = new Set(argv);
710
+ const isCheck = flags.has('--check');
711
+ const isDryRun = flags.has('--dry-run');
712
+
713
+ const androidDir = findAndroidDir();
714
+ if (!androidDir) {
715
+ console.error(paint('✗ No android/ directory found.', C.red));
716
+ console.error(' Run this from a React Native project root (the folder containing android/).');
717
+ process.exit(2);
718
+ }
719
+
720
+ const found = androidCodemod.findMainApplication(androidDir, fs, path);
721
+ if (!found) {
722
+ console.error(paint('✗ Could not find MainApplication.kt or .java under android/app/src/main.', C.red));
723
+ console.error(' This project uses a non-standard layout — wire OTA bundle resolution manually.');
724
+ process.exit(2);
725
+ }
726
+
727
+ const minor = rnMinor();
728
+ const rel = path.relative(CWD, found.path);
729
+ console.log('');
730
+ console.log(paint('ScaleBun init android', C.bold + C.cyan));
731
+ console.log(` MainApplication: ${paint(rel, C.cyan)} (${found.kind})`);
732
+
733
+ // Java: not auto-patched — print clear manual instructions, fail non-zero.
734
+ if (found.kind === 'java') {
735
+ console.log('');
736
+ console.log(androidCodemod.javaInstructions(minor));
737
+ printAndroidChecklist(detectAndroidCapabilities(androidDir));
738
+ process.exit(isCheck ? 1 : 2);
739
+ }
740
+
741
+ const original = readText(found.path) || '';
742
+
743
+ // --check: verify, never write.
744
+ if (isCheck) {
745
+ const a = androidCodemod.analyzeKotlin(original);
746
+ const wired = androidCodemod.isFullyWiredKotlin(original, minor);
747
+ console.log('');
748
+ console.log(paint('OTA wiring status', C.bold));
749
+ line(a.hasImport ? OK() : FAIL(), 'ScaleBunOtaModule import', a.hasImport ? 'present' : null);
750
+ line(a.hasBundleWiring ? OK() : FAIL(), 'getJSBundleFile wiring', a.hasBundleWiring ? 'present' : null);
751
+ if (androidCodemod.isReactHostEra(minor)) {
752
+ line(a.hasJsBundleFilePathArg ? OK() : FAIL(), 'jsBundleFilePath arg (RN >= 0.82)', a.hasJsBundleFilePathArg ? 'present' : null);
753
+ }
754
+ printAndroidChecklist(detectAndroidCapabilities(androidDir));
755
+ console.log('');
756
+ if (wired) {
757
+ console.log(paint('✓ MainApplication is wired for ScaleBun OTA bundle resolution.', C.green));
758
+ process.exit(0);
759
+ }
760
+ console.log(paint('✗ MainApplication is missing OTA bundle wiring. Run `npx scalebun init android`.', C.red));
761
+ process.exit(1);
762
+ }
763
+
764
+ const result = androidCodemod.patchKotlin(original, { minor });
765
+
766
+ if (!result.changed) {
767
+ console.log('');
768
+ if (result.manual.length) {
769
+ console.log(paint('Could not auto-apply — wire it by hand:', C.bold + C.yellow));
770
+ result.manual.forEach((m) => console.log(m));
771
+ } else {
772
+ console.log(paint('✓ Already wired — no changes needed (idempotent).', C.green));
773
+ }
774
+ printAndroidChecklist(detectAndroidCapabilities(androidDir));
775
+ process.exit(result.manual.length ? 1 : 0);
776
+ }
777
+
778
+ console.log('');
779
+ console.log(paint(isDryRun ? 'Planned changes (--dry-run, nothing written)' : 'Changes', C.bold));
780
+ result.summary.forEach((sline, i) => console.log(` ${i + 1}. ${sline}`));
781
+ if (result.manual.length) {
782
+ console.log('');
783
+ console.log(paint('Could not auto-apply (edit by hand)', C.bold + C.yellow));
784
+ result.manual.forEach((m) => console.log(m));
785
+ }
786
+
787
+ if (isDryRun) {
788
+ console.log('');
789
+ console.log(paint('Dry run — no files written. Re-run without --dry-run to apply.', C.dim));
790
+ printAndroidChecklist(detectAndroidCapabilities(androidDir));
791
+ process.exit(0);
792
+ }
793
+
794
+ // Apply. Never stage / commit — only edits the working-tree file.
795
+ fs.writeFileSync(found.path, result.source, 'utf8');
796
+ console.log('');
797
+ console.log(paint(`✓ Patched ${rel}`, C.green));
798
+ console.log(paint(' (working-tree edit only — nothing staged or committed)', C.dim));
799
+
800
+ printAndroidChecklist(detectAndroidCapabilities(androidDir));
801
+
802
+ console.log('');
803
+ console.log(paint('Next:', C.bold));
804
+ console.log(' Rebuild the native app (a JS reload does not load the newly-wired native path):');
805
+ console.log(paint(' npx react-native run-android # or your gradle build', C.dim));
806
+ console.log('');
807
+ process.exit(0);
808
+ }
809
+
600
810
  // ─── entry ──────────────────────────────────────────────────────────────────
601
811
 
602
812
  function main() {
@@ -610,9 +820,11 @@ function main() {
610
820
  const target = argv[1];
611
821
  if (target === 'ios') {
612
822
  runInitIos(argv.slice(2));
823
+ } else if (target === 'android') {
824
+ runInitAndroid(argv.slice(2));
613
825
  } else {
614
826
  console.error(`Unknown init target: ${target ?? '(none)'}`);
615
- console.error('Usage: npx scalebun init ios [--check | --dry-run]');
827
+ console.error('Usage: npx scalebun init <ios|android> [--check | --dry-run]');
616
828
  process.exit(2);
617
829
  }
618
830
  break;
@@ -621,19 +833,7 @@ function main() {
621
833
  case 'help':
622
834
  case '--help':
623
835
  case '-h':
624
- console.log('');
625
- console.log(paint('ScaleBun SDK CLI', C.bold));
626
- console.log('');
627
- console.log('Usage:');
628
- console.log(' npx scalebun doctor Diagnose push-notification setup');
629
- console.log(' npx scalebun init ios Wire the iOS AppDelegate for Direct APNs');
630
- console.log(' npx scalebun init ios --check Verify hooks exist (exit 1 if missing)');
631
- console.log(' npx scalebun init ios --dry-run Preview changes without writing');
632
- console.log('');
633
- console.log('OTA commands (login, quickstart, ota publish, releases, rollout…)');
634
- console.log('ship with this package (via the bundled @scalebun/cli) and are');
635
- console.log('forwarded automatically — no extra install needed.');
636
- console.log('');
836
+ printFullHelp();
637
837
  break;
638
838
  default:
639
839
  // TWO PACKAGES, ONE COMMAND NAME.
@@ -651,19 +851,23 @@ function main() {
651
851
  }
652
852
  }
653
853
 
654
- /**
655
- * Hand the invocation to @scalebun/cli, preserving argv and exit code.
656
- * Falls back to an actionable install instruction when it is not present.
657
- */
658
- function delegateToOtaCli(argv) {
659
- let cliEntry = null;
854
+ /** Resolve the bundled @scalebun/cli compiled entry, or null when absent. */
855
+ function resolveOtaCli() {
660
856
  try {
661
- cliEntry = require.resolve('@scalebun/cli/lib/index.js', {
857
+ return require.resolve('@scalebun/cli/lib/index.js', {
662
858
  paths: [process.cwd(), __dirname],
663
859
  });
664
860
  } catch {
665
- cliEntry = null;
861
+ return null;
666
862
  }
863
+ }
864
+
865
+ /**
866
+ * Hand the invocation to @scalebun/cli, preserving argv and exit code.
867
+ * Falls back to an actionable install instruction when it is not present.
868
+ */
869
+ function delegateToOtaCli(argv) {
870
+ const cliEntry = resolveOtaCli();
667
871
 
668
872
  if (!cliEntry) {
669
873
  const cmd = argv[0];
@@ -690,4 +894,40 @@ function delegateToOtaCli(argv) {
690
894
  process.exit(res.status ?? 1);
691
895
  }
692
896
 
897
+ /**
898
+ * Print the full command surface: the SDK-setup commands this package handles
899
+ * directly, followed by the complete @scalebun/cli command tree.
900
+ *
901
+ * The CLI tree is rendered by delegating to `@scalebun/cli --help` rather than a
902
+ * hardcoded list, so it is a single source of truth and cannot drift. When the
903
+ * CLI cannot be resolved (should not happen — it is a dependency) we fall back to
904
+ * a prose note instead of an error, since this is a help screen.
905
+ */
906
+ function printFullHelp() {
907
+ console.log('');
908
+ console.log(paint('ScaleBun SDK CLI', C.bold));
909
+ console.log('');
910
+ console.log(paint('SDK setup (bundled with @scalebun/react-native):', C.bold));
911
+ console.log(' npx scalebun doctor Diagnose push-notification setup');
912
+ console.log(' npx scalebun init ios Wire the iOS AppDelegate for Direct APNs');
913
+ console.log(' npx scalebun init android Wire Android MainApplication for OTA bundle loading');
914
+ console.log(paint(' add --check to verify (exit 1 if missing) or --dry-run to preview', C.dim));
915
+ console.log('');
916
+
917
+ const cliEntry = resolveOtaCli();
918
+ if (!cliEntry) {
919
+ console.log(paint('OTA & account commands:', C.bold));
920
+ console.log(' Provided by @scalebun/cli (login, quickstart, apps, ota publish,');
921
+ console.log(' releases, rollout…). It ships with this package but could not be');
922
+ console.log(' resolved — run `npm install`, then `npx scalebun --help` again.');
923
+ console.log('');
924
+ return;
925
+ }
926
+
927
+ console.log(paint('OTA & account commands (from @scalebun/cli):', C.bold));
928
+ const { spawnSync } = require('child_process');
929
+ spawnSync(process.execPath, [cliEntry, '--help'], { stdio: 'inherit' });
930
+ console.log('');
931
+ }
932
+
693
933
  main();
@@ -489,7 +489,7 @@ var SDK_VERSION;
489
489
  var init_version = __esm({
490
490
  "lib/module/core/constants/version.js"() {
491
491
  "use strict";
492
- SDK_VERSION = "1.5.0";
492
+ SDK_VERSION = "1.6.0";
493
493
  }
494
494
  });
495
495
 
@@ -492,7 +492,7 @@ var SDK_VERSION;
492
492
  var init_version = __esm({
493
493
  "lib/module/core/constants/version.js"() {
494
494
  "use strict";
495
- SDK_VERSION = "1.5.0";
495
+ SDK_VERSION = "1.6.0";
496
496
  }
497
497
  });
498
498
 
@@ -9,5 +9,5 @@ exports.SDK_VERSION = void 0;
9
9
  * can attribute telemetry to the SDK build that produced it.
10
10
  * Keep in sync with package.json "version".
11
11
  */
12
- const SDK_VERSION = exports.SDK_VERSION = '1.5.0';
12
+ const SDK_VERSION = exports.SDK_VERSION = '1.6.0';
13
13
  //# sourceMappingURL=version.js.map
@@ -3,5 +3,5 @@
3
3
  * can attribute telemetry to the SDK build that produced it.
4
4
  * Keep in sync with package.json "version".
5
5
  */
6
- export const SDK_VERSION = '1.5.0';
6
+ export const SDK_VERSION = '1.6.0';
7
7
  //# sourceMappingURL=version.js.map
@@ -3,5 +3,5 @@
3
3
  * can attribute telemetry to the SDK build that produced it.
4
4
  * Keep in sync with package.json "version".
5
5
  */
6
- export declare const SDK_VERSION = "1.5.0";
6
+ export declare const SDK_VERSION = "1.6.0";
7
7
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalebun/react-native",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Production-grade React Native SDK for ScaleBun",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
@@ -105,7 +105,7 @@
105
105
  "@babel/runtime": "^7.25.0",
106
106
  "@jridgewell/sourcemap-codec": "1.5.5",
107
107
  "@jridgewell/trace-mapping": "0.3.31",
108
- "@scalebun/cli": "^1.4.0"
108
+ "@scalebun/cli": "^1.6.0"
109
109
  },
110
110
  "codegenConfig": {
111
111
  "name": "ScaleBunSpec",
@@ -3,4 +3,4 @@
3
3
  * can attribute telemetry to the SDK build that produced it.
4
4
  * Keep in sync with package.json "version".
5
5
  */
6
- export const SDK_VERSION = '1.5.0';
6
+ export const SDK_VERSION = '1.6.0';