mobile-debug-mcp 0.12.1 → 0.12.2

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,4 +1,4 @@
1
- # Mobile Dev MCP
1
+ # Mobile Dev Tools
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
 
@@ -1,24 +1 @@
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
- }
1
+ export { execAdbWithDiagnostics } from '../utils/diagnostics.js';
@@ -3,7 +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
+ import { execAdbWithDiagnostics } from '../utils/diagnostics.js';
7
7
  import { detectJavaHome } from '../utils/java.js';
8
8
  export class AndroidManage {
9
9
  async build(projectPath, _variant) {
@@ -1,5 +1,5 @@
1
1
  import { promises as fs } from "fs";
2
- import { spawn } from "child_process";
2
+ import { spawn, spawnSync } from "child_process";
3
3
  import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "./utils.js";
4
4
  import path from "path";
5
5
  export class iOSManage {
@@ -34,10 +34,12 @@ export class iOSManage {
34
34
  catch { }
35
35
  return null;
36
36
  }
37
- const projectInfo = await findProject(projectPath, 3);
37
+ // Resolve projectPath to an absolute path to avoid cwd-relative resolution issues
38
+ const absProjectPath = path.resolve(projectPath);
39
+ const projectInfo = await findProject(absProjectPath, 3);
38
40
  if (!projectInfo)
39
41
  return { error: 'No Xcode project or workspace found' };
40
- const projectRootDir = projectInfo.dir || projectPath;
42
+ const projectRootDir = projectInfo.dir || absProjectPath;
41
43
  const workspace = projectInfo.workspace;
42
44
  const proj = projectInfo.proj;
43
45
  // Determine destination: prefer explicit env var, otherwise use booted simulator UDID
@@ -50,15 +52,38 @@ export class iOSManage {
50
52
  }
51
53
  catch { }
52
54
  }
55
+ // Determine xcode command early so it can be used when detecting schemes
56
+ const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
57
+ // Determine available schemes by querying xcodebuild -list rather than guessing
58
+ async function detectScheme(xcodeCmdInner, workspacePath, projectPathFull, cwd) {
59
+ try {
60
+ const args = workspacePath ? ['-list', '-workspace', workspacePath] : ['-list', '-project', projectPathFull];
61
+ // Run xcodebuild directly to list schemes
62
+ const res = spawnSync(xcodeCmdInner, args, { cwd: cwd || projectRootDir, encoding: 'utf8', timeout: 20000 });
63
+ const out = res.stdout || '';
64
+ const schemesMatch = out.match(/Schemes:\s*\n([\s\S]*?)(?:\n\n|$)/m);
65
+ if (schemesMatch) {
66
+ const block = schemesMatch[1];
67
+ const schemes = block.split(/\n/).map(s => s.trim()).filter(Boolean);
68
+ if (schemes.length)
69
+ return schemes[0];
70
+ }
71
+ }
72
+ catch { }
73
+ return null;
74
+ }
53
75
  let buildArgs;
76
+ let chosenScheme = null;
54
77
  if (workspace) {
55
78
  const workspacePath = path.join(projectRootDir, workspace);
56
- const scheme = workspace.replace(/\.xcworkspace$/, '');
79
+ chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir);
80
+ const scheme = chosenScheme || workspace.replace(/\.xcworkspace$/, '');
57
81
  buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
58
82
  }
59
83
  else {
60
84
  const projectPathFull = path.join(projectRootDir, proj);
61
- const scheme = proj.replace(/\.xcodeproj$/, '');
85
+ chosenScheme = await detectScheme(xcodeCmd, undefined, projectPathFull, projectRootDir);
86
+ const scheme = chosenScheme || proj.replace(/\.xcodeproj$/, '');
62
87
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
63
88
  }
64
89
  // If we have a destination UDID, add an explicit destination to avoid xcodebuild picking an ambiguous target
@@ -71,7 +96,6 @@ export class iOSManage {
71
96
  await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => { });
72
97
  await fs.mkdir(resultsDir, { recursive: true }).catch(() => { });
73
98
  // Skip specifying -resultBundlePath to avoid platform-specific collisions; rely on stdout/stderr logs
74
- const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
75
99
  const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000; // default 3 minutes
76
100
  const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1;
77
101
  const tries = MAX_RETRIES + 1;
@@ -81,7 +105,7 @@ export class iOSManage {
81
105
  for (let attempt = 1; attempt <= tries; attempt++) {
82
106
  // Run xcodebuild with a watchdog
83
107
  const res = await new Promise((resolve) => {
84
- const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath });
108
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir });
85
109
  let stdout = '';
86
110
  let stderr = '';
87
111
  proc.stdout?.on('data', d => stdout += d.toString());
@@ -112,6 +136,9 @@ export class iOSManage {
112
136
  }
113
137
  // record the failure for reporting
114
138
  lastErr = new Error(res.stderr || `xcodebuild failed with code ${res.code}`);
139
+ lastErr.code = res.code;
140
+ lastErr.exitCode = res.code;
141
+ lastErr.killedByWatchdog = !!res.killedByWatchdog;
115
142
  // write logs for diagnostics (helpful whether killed or not)
116
143
  try {
117
144
  await fs.writeFile(path.join(resultsDir, `xcodebuild-${attempt}.stdout.log`), res.stdout).catch(() => { });
@@ -127,8 +154,10 @@ export class iOSManage {
127
154
  break;
128
155
  }
129
156
  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}` };
157
+ // Include diagnostics and result bundle path when available; provide structured info useful for agents
158
+ const invokedCommand = `${xcodeCmd} ${buildArgs.map(a => a.includes(' ') ? `"${a}"` : a).join(' ')}`;
159
+ const envSnapshot = { PATH: process.env.PATH };
160
+ return { error: `xcodebuild failed: ${lastErr.message}. See build-results for logs.`, output: `stdout:\n${lastStdout}\nstderr:\n${lastStderr}`, diagnostics: { exitCode: lastErr.code || null, invokedCommand, cwd: projectRootDir, envSnapshot } };
132
161
  }
133
162
  // Try to locate built .app. First search project tree, then DerivedData if necessary
134
163
  const built = await findAppBundle(projectPath);
@@ -1,4 +1,4 @@
1
- import { resolveTargetDevice } from '../resolve-device.js';
1
+ import { resolveTargetDevice } from '../utils/resolve-device.js';
2
2
  import { AndroidInteract } from '../android/interact.js';
3
3
  import { iOSInteract } from '../ios/interact.js';
4
4
  export class ToolsInteract {
@@ -1,6 +1,6 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
- import { resolveTargetDevice, listDevices } from '../resolve-device.js';
3
+ import { resolveTargetDevice, listDevices } from '../utils/resolve-device.js';
4
4
  import { AndroidManage } from '../android/manage.js';
5
5
  import { iOSManage } from '../ios/manage.js';
6
6
  export class ToolsManage {
@@ -1,4 +1,4 @@
1
- import { resolveTargetDevice } from '../resolve-device.js';
1
+ import { resolveTargetDevice } from '../utils/resolve-device.js';
2
2
  import { AndroidObserve } from '../android/observe.js';
3
3
  import { iOSObserve } from '../ios/observe.js';
4
4
  export class ToolsObserve {
@@ -23,3 +23,27 @@ export class DiagnosticError extends Error {
23
23
  this.runResult = runResult;
24
24
  }
25
25
  }
26
+ // Exec ADB with diagnostics — moved from src/android/diagnostics.ts
27
+ import { spawnSync } from 'child_process';
28
+ import { getAdbCmd } from '../android/utils.js';
29
+ export function execAdbWithDiagnostics(args, deviceId) {
30
+ const adbArgs = deviceId ? ['-s', deviceId, ...args] : args;
31
+ const timeout = 120000;
32
+ const res = spawnSync(getAdbCmd(), adbArgs, { encoding: 'utf8', timeout });
33
+ const runResult = {
34
+ exitCode: typeof res.status === 'number' ? res.status : null,
35
+ stdout: res.stdout || '',
36
+ stderr: res.stderr || '',
37
+ envSnapshot: makeEnvSnapshot(['PATH', 'ADB_PATH', 'HOME', 'JAVA_HOME']),
38
+ command: getAdbCmd(),
39
+ args: adbArgs,
40
+ suggestedFixes: []
41
+ };
42
+ if (res.status !== 0) {
43
+ if ((runResult.stderr || '').includes('device not found'))
44
+ runResult.suggestedFixes.push('Ensure device is connected and adb is authorized (adb devices)');
45
+ if ((runResult.stderr || '').includes('No such file or directory'))
46
+ runResult.suggestedFixes.push('Verify ADB_PATH or that adb is installed');
47
+ }
48
+ return { runResult };
49
+ }
@@ -0,0 +1,62 @@
1
+ import { listAndroidDevices } from "../android/utils.js";
2
+ import { listIOSDevices } from "../ios/utils.js";
3
+ function parseNumericVersion(v) {
4
+ if (!v)
5
+ return 0;
6
+ const m = v.match(/(\d+)(?:[\.\-](\d+))?/);
7
+ if (!m)
8
+ return 0;
9
+ const major = parseInt(m[1], 10) || 0;
10
+ const minor = parseInt(m[2] || "0", 10) || 0;
11
+ return major + minor / 100;
12
+ }
13
+ export async function listDevices(platform, appId) {
14
+ if (!platform || platform === "android") {
15
+ const android = await listAndroidDevices(appId);
16
+ if (platform === "android")
17
+ return android;
18
+ const ios = await listIOSDevices(appId);
19
+ return [...android, ...ios];
20
+ }
21
+ return listIOSDevices(appId);
22
+ }
23
+ export async function resolveTargetDevice(opts) {
24
+ const { platform, appId, prefer, deviceId } = opts;
25
+ const devices = await listDevices(platform, appId);
26
+ if (deviceId) {
27
+ const found = devices.find(d => d.id === deviceId);
28
+ if (!found)
29
+ throw new Error(`Device '${deviceId}' not found for platform ${platform}`);
30
+ return found;
31
+ }
32
+ let candidates = devices.slice();
33
+ if (prefer === "physical")
34
+ candidates = candidates.filter(d => !d.simulator);
35
+ if (prefer === "emulator")
36
+ candidates = candidates.filter(d => d.simulator);
37
+ if (appId) {
38
+ const installed = candidates.filter(d => d.appInstalled);
39
+ if (installed.length > 0)
40
+ candidates = installed;
41
+ }
42
+ if (candidates.length === 1)
43
+ return candidates[0];
44
+ if (candidates.length > 1) {
45
+ if (!prefer) {
46
+ const physical = candidates.filter(d => !d.simulator);
47
+ if (physical.length === 1)
48
+ return physical[0];
49
+ if (physical.length > 1)
50
+ candidates = physical;
51
+ }
52
+ candidates.sort((a, b) => parseNumericVersion(b.osVersion) - parseNumericVersion(a.osVersion));
53
+ if (candidates.length > 1 && parseNumericVersion(candidates[0].osVersion) > parseNumericVersion(candidates[1].osVersion)) {
54
+ return candidates[0];
55
+ }
56
+ const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled }));
57
+ const err = new Error(`Multiple matching devices found: ${JSON.stringify(list, null, 2)}`);
58
+ err.devices = list;
59
+ throw err;
60
+ }
61
+ throw new Error(`No devices found for platform ${platform}`);
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +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
+ import { execAdbWithDiagnostics } from '../utils/diagnostics.js'
7
7
  import { detectJavaHome } from '../utils/java.js'
8
8
  import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
9
9
 
package/src/ios/manage.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { promises as fs } from "fs"
2
- import { spawn } from "child_process"
2
+ import { spawn, spawnSync } from "child_process"
3
3
  import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, InstallAppResponse } from "../types.js"
4
4
  import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "./utils.js"
5
5
  import path from "path"
6
6
 
7
7
  export class iOSManage {
8
- async build(projectPath: string, _variant?: string): Promise<{ artifactPath: string, output?: string } | { error: string }> {
8
+ async build(projectPath: string, _variant?: string): Promise<{ artifactPath: string, output?: string } | { error: string, diagnostics?: any }> {
9
9
  void _variant
10
10
  try {
11
11
  // Look for an Xcode workspace or project at the provided path. If not present, scan subdirectories (limited depth)
@@ -34,9 +34,11 @@ export class iOSManage {
34
34
  return null
35
35
  }
36
36
 
37
- const projectInfo = await findProject(projectPath, 3)
37
+ // Resolve projectPath to an absolute path to avoid cwd-relative resolution issues
38
+ const absProjectPath = path.resolve(projectPath)
39
+ const projectInfo = await findProject(absProjectPath, 3)
38
40
  if (!projectInfo) return { error: 'No Xcode project or workspace found' }
39
- const projectRootDir = projectInfo.dir || projectPath
41
+ const projectRootDir = projectInfo.dir || absProjectPath
40
42
  const workspace = projectInfo.workspace
41
43
  const proj = projectInfo.proj
42
44
 
@@ -49,14 +51,37 @@ export class iOSManage {
49
51
  } catch {}
50
52
  }
51
53
 
54
+ // Determine xcode command early so it can be used when detecting schemes
55
+ const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild'
56
+
57
+ // Determine available schemes by querying xcodebuild -list rather than guessing
58
+ async function detectScheme(xcodeCmdInner: string, workspacePath?: string, projectPathFull?: string, cwd?: string): Promise<string | null> {
59
+ try {
60
+ const args = workspacePath ? ['-list', '-workspace', workspacePath] : ['-list', '-project', projectPathFull!]
61
+ // Run xcodebuild directly to list schemes
62
+ const res = spawnSync(xcodeCmdInner, args, { cwd: cwd || projectRootDir, encoding: 'utf8', timeout: 20000 })
63
+ const out = res.stdout || ''
64
+ const schemesMatch = out.match(/Schemes:\s*\n([\s\S]*?)(?:\n\n|$)/m)
65
+ if (schemesMatch) {
66
+ const block = schemesMatch[1]
67
+ const schemes = block.split(/\n/).map(s => s.trim()).filter(Boolean)
68
+ if (schemes.length) return schemes[0]
69
+ }
70
+ } catch {}
71
+ return null
72
+ }
73
+
52
74
  let buildArgs: string[]
75
+ let chosenScheme: string | null = null
53
76
  if (workspace) {
54
77
  const workspacePath = path.join(projectRootDir, workspace)
55
- const scheme = workspace.replace(/\.xcworkspace$/, '')
78
+ chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir)
79
+ const scheme = chosenScheme || workspace.replace(/\.xcworkspace$/, '')
56
80
  buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build']
57
81
  } else {
58
82
  const projectPathFull = path.join(projectRootDir, proj!)
59
- const scheme = proj!.replace(/\.xcodeproj$/, '')
83
+ chosenScheme = await detectScheme(xcodeCmd, undefined, projectPathFull, projectRootDir)
84
+ const scheme = chosenScheme || proj!.replace(/\.xcodeproj$/, '')
60
85
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build']
61
86
  }
62
87
 
@@ -73,7 +98,6 @@ export class iOSManage {
73
98
  // Skip specifying -resultBundlePath to avoid platform-specific collisions; rely on stdout/stderr logs
74
99
 
75
100
 
76
- const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild'
77
101
  const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000 // default 3 minutes
78
102
  const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1
79
103
 
@@ -85,7 +109,7 @@ export class iOSManage {
85
109
  for (let attempt = 1; attempt <= tries; attempt++) {
86
110
  // Run xcodebuild with a watchdog
87
111
  const res = await new Promise<{ code: number | null, stdout: string, stderr: string, killedByWatchdog?: boolean }>((resolve) => {
88
- const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath })
112
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir })
89
113
  let stdout = ''
90
114
  let stderr = ''
91
115
 
@@ -119,6 +143,10 @@ export class iOSManage {
119
143
 
120
144
  // record the failure for reporting
121
145
  lastErr = new Error(res.stderr || `xcodebuild failed with code ${res.code}`)
146
+ // Attach exit code and watchdog info so diagnostics can include them
147
+ ;(lastErr as any).code = res.code
148
+ ;(lastErr as any).exitCode = res.code
149
+ ;(lastErr as any).killedByWatchdog = !!res.killedByWatchdog
122
150
 
123
151
  // write logs for diagnostics (helpful whether killed or not)
124
152
  try {
@@ -136,8 +164,10 @@ export class iOSManage {
136
164
  }
137
165
 
138
166
  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}` }
