@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.
- package/CHANGELOG.md +23 -0
- package/README.md +12 -1
- package/dist/cli-commands.js +1 -1
- package/dist/cli.js +2 -0
- package/dist/command-contract.js +441 -0
- package/dist/command-journal.js +225 -0
- package/dist/commands/call.js +40 -17
- package/dist/commands/check.js +9 -3
- package/dist/commands/device-target.js +27 -12
- package/dist/commands/doctor.js +19 -6
- package/dist/commands/fixtures.js +1 -1
- package/dist/commands/last.js +52 -0
- package/dist/commands/launch/index.js +156 -59
- package/dist/commands/manifest.js +147 -9
- package/dist/commands/parse-args.js +2 -0
- package/dist/commands/provision.js +10 -3
- package/dist/commands/run-engine.js +34 -11
- package/dist/commands/run-report.js +12 -3
- package/dist/commands/run.js +194 -39
- package/dist/commands/shared.js +11 -1
- package/dist/commands/status.js +1 -1
- package/dist/commands/stop.js +7 -2
- package/dist/harness.js +16 -4
- package/dist/json-stream.js +57 -0
- package/dist/mm-harness-cli.js +114 -3
- package/dist/run-diagnostics.js +271 -0
- package/dist/runner.js +32 -1
- package/docs/CLI-ERGONOMICS-AUDIT.md +32 -0
- package/docs/CLI-ERGONOMICS-HUMAN-QA.md +104 -0
- package/docs/CLI-SPEC.md +63 -19
- package/docs/MENTAL-MODEL.md +2 -2
- package/docs/UX-PRINCIPLES.md +2 -0
- package/package.json +2 -1
|
@@ -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
|
|
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(
|
|
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
|
|
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";
|
package/dist/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
85
|
+
return emitRunUsageError(jsonOutput, stream, adapter, targetRecipe, dtResult.code, dtResult.message, dtResult.userAction);
|
|
50
86
|
}
|
|
51
|
-
if (recipeRunning(target)) return emitRunRecipeRunning(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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)
|
|
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,
|
|
71
|
-
|
|
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:
|
|
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,
|
|
153
|
+
() => recoverRunInfra(adapter, target, machine),
|
|
154
|
+
(code) => stream.phase("recover", { code })
|
|
88
155
|
);
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
};
|
package/dist/commands/shared.js
CHANGED
|
@@ -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
|
};
|
package/dist/commands/status.js
CHANGED
|
@@ -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,
|
|
34
|
+
return usageOut(json, "status", dtResult.message, dtResult.userAction);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
const next = surface.hints.relaunch;
|