@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.
@@ -1,7 +1,7 @@
1
1
  import { detectAdapter } from "../harness.js";
2
2
  import { assertAdapter } from "../paths.js";
3
3
  import { getAdapterSurface } from "../adapters/surface.js";
4
- import { ADAPTER_DETECT_NEXT, usageOut } from "./shared.js";
4
+ import { ADAPTER_DETECT_NEXT, usageOut, writeInteractiveProgress } from "./shared.js";
5
5
  import {
6
6
  applyWatcherPortOption,
7
7
  optionFlag,
@@ -25,9 +25,13 @@ async function handleProvision({ positional, options, rawArgv }) {
25
25
  }
26
26
  const surface = getAdapterSurface(adapter);
27
27
  const rerunCommand = provisionRerunCommand(rawArgv, options, adapter, target);
28
+ const platform = optionString(options, "platform") ?? optionString(options, "devicePlatform") ?? (positional[0] === "runway" ? positional[1] : positional[0]) ?? "ios";
29
+ if (adapter === "mobile") {
30
+ writeInteractiveProgress(json, `\u2192 provision mobile ${platform} \u2014 resolving simulator and Runway artifact`);
31
+ }
28
32
  const result = await surface.runwayProvision.run(target, {
29
33
  json,
30
- platform: optionString(options, "platform") ?? optionString(options, "devicePlatform") ?? (positional[0] === "runway" ? positional[1] : positional[0]) ?? "ios",
34
+ platform,
31
35
  branch: optionString(options, "branch"),
32
36
  defaultBranch: optionString(options, "defaultBranch"),
33
37
  run: optionString(options, "run"),
@@ -42,8 +46,10 @@ async function handleProvision({ positional, options, rawArgv }) {
42
46
  resolveOnly: optionFlag(options, "resolveOnly"),
43
47
  rerunCommand
44
48
  });
49
+ const next = result.status === "pass" && adapter === "mobile" && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? platform)} --adapter mobile --target ${shellQuote(target)}` : void 0;
50
+ const output = next ? { ...result, next } : result;
45
51
  if (json) {
46
- console.log(JSON.stringify(result, null, 2));
52
+ console.log(JSON.stringify(output, null, 2));
47
53
  } else if (result.status === "pass") {
48
54
  const cache = typeof result.cache === "object" && result.cache ? result.cache : void 0;
49
55
  const simulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
@@ -54,6 +60,7 @@ async function handleProvision({ positional, options, rawArgv }) {
54
60
  const action = result.skipped ? "already provisioned" : "provisioned";
55
61
  console.error(`\u2713 ${action} ${adapter} ${result.platform ?? ""} simulator=${simulator?.name ?? "unknown"} cache=${cache?.status ?? "unknown"}`);
56
62
  }
63
+ if (next) console.error(` Next: ${next}`);
57
64
  } else {
58
65
  console.error(`\u2717 mm-harness provision: ${result.error?.message ?? "provision failed"}