167
+ // Include diagnostics and result bundle path when available; provide structured info useful for agents
168
+ const invokedCommand = `${xcodeCmd} ${buildArgs.map(a => a.includes(' ') ? `"${a}"` : a).join(' ')}`
169
+ const envSnapshot = { PATH: process.env.PATH }
170
+ return { error: `xcodebuild failed: ${lastErr.message}. See build-results for logs.`, output: `stdout:\n${lastStdout}\nstderr:\n${lastStderr}`, diagnostics: { exitCode: (lastErr as any).code || null, invokedCommand, cwd: projectRootDir, envSnapshot } }
141
171
  }
142
172
 
143
173
  // Try to locate built .app. First search project tree, then DerivedData if necessary
@@ -1,4 +1,4 @@
1
- import { resolveTargetDevice } from '../resolve-device.js'
1
+ import { resolveTargetDevice } from '../utils/resolve-device.js'
2
2
  import { AndroidInteract } from '../android/interact.js'
3
3
  import { iOSInteract } from '../ios/interact.js'
4
4
 
@@ -1,6 +1,6 @@
1
1
  import { promises as fs } from 'fs'
2
2
  import path from 'path'
3
- import { resolveTargetDevice, listDevices } from '../resolve-device.js'
3
+ import { resolveTargetDevice, listDevices } from '../utils/resolve-device.js'
4
4
  import { AndroidManage } from '../android/manage.js'
