mobile-debug-mcp 0.12.0 → 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/src/ios/manage.ts CHANGED
@@ -1,44 +1,191 @@
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
- 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
+ // 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)
40
+ if (!projectInfo) return { error: 'No Xcode project or workspace found' }
41
+ const projectRootDir = projectInfo.dir || absProjectPath
42
+ const workspace = projectInfo.workspace
43
+ const proj = projectInfo.proj
44
+
45
+ // Determine destination: prefer explicit env var, otherwise use booted simulator UDID
46
+ let destinationUDID = process.env.MCP_XCODE_DESTINATION_UDID || process.env.MCP_XCODE_DESTINATION || ''
47
+ if (!destinationUDID) {
48
+ try {
49
+ const meta = await getIOSDeviceMetadata('booted')
50
+ if (meta && meta.id) destinationUDID = meta.id
51
+ } catch {}
52
+ }
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
+ }
15
73
 
16
74
  let buildArgs: string[]
75
+ let chosenScheme: string | null = null
17
76
  if (workspace) {
18
- const workspacePath = path.join(projectPath, workspace)
19
- const scheme = workspace.replace(/\.xcworkspace$/, '')
77
+ const workspacePath = path.join(projectRootDir, workspace)
78
+ chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir)
79
+ const scheme = chosenScheme || workspace.replace(/\.xcworkspace$/, '')
20
80
  buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build']
21
81
  } else {
22
- const projectPathFull = path.join(projectPath, proj!)
23
- const scheme = proj!.replace(/\.xcodeproj$/, '')
82
+ const projectPathFull = path.join(projectRootDir, proj!)
83
+ chosenScheme = await detectScheme(xcodeCmd, undefined, projectPathFull, projectRootDir)
84
+ const scheme = chosenScheme || proj!.replace(/\.xcodeproj$/, '')
24
85
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build']
25
86
  }
26
87
 
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}`))
88
+ // If we have a destination UDID, add an explicit destination to avoid xcodebuild picking an ambiguous target
89
+ if (destinationUDID) {
90
+ buildArgs.push('-destination', `platform=iOS Simulator,id=${destinationUDID}`)
91
+ }
92
+
93
+ // Add result bundle path for diagnostics
94
+ const resultsDir = path.join(projectPath, 'build-results')
95
+ // Remove any stale results to avoid xcodebuild complaining about existing result bundles
96
+ await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => {})
97
+ await fs.mkdir(resultsDir, { recursive: true }).catch(() => {})
98
+ // Skip specifying -resultBundlePath to avoid platform-specific collisions; rely on stdout/stderr logs
99
+
100
+
101
+ const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000 // default 3 minutes
102
+ const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1
103
+
104
+ const tries = MAX_RETRIES + 1
105
+ let lastStdout = ''
106
+ let lastStderr = ''
107
+ let lastErr: any = null
108
+
109
+ for (let attempt = 1; attempt <= tries; attempt++) {
110
+ // Run xcodebuild with a watchdog
111
+ const res = await new Promise<{ code: number | null, stdout: string, stderr: string, killedByWatchdog?: boolean }>((resolve) => {
112
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir })
113
+ let stdout = ''
114
+ let stderr = ''
115
+
116
+ proc.stdout?.on('data', d => stdout += d.toString())
117
+ proc.stderr?.on('data', d => stderr += d.toString())
118
+
119
+ let killed = false
120
+ const to = setTimeout(() => {
121
+ killed = true
122
+ try { proc.kill('SIGKILL') } catch {}
123
+ }, XCODEBUILD_TIMEOUT)
124
+
125
+ proc.on('close', (code) => {
126
+ clearTimeout(to)
127
+ resolve({ code, stdout, stderr, killedByWatchdog: killed })
128
+ })
129
+ proc.on('error', (err) => {
130
+ clearTimeout(to)
131
+ resolve({ code: null, stdout, stderr: String(err), killedByWatchdog: killed })
132
+ })
35
133
  })
36
- proc.on('error', err => reject(err))
37
- })
38
134
 
135
+ lastStdout = res.stdout
136
+ lastStderr = res.stderr
137
+
138
+ if (res.code === 0) {
139
+ // success — clear any previous error and stop retrying
140
+ lastErr = null
141
+ break
142
+ }
143
+
144
+ // record the failure for reporting
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
150
+
151
+ // write logs for diagnostics (helpful whether killed or not)
152
+ try {
153
+ await fs.writeFile(path.join(resultsDir, `xcodebuild-${attempt}.stdout.log`), res.stdout).catch(() => {})
154
+ await fs.writeFile(path.join(resultsDir, `xcodebuild-${attempt}.stderr.log`), res.stderr).catch(() => {})
155
+ } catch {}
156
+
157
+ // If killed by watchdog and there are remaining attempts, continue to retry
158
+ if (res.killedByWatchdog && attempt < tries) {
159
+ continue
160
+ }
161
+
162
+ // no more retries or not a watchdog kill — break to report lastErr
163
+ if (attempt >= tries) break
164
+ }
165
+
166
+ if (lastErr) {
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 } }
171
+ }
172
+
173
+ // Try to locate built .app. First search project tree, then DerivedData if necessary
39
174
  const built = await findAppBundle(projectPath)
40
- if (!built) return { error: 'Could not find .app after build' }
41
- return { artifactPath: built }
175
+ if (built) return { artifactPath: built }
176
+
177
+ // Fallback: search DerivedData for matching product
178
+ const dd = path.join(process.env.HOME || '', 'Library', 'Developer', 'Xcode', 'DerivedData')
179
+ try {
180
+ const entries = await fs.readdir(dd).catch(() => [])
181
+ for (const e of entries) {
182
+ const candidate = path.join(dd, e)
183
+ const found = await findAppBundle(candidate).catch(() => undefined)
184
+ if (found) return { artifactPath: found }
185
+ }
186
+ } catch {}
187
+
188
+ return { error: 'Could not find .app after build' }
42
189
  } catch (e) {
43
190
  return { error: e instanceof Error ? e.message : String(e) }
44
191
  }
@@ -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,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
- })();
@@ -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
- }