@deeeed/metamask-harness 0.13.0 → 0.14.1

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 (66) hide show
  1. package/CHANGELOG.md +43 -4
  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 +15 -4
  10. package/adapters/extension/wallet-fixture-state.cjs +6 -11
  11. package/adapters/mobile/open-device.sh +32 -5
  12. package/adapters/mobile/start-metro.sh +15 -1
  13. package/adapters/mobile/verify.sh +50 -13
  14. package/adapters/shared/log-tui.mjs +7 -3
  15. package/adapters/shared/resolve-slot-ports-core.mjs +14 -4
  16. package/dist/adapters/core/surface.js +1 -0
  17. package/dist/adapters/extension/runtime.js +14 -5
  18. package/dist/adapters/extension/surface.js +1 -0
  19. package/dist/adapters/mobile/provision.js +49 -4
  20. package/dist/adapters/mobile/surface.js +1 -0
  21. package/dist/adapters/slot-ports.js +11 -4
  22. package/dist/cli-commands.js +5 -4
  23. package/dist/cli.js +4 -0
  24. package/dist/commands/call.js +79 -6
  25. package/dist/commands/check.js +326 -0
  26. package/dist/commands/core-readiness.js +75 -0
  27. package/dist/commands/device-target.js +123 -13
  28. package/dist/commands/doctor.js +30 -18
  29. package/dist/commands/fixtures.js +2 -1
  30. package/dist/commands/launch/extension.js +105 -9
  31. package/dist/commands/launch/index.js +116 -15
  32. package/dist/commands/mobile-device-view.js +140 -0
  33. package/dist/commands/parse-args.js +4 -1
  34. package/dist/commands/recipe-quality.js +6 -2
  35. package/dist/commands/run-engine.js +90 -5
  36. package/dist/commands/run-report.js +115 -0
  37. package/dist/commands/run.js +115 -8
  38. package/dist/commands/self-test.js +1 -1
  39. package/dist/commands/shared.js +2 -1
  40. package/dist/commands/status-probe.js +9 -3
  41. package/dist/commands/status.js +45 -50
  42. package/dist/live-adapter-contract.js +5 -1
  43. package/dist/mm-harness-cli.js +68 -27
  44. package/dist/recipe-files.js +14 -0
  45. package/docs/CLI-SPEC.md +25 -0
  46. package/docs/architecture.md +2 -2
  47. package/docs/live-adapter-contract.md +1 -1
  48. package/docs/recipe-libraries.md +21 -18
  49. package/library/actions/extension/platform/cdp.mjs +7 -4
  50. package/library/recipes/{app-lifecycle-android-smoke.mobile.recipe.json → app/lifecycle.android-smoke.mobile.recipe.json} +1 -1
  51. package/library/recipes/{perps-performance.mobile.recipe.json → perps/performance.mobile.recipe.json} +1 -1
  52. package/library/recipes/perps/smoke.core.recipe.json +39 -0
  53. package/library/recipes/perps/smoke.extension.recipe.json +51 -0
  54. package/library/recipes/perps/smoke.mobile.recipe.json +51 -0
  55. package/library/recipes/{action-validation.extension.recipe.json → runner/action-validation.extension.recipe.json} +7 -7
  56. package/library/recipes/{action-validation.mobile.recipe.json → runner/action-validation.mobile.recipe.json} +7 -7
  57. package/package.json +1 -1
  58. /package/library/recipes/{perps-lifecycle.recipe.json → perps/lifecycle.recipe.json} +0 -0
  59. /package/library/recipes/{order-lifecycle.core.recipe.json → perps/order-lifecycle.core.recipe.json} +0 -0
  60. /package/library/recipes/{perps-performance-background-resume.mobile.recipe.json → perps/performance.background-resume.mobile.recipe.json} +0 -0
  61. /package/library/recipes/{perps-performance-cold-start.mobile.recipe.json → perps/performance.cold-start.mobile.recipe.json} +0 -0
  62. /package/library/recipes/{perps-performance-warm-start.mobile.recipe.json → perps/performance.warm-start.mobile.recipe.json} +0 -0
  63. /package/library/recipes/{read-markets.core.recipe.json → perps/read-markets.core.recipe.json} +0 -0
  64. /package/library/recipes/{trading-lifecycle.core.recipe.json → perps/trading-lifecycle.core.recipe.json} +0 -0
  65. /package/library/recipes/{smoke.extension.recipe.json → runner/smoke.extension.recipe.json} +0 -0
  66. /package/library/recipes/{smoke.mobile.recipe.json → runner/smoke.mobile.recipe.json} +0 -0
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { walletFixturePath } from "../paths.js";
4
+ import { color } from "../cli-color.js";
5
+ import { recipeRunning } from "../heal-bounds.js";
4
6
  import { EXIT } from "./shared.js";