59
66
  Next: ${result.error?.userAction ?? rerunCommand}`);
@@ -20,12 +20,18 @@ import {
20
20
  import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
21
21
  import { listRecipeFiles } from "../recipe-files.js";
22
22
  import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
23
+ import {
24
+ beginRunDiagnostics,
25
+ finishRunDiagnostics,
26
+ stopRunDiagnostics
27
+ } from "../run-diagnostics.js";
23
28
  import { EXIT } from "./shared.js";
24
29
  import {
25
30
  actionManifestPathOption,
26
31
  optionString,
27
32
  isRecord,
28
33
  shellQuoteArg,
34
+ targetPath,
29
35
  usageError
30
36
  } from "./parse-args.js";
31
37
  async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}) {
@@ -49,6 +55,7 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
49
55
  try {
50
56
  await prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions);
51
57
  const absoluteArtifactsDir = path.resolve(artifactsDir);
58
+ const diagnosticBaseline = await beginRunDiagnostics(adapter, projectRoot);
52
59
  const recordVideo = runtimeOptions.recordVideo ?? false;
53
60
  const useFramedExtensionRecording = adapter === "extension" && recordVideo === "full-run" && captureHelperSupportsRecordSessionSnapshots(projectRoot);
54
61
  const recording = useFramedExtensionRecording ? await startRecipeRecording(adapter, projectRoot, absoluteArtifactsDir, {
@@ -60,7 +67,8 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
60
67
  await validateManifest(manifest);
61
68
  const { createMetaMaskRunner } = await import("../runner.js");
62
69
  const runner = await createMetaMaskRunner(adapter, manifest, {
63
- quietStdout: runtimeOptions.stdoutIsMachineContract === true
70
+ quietStdout: runtimeOptions.stdoutIsMachineContract === true,
71
+ onActionEvent: runtimeOptions.onActionEvent
64
72
  });
65
73
  const runRequest = {
66
74
  recipePath: path.resolve(recipe),
@@ -80,8 +88,9 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
80
88
  }
81
89
  }
82
90
  await stopRecipeRecording(recording, result);
83
- return result;
91
+ return finishRunDiagnostics(diagnosticBaseline, result);
84
92
  } finally {
93
+ stopRunDiagnostics(diagnosticBaseline);
85
94
  await stopRecipeRecording(recording);
86
95
  }
87
96
  } finally {
@@ -341,6 +350,7 @@ async function actionInsteadOfRecipeHint(recipeArg, adapter, options) {
341
350
  const device = optionString(options, "device");
342
351
  const actionManifest = optionString(options, "actionManifest");
343
352
  const parts = ["mm-harness", "call", matches[0]];
353
+ parts.push("--adapter", adapter, "--target", shellQuoteArg(targetPath(options)));
344
354
  if (device) parts.push("--device", shellQuoteArg(device));
345
355
  if (actionManifest) parts.push("--action-manifest", shellQuoteArg(actionManifest));
346
356
  return ` This is an action, not a recipe. Use: ${parts.join(" ")}.`;
@@ -398,10 +408,12 @@ async function runOneNode(adapter, action, args, target, actionManifest) {
398
408
  async function prepareHeal(adapter, target, options, json, prepareOptions = {}) {
399
409
  if (recipeRunning(target)) {
400
410
  const msg = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
411
+ const userAction = `inspect the checkout state with: mm-harness status --target ${shellQuoteArg(target)} --json; retry after the active recipe finishes`;
401
412
  if (json) {
402
- console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg } }, null, 2));
413
+ console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg, userAction } }, null, 2));
403
414
  } else {
404
- console.error(`\u2717 mm-harness: ${msg}`);
415
+ console.error(`\u2717 mm-harness: ${msg}
416
+ Next: ${userAction}`);
405
417
  }
406
418
  return EXIT.bounded;
407
419
  }
@@ -414,21 +426,25 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
414
426
  return EXIT.usage;
415
427
  }
416
428
  const state = newHealState();
429
+ prepareOptions.onPhase?.("install");
417
430
  const ensured = await ensureOverlay(adapter, target, heal, state, json);
418
431
  if (!ensured.ok) {
432
+ const userAction = `mm-harness install --adapter ${adapter} --target ${shellQuoteArg(target)}`;
419
433
  if (json) {
420
434
  console.log(
421
435
  JSON.stringify(
422
- { schemaVersion: 1, status: "fail", recoverable: false, mutations: state.mutations, error: { code: "OVERLAY_INSTALL_FAILED", message: ensured.error } },
436
+ { schemaVersion: 1, status: "fail", recoverable: false, mutations: state.mutations, error: { code: "OVERLAY_INSTALL_FAILED", message: ensured.error, userAction } },
423
437
  null,
424
438
  2
425
439
  )
426
440
  );
427
441
  } else {
428
- console.error(`\u2717 overlay auto-ensure failed: ${ensured.error}`);
442
+ console.error(`\u2717 overlay auto-ensure failed: ${ensured.error}
443
+ Next: ${userAction}`);
429
444
  }
430
445
  return EXIT.infra;
431
446
  }
447
+ prepareOptions.onPhase?.("healthcheck");
432
448
  if (adapter === "extension") {
433
449
  const previousCdpPort = process.env.CDP_PORT;
434
450
  const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
@@ -441,7 +457,9 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
441
457
  }
442
458
  if (explicitWatcherPort) process.env.WATCHER_PORT = explicitWatcherPort;
443
459
  try {
444
- const current = await ensureExtensionProofRuntime(target, heal, state, json);
460
+ const current = await ensureExtensionProofRuntime(target, heal, state, json, {
461
+ onRecovery: (code) => prepareOptions.onPhase?.("recover", { code })
462
+ });
445
463
  if (current !== null) return current;
446
464
  } finally {
447
465
  restoreEnv("CDP_PORT", previousCdpPort);
@@ -450,7 +468,9 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
450
468
  }
451
469
  }
452
470
  if (adapter === "mobile" && prepareOptions.skipMobileSourceFreshness !== true) {
453
- const current = await ensureMobileProofRuntime(target, heal, state, json);
471
+ const current = await ensureMobileProofRuntime(target, heal, state, json, {
472
+ onRecovery: (code) => prepareOptions.onPhase?.("recover", { code })
473
+ });
454
474
  if (current !== null) return current;
455
475
  }
456
476
  return { state, heal };
@@ -495,6 +515,7 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
495
515
  }
496
516
  const recoveryCode = "mobile.source_reloaded";
497
517
  state.attemptedRecoveries.push(recoveryCode);
518
+ deps.onRecovery?.(recoveryCode);
498
519
  if (!json) console.error(`\u2192 Mobile proof preflight: source ${initial.status}; restarting the app before execution`);
499
520
  const restart = deps.restart ?? (async () => {
500
521
  const result2 = await runOneNode(
@@ -581,6 +602,7 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
581
602
  }
582
603
  const recoveryCode = tier === "build" ? "extension.runtime_rebuilt" : "extension.runtime_reloaded";
583
604
  state.attemptedRecoveries.push(recoveryCode);
605
+ deps.onRecovery?.(recoveryCode);
584
606
  if (!json) console.error(`\u2192 Extension proof preflight: ${initial.reasonCode}; ${tier === "build" ? "rebuilding" : "reloading"} before execution`);
585
607
  const launch = deps.launch ?? (async (requestedTier) => {
586
608
  const { launchExtension } = await import("./launch/extension.js");
@@ -635,7 +657,7 @@ function readRunFailureText(result) {
635
657
  return "";
636
658
  }
637
659
  }
638
- async function executeWithHealBounds(exec, adapter, target, heal, state, recover = () => recoverRunInfra(adapter, target, false)) {
660
+ async function executeWithHealBounds(exec, adapter, target, heal, state, recover = () => recoverRunInfra(adapter, target, false), onRecovery = () => void 0) {
639
661
  let result = await exec();
640
662
  for (; ; ) {
641
663
  if (result.status === "pass") return { result, violation: null };
@@ -644,6 +666,7 @@ async function executeWithHealBounds(exec, adapter, target, heal, state, recover
644
666
  if (heal === "off") return { result, violation: null };
645
667
  const recoveryCode = RUN_RECOVERY_CODE[adapter];
646
668
  state.attemptedRecoveries.push(recoveryCode);
669
+ onRecovery(recoveryCode);
647
670
  await recover();
648
671
  result = await exec();
649
672
  if (result.status === "pass") {
@@ -653,7 +676,7 @@ async function executeWithHealBounds(exec, adapter, target, heal, state, recover
653
676
  }
654
677
  }
655
678
  function emitHealViolation(json, command, result, violation, state, adapter) {
656
- const userAction = adapter === "core" && violation.code === "WALLET_STATE_REQUIRED" ? 'set MM_TEST_ACCOUNT_ADDRESS=<0x\u2026> in env, or add "account": "<0x\u2026>" to the node block in the recipe' : violation.userAction;
679
+ const userAction = adapter === "core" && violation.code === "WALLET_STATE_REQUIRED" ? 'set MM_TEST_ACCOUNT_ADDRESS=<0x\u2026> in env, or add "account": "<0x\u2026>" to the node block in the recipe' : violation.userAction ?? `inspect ${shellQuoteArg(result.summaryPath)} and ${shellQuoteArg(result.tracePath)}; fix the application or recipe failure before retrying`;
657
680
  if (json) {
658
681
  console.log(
659
682
  JSON.stringify(
@@ -673,7 +696,7 @@ function emitHealViolation(json, command, result, violation, state, adapter) {
673
696
  code: violation.code,
674
697
  message: violation.message,
675
698
  retryable: false,
676
- userAction: userAction ?? null,
699
+ userAction,
677
700
  originalError: violation.originalError ?? null
678
701
  }
679
702
  },
@@ -25,10 +25,19 @@ function renderRunReport(summary, entries) {
25
25
  "",
26
26
  `Status: ${String(summary.status ?? "unknown")}`,
27
27
  `Duration: ${formatDuration(Number(summary.durationMs ?? 0))}`,
28
- `Nodes: ${Number(summary.passed ?? 0)}/${Number(summary.total ?? entries.length)} passed`,
29
- "",
30
- "## Steps"
28
+ `Nodes: ${Number(summary.passed ?? 0)}/${Number(summary.total ?? entries.length)} passed`
31
29
  ];
30
+ const sideFindings = isRecord(summary.sideFindings) ? summary.sideFindings : void 0;
31
+ const sideFindingCounts = sideFindings && isRecord(sideFindings.counts) ? sideFindings.counts : void 0;
32
+ const sideFindingTotal = Number(sideFindingCounts?.total ?? 0);
33
+ if (sideFindingTotal > 0) {
34
+ lines.push(
35
+ "",
36
+ "## Side findings",
37
+ `- REVIEW ${sideFindingTotal} distinct application warning/error event(s); see diagnostics.json (non-blocking)`
38
+ );
39
+ }
40
+ lines.push("", "## Steps");
32
41
  for (const entry of entries) {
33
42
  if (!isRecord(entry)) continue;
34
43
  const mark = entry.ok === false ? "FAIL" : "PASS";
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { walletFixturePath } from "../paths.js";
4
4
  import { color } from "../cli-color.js";
5
5
  import { recipeRunning } from "../heal-bounds.js";
6
- import { checkoutBusyOut, EXIT, usageOut } from "./shared.js";
6
+ import { checkoutBusyOut, EXIT, usageOut, writeInteractiveProgress } from "./shared.js";
7
7
  import {
8
8
  optionFlag,
9
9
  optionString,
@@ -11,6 +11,8 @@ import {
11
11
  resolveAdapter,
12
12
  runtimeOptionsFromCli,
13
13
  isRecord,
14
+ shellQuote,
15
+ targetPath,
14
16
  usageError
15
17
  } from "./parse-args.js";
16
18
  import {
@@ -28,54 +30,118 @@ import { getAdapterSurface } from "../adapters/surface.js";
28
30
  import { coreDependencyBlock } from "./core-readiness.js";
29
31
  import { writeRunReport } from "./run-report.js";
30
32
  import { acquireCheckoutLock } from "../checkout-lock.js";
31
- async function handleRun({ positional, options }) {
32
- if (optionFlag(options, "list")) return handleListExecutables("run", options);
33
+ import { JsonStreamWriter } from "../json-stream.js";
34
+ async function handleRun(parsed) {
35
+ const stream = new JsonStreamWriter("run", optionFlag(parsed.options, "jsonStream"));
36
+ const restoreStdout = stream.isolateStdout();
37
+ const target = targetPath(parsed.options);
38
+ try {
39
+ const exitCode = await handleRunInner(parsed, stream);
40
+ stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode);
41
+ return exitCode;
42
+ } catch (error) {
43
+ const exitCode = error !== null && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number" ? error.exitCode : EXIT.runtime;
44
+ stream.error({
45
+ code: exitCode === EXIT.usage ? "CLI_USAGE_ERROR" : "RUN_FAILED",
46
+ message: error instanceof Error ? error.message : String(error),
47
+ userAction: `mm-harness doctor --target ${shellQuote(target)} --json`
48
+ });
49
+ stream.complete("fail", exitCode);
50
+ throw error;
51
+ } finally {
52
+ restoreStdout();
53
+ }
54
+ }
55
+ async function handleRunInner({ positional, options }, stream) {
56
+ if (optionFlag(options, "list")) {
57
+ if (stream.enabled) {
58
+ const message = "--json-stream is for recipe execution; use run --list --json for discovery.";
59
+ stream.error({ code: "JSON_STREAM_UNSUPPORTED_MODE", message, userAction: "mm-harness run --list --json" });
60
+ return EXIT.usage;
61
+ }
62
+ return handleListExecutables("run", options);
63
+ }
33
64
  const targetRecipe = positional[0];
34
65
  if (!targetRecipe) throw usageError("run requires <recipe.json>.");
35
- if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options);
66
+ if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options, stream);
36
67
  const { adapter, target } = resolveAdapter(options);
37
68
  const json = optionFlag(options, "json");
69
+ const jsonOutput = json && !stream.enabled;
70
+ const machine = json || stream.enabled;
71
+ stream.phase("resolve", { adapter, target, recipe: targetRecipe });
38
72
  if (!fs.existsSync(target)) {
39
- return usageOut(
40
- json,
41
- "run",
42
- `target does not exist: ${target}`,
43
- "pass --target <metamask-checkout> pointing to an existing checkout"
44
- );
73
+ const message = `target does not exist: ${target}`;
74
+ const userAction = "pass --target <metamask-checkout> pointing to an existing checkout";
75
+ if (stream.enabled) {
76
+ stream.error({ code: "TARGET_NOT_FOUND", message, userAction });
77
+ return EXIT.usage;
78
+ }
79
+ return usageOut(jsonOutput, "run", message, userAction);
45
80
  }
81
+ writeInteractiveProgress(machine, `\u2192 recipe run \u2014 validating ${targetRecipe} \xB7 ${adapter}`);
46
82
  getAdapterSurface(adapter).resolveSlotPorts(target);
47
83
  const dtResult = applyDeviceTargeting("run", adapter, options, { gate: true, rerun: "" });
48
84
  if ("code" in dtResult) {
49
- return emitRunUsageError(json, adapter, targetRecipe, dtResult.code, dtResult.message);
85
+ return emitRunUsageError(jsonOutput, stream, adapter, targetRecipe, dtResult.code, dtResult.message, dtResult.userAction);
50
86
  }
51
- if (recipeRunning(target)) return emitRunRecipeRunning(json);
87
+ if (recipeRunning(target)) return emitRunRecipeRunning(jsonOutput, stream, target);
52
88
  if (optionString(options, "artifactsDir") === void 0 && runArgLooksLikeRecipeFile(targetRecipe)) {
53
89
  requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
54
90
  }
91
+ stream.phase("validate");
55
92
  const validated = await validateRunRecipeStatic(targetRecipe, adapter, options);
56
93
  if (validated.usageError) {
57
- return emitRunUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
94
+ const userAction = runUsageRecovery(
95
+ validated.usageError.code,
96
+ validated.usageError.message,
97
+ adapter,
98
+ validated.recipeFile
99
+ );
100
+ return emitRunUsageError(
101
+ jsonOutput,
102
+ stream,
103
+ adapter,
104
+ validated.recipeFile,
105
+ validated.usageError.code,
106
+ validated.usageError.message,
107
+ userAction
108
+ );
58
109
  }
59
110
  if (validated.errorCount > 0) {
60
- return emitRunValidationError(json, adapter, validated.recipeFile, validated.findings, validated.errorCount);
111
+ return emitRunValidationError(jsonOutput, stream, adapter, validated.recipeFile, validated.findings, validated.errorCount);
61
112
  }
62
113
  const depsBlock = adapter === "core" && recipeUsesCoreController(validated.recipe, validated.librarySources) ? coreDependencyBlock(target) : null;
63
114
  if (depsBlock) {
64
- return emitRunUsageError(json, adapter, validated.recipeFile, depsBlock.code, depsBlock.message, depsBlock.userAction);
115
+ return emitRunUsageError(jsonOutput, stream, adapter, validated.recipeFile, depsBlock.code, depsBlock.message, depsBlock.userAction);
65
116
  }
66
117
  const artifactsDir = requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
67
118
  const lock = acquireCheckoutLock(target, "run");
68
- if ("message" in lock) return checkoutBusyOut(json, "run", lock.message, lock.path);
119
+ if ("message" in lock) {
120
+ const userAction = `wait for the current owner, or inspect ${lock.path} if its process has exited`;
121
+ stream.error({ code: "SANDBOX_BUSY", message: lock.message, userAction });
122
+ return checkoutBusyOut(jsonOutput, "run", lock.message, lock.path);
123
+ }
69
124
  try {
70
- const prepared = await prepareHeal(adapter, target, options, json);
71
- if (typeof prepared === "number") return prepared;
125
+ const prepared = await prepareHeal(adapter, target, options, machine, {
126
+ onPhase: (phase, fields) => stream.phase(phase, fields)
127
+ });
128
+ if (typeof prepared === "number") {
129
+ stream.error({
130
+ code: "RUN_PREPARE_FAILED",
131
+ message: "runtime preparation failed; inspect stderr for the exact probe",
132
+ userAction: `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`
133
+ });
134
+ return prepared;
135
+ }
72
136
  const { state, heal } = prepared;
73
137
  const librarySources = validated.librarySources;
74
138
  const runtimeOptions = {
75
139
  ...runtimeOptionsFromCli(options),
76
140
  ...librarySources ? { librarySources } : {},
77
- stdoutIsMachineContract: json
141
+ stdoutIsMachineContract: machine,
142
+ onActionEvent: ({ nodeId, action, status }) => stream.node(nodeId, action, status)
78
143
  };
144
+ stream.phase("execute");
79
145
  const { result, violation } = await executeWithHealBounds(
80
146
  // validated.recipeFile, not the raw arg: the arg may be a library recipe NAME
81
147
  // that only the resolver knows how to turn into a file.
@@ -84,12 +150,42 @@ async function handleRun({ positional, options }) {
84
150
  target,
85
151
  heal,
86
152
  state,
87
- () => recoverRunInfra(adapter, target, json)
153
+ () => recoverRunInfra(adapter, target, machine),
154
+ (code) => stream.phase("recover", { code })
88
155
  );
89
- if (violation !== null) return emitHealViolation(json, "run", result, violation, state, adapter);
156
+ for (const mutation of state.mutations) stream.mutation(mutation);
157
+ for (const recovery of state.recovered) stream.recovery(recovery);
158
+ if (violation !== null) {
159
+ const userAction = violation.userAction ?? `inspect ${shellQuote(result.summaryPath)} and ${shellQuote(result.tracePath)}; fix the application or recipe failure before retrying`;
160
+ stream.error({
161
+ code: violation.code,
162
+ message: violation.message,
163
+ userAction,
164
+ originalError: violation.originalError ?? null
165
+ });
166
+ return emitHealViolation(jsonOutput, "run", result, violation, state, adapter);
167
+ }
90
168
  const report = writeRunReport(result);
91
169
  const exitCode = result.status === "pass" ? EXIT.ok : EXIT.runtime;
92
- if (json) {
170
+ const failureUserAction = `mm-harness last --target ${shellQuote(target)} --json`;
171
+ if (stream.enabled) {
172
+ if (result.status === "fail") {
173
+ stream.error({
174
+ code: "RECIPE_EXECUTION_FAILED",
175
+ message: "recipe execution failed; inspect the persisted result and evidence paths",
176
+ userAction: failureUserAction
177
+ });
178
+ }
179
+ stream.complete(result.status === "pass" ? "pass" : "fail", exitCode, {
180
+ adapter,
181
+ reportPath: report.path,
182
+ summaryPath: result.summaryPath,
183
+ tracePath: result.tracePath,
184
+ artifactManifestPath: result.artifactManifestPath,
185
+ recovered: state.recovered,
186
+ mutations: state.mutations
187
+ });
188
+ } else if (json) {
93
189
  console.log(
94
190
  JSON.stringify(
95
191
  {
@@ -101,7 +197,8 @@ async function handleRun({ positional, options }) {
101
197
  recovered: state.recovered,
102
198
  mutations: state.mutations,
103
199
  reportPath: report.path,
104
- result
200
+ result,
201
+ ...result.status === "fail" ? { error: { code: "RECIPE_EXECUTION_FAILED", message: "recipe execution failed; inspect the persisted result and evidence paths", userAction: failureUserAction } } : {}
105
202
  },
106
203
  null,
107
204
  2
@@ -116,6 +213,7 @@ async function handleRun({ positional, options }) {
116
213
  }
117
214
  console.log(`${out("label", "report:")} ${out("path", report.path)}`);
118
215
  console.log(`${out("label", "artifacts:")} ${out("path", result.artifactManifestPath)}`);
216
+ if (result.status === "fail") console.error(` Next: ${failureUserAction}`);
119
217
  }
120
218
  return exitCode;
121
219
  } finally {
@@ -193,12 +291,29 @@ function loadFlowCatalogs(librarySources) {
193
291
  function actionUsesCoreController(action) {
194
292
  return typeof action === "string" && action.startsWith("metamask.perps.");
195
293
  }
196
- async function handleRunPlan(recipeArg, options) {
294
+ async function handleRunPlan(recipeArg, options, stream) {
197
295
  const json = optionFlag(options, "json");
296
+ const jsonOutput = json && !stream.enabled;
198
297
  const { adapter, target } = resolveAdapter(options);
298
+ stream.phase("resolve", { adapter, target, recipe: recipeArg });
299
+ stream.phase("validate");
199
300
  const validated = await validateRunRecipeStatic(recipeArg, adapter, options);
200
301
  if (validated.usageError) {
201
- return emitPlanUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
302
+ const userAction = runUsageRecovery(
303
+ validated.usageError.code,
304
+ validated.usageError.message,
305
+ adapter,
306
+ validated.recipeFile
307
+ );
308
+ return emitPlanUsageError(
309
+ jsonOutput,
310
+ stream,
311
+ adapter,
312
+ validated.recipeFile,
313
+ validated.usageError.code,
314
+ validated.usageError.message,
315
+ userAction
316
+ );
202
317
  }
203
318
  const { recipe, recipeFile, findings, errorCount, manifestOk, schemaValid } = validated;
204
319
  const status = errorCount === 0 ? "pass" : "fail";
@@ -252,9 +367,27 @@ async function handleRunPlan(recipeArg, options) {
252
367
  detail: nodeCount === void 0 ? "would execute the recipe nodes" : `would execute ${nodeCount} recipe node(s)`
253
368
  }
254
369
  ];
255
- if (json) {
256
- const payload = { schemaVersion: 1, command: "run", mode: "plan", status, adapter, recipe: recipeFile, findings, plan };
257
- if (status === "fail") payload.error = { code: "RECIPE_VALIDATION_FAILED", message: `recipe validation found ${errorCount} error(s)` };
370
+ const payload = {
371
+ schemaVersion: 1,
372
+ command: "run",
373
+ mode: "plan",
374
+ status,
375
+ adapter,
376
+ recipe: recipeFile,
377
+ findings,
378
+ plan
379
+ };
380
+ if (status === "fail") {
381
+ payload.error = {
382
+ code: "RECIPE_VALIDATION_FAILED",
383
+ message: `recipe validation found ${errorCount} error(s)`,
384
+ userAction: runPlanProbe(adapter, recipeFile)
385
+ };
386
+ }
387
+ if (stream.enabled) {
388
+ if (status === "fail") stream.error(payload.error);
389
+ stream.complete(status, status === "pass" ? EXIT.ok : EXIT.validation, { mode: "plan", adapter, recipe: recipeFile, findings, plan });
390
+ } else if (json) {
258
391
  console.log(JSON.stringify(payload, null, 2));
259
392
  } else {
260
393
  console.log(`plan ${status} \u2014 ${adapter} \u2014 ${recipeFile}`);
@@ -268,52 +401,61 @@ async function handleRunPlan(recipeArg, options) {
268
401
  console.log(` ${finding.severity === "error" ? "\u2717" : "\u26A0"} ${finding.code} ${finding.path} \u2014 ${finding.message}`);
269
402
  }
270
403
  }
404
+ if (status === "fail") console.error(` Next: ${runPlanProbe(adapter, recipeFile)}`);
271
405
  }
272
406
  return status === "pass" ? EXIT.ok : EXIT.validation;
273
407
  }
274
- function emitPlanUsageError(json, adapter, recipeFile, code, message) {
408
+ function emitPlanUsageError(json, stream, adapter, recipeFile, code, message, userAction) {
409
+ stream.error({ code, message, userAction, mode: "plan", adapter, recipe: recipeFile });
275
410
  if (json) {
276
411
  console.log(
277
412
  JSON.stringify(
278
- { schemaVersion: 1, command: "run", mode: "plan", status: "fail", adapter, recipe: recipeFile, error: { code, message } },
413
+ { schemaVersion: 1, command: "run", mode: "plan", status: "fail", adapter, recipe: recipeFile, error: { code, message, userAction } },
279
414
  null,
280
415
  2
281
416
  )
282
417
  );
283
418
  } else {
284
- console.error(`\u2717 run --plan: ${message}`);
419
+ console.error(`\u2717 run --plan: ${message}
420
+ Next: ${userAction}`);
285
421
  }
286
422
  return EXIT.usage;
287
423
  }
288
424
  function runArgLooksLikeRecipeFile(value) {
289
425
  return path.isAbsolute(value) || value.includes("/") || value.includes(path.sep) || value.endsWith(".json") || fs.existsSync(path.resolve(value));
290
426
  }
291
- function emitRunRecipeRunning(json, detail) {
427
+ function emitRunRecipeRunning(json, stream, target, detail) {
292
428
  const message = detail ?? "a recipe is currently running \u2014 refusing to start while another recipe executes.";
429
+ const userAction = `inspect the checkout state with: mm-harness status --target ${shellQuote(target)} --json; retry after the active recipe finishes`;
430
+ stream.error({ code: "RECIPE_RUNNING", message, userAction, recoverable: false });
293
431
  if (json) {
294
- console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message } }, null, 2));
432
+ console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message, userAction } }, null, 2));
295
433
  } else {
296
- console.error(`\u2717 mm-harness: ${message}`);
434
+ console.error(`\u2717 mm-harness: ${message}
435
+ Next: ${userAction}`);
297
436
  }
298
437
  return EXIT.bounded;
299
438
  }
300
- function emitRunUsageError(json, adapter, recipeFile, code, message, userAction) {
439
+ function emitRunUsageError(json, stream, adapter, recipeFile, code, message, userAction) {
440
+ stream.error({ code, message, userAction });
301
441
  if (json) {
302
442
  console.log(
303
443
  JSON.stringify(
304
- { schemaVersion: 1, command: "run", adapter, status: "fail", exitCode: EXIT.usage, recipe: recipeFile, error: { code, message, ...userAction ? { userAction } : {} } },
444
+ { schemaVersion: 1, command: "run", adapter, status: "fail", exitCode: EXIT.usage, recipe: recipeFile, error: { code, message, userAction } },
305
445
  null,
306
446
  2
307
447
  )
308
448
  );
309
449
  } else {
310
450
  console.error(`\u2717 run: ${message}`);
311
- if (userAction) console.error(` Next: ${userAction}`);
451
+ console.error(` Next: ${userAction}`);
312
452
  }
313
453
  return EXIT.usage;
314
454
  }
315
- function emitRunValidationError(json, adapter, recipeFile, findings, errorCount) {
455
+ function emitRunValidationError(json, stream, adapter, recipeFile, findings, errorCount) {
316
456
  const message = `recipe validation found ${errorCount} error(s)`;
457
+ const userAction = runPlanProbe(adapter, recipeFile);
458
+ stream.error({ code: "RECIPE_VALIDATION_FAILED", message, userAction, findings });
317
459
  if (json) {
318
460
  console.log(
319
461
  JSON.stringify(
@@ -327,7 +469,7 @@ function emitRunValidationError(json, adapter, recipeFile, findings, errorCount)
327
469
  mutations: [],
328
470
  recipe: recipeFile,
329
471
  findings,
330
- error: { code: "RECIPE_VALIDATION_FAILED", message }
472
+ error: { code: "RECIPE_VALIDATION_FAILED", message, userAction }
331
473
  },
332
474
  null,
333
475
  2
@@ -338,9 +480,22 @@ function emitRunValidationError(json, adapter, recipeFile, findings, errorCount)
338
480
  for (const finding of findings) {
339
481
  if (finding.severity === "error") console.error(` ${finding.code} ${finding.path} \u2014 ${finding.message}`);
340
482
  }
483
+ console.error(` Next: ${userAction}`);
341
484
  }
342
485
  return EXIT.validation;
343
486
  }
487
+ function runPlanProbe(adapter, recipeFile) {
488
+ return `mm-harness run --plan ${shellQuote(recipeFile)} --adapter ${adapter} --json`;
489
+ }
490
+ function runUsageRecovery(code, message, adapter, recipeFile) {
491
+ const actionCommand = /This is an action, not a recipe\. Use: (mm-harness call .+)\.$/u.exec(message)?.[1];
492
+ if (actionCommand) return actionCommand;
493
+ if (code === "RECIPE_NOT_FOUND") return `mm-harness run --list --adapter ${adapter} --json`;
494
+ if (code === "RECIPE_UNPARSEABLE") {
495
+ return `fix the JSON syntax in ${shellQuote(recipeFile)}, then retry: ${runPlanProbe(adapter, recipeFile)}`;
496
+ }
497
+ return runPlanProbe(adapter, recipeFile);
498
+ }
344
499
  export {
345
500
  handleRun
346
501
  };
@@ -3,6 +3,15 @@ import path from "node:path";
3
3
  import { detectAdapter } from "../harness.js";
4
4
  import { resolveLeafInvoke, shellLeafMissing } from "../leaf-invoke.js";
5
5
  const EXIT = { ok: 0, runtime: 1, usage: 2, infra: 3, bounded: 4, validation: 5 };
6
+ function writeInteractiveProgress(json, message, {
7
+ stdoutIsTTY = Boolean(process.stdout.isTTY),
8
+ stream = process.stderr
9
+ } = {}) {
10
+ if (json || !stdoutIsTTY) return false;
11
+ stream.write(`${message}
12
+ `);
13
+ return true;
14
+ }
6
15
  function parseFlags(argv, booleans) {
7
16
  const positional = [];
8
17
  const options = {};
@@ -262,5 +271,6 @@ export {
262
271
  spawnScriptStreaming,
263
272
  str,
264
273
  targetOf,
265
- usageOut
274
+ usageOut,
275
+ writeInteractiveProgress
266
276
  };
@@ -31,7 +31,7 @@ async function handleStatus({ options }) {
31
31
  if (adapter === "mobile") {
32
32
  const dtResult = applyDeviceTargeting("status", adapter, options, { gate: false, rerun: "" });
33
33
  if ("code" in dtResult) {
34
- return usageOut(json, "status", dtResult.message, "mm-harness status --device <id> or --all-devices");
34
+ return usageOut(json, "status", dtResult.message, dtResult.userAction);
35
35
  }
36
36
  }
37
37
  const next = surface.hints.relaunch;