mobile-debug-mcp 0.14.0 → 0.15.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 (98) hide show
  1. package/dist/android/interact.js +2 -2
  2. package/dist/android/observe.js +13 -0
  3. package/dist/cli/ios/run-ios-smoke.js +2 -2
  4. package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
  5. package/dist/interact/android.js +91 -0
  6. package/dist/interact/index.js +37 -0
  7. package/dist/interact/ios.js +120 -0
  8. package/dist/interact/shared/fingerprint.js +72 -0
  9. package/dist/interact/shared/scroll_to_element.js +98 -0
  10. package/dist/ios/interact.js +2 -2
  11. package/dist/ios/observe.js +12 -0
  12. package/dist/manage/android.js +162 -0
  13. package/dist/manage/index.js +364 -0
  14. package/dist/manage/ios.js +353 -0
  15. package/dist/observe/android.js +351 -0
  16. package/dist/observe/fingerprint.js +1 -0
  17. package/dist/observe/index.js +85 -0
  18. package/dist/observe/ios.js +320 -0
  19. package/dist/observe/test/device/logstream-real.js +34 -0
  20. package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
  21. package/dist/observe/test/device/run-scroll-test-android.js +22 -0
  22. package/dist/observe/test/device/test-ui-tree.js +67 -0
  23. package/dist/observe/test/device/wait_for_element_real.js +69 -0
  24. package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
  25. package/dist/observe/test/unit/logparse.test.js +39 -0
  26. package/dist/observe/test/unit/logstream.test.js +41 -0
  27. package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
  28. package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
  29. package/dist/server.js +21 -5
  30. package/dist/shared/fingerprint.js +72 -0
  31. package/dist/shared/scroll_to_element.js +98 -0
  32. package/dist/tools/interact.js +2 -2
  33. package/dist/tools/manage.js +2 -2
  34. package/dist/tools/observe.js +45 -43
  35. package/dist/utils/android/utils.js +429 -0
  36. package/dist/utils/cli/idb/check-idb.js +84 -0
  37. package/dist/utils/cli/idb/idb-helper.js +91 -0
  38. package/dist/utils/cli/idb/install-idb.js +82 -0
  39. package/dist/utils/cli/ios/preflight-ios.js +155 -0
  40. package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
  41. package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
  42. package/dist/utils/diagnostics.js +1 -1
  43. package/dist/utils/ios/utils.js +301 -0
  44. package/dist/utils/resolve-device.js +2 -2
  45. package/docs/CHANGELOG.md +4 -0
  46. package/docs/tools/observe.md +24 -0
  47. package/package.json +1 -1
  48. package/src/{android/interact.ts → interact/android.ts} +3 -3
  49. package/src/{tools/interact.ts → interact/index.ts} +4 -3
  50. package/src/{ios/interact.ts → interact/ios.ts} +3 -3
  51. package/src/interact/shared/fingerprint.ts +73 -0
  52. package/src/{tools → interact/shared}/scroll_to_element.ts +1 -1
  53. package/src/{android/manage.ts → manage/android.ts} +2 -2
  54. package/src/{tools/manage.ts → manage/index.ts} +7 -4
  55. package/src/{ios/manage.ts → manage/ios.ts} +1 -1
  56. package/src/{android/observe.ts → observe/android.ts} +14 -26
  57. package/src/observe/index.ts +92 -0
  58. package/src/{ios/observe.ts → observe/ios.ts} +17 -35
  59. package/src/server.ts +23 -6
  60. package/src/{android → utils/android}/utils.ts +2 -2
  61. package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
  62. package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
  63. package/src/utils/diagnostics.ts +1 -1
  64. package/src/{ios → utils/ios}/utils.ts +2 -2
  65. package/src/utils/resolve-device.ts +2 -2
  66. package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
  67. package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
  68. package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
  69. package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
  70. package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
  71. package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
  72. package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
  73. package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
  74. package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
  75. package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
  76. package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
  77. package/test/observe/device/run-screen-fingerprint.ts +36 -0
  78. package/test/{device/observe → observe/device}/run-scroll-test-android.ts +2 -2
  79. package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
  80. package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
  81. package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
  82. package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
  83. package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
  84. package/test/{unit/observe → observe/unit}/scroll_to_element.test.ts +3 -3
  85. package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +2 -2
  86. package/test/unit/index.ts +12 -11
  87. package/src/tools/observe.ts +0 -82
  88. package/test/device/README.md +0 -49
  89. package/test/device/index.ts +0 -27
  90. package/test/device/utils/test-dist.ts +0 -41
  91. package/test/unit/utils/detect-java.test.ts +0 -22
  92. /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
  93. /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
  94. /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
  95. /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
  96. /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
  97. /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
  98. /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
