@deeeed/metamask-harness 0.16.0 → 0.17.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 (119) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +90 -137
  3. package/adapters/core/cleanup.sh +0 -0
  4. package/adapters/core/inject.sh +0 -0
  5. package/adapters/extension/cleanup.mjs +0 -0
  6. package/adapters/extension/console-tail.mjs +51 -24
  7. package/adapters/extension/ensure-browser.sh +0 -0
  8. package/adapters/extension/inject.mjs +5 -9
  9. package/adapters/extension/launch-browser.cjs +0 -0
  10. package/adapters/extension/launch-webpack.cjs +83 -0
  11. package/adapters/extension/launch.sh +8 -11
  12. package/adapters/extension/live.sh +2 -0
  13. package/adapters/extension/readiness.mjs +0 -0
  14. package/adapters/extension/reattach.sh +1 -0
  15. package/adapters/extension/refresh-build.sh +0 -0
  16. package/adapters/extension/seed-fixture.sh +4 -12
  17. package/adapters/extension/sidepanel-toggle.sh +4 -1
  18. package/adapters/extension/snapshot-dist.sh +0 -0
  19. package/adapters/extension/stamp-runtime-title.cjs +49 -0
  20. package/adapters/extension/start-watch.sh +16 -4
  21. package/adapters/extension/stop-viewers.sh +1 -1
  22. package/adapters/extension/sync-webpack-dist.cjs +105 -0
  23. package/adapters/extension/verify.sh +1 -1
  24. package/adapters/extension/wallet-fixture-state.cjs +0 -0
  25. package/adapters/manifest.json +46 -38
  26. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +0 -0
  27. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +1 -1
  28. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +2 -3
  29. package/adapters/mobile/bridge-runtime/setup-wallet.sh +0 -0
  30. package/adapters/mobile/cleanup.sh +16 -176
  31. package/adapters/mobile/inject.sh +88 -671
  32. package/adapters/mobile/launch-console-forwarder.cjs +39 -0
  33. package/adapters/mobile/lib/metro-listener.sh +0 -0
  34. package/adapters/mobile/lib/tmux-viewer.sh +0 -0
  35. package/adapters/mobile/open-device.sh +0 -0
  36. package/adapters/mobile/prewarm-bundle.sh +0 -0
  37. package/adapters/mobile/start-console-forwarder.sh +70 -0
  38. package/adapters/mobile/start-metro.sh +0 -41
  39. package/adapters/mobile/stop-metro.sh +0 -0
  40. package/adapters/mobile/verify.sh +4 -61
  41. package/adapters/mobile/wait-for-bridge.sh +0 -0
  42. package/adapters/mobile/yarn-setup.sh +0 -0
  43. package/adapters/shared/activate-repo-node.sh +0 -0
  44. package/adapters/shared/activate-repo-ruby.sh +0 -0
  45. package/adapters/shared/cli-ux.sh +0 -0
  46. package/adapters/shared/ensure-runner-deps.sh +0 -0
  47. package/adapters/shared/harness-path.sh +0 -0
  48. package/adapters/shared/hash-helpers.sh +0 -0
  49. package/adapters/shared/install-repo-deps.sh +0 -0
  50. package/adapters/shared/json-field.sh +0 -0
  51. package/adapters/shared/open-debug.mjs +90 -36
  52. package/adapters/shared/open-log-window.sh +0 -0
  53. package/adapters/shared/reap-checkout-metros.sh +0 -0
  54. package/adapters/shared/resolve-slot-ports-core.mjs +0 -6
  55. package/adapters/shared/resolve-slot-ports.mjs +0 -0
  56. package/adapters/shared/resolve-slot-ports.sh +0 -5
  57. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  58. package/adapters/shared/tmux-session.sh +0 -0
  59. package/adapters/shared/tmux-viewer.sh +0 -0
  60. package/dist/adapters/extension/console-capture.js +84 -0
  61. package/dist/adapters/extension/runtime-decision.js +14 -2
  62. package/dist/adapters/extension/surface.js +3 -2
  63. package/dist/adapters/mobile/prepare.js +22 -7
  64. package/dist/adapters/mobile/surface.js +1 -1
  65. package/dist/adapters/resolve-slot-ports.js +0 -2
  66. package/dist/adapters/slot-ports.js +8 -0
  67. package/dist/cli.js +5 -11
  68. package/dist/command-contract.js +441 -0
  69. package/dist/command-journal.js +225 -0
  70. package/dist/commands/call.js +43 -20
  71. package/dist/commands/check.js +9 -3
  72. package/dist/commands/debug.js +38 -31
  73. package/dist/commands/device-target.js +27 -12
  74. package/dist/commands/doctor.js +19 -6
  75. package/dist/commands/fixtures.js +19 -11
  76. package/dist/commands/last.js +52 -0
  77. package/dist/commands/launch/index.js +166 -59
  78. package/dist/commands/logs.js +8 -6
  79. package/dist/commands/manifest.js +114 -29
  80. package/dist/commands/parse-args.js +1 -0
  81. package/dist/commands/provision.js +10 -3
  82. package/dist/commands/run-engine.js +27 -42
  83. package/dist/commands/run-report.js +1 -1
  84. package/dist/commands/run.js +243 -41
  85. package/dist/commands/shared.js +11 -1
  86. package/dist/commands/status-probe.js +1 -1
  87. package/dist/commands/status.js +1 -1
  88. package/dist/commands/stop.js +7 -2
  89. package/dist/doctor.js +1 -4
  90. package/dist/harness.js +19 -125
  91. package/dist/json-stream.js +57 -0
  92. package/dist/live-adapter-contract.js +6 -20
  93. package/dist/mm-harness-cli.js +111 -41
  94. package/dist/paths.js +2 -5
  95. package/dist/run-diagnostics.js +35 -40
  96. package/dist/runner.js +32 -1
  97. package/dist/runtime-context.js +26 -8
  98. package/docs/CHEATSHEET.md +39 -52
  99. package/docs/CLI-ERGONOMICS-AUDIT.md +32 -0
  100. package/docs/CLI-ERGONOMICS-HUMAN-QA.md +48 -0
  101. package/docs/CODE-MAP.md +1 -1
  102. package/docs/UX-PRINCIPLES.md +2 -0
  103. package/docs/VIDEO-DEMO-VALIDATION.md +62 -0
  104. package/docs/architecture.md +65 -375
  105. package/docs/live-adapter-contract.md +3 -1
  106. package/library/actions/core/perps/_controller.mjs +10 -55
  107. package/library/manifests/mobile.action-manifest.json +1 -1
  108. package/package.json +3 -2
  109. package/scripts/completions.sh +0 -0
  110. package/scripts/install-completions.sh +0 -0
  111. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -185
  112. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -1662
  113. package/adapters/mobile/overlay/compat/README.md +0 -28
  114. package/adapters/mobile/overlay/compat/rn81-message-event-source.patch +0 -42
  115. package/adapters/shared/resolve-farmslot-ports-core.mjs +0 -3
  116. package/adapters/shared/resolve-farmslot-ports.mjs +0 -5
  117. package/adapters/shared/resolve-farmslot-ports.sh +0 -7
  118. package/docs/CLI-SPEC.md +0 -1102
  119. package/docs/MENTAL-MODEL.md +0 -295
