@deeeed/metamask-harness 0.14.5 → 0.15.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +96 -6
  2. package/adapters/extension/ensure-browser.sh +4 -2
  3. package/adapters/extension/launch-browser.cjs +73 -34
  4. package/adapters/extension/live.sh +21 -10
  5. package/adapters/extension/reattach.sh +3 -3
  6. package/adapters/extension/sidepanel-toggle.sh +10 -4
  7. package/adapters/extension/start-watch.sh +2 -2
  8. package/adapters/extension/verify.sh +19 -2
  9. package/adapters/extension/wallet-fixture-state.cjs +14 -1
  10. package/adapters/manifest.json +19 -3
  11. package/adapters/mobile/bridge-runtime/setup-wallet.sh +6 -4
  12. package/adapters/mobile/launch-metro.cjs +74 -0
  13. package/adapters/mobile/lib/tmux-viewer.sh +4 -4
  14. package/adapters/mobile/open-device.sh +3 -1
  15. package/adapters/mobile/start-metro.sh +12 -18
  16. package/adapters/mobile/stop-metro.sh +1 -0
  17. package/adapters/mobile/verify.sh +5 -5
  18. package/adapters/shared/install-repo-deps.sh +29 -0
  19. package/adapters/shared/open-debug.mjs +35 -4
  20. package/adapters/shared/open-log-window.sh +1 -1
  21. package/adapters/shared/resolve-slot-ports-core.mjs +72 -1
  22. package/adapters/shared/resolve-slot-ports.sh +21 -17
  23. package/adapters/shared/tmux-session.sh +0 -5
  24. package/adapters/shared/tmux-viewer.sh +7 -2
  25. package/dist/adapters/core/surface.js +3 -0
  26. package/dist/adapters/extension/ensure-ready.js +0 -7
  27. package/dist/adapters/extension/runtime-decision.js +93 -3
  28. package/dist/adapters/extension/surface.js +17 -6
  29. package/dist/adapters/mobile/metro-env.js +67 -0
  30. package/dist/adapters/mobile/perps-env.js +101 -0
  31. package/dist/adapters/mobile/prepare.js +32 -14
  32. package/dist/adapters/mobile/runtime-decision.js +21 -2
  33. package/dist/adapters/mobile/source-freshness.js +116 -0
  34. package/dist/adapters/mobile/surface.js +5 -1
  35. package/dist/adapters/resolve-slot-ports.js +4 -0
  36. package/dist/adapters/slot-ports.js +131 -49
  37. package/dist/adapters.js +7 -2
  38. package/dist/checkout-lock.js +72 -0
  39. package/dist/cli-commands.js +1 -1
  40. package/dist/cli.js +2 -0
  41. package/dist/commands/call.js +91 -54
  42. package/dist/commands/check.js +266 -46
  43. package/dist/commands/checklist.js +136 -0
  44. package/dist/commands/debug.js +21 -2
  45. package/dist/commands/doctor.js +233 -18
  46. package/dist/commands/farmslot-ready.js +25 -0
  47. package/dist/commands/fixtures.js +177 -42
  48. package/dist/commands/launch/extension.js +6 -1
  49. package/dist/commands/launch/index.js +54 -22
  50. package/dist/commands/logs.js +24 -4
  51. package/dist/commands/parse-args.js +1 -0
  52. package/dist/commands/run-engine.js +223 -7
  53. package/dist/commands/run.js +67 -50
  54. package/dist/commands/shared.js +86 -5
  55. package/dist/commands/stop.js +1 -2
  56. package/dist/doctor.js +39 -17
  57. package/dist/harness.js +5 -2
  58. package/dist/heal-bounds.js +1 -1
  59. package/dist/mm-harness-cli.js +41 -12
  60. package/dist/runner.js +35 -5
  61. package/dist/runtime-context.js +228 -0
  62. package/docs/CLI-SPEC.md +15 -11
  63. package/docs/MENTAL-MODEL.md +6 -6
  64. package/library/actions/extension/perps/perps.mjs +29 -8
  65. package/library/actions/extension/platform/cdp.mjs +128 -28
  66. package/library/actions/extension/ui/navigate.mjs +1 -1
  67. package/library/actions/mobile/perps/perps.mjs +13 -1
  68. package/library/actions/mobile/platform/bridge.mjs +302 -29
  69. package/library/actions/mobile/wallet/ensure_unlocked.mjs +7 -2
  70. package/library/manifests/extension.action-manifest.json +32 -12
  71. package/library/manifests/mobile.action-manifest.json +25 -3
  72. package/package.json +4 -4
  73. package/scripts/completions.sh +7 -4
