@deeeed/metamask-harness 0.17.4 → 0.18.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 (93) hide show
  1. package/CHANGELOG.md +775 -0
  2. package/README.md +65 -71
  3. package/adapters/extension/ensure-browser.sh +12 -1
  4. package/adapters/extension/inject.mjs +7 -0
  5. package/adapters/extension/launch-browser.cjs +10 -3
  6. package/adapters/extension/lib/chrome-args.cjs +31 -0
  7. package/adapters/extension/lib/macos-focus.cjs +32 -0
  8. package/adapters/extension/live.sh +10 -20
  9. package/adapters/manifest.json +8 -0
  10. package/adapters/mobile/open-device.sh +45 -7
  11. package/adapters/mobile/verify.sh +15 -3
  12. package/adapters/shared/harness-source-fingerprint.mjs +49 -0
  13. package/adapters/shared/install-repo-deps.sh +1 -5
  14. package/adapters/shared/open-debug.mjs +184 -103
  15. package/adapters/shared/resolve-slot-ports-core.mjs +23 -6
  16. package/adapters/shared/resolve-slot-ports.sh +22 -5
  17. package/bin/mm-harness +15 -3
  18. package/dist/adapters/core/surface.js +4 -1
  19. package/dist/adapters/extension/console-capture.js +3 -1
  20. package/dist/adapters/extension/harness-freshness.js +39 -0
  21. package/dist/adapters/extension/product-config.js +110 -0
  22. package/dist/adapters/extension/runtime-decision.js +20 -71
  23. package/dist/adapters/extension/surface.js +19 -1
  24. package/dist/adapters/mobile/prepare.js +17 -0
  25. package/dist/adapters/mobile/source-freshness.js +26 -41
  26. package/dist/adapters/mobile/surface.js +4 -1
  27. package/dist/adapters/resolve-slot-ports.js +2 -0
  28. package/dist/adapters/slot-ports.js +13 -32
  29. package/dist/adapters.js +50 -17
  30. package/dist/checkout-lock.js +27 -2
  31. package/dist/cli-color.js +19 -0
  32. package/dist/cli-commands.js +1 -1
  33. package/dist/cli.js +2 -3
  34. package/dist/command-contract.js +13 -3
  35. package/dist/commands/call.js +115 -29
  36. package/dist/commands/checklist.js +4 -1
  37. package/dist/commands/completion-candidates.js +20 -13
  38. package/dist/commands/debug.js +31 -38
  39. package/dist/commands/doctor.js +33 -6
  40. package/dist/commands/fixtures.js +65 -17
  41. package/dist/commands/flows.js +39 -10
  42. package/dist/commands/launch/extension.js +40 -15
  43. package/dist/commands/launch/index.js +41 -5
  44. package/dist/commands/list-executables.js +151 -29
  45. package/dist/commands/manifest.js +127 -18
  46. package/dist/commands/parse-args.js +11 -1
  47. package/dist/commands/run-engine.js +384 -56
  48. package/dist/commands/run.js +112 -17
  49. package/dist/commands/shared.js +22 -2
  50. package/dist/commands/status-probe.js +3 -0
  51. package/dist/commands/status.js +1 -0
  52. package/dist/completions-cache.js +1 -1
  53. package/dist/doctor.js +56 -6
  54. package/dist/harness.js +6 -5
  55. package/dist/heal-bounds.js +1 -1
  56. package/dist/live-adapter-contract.js +132 -12
  57. package/dist/manifest.js +161 -1
  58. package/dist/mm-harness-cli.js +13 -7
  59. package/dist/recipe-security.js +178 -0
  60. package/dist/runner.js +87 -13
  61. package/dist/runtime-context.js +8 -26
  62. package/docs/CONTRIBUTING.md +137 -0
  63. package/docs/QA.md +185 -0
  64. package/docs/RECIPES.md +161 -0
  65. package/docs/SECURITY.md +88 -0
  66. package/library/actions/core/perps/read_account.mjs +2 -2
  67. package/library/actions/core/perps/read_orders.mjs +2 -1
  68. package/library/actions/core/perps/read_positions.mjs +2 -1
  69. package/library/actions/core/wallet/list_accounts.mjs +95 -0
  70. package/library/actions/extension/platform/cdp.mjs +1 -0
  71. package/library/actions/extension/wallet/list_accounts.mjs +41 -0
  72. package/library/actions/mobile/platform/bridge.mjs +1 -5
  73. package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
  74. package/library/manifests/core.action-manifest.json +61 -0
  75. package/library/manifests/extension.action-manifest.json +53 -0
  76. package/library/manifests/mobile.action-manifest.json +53 -0
  77. package/library/recipes/runner/action-validation.extension.recipe.json +8 -1
  78. package/library/recipes/runner/action-validation.mobile.recipe.json +8 -1
  79. package/package.json +10 -6
  80. package/scripts/completions.sh +7 -7
  81. package/docs/ADAPTER-SURFACE.md +0 -26
  82. package/docs/CHEATSHEET.md +0 -48
  83. package/docs/CLI-ERGONOMICS-AUDIT.md +0 -32
  84. package/docs/CLI-ERGONOMICS-HUMAN-QA.md +0 -64
  85. package/docs/CODE-MAP.md +0 -62
  86. package/docs/UX-PRINCIPLES.md +0 -66
  87. package/docs/VIDEO-DEMO-VALIDATION.md +0 -74
  88. package/docs/architecture.md +0 -88
  89. package/docs/live-adapter-contract.md +0 -190
  90. package/docs/package-boundaries.md +0 -47
  91. package/docs/perps-flow-catalog.md +0 -235
  92. package/docs/recipe-libraries.md +0 -71
  93. package/docs/runtime-file-conventions.md +0 -36
