react-native-debug-toolkit 3.3.3 → 3.3.4

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
@@ -77,11 +77,44 @@ In the app, open Debug Panel -> `DevConnect` -> `Send Once` or `Start Live Sync`
77
77
 
78
78
  DevConnect auto-detects simulator/emulator and uses local host settings automatically. On real devices, enter your computer IP to connect.
79
79
 
80
- For Remote JS Bundle, run Metro on your computer, enter computer IP and Metro port in `DevConnect`, then tap `Use Metro Bundle`. DevConnect persists the host and hot-reloads from Metro.
80
+ ### Embedded Debug Bundle
81
+
82
+ Debug builds need an embedded JS bundle for cold start when Metro is off.
83
+
84
+ Bare React Native:
85
+
86
+ ```bash
87
+ npx debug-toolkit setup-bundle
88
+ git diff
89
+ git commit -am "chore: enable debug bundle embedding"
90
+ ```
91
+
92
+ Expo dev-client:
93
+
94
+ ```json
95
+ {
96
+ "expo": {
97
+ "plugins": [
98
+ ["react-native-debug-toolkit/dev-client", { "embedBundle": true }]
99
+ ]
100
+ }
101
+ }
102
+ ```
103
+
104
+ Verify built artifacts:
105
+
106
+ ```bash
107
+ npx debug-toolkit doctor-bundle --platform ios --app path/to/App.app
108
+ npx debug-toolkit doctor-bundle --platform android --apk path/to/app-debug.apk
109
+ ```
110
+
111
+ After setup, build machines run normal Xcode, Gradle, React Native, or EAS commands. Do not run a separate mutation command on every build.
112
+
113
+ For Remote JS Bundle, run Metro on your computer, enter computer IP and Metro port in `DevConnect`, then tap `Use Metro Bundle`. DevConnect persists the host and hot-reloads from Metro. Use **Reset** to go back to the embedded bundle.
81
114
 
82
115
  > **Debug builds only.** Metro host switching works in Debug builds. Release builds load the embedded bundle and the controls are disabled (`release: disabled` badge).
83
116
 
84
- **iOS — no AppDelegate changes required.** On install, DevConnect hooks `RCTBundleURLProvider` so the app **cold-starts from the embedded `main.jsbundle`** and only connects to Metro after you apply a host in the panel (fixes Expo `.expo/.virtual-metro-entry` red screens when Metro is off). Use **Reset** to go back to the embedded bundle.
117
+ **iOS — no AppDelegate changes required.** On install, DevConnect hooks `RCTBundleURLProvider` so the app **cold-starts from the embedded `main.jsbundle`** and only connects to Metro after you apply a host in the panel (fixes Expo `.expo/.virtual-metro-entry` red screens when Metro is off).
85
118
 
86
119
  The IP and ports are persisted through AsyncStorage when installed, or through the native module after rebuild.
87
120
 
package/README.zh-CN.md CHANGED
@@ -77,11 +77,44 @@ App 内打开 Debug Panel -> `DevConnect` -> `Send Once` 或 `Start Live Sync`
77
77
 
78
78
  DevConnect 自动识别模拟器/真机,模拟器下自动使用本机 Metro/daemon 地址。真机需输入电脑 IP 地址。
79
79
 
80
- Remote JS Bundle:先在电脑启动 Metro,在 `DevConnect` 输入电脑 IP 和 Metro 端口,然后点 `Use Metro Bundle`。DevConnect 会持久化 host 并从 Metro 热重载。
80
+ ### Debug 包内置 Bundle
81
+
82
+ Debug 包要在 Metro 关闭时冷启动,必须内置 JS bundle。
83
+
84
+ Bare React Native:
85
+
86
+ ```bash
87
+ npx debug-toolkit setup-bundle
88
+ git diff
89
+ git commit -am "chore: enable debug bundle embedding"
90
+ ```
91
+
92
+ Expo dev-client:
93
+
94
+ ```json
95
+ {
96
+ "expo": {
97
+ "plugins": [
98
+ ["react-native-debug-toolkit/dev-client", { "embedBundle": true }]
99
+ ]
100
+ }
101
+ }
102
+ ```
103
+
104
+ 验证产物:
105
+
106
+ ```bash
107
+ npx debug-toolkit doctor-bundle --platform ios --app path/to/App.app
108
+ npx debug-toolkit doctor-bundle --platform android --apk path/to/app-debug.apk
109
+ ```
110
+
111
+ setup 后配置进仓库,打包机继续跑正常 Xcode、Gradle、React Native 或 EAS 命令。不要每次打包再跑额外修改命令。
112
+
113
+ Remote JS Bundle:先在电脑启动 Metro,在 `DevConnect` 输入电脑 IP 和 Metro 端口,然后点 `Use Metro Bundle`。DevConnect 会持久化 host 并从 Metro 热重载。点 **Reset** 可回到内置 bundle。
81
114
 
