@scalebun/react-native 1.3.3 → 1.5.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 (97) hide show
  1. package/android/src/main/java/com/scalebun/rn/ota/BsPatch.kt +182 -0
  2. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +88 -13
  3. package/android/src/oldarch/java/com/scalebun/rn/crash/ScaleBunCrashSpec.kt +16 -0
  4. package/bin/scalebun.js +55 -3
  5. package/dist/scalebun.full.js +190 -110
  6. package/dist/scalebun.full.js.map +1 -1
  7. package/dist/scalebun.slim.js +188 -109
  8. package/dist/scalebun.slim.js.map +1 -1
  9. package/ios/Ota/BsPatch.swift +180 -0
  10. package/ios/Ota/OtaSlotManager.swift +59 -2
  11. package/ios/Ota/ScaleBunOtaModule.swift +89 -10
  12. package/lib/commonjs/analytics/EventTracker.js +26 -4
  13. package/lib/commonjs/analytics/EventTracker.js.map +1 -1
  14. package/lib/commonjs/analytics/eventLane.js +23 -0
  15. package/lib/commonjs/analytics/eventLane.js.map +1 -1
  16. package/lib/commonjs/core/constants/version.js +1 -1
  17. package/lib/commonjs/core/context/device.js +18 -0
  18. package/lib/commonjs/core/context/device.js.map +1 -1
  19. package/lib/commonjs/core/id/installationId.js +55 -0
  20. package/lib/commonjs/core/id/installationId.js.map +1 -0
  21. package/lib/commonjs/debug/bootstrap.js +14 -1
  22. package/lib/commonjs/debug/bootstrap.js.map +1 -1
  23. package/lib/commonjs/debug/transport.js +2 -1
  24. package/lib/commonjs/debug/transport.js.map +1 -1
  25. package/lib/commonjs/features/journey/journeyManager.js +2 -1
  26. package/lib/commonjs/features/journey/journeyManager.js.map +1 -1
  27. package/lib/commonjs/features/ota/OtaOrchestrator.js +2 -30
  28. package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
  29. package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js +27 -3
  30. package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
  31. package/lib/commonjs/features/performance/collectors/JsStallCollector.js +20 -1
  32. package/lib/commonjs/features/performance/collectors/JsStallCollector.js.map +1 -1
  33. package/lib/commonjs/features/session/BackendSessionAdapter.js +8 -0
  34. package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
  35. package/lib/commonjs/public/ScaleBunFacade.js +10 -2
  36. package/lib/commonjs/public/ScaleBunFacade.js.map +1 -1
  37. package/lib/module/analytics/EventTracker.js +26 -4
  38. package/lib/module/analytics/EventTracker.js.map +1 -1
  39. package/lib/module/analytics/eventLane.js +22 -0
  40. package/lib/module/analytics/eventLane.js.map +1 -1
  41. package/lib/module/core/constants/version.js +1 -1
  42. package/lib/module/core/context/device.js +17 -0
  43. package/lib/module/core/context/device.js.map +1 -1
  44. package/lib/module/core/id/installationId.js +50 -0
  45. package/lib/module/core/id/installationId.js.map +1 -0
  46. package/lib/module/debug/bootstrap.js +14 -1
  47. package/lib/module/debug/bootstrap.js.map +1 -1
  48. package/lib/module/debug/transport.js +2 -1
  49. package/lib/module/debug/transport.js.map +1 -1
  50. package/lib/module/features/journey/journeyManager.js +2 -1
  51. package/lib/module/features/journey/journeyManager.js.map +1 -1
  52. package/lib/module/features/ota/OtaOrchestrator.js +1 -29
  53. package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
  54. package/lib/module/features/performance/collectors/FrameMetricsCollector.js +27 -3
  55. package/lib/module/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
  56. package/lib/module/features/performance/collectors/JsStallCollector.js +20 -1
  57. package/lib/module/features/performance/collectors/JsStallCollector.js.map +1 -1
  58. package/lib/module/features/session/BackendSessionAdapter.js +8 -0
  59. package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
  60. package/lib/module/public/ScaleBunFacade.js +12 -4
  61. package/lib/module/public/ScaleBunFacade.js.map +1 -1
  62. package/lib/typescript/analytics/EventTracker.d.ts.map +1 -1
  63. package/lib/typescript/analytics/eventLane.d.ts +2 -0
  64. package/lib/typescript/analytics/eventLane.d.ts.map +1 -1
  65. package/lib/typescript/core/constants/version.d.ts +1 -1
  66. package/lib/typescript/core/context/device.d.ts +13 -0
  67. package/lib/typescript/core/context/device.d.ts.map +1 -1
  68. package/lib/typescript/core/id/installationId.d.ts +20 -0
  69. package/lib/typescript/core/id/installationId.d.ts.map +1 -0
  70. package/lib/typescript/debug/bootstrap.d.ts +18 -0
  71. package/lib/typescript/debug/bootstrap.d.ts.map +1 -1
  72. package/lib/typescript/debug/transport.d.ts.map +1 -1
  73. package/lib/typescript/features/journey/journeyManager.d.ts.map +1 -1
  74. package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
  75. package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts +14 -0
  76. package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts.map +1 -1
  77. package/lib/typescript/features/performance/collectors/JsStallCollector.d.ts +5 -0
  78. package/lib/typescript/features/performance/collectors/JsStallCollector.d.ts.map +1 -1
  79. package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
  80. package/lib/typescript/public/ScaleBunFacade.d.ts.map +1 -1
  81. package/lib/typescript/public/types.d.ts +1 -1
  82. package/lib/typescript/public/types.d.ts.map +1 -1
  83. package/package.json +3 -2
  84. package/src/analytics/EventTracker.ts +26 -4
  85. package/src/analytics/eventLane.ts +22 -0
  86. package/src/core/constants/version.ts +1 -1
  87. package/src/core/context/device.ts +17 -0
  88. package/src/core/id/installationId.ts +52 -0
  89. package/src/debug/bootstrap.ts +38 -1
  90. package/src/debug/transport.ts +2 -1
  91. package/src/features/journey/journeyManager.ts +2 -1
  92. package/src/features/ota/OtaOrchestrator.ts +1 -30
  93. package/src/features/performance/collectors/FrameMetricsCollector.ts +32 -3
  94. package/src/features/performance/collectors/JsStallCollector.ts +20 -1
  95. package/src/features/session/BackendSessionAdapter.ts +8 -0
  96. package/src/public/ScaleBunFacade.ts +12 -4
  97. package/src/public/types.ts +1 -1