@@ -1,4 +1,4 @@
1
- import { loadActionManifest } from "../manifest.js";
1
+ import { resolveActionManifest } from "../manifest.js";
2
2
  import {
3
3
  invalidateCompletionCache,
4
4
  readFreshCandidates,
@@ -13,13 +13,13 @@ import {
13
13
  resolveAdapter,
14
14
  targetPath
15
15
  } from "./parse-args.js";
16
- import { resolveMetaMaskLibrarySources } from "./run-engine.js";
16
+ import { listRunnableRecipes, resolveMetaMaskLibrarySources } from "./run-engine.js";
17
17
  async function handleCompletionCandidates(argv) {
18
18
  const kind = argv[0];
19
19
  const { options } = parseArgs(argv.slice(1), "completion-candidates");
20
20
  const json = optionFlag(options, "json");
21
- if (kind !== "actions" && kind !== "flows") {
22
- console.error("completion-candidates requires <actions|flows>.");
21
+ if (kind !== "actions" && kind !== "flows" && kind !== "recipes") {
22
+ console.error("completion-candidates requires <actions|flows|recipes>.");
23
23
  return EXIT.usage;
24
24
  }
25
25
  const target = targetPath(options);
@@ -27,17 +27,15 @@ async function handleCompletionCandidates(argv) {
27
27
  let candidates;
28
28
  if (kind === "actions") {
29
29
  adapter = resolveAdapter(options).adapter;
30
- candidates = readFreshCandidates(target, "actions");
31
- if (!candidates) {
32
- const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
33
- const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
34
- candidates = getRecipeActionManifestActionNames(manifest);
35
- writeCompletionCandidates(target, "actions", candidates);
36
- }
37
- } else {
30
+ const sources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
31
+ const { manifest } = await resolveActionManifest(adapter, optionString(options, "actionManifest"), sources);
32
+ const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
33
+ candidates = getRecipeActionManifestActionNames(manifest);
34
+ writeCompletionCandidates(target, "actions", candidates);
35
+ } else if (kind === "flows") {
38
36
  candidates = readFreshCandidates(target, "flows");
39
37
  if (!candidates) {
40
- const sources = await resolveMetaMaskLibrarySources(void 0).catch(() => void 0);
38
+ const sources = await resolveMetaMaskLibrarySources(optionString(options, "library")).catch(() => void 0);
41
39
  if (!sources) {
42
40
  candidates = [];
43
41
  } else {
@@ -47,6 +45,15 @@ async function handleCompletionCandidates(argv) {
47
45
  writeCompletionCandidates(target, "flows", candidates);
48
46
  }
49
47
  }
48
+ } else {
49
+ adapter = resolveAdapter(options).adapter;
50
+ const cacheKey = `recipes:${adapter}`;
51
+ candidates = readFreshCandidates(target, cacheKey);
52
+ if (!candidates) {
53
+ const sources = await resolveMetaMaskLibrarySources(optionString(options, "library")).catch(() => void 0);
54
+ candidates = sources ? listRunnableRecipes(adapter, sources).map((recipe) => recipe.name) : [];
55
+ writeCompletionCandidates(target, cacheKey, candidates);
56
+ }
50
57
  }
51
58
  if (json) console.log(JSON.stringify({ schemaVersion: 1, kind, adapter, candidates }, null, 2));
52
59
  else for (const candidate of candidates) console.log(candidate);
@@ -1,8 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { runnerDir } from "../paths.js";
3
- import { acquireCheckoutLock } from "../checkout-lock.js";
4
3
  import { getAdapterSurface } from "../adapters/surface.js";
5
- import { ADAPTER_DETECT_NEXT, checkoutBusyOut, EXIT, flag, parseFlags, resolveAdapter, spawnScript, targetOf, usageOut } from "./shared.js";
4
+ import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, targetOf, usageOut } from "./shared.js";
6
5
  const DEBUG_BOOLEANS = /* @__PURE__ */ new Set(["worker", "devMenu", "json", "noOpen"]);
7
6
  async function handleDebug(argv) {
8
7
  const { options } = parseFlags(argv, DEBUG_BOOLEANS);
@@ -32,43 +31,37 @@ async function handleDebug(argv) {
32
31
  "mm-harness debug --worker"
33
32
  );
34
33
  }
35
- const lock = acquireCheckoutLock(target, "debug");
36
- if ("message" in lock) return checkoutBusyOut(json, "debug", lock.message, lock.path);
37
- try {
38
- const openDebug = path.join(runnerDir, "adapters/shared/open-debug.mjs");
39
- const debugArgs = ["--adapter", adapter];
40
- if (adapter === "extension") {
41
- if (process.env.CDP_PORT) debugArgs.push("--cdp-port", process.env.CDP_PORT);
42
- debugArgs.push("--target", worker ? "worker" : "page");
43
- } else {
44
- if (process.env.WATCHER_PORT) debugArgs.push("--port", process.env.WATCHER_PORT);
45
- debugArgs.push("--action", devMenu ? "dev-menu" : "debug");
46
- }
47
- if (json) debugArgs.push("--json");
48
- if (noOpen) debugArgs.push("--no-open");
49
- const result = spawnScript(process.execPath, [openDebug, ...debugArgs], target, json);
50
- if (json) {
51
- const leaf = parseDebugLeaf(result.output);
52
- console.log(
53
- JSON.stringify(
54
- {
55
- ...leaf,
56
- schemaVersion: 1,
57
- command: "debug",
58
- adapter,
59
- mode: worker ? "worker" : devMenu ? "dev-menu" : "default",
60
- opened: leaf.opened === true,
61
- exitCode: result.status === 0 ? EXIT.ok : EXIT.runtime
62
- },
63
- null,
64
- 2
65
- )
66
- );
67
- }
68
- return result.status === 0 ? EXIT.ok : EXIT.runtime;
69
- } finally {
70
- lock.release();
34
+ const openDebug = path.join(runnerDir, "adapters/shared/open-debug.mjs");
35
+ const debugArgs = ["--adapter", adapter];
36
+ if (adapter === "extension") {
37
+ if (process.env.CDP_PORT) debugArgs.push("--cdp-port", process.env.CDP_PORT);
38
+ debugArgs.push("--target", worker ? "worker" : "page");
39
+ } else {
40
+ if (process.env.WATCHER_PORT) debugArgs.push("--port", process.env.WATCHER_PORT);
41
+ debugArgs.push("--action", devMenu ? "dev-menu" : "debug");
42
+ }
43
+ if (json) debugArgs.push("--json");
44
+ if (noOpen) debugArgs.push("--no-open");
45
+ const result = spawnScript(process.execPath, [openDebug, ...debugArgs], target, json);
46
+ if (json) {
47
+ const leaf = parseDebugLeaf(result.output);
48
+ console.log(
49
+ JSON.stringify(
50
+ {
51
+ ...leaf,
52
+ schemaVersion: 1,
53
+ command: "debug",
54
+ adapter,
55
+ mode: worker ? "worker" : devMenu ? "dev-menu" : "default",
56
+ opened: leaf.opened === true,
57
+ exitCode: result.status === 0 ? EXIT.ok : EXIT.runtime
58
+ },
59
+ null,
60
+ 2
61
+ )
62
+ );
71
63
  }
64
+ return result.status === 0 ? EXIT.ok : EXIT.runtime;
72
65
  }
73
66
  function parseDebugLeaf(output) {
74
67
  try {
@@ -7,6 +7,7 @@ import { createDoctorReport, fixtureSummary, renderRuntimeContext, requiredDocto
7
7
  import { detectAdapter, resolveRuntimeContextPath } from "../harness.js";
8
8
  import { assertAdapter, runnerDir } from "../paths.js";
9
9
  import { getAdapterSurface } from "../adapters/surface.js";
10
+ import { extensionProductConfigBlock } from "../adapters/extension/product-config.js";
10
11
  import { ensureMobilePerpsEnvironment, mobilePerpsEnvironment } from "../adapters/mobile/perps-env.js";
11
12
  import { loadActionManifest, validateManifest } from "../manifest.js";
12
13
  import { ensureOverlay, newHealState, recipeRunning } from "../heal-bounds.js";
@@ -96,7 +97,19 @@ async function handleDoctor({ options }) {
96
97
  lock.release();
97
98
  }
98
99
  const result2 = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
100
+ let runtime2;
101
+ let runtimeProbeError2;
102
+ try {
103
+ runtime2 = await surface.runtimeStatus(target);
104
+ } catch (error2) {
105
+ runtimeProbeError2 = error2 instanceof Error ? error2.message : String(error2);
106
+ }
107
+ const ready = runtime2?.decision === "ready";
108
+ if (adapter === "extension" && runtime2?.reasonCode === "product-config-required") {
109
+ failed.push("extension-product-config");
110
+ }
99
111
  const nextActions = doctorFixNextActions(failed, result2.fixture, adapter, target);
112
+ if (failed.length === 0 && !ready && runtime2?.nextAction) nextActions.push(runtime2.nextAction);
100
113
  const status = result2.status === "pass" && failed.length === 0 ? "pass" : "fail";
101
114
  const retryDoctor = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
102
115
  const fixUserAction = failed.includes("wallet-fixture") ? `choose one wallet fixture source from nextActions, run it, then retry: ${retryDoctor}` : nextActions[0] ?? retryDoctor;
@@ -105,9 +118,15 @@ async function handleDoctor({ options }) {
105
118
  message: "doctor --fix could not make every required check pass",
106
119
  userAction: fixUserAction
107
120
  } : void 0;
108
- if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, nextActions, ...error ? { error } : {} }, null, 2));
121
+ if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, ready, runtime: runtime2, ...runtimeProbeError2 ? { runtimeProbeError: runtimeProbeError2 } : {}, nextActions, ...error ? { error } : {} }, null, 2));
109
122
  else {
110
- console.log(`${status} ${adapter} ${result2.compatibilityMode} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
123
+ console.log(`${status} ${adapter} ${result2.compatibilityMode} ready=${ready} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
124
+ if (runtime2) {
125
+ console.log(`runtime: decision=${runtime2.decision}${runtime2.reasonCode ? ` (${runtime2.reasonCode})` : ""}`);
126
+ for (const reason of runtime2.reasons) console.log(` ${reason}`);
127
+ } else if (runtimeProbeError2) {
128
+ console.log(`runtime probe failed: ${runtimeProbeError2}`);
129
+ }
111
130
  for (const action of nextActions) console.error(` Next: ${action}`);
112
131
  }
113
132
  return status === "pass" ? 0 : 1;
@@ -161,11 +180,15 @@ async function handleDoctor({ options }) {
161
180
  }
162
181
  const doctorUserAction = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
163
182
  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));