82
115
  > **仅 Debug 包可用。** Release 包控件会显示 `release: disabled` 并禁用。
83
116
 
84
- **iOS — 无需改 AppDelegate。** 安装本库后会 hook `RCTBundleURLProvider`:**未配置 IP 时冷启动走内置 `main.jsbundle`**,只有在面板里应用 host 后才连 Metro(可避免 Metro 未开时的 `.expo/.virtual-metro-entry` 红屏)。点 **Reset** 可回到内置 bundle。
117
+ **iOS — 无需改 AppDelegate。** 安装本库后会 hook `RCTBundleURLProvider`:**未配置 IP 时冷启动走内置 `main.jsbundle`**,只有在面板里应用 host 后才连 Metro(可避免 Metro 未开时的 `.expo/.virtual-metro-entry` 红屏)。
85
118
 
86
119
  IP 和端口会通过 AsyncStorage 持久化;如果没装 AsyncStorage,则在重建后通过本库原生模块持久化。
87
120
 
package/app.plugin.js ADDED
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ function markTestConfig(config) {
4
+ config.extra = config.extra || {};
5
+ config.extra.reactNativeDebugToolkit = {
6
+ ...(config.extra.reactNativeDebugToolkit || {}),
7
+ embedBundle: true,
8
+ };
9
+ return config;
10
+ }
11
+
12
+ function loadConfigPlugins() {
13
+ try {
14
+ return require('@expo/config-plugins');
15
+ } catch {
16
+ throw new Error(
17
+ 'react-native-debug-toolkit/dev-client requires @expo/config-plugins during Expo prebuild.',
18
+ );
19
+ }
20
+ }
21
+
22
+ function withDebugToolkitDevClient(config, props = {}) {
23
+ if (!props.embedBundle) {
24
+ return config;
25
+ }
26
+
27
+ if (props._testOnly) {
28
+ return markTestConfig(config);
29
+ }
30
+
31
+ const { withDangerousMod } = loadConfigPlugins();
32
+ const { setupIosBundle } = require('./scripts/bundle/ios');
33
+ const { setupAndroidBundle } = require('./scripts/bundle/android');
34
+
35
+ config = withDangerousMod(config, ['ios', async (modConfig) => {
36
+ setupIosBundle({
37
+ cwd: modConfig.modRequest.projectRoot,
38
+ iosTarget: props.iosTarget,
39
+ });
40
+ return modConfig;
41
+ }]);
42
+
43
+ config = withDangerousMod(config, ['android', async (modConfig) => {
44
+ setupAndroidBundle({ cwd: modConfig.modRequest.projectRoot });
45
+ return modConfig;
46
+ }]);
47
+
48
+ return config;
49
+ }
50
+
51
+ module.exports = withDebugToolkitDevClient;
@@ -27,21 +27,19 @@ function hasHelpFlag(args) {
27
27
  function printHelp() {
28
28
  process.stderr.write(
29
29
  'Usage: debug-toolkit [--host 0.0.0.0] [--port 3799] [--token dev-token] [--store ~/.react-native-debug-toolkit/daemon-devices.json] [--daemon-only]\n'
30
- + ' debug-toolkit embed [--platform ios|android] [--undo] [--yes]\n'
30
+ + ' debug-toolkit setup-bundle [--platform ios|android] [--undo] [--check] [--ios-target <name>]\n'
31
+ + ' debug-toolkit doctor-bundle --platform ios --app <path-to.app>\n'
32
+ + ' debug-toolkit doctor-bundle --platform android --apk <path-to.apk>\n'
31
33
  + '\n'
32
34
  + 'Starts the debug toolkit: daemon (HTTP + Web Console) and MCP stdio server.\n'
33
35
  + '\n'
34
36
  + 'Commands:\n'
35
- + ' embed Embed JS bundle in debug builds (run in host app root)\n'
36
- + '\n'
37
- + 'Embed options:\n'
38
- + ' --platform <p> Target platform: ios or android (default: both)\n'
39
- + ' --undo Remove embed injections\n'
40
- + ' --yes Skip confirmations (CI/EAS)\n'
37
+ + ' setup-bundle Persistently configure host app debug builds to embed JS bundle\n'
38
+ + ' doctor-bundle Verify source config or built app package contains embedded bundle\n'
41
39
  + '\n'
42
40
  + 'Daemon options:\n'
43
41
  + ' --host <addr> Host to bind (default: 0.0.0.0)\n'
44
- + ' --port <port> Port to bind (default: 3799)\n'
42
+ + ' --port <port> Port to bind (default: 3799)\n'
45
43
  + ' --token <str> Auth token for daemon endpoints\n'
46
44
  + ' --store <path> Device log store path\n'
47
45
  + ' --daemon-only Start only the HTTP daemon and Web Console\n'
@@ -66,10 +64,13 @@ async function main() {
66
64
  return;
67
65
  }
68
66
 
69
- // Route embed subcommand
70
- if (args[0] === 'embed') {
71
- const { main: embedMain } = require('../scripts/embed');
72
- return embedMain(args.slice(1));
67
+ if (['setup-bundle', 'doctor-bundle', 'embed'].includes(args[0])) {
68
+ const { runBundleCli } = require('../scripts/bundle/cli');
69
+ const code = await runBundleCli(args);
70
+ if (code !== 0) {
71
+ process.exitCode = code;
72
+ }
73
+ return;
73
74
  }
74
75
 
75
76
  const host = readOption(args, '--host', process.env.DEBUG_TOOLKIT_DAEMON_HOST || DEFAULT_HOST);
package/dev-client.js ADDED
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = require('./app.plugin');
@@ -15,9 +15,10 @@
15
15
  // Debug-only Metro host switching — zero host-app native changes required.
16
16
  //
17
17
  // RN/Expo Debug templates call jsBundleURLForBundleRoot:fallbackURLProvider:, which consults
18
- // -packagerServerHostPort (guessPackagerHost on simulator when Metro is running). We hook:
19
- // 1. jsBundleURLForBundleRoot:fallbackURLProvider: no DevConnect host main.jsbundle first.
20
- // 2. packagerServerHostPort no DevConnect host nil (block auto-discovered localhost:8081).
18
+ // -packagerServerHostPort (guessPackagerHost on simulator when Metro is running). We only hook
19
+ // jsBundleURLForBundleRoot:fallbackURLProvider: so no DevConnect host starts from main.jsbundle.
20
+ // Persisted DevConnect hosts still flow through RCTBundleURLProvider, preserving RN's reachability
21
+ // and fallback behavior when a saved Metro host is stale.
21
22
  //
22
23
  // Optional: host apps may call DebugToolkitMetroBundleURL() from bundleURL() for explicit control.
23
24
 
@@ -26,7 +27,6 @@ static NSString *const kBundleRoot = @"index";
26
27
  static NSString *const kExpoVirtualMetroEntry = @".expo/.virtual-metro-entry";
27
28
  static NSString *const kMetroHostKey = @"_devconnect_metro_host";
28
29
 
29
- static BOOL gPackagerHookInstalled = NO;
30
30
  static BOOL gBundleRootHookInstalled = NO;
31
31
 
32
32
  static NSURL *(*gOrigJsBundleURLForBundleRootWithFallback)(id, SEL, NSString *, NSURL * _Nonnull (^)(void));
@@ -97,17 +97,6 @@ static NSURL *DevConnectMetroURLForPersistedHost(void)
97
97
  return [settings jsBundleURLForBundleRoot:DevConnectMetroBundleRoot()];
98
98
  }
99
99
 
100
- /// When no DevConnect host: block guessPackagerHost (simulator often has Metro on :8081).
101
- static NSString *replacement_packagerServerHostPort(id self, SEL _cmd)
102
- {
103
- NSString *host = DevConnectPersistedMetroHost();
104
- if (host.length == 0) {
105
- return nil;
106
- }
107
- [(RCTBundleURLProvider *)self setJsLocation:host];
108
- return host;
109
- }
110
-
111
100
  /// Primary hook: return embedded main.jsbundle before RN/Expo tries Metro / virtual-metro-entry.
112
101
  static NSURL *replacement_jsBundleURLForBundleRoot_fallback(
113
102
  id self, SEL _cmd, NSString *bundleRoot, NSURL * _Nonnull (^fallbackURLProvider)(void))
@@ -127,25 +116,6 @@ static NSURL *replacement_jsBundleURLForBundleRoot_fallback(
127
116
  return fallbackURLProvider ? fallbackURLProvider() : nil;
128
117
  }
129
118
 
130
- static void DebugToolkitInstallPackagerHook(Class cls)
131
- {
132
- if (gPackagerHookInstalled) {
133
- return;
134
- }
135
- Method method = class_getInstanceMethod(cls, @selector(packagerServerHostPort));
136
- if (!method) {
137
- NSLog(@"[DevConnect] packagerServerHostPort not found");
138
- return;
139
- }
140
- IMP replacement = (IMP)replacement_packagerServerHostPort;
141
- if (method_getImplementation(method) == replacement) {
142
- gPackagerHookInstalled = YES;
143
- return;
144
- }
145
- method_setImplementation(method, replacement);
146
- gPackagerHookInstalled = YES;
147
- }
148
-
149
119
  static void DebugToolkitInstallBundleRootHook(Class cls)
150
120
  {
151
121
  if (gBundleRootHookInstalled) {
@@ -170,7 +140,7 @@ static void DebugToolkitInstallBundleRootHook(Class cls)
170
140
 
171
141
  static void DebugToolkitInstallAllHooks(void)
172
142
  {
173
- if (gBundleRootHookInstalled && gPackagerHookInstalled) {
143
+ if (gBundleRootHookInstalled) {
174
144
  return;
175
145
  }
176
146
 
@@ -181,15 +151,12 @@ static void DebugToolkitInstallAllHooks(void)
181
151
  }
182
152
 
183
153
  DebugToolkitInstallBundleRootHook(cls);
184
- DebugToolkitInstallPackagerHook(cls);
185
154
 
186
155
  static BOOL didLogOutcome = NO;
187
- if (!didLogOutcome && (gBundleRootHookInstalled || gPackagerHookInstalled)) {
156
+ if (!didLogOutcome) {
188
157
  didLogOutcome = YES;
189
158
  if (DevConnectEmbeddedFirstHooksActive()) {
190
- NSLog(@"[DevConnect] embedded-first hooks active (bundleRoot=%@ packager=%@)",
191
- gBundleRootHookInstalled ? @"Y" : @"N",
192
- gPackagerHookInstalled ? @"Y" : @"N");
159
+ NSLog(@"[DevConnect] embedded-first hook active");
193
160
  } else {
194
161
  NSLog(@"[DevConnect] embedded-first hooks FAILED — rebuild / check React linkage");
195
162
  }
@@ -365,7 +332,7 @@ RCT_EXPORT_METHOD(getDiagnostics:(RCTPromiseResolveBlock)resolve
365
332
  @"isDebugBuild": @(isDebug),
366
333
  @"hasEmbeddedBundle": @(DebugToolkitEmbeddedBundleURL() != nil),
367
334
  @"embeddedFirstHookInstalled": @(DevConnectEmbeddedFirstHooksActive()),
368
- @"packagerHookInstalled": @(gPackagerHookInstalled),
335
+ @"packagerHookInstalled": @NO,
369
336
  @"bundleRootHookInstalled": @(gBundleRootHookInstalled),
370
337
  });
371
338
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-debug-toolkit",
3
- "version": "3.3.3",
3
+ "version": "3.3.4",
4
4
  "description": "A local-first React Native debug toolkit with Web Console, HTTP API, and MCP support for AI-readable app logs",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -11,6 +11,8 @@
11
11
  "bin",
12
12
  "node",
13
13
  "scripts",
14
+ "app.plugin.js",
15
+ "dev-client.js",
14
16
  "ios",
15
17
  "android",
16
18
  "react-native-debug-toolkit.podspec",
@@ -95,6 +97,6 @@
95
97
  },
96
98
  "dependencies": {
97
99
  "@babel/runtime": "^7.29.2",
98
- "pbxproj": "^0.0.5"
100
+ "xcode": "^3.0.1"
99
101
  }
100
102
  }
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+
7
+ const BEGIN = '// react-native-debug-toolkit: begin debug bundle';
8
+ const END = '// react-native-debug-toolkit: end debug bundle';
9
+ const REL_SCRIPT = '../../node_modules/react-native-debug-toolkit/scripts/debug-bundle.gradle';
10
+ const EXPECTED_TASK = 'createDebugToolkitDebugJsAndAssets';
11
+ const GRADLE_TASKS_ARGS = [':app:tasks', '--all', '--no-daemon', '--console=plain'];
12
+
13
+ function findGradleFile(cwd) {
14
+ const groovy = path.join(cwd, 'android/app/build.gradle');
15
+ const kotlin = path.join(cwd, 'android/app/build.gradle.kts');
16
+
17
+ if (fs.existsSync(kotlin)) {
18
+ return { file: kotlin, kind: 'kotlin' };
19
+ }
20
+
21
+ if (fs.existsSync(groovy)) {
22
+ return { file: groovy, kind: 'groovy' };
23
+ }
24
+
25
+ throw new Error('android/app/build.gradle(.kts) not found.');
26
+ }
27
+
28
+ function block(kind) {
29
+ const applyLine = kind === 'kotlin'
30
+ ? `apply(from = "${REL_SCRIPT}")`
31
+ : `apply from: "${REL_SCRIPT}"`;
32
+
33
+ return `${BEGIN}\n${applyLine}\n${END}`;
34
+ }
35
+
36
+ function removeBlock(content) {
37
+ return content.replace(
38
+ /\/\/ react-native-debug-toolkit: begin debug bundle\n[\s\S]*?\/\/ react-native-debug-toolkit: end debug bundle\n?/g,
39
+ '',
40
+ );
41
+ }
42
+
43
+ function setupAndroidBundle(options) {
44
+ const gradle = findGradleFile(options.cwd);
45
+ const content = fs.readFileSync(gradle.file, 'utf8');
46
+
47
+ if (content.includes(BEGIN)) {
48
+ return { ok: true, changed: false, file: gradle.file };
49
+ }
50
+
51
+ fs.writeFileSync(gradle.file, `${content.trimEnd()}\n\n${block(gradle.kind)}\n`);
52
+ return { ok: true, changed: true, file: gradle.file };
53
+ }
54
+
55
+ function undoAndroidBundle(options) {
56
+ const gradle = findGradleFile(options.cwd);
57
+ const content = fs.readFileSync(gradle.file, 'utf8');
58
+ const next = removeBlock(content).replace(/\n{3,}/g, '\n\n');
59
+
60
+ if (next === content) {
61
+ return { ok: true, changed: false, file: gradle.file };
62
+ }
63
+
64
+ fs.writeFileSync(gradle.file, next);
65
+ return { ok: true, changed: true, file: gradle.file };
66
+ }
67
+
68
+ function checkAndroidBundle(options) {
69
+ const gradle = findGradleFile(options.cwd);
70
+ const content = fs.readFileSync(gradle.file, 'utf8');
71
+ const hasMarker = content.includes(BEGIN) && content.includes('scripts/debug-bundle.gradle');
72
+
73
+ if (!hasMarker) {
74
+ return { ok: false, reason: 'marker_missing', file: gradle.file };
75
+ }
76
+
77
+ const androidDir = path.join(options.cwd, 'android');
78
+ const gradlew = path.join(androidDir, 'gradlew');
79
+ const command = fs.existsSync(gradlew) ? gradlew : 'gradle';
80
+ const runGradle = options.runGradle || ((cmd, args, opts) => execFileSync(cmd, args, {
81
+ cwd: opts.cwd,
82
+ encoding: 'utf8',
83
+ stdio: ['ignore', 'pipe', 'pipe'],
84
+ }));
85
+ const output = runGradle(command, GRADLE_TASKS_ARGS, { cwd: androidDir });
86
+
87
+ if (!String(output).includes(EXPECTED_TASK)) {
88
+ return { ok: false, reason: 'gradle_task_missing', file: gradle.file };
89
+ }
90
+
91
+ return { ok: true, file: gradle.file };
92
+ }
93
+
94
+ module.exports = {
95
+ setupAndroidBundle,
96
+ undoAndroidBundle,
97
+ checkAndroidBundle,
98
+ BEGIN,
99
+ END,
100
+ EXPECTED_TASK,
101
+ };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ function readOption(args, name) {
4
+ const index = args.indexOf(name);
5
+ return index >= 0 ? args[index + 1] : undefined;
6
+ }
7
+
8
+ function hasFlag(args, name) {
9
+ return args.includes(name);
10
+ }
11
+
12
+ function parseCommon(args, cwd) {
13
+ return {
14
+ cwd,
15
+ platform: readOption(args, '--platform') || 'all',
16
+ undo: hasFlag(args, '--undo'),
17
+ check: hasFlag(args, '--check'),
18
+ iosTarget: readOption(args, '--ios-target'),
19
+ yes: hasFlag(args, '--yes'),
20
+ };
21
+ }
22
+
23
+ async function runBundleCli(args, deps = {}) {
24
+ const cwd = deps.cwd || process.cwd();
25
+ const io = deps.io || {
26
+ writeOut: (value) => process.stdout.write(value),
27
+ writeErr: (value) => process.stderr.write(value),
28
+ };
29
+ const command = args[0];
30
+
31
+ if (command === 'setup-bundle') {
32
+ const setupBundle = deps.setupBundle || require('./setup').setupBundle;
33
+ await setupBundle(parseCommon(args.slice(1), cwd));
34
+ return 0;
35
+ }
36
+
37
+ if (command === 'doctor-bundle') {
38
+ const doctorBundle = deps.doctorBundle || require('./doctor').doctorBundle;
39
+ await doctorBundle({
40
+ cwd,
41
+ platform: readOption(args.slice(1), '--platform') || 'all',
42
+ app: readOption(args.slice(1), '--app'),
43
+ apk: readOption(args.slice(1), '--apk'),
44
+ });
45
+ return 0;
46
+ }
47
+
48
+ if (command === 'embed') {
49
+ io.writeErr('Unknown command: embed\nUse: debug-toolkit setup-bundle\n');
50
+ return 1;
51
+ }
52
+
53
+ io.writeErr(`Unknown command: ${command || '(none)'}\n`);
54
+ return 1;
55
+ }
56
+
57
+ module.exports = { runBundleCli };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+
7
+ function readZipEntriesWithUnzip(file) {
8
+ const output = execFileSync('unzip', ['-Z1', file], { encoding: 'utf8' });
9
+ return output.split(/\r?\n/).filter(Boolean);
10
+ }
11
+
12
+ async function doctorIos(options) {
13
+ if (!options.app) throw new Error('--app is required for iOS doctor.');
14
+ const bundle = path.join(options.app, 'main.jsbundle');
15
+ if (!fs.existsSync(bundle)) {
16
+ throw new Error(`main.jsbundle not found in ${options.app}`);
17
+ }
18
+ return { ok: true, platform: 'ios', bundle };
19
+ }
20
+
21
+ async function doctorAndroid(options) {
22
+ if (!options.apk) throw new Error('--apk is required for Android doctor.');
23
+ const readZipEntries = options.readZipEntries || readZipEntriesWithUnzip;
24
+ const entries = await readZipEntries(options.apk);
25
+ const bundle = entries.find((entry) => /^assets\/.+\.bundle$/.test(entry));
26
+ if (!bundle) {
27
+ throw new Error(`Android JS bundle not found in ${options.apk}`);
28
+ }
29
+ return { ok: true, platform: 'android', bundle };
30
+ }
31
+
32
+ async function doctorBundle(options) {
33
+ if (options.platform === 'ios') return doctorIos(options);
34
+ if (options.platform === 'android') return doctorAndroid(options);
35
+ throw new Error(`Unsupported platform for doctor-bundle: ${options.platform}`);
36
+ }
37
+
38
+ module.exports = { doctorBundle, readZipEntriesWithUnzip };