mobile-debug-mcp 0.11.0 → 0.12.1

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 (61) hide show
  1. package/README.md +10 -5
  2. package/dist/android/diagnostics.js +24 -0
  3. package/dist/android/manage.js +33 -8
  4. package/dist/android/observe.js +4 -4
  5. package/dist/android/utils.js +3 -3
  6. package/dist/cli/idb/check-idb.js +84 -0
  7. package/dist/cli/idb/idb-helper.js +91 -0
  8. package/dist/cli/idb/install-idb.js +82 -0
  9. package/dist/cli/ios/preflight-ios.js +155 -0
  10. package/dist/cli/ios/run-ios-smoke.js +28 -0
  11. package/dist/cli/ios/run-ios-ui-tree-tap.js +29 -0
  12. package/dist/ios/interact.js +4 -8
  13. package/dist/ios/manage.js +177 -45
  14. package/dist/ios/observe.js +24 -15
  15. package/dist/ios/utils.js +121 -8
  16. package/dist/server.js +18 -0
  17. package/dist/utils/diagnostics.js +25 -0
  18. package/docs/CHANGELOG.md +19 -0
  19. package/eslint.config.js +21 -1
  20. package/package.json +10 -5
  21. package/src/android/diagnostics.ts +23 -0
  22. package/src/android/manage.ts +30 -8
  23. package/src/android/observe.ts +4 -4
  24. package/src/android/utils.ts +3 -3
  25. package/src/cli/idb/check-idb.ts +73 -0
  26. package/src/cli/idb/idb-helper.ts +75 -0
  27. package/src/cli/idb/install-idb.ts +90 -0
  28. package/src/cli/ios/preflight-ios.ts +144 -0
  29. package/src/cli/ios/run-ios-smoke.ts +34 -0
  30. package/src/cli/ios/run-ios-ui-tree-tap.ts +33 -0
  31. package/src/ios/interact.ts +4 -8
  32. package/src/ios/manage.ts +202 -64
  33. package/src/ios/observe.ts +24 -16
  34. package/src/ios/utils.ts +109 -8
  35. package/src/server.ts +19 -0
  36. package/src/types.ts +9 -0
  37. package/src/utils/diagnostics.ts +36 -0
  38. package/test/device/README.md +49 -0
  39. package/test/device/index.ts +27 -0
  40. package/test/device/manage/run-build-install-ios.ts +82 -0
  41. package/test/{integration → device/manage}/run-install-android.ts +4 -4
  42. package/test/{integration → device/manage}/run-install-ios.ts +4 -4
  43. package/test/{integration → device/utils}/test-dist.ts +2 -2
  44. package/test/unit/index.ts +10 -6
  45. package/test/unit/{build.test.ts → manage/build.test.ts} +16 -17
  46. package/test/unit/{build_and_install.test.ts → manage/build_and_install.test.ts} +20 -18
  47. package/test/unit/manage/diagnostics.test.ts +85 -0
  48. package/test/unit/{install.test.ts → manage/install.test.ts} +26 -17
  49. package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
  50. package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +2 -2
  51. package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
  52. package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
  53. package/tsconfig.json +2 -1
  54. package/test/integration/index.ts +0 -8
  55. package/test/integration/test-dist.mjs +0 -41
  56. /package/test/{integration → device/interact}/run-real-test.ts +0 -0
  57. /package/test/{integration → device/interact}/smoke-test.ts +0 -0
  58. /package/test/{integration → device/manage}/install.integration.ts +0 -0
  59. /package/test/{integration → device/observe}/logstream-real.ts +0 -0
  60. /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
  61. /package/test/{integration → device/observe}/wait_for_element_real.ts +0 -0
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
- # Mobile Debug MCP
1
+ # Mobile Dev MCP
2
2
 
3
3
  A minimal, secure MCP server for AI-assisted mobile development. Build, install, and inspect Android/iOS apps from an MCP-compatible client.
4
4
 
5
- > **Note:** iOS support is currently an untested Work In Progress (WIP). Please use with caution and report any issues.
5
+ > **Note:** iOS support is limited currently, Please use with caution and report any issues.
6
6
 
7
7
  ## Requirements
8
8
 
9
9
  - Node.js >= 18
