@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
@@ -4,7 +4,9 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
6
6
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "../../paths.js";
7
+ import { extensionIdFromKey } from "../../adapters/extension/extension-id.js";
7
8
  import { isExtensionDistStale } from "../../adapters/extension/runtime-decision.js";
9
+ import { checkExtensionRuntimeHealth } from "../../adapters/extension/runtime.js";
8
10
  import { stopExtensionWatcher } from "../../adapters/slot-ports.js";
9
11
  import { spawnScriptStreaming } from "../shared.js";
10
12
  function extensionDepsBlock(target) {
@@ -22,7 +24,7 @@ function extensionDepsBlock(target) {
22
24
  }
23
25
  return null;
24
26
  }
25
- async function launchExtension(target, tier, wantWatch) {
27
+ async function launchExtension(target, tier, wantWatch, displayMode = "fullscreen") {
26
28
  if (wantWatch) {
27
29
  const startWatchSh = path.join(runnerDir, "adapters/extension/start-watch.sh");
28
30
  const watchArgs = ["--target", target];
@@ -34,29 +36,53 @@ async function launchExtension(target, tier, wantWatch) {
34
36
  return extensionRebuild(target);
35
37
  }
36
38
  if (await extensionRuntimeReusable(target)) {
37
- return extensionReattach(target);
39
+ return extensionReattach(target, displayMode);
38
40
  }
39
41
  return extensionRebuild(target);
40
42
  }
41
43
  async function extensionRuntimeReusable(target) {
42
- const watcherPort = process.env.WATCHER_PORT;
43
44
  const cdpPort = process.env.CDP_PORT;
44
- if (!watcherPort || !cdpPort) return false;
45
- if (!portHasListener(watcherPort)) return false;
45
+ if (!cdpPort) return false;
46
+ if (!cdpOwnedByExpectedRuntime(cdpPort, target)) return false;
46
47
  if (!await cdpVersionReachable(cdpPort)) return false;
48
+ if (!await cdpHasExpectedExtensionTarget(cdpPort, target)) return false;
49
+ if (!await cdpRuntimeHealthy(cdpPort, target)) return false;
47
50
  if (isExtensionDistStale(target)) return false;
48
51
  return true;
49
52
  }
50
- function portHasListener(port) {
53
+ function expectedChromeProfile(target) {
54
+ const profileName = process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile";
55
+ return path.resolve(process.env.CHROME_USER_DATA_DIR || path.join(target, recipeRuntimeDir(), profileName));
56
+ }
57
+ function expectedRuntimeDist(target) {
58
+ return path.resolve(path.join(target, recipeRuntimeDir(), process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist"));
59
+ }
60
+ function cdpOwnedByExpectedRuntime(port, target) {
61
+ let pids = [];
51
62
  try {
52
63
  const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
53
64
  encoding: "utf8",
54
65
  stdio: ["ignore", "pipe", "ignore"]
55
66
  });
56
- return out.split(/\s+/u).some((value) => /^\d+$/u.test(value));
67
+ pids = out.split(/\s+/u).filter((value) => /^\d+$/u.test(value));
57
68
  } catch {
58
69
  return false;
59
70
  }
71
+ const profile = expectedChromeProfile(target);
72
+ const runtimeDist = expectedRuntimeDist(target);
73
+ for (const pid of pids) {
74
+ try {
75
+ const command = execFileSync("ps", ["-ww", "-o", "command=", "-p", pid], {
76
+ encoding: "utf8",
77
+ stdio: ["ignore", "pipe", "ignore"]
78
+ });
79
+ if (command.includes(`--user-data-dir=${profile}`) && (command.includes(`--load-extension=${runtimeDist}`) || command.includes(`--disable-extensions-except=${runtimeDist}`))) {
80
+ return true;
81
+ }
82
+ } catch {
83
+ }
84
+ }
85
+ return false;
60
86
  }
61
87
  function cdpVersionReachable(port) {
62
88
  return new Promise((resolve) => {
@@ -75,9 +101,79 @@ function cdpVersionReachable(port) {
75
101
  request.on("error", () => resolve(false));
76
102
  });
77
103
  }
78
- async function extensionReattach(target) {
104
+ function expectedExtensionId(target) {
105
+ const runtimeDir = recipeRuntimeDir();
106
+ try {
107
+ const manifest = JSON.parse(
108
+ fs.readFileSync(path.join(target, runtimeDir, process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist", "manifest.json"), "utf8")
109
+ );
110
+ if (typeof manifest.key === "string" && manifest.key) return extensionIdFromKey(manifest.key);
111
+ } catch {
112
+ }
113
+ try {
114
+ const marker = fs.readFileSync(path.join(target, runtimeDir, "extension.id"), "utf8").trim();
115
+ return /^[a-p]{32}$/u.test(marker) ? marker : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+ function cdpHasExpectedExtensionTarget(port, target) {
121
+ return new Promise((resolve) => {
122
+ const request = http.get(
123
+ { host: "127.0.0.1", port: Number(port), path: "/json/list", timeout: 2e3 },
124
+ (response) => {
125
+ let body = "";
126
+ response.setEncoding("utf8");
127
+ response.on("data", (chunk) => {
128
+ body += chunk;
129
+ });
130
+ response.on("end", () => {
131
+ if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300) {
132
+ resolve(false);
133
+ return;
134
+ }
135
+ let targets;
136
+ try {
137
+ targets = JSON.parse(body);
138
+ } catch {
139
+ resolve(false);
140
+ return;
141
+ }
142
+ if (!Array.isArray(targets)) {
143
+ resolve(false);
144
+ return;
145
+ }
146
+ const expectedId = expectedExtensionId(target);
147
+ if (!expectedId) {
148
+ resolve(false);
149
+ return;
150
+ }
151
+ resolve(targets.some((entry) => {
152
+ if (!entry || typeof entry !== "object") return false;
153
+ const candidate = entry;
154
+ return typeof candidate.url === "string" && candidate.url.startsWith(`chrome-extension://${expectedId}/`);
155
+ }));
156
+ });
157
+ }
158
+ );
159
+ request.on("timeout", () => {
160
+ request.destroy();
161
+ resolve(false);
162
+ });
163
+ request.on("error", () => resolve(false));
164
+ });
165
+ }
166
+ async function cdpRuntimeHealthy(port, target) {
167
+ try {
168
+ const report = await checkExtensionRuntimeHealth(target, Number(port), { pageMode: "home-or-sidepanel" });
169
+ return report.status === "PASS";
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+ async function extensionReattach(target, displayMode = "fullscreen") {
79
175
  const reattachScript = recipeHarnessPath(target, "extension", "scripts", "reattach.sh");
80
- const reattachArgs = ["--target", target];
176
+ const reattachArgs = ["--target", target, "--display-mode", displayMode];
81
177
  if (process.env.CDP_PORT) reattachArgs.push("--cdp-port", process.env.CDP_PORT);
82
178
  if (process.env.WATCHER_PORT) reattachArgs.push("--watcher-port", process.env.WATCHER_PORT);
83
179
  if (process.env.EXTENSION_START_URL) reattachArgs.push("--start-url", process.env.EXTENSION_START_URL);
@@ -35,6 +35,7 @@ const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
35
35
  "runway",
36
36
  "json"
37
37
  ]);
38
+ const DEFAULT_EXTENSION_DAPP_URL = "https://metamask.github.io/test-dapp/";
38
39
  async function handleLaunch(argv) {
39
40
  const { positional, options } = parseFlags(argv, LAUNCH_BOOLEANS);
40
41
  const json = flag(options, "json");
@@ -85,10 +86,20 @@ async function handleLaunch(argv) {
85
86
  if (envResult && "code" in envResult) {
86
87
  return usageOut(json, "launch", envResult.message, "mm-harness launch android --device <adb-serial|device-name>");
87
88
  }
88
- if (adapter === "extension" && typeof options["url"] === "string" && options["url"]) {
89
- process.env.EXTENSION_START_URL = options["url"];
90
- }
91
89
  const tier = wantVerify ? "verify" : wantBuild ? "build" : "quick";
90
+ if (adapter === "extension") {
91
+ const requestedUrl = typeof options["url"] === "string" && options["url"] ? options["url"] : "";
92
+ if (requestedUrl || displayMode === "sidepanel") {
93
+ process.env.EXTENSION_START_URL = requestedUrl || DEFAULT_EXTENSION_DAPP_URL;
94
+ }
95
+ }
96
+ if (!json && adapter === "extension" && tier !== "verify") {
97
+ const modeNote = displayMode === "sidepanel" ? `sidepanel \xB7 dapp ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}` : "fullscreen";
98
+ const workNote = wantWatch ? "watch only" : tier === "build" ? "clean build" : "quick reuse probe";
99
+ console.error(
100
+ `\u2192 extension launch \u2014 ${modeNote} \xB7 CDP :${process.env.CDP_PORT ?? "default"} \xB7 ${workNote}`
101
+ );
102
+ }
92
103
  const state = newHealState();
93
104
  if (tier === "quick" && nativeInputsChanged(target, adapter)) {
94
105
  return usageOut(
@@ -130,9 +141,9 @@ async function handleLaunch(argv) {
130
141
  }
131
142
  }
132
143
  }
133
- let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json);
144
+ let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
134
145
  if (attempt.status === 0) {
135
- return finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state);
146
+ return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch);
136
147
  }
137
148
  if (heal === "off") {
138
149
  return launchFail(json, adapter, mobileTarget, tier, state, {
@@ -142,6 +153,32 @@ async function handleLaunch(argv) {
142
153
  exitCode: EXIT.infra
143
154
  });
144
155
  }
156
+ if (adapter === "extension" && tier === "quick" && extensionQuickReattachFailed(attempt.output)) {
157
+ const recoveryCode2 = "chrome.relaunched";
158
+ state.attemptedRecoveries.push(recoveryCode2);
159
+ const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, json, displayMode);
160
+ if (rebuildAttempt.status === 0) {
161
+ state.recovered.push(recoveryCode2);
162
+ return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch);
163
+ }
164
+ return launchFail(json, adapter, mobileTarget, tier, state, {
165
+ code: "EXTENSION_RELAUNCH_FAILED",
166
+ message: "quick reattach failed, and clean relaunch failed too.",
167
+ recoverable: false,
168
+ exitCode: EXIT.infra,
169
+ originalError: rebuildAttempt.output.trim() || void 0
170
+ });
171
+ }
172
+ if (adapter === "mobile" && mobileProvisioningBlocked(attempt.output)) {
173
+ return launchFail(json, adapter, mobileTarget, tier, state, {
174
+ code: "MOBILE_PROVISION_REQUIRED",
175
+ message: `mobile ${mobileTarget ?? "runtime"} is not provisioned for this slot.`,
176
+ recoverable: false,
177
+ userAction: mobileProvisionCommand(target, mobileTarget),
178
+ exitCode: EXIT.runtime,
179
+ originalError: attempt.output.trim() || void 0
180
+ });
181
+ }
145
182
  const bound = checkHealBounds(target, attempt.output, state);
146
183
  if (bound !== null) {
147
184
  return launchFail(json, adapter, mobileTarget, tier, state, {
@@ -155,10 +192,10 @@ async function handleLaunch(argv) {
155
192
  }
156
193
  const recoveryCode = RECOVERY_CODE[adapter];
157
194
  state.attemptedRecoveries.push(recoveryCode);
158
- attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json);
195
+ attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
159
196
  if (attempt.status === 0) {
160
197
  state.recovered.push(recoveryCode);
161
- return finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state);
198
+ return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch);
162
199
  }
163
200
  return launchFail(json, adapter, mobileTarget, tier, state, {
164
201
  code: "SAME_RECOVERY_TWICE",
@@ -168,11 +205,35 @@ async function handleLaunch(argv) {
168
205
  originalError: attempt.output.trim() || void 0
169
206
  });
170
207
  }
208
+ function extensionQuickReattachFailed(output) {
209
+ return /\[reattach\]|extension quick reattach|mm-harness launch --build/u.test(output);
210
+ }
211
+ function extensionRuntimeBlocked(output) {
212
+ return /ERR_BLOCKED_BY_CLIENT|has been blocked by Chromium/u.test(output);
213
+ }
214
+ function mobileProvisioningBlocked(output) {
215
+ return /open-device: configured iOS simulator '.+' does not exist|open-device: no MetaMask bundle found|fast mode requires an installed (?:iOS dev client|Android dev client)/u.test(output);
216
+ }
217
+ function mobileProvisionCommand(target, mobileTarget) {
218
+ const platform = mobileTarget === "android" ? "android" : "ios";
219
+ const device = platform === "ios" ? process.env.IOS_SIMULATOR || process.env.SIM_UDID : process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE;
220
+ const parts = ["mm-harness", "provision", "runway", platform, "--adapter", "mobile", "--target", shellQuote(target)];
221
+ if (device) parts.push("--device", shellQuote(device));
222
+ return parts.join(" ");
223
+ }
224
+ function shellQuote(value) {
225
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) return value;
226
+ return `'${value.replace(/'/gu, `'"'"'`)}'`;
227
+ }
171
228
  function applyLaunchEnvOverrides(options, adapter, mobileTarget, target) {
172
229
  getAdapterSurface(adapter).resolveSlotPorts(target);
173
230
  const device = str(options, "device");
174
- if (device && adapter === "mobile") {
175
- const result = applyDeviceTargeting("launch", adapter, options, { gate: false, rerun: "" });
231
+ if (adapter === "mobile" && (device || mobileTarget === "ios" || mobileTarget === "android")) {
232
+ const targetingOptions = {
233
+ ...options,
234
+ ...mobileTarget && !device ? { platform: mobileTarget } : {}
235
+ };
236
+ const result = applyDeviceTargeting("launch", adapter, targetingOptions, { gate: false, rerun: "" });
176
237
  if ("code" in result) {
177
238
  if (mobileTarget === "ios" && result.code === "DEVICE_NOT_FOUND") {
178
239
  process.env.IOS_SIMULATOR = device;
@@ -185,14 +246,14 @@ function applyLaunchEnvOverrides(options, adapter, mobileTarget, target) {
185
246
  return result;
186
247
  }
187
248
  }
188
- if (mobileTarget === "android" && !process.env.ADB_SERIAL && !process.env.ANDROID_SERIAL) {
249
+ if (mobileTarget === "android" && device && !process.env.ADB_SERIAL && !process.env.ANDROID_SERIAL) {
189
250
  return {
190
251
  ok: false,
191
252
  code: "DEVICE_WRONG_PLATFORM",
192
253
  message: `--device ${device} did not resolve to an Android device for launch android.`
193
254
  };
194
255
  }
195
- if (mobileTarget === "ios" && !process.env.IOS_SIMULATOR) {
256
+ if (mobileTarget === "ios" && device && !process.env.IOS_SIMULATOR) {
196
257
  return {
197
258
  ok: false,
198
259
  code: "DEVICE_WRONG_PLATFORM",
@@ -236,19 +297,50 @@ function nativeInputsChanged(target, adapter) {
236
297
  return false;
237
298
  }
238
299
  }
239
- async function executeComposition(adapter, mobileTarget, tier, wantWatch, target, json) {
300
+ async function executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode = "fullscreen") {
240
301
  if (adapter === "mobile") {
241
302
  return launchMobile(target, mobileTarget, tier, json);
242
303
  }
243
- return launchExtension(target, tier, wantWatch);
304
+ return launchExtension(target, tier, wantWatch, displayMode);
244
305
  }
245
- function finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state) {
306
+ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, sidepanelRecoveryAllowed = true) {
246
307
  if (adapter === "extension" && displayMode === "sidepanel") {
247
308
  const sidepanelSh = path.join(runnerDir, "adapters/extension/sidepanel-toggle.sh");
248
309
  const sidepanelArgs = ["open"];
249
310
  if (process.env.CDP_PORT) sidepanelArgs.push("--cdp-port", process.env.CDP_PORT);
311
+ if (!json) {
312
+ console.error(
313
+ `[sidepanel] opening panel beside ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}`
314
+ );
315
+ }
250
316
  const sidepanel = spawnScript(sidepanelSh, sidepanelArgs, target, json, { REPO: target });
251
317
  if (sidepanel.status !== 0) {
318
+ const recoveryCode = "chrome.relaunched";
319
+ if (sidepanelRecoveryAllowed && tier === "quick" && extensionRuntimeBlocked(sidepanel.output) && !state.attemptedRecoveries.includes(recoveryCode)) {
320
+ state.attemptedRecoveries.push(recoveryCode);
321
+ const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, json, displayMode);
322
+ if (rebuildAttempt.status === 0) {
323
+ state.recovered.push(recoveryCode);
324
+ return await finishLaunch(
325
+ json,
326
+ adapter,
327
+ mobileTarget,
328
+ tier,
329
+ displayMode,
330
+ target,
331
+ state,
332
+ wantWatch,
333
+ false
334
+ );
335
+ }
336
+ return launchFail(json, adapter, mobileTarget, tier, state, {
337
+ code: "EXTENSION_RELAUNCH_FAILED",
338
+ message: "sidepanel open found a blocked extension runtime, and clean relaunch failed too.",
339
+ recoverable: false,
340
+ exitCode: EXIT.infra,
341
+ originalError: rebuildAttempt.output.trim() || void 0
342
+ });
343
+ }
252
344
  return launchFail(json, adapter, mobileTarget, tier, state, {
253
345
  code: "SIDEPANEL_OPEN_FAILED",
254
346
  message: "app launched but opening the side panel failed.",
@@ -282,7 +374,7 @@ function launchPass(json, adapter, mobileTarget, tier, displayMode, state) {
282
374
  )
283
375
  );
284
376
  } else {
285
- const device = adapter === "mobile" ? process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || "booted device" : displayMode;
377
+ const device = adapter === "mobile" ? launchDeviceLabel(mobileTarget) : displayMode;
286
378
  const tierNote = tier === "quick" ? "quick relaunch, no native build" : tier;
287
379
  const devNote = process.env.MM_HARNESS_BIN ? ` ${color("dim", "[dev: MM_HARNESS_BIN]")}` : "";
288
380
  console.error(
@@ -291,6 +383,15 @@ function launchPass(json, adapter, mobileTarget, tier, displayMode, state) {
291
383
  }
292
384
  return EXIT.ok;
293
385
  }
386
+ function launchDeviceLabel(mobileTarget) {
387
+ if (mobileTarget === "android") {
388
+ return process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE || "android device";
389
+ }
390
+ if (mobileTarget === "ios") {
391
+ return process.env.IOS_SIMULATOR || process.env.SIM_UDID || "iOS simulator";
392
+ }
393
+ return process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || "booted device";
394
+ }
294
395
  function launchFail(json, adapter, mobileTarget, tier, state, failure) {
295
396
  if (json) {
296
397
  console.log(
@@ -0,0 +1,140 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { listConnectedDevices } from "../devices.js";
3
+ import { renderDeviceList, scopedDevices } from "./device-target.js";
4
+ import { probeMobileLiveState } from "./status-probe.js";
5
+ function mobileDeviceView(allDevices) {
6
+ const allConnectedDevices = listConnectedDevices();
7
+ return {
8
+ allConnectedDevices,
9
+ devices: scopedDevices(allConnectedDevices, allDevices)
10
+ };
11
+ }
12
+ async function mobileDeviceLiveView(target, view) {
13
+ const allLiveMap = await probeMobileLiveState(target, view.allConnectedDevices);
14
+ const liveMap = scopedLiveMap(view.devices, allLiveMap);
15
+ const devicesWithLive = view.devices.map((device) => mergeDeviceLive(device, liveMap.get(device.id)));
16
+ const reachableDevices = detectAdditionalReachableDevices(view.devices, view.allConnectedDevices, allLiveMap);
17
+ return {
18
+ liveMap,
19
+ devicesWithLive,
20
+ additionalReachableDevices: reachableDevices,
21
+ androidPortHints: selectedAndroidPortHints(view.devices, liveMap)
22
+ };
23
+ }
24
+ function scopedLiveMap(devices, allLiveMap) {
25
+ const liveMap = /* @__PURE__ */ new Map();
26
+ for (const device of devices) {
27
+ const live = allLiveMap.get(device.id);
28
+ if (live) liveMap.set(device.id, live);
29
+ }
30
+ return liveMap;
31
+ }
32
+ function detectAdditionalReachableDevices(scoped, allConnected, liveMap) {
33
+ const scopedIds = new Set(scoped.map((device) => device.id));
34
+ return allConnected.filter((device) => !scopedIds.has(device.id)).map((device) => {
35
+ const live = liveMap.get(device.id);
36
+ if (!live || live.liveState === "no-bridge") return null;
37
+ return mergeDeviceLive({ ...device, selected: false }, live);
38
+ }).filter((device) => device !== null);
39
+ }
40
+ function renderMobileDeviceList(view, out) {
41
+ renderDeviceList(view.devices, out);
42
+ }
43
+ function renderAdditionalReachableDevices(devices, out) {
44
+ console.log(`${out("warn", "additional reachable target:")} ${devices.length} device(s) answered on this Metro but are not the active target for this command`);
45
+ for (const device of devices) {
46
+ console.log(` ${out("warn", "\u25CB")} ${device.id}${device.name ? ` (${device.name})` : ""} ${out("dim", `[${device.state}] ${device.platform}`)}`);
47
+ const live = formatDeviceLive(device, out);
48
+ if (live) console.log(` ${live}`);
49
+ console.log(` ${out("dim", `Use --device ${device.id} to inspect/run/call this target intentionally.`)}`);
50
+ }
51
+ }
52
+ function renderMobileLiveBlock(devices, liveMap, out) {
53
+ console.log(`${out("label", "live:")}`);
54
+ for (const device of devices) {
55
+ const live = liveMap.get(device.id);
56
+ const prefix = ` ${device.platform} ${out("dim", device.id)}${device.name ? ` (${device.name})` : ""}`;
57
+ if (!live || live.liveState === "no-bridge") {
58
+ console.log(`${prefix}: ${out("dim", "(no-bridge)")}`);
59
+ continue;
60
+ }
61
+ const rendered = formatDeviceLive(mergeDeviceLive(device, live), out);
62
+ console.log(`${prefix}: ${rendered.replace(/^live: /u, "")}`);
63
+ }
64
+ }
65
+ function renderAndroidPortHints(hints, out) {
66
+ for (const hint of hints) {
67
+ const current = hint.currentPort ? `; current slot Metro is ${hint.currentPort}` : "";
68
+ console.log(`${out("warn", "android reverse:")} selected ${hint.deviceId} has reverse port(s) ${hint.reversePorts.join(", ")}${current}`);
69
+ console.log(` ${out("dim", `Next: ${hint.next} # rewrites adb reverse and reopens the dev client`)}`);
70
+ }
71
+ }
72
+ function nextForLive(fallback, liveMap) {
73
+ for (const live of liveMap.values()) {
74
+ if (live.liveState === void 0) return "mm-harness logs";
75
+ }
76
+ return fallback;
77
+ }
78
+ function mergeDeviceLive(device, live) {
79
+ if (!live) return device;
80
+ return {
81
+ ...device,
82
+ fixtureStatus: live.fixtureStatus,
83
+ ...live.liveState !== void 0 ? { liveState: live.liveState } : {},
84
+ ...live.currentScreen !== void 0 ? { currentScreen: live.currentScreen } : {},
85
+ ...live.walletState !== void 0 ? { walletState: live.walletState } : {},
86
+ ...live.selectedAccount !== void 0 ? { selectedAccount: live.selectedAccount } : {}
87
+ };
88
+ }
89
+ function formatDeviceLive(device, out) {
90
+ if (device.liveState === "bridge-absent") {
91
+ return out("warn", "live: bridge-absent \u2014 app attached, build lacks __AGENTIC__; rebuild/reinstall a dev build");
92
+ }
93
+ const parts = [];
94
+ if (device.currentScreen !== void 0) parts.push(`screen=${out("cmd", device.currentScreen)}`);
95
+ if (device.walletState !== void 0) {
96
+ parts.push(`wallet=${out(device.walletState === "unlocked" ? "ok" : "warn", device.walletState)}`);
97
+ }
98
+ if (device.selectedAccount !== void 0) {
99
+ parts.push(`account=${device.selectedAccount.label} ${out("dim", `(${device.selectedAccount.address})`)}`);
100
+ }
101
+ if (device.fixtureStatus !== void 0) {
102
+ parts.push(`fixture=${out(device.fixtureStatus === "READY" ? "ok" : "warn", device.fixtureStatus)}`);
103
+ }
104
+ return parts.length > 0 ? `live: ${parts.join(" ")}` : "";
105
+ }
106
+ function selectedAndroidPortHints(devices, liveMap) {
107
+ const currentPort = process.env.WATCHER_PORT || process.env.METRO_PORT || void 0;
108
+ return devices.filter((device) => device.selected && device.platform === "android").filter((device) => liveMap.get(device.id)?.liveState === "no-bridge").map((device) => {
109
+ const reversePorts = androidReversePorts(device.id).filter((port) => currentPort === void 0 || port !== currentPort);
110
+ return {
111
+ deviceId: device.id,
112
+ ...currentPort !== void 0 ? { currentPort } : {},
113
+ reversePorts,
114
+ next: `mm-harness launch android --device ${device.id}`
115
+ };
116
+ }).filter((hint) => hint.reversePorts.length > 0);
117
+ }
118
+ function androidReversePorts(serial) {
119
+ try {
120
+ const out = execFileSync("adb", ["-s", serial, "reverse", "--list"], {
121
+ encoding: "utf8",
122
+ stdio: ["ignore", "pipe", "ignore"],
123
+ timeout: 5e3
124
+ });
125
+ return [...out.matchAll(/tcp:(\d+)\s+tcp:(\d+)/gu)].map((match) => match[2]).filter((port, index, ports) => ports.indexOf(port) === index);
126
+ } catch {
127
+ return [];
128
+ }
129
+ }
130
+ export {
131
+ detectAdditionalReachableDevices,
132
+ mergeDeviceLive,
133
+ mobileDeviceLiveView,
134
+ mobileDeviceView,
135
+ nextForLive,
136
+ renderAdditionalReachableDevices,
137
+ renderAndroidPortHints,
138
+ renderMobileDeviceList,
139
+ renderMobileLiveBlock
140
+ };
@@ -30,6 +30,7 @@ function parseArgs(argv, command) {
30
30
  "resolveOnly",
31
31
  "expectLive",
32
32
  "fast",
33
+ "allDevices",
33
34
  "help"
34
35
  ]);
35
36
  for (let i = 0; i < argv.length; i += 1) {
@@ -149,7 +150,9 @@ function runtimeOptionsFromCli(options) {
149
150
  }
150
151
  function resolveAdapter(options) {
151
152
  const target = targetPath(options);
152
- const explicit = optionString(options, "adapter") ?? optionString(options, "platform");
153
+ const adapterOptionValue = optionString(options, "adapter");
154
+ const platformOptionValue = optionString(options, "platform");
155
+ const explicit = adapterOptionValue ?? (platformOptionValue === "ios" || platformOptionValue === "android" ? "mobile" : platformOptionValue);
153
156
  const adapter = explicit ?? detectAdapter(target);
154
157
  if (!adapter) {
155
158
  throw usageError(`could not detect the MetaMask repo type for ${target}
@@ -6,6 +6,7 @@ import {
6
6
  import { EXIT, usageOut } from "./shared.js";
7
7
  import { optionFlag, optionString, parseArgs } from "./parse-args.js";
8
8
  const BUILD_USAGE = "mm-harness recipe-quality build --input <compact.json> --output <path> [--json]";
9
+ const SHORTHAND_USAGE = "mm-harness recipe-quality build --input <compact.json> --output <path> [--json]\n Shorthand: mm-harness recipe-quality --input <compact.json> --output <path> [--json]\n Note: this CLI builds the recipe-quality artifact from an existing compact verdict; recipe critique is performed by the fs-recipe-quality skill.";
9
10
  function errorMessage(error) {
10
11
  return error instanceof Error ? error.message : String(error);
11
12
  }
@@ -36,9 +37,12 @@ async function handleRecipeQuality(argv) {
36
37
  const { positional, options } = parseArgs(argv, "recipe-quality");
37
38
  const json = optionFlag(options, "json");
38
39
  const action = positional[0];
40
+ if (!action && (optionString(options, "input") || optionString(options, "output"))) {
41
+ return buildArtifact(options, json);
42
+ }
39
43
  if (action !== "build") {
40
- const message = action ? `unknown action '${action}'` : "missing action";
41
- return usageOut(json, "recipe-quality", message, BUILD_USAGE);
44
+ const message = action ? `unknown action '${action}'` : "missing action: use build, or pass --input and --output";
45
+ return usageOut(json, "recipe-quality", message, SHORTHAND_USAGE);
42
46
  }
43
47
  return buildArtifact(options, json);
44
48
  }
@@ -25,6 +25,7 @@ import {
25
25
  actionManifestPathOption,
26
26
  optionString,
27
27
  isRecord,
28
+ shellQuoteArg,
28
29
  usageError
29
30
  } from "./parse-args.js";
30
31
  async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}) {
@@ -119,7 +120,8 @@ async function prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions) {
119
120
  cdpPort: runtimeOptions.cdpPort,
120
121
  slot: runtimeOptions.slot,
121
122
  launchExistingDist: runtimeOptions.launchExistingDist === true,
122
- validationRuntimeDir: runtimeOptions.validationRuntimeDir
123
+ validationRuntimeDir: runtimeOptions.validationRuntimeDir,
124
+ pageMode: "home-or-sidepanel"
123
125
  });
124
126
  }
125
127
  function restoreEnv(key, value) {
@@ -260,7 +262,13 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
260
262
  ...librarySources ? { librarySources } : {}
261
263
  };
262
264
  if ("notFound" in resolved) {
263
- return { ...empty, usageError: { code: "RECIPE_NOT_FOUND", message: resolved.notFound } };
265
+ return {
266
+ ...empty,
267
+ usageError: {
268
+ code: "RECIPE_NOT_FOUND",
269
+ message: resolved.notFound + await actionInsteadOfRecipeHint(recipeArg, adapter, options)
270
+ }
271
+ };
264
272
  }
265
273
  let recipe;
266
274
  try {
@@ -301,6 +309,24 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
301
309
  ...librarySources ? { librarySources } : {}
302
310
  };
303
311
  }
312
+ async function actionInsteadOfRecipeHint(recipeArg, adapter, options) {
313
+ if (recipeArg.includes("/") || recipeArg.includes(path.sep)) return "";
314
+ try {
315
+ const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
316
+ const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
317
+ const actions = getRecipeActionManifestActionNames(manifest);
318
+ const matches = actions.includes(recipeArg) ? [recipeArg] : actions.filter((name) => name.split(".").pop() === recipeArg);
319
+ if (matches.length !== 1) return "";
320
+ const device = optionString(options, "device");
321
+ const actionManifest = optionString(options, "actionManifest");
322
+ const parts = ["mm-harness", "call", matches[0]];
323
+ if (device) parts.push("--device", shellQuoteArg(device));
324
+ if (actionManifest) parts.push("--action-manifest", shellQuoteArg(actionManifest));
325
+ return ` This is an action, not a recipe. Use: ${parts.join(" ")}.`;
326
+ } catch {
327
+ return "";
328
+ }
329
+ }
304
330
  async function resolveMetaMaskLibrarySources(libraryEntry) {
305
331
  const harness = await importRecipeHarness();
306
332
  if (typeof harness.resolveRecipeLibrarySources !== "function") {