@deeeed/metamask-harness 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/adapters/extension/inject.mjs +1 -0
  3. package/adapters/extension/launch-browser.cjs +15 -3
  4. package/adapters/extension/lib/extension-id.cjs +36 -0
  5. package/adapters/extension/live.sh +5 -3
  6. package/adapters/extension/readiness.mjs +48 -0
  7. package/adapters/extension/reattach.sh +163 -55
  8. package/adapters/extension/sidepanel-toggle.sh +96 -24
  9. package/adapters/extension/verify.sh +11 -0
  10. package/adapters/extension/wallet-fixture-state.cjs +6 -11
  11. package/adapters/mobile/open-device.sh +32 -5
  12. package/adapters/shared/resolve-slot-ports-core.mjs +14 -4
  13. package/dist/adapters/core/surface.js +1 -0
  14. package/dist/adapters/extension/runtime.js +14 -5
  15. package/dist/adapters/extension/surface.js +1 -0
  16. package/dist/adapters/mobile/provision.js +49 -4
  17. package/dist/adapters/mobile/surface.js +1 -0
  18. package/dist/adapters/slot-ports.js +11 -4
  19. package/dist/cli.js +4 -0
  20. package/dist/commands/call.js +19 -2
  21. package/dist/commands/check.js +326 -0
  22. package/dist/commands/core-readiness.js +75 -0
  23. package/dist/commands/device-target.js +113 -10
  24. package/dist/commands/doctor.js +30 -18
  25. package/dist/commands/launch/extension.js +105 -9
  26. package/dist/commands/launch/index.js +116 -15
  27. package/dist/commands/mobile-device-view.js +140 -0
  28. package/dist/commands/parse-args.js +4 -1
  29. package/dist/commands/recipe-quality.js +6 -2
  30. package/dist/commands/run-engine.js +28 -2
  31. package/dist/commands/run-report.js +115 -0
  32. package/dist/commands/run.js +113 -8
  33. package/dist/commands/shared.js +2 -1
  34. package/dist/commands/status-probe.js +9 -3
  35. package/dist/commands/status.js +40 -60
  36. package/dist/live-adapter-contract.js +5 -1
  37. package/dist/mm-harness-cli.js +61 -22
  38. package/docs/CLI-SPEC.md +25 -0
  39. package/library/actions/extension/platform/cdp.mjs +7 -4
  40. package/package.json +4 -4
