mobile-debug-mcp 0.13.0 → 0.15.0

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.
Files changed (103) hide show
  1. package/README.md +2 -2
  2. package/dist/android/interact.js +13 -1
  3. package/dist/android/observe.js +13 -0
  4. package/dist/cli/ios/run-ios-smoke.js +2 -2
  5. package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
  6. package/dist/interact/android.js +91 -0
  7. package/dist/interact/index.js +37 -0
  8. package/dist/interact/ios.js +120 -0
  9. package/dist/interact/shared/fingerprint.js +72 -0
  10. package/dist/interact/shared/scroll_to_element.js +98 -0
  11. package/dist/ios/interact.js +52 -1
  12. package/dist/ios/observe.js +12 -0
  13. package/dist/manage/android.js +162 -0
  14. package/dist/manage/index.js +364 -0
  15. package/dist/manage/ios.js +353 -0
  16. package/dist/observe/android.js +351 -0
  17. package/dist/observe/fingerprint.js +1 -0
  18. package/dist/observe/index.js +85 -0
  19. package/dist/observe/ios.js +320 -0
  20. package/dist/observe/test/device/logstream-real.js +34 -0
  21. package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
  22. package/dist/observe/test/device/run-scroll-test-android.js +22 -0
  23. package/dist/observe/test/device/test-ui-tree.js +67 -0
  24. package/dist/observe/test/device/wait_for_element_real.js +69 -0
  25. package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
  26. package/dist/observe/test/unit/logparse.test.js +39 -0
  27. package/dist/observe/test/unit/logstream.test.js +41 -0
  28. package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
  29. package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
  30. package/dist/server.js +54 -9
  31. package/dist/shared/fingerprint.js +72 -0
  32. package/dist/shared/scroll_to_element.js +98 -0
  33. package/dist/tools/interact.js +19 -22
  34. package/dist/tools/manage.js +2 -2
  35. package/dist/tools/observe.js +45 -43
  36. package/dist/tools/scroll_to_element.js +98 -0
  37. package/dist/utils/android/utils.js +429 -0
  38. package/dist/utils/cli/idb/check-idb.js +84 -0
  39. package/dist/utils/cli/idb/idb-helper.js +91 -0
  40. package/dist/utils/cli/idb/install-idb.js +82 -0
  41. package/dist/utils/cli/ios/preflight-ios.js +155 -0
  42. package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
  43. package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
  44. package/dist/utils/diagnostics.js +1 -1
  45. package/dist/utils/ios/utils.js +301 -0
  46. package/dist/utils/resolve-device.js +2 -2
  47. package/docs/CHANGELOG.md +11 -0
  48. package/docs/tools/TOOLS.md +3 -3
  49. package/docs/tools/interact.md +31 -0
  50. package/docs/tools/observe.md +24 -0
  51. package/package.json +1 -1
  52. package/src/{android/interact.ts → interact/android.ts} +15 -2
  53. package/src/interact/index.ts +47 -0
  54. package/src/{ios/interact.ts → interact/ios.ts} +58 -3
  55. package/src/interact/shared/fingerprint.ts +73 -0
  56. package/src/interact/shared/scroll_to_element.ts +110 -0
  57. package/src/{android/manage.ts → manage/android.ts} +2 -2
  58. package/src/{tools/manage.ts → manage/index.ts} +7 -4
  59. package/src/{ios/manage.ts → manage/ios.ts} +1 -1
  60. package/src/{android/observe.ts → observe/android.ts} +14 -26
  61. package/src/observe/index.ts +92 -0
  62. package/src/{ios/observe.ts → observe/ios.ts} +17 -35
  63. package/src/server.ts +57 -10
  64. package/src/{android → utils/android}/utils.ts +2 -2
  65. package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
  66. package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
  67. package/src/utils/diagnostics.ts +1 -1
  68. package/src/{ios → utils/ios}/utils.ts +2 -2
  69. package/src/utils/resolve-device.ts +2 -2
  70. package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
  71. package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
  72. package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
  73. package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
  74. package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
  75. package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
  76. package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
  77. package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
  78. package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
  79. package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
  80. package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
  81. package/test/observe/device/run-screen-fingerprint.ts +36 -0
  82. package/test/observe/device/run-scroll-test-android.ts +24 -0
  83. package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
  84. package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
  85. package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
  86. package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
  87. package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
  88. package/test/observe/unit/scroll_to_element.test.ts +129 -0
  89. package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +3 -3
  90. package/test/unit/index.ts +12 -11
  91. package/src/tools/interact.ts +0 -45
  92. package/src/tools/observe.ts +0 -82
  93. package/test/device/README.md +0 -49
  94. package/test/device/index.ts +0 -27
  95. package/test/device/utils/test-dist.ts +0 -41
  96. package/test/unit/utils/detect-java.test.ts +0 -22
  97. /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
  98. /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
  99. /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
  100. /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
  101. /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
  102. /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
  103. /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
