mobile-debug-mcp 0.12.1 → 0.12.3

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,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,31 +51,77 @@ 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
+
74
+ // Prepare build flags and paths (support incremental builds)
52
75
  let buildArgs: string[]
76
+ let chosenScheme: string | null = null
77
+
78
+ // Derived data and result bundle (agent-configurable)
79
+ const derivedDataPath = process.env.MCP_DERIVED_DATA || path.join(projectRootDir, 'build', 'DerivedData')
80
+ const resultBundlePath = path.join(projectRootDir, 'build', 'xcresults', 'ResultBundle.xcresult')
81
+ const xcodeJobs = parseInt(process.env.MCP_XCODE_JOBS || '', 10) || 4
82
+ const forceClean = process.env.MCP_FORCE_CLEAN === '1'
83
+
84
+ // ensure result dirs exist
85
+ await fs.mkdir(path.dirname(resultBundlePath), { recursive: true }).catch(() => {})
86
+ await fs.mkdir(derivedDataPath, { recursive: true }).catch(() => {})
87
+
53
88
  if (workspace) {
54
89
  const workspacePath = path.join(projectRootDir, workspace)
55
- const scheme = workspace.replace(/\.xcworkspace$/, '')
90
+ chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir)
91
+ const scheme = chosenScheme || workspace.replace(/\.xcworkspace$/, '')
56
92
  buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build']
57
93
  } else {
58
94
  const projectPathFull = path.join(projectRootDir, proj!)
59
- const scheme = proj!.replace(/\.xcodeproj$/, '')
95
+ chosenScheme = await detectScheme(xcodeCmd, undefined, projectPathFull, projectRootDir)
96
+ const scheme = chosenScheme || proj!.replace(/\.xcodeproj$/, '')
60
97
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build']
61
98
  }
62
99
 
100
+ // Insert clean if explicitly requested via env
101
+ if (forceClean) {
102
+ const idx = buildArgs.indexOf('build')
103
+ if (idx >= 0) buildArgs.splice(idx, 0, 'clean')
104
+ }
105
+
63
106
  // If we have a destination UDID, add an explicit destination to avoid xcodebuild picking an ambiguous target
64
107
  if (destinationUDID) {
65
108
  buildArgs.push('-destination', `platform=iOS Simulator,id=${destinationUDID}`)
66
109
  }
67
110
 
68
- // Add result bundle path for diagnostics
111
+ // Add derived data and result bundle for diagnostics and faster incremental builds
112
+ buildArgs.push('-derivedDataPath', derivedDataPath)
113
+ buildArgs.push('-resultBundlePath', resultBundlePath)
114
+ // parallelisation and jobs
115
+ buildArgs.push('-parallelizeTargets')
116
+ buildArgs.push('-jobs', String(xcodeJobs))
117
+
118
+ // Prepare results directory for backwards-compatible logs
69
119
  const resultsDir = path.join(projectPath, 'build-results')
70
120
  // Remove any stale results to avoid xcodebuild complaining about existing result bundles
71
121
  await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => {})
72
122
  await fs.mkdir(resultsDir, { recursive: true }).catch(() => {})
73
- // Skip specifying -resultBundlePath to avoid platform-specific collisions; rely on stdout/stderr logs
74
123
 
75
124
 
76
- const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild'
77
125
  const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000 // default 3 minutes
78
126
  const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1
79
127
 
@@ -85,7 +133,7 @@ export class iOSManage {
85
133
  for (let attempt = 1; attempt <= tries; attempt++) {
86
134
  // Run xcodebuild with a watchdog
87
135
  const res = await new Promise<{ code: number | null, stdout: string, stderr: string, killedByWatchdog?: boolean }>((resolve) => {
88
- const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath })
136
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir })
89
137
  let stdout = ''
90
138
  let stderr = ''
91
139
 
