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/README.md +1 -1
- package/dist/android/diagnostics.js +1 -24
- package/dist/android/manage.js +1 -1
- package/dist/android/utils.js +11 -1
- package/dist/ios/manage.js +60 -11
- package/dist/ios/utils.js +41 -22
- package/dist/server.js +8 -6
- package/dist/tools/interact.js +1 -1
- package/dist/tools/manage.js +181 -20
- package/dist/tools/observe.js +1 -1
- package/dist/utils/diagnostics.js +24 -0
- package/dist/utils/resolve-device.js +70 -0
- package/docs/CHANGELOG.md +7 -0
- package/docs/TOOLS.md +7 -268
- package/docs/interact.md +43 -0
- package/docs/manage.md +140 -0
- package/docs/observe.md +86 -0
- package/package.json +1 -1
- package/src/android/manage.ts +1 -1
- package/src/android/utils.ts +13 -1
- package/src/ios/manage.ts +66 -12
- package/src/ios/utils.ts +38 -21
- package/src/server.ts +9 -6
- package/src/tools/interact.ts +1 -1
- package/src/tools/manage.ts +165 -16
- package/src/tools/observe.ts +1 -1
- package/src/utils/diagnostics.ts +24 -0
- package/src/{resolve-device.ts → utils/resolve-device.ts} +10 -11
- package/test/device/manage/run-install-kmp.ts +18 -0
- package/test/unit/index.ts +2 -0
- package/test/unit/manage/detection.test.ts +48 -0
- package/test/unit/manage/diagnostics.test.ts +13 -8
- package/test/unit/manage/install.test.ts +6 -1
- package/test/unit/manage/mcp_disable_autodetect.test.ts +35 -0
- package/test/unit/observe/wait_for_element_mock.ts +1 -1
- package/src/android/diagnostics.ts +0 -23
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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: [
|
package/src/tools/interact.ts
CHANGED
package/src/tools/manage.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
else
|
|
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
|
-
|
|
277
|
+
// detection failed; avoid guessing a platform
|
|
129
278
|
}
|
|
130
279
|
|
|
131
280
|
pushEvent({ type: 'build', status: 'started', platform: chosenPlatform })
|
package/src/tools/observe.ts
CHANGED
package/src/utils/diagnostics.ts
CHANGED
|
@@ -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 "
|
|
2
|
-
import { listAndroidDevices } from "
|
|
3
|
-
import { listIOSDevices } from "
|
|
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
|
-
//
|
|
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) })
|
package/test/unit/index.ts
CHANGED
|
@@ -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.')
|