5
5
  import { iOSManage } from '../ios/manage.js'
6
6
  import type { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
@@ -1,4 +1,4 @@
1
- import { resolveTargetDevice } from '../resolve-device.js'
1
+ import { resolveTargetDevice } from '../utils/resolve-device.js'
2
2
  import { AndroidObserve } from '../android/observe.js'
3
3
  import { iOSObserve } from '../ios/observe.js'
4
4
 
@@ -34,3 +34,27 @@ export class DiagnosticError extends Error {
34
34
  this.runResult = runResult
35
35
  }
36
36
  }
37
+
38
+ // Exec ADB with diagnostics — moved from src/android/diagnostics.ts
39
+ import { spawnSync } from 'child_process'
40
+ import { getAdbCmd } from '../android/utils.js'
41
+
42
+ export function execAdbWithDiagnostics(args: string[], deviceId?: string) {
43
+ const adbArgs = deviceId ? ['-s', deviceId, ...args] : args
44
+ const timeout = 120000
45
+ const res = spawnSync(getAdbCmd(), adbArgs, { encoding: 'utf8', timeout })
46
+ const runResult: RunResult = {
47
+ exitCode: typeof res.status === 'number' ? res.status : null,
48
+ stdout: res.stdout || '',
49
+ stderr: res.stderr || '',
50
+ envSnapshot: makeEnvSnapshot(['PATH','ADB_PATH','HOME','JAVA_HOME']),
51
+ command: getAdbCmd(),
52
+ args: adbArgs,
53
+ suggestedFixes: []
54
+ }
55
+ if (res.status !== 0) {
56
+ if ((runResult.stderr || '').includes('device not found')) runResult.suggestedFixes!.push('Ensure device is connected and adb is authorized (adb devices)')
57
+ if ((runResult.stderr || '').includes('No such file or directory')) runResult.suggestedFixes!.push('Verify ADB_PATH or that adb is installed')
58
+ }
59
+ return { runResult }
60
+ }
@@ -1,6 +1,6 @@
1
- import { DeviceInfo } from "./types.js"
2
- import { listAndroidDevices } from "./android/utils.js"
3
- import { listIOSDevices } from "./ios/utils.js"
1
+ import { DeviceInfo } from "../types.js"
2
+ import { listAndroidDevices } from "../android/utils.js"
3
+ import { listIOSDevices } from "../ios/utils.js"
4
4
 