10
- - Android SDK (adb) for Android support
10
+ - [Android SDK](https://developer.android.com/studio) (adb) for Android support
11
11
  - Xcode command-line tools for iOS support
12
- - Optional: idb for enhanced iOS device support
12
+ - [idb](https://github.com/facebook/idb) for iOS device support
13
13
 
14
14
  ## Configuration example
15
15
 
@@ -19,12 +19,16 @@ A minimal, secure MCP server for AI-assisted mobile development. Build, install,
19
19
  "mobile-debug": {
20
20
  "command": "npx",
21
21
  "args": ["--yes","mobile-debug-mcp","server"],
22
- "env": { "ADB_PATH": "/path/to/adb", "XCRUN_PATH": "/usr/bin/xcrun" }
22
+ "env": { "ADB_PATH": "/path/to/adb", "XCRUN_PATH": "/usr/bin/xcrun", "IDB_PATH": "/path/to/idb" }
23
23
  }
24
24
  }
25
25
  }
26
26
  ```
27
27
  ## Usage
28
+
29
+ Example:
30
+ After a crash tell the agent the following:
31
+
28
32
  I have a crash on the app, can you diagnose it, fix and validate using the mcp tools available
29
33
 
30
34
  ## Docs
@@ -35,3 +39,4 @@ I have a crash on the app, can you diagnose it, fix and validate using the mcp t
35
39
  ## License
36
40
 
37
41
  MIT
42
+
@@ -0,0 +1,24 @@
1
+ import { spawnSync } from 'child_process';
2
+ import { getAdbCmd } from './utils.js';
3
+ import { makeEnvSnapshot } from '../utils/diagnostics.js';
4
+ export function execAdbWithDiagnostics(args, deviceId) {
5
+ const adbArgs = deviceId ? ['-s', deviceId, ...args] : args;
6
+ const timeout = 120000;
7
+ const res = spawnSync(getAdbCmd(), adbArgs, { encoding: 'utf8', timeout });
8
+ const runResult = {
9
+ exitCode: typeof res.status === 'number' ? res.status : null,
10
+ stdout: res.stdout || '',
11
+ stderr: res.stderr || '',
12
+ envSnapshot: makeEnvSnapshot(['PATH', 'ADB_PATH', 'HOME', 'JAVA_HOME']),
13
+ command: getAdbCmd(),
14
+ args: adbArgs,
15
+ suggestedFixes: []
16
+ };
17
+ if (res.status !== 0) {
18
+ if ((runResult.stderr || '').includes('device not found'))
19
+ runResult.suggestedFixes.push('Ensure device is connected and adb is authorized (adb devices)');
20
+ if ((runResult.stderr || '').includes('No such file or directory'))
21
+ runResult.suggestedFixes.push('Verify ADB_PATH or that adb is installed');
22
+ }
23
+ return { runResult };
24
+ }
@@ -3,6 +3,7 @@ import { spawn } from 'child_process';
3
3
  import path from 'path';
4
4
  import { existsSync } from 'fs';
5
5
  import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from './utils.js';
6
+ import { execAdbWithDiagnostics } from './diagnostics.js';
6
7
  import { detectJavaHome } from '../utils/java.js';
7
8
  export class AndroidManage {
8
9
  async build(projectPath, _variant) {
@@ -34,8 +35,8 @@ export class AndroidManage {
34
35
  async installApp(apkPath, deviceId) {
35
36
  const metadata = await getAndroidDeviceMetadata('', deviceId);
36
37
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
38
+ let apkToInstall = apkPath;
37
39
  try {
38
- let apkToInstall = apkPath;
39
40
  const stat = await fs.stat(apkPath).catch(() => null);
40
41
  if (stat && stat.isDirectory()) {
41
42
  const detectedJavaHome = await detectJavaHome().catch(() => undefined);
@@ -104,20 +105,38 @@ export class AndroidManage {
104
105
  return { device: deviceInfo, installed: true, output: pmOut };
105
106
  }
106
107
  catch (e) {
107
- return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) };
108
+ // gather diagnostics for attempted adb operations
109
+ const basename = path.basename(apkToInstall);
110
+ const remotePath = `/data/local/tmp/${basename}`;
111
+ const installDiag = execAdbWithDiagnostics(['install', '-r', apkToInstall], deviceId);
112
+ const pushDiag = execAdbWithDiagnostics(['push', apkToInstall, remotePath], deviceId);
113
+ const pmDiag = execAdbWithDiagnostics(['shell', 'pm', 'install', '-r', remotePath], deviceId);
114
+ return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: { installDiag, pushDiag, pmDiag } };
108
115
  }
109
116
  }
110
117
  async startApp(appId, deviceId) {
111
118
  const metadata = await getAndroidDeviceMetadata(appId, deviceId);
112
119
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
113
- await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
114
- return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
120
+ try {
121
+ await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
122
+ return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
123
+ }
124
+ catch (e) {
125
+ const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
126
+ return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
127
+ }
115
128
  }
116
129
  async terminateApp(appId, deviceId) {
117
130
  const metadata = await getAndroidDeviceMetadata(appId, deviceId);
118
131
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
119
- await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
120
- return { device: deviceInfo, appTerminated: true };
132
+ try {
133
+ await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
134
+ return { device: deviceInfo, appTerminated: true };
135
+ }
136
+ catch (e) {
137
+ const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId);
138
+ return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
139
+ }
121
140
  }
122
141
  async restartApp(appId, deviceId) {
123
142
  await this.terminateApp(appId, deviceId);
@@ -131,7 +150,13 @@ export class AndroidManage {
131
150
  async resetAppData(appId, deviceId) {
132
151
  const metadata = await getAndroidDeviceMetadata(appId, deviceId);
133
152
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
134
- const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
135
- return { device: deviceInfo, dataCleared: output === 'Success' };
153
+ try {
154
+ const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
155
+ return { device: deviceInfo, dataCleared: output === 'Success' };
156
+ }
157
+ catch (e) {
158
+ const diag = execAdbWithDiagnostics(['shell', 'pm', 'clear', appId], deviceId);
159
+ return { device: deviceInfo, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
160
+ }
136
161
  }
137
162
  }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "child_process";
2
2
  import { XMLParser } from "fast-xml-parser";
3
- import { ADB, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "./utils.js";
3
+ import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "./utils.js";
4
4
  import { createWriteStream } from "fs";
5
5
  import { promises as fsPromises } from "fs";
6
6
  import path from "path";
@@ -67,7 +67,7 @@ export class AndroidObserve {
67
67
  };
68
68
  }
69
69
  catch (e) {
70
- const errorMessage = `Failed to get UI tree. ADB Path: '${ADB}'. Error: ${e instanceof Error ? e.message : String(e)}`;
70
+ const errorMessage = `Failed to get UI tree. ADB Path: '${getAdbCmd()}'. Error: ${e instanceof Error ? e.message : String(e)}`;
71
71
  console.error(errorMessage);
72
72
  return {
73
73
  device: deviceInfo,
@@ -115,7 +115,7 @@ export class AndroidObserve {
115
115
  // Need to construct ADB args manually since spawn handles it
116
116
  const args = deviceId ? ['-s', deviceId, 'exec-out', 'screencap', '-p'] : ['exec-out', 'screencap', '-p'];
117
117
  // Using spawn for screencap as well to ensure consistent process handling
118
- const child = spawn(ADB, args);
118
+ const child = spawn(getAdbCmd(), args);
119
119
  const chunks = [];
120
120
  let stderr = '';
121
121
  child.stdout.on('data', (chunk) => {
@@ -245,7 +245,7 @@ export class AndroidObserve {
245
245
  activeLogStreams.delete(sessionId);
246
246
  }
247
247
  const args = ['logcat', `--pid=${pid}`, filter];
248
- const proc = spawn(ADB, args);
248
+ const proc = spawn(getAdbCmd(), args);
249
249
  const tmpDir = process.env.TMPDIR || '/tmp';
250
250
  const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`);