@@ -0,0 +1,225 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { recipeRuntimeDir } from "./paths.js";
5
+ const COMMAND_JOURNAL_FILE = "last-command.json";
6
+ const JOURNALED_COMMANDS = /* @__PURE__ */ new Set([
7
+ "call",
8
+ "check",
9
+ "checklist",
10
+ "cleanup",
11
+ "doctor",
12
+ "fixtures",
13
+ "install",
14
+ "launch",
15
+ "provision",
16
+ "recipe-quality",
17
+ "run",
18
+ "stop",
19
+ "verify"
20
+ ]);
21
+ const SENSITIVE_KEY = /(?:auth|credential|mnemonic|pass(?:word)?|private[-_]?key|secret|seed|srp|token)/iu;
22
+ const EVIDENCE_OPTIONS = /* @__PURE__ */ new Set(["--artifacts-dir", "--out", "--output"]);
23
+ async function withCommandJournal(command, argv, execute) {
24
+ if (!JOURNALED_COMMANDS.has(command)) return execute();
25
+ const handle = tryBeginCommandJournal(command, argv);
26
+ try {
27
+ const exitCode = await execute();
28
+ tryFinishCommandJournal(handle, exitCode);
29
+ return exitCode;
30
+ } catch (error) {
31
+ tryFinishCommandJournal(handle, 1);
32
+ throw error;
33
+ }
34
+ }
35
+ function commandJournalPath(target, runtimeDir) {
36
+ const relative = runtimeDir ?? recipeRuntimeDir();
37
+ assertRelativeRuntimeDir(relative);
38
+ return path.join(path.resolve(target), relative, COMMAND_JOURNAL_FILE);
39
+ }
40
+ function readCommandJournal(target, runtimeDir) {
41
+ const file = commandJournalPath(target, runtimeDir);
42
+ let descriptor;
43
+ try {
44
+ const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
45
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
46
+ const descriptorStat = fs.fstatSync(descriptor, { bigint: true });
47
+ const pathStat = fs.lstatSync(file, { bigint: true });
48
+ if (!descriptorStat.isFile() || !pathStat.isFile() || pathStat.isSymbolicLink() || pathStat.dev === 0n && pathStat.ino === 0n || descriptorStat.dev !== pathStat.dev || descriptorStat.ino !== pathStat.ino) {
49
+ return { file, record: null };
50
+ }
51
+ const parsed = JSON.parse(fs.readFileSync(descriptor, "utf8"));
52
+ return isCommandJournalRecord(parsed) ? { file, record: parsed } : { file, record: null };
53
+ } catch {
54
+ return { file, record: null };
55
+ } finally {
56
+ if (descriptor !== void 0) fs.closeSync(descriptor);
57
+ }
58
+ }
59
+ function redactCommandArgs(argv) {
60
+ const redacted = [];
61
+ for (let index = 0; index < argv.length; index += 1) {
62
+ const token = argv[index] ?? "";
63
+ const equals = token.indexOf("=");
64
+ if (token.startsWith("--") && equals !== -1) {
65
+ const option = token.slice(0, equals);
66
+ const value = token.slice(equals + 1);
67
+ redacted.push(`${option}=${option === "--arg" ? redactAssignment(value) : SENSITIVE_KEY.test(option) ? "<redacted>" : redactUrl(value)}`);
68
+ continue;
69
+ }
70
+ if (token.startsWith("--") && SENSITIVE_KEY.test(token)) {
71
+ redacted.push(token);
72
+ const value = argv[index + 1];
73
+ if (value !== void 0 && !value.startsWith("--")) {
74
+ redacted.push("<redacted>");
75
+ index += 1;
76
+ }
77
+ continue;
78
+ }
79
+ if (token === "--arg") {
80
+ redacted.push(token);
81
+ const value = argv[index + 1];
82
+ if (value !== void 0) {
83
+ redacted.push(redactAssignment(value));
84
+ index += 1;
85
+ }
86
+ continue;
87
+ }
88
+ redacted.push(redactAssignment(redactUrl(token)));
89
+ }
90
+ return redacted;
91
+ }
92
+ function tryBeginCommandJournal(command, argv) {
93
+ try {
94
+ const target = invocationTarget(argv);
95
+ if (!fs.existsSync(target)) return void 0;
96
+ const runtimeDir = optionValue(argv, "--runtime-dir");
97
+ const file = commandJournalPath(target, runtimeDir);
98
+ const now = (/* @__PURE__ */ new Date()).toISOString();
99
+ const record = {
100
+ schemaVersion: 1,
101
+ command,
102
+ args: redactCommandArgs(argv.slice(1)),
103
+ target,
104
+ verdict: "running",
105
+ exitCode: null,
106
+ evidencePaths: evidencePaths(argv),
107
+ startedAt: now,
108
+ finishedAt: null
109
+ };
110
+ writeAtomic(file, record);
111
+ return { file, record };
112
+ } catch (error) {
113
+ process.stderr.write(`mm-harness: resumability journal unavailable: ${errorMessage(error)}
114
+ `);
115
+ return void 0;
116
+ }
117
+ }
118
+ function tryFinishCommandJournal(handle, exitCode) {
119
+ if (!handle) return;
120
+ try {
121
+ writeAtomic(handle.file, {
122
+ ...handle.record,
123
+ verdict: exitCode === 0 ? "pass" : "fail",
124
+ exitCode,
125
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
126
+ });
127
+ } catch (error) {
128
+ process.stderr.write(`mm-harness: resumability journal could not finalize: ${errorMessage(error)}
129
+ `);
130
+ }
131
+ }
132
+ function writeAtomic(file, record) {
133
+ fs.mkdirSync(path.dirname(file), { recursive: true });
134
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
135
+ let descriptor;
136
+ try {
137
+ descriptor = fs.openSync(temporary, "wx", 384);
138
+ fs.writeFileSync(descriptor, `${JSON.stringify(record, null, 2)}
139
+ `);
140
+ fs.fsyncSync(descriptor);
141
+ fs.closeSync(descriptor);
142
+ descriptor = void 0;
143
+ fs.renameSync(temporary, file);
144
+ } finally {
145
+ if (descriptor !== void 0) fs.closeSync(descriptor);
146
+ fs.rmSync(temporary, { force: true });
147
+ }
148
+ }
149
+ function invocationTarget(argv) {
150
+ return path.resolve(optionValue(argv, "--target") ?? optionValue(argv, "--project-root") ?? process.cwd());
151
+ }
152
+ function evidencePaths(argv) {
153
+ const values = /* @__PURE__ */ new Set();
154
+ for (const option of EVIDENCE_OPTIONS) {
155
+ const value = optionValue(argv, option);
156
+ if (value) values.add(path.resolve(value));
157
+ }
158
+ return [...values];
159
+ }
160
+ function optionValue(argv, option) {
161
+ const divider = argv.indexOf("--");
162
+ const end = divider === -1 ? argv.length : divider;
163
+ for (let index = 1; index < end; index += 1) {
164
+ const token = argv[index] ?? "";
165
+ if (token === option) {
166
+ const value = argv[index + 1];
167
+ return value && !value.startsWith("--") ? value : void 0;
168
+ }
169
+ if (token.startsWith(`${option}=`)) return token.slice(option.length + 1);
170
+ }
171
+ return void 0;
172
+ }
173
+ function redactAssignment(token) {
174
+ const equals = token.indexOf("=");
175
+ if (equals <= 0) return token;
176
+ const key = token.slice(0, equals);
177
+ const value = token.slice(equals + 1);
178
+ return `${key}=${SENSITIVE_KEY.test(key) ? "<redacted>" : redactValue(value)}`;
179
+ }
180
+ function redactUrl(token) {
181
+ return token.replace(/(\w+:\/\/)[^/@\s:]+:[^/@\s]+@/gu, "$1<redacted>@");
182
+ }
183
+ function redactValue(value) {
184
+ try {
185
+ return JSON.stringify(redactStructuredValue(JSON.parse(value)));
186
+ } catch {
187
+ return value.replace(
188
+ /((?:auth|credential|mnemonic|pass(?:word)?|private[-_]?key|secret|seed|srp|token)=)[^\s]+/giu,
189
+ "$1<redacted>"
190
+ );
191
+ }
192
+ }
193
+ function redactStructuredValue(value) {
194
+ if (Array.isArray(value)) return value.map(redactStructuredValue);
195
+ if (!value || typeof value !== "object") return value;
196
+ return Object.fromEntries(
197
+ Object.entries(value).map(([key, entry]) => [
198
+ key,
199
+ SENSITIVE_KEY.test(key) ? "<redacted>" : redactStructuredValue(entry)
200
+ ])
201
+ );
202
+ }
203
+ function assertRelativeRuntimeDir(value) {
204
+ if (!value || path.isAbsolute(value) || !/^[A-Za-z0-9._/-]+$/u.test(value)) {
205
+ throw new Error(`--runtime-dir must be a safe relative path: ${value}`);
206
+ }
207
+ if (value.split("/").some((part) => !part || part === "." || part === "..")) {
208
+ throw new Error(`--runtime-dir contains an unsafe path component: ${value}`);
209
+ }
210
+ }
211
+ function isCommandJournalRecord(value) {
212
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
213
+ const record = value;
214
+ return record.schemaVersion === 1 && typeof record.command === "string" && Array.isArray(record.args) && record.args.every((entry) => typeof entry === "string") && typeof record.target === "string" && (record.verdict === "running" || record.verdict === "pass" || record.verdict === "fail") && (record.exitCode === null || typeof record.exitCode === "number") && Array.isArray(record.evidencePaths) && record.evidencePaths.every((entry) => typeof entry === "string") && typeof record.startedAt === "string" && (record.finishedAt === null || typeof record.finishedAt === "string");
215
+ }
216
+ function errorMessage(error) {
217
+ return error instanceof Error ? error.message : String(error);
218
+ }
219
+ export {
220
+ COMMAND_JOURNAL_FILE,
221
+ commandJournalPath,
222
+ readCommandJournal,
223
+ redactCommandArgs,
224
+ withCommandJournal
225
+ };
@@ -15,6 +15,7 @@ import {
15
15
  optionString,
16
16
  resolveAdapter,
17
17
  runtimeOptionsFromCli,
18
+ shellQuote,
18
19
  usageError
19
20
  } from "./parse-args.js";