@@ -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 { EXIT } from "./shared.js";
6
+ import { checkoutBusyOut, EXIT, usageOut } from "./shared.js";
7
7
  import {
8
8
  optionFlag,
9
9
  optionString,
@@ -18,6 +18,7 @@ import {
18
18
  emitHealViolation,
19
19
  executeWithHealBounds,
20
20
  prepareHeal,
21
+ recoverRunInfra,
21
22
  runRecipe,
22
23
  validateRunRecipeStatic
23
24
  } from "./run-engine.js";
@@ -26,6 +27,7 @@ import { applyDeviceTargeting } from "./device-target.js";
26
27
  import { getAdapterSurface } from "../adapters/surface.js";
27
28
  import { coreDependencyBlock } from "./core-readiness.js";
28
29
  import { writeRunReport } from "./run-report.js";
30
+ import { acquireCheckoutLock } from "../checkout-lock.js";
29
31
  async function handleRun({ positional, options }) {
30
32
  if (optionFlag(options, "list")) return handleListExecutables("run", options);
31
33
  const targetRecipe = positional[0];
@@ -33,6 +35,14 @@ async function handleRun({ positional, options }) {
33
35
  if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options);
34
36
  const { adapter, target } = resolveAdapter(options);
35
37
  const json = optionFlag(options, "json");
38
+ if (!fs.existsSync(target)) {
39
+ return usageOut(
40
+ json,
41
+ "run",
42
+ `target does not exist: ${target}`,
43
+ "pass --target <metamask-checkout> pointing to an existing checkout"
44
+ );
45
+ }
36
46
  getAdapterSurface(adapter).resolveSlotPorts(target);
37
47
  const dtResult = applyDeviceTargeting("run", adapter, options, { gate: true, rerun: "" });
38
48
  if ("code" in dtResult) {
@@ -54,56 +64,63 @@ async function handleRun({ positional, options }) {
54
64
  return emitRunUsageError(json, adapter, validated.recipeFile, depsBlock.code, depsBlock.message, depsBlock.userAction);
55
65
  }
56
66
  const artifactsDir = requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
57
- const prepared = await prepareHeal(adapter, target, options, json);
58
- if (typeof prepared === "number") return prepared;
59
- const { state, heal } = prepared;
60
- const librarySources = validated.librarySources;
61
- const runtimeOptions = {
62
- ...runtimeOptionsFromCli(options),
63
- ...librarySources ? { librarySources } : {},
64
- stdoutIsMachineContract: json
65
- };
66
- const { result, violation } = await executeWithHealBounds(
67
- // validated.recipeFile, not the raw arg: the arg may be a library recipe NAME
68
- // that only the resolver knows how to turn into a file.
69
- () => runRecipe(adapter, validated.recipeFile, artifactsDir, target, optionString(options, "actionManifest"), runtimeOptions),
70
- adapter,
71
- target,
72
- heal,
73
- state
74
- );
75
- if (violation !== null) return emitHealViolation(json, "run", result, violation, state, adapter);
76
- const report = writeRunReport(result);
77
- const exitCode = result.status === "pass" ? EXIT.ok : EXIT.runtime;
78
- if (json) {
79
- console.log(
80
- JSON.stringify(
81
- {
82
- schemaVersion: 1,
83
- command: "run",
84
- adapter,
85
- status: result.status,
86
- exitCode,
87
- recovered: state.recovered,
88
- mutations: state.mutations,
89
- reportPath: report.path,
90
- result
91
- },
92
- null,
93
- 2
94
- )
67
+ const lock = acquireCheckoutLock(target, "run");
68
+ if ("message" in lock) return checkoutBusyOut(json, "run", lock.message, lock.path);
69
+ try {
70
+ const prepared = await prepareHeal(adapter, target, options, json);
71
+ if (typeof prepared === "number") return prepared;
72
+ const { state, heal } = prepared;
73
+ const librarySources = validated.librarySources;
74
+ const runtimeOptions = {
75
+ ...runtimeOptionsFromCli(options),
76
+ ...librarySources ? { librarySources } : {},
77
+ stdoutIsMachineContract: json
78
+ };
79
+ const { result, violation } = await executeWithHealBounds(
80
+ // validated.recipeFile, not the raw arg: the arg may be a library recipe NAME
81
+ // that only the resolver knows how to turn into a file.
82
+ () => runRecipe(adapter, validated.recipeFile, artifactsDir, target, optionString(options, "actionManifest"), runtimeOptions),
83
+ adapter,
84
+ target,
85
+ heal,
86
+ state,
87
+ () => recoverRunInfra(adapter, target, json)
95
88
  );
96
- } else {
97
- const out = (style, text) => color(style, text, { stream: process.stdout });
98
- console.log(`${out(result.status === "pass" ? "ok" : "err", result.status.toUpperCase())} ${out("bold", "recipe run")} ${out("dim", `[${adapter}]`)}`);
99
- if (report.preview.length > 0) {
100
- console.log(out("label", "summary:"));
101
- for (const line of report.preview) console.log(` ${formatPreviewLine(line, out)}`);
89
+ if (violation !== null) return emitHealViolation(json, "run", result, violation, state, adapter);
90
+ const report = writeRunReport(result);
91
+ const exitCode = result.status === "pass" ? EXIT.ok : EXIT.runtime;
92
+ if (json) {
93
+ console.log(
94
+ JSON.stringify(
95
+ {
96
+ schemaVersion: 1,
97
+ command: "run",
98
+ adapter,
99
+ status: result.status,
100
+ exitCode,
101
+ recovered: state.recovered,
102
+ mutations: state.mutations,
103
+ reportPath: report.path,
104
+ result
105
+ },
106
+ null,
107
+ 2
108
+ )
109
+ );
110
+ } else {
111
+ const out = (style, text) => color(style, text, { stream: process.stdout });
112
+ console.log(`${out(result.status === "pass" ? "ok" : "err", result.status.toUpperCase())} ${out("bold", "recipe run")} ${out("dim", `[${adapter}]`)}`);
113
+ if (report.preview.length > 0) {
114
+ console.log(out("label", "summary:"));
115
+ for (const line of report.preview) console.log(` ${formatPreviewLine(line, out)}`);
116
+ }
117
+ console.log(`${out("label", "report:")} ${out("path", report.path)}`);
118
+ console.log(`${out("label", "artifacts:")} ${out("path", result.artifactManifestPath)}`);
102
119
  }
103
- console.log(`${out("label", "report:")} ${out("path", report.path)}`);
104
- console.log(`${out("label", "artifacts:")} ${out("path", result.artifactManifestPath)}`);
120
+ return exitCode;
121
+ } finally {
122
+ lock.release();
105
123
  }
106
- return exitCode;
107
124
  }
108
125
  function formatPreviewLine(line, out) {
109
126
  const match = /^(PASS|FAIL)\s+(.+)$/u.exec(line);
@@ -271,8 +288,8 @@ function emitPlanUsageError(json, adapter, recipeFile, code, message) {
271
288
  function runArgLooksLikeRecipeFile(value) {
272
289
  return path.isAbsolute(value) || value.includes("/") || value.includes(path.sep) || value.endsWith(".json") || fs.existsSync(path.resolve(value));
273
290
  }
274
- function emitRunRecipeRunning(json) {
275
- const message = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
291
+ function emitRunRecipeRunning(json, detail) {
292
+ const message = detail ?? "a recipe is currently running \u2014 refusing to start while another recipe executes.";
276
293
  if (json) {
277
294
  console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message } }, null, 2));
278
295
  } else {
@@ -111,7 +111,8 @@ function spawnInherit(script, args, cwd) {
111
111
  if (result.status !== null) return result.status;
112
112
  return result.signal === "SIGINT" || result.signal === "SIGTERM" ? 0 : 1;
113
113
  }
114
- function spawnScriptStreaming(script, args, cwd, env) {
114
+ function spawnScriptStreaming(script, args, cwd, options) {
115
+ const spawnOptions = options && ("env" in options || "timeoutMs" in options) ? options : { env: options };
115
116
  const isNodeScript = script === process.execPath && args.length > 0;
116
117
  const stem = isNodeScript ? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase() : path.basename(script).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase();
117
118
  const override = process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
@@ -126,12 +127,58 @@ function spawnScriptStreaming(script, args, cwd, env) {
126
127
  }
127
128
  const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
128
129
  return new Promise((resolve) => {
130
+ const ownsProcessGroup = process.platform !== "win32" && Number(spawnOptions.timeoutMs) > 0;
129
131
  const child = spawn(invokeBin, spawnArgs, {
130
132
  cwd,
131
- env: env ? { ...process.env, ...env } : process.env,
132
- stdio: ["ignore", "pipe", "pipe"]
133
+ env: spawnOptions.env ? { ...process.env, ...spawnOptions.env } : process.env,
134
+ stdio: ["ignore", "pipe", "pipe"],
135
+ detached: ownsProcessGroup
133
136
  });
134
137
  let output = "";
138
+ let settled = false;
139
+ let didTimeout = false;
140
+ let escalation;
141
+ const parentSignals = ["SIGINT", "SIGTERM"];
142
+ const removeParentSignalHandlers = () => {
143
+ for (const signal of parentSignals) process.removeListener(signal, parentSignalHandlers[signal]);
144
+ };
145
+ const finish = (result) => {
146
+ if (settled) return;
147
+ settled = true;
148
+ if (timer) clearTimeout(timer);
149
+ if (escalation) clearTimeout(escalation);
150
+ removeParentSignalHandlers();
151
+ resolve(result);
152
+ };
153
+ const signalChildTree = (signal) => {
154
+ try {
155
+ if (ownsProcessGroup && child.pid) process.kill(-child.pid, signal);
156
+ else child.kill(signal);
157
+ } catch {
158
+ }
159
+ };
160
+ const parentSignalHandlers = Object.fromEntries(parentSignals.map((signal) => [signal, () => {
161
+ signalChildTree(signal);
162
+ removeParentSignalHandlers();
163
+ process.kill(process.pid, signal);
164
+ }]));
165
+ if (ownsProcessGroup) {
166
+ for (const signal of parentSignals) process.once(signal, parentSignalHandlers[signal]);
167
+ }
168
+ const timeoutMs = spawnOptions.timeoutMs;
169
+ const timer = Number.isFinite(timeoutMs) && Number(timeoutMs) > 0 ? setTimeout(() => {
170
+ didTimeout = true;
171
+ const message = `leaf timed out after ${String(timeoutMs)}ms: ${path.basename(script)}`;
172
+ output += `${output.endsWith("\n") || output.length === 0 ? "" : "\n"}${message}
173
+ `;
174
+ process.stderr.write(`${message}
175
+ `);
176
+ signalChildTree("SIGTERM");
177
+ escalation = setTimeout(() => {
178
+ signalChildTree("SIGKILL");
179
+ finish({ status: 1, output, timedOut: true, timeoutMs: Number(timeoutMs) });
180
+ }, 1e3);
181
+ }, Number(timeoutMs)) : void 0;
135
182
  const tee = (chunk) => {
136
183
  const text = chunk.toString("utf8");
137
184
  output += text;
@@ -146,10 +193,20 @@ function spawnScriptStreaming(script, args, cwd, env) {
146
193
  Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the shell leaf is missing or not executable`;
147
194
  process.stderr.write(`${message}
148
195
  `);
149
- resolve({ status: 1, output: message });
196
+ finish({ status: 1, output: message });
197
+ });
198
+ child.on("exit", () => {
199
+ if (!didTimeout) return;
200
+ signalChildTree("SIGKILL");
201
+ finish({ status: 1, output, timedOut: true, timeoutMs: Number(timeoutMs) });
150
202
  });
151
203
  child.on("close", (status) => {
152
- resolve({ status: status ?? 1, output });
204
+ if (didTimeout) signalChildTree("SIGKILL");
205
+ finish({
206
+ status: didTimeout ? 1 : status ?? 1,
207
+ output,
208
+ ...didTimeout ? { timedOut: true, timeoutMs: Number(timeoutMs) } : {}
209
+ });
153
210
  });
154
211
  });
155
212
  }
@@ -169,9 +226,33 @@ function usageOut(json, command, message, userAction) {
169
226
  }
170
227
  return EXIT.usage;
171
228
  }
229
+ function checkoutBusyOut(json, command, message, lockPath) {
230
+ const userAction = `wait for the current owner, or inspect ${lockPath} if its process has exited`;
231
+ if (json) {
232
+ console.log(
233
+ JSON.stringify(
234
+ {
235
+ schemaVersion: 1,
236
+ command,
237
+ status: "fail",
238
+ exitCode: EXIT.bounded,
239
+ recoverable: false,
240
+ error: { code: "SANDBOX_BUSY", message, userAction }
241
+ },
242
+ null,
243
+ 2
244
+ )
245
+ );
246
+ } else {
247
+ console.error(`\u2717 mm-harness ${command}: ${message}
248
+ Next: ${userAction}`);
249
+ }
250
+ return EXIT.bounded;
251
+ }
172
252
  export {
173
253
  ADAPTER_DETECT_NEXT,
174
254
  EXIT,
255
+ checkoutBusyOut,
175
256
  flag,
176
257
  parseFlags,
177
258
  resolveAdapter,
@@ -12,12 +12,11 @@ async function handleStop(argv) {
12
12
  const json = optionFlag(options, "json");
13
13
  const { adapter, target } = resolveAdapter(options);
14
14
  const surface = getAdapterSurface(adapter);
15
+ surface.resolveSlotPorts(target);
15
16
  const explicitPort = optionString(options, "port") ?? optionString(options, "watcherPort");
16
17
  if (explicitPort) {
17
18
  process.env.WATCHER_PORT = explicitPort;
18
19
  process.env.METRO_PORT = explicitPort;
19
- } else {
20
- surface.resolveSlotPorts(target);
21
20
  }
22
21
  const stop = surface.devServer.stop(target);
23
22
  if (stop.kind === "headless") {
package/dist/doctor.js CHANGED
@@ -1,8 +1,19 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { color } from "./cli-color.js";
4
+ import { mobilePerpsEnvironment } from "./adapters/mobile/perps-env.js";
4
5
  import { readRuntimeContextField, resolveRuntimeContextPath } from "./harness.js";
5
6
  import { manifestPath, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "./paths.js";
7
+ function requiredDoctorCheckSummary(checks) {
8
+ const required = checks.filter((check) => check.required);
9
+ const failed = required.filter((check) => check.status === "fail").map((check) => check.id);
10
+ return {
11
+ status: failed.length === 0 ? "pass" : "fail",
12
+ total: required.length,
13
+ passed: required.length - failed.length,
14
+ failed
15
+ };
16
+ }
6
17
  function repoShape(target) {
7
18
  const exists = (rel) => fs.existsSync(path.join(target, rel));
8
19
  const packageInfo = readPackageInfo(target);
@@ -56,31 +67,36 @@ function readPackageInfo(target) {
56
67
  };
57
68
  }
58
69
  }
59
- function fixtureSummary(target) {
70
+ function fixtureSummary(target, adapter) {
60
71
  const candidates = [
61
72
  `${recipeRuntimeDir()}/wallet-fixture.json`
62
73
  ];
63
74
  const rel = candidates.find((candidate) => fs.existsSync(path.join(target, candidate)));
64
75
  if (!rel) return { status: "missing", path: null };
76
+ return fixtureFileSummary(path.join(target, rel), adapter, rel);
77
+ }
78
+ function fixtureFileSummary(file, adapter, reportedPath = file) {
65
79
  try {
66
- const data = readJsonObject(path.join(target, rel));
80
+ const data = readJsonObject(file);
81
+ const hasAccounts = Array.isArray(data.accounts) && data.accounts.length > 0;
82
+ const hasPassword = typeof data.password === "string" && data.password.length > 0;
67
83
  return {
68
- status: Array.isArray(data.accounts) && data.accounts.length > 0 ? "ready" : "incomplete",
69
- path: rel,
84
+ status: hasAccounts && (adapter === "core" || hasPassword) ? "ready" : "incomplete",
85
+ path: reportedPath,
70
86
  accountCount: Array.isArray(data.accounts) ? data.accounts.length : 0,
71
- hasPassword: typeof data.password === "string" && data.password.length > 0
87
+ hasPassword
72
88
  };
73
89
  } catch (error) {
74
- return { status: "invalid", path: rel, error: error instanceof Error ? error.message : String(error) };
90
+ return { status: "invalid", path: reportedPath, error: error instanceof Error ? error.message : String(error) };
75
91
  }
76
92
  }
77
93
  const RUNTIME_CONTEXT_FIELDS = [
78
- { key: "slotId", envVars: ["RECIPE_SLOT_ID"], envVar: "RECIPE_SLOT_ID", customize: "farmslot dispatch writes this", adapters: ["mobile", "extension", "core"] },
79
- { key: "extensionId", envVars: ["RECIPE_HARNESS_EXTENSION_ID"], envVar: "RECIPE_HARNESS_EXTENSION_ID", customize: "auto-resolved; edit file to pin", adapters: ["extension"] },
80
- { key: "cdpPort", envVars: ["RECIPE_CDP_PORT", "CDP_PORT"], envVar: "CDP_PORT", customize: "edit file or pass --cdp-port", adapters: ["extension"] },
81
- { key: "runtimeStart.approved", envVars: ["RECIPE_RUNTIME_START_APPROVED"], envVar: "RECIPE_RUNTIME_START_APPROVED", customize: "edit file (true/false)", adapters: ["mobile", "extension"] },
82
- { key: "runtimeStart.command", envVars: [], envVar: null, customize: "edit file", adapters: ["mobile", "extension"] },
83
- { key: "runtimeStart.readyUrl", envVars: ["RECIPE_RUNTIME_READY_URL"], envVar: "RECIPE_RUNTIME_READY_URL", customize: "edit file", adapters: ["mobile", "extension"] }
94
+ { key: "slotId", envVars: ["RECIPE_SLOT_ID"], envVar: "RECIPE_SLOT_ID", customize: "doctor --fix creates local identity; Farmslot may override", adapters: ["mobile", "extension", "core"] },
95
+ { key: "extensionId", envVars: ["RECIPE_HARNESS_EXTENSION_ID"], envVar: "RECIPE_HARNESS_EXTENSION_ID", customize: "resolved from the built extension", adapters: ["extension"] },
96
+ { key: "cdpPort", envVars: ["RECIPE_CDP_PORT", "CDP_PORT"], envVar: "CDP_PORT", customize: "doctor --fix claims one; --cdp-port overrides", adapters: ["extension"] },
97
+ { key: "runtimeStart.approved", envVars: ["RECIPE_RUNTIME_START_APPROVED"], envVar: "RECIPE_RUNTIME_START_APPROVED", customize: "optional orchestrator-provided legacy live context", adapters: ["mobile", "extension"] },
98
+ { key: "runtimeStart.command", envVars: [], envVar: null, customize: "optional orchestrator-provided legacy live context", adapters: ["mobile", "extension"] },
99
+ { key: "runtimeStart.readyUrl", envVars: ["RECIPE_RUNTIME_READY_URL"], envVar: "RECIPE_RUNTIME_READY_URL", customize: "optional orchestrator-provided legacy live context", adapters: ["mobile", "extension"] }
84
100
  ];
85
101
  function runtimeContextSummary(target, adapter) {
86
102
  const contextPath = resolveRuntimeContextPath(target);
@@ -104,7 +120,7 @@ function renderRuntimeContext(runtimeContext) {
104
120
  const out = (style, text) => color(style, text, { stream: process.stdout });
105
121
  const lines = [];
106
122
  lines.push(
107
- runtimeContext.fileExists ? `${out("label", "runtime-context:")} ${runtimeContext.file} ${out("ok", "(present)")}` : `${out("label", "runtime-context:")} ${runtimeContext.file} ${out("dim", "(absent \u2014 written by farmslot prepare/dispatch)")}`
123
+ runtimeContext.fileExists ? `${out("label", "runtime-context:")} ${runtimeContext.file} ${out("ok", "(present)")}` : `${out("label", "runtime-context:")} ${runtimeContext.file} ${out("dim", "(absent \u2014 run mm-harness doctor --fix)")}`
108
124
  );
109
125
  for (const [key, field] of Object.entries(runtimeContext.fields)) {
110
126
  const isSet = field.value !== void 0 && field.value !== null && field.value !== "";
@@ -118,25 +134,28 @@ function renderRuntimeContext(runtimeContext) {
118
134
  function createDoctorReport(adapter, target, manifestValidation, actionManifestPath = manifestPath(adapter)) {
119
135
  const mode = compatibilityMode(adapter, target);
120
136
  const manifestErrors = Number(manifestValidation.summary?.errors ?? 0);
121
- const status = manifestErrors > 0 ? "fail" : "pass";
122
137
  const checks = [
123
138
  {
124
139
  id: "manifest",
125
140
  status: manifestErrors === 0 ? "pass" : "fail",
141
+ required: true,
126
142
  message: manifestErrors === 0 ? "Action manifest is valid Recipe v1." : `Action manifest has ${manifestErrors} validation error(s).`
127
143
  },
128
144
  {
129
145
  id: "bridge",
130
146
  status: mode === "unsupported/no bridge" ? "fail" : "pass",
147
+ required: false,
131
148
  message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
132
149
  }
133
150
  ];
151
+ const requiredChecks = requiredDoctorCheckSummary(checks);
134
152
  return {
135
153
  schemaVersion: 1,
136
154
  protocolVersion: "v1",
137
155
  runner_protocol_version: 1,
138
- status,
139
- checks: [...checks],
156
+ status: requiredChecks.status,
157
+ checks,
158
+ requiredChecks,
140
159
  adapter,
141
160
  target,
142
161
  runner: {
@@ -147,7 +166,8 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
147
166
  },
148
167
  compatibilityMode: mode,
149
168
  shape: repoShape(target),
150
- fixture: fixtureSummary(target),
169
+ fixture: fixtureSummary(target, adapter),
170
+ environment: adapter === "mobile" ? { mobilePerps: mobilePerpsEnvironment(target) } : {},
151
171
  runtimeContext: runtimeContextSummary(target, adapter),
152
172
  manifestValidation: manifestValidation.summary
153
173
  };
@@ -162,8 +182,10 @@ function readJsonObject(file) {
162
182
  export {
163
183
  compatibilityMode,
164
184
  createDoctorReport,
185
+ fixtureFileSummary,
165
186
  fixtureSummary,
166
187
  renderRuntimeContext,
167
188
  repoShape,
189
+ requiredDoctorCheckSummary,
168
190
  runtimeContextSummary
169
191
  };
package/dist/harness.js CHANGED
@@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { resolveLeafInvoke, shellLeafMissing, missingShellLeafMessage } from "./leaf-invoke.js";
5
+ import { resolveExtensionSlotPorts } from "./adapters/slot-ports.js";
5
6
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "./paths.js";
6
7
  import { prepareMobile } from "./adapters/mobile/prepare.js";
7
8
  const HARNESS_ACTIONS = ["install", "verify", "cleanup", "live"];
@@ -186,11 +187,13 @@ function applyExtensionRuntimeEnv(target, action, args) {
186
187
  const readyUrl = readRuntimeContextField(contextPath, "runtimeStart.readyUrl");
187
188
  if (readyUrl) process.env.RECIPE_RUNTIME_READY_URL = readyUrl;
188
189
  }
190
+ } else {
191
+ delete process.env.RECIPE_RUNTIME_CONTEXT;
189
192
  }
193
+ resolveExtensionSlotPorts(target);
190
194
  let result = [...args];
191
195
  if (!hasArg(result, "--cdp-port")) {
192
- const contextPort = contextExists ? readRuntimeContextField(contextPath, "cdpPort") : void 0;
193
- const cdpPort = contextPort ?? process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
196
+ const cdpPort = process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
194
197
  if (cdpPort) {
195
198
  process.env.RECIPE_CDP_PORT = cdpPort;
196
199
  process.env.CDP_PORT = cdpPort;
@@ -103,7 +103,7 @@ function checkHealBounds(target, output, state) {
103
103
  return {
104
104
  code: "SAME_RECOVERY_TWICE",
105
105
  exitCode: EXIT.bounded,
106
- message: "same recovery already failed once this invocation \u2014 refusing to loop.",
106
+ message: state.recovered.length > 0 ? "one recovery already succeeded this invocation \u2014 bounded policy refuses a second recovery." : "one recovery was already attempted this invocation \u2014 refusing to loop.",
107
107
  originalError
108
108
  };
109
109
  }
@@ -51,6 +51,22 @@ Example:
51
51
  mm-harness status --json
52
52
  mm-harness status --fast`
53
53
  },
54
+ {
55
+ name: "checklist",
56
+ summary: "Mark task-local checklist progress through the bundled agent runtime.",
57
+ example: "mm-harness checklist mark <task-dir> start",
58
+ helpText: `mm-harness checklist mark <task-dir> <step> [options]
59
+
60
+ Mark checklist progress through mm-harness's bundled @farmslot/agent-runtime.
61
+ The task directory must contain CHECKLIST.md and checklist-target.json.
62
+
63
+ Steps: start | 1 | 2 | ... | complete | no-change | blocked
64
+
65
+ Example:
66
+ mm-harness checklist mark temp/tasks/recipe-cook/<task> start
67
+ mm-harness checklist mark temp/tasks/recipe-cook/<task> 1
68
+ mm-harness checklist mark temp/tasks/recipe-cook/<task> complete --mark-last`
69
+ },
54
70
  {
55
71
  name: "actions",
56
72
  summary: "List the action vocabulary + field schemas (--raw dumps the raw action registry JSON).",
@@ -72,18 +88,18 @@ Example:
72
88
  },
73
89
  {
74
90
  name: "stop",
75
- summary: "Stop the dev server this checkout owns (mobile Metro / extension webpack watcher) and close its log window.",
91
+ summary: "Stop this checkout runtime (mobile Metro / Extension watcher + owned browser) and close its log windows.",
76
92
  example: "mm-harness stop",
77
93
  helpText: `mm-harness stop [flags]
78
94
 
79
- Stop the dev server this checkout owns and close its tmux log-tail window,
95
+ Stop the runtime this checkout owns and close its tmux log-tail windows,
80
96
  scoped to this checkout so concurrent slots are untouched. Idempotent \u2014
81
97
  nothing running is success, not an error. Behavior is per platform:
82
98
  mobile stop the port-scoped Metro dev server
83
- extension stop the checkout's webpack watcher (pid file + orphan scan)
99
+ extension stop webpack watcher, owned Chrome/CDP profile processes, viewers, and stale markers
84
100
  core headless \u2014 no dev server to stop (teaching error)
85
101
 
86
- --port <port> Dev-server port (default: the checkout's slot context)
102
+ --port <port> Metro/webpack port override; Extension CDP remains checkout-context scoped
87
103
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
88
104
  --target <path> Checkout path (default: cwd)
89
105
  --json Machine-readable output
@@ -101,7 +117,8 @@ Example:
101
117
  Run one action in isolation as a one-node recipe through the real engine path.
102
118
  Fuzzy short-name: 'ensure_unlocked' resolves to 'metamask.wallet.ensure_unlocked'
103
119
  if unique; ambiguous = exit 2. Pass action fields as key=value shorthand or
104
- with --arg k=v. Actions differ per adapter \u2014 list this checkout's with:
120
+ with --arg k=v; numbers, booleans, arrays, and objects are parsed as JSON-like values.
121
+ Actions differ per adapter \u2014 list this checkout's with:
105
122
  mm-harness actions.
106
123
 
107
124
  --list List everything invocable for the adapter (actions + flows); no <action> needed
@@ -171,19 +188,20 @@ Example:
171
188
  sections so there is no hunting for files.
172
189
 
173
190
  --fix Repair the overlay/runtime-context WITHOUT launching (no fixture reseed); --json adds fixed[]/failed[]
174
- --expect-live Exit 0 iff the runtime is live (extension: watcher+CDP; mobile: Metro+bridge; core: deps), non-zero + teaching escape otherwise
191
+ --expect-live Exit-coded liveness gate only (human verdict on stderr). Use without --print-ready when callers need pass/fail without a Farmslot indicator line
192
+ --print-ready Farmslot health_check mode: implies --expect-live and prints health.ready_indicator on stdout (extension/mobile: OK; core: ready)
175
193
  --cdp-port <port> Extension CDP port for the liveness probe (env: CDP_PORT / RECIPE_CDP_PORT)
176
194
  --device <udid|serial|name> Mobile only: target this device (env: IOS_SIMULATOR / ADB_SERIAL). doctor reports the connected devices; it never gates on ambiguity.
177
195
  --all-devices Mobile only: show every connected device instead of the slot-scoped target
178
196
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
179
197
  --target <path> Checkout path (default: cwd)
180
198
  --runtime-dir <dir> Runtime dir containing agentic-runtime.json (relative to target)
181
- --json Machine-readable output
199
+ --json Machine-readable output (incompatible with --print-ready)
182
200
 
183
201
  Example:
184
202
  mm-harness doctor
185
203
  mm-harness doctor --fix --json
186
- mm-harness doctor --adapter extension --target /path/to/checkout --cdp-port 6662 --expect-live
204
+ mm-harness doctor --adapter extension --target /path/to/checkout --cdp-port 6662 --print-ready
187
205
  mm-harness doctor --adapter mobile --target /path/to/checkout`
188
206
  },
189
207
  {
@@ -200,11 +218,12 @@ Example:
200
218
  --adapter <mobile|extension|core> Adapter label (auto-detected when possible)
201
219
  --base <ref> Diff base (default: PR base, then remote HEAD, then repo fallback)
202
220
  --profile <fast|full> Validation depth (default: fast)
221
+ --fix Fix changed files, then validate them
203
222
  --artifacts-dir <dir> Write validation-summary.json/.md and per-check logs
204
223
  --json Machine-readable envelope
205
224
 
206
225
  Example:
207
- mm-harness check diff --profile fast --artifacts-dir artifacts/validation
226
+ mm-harness check diff --fix --profile fast --artifacts-dir artifacts/validation
208
227
  mm-harness check diff --profile full --json`
209
228
  },
210
229
  {
@@ -305,6 +324,8 @@ Example:
305
324
  helpText: `mm-harness verify [flags]
306
325
 
307
326
  Check the runtime overlay is present and healthy (no app launch).
327
+ Extension live checks infer the checkout CDP port from runtime context,
328
+ pool, or slot suffix when --cdp-port is omitted.
308
329
 
309
330
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
310
331
  --target <path> Checkout path (default: cwd)
@@ -394,6 +415,7 @@ Example:
394
415
 
395
416
  --worker Extension service-worker DevTools
396
417
  --dev-menu Mobile RN developer menu
418
+ --no-open Resolve target/method/endpoint without opening a UI
397
419
  --adapter <mobile|extension> Target adapter (auto-detected inside a checkout)
398
420
  --target <path> Checkout path (default: cwd)
399
421
  --json Machine-readable output
@@ -424,11 +446,13 @@ Example:
424
446
  },
425
447
  {
426
448
  name: "fixtures",
427
- summary: "Manage the canonical wallet fixture (wallet DATA only) \u2014 sync / set / generate / finalize.",
449
+ summary: "Manage the canonical wallet fixture (wallet DATA only) \u2014 init / sync / set / generate / finalize.",
428
450
  example: "mm-harness fixtures set",
429
- helpText: `mm-harness fixtures <sync|set|generate|finalize> [flags]
451
+ helpText: `mm-harness fixtures <init|sync|set|generate|finalize> [flags]
430
452
 
431
453
  Manage the ONE canonical wallet fixture per checkout \u2014 wallet DATA only.
454
+ init Create it from --from <path>, or explicitly choose --dev for a
455
+ disposable public test wallet that must never hold real funds.
432
456
  sync Refresh the wallet fixture files on the target.
433
457
  set Apply the canonical fixture (SRP/password/accounts); the password is
434
458
  read FROM the fixture, never typed.
@@ -440,6 +464,9 @@ Example:
440
464
  Want different accounts? Edit the fixture file directly:
441
465
  <checkout>/temp/recipe/runtime/wallet-fixture.json
442
466
 
467
+ --from <path> Existing wallet fixture source (init)
468
+ --dev Create a disposable public test wallet (init)
469
+ --force Replace an existing canonical fixture (init)
443
470
  --fixture <path> Wallet fixture path (generate/finalize input; agent override for set/sync \u2014 env: RECIPE_WALLET_FIXTURE)
444
471
  --out <path> generate: output fixture-state.json; finalize: optional validation report
445
472
  --state <path> finalize: the pre-launch fixture-state.json to seed
@@ -452,6 +479,8 @@ Example:
452
479
  --json Machine-readable output
453
480
 
454
481
  Example:
482
+ mm-harness fixtures init --from /secure/path/wallet-fixture.json
483
+ mm-harness fixtures init --dev
455
484
  mm-harness fixtures sync
456
485
  mm-harness fixtures set
457
486
  mm-harness fixtures generate --fixture wallet-fixture.json --out fixture-state.json
@@ -487,7 +516,7 @@ const HELP_GROUPS = [
487
516
  {
488
517
  title: "PROVE",
489
518
  blurb: "run recipes and inspect readiness",
490
- commands: ["run", "doctor", "check", "recipe-quality"]
519
+ commands: ["run", "doctor", "check", "checklist", "recipe-quality"]
491
520
  },
492
521
  {
493
522
  title: "RUNTIME OVERLAY",