@deeeed/metamask-harness 0.14.7 → 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 (100) hide show
  1. package/CHANGELOG.md +81 -6
  2. package/adapters/core/cleanup.sh +0 -0
  3. package/adapters/core/inject.sh +0 -0
  4. package/adapters/extension/cleanup.mjs +0 -0
  5. package/adapters/extension/ensure-browser.sh +4 -2
  6. package/adapters/extension/inject.mjs +0 -0
  7. package/adapters/extension/launch-browser.cjs +73 -34
  8. package/adapters/extension/launch.sh +0 -0
  9. package/adapters/extension/live.sh +21 -10
  10. package/adapters/extension/readiness.mjs +0 -0
  11. package/adapters/extension/reattach.sh +3 -3
  12. package/adapters/extension/refresh-build.sh +0 -0
  13. package/adapters/extension/seed-fixture.sh +0 -0
  14. package/adapters/extension/sidepanel-toggle.sh +10 -4
  15. package/adapters/extension/snapshot-dist.sh +0 -0
  16. package/adapters/extension/start-watch.sh +2 -2
  17. package/adapters/extension/stop-viewers.sh +0 -0
  18. package/adapters/extension/verify.sh +19 -2
  19. package/adapters/extension/wallet-fixture-state.cjs +14 -1
  20. package/adapters/manifest.json +19 -3
  21. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +0 -0
  22. package/adapters/mobile/bridge-runtime/setup-wallet.sh +6 -4
  23. package/adapters/mobile/cleanup.sh +0 -0
  24. package/adapters/mobile/inject.sh +0 -0
  25. package/adapters/mobile/launch-metro.cjs +74 -0
  26. package/adapters/mobile/lib/metro-listener.sh +0 -0
  27. package/adapters/mobile/lib/tmux-viewer.sh +4 -4
  28. package/adapters/mobile/open-device.sh +3 -1
  29. package/adapters/mobile/prewarm-bundle.sh +0 -0
  30. package/adapters/mobile/start-metro.sh +12 -18
  31. package/adapters/mobile/stop-metro.sh +1 -0
  32. package/adapters/mobile/verify.sh +5 -5
  33. package/adapters/mobile/wait-for-bridge.sh +0 -0
  34. package/adapters/mobile/yarn-setup.sh +0 -0
  35. package/adapters/shared/activate-repo-node.sh +0 -0
  36. package/adapters/shared/activate-repo-ruby.sh +0 -0
  37. package/adapters/shared/cli-ux.sh +0 -0
  38. package/adapters/shared/ensure-runner-deps.sh +0 -0
  39. package/adapters/shared/harness-path.sh +0 -0
  40. package/adapters/shared/hash-helpers.sh +0 -0
  41. package/adapters/shared/install-repo-deps.sh +29 -0
  42. package/adapters/shared/json-field.sh +0 -0
  43. package/adapters/shared/open-debug.mjs +35 -4
  44. package/adapters/shared/open-log-window.sh +1 -1
  45. package/adapters/shared/reap-checkout-metros.sh +0 -0
  46. package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
  47. package/adapters/shared/resolve-farmslot-ports.sh +0 -0
  48. package/adapters/shared/resolve-slot-ports-core.mjs +72 -1
  49. package/adapters/shared/resolve-slot-ports.mjs +0 -0
  50. package/adapters/shared/resolve-slot-ports.sh +21 -17
  51. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  52. package/adapters/shared/tmux-session.sh +0 -5
  53. package/adapters/shared/tmux-viewer.sh +7 -2
  54. package/dist/adapters/core/surface.js +3 -0
  55. package/dist/adapters/extension/ensure-ready.js +0 -7
  56. package/dist/adapters/extension/runtime-decision.js +93 -3
  57. package/dist/adapters/extension/surface.js +17 -6
  58. package/dist/adapters/mobile/metro-env.js +67 -0
  59. package/dist/adapters/mobile/perps-env.js +101 -0
  60. package/dist/adapters/mobile/prepare.js +32 -14
  61. package/dist/adapters/mobile/runtime-decision.js +21 -2
  62. package/dist/adapters/mobile/source-freshness.js +116 -0
  63. package/dist/adapters/mobile/surface.js +3 -0
  64. package/dist/adapters/resolve-slot-ports.js +4 -0
  65. package/dist/adapters/slot-ports.js +131 -49
  66. package/dist/adapters.js +7 -2
  67. package/dist/checkout-lock.js +72 -0
  68. package/dist/cli.js +2 -0
  69. package/dist/commands/call.js +91 -54
  70. package/dist/commands/check.js +266 -46
  71. package/dist/commands/checklist.js +136 -0
  72. package/dist/commands/debug.js +21 -2
  73. package/dist/commands/doctor.js +187 -12
  74. package/dist/commands/fixtures.js +177 -42
  75. package/dist/commands/launch/extension.js +6 -1
  76. package/dist/commands/launch/index.js +54 -22
  77. package/dist/commands/logs.js +24 -4
  78. package/dist/commands/run-engine.js +223 -7
  79. package/dist/commands/run.js +67 -50
  80. package/dist/commands/shared.js +86 -5
  81. package/dist/commands/stop.js +1 -2
  82. package/dist/doctor.js +39 -17
  83. package/dist/harness.js +5 -2
  84. package/dist/heal-bounds.js +1 -1
  85. package/dist/mm-harness-cli.js +37 -9
  86. package/dist/runner.js +35 -5
  87. package/dist/runtime-context.js +228 -0
  88. package/docs/CLI-SPEC.md +15 -11
  89. package/docs/MENTAL-MODEL.md +6 -6
  90. package/library/actions/extension/perps/perps.mjs +29 -8
  91. package/library/actions/extension/platform/cdp.mjs +128 -28
  92. package/library/actions/extension/ui/navigate.mjs +1 -1
  93. package/library/actions/mobile/perps/perps.mjs +13 -1
  94. package/library/actions/mobile/platform/bridge.mjs +302 -29
  95. package/library/actions/mobile/wallet/ensure_unlocked.mjs +7 -2
  96. package/library/manifests/extension.action-manifest.json +32 -12
  97. package/library/manifests/mobile.action-manifest.json +25 -3
  98. package/package.json +5 -5
  99. package/scripts/completions.sh +7 -4
  100. package/scripts/install-completions.sh +0 -0