@@ -0,0 +1,182 @@
1
+ package com.scalebun.rn.ota
2
+
3
+ import java.io.ByteArrayOutputStream
4
+ import java.util.zip.Inflater
5
+
6
+ /**
7
+ * bspatch — reconstructs a bundle from a previous bundle plus a delta.
8
+ *
9
+ * WHAT WAS WRONG. `stagePatch` resolved `false` unconditionally on both
10
+ * platforms, so the whole delta pipeline was decorative: the server generated
11
+ * patches, the check response advertised them, the orchestrator asked for
12
+ * them, and the device always fell back to a full download. Nobody ever
13
+ * downloaded less than the entire bundle.
14
+ *
15
+ * FORMAT (produced by the backend's `bsdiff.ts`; all integers little-endian):
16
+ *
17
+ * 0 8 magic "SBDIFF01"
18
+ * 8 4 target size — the exact number of bytes this must produce
19
+ * 12 4 deflated control-block length
20
+ * 16 4 inflated control-block length
21
+ * 20 4 deflated diff-block length
22
+ * 24 4 inflated diff-block length
23
+ * 28 4 deflated extra-block length
24
+ * 32 4 inflated extra-block length
25
+ * 36 . deflate(control) ++ deflate(diff) ++ deflate(extra)
26
+ *
27
+ * Control is a run of (addLen, extraLen, seek) int32 triples. `addLen` bytes
28
+ * are read from the diff block and added, bytewise mod 256, to the source at
29
+ * the current read cursor; `extraLen` bytes are copied verbatim from the extra
30
+ * block; then the source cursor moves by the signed `seek`.
31
+ *
32
+ * WHY RAW DEFLATE. Android has `java.util.zip` and iOS has the Compression
33
+ * framework; neither ships zstd. On real Hermes bundles zstd-19 beat deflate-9
34
+ * by 0.3 percentage points (95.79% vs 95.51%) — not worth a native dependency
35
+ * on two platforms forever. See the backend module for the measurements.
36
+ *
37
+ * EVERY FIELD HERE IS UNTRUSTED. The patch arrives over the network, so a
38
+ * malformed or hostile one must fail cleanly rather than index out of bounds.
39
+ * Each run is bounds-checked against all four buffers before it is used, and
40
+ * the caller still verifies the SHA-256 of the result before anything is
41
+ * promoted — this routine producing the wrong bytes can never install them.
42
+ */
43
+ internal object BsPatch {
44
+
45
+ private const val MAGIC = "SBDIFF01"
46
+ private const val HEADER_BYTES = 36
47
+
48
+ /** Refuse absurd allocations from a corrupt header (bundles are single-digit MB). */
49
+ private const val MAX_TARGET_BYTES = 256 * 1024 * 1024
50
+
51
+ class PatchFormatException(message: String) : Exception(message)
52
+
53
+ private fun u32(b: ByteArray, off: Int): Long =
54
+ (b[off].toLong() and 0xff) or
55
+ ((b[off + 1].toLong() and 0xff) shl 8) or
56
+ ((b[off + 2].toLong() and 0xff) shl 16) or
57
+ ((b[off + 3].toLong() and 0xff) shl 24)
58
+
59
+ private fun i32(b: ByteArray, off: Int): Int =
60
+ (b[off].toInt() and 0xff) or
61
+ ((b[off + 1].toInt() and 0xff) shl 8) or
62
+ ((b[off + 2].toInt() and 0xff) shl 16) or
63
+ ((b[off + 3].toInt() and 0xff) shl 24)
64
+
65
+ /** Raw DEFLATE (no zlib header) — matches Node's `deflateRawSync`. */
66
+ private fun inflateRaw(data: ByteArray, offset: Int, length: Int): ByteArray {
67
+ val inflater = Inflater(true)
68
+ try {
69
+ inflater.setInput(data, offset, length)
70
+ val out = ByteArrayOutputStream(maxOf(length * 4, 1024))
71
+ val buf = ByteArray(64 * 1024)
72
+ while (true) {
73
+ val n = inflater.inflate(buf)
74
+ if (n > 0) {
75
+ out.write(buf, 0, n)
76
+ continue
77
+ }
78
+ // ORDER MATTERS. A block that legitimately decodes to zero bytes
79
+ // — an empty extra block, which happens whenever a patch is one
80
+ // unbroken run — also reports needsInput() once the stream is
81
+ // consumed. Testing needsInput() first therefore rejected valid
82
+ // patches as truncated; `finished()` has to be asked first.
83
+ if (inflater.finished()) break
84
+ if (inflater.needsInput() || inflater.needsDictionary()) {
85
+ throw PatchFormatException("compressed block is truncated")
86
+ }
87
+ }
88
+ return out.toByteArray()
89
+ } finally {
90
+ inflater.end()
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Apply [patch] to [source], returning the reconstructed bundle.
96
+ *
97
+ * @throws PatchFormatException if the patch is malformed, truncated, or
98
+ * inconsistent with [source]. Callers treat this as "fall back to a full
99
+ * download", never as a fatal error.
100
+ */
101
+ fun apply(source: ByteArray, patch: ByteArray): ByteArray {
102
+ if (patch.size < HEADER_BYTES) throw PatchFormatException("patch too short")
103
+ val magic = String(patch, 0, 8, Charsets.ISO_8859_1)
104
+ if (magic != MAGIC) throw PatchFormatException("bad patch magic '$magic'")
105
+
106
+ val targetSizeL = u32(patch, 8)
107
+ val cLen = u32(patch, 12)
108
+ val cRaw = u32(patch, 16)
109
+ val dLen = u32(patch, 20)
110
+ val dRaw = u32(patch, 24)
111
+ val eLen = u32(patch, 28)
112
+ val eRaw = u32(patch, 32)
113
+
114
+ if (targetSizeL > MAX_TARGET_BYTES) {
115
+ throw PatchFormatException("declared target size $targetSizeL is implausible")
116
+ }
117
+ if (cRaw > MAX_TARGET_BYTES || dRaw > MAX_TARGET_BYTES || eRaw > MAX_TARGET_BYTES) {
118
+ throw PatchFormatException("declared block size is implausible")
119
+ }
120
+ if (HEADER_BYTES + cLen + dLen + eLen != patch.size.toLong()) {
121
+ throw PatchFormatException("block lengths do not match patch size")
122
+ }
123
+ val targetSize = targetSizeL.toInt()
124
+
125
+ var off = HEADER_BYTES
126
+ val control = inflateRaw(patch, off, cLen.toInt()); off += cLen.toInt()
127
+ val diff = inflateRaw(patch, off, dLen.toInt()); off += dLen.toInt()
128
+ val extra = inflateRaw(patch, off, eLen.toInt())
129
+
130
+ // A block that inflates to a size other than the declared one is
131
+ // corrupt; iOS sizes its buffers from these numbers, so Android holds
132
+ // the format to the same contract rather than trusting the stream.
133
+ if (control.size.toLong() != cRaw) throw PatchFormatException("control block size mismatch")
134
+ if (diff.size.toLong() != dRaw) throw PatchFormatException("diff block size mismatch")
135
+ if (extra.size.toLong() != eRaw) throw PatchFormatException("extra block size mismatch")
136
+
137
+ if (control.size % 12 != 0) {
138
+ throw PatchFormatException("control block is not a whole number of triples")
139
+ }
140
+
141
+ val out = ByteArray(targetSize)
142
+ var newpos = 0
143
+ var oldpos = 0
144
+ var dpos = 0
145
+ var epos = 0
146
+
147
+ var c = 0
148
+ while (c < control.size) {
149
+ val addLen = i32(control, c)
150
+ val extraLen = i32(control, c + 4)
151
+ val seek = i32(control, c + 8)
152
+ c += 12
153
+
154
+ if (addLen < 0 || extraLen < 0) throw PatchFormatException("negative run length")
155
+ if (newpos + addLen > targetSize) throw PatchFormatException("add run overruns target")
156
+ if (dpos + addLen > diff.size) throw PatchFormatException("add run overruns diff block")
157
+ if (oldpos < 0 || oldpos + addLen > source.size) {
158
+ throw PatchFormatException("add run overruns source")
159
+ }
160
+
161
+ for (i in 0 until addLen) {
162
+ out[newpos + i] = ((diff[dpos + i].toInt() + source[oldpos + i].toInt()) and 0xff).toByte()
163
+ }
164
+ newpos += addLen
165
+ oldpos += addLen
166
+ dpos += addLen
167
+
168
+ if (newpos + extraLen > targetSize) throw PatchFormatException("extra run overruns target")
169
+ if (epos + extraLen > extra.size) throw PatchFormatException("extra run overruns extra block")
170
+ System.arraycopy(extra, epos, out, newpos, extraLen)
171
+ newpos += extraLen
172
+ epos += extraLen
173
+
174
+ oldpos += seek
175
+ }
176
+
177
+ if (newpos != targetSize) {
178
+ throw PatchFormatException("patch produced $newpos bytes, declared $targetSize")
179
+ }
180
+ return out
181
+ }
182
+ }
@@ -191,20 +191,21 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
191
191
  }
192
192
 
193
193
  /**
194
- * Binary-patch staging (S7-NAT-1) — NOT IMPLEMENTED on Android.
194
+ * Binary-patch staging (S7-NAT-1).
195
195
  *
196
- * [SlotManager] has no patch/bspatch path, so there is nothing to apply a diff
197
- * with. The JS contract in `src/specs/NativeScaleBunOta.ts` defines `false` as
198
- * "patching unavailable fall back to a full-bundle download", which is exactly
199
- * the honest answer here; OtaOrchestrator then downloads the whole bundle and the
200
- * update still lands. Resolving false (rather than rejecting) keeps that fallback
201
- * on its documented path instead of surfacing an error the caller must special-case.
196
+ * Downloads a delta, reconstructs the full bundle from the currently
197
+ * installed one, and hands the result to the ordinary staging path — so a
198
+ * patched bundle passes exactly the same SHA-256 and slot-promotion checks
199
+ * as a fully downloaded one. [expectedSha256] is the hash of the COMPLETE
200
+ * bundle, which is what makes a bad patch unable to install: reconstruct
201
+ * the wrong bytes and staging rejects them.
202
202
  *
203
- * This override must exist regardless: on the New Architecture the codegen base
204
- * (`NativeScaleBunOtaSpec`, generated from the JS spec) declares `stagePatch`
205
- * abstract, so omitting it fails Kotlin compilation for every new-arch consumer.
206
- *
207
- * Replace the body with a real bspatch once SlotManager grows patch support.
203
+ * Resolves `false` never rejects for every "cannot patch" case, because
204
+ * `src/specs/NativeScaleBunOta.ts` defines false as "patching unavailable,
205
+ * fall back to a full download". The update still lands, just larger. The
206
+ * cases are: no OTA bundle installed yet (nothing to patch against — the
207
+ * first install is always full), a patch that does not match its own
208
+ * advertised hash, and a malformed patch.
208
209
  */
209
210
  @ReactMethod
210
211
  override fun stagePatch(
@@ -213,7 +214,81 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
213
214
  expectedSha256: String,
214
215
  promise: Promise,
215
216
  ) {
216
- promise.resolve(false)
217
+ var downloadedPatch: File? = null
218
+ var rebuilt: File? = null
219
+ try {
220
+ // Patching is relative to the bundle already on disk. With no OTA
221
+ // bundle installed the source would be the APK's built-in asset,
222
+ // which the server did not diff against — so there is nothing valid
223
+ // to apply and a full download is the correct path.
224
+ val currentBundle = slotManager.currentBundlePath()
225
+ if (currentBundle == null || !currentBundle.exists()) {
226
+ android.util.Log.i(NAME, "stagePatch: no current OTA bundle to patch — full download")
227
+ promise.resolve(false)
228
+ return
229
+ }
230
+
231
+ val temp = File(reactApplicationContext.cacheDir, "scalebun_ota_patch_${System.nanoTime()}")
232
+ downloadedPatch = temp
233
+ if (!BundleDownloader.download(patchUrl, temp, ::emitProgress)) {
234
+ promise.resolve(false)
235
+ return
236
+ }
237
+
238
+ val patchBytes = temp.readBytes()
239
+
240
+ // Verify the patch against its own hash before parsing it. This is
241
+ // cheap and keeps obviously-corrupt input away from the parser.
242
+ val actualPatchSha = sha256Hex(patchBytes)
243
+ if (!actualPatchSha.equals(patchSha256, ignoreCase = true)) {
244
+ android.util.Log.w(NAME, "stagePatch: patch hash mismatch — full download")
245
+ promise.resolve(false)
246
+ return
247
+ }
248
+
249
+ val sourceBytes = currentBundle.readBytes()
250
+ val rebuiltBytes = try {
251
+ BsPatch.apply(sourceBytes, patchBytes)
252
+ } catch (e: BsPatch.PatchFormatException) {
253
+ android.util.Log.w(NAME, "stagePatch: ${e.message} — full download")
254
+ promise.resolve(false)
255
+ return
256
+ }
257
+
258
+ val out = File(reactApplicationContext.cacheDir, "scalebun_ota_rebuilt_${System.nanoTime()}")
259
+ rebuilt = out
260
+ out.writeBytes(rebuiltBytes)
261
+
262
+ val meta = JSONObject()
263
+ meta.put("sha256", expectedSha256)
264
+ meta.put("installedAt", System.currentTimeMillis())
265
+ meta.put("patched", true)
266
+
267
+ // SlotManager re-hashes and compares against the full-bundle hash;
268
+ // a mis-applied patch cannot get past this.
269
+ val success = slotManager.stage(out, expectedSha256, meta)
270
+ if (!success) {
271
+ android.util.Log.w(NAME, "stagePatch: rebuilt bundle failed verification — full download")
272
+ }
273
+ promise.resolve(success)
274
+ } catch (e: Exception) {
275
+ // Any unexpected failure is still just "no patch" — the caller has
276
+ // a working full-download path and a rejected promise would turn a
277
+ // recoverable situation into a failed update.
278
+ android.util.Log.w(NAME, "stagePatch: ${e.message} — full download")
279
+ promise.resolve(false)
280
+ } finally {
281
+ downloadedPatch?.delete()
282
+ rebuilt?.delete()
283
+ }
284
+ }
285
+
286
+ private fun sha256Hex(bytes: ByteArray): String {
287
+ val digest = java.security.MessageDigest.getInstance("SHA-256")
288
+ val hash = digest.digest(bytes)
289
+ val sb = StringBuilder(hash.size * 2)
290
+ for (b in hash) sb.append(String.format("%02x", b))
291
+ return sb.toString()
217
292
  }
218
293
 
219
294
  @ReactMethod
@@ -18,4 +18,20 @@ abstract class ScaleBunCrashSpec(reactContext: ReactApplicationContext) :
18
18
  abstract fun install(promise: Promise)
19
19
  abstract fun crashNow(promise: Promise)
20
20
  abstract fun drainPendingCrash(promise: Promise)
21
+
22
+ /**
23
+ * These two were added to the JS spec and to [ScaleBunCrashModule] without
24
+ * being added here, and the omission is invisible on the New Architecture:
25
+ * there the base is GENERATED from the JS spec, so it grew the methods
26
+ * automatically. On the Old Architecture this file IS the base and it has
27
+ * to be edited by hand — so `ScaleBunCrashModule` was overriding two
28
+ * methods that did not exist, and the SDK failed to compile for every
29
+ * old-arch consumer with "'setActiveSessionId' overrides nothing".
30
+ *
31
+ * Any change to `src/specs/NativeScaleBunCrash.ts` has to be mirrored here.
32
+ * That is the cost of the two-base design described above; the alternative
33
+ * is that old-arch apps silently stop building.
34
+ */
35
+ abstract fun setActiveSessionId(sessionId: String?, promise: Promise)
36
+ abstract fun clearPendingCrash(crashId: String?, promise: Promise)
21
37
  }