183
+ const next = doctorError?.userAction ?? (runtime?.decision === "ready" ? void 0 : runtime?.nextAction);
184
+ if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, orphanMetros, capture, devices, additionalReachableDevices, ...next ? { next } : {}, ...doctorError ? { error: doctorError } : {} }, null, 2));
165
185
  else {
166
186
  const out = (style, text) => color(style, text, { stream: process.stdout });
167
187
  const stateStyle = (value, good) => value === good ? "ok" : "warn";
168
188
  console.log(`${out(doctorResult.status === "pass" ? "ok" : "err", doctorResult.status)} ${out("bold", adapter)} ${doctorResult.compatibilityMode} ${out("dim", `manifest=${actionManifestPath}`)}`);
189
+ console.log(
190
+ `${out("label", "harness:")} ${doctorResult.runner.packageName}@${doctorResult.runner.version} ${out("accent", doctorResult.runner.installKind)} ${out("dim", `executable=${doctorResult.runner.executablePath}`)}`
191
+ );
169
192
  if (runtime) {
170
193
  const provisionedDepsPending = runtime.reasonCode === "app-installed-deps-pending";
171
194
  const decisionStyle = provisionedDepsPending ? "info" : runtime.decision === "ready" ? "ok" : runtime.decision === "blocked" ? "err" : "warn";
@@ -198,7 +221,7 @@ async function handleDoctor({ options }) {
198
221
  if (additionalReachableDevices.length > 0) renderAdditionalReachableDevices(additionalReachableDevices, out);
199
222
  if (liveView) renderMobileLiveBlock(deviceView?.devices ?? [], liveView.liveMap, out);
200
223
  }
201
- if (doctorError) console.log(` ${out("dim", `Next: ${doctorUserAction}`)}`);
224
+ if (next) console.log(` ${out("dim", `Next: ${next}`)}`);
202
225
  }
203
226
  return doctorResult.status === "pass" ? 0 : 1;
204
227
  }