20
21
  import { getAdapterSurface } from "../adapters/surface.js";
@@ -32,6 +33,7 @@ import {
32
33
  validateRecipeAdapterAware
33
34
  } from "./run-engine.js";
34
35
  import { acquireCheckoutLock } from "../checkout-lock.js";
36
+ import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument } from "../run-diagnostics.js";
35
37
  async function handleCall(argv) {
36
38
  if (argv.includes("--list")) {
37
39
  const { options: options2 } = parseArgs(argv, "call");
@@ -59,7 +61,13 @@ async function handleCall(argv) {
59
61
  }
60
62
  const message = `call requires <action>. Example: ${example}`;
61
63
  const userAction = `${example} # see the vocabulary: ${discovery}`;
62
- if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", error: { code: "USAGE", message, userAction } }, null, 2));
64
+ if (json) console.log(JSON.stringify({
65
+ schemaVersion: 1,
66
+ command: "call",
67
+ status: "fail",
68
+ error: { code: "CLI_MISSING_POSITIONAL", message, userAction },
69
+ exitCode: EXIT.usage
70
+ }, null, 2));
63
71
  else console.error(`${message}
64
72
  See the vocabulary: ${discovery}`);
65
73
  return EXIT.usage;
@@ -76,16 +84,19 @@ async function handleCall(argv) {
76
84
  getAdapterSurface(adapter).resolveSlotPorts(target);
77
85
  const dtResult = applyDeviceTargeting("call", adapter, options, { gate: true, rerun: "" });
78
86
  if ("code" in dtResult) {
79
- if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
80
- else console.error(`\u2717 call: ${dtResult.message}`);
87
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, error: { code: dtResult.code, message: dtResult.message, userAction: dtResult.userAction } }, null, 2));
88
+ else console.error(`\u2717 call: ${dtResult.message}
89
+ Next: ${dtResult.userAction}`);
81
90
  return EXIT.usage;
82
91
  }