@@ -0,0 +1,326 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { detectAdapter } from "../harness.js";
5
+ import { EXIT, usageOut } from "./shared.js";
6
+ import { optionFlag, optionString, parseArgs } from "./parse-args.js";
7
+ const USAGE = "mm-harness check diff [--target <repo>] [--adapter mobile|extension|core] [--base <ref>] [--profile fast|full] [--artifacts-dir <dir>] [--json]";
8
+ const JS_TS_EXT_RE = /\.(?:c|m)?jsx?$|\.tsx?$/u;
9
+ const FORMAT_EXT_RE = /\.(?:c|m)?jsx?$|\.tsx?$|\.json$|\.md$|\.css$|\.scss$|\.ya?ml$/u;
10
+ const TEST_FILE_RE = /(?:^|[./_-])(?:test|spec)\.(?:c|m)?jsx?$|(?:^|[./_-])(?:test|spec)\.tsx?$/u;
11
+ async function handleCheck(argv) {
12
+ const { positional, options } = parseArgs(argv, "check");
13
+ const action = positional[0];
14
+ const json = optionFlag(options, "json");
15
+ if (action !== "diff") {
16
+ const message = action ? `unknown action '${action}'` : "missing action";
17
+ return usageOut(json, "check", message, USAGE);
18
+ }
19
+ const target = path.resolve(optionString(options, "target") ?? optionString(options, "projectRoot") ?? process.cwd());
20
+ const explicitAdapter = optionString(options, "adapter") ?? optionString(options, "platform");
21
+ const detected = explicitAdapter ?? detectAdapter(target);
22
+ if (detected && !["mobile", "extension", "core"].includes(detected)) {
23
+ return usageOut(json, "check", `unsupported adapter '${detected}'`, USAGE);
24
+ }
25
+ const adapter = detected;
26
+ const profileInput = optionString(options, "profile");
27
+ if (profileInput && profileInput !== "fast" && profileInput !== "full") {
28
+ return usageOut(json, "check", "--profile must be fast or full", USAGE);
29
+ }
30
+ const profile = profileInput || "fast";
31
+ const explicitBaseRef = optionString(options, "base");
32
+ const base = explicitBaseRef ? { ref: explicitBaseRef, source: "flag" } : defaultBaseRef(target);
33
+ const baseRef = base.ref;
34
+ const artifactsDir = path.resolve(
35
+ optionString(options, "artifactsDir") ?? path.join(target, "temp", "recipe", "check-diff", timestampSlug())
36
+ );
37
+ fs.mkdirSync(artifactsDir, { recursive: true });
38
+ if (!isGitRepo(target)) {
39
+ return failEnvelope({
40
+ json,
41
+ adapter,
42
+ target,
43
+ profile,
44
+ baseRef,
45
+ artifactsDir,
46
+ message: `${target} is not a git checkout`,
47
+ code: "CHECK_DIFF_NOT_GIT"
48
+ });
49
+ }
50
+ const packageJson = readPackageJson(target);
51
+ const mergeBase = resolveMergeBase(target, baseRef);
52
+ const changedFiles = listChangedFiles(target, mergeBase, artifactsDir);
53
+ const existingChangedFiles = changedFiles.filter((file) => fs.existsSync(path.join(target, file)));
54
+ const checks = [];
55
+ checks.push(
56
+ runToolCheck({
57
+ id: "eslint",
58
+ label: "Changed-file ESLint",
59
+ target,
60
+ artifactsDir,
61
+ files: existingChangedFiles.filter((file) => JS_TS_EXT_RE.test(file)),
62
+ command: executable(target, "eslint"),
63
+ args: ["--cache", "--cache-location", path.join(artifactsDir, "eslintcache"), "--max-warnings=0", "--no-warn-ignored"]
64
+ })
65
+ );
66
+ checks.push(
67
+ runToolCheck({
68
+ id: "prettier",
69
+ label: "Changed-file Prettier",
70
+ target,
71
+ artifactsDir,
72
+ files: existingChangedFiles.filter((file) => FORMAT_EXT_RE.test(file)),
73
+ command: executable(target, "prettier"),
74
+ args: ["--check"]
75
+ })
76
+ );
77
+ checks.push(
78
+ runToolCheck({
79
+ id: "jest",
80
+ label: "Changed test files",
81
+ target,
82
+ artifactsDir,
83
+ files: existingChangedFiles.filter((file) => TEST_FILE_RE.test(file)),
84
+ command: executable(target, "jest"),
85
+ args: ["--no-coverage"]
86
+ })
87
+ );
88
+ if (profile === "full") {
89
+ checks.push(runTypecheck({ target, artifactsDir, packageJson }));
90
+ } else {
91
+ checks.push({
92
+ id: "typecheck",
93
+ label: "TypeScript project check",
94
+ status: "skip",
95
+ reason: "profile=fast; run with --profile full for repo-wide typecheck"
96
+ });
97
+ }
98
+ const status = checks.some((check) => check.status === "fail") ? "fail" : "pass";
99
+ const exitCode = status === "pass" ? EXIT.ok : EXIT.validation;
100
+ const summary = {
101
+ schemaVersion: 1,
102
+ command: "check",
103
+ action: "diff",
104
+ status,
105
+ exitCode,
106
+ adapter: adapter ?? null,
107
+ target,
108
+ profile,
109
+ baseRef,
110
+ baseSource: base.source,
111
+ baseLabel: base.label ?? null,
112
+ mergeBase,
113
+ changedFiles,
114
+ checks,
115
+ artifacts: {
116
+ dir: artifactsDir,
117
+ summaryJson: path.join(artifactsDir, "validation-summary.json"),
118
+ summaryMarkdown: path.join(artifactsDir, "validation-summary.md")
119
+ }
120
+ };
121
+ writeSummaryArtifacts(summary);
122
+ if (json) console.log(JSON.stringify(summary, null, 2));
123
+ else renderHuman(summary);
124
+ return exitCode;
125
+ }
126
+ function timestampSlug() {
127
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/gu, "").replace(/\.\d{3}Z$/u, "Z");
128
+ }
129
+ function isGitRepo(target) {
130
+ return runCommand("git", ["rev-parse", "--is-inside-work-tree"], target).status === 0;
131
+ }
132
+ function defaultBaseRef(target) {
133
+ const prBase = githubPullRequestBase(target);
134
+ if (prBase) return prBase;
135
+ const remoteHead = runCommand("git", ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], target).stdout.trim();
136
+ if (remoteHead && verifyRef(target, remoteHead)) return { ref: remoteHead, source: "remote-head" };
137
+ for (const ref of ["origin/develop", "origin/main", "develop", "main", "HEAD"]) {
138
+ if (verifyRef(target, ref)) return { ref, source: "fallback" };
139
+ }
140
+ return { ref: "", source: "fallback" };
141
+ }
142
+ function githubPullRequestBase(target) {
143
+ const result = runCommand("gh", ["pr", "view", "--json", "baseRefName", "--jq", ".baseRefName"], target);
144
+ if (result.status !== 0) return null;
145
+ const baseName = result.stdout.trim();
146
+ if (!baseName) return null;
147
+ for (const ref of [`origin/${baseName}`, `upstream/${baseName}`, baseName]) {
148
+ if (verifyRef(target, ref)) return { ref, source: "github-pr", label: baseName };
149
+ }
150
+ return null;
151
+ }
152
+ function verifyRef(target, ref) {
153
+ return runCommand("git", ["rev-parse", "--verify", ref], target).status === 0;
154
+ }
155
+ function resolveMergeBase(target, baseRef) {
156
+ if (!baseRef) return null;
157
+ const result = runCommand("git", ["merge-base", "HEAD", baseRef], target);
158
+ if (result.status === 0) return result.stdout.trim() || null;
159
+ const verify = runCommand("git", ["rev-parse", "--verify", baseRef], target);
160
+ return verify.status === 0 ? verify.stdout.trim() || null : null;
161
+ }
162
+ function listChangedFiles(target, mergeBase, artifactsDir) {
163
+ const files = /* @__PURE__ */ new Set();
164
+ const addLines = (output) => {
165
+ for (const line of output.split("\n")) {
166
+ const file = line.trim();
167
+ if (file) files.add(file);
168
+ }
169
+ };
170
+ if (mergeBase) {
171
+ addLines(runCommand("git", ["diff", "--name-only", "--diff-filter=ACMR", `${mergeBase}...HEAD`], target).stdout);
172
+ }
173
+ addLines(runCommand("git", ["diff", "--name-only", "--diff-filter=ACMR", "HEAD"], target).stdout);
174
+ addLines(runCommand("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], target).stdout);
175
+ addLines(runCommand("git", ["ls-files", "--others", "--exclude-standard"], target).stdout);
176
+ return [...files].filter((file) => !isUnderArtifactsDir(target, artifactsDir, file)).sort();
177
+ }
178
+ function isUnderArtifactsDir(target, artifactsDir, file) {
179
+ const artifactRel = normalizeRelativePath(path.relative(path.resolve(target), path.resolve(artifactsDir)));
180
+ if (!artifactRel || artifactRel.startsWith("../") || artifactRel === "..") return false;
181
+ const normalized = normalizeRelativePath(file);
182
+ return normalized === artifactRel || normalized.startsWith(`${artifactRel}/`);
183
+ }
184
+ function normalizeRelativePath(value) {
185
+ return value.split(path.sep).join("/");
186
+ }
187
+ function executable(target, name) {
188
+ const local = path.join(target, "node_modules", ".bin", name);
189
+ return fs.existsSync(local) ? local : null;
190
+ }
191
+ function runToolCheck(input) {
192
+ if (input.files.length === 0) {
193
+ return { id: input.id, label: input.label, status: "skip", reason: "no changed matching files" };
194
+ }
195
+ if (!input.command) {
196
+ return { id: input.id, label: input.label, status: "skip", reason: `${input.id} binary not found in node_modules/.bin` };
197
+ }
198
+ return runCheckCommand(input.id, input.label, input.target, input.artifactsDir, input.command, [...input.args, ...input.files]);
199
+ }
200
+ function runTypecheck(input) {
201
+ const scripts = input.packageJson?.scripts && typeof input.packageJson.scripts === "object" ? input.packageJson.scripts : {};
202
+ if (typeof scripts["lint:tsc"] === "string") {
203
+ return runCheckCommand("typecheck", "TypeScript project check", input.target, input.artifactsDir, "yarn", ["lint:tsc"]);
204
+ }
205
+ const tsc = executable(input.target, "tsc");
206
+ if (tsc) {
207
+ return runCheckCommand("typecheck", "TypeScript project check", input.target, input.artifactsDir, tsc, ["--noEmit"]);
208
+ }
209
+ return { id: "typecheck", label: "TypeScript project check", status: "skip", reason: "no lint:tsc script or tsc binary found" };
210
+ }
211
+ function runCheckCommand(id, label, target, artifactsDir, command, args) {
212
+ const result = runCommand(command, args, target);
213
+ const logPath = path.join(artifactsDir, `${id}.log`);
214
+ fs.writeFileSync(logPath, renderCommandLog(command, args, result));
215
+ return {
216
+ id,
217
+ label,
218
+ status: result.status === 0 ? "pass" : "fail",
219
+ command: [command, ...args].join(" "),
220
+ exitCode: result.status,
221
+ logPath,
222
+ durationMs: result.durationMs
223
+ };
224
+ }
225
+ function runCommand(command, args, cwd) {
226
+ const started = Date.now();
227
+ const result = spawnSync(command, args, {
228
+ cwd,
229
+ encoding: "utf8",
230
+ env: process.env,
231
+ maxBuffer: 64 * 1024 * 1024
232
+ });
233
+ const status = result.error ? 127 : result.status ?? 1;
234
+ return {
235
+ status,
236
+ stdout: result.stdout ?? "",
237
+ stderr: result.error ? String(result.error) : result.stderr ?? "",
238
+ durationMs: Date.now() - started
239
+ };
240
+ }
241
+ function renderCommandLog(command, args, result) {
242
+ return [
243
+ `$ ${[command, ...args].join(" ")}`,
244
+ `exit: ${result.status}`,
245
+ `duration_ms: ${result.durationMs}`,
246
+ "",
247
+ "--- stdout ---",
248
+ result.stdout.trimEnd(),
249
+ "",
250
+ "--- stderr ---",
251
+ result.stderr.trimEnd(),
252
+ ""
253
+ ].join("\n");
254
+ }
255
+ function readPackageJson(target) {
256
+ try {
257
+ return JSON.parse(fs.readFileSync(path.join(target, "package.json"), "utf8"));
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+ function writeSummaryArtifacts(summary) {
263
+ fs.writeFileSync(summary.artifacts.summaryJson, `${JSON.stringify(summary, null, 2)}
264
+ `);
265
+ fs.writeFileSync(summary.artifacts.summaryMarkdown, renderMarkdown(summary));
266
+ }
267
+ function renderMarkdown(summary) {
268
+ const lines = [
269
+ "# mm-harness check diff",
270
+ "",
271
+ `Verdict: ${summary.status}`,
272
+ `Profile: ${summary.profile}`,
273
+ `Base: ${summary.baseRef || "(none)"} (${summary.baseSource || "unknown"}${summary.baseLabel ? `: ${summary.baseLabel}` : ""})`,
274
+ `Changed files: ${summary.changedFiles.length}`,
275
+ "",
276
+ "## Checks",
277
+ ""
278
+ ];
279
+ for (const check of summary.checks) {
280
+ lines.push(`- ${check.status.toUpperCase()} ${check.id}${check.reason ? ` \u2014 ${check.reason}` : ""}${check.logPath ? ` (${check.logPath})` : ""}`);
281
+ }
282
+ lines.push("", "## Changed Files", "");
283
+ for (const file of summary.changedFiles) lines.push(`- ${file}`);
284
+ return `${lines.join("\n")}
285
+ `;
286
+ }
287
+ function renderHuman(summary) {
288
+ const mark = summary.status === "pass" ? "\u2713" : "\u2717";
289
+ const baseText = `${summary.baseRef || "(none)"}${summary.baseSource ? ` [${summary.baseSource}]` : ""}`;
290
+ console.log(`${mark} check diff: ${summary.status} \xB7 ${summary.changedFiles.length} changed file(s) \xB7 profile=${summary.profile} \xB7 base=${baseText}`);
291
+ if (summary.changedFiles.length > 0) {
292
+ console.log("Changed files:");
293
+ for (const file of summary.changedFiles.slice(0, 100)) console.log(` - ${file}`);
294
+ if (summary.changedFiles.length > 100) console.log(` ... ${summary.changedFiles.length - 100} more; full list in validation-summary.json`);
295
+ }
296
+ for (const check of summary.checks) {
297
+ const prefix = check.status === "pass" ? "\u2713" : check.status === "fail" ? "\u2717" : "-";
298
+ console.log(` ${prefix} ${check.id}: ${check.status}${check.reason ? ` (${check.reason})` : ""}`);
299
+ if (check.status === "fail" && check.logPath) console.log(` log: ${check.logPath}`);
300
+ }
301
+ console.log(`Artifacts: ${summary.artifacts.summaryJson}`);
302
+ }
303
+ function failEnvelope(input) {
304
+ const exitCode = EXIT.usage;
305
+ const body = {
306
+ schemaVersion: 1,
307
+ command: "check",
308
+ action: "diff",
309
+ status: "fail",
310
+ exitCode,
311
+ adapter: input.adapter ?? null,
312
+ target: input.target,
313
+ profile: input.profile,
314
+ baseRef: input.baseRef,
315
+ baseSource: "unknown",
316
+ artifacts: { dir: input.artifactsDir },
317
+ error: { code: input.code, message: input.message }
318
+ };
319
+ if (input.json) console.log(JSON.stringify(body, null, 2));
320
+ else console.error(`\u2717 check diff: ${input.message}
321
+ Next: ${USAGE}`);
322
+ return exitCode;
323
+ }
324
+ export {
325
+ handleCheck
326
+ };
@@ -0,0 +1,75 @@
1
+ import { createRequire } from "node:module";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const requireFromHarness = createRequire(import.meta.url);
5
+ const CORE_RUNTIME_DEPS = ["immer"];
6
+ function coreDependencyBlock(target) {
7
+ const resolved = path.resolve(target);
8
+ const nodeLinker = readYarnNodeLinker(resolved);
9
+ const installAction = `cd ${shellQuote(resolved)} && yarn install --immutable`;
10
+ if (nodeLinker === "pnp") {
11
+ if (!fs.existsSync(path.join(resolved, ".pnp.cjs"))) {
12
+ return {
13
+ code: "CORE_DEPS_MISSING",
14
+ message: "core dependencies are not installed for Yarn PnP (.pnp.cjs is missing).",
15
+ userAction: installAction
16
+ };
17
+ }
18
+ return null;
19
+ }
20
+ if (!fs.existsSync(path.join(resolved, "node_modules"))) {
21
+ return {
22
+ code: "CORE_DEPS_MISSING",
23
+ message: nodeLinker === "node-modules" ? "core dependencies are not installed (nodeLinker requires node_modules, but node_modules is missing)." : "core dependencies are not installed (node_modules is missing).",
24
+ userAction: installAction
25
+ };
26
+ }
27
+ if (!fs.existsSync(path.join(resolved, "node_modules/.bin/tsx"))) {
28
+ return {
29
+ code: "CORE_DEPS_INCOMPLETE",
30
+ message: "core dependencies are incomplete (node_modules/.bin/tsx is missing).",
31
+ userAction: installAction
32
+ };
33
+ }
34
+ for (const dependency of CORE_RUNTIME_DEPS) {
35
+ if (!canResolveFromTarget(dependency, resolved)) {
36
+ return {
37
+ code: "CORE_DEPS_INCOMPLETE",
38
+ message: `core dependencies are incomplete (cannot resolve ${dependency} from the target checkout).`,
39
+ userAction: installAction
40
+ };
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+ function corePnpNodeOptions(target, current) {
46
+ if (readYarnNodeLinker(target) !== "pnp") return current;
47
+ const pnp = path.join(path.resolve(target), ".pnp.cjs");
48
+ if (!fs.existsSync(pnp)) return current;
49
+ const pnpOption = `--require ${pnp}`;
50
+ return current ? `${current} ${pnpOption}` : pnpOption;
51
+ }
52
+ function canResolveFromTarget(specifier, target) {
53
+ try {
54
+ requireFromHarness.resolve(specifier, { paths: [target] });
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+ function readYarnNodeLinker(target) {
61
+ try {
62
+ const yarnrc = fs.readFileSync(path.join(target, ".yarnrc.yml"), "utf8");
63
+ const match = /^nodeLinker:\s*["']?([^"'\s#]+)["']?/mu.exec(yarnrc);
64
+ return match?.[1];
65
+ } catch {
66
+ return void 0;
67
+ }
68
+ }
69
+ function shellQuote(value) {
70
+ return `'${value.replace(/'/gu, `'\\''`)}'`;
71
+ }
72
+ export {
73
+ coreDependencyBlock,
74
+ corePnpNodeOptions
75
+ };
@@ -49,7 +49,13 @@ function renderDeviceList(devices, out) {
49
49
  }
50
50
  function deviceSelected(device) {
51
51
  if (device.platform === "android") {
52
- return process.env.ADB_SERIAL === device.id || process.env.ANDROID_SERIAL === device.id;
52
+ if (process.env.ADB_SERIAL === device.id || process.env.ANDROID_SERIAL === device.id) return true;
53
+ const pinName = process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ANDROID_DEVICE || "";
54
+ if (!pinName) return false;
55
+ if (pinName === device.id) return true;
56
+ const name = device.name ? normalizeDeviceName(device.name) : "";
57
+ const normalizedPin = normalizeDeviceName(pinName);
58
+ return Boolean(name && (normalizedPin === name || normalizedPin.startsWith(`${name} -`)));
53
59
  }
54
60
  return process.env.IOS_SIMULATOR === device.id || process.env.SIM_UDID === device.id || process.env.IOS_SIMULATOR === device.name;
55
61
  }
@@ -58,8 +64,20 @@ function isTargetable(device) {
58
64
  if (device.platform === "ios") return device.state === "Booted";
59
65
  return false;
60
66
  }
67
+ function mobilePlatformPreference(options) {
68
+ const platform = optionString(options, "platform");
69
+ return platform === "ios" || platform === "android" ? platform : void 0;
70
+ }
71
+ function configuredPinForPlatform(platform) {
72
+ if (platform === "ios") return process.env.IOS_SIMULATOR || process.env.SIM_UDID || void 0;
73
+ return process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ANDROID_DEVICE || void 0;
74
+ }
75
+ function oppositePlatform(platform) {
76
+ return platform === "ios" ? "android" : "ios";
77
+ }
61
78
  function applyDeviceTargeting(command, adapter, options, opts) {
62
79
  const device = optionString(options, "device");
80
+ const platformPreference = mobilePlatformPreference(options);
63
81
  if (adapter !== "mobile") {
64
82
  if (device !== void 0) {
65
83
  return {
@@ -73,15 +91,26 @@ function applyDeviceTargeting(command, adapter, options, opts) {
73
91
  }
74
92
  if (device !== void 0) {
75
93
  const connected2 = listConnectedDevices();
94
+ const setResolvedDevice = (matched) => {
95
+ if (platformPreference && matched.platform !== platformPreference) {
96
+ return {
97
+ ok: false,
98
+ code: "DEVICE_PLATFORM_CONFLICT",
99
+ message: `--device ${device} resolves to ${matched.platform}, but --platform ${platformPreference} was requested.
100
+ To use this device intentionally, drop --platform ${platformPreference} or choose a ${platformPreference} device.`
101
+ };
102
+ }
103
+ if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
104
+ else setIosDeviceEnv(matched.id, matched.name);
105
+ return { ok: true };
106
+ };
76
107
  const androidById = connected2.find((d) => d.platform === "android" && d.id === device);
77
108
  if (androidById) {
78
- setAndroidDeviceEnv(device, androidById.name);
79
- return { ok: true };
109
+ return setResolvedDevice(androidById);
80
110
  }
81
111
  const iosById = connected2.find((d) => d.platform === "ios" && d.id === device);
82
112
  if (iosById) {
83
- setIosDeviceEnv(device, iosById.name);
84
- return { ok: true };
113
+ return setResolvedDevice(iosById);
85
114
  }
86
115
  const normalizedDevice = normalizeDeviceName(device);
87
116
  const byName = connected2.filter((d) => {
@@ -90,10 +119,7 @@ function applyDeviceTargeting(command, adapter, options, opts) {
90
119
  return name === device || normalizedName === normalizedDevice || d.platform === "android" && normalizedDevice.startsWith(`${normalizedName} -`);
91
120
  });
92
121
  if (byName.length === 1) {
93
- const matched = byName[0];
94
- if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
95
- else setIosDeviceEnv(matched.id, matched.name);
96
- return { ok: true };
122
+ return setResolvedDevice(byName[0]);
97
123
  }
98
124
  if (byName.length > 1) {
99
125
  return {
@@ -113,6 +139,75 @@ ${formatConnectedDevices(connected2)}` : ` No devices are currently connected (
113
139
  }
114
140
  const connected = listConnectedDevices();
115
141
  const targetable = connected.filter(isTargetable);
142
+ if (platformPreference) {
143
+ const selectedTargetable2 = targetable.filter(deviceSelected);
144
+ const platformTargetable = targetable.filter((d) => d.platform === platformPreference);
145
+ const selectedPlatformTargetable = platformTargetable.filter(deviceSelected);
146
+ const configuredPin = configuredPinForPlatform(platformPreference);
147
+ const oppositePin = configuredPinForPlatform(oppositePlatform(platformPreference));
148
+ if (selectedPlatformTargetable.length === 1) {
149
+ const selected = selectedPlatformTargetable[0];
150
+ if (selected.platform === "android") setAndroidDeviceEnv(selected.id, selected.name);
151
+ else setIosDeviceEnv(selected.id, selected.name);
152
+ return { ok: true };
153
+ }
154
+ if (selectedTargetable2.length > 0) {
155
+ return {
156
+ ok: false,
157
+ code: "DEVICE_PLATFORM_CONFLICT",
158
+ message: `--platform ${platformPreference} conflicts with the current slot-selected device.
159
+ Selected devices:
160
+ ${formatConnectedDevices(selectedTargetable2)}
161
+ To use a different connected device intentionally, pass --device <id>.`
162
+ };
163
+ }
164
+ if (!configuredPin && oppositePin) {
165
+ return {
166
+ ok: false,
167
+ code: "DEVICE_PLATFORM_CONFLICT",
168
+ message: `--platform ${platformPreference} conflicts with the configured slot target.
169
+ Configured ${oppositePlatform(platformPreference)} target: ${oppositePin}
170
+ To use a different connected device intentionally, pass --device <id>.`
171
+ };
172
+ }
173
+ if (configuredPin) {
174
+ if (command === "launch" || !opts.gate) return { ok: true };
175
+ return {
176
+ ok: false,
177
+ code: "DEVICE_PLATFORM_PIN_NOT_READY",
178
+ message: `--platform ${platformPreference} matches the configured slot target, but that target is not ready.
179
+ Configured target: ${configuredPin}
180
+ Connected devices:
181
+ ${connected.length > 0 ? formatConnectedDevices(connected) : " - none"}
182
+ Next: run mm-harness launch ${platformPreference}, or pass --device <id> to intentionally use another target.`
183
+ };
184
+ }
185
+ if (platformTargetable.length === 1) {
186
+ const selected = platformTargetable[0];
187
+ if (selected.platform === "android") setAndroidDeviceEnv(selected.id, selected.name);
188
+ else setIosDeviceEnv(selected.id, selected.name);
189
+ return { ok: true };
190
+ }
191
+ if (platformTargetable.length === 0) {
192
+ if (command === "launch" && platformPreference === "ios") return { ok: true };
193
+ return {
194
+ ok: false,
195
+ code: "DEVICE_PLATFORM_NOT_FOUND",
196
+ message: `--platform ${platformPreference} did not match any ready connected device.
197
+ ` + (connected.length > 0 ? ` Connected devices:
198
+ ${formatConnectedDevices(connected)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`)
199
+ };
200
+ }
201
+ return {
202
+ ok: false,
203
+ code: "DEVICE_PLATFORM_AMBIGUOUS",
204
+ message: `${platformTargetable.length} ${platformPreference} devices available \u2014 ${command} needs exactly one target.
205
+ Connected devices:
206
+ ${formatConnectedDevices(connected)}
207
+ Add --device <id> to disambiguate, e.g.:${platformTargetable.map((d) => `
208
+ --device ${d.id}`).join("")}`
209
+ };
210
+ }
116
211
  const selectedTargetable = targetable.filter(deviceSelected);
117
212
  if (selectedTargetable.length === 1) {
118
213
  const selected = selectedTargetable[0];
@@ -134,9 +229,17 @@ ${formatConnectedDevices(connected)}
134
229
  }
135
230
  return { ok: true };
136
231
  }
232
+ function scopedDevices(devices, allDevices = false) {
233
+ const withSelection = devices.map((device) => ({ ...device, selected: deviceSelected(device) }));
234
+ if (allDevices) return withSelection;
235
+ const selected = withSelection.filter((device) => device.selected);
236
+ return selected.length > 0 ? selected : withSelection;
237
+ }
137
238
  export {
138
239
  applyDeviceTargeting,
139
240
  deviceSelected,
140
241
  formatConnectedDevices,
141
- renderDeviceList
242
+ mobilePlatformPreference,
243
+ renderDeviceList,
244
+ scopedDevices
142
245
  };
@@ -7,8 +7,14 @@ import { assertAdapter, runnerDir } from "../paths.js";
7
7
  import { getAdapterSurface } from "../adapters/surface.js";
8
8
  import { loadActionManifest, validateManifest } from "../manifest.js";
9
9
  import { ensureOverlay, newHealState, recipeRunning } from "../heal-bounds.js";
10
- import { listConnectedDevices } from "../devices.js";
11
- import { applyDeviceTargeting, deviceSelected, renderDeviceList } from "./device-target.js";
10
+ import { applyDeviceTargeting } from "./device-target.js";
11
+ import {
12
+ mobileDeviceView,
13
+ mobileDeviceLiveView,
14
+ renderAdditionalReachableDevices,
15
+ renderMobileDeviceList,
16
+ renderMobileLiveBlock
17
+ } from "./mobile-device-view.js";
12
18
  import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
13
19
  import {
14
20
  actionManifestPathOption,
@@ -35,18 +41,14 @@ async function handleDoctor({ options }) {
35
41
  const target = targetPath(options);
36
42
  const json = optionFlag(options, "json");
37
43
  const expectLive = optionFlag(options, "expectLive");
38
- const explicitAdapter = optionString(options, "adapter") ?? optionString(options, "platform");
44
+ const allDevices = optionFlag(options, "allDevices");
45
+ const platformOption = optionString(options, "platform");
46
+ const explicitAdapter = optionString(options, "adapter") ?? (platformOption === "ios" || platformOption === "android" ? "mobile" : platformOption);
39
47
  const adapter = explicitAdapter ?? detectAdapter(target);
40
48
  if (!adapter) {
41
49
  return usageOut(json, "doctor", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
42
50
  }
43
51
  assertAdapter(adapter);
44
- const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
45
- if ("code" in dtResult) {
46
- if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
47
- else console.error(`\u2717 doctor: ${dtResult.message}`);
48
- return EXIT.usage;
49
- }
50
52
  const actionManifestPath = actionManifestPathOption(options, adapter);
51
53
  const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
52
54
  const manifestValidation = await validateManifest(manifest);
@@ -62,15 +64,24 @@ async function handleDoctor({ options }) {
62
64
  try {
63
65
  const surface = getAdapterSurface(adapter);
64
66
  surface.resolveSlotPorts(target);
67
+ const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
68
+ if ("code" in dtResult) {
69
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
70
+ else console.error(`\u2717 doctor: ${dtResult.message}`);
71
+ return EXIT.usage;
72
+ }
65
73
  applyDoctorRuntimePorts(options);
66
74
  runtime = await surface.runtimeStatus(target);
67
75
  } catch {
68
76
  }
69
77
  const orphanMetros = adapter === "mobile" ? detectOrphanMetros(target, process.env.WATCHER_PORT) : [];
70
78
  const capture = captureHelperHealth();
71
- const devices = adapter === "mobile" ? enumerateSelectedDevices() : [];
72
- if (expectLive) return emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, json);
73
- if (json) console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices }, null, 2));
79
+ const deviceView = adapter === "mobile" ? mobileDeviceView(allDevices) : null;
80
+ const liveView = deviceView ? await mobileDeviceLiveView(target, deviceView) : null;
81
+ const devices = liveView?.devicesWithLive ?? deviceView?.devices ?? [];
82
+ const additionalReachableDevices = liveView?.additionalReachableDevices ?? [];
83
+ if (expectLive) return emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json);
84
+ if (json) console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
74
85
  else {
75
86
  const out = (style, text) => color(style, text, { stream: process.stdout });
76
87
  const stateStyle = (value, good) => value === good ? "ok" : "warn";
@@ -100,17 +111,18 @@ async function handleDoctor({ options }) {
100
111
  console.log(` ${out("dim", "Next: grant Screen Recording (System Settings \u2192 Privacy & Security \u2192 Screen Recording), or run: capture-helper doctor --open-permissions")}`);
101
112
  }
102
113
  }
103
- if (adapter === "mobile") renderDeviceList(devices, out);
114
+ if (adapter === "mobile") {
115
+ if (deviceView) renderMobileDeviceList(deviceView, out);
116
+ if (additionalReachableDevices.length > 0) renderAdditionalReachableDevices(additionalReachableDevices, out);
117
+ if (liveView) renderMobileLiveBlock(deviceView?.devices ?? [], liveView.liveMap, out);
118
+ }
104
119
  }
105
120
  return result.status === "pass" ? 0 : 1;
106
121
  }
107
- function enumerateSelectedDevices() {
108
- return listConnectedDevices().map((device) => ({ ...device, selected: deviceSelected(device) }));
109
- }
110
- function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, json) {
122
+ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json) {
111
123
  const live = runtime?.decision === "ready";
112
124
  if (json) {
113
- console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices }, null, 2));
125
+ console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
114
126
  return live ? EXIT.ok : EXIT.runtime;
115
127
  }
116
128
  const out = (style, text) => color(style, text, { stream: process.stdout });