@@ -7,6 +7,7 @@ import { recipeRuntimePath, runnerDir } from "../../paths.js";
7
7
  import { getAdapterSurface } from "../../adapters/surface.js";
8
8
  import {
9
9
  ADAPTER_DETECT_NEXT,
10
+ checkoutBusyOut,
10
11
  EXIT,
11
12
  flag,
12
13
  parseFlags,
@@ -23,9 +24,15 @@ import {
23
24
  newHealState,
24
25
  parseHeal
25
26
  } from "../../heal-bounds.js";
26
- import { extensionDepsBlock, extensionRuntimeReusable, launchExtension } from "./extension.js";
27
+ import {
28
+ extensionDepsBlock,
29
+ extensionRuntimeReusable,
30
+ installExtensionDeps,
31
+ launchExtension
32
+ } from "./extension.js";
27
33
  import { launchMobile } from "./mobile.js";
28
34
  import { applyDeviceTargeting } from "../device-target.js";
35
+ import { acquireCheckoutLock } from "../../checkout-lock.js";
29
36
  const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
30
37
  "build",
31
38
  "watch",
@@ -37,6 +44,21 @@ const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
37
44
  ]);
38
45
  const DEFAULT_EXTENSION_DAPP_URL = "https://metamask.github.io/test-dapp/";
