@deeeed/metamask-harness 0.15.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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";
@@ -59,7 +60,13 @@ async function handleCall(argv) {
59
60
  }
60
61
  const message = `call requires <action>. Example: ${example}`;
61
62
  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));
63
+ if (json) console.log(JSON.stringify({
64
+ schemaVersion: 1,
65
+ command: "call",
66
+ status: "fail",
67
+ error: { code: "CLI_MISSING_POSITIONAL", message, userAction },
68
+ exitCode: EXIT.usage
69
+ }, null, 2));
63
70
  else console.error(`${message}
64
71
  See the vocabulary: ${discovery}`);
65
72
  return EXIT.usage;
@@ -76,16 +83,19 @@ async function handleCall(argv) {
76
83
  getAdapterSurface(adapter).resolveSlotPorts(target);
77
84
  const dtResult = applyDeviceTargeting("call", adapter, options, { gate: true, rerun: "" });
78
85
  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}`);
86
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, error: { code: dtResult.code, message: dtResult.message, userAction: dtResult.userAction } }, null, 2));
87
+ else console.error(`\u2717 call: ${dtResult.message}
88
+ Next: ${dtResult.userAction}`);
81
89
  return EXIT.usage;
82
90
  }
83
91
  if (recipeRunning(target)) {
84
92
  const msg = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
93
+ const userAction = `inspect the checkout state with: mm-harness status --target ${shellQuote(target)} --json; retry after the active recipe finishes`;
85
94
  if (json) {
86
- console.log(JSON.stringify({ schemaVersion: 1, command: "call", status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg } }, null, 2));
95
+ console.log(JSON.stringify({ schemaVersion: 1, command: "call", status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg, userAction } }, null, 2));
87
96
  } else {
88
- console.error(`\u2717 mm-harness call: ${msg}`);
97
+ console.error(`\u2717 mm-harness call: ${msg}
98
+ Next: ${userAction}`);
89
99
  }
90
100
  return EXIT.bounded;
91
101
  }
@@ -95,16 +105,19 @@ async function handleCall(argv) {
95
105
  const names = getRecipeActionManifestActionNames(manifest);
96
106
  const resolution = resolveActionName(shortName, names);
97
107
  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);
108
+ const message = `unknown action "${shortName}" for the ${adapter} adapter.`;
109
+ const userAction = `mm-harness actions --adapter ${adapter} --json`;
110
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
111
+ else console.error(`\u2717 call: ${message}
112
+ Next: ${userAction}`);
102
113
  return EXIT.usage;
103
114
  }
104
115
  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);
116
+ const message = `"${shortName}" is ambiguous: ${resolution.candidates.join(", ")} \u2014 use the full name.`;
117
+ const userAction = `mm-harness actions --action ${resolution.candidates[0]} --adapter ${adapter} --json`;
118
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_AMBIGUOUS", message, candidates: resolution.candidates, userAction } }, null, 2));
119
+ else console.error(`\u2717 call: ${message}
120
+ Next: ${userAction}`);
108
121
  return EXIT.usage;
109
122
  }
110
123
  const resolvedAction = resolution.resolved;
@@ -121,11 +134,13 @@ async function handleCall(argv) {
121
134
  const recipe = synthesizeOneNodeRecipe(resolvedAction, args);
122
135
  const validation = await validateRecipeAdapterAware(recipe, manifest);
123
136
  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));
137
+ const message = `call ${resolvedAction}: recipe validation failed`;
138
+ const userAction = `mm-harness actions --action ${resolvedAction} --adapter ${adapter} --json`;
139
+ 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
140
  else {
127
- console.error(message);
141
+ console.error(`\u2717 ${message}`);
128
142
  for (const finding of validation.findings) console.error(` ${finding.code} ${finding.path} \u2014 ${finding.message}`);
143
+ console.error(` Next: ${userAction}`);
129
144
  }
130
145
  return EXIT.validation;
131
146
  }
@@ -160,6 +175,7 @@ async function handleCall(argv) {
160
175
  );
161
176
  if (violation !== null) return emitHealViolation(json, "call", result, violation, state, adapter);
162
177
  const callOutput = readCallOutput(result.tracePath);
178
+ const failureUserAction = `mm-harness last --target ${shellQuote(target)} --json`;
163
179
  if (json) {
164
180
  console.log(
165
181
  JSON.stringify(
@@ -174,10 +190,13 @@ async function handleCall(argv) {
174
190
  summaryPath: result.summaryPath,
175
191
  tracePath: result.tracePath,
176
192
  artifactManifestPath: result.artifactManifestPath,
193
+ ...result.diagnosticsPath ? { diagnosticsPath: result.diagnosticsPath } : {},
194
+ ...result.sideFindings ? { sideFindings: result.sideFindings } : {},
177
195
  ...callOutput !== void 0 ? { output: callOutput } : {},
178
196
  recovered: state.recovered,
179
197
  mutations: state.mutations,
180
- exitCode: result.status === "pass" ? EXIT.ok : EXIT.runtime
198
+ exitCode: result.status === "pass" ? EXIT.ok : EXIT.runtime,
199
+ ...result.status === "fail" ? { error: { code: "ACTION_EXECUTION_FAILED", message: `${resolvedAction} failed; inspect the persisted result and evidence paths`, userAction: failureUserAction } } : {}
181
200
  },
182
201
  null,
183
202
  2
@@ -187,12 +206,16 @@ async function handleCall(argv) {
187
206
  const rendered = callOutput !== void 0 ? `