@@ -0,0 +1,162 @@
1
+ import { promises as fs } from 'fs';
2
+ import { spawn } from 'child_process';
3
+ import path from 'path';
4
+ import { existsSync } from 'fs';
5
+ import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from '../utils/android/utils.js';
6
+ import { execAdbWithDiagnostics } from '../utils/diagnostics.js';
7
+ import { detectJavaHome } from '../utils/java.js';
8
+ export class AndroidManage {
9
+ async build(projectPath, _variant) {
10
+ void _variant;
11
+ try {
12
+ // Always use the shared prepareGradle utility for consistent env/setup
13
+ const { execCmd, gradleArgs, spawnOpts } = await (await import('../utils/android/utils.js')).prepareGradle(projectPath);
14
+ await new Promise((resolve, reject) => {
15
+ const proc = spawn(execCmd, gradleArgs, spawnOpts);
16
+ let stderr = '';
17
+ proc.stderr?.on('data', d => stderr += d.toString());
18
+ proc.on('close', code => {
19
+ if (code === 0)
20
+ resolve();
21
+ else
22
+ reject(new Error(stderr || `Gradle failed with code ${code}`));
23
+ });
24
+ proc.on('error', err => reject(err));
25
+ });
26
+ const apk = await findApk(projectPath);
27
+ if (!apk)
28
+ return { error: 'Could not find APK after build' };
29
+ return { artifactPath: apk };
30
+ }
31
+ catch (e) {
32
+ return { error: e instanceof Error ? e.message : String(e) };
33
+ }
34
+ }
35
+ async installApp(apkPath, deviceId) {
36
+ const metadata = await getAndroidDeviceMetadata('', deviceId);
37
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
38
+ let apkToInstall = apkPath;
39
+ try {
40
+ const stat = await fs.stat(apkPath).catch(() => null);
41
+ if (stat && stat.isDirectory()) {
42
+ const detectedJavaHome = await detectJavaHome().catch(() => undefined);
43
+ const env = Object.assign({}, process.env);
44
+ if (detectedJavaHome) {
45
+ if (env.JAVA_HOME !== detectedJavaHome) {
46
+ env.JAVA_HOME = detectedJavaHome;
47
+ env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`;
48
+ console.debug('[android-run] Overriding JAVA_HOME with detected path:', detectedJavaHome);
49
+ }
50
+ }
51
+ try {
52
+ delete env.SHELL;
53
+ }
54
+ catch { }
55
+ const gradleArgs = ['assembleDebug'];
56
+ if (detectedJavaHome) {
57
+ gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`);
58
+ gradleArgs.push('--no-daemon');
59
+ env.GRADLE_JAVA_HOME = detectedJavaHome;
60
+ }
61
+ const wrapperPath = path.join(apkPath, 'gradlew');
62
+ const useWrapper = existsSync(wrapperPath);
63
+ const execCmd = useWrapper ? wrapperPath : 'gradle';
64
+ const spawnOpts = { cwd: apkPath, env };
65
+ if (useWrapper) {
66
+ await fs.chmod(wrapperPath, 0o755).catch(() => { });
67
+ spawnOpts.shell = false;
68
+ }
69
+ else
70
+ spawnOpts.shell = true;
71
+ const proc = spawn(execCmd, gradleArgs, spawnOpts);
72
+ let stderr = '';
73
+ await new Promise((resolve, reject) => {
74
+ proc.stderr?.on('data', d => stderr += d.toString());
75
+ proc.on('close', code => {
76
+ if (code === 0)
77
+ resolve();
78
+ else
79
+ reject(new Error(stderr || `Gradle build failed with code ${code}`));
80
+ });
81
+ proc.on('error', err => reject(err));
82
+ });
83
+ const built = await findApk(apkPath);
84
+ if (!built)
85
+ throw new Error('Could not locate built APK after running Gradle');
86
+ apkToInstall = built;
87
+ }
88
+ try {
89
+ const res = await spawnAdb(['install', '-r', apkToInstall], deviceId);
90
+ if (res.code === 0) {
91
+ return { device: deviceInfo, installed: true, output: res.stdout };
92
+ }
93
+ }
94
+ catch (e) {
95
+ console.debug('[android-run] adb install failed, attempting push+pm fallback:', e instanceof Error ? e.message : String(e));
96
+ }
97
+ const basename = path.basename(apkToInstall);
98
+ const remotePath = `/data/local/tmp/${basename}`;
99
+ await execAdb(['push', apkToInstall, remotePath], deviceId);
100
+ const pmOut = await execAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId);
101
+ try {
102
+ await execAdb(['shell', 'rm', remotePath], deviceId);
103
+ }
104
+ catch { }
105
+ return { device: deviceInfo, installed: true, output: pmOut };
106
+ }
107
+ catch (e) {
108
+ // gather diagnostics for attempted adb operations
109
+ const basename = path.basename(apkToInstall);
110
+ const remotePath = `/data/local/tmp/${basename}`;
111
+ const installDiag = execAdbWithDiagnostics(['install', '-r', apkToInstall], deviceId);
112
+ const pushDiag = execAdbWithDiagnostics(['push', apkToInstall, remotePath], deviceId);
113
+ const pmDiag = execAdbWithDiagnostics(['shell', 'pm', 'install', '-r', remotePath], deviceId);
114
+ return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: { installDiag, pushDiag, pmDiag } };
115
+ }
116
+ }
117
+ async startApp(appId, deviceId) {
118
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
119
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
120
+ try {
121
+ await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
122
+ return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
123
+ }
124
+ catch (e) {
125
+ const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
126
+ return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
127
+ }
128
+ }
129
+ async terminateApp(appId, deviceId) {
130
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
131
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
132
+ try {
133
+ await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
134
+ return { device: deviceInfo, appTerminated: true };
135
+ }
136
+ catch (e) {
137
+ const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId);
138
+ return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
139
+ }
140
+ }
141
+ async restartApp(appId, deviceId) {
142
+ await this.terminateApp(appId, deviceId);
143
+ const startResult = await this.startApp(appId, deviceId);
144
+ return {
145
+ device: startResult.device,
146
+ appRestarted: startResult.appStarted,
147
+ launchTimeMs: startResult.launchTimeMs
148
+ };
149
+ }
150
+ async resetAppData(appId, deviceId) {
151
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
152
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
153
+ try {
154
+ const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
155
+ return { device: deviceInfo, dataCleared: output === 'Success' };
156
+ }
157
+ catch (e) {
158
+ const diag = execAdbWithDiagnostics(['shell', 'pm', 'clear', appId], deviceId);
159
+ return { device: deviceInfo, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
160
+ }
161
+ }
162
+ }
@@ -0,0 +1,364 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { resolveTargetDevice, listDevices } from '../utils/resolve-device.js';
4
+ import { AndroidManage } from './android.js';
5
+ import { iOSManage } from './ios.js';
6
+ import { findApk } from '../utils/android/utils.js';
7
+ import { findAppBundle } from '../utils/ios/utils.js';
8
+ import { execSync } from 'child_process';
9
+ export { AndroidManage } from './android.js';
10
+ export { iOSManage } from './ios.js';
11
+ export async function detectProjectPlatform(projectPath) {
12
+ // Recursively scan up to a limited depth for platform markers to avoid mis-detection
13
+ async function scan(dir, depth = 3) {
14
+ const res = { ios: false, android: false };
15
+ try {
16
+ const ents = await fs.readdir(dir).catch(() => []);
17
+ for (const e of ents) {
18
+ if (e.endsWith('.xcworkspace') || e.endsWith('.xcodeproj'))
19
+ res.ios = true;
20
+ if (e === 'gradlew' || e === 'build.gradle' || e === 'settings.gradle')
21
+ res.android = true;
22
+ if (res.ios && res.android)
23
+ return res;
24
+ }
25
+ if (depth <= 0)
26
+ return res;
27
+ for (const e of ents) {
28
+ try {
29
+ const full = path.join(dir, e);
30
+ const st = await fs.stat(full).catch(() => null);
31
+ if (st && st.isDirectory()) {
32
+ const child = await scan(full, depth - 1);
33
+ if (child.ios)
34
+ res.ios = true;
35
+ if (child.android)
36
+ res.android = true;
37
+ if (res.ios && res.android)
38
+ return res;
39
+ }
40
+ }
41
+ catch { }
42
+ }
43
+ }
44
+ catch { }
45
+ return res;
46
+ }
47
+ try {
48
+ const stat = await fs.stat(projectPath).catch(() => null);
49
+ if (stat && stat.isDirectory()) {
50
+ const { ios: hasIos, android: hasAndroid } = await scan(projectPath, 3);
51
+ if (hasIos && !hasAndroid)
52
+ return 'ios';
53
+ if (hasAndroid && !hasIos)
54
+ return 'android';
55
+ if (hasIos && hasAndroid)
56
+ return 'ambiguous';
57
+ // no explicit markers found
58
+ return 'unknown';
59
+ }
60
+ else {
61
+ const ext = path.extname(projectPath).toLowerCase();
62
+ if (ext === '.apk')
63
+ return 'android';
64
+ if (ext === '.ipa' || ext === '.app')
65
+ return 'ios';
66
+ return 'unknown';
67
+ }
68
+ }
69
+ catch {
70
+ return 'unknown';
71
+ }
72
+ }
73
+ export class ToolsManage {
74
+ static async build_android({ projectPath, gradleTask, maxWorkers, gradleCache, forceClean }) {
75
+ const android = new AndroidManage();
76
+ // prepare gradle options via environment hints
77
+ if (typeof maxWorkers === 'number')
78
+ process.env.MCP_GRADLE_WORKERS = String(maxWorkers);
79
+ if (typeof gradleCache === 'boolean')
80
+ process.env.MCP_GRADLE_CACHE = gradleCache ? '1' : '0';
81
+ if (forceClean)
82
+ process.env.MCP_FORCE_CLEAN_ANDROID = '1';
83
+ const task = gradleTask || 'assembleDebug';
84
+ const artifact = await android.build(projectPath, task);
85
+ return artifact;
86
+ }
87
+ static async build_ios({ projectPath, workspace: _workspace, project: _project, scheme: _scheme, destinationUDID, derivedDataPath, buildJobs, forceClean }) {
88
+ const ios = new iOSManage();
89
+ // Use provided options rather than env-only; still set env fallbacks for downstream tools
90
+ if (derivedDataPath)
91
+ process.env.MCP_DERIVED_DATA = derivedDataPath;
92
+ if (typeof buildJobs === 'number')
93
+ process.env.MCP_BUILD_JOBS = String(buildJobs);
94
+ if (forceClean)
95
+ process.env.MCP_FORCE_CLEAN_IOS = '1';
96
+ if (destinationUDID)
97
+ process.env.MCP_XCODE_DESTINATION_UDID = destinationUDID;
98
+ const opts = {};
99
+ if (_workspace)
100
+ opts.workspace = _workspace;
101
+ if (_project)
102
+ opts.project = _project;
103
+ if (_scheme)
104
+ opts.scheme = _scheme;
105
+ if (destinationUDID)
106
+ opts.destinationUDID = destinationUDID;
107
+ if (derivedDataPath)
108
+ opts.derivedDataPath = derivedDataPath;
109
+ if (forceClean)
110
+ opts.forceClean = forceClean;
111
+ // prefer explicit xcodebuild path from env
112
+ if (process.env.XCODEBUILD_PATH)
113
+ opts.xcodeCmd = process.env.XCODEBUILD_PATH;
114
+ const artifact = await ios.build(projectPath, opts);
115
+ return artifact;
116
+ }
117
+ static async build_flutter({ projectPath, platform, buildMode, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
118
+ // Prefer using flutter CLI when available; otherwise delegate to native subproject builders
119
+ const flutterCmd = process.env.FLUTTER_PATH || 'flutter';
120
+ // silence unused params
121
+ void _maxWorkers;
122
+ void _forceClean;
123
+ try {
124
+ // Check flutter presence without streaming output
125
+ execSync(`${flutterCmd} --version`, { stdio: 'ignore' });
126
+ if (!platform || platform === 'android') {
127
+ const mode = buildMode || 'debug';
128
+ try {
129
+ const out = execSync(`${flutterCmd} build apk --${mode}`, { cwd: projectPath, encoding: 'utf8' });
130
+ // Try to find built APK
131
+ const apk = await findApk(path.join(projectPath));
132
+ if (apk)
133
+ return { artifactPath: apk, output: out };
134
+ }
135
+ catch (err) {
136
+ const stdout = err && err.stdout ? String(err.stdout) : '';
137
+ const stderr = err && err.stderr ? String(err.stderr) : '';
138
+ throw new Error(`flutter build apk failed: ${stderr || stdout || err.message}`);
139
+ }
140
+ }
141
+ if (!platform || platform === 'ios') {
142
+ const mode = buildMode || 'debug';
143
+ try {
144
+ const out = execSync(`${flutterCmd} build ios --${mode} --no-codesign`, { cwd: projectPath, encoding: 'utf8' });
145
+ const app = await findAppBundle(path.join(projectPath));
146
+ if (app)
147
+ return { artifactPath: app, output: out };
148
+ }
149
+ catch (err) {
150
+ const stdout = err && err.stdout ? String(err.stdout) : '';
151
+ const stderr = err && err.stderr ? String(err.stderr) : '';
152
+ throw new Error(`flutter build ios failed: ${stderr || stdout || err.message}`);
153
+ }
154
+ }
155
+ }
156
+ catch (e) {
157
+ // If flutter CLI not available or command fails, fall back to native subprojects
158
+ // Preserve error message for diagnostics if needed
159
+ void e;
160
+ }
161
+ // Fallback: try native subproject builds
162
+ if (!platform || platform === 'android') {
163
+ const androidDir = path.join(projectPath, 'android');
164
+ const android = new AndroidManage();
165
+ const artifact = await android.build(androidDir, _forceClean ? 'clean && assembleDebug' : 'assembleDebug');
166
+ return artifact;
167
+ }
168
+ if (!platform || platform === 'ios') {
169
+ const iosDir = path.join(projectPath, 'ios');
170
+ const ios = new iOSManage();
171
+ const artifact = await ios.build(iosDir);
172
+ return artifact;
173
+ }
174
+ return { error: 'Unable to build flutter project' };
175
+ }
176
+ static async build_react_native({ projectPath, platform, variant, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
177
+ // silence unused params
178
+ void _maxWorkers;
179
+ void _forceClean;
180
+ // React Native typically uses native subprojects. Delegate to Android/iOS builders.
181
+ if (!platform || platform === 'android') {
182
+ const androidDir = path.join(projectPath, 'android');
183
+ const android = new AndroidManage();
184
+ const artifact = await android.build(androidDir, variant || 'assembleDebug');
185
+ return artifact;
186
+ }
187
+ if (!platform || platform === 'ios') {
188
+ const iosDir = path.join(projectPath, 'ios');
189
+ // Recommend running `pod install` prior to building in CI; not performed automatically here
190
+ const ios = new iOSManage();
191
+ const artifact = await ios.build(iosDir);
192
+ return artifact;
193
+ }
194
+ return { error: 'Unable to build react-native project' };
195
+ }
196
+ static async buildAppHandler({ platform, projectPath, variant, projectType: _projectType }) {
197
+ void _projectType;
198
+ // delegate to platform-specific build implementations
199
+ const chosen = platform || 'android';
200
+ if (chosen === 'android') {
201
+ const android = new AndroidManage();
202
+ const artifact = await android.build(projectPath, variant);
203
+ return artifact;
204
+ }
205
+ else {
206
+ const ios = new iOSManage();
207
+ const artifact = await ios.build(projectPath, variant);
208
+ return artifact;
209
+ }
210
+ }
211
+ static async installAppHandler({ platform, appPath, deviceId, projectType }) {
212
+ // Enforce explicit platform and projectType: both are mandatory to avoid ambiguity
213
+ if (!platform || !projectType) {
214
+ throw new Error('Both platform and projectType parameters are required (platform: ios|android, projectType: native|kmp|react-native|flutter).');
215
+ }
216
+ const chosenPlatform = platform;
217
+ if (chosenPlatform === 'android') {
218
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
219
+ const androidRun = new AndroidManage();
220
+ const result = await androidRun.installApp(appPath, resolved.id);
221
+ return result;
222
+ }
223
+ else {
224
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
225
+ const iosRun = new iOSManage();
226
+ const result = await iosRun.installApp(appPath, resolved.id);
227
+ return result;
228
+ }
229
+ }
230
+ static async startAppHandler({ platform, appId, deviceId }) {
231
+ if (platform === 'android') {
232
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
233
+ return await new AndroidManage().startApp(appId, resolved.id);
234
+ }
235
+ else {
236
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
237
+ return await new iOSManage().startApp(appId, resolved.id);
238
+ }
239
+ }
240
+ static async terminateAppHandler({ platform, appId, deviceId }) {
241
+ if (platform === 'android') {
242
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
243
+ return await new AndroidManage().terminateApp(appId, resolved.id);
244
+ }
245
+ else {
246
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
247
+ return await new iOSManage().terminateApp(appId, resolved.id);
248
+ }
249
+ }
250
+ static async restartAppHandler({ platform, appId, deviceId }) {
251
+ if (platform === 'android') {
252
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
253
+ return await new AndroidManage().restartApp(appId, resolved.id);
254
+ }
255
+ else {
256
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
257
+ return await new iOSManage().restartApp(appId, resolved.id);
258
+ }
259
+ }
260
+ static async resetAppDataHandler({ platform, appId, deviceId }) {
261
+ if (platform === 'android') {
262
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
263
+ return await new AndroidManage().resetAppData(appId, resolved.id);
264
+ }
265
+ else {
266
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
267
+ return await new iOSManage().resetAppData(appId, resolved.id);
268
+ }
269
+ }
270
+ static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout, projectType }) {
271
+ const events = [];
272
+ const pushEvent = (obj) => events.push(JSON.stringify(obj));
273
+ const effectiveTimeout = timeout ?? 180000; // reserved for future streaming/timeouts
274
+ void effectiveTimeout;
275
+ // Require explicit platform and projectType to avoid ambiguous autodetection
276
+ if (!platform || !projectType) {
277
+ pushEvent({ type: 'build', status: 'failed', error: 'Both platform and projectType parameters are required.' });
278
+ 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).' } };
279
+ }
280
+ // determine platform if not provided by inspecting path or projectType hint
281
+ let chosenPlatform = platform;
282
+ try {
283
+ if (!chosenPlatform) {
284
+ // If caller provided projectType, respect it as a hard override and map to platform
285
+ if (projectType) {
286
+ if (projectType === 'kmp' || projectType === 'react-native' || projectType === 'flutter') {
287
+ chosenPlatform = 'android';
288
+ pushEvent({ type: 'build', status: 'info', message: `projectType=${projectType} -> forcing android platform` });
289
+ }
290
+ else if (projectType === 'native' || projectType === 'ios') {
291
+ chosenPlatform = 'ios';
292
+ pushEvent({ type: 'build', status: 'info', message: `projectType=${projectType} -> forcing ios platform` });
293
+ }
294
+ else {
295
+ pushEvent({ type: 'build', status: 'failed', error: `Unknown projectType: ${projectType}` });
296
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: `Unknown projectType: ${projectType}` } };
297
+ }
298
+ }
299
+ else {
300
+ // If autodetect is disabled, require explicit platform or projectType
301
+ if (process.env.MCP_DISABLE_AUTODETECT === '1') {
302
+ pushEvent({ type: 'build', status: 'failed', error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType' });
303
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType (ios|android).' } };
304
+ }
305
+ const det = await detectProjectPlatform(projectPath);
306
+ if (det === 'ios' || det === 'android') {
307
+ chosenPlatform = det;
308
+ }
309
+ else if (det === 'ambiguous') {
310
+ pushEvent({ type: 'build', status: 'failed', error: 'Ambiguous project (contains both iOS and Android). Please provide platform: "ios" or "android".' });
311
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Ambiguous project - please provide explicit platform parameter (ios|android).' } };
312
+ }
313
+ else {
314
+ // Unknown project type - do not guess. Request explicit platform.
315
+ pushEvent({ type: 'build', status: 'failed', error: 'Unknown project type - unable to autodetect platform. Please provide platform or projectType.' });
316
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Unknown project type - please provide platform or projectType (ios|android).' } };
317
+ }
318
+ }
319
+ }
320
+ }
321
+ catch {
322
+ // detection failed; avoid guessing a platform
323
+ }
324
+ pushEvent({ type: 'build', status: 'started', platform: chosenPlatform });
325
+ let buildRes;
326
+ try {
327
+ buildRes = await ToolsManage.buildAppHandler({ platform: chosenPlatform, projectPath });
328
+ if (buildRes && buildRes.error) {
329
+ pushEvent({ type: 'build', status: 'failed', error: buildRes.error });
330
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: buildRes.error } };
331
+ }
332
+ pushEvent({ type: 'build', status: 'finished', artifactPath: buildRes.artifactPath });
333
+ }
334
+ catch (e) {
335
+ const msg = e instanceof Error ? e.message : String(e);
336
+ pushEvent({ type: 'build', status: 'failed', error: msg });
337
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: msg } };
338
+ }
339
+ // Install phase
340
+ const artifact = buildRes.artifactPath || projectPath;
341
+ pushEvent({ type: 'install', status: 'started', artifactPath: artifact, deviceId });
342
+ let installRes;
343
+ try {
344
+ installRes = await ToolsManage.installAppHandler({ platform: chosenPlatform, appPath: artifact, deviceId, projectType });
345
+ if (installRes && installRes.installed === true) {
346
+ pushEvent({ type: 'install', status: 'finished', artifactPath: artifact, device: installRes.device });
347
+ return { ndjson: events.join('\n') + '\n', result: { success: true, artifactPath: artifact, device: installRes.device, output: installRes.output } };
348
+ }
349
+ else {
350
+ pushEvent({ type: 'install', status: 'failed', error: installRes.error || 'unknown' });
351
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: installRes.error || 'install failed' } };
352
+ }
353
+ }
354
+ catch (e) {
355
+ const msg = e instanceof Error ? e.message : String(e);
356
+ pushEvent({ type: 'install', status: 'failed', error: msg });
357
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: msg } };
358
+ }
359
+ }
360
+ static async listDevicesHandler({ platform, appId }) {
361
+ const devices = await listDevices(platform, appId);
362
+ return { devices };
363
+ }
364
+ }