5
7
  import {
6
8
  optionFlag,
@@ -8,6 +10,7 @@ import {
8
10
  requiredOption,
9
11
  resolveAdapter,
10
12
  runtimeOptionsFromCli,
13
+ isRecord,
11
14
  usageError
12
15
  } from "./parse-args.js";
13
16
  import {
@@ -20,6 +23,9 @@ import {
20
23
  } from "./run-engine.js";
21
24
  import { handleListExecutables } from "./list-executables.js";
22
25
  import { applyDeviceTargeting } from "./device-target.js";
26
+ import { getAdapterSurface } from "../adapters/surface.js";
27
+ import { coreDependencyBlock } from "./core-readiness.js";
28
+ import { writeRunReport } from "./run-report.js";
23
29
  async function handleRun({ positional, options }) {
24
30
  if (optionFlag(options, "list")) return handleListExecutables("run", options);
25
31
  const targetRecipe = positional[0];
@@ -27,14 +33,15 @@ async function handleRun({ positional, options }) {
27
33
  if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options);
28
34
  const { adapter, target } = resolveAdapter(options);
29
35
  const json = optionFlag(options, "json");
36
+ getAdapterSurface(adapter).resolveSlotPorts(target);
30
37
  const dtResult = applyDeviceTargeting("run", adapter, options, { gate: true, rerun: "" });
31
38
  if ("code" in dtResult) {
32
39
  return emitRunUsageError(json, adapter, targetRecipe, dtResult.code, dtResult.message);
33
40
  }
34
- const artifactsDir = requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
35
- const prepared = await prepareHeal(adapter, target, options, json);
36
- if (typeof prepared === "number") return prepared;
37
- const { state, heal } = prepared;
41
+ if (recipeRunning(target)) return emitRunRecipeRunning(json);
42
+ if (optionString(options, "artifactsDir") === void 0 && runArgLooksLikeRecipeFile(targetRecipe)) {
43
+ requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
44
+ }
38
45
  const validated = await validateRunRecipeStatic(targetRecipe, adapter, options);
39
46
  if (validated.usageError) {
40
47
  return emitRunUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
@@ -42,6 +49,14 @@ async function handleRun({ positional, options }) {
42
49
  if (validated.errorCount > 0) {
43
50
  return emitRunValidationError(json, adapter, validated.recipeFile, validated.findings, validated.errorCount);
44
51
  }
52
+ const depsBlock = adapter === "core" && recipeUsesCoreController(validated.recipe, validated.librarySources) ? coreDependencyBlock(target) : null;
53
+ if (depsBlock) {
54
+ return emitRunUsageError(json, adapter, validated.recipeFile, depsBlock.code, depsBlock.message, depsBlock.userAction);
55
+ }
56
+ const artifactsDir = requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
57
+ const prepared = await prepareHeal(adapter, target, options, json);
58
+ if (typeof prepared === "number") return prepared;
59
+ const { state, heal } = prepared;
45
60
  const librarySources = validated.librarySources;
46
61
  const runtimeOptions = {
47
62
  ...runtimeOptionsFromCli(options),
@@ -58,6 +73,7 @@ async function handleRun({ positional, options }) {
58
73
  state
59
74
  );
60
75
  if (violation !== null) return emitHealViolation(json, "run", result, violation, state, adapter);
76
+ const report = writeRunReport(result);
61
77
  const exitCode = result.status === "pass" ? EXIT.ok : EXIT.runtime;
62
78
  if (json) {
63
79
  console.log(
@@ -70,6 +86,7 @@ async function handleRun({ positional, options }) {
70
86
  exitCode,
71
87
  recovered: state.recovered,
72
88
  mutations: state.mutations,
89
+ reportPath: report.path,
73
90
  result
74
91
  },
75
92
  null,
@@ -77,11 +94,88 @@ async function handleRun({ positional, options }) {
77
94
  )
78
95
  );
79
96
  } else {
80
- console.log(`MetaMask recipe run: ${result.status}
81
- Artifacts: ${result.artifactManifestPath}`);
97
+ const out = (style, text) => color(style, text, { stream: process.stdout });
98
+ console.log(`${out(result.status === "pass" ? "ok" : "err", result.status.toUpperCase())} ${out("bold", "recipe run")} ${out("dim", `[${adapter}]`)}`);
99
+ if (report.preview.length > 0) {
100
+ console.log(out("label", "summary:"));
101
+ for (const line of report.preview) console.log(` ${formatPreviewLine(line, out)}`);
102
+ }
103
+ console.log(`${out("label", "report:")} ${out("path", report.path)}`);
104
+ console.log(`${out("label", "artifacts:")} ${out("path", result.artifactManifestPath)}`);
82
105
  }
83
106
  return exitCode;
84
107
  }
108
+ function formatPreviewLine(line, out) {
109
+ const match = /^(PASS|FAIL)\s+(.+)$/u.exec(line);
110
+ if (!match) return line;
111
+ const status = match[1];
112
+ const rest = match[2];
113
+ return `${out(status === "PASS" ? "ok" : "err", status)} ${rest}`;
114
+ }
115
+ function recipeUsesCoreController(recipe, librarySources) {
116
+ if (objectUsesCoreController(recipe)) return true;
117
+ const refs = collectCallRefs(recipe);
118
+ if (refs.length === 0) return false;
119
+ const flows = loadFlowCatalogs(librarySources ?? []);
120
+ const visited = /* @__PURE__ */ new Set();
121
+ const visit = (ref) => {
122
+ if (visited.has(ref)) return false;
123
+ visited.add(ref);
124
+ const flow = flows.get(ref);
125
+ if (!flow) return false;
126
+ if (objectUsesCoreController(flow)) return true;
127
+ return collectCallRefs(flow).some(visit);
128
+ };
129
+ return refs.some(visit);
130
+ }
131
+ function objectUsesCoreController(value) {
132
+ if (Array.isArray(value)) return value.some(objectUsesCoreController);
133
+ if (!isRecord(value)) return false;
134
+ if (actionUsesCoreController(value.action)) return true;
135
+ return Object.values(value).some(objectUsesCoreController);
136
+ }
137
+ function collectCallRefs(value) {
138
+ const refs = [];
139
+ const visit = (node) => {
140
+ if (Array.isArray(node)) {
141
+ for (const item of node) visit(item);
142
+ return;
143
+ }
144
+ if (!isRecord(node)) return;
145
+ if (node.action === "call" && typeof node.ref === "string" && node.ref) refs.push(node.ref);
146
+ for (const child of Object.values(node)) visit(child);
147
+ };
148
+ visit(value);
149
+ return [...new Set(refs)];
150
+ }
151
+ function loadFlowCatalogs(librarySources) {
152
+ const flows = /* @__PURE__ */ new Map();
153
+ for (const source of librarySources) {
154
+ const flowsDir = path.join(source.root, "flows");
155
+ let entries;
156
+ try {
157
+ entries = fs.readdirSync(flowsDir);
158
+ } catch {
159
+ continue;
160
+ }
161
+ for (const entry of entries) {
162
+ if (!entry.endsWith(".json")) continue;
163
+ try {
164
+ const parsed = JSON.parse(fs.readFileSync(path.join(flowsDir, entry), "utf8"));
165
+ if (!isRecord(parsed) || !isRecord(parsed.flows)) continue;
166
+ for (const [id, flow] of Object.entries(parsed.flows)) {
167
+ if (!flows.has(id)) flows.set(id, flow);
168
+ }
169
+ } catch {
170
+ continue;
171
+ }
172
+ }
173
+ }
174
+ return flows;
175
+ }
176
+ function actionUsesCoreController(action) {
177
+ return typeof action === "string" && action.startsWith("metamask.perps.");
178
+ }
85
179
  async function handleRunPlan(recipeArg, options) {
86
180
  const json = optionFlag(options, "json");
87
181
  const { adapter, target } = resolveAdapter(options);
@@ -174,17 +268,30 @@ function emitPlanUsageError(json, adapter, recipeFile, code, message) {
174
268
  }
175
269
  return EXIT.usage;
176
270
  }
177
- function emitRunUsageError(json, adapter, recipeFile, code, message) {
271
+ function runArgLooksLikeRecipeFile(value) {
272
+ return path.isAbsolute(value) || value.includes("/") || value.includes(path.sep) || value.endsWith(".json") || fs.existsSync(path.resolve(value));
273
+ }
274
+ function emitRunRecipeRunning(json) {
275
+ const message = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
276
+ if (json) {
277
+ console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message } }, null, 2));
278
+ } else {
279
+ console.error(`\u2717 mm-harness: ${message}`);
280
+ }
281
+ return EXIT.bounded;
282
+ }
283
+ function emitRunUsageError(json, adapter, recipeFile, code, message, userAction) {
178
284
  if (json) {
179
285
  console.log(
180
286
  JSON.stringify(
181
- { schemaVersion: 1, command: "run", adapter, status: "fail", exitCode: EXIT.usage, recipe: recipeFile, error: { code, message } },
287
+ { schemaVersion: 1, command: "run", adapter, status: "fail", exitCode: EXIT.usage, recipe: recipeFile, error: { code, message, ...userAction ? { userAction } : {} } },
182
288
  null,
183
289
  2
184
290
  )
185
291
  );
186
292
  } else {
187
293
  console.error(`\u2717 run: ${message}`);
294
+ if (userAction) console.error(` Next: ${userAction}`);
188
295
  }
189
296
  return EXIT.usage;
190
297
  }
@@ -26,7 +26,7 @@ async function runSelfTest(options) {
26
26
  const manifest = loadActionManifest(adapter);
27
27
  const manifestValidation = await validateManifest(manifest);
28
28
  const smokeRecipe = recipePath(
29
- adapter === "mobile" ? "smoke.mobile.recipe.json" : "smoke.extension.recipe.json"
29
+ adapter === "mobile" ? "runner/smoke.mobile.recipe.json" : "runner/smoke.extension.recipe.json"
30
30
  );
31
31
  const artifactsDir = path.join(root, adapter);
32
32
  const result = await runRecipe(adapter, smokeRecipe, artifactsDir, runnerDir, void 0, {
@@ -47,7 +47,8 @@ function targetOf(options) {
47
47
  }
48
48
  const ADAPTER_TOKENS = ["mobile", "extension", "core"];
49
49
  function resolveAdapter(options, target, hint) {
50
- const explicit = str(options, "adapter") ?? str(options, "platform");
50
+ const platform = str(options, "platform");
51
+ const explicit = str(options, "adapter") ?? (platform === "ios" || platform === "android" ? "mobile" : platform);
51
52
  if (explicit && ADAPTER_TOKENS.includes(explicit)) return explicit;
52
53
  if (hint) return hint;
53
54
  return detectAdapter(target);
@@ -38,15 +38,21 @@ function parseBridgeEntries(raw) {
38
38
  ...typeof e.agenticPresent === "boolean" ? { agenticPresent: e.agenticPresent } : {}
39
39
  }));
40
40
  }
41
- function matchBridgeEntry(device, entries) {
41
+ function matchBridgeEntry(device, entries, devices) {
42
42
  const byId = entries.find((e) => e.deviceName === device.id);
43
43
  if (byId) return byId;
44
44
  if (device.name) {
45
45
  const byName = entries.find((e) => e.deviceName === device.name);
46
46
  if (byName) return byName;
47
+ if (device.platform === "android") {
48
+ const model = device.name.replace(/_/gu, " ");
49
+ const byModel = entries.find((e) => e.deviceName === model || e.deviceName.startsWith(`${model} -`));
50
+ if (byModel) return byModel;
51
+ }
47
52
  }
48
53
  const platformEntries = entries.filter((e) => e.platform === device.platform);
49
- if (platformEntries.length === 1) return platformEntries[0];
54
+ const platformDevices = devices.filter((d) => d.platform === device.platform);
55
+ if (!device.name && platformEntries.length === 1 && platformDevices.length === 1) return platformEntries[0];
50
56
  return null;
51
57
  }
52
58
  function probeMobileBridge(target) {
@@ -123,7 +129,7 @@ async function probeMobileLiveState(target, devices) {
123
129
  const matched = /* @__PURE__ */ new Map();
124
130
  const usedEntries = /* @__PURE__ */ new Set();
125
131
  for (const device of devices) {
126
- const entry = matchBridgeEntry(device, entries.filter((e) => !usedEntries.has(e)));
132
+ const entry = matchBridgeEntry(device, entries.filter((e) => !usedEntries.has(e)), devices);
127
133
  if (entry) {
128
134
  matched.set(device.id, entry);
129
135
  usedEntries.add(entry);
@@ -2,30 +2,57 @@ import { color } from "../cli-color.js";
2
2
  import { detectAdapter } from "../harness.js";
3
3
  import { assertAdapter } from "../paths.js";
4
4
  import { getAdapterSurface } from "../adapters/surface.js";
5
- import { listConnectedDevices } from "../devices.js";
6
- import { deviceSelected, renderDeviceList } from "./device-target.js";
7
- import { probeMobileLiveState } from "./status-probe.js";
5
+ import {
6
+ applyDeviceTargeting
7
+ } from "./device-target.js";
8
+ import {
9
+ mobileDeviceView,
10
+ mobileDeviceLiveView,
11
+ nextForLive,
12
+ renderAndroidPortHints,
13
+ renderMobileDeviceList,
14
+ renderAdditionalReachableDevices,
15
+ renderMobileLiveBlock
16
+ } from "./mobile-device-view.js";
8
17
  import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
9
18
  import { optionFlag, targetPath } from "./parse-args.js";
10
19
  async function handleStatus({ options }) {
11
20
  const target = targetPath(options);
12
21
  const json = optionFlag(options, "json");
13
22
  const fast = optionFlag(options, "fast");
23
+ const allDevices = optionFlag(options, "allDevices");
14
24
  const adapter = detectAdapter(target);
15
25
  if (!adapter) {
16
26
  return usageOut(json, "status", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
17
27
  }
18
28
  assertAdapter(adapter);
19
- const devices = adapter === "mobile" ? listConnectedDevices().map((device) => ({ ...device, selected: deviceSelected(device) })) : [];
20
- const next = getAdapterSurface(adapter).hints.relaunch;
29
+ const surface = getAdapterSurface(adapter);
30
+ surface.resolveSlotPorts(target);
31
+ if (adapter === "mobile") {
32
+ const dtResult = applyDeviceTargeting("status", adapter, options, { gate: false, rerun: "" });
33
+ if ("code" in dtResult) {
34
+ return usageOut(json, "status", dtResult.message, "mm-harness status --device <id> or --all-devices");
35
+ }
36
+ }
37
+ const next = surface.hints.relaunch;
38
+ const deviceView = adapter === "mobile" ? mobileDeviceView(allDevices) : null;
39
+ const devices = deviceView?.devices ?? [];
21
40
  if (json) {
22
- const doProbe = !fast && adapter === "mobile" && devices.length > 0;
41
+ const doProbe = !fast && adapter === "mobile" && deviceView !== null && deviceView.allConnectedDevices.length > 0;
23
42
  if (doProbe) {
24
- const liveMap = await probeMobileLiveState(target, devices);
25
- const devicesWithLive = devices.map((d) => mergeDeviceLive(d, liveMap.get(d.id)));
43
+ const liveView = await mobileDeviceLiveView(target, deviceView);
26
44
  console.log(
27
45
  JSON.stringify(
28
- { schemaVersion: 1, command: "status", adapter, target, devices: devicesWithLive, next },
46
+ {
47
+ schemaVersion: 1,
48
+ command: "status",
49
+ adapter,
50
+ target,
51
+ devices: liveView.devicesWithLive,
52
+ ...liveView.additionalReachableDevices.length > 0 ? { additionalReachableDevices: liveView.additionalReachableDevices } : {},
53
+ ...liveView.androidPortHints.length > 0 ? { androidPortHints: liveView.androidPortHints } : {},
54
+ next: nextForLive(next, liveView.liveMap)
55
+ },
29
56
  null,
30
57
  2
31
58
  )
@@ -39,50 +66,18 @@ async function handleStatus({ options }) {
39
66
  }
40
67
  const out = (style, text) => color(style, text, { stream: process.stdout });
41
68
  console.log(`${out("label", "status")} ${out("bold", adapter)} ${out("dim", target)}`);
42
- if (adapter === "mobile") renderDeviceList(devices, out);
43
- console.log(`${out("label", "Next:")} ${out("cmd", next)}`);
44
- if (!fast && adapter === "mobile" && devices.length > 0) {
45
- const liveMap = await probeMobileLiveState(target, devices);
46
- renderLiveBlock(devices, liveMap, out);
69
+ if (deviceView) renderMobileDeviceList(deviceView, out);
70
+ if (!fast && adapter === "mobile" && deviceView !== null && deviceView.allConnectedDevices.length > 0) {
71
+ const liveView = await mobileDeviceLiveView(target, deviceView);
72
+ if (liveView.additionalReachableDevices.length > 0) renderAdditionalReachableDevices(liveView.additionalReachableDevices, out);
73
+ renderMobileLiveBlock(devices, liveView.liveMap, out);
74
+ renderAndroidPortHints(liveView.androidPortHints, out);
75
+ console.log(`${out("label", "Next:")} ${out("cmd", nextForLive(next, liveView.liveMap))}`);
76
+ } else {
77
+ console.log(`${out("label", "Next:")} ${out("cmd", next)}`);
47
78
  }
48
79
  return EXIT.ok;
49
80
  }
50
- function mergeDeviceLive(device, live) {
51
- if (!live) return device;
52
- return {
53
- ...device,
54
- fixtureStatus: live.fixtureStatus,
55
- ...live.liveState !== void 0 ? { liveState: live.liveState } : {},
56
- ...live.currentScreen !== void 0 ? { currentScreen: live.currentScreen } : {},
57
- ...live.walletState !== void 0 ? { walletState: live.walletState } : {},
58
- ...live.selectedAccount !== void 0 ? { selectedAccount: live.selectedAccount } : {}
59
- };
60
- }
61
- function renderLiveBlock(devices, liveMap, out) {
62
- console.log(`${out("label", "live:")}`);
63
- for (const device of devices) {
64
- const live = liveMap.get(device.id);
65
- const prefix = ` ${device.platform} ${out("dim", device.id)}${device.name ? ` (${device.name})` : ""}`;
66
- if (!live || live.liveState === "no-bridge") {
67
- console.log(`${prefix}: ${out("dim", "(no-bridge)")}`);
68
- continue;
69
- }
70
- if (live.liveState === "bridge-absent") {
71
- console.log(`${prefix}: ${out("warn", "(bridge-absent \u2014 app attached, build lacks __AGENTIC__; rebuild/reinstall a dev build)")}`);
72
- continue;
73
- }
74
- const parts = [];
75
- if (live.currentScreen !== void 0) parts.push(`screen=${out("cmd", live.currentScreen)}`);
76
- if (live.walletState !== void 0) parts.push(`wallet=${out(live.walletState === "unlocked" ? "ok" : "warn", live.walletState)}`);
77
- if (live.selectedAccount !== void 0) {
78
- parts.push(`account=${live.selectedAccount.label} ${out("dim", `(${live.selectedAccount.address})`)}`);
79
- }
80
- if (live.fixtureStatus !== void 0) {
81
- parts.push(`fixture=${out(live.fixtureStatus === "READY" ? "ok" : "warn", live.fixtureStatus)}`);
82
- }
83
- console.log(`${prefix}: ${parts.join(" ")}`);
84
- }
85
- }
86
81
  export {
87
82
  handleStatus
88
83
  };
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
  import { resolveRequiredLocalProtocolRoot, runnerDir } from "./paths.js";
7
+ import { corePnpNodeOptions } from "./commands/core-readiness.js";
7
8
  function actionFileStem(action) {
8
9
  return String(action).replace(/[^a-zA-Z0-9._-]/g, "_");
9
10
  }
@@ -199,7 +200,10 @@ async function platformAdapterEnv(platform, projectRoot, tempDir) {
199
200
  `${JSON.stringify({ compilerOptions: { baseUrl: projectRoot, paths } }, null, 2)}
200
201
  `
201
202
  );
202
- return { TSX_TSCONFIG_PATH: tsconfigPath };
203
+ return {
204
+ TSX_TSCONFIG_PATH: tsconfigPath,
205
+ NODE_OPTIONS: corePnpNodeOptions(projectRoot, process.env.NODE_OPTIONS)
206
+ };
203
207
  }
204
208
  async function runLiveAdapterScript({ platform, action, node, context }) {
205
209
  const script = await resolveLiveAdapter(platform, action);
@@ -6,6 +6,8 @@ import { Command } from "commander";
6
6
  import { color } from "./cli-color.js";
7
7
  import { handleUpdate, maybeNudge } from "./commands/update.js";
8
8
  import { handleCallHelp } from "./commands/call.js";
9
+ import { getAdapterSurface } from "./adapters/surface.js";
10
+ import { detectAdapter } from "./harness.js";
9
11
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
12
  globalThis.__MM_HARNESS_WRAPPER__ = true;
11
13
  const { main: recipeMain } = await import("./cli.js");
@@ -24,9 +26,13 @@ const REAL = [
24
26
  --target <path> Checkout path (default: cwd)
25
27
  --json Machine-readable envelope { adapter, target, devices[], next }
26
28
  --fast Skip all live-state probes (instant output, safe for scripts)
29
+ --all-devices Mobile only: show every connected device instead of the slot-scoped target
27
30
 
28
31
  Human output: static device list prints immediately; live-state lines append
29
32
  after the ~2s probe window (screen, wallet, account, fixture per device).
33
+ By default mobile status shows the configured target only; --device <id>
34
+ switches the active target for that command. Other devices that answer on the
35
+ same Metro are reported as additional reachable targets.
30
36
 
31
37
  The devices[] section carries per device:
32
38
  { platform, id, name, state, selected } always present
@@ -90,15 +96,16 @@ Example:
90
96
  name: "call",
91
97
  summary: "Run one action in isolation as a one-node recipe through the real engine path (fuzzy short names; --arg k=v; same trace/evidence as run).",
92
98
  example: "mm-harness call ensure_unlocked",
93
- helpText: `mm-harness call <action> [--arg k=v ...] [flags]
99
+ helpText: `mm-harness call <action> [key=value ...] [--arg k=v ...] [flags]
94
100
 
95
101
  Run one action in isolation as a one-node recipe through the real engine path.
96
102
  Fuzzy short-name: 'ensure_unlocked' resolves to 'metamask.wallet.ensure_unlocked'
97
- if unique; ambiguous = exit 2. Actions differ per adapter \u2014 list this checkout's
98
- with: mm-harness actions.
103
+ if unique; ambiguous = exit 2. Pass action fields as key=value shorthand or
104
+ with --arg k=v. Actions differ per adapter \u2014 list this checkout's with:
105
+ mm-harness actions.
99
106
 
100
107
  --list List everything invocable for the adapter (actions + flows); no <action> needed
101
- --arg k=v Action field value (repeatable)
108
+ --arg k=v Action field value (repeatable; equivalent to key=value shorthand)
102
109
  --device <udid|serial|name> Mobile only: target this device (env: IOS_SIMULATOR / ADB_SERIAL). Without it, >1 connected mobile device fails fast and lists them.
103
110
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
104
111
  --target <path> Checkout path (default: cwd)
@@ -110,7 +117,8 @@ Example:
110
117
 
111
118
  Example (real actions; run mm-harness actions for this checkout's full set):
112
119
  mm-harness call ensure_unlocked --adapter extension # a wallet action (extension/mobile)
113
- mm-harness call command --arg cmd="echo hi" --adapter core # the universal action (all adapters)`
120
+ mm-harness call navigate page=perps --adapter mobile
121
+ mm-harness call command cmd="echo hi" --adapter core # the universal action (all adapters)`
114
122
  },
115
123
  {
116
124
  name: "flows",
@@ -166,6 +174,7 @@ Example:
166
174
  --expect-live Exit 0 iff the runtime is live (extension: watcher+CDP; mobile: Metro+bridge; core: deps), non-zero + teaching escape otherwise
167
175
  --cdp-port <port> Extension CDP port for the liveness probe (env: CDP_PORT / RECIPE_CDP_PORT)
168
176
  --device <udid|serial|name> Mobile only: target this device (env: IOS_SIMULATOR / ADB_SERIAL). doctor reports the connected devices; it never gates on ambiguity.
177
+ --all-devices Mobile only: show every connected device instead of the slot-scoped target
169
178
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
170
179
  --target <path> Checkout path (default: cwd)
171
180
  --runtime-dir <dir> Runtime dir containing agentic-runtime.json (relative to target)
@@ -177,6 +186,27 @@ Example:
177
186
  mm-harness doctor --adapter extension --target /path/to/checkout --cdp-port 6662 --expect-live
178
187
  mm-harness doctor --adapter mobile --target /path/to/checkout`
179
188
  },
189
+ {
190
+ name: "check",
191
+ summary: "Run bounded repo-local checks for an active git diff and write validation artifacts.",
192
+ example: "mm-harness check diff --profile fast",
193
+ helpText: `mm-harness check diff [flags]
194
+
195
+ Run bounded repo-local checks for the active git diff. Fast profile runs changed-file
196
+ ESLint, Prettier, and changed test files. Full profile also runs repo typecheck
197
+ when available. Does not launch an app or run a recipe.
198
+
199
+ --target <path> Checkout path (default: cwd)
200
+ --adapter <mobile|extension|core> Adapter label (auto-detected when possible)
201
+ --base <ref> Diff base (default: PR base, then remote HEAD, then repo fallback)
202
+ --profile <fast|full> Validation depth (default: fast)
203
+ --artifacts-dir <dir> Write validation-summary.json/.md and per-check logs
204
+ --json Machine-readable envelope
205
+
206
+ Example:
207
+ mm-harness check diff --profile fast --artifacts-dir artifacts/validation
208
+ mm-harness check diff --profile full --json`
209
+ },
180
210
  {
181
211
  name: "recipe-quality",
182
212
  summary: "Build the recipe-quality artifact from a compact verdict JSON (validates before writing).",
@@ -186,7 +216,12 @@ Example:
186
216
  Build artifacts/recipe-quality.json from the compact fields a recipe-quality pass
187
217
  produces (verdict + reasons + optional guidance/dimensions/findings/delta/training).
188
218
  The artifact is validated against the RecipeQualityArtifact schema before it is
189
- written \u2014 an invalid input never reaches disk.
219
+ written \u2014 an invalid input never reaches disk. Shorthand without "build" is
220
+ also accepted when --input/--output are present.
221
+
222
+ This CLI does not run the recipe critique itself; use the fs-recipe-quality
223
+ skill to judge recipe/evidence quality, then use this command to build the
224
+ canonical artifact from that compact verdict.
190
225
 
191
226
  --input <compact.json> Compact verdict JSON: { "verdict": "pass|warn|fail", "reasons": [..],
192
227
  "betterVersionGuidance"?: [..], "dimensions"?: {..}, "trainingFields"?: {..}, \u2026 }
@@ -197,6 +232,7 @@ Example:
197
232
 
198
233
  Example:
199
234
  mm-harness recipe-quality build --input compact.json --output artifacts/recipe-quality.json
235
+ mm-harness recipe-quality --input compact.json --output artifacts/recipe-quality.json
200
236
  mm-harness recipe-quality build --input compact.json --output artifacts/recipe-quality.json --json`
201
237
  },
202
238
  {
@@ -295,7 +331,7 @@ Example:
295
331
  },
296
332
  {
297
333
  name: "launch",
298
- summary: "Launch the app (Metro/build + boot), auto-ensuring the runtime overlay first. Mobile: ios|android required.",
334
+ summary: "Launch the app and adapter dev server, auto-ensuring the runtime overlay first. Mobile: ios|android required.",
299
335
  example: "mm-harness launch ios",
300
336
  helpText: `mm-harness launch [ios|android] [flags]
301
337
 
@@ -310,7 +346,7 @@ Example:
310
346
  --runway Post-launch runway check (mobile only; teaching error elsewhere)
311
347
  --watch Persistent webpack watcher then relaunch (extension only)
312
348
  --sidepanel | --fullscreen Extension display mode (default --fullscreen)
313
- --url <dapp-url> Open a dapp in the main tab beside the sidepanel (extension only)
349
+ --url <dapp-url> Open a dapp beside the sidepanel (default: MetaMask test dapp)
314
350
  --heal <off|infra-only|auto> Healing policy (default auto); bounds always enforced
315
351
  --device <udid|name> Target simulator/device (env: IOS_SIMULATOR / ADB_SERIAL)
316
352
  --cdp-port <port> Extension CDP port (env: CDP_PORT / RECIPE_CDP_PORT)
@@ -422,14 +458,6 @@ Example:
422
458
  mm-harness fixtures finalize --fixture wallet-fixture.json --state fixture-state.json --cdp-port 6661 --extension-dir dist/chrome`
423
459
  }
424
460
  ];
425
- const RETIRED_INTERNAL = [
426
- "runtime-health",
427
- "runtime-decision",
428
- "runtime-launch",
429
- "resolve-extension",
430
- "ensure-ready",
431
- "self-test"
432
- ];
433
461
  const RETIRED = [
434
462
  {
435
463
  name: "live",
@@ -443,14 +471,7 @@ Replacement: mm-harness launch --verify (install overlay \u2192 launch \u2192 CD
443
471
 
444
472
  Replacement: mm-harness actions --raw (works now \u2014 dumps the raw action registry JSON,
445
473
  identical to the old \`manifest --json\`). Manifest validation moved into doctor / run --plan.`
446
- },
447
- ...RETIRED_INTERNAL.map((name) => ({
448
- name,
449
- message: `mm-harness ${name} is retired (exit 2).
450
-
451
- It is internal now \u2014 its logic lives inside doctor / launch / verify self-healing.
452
- Use: mm-harness doctor`
453
- }))
474
+ }
454
475
  ];
455
476
  const HELP_GROUPS = [
456
477
  {
@@ -466,7 +487,7 @@ const HELP_GROUPS = [
466
487
  {
467
488
  title: "PROVE",
468
489
  blurb: "run recipes and inspect readiness",
469
- commands: ["run", "doctor", "recipe-quality"]
490
+ commands: ["run", "doctor", "check", "recipe-quality"]
470
491
  },
471
492
  {
472
493
  title: "RUNTIME OVERLAY",
@@ -484,6 +505,14 @@ function commandMeta(name) {
484
505
  if (real) return { summary: real.summary, example: real.example, planned: false };
485
506
  return { summary: "", example: "", planned: false };
486
507
  }
508
+ function adapterFromRuntimeContext(ctx) {
509
+ const raw = typeof ctx.platform === "string" ? ctx.platform : void 0;
510
+ if (!raw) return void 0;
511
+ if (raw === "mobile" || raw === "ios" || raw === "android") return "mobile";
512
+ if (raw === "extension" || raw === "chrome-extension") return "extension";
513
+ if (raw === "core") return "core";
514
+ return void 0;
515
+ }
487
516
  function detectedSlotLine(out) {
488
517
  const ctxPath = path.join(
489
518
  process.cwd(),
@@ -492,10 +521,14 @@ function detectedSlotLine(out) {
492
521
  );
493
522
  try {
494
523
  const ctx = JSON.parse(fs.readFileSync(ctxPath, "utf8"));
524
+ const adapter = adapterFromRuntimeContext(ctx) ?? detectAdapter(process.cwd());
495
525
  const parts = [];
496
526
  if (ctx.slotId) parts.push(`slot ${out("ok", String(ctx.slotId))}`);
497
527
  if (ctx.simulator) parts.push(`device ${out("ok", String(ctx.simulator))}`);
498
- if (ctx.metroPort) parts.push(`metro :${out("ok", String(ctx.metroPort))}`);
528
+ const devServerPort = ctx.watcherPort ?? ctx.devServerPort ?? ctx.metroPort;
529
+ if (adapter && adapter !== "core" && devServerPort) {
530
+ parts.push(`${getAdapterSurface(adapter).devServer.label} :${out("ok", String(devServerPort))}`);
531
+ }
499
532
  if (ctx.gitBranch) parts.push(`branch ${out("info", String(ctx.gitBranch))}`);
500
533
  if (parts.length === 0) return null;
501
534
  return `${out("label", "SLOT")} \u2014 this checkout is a prepared slot: ${parts.join(" \xB7 ")}`;
@@ -580,7 +613,15 @@ for (const command of REAL) {
580
613
  process.exit(await delegate(argv));
581
614
  });
582
615
  }
583
- const HIDDEN = ["completion-candidates"];
616
+ const HIDDEN = [
617
+ "completion-candidates",
618
+ "runtime-health",
619
+ "runtime-decision",
620
+ "runtime-launch",
621
+ "resolve-extension",
622
+ "ensure-ready",
623
+ "self-test"
624
+ ];
584
625
  for (const name of HIDDEN) {
585
626
  program.command(name, { hidden: true }).allowUnknownOption().helpOption(false).argument("[args...]").action(async () => {
586
627
  process.exit(await delegate(rawArgv));
@@ -0,0 +1,14 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ function listRecipeFiles(root, base = "") {
4
+ const out = [];
5
+ for (const entry of fs.readdirSync(path.join(root, base), { withFileTypes: true })) {
6
+ const rel = path.join(base, entry.name);
7
+ if (entry.isDirectory()) out.push(...listRecipeFiles(root, rel));
8
+ else if (entry.isFile() && entry.name.endsWith(".recipe.json")) out.push(rel);
9
+ }
10
+ return out;
11
+ }
12
+ export {
13
+ listRecipeFiles
14
+ };