188
207
  Result:
189
208
  ${formatCallOutput(callOutput)}` : "";
209
+ const sideFindingTotal = result.sideFindings?.counts.total ?? 0;
190
210
  const out = (style, text) => color(style, text, { stream: process.stdout });
211
+ 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)
212
+ ` : "";
191
213
  console.log(
192
214
  `${out("label", "call")} ${out("cmd", resolvedAction)}: ${out(result.status === "pass" ? "ok" : "err", result.status)}${rendered ? `
193
215
  ${out("label", "Result:")}
194
216
  ${formatCallOutput(callOutput)}` : ""}
195
- ${out("label", "Artifacts:")} ${out("path", result.artifactManifestPath)}`
217
+ ` + sideFindings + `${out("label", "Artifacts:")} ${out("path", result.artifactManifestPath)}` + (result.status === "fail" ? `
218
+ Next: ${failureUserAction}` : "")
196
219
  );
197
220
  }
198
221
  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,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 };
@@ -81,7 +81,7 @@ async function handleDoctor({ options }) {
81
81
  surface.resolveSlotPorts(target);
82
82
  const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
83
83
  if ("code" in dtResult) {
84
- return usageOut(json, "doctor", dtResult.message, "mm-harness doctor --fix --adapter mobile --device <adb-serial|simulator-udid>");
84
+ return usageOut(json, "doctor", dtResult.message, dtResult.userAction);
85
85
  }
86
86
  applyDoctorRuntimePorts(options);
87
87
  const lock = acquireCheckoutLock(target, "doctor-fix");
@@ -98,7 +98,14 @@ async function handleDoctor({ options }) {
98
98
  const result2 = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
99
99
  const nextActions = doctorFixNextActions(failed, result2.fixture, adapter, target);
100
100
  const status = result2.status === "pass" && failed.length === 0 ? "pass" : "fail";
101
- if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, nextActions }, null, 2));
101
+ const retryDoctor = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
102
+ const fixUserAction = failed.includes("wallet-fixture") ? `choose one wallet fixture source from nextActions, run it, then retry: ${retryDoctor}` : nextActions[0] ?? retryDoctor;
103
+ const error = status === "fail" ? {
104
+ code: "DOCTOR_FIX_INCOMPLETE",
105
+ message: "doctor --fix could not make every required check pass",
106
+ userAction: fixUserAction
107
+ } : void 0;
108
+ if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, nextActions, ...error ? { error } : {} }, null, 2));
102
109
  else {
103
110
  console.log(`${status} ${adapter} ${result2.compatibilityMode} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
104
111
  for (const action of nextActions) console.error(` Next: ${action}`);
@@ -113,8 +120,9 @@ async function handleDoctor({ options }) {
113
120
  surface.resolveSlotPorts(target);
114
121
  const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
115
122
  if ("code" in dtResult) {
116
- if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
117
- else console.error(`\u2717 doctor: ${dtResult.message}`);
123
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message, userAction: dtResult.userAction } }, null, 2));
124
+ else console.error(`\u2717 doctor: ${dtResult.message}
125
+ Next: ${dtResult.userAction}`);
118
126
  return EXIT.usage;
119
127
  }
120
128
  applyDoctorRuntimePorts(options);
@@ -151,7 +159,9 @@ async function handleDoctor({ options }) {
151
159
  printReady
152
160
  );
153
161
  }
