@deeeed/metamask-harness 0.16.0 → 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 +16 -0
- package/README.md +4 -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 +35 -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 +109 -12
- package/dist/commands/parse-args.js +1 -0
- package/dist/commands/provision.js +10 -3
- package/dist/commands/run-engine.js +26 -10
- 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 +110 -3
- 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 +58 -18
- package/docs/MENTAL-MODEL.md +1 -1
- 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}`);
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
optionString,
|
|
32
32
|
isRecord,
|
|
33
33
|
shellQuoteArg,
|
|
34
|
+
targetPath,
|
|
34
35
|
usageError
|
|
35
36
|
} from "./parse-args.js";
|
|
36
37
|
async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}) {
|
|
@@ -66,7 +67,8 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
|
|
|
66
67
|
await validateManifest(manifest);
|
|
67
68
|
const { createMetaMaskRunner } = await import("../runner.js");
|
|
68
69
|
const runner = await createMetaMaskRunner(adapter, manifest, {
|
|
69
|
-
quietStdout: runtimeOptions.stdoutIsMachineContract === true
|
|
70
|
+
quietStdout: runtimeOptions.stdoutIsMachineContract === true,
|
|
71
|
+
onActionEvent: runtimeOptions.onActionEvent
|
|
70
72
|
});
|
|
71
73
|
const runRequest = {
|
|
72
74
|
recipePath: path.resolve(recipe),
|
|
@@ -348,6 +350,7 @@ async function actionInsteadOfRecipeHint(recipeArg, adapter, options) {
|
|
|
348
350
|
const device = optionString(options, "device");
|
|
349
351
|
const actionManifest = optionString(options, "actionManifest");
|
|
350
352
|
const parts = ["mm-harness", "call", matches[0]];
|
|
353
|
+
parts.push("--adapter", adapter, "--target", shellQuoteArg(targetPath(options)));
|
|
351
354
|
if (device) parts.push("--device", shellQuoteArg(device));
|
|
352
355
|
if (actionManifest) parts.push("--action-manifest", shellQuoteArg(actionManifest));
|
|
353
356
|
return ` This is an action, not a recipe. Use: ${parts.join(" ")}.`;
|
|
@@ -405,10 +408,12 @@ async function runOneNode(adapter, action, args, target, actionManifest) {
|
|
|
405
408
|
async function prepareHeal(adapter, target, options, json, prepareOptions = {}) {
|
|
406
409
|
if (recipeRunning(target)) {
|
|
407
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`;
|
|
408
412
|
if (json) {
|
|
409
|
-
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));
|
|
410
414
|
} else {
|
|
411
|
-
console.error(`\u2717 mm-harness: ${msg}
|
|
415
|
+
console.error(`\u2717 mm-harness: ${msg}
|
|
416
|
+
Next: ${userAction}`);
|
|
412
417
|
}
|
|
413
418
|
return EXIT.bounded;
|
|
414
419
|
}
|
|
@@ -421,21 +426,25 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
|
|
|
421
426
|
return EXIT.usage;
|
|
422
427
|
}
|
|
423
428
|
const state = newHealState();
|
|
429
|
+
prepareOptions.onPhase?.("install");
|
|
424
430
|
const ensured = await ensureOverlay(adapter, target, heal, state, json);
|
|
425
431
|
if (!ensured.ok) {
|
|
432
|
+
const userAction = `mm-harness install --adapter ${adapter} --target ${shellQuoteArg(target)}`;
|
|
426
433
|
if (json) {
|
|
427
434
|
console.log(
|
|
428
435
|
JSON.stringify(
|
|
429
|
-
{ 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 } },
|
|
430
437
|
null,
|
|
431
438
|
2
|
|
432
439
|
)
|
|
433
440
|
);
|
|
434
441
|
} else {
|
|
435
|
-
console.error(`\u2717 overlay auto-ensure failed: ${ensured.error}
|
|
442
|
+
console.error(`\u2717 overlay auto-ensure failed: ${ensured.error}
|
|
443
|
+
Next: ${userAction}`);
|
|
436
444
|
}
|
|
437
445
|
return EXIT.infra;
|
|
438
446
|
}
|
|
447
|
+
prepareOptions.onPhase?.("healthcheck");
|
|
439
448
|
if (adapter === "extension") {
|
|
440
449
|
const previousCdpPort = process.env.CDP_PORT;
|
|
441
450
|
const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
|
|
@@ -448,7 +457,9 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
|
|
|
448
457
|
}
|
|
449
458
|
if (explicitWatcherPort) process.env.WATCHER_PORT = explicitWatcherPort;
|
|
450
459
|
try {
|
|
451
|
-
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
|
+
});
|
|
452
463
|
if (current !== null) return current;
|
|
453
464
|
} finally {
|
|
454
465
|
restoreEnv("CDP_PORT", previousCdpPort);
|
|
@@ -457,7 +468,9 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
|
|
|
457
468
|
}
|
|
458
469
|
}
|
|
459
470
|
if (adapter === "mobile" && prepareOptions.skipMobileSourceFreshness !== true) {
|
|
460
|
-
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
|
+
});
|
|
461
474
|
if (current !== null) return current;
|
|
462
475
|
}
|
|
463
476
|
return { state, heal };
|
|
@@ -502,6 +515,7 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
|
|
|
502
515
|
}
|
|
503
516
|
const recoveryCode = "mobile.source_reloaded";
|
|
504
517
|
state.attemptedRecoveries.push(recoveryCode);
|
|
518
|
+
deps.onRecovery?.(recoveryCode);
|
|
505
519
|
if (!json) console.error(`\u2192 Mobile proof preflight: source ${initial.status}; restarting the app before execution`);
|
|
506
520
|
const restart = deps.restart ?? (async () => {
|
|
507
521
|
const result2 = await runOneNode(
|
|
@@ -588,6 +602,7 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
|
|
|
588
602
|
}
|
|
589
603
|
const recoveryCode = tier === "build" ? "extension.runtime_rebuilt" : "extension.runtime_reloaded";
|
|
590
604
|
state.attemptedRecoveries.push(recoveryCode);
|
|
605
|
+
deps.onRecovery?.(recoveryCode);
|
|
591
606
|
if (!json) console.error(`\u2192 Extension proof preflight: ${initial.reasonCode}; ${tier === "build" ? "rebuilding" : "reloading"} before execution`);
|
|
592
607
|
const launch = deps.launch ?? (async (requestedTier) => {
|
|
593
608
|
const { launchExtension } = await import("./launch/extension.js");
|
|
@@ -642,7 +657,7 @@ function readRunFailureText(result) {
|
|
|
642
657
|
return "";
|
|
643
658
|
}
|
|
644
659
|
}
|
|
645
|
-
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) {
|
|
646
661
|
let result = await exec();
|
|
647
662
|
for (; ; ) {
|
|
648
663
|
if (result.status === "pass") return { result, violation: null };
|
|
@@ -651,6 +666,7 @@ async function executeWithHealBounds(exec, adapter, target, heal, state, recover
|
|
|
651
666
|
if (heal === "off") return { result, violation: null };
|
|
652
667
|
const recoveryCode = RUN_RECOVERY_CODE[adapter];
|
|
653
668
|
state.attemptedRecoveries.push(recoveryCode);
|
|
669
|
+
onRecovery(recoveryCode);
|
|
654
670
|
await recover();
|
|
655
671
|
result = await exec();
|
|
656
672
|
if (result.status === "pass") {
|
|
@@ -660,7 +676,7 @@ async function executeWithHealBounds(exec, adapter, target, heal, state, recover
|
|
|
660
676
|
}
|
|
661
677
|
}
|
|
662
678
|
function emitHealViolation(json, command, result, violation, state, adapter) {
|
|
663
|
-
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`;
|
|
664
680
|
if (json) {
|
|
665
681
|
console.log(
|
|
666
682
|
JSON.stringify(
|
|
@@ -680,7 +696,7 @@ function emitHealViolation(json, command, result, violation, state, adapter) {
|
|
|
680
696
|
code: violation.code,
|
|
681
697
|
message: violation.message,
|
|
682
698
|
retryable: false,
|
|
683
|
-
userAction
|
|
699
|
+
userAction,
|
|
684
700
|
originalError: violation.originalError ?? null
|
|
685
701
|
}
|
|
686
702
|
},
|
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;
|
package/dist/commands/stop.js
CHANGED
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
parseArgs,
|
|
6
6
|
optionFlag,
|
|
7
7
|
optionString,
|
|
8
|
-
resolveAdapter
|
|
8
|
+
resolveAdapter,
|
|
9
|
+
shellQuote
|
|
9
10
|
} from "./parse-args.js";
|
|
10
11
|
async function handleStop(argv) {
|
|
11
12
|
const { options } = parseArgs(argv, "stop");
|
|
@@ -22,6 +23,8 @@ async function handleStop(argv) {
|
|
|
22
23
|
if (stop.kind === "headless") {
|
|
23
24
|
return usageOut(json, "stop", stop.message, stop.userAction);
|
|
24
25
|
}
|
|
26
|
+
const next = adapter === "extension" ? `${surface.hints.launch} --target ${shellQuote(target)}` : void 0;
|
|
27
|
+
const userAction = `mm-harness status --target ${shellQuote(target)} --json`;
|
|
25
28
|
if (json) {
|
|
26
29
|
console.log(
|
|
27
30
|
JSON.stringify(
|
|
@@ -33,7 +36,8 @@ async function handleStop(argv) {
|
|
|
33
36
|
status: stop.status === 0 ? "pass" : "fail",
|
|
34
37
|
...stop.signalled !== void 0 ? { signalled: stop.signalled } : {},
|
|
35
38
|
exitCode: stop.status,
|
|
36
|
-
...stop.output ? { output: stop.output } : {}
|
|
39
|
+
...stop.output ? { output: stop.output } : {},
|
|
40
|
+
...stop.status === 0 ? next ? { next } : {} : { error: { code: "DEV_SERVER_STOP_FAILED", message: stop.summary, userAction } }
|
|
37
41
|
},
|
|
38
42
|
null,
|
|
39
43
|
2
|
|
@@ -43,6 +47,7 @@ async function handleStop(argv) {
|
|
|
43
47
|
if (stop.output) process.stderr.write(`${stop.output}
|
|
44
48
|
`);
|
|
45
49
|
console.error(`${color(stop.status === 0 ? "ok" : "err", stop.status === 0 ? "\u2713" : "\u2717")} ${stop.summary}`);
|
|
50
|
+
if (stop.status !== 0 || next) console.error(` Next: ${stop.status === 0 ? next : userAction}`);
|
|
46
51
|
}
|
|
47
52
|
return stop.status;
|
|
48
53
|
}
|