@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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { recipeRuntimeDir } from "./paths.js";
|
|
5
|
+
const COMMAND_JOURNAL_FILE = "last-command.json";
|
|
6
|
+
const JOURNALED_COMMANDS = /* @__PURE__ */ new Set([
|
|
7
|
+
"call",
|
|
8
|
+
"check",
|
|
9
|
+
"checklist",
|
|
10
|
+
"cleanup",
|
|
11
|
+
"doctor",
|
|
12
|
+
"fixtures",
|
|
13
|
+
"install",
|
|
14
|
+
"launch",
|
|
15
|
+
"provision",
|
|
16
|
+
"recipe-quality",
|
|
17
|
+
"run",
|
|
18
|
+
"stop",
|
|
19
|
+
"verify"
|
|
20
|
+
]);
|
|
21
|
+
const SENSITIVE_KEY = /(?:auth|credential|mnemonic|pass(?:word)?|private[-_]?key|secret|seed|srp|token)/iu;
|
|
22
|
+
const EVIDENCE_OPTIONS = /* @__PURE__ */ new Set(["--artifacts-dir", "--out", "--output"]);
|
|
23
|
+
async function withCommandJournal(command, argv, execute) {
|
|
24
|
+
if (!JOURNALED_COMMANDS.has(command)) return execute();
|
|
25
|
+
const handle = tryBeginCommandJournal(command, argv);
|
|
26
|
+
try {
|
|
27
|
+
const exitCode = await execute();
|
|
28
|
+
tryFinishCommandJournal(handle, exitCode);
|
|
29
|
+
return exitCode;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
tryFinishCommandJournal(handle, 1);
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function commandJournalPath(target, runtimeDir) {
|
|
36
|
+
const relative = runtimeDir ?? recipeRuntimeDir();
|
|
37
|
+
assertRelativeRuntimeDir(relative);
|
|
38
|
+
return path.join(path.resolve(target), relative, COMMAND_JOURNAL_FILE);
|
|
39
|
+
}
|
|
40
|
+
function readCommandJournal(target, runtimeDir) {
|
|
41
|
+
const file = commandJournalPath(target, runtimeDir);
|
|
42
|
+
let descriptor;
|
|
43
|
+
try {
|
|
44
|
+
const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
45
|
+
descriptor = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
|
|
46
|
+
const descriptorStat = fs.fstatSync(descriptor, { bigint: true });
|
|
47
|
+
const pathStat = fs.lstatSync(file, { bigint: true });
|
|
48
|
+
if (!descriptorStat.isFile() || !pathStat.isFile() || pathStat.isSymbolicLink() || pathStat.dev === 0n && pathStat.ino === 0n || descriptorStat.dev !== pathStat.dev || descriptorStat.ino !== pathStat.ino) {
|
|
49
|
+
return { file, record: null };
|
|
50
|
+
}
|
|
51
|
+
const parsed = JSON.parse(fs.readFileSync(descriptor, "utf8"));
|
|
52
|
+
return isCommandJournalRecord(parsed) ? { file, record: parsed } : { file, record: null };
|
|
53
|
+
} catch {
|
|
54
|
+
return { file, record: null };
|
|
55
|
+
} finally {
|
|
56
|
+
if (descriptor !== void 0) fs.closeSync(descriptor);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function redactCommandArgs(argv) {
|
|
60
|
+
const redacted = [];
|
|
61
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
62
|
+
const token = argv[index] ?? "";
|
|
63
|
+
const equals = token.indexOf("=");
|
|
64
|
+
if (token.startsWith("--") && equals !== -1) {
|
|
65
|
+
const option = token.slice(0, equals);
|
|
66
|
+
const value = token.slice(equals + 1);
|
|
67
|
+
redacted.push(`${option}=${option === "--arg" ? redactAssignment(value) : SENSITIVE_KEY.test(option) ? "<redacted>" : redactUrl(value)}`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (token.startsWith("--") && SENSITIVE_KEY.test(token)) {
|
|
71
|
+
redacted.push(token);
|
|
72
|
+
const value = argv[index + 1];
|
|
73
|
+
if (value !== void 0 && !value.startsWith("--")) {
|
|
74
|
+
redacted.push("<redacted>");
|
|
75
|
+
index += 1;
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (token === "--arg") {
|
|
80
|
+
redacted.push(token);
|
|
81
|
+
const value = argv[index + 1];
|
|
82
|
+
if (value !== void 0) {
|
|
83
|
+
redacted.push(redactAssignment(value));
|
|
84
|
+
index += 1;
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
redacted.push(redactAssignment(redactUrl(token)));
|
|
89
|
+
}
|
|
90
|
+
return redacted;
|
|
91
|
+
}
|
|
92
|
+
function tryBeginCommandJournal(command, argv) {
|
|
93
|
+
try {
|
|
94
|
+
const target = invocationTarget(argv);
|
|
95
|
+
if (!fs.existsSync(target)) return void 0;
|
|
96
|
+
const runtimeDir = optionValue(argv, "--runtime-dir");
|
|
97
|
+
const file = commandJournalPath(target, runtimeDir);
|
|
98
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
99
|
+
const record = {
|
|
100
|
+
schemaVersion: 1,
|
|
101
|
+
command,
|
|
102
|
+
args: redactCommandArgs(argv.slice(1)),
|
|
103
|
+
target,
|
|
104
|
+
verdict: "running",
|
|
105
|
+
exitCode: null,
|
|
106
|
+
evidencePaths: evidencePaths(argv),
|
|
107
|
+
startedAt: now,
|
|
108
|
+
finishedAt: null
|
|
109
|
+
};
|
|
110
|
+
writeAtomic(file, record);
|
|
111
|
+
return { file, record };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
process.stderr.write(`mm-harness: resumability journal unavailable: ${errorMessage(error)}
|
|
114
|
+
`);
|
|
115
|
+
return void 0;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function tryFinishCommandJournal(handle, exitCode) {
|
|
119
|
+
if (!handle) return;
|
|
120
|
+
try {
|
|
121
|
+
writeAtomic(handle.file, {
|
|
122
|
+
...handle.record,
|
|
123
|
+
verdict: exitCode === 0 ? "pass" : "fail",
|
|
124
|
+
exitCode,
|
|
125
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
process.stderr.write(`mm-harness: resumability journal could not finalize: ${errorMessage(error)}
|
|
129
|
+
`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function writeAtomic(file, record) {
|
|
133
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
134
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
135
|
+
let descriptor;
|
|
136
|
+
try {
|
|
137
|
+
descriptor = fs.openSync(temporary, "wx", 384);
|
|
138
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(record, null, 2)}
|
|
139
|
+
`);
|
|
140
|
+
fs.fsyncSync(descriptor);
|
|
141
|
+
fs.closeSync(descriptor);
|
|
142
|
+
descriptor = void 0;
|
|
143
|
+
fs.renameSync(temporary, file);
|
|
144
|
+
} finally {
|
|
145
|
+
if (descriptor !== void 0) fs.closeSync(descriptor);
|
|
146
|
+
fs.rmSync(temporary, { force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function invocationTarget(argv) {
|
|
150
|
+
return path.resolve(optionValue(argv, "--target") ?? optionValue(argv, "--project-root") ?? process.cwd());
|
|
151
|
+
}
|
|
152
|
+
function evidencePaths(argv) {
|
|
153
|
+
const values = /* @__PURE__ */ new Set();
|
|
154
|
+
for (const option of EVIDENCE_OPTIONS) {
|
|
155
|
+
const value = optionValue(argv, option);
|
|
156
|
+
if (value) values.add(path.resolve(value));
|
|
157
|
+
}
|
|
158
|
+
return [...values];
|
|
159
|
+
}
|
|
160
|
+
function optionValue(argv, option) {
|
|
161
|
+
const divider = argv.indexOf("--");
|
|
162
|
+
const end = divider === -1 ? argv.length : divider;
|
|
163
|
+
for (let index = 1; index < end; index += 1) {
|
|
164
|
+
const token = argv[index] ?? "";
|
|
165
|
+
if (token === option) {
|
|
166
|
+
const value = argv[index + 1];
|
|
167
|
+
return value && !value.startsWith("--") ? value : void 0;
|
|
168
|
+
}
|
|
169
|
+
if (token.startsWith(`${option}=`)) return token.slice(option.length + 1);
|
|
170
|
+
}
|
|
171
|
+
return void 0;
|
|
172
|
+
}
|
|
173
|
+
function redactAssignment(token) {
|
|
174
|
+
const equals = token.indexOf("=");
|
|
175
|
+
if (equals <= 0) return token;
|
|
176
|
+
const key = token.slice(0, equals);
|
|
177
|
+
const value = token.slice(equals + 1);
|
|
178
|
+
return `${key}=${SENSITIVE_KEY.test(key) ? "<redacted>" : redactValue(value)}`;
|
|
179
|
+
}
|
|
180
|
+
function redactUrl(token) {
|
|
181
|
+
return token.replace(/(\w+:\/\/)[^/@\s:]+:[^/@\s]+@/gu, "$1<redacted>@");
|
|
182
|
+
}
|
|
183
|
+
function redactValue(value) {
|
|
184
|
+
try {
|
|
185
|
+
return JSON.stringify(redactStructuredValue(JSON.parse(value)));
|
|
186
|
+
} catch {
|
|
187
|
+
return value.replace(
|
|
188
|
+
/((?:auth|credential|mnemonic|pass(?:word)?|private[-_]?key|secret|seed|srp|token)=)[^\s]+/giu,
|
|
189
|
+
"$1<redacted>"
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function redactStructuredValue(value) {
|
|
194
|
+
if (Array.isArray(value)) return value.map(redactStructuredValue);
|
|
195
|
+
if (!value || typeof value !== "object") return value;
|
|
196
|
+
return Object.fromEntries(
|
|
197
|
+
Object.entries(value).map(([key, entry]) => [
|
|
198
|
+
key,
|
|
199
|
+
SENSITIVE_KEY.test(key) ? "<redacted>" : redactStructuredValue(entry)
|
|
200
|
+
])
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
function assertRelativeRuntimeDir(value) {
|
|
204
|
+
if (!value || path.isAbsolute(value) || !/^[A-Za-z0-9._/-]+$/u.test(value)) {
|
|
205
|
+
throw new Error(`--runtime-dir must be a safe relative path: ${value}`);
|
|
206
|
+
}
|
|
207
|
+
if (value.split("/").some((part) => !part || part === "." || part === "..")) {
|
|
208
|
+
throw new Error(`--runtime-dir contains an unsafe path component: ${value}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function isCommandJournalRecord(value) {
|
|
212
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
213
|
+
const record = value;
|
|
214
|
+
return record.schemaVersion === 1 && typeof record.command === "string" && Array.isArray(record.args) && record.args.every((entry) => typeof entry === "string") && typeof record.target === "string" && (record.verdict === "running" || record.verdict === "pass" || record.verdict === "fail") && (record.exitCode === null || typeof record.exitCode === "number") && Array.isArray(record.evidencePaths) && record.evidencePaths.every((entry) => typeof entry === "string") && typeof record.startedAt === "string" && (record.finishedAt === null || typeof record.finishedAt === "string");
|
|
215
|
+
}
|
|
216
|
+
function errorMessage(error) {
|
|
217
|
+
return error instanceof Error ? error.message : String(error);
|
|
218
|
+
}
|
|
219
|
+
export {
|
|
220
|
+
COMMAND_JOURNAL_FILE,
|
|
221
|
+
commandJournalPath,
|
|
222
|
+
readCommandJournal,
|
|
223
|
+
redactCommandArgs,
|
|
224
|
+
withCommandJournal
|
|
225
|
+
};
|
package/dist/commands/call.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
optionString,
|
|
16
16
|
resolveAdapter,
|
|
17
17
|
runtimeOptionsFromCli,
|
|
18
|
+
shellQuote,
|
|
18
19
|
usageError
|
|
19
20
|
} from "./parse-args.js";
|
|
20
21
|
import { getAdapterSurface } from "../adapters/surface.js";
|
|
@@ -59,7 +60,13 @@ async function handleCall(argv) {
|
|
|
59
60
|
}
|
|
60
61
|
const message = `call requires <action>. Example: ${example}`;
|
|
61
62
|
const userAction = `${example} # see the vocabulary: ${discovery}`;
|
|
62
|
-
if (json) console.log(JSON.stringify({
|
|
63
|
+
if (json) console.log(JSON.stringify({
|
|
64
|
+
schemaVersion: 1,
|
|
65
|
+
command: "call",
|
|
66
|
+
status: "fail",
|
|
67
|
+
error: { code: "CLI_MISSING_POSITIONAL", message, userAction },
|
|
68
|
+
exitCode: EXIT.usage
|
|
69
|
+
}, null, 2));
|
|
63
70
|
else console.error(`${message}
|
|
64
71
|
See the vocabulary: ${discovery}`);
|
|
65
72
|
return EXIT.usage;
|
|
@@ -76,16 +83,19 @@ async function handleCall(argv) {
|
|
|
76
83
|
getAdapterSurface(adapter).resolveSlotPorts(target);
|
|
77
84
|
const dtResult = applyDeviceTargeting("call", adapter, options, { gate: true, rerun: "" });
|
|
78
85
|
if ("code" in dtResult) {
|
|
79
|
-
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
|
|
80
|
-
else console.error(`\u2717 call: ${dtResult.message}
|
|
86
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, error: { code: dtResult.code, message: dtResult.message, userAction: dtResult.userAction } }, null, 2));
|
|
87
|
+
else console.error(`\u2717 call: ${dtResult.message}
|
|
88
|
+
Next: ${dtResult.userAction}`);
|
|
81
89
|
return EXIT.usage;
|
|
82
90
|
}
|
|
83
91
|
if (recipeRunning(target)) {
|
|
84
92
|
const msg = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
|
|
93
|
+
const userAction = `inspect the checkout state with: mm-harness status --target ${shellQuote(target)} --json; retry after the active recipe finishes`;
|
|
85
94
|
if (json) {
|
|
86
|
-
console.log(JSON.stringify({ schemaVersion: 1, command: "call", status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg } }, null, 2));
|
|
95
|
+
console.log(JSON.stringify({ schemaVersion: 1, command: "call", status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg, userAction } }, null, 2));
|
|
87
96
|
} else {
|
|
88
|
-
console.error(`\u2717 mm-harness call: ${msg}
|
|
97
|
+
console.error(`\u2717 mm-harness call: ${msg}
|
|
98
|
+
Next: ${userAction}`);
|
|
89
99
|
}
|
|
90
100
|
return EXIT.bounded;
|
|
91
101
|
}
|
|
@@ -95,16 +105,19 @@ async function handleCall(argv) {
|
|
|
95
105
|
const names = getRecipeActionManifestActionNames(manifest);
|
|
96
106
|
const resolution = resolveActionName(shortName, names);
|
|
97
107
|
if (resolution.status === "unknown") {
|
|
98
|
-
const message =
|
|
99
|
-
|
|
100
|
-
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_UNKNOWN", message } }, null, 2));
|
|
101
|
-
else console.error(message
|
|
108
|
+
const message = `unknown action "${shortName}" for the ${adapter} adapter.`;
|
|
109
|
+
const userAction = `mm-harness actions --adapter ${adapter} --json`;
|
|
110
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
|
|
111
|
+
else console.error(`\u2717 call: ${message}
|
|
112
|
+
Next: ${userAction}`);
|
|
102
113
|
return EXIT.usage;
|
|
103
114
|
}
|
|
104
115
|
if (resolution.status === "ambiguous") {
|
|
105
|
-
const message =
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
const message = `"${shortName}" is ambiguous: ${resolution.candidates.join(", ")} \u2014 use the full name.`;
|
|
117
|
+
const userAction = `mm-harness actions --action ${resolution.candidates[0]} --adapter ${adapter} --json`;
|
|
118
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_AMBIGUOUS", message, candidates: resolution.candidates, userAction } }, null, 2));
|
|
119
|
+
else console.error(`\u2717 call: ${message}
|
|
120
|
+
Next: ${userAction}`);
|
|
108
121
|
return EXIT.usage;
|
|
109
122
|
}
|
|
110
123
|
const resolvedAction = resolution.resolved;
|
|
@@ -121,11 +134,13 @@ async function handleCall(argv) {
|
|
|
121
134
|
const recipe = synthesizeOneNodeRecipe(resolvedAction, args);
|
|
122
135
|
const validation = await validateRecipeAdapterAware(recipe, manifest);
|
|
123
136
|
if (validation.status === "invalid") {
|
|
124
|
-
const message =
|
|
125
|
-
|
|
137
|
+
const message = `call ${resolvedAction}: recipe validation failed`;
|
|
138
|
+
const userAction = `mm-harness actions --action ${resolvedAction} --adapter ${adapter} --json`;
|
|
139
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, resolvedAction, args, findings: validation.findings, error: { code: "RECIPE_VALIDATION_FAILED", message, userAction } }, null, 2));
|
|
126
140
|
else {
|
|
127
|
-
console.error(message);
|
|
141
|
+
console.error(`\u2717 ${message}`);
|
|
128
142
|
for (const finding of validation.findings) console.error(` ${finding.code} ${finding.path} \u2014 ${finding.message}`);
|
|
143
|
+
console.error(` Next: ${userAction}`);
|
|
129
144
|
}
|
|
130
145
|
return EXIT.validation;
|
|
131
146
|
}
|
|
@@ -160,6 +175,7 @@ async function handleCall(argv) {
|
|
|
160
175
|
);
|
|
161
176
|
if (violation !== null) return emitHealViolation(json, "call", result, violation, state, adapter);
|
|
162
177
|
const callOutput = readCallOutput(result.tracePath);
|
|
178
|
+
const failureUserAction = `mm-harness last --target ${shellQuote(target)} --json`;
|
|
163
179
|
if (json) {
|
|
164
180
|
console.log(
|
|
165
181
|
JSON.stringify(
|
|
@@ -174,10 +190,13 @@ async function handleCall(argv) {
|
|
|
174
190
|
summaryPath: result.summaryPath,
|
|
175
191
|
tracePath: result.tracePath,
|
|
176
192
|
artifactManifestPath: result.artifactManifestPath,
|
|
193
|
+
...result.diagnosticsPath ? { diagnosticsPath: result.diagnosticsPath } : {},
|
|
194
|
+
...result.sideFindings ? { sideFindings: result.sideFindings } : {},
|
|
177
195
|
...callOutput !== void 0 ? { output: callOutput } : {},
|
|
178
196
|
recovered: state.recovered,
|
|
179
197
|
mutations: state.mutations,
|
|
180
|
-
exitCode: result.status === "pass" ? EXIT.ok : EXIT.runtime
|
|
198
|
+
exitCode: result.status === "pass" ? EXIT.ok : EXIT.runtime,
|
|
199
|
+
...result.status === "fail" ? { error: { code: "ACTION_EXECUTION_FAILED", message: `${resolvedAction} failed; inspect the persisted result and evidence paths`, userAction: failureUserAction } } : {}
|
|
181
200
|
},
|
|
182
201
|
null,
|
|
183
202
|
2
|
|
@@ -187,12 +206,16 @@ async function handleCall(argv) {
|
|
|
187
206
|
const rendered = callOutput !== void 0 ? `
|
|
188
207
|
Result:
|
|
189
208
|
${formatCallOutput(callOutput)}` : "";
|
|
209
|
+
const sideFindingTotal = result.sideFindings?.counts.total ?? 0;
|
|
190
210
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
211
|
+
const sideFindings = sideFindingTotal > 0 ? `${out("label", "Side findings:")} REVIEW ${sideFindingTotal} distinct application warning/error event(s); see ${out("path", result.diagnosticsPath ?? "diagnostics.json")} (non-blocking)
|
|
212
|
+
` : "";
|
|
191
213
|
console.log(
|
|
192
214
|
`${out("label", "call")} ${out("cmd", resolvedAction)}: ${out(result.status === "pass" ? "ok" : "err", result.status)}${rendered ? `
|
|
193
215
|
${out("label", "Result:")}
|
|
194
216
|
${formatCallOutput(callOutput)}` : ""}
|
|
195
|
-
|
|
217
|
+
` + sideFindings + `${out("label", "Artifacts:")} ${out("path", result.artifactManifestPath)}` + (result.status === "fail" ? `
|
|
218
|
+
Next: ${failureUserAction}` : "")
|
|
196
219
|
);
|
|
197
220
|
}
|
|
198
221
|
return result.status === "pass" ? EXIT.ok : EXIT.runtime;
|
package/dist/commands/check.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { detectAdapter } from "../harness.js";
|
|
5
5
|
import { EXIT, usageOut } from "./shared.js";
|
|
6
|
-
import { optionFlag, optionString, parseArgs } from "./parse-args.js";
|
|
6
|
+
import { optionFlag, optionString, parseArgs, shellQuote } from "./parse-args.js";
|
|
7
7
|
const USAGE = "mm-harness check diff [--fix] [--target <repo>] [--adapter mobile|extension|core] [--base <ref>] [--profile fast|full] [--artifacts-dir <dir>] [--json]";
|
|
8
8
|
const JS_TS_EXT_RE = /\.(?:c|m)?jsx?$|\.tsx?$/u;
|
|
9
9
|
const FORMAT_EXT_RE = /\.(?:c|m)?jsx?$|\.tsx?$|\.json$|\.md$|\.css$|\.scss$|\.ya?ml$/u;
|
|
@@ -111,6 +111,7 @@ async function handleCheck(argv) {
|
|
|
111
111
|
const status = [...fixes, ...checks].some((check) => check.status === "fail") ? "fail" : "pass";
|
|
112
112
|
const exitCode = status === "pass" ? EXIT.ok : EXIT.validation;
|
|
113
113
|
const guidance = policyFailed ? ["Remove the newly added eslint-disable directive(s). Fix the underlying violation with existing named styles, project tokens, or another established source pattern; then rerun mm-harness check diff [--fix]. Other checks were skipped until this policy failure is removed."] : checks.some((check) => check.id === "eslint" && check.status === "fail") ? ["Fix the reported violations in changed source using existing project patterns and tokens; do not suppress rules, hide violations behind indirection, weaken configuration, or bypass check diff. Then rerun mm-harness check diff [--fix]."] : [];
|
|
114
|
+
const failureUserAction = guidance[0] ?? `inspect ${path.join(artifactsDir, "validation-summary.json")}, fix the failing check, then retry: mm-harness check diff --target ${shellQuote(target)} --base ${shellQuote(baseRef)} --profile ${profile} --json`;
|
|
114
115
|
const summary = {
|
|
115
116
|
schemaVersion: 1,
|
|
116
117
|
command: "check",
|
|
@@ -129,6 +130,7 @@ async function handleCheck(argv) {
|
|
|
129
130
|
fixes,
|
|
130
131
|
checks,
|
|
131
132
|
guidance,
|
|
133
|
+
...status === "fail" ? { error: { code: "CHECK_DIFF_FAILED", message: "one or more bounded checks failed", userAction: failureUserAction } } : {},
|
|
132
134
|
artifacts: {
|
|
133
135
|
dir: artifactsDir,
|
|
134
136
|
summaryJson: path.join(artifactsDir, "validation-summary.json"),
|
|
@@ -518,10 +520,14 @@ function renderHuman(summary) {
|
|
|
518
520
|
if (check.status === "fail" && check.logPath) console.log(` log: ${check.logPath}`);
|
|
519
521
|
}
|
|
520
522
|
for (const item of summary.guidance) console.log(` Next: ${item}`);
|
|
523
|
+
if (summary.guidance.length === 0 && summary.status === "fail" && summary.error?.userAction) {
|
|
524
|
+
console.log(` Next: ${summary.error.userAction}`);
|
|
525
|
+
}
|
|
521
526
|
console.log(`Artifacts: ${summary.artifacts.summaryJson}`);
|
|
522
527
|
}
|
|
523
528
|
function failEnvelope(input) {
|
|
524
529
|
const exitCode = EXIT.usage;
|
|
530
|
+
const userAction = USAGE;
|
|
525
531
|
const body = {
|
|
526
532
|
schemaVersion: 1,
|
|
527
533
|
command: "check",
|
|
@@ -534,11 +540,11 @@ function failEnvelope(input) {
|
|
|
534
540
|
baseRef: input.baseRef,
|
|
535
541
|
baseSource: "unknown",
|
|
536
542
|
artifacts: { dir: input.artifactsDir },
|
|
537
|
-
error: { code: input.code, message: input.message }
|
|
543
|
+
error: { code: input.code, message: input.message, userAction }
|
|
538
544
|
};
|
|
539
545
|
if (input.json) console.log(JSON.stringify(body, null, 2));
|
|
540
546
|
else console.error(`\u2717 check diff: ${input.message}
|
|
541
|
-
Next: ${
|
|
547
|
+
Next: ${userAction}`);
|
|
542
548
|
return exitCode;
|
|
543
549
|
}
|
|
544
550
|
export {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { listConnectedDevices } from "../devices.js";
|
|
3
|
-
import { optionString } from "./parse-args.js";
|
|
3
|
+
import { optionString, shellQuoteArg, targetPath } from "./parse-args.js";
|
|
4
4
|
function normalizeDeviceName(value) {
|
|
5
5
|
return value.replace(/_/gu, " ").trim();
|
|
6
6
|
}
|
|
@@ -75,6 +75,12 @@ function configuredPinForPlatform(platform) {
|
|
|
75
75
|
function oppositePlatform(platform) {
|
|
76
76
|
return platform === "ios" ? "android" : "ios";
|
|
77
77
|
}
|
|
78
|
+
function deviceProbe(options) {
|
|
79
|
+
return `mm-harness status --target ${shellQuoteArg(targetPath(options))} --all-devices --json`;
|
|
80
|
+
}
|
|
81
|
+
function deviceRecovery(options, rerun) {
|
|
82
|
+
return rerun ? `${deviceProbe(options)}; then retry: ${rerun}` : deviceProbe(options);
|
|
83
|
+
}
|
|
78
84
|
function applyDeviceTargeting(command, adapter, options, opts) {
|
|
79
85
|
const device = optionString(options, "device");
|
|
80
86
|
const platformPreference = mobilePlatformPreference(options);
|
|
@@ -84,7 +90,8 @@ function applyDeviceTargeting(command, adapter, options, opts) {
|
|
|
84
90
|
ok: false,
|
|
85
91
|
code: "DEVICE_WRONG_ADAPTER",
|
|
86
92
|
message: `--device is only supported on the mobile adapter (the ${adapter} adapter has no device to target).
|
|
87
|
-
Drop --device, or run this ${command} inside a metamask-mobile checkout
|
|
93
|
+
Drop --device, or run this ${command} inside a metamask-mobile checkout.`,
|
|
94
|
+
userAction: `remove --device; ${adapter} has no device target`
|
|
88
95
|
};
|
|
89
96
|
}
|
|
90
97
|
return { ok: true };
|
|
@@ -97,7 +104,8 @@ function applyDeviceTargeting(command, adapter, options, opts) {
|
|
|
97
104
|
ok: false,
|
|
98
105
|
code: "DEVICE_PLATFORM_CONFLICT",
|
|
99
106
|
message: `--device ${device} resolves to ${matched.platform}, but --platform ${platformPreference} was requested.
|
|
100
|
-
To use this device intentionally, drop --platform ${platformPreference} or choose a ${platformPreference} device
|
|
107
|
+
To use this device intentionally, drop --platform ${platformPreference} or choose a ${platformPreference} device.`,
|
|
108
|
+
userAction: deviceRecovery(options, opts.rerun)
|
|
101
109
|
};
|
|
102
110
|
}
|
|
103
111
|
if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
|
|
@@ -126,7 +134,8 @@ function applyDeviceTargeting(command, adapter, options, opts) {
|
|
|
126
134
|
ok: false,
|
|
127
135
|
code: "DEVICE_NAME_AMBIGUOUS",
|
|
128
136
|
message: `device name '${device}' is ambiguous; use the id:
|
|
129
|
-
` + byName.map((d) => ` - ${d.id}${d.name ? ` (${d.name})` : ""} [${d.state}] ${d.platform}`).join("\n")
|
|
137
|
+
` + byName.map((d) => ` - ${d.id}${d.name ? ` (${d.name})` : ""} [${d.state}] ${d.platform}`).join("\n"),
|
|
138
|
+
userAction: deviceRecovery(options, opts.rerun)
|
|
130
139
|
};
|
|
131
140
|
}
|
|
132
141
|
return {
|
|
@@ -134,7 +143,8 @@ function applyDeviceTargeting(command, adapter, options, opts) {
|
|
|
134
143
|
code: "DEVICE_NOT_FOUND",
|
|
135
144
|
message: `--device ${device} did not match any connected device.
|
|
136
145
|
` + (connected2.length > 0 ? ` Connected devices:
|
|
137
|
-
${formatConnectedDevices(connected2)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`)
|
|
146
|
+
${formatConnectedDevices(connected2)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`),
|
|
147
|
+
userAction: deviceRecovery(options, opts.rerun)
|
|
138
148
|
};
|
|
139
149
|
}
|
|
140
150
|
const connected = listConnectedDevices();
|
|
@@ -158,7 +168,8 @@ ${formatConnectedDevices(connected2)}` : ` No devices are currently connected (
|
|
|
158
168
|
message: `--platform ${platformPreference} conflicts with the current slot-selected device.
|
|
159
169
|
Selected devices:
|
|
160
170
|
${formatConnectedDevices(selectedTargetable2)}
|
|
161
|
-
To use a different connected device intentionally, pass --device <id
|
|
171
|
+
To use a different connected device intentionally, pass --device <id>.`,
|
|
172
|
+
userAction: deviceRecovery(options, opts.rerun)
|
|
162
173
|
};
|
|
163
174
|
}
|
|
164
175
|
if (!configuredPin && oppositePin) {
|
|
@@ -167,7 +178,8 @@ ${formatConnectedDevices(selectedTargetable2)}
|
|
|
167
178
|
code: "DEVICE_PLATFORM_CONFLICT",
|
|
168
179
|
message: `--platform ${platformPreference} conflicts with the configured slot target.
|
|
169
180
|
Configured ${oppositePlatform(platformPreference)} target: ${oppositePin}
|
|
170
|
-
To use a different connected device intentionally, pass --device <id
|
|
181
|
+
To use a different connected device intentionally, pass --device <id>.`,
|
|
182
|
+
userAction: deviceRecovery(options, opts.rerun)
|
|
171
183
|
};
|
|
172
184
|
}
|
|
173
185
|
if (configuredPin) {
|
|
@@ -178,8 +190,8 @@ ${formatConnectedDevices(selectedTargetable2)}
|
|
|
178
190
|
message: `--platform ${platformPreference} matches the configured slot target, but that target is not ready.
|
|
179
191
|
Configured target: ${configuredPin}
|
|
180
192
|
Connected devices:
|
|
181
|
-
${connected.length > 0 ? formatConnectedDevices(connected) : " - none"}
|
|
182
|
-
|
|
193
|
+
${connected.length > 0 ? formatConnectedDevices(connected) : " - none"}`,
|
|
194
|
+
userAction: `mm-harness launch ${platformPreference} --target ${shellQuoteArg(targetPath(options))}`
|
|
183
195
|
};
|
|
184
196
|
}
|
|
185
197
|
if (platformTargetable.length === 1) {
|
|
@@ -195,7 +207,8 @@ ${connected.length > 0 ? formatConnectedDevices(connected) : " - none"}
|
|
|
195
207
|
code: "DEVICE_PLATFORM_NOT_FOUND",
|
|
196
208
|
message: `--platform ${platformPreference} did not match any ready connected device.
|
|
197
209
|
` + (connected.length > 0 ? ` Connected devices:
|
|
198
|
-
${formatConnectedDevices(connected)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`)
|
|
210
|
+
${formatConnectedDevices(connected)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`),
|
|
211
|
+
userAction: deviceRecovery(options, opts.rerun)
|
|
199
212
|
};
|
|
200
213
|
}
|
|
201
214
|
return {
|
|
@@ -205,7 +218,8 @@ ${formatConnectedDevices(connected)}` : ` No devices are currently connected (a
|
|
|
205
218
|
Connected devices:
|
|
206
219
|
${formatConnectedDevices(connected)}
|
|
207
220
|
Add --device <id> to disambiguate, e.g.:${platformTargetable.map((d) => `
|
|
208
|
-
--device ${d.id}`).join("")}
|
|
221
|
+
--device ${d.id}`).join("")}`,
|
|
222
|
+
userAction: deviceRecovery(options, opts.rerun)
|
|
209
223
|
};
|
|
210
224
|
}
|
|
211
225
|
const selectedTargetable = targetable.filter(deviceSelected);
|
|
@@ -224,7 +238,8 @@ ${formatConnectedDevices(connected)}
|
|
|
224
238
|
Connected devices:
|
|
225
239
|
${formatConnectedDevices(connected)}
|
|
226
240
|
` + (selectedTargetable.length > 1 ? ` The current slot context selects more than one target; fix the slot device pins or add --device <id>.` : ` Add --device <id> to disambiguate, e.g.:${targetable.map((d) => `
|
|
227
|
-
--device ${d.id}`).join("")}`)
|
|
241
|
+
--device ${d.id}`).join("")}`),
|
|
242
|
+
userAction: deviceRecovery(options, opts.rerun)
|
|
228
243
|
};
|
|
229
244
|
}
|
|
230
245
|
return { ok: true };
|
package/dist/commands/doctor.js
CHANGED
|
@@ -81,7 +81,7 @@ async function handleDoctor({ options }) {
|
|
|
81
81
|
surface.resolveSlotPorts(target);
|
|
82
82
|
const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
|
|
83
83
|
if ("code" in dtResult) {
|
|
84
|
-
return usageOut(json, "doctor", dtResult.message,
|
|
84
|
+
return usageOut(json, "doctor", dtResult.message, dtResult.userAction);
|
|
85
85
|
}
|
|
86
86
|
applyDoctorRuntimePorts(options);
|
|
87
87
|
const lock = acquireCheckoutLock(target, "doctor-fix");
|
|
@@ -98,7 +98,14 @@ async function handleDoctor({ options }) {
|
|
|
98
98
|
const result2 = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
|
|
99
99
|
const nextActions = doctorFixNextActions(failed, result2.fixture, adapter, target);
|
|
100
100
|
const status = result2.status === "pass" && failed.length === 0 ? "pass" : "fail";
|
|
101
|
-
|
|
101
|
+
const retryDoctor = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
|
|
102
|
+
const fixUserAction = failed.includes("wallet-fixture") ? `choose one wallet fixture source from nextActions, run it, then retry: ${retryDoctor}` : nextActions[0] ?? retryDoctor;
|
|
103
|
+
const error = status === "fail" ? {
|
|
104
|
+
code: "DOCTOR_FIX_INCOMPLETE",
|
|
105
|
+
message: "doctor --fix could not make every required check pass",
|
|
106
|
+
userAction: fixUserAction
|
|
107
|
+
} : void 0;
|
|
108
|
+
if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, nextActions, ...error ? { error } : {} }, null, 2));
|
|
102
109
|
else {
|
|
103
110
|
console.log(`${status} ${adapter} ${result2.compatibilityMode} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
|
|
104
111
|
for (const action of nextActions) console.error(` Next: ${action}`);
|
|
@@ -113,8 +120,9 @@ async function handleDoctor({ options }) {
|
|
|
113
120
|
surface.resolveSlotPorts(target);
|
|
114
121
|
const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
|
|
115
122
|
if ("code" in dtResult) {
|
|
116
|
-
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
|
|
117
|
-
else console.error(`\u2717 doctor: ${dtResult.message}
|
|
123
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message, userAction: dtResult.userAction } }, null, 2));
|
|
124
|
+
else console.error(`\u2717 doctor: ${dtResult.message}
|
|
125
|
+
Next: ${dtResult.userAction}`);
|
|
118
126
|
return EXIT.usage;
|
|
119
127
|
}
|
|
120
128
|
applyDoctorRuntimePorts(options);
|
|
@@ -151,7 +159,9 @@ async function handleDoctor({ options }) {
|
|
|
151
159
|
printReady
|
|
152
160
|
);
|
|
153
161
|
}
|
|
154
|
-
|
|
162
|
+
const doctorUserAction = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
|
|
163
|
+
const doctorError = doctorResult.status === "fail" ? { code: "DOCTOR_CHECKS_FAILED", message: "one or more required doctor checks failed", userAction: doctorUserAction } : void 0;
|
|
164
|
+
if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, orphanMetros, capture, devices, additionalReachableDevices, ...doctorError ? { error: doctorError } : {} }, null, 2));
|
|
155
165
|
else {
|
|
156
166
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
157
167
|
const stateStyle = (value, good) => value === good ? "ok" : "warn";
|
|
@@ -194,6 +204,7 @@ async function handleDoctor({ options }) {
|
|
|
194
204
|
if (additionalReachableDevices.length > 0) renderAdditionalReachableDevices(additionalReachableDevices, out);
|
|
195
205
|
if (liveView) renderMobileLiveBlock(deviceView?.devices ?? [], liveView.liveMap, out);
|
|
196
206
|
}
|
|
207
|
+
if (doctorError) console.log(` ${out("dim", `Next: ${doctorUserAction}`)}`);
|
|
197
208
|
}
|
|
198
209
|
return doctorResult.status === "pass" ? 0 : 1;
|
|
199
210
|
}
|
|
@@ -256,7 +267,9 @@ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture,
|
|
|
256
267
|
return EXIT.runtime;
|
|
257
268
|
}
|
|
258
269
|
if (json) {
|
|
259
|
-
|
|
270
|
+
const userAction = adapter === "extension" ? `mm-harness launch --adapter extension --target ${shellQuote(target)}` : adapter === "core" ? `mm-harness verify --adapter core --target ${shellQuote(target)}` : `mm-harness status --target ${shellQuote(target)} --json`;
|
|
271
|
+
const error = live ? void 0 : { code: "RUNTIME_NOT_LIVE", message: `${adapter} runtime is not live`, userAction };
|
|
272
|
+
console.log(JSON.stringify({ ...result, ready: live, runtime, orphanMetros, capture, devices, additionalReachableDevices, ...error ? { error } : {} }, null, 2));
|
|
260
273
|
return live ? EXIT.ok : EXIT.runtime;
|
|
261
274
|
}
|
|
262
275
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
@@ -65,7 +65,7 @@ async function handleFixturesLocked(argv, deps) {
|
|
|
65
65
|
surface.resolveSlotPorts(target);
|
|
66
66
|
const dtResult = applyDeviceTargeting("fixtures", adapter, options, { gate: false, rerun: "" });
|
|
67
67
|
if ("code" in dtResult) {
|
|
68
|
-
return usageOut(json, "fixtures", dtResult.message,
|
|
68
|
+
return usageOut(json, "fixtures", dtResult.message, dtResult.userAction);
|
|
69
69
|
}
|
|
70
70
|
if (sub === "generate") return fixturesGenerate(adapter, target, options, json);
|
|
71
71
|
if (sub === "finalize") return fixturesFinalize(adapter, target, options, json);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readCommandJournal } from "../command-journal.js";
|
|
2
|
+
import { EXIT } from "./shared.js";
|
|
3
|
+
import {
|
|
4
|
+
optionFlag,
|
|
5
|
+
optionString,
|
|
6
|
+
targetPath
|
|
7
|
+
} from "./parse-args.js";
|
|
8
|
+
async function handleLast({ options }) {
|
|
9
|
+
const target = targetPath(options);
|
|
10
|
+
const { file, record } = readCommandJournal(target, optionString(options, "runtimeDir"));
|
|
11
|
+
const json = optionFlag(options, "json");
|
|
12
|
+
if (!record) {
|
|
13
|
+
const message = `no resumability journal exists for ${target}`;
|
|
14
|
+
const userAction = "run a proof or runtime command in this checkout, then re-run mm-harness last --json";
|
|
15
|
+
if (json) {
|
|
16
|
+
console.log(JSON.stringify({
|
|
17
|
+
schemaVersion: 1,
|
|
18
|
+
command: "last",
|
|
19
|
+
target,
|
|
20
|
+
journalPath: file,
|
|
21
|
+
status: "fail",
|
|
22
|
+
exitCode: EXIT.runtime,
|
|
23
|
+
error: { code: "LAST_NOT_FOUND", message, userAction }
|
|
24
|
+
}, null, 2));
|
|
25
|
+
} else {
|
|
26
|
+
console.error(`\u2717 mm-harness last: ${message}
|
|
27
|
+
Next: ${userAction}`);
|
|
28
|
+
}
|
|
29
|
+
return EXIT.runtime;
|
|
30
|
+
}
|
|
31
|
+
if (json) {
|
|
32
|
+
console.log(JSON.stringify({
|
|
33
|
+
schemaVersion: 1,
|
|
34
|
+
command: "last",
|
|
35
|
+
target,
|
|
36
|
+
journalPath: file,
|
|
37
|
+
status: "pass",
|
|
38
|
+
exitCode: EXIT.ok,
|
|
39
|
+
last: record
|
|
40
|
+
}, null, 2));
|
|
41
|
+
} else {
|
|
42
|
+
const end = record.finishedAt ?? "still running or interrupted";
|
|
43
|
+
console.log(`${record.verdict.toUpperCase()} ${record.command} (exit ${record.exitCode ?? "pending"})`);
|
|
44
|
+
console.log(`started: ${record.startedAt}`);
|
|
45
|
+
console.log(`finished: ${end}`);
|
|
46
|
+
if (record.evidencePaths.length > 0) console.log(`evidence: ${record.evidencePaths.join(", ")}`);
|
|
47
|
+
}
|
|
48
|
+
return EXIT.ok;
|
|
49
|
+
}
|
|
50
|
+
export {
|
|
51
|
+
handleLast
|
|
52
|
+
};
|