mobile-debug-mcp 0.12.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.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
- # Mobile Dev Agent Tools
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 only tested on simulator. 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
 
@@ -40,11 +40,3 @@ I have a crash on the app, can you diagnose it, fix and validate using the mcp t
40
40
 
41
41
  MIT
42
42
 
43
- ## IDB/ADB healthcheck and diagnostics
44
-
45
- The agent provides healthcheck and optional auto-install scripts for iOS (idb) and Android (adb).
46
-
47
- - Run `npm run healthcheck` to verify idb is available. Set `MCP_AUTO_INSTALL_IDB=true` to allow the installer to run in CI or non-interactive environments.
48
- - Override detection with `IDB_PATH` or `ADB_PATH` environment variables.
49
- - Tools now return structured diagnostics on failures: { exitCode, stdout, stderr, command, args, envSnapshot, suggestedFixes } which helps agents decide corrective actions.
50
-
@@ -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
+ }
@@ -1,15 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { execSync, spawnSync } from 'child_process';
2
+ import { spawnSync } from 'child_process';
3
3
  import readline from 'readline';
4
+ import { getIdbCmd, isIDBInstalled, commandWhich } from './idb-helper.js';
4
5
  const IDB_PKG = 'fb-idb';
5
- function which(cmd) {
6
- try {
7
- return execSync(`which ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
8
- }
9
- catch {
10
- return null;
11
- }
12
- }
13
6
  function runCommand(cmd, args) {
14
7
  const res = spawnSync(cmd, args, { stdio: 'inherit' });
15
8
  return typeof res.status === 'number' ? res.status : 1;
@@ -28,8 +21,8 @@ async function confirm(prompt) {
28
21
  async function main() {
29
22
  try {
30
23
  const idbFromEnv = process.env.IDB_PATH;
31
- const existing = idbFromEnv || which('idb') || which('command -v idb');
32
- if (existing) {
24
+ const existing = idbFromEnv || getIdbCmd();
25
+ if (existing && isIDBInstalled()) {
33
26
  console.log('idb already available at:', existing);
34
27
  process.exit(0);
35
28
  }
@@ -45,12 +38,12 @@ async function main() {
45
38
  console.log('Auto-install enabled (MCP_AUTO_INSTALL_IDB=true or CI=true)');
46
39
  }
47
40
  const attempts = [];
48
- if (which('pipx'))
41
+ if (commandWhich('pipx'))
49
42
  attempts.push({ name: 'pipx', cmd: 'pipx', args: ['install', IDB_PKG] });
50
- if (which('pip') || which('python3'))
51
- attempts.push({ name: 'pip', cmd: which('pip') ? 'pip' : 'python3', args: which('pip') ? ['install', '--user', IDB_PKG] : ['-m', 'pip', 'install', '--user', 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] });
52
45
  // Add brew as a fallback on macOS if present (best-effort)
53
- if (process.platform === 'darwin' && which('brew')) {
46
+ if (process.platform === 'darwin' && commandWhich('brew')) {
54
47
  attempts.push({ name: 'brew', cmd: 'brew', args: ['install', 'idb'] });
55
48
  }
56
49
  if (attempts.length === 0) {
@@ -68,7 +61,7 @@ async function main() {
68
61
  catch (e) {
69
62
  console.warn(`${a.name} install failed: ${e instanceof Error ? e.message : String(e)}`);
70
63
  }
71
- const found = which('idb') || which('command -v idb');
64
+ const found = commandWhich('idb') || commandWhich('command -v idb');
72
65
  if (found) {
73
66
  console.log('idb installed at:', found);
74
67
  process.exit(0);
@@ -82,7 +75,8 @@ async function main() {
82
75
  process.exit(2);
83
76
  }
84
77
  }
85
- if (require.main === module) {
86
- main();
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); });
87
81
  }
88
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();
@@ -0,0 +1,29 @@
1
+ import { iOSObserve } from '../../ios/observe.js';
2
+ import { iOSInteract } from '../../ios/interact.js';
3
+ async function main() {
4
+ const deviceId = 'booted';
5
+ const obs = new iOSObserve();
6
+ const interact = new iOSInteract();
7
+ console.log('Fetching UI tree...');
8
+ const tree = await obs.getUITree(deviceId);
9
+ if (tree.error) {
10
+ console.error('getUITree error:', tree.error);
11
+ process.exit(2);
12
+ }
13
+ console.log('Elements found:', tree.elements.length);
14
+ if (!tree.elements || tree.elements.length === 0) {
15
+ console.error('No elements found; aborting');
16
+ process.exit(3);
17
+ }
18
+ const clickable = tree.elements.find(e => e.clickable) || tree.elements[0];
19
+ console.log('Using element:', clickable.text || '(no text)', 'clickable=', clickable.clickable, 'center=', clickable.center);
20
+ const [x, y] = clickable.center || [0, 0];
21
+ console.log(`Tapping at ${x},${y}...`);
22
+ const res = await interact.tap(x, y, deviceId);
23
+ console.log('Tap result:', res);
24
+ if (res.success)
25
+ process.exit(0);
26
+ else
27
+ process.exit(4);
28
+ }
29
+ main();
@@ -6,39 +6,147 @@ export class iOSManage {
6
6
  async build(projectPath, _variant) {
7
7
  void _variant;
8
8
  try {
9
- const files = await fs.readdir(projectPath).catch(() => []);
10
- const workspace = files.find(f => f.endsWith('.xcworkspace'));
11
- const proj = files.find(f => f.endsWith('.xcodeproj'));
12
- if (!workspace && !proj)
9
+ // Look for an Xcode workspace or project at the provided path. If not present, scan subdirectories (limited depth)
10
+ async function findProject(root, maxDepth = 3) {
11
+ try {
12
+ const ents = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
13
+ for (const e of ents) {
14
+ // .xcworkspace and .xcodeproj are directories on disk (bundles), not regular files
15
+ if (e.name.endsWith('.xcworkspace'))
16
+ return { dir: root, workspace: e.name };
17
+ if (e.name.endsWith('.xcodeproj'))
18
+ return { dir: root, proj: e.name };
19
+ }
20
+ }
21
+ catch { }
22
+ if (maxDepth <= 0)
23
+ return null;
24
+ try {
25
+ const ents = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
26
+ for (const e of ents) {
27
+ if (e.isDirectory()) {
28
+ const candidate = await findProject(path.join(root, e.name), maxDepth - 1);
29
+ if (candidate)
30
+ return candidate;
31
+ }
32
+ }
33
+ }
34
+ catch { }
35
+ return null;
36
+ }
37
+ const projectInfo = await findProject(projectPath, 3);
38
+ if (!projectInfo)
13
39
  return { error: 'No Xcode project or workspace found' };
40
+ const projectRootDir = projectInfo.dir || projectPath;
41
+ const workspace = projectInfo.workspace;
42
+ const proj = projectInfo.proj;
43
+ // Determine destination: prefer explicit env var, otherwise use booted simulator UDID
44
+ let destinationUDID = process.env.MCP_XCODE_DESTINATION_UDID || process.env.MCP_XCODE_DESTINATION || '';
45
+ if (!destinationUDID) {
46
+ try {
47
+ const meta = await getIOSDeviceMetadata('booted');
48
+ if (meta && meta.id)
49
+ destinationUDID = meta.id;
50
+ }
51
+ catch { }
52
+ }
14
53
  let buildArgs;
15
54
  if (workspace) {
16
- const workspacePath = path.join(projectPath, workspace);
55
+ const workspacePath = path.join(projectRootDir, workspace);
17
56
  const scheme = workspace.replace(/\.xcworkspace$/, '');
18
57
  buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
19
58
  }
20
59
  else {
21
- const projectPathFull = path.join(projectPath, proj);
60
+ const projectPathFull = path.join(projectRootDir, proj);
22
61
  const scheme = proj.replace(/\.xcodeproj$/, '');
23
62
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
24
63
  }
25
- await new Promise((resolve, reject) => {
26
- const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
27
- const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath });
28
- let stderr = '';
29
- proc.stderr?.on('data', d => stderr += d.toString());
30
- proc.on('close', code => {
31
- if (code === 0)
32
- resolve();
33
- else
34
- reject(new Error(stderr || `xcodebuild failed with code ${code}`));
64
+ // If we have a destination UDID, add an explicit destination to avoid xcodebuild picking an ambiguous target
65
+ if (destinationUDID) {
66
+ buildArgs.push('-destination', `platform=iOS Simulator,id=${destinationUDID}`);
67
+ }
68
+ // Add result bundle path for diagnostics
69
+ const resultsDir = path.join(projectPath, 'build-results');
70
+ // Remove any stale results to avoid xcodebuild complaining about existing result bundles
71
+ await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => { });
72
+ await fs.mkdir(resultsDir, { recursive: true }).catch(() => { });
73
+ // Skip specifying -resultBundlePath to avoid platform-specific collisions; rely on stdout/stderr logs
74
+ const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
75
+ const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000; // default 3 minutes
76
+ const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1;
77
+ const tries = MAX_RETRIES + 1;
78
+ let lastStdout = '';
79
+ let lastStderr = '';
80
+ let lastErr = null;
81
+ for (let attempt = 1; attempt <= tries; attempt++) {
82
+ // Run xcodebuild with a watchdog
83
+ const res = await new Promise((resolve) => {
84
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath });
85
+ let stdout = '';
86
+ let stderr = '';
87
+ proc.stdout?.on('data', d => stdout += d.toString());
88
+ proc.stderr?.on('data', d => stderr += d.toString());
89
+ let killed = false;
90
+ const to = setTimeout(() => {
91
+ killed = true;
92
+ try {
93
+ proc.kill('SIGKILL');
94
+ }
95
+ catch { }
96
+ }, XCODEBUILD_TIMEOUT);
97
+ proc.on('close', (code) => {
98
+ clearTimeout(to);
99
+ resolve({ code, stdout, stderr, killedByWatchdog: killed });
100
+ });
101
+ proc.on('error', (err) => {
102
+ clearTimeout(to);
103
+ resolve({ code: null, stdout, stderr: String(err), killedByWatchdog: killed });
104
+ });
35
105
  });
36
- proc.on('error', err => reject(err));
37
- });
106
+ lastStdout = res.stdout;
107
+ lastStderr = res.stderr;
108
+ if (res.code === 0) {
109
+ // success — clear any previous error and stop retrying
110
+ lastErr = null;
111
+ break;
112
+ }
113
+ // record the failure for reporting
114
+ lastErr = new Error(res.stderr || `xcodebuild failed with code ${res.code}`);
115
+ // write logs for diagnostics (helpful whether killed or not)
116
+ try {
117
+ await fs.writeFile(path.join(resultsDir, `xcodebuild-${attempt}.stdout.log`), res.stdout).catch(() => { });
118
+ await fs.writeFile(path.join(resultsDir, `xcodebuild-${attempt}.stderr.log`), res.stderr).catch(() => { });
119
+ }
120
+ catch { }
121
+ // If killed by watchdog and there are remaining attempts, continue to retry
122
+ if (res.killedByWatchdog && attempt < tries) {
123
+ continue;
124
+ }
125
+ // no more retries or not a watchdog kill — break to report lastErr
126
+ if (attempt >= tries)
127
+ break;
128
+ }
129
+ if (lastErr) {
130
+ // Include diagnostics and result bundle path when available
131
+ return { error: `xcodebuild failed: ${lastErr.message}. See build-results for logs.`, output: `stdout:\n${lastStdout}\nstderr:\n${lastStderr}` };
132
+ }
133
+ // Try to locate built .app. First search project tree, then DerivedData if necessary
38
134
  const built = await findAppBundle(projectPath);
39
- if (!built)
40
- return { error: 'Could not find .app after build' };
41
- return { artifactPath: built };
135
+ if (built)
136
+ return { artifactPath: built };
137
+ // Fallback: search DerivedData for matching product
138
+ const dd = path.join(process.env.HOME || '', 'Library', 'Developer', 'Xcode', 'DerivedData');
139
+ try {
140
+ const entries = await fs.readdir(dd).catch(() => []);
141
+ for (const e of entries) {
142
+ const candidate = path.join(dd, e);
143
+ const found = await findAppBundle(candidate).catch(() => undefined);
144
+ if (found)
145
+ return { artifactPath: found };
146
+ }
147
+ }
148
+ catch { }
149
+ return { error: 'Could not find .app after build' };
42
150
  }
43
151
  catch (e) {
44
152
  return { error: e instanceof Error ? e.message : String(e) };
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.12.1]
6
+ - Improve iOS build/install reliability: project auto-scan, explicit simulator destination, configurable watchdog timeout (MCP_XCODEBUILD_TIMEOUT) and retries (MCP_XCODEBUILD_RETRIES), and DerivedData fallback for locating .app artifacts.
7
+ - Make install_app capable of building iOS projects before installing so agents can autonomously fix, build, install and validate apps.
8
+ - Migrate CLI scripts into typed src/cli/* modules and update npm scripts; fix ESM import paths and lint issues.
9
+ - Add preflight checks and idb resolution helpers (getIdbCmd, isIDBInstalled) and add idb_companion health checks.
10
+ - Capture build stdout/stderr into build-results/ for easier diagnostics and surfaced suggestions when KMP frameworks are missing.
11
+ - Add device test runner under test/device and gate device-dependent tests behind RUN_DEVICE_TESTS.
12
+
13
+
5
14
  ## [0.12.0]
6
15
  - Add iOS idb integration: config-driven idb path resolution (MCP_IDB_PATH / MCP config / IDB_PATH), robust idb detection and parsing of `ui describe-all` output.
7
16
  - Add reusable helpers: `isIDBInstalled()` and `getIdbCmd()` to centralise idb resolution and diagnostics.
package/eslint.config.js CHANGED
@@ -12,7 +12,6 @@ export default [
12
12
  '.vscode/',
13
13
  'coverage/',
14
14
  '.env',
15
- 'scripts/'
16
15
  ]
17
16
  },
18
17
  // Apply rules to JS/TS source
@@ -58,9 +57,9 @@ export default [
58
57
  '@typescript-eslint/no-unused-vars': 'off'
59
58
  }
60
59
  },
61
- // Apply rules to scripts and tooling (support TS syntax in scripts)
60
+ // Apply rules to CLI tooling
62
61
  {
63
- files: ['scripts/**/*.ts', 'scripts/**/*.js'],
62
+ files: ['src/cli/**/*.ts', 'src/cli/**/*.js'],
64
63
  languageOptions: {
65
64
  parser: tsParser,
66
65
  parserOptions: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,8 +10,9 @@
10
10
  "build": "tsc",
11
11
  "start": "node ./dist/server.js",
12
12
  "prepare": "npm run build",
13
- "healthcheck": "tsx ./scripts/check-idb.ts",
14
- "install-idb": "tsx ./scripts/install-idb.ts",
13
+ "healthcheck": "tsx ./src/cli/idb/check-idb.ts",
14
+ "install-idb": "tsx ./src/cli/idb/install-idb.ts",
15
+ "preflight-ios": "tsx ./src/cli/ios/preflight-ios.ts",
15
16
  "test:unit": "tsx test/unit/index.ts",
16
17
  "test:integration": "npm run build && tsx test/device/index.ts",
17
18
  "test:device": "npm run build && tsx test/device/index.ts",
@@ -19,6 +20,7 @@
19
20
  "lint": "eslint --ext .ts,.js src test --quiet",
20
21
  "lint:fix": "eslint --ext .ts,.js src test --fix"
21
22
  },
23
+
22
24
  "engines": {
23
25
  "node": ">=18"
24
26
  },
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync, spawnSync } from 'child_process'
3
- import { main as installMain } from './install-idb'
4
- import { getIdbCmd, isIDBInstalled } from './idb-helper'
3
+ import { main as installMain } from './install-idb.js'
4
+ import { getIdbCmd, isIDBInstalled } from './idb-helper.js'
5
5
 
6
6
  function which(cmd: string): string | null {
7
7
  try {
@@ -24,7 +24,7 @@ async function runInstaller() {
24
24
  // prefer invoking the TS script via npx/tsx to ensure environment
25
25
  const runner = which('npx') ? 'npx' : which('tsx') ? 'tsx' : null
26
26
  if (runner) {
27
- const args = runner === 'npx' ? ['tsx', './scripts/install-idb.ts'] : ['./scripts/install-idb.ts']
27
+ const args = runner === 'npx' ? ['tsx', './src/cli/idb/install-idb.ts'] : ['./src/cli/idb/install-idb.ts']
28
28
  const res = spawnSync(runner, args, { stdio: 'inherit' as any })
29
29
  return typeof res.status === 'number' ? res.status === 0 : false
30
30
  }
@@ -59,7 +59,7 @@ try {
59
59
  const auto = process.env.MCP_AUTO_INSTALL_IDB === 'true'
60
60
  if (auto) {
61
61
  print('MCP_AUTO_INSTALL_IDB=true, attempting installer...')
62
- const ok = runInstaller()
62
+ const ok = await runInstaller()
63
63
  if (ok) process.exit(0)
64
64
  print('Installer failed or did not produce idb')
65
65
  process.exit(2)
@@ -1,6 +1,5 @@
1
1
  import { execSync, spawnSync } from 'child_process'
2
2
  import fs from 'fs'
3
- import path from 'path'
4
3
 
5
4
  export function getConfiguredIdbPath(): string | undefined {
6
5
  if (process.env.MCP_IDB_PATH) return process.env.MCP_IDB_PATH
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { execSync, spawnSync } from 'child_process'
2
+ import { spawnSync } from 'child_process'
3
3
  import readline from 'readline'
4
- import { getIdbCmd, isIDBInstalled, commandWhich } from './idb-helper'
4
+ import { getIdbCmd, isIDBInstalled, commandWhich } from './idb-helper.js'
5
5
 
6
6
  const IDB_PKG = 'fb-idb'
7
7
 
@@ -67,7 +67,7 @@ async function main() {
67
67
  console.warn(`${a.name} install failed: ${e instanceof Error ? e.message : String(e)}`)
68
68
  }
69
69
 
70
- const found = which('idb') || which('command -v idb')
70
+ const found = commandWhich('idb') || commandWhich('command -v idb')
71
71
  if (found) {
72
72
  console.log('idb installed at:', found)
73
73
  process.exit(0)
@@ -0,0 +1,144 @@
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
+
7
+ async function exists(p: string) {
8
+ try { await fs.promises.access(p); return true } catch { return false }
9
+ }
10
+
11
+ async function findFirst(root: string, patterns: string[], maxDepth = 4): Promise<string | null> {
12
+ const queue: Array<{dir:string, depth:number}> = [{dir: root, depth:0}]
13
+ while (queue.length) {
14
+ const {dir, depth} = queue.shift()!
15
+ try {
16
+ console.error('DEBUG findFirst: reading dir', dir, 'depth', depth)
17
+ const ents: fs.Dirent[] = await fs.promises.readdir(dir, { withFileTypes: true })
18
+ console.error('DEBUG findFirst: entries', ents.map(e=>e.name))
19
+ for (const e of ents) {
20
+ const full = path.join(dir, e.name)
21
+ for (const p of patterns) {
22
+ if (e.name.endsWith(p)) {
23
+ console.error('DEBUG findFirst: matched', full)
24
+ return full
25
+ }
26
+ }
27
+ if (e.isDirectory() && depth < maxDepth) queue.push({dir: full, depth: depth+1})
28
+ }
29
+ } catch (err) {
30
+ console.error('DEBUG findFirst: read failed for', dir, err)
31
+ // ignore
32
+ }
33
+ }
34
+ return null
35
+ }
36
+
37
+ function startCompanionIfNeeded(companionPath: string | null, udid: string | null) {
38
+ if (!companionPath || !udid) return { started: false, error: 'missing companion or udid' }
39
+ try {
40
+ const child = spawn(companionPath, ['--udid', udid], { detached: true, stdio: 'ignore' })
41
+ child.unref()
42
+ return { started: true }
43
+ } catch (e:any) {
44
+ return { started: false, error: e.message }
45
+ }
46
+ }
47
+
48
+ async function main() {
49
+ const args = process.argv.slice(2)
50
+ let projectArg: string | undefined
51
+ let udid: string | undefined
52
+ let startCompanion = false
53
+ let kmpBuild: any = null
54
+ for (let i=0;i<args.length;i++) {
55
+ const a = args[i]
56
+ if (a === '--project' && args[i+1]) { projectArg = args[i+1]; i++ }
57
+ else if (a === '--udid' && args[i+1]) { udid = args[i+1]; i++ }
58
+ else if (a === '--start-companion') startCompanion = true
59
+ else if (!projectArg) projectArg = a
60
+ }
61
+
62
+ const cwd = process.cwd()
63
+ const projectRoot = projectArg ? path.resolve(projectArg) : cwd
64
+
65
+ // If user passed a direct .xcodeproj or .xcworkspace path, accept it as the projectFile
66
+ let projectFile: string | null = null
67
+ try {
68
+ const stat = await fs.promises.stat(projectRoot)
69
+ if (stat.isFile() && (projectRoot.endsWith('.xcodeproj') || projectRoot.endsWith('.xcworkspace'))) {
70
+ projectFile = projectRoot
71
+ }
72
+ } catch {
73
+ // ignore
74
+ }
75
+
76
+ // detect project if not a direct file
77
+ if (!projectFile) {
78
+ const projectFound = await exists(projectRoot)
79
+ if (projectFound) {
80
+ projectFile = await findFirst(projectRoot, ['.xcworkspace', '.xcodeproj'], 2)
81
+ } else {
82
+ // attempt to find under cwd
83
+ projectFile = await findFirst(cwd, ['.xcworkspace', '.xcodeproj'], 3)
84
+ }
85
+ }
86
+
87
+ // 2) KMP Shared.framework detection (search for *.framework named Shared.framework)
88
+ let kmpFramework: string | null = null
89
+ const projectSearchRoot = projectFile ? (fs.existsSync(projectFile) && fs.lstatSync(projectFile).isDirectory() ? projectFile : path.dirname(projectFile)) : cwd
90
+ kmpFramework = await findFirst(projectSearchRoot, ['Shared.framework'], 5)
91
+ if (!kmpFramework) kmpFramework = await findFirst(cwd, ['Shared.framework'], 6)
92
+
93
+ // 3) idb detection
94
+ const idbPath = getIdbCmd()
95
+ const idbAvailable = idbPath ? isIDBInstalled() : false
96
+
97
+ // 4) idb_companion
98
+ const companionPath = commandWhich('idb_companion')
99
+ const companionAvailable = !!companionPath
100
+
101
+ const suggestions: string[] = []
102
+ if (!projectFile) suggestions.push('Provide correct project path or ensure .xcodeproj/.xcworkspace exists in project dir')
103
+ if (!kmpFramework) suggestions.push('Run KMP Gradle task to produce Shared.framework before xcodebuild (e.g., :shared:embedAndSignAppleFrameworkForXcode)')
104
+ if (!idbAvailable) suggestions.push('Ensure idb is in PATH or set MCP_IDB_PATH / IDB_PATH')
105
+ if (!companionAvailable) suggestions.push('Install idb_companion and ensure it is in PATH')
106
+
107
+ const result: any = {
108
+ ok: !!projectFile && !!idbAvailable,
109
+ project: {
110
+ root: projectRoot,
111
+ found: !!projectFile,
112
+ projectFile: projectFile
113
+ },
114
+ kmp: {
115
+ found: !!kmpFramework,
116
+ path: kmpFramework,
117
+ build: kmpBuild
118
+ },
119
+ idb: {
120
+ cmd: idbPath,
121
+ installed: idbAvailable
122
+ },
123
+ idb_companion: {
124
+ cmd: companionPath,
125
+ installed: companionAvailable
126
+ },
127
+ suggestions
128
+ }
129
+
130
+ if (startCompanion && udid) {
131
+ const started = startCompanionIfNeeded(companionPath, udid)
132
+ result.idb_companion.start = started
133
+ if (started.started) result.ok = result.ok && true
134
+ }
135
+
136
+ console.log(JSON.stringify(result, null, 2))
137
+ process.exit(result.ok ? 0 : 2)
138
+ }
139
+
140
+ main().catch(e => {
141
+ // Report structured error on stdout (avoid noisy stderr in normal runs)
142
+ console.log(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }, null, 2))
143
+ process.exit(2)
144
+ })
@@ -1,5 +1,5 @@
1
- import { iOSObserve } from '../src/ios/observe.js';
2
- import { iOSManage } from '../src/ios/manage.js';
1
+ import { iOSObserve } from '../../ios/observe.js';
2
+ import { iOSManage } from '../../ios/manage.js';
3
3
 
4
4
  async function main() {
5
5
  const appId = process.argv[2] || 'com.apple.springboard';
@@ -1,5 +1,5 @@
1
- import { iOSObserve } from '../src/ios/observe.js';
2
- import { iOSInteract } from '../src/ios/interact.js';
1
+ import { iOSObserve } from '../../ios/observe.js';
2
+ import { iOSInteract } from '../../ios/interact.js';
3
3
 
4
4
  async function main() {
5
5
  const deviceId = 'booted';
package/src/ios/manage.ts CHANGED
@@ -8,37 +8,154 @@ export class iOSManage {
8
8
  async build(projectPath: string, _variant?: string): Promise<{ artifactPath: string, output?: string } | { error: string }> {
9
9
  void _variant
10
10
  try {
11
- const files = await fs.readdir(projectPath).catch(() => [])
12
- const workspace = files.find(f => f.endsWith('.xcworkspace'))
13
- const proj = files.find(f => f.endsWith('.xcodeproj'))
14
- if (!workspace && !proj) return { error: 'No Xcode project or workspace found' }
11
+ // Look for an Xcode workspace or project at the provided path. If not present, scan subdirectories (limited depth)
12
+ async function findProject(root: string, maxDepth = 3): Promise<{ dir: string, workspace?: string, proj?: string } | null> {
13
+ try {
14
+ const ents = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
15
+ for (const e of ents) {
16
+ // .xcworkspace and .xcodeproj are directories on disk (bundles), not regular files
17
+ if (e.name.endsWith('.xcworkspace')) return { dir: root, workspace: e.name }
18
+ if (e.name.endsWith('.xcodeproj')) return { dir: root, proj: e.name }
19
+ }
20
+ } catch {}
21
+
22
+ if (maxDepth <= 0) return null
23
+
24
+ try {
25
+ const ents = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
26
+ for (const e of ents) {
27
+ if (e.isDirectory()) {
28
+ const candidate = await findProject(path.join(root, e.name), maxDepth - 1)
29
+ if (candidate) return candidate
30
+ }
31
+ }
32
+ } catch {}
33
+
34
+ return null
35
+ }
36
+
37
+ const projectInfo = await findProject(projectPath, 3)
38
+ if (!projectInfo) return { error: 'No Xcode project or workspace found' }
39
+ const projectRootDir = projectInfo.dir || projectPath
40
+ const workspace = projectInfo.workspace
41
+ const proj = projectInfo.proj
42
+
43
+ // Determine destination: prefer explicit env var, otherwise use booted simulator UDID
44
+ let destinationUDID = process.env.MCP_XCODE_DESTINATION_UDID || process.env.MCP_XCODE_DESTINATION || ''
45
+ if (!destinationUDID) {
46
+ try {
47
+ const meta = await getIOSDeviceMetadata('booted')
48
+ if (meta && meta.id) destinationUDID = meta.id
49
+ } catch {}
50
+ }
15
51
 
16
52
  let buildArgs: string[]
17
53
  if (workspace) {
18
- const workspacePath = path.join(projectPath, workspace)
54
+ const workspacePath = path.join(projectRootDir, workspace)
19
55
  const scheme = workspace.replace(/\.xcworkspace$/, '')
20
56
  buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build']
21
57
  } else {
22
- const projectPathFull = path.join(projectPath, proj!)
58
+ const projectPathFull = path.join(projectRootDir, proj!)
23
59
  const scheme = proj!.replace(/\.xcodeproj$/, '')
24
60
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build']
25
61
  }
26
62
 
27
- await new Promise<void>((resolve, reject) => {
28
- const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild'
29
- const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath })
30
- let stderr = ''
31
- proc.stderr?.on('data', d => stderr += d.toString())
32
- proc.on('close', code => {
33
- if (code === 0) resolve()
34
- else reject(new Error(stderr || `xcodebuild failed with code ${code}`))
63
+ // If we have a destination UDID, add an explicit destination to avoid xcodebuild picking an ambiguous target
64
+ if (destinationUDID) {
65
+ buildArgs.push('-destination', `platform=iOS Simulator,id=${destinationUDID}`)
66
+ }
67
+
68
+ // Add result bundle path for diagnostics
69
+ const resultsDir = path.join(projectPath, 'build-results')
70
+ // Remove any stale results to avoid xcodebuild complaining about existing result bundles
71
+ await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => {})
72
+ await fs.mkdir(resultsDir, { recursive: true }).catch(() => {})
73
+ // Skip specifying -resultBundlePath to avoid platform-specific collisions; rely on stdout/stderr logs
74
+
75
+
76
+ const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild'
77
+ const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000 // default 3 minutes
78
+ const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1
79
+
80
+ const tries = MAX_RETRIES + 1
81
+ let lastStdout = ''
82
+ let lastStderr = ''
83
+ let lastErr: any = null
84
+
85
+ for (let attempt = 1; attempt <= tries; attempt++) {
86
+ // Run xcodebuild with a watchdog
87
+ const res = await new Promise<{ code: number | null, stdout: string, stderr: string, killedByWatchdog?: boolean }>((resolve) => {
88
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath })
89
+ let stdout = ''
90
+ let stderr = ''
91
+
92
+ proc.stdout?.on('data', d => stdout += d.toString())
93
+ proc.stderr?.on('data', d => stderr += d.toString())
94
+
95
+ let killed = false
96
+ const to = setTimeout(() => {
97
+ killed = true
98
+ try { proc.kill('SIGKILL') } catch {}
99
+ }, XCODEBUILD_TIMEOUT)
100
+
101
+ proc.on('close', (code) => {
102
+ clearTimeout(to)
103
+ resolve({ code, stdout, stderr, killedByWatchdog: killed })
104
+ })
105
+ proc.on('error', (err) => {
106
+ clearTimeout(to)
107
+ resolve({ code: null, stdout, stderr: String(err), killedByWatchdog: killed })
108
+ })
35
109
  })
36
- proc.on('error', err => reject(err))
37
- })
38
110
 
111
+ lastStdout = res.stdout
112
+ lastStderr = res.stderr
113
+
114
+ if (res.code === 0) {
115
+ // success — clear any previous error and stop retrying
116
+ lastErr = null
117
+ break
118
+ }
119
+
120
+ // record the failure for reporting
121
+ lastErr = new Error(res.stderr || `xcodebuild failed with code ${res.code}`)
122
+
123
+ // write logs for diagnostics (helpful whether killed or not)
124
+ try {
125
+ await fs.writeFile(path.join(resultsDir, `xcodebuild-${attempt}.stdout.log`), res.stdout).catch(() => {})
126
+ await fs.writeFile(path.join(resultsDir, `xcodebuild-${attempt}.stderr.log`), res.stderr).catch(() => {})
127
+ } catch {}
128
+
129
+ // If killed by watchdog and there are remaining attempts, continue to retry
130
+ if (res.killedByWatchdog && attempt < tries) {
131
+ continue
132
+ }
133
+
134
+ // no more retries or not a watchdog kill — break to report lastErr
135
+ if (attempt >= tries) break
136
+ }
137
+
138
+ if (lastErr) {
139
+ // Include diagnostics and result bundle path when available
140
+ return { error: `xcodebuild failed: ${lastErr.message}. See build-results for logs.`, output: `stdout:\n${lastStdout}\nstderr:\n${lastStderr}` }
141
+ }
142
+
143
+ // Try to locate built .app. First search project tree, then DerivedData if necessary
39
144
  const built = await findAppBundle(projectPath)
40
- if (!built) return { error: 'Could not find .app after build' }
41
- return { artifactPath: built }
145
+ if (built) return { artifactPath: built }
146
+
147
+ // Fallback: search DerivedData for matching product
148
+ const dd = path.join(process.env.HOME || '', 'Library', 'Developer', 'Xcode', 'DerivedData')
149
+ try {
150
+ const entries = await fs.readdir(dd).catch(() => [])
151
+ for (const e of entries) {
152
+ const candidate = path.join(dd, e)
153
+ const found = await findAppBundle(candidate).catch(() => undefined)
154
+ if (found) return { artifactPath: found }
155
+ }
156
+ } catch {}
157
+
158
+ return { error: 'Could not find .app after build' }
42
159
  } catch (e) {
43
160
  return { error: e instanceof Error ? e.message : String(e) }
44
161
  }
@@ -1,83 +0,0 @@
1
- #!/usr/bin/env node
2
- import { execSync, spawnSync } from 'child_process';
3
- import { main as installMain } from './install-idb';
4
- function which(cmd) {
5
- try {
6
- // Prefer POSIX `command -v` which can resolve shell builtins. Use spawnSync
7
- // to avoid shell interpolation and injection risks. Fall back to `which`.
8
- const res = spawnSync('command', ['-v', cmd], { stdio: ['ignore', 'pipe', 'ignore'] });
9
- if (res && res.status === 0 && res.stdout) return res.stdout.toString().trim();
10
- return execSync(`which ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
11
- }
12
- catch {
13
- return null;
14
- }
15
- }
16
- function print(...args) {
17
- console.log(...args);
18
- }
19
- async function runInstaller() {
20
- try {
21
- // prefer invoking the TS script via npx/tsx to ensure environment
22
- const runner = which('npx') ? 'npx' : which('tsx') ? 'tsx' : null;
23
- if (runner) {
24
- const args = runner === 'npx' ? ['tsx', './scripts/install-idb.ts'] : ['./scripts/install-idb.ts'];
25
- const res = spawnSync(runner, args, { stdio: 'inherit' });
26
- return typeof res.status === 'number' ? res.status === 0 : false;
27
- }
28
- // fallback: attempt to import and run the installer directly (may rely on ts-node/tsx)
29
- try {
30
- // call the exported main; it returns a promise
31
- await installMain();
32
- return true;
33
- }
34
- catch {
35
- return false;
36
- }
37
- }
38
- catch (e) {
39
- console.error('Failed to run installer:', e instanceof Error ? e.message : String(e));
40
- return false;
41
- }
42
- }
43
- (async () => {
44
- try {
45
- print('PATH=', process.env.PATH);
46
- const idb = process.env.IDB_PATH || which('idb');
47
- print('which idb:', idb);
48
- if (idb) {
49
- try {
50
- print('idb --version:', execSync('idb --version', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim());
51
- }
52
- catch (e) {
53
- print('idb --version: (failed)', e instanceof Error ? e.message : String(e));
54
- }
55
- const companion = which('idb_companion');
56
- print('which idb_companion:', companion);
57
- if (companion)
58
- try {
59
- print('idb_companion --version:', execSync('idb_companion --version', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim());
60
- }
61
- catch (e) {
62
- print('idb_companion --version: (failed)', e instanceof Error ? e.message : String(e));
63
- }
64
- process.exit(0);
65
- }
66
- print('idb not found');
67
- const auto = process.env.MCP_AUTO_INSTALL_IDB === 'true';
68
- if (auto) {
69
- print('MCP_AUTO_INSTALL_IDB=true, attempting installer...');
70
- const ok = await runInstaller();
71
- if (ok)
72
- process.exit(0);
73
- print('Installer failed or did not produce idb');
74
- process.exit(2);
75
- }
76
- print('Set MCP_AUTO_INSTALL_IDB=true to attempt automatic installation (CI-friendly).');
77
- process.exit(2);
78
- }
79
- catch (e) {
80
- console.error('idb healthcheck failed:', e instanceof Error ? e.message : String(e));
81
- process.exit(2);
82
- }
83
- })();