39
46
  async function handleLaunch(argv) {
47
+ const { options } = parseFlags(argv, LAUNCH_BOOLEANS);
48
+ const json = flag(options, "json");
49
+ const target = targetOf(options);
50
+ if (!fs.existsSync(target)) return handleLaunchLocked(argv);
51
+ const lock = acquireCheckoutLock(target, "launch");
52
+ if ("message" in lock) {
53
+ return checkoutBusyOut(json, "launch", lock.message, lock.path);
54
+ }
55
+ try {
56
+ return await handleLaunchLocked(argv);
57
+ } finally {
58
+ lock.release();
59
+ }
60
+ }
61
+ async function handleLaunchLocked(argv) {
40
62
  const { positional, options } = parseFlags(argv, LAUNCH_BOOLEANS);
41
63
  const json = flag(options, "json");
42
64
  const target = targetOf(options);
@@ -86,14 +108,14 @@ async function handleLaunch(argv) {
86
108
  if (envResult && "code" in envResult) {
87
109
  return usageOut(json, "launch", envResult.message, "mm-harness launch android --device <adb-serial|device-name>");
88
110
  }
89
- const tier = wantVerify ? "verify" : wantBuild ? "build" : "quick";
111
+ const tier = wantBuild ? "build" : "quick";
90
112
  if (adapter === "extension") {
91
113
  const requestedUrl = typeof options["url"] === "string" && options["url"] ? options["url"] : "";
92
114
  if (requestedUrl || displayMode === "sidepanel") {
93
115
  process.env.EXTENSION_START_URL = requestedUrl || DEFAULT_EXTENSION_DAPP_URL;
94
116
  }
95
117
  }
96
- if (!json && adapter === "extension" && tier !== "verify") {
118
+ if (!json && adapter === "extension") {
97
119
  const modeNote = displayMode === "sidepanel" ? `sidepanel \xB7 dapp ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}` : "fullscreen";
98
120
  const workNote = wantWatch ? "watch only" : tier === "build" ? "clean build" : "quick reuse probe";
99
121
  console.error(
@@ -118,32 +140,35 @@ async function handleLaunch(argv) {
118
140
  exitCode: EXIT.infra
119
141
  });
120
142
  }
121
- if (tier === "verify") {
122
- const verifyCdpPort = Number(process.env.CDP_PORT ?? "");
123
- if (adapter === "extension" && Number.isInteger(verifyCdpPort) && verifyCdpPort > 0) {
124
- const { ensureExtensionReady } = await import("../../adapters/extension/ensure-ready.js");
125
- const convergence = await ensureExtensionReady(target, { cdpPort: verifyCdpPort });
126
- console.error(
127
- `[launch] home-tab convergence: ${convergence.action} (${convergence.homeTabs.before}\u2192${convergence.homeTabs.after} tabs, closed ${convergence.homeTabs.closed})${convergence.ready ? "" : ` \u2014 ${convergence.reasonCode}`}`
128
- );
129
- }
130
- const liveArgs = ["live", "--adapter", adapter, "--target", target];
131
- if (adapter === "mobile" && mobileTarget) liveArgs.push("--platform", mobileTarget);
132
- if (json) liveArgs.push("--json");
133
- return handleHarness(liveArgs);
134
- }
135
143
  if (adapter === "extension") {
136
144
  const willBuild = wantWatch || tier === "build" || !await extensionRuntimeReusable(target);
137
145
  if (willBuild) {
138
146
  const depsBlock = extensionDepsBlock(target);
139
147
  if (depsBlock) {
140
- return usageOut(json, "launch", depsBlock.message, depsBlock.userAction);
148
+ if (heal === "off") {
149
+ return usageOut(json, "launch", depsBlock.message, depsBlock.userAction);
150
+ }
151
+ const recoveryCode2 = "deps.installed";
152
+ state.attemptedRecoveries.push(recoveryCode2);
153
+ const repaired = await installExtensionDeps(target);
154
+ if (repaired.status !== 0 || extensionDepsBlock(target)) {
155
+ return launchFail(json, adapter, mobileTarget, tier, state, {
156
+ code: "DEPENDENCY_INSTALL_FAILED",
157
+ message: depsBlock.message,
158
+ recoverable: false,
159
+ userAction: "install the checkout-pinned Node version, then re-run mm-harness launch",
160
+ originalError: repaired.output,
161
+ exitCode: EXIT.infra
162
+ });
163
+ }
164
+ state.recovered.push(recoveryCode2);
165
+ state.mutations.push({ type: "dependencies", action: "installed", path: target });
141
166
  }
142
167
  }
143
168
  }
144
169
  let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
145
170
  if (attempt.status === 0) {
146
- return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch);
171
+ return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
147
172
  }
148
173
  if (heal === "off") {
149
174
  return launchFail(json, adapter, mobileTarget, tier, state, {
@@ -159,7 +184,7 @@ async function handleLaunch(argv) {
159
184
  const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, json, displayMode);
160
185
  if (rebuildAttempt.status === 0) {
161
186
  state.recovered.push(recoveryCode2);
162
- return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch);
187
+ return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
163
188
  }
164
189
  return launchFail(json, adapter, mobileTarget, tier, state, {
165
190
  code: "EXTENSION_RELAUNCH_FAILED",
@@ -195,7 +220,7 @@ async function handleLaunch(argv) {
195
220
  attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
196
221
  if (attempt.status === 0) {
197
222
  state.recovered.push(recoveryCode);
198
- return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch);
223
+ return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
199
224
  }
200
225
  return launchFail(json, adapter, mobileTarget, tier, state, {
201
226
  code: "SAME_RECOVERY_TWICE",
@@ -303,7 +328,7 @@ async function executeComposition(adapter, mobileTarget, tier, wantWatch, target
303
328
  }
304
329
  return launchExtension(target, tier, wantWatch, displayMode);
305
330
  }
306
- async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, sidepanelRecoveryAllowed = true) {
331
+ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true) {
307
332
  if (adapter === "extension" && displayMode === "sidepanel") {
308
333
  const sidepanelSh = path.join(runnerDir, "adapters/extension/sidepanel-toggle.sh");
309
334
  const sidepanelArgs = ["open"];
@@ -330,6 +355,7 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
330
355
  target,
331
356
  state,
332
357
  wantWatch,
358
+ wantVerify,
333
359
  false
334
360
  );
335
361
  }
@@ -351,6 +377,12 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
351
377
  });
352
378
  }
353
379
  }
380
+ if (wantVerify) {
381
+ const verifyArgs = ["verify", "--adapter", adapter, "--target", target];
382
+ if (adapter === "mobile" && mobileTarget) verifyArgs.push("--platform", mobileTarget);
383
+ if (json) verifyArgs.push("--json");
384
+ return handleHarness(verifyArgs);
385
+ }
354
386
  return launchPass(json, adapter, mobileTarget, tier, displayMode, state);
355
387
  }
356
388
  function launchPass(json, adapter, mobileTarget, tier, displayMode, state) {
@@ -17,10 +17,11 @@ async function handleLogs(argv) {
17
17
  return usageOut(json, "logs", "core is headless; it has no dev server logs.", surface.hints.launch);
18
18
  }
19
19
  const logSources = surface.logSources(target);
20
+ const appLogSource = surface.appLogSource(target);
20
21
  const sourceLabels = logSources.map((entry) => entry.label);
21
22
  const defaultSource = sourceLabels[0];
22
23
  const source = str(options, "source") ?? defaultSource;
23
- const validSources = [...sourceLabels, "app"];
24
+ const validSources = [...sourceLabels, ...appLogSource ? ["app"] : []];
24
25
  if (!validSources.includes(source)) {
25
26
  return usageOut(json, "logs", `--source must be one of: ${validSources.join(", ")}.`, `mm-harness logs --source ${defaultSource}`);
26
27
  }
@@ -31,11 +32,30 @@ async function handleLogs(argv) {
31
32
  }
32
33
  process.env.RECIPE_LOG_EVENTS = events;
33
34
  }
34
- const requested = logSources.find((entry) => entry.label === source);
35
- const ordered = requested ? [requested, ...logSources.filter((entry) => entry !== requested)] : logSources;
35
+ const requested = source === "app" ? appLogSource : logSources.find((entry) => entry.label === source);
36
+ const ordered = source === "app" ? requested ? [requested] : [] : requested ? [requested, ...logSources.filter((entry) => entry !== requested)] : logSources;
36
37
  const logFile = ordered.find((entry) => fs.existsSync(entry.path))?.path;
37
38
  if (!logFile) {
38
- const names = logSources.map((entry) => path.basename(entry.path)).join(" / ");
39
+ if (source === "app" && appLogSource) {
40
+ const message = `application console unavailable for this checkout (${path.basename(appLogSource.path)} is absent).`;
41
+ const userAction = surface.hints.launch;
42
+ if (json) {
43
+ console.log(JSON.stringify({
44
+ schemaVersion: 1,
45
+ command: "logs",
46
+ adapter,
47
+ source,
48
+ status: "fail",
49
+ error: { code: "APP_LOG_UNAVAILABLE", message, userAction },
50
+ exitCode: EXIT.runtime
51
+ }, null, 2));
52
+ } else {
53
+ console.error(`\u2717 logs: ${message}`);
54
+ console.error(` Next: ${userAction}`);
55
+ }
56
+ return EXIT.runtime;
57
+ }
58
+ const names = source === "app" && appLogSource ? path.basename(appLogSource.path) : logSources.map((entry) => path.basename(entry.path)).join(" / ");
39
59
  return usageOut(
40
60
  json,
41
61
  "logs",
@@ -298,6 +298,27 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
298
298
  }
299
299
  const validation = manifestOk ? await validateRecipeAdapterAware(recipe, manifest, librarySources) : { status: "invalid", findings: [], summary: { errors: 1, warnings: 0 } };
300
300
  findings.push(...validation.findings);
301
+ if (isRecord(recipe)) {
302
+ const validate = isRecord(recipe.validate) ? recipe.validate : void 0;
303
+ const workflow = validate && isRecord(validate.workflow) ? validate.workflow : void 0;
304
+ if (workflow?.startState != null) {
305
+ findings.push({
306
+ severity: "error",
307
+ code: "recipe.start-state-placement",
308
+ path: "validate.workflow.startState",
309
+ message: "startState must be declared at the recipe root, not validate.workflow.startState"
310
+ });
311
+ }
312
+ const startState = isRecord(recipe.startState) ? recipe.startState : void 0;
313
+ if (adapter === "extension" && startState?.action === "metamask.perps.start_state" && startState.page === false) {
314
+ findings.push({
315
+ severity: "error",
316
+ code: "recipe.extension-perps-start-page",
317
+ path: "startState.page",
318
+ message: "Extension metamask.perps.start_state must enter Perps; omit page or provide a string target instead of false"
319
+ });
320
+ }
321
+ }
301
322
  const errorCount = findings.filter((finding) => finding.severity === "error").length;
302
323
  return {
303
324
  recipe,
@@ -305,7 +326,7 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
305
326
  findings,
306
327
  errorCount,
307
328
  manifestOk,
308
- schemaValid: validation.status === "valid",
329
+ schemaValid: validation.status === "valid" && errorCount === 0,
309
330
  ...librarySources ? { librarySources } : {}
310
331
  };
311
332
  }
@@ -369,11 +390,12 @@ async function runOneNode(adapter, action, args, target, actionManifest) {
369
390
  fs.writeFileSync(recipeFile, `${JSON.stringify(recipe, null, 2)}
370
391
  `);
371
392
  const result = await runRecipe(adapter, recipeFile, path.join(scratch, "artifacts"), target, actionManifest, {
372
- librarySources: await resolveMetaMaskLibrarySources(void 0)
393
+ librarySources: await resolveMetaMaskLibrarySources(void 0),
394
+ stdoutIsMachineContract: true
373
395
  });
374
396
  return { status: result.status === "pass" ? "pass" : "fail" };
375
397
  }
376
- async function prepareHeal(adapter, target, options, json) {
398
+ async function prepareHeal(adapter, target, options, json, prepareOptions = {}) {
377
399
  if (recipeRunning(target)) {
378
400
  const msg = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
379
401
  if (json) {
@@ -407,13 +429,203 @@ async function prepareHeal(adapter, target, options, json) {
407
429
  }
408
430
  return EXIT.infra;
409
431
  }
432
+ if (adapter === "extension") {
433
+ const previousCdpPort = process.env.CDP_PORT;
434
+ const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
435
+ const previousWatcherPort = process.env.WATCHER_PORT;
436
+ const explicitCdpPort = optionString(options, "cdpPort");
437
+ const explicitWatcherPort = optionString(options, "watcherPort") ?? optionString(options, "metroPort");
438
+ if (explicitCdpPort) {
439
+ process.env.CDP_PORT = explicitCdpPort;
440
+ process.env.RECIPE_CDP_PORT = explicitCdpPort;
441
+ }
442
+ if (explicitWatcherPort) process.env.WATCHER_PORT = explicitWatcherPort;
443
+ try {
444
+ const current = await ensureExtensionProofRuntime(target, heal, state, json);
445
+ if (current !== null) return current;
446
+ } finally {
447
+ restoreEnv("CDP_PORT", previousCdpPort);
448
+ restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
449
+ restoreEnv("WATCHER_PORT", previousWatcherPort);
450
+ }
451
+ }
452
+ if (adapter === "mobile" && prepareOptions.skipMobileSourceFreshness !== true) {
453
+ const current = await ensureMobileProofRuntime(target, heal, state, json);
454
+ if (current !== null) return current;
455
+ }
410
456
  return { state, heal };
411
457
  }
458
+ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
459
+ const source = await import("../adapters/mobile/source-freshness.js");
460
+ const check = deps.check ?? (() => source.mobileSourceCheck(target));
461
+ const record = deps.record ?? ((fingerprint) => source.recordMobileSourceBaseline(target, fingerprint));
462
+ const initial = check();
463
+ if (initial.status === "unavailable") {
464
+ if (json) {
465
+ console.log(JSON.stringify({
466
+ schemaVersion: 1,
467
+ status: "fail",
468
+ recoverable: false,
469
+ error: initial.error
470
+ }, null, 2));
471
+ } else {
472
+ console.error(`\u2717 ${initial.error.message}`);
473
+ console.error(` Next: ${initial.error.userAction}`);
474
+ }
475
+ return EXIT.usage;
476
+ }
477
+ if (initial.status === "current") return null;
478
+ const targetArg = JSON.stringify(path.resolve(target));
479
+ const userAction = `mm-harness call app.lifecycle --arg command=restart --adapter mobile --target ${targetArg}`;
480
+ if (heal === "off") {
481
+ const message = `Mobile loaded source is not current (${initial.status}).`;
482
+ if (json) {
483
+ console.log(JSON.stringify({
484
+ schemaVersion: 1,
485
+ status: "fail",
486
+ recoverable: true,
487
+ mutations: state.mutations,
488
+ error: { code: "MOBILE_SOURCE_NOT_LOADED", message, userAction }
489
+ }, null, 2));
490
+ } else {
491
+ console.error(`\u2717 ${message}`);
492
+ console.error(` Next: ${userAction}`);
493
+ }
494
+ return EXIT.infra;
495
+ }
496
+ const recoveryCode = "mobile.source_reloaded";
497
+ state.attemptedRecoveries.push(recoveryCode);
498
+ if (!json) console.error(`\u2192 Mobile proof preflight: source ${initial.status}; restarting the app before execution`);
499
+ const restart = deps.restart ?? (async () => {
500
+ const result2 = await runOneNode(
501
+ "mobile",
502
+ "app.lifecycle",
503
+ { command: "restart" },
504
+ target,
505
+ void 0
506
+ );
507
+ return { status: result2.status === "pass" ? 0 : EXIT.runtime, output: result2.status };
508
+ });
509
+ const result = await restart();
510
+ const recorded = result.status === 0 && record(initial.fingerprint);
511
+ const verified = recorded ? check() : initial;
512
+ if (result.status !== 0 || !recorded || verified.status !== "current") {
513
+ const message = "Mobile source restart did not produce a current loaded-source baseline.";
514
+ if (json) {
515
+ console.log(JSON.stringify({
516
+ schemaVersion: 1,
517
+ status: "fail",
518
+ recoverable: false,
519
+ attemptedRecoveries: state.attemptedRecoveries,
520
+ error: { code: "MOBILE_SOURCE_RELOAD_FAILED", message, userAction, originalError: result.output }
521
+ }, null, 2));
522
+ } else {
523
+ console.error(`\u2717 ${message}`);
524
+ console.error(` Next: ${userAction}`);
525
+ }
526
+ return EXIT.infra;
527
+ }
528
+ state.recovered.push(recoveryCode);
529
+ state.mutations.push({ type: "runtime", action: "restarted", reasonCode: `source-${initial.status}` });
530
+ return null;
531
+ }
532
+ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {}) {
533
+ const decide = deps.decide ?? (async () => {
534
+ const { decideExtensionReadiness } = await import("../adapters/extension/runtime-decision.js");
535
+ const port = Number(process.env.CDP_PORT);
536
+ return decideExtensionReadiness(target, {
537
+ ...Number.isInteger(port) && port > 0 ? { cdpPort: port } : {},
538
+ pageMode: "home-or-sidepanel"
539
+ });
540
+ });
541
+ const initial = await decide();
542
+ if (initial.decision === "ready") return null;
543
+ if (initial.reasonCode === "runtime-dist-check-failed") {
544
+ const message = `Extension proof runtime freshness could not be checked: ${initial.reasons[0] ?? initial.decision}`;
545
+ const userAction2 = `mm-harness doctor --adapter extension --target ${JSON.stringify(path.resolve(target))}`;
546
+ if (json) {
547
+ console.log(JSON.stringify({
548
+ schemaVersion: 1,
549
+ status: "fail",
550
+ recoverable: false,
551
+ error: { code: "EXTENSION_RUNTIME_CHECK_FAILED", message, userAction: userAction2 }
552
+ }, null, 2));
553
+ } else {
554
+ console.error(`\u2717 ${message}`);
555
+ console.error(` Next: ${userAction2}`);
556
+ }
557
+ return EXIT.infra;
558
+ }
559
+ if (!["dist-stale", "dist-missing", "runtime-dist-stale", "runtime-dist-missing"].includes(initial.reasonCode)) {
560
+ return null;
561
+ }
562
+ const tier = initial.decision === "build" ? "build" : "quick";
563
+ const canHeal = heal === "auto" || heal === "infra-only" && tier === "quick";
564
+ const targetArg = JSON.stringify(path.resolve(target));
565
+ const userAction = tier === "build" ? `mm-harness launch --build --verify --adapter extension --target ${targetArg}` : `mm-harness launch --verify --adapter extension --target ${targetArg}`;
566
+ if (!canHeal) {
567
+ const message = `Extension proof runtime is not current (${initial.reasonCode}): ${initial.reasons[0] ?? initial.decision}`;
568
+ if (json) {
569
+ console.log(JSON.stringify({
570
+ schemaVersion: 1,
571
+ status: "fail",
572
+ recoverable: true,
573
+ mutations: state.mutations,
574
+ error: { code: "EXTENSION_RUNTIME_NOT_CURRENT", message, userAction }
575
+ }, null, 2));
576
+ } else {
577
+ console.error(`\u2717 ${message}`);
578
+ console.error(` Next: ${userAction}`);
579
+ }
580
+ return EXIT.infra;
581
+ }
582
+ const recoveryCode = tier === "build" ? "extension.runtime_rebuilt" : "extension.runtime_reloaded";
583
+ state.attemptedRecoveries.push(recoveryCode);
584
+ if (!json) console.error(`\u2192 Extension proof preflight: ${initial.reasonCode}; ${tier === "build" ? "rebuilding" : "reloading"} before execution`);
585
+ const launch = deps.launch ?? (async (requestedTier) => {
586
+ const { launchExtension } = await import("./launch/extension.js");
587
+ return launchExtension(target, requestedTier, false);
588
+ });
589
+ const result = await launch(tier);
590
+ const verified = result.status === 0 ? await decide() : initial;
591
+ if (result.status !== 0 || verified.decision !== "ready") {
592
+ const message = `Extension runtime recovery did not produce a current healthy runtime (${verified.reasonCode}).`;
593
+ if (json) {
594
+ console.log(JSON.stringify({
595
+ schemaVersion: 1,
596
+ status: "fail",
597
+ recoverable: false,
598
+ attemptedRecoveries: state.attemptedRecoveries,
599
+ error: { code: "EXTENSION_RUNTIME_RECOVERY_FAILED", message, userAction, originalError: result.output }
600
+ }, null, 2));
601
+ } else {
602
+ console.error(`\u2717 ${message}`);
603
+ console.error(` Next: ${userAction}`);
604
+ }
605
+ return EXIT.infra;
606
+ }
607
+ state.recovered.push(recoveryCode);
608
+ state.mutations.push({ type: "runtime", action: tier === "build" ? "rebuilt" : "reloaded", reasonCode: initial.reasonCode });
609
+ return null;
610
+ }
412
611
  const RUN_RECOVERY_CODE = {
413
- mobile: "metro.restarted",
414
- extension: "chrome.reopened",
415
- core: "runtime.reset"
612
+ mobile: "mobile.runtime_recovered",
613
+ extension: "extension.runtime_recovered",
614
+ core: "run.retried"
416
615
  };
616
+ async function recoverRunInfra(adapter, target, json) {
617
+ getAdapterSurface(adapter).resolveSlotPorts(target);
618
+ if (adapter === "extension") {
619
+ const { launchExtension } = await import("./launch/extension.js");
620
+ return launchExtension(target, "quick", false);
621
+ }
622
+ if (adapter === "mobile") {
623
+ const { launchMobile } = await import("./launch/mobile.js");
624
+ const platform = process.env.PLATFORM === "android" ? "android" : "ios";
625
+ return launchMobile(target, platform, "quick", json);
626
+ }
627
+ return { status: 0, output: "headless run retry" };
628
+ }
417
629
  function readRunFailureText(result) {
418
630
  try {
419
631
  const trace = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
@@ -423,7 +635,7 @@ function readRunFailureText(result) {
423
635
  return "";
424
636
  }
425
637
  }
426
- async function executeWithHealBounds(exec, adapter, target, heal, state) {
638
+ async function executeWithHealBounds(exec, adapter, target, heal, state, recover = () => recoverRunInfra(adapter, target, false)) {
427
639
  let result = await exec();
428
640
  for (; ; ) {
429
641
  if (result.status === "pass") return { result, violation: null };
@@ -432,6 +644,7 @@ async function executeWithHealBounds(exec, adapter, target, heal, state) {
432
644
  if (heal === "off") return { result, violation: null };
433
645
  const recoveryCode = RUN_RECOVERY_CODE[adapter];
434
646
  state.attemptedRecoveries.push(recoveryCode);
647
+ await recover();
435
648
  result = await exec();
436
649
  if (result.status === "pass") {
437
650
  state.recovered.push(recoveryCode);
@@ -488,8 +701,11 @@ function countRecipeNodes(recipe) {
488
701
  export {
489
702
  countRecipeNodes,
490
703
  emitHealViolation,
704
+ ensureExtensionProofRuntime,
705
+ ensureMobileProofRuntime,
491
706
  executeWithHealBounds,
492
707
  prepareHeal,
708
+ recoverRunInfra,
493
709
  resolveMetaMaskLibrarySources,
494
710
  resolveRunRecipeArg,
495
711
  runOneNode,
@@ -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 {