154
- if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
162
+ const doctorUserAction = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
163
+ const doctorError = doctorResult.status === "fail" ? { code: "DOCTOR_CHECKS_FAILED", message: "one or more required doctor checks failed", userAction: doctorUserAction } : void 0;
164
+ if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, orphanMetros, capture, devices, additionalReachableDevices, ...doctorError ? { error: doctorError } : {} }, null, 2));
155
165
  else {
156
166
  const out = (style, text) => color(style, text, { stream: process.stdout });
157
167
  const stateStyle = (value, good) => value === good ? "ok" : "warn";
@@ -194,6 +204,7 @@ async function handleDoctor({ options }) {
194
204
  if (additionalReachableDevices.length > 0) renderAdditionalReachableDevices(additionalReachableDevices, out);
195
205
  if (liveView) renderMobileLiveBlock(deviceView?.devices ?? [], liveView.liveMap, out);
196
206
  }
207
+ if (doctorError) console.log(` ${out("dim", `Next: ${doctorUserAction}`)}`);
197
208
  }
198
209
  return doctorResult.status === "pass" ? 0 : 1;
199
210
  }
@@ -256,7 +267,9 @@ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture,
256
267
  return EXIT.runtime;
257
268
  }
258
269
  if (json) {
259
- console.log(JSON.stringify({ ...result, ready: live, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
270
+ const userAction = adapter === "extension" ? `mm-harness launch --adapter extension --target ${shellQuote(target)}` : adapter === "core" ? `mm-harness verify --adapter core --target ${shellQuote(target)}` : `mm-harness status --target ${shellQuote(target)} --json`;
271
+ const error = live ? void 0 : { code: "RUNTIME_NOT_LIVE", message: `${adapter} runtime is not live`, userAction };
272
+ console.log(JSON.stringify({ ...result, ready: live, runtime, orphanMetros, capture, devices, additionalReachableDevices, ...error ? { error } : {} }, null, 2));
260
273
  return live ? EXIT.ok : EXIT.runtime;
261
274
  }
262
275
  const out = (style, text) => color(style, text, { stream: process.stdout });
@@ -65,7 +65,7 @@ async function handleFixturesLocked(argv, deps) {
65
65
  surface.resolveSlotPorts(target);
66
66
  const dtResult = applyDeviceTargeting("fixtures", adapter, options, { gate: false, rerun: "" });
67
67
  if ("code" in dtResult) {
68
- return usageOut(json, "fixtures", dtResult.message, "mm-harness fixtures set --adapter mobile --device <adb-serial|simulator-udid>");
68
+ return usageOut(json, "fixtures", dtResult.message, dtResult.userAction);
69
69
  }
70
70
  if (sub === "generate") return fixturesGenerate(adapter, target, options, json);
71
71
  if (sub === "finalize") return fixturesFinalize(adapter, target, options, json);
@@ -0,0 +1,52 @@
1
+ import { readCommandJournal } from "../command-journal.js";
2
+ import { EXIT } from "./shared.js";
3
+ import {
4
+ optionFlag,
5
+ optionString,
6
+ targetPath
7
+ } from "./parse-args.js";
8
+ async function handleLast({ options }) {
9
+ const target = targetPath(options);
10
+ const { file, record } = readCommandJournal(target, optionString(options, "runtimeDir"));
11
+ const json = optionFlag(options, "json");
12
+ if (!record) {
13
+ const message = `no resumability journal exists for ${target}`;
14
+ const userAction = "run a proof or runtime command in this checkout, then re-run mm-harness last --json";
15
+ if (json) {
16
+ console.log(JSON.stringify({
17
+ schemaVersion: 1,
18
+ command: "last",
19
+ target,
20
+ journalPath: file,
21
+ status: "fail",
22
+ exitCode: EXIT.runtime,
23
+ error: { code: "LAST_NOT_FOUND", message, userAction }
24
+ }, null, 2));
25
+ } else {
26
+ console.error(`\u2717 mm-harness last: ${message}
27
+ Next: ${userAction}`);
28
+ }
29
+ return EXIT.runtime;
30
+ }
31
+ if (json) {
32
+ console.log(JSON.stringify({
33
+ schemaVersion: 1,
34
+ command: "last",
35
+ target,
36
+ journalPath: file,
37
+ status: "pass",
38
+ exitCode: EXIT.ok,
39
+ last: record
40
+ }, null, 2));
41
+ } else {
42
+ const end = record.finishedAt ?? "still running or interrupted";
43
+ console.log(`${record.verdict.toUpperCase()} ${record.command} (exit ${record.exitCode ?? "pending"})`);
44
+ console.log(`started: ${record.startedAt}`);
45
+ console.log(`finished: ${end}`);
46
+ if (record.evidencePaths.length > 0) console.log(`evidence: ${record.evidencePaths.join(", ")}`);
47
+ }
48
+ return EXIT.ok;
49
+ }
50
+ export {
51
+ handleLast
52
+ };