mobile-debug-mcp 0.12.2 → 0.12.4
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/dist/android/utils.js +11 -1
- package/dist/ios/manage.js +64 -17
- package/dist/ios/utils.js +41 -22
- package/dist/server.js +14 -12
- package/dist/tools/manage.js +234 -52
- package/dist/utils/resolve-device.js +8 -0
- package/docs/CHANGELOG.md +10 -0
- package/docs/tools/TOOLS.md +11 -0
- package/docs/tools/interact.md +43 -0
- package/docs/tools/manage.md +140 -0
- package/docs/tools/observe.md +86 -0
- package/package.json +1 -1
- package/src/android/utils.ts +13 -1
- package/src/ios/manage.ts +65 -17
- package/src/ios/utils.ts +38 -21
- package/src/server.ts +15 -12
- package/src/tools/manage.ts +210 -41
- package/src/utils/resolve-device.ts +7 -0
- 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 +36 -0
- package/test/unit/observe/wait_for_element_mock.ts +1 -1
- package/docs/TOOLS.md +0 -272
package/dist/android/utils.js
CHANGED
|
@@ -11,7 +11,17 @@ export async function prepareGradle(projectPath) {
|
|
|
11
11
|
const gradlewPath = path.join(projectPath, 'gradlew');
|
|
12
12
|
const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle';
|
|
13
13
|
const execCmd = existsSync(gradlewPath) ? gradlewPath : gradleCmd;
|
|
14
|
-
|
|
14
|
+
// Start with a default task; callers may append/override via env flags
|
|
15
|
+
const gradleArgs = [process.env.MCP_GRADLE_TASK || 'assembleDebug'];
|
|
16
|
+
// Respect generic MCP_BUILD_JOBS and Android-specific MCP_GRADLE_WORKERS
|
|
17
|
+
const workers = process.env.MCP_GRADLE_WORKERS || process.env.MCP_BUILD_JOBS;
|
|
18
|
+
if (workers) {
|
|
19
|
+
gradleArgs.push(`--max-workers=${workers}`);
|
|
20
|
+
}
|
|
21
|
+
// Respect gradle cache env: default enabled; set MCP_GRADLE_CACHE=0 to disable
|
|
22
|
+
if (process.env.MCP_GRADLE_CACHE === '0') {
|
|
23
|
+
gradleArgs.push('-Dorg.gradle.caching=false');
|
|
24
|
+
}
|
|
15
25
|
const detectedJavaHome = await detectJavaHome().catch(() => undefined);
|
|
16
26
|
const env = Object.assign({}, process.env);
|
|
17
27
|
if (detectedJavaHome) {
|
package/dist/ios/manage.js
CHANGED
|
@@ -3,11 +3,16 @@ 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 {
|
|
6
|
-
async build(projectPath,
|
|
7
|
-
|
|
6
|
+
async build(projectPath, optsOrVariant) {
|
|
7
|
+
// Support legacy variant string as second arg
|
|
8
|
+
let opts = {};
|
|
9
|
+
if (typeof optsOrVariant === 'string')
|
|
10
|
+
opts.variant = optsOrVariant;
|
|
11
|
+
else
|
|
12
|
+
opts = optsOrVariant || {};
|
|
8
13
|
try {
|
|
9
14
|
// Look for an Xcode workspace or project at the provided path. If not present, scan subdirectories (limited depth)
|
|
10
|
-
async function findProject(root, maxDepth =
|
|
15
|
+
async function findProject(root, maxDepth = 4) {
|
|
11
16
|
try {
|
|
12
17
|
const ents = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
13
18
|
for (const e of ents) {
|
|
@@ -36,14 +41,31 @@ export class iOSManage {
|
|
|
36
41
|
}
|
|
37
42
|
// Resolve projectPath to an absolute path to avoid cwd-relative resolution issues
|
|
38
43
|
const absProjectPath = path.resolve(projectPath);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// If caller supplied explicit workspace/project, prefer those and set projectRootDir accordingly
|
|
45
|
+
let projectRootDir = absProjectPath;
|
|
46
|
+
let workspace = opts.workspace;
|
|
47
|
+
let proj = opts.project;
|
|
48
|
+
if (workspace) {
|
|
49
|
+
// normalize workspace path and set root to its parent
|
|
50
|
+
workspace = path.isAbsolute(workspace) ? workspace : path.join(absProjectPath, workspace);
|
|
51
|
+
projectRootDir = path.dirname(workspace);
|
|
52
|
+
workspace = path.basename(workspace);
|
|
53
|
+
}
|
|
54
|
+
else if (proj) {
|
|
55
|
+
proj = path.isAbsolute(proj) ? proj : path.join(absProjectPath, proj);
|
|
56
|
+
projectRootDir = path.dirname(proj);
|
|
57
|
+
proj = path.basename(proj);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const projectInfo = await findProject(absProjectPath, 4);
|
|
61
|
+
if (!projectInfo)
|
|
62
|
+
return { error: 'No Xcode project or workspace found' };
|
|
63
|
+
projectRootDir = projectInfo.dir || absProjectPath;
|
|
64
|
+
workspace = projectInfo.workspace;
|
|
65
|
+
proj = projectInfo.proj;
|
|
66
|
+
}
|
|
67
|
+
// Determine destination: prefer explicit option, then env var, otherwise use booted simulator UDID
|
|
68
|
+
let destinationUDID = opts.destinationUDID || process.env.MCP_XCODE_DESTINATION_UDID || process.env.MCP_XCODE_DESTINATION || '';
|
|
47
69
|
if (!destinationUDID) {
|
|
48
70
|
try {
|
|
49
71
|
const meta = await getIOSDeviceMetadata('booted');
|
|
@@ -53,7 +75,7 @@ export class iOSManage {
|
|
|
53
75
|
catch { }
|
|
54
76
|
}
|
|
55
77
|
// Determine xcode command early so it can be used when detecting schemes
|
|
56
|
-
const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
|
|
78
|
+
const xcodeCmd = opts.xcodeCmd || process.env.XCODEBUILD_PATH || 'xcodebuild';
|
|
57
79
|
// Determine available schemes by querying xcodebuild -list rather than guessing
|
|
58
80
|
async function detectScheme(xcodeCmdInner, workspacePath, projectPathFull, cwd) {
|
|
59
81
|
try {
|
|
@@ -72,30 +94,55 @@ export class iOSManage {
|
|
|
72
94
|
catch { }
|
|
73
95
|
return null;
|
|
74
96
|
}
|
|
97
|
+
// Prepare build flags and paths (support incremental builds)
|
|
75
98
|
let buildArgs;
|
|
76
|
-
let chosenScheme = null;
|
|
99
|
+
let chosenScheme = opts.scheme || null;
|
|
100
|
+
// Derived data and result bundle (agent-configurable)
|
|
101
|
+
const derivedDataPath = opts.derivedDataPath || process.env.MCP_DERIVED_DATA || path.join(projectRootDir, 'build', 'DerivedData');
|
|
102
|
+
// Use unique result bundle path by default to avoid collisions
|
|
103
|
+
const resultBundlePath = process.env.MCP_XCODE_RESULTBUNDLE_PATH || path.join(projectRootDir, 'build', 'xcresults', `ResultBundle-${Date.now()}-${Math.random().toString(36).slice(2)}.xcresult`);
|
|
104
|
+
const xcodeJobs = parseInt(process.env.MCP_XCODE_JOBS || '', 10) || 4;
|
|
105
|
+
const forceClean = opts.forceClean || process.env.MCP_FORCE_CLEAN === '1';
|
|
106
|
+
// ensure result dirs exist
|
|
107
|
+
await fs.mkdir(path.dirname(resultBundlePath), { recursive: true }).catch(() => { });
|
|
108
|
+
await fs.mkdir(derivedDataPath, { recursive: true }).catch(() => { });
|
|
109
|
+
// remove any pre-existing result bundle path to avoid xcodebuild complaining
|
|
110
|
+
await fs.rm(resultBundlePath, { recursive: true, force: true }).catch(() => { });
|
|
77
111
|
if (workspace) {
|
|
78
112
|
const workspacePath = path.join(projectRootDir, workspace);
|
|
79
|
-
|
|
113
|
+
if (!chosenScheme)
|
|
114
|
+
chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir);
|
|
80
115
|
const scheme = chosenScheme || workspace.replace(/\.xcworkspace$/, '');
|
|
81
116
|
buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
|
|
82
117
|
}
|
|
83
118
|
else {
|
|
84
119
|
const projectPathFull = path.join(projectRootDir, proj);
|
|
85
|
-
|
|
120
|
+
if (!chosenScheme)
|
|
121
|
+
chosenScheme = await detectScheme(xcodeCmd, undefined, projectPathFull, projectRootDir);
|
|
86
122
|
const scheme = chosenScheme || proj.replace(/\.xcodeproj$/, '');
|
|
87
123
|
buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
|
|
88
124
|
}
|
|
125
|
+
// Insert clean if explicitly requested via env or opts
|
|
126
|
+
if (forceClean) {
|
|
127
|
+
const idx = buildArgs.indexOf('build');
|
|
128
|
+
if (idx >= 0)
|
|
129
|
+
buildArgs.splice(idx, 0, 'clean');
|
|
130
|
+
}
|
|
89
131
|
// If we have a destination UDID, add an explicit destination to avoid xcodebuild picking an ambiguous target
|
|
90
132
|
if (destinationUDID) {
|
|
91
133
|
buildArgs.push('-destination', `platform=iOS Simulator,id=${destinationUDID}`);
|
|
92
134
|
}
|
|
93
|
-
// Add result bundle
|
|
135
|
+
// Add derived data and result bundle for diagnostics and faster incremental builds
|
|
136
|
+
buildArgs.push('-derivedDataPath', derivedDataPath);
|
|
137
|
+
buildArgs.push('-resultBundlePath', resultBundlePath);
|
|
138
|
+
// parallelisation and jobs
|
|
139
|
+
buildArgs.push('-parallelizeTargets');
|
|
140
|
+
buildArgs.push('-jobs', String(xcodeJobs));
|
|
141
|
+
// Prepare results directory for backwards-compatible logs
|
|
94
142
|
const resultsDir = path.join(projectPath, 'build-results');
|
|
95
143
|
// Remove any stale results to avoid xcodebuild complaining about existing result bundles
|
|
96
144
|
await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => { });
|
|
97
145
|
await fs.mkdir(resultsDir, { recursive: true }).catch(() => { });
|
|
98
|
-
// Skip specifying -resultBundlePath to avoid platform-specific collisions; rely on stdout/stderr logs
|
|
99
146
|
const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000; // default 3 minutes
|
|
100
147
|
const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1;
|
|
101
148
|
const tries = MAX_RETRIES + 1;
|
package/dist/ios/utils.js
CHANGED
|
@@ -239,6 +239,7 @@ export async function getIOSDeviceMetadata(deviceId = "booted") {
|
|
|
239
239
|
}
|
|
240
240
|
export async function listIOSDevices(appId) {
|
|
241
241
|
return new Promise((resolve) => {
|
|
242
|
+
// Query all devices and separately query booted devices to mark them
|
|
242
243
|
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
|
|
243
244
|
if (err || !stdout)
|
|
244
245
|
return resolve([]);
|
|
@@ -247,32 +248,50 @@ export async function listIOSDevices(appId) {
|
|
|
247
248
|
const devicesMap = data.devices || {};
|
|
248
249
|
const out = [];
|
|
249
250
|
const checks = [];
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
// check if installed
|
|
263
|
-
const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
|
|
264
|
-
.then(() => { info.appInstalled = true; })
|
|
265
|
-
.catch(() => { info.appInstalled = false; })
|
|
266
|
-
.then(() => { out.push(info); });
|
|
267
|
-
checks.push(p);
|
|
251
|
+
// Get booted devices set
|
|
252
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err2, stdout2) => {
|
|
253
|
+
const bootedSet = new Set();
|
|
254
|
+
if (!err2 && stdout2) {
|
|
255
|
+
try {
|
|
256
|
+
const bdata = JSON.parse(stdout2);
|
|
257
|
+
const bmap = bdata.devices || {};
|
|
258
|
+
for (const rt in bmap) {
|
|
259
|
+
const devs = bmap[rt];
|
|
260
|
+
if (Array.isArray(devs))
|
|
261
|
+
for (const d of devs)
|
|
262
|
+
bootedSet.add(d.udid);
|
|
268
263
|
}
|
|
269
|
-
|
|
270
|
-
|
|
264
|
+
}
|
|
265
|
+
catch { }
|
|
266
|
+
}
|
|
267
|
+
for (const runtime in devicesMap) {
|
|
268
|
+
const devices = devicesMap[runtime];
|
|
269
|
+
if (Array.isArray(devices)) {
|
|
270
|
+
for (const device of devices) {
|
|
271
|
+
const info = {
|
|
272
|
+
platform: 'ios',
|
|
273
|
+
id: device.udid,
|
|
274
|
+
osVersion: parseRuntimeName(runtime),
|
|
275
|
+
model: device.name,
|
|
276
|
+
simulator: true,
|
|
277
|
+
booted: bootedSet.has(device.udid)
|
|
278
|
+
};
|
|
279
|
+
if (appId) {
|
|
280
|
+
// check if installed
|
|
281
|
+
const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
|
|
282
|
+
.then(() => { info.appInstalled = true; })
|
|
283
|
+
.catch(() => { info.appInstalled = false; })
|
|
284
|
+
.then(() => { out.push(info); });
|
|
285
|
+
checks.push(p);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
out.push(info);
|
|
289
|
+
}
|
|
271
290
|
}
|
|
272
291
|
}
|
|
273
292
|
}
|
|
274
|
-
|
|
275
|
-
|
|
293
|
+
Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out));
|
|
294
|
+
});
|
|
276
295
|
}
|
|
277
296
|
catch {
|
|
278
297
|
resolve([]);
|
package/dist/server.js
CHANGED
|
@@ -115,28 +115,30 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
name: "install_app",
|
|
118
|
-
description: "Install an app on Android or iOS. Accepts a built binary (apk/.ipa/.app) or a project directory to build then install.",
|
|
118
|
+
description: "Install an app on Android or iOS. Accepts a built binary (apk/.ipa/.app) or a project directory to build then install. platform and projectType are required.",
|
|
119
119
|
inputSchema: {
|
|
120
120
|
type: "object",
|
|
121
121
|
properties: {
|
|
122
|
-
platform: { type: "string", enum: ["android", "ios"], description: "
|
|
122
|
+
platform: { type: "string", enum: ["android", "ios"], description: "Platform to install to (required)." },
|
|
123
|
+
projectType: { type: "string", enum: ["native", "kmp", "react-native", "flutter"], description: "Project type to guide build/install tool selection (required)." },
|
|
123
124
|
appPath: { type: "string", description: "Path to APK, .app, .ipa, or project directory" },
|
|
124
125
|
deviceId: { type: "string", description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected." }
|
|
125
126
|
},
|
|
126
|
-
required: ["appPath"]
|
|
127
|
+
required: ["platform", "projectType", "appPath"]
|
|
127
128
|
}
|
|
128
129
|
},
|
|
129
130
|
{
|
|
130
131
|
name: "build_app",
|
|
131
|
-
description: "Build a project for Android or iOS and return the built artifact path. Does not install.",
|
|
132
|
+
description: "Build a project for Android or iOS and return the built artifact path. Does not install. platform and projectType are required.",
|
|
132
133
|
inputSchema: {
|
|
133
134
|
type: "object",
|
|
134
135
|
properties: {
|
|
135
|
-
platform: { type: "string", enum: ["android", "ios"], description: "
|
|
136
|
+
platform: { type: "string", enum: ["android", "ios"], description: "Platform to build for (required)." },
|
|
137
|
+
projectType: { type: "string", enum: ["native", "kmp", "react-native", "flutter"], description: "Project type to guide build tool selection (required)." },
|
|
136
138
|
projectPath: { type: "string", description: "Path to project directory (contains gradlew or xcodeproj/xcworkspace)" },
|
|
137
139
|
variant: { type: "string", description: "Optional build variant (e.g., Debug/Release)" }
|
|
138
140
|
},
|
|
139
|
-
required: ["projectPath"]
|
|
141
|
+
required: ["platform", "projectType", "projectPath"]
|
|
140
142
|
}
|
|
141
143
|
},
|
|
142
144
|
{
|
|
@@ -414,8 +416,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
414
416
|
return wrapResponse(response);
|
|
415
417
|
}
|
|
416
418
|
if (name === "install_app") {
|
|
417
|
-
const { platform, appPath, deviceId } = args;
|
|
418
|
-
const res = await ToolsManage.installAppHandler({ platform, appPath, deviceId });
|
|
419
|
+
const { platform, projectType, appPath, deviceId } = args;
|
|
420
|
+
const res = await ToolsManage.installAppHandler({ platform, appPath, deviceId, projectType });
|
|
419
421
|
const response = {
|
|
420
422
|
device: res.device,
|
|
421
423
|
installed: res.installed,
|
|
@@ -425,13 +427,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
425
427
|
return wrapResponse(response);
|
|
426
428
|
}
|
|
427
429
|
if (name === "build_app") {
|
|
428
|
-
const { platform, projectPath, variant } = args;
|
|
429
|
-
const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant });
|
|
430
|
+
const { platform, projectType, projectPath, variant } = args;
|
|
431
|
+
const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant, projectType });
|
|
430
432
|
return wrapResponse(res);
|
|
431
433
|
}
|
|
432
434
|
if (name === 'build_and_install') {
|
|
433
|
-
const { platform, projectPath, deviceId, timeout } = args;
|
|
434
|
-
const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout });
|
|
435
|
+
const { platform, projectType, projectPath, deviceId, timeout } = args;
|
|
436
|
+
const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout, projectType });
|
|
435
437
|
// res: { ndjson, result }
|
|
436
438
|
return {
|
|
437
439
|
content: [
|
package/dist/tools/manage.js
CHANGED
|
@@ -3,8 +3,196 @@ import path from 'path';
|
|
|
3
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';
|
|
9
|
+
export async function detectProjectPlatform(projectPath) {
|
|
10
|
+
// Recursively scan up to a limited depth for platform markers to avoid mis-detection
|
|
11
|
+
async function scan(dir, depth = 3) {
|
|
12
|
+
const res = { ios: false, android: false };
|
|
13
|
+
try {
|
|
14
|
+
const ents = await fs.readdir(dir).catch(() => []);
|
|
15
|
+
for (const e of ents) {
|
|
16
|
+
if (e.endsWith('.xcworkspace') || e.endsWith('.xcodeproj'))
|
|
17
|
+
res.ios = true;
|
|
18
|
+
if (e === 'gradlew' || e === 'build.gradle' || e === 'settings.gradle')
|
|
19
|
+
res.android = true;
|
|
20
|
+
if (res.ios && res.android)
|
|
21
|
+
return res;
|
|
22
|
+
}
|
|
23
|
+
if (depth <= 0)
|
|
24
|
+
return res;
|
|
25
|
+
for (const e of ents) {
|
|
26
|
+
try {
|
|
27
|
+
const full = path.join(dir, e);
|
|
28
|
+
const st = await fs.stat(full).catch(() => null);
|
|
29
|
+
if (st && st.isDirectory()) {
|
|
30
|
+
const child = await scan(full, depth - 1);
|
|
31
|
+
if (child.ios)
|
|
32
|
+
res.ios = true;
|
|
33
|
+
if (child.android)
|
|
34
|
+
res.android = true;
|
|
35
|
+
if (res.ios && res.android)
|
|
36
|
+
return res;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch { }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch { }
|
|
43
|
+
return res;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const stat = await fs.stat(projectPath).catch(() => null);
|
|
47
|
+
if (stat && stat.isDirectory()) {
|
|
48
|
+
const { ios: hasIos, android: hasAndroid } = await scan(projectPath, 3);
|
|
49
|
+
if (hasIos && !hasAndroid)
|
|
50
|
+
return 'ios';
|
|
51
|
+
if (hasAndroid && !hasIos)
|
|
52
|
+
return 'android';
|
|
53
|
+
if (hasIos && hasAndroid)
|
|
54
|
+
return 'ambiguous';
|
|
55
|
+
// no explicit markers found
|
|
56
|
+
return 'unknown';
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const ext = path.extname(projectPath).toLowerCase();
|
|
60
|
+
if (ext === '.apk')
|
|
61
|
+
return 'android';
|
|
62
|
+
if (ext === '.ipa' || ext === '.app')
|
|
63
|
+
return 'ios';
|
|
64
|
+
return 'unknown';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return 'unknown';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
6
71
|
export class ToolsManage {
|
|
7
|
-
static async
|
|
72
|
+
static async build_android({ projectPath, gradleTask, maxWorkers, gradleCache, forceClean }) {
|
|
73
|
+
const android = new AndroidManage();
|
|
74
|
+
// prepare gradle options via environment hints
|
|
75
|
+
if (typeof maxWorkers === 'number')
|
|
76
|
+
process.env.MCP_GRADLE_WORKERS = String(maxWorkers);
|
|
77
|
+
if (typeof gradleCache === 'boolean')
|
|
78
|
+
process.env.MCP_GRADLE_CACHE = gradleCache ? '1' : '0';
|
|
79
|
+
if (forceClean)
|
|
80
|
+
process.env.MCP_FORCE_CLEAN_ANDROID = '1';
|
|
81
|
+
const task = gradleTask || 'assembleDebug';
|
|
82
|
+
const artifact = await android.build(projectPath, task);
|
|
83
|
+
return artifact;
|
|
84
|
+
}
|
|
85
|
+
static async build_ios({ projectPath, workspace: _workspace, project: _project, scheme: _scheme, destinationUDID, derivedDataPath, buildJobs, forceClean }) {
|
|
86
|
+
const ios = new iOSManage();
|
|
87
|
+
// Use provided options rather than env-only; still set env fallbacks for downstream tools
|
|
88
|
+
if (derivedDataPath)
|
|
89
|
+
process.env.MCP_DERIVED_DATA = derivedDataPath;
|
|
90
|
+
if (typeof buildJobs === 'number')
|
|
91
|
+
process.env.MCP_BUILD_JOBS = String(buildJobs);
|
|
92
|
+
if (forceClean)
|
|
93
|
+
process.env.MCP_FORCE_CLEAN_IOS = '1';
|
|
94
|
+
if (destinationUDID)
|
|
95
|
+
process.env.MCP_XCODE_DESTINATION_UDID = destinationUDID;
|
|
96
|
+
const opts = {};
|
|
97
|
+
if (_workspace)
|
|
98
|
+
opts.workspace = _workspace;
|
|
99
|
+
if (_project)
|
|
100
|
+
opts.project = _project;
|
|
101
|
+
if (_scheme)
|
|
102
|
+
opts.scheme = _scheme;
|
|
103
|
+
if (destinationUDID)
|
|
104
|
+
opts.destinationUDID = destinationUDID;
|
|
105
|
+
if (derivedDataPath)
|
|
106
|
+
opts.derivedDataPath = derivedDataPath;
|
|
107
|
+
if (forceClean)
|
|
108
|
+
opts.forceClean = forceClean;
|
|
109
|
+
// prefer explicit xcodebuild path from env
|
|
110
|
+
if (process.env.XCODEBUILD_PATH)
|
|
111
|
+
opts.xcodeCmd = process.env.XCODEBUILD_PATH;
|
|
112
|
+
const artifact = await ios.build(projectPath, opts);
|
|
113
|
+
return artifact;
|
|
114
|
+
}
|
|
115
|
+
static async build_flutter({ projectPath, platform, buildMode, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
|
|
116
|
+
// Prefer using flutter CLI when available; otherwise delegate to native subproject builders
|
|
117
|
+
const flutterCmd = process.env.FLUTTER_PATH || 'flutter';
|
|
118
|
+
// silence unused params
|
|
119
|
+
void _maxWorkers;
|
|
120
|
+
void _forceClean;
|
|
121
|
+
try {
|
|
122
|
+
// Check flutter presence without streaming output
|
|
123
|
+
execSync(`${flutterCmd} --version`, { stdio: 'ignore' });
|
|
124
|
+
if (!platform || platform === 'android') {
|
|
125
|
+
const mode = buildMode || 'debug';
|
|
126
|
+
try {
|
|
127
|
+
const out = execSync(`${flutterCmd} build apk --${mode}`, { cwd: projectPath, encoding: 'utf8' });
|
|
128
|
+
// Try to find built APK
|
|
129
|
+
const apk = await findApk(path.join(projectPath));
|
|
130
|
+
if (apk)
|
|
131
|
+
return { artifactPath: apk, output: out };
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
const stdout = err && err.stdout ? String(err.stdout) : '';
|
|
135
|
+
const stderr = err && err.stderr ? String(err.stderr) : '';
|
|
136
|
+
throw new Error(`flutter build apk failed: ${stderr || stdout || err.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!platform || platform === 'ios') {
|
|
140
|
+
const mode = buildMode || 'debug';
|
|
141
|
+
try {
|
|
142
|
+
const out = execSync(`${flutterCmd} build ios --${mode} --no-codesign`, { cwd: projectPath, encoding: 'utf8' });
|
|
143
|
+
const app = await findAppBundle(path.join(projectPath));
|
|
144
|
+
if (app)
|
|
145
|
+
return { artifactPath: app, output: out };
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const stdout = err && err.stdout ? String(err.stdout) : '';
|
|
149
|
+
const stderr = err && err.stderr ? String(err.stderr) : '';
|
|
150
|
+
throw new Error(`flutter build ios failed: ${stderr || stdout || err.message}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
// If flutter CLI not available or command fails, fall back to native subprojects
|
|
156
|
+
// Preserve error message for diagnostics if needed
|
|
157
|
+
void e;
|
|
158
|
+
}
|
|
159
|
+
// Fallback: try native subproject builds
|
|
160
|
+
if (!platform || platform === 'android') {
|
|
161
|
+
const androidDir = path.join(projectPath, 'android');
|
|
162
|
+
const android = new AndroidManage();
|
|
163
|
+
const artifact = await android.build(androidDir, _forceClean ? 'clean && assembleDebug' : 'assembleDebug');
|
|
164
|
+
return artifact;
|
|
165
|
+
}
|
|
166
|
+
if (!platform || platform === 'ios') {
|
|
167
|
+
const iosDir = path.join(projectPath, 'ios');
|
|
168
|
+
const ios = new iOSManage();
|
|
169
|
+
const artifact = await ios.build(iosDir);
|
|
170
|
+
return artifact;
|
|
171
|
+
}
|
|
172
|
+
return { error: 'Unable to build flutter project' };
|
|
173
|
+
}
|
|
174
|
+
static async build_react_native({ projectPath, platform, variant, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
|
|
175
|
+
// silence unused params
|
|
176
|
+
void _maxWorkers;
|
|
177
|
+
void _forceClean;
|
|
178
|
+
// React Native typically uses native subprojects. Delegate to Android/iOS builders.
|
|
179
|
+
if (!platform || platform === 'android') {
|
|
180
|
+
const androidDir = path.join(projectPath, 'android');
|
|
181
|
+
const android = new AndroidManage();
|
|
182
|
+
const artifact = await android.build(androidDir, variant || 'assembleDebug');
|
|
183
|
+
return artifact;
|
|
184
|
+
}
|
|
185
|
+
if (!platform || platform === 'ios') {
|
|
186
|
+
const iosDir = path.join(projectPath, 'ios');
|
|
187
|
+
// Recommend running `pod install` prior to building in CI; not performed automatically here
|
|
188
|
+
const ios = new iOSManage();
|
|
189
|
+
const artifact = await ios.build(iosDir);
|
|
190
|
+
return artifact;
|
|
191
|
+
}
|
|
192
|
+
return { error: 'Unable to build react-native project' };
|
|
193
|
+
}
|
|
194
|
+
static async buildAppHandler({ platform, projectPath, variant, projectType: _projectType }) {
|
|
195
|
+
void _projectType;
|
|
8
196
|
// delegate to platform-specific build implementations
|
|
9
197
|
const chosen = platform || 'android';
|
|
10
198
|
if (chosen === 'android') {
|
|
@@ -18,41 +206,12 @@ export class ToolsManage {
|
|
|
18
206
|
return artifact;
|
|
19
207
|
}
|
|
20
208
|
}
|
|
21
|
-
static async installAppHandler({ platform, appPath, deviceId }) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (stat && stat.isDirectory()) {
|
|
26
|
-
// If the directory itself looks like an .app bundle, treat as iOS
|
|
27
|
-
if (appPath.endsWith('.app')) {
|
|
28
|
-
chosenPlatform = 'ios';
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
const files = (await fs.readdir(appPath).catch(() => []));
|
|
32
|
-
if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) {
|
|
33
|
-
chosenPlatform = 'ios';
|
|
34
|
-
}
|
|
35
|
-
else if (files.includes('gradlew') || files.includes('build.gradle') || files.includes('settings.gradle') || (files.includes('app') && (await fs.stat(path.join(appPath, 'app')).catch(() => null)))) {
|
|
36
|
-
chosenPlatform = 'android';
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
chosenPlatform = 'android';
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
else if (typeof appPath === 'string') {
|
|
44
|
-
const ext = path.extname(appPath).toLowerCase();
|
|
45
|
-
if (ext === '.apk')
|
|
46
|
-
chosenPlatform = 'android';
|
|
47
|
-
else if (ext === '.ipa' || ext === '.app')
|
|
48
|
-
chosenPlatform = 'ios';
|
|
49
|
-
else
|
|
50
|
-
chosenPlatform = 'android';
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
chosenPlatform = 'android';
|
|
209
|
+
static async installAppHandler({ platform, appPath, deviceId, projectType }) {
|
|
210
|
+
// Enforce explicit platform and projectType: both are mandatory to avoid ambiguity
|
|
211
|
+
if (!platform || !projectType) {
|
|
212
|
+
throw new Error('Both platform and projectType parameters are required (platform: ios|android, projectType: native|kmp|react-native|flutter).');
|
|
55
213
|
}
|
|
214
|
+
const chosenPlatform = platform;
|
|
56
215
|
if (chosenPlatform === 'android') {
|
|
57
216
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
58
217
|
const androidRun = new AndroidManage();
|
|
@@ -106,36 +265,59 @@ export class ToolsManage {
|
|
|
106
265
|
return await new iOSManage().resetAppData(appId, resolved.id);
|
|
107
266
|
}
|
|
108
267
|
}
|
|
109
|
-
static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout }) {
|
|
268
|
+
static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout, projectType }) {
|
|
110
269
|
const events = [];
|
|
111
270
|
const pushEvent = (obj) => events.push(JSON.stringify(obj));
|
|
112
271
|
const effectiveTimeout = timeout ?? 180000; // reserved for future streaming/timeouts
|
|
113
272
|
void effectiveTimeout;
|
|
114
|
-
//
|
|
273
|
+
// Require explicit platform and projectType to avoid ambiguous autodetection
|
|
274
|
+
if (!platform || !projectType) {
|
|
275
|
+
pushEvent({ type: 'build', status: 'failed', error: 'Both platform and projectType parameters are required.' });
|
|
276
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Both platform and projectType parameters are required (platform: ios|android, projectType: native|kmp|react-native|flutter).' } };
|
|
277
|
+
}
|
|
278
|
+
// determine platform if not provided by inspecting path or projectType hint
|
|
115
279
|
let chosenPlatform = platform;
|
|
116
280
|
try {
|
|
117
|
-
const stat = await fs.stat(projectPath).catch(() => null);
|
|
118
281
|
if (!chosenPlatform) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
chosenPlatform = 'ios';
|
|
123
|
-
else
|
|
282
|
+
// If caller provided projectType, respect it as a hard override and map to platform
|
|
283
|
+
if (projectType) {
|
|
284
|
+
if (projectType === 'kmp' || projectType === 'react-native' || projectType === 'flutter') {
|
|
124
285
|
chosenPlatform = 'android';
|
|
286
|
+
pushEvent({ type: 'build', status: 'info', message: `projectType=${projectType} -> forcing android platform` });
|
|
287
|
+
}
|
|
288
|
+
else if (projectType === 'native' || projectType === 'ios') {
|
|
289
|
+
chosenPlatform = 'ios';
|
|
290
|
+
pushEvent({ type: 'build', status: 'info', message: `projectType=${projectType} -> forcing ios platform` });
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
pushEvent({ type: 'build', status: 'failed', error: `Unknown projectType: ${projectType}` });
|
|
294
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: `Unknown projectType: ${projectType}` } };
|
|
295
|
+
}
|
|
125
296
|
}
|
|
126
297
|
else {
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
298
|
+
// If autodetect is disabled, require explicit platform or projectType
|
|
299
|
+
if (process.env.MCP_DISABLE_AUTODETECT === '1') {
|
|
300
|
+
pushEvent({ type: 'build', status: 'failed', error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType' });
|
|
301
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType (ios|android).' } };
|
|
302
|
+
}
|
|
303
|
+
const det = await detectProjectPlatform(projectPath);
|
|
304
|
+
if (det === 'ios' || det === 'android') {
|
|
305
|
+
chosenPlatform = det;
|
|
306
|
+
}
|
|
307
|
+
else if (det === 'ambiguous') {
|
|
308
|
+
pushEvent({ type: 'build', status: 'failed', error: 'Ambiguous project (contains both iOS and Android). Please provide platform: "ios" or "android".' });
|
|
309
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Ambiguous project - please provide explicit platform parameter (ios|android).' } };
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
// Unknown project type - do not guess. Request explicit platform.
|
|
313
|
+
pushEvent({ type: 'build', status: 'failed', error: 'Unknown project type - unable to autodetect platform. Please provide platform or projectType.' });
|
|
314
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Unknown project type - please provide platform or projectType (ios|android).' } };
|
|
315
|
+
}
|
|
134
316
|
}
|
|
135
317
|
}
|
|
136
318
|
}
|
|
137
319
|
catch {
|
|
138
|
-
|
|
320
|
+
// detection failed; avoid guessing a platform
|
|
139
321
|
}
|
|
140
322
|
pushEvent({ type: 'build', status: 'started', platform: chosenPlatform });
|
|
141
323
|
let buildRes;
|
|
@@ -157,7 +339,7 @@ export class ToolsManage {
|
|
|
157
339
|
pushEvent({ type: 'install', status: 'started', artifactPath: artifact, deviceId });
|
|
158
340
|
let installRes;
|
|
159
341
|
try {
|
|
160
|
-
installRes = await ToolsManage.installAppHandler({ platform: chosenPlatform, appPath: artifact, deviceId });
|
|
342
|
+
installRes = await ToolsManage.installAppHandler({ platform: chosenPlatform, appPath: artifact, deviceId, projectType });
|
|
161
343
|
if (installRes && installRes.installed === true) {
|
|
162
344
|
pushEvent({ type: 'install', status: 'finished', artifactPath: artifact, device: installRes.device });
|
|
163
345
|
return { ndjson: events.join('\n') + '\n', result: { success: true, artifactPath: artifact, device: installRes.device, output: installRes.output } };
|
|
@@ -49,6 +49,14 @@ export async function resolveTargetDevice(opts) {
|
|
|
49
49
|
if (physical.length > 1)
|
|
50
50
|
candidates = physical;
|
|
51
51
|
}
|
|
52
|
+
// Prefer booted iOS simulators if present
|
|
53
|
+
if (platform === 'ios') {
|
|
54
|
+
const booted = candidates.filter((d) => !!d.booted);
|
|
55
|
+
if (booted.length === 1)
|
|
56
|
+
return booted[0];
|
|
57
|
+
if (booted.length > 1)
|
|
58
|
+
return booted[0]; // if multiple booted, pick the first
|
|
59
|
+
}
|
|
52
60
|
candidates.sort((a, b) => parseNumericVersion(b.osVersion) - parseNumericVersion(a.osVersion));
|
|
53
61
|
if (candidates.length > 1 && parseNumericVersion(candidates[0].osVersion) > parseNumericVersion(candidates[1].osVersion)) {
|
|
54
62
|
return candidates[0];
|