251
251
  const stream = createWriteStream(file, { flags: 'a' });
@@ -2,7 +2,7 @@ import { spawn } from 'child_process';
2
2
  import { promises as fsPromises, existsSync } from 'fs';
3
3
  import path from 'path';
4
4
  import { detectJavaHome } from '../utils/java.js';
5
- export const ADB = process.env.ADB_PATH || 'adb';
5
+ export function getAdbCmd() { return process.env.ADB_PATH || 'adb'; }
6
6
  /**
7
7
  * Prepare Gradle execution options for building an Android project.
8
8
  * Returns execCmd (wrapper or gradle), base gradleArgs array, and spawn options including env.
@@ -72,7 +72,7 @@ export function execAdb(args, deviceId, options = {}) {
72
72
  // Extract timeout from options if present, otherwise pass options to spawn
73
73
  const { timeout: customTimeout, ...spawnOptions } = options;
74
74
  // Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
75
- const child = spawn(ADB, adbArgs, spawnOptions);
75
+ const child = spawn(getAdbCmd(), adbArgs, spawnOptions);
76
76
  let stdout = '';
77
77
  let stderr = '';
78
78
  if (child.stdout) {
@@ -112,7 +112,7 @@ export function spawnAdb(args, deviceId, options = {}) {
112
112
  const adbArgs = getAdbArgs(args, deviceId);
113
113
  return new Promise((resolve, reject) => {
114
114
  const { timeout: customTimeout, ...spawnOptions } = options;
115
- const child = spawn(ADB, adbArgs, spawnOptions);
115
+ const child = spawn(getAdbCmd(), adbArgs, spawnOptions);
116
116
  let stdout = '';
117
117
  let stderr = '';
118
118
  if (child.stdout)
@@ -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 };
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { spawn } from 'child_process';
5
+ import { getIdbCmd, isIDBInstalled, commandWhich } from '../idb/idb-helper.js';
6
+ async function exists(p) {
7
+ try {
8
+ await fs.promises.access(p);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ async function findFirst(root, patterns, maxDepth = 4) {
16
+ const queue = [{ dir: root, depth: 0 }];
17
+ while (queue.length) {
18
+ const { dir, depth } = queue.shift();
19
+ try {
20
+ console.error('DEBUG findFirst: reading dir', dir, 'depth', depth);
21
+ const ents = await fs.promises.readdir(dir, { withFileTypes: true });
22
+ console.error('DEBUG findFirst: entries', ents.map(e => e.name));
23
+ for (const e of ents) {
24
+ const full = path.join(dir, e.name);
25
+ for (const p of patterns) {
26
+ if (e.name.endsWith(p)) {
27
+ console.error('DEBUG findFirst: matched', full);
28
+ return full;
29
+ }
30
+ }
31
+ if (e.isDirectory() && depth < maxDepth)
32
+ queue.push({ dir: full, depth: depth + 1 });
33
+ }
34
+ }
35
+ catch (err) {
36
+ console.error('DEBUG findFirst: read failed for', dir, err);
37
+ // ignore
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ function startCompanionIfNeeded(companionPath, udid) {
43
+ if (!companionPath || !udid)
44
+ return { started: false, error: 'missing companion or udid' };
45
+ try {
46
+ const child = spawn(companionPath, ['--udid', udid], { detached: true, stdio: 'ignore' });
47
+ child.unref();
48
+ return { started: true };
49
+ }
50
+ catch (e) {
51
+ return { started: false, error: e.message };
52
+ }
53
+ }
54
+ async function main() {
55
+ const args = process.argv.slice(2);
56
+ let projectArg;
57
+ let udid;
58
+ let startCompanion = false;
59
+ let kmpBuild = null;
60
+ for (let i = 0; i < args.length; i++) {
61
+ const a = args[i];
62
+ if (a === '--project' && args[i + 1]) {
63
+ projectArg = args[i + 1];
64
+ i++;
65
+ }
66
+ else if (a === '--udid' && args[i + 1]) {
67
+ udid = args[i + 1];
68
+ i++;
69
+ }
70
+ else if (a === '--start-companion')
71
+ startCompanion = true;
72
+ else if (!projectArg)
73
+ projectArg = a;
74
+ }
75
+ const cwd = process.cwd();
76
+ const projectRoot = projectArg ? path.resolve(projectArg) : cwd;
77
+ // If user passed a direct .xcodeproj or .xcworkspace path, accept it as the projectFile
78
+ let projectFile = null;
79
+ try {
80
+ const stat = await fs.promises.stat(projectRoot);
81
+ if (stat.isFile() && (projectRoot.endsWith('.xcodeproj') || projectRoot.endsWith('.xcworkspace'))) {
82
+ projectFile = projectRoot;
83
+ }
84
+ }
85
+ catch {
86
+ // ignore
87
+ }
88
+ // detect project if not a direct file
89
+ if (!projectFile) {
90
+ const projectFound = await exists(projectRoot);
91
+ if (projectFound) {
92
+ projectFile = await findFirst(projectRoot, ['.xcworkspace', '.xcodeproj'], 2);
93
+ }
94
+ else {
95
+ // attempt to find under cwd
96
+ projectFile = await findFirst(cwd, ['.xcworkspace', '.xcodeproj'], 3);
97
+ }
98
+ }
99
+ // 2) KMP Shared.framework detection (search for *.framework named Shared.framework)
100
+ let kmpFramework = null;
101
+ const projectSearchRoot = projectFile ? (fs.existsSync(projectFile) && fs.lstatSync(projectFile).isDirectory() ? projectFile : path.dirname(projectFile)) : cwd;
102
+ kmpFramework = await findFirst(projectSearchRoot, ['Shared.framework'], 5);
103
+ if (!kmpFramework)
104
+ kmpFramework = await findFirst(cwd, ['Shared.framework'], 6);
105
+ // 3) idb detection
106
+ const idbPath = getIdbCmd();
107
+ const idbAvailable = idbPath ? isIDBInstalled() : false;
108
+ // 4) idb_companion
109
+ const companionPath = commandWhich('idb_companion');
110
+ const companionAvailable = !!companionPath;
111
+ const suggestions = [];
112
+ if (!projectFile)
113
+ suggestions.push('Provide correct project path or ensure .xcodeproj/.xcworkspace exists in project dir');
114
+ if (!kmpFramework)
115
+ suggestions.push('Run KMP Gradle task to produce Shared.framework before xcodebuild (e.g., :shared:embedAndSignAppleFrameworkForXcode)');
116
+ if (!idbAvailable)
117
+ suggestions.push('Ensure idb is in PATH or set MCP_IDB_PATH / IDB_PATH');
118
+ if (!companionAvailable)
119
+ suggestions.push('Install idb_companion and ensure it is in PATH');
120
+ const result = {
121
+ ok: !!projectFile && !!idbAvailable,
122
+ project: {
123
+ root: projectRoot,
124
+ found: !!projectFile,
125
+ projectFile: projectFile
126
+ },
127
+ kmp: {
128
+ found: !!kmpFramework,
129
+ path: kmpFramework,
130
+ build: kmpBuild
131
+ },
132
+ idb: {
133
+ cmd: idbPath,
134
+ installed: idbAvailable
135
+ },
136
+ idb_companion: {
137
+ cmd: companionPath,
138
+ installed: companionAvailable
139
+ },
140
+ suggestions
141
+ };
142
+ if (startCompanion && udid) {
143
+ const started = startCompanionIfNeeded(companionPath, udid);
144
+ result.idb_companion.start = started;
145
+ if (started.started)
146
+ result.ok = result.ok && true;
147
+ }
148
+ console.log(JSON.stringify(result, null, 2));
149
+ process.exit(result.ok ? 0 : 2);
150
+ }
151
+ main().catch(e => {
152
+ // Report structured error on stdout (avoid noisy stderr in normal runs)
153
+ console.log(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }, null, 2));
154
+ process.exit(2);
155
+ });
@@ -0,0 +1,28 @@
1
+ import { iOSObserve } from '../../ios/observe.js';
2
+ import { iOSManage } from '../../ios/manage.js';
3
+ async function main() {
4
+ const appId = process.argv[2] || 'com.apple.springboard';
5
+ const deviceId = 'booted';
6
+ const obs = new iOSObserve();
7
+ const manage = new iOSManage();
8
+ try {
9
+ console.log('[1] startApp ->', appId);
10
+ const start = await manage.startApp(appId, deviceId);
11
+ console.log('start result:', start);
12
+ console.log('[2] captureScreenshot');
13
+ const shot = await obs.captureScreenshot(deviceId);
14
+ console.log('screenshot OK? size:', shot && shot.screenshot ? shot.screenshot.length : 0);
15
+ console.log('[3] getLogs');
16
+ const logs = await obs.getLogs(appId, undefined);
17
+ console.log('logs count:', logs.logCount);
18
+ console.log('[4] terminateApp');
19
+ const term = await manage.terminateApp(appId, deviceId);
20
+ console.log('terminate:', term);
21
+ console.log('SMOKE OK');
22
+ }
23
+ catch (err) {
24
+ console.error('SMOKE ERROR:', err instanceof Error ? err.message : String(err));
25
+ process.exit(1);
26
+ }
27
+ }
28
+ main();