package/bin/scalebun.js CHANGED
@@ -630,12 +630,64 @@ function main() {
630
630
  console.log(' npx scalebun init ios --check Verify hooks exist (exit 1 if missing)');
631
631
  console.log(' npx scalebun init ios --dry-run Preview changes without writing');
632
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('');
633
637
  break;
634
638
  default:
635
- console.error(`Unknown command: ${cmd}`);
636
- console.error('Run `npx scalebun help` for usage.');
637
- process.exit(2);
639
+ // TWO PACKAGES, ONE COMMAND NAME.
640
+ //
641
+ // The OTA commands (login, ota publish, releases, rollout, quickstart…)
642
+ // live in @scalebun/cli, which declares the SAME `scalebun` bin as this
643
+ // package. Every doc and generated CI pipeline says `npx scalebun ota
644
+ // publish`, so whichever package won the bin race decided whether that
645
+ // worked — and when this one won, a new user's very first command died
646
+ // with "Unknown command: ota" and no way to find out why.
647
+ //
648
+ // Delegate instead of guessing: if @scalebun/cli is installed, run it.
649
+ // If not, say exactly what to install rather than what went wrong.
650
+ delegateToOtaCli(argv);
638
651
  }
639
652
  }
640
653
 
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;
660
+ try {
661
+ cliEntry = require.resolve('@scalebun/cli/lib/index.js', {
662
+ paths: [process.cwd(), __dirname],
663
+ });
664
+ } catch {
665
+ cliEntry = null;
666
+ }
667
+
668
+ if (!cliEntry) {
669
+ const cmd = argv[0];
670
+ console.error('');
671
+ console.error(`"${cmd}" is an OTA command, provided by @scalebun/cli.`);
672
+ console.error('');
673
+ console.error('@scalebun/cli ships as a dependency of @scalebun/react-native, so it');
674
+ console.error('should already be present. It could not be resolved — your install may');
675
+ console.error('be incomplete or the dependency was pruned.');
676
+ console.error('');
677
+ console.error('Reinstall dependencies, then re-run:');
678
+ console.error(' npm install');
679
+ console.error(` npx scalebun ${argv.join(' ')}`);
680
+ console.error('');
681
+ console.error('(Or install it explicitly: npm i -D @scalebun/cli)');
682
+ console.error('');
683
+ process.exit(2);
684
+ }
685
+
686
+ const { spawnSync } = require('child_process');
687
+ const res = spawnSync(process.execPath, [cliEntry, ...argv], {
688
+ stdio: 'inherit',
689
+ });
690
+ process.exit(res.status ?? 1);
691
+ }
692
+
641
693
  main();