83
92
  if (recipeRunning(target)) {
84
93
  const msg = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
94
+ const userAction = `inspect the checkout state with: mm-harness status --target ${shellQuote(target)} --json; retry after the active recipe finishes`;
85
95
  if (json) {
86
- console.log(JSON.stringify({ schemaVersion: 1, command: "call", status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg } }, null, 2));
96
+ console.log(JSON.stringify({ schemaVersion: 1, command: "call", status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg, userAction } }, null, 2));
87
97
  } else {
88
- console.error(`\u2717 mm-harness call: ${msg}`);
98
+ console.error(`\u2717 mm-harness call: ${msg}
99
+ Next: ${userAction}`);
89
100
  }
90
101
  return EXIT.bounded;
91
102
  }
@@ -95,16 +106,19 @@ async function handleCall(argv) {
95
106
  const names = getRecipeActionManifestActionNames(manifest);
96
107
  const resolution = resolveActionName(shortName, names);
97
108
  if (resolution.status === "unknown") {
98
- const message = `\u2717 call: unknown action "${shortName}" for the ${adapter} adapter.
99
- See the vocabulary: mm-harness actions --adapter ${adapter} --json`;
100
- if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_UNKNOWN", message } }, null, 2));
101
- else console.error(message);
109
+ const message = `unknown action "${shortName}" for the ${adapter} adapter.`;
110
+ const userAction = `mm-harness actions --adapter ${adapter} --json`;
111
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
112
+ else console.error(`\u2717 call: ${message}
113
+ Next: ${userAction}`);
102
114
  return EXIT.usage;
103
115
  }
104
116
  if (resolution.status === "ambiguous") {
105
- const message = `\u2717 call: "${shortName}" is ambiguous: ${resolution.candidates.join(", ")} \u2014 use the full name.`;
106
- if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_AMBIGUOUS", message, candidates: resolution.candidates } }, null, 2));
107
- else console.error(message);
117
+ const message = `"${shortName}" is ambiguous: ${resolution.candidates.join(", ")} \u2014 use the full name.`;
118
+ const userAction = `mm-harness actions --action ${resolution.candidates[0]} --adapter ${adapter} --json`;
119
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_AMBIGUOUS", message, candidates: resolution.candidates, userAction } }, null, 2));
120
+ else console.error(`\u2717 call: ${message}
121
+ Next: ${userAction}`);
108
122
  return EXIT.usage;
109
123
  }
110
124
  const resolvedAction = resolution.resolved;
@@ -121,11 +135,13 @@ async function handleCall(argv) {
121
135
  const recipe = synthesizeOneNodeRecipe(resolvedAction, args);
122
136
  const validation = await validateRecipeAdapterAware(recipe, manifest);
123
137
  if (validation.status === "invalid") {
124
- const message = `\u2717 call ${resolvedAction}: recipe validation failed`;
125
- if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, resolvedAction, args, findings: validation.findings, error: { code: "RECIPE_VALIDATION_FAILED", message } }, null, 2));
138
+ const message = `call ${resolvedAction}: recipe validation failed`;
139
+ const userAction = `mm-harness actions --action ${resolvedAction} --adapter ${adapter} --json`;
140
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, resolvedAction, args, findings: validation.findings, error: { code: "RECIPE_VALIDATION_FAILED", message, userAction } }, null, 2));
126
141
  else {
127
- console.error(message);
142
+ console.error(`\u2717 ${message}`);
128
143
  for (const finding of validation.findings) console.error(` ${finding.code} ${finding.path} \u2014 ${finding.message}`);
144
+ console.error(` Next: ${userAction}`);
129
145
  }