@@ -236,6 +259,10 @@ function doctorFixNextActions(failed, fixture, adapter, target) {
236
259
  );
237
260
  }
238
261
  if (failed.includes("overlay")) actions.push(`${executable} install ${scope}`);
262
+ if (adapter === "extension" && failed.includes("extension-product-config")) {
263
+ const block = extensionProductConfigBlock(target);
264
+ if (block) actions.push(block.userAction);
265
+ }
239
266
  return actions;
240
267
  }
241
268
  function emitExpectLiveVerdict(adapter, target, runtime, verdict) {
@@ -243,7 +270,7 @@ function emitExpectLiveVerdict(adapter, target, runtime, verdict) {
243
270
  const detail = runtime ? `${runtime.decision}${runtime.reasonCode ? ` (${runtime.reasonCode})` : ""}` : "runtime probe unavailable";
244
271
  console.error(`${out("err", verdict)} ${out("bold", adapter)} ${detail} ${out("dim", target)}`);
245
272
  for (const reason of runtime?.reasons ?? []) console.error(` ${out("dim", reason)}`);
246
- console.error(` Next: ${getAdapterSurface(adapter).hints.relaunch}`);
273
+ console.error(` Next: ${runtime?.nextAction ?? getAdapterSurface(adapter).hints.runtimeProbeRecovery(target)}`);
247
274
  }
248
275
  function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json, printReady) {
249
276
  const live = runtime?.decision === "ready";
@@ -261,7 +288,7 @@ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture,
261
288
  return EXIT.runtime;
262
289
  }