@@ -119,6 +167,10 @@ export class iOSManage {
119
167
 
120
168
  // record the failure for reporting
121
169
  lastErr = new Error(res.stderr || `xcodebuild failed with code ${res.code}`)
170
+ // Attach exit code and watchdog info so diagnostics can include them
171
+ ;(lastErr as any).code = res.code
172
+ ;(lastErr as any).exitCode = res.code
173
+ ;(lastErr as any).killedByWatchdog = !!res.killedByWatchdog
122
174
 
123
175
  // write logs for diagnostics (helpful whether killed or not)
124
176
  try {
@@ -136,8 +188,10 @@ export class iOSManage {
136
188
  }
137
189
 
138
190
  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}` }
191
+ // Include diagnostics and result bundle path when available; provide structured info useful for agents
192
+ const invokedCommand = `${xcodeCmd} ${buildArgs.map(a => a.includes(' ') ? `"${a}"` : a).join(' ')}`
193
+ const envSnapshot = { PATH: process.env.PATH }
194
+ 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
195
  }
142
196
 
143
197
  // Try to locate built .app. First search project tree, then DerivedData if necessary
package/src/ios/utils.ts CHANGED
@@ -246,6 +246,7 @@ export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise
246
246
 
247
247
  export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
248
248
  return new Promise((resolve) => {
249
+ // Query all devices and separately query booted devices to mark them
249
250
  execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
250
251
  if (err || !stdout) return resolve([])
251
252
  try {
@@ -254,33 +255,49 @@ export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
254
255
  const out: DeviceInfo[] = []
255
256
  const checks: Promise<void>[] = []
256
257
 
257
- for (const runtime in devicesMap) {
258
- const devices = devicesMap[runtime]
259
- if (Array.isArray(devices)) {
260
- for (const device of devices) {
261
- const info: any = {
262
- platform: 'ios',
263
- id: device.udid,
264
- osVersion: parseRuntimeName(runtime),
265
- model: device.name,
266
- simulator: true
258
+ // Get booted devices set
259
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err2, stdout2) => {
260
+ const bootedSet = new Set<string>()
261
+ if (!err2 && stdout2) {
262
+ try {
263
+ const bdata = JSON.parse(stdout2)
264
+ const bmap = bdata.devices || {}
265
+ for (const rt in bmap) {
266
+ const devs = bmap[rt]
267
+ if (Array.isArray(devs)) for (const d of devs) bootedSet.add(d.udid)
267
268
  }
269
+ } catch {}
270
+ }
268
271
 
269
- if (appId) {
270
- // check if installed
271
- const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
272
- .then(() => { info.appInstalled = true })
273
- .catch(() => { info.appInstalled = false })
274
- .then(() => { out.push(info) })
275
- checks.push(p)
276
- } else {
277
- out.push(info)
272
+ for (const runtime in devicesMap) {
273
+ const devices = devicesMap[runtime]
274
+ if (Array.isArray(devices)) {
275
+ for (const device of devices) {
276
+ const info: any = {
277
+ platform: 'ios',
278
+ id: device.udid,
279
+ osVersion: parseRuntimeName(runtime),
280
+ model: device.name,
281
+ simulator: true,
282
+ booted: bootedSet.has(device.udid)
283
+ }
284
+
285
+ if (appId) {
286
+ // check if installed
287
+ const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
288
+ .then(() => { info.appInstalled = true })
289
+ .catch(() => { info.appInstalled = false })
290
+ .then(() => { out.push(info) })
291
+ checks.push(p)
292
+ } else {
293
+ out.push(info)
294
+ }
278
295
  }
279
296
  }
280
297
  }
281
- }
282
298
 
283
- Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out))
299
+ Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out))
300
+ })
284
301
  } catch {
285
302
  resolve([])
286
303
  }
package/src/server.ts CHANGED
@@ -139,6 +139,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
139
139
  type: "object",
140
140
  properties: {
141
141
  platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from appPath/project files." },
142
+ projectType: { type: "string", enum: ["native","kmp","react-native","flutter"], description: "Optional project type to guide build tool selection (e.g., kmp, react-native)." },
142
143
  appPath: { type: "string", description: "Path to APK, .app, .ipa, or project directory" },
143
144
  deviceId: { type: "string", description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected." }
144
145
  },
@@ -152,12 +153,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
152
153
  type: "object",
153
154
  properties: {
154
155
  platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from projectPath files." },
156
+ projectType: { type: "string", enum: ["native","kmp","react-native","flutter"], description: "Optional project type to guide build tool selection (e.g., kmp, react-native)." },
155
157
  projectPath: { type: "string", description: "Path to project directory (contains gradlew or xcodeproj/xcworkspace)" },
156
158
  variant: { type: "string", description: "Optional build variant (e.g., Debug/Release)" }
157
159
  },
158
160
  required: ["projectPath"]
159
161
  }
160
162
  },
163
+
161
164
  {
162
165
  name: "get_logs",
163
166
  description: "Get recent logs from Android or iOS simulator. Returns device metadata and the log output.",
@@ -440,8 +443,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
440
443
  }
441
444
 
442
445
  if (name === "install_app") {
443
- const { platform, appPath, deviceId } = args as any
444
- const res = await ToolsManage.installAppHandler({ platform, appPath, deviceId })
446
+ const { platform, projectType, appPath, deviceId } = args as any
447
+ const res = await ToolsManage.installAppHandler({ platform, appPath, deviceId, projectType })
445
448
  const response: InstallAppResponse = {
446
449
  device: res.device,
447
450
  installed: res.installed,
@@ -452,14 +455,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
452
455
  }
453
456
 
454
457
  if (name === "build_app") {
455
- const { platform, projectPath, variant } = args as any
456
- const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant })
458
+ const { platform, projectType, projectPath, variant } = args as any
459
+ const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant, projectType })
457
460
  return wrapResponse(res)
458
461
  }
459
462
 
460
463
  if (name === 'build_and_install') {
461
- const { platform, projectPath, deviceId, timeout } = args as any
462
- const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout })
464
+ const { platform, projectType, projectPath, deviceId, timeout } = args as any
465
+ const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout, projectType })
463
466
  // res: { ndjson, result }
464
467
  return {
465
468
  content: [
@@ -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,12 +1,139 @@
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
+ import { findApk } from '../android/utils.js'
7
+ import { findAppBundle } from '../ios/utils.js'
8
+ import { execSync } from 'child_process'
6
9
  import type { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
7
10
 
11
+ export async function detectProjectPlatform(projectPath: string): Promise<'ios'|'android'|'ambiguous'|'unknown'> {
12
+ try {
13
+ const stat = await fs.stat(projectPath).catch(() => null)
14
+ if (stat && stat.isDirectory()) {
15
+ const files = (await fs.readdir(projectPath).catch(() => [])) as string[]
16
+ const hasIos = files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))
17
+ const hasAndroid = files.includes('gradlew') || files.includes('build.gradle') || files.includes('settings.gradle') || (files.includes('app') && (await fs.stat(path.join(projectPath, 'app')).catch(() => null)))
18
+ if (hasIos && !hasAndroid) return 'ios'
19
+ if (hasAndroid && !hasIos) return 'android'
20
+ if (hasIos && hasAndroid) return 'ambiguous'
21
+ return 'unknown'
22
+ } else {
23
+ const ext = path.extname(projectPath).toLowerCase()
24
+ if (ext === '.apk') return 'android'
25
+ if (ext === '.ipa' || ext === '.app') return 'ios'
26
+ return 'unknown'
27
+ }
28
+ } catch {
29
+ return 'unknown'
30
+ }
31
+ }
32
+
8
33
  export class ToolsManage {
9
- static async buildAppHandler({ platform, projectPath, variant }: { platform?: 'android' | 'ios', projectPath: string, variant?: string }) {
34
+ static async build_android({ projectPath, gradleTask, maxWorkers, gradleCache, forceClean }: { projectPath: string, gradleTask?: string, maxWorkers?: number, gradleCache?: boolean, forceClean?: boolean }) {
35
+ const android = new AndroidManage()
36
+ // prepare gradle options via environment hints
37
+ if (typeof maxWorkers === 'number') process.env.MCP_GRADLE_WORKERS = String(maxWorkers)
38
+ if (typeof gradleCache === 'boolean') process.env.MCP_GRADLE_CACHE = gradleCache ? '1' : '0'
39
+ if (forceClean) process.env.MCP_FORCE_CLEAN_ANDROID = '1'
40
+ const task = gradleTask || 'assembleDebug'
41
+ const artifact = await (android as any).build(projectPath, task)
42
+ return artifact
43
+ }
44
+
45
+ static async build_ios({ projectPath, workspace: _workspace, project: _project, scheme: _scheme, destinationUDID, derivedDataPath, buildJobs, forceClean }: { projectPath: string, workspace?: string, project?: string, scheme?: string, destinationUDID?: string, derivedDataPath?: string, buildJobs?: number, forceClean?: boolean }) {
46
+ const ios = new iOSManage()
47
+ // silence unused param lints
48
+ void _workspace; void _project; void _scheme;
49
+ if (derivedDataPath) process.env.MCP_DERIVED_DATA = derivedDataPath
50
+ if (typeof buildJobs === 'number') process.env.MCP_BUILD_JOBS = String(buildJobs)
51
+ if (forceClean) process.env.MCP_FORCE_CLEAN_IOS = '1'
52
+ if (destinationUDID) process.env.MCP_XCODE_DESTINATION_UDID = destinationUDID
53
+ const artifact = await (ios as any).build(projectPath)
54
+ return artifact
55
+ }
56
+
57
+ static async build_flutter({ projectPath, platform, buildMode, maxWorkers: _maxWorkers, forceClean: _forceClean }: { projectPath: string, platform?: 'android'|'ios', buildMode?: 'debug'|'release'|'profile', maxWorkers?: number, forceClean?: boolean }) {
58
+ // Prefer using flutter CLI when available; otherwise delegate to native subproject builders
59
+ const flutterCmd = process.env.FLUTTER_PATH || 'flutter'
60
+ // silence unused params
61
+ void _maxWorkers; void _forceClean;
62
+ try {
63
+ // Check flutter presence without streaming output
64
+ execSync(`${flutterCmd} --version`, { stdio: 'ignore' })
65
+
66
+ if (!platform || platform === 'android') {
67
+ const mode = buildMode || 'debug'
68
+ try {
69
+ const out = execSync(`${flutterCmd} build apk --${mode}`, { cwd: projectPath, encoding: 'utf8' })
70
+ // Try to find built APK
71
+ const apk = await findApk(path.join(projectPath))
72
+ if (apk) return { artifactPath: apk, output: out }
73
+ } catch (err: any) {
74
+ const stdout = err && err.stdout ? String(err.stdout) : ''
75
+ const stderr = err && err.stderr ? String(err.stderr) : ''
76
+ throw new Error(`flutter build apk failed: ${stderr || stdout || err.message}`)
77
+ }
78
+ }
79
+
80
+ if (!platform || platform === 'ios') {
81
+ const mode = buildMode || 'debug'
82
+ try {
83
+ const out = execSync(`${flutterCmd} build ios --${mode} --no-codesign`, { cwd: projectPath, encoding: 'utf8' })
84
+ const app = await findAppBundle(path.join(projectPath))
85
+ if (app) return { artifactPath: app, output: out }
86
+ } catch (err: any) {
87
+ const stdout = err && err.stdout ? String(err.stdout) : ''
88
+ const stderr = err && err.stderr ? String(err.stderr) : ''
89
+ throw new Error(`flutter build ios failed: ${stderr || stdout || err.message}`)
90
+ }
91
+ }
92
+ } catch (e) {
93
+ // If flutter CLI not available or command fails, fall back to native subprojects
94
+ // Preserve error message for diagnostics if needed
95
+ void e
96
+ }
97
+
98
+ // Fallback: try native subproject builds
99
+ if (!platform || platform === 'android') {
100
+ const androidDir = path.join(projectPath, 'android')
101
+ const android = new AndroidManage()
102
+ const artifact = await (android as any).build(androidDir, _forceClean ? 'clean && assembleDebug' : 'assembleDebug')
103
+ return artifact
104
+ }
105
+ if (!platform || platform === 'ios') {
106
+ const iosDir = path.join(projectPath, 'ios')
107
+ const ios = new iOSManage()
108
+ const artifact = await (ios as any).build(iosDir)
109
+ return artifact
110
+ }
111
+
112
+ return { error: 'Unable to build flutter project' }
113
+ }
114
+
115
+ static async build_react_native({ projectPath, platform, variant, maxWorkers: _maxWorkers, forceClean: _forceClean }: { projectPath: string, platform?: 'android'|'ios', variant?: string, maxWorkers?: number, forceClean?: boolean }) {
116
+ // silence unused params
117
+ void _maxWorkers; void _forceClean;
118
+ // React Native typically uses native subprojects. Delegate to Android/iOS builders.
119
+ if (!platform || platform === 'android') {
120
+ const androidDir = path.join(projectPath, 'android')
121
+ const android = new AndroidManage()
122
+ const artifact = await (android as any).build(androidDir, variant || 'assembleDebug')
123
+ return artifact
124
+ }
125
+ if (!platform || platform === 'ios') {
126
+ const iosDir = path.join(projectPath, 'ios')
127
+ // Recommend running `pod install` prior to building in CI; not performed automatically here
128
+ const ios = new iOSManage()
129
+ const artifact = await (ios as any).build(iosDir)
130
+ return artifact
131
+ }
132
+ return { error: 'Unable to build react-native project' }
133
+ }
134
+
135
+ static async buildAppHandler({ platform, projectPath, variant, projectType: _projectType }: { platform?: 'android' | 'ios', projectPath: string, variant?: string, projectType?: 'native' | 'kmp' | 'react-native' | 'flutter' }) {
136
+ void _projectType;
10
137
  // delegate to platform-specific build implementations
11
138
  const chosen = platform || 'android'
12
139
  if (chosen === 'android') {
@@ -20,8 +147,19 @@ export class ToolsManage {
20
147
  }
21
148
  }
22
149
 
23
- static async installAppHandler({ platform, appPath, deviceId }: { platform?: 'android' | 'ios', appPath: string, deviceId?: string }): Promise<InstallAppResponse> {
24
- let chosenPlatform: 'android' | 'ios' | undefined = platform
150
+ static async installAppHandler({ platform, appPath, deviceId, projectType }: { platform?: 'android' | 'ios', appPath: string, deviceId?: string, projectType?: 'native' | 'kmp' | 'react-native' | 'flutter' }): Promise<InstallAppResponse> {
151
+ // Use projectType hint to influence platform detection when explicit platform is not provided
152
+ let chosenPlatform: 'android'|'ios'|undefined = platform
153
+ if (!chosenPlatform && projectType) {
154
+ // Heuristic defaults: KMP, React Native and Flutter commonly target Android by default in CI
155
+ if (projectType === 'kmp' || projectType === 'react-native' || projectType === 'flutter') {
156
+ chosenPlatform = 'android'
157
+ console.debug('[manage] projectType hint -> selecting android by default for', projectType)
158
+ } else if (projectType === 'native' || projectType === 'ios') {
159
+ chosenPlatform = 'ios'
160
+ console.debug('[manage] projectType hint -> selecting ios by default for', projectType)
161
+ }
162
+ }
25
163
 
26
164
  try {
27
165
  const stat = await fs.stat(appPath).catch(() => null)
@@ -102,30 +240,41 @@ export class ToolsManage {
102
240
  }
103
241
  }
104
242
 
105
- static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout }: { platform?: 'android' | 'ios', projectPath: string, deviceId?: string, timeout?: number }) {
243
+ static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout, projectType }: { platform?: 'android' | 'ios', projectPath: string, deviceId?: string, timeout?: number, projectType?: 'native' | 'kmp' | 'react-native' | 'flutter' }) {
106
244
  const events: string[] = []
107
245
  const pushEvent = (obj: any) => events.push(JSON.stringify(obj))
108
246
  const effectiveTimeout = timeout ?? 180000 // reserved for future streaming/timeouts
109
247
  void effectiveTimeout
110
248
 
111
- // determine platform if not provided by inspecting path
249
+ // determine platform if not provided by inspecting path or projectType hint
112
250
  let chosenPlatform = platform
113
251
  try {
114
- const stat = await fs.stat(projectPath).catch(() => null)
115
252
  if (!chosenPlatform) {
116
- if (stat && stat.isDirectory()) {
117
- const files = (await fs.readdir(projectPath).catch(() => [])) as string[]
118
- if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) chosenPlatform = 'ios'
119
- else chosenPlatform = 'android'
253
+ // If autodetect is disabled, require explicit platform or projectType
254
+ if (process.env.MCP_DISABLE_AUTODETECT === '1') {
255
+ pushEvent({ type: 'build', status: 'failed', error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType' })
256
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType (ios|android).' } }
257
+ }
258
+ // If caller indicated KMP, prefer android by default (most KMP modul8 setups target Android)
259
+ if (projectType === 'kmp') {
260
+ chosenPlatform = 'android'
261
+ pushEvent({ type: 'build', status: 'info', message: 'projectType=kmp -> selecting android platform by default' })
120
262
  } else {
121
- const ext = path.extname(projectPath).toLowerCase()
122
- if (ext === '.apk') chosenPlatform = 'android'
123
- else if (ext === '.ipa' || ext === '.app') chosenPlatform = 'ios'
124
- else chosenPlatform = 'android'
263
+ const det = await detectProjectPlatform(projectPath)
264
+ if (det === 'ios' || det === 'android') {
265
+ chosenPlatform = det
266
+ } else if (det === 'ambiguous') {
267
+ pushEvent({ type: 'build', status: 'failed', error: 'Ambiguous project (contains both iOS and Android). Please provide platform: "ios" or "android".' })
268
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Ambiguous project - please provide explicit platform parameter (ios|android).' } }
269
+ } else {
270
+ // Unknown project type - do not guess. Request explicit platform.
271
+ pushEvent({ type: 'build', status: 'failed', error: 'Unknown project type - unable to autodetect platform. Please provide platform or projectType.' })
272
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Unknown project type - please provide platform or projectType (ios|android).' } }
273
+ }
125
274
  }
126
275
  }
127
276
  } catch {
128
- chosenPlatform = chosenPlatform || 'android'
277
+ // detection failed; avoid guessing a platform
129
278
  }
130
279
 
131
280
  pushEvent({ type: 'build', status: 'started', platform: chosenPlatform })
@@ -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,24 @@ 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
60
+ // Prefer booted iOS simulators if present
61
+ if (platform === 'ios') {
62
+ const booted = candidates.filter((d: any) => !!d.booted)
63
+ if (booted.length === 1) return booted[0]
64
+ if (booted.length > 1) return booted[0] // if multiple booted, pick the first
65
+ }
66
+
66
67
  candidates.sort((a, b) => parseNumericVersion(b.osVersion) - parseNumericVersion(a.osVersion))
67
- // If top is unique (numeric differs), return it
68
68
  if (candidates.length > 1 && parseNumericVersion(candidates[0].osVersion) > parseNumericVersion(candidates[1].osVersion)) {
69
69
  return candidates[0]
70
70
  }
71
71
 
72
- // Ambiguous: throw an error with candidate list so caller (agent) can present choices
73
72
  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
73
  const err = new Error(`Multiple matching devices found: ${JSON.stringify(list, null, 2)}`)
75
74
  ;(err as any).devices = list
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { ToolsManage } from '../../../dist/tools/manage.js'
3
+ import path from 'path'
4
+
5
+ async function main() {
6
+ // Prefer a repo-local sample modul8 project if present, otherwise allow overriding via KMP_PROJECT env var
7
+ const defaultRelative = path.join(process.cwd(), '..', '..', '..', '..', 'test-fixtures', 'modul8')
8
+ const project = process.env.KMP_PROJECT || defaultRelative
9
+ console.log('Running KMP build+install for project', project)
10
+ // Use projectType=kmp and let handler pick android by default for KMP
11
+ // Request iOS explicitly for this run to test iOS build path
12
+ const res = await ToolsManage.buildAndInstallHandler({ platform: 'ios', projectPath: project, projectType: 'kmp', timeout: 600000, deviceId: undefined })
13
+ console.log(JSON.stringify(res, null, 2))
14
+ if (res.result && res.result.success) process.exit(0)
15
+ process.exit(1)
16
+ }
17
+
18
+ main().catch(e => { console.error(e); process.exit(2) })
@@ -7,5 +7,7 @@ import './manage/install.test.ts'
7
7
  import './manage/build.test.ts'
8
8
  import './manage/build_and_install.test.ts'
9
9
  import './manage/diagnostics.test.ts'
10
+ import './manage/detection.test.ts'
11
+ import './manage/mcp_disable_autodetect.test.ts'
10
12
 
11
13
  console.log('Unit tests loaded.')