@@ -0,0 +1,429 @@
1
+ import { spawn } from 'child_process';
2
+ import { promises as fsPromises, existsSync } from 'fs';
3
+ import path from 'path';
4
+ import { detectJavaHome } from '../java.js';
5
+ export function getAdbCmd() { return process.env.ADB_PATH || 'adb'; }
6
+ /**
7
+ * Prepare Gradle execution options for building an Android project.
8
+ * Returns execCmd (wrapper or gradle), base gradleArgs array, and spawn options including env.
9
+ */
10
+ export async function prepareGradle(projectPath) {
11
+ const gradlewPath = path.join(projectPath, 'gradlew');
12
+ const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle';
13
+ const execCmd = existsSync(gradlewPath) ? gradlewPath : gradleCmd;
14
+ // Start with a default task; callers may append/override via env flags
15
+ const gradleArgs = [process.env.MCP_GRADLE_TASK || 'assembleDebug'];
16
+ // Respect generic MCP_BUILD_JOBS and Android-specific MCP_GRADLE_WORKERS
17
+ const workers = process.env.MCP_GRADLE_WORKERS || process.env.MCP_BUILD_JOBS;
18
+ if (workers) {
19
+ gradleArgs.push(`--max-workers=${workers}`);
20
+ }
21
+ // Respect gradle cache env: default enabled; set MCP_GRADLE_CACHE=0 to disable
22
+ if (process.env.MCP_GRADLE_CACHE === '0') {
23
+ gradleArgs.push('-Dorg.gradle.caching=false');
24
+ }
25
+ const detectedJavaHome = await detectJavaHome().catch(() => undefined);
26
+ const env = Object.assign({}, process.env);
27
+ if (detectedJavaHome) {
28
+ if (env.JAVA_HOME !== detectedJavaHome) {
29
+ env.JAVA_HOME = detectedJavaHome;
30
+ env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`;
31
+ }
32
+ gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`);
33
+ gradleArgs.push('--no-daemon');
34
+ env.GRADLE_JAVA_HOME = detectedJavaHome;
35
+ }
36
+ try {
37
+ delete env.SHELL;
38
+ }
39
+ catch { }
40
+ const useWrapper = existsSync(gradlewPath);
41
+ const spawnOpts = { cwd: projectPath, env };
42
+ if (useWrapper) {
43
+ try {
44
+ await fsPromises.chmod(gradlewPath, 0o755);
45
+ }
46
+ catch { }
47
+ spawnOpts.shell = false;
48
+ }
49
+ else {
50
+ spawnOpts.shell = true;
51
+ }
52
+ return { execCmd, gradleArgs, spawnOpts };
53
+ }
54
+ // Helper to construct ADB args with optional device ID
55
+ function getAdbArgs(args, deviceId) {
56
+ if (deviceId) {
57
+ return ['-s', deviceId, ...args];
58
+ }
59
+ return args;
60
+ }
61
+ /**
62
+ * Determine an effective ADB timeout (ms) prioritizing:
63
+ * 1. provided customTimeout
64
+ * 2. MCP_ADB_TIMEOUT or ADB_TIMEOUT env vars
65
+ * 3. sensible per-command defaults
66
+ */
67
+ function getAdbTimeout(args, customTimeout) {
68
+ if (typeof customTimeout === 'number' && !isNaN(customTimeout))
69
+ return customTimeout;
70
+ const envTimeout = parseInt(process.env.MCP_ADB_TIMEOUT || process.env.ADB_TIMEOUT || '', 10);
71
+ if (!isNaN(envTimeout) && envTimeout > 0)
72
+ return envTimeout;
73
+ if (args.includes('logcat'))
74
+ return 10000;
75
+ if (args.includes('uiautomator') && args.includes('dump'))
76
+ return 20000;
77
+ return 120000;
78
+ }
79
+ export function execAdb(args, deviceId, options = {}) {
80
+ const adbArgs = getAdbArgs(args, deviceId);
81
+ return new Promise((resolve, reject) => {
82
+ // Extract timeout from options if present, otherwise pass options to spawn
83
+ const { timeout: customTimeout, ...spawnOptions } = options;
84
+ // Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
85
+ const child = spawn(getAdbCmd(), adbArgs, spawnOptions);
86
+ let stdout = '';
87
+ let stderr = '';
88
+ if (child.stdout) {
89
+ child.stdout.on('data', (data) => {
90
+ stdout += data.toString();
91
+ });
92
+ }
93
+ if (child.stderr) {
94
+ child.stderr.on('data', (data) => {
95
+ stderr += data.toString();
96
+ });
97
+ }
98
+ const timeoutMs = getAdbTimeout(args, customTimeout);
99
+ const timeout = setTimeout(() => {
100
+ child.kill();
101
+ reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`));
102
+ }, timeoutMs);
103
+ child.on('close', (code) => {
104
+ clearTimeout(timeout);
105
+ if (code !== 0) {
106
+ // If there's an actual error (non-zero exit code), reject
107
+ reject(new Error(stderr.trim() || `Command failed with code ${code}`));
108
+ }
109
+ else {
110
+ // If exit code is 0, resolve with stdout
111
+ resolve(stdout.trim());
112
+ }
113
+ });
114
+ child.on('error', (err) => {
115
+ clearTimeout(timeout);
116
+ reject(err);
117
+ });
118
+ });
119
+ }
120
+ // Spawn adb but return full streams and exit code so callers can implement fallbacks or stream output
121
+ export function spawnAdb(args, deviceId, options = {}) {
122
+ const adbArgs = getAdbArgs(args, deviceId);
123
+ return new Promise((resolve, reject) => {
124
+ const { timeout: customTimeout, ...spawnOptions } = options;
125
+ const child = spawn(getAdbCmd(), adbArgs, spawnOptions);
126
+ let stdout = '';
127
+ let stderr = '';
128
+ if (child.stdout)
129
+ child.stdout.on('data', d => { stdout += d.toString(); });
130
+ if (child.stderr)
131
+ child.stderr.on('data', d => { stderr += d.toString(); });
132
+ const timeoutMs = getAdbTimeout(args, customTimeout);
133
+ const timeout = setTimeout(() => {
134
+ try {
135
+ child.kill();
136
+ }
137
+ catch { }
138
+ reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`));
139
+ }, timeoutMs);
140
+ child.on('close', (code) => {
141
+ clearTimeout(timeout);
142
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code });
143
+ });
144
+ child.on('error', (err) => {
145
+ clearTimeout(timeout);
146
+ reject(err);
147
+ });
148
+ });
149
+ }
150
+ export function getDeviceInfo(deviceId, metadata = {}) {
151
+ return {
152
+ platform: 'android',
153
+ id: deviceId || 'default',
154
+ osVersion: metadata.osVersion || '',
155
+ model: metadata.model || '',
156
+ simulator: metadata.simulator || false
157
+ };
158
+ }
159
+ export async function getAndroidDeviceMetadata(appId, deviceId) {
160
+ try {
161
+ // If no deviceId provided, try to auto-detect a single connected device
162
+ let resolvedDeviceId = deviceId;
163
+ if (!resolvedDeviceId) {
164
+ try {
165
+ const devicesOutput = await execAdb(['devices']);
166
+ // Parse lines like: "<serial>\tdevice"
167
+ const lines = devicesOutput.split('\n').map(l => l.trim()).filter(Boolean);
168
+ const deviceLines = lines.slice(1) // skip header
169
+ .map(l => l.split('\t'))
170
+ .filter(parts => parts.length >= 2 && parts[1] === 'device')
171
+ .map(parts => parts[0]);
172
+ if (deviceLines.length === 1) {
173
+ resolvedDeviceId = deviceLines[0];
174
+ }
175
+ }
176
+ catch {
177
+ // ignore and continue without resolvedDeviceId
178
+ }
179
+ }
180
+ // Run these in parallel to avoid sequential timeouts
181
+ const [osVersion, model, simOutput] = await Promise.all([
182
+ execAdb(['shell', 'getprop', 'ro.build.version.release'], resolvedDeviceId).catch(() => ''),
183
+ execAdb(['shell', 'getprop', 'ro.product.model'], resolvedDeviceId).catch(() => ''),
184
+ execAdb(['shell', 'getprop', 'ro.kernel.qemu'], resolvedDeviceId).catch(() => '0')
185
+ ]);
186
+ const simulator = simOutput === '1';
187
+ return { platform: 'android', id: resolvedDeviceId || 'default', osVersion, model, simulator };
188
+ }
189
+ catch {
190
+ return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false };
191
+ }
192
+ }
193
+ export async function findApk(dir) {
194
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true }).catch(() => []);
195
+ for (const e of entries) {
196
+ const full = path.join(dir, e.name);
197
+ if (e.isDirectory()) {
198
+ const found = await findApk(full);
199
+ if (found)
200
+ return found;
201
+ }
202
+ else if (e.isFile() && full.endsWith('.apk')) {
203
+ return full;
204
+ }
205
+ }
206
+ return undefined;
207
+ }
208
+ export async function listAndroidDevices(appId) {
209
+ try {
210
+ const devicesOutput = await execAdb(['devices', '-l']);
211
+ const lines = devicesOutput.split('\n').map(l => l.trim()).filter(Boolean);
212
+ // Skip header if present (some adb versions include 'List of devices attached')
213
+ const deviceLines = lines.filter(l => !l.startsWith('List of devices')).map(l => l);
214
+ const serials = deviceLines.map(line => line.split(/\s+/)[0]).filter(Boolean);
215
+ const infos = await Promise.all(serials.map(async (serial) => {
216
+ try {
217
+ const [osVersion, model, simOutput] = await Promise.all([
218
+ execAdb(['shell', 'getprop', 'ro.build.version.release'], serial).catch(() => ''),
219
+ execAdb(['shell', 'getprop', 'ro.product.model'], serial).catch(() => ''),
220
+ execAdb(['shell', 'getprop', 'ro.kernel.qemu'], serial).catch(() => '0')
221
+ ]);
222
+ const simulator = simOutput === '1';
223
+ let appInstalled = false;
224
+ if (appId) {
225
+ try {
226
+ const pm = await execAdb(['shell', 'pm', 'path', appId], serial);
227
+ appInstalled = !!(pm && pm.includes('package:'));
228
+ }
229
+ catch {
230
+ appInstalled = false;
231
+ }
232
+ }
233
+ return { platform: 'android', id: serial, osVersion, model, simulator, appInstalled };
234
+ }
235
+ catch {
236
+ return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false };
237
+ }
238
+ }));
239
+ return infos;
240
+ }
241
+ catch {
242
+ return [];
243
+ }
244
+ }
245
+ // UI helper utilities shared by observe/interact
246
+ export const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
247
+ export function parseBounds(bounds) {
248
+ const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
249
+ if (match) {
250
+ return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4])];
251
+ }
252
+ return [0, 0, 0, 0];
253
+ }
254
+ export function getCenter(bounds) {
255
+ const [x1, y1, x2, y2] = bounds;
256
+ return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
257
+ }
258
+ export async function getScreenResolution(deviceId) {
259
+ try {
260
+ const output = await execAdb(['shell', 'wm', 'size'], deviceId);
261
+ const match = output.match(/Physical size: (\d+)x(\d+)/);
262
+ if (match) {
263
+ return { width: parseInt(match[1]), height: parseInt(match[2]) };
264
+ }
265
+ }
266
+ catch {
267
+ // ignore
268
+ }
269
+ return { width: 0, height: 0 };
270
+ }
271
+ export function traverseNode(node, elements, parentIndex = -1, depth = 0) {
272
+ if (!node)
273
+ return -1;
274
+ let currentIndex = -1;
275
+ if (node['@_class']) {
276
+ const text = node['@_text'] || null;
277
+ const contentDescription = node['@_content-desc'] || null;
278
+ const clickable = node['@_clickable'] === 'true';
279
+ const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
280
+ const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
281
+ if (isUseful) {
282
+ const element = {
283
+ text,
284
+ contentDescription,
285
+ type: node['@_class'] || 'unknown',
286
+ resourceId: node['@_resource-id'] || null,
287
+ clickable,
288
+ enabled: node['@_enabled'] === 'true',
289
+ visible: true,
290
+ bounds,
291
+ center: getCenter(bounds),
292
+ depth
293
+ };
294
+ if (parentIndex !== -1) {
295
+ element.parentId = parentIndex;
296
+ }
297
+ elements.push(element);
298
+ currentIndex = elements.length - 1;
299
+ }
300
+ }
301
+ const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
302
+ const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
303
+ const childrenIndices = [];
304
+ if (node.node) {
305
+ if (Array.isArray(node.node)) {
306
+ node.node.forEach((child) => {
307
+ const childIndex = traverseNode(child, elements, nextParentIndex, nextDepth);
308
+ if (childIndex !== -1)
309
+ childrenIndices.push(childIndex);
310
+ });
311
+ }
312
+ else {
313
+ const childIndex = traverseNode(node.node, elements, nextParentIndex, nextDepth);
314
+ if (childIndex !== -1)
315
+ childrenIndices.push(childIndex);
316
+ }
317
+ }
318
+ if (currentIndex !== -1 && childrenIndices.length > 0) {
319
+ elements[currentIndex].children = childrenIndices;
320
+ }
321
+ return currentIndex;
322
+ }
323
+ // Log stream management (one stream per session)
324
+ // (Legacy active stream map removed from utils during refactor; Observe modules manage their own active streams.)
325
+ // Robust log line parser supporting multiple logcat formats
326
+ export function parseLogLine(line) {
327
+ const rawLine = line;
328
+ const normalizedLine = rawLine.replace(/\r?\n/g, ' ');
329
+ const entry = { timestamp: '', level: '', tag: '', message: rawLine, _iso: null, crash: false };
330
+ const nowYear = new Date().getFullYear();
331
+ const tryIso = (ts) => {
332
+ if (!ts)
333
+ return null;
334
+ // If it's already ISO
335
+ if (/^\d{4}-\d{2}-\d{2}T/.test(ts))
336
+ return ts;
337
+ // If format MM-DD HH:MM:SS(.sss)
338
+ const m = ts.match(/^(\d{2})-(\d{2})\s+(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)$/);
339
+ if (m) {
340
+ const month = m[1];
341
+ const day = m[2];
342
+ const time = m[3];
343
+ const candidate = `${nowYear}-${month}-${day}T${time}`;
344
+ const d = new Date(candidate);
345
+ if (!isNaN(d.getTime()))
346
+ return d.toISOString();
347
+ }
348
+ // If format YYYY-MM-DD HH:MM:SS(.sss)
349
+ const m2 = ts.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)$/);
350
+ if (m2) {
351
+ const candidate = `${m2[1]}T${m2[2]}`;
352
+ const d = new Date(candidate);
353
+ if (!isNaN(d.getTime()))
354
+ return d.toISOString();
355
+ }
356
+ return null;
357
+ };
358
+ // Patterns to try (ordered)
359
+ const patterns = [
360
+ // MM-DD HH:MM:SS.mmm PID TID LEVEL/Tag: msg
361
+ { re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
362
+ // MM-DD HH:MM:SS.mmm PID TID LEVEL Tag: msg (space between level and tag)
363
+ { re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
364
+ // YYYY-MM-DD full date with PID TID LEVEL/Tag
365
+ { re: /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
366
+ // YYYY-MM-DD with space separation
367
+ { re: /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
368
+ // MM-DD PID LEVEL/Tag: msg
369
+ { re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'level', 'tag', 'msg'] },
370
+ // MM-DD PID LEVEL Tag: msg (space)
371
+ { re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'level', 'tag', 'msg'] },
372
+ // Short form LEVEL/Tag: msg
373
+ { re: /^([VDIWE])\/([^\(\:]+)(?:\([0-9]+\))?:\s*(.*)$/, groups: ['level', 'tag', 'msg'] },
374
+ // Short form LEVEL Tag: msg
375
+ { re: /^([VDIWE])\s+([^\(\:]+)(?:\([0-9]+\))?:\s*(.*)$/, groups: ['level', 'tag', 'msg'] },
376
+ ];
377
+ for (const p of patterns) {
378
+ const m = normalizedLine.match(p.re);
379
+ if (m) {
380
+ const g = p.groups;
381
+ const vals = {};
382
+ for (let i = 0; i < g.length; i++)
383
+ vals[g[i]] = m[i + 1];
384
+ const ts = vals.ts;
385
+ if (ts) {
386
+ const iso = tryIso(ts);
387
+ if (iso) {
388
+ entry.timestamp = ts;
389
+ entry._iso = iso;
390
+ }
391
+ else {
392
+ entry.timestamp = ts;
393
+ }
394
+ }
395
+ if (vals.level)
396
+ entry.level = vals.level;
397
+ if (vals.tag)
398
+ entry.tag = vals.tag.trim();
399
+ entry.message = vals.msg || entry.message;
400
+ // Crash heuristics
401
+ const msg = (entry.message || '').toString();
402
+ const crash = /FATAL EXCEPTION/i.test(msg) || /\b([A-Za-z0-9_$.]+Exception)\b/.test(msg);
403
+ if (crash) {
404
+ entry.crash = true;
405
+ const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/);
406
+ if (exMatch)
407
+ entry.exception = exMatch[1];
408
+ }
409
+ return entry;
410
+ }
411
+ }
412
+ // No pattern matched: attempt to extract level/tag like '... E/Tag: msg'
413
+ const alt = normalizedLine.match(/([VDIWE])\/([^:]+):\s*(.*)$/);
414
+ if (alt) {
415
+ entry.level = alt[1];
416
+ entry.tag = alt[2].trim();
417
+ entry.message = alt[3];
418
+ const msg = entry.message;
419
+ const crash = /FATAL EXCEPTION/i.test(msg) || /\b([A-Za-z0-9_$.]+Exception)\b/.test(msg);
420
+ if (crash) {
421
+ entry.crash = true;
422
+ const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/);
423
+ if (exMatch)
424
+ entry.exception = exMatch[1];
425
+ }
426
+ }
427
+ return entry;
428
+ }
429
+ // Legacy readLogStreamLines shim removed. Use AndroidObserve.readLogStream(sessionId, limit, since) instead.
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ import { execSync, spawnSync } from 'child_process';
3
+ import { main as installMain } from './install-idb.js';
4
+ import { getIdbCmd, isIDBInstalled } from './idb-helper.js';
5
+ function which(cmd) {
6
+ try {
7
+ const r = spawnSync('command', ['-v', cmd], { stdio: ['ignore', 'pipe', 'ignore'] });
8
+ if (r && r.status === 0 && r.stdout)
9
+ return r.stdout.toString().trim();
10
+ }
11
+ catch { }
12
+ try {
13
+ return execSync(`which ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function print(...args) {
20
+ console.log(...args);
21
+ }
22
+ async function runInstaller() {
23
+ try {
24
+ // prefer invoking the TS script via npx/tsx to ensure environment
25
+ const runner = which('npx') ? 'npx' : which('tsx') ? 'tsx' : null;
26
+ if (runner) {
27
+ const args = runner === 'npx' ? ['tsx', './src/cli/idb/install-idb.ts'] : ['./src/cli/idb/install-idb.ts'];
28
+ const res = spawnSync(runner, args, { stdio: 'inherit' });
29
+ return typeof res.status === 'number' ? res.status === 0 : false;
30
+ }
31
+ // fallback: attempt to import and run the installer directly (may rely on ts-node/tsx)
32
+ try {
33
+ // call the exported main; it returns a promise
34
+ await installMain();
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ catch (e) {
42
+ console.error('Failed to run installer:', e instanceof Error ? e.message : String(e));
43
+ return false;
44
+ }
45
+ }
46
+ try {
47
+ print('PATH=', process.env.PATH);
48
+ const idb = process.env.IDB_PATH || getIdbCmd();
49
+ print('idb:', idb);
50
+ if (idb && isIDBInstalled()) {
51
+ try {
52
+ print('idb --version:', execSync(`${idb} --version`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim());
53
+ }
54
+ catch (e) {
55
+ print('idb --version: (failed)', e instanceof Error ? e.message : String(e));
56
+ }
57
+ const companion = which('idb_companion');
58
+ print('which idb_companion:', companion);
59
+ if (companion)
60
+ try {
61
+ print('idb_companion --version:', execSync('idb_companion --version', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim());
62
+ }
63
+ catch (e) {
64
+ print('idb_companion --version: (failed)', e instanceof Error ? e.message : String(e));
65
+ }
66
+ process.exit(0);
67
+ }
68
+ print('idb not found or not responding');
69
+ const auto = process.env.MCP_AUTO_INSTALL_IDB === 'true';
70
+ if (auto) {
71
+ print('MCP_AUTO_INSTALL_IDB=true, attempting installer...');
72
+ const ok = await runInstaller();
73
+ if (ok)
74
+ process.exit(0);
75
+ print('Installer failed or did not produce idb');
76
+ process.exit(2);
77
+ }
78
+ print('Set MCP_AUTO_INSTALL_IDB=true to attempt automatic installation (CI-friendly).');
79
+ process.exit(2);
80
+ }
81
+ catch (e) {
82
+ console.error('idb healthcheck failed:', e instanceof Error ? e.message : String(e));
83
+ process.exit(2);
84
+ }
@@ -0,0 +1,91 @@
1
+ import { execSync, spawnSync } from 'child_process';
2
+ import fs from 'fs';
3
+ export function getConfiguredIdbPath() {
4
+ if (process.env.MCP_IDB_PATH)
5
+ return process.env.MCP_IDB_PATH;
6
+ if (process.env.IDB_PATH)
7
+ return process.env.IDB_PATH;
8
+ const cfgPaths = [
9
+ process.env.MCP_CONFIG_PATH || (process.env.HOME ? `${process.env.HOME}/.mcp/config.json` : ''),
10
+ `${process.cwd()}/mcp.config.json`
11
+ ];
12
+ for (const p of cfgPaths) {
13
+ if (!p)
14
+ continue;
15
+ try {
16
+ if (fs.existsSync(p)) {
17
+ const raw = fs.readFileSync(p, 'utf8');
18
+ const json = JSON.parse(raw);
19
+ if (json) {
20
+ if (json.idbPath)
21
+ return json.idbPath;
22
+ if (json.IDB_PATH)
23
+ return json.IDB_PATH;
24
+ }
25
+ }
26
+ }
27
+ catch { }
28
+ }
29
+ return undefined;
30
+ }
31
+ export function commandWhich(cmd) {
32
+ try {
33
+ const r = spawnSync('command', ['-v', cmd], { stdio: ['ignore', 'pipe', 'ignore'] });
34
+ if (r && r.status === 0 && r.stdout)
35
+ return r.stdout.toString().trim();
36
+ }
37
+ catch { }
38
+ try {
39
+ const p = execSync(`which ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
40
+ if (p)
41
+ return p;
42
+ }
43
+ catch { }
44
+ return null;
45
+ }
46
+ export function getIdbCmd() {
47
+ const cfg = getConfiguredIdbPath();
48
+ if (cfg)
49
+ return cfg;
50
+ if (process.env.IDB_PATH)
51
+ return process.env.IDB_PATH;
52
+ // Prefer command -v/which
53
+ const found = commandWhich('idb');
54
+ if (found)
55
+ return found;
56
+ // Common locations
57
+ const common = [
58
+ process.env.HOME ? `${process.env.HOME}/Library/Python/3.9/bin/idb` : '',
59
+ process.env.HOME ? `${process.env.HOME}/Library/Python/3.10/bin/idb` : '',
60
+ '/opt/homebrew/bin/idb',
61
+ '/usr/local/bin/idb'
62
+ ];
63
+ for (const c of common) {
64
+ if (!c)
65
+ continue;
66
+ try {
67
+ execSync(`test -x ${c}`, { stdio: ['ignore', 'pipe', 'ignore'] });
68
+ return c;
69
+ }
70
+ catch { }
71
+ }
72
+ return null;
73
+ }
74
+ export function isIDBInstalled() {
75
+ const cmd = getIdbCmd();
76
+ if (!cmd)
77
+ return false;
78
+ try {
79
+ // command -v <cmd>
80
+ const r = spawnSync('command', ['-v', cmd], { stdio: ['ignore', 'pipe', 'ignore'] });
81
+ if (r && r.status === 0)
82
+ return true;
83
+ }
84
+ catch { }
85
+ try {
86
+ execSync(`${cmd} list-targets --json`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 });
87
+ return true;
88
+ }
89
+ catch { }
90
+ return false;
91
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'child_process';
3
+ import readline from 'readline';
4
+ import { getIdbCmd, isIDBInstalled, commandWhich } from './idb-helper.js';
5
+ const IDB_PKG = 'fb-idb';
6
+ function runCommand(cmd, args) {
7
+ const res = spawnSync(cmd, args, { stdio: 'inherit' });
8
+ return typeof res.status === 'number' ? res.status : 1;
9
+ }
10
+ async function confirm(prompt) {
11
+ if (!process.stdin.isTTY)
12
+ return false;
13
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
14
+ return new Promise((resolve) => {
15
+ rl.question(`${prompt} (y/N): `, (ans) => {
16
+ rl.close();
17
+ resolve(ans.trim().toLowerCase() === 'y');
18
+ });
19
+ });
20
+ }
21
+ async function main() {
22
+ try {
23
+ const idbFromEnv = process.env.IDB_PATH;
24
+ const existing = idbFromEnv || getIdbCmd();
25
+ if (existing && isIDBInstalled()) {
26
+ console.log('idb already available at:', existing);
27
+ process.exit(0);
28
+ }
29
+ const auto = process.env.MCP_AUTO_INSTALL_IDB === 'true' || process.env.CI === 'true';
30
+ if (!auto) {
31
+ const ok = await confirm('idb not found. Attempt to install fb-idb now?');
32
+ if (!ok) {
33
+ console.log('Aborting install; set MCP_AUTO_INSTALL_IDB=true to auto-install in CI or non-interactive environments.');
34
+ process.exit(2);
35
+ }
36
+ }
37
+ else {
38
+ console.log('Auto-install enabled (MCP_AUTO_INSTALL_IDB=true or CI=true)');
39
+ }
40
+ const attempts = [];
41
+ if (commandWhich('pipx'))
42
+ attempts.push({ name: 'pipx', cmd: 'pipx', args: ['install', IDB_PKG] });
43
+ if (commandWhich('pip') || commandWhich('python3'))
44
+ attempts.push({ name: 'pip', cmd: commandWhich('pip') ? 'pip' : 'python3', args: commandWhich('pip') ? ['install', '--user', IDB_PKG] : ['-m', 'pip', 'install', '--user', IDB_PKG] });
45
+ // Add brew as a fallback on macOS if present (best-effort)
46
+ if (process.platform === 'darwin' && commandWhich('brew')) {
47
+ attempts.push({ name: 'brew', cmd: 'brew', args: ['install', 'idb'] });
48
+ }
49
+ if (attempts.length === 0) {
50
+ console.error('No installer tool (pipx/pip/brew) detected. Please install pipx or pip and re-run.');
51
+ process.exit(2);
52
+ }
53
+ for (const a of attempts) {
54
+ console.log(`Attempting install with ${a.name}: ${a.cmd} ${a.args.join(' ')}`);
55
+ try {
56
+ const code = runCommand(a.cmd, a.args);
57
+ if (code !== 0) {
58
+ console.warn(`${a.name} install exited with code ${code}`);
59
+ }
60
+ }
61
+ catch (e) {
62
+ console.warn(`${a.name} install failed: ${e instanceof Error ? e.message : String(e)}`);
63
+ }
64
+ const found = commandWhich('idb') || commandWhich('command -v idb');
65
+ if (found) {
66
+ console.log('idb installed at:', found);
67
+ process.exit(0);
68
+ }
69
+ }
70
+ console.error('idb was not installed by any installer tried. Please install fb-idb manually and re-run healthcheck.');
71
+ process.exit(2);
72
+ }
73
+ catch (e) {
74
+ console.error('Installer failed:', e instanceof Error ? e.message : String(e));
75
+ process.exit(2);
76
+ }
77
+ }
78
+ const scriptPath = new URL(import.meta.url).pathname;
79
+ if (scriptPath === process.argv[1]) {
80
+ main().catch(e => { console.error('Installer failed:', e instanceof Error ? e.message : String(e)); process.exit(2); });
81
+ }
82
+ export { main };