130
146
  return EXIT.validation;
131
147
  }
@@ -160,6 +176,7 @@ async function handleCall(argv) {
160
176
  );
161
177
  if (violation !== null) return emitHealViolation(json, "call", result, violation, state, adapter);
162
178
  const callOutput = readCallOutput(result.tracePath);
179
+ const failureUserAction = `mm-harness last --target ${shellQuote(target)} --json`;
163
180
  if (json) {
164
181
  console.log(
165
182
  JSON.stringify(
@@ -179,7 +196,8 @@ async function handleCall(argv) {
179
196
  ...callOutput !== void 0 ? { output: callOutput } : {},
180
197
  recovered: state.recovered,
181
198
  mutations: state.mutations,
182
- exitCode: result.status === "pass" ? EXIT.ok : EXIT.runtime
199
+ exitCode: result.status === "pass" ? EXIT.ok : EXIT.runtime,
200
+ ...result.status === "fail" ? { error: { code: "ACTION_EXECUTION_FAILED", message: `${resolvedAction} failed; inspect the persisted result and evidence paths`, userAction: failureUserAction } } : {}
183
201
  },
184
202
  null,
185
203
  2
@@ -189,15 +207,20 @@ async function handleCall(argv) {
189
207
  const rendered = callOutput !== void 0 ? `
190
208
  Result:
191
209
  ${formatCallOutput(callOutput)}` : "";
192
- const sideFindingTotal = result.sideFindings?.counts.total ?? 0;
193
210
  const out = (style, text) => color(style, text, { stream: process.stdout });
194
- const sideFindings = sideFindingTotal > 0 ? `${out("label", "Side findings:")} REVIEW ${sideFindingTotal} distinct application warning/error event(s); see ${out("path", result.diagnosticsPath ?? "diagnostics.json")} (non-blocking)
195
- ` : "";
211
+ const diagnostics = formatRunDiagnosticsForHuman(
212
+ readRunDiagnosticsDocument(result.diagnosticsPath),
213
+ adapter
214
+ );
215
+ const renderedDiagnostics = `${out("label", "Diagnostics:")}
216
+ ${diagnostics.map((line) => ` ${line}`).join("\n")}
217
+ `;
196
218
  console.log(
197
219
  `${out("label", "call")} ${out("cmd", resolvedAction)}: ${out(result.status === "pass" ? "ok" : "err", result.status)}${rendered ? `
198
220
  ${out("label", "Result:")}
199
221
  ${formatCallOutput(callOutput)}` : ""}
200
- ` + sideFindings + `${out("label", "Artifacts:")} ${out("path", result.artifactManifestPath)}`
222
+ ` + renderedDiagnostics + `${out("label", "Artifacts:")} ${out("path", result.artifactManifestPath)}` + (result.status === "fail" ? `
223
+ Next: ${failureUserAction}` : "")
201
224
  );
202
225
  }
203
226
  return result.status === "pass" ? EXIT.ok : EXIT.runtime;
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { detectAdapter } from "../harness.js";
5
5
  import { EXIT, usageOut } from "./shared.js";
6
- import { optionFlag, optionString, parseArgs } from "./parse-args.js";
6
+ import { optionFlag, optionString, parseArgs, shellQuote } from "./parse-args.js";
7
7
  const USAGE = "mm-harness check diff [--fix] [--target <repo>] [--adapter mobile|extension|core] [--base <ref>] [--profile fast|full] [--artifacts-dir <dir>] [--json]";
8
8
  const JS_TS_EXT_RE = /\.(?:c|m)?jsx?$|\.tsx?$/u;
9
9
  const FORMAT_EXT_RE = /\.(?:c|m)?jsx?$|\.tsx?$|\.json$|\.md$|\.css$|\.scss$|\.ya?ml$/u;
@@ -111,6 +111,7 @@ async function handleCheck(argv) {
111
111
  const status = [...fixes, ...checks].some((check) => check.status === "fail") ? "fail" : "pass";
112
112
  const exitCode = status === "pass" ? EXIT.ok : EXIT.validation;
113
113
  const guidance = policyFailed ? ["Remove the newly added eslint-disable directive(s). Fix the underlying violation with existing named styles, project tokens, or another established source pattern; then rerun mm-harness check diff [--fix]. Other checks were skipped until this policy failure is removed."] : checks.some((check) => check.id === "eslint" && check.status === "fail") ? ["Fix the reported violations in changed source using existing project patterns and tokens; do not suppress rules, hide violations behind indirection, weaken configuration, or bypass check diff. Then rerun mm-harness check diff [--fix]."] : [];
114
+ const failureUserAction = guidance[0] ?? `inspect ${path.join(artifactsDir, "validation-summary.json")}, fix the failing check, then retry: mm-harness check diff --target ${shellQuote(target)} --base ${shellQuote(baseRef)} --profile ${profile} --json`;
114
115
  const summary = {
115
116
  schemaVersion: 1,
116
117
  command: "check",
@@ -129,6 +130,7 @@ async function handleCheck(argv) {
129
130
  fixes,
130
131
  checks,
131
132
  guidance,
133
+ ...status === "fail" ? { error: { code: "CHECK_DIFF_FAILED", message: "one or more bounded checks failed", userAction: failureUserAction } } : {},
132
134
  artifacts: {
133
135
  dir: artifactsDir,
134
136
  summaryJson: path.join(artifactsDir, "validation-summary.json"),
@@ -518,10 +520,14 @@ function renderHuman(summary) {
518
520
  if (check.status === "fail" && check.logPath) console.log(` log: ${check.logPath}`);
519
521
  }
520
522
  for (const item of summary.guidance) console.log(` Next: ${item}`);
523
+ if (summary.guidance.length === 0 && summary.status === "fail" && summary.error?.userAction) {
524
+ console.log(` Next: ${summary.error.userAction}`);
525
+ }
521
526
  console.log(`Artifacts: ${summary.artifacts.summaryJson}`);
522
527
  }
523
528
  function failEnvelope(input) {
524
529
  const exitCode = EXIT.usage;
530
+ const userAction = USAGE;
525
531
  const body = {
526
532
  schemaVersion: 1,
527
533
  command: "check",
@@ -534,11 +540,11 @@ function failEnvelope(input) {
534
540
  baseRef: input.baseRef,
535
541
  baseSource: "unknown",
536
542
  artifacts: { dir: input.artifactsDir },
537
- error: { code: input.code, message: input.message }
543
+ error: { code: input.code, message: input.message, userAction }
538
544
  };
539
545
  if (input.json) console.log(JSON.stringify(body, null, 2));
540
546
  else console.error(`\u2717 check diff: ${input.message}
541
- Next: ${USAGE}`);
547
+ Next: ${userAction}`);
542
548
  return exitCode;
543
549
  }
544
550
  export {
@@ -1,7 +1,8 @@
1
1
  import path from "node:path";
2
2
  import { runnerDir } from "../paths.js";
3
+ import { acquireCheckoutLock } from "../checkout-lock.js";
3
4
  import { getAdapterSurface } from "../adapters/surface.js";
4
- import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, targetOf, usageOut } from "./shared.js";
5
+ import { ADAPTER_DETECT_NEXT, checkoutBusyOut, EXIT, flag, parseFlags, resolveAdapter, spawnScript, targetOf, usageOut } from "./shared.js";
5
6
  const DEBUG_BOOLEANS = /* @__PURE__ */ new Set(["worker", "devMenu", "json", "noOpen"]);
6
7
  async function handleDebug(argv) {
7
8
  const { options } = parseFlags(argv, DEBUG_BOOLEANS);
@@ -31,37 +32,43 @@ async function handleDebug(argv) {
31
32
  "mm-harness debug --worker"
32
33
  );
33
34
  }
34
- const openDebug = path.join(runnerDir, "adapters/shared/open-debug.mjs");
35
- const debugArgs = ["--adapter", adapter];
36
- if (adapter === "extension") {
37
- if (process.env.CDP_PORT) debugArgs.push("--cdp-port", process.env.CDP_PORT);
38
- debugArgs.push("--target", worker ? "worker" : "page");
39
- } else {
40
- if (process.env.WATCHER_PORT) debugArgs.push("--port", process.env.WATCHER_PORT);
41
- debugArgs.push("--action", devMenu ? "dev-menu" : "debug");
42
- }
43
- if (json) debugArgs.push("--json");
44
- if (noOpen) debugArgs.push("--no-open");
45
- const result = spawnScript(process.execPath, [openDebug, ...debugArgs], target, json);
46
- if (json) {
47
- const leaf = parseDebugLeaf(result.output);
48
- console.log(
49
- JSON.stringify(
50
- {
51
- ...leaf,
52
- schemaVersion: 1,
53
- command: "debug",
54
- adapter,
55
- mode: worker ? "worker" : devMenu ? "dev-menu" : "default",
56
- opened: leaf.opened === true,
57
- exitCode: result.status === 0 ? EXIT.ok : EXIT.runtime
58
- },
59
- null,
60
- 2
61
- )
62
- );
35
+ const lock = acquireCheckoutLock(target, "debug");
36
+ if ("message" in lock) return checkoutBusyOut(json, "debug", lock.message, lock.path);
37
+ try {
38
+ const openDebug = path.join(runnerDir, "adapters/shared/open-debug.mjs");
39
+ const debugArgs = ["--adapter", adapter];
40
+ if (adapter === "extension") {
41
+ if (process.env.CDP_PORT) debugArgs.push("--cdp-port", process.env.CDP_PORT);
42
+ debugArgs.push("--target", worker ? "worker" : "page");
43
+ } else {
44
+ if (process.env.WATCHER_PORT) debugArgs.push("--port", process.env.WATCHER_PORT);
45
+ debugArgs.push("--action", devMenu ? "dev-menu" : "debug");
46
+ }
47
+ if (json) debugArgs.push("--json");
48
+ if (noOpen) debugArgs.push("--no-open");
49
+ const result = spawnScript(process.execPath, [openDebug, ...debugArgs], target, json);
50
+ if (json) {
51
+ const leaf = parseDebugLeaf(result.output);
52
+ console.log(
53
+ JSON.stringify(
54
+ {
55
+ ...leaf,
56
+ schemaVersion: 1,
57
+ command: "debug",
58
+ adapter,
59
+ mode: worker ? "worker" : devMenu ? "dev-menu" : "default",
60
+ opened: leaf.opened === true,
61
+ exitCode: result.status === 0 ? EXIT.ok : EXIT.runtime
62
+ },
63
+ null,
64
+ 2
65
+ )
66
+ );
67
+ }
68
+ return result.status === 0 ? EXIT.ok : EXIT.runtime;
69
+ } finally {
70
+ lock.release();
63
71
  }
64
- return result.status === 0 ? EXIT.ok : EXIT.runtime;
65
72
  }
66
73
  function parseDebugLeaf(output) {
67
74
  try {
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { listConnectedDevices } from "../devices.js";
3
- import { optionString } from "./parse-args.js";
3
+ import { optionString, shellQuoteArg, targetPath } from "./parse-args.js";
4
4
  function normalizeDeviceName(value) {
5
5
  return value.replace(/_/gu, " ").trim();
6
6
  }
@@ -75,6 +75,12 @@ function configuredPinForPlatform(platform) {
75
75
  function oppositePlatform(platform) {
76
76
  return platform === "ios" ? "android" : "ios";
77
77
  }
78
+ function deviceProbe(options) {
79
+ return `mm-harness status --target ${shellQuoteArg(targetPath(options))} --all-devices --json`;
80
+ }
81
+ function deviceRecovery(options, rerun) {
82
+ return rerun ? `${deviceProbe(options)}; then retry: ${rerun}` : deviceProbe(options);
83
+ }
78
84
  function applyDeviceTargeting(command, adapter, options, opts) {
79
85
  const device = optionString(options, "device");
80
86
  const platformPreference = mobilePlatformPreference(options);
@@ -84,7 +90,8 @@ function applyDeviceTargeting(command, adapter, options, opts) {
84
90
  ok: false,
85
91
  code: "DEVICE_WRONG_ADAPTER",
86
92
  message: `--device is only supported on the mobile adapter (the ${adapter} adapter has no device to target).
87
- Drop --device, or run this ${command} inside a metamask-mobile checkout.`
93
+ Drop --device, or run this ${command} inside a metamask-mobile checkout.`,
94
+ userAction: `remove --device; ${adapter} has no device target`
88
95
  };
89
96
  }
90
97
  return { ok: true };
@@ -97,7 +104,8 @@ function applyDeviceTargeting(command, adapter, options, opts) {
97
104
  ok: false,
98
105
  code: "DEVICE_PLATFORM_CONFLICT",
99
106
  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.`
107
+ To use this device intentionally, drop --platform ${platformPreference} or choose a ${platformPreference} device.`,
108
+ userAction: deviceRecovery(options, opts.rerun)
101
109
  };
102
110
  }
103
111
  if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
@@ -126,7 +134,8 @@ function applyDeviceTargeting(command, adapter, options, opts) {
126
134
  ok: false,
127
135
  code: "DEVICE_NAME_AMBIGUOUS",
128
136
  message: `device name '${device}' is ambiguous; use the id:
129
- ` + byName.map((d) => ` - ${d.id}${d.name ? ` (${d.name})` : ""} [${d.state}] ${d.platform}`).join("\n")
137
+ ` + byName.map((d) => ` - ${d.id}${d.name ? ` (${d.name})` : ""} [${d.state}] ${d.platform}`).join("\n"),
138
+ userAction: deviceRecovery(options, opts.rerun)
130
139
  };
131
140
  }
132
141
  return {
@@ -134,7 +143,8 @@ function applyDeviceTargeting(command, adapter, options, opts) {
134
143
  code: "DEVICE_NOT_FOUND",
135
144
  message: `--device ${device} did not match any connected device.
136
145
  ` + (connected2.length > 0 ? ` Connected devices:
137
- ${formatConnectedDevices(connected2)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`)
146
+ ${formatConnectedDevices(connected2)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`),
147
+ userAction: deviceRecovery(options, opts.rerun)
138
148
  };
139
149
  }
140
150
  const connected = listConnectedDevices();
@@ -158,7 +168,8 @@ ${formatConnectedDevices(connected2)}` : ` No devices are currently connected (
158
168
  message: `--platform ${platformPreference} conflicts with the current slot-selected device.
159
169
  Selected devices:
160
170
  ${formatConnectedDevices(selectedTargetable2)}
161
- To use a different connected device intentionally, pass --device <id>.`
171
+ To use a different connected device intentionally, pass --device <id>.`,
172
+ userAction: deviceRecovery(options, opts.rerun)
162
173
  };
163
174
  }
164
175
  if (!configuredPin && oppositePin) {
@@ -167,7 +178,8 @@ ${formatConnectedDevices(selectedTargetable2)}
167
178
  code: "DEVICE_PLATFORM_CONFLICT",
168
179
  message: `--platform ${platformPreference} conflicts with the configured slot target.
169
180
  Configured ${oppositePlatform(platformPreference)} target: ${oppositePin}
170
- To use a different connected device intentionally, pass --device <id>.`
181
+ To use a different connected device intentionally, pass --device <id>.`,
182
+ userAction: deviceRecovery(options, opts.rerun)
171
183
  };
172
184
  }
173
185
  if (configuredPin) {
@@ -178,8 +190,8 @@ ${formatConnectedDevices(selectedTargetable2)}
178
190
  message: `--platform ${platformPreference} matches the configured slot target, but that target is not ready.
179
191
  Configured target: ${configuredPin}
180
192
  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.`
193
+ ${connected.length > 0 ? formatConnectedDevices(connected) : " - none"}`,
194
+ userAction: `mm-harness launch ${platformPreference} --target ${shellQuoteArg(targetPath(options))}`
183
195
  };
184
196
  }
185
197
  if (platformTargetable.length === 1) {
@@ -195,7 +207,8 @@ ${connected.length > 0 ? formatConnectedDevices(connected) : " - none"}
195
207
  code: "DEVICE_PLATFORM_NOT_FOUND",
196
208
  message: `--platform ${platformPreference} did not match any ready connected device.
197
209
  ` + (connected.length > 0 ? ` Connected devices:
198
- ${formatConnectedDevices(connected)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`)
210
+ ${formatConnectedDevices(connected)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`),
211
+ userAction: deviceRecovery(options, opts.rerun)
199
212
  };
200
213
  }
201
214
  return {
@@ -205,7 +218,8 @@ ${formatConnectedDevices(connected)}` : ` No devices are currently connected (a
205
218
  Connected devices:
206
219
  ${formatConnectedDevices(connected)}
207
220
  Add --device <id> to disambiguate, e.g.:${platformTargetable.map((d) => `
208
- --device ${d.id}`).join("")}`
221
+ --device ${d.id}`).join("")}`,
222
+ userAction: deviceRecovery(options, opts.rerun)
209
223
  };
210
224
  }
211
225
  const selectedTargetable = targetable.filter(deviceSelected);
@@ -224,7 +238,8 @@ ${formatConnectedDevices(connected)}
224
238
  Connected devices:
225
239
  ${formatConnectedDevices(connected)}
226
240
  ` + (selectedTargetable.length > 1 ? ` The current slot context selects more than one target; fix the slot device pins or add --device <id>.` : ` Add --device <id> to disambiguate, e.g.:${targetable.map((d) => `
227
- --device ${d.id}`).join("")}`)
241
+ --device ${d.id}`).join("")}`),
242
+ userAction: deviceRecovery(options, opts.rerun)
228
243
  };
229
244
  }
230
245
  return { ok: true };