5
5
  export interface ResolveOptions {
6
6
  platform: "android" | "ios"
@@ -11,7 +11,6 @@ export interface ResolveOptions {
11
11
 
12
12
  function parseNumericVersion(v: string): number {
13
13
  if (!v) return 0
14
- // extract first number groups like 17.0 -> 17.0 or Android 12 -> 12
15
14
  const m = v.match(/(\d+)(?:[\.\-](\d+))?/)
16
15
  if (!m) return 0
17
16
  const major = parseInt(m[1], 10) || 0
@@ -23,7 +22,6 @@ export async function listDevices(platform?: "android" | "ios", appId?: string):
23
22
  if (!platform || platform === "android") {
24
23
  const android = await listAndroidDevices(appId)
25
24
  if (platform === "android") return android
26
- // if no platform specified, merge with ios below
27
25
  const ios = await listIOSDevices(appId)
28
26
  return [...android, ...ios]
29
27
  }
@@ -42,11 +40,9 @@ export async function resolveTargetDevice(opts: ResolveOptions): Promise<DeviceI
42
40
 
43
41
  let candidates = devices.slice()
44
42
 
45
- // Apply prefer filter
46
43
  if (prefer === "physical") candidates = candidates.filter(d => !d.simulator)
47
44
  if (prefer === "emulator") candidates = candidates.filter(d => d.simulator)
48
45
 
49
- // If appId provided, prefer devices with appInstalled
50
46
  if (appId) {
51
47
  const installed = candidates.filter(d => (d as any).appInstalled)
52
48
  if (installed.length > 0) candidates = installed
@@ -55,21 +51,17 @@ export async function resolveTargetDevice(opts: ResolveOptions): Promise<DeviceI
55
51
  if (candidates.length === 1) return candidates[0]
56
52
 
57
53
  if (candidates.length > 1) {
58
- // Prefer physical over emulator unless prefer=emulator
59
54
  if (!prefer) {
60
55
  const physical = candidates.filter(d => !d.simulator)
61
56
  if (physical.length === 1) return physical[0]
62
57
  if (physical.length > 1) candidates = physical
63
58
  }
64
59
 
65
- // Pick highest OS version
66
60
  candidates.sort((a, b) => parseNumericVersion(b.osVersion) - parseNumericVersion(a.osVersion))
67
- // If top is unique (numeric differs), return it
68
61
  if (candidates.length > 1 && parseNumericVersion(candidates[0].osVersion) > parseNumericVersion(candidates[1].osVersion)) {
69
62
  return candidates[0]
70
63
  }
71
64
 
72
- // Ambiguous: throw an error with candidate list so caller (agent) can present choices
73
65
  const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: (d as any).appInstalled }))
74
66
  const err = new Error(`Multiple matching devices found: ${JSON.stringify(list, null, 2)}`)
75
67
  ;(err as any).devices = list
@@ -1,23 +0,0 @@
1
- import { spawnSync } from 'child_process'
2
- import { getAdbCmd } from './utils.js'
3
- import { RunResult, makeEnvSnapshot } from '../utils/diagnostics.js'
4
-
5
- export function execAdbWithDiagnostics(args: string[], deviceId?: string) {
6
- const adbArgs = deviceId ? ['-s', deviceId, ...args] : args
7
- const timeout = 120000
8
- const res = spawnSync(getAdbCmd(), adbArgs, { encoding: 'utf8', timeout }) as any
9
- const runResult: RunResult = {
10
- exitCode: typeof res.status === 'number' ? res.status : null,
11
- stdout: res.stdout || '',
12
- stderr: res.stderr || '',
13
- envSnapshot: makeEnvSnapshot(['PATH','ADB_PATH','HOME','JAVA_HOME']),
14
- command: getAdbCmd(),
15
- args: adbArgs,
16
- suggestedFixes: []
17
- }
18
- if (res.status !== 0) {
19
- if ((runResult.stderr || '').includes('device not found')) runResult.suggestedFixes!.push('Ensure device is connected and adb is authorized (adb devices)')
20
- if ((runResult.stderr || '').includes('No such file or directory')) runResult.suggestedFixes!.push('Verify ADB_PATH or that adb is installed')
21
- }
22
- return { runResult }
23
- }