263
290
  if (json) {
264
- 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`;
291
+ const userAction = runtime?.nextAction ?? getAdapterSurface(adapter).hints.runtimeProbeRecovery(target);
265
292
  const error = live ? void 0 : { code: "RUNTIME_NOT_LIVE", message: `${adapter} runtime is not live`, userAction };
266
293
  console.log(JSON.stringify({ ...result, ready: live, runtime, orphanMetros, capture, devices, additionalReachableDevices, ...error ? { error } : {} }, null, 2));
267
294
  return live ? EXIT.ok : EXIT.runtime;
@@ -5,21 +5,22 @@ import { runnerDir, walletFixturePath } from "../paths.js";
5
5
  import { getAdapterSurface } from "../adapters/surface.js";
6
6
  import { fixtureFileSummary, fixtureSummary } from "../doctor.js";
7
7
  import { acquireCheckoutLock } from "../checkout-lock.js";
8
+ import { colorHumanMessage } from "../cli-color.js";
8
9
  import { applyDeviceTargeting } from "./device-target.js";
9
10
  import { ADAPTER_DETECT_NEXT, checkoutBusyOut, EXIT, flag, parseFlags, resolveAdapter, scriptOverride, spawnScript, spawnScriptStreaming, str, targetOf, usageOut } from "./shared.js";
10
11
  const FIXTURES_BOOLEANS = /* @__PURE__ */ new Set(["dev", "force", "json"]);
11
12
  const FIXTURE_STREAM_TIMEOUT_MS = 12e4;
12
- async function handleFixtures(argv, deps) {
13
+ async function handleFixtures(argv) {
13
14
  const { options } = parseFlags(argv, FIXTURES_BOOLEANS);
14
15
  const json = flag(options, "json");
15
16
  const target = targetOf(options);
16
- if (!fs.existsSync(target)) return handleFixturesLocked(argv, deps);
17
+ if (!fs.existsSync(target)) return handleFixturesLocked(argv);
17
18
  const lock = acquireCheckoutLock(target, "fixtures");
18
19
  if ("message" in lock) {
19
20
  return checkoutBusyOut(json, "fixtures", lock.message, lock.path);
20
21
  }
21
22
  try {
22
- return await handleFixturesLocked(argv, deps);
23
+ return await handleFixturesLocked(argv);
23
24
  } finally {
24
25
  lock.release();
25
26
  }
@@ -49,7 +50,7 @@ function ensureFixturePublicationParent(target) {
49
50
  }
50
51
  return fs.realpathSync(parent) === parent ? null : `wallet fixture runtime path escapes the checkout: ${parent}`;
51
52
  }
52
- async function handleFixturesLocked(argv, deps) {
53
+ async function handleFixturesLocked(argv) {
53
54
  const { positional, options } = parseFlags(argv, FIXTURES_BOOLEANS);
54
55
  const json = flag(options, "json");
55
56
  const sub = positional[0];
@@ -112,6 +113,9 @@ async function handleFixturesLocked(argv, deps) {
112
113
  "mm-harness fixtures init --dev && mm-harness fixtures set"
113
114
  );
114
115
  }
116
+ if (adapter === "extension") {
117
+ return applyExtensionFixture(target, fixturePath, canonicalFixture, json);
118
+ }
115
119
  process.stderr.write(`\u2192 fixtures set ${adapter} \u2014 connecting bridge + applying wallet fixture (can take ~30s)\u2026
116
120
  `);
117
121
  let status;
@@ -144,19 +148,6 @@ async function handleFixturesLocked(argv, deps) {
144
148
  if (!json && status === "fail") {
145
149
  console.error(` Next: ${retryHint}`);
146
150
  }
147
- } else {
148
- const previousFixtureEnv = process.env.RECIPE_WALLET_FIXTURE;
149
- process.env.RECIPE_WALLET_FIXTURE = fixturePath;
150
- try {
151
- const result = await deps.runOneNode("extension", "metamask.wallet.setup", {}, target, str(options, "actionManifest"));
152
- status = result.status;
153
- } finally {
154
- if (previousFixtureEnv === void 0) delete process.env.RECIPE_WALLET_FIXTURE;
155
- else process.env.RECIPE_WALLET_FIXTURE = previousFixtureEnv;
156
- }
157
- if (!json && status === "fail") {
158
- console.error(` Next: ${retryHint}`);
159
- }
160
151
  }
161
152
  const teaching = status === "pass" ? `Wallet fixture applied. Want different accounts? Edit: ${canonicalFixture}` : "Wallet fixture was not applied.";
162
153
  if (json) {
@@ -187,6 +178,63 @@ async function handleFixturesLocked(argv, deps) {
187
178
  }
188
179
  return status === "pass" ? EXIT.ok : EXIT.runtime;
189
180
  }
181
+ async function applyExtensionFixture(target, fixturePath, canonicalFixture, json) {
182
+ const distManifest = path.join(target, "dist/chrome/manifest.json");
183
+ const userAction = `mm-harness launch --build --verify --target ${JSON.stringify(target)}`;
184
+ if (!fs.existsSync(distManifest)) {
185
+ const message2 = `Extension fixture needs an existing dist/chrome build; no manifest was found at ${distManifest}.`;
186
+ if (json) {
187
+ console.log(JSON.stringify({
188
+ schemaVersion: 1,
189
+ command: "fixtures",
190
+ action: "set",
191
+ adapter: "extension",
192
+ fixture: fixturePath,
193
+ canonicalFixture,
194
+ status: "fail",
195
+ exitCode: EXIT.runtime,
196
+ error: { code: "EXTENSION_FIXTURE_BUILD_REQUIRED", message: message2, userAction }
197
+ }, null, 2));
198
+ } else {
199
+ console.error(`\u2717 mm-harness fixtures set: ${message2}
200
+ Next: ${userAction}`);
201
+ }
202
+ return EXIT.runtime;
203
+ }
204
+ process.stderr.write(`${colorHumanMessage("\u2192 fixtures set extension \u2014 resetting and seeding the slot-owned profile from the existing build\u2026")}
205
+ `);
206
+ const liveScript = path.join(runnerDir, "adapters/extension/live.sh");
207
+ const args = ["--target", target, "--launch-existing-dist"];
208
+ if (process.env.CDP_PORT) args.push("--cdp-port", process.env.CDP_PORT);
209
+ const result = await spawnScriptStreaming(liveScript, args, target, {
210
+ env: { RECIPE_WALLET_FIXTURE: fixturePath },
211
+ timeoutMs: FIXTURE_STREAM_TIMEOUT_MS
212
+ });
213
+ const status = result.status === 0 ? "pass" : "fail";
214
+ const exitCode = status === "pass" ? EXIT.ok : EXIT.runtime;
215
+ const message = status === "pass" ? `Wallet fixture applied and validated. Want different accounts? Edit: ${canonicalFixture}` : "Extension wallet fixture could not be applied and validated.";
216
+ if (json) {
217
+ console.log(JSON.stringify({
218
+ schemaVersion: 1,
219
+ command: "fixtures",
220
+ action: "set",
221
+ adapter: "extension",
222
+ fixture: fixturePath,
223
+ canonicalFixture,
224
+ status,
225
+ exitCode,
226
+ profileReset: status === "pass",
227
+ message,
228
+ error: status === "fail" ? { code: "EXTENSION_FIXTURE_APPLY_FAILED", message, userAction } : null
229
+ }, null, 2));
230
+ } else if (status === "pass") {
231
+ console.error(message);
232
+ } else {
233
+ console.error(`\u2717 mm-harness fixtures set: ${message}
234
+ Next: ${userAction}`);
235
+ }
236
+ return exitCode;
237
+ }
190
238
  const EXT_FIXTURE_LEAF = "adapters/extension/wallet-fixture-state.cjs";
191
239
  function emitLeafMissing(json, action, fields, leaf) {
192
240
  const message = `leaf could not start: ${path.basename(leaf)} (ENOENT).`;
@@ -2,18 +2,18 @@ import path from "node:path";
2
2
  import { color } from "../cli-color.js";
3
3
  import { importRecipeHarnessCli } from "../paths.js";
4
4
  import { EXIT } from "./shared.js";
5
- import { usageError } from "./parse-args.js";
5
+ import { parseArgs, usageError } from "./parse-args.js";
6
6
  import { resolveMetaMaskLibrarySources } from "./run-engine.js";
7
- const FLOWS_SUBCOMMANDS = ["list", "promote"];
8
- function stripTargetFlag(argv) {
7
+ const FLOWS_SUBCOMMANDS = ["list", "describe", "promote"];
8
+ function stripFrontOnlyFlags(argv) {
9
9
  const out = [];
10
10
  for (let i = 0; i < argv.length; i += 1) {
11
11
  const arg = argv[i];
12
- if (arg === "--target") {
12
+ if (arg === "--target" || arg === "--library") {
13
13
  i += 1;
14
14
  continue;
15
15
  }
16
- if (arg.startsWith("--target=")) continue;
16
+ if (arg.startsWith("--target=") || arg.startsWith("--library=")) continue;
17
17
  out.push(arg);
18
18
  }
19
19
  return out;
@@ -30,14 +30,37 @@ function restoreEnv(key, value) {
30
30
  if (value === void 0) delete process.env[key];
31
31
  else process.env[key] = value;
32
32
  }
33
+ function libraryEntries(argv) {
34
+ const entries = [];
35
+ for (let index = 0; index < argv.length; index += 1) {
36
+ const argument = argv[index];
37
+ if (argument === "--library") {
38
+ const value = argv[index + 1];
39
+ if (value !== void 0) entries.push(value);
40
+ index += 1;
41
+ } else if (argument.startsWith("--library=")) {
42
+ entries.push(argument.slice("--library=".length));
43
+ }
44
+ }
45
+ return entries;
46
+ }
33
47
  async function handleFlows(argv) {
34
- const sources = await resolveMetaMaskLibrarySources(void 0);
48
+ const { positional } = parseArgs(argv);
49
+ const subcommand = positional[0];
50
+ if (subcommand === "describe" && !positional[1]) {
51
+ throw usageError("flows describe requires <ref>. Next: mm-harness flows list");
52
+ }
53
+ if (subcommand !== "describe" && positional[1]) {
54
+ throw usageError(`flows ${subcommand ?? "list"} does not accept positional ${positional[1]}.`);
55
+ }
56
+ const configuredLibraries = libraryEntries(argv);
57
+ const sources = await resolveMetaMaskLibrarySources(configuredLibraries);
35
58
  if (!sources) {
36
59
  throw usageError(
37
60
  "flows requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support; flows resolve personal > team > canonical). Next: update the dependency and run yarn install."
38
61
  );
39
62
  }
40
- const forwarded = stripTargetFlag(argv);
63
+ const forwarded = stripFrontOnlyFlags(argv);
41
64
  const jsonMode = forwarded.includes("--json");
42
65
  if (!jsonMode) {
43
66
  console.error(color("label", "flows resolve by precedence (highest wins; local shadows canonical):"));
@@ -46,16 +69,22 @@ async function handleFlows(argv) {
46
69
  console.error(` ${index + 1}. ${color("cmd", name)} ${color("dim", `[${flowSourceTier(source, index, sources.length)}]`)}`);
47
70
  });
48
71
  }
49
- const withSubcommand = FLOWS_SUBCOMMANDS.includes(forwarded[0]) ? forwarded : ["list", ...forwarded];
72
+ const resolvedSubcommand = subcommand && FLOWS_SUBCOMMANDS.includes(subcommand) ? subcommand : "list";
73
+ const forwardedSubcommandIndex = forwarded.indexOf(resolvedSubcommand);
74
+ const delegatedArgs = forwardedSubcommandIndex === -1 ? forwarded : forwarded.filter((_, index) => index !== forwardedSubcommandIndex);
75
+ const withSubcommand = [resolvedSubcommand, ...delegatedArgs];
50
76
  const { runRecipeHarnessCli } = await importRecipeHarnessCli();
51
77
  const previousLibraryPath = process.env.RECIPE_LIBRARY_PATH;
78
+ const previousExitCode = process.exitCode;
52
79
  process.env.RECIPE_LIBRARY_PATH = serializeLibrarySources(sources);
80
+ process.exitCode = void 0;
53
81
  try {
54
- await runRecipeHarnessCli(["flows", ...withSubcommand]);
82
+ await runRecipeHarnessCli(["flows", ...withSubcommand], { commandName: "mm-harness" });
83
+ return typeof process.exitCode === "number" ? process.exitCode : EXIT.ok;
55
84
  } finally {
56
85
  restoreEnv("RECIPE_LIBRARY_PATH", previousLibraryPath);
86
+ process.exitCode = previousExitCode;
57
87
  }
58
- return EXIT.ok;
59
88
  }
60
89
  export {
61
90
  handleFlows
@@ -3,12 +3,15 @@ import http from "node:http";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
6
+ import { colorHumanMessage } from "../../cli-color.js";
6
7
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "../../paths.js";
7
8
  import { extensionIdFromKey } from "../../adapters/extension/extension-id.js";
9
+ import { extensionProductConfigBlock } from "../../adapters/extension/product-config.js";
8
10
  import { isExtensionDistStale } from "../../adapters/extension/runtime-decision.js";
9
11
  import { checkExtensionRuntimeHealth } from "../../adapters/extension/runtime.js";
12
+ import { ensureExtensionHarnessFresh } from "../../adapters/extension/harness-freshness.js";
10
13
  import { stopExtensionWatcher } from "../../adapters/slot-ports.js";
11
- import { spawnScriptStreaming } from "../shared.js";
14
+ import { EXIT, spawnScriptStreaming } from "../shared.js";
12
15
  function extensionDepsBlock(target) {
13
16
  if (!fs.existsSync(path.join(target, "package.json"))) return null;
14
17
  const deps = depsCheck(target);
@@ -29,17 +32,32 @@ function installExtensionDeps(target) {
29
32
  return spawnScriptStreaming(installScript, ["--target", target], target);
30
33
  }
31
34
  async function launchExtension(target, tier, wantWatch, displayMode = "fullscreen") {
35
+ if (process.env.CHROME_USER_DATA_DIR) {
36
+ process.env.CHROME_USER_DATA_DIR = path.resolve(process.env.CHROME_USER_DATA_DIR);
37
+ }
38
+ const reusable = !wantWatch && tier !== "build" && await extensionRuntimeReusable(target);
39
+ if (!reusable) {
40
+ const block = extensionProductConfigBlock(target);
41
+ if (block) {
42
+ return {
43
+ status: EXIT.infra,
44
+ output: `EXTENSION_PRODUCT_CONFIG_REQUIRED: ${block.message}
45
+ Next: ${block.userAction}`
46
+ };
47
+ }
48
+ }
49
+ await ensureExtensionHarnessFresh(target);
32
50
  if (wantWatch) {
33
51
  const startWatchSh = path.join(runnerDir, "adapters/extension/start-watch.sh");
34
52
  const watchArgs = ["--target", target];
35
53
  if (process.env.WATCHER_PORT) watchArgs.push("--watcher-port", process.env.WATCHER_PORT);
36
- console.error(`\u2192 extension watch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} (output streams below)`);
54
+ console.error(colorHumanMessage(`\u2192 extension watch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} (output streams below)`));
37
55
  return spawnScriptStreaming(startWatchSh, watchArgs, target);
38
56
  }
39
57
  if (tier === "build") {
40
58
  return extensionRebuild(target);
41
59
  }
42
- if (await extensionRuntimeReusable(target)) {
60
+ if (reusable) {
43
61
  return extensionReattach(target, displayMode);
44
62
  }
45
63
  return extensionRebuild(target);
@@ -47,12 +65,16 @@ async function launchExtension(target, tier, wantWatch, displayMode = "fullscree
47
65
  async function extensionRuntimeReusable(target) {
48
66
  const cdpPort = process.env.CDP_PORT;
49
67
  if (!cdpPort) return false;
50
- if (!cdpOwnedByExpectedRuntime(cdpPort, target)) return false;
51
- if (!await cdpVersionReachable(cdpPort)) return false;
52
- if (!await cdpHasExpectedExtensionTarget(cdpPort, target)) return false;
53
- if (!await cdpRuntimeHealthy(cdpPort, target)) return false;
54
68
  if (isExtensionDistStale(target)) return false;
55
- return true;
69
+ for (let attempt = 0; attempt < 3; attempt += 1) {
70
+ const reachable = await cdpVersionReachable(cdpPort);
71
+ const owned = reachable && cdpOwnedByExpectedRuntime(cdpPort, target);
72
+ const targetPresent = owned && await cdpHasExpectedExtensionTarget(cdpPort, target);
73
+ const healthy = targetPresent && await cdpRuntimeHealthy(cdpPort, target);
74
+ if (healthy) return true;
75
+ if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 250));
76
+ }
77
+ return false;
56
78
  }
57
79
  function expectedChromeProfile(target) {
58
80
  const profileName = process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile";
@@ -168,12 +190,15 @@ function cdpHasExpectedExtensionTarget(port, target) {
168
190
  });
169
191
  }
170
192
  async function cdpRuntimeHealthy(port, target) {
171
- try {
172
- const report = await checkExtensionRuntimeHealth(target, Number(port), { pageMode: "home-or-sidepanel" });
173
- return report.status === "PASS";
174
- } catch {
175
- return false;
193
+ for (let attempt = 0; attempt < 3; attempt += 1) {
194
+ try {
195
+ const report = await checkExtensionRuntimeHealth(target, Number(port), { pageMode: "home-or-sidepanel" });
196
+ if (report.status === "PASS") return true;
197
+ } catch {
198
+ }
199
+ if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 250));
176
200
  }
201
+ return false;
177
202
  }
178
203
  async function extensionReattach(target, displayMode = "fullscreen") {
179
204
  const reattachScript = recipeHarnessPath(target, "extension", "scripts", "reattach.sh");
@@ -181,7 +206,7 @@ async function extensionReattach(target, displayMode = "fullscreen") {
181
206
  if (process.env.CDP_PORT) reattachArgs.push("--cdp-port", process.env.CDP_PORT);
182
207
  if (process.env.WATCHER_PORT) reattachArgs.push("--watcher-port", process.env.WATCHER_PORT);
183
208
  if (process.env.EXTENSION_START_URL) reattachArgs.push("--start-url", process.env.EXTENSION_START_URL);
184
- console.error(`\u2192 extension quick reattach \u2014 reload in place \xB7 CDP :${process.env.CDP_PORT ?? "default"} (no rebuild, no relaunch)`);
209
+ console.error(colorHumanMessage(`\u2192 extension quick reattach \u2014 reload in place \xB7 CDP :${process.env.CDP_PORT ?? "default"} (no rebuild, no relaunch)`));
185
210
  return spawnScriptStreaming(reattachScript, reattachArgs, target);
186
211
  }
187
212
  async function extensionRebuild(target) {
@@ -195,7 +220,7 @@ async function extensionRebuild(target) {
195
220
  const liveArgs = ["--target", target, "--start-watch"];
196
221
  if (process.env.CDP_PORT) liveArgs.push("--cdp-port", process.env.CDP_PORT);
197
222
  if (process.env.EXTENSION_START_URL) liveArgs.push("--start-url", process.env.EXTENSION_START_URL);
198
- console.error(`\u2192 extension quick relaunch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} \xB7 CDP :${process.env.CDP_PORT ?? "default"} (output streams below)`);
223
+ console.error(colorHumanMessage(`\u2192 extension quick relaunch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} \xB7 CDP :${process.env.CDP_PORT ?? "default"} (output streams below)`));
199
224
  const result = await spawnScriptStreaming(liveScript, liveArgs, target);
200
225
  if (result.output) {
201
226
  try {