@deeeed/metamask-harness 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.4 - 2026-07-07
4
+
5
+ ### Added
6
+ - **`fixtures generate`** — renders the extension wallet `fixture-state.json` from a wallet fixture by fronting `adapters/extension/wallet-fixture-state.cjs generate` (no reimplementation). Extension-only (mobile/core teach); requires `--fixture <wallet-fixture.json>` and `--out <fixture-state.json>`, with a `--json` machine envelope and teaching escapes on missing inputs or a non-extension adapter. This is the public surface farm packs call instead of reaching into the internal leaf, so the Extension pack's `setup/generate-fixture.cjs` resolver can be replaced by a one-line `mm-harness fixtures generate` hook.
7
+ - **`fixtures finalize`** — seeds account labels/selection into the **live** extension over CDP and validates import parity, by fronting `adapters/extension/wallet-fixture-state.cjs seed-cdp` (no reimplementation). Extension-only, post-launch (mobile/core teach); requires `--fixture`, `--state`, `--cdp-port`, `--extension-dir` (optional `--extension-id-file` / `--out`), with a pure `--json` envelope (leaf progress on stderr), the shared `LEAF_MISSING` pre-check, and teaching escapes on missing flags or a non-extension adapter. This is the public surface for the Extension pack's `setup/preflight.sh` account-label finalization, retiring its last `--resolve-script` reach-in into the internal leaf.
8
+
9
+ ### Fixed
10
+ - **`fixtures generate` teaches on a missing leaf instead of leaking a node error.** When the `wallet-fixture-state.cjs` leaf is absent (a corrupt packed install), `generate` now pre-checks the leaf on disk and fails with a one-sentence teaching error plus a `Next:` reinstall command (and a clean `--json` `LEAF_MISSING` envelope), instead of surfacing node's opaque "Cannot find module".
11
+ - **Extension readiness no longer spawns a duplicate home tab on a transient CDP hiccup.** `ensureExtensionReady` treated any `/json/list` failure as an empty tab set, so a list that transiently failed mid-prepare (unlock → home rerender) looked like "no home tab" and opened a second one next to the healthy tab — the following health check then found two targets and failed prepare. `/json/list` now distinguishes "CDP answered: N targets" from "CDP unreachable" (retried a few times), returns `reasonCode: 'cdp-unreachable'` without touching any tab when the state is unknown, confirms a zero count with a second listing before opening, and converges over a few passes so a wrong action is corrected within the call instead of tripping the health check.
12
+ - **`readiness` no longer opens a duplicate home tab when the existing one is attached.** `findPageTarget` required `webSocketDebuggerUrl`, but Chrome omits that field for any target another client is attached to — during prepare the fixture finalizer / smoke recipe / CDP evals attach to the healthy home tab, so it listed without a wsUrl, looked absent, and `readiness` opened a second `home.html` that then tripped the exactly-one-home health gate. Page existence is now decided by URL in any attach state; the websocket only gates UI inspection (a present-but-attached tab is reported `pagePresent: true`, `pageInspected: false` and left alone instead of duplicated). URL matching also tolerates the `home.html#/` router rewrite and query suffixes. New additive report fields: `pagePresent`, `pageInspected`.
13
+
14
+ ### Changed
15
+ - **`install --adapter core` is documented and contract-locked as the public core-runner install surface for farm packs.** It already fronts `adapters/core/inject.sh` and produces the runner delegate at `<harness>/core/runner/bin/mm-harness`; a new CLI-level contract test (`tests/contract/core-install-cli.test.sh`) pins the happy path, `--json` envelope purity, and the adapter-detection teaching escape. Packs call `mm-harness install --adapter core --target <repo>` (inheriting `RECIPE_HARNESS_ROOT` / `FARMSLOT_ROOT`) instead of cloning a runner checkout and invoking the internal leaf directly — no separate top-level command is minted.
16
+ - **`EnsureReadyResult` gains a machine-readable `action` field** (`none` | `opened` | `reopened` | `skipped`) stating the tab decision the call took, so a consumer distinguishes "opened one because a zero was confirmed" from "touched nothing because CDP state was unknown" straight from the JSON envelope.
17
+
3
18
  ## 0.7.3
4
19
 
5
20
  ### Fixed
@@ -271,21 +271,40 @@ async function openExtensionPage(cdpPort, extensionId, pagePath) {
271
271
  }
272
272
  }
273
273
 
274
+ // The page path only, with any '#fragment' or '?query' stripped. The app router
275
+ // rewrites home.html to home.html#/ once loaded, so an exact '/home.html' suffix
276
+ // match must compare against this, not the raw url.
277
+ function pagePathOf(url) {
278
+ return String(url || '').split('#')[0].split('?')[0];
279
+ }
280
+
281
+ // Find the extension home page target. Existence is decided by URL alone and is
282
+ // deliberately independent of webSocketDebuggerUrl: Chrome OMITS that field for a
283
+ // target another client is already attached to (the fixture finalizer / smoke
284
+ // recipe / CDP evals attach to the healthy home tab during prepare). Requiring it
285
+ // here made an attached-but-healthy tab look absent, so the caller opened a second
286
+ // home tab and the exactly-one-home health gate then failed. wsUrl only gates
287
+ // whether the chosen page can be inspected (see inspectCdp), never whether it
288
+ // exists. Among matches, prefer the requested page, then a non-popup page, then an
289
+ // inspectable (wsUrl-bearing) one — so a fresh unattached tab is inspected when one
290
+ // is available, while a sole attached tab is still recognized as present.
274
291
  function findPageTarget(targets, selectedExtensionId, preferredPagePath = '') {
275
292
  const extensionPages = targets.filter((target) => {
276
293
  const url = String(target.url || '');
277
- return (
278
- target.type === 'page' &&
279
- url.startsWith(`chrome-extension://${selectedExtensionId}/`) &&
280
- typeof target.webSocketDebuggerUrl === 'string'
281
- );
294
+ return target.type === 'page' && url.startsWith(`chrome-extension://${selectedExtensionId}/`);
282
295
  });
296
+ if (extensionPages.length === 0) return undefined;
283
297
  const normalizedPreferred = String(preferredPagePath || '').replace(/^\/+/u, '');
284
- return (
285
- extensionPages.find((target) => normalizedPreferred && String(target.url || '').endsWith(`/${normalizedPreferred}`)) ||
286
- extensionPages.find((target) => !String(target.url || '').includes('/popup-init.html')) ||
287
- extensionPages[0]
288
- );
298
+ const score = (target) => {
299
+ const pagePath = pagePathOf(target.url);
300
+ let value = 0;
301
+ if (normalizedPreferred && pagePath.endsWith(`/${normalizedPreferred}`)) value += 4;
302
+ if (!pagePath.endsWith('/popup-init.html')) value += 2;
303
+ if (typeof target.webSocketDebuggerUrl === 'string') value += 1;
304
+ return value;
305
+ };
306
+ // reduce keeps the FIRST target on a score tie, preserving /json/list order.
307
+ return extensionPages.reduce((best, current) => (score(current) > score(best) ? current : best), extensionPages[0]);
289
308
  }
290
309
 
291
310
  async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceWorker, extensionPagePath) {
@@ -299,14 +318,21 @@ async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceW
299
318
  let pageTarget = findPageTarget(targets, selectedExtensionId, extensionPagePath);
300
319
  let openedPage = false;
301
320
  if (!pageTarget) {
321
+ // Confirmed absence in ANY attach state (findPageTarget ignores wsUrl), so a
322
+ // home tab that merely has a client attached is never mistaken for missing.
323
+ // Only a genuinely absent page reaches here; open one and recheck.
302
324
  openedPage = await openExtensionPage(cdpPort, selectedExtensionId, extensionPagePath);
303
325
  await new Promise((resolve) => setTimeout(resolve, 500));
304
326
  targets = await httpJson(`http://127.0.0.1:${cdpPort}/json/list`);
305
327
  ({ extensionIds, selectedExtensionId } = chooseExtensionId(targets, expectedExtensionId, expectedServiceWorker));
306
328
  pageTarget = findPageTarget(targets, selectedExtensionId, extensionPagePath);
307
329
  }
330
+ const pagePresent = Boolean(pageTarget);
331
+ let pageInspected = false;
308
332
  let ui = null;
309
- if (pageTarget) {
333
+ // Inspect only when the chosen page exposes a websocket. A present-but-attached
334
+ // tab has none; skip inspection and report it rather than opening a duplicate.
335
+ if (pageTarget && typeof pageTarget.webSocketDebuggerUrl === 'string') {
310
336
  ui = await cdpEvaluate(
311
337
  target,
312
338
  pageTarget.webSocketDebuggerUrl,
@@ -330,18 +356,19 @@ async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceW
330
356
  };
331
357
  })()`,
332
358
  );
333
- if (ui && !ui.skipped && ui.hasStartupError) {
359
+ pageInspected = Boolean(ui) && !ui.skipped;
360
+ if (pageInspected && ui.hasStartupError) {
334
361
  throw Object.assign(new Error('MetaMask extension page loaded startup error UI'), {
335
362
  report: { cdp: { browser: version.Browser || 'unknown', selectedExtensionId, ui } },
336
363
  });
337
364
  }
338
- if (ui && !ui.skipped && ui.hasErrorBoundary) {
365
+ if (pageInspected && ui.hasErrorBoundary) {
339
366
  const detail = [ui.errorBoundaryName, ui.errorBoundaryMessage].filter(Boolean).join(': ');
340
367
  throw Object.assign(new Error(`MetaMask UI crashed (React error boundary)${detail ? `: ${detail}` : ''}`), {
341
368
  report: { cdp: { browser: version.Browser || 'unknown', selectedExtensionId, ui } },
342
369
  });
343
370
  }
344
- if (ui && !ui.skipped && String(ui.url || '').startsWith('chrome-error://')) {
371
+ if (pageInspected && String(ui.url || '').startsWith('chrome-error://')) {
345
372
  throw Object.assign(new Error('MetaMask extension page loaded Chrome error UI'), {
346
373
  report: { cdp: { browser: version.Browser || 'unknown', selectedExtensionId, ui } },
347
374
  });
@@ -355,6 +382,8 @@ async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceW
355
382
  targetCount: targets.length,
356
383
  openedPage,
357
384
  openedPagePath: extensionPagePath,
385
+ pagePresent,
386
+ pageInspected,
358
387
  ui,
359
388
  };
360
389
  }
@@ -2,14 +2,18 @@ import path from "node:path";
2
2
  import { resolveExtensionId } from "./extension-id.js";
3
3
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
4
  async function jsonList(port) {
5
- try {
6
- const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5e3) });
7
- if (!res.ok) return [];
8
- const value = await res.json();
9
- return Array.isArray(value) ? value : [];
10
- } catch {
11
- return [];
5
+ for (let attempt = 0; attempt < 3; attempt += 1) {
6
+ try {
7
+ const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5e3) });
8
+ if (res.ok) {
9
+ const value = await res.json();
10
+ if (Array.isArray(value)) return { ok: true, targets: value };
11
+ }
12
+ } catch {
13
+ }
14
+ if (attempt < 2) await sleep(500);
12
15
  }
16
+ return { ok: false, targets: [] };
13
17
  }
14
18
  async function closeTab(port, id) {
15
19
  try {
@@ -53,6 +57,7 @@ async function ensureExtensionReady(target, options) {
53
57
  cdpPort,
54
58
  extensionId: null,
55
59
  opened: false,
60
+ action: "none",
56
61
  homeTabs: { before: 0, closed: 0, after: 0 },
57
62
  ready: false,
58
63
  reasonCode: "unknown",
@@ -60,32 +65,67 @@ async function ensureExtensionReady(target, options) {
60
65
  ...extra
61
66
  });
62
67
  const { extensionId } = await resolveExtensionId(resolved, { cdpPort });
63
- if (!extensionId) return base({ reasonCode: "no-extension-id" });
64
- const homes = homePages(await jsonList(cdpPort), extensionId);
65
- const before = homes.length;
68
+ if (!extensionId) return base({ reasonCode: "no-extension-id", action: "skipped" });
69
+ const initial = await jsonList(cdpPort);
70
+ if (!initial.ok) return base({ extensionId, reasonCode: "cdp-unreachable", action: "skipped" });
71
+ const before = homePages(initial.targets, extensionId).length;
72
+ const MAX_PASSES = 3;
66
73
  let opened = false;
67
74
  let closed = 0;
68
- if (homes.length > 1) {
69
- for (const h of homes) {
70
- await closeTab(cdpPort, String(h.id));
71
- closed += 1;
75
+ let action = "none";
76
+ let targets = initial.targets;
77
+ for (let pass = 0; pass < MAX_PASSES; pass += 1) {
78
+ const homes = homePages(targets, extensionId);
79
+ if (homes.length === 1) break;
80
+ if (homes.length === 0) {
81
+ await sleep(750);
82
+ const recheck = await jsonList(cdpPort);
83
+ if (!recheck.ok) {
84
+ return base({ extensionId, opened, action: action === "none" ? "skipped" : action, homeTabs: { before, closed, after: 0 }, reasonCode: "cdp-unreachable" });
85
+ }
86
+ if (homePages(recheck.targets, extensionId).length !== 0) {
87
+ targets = recheck.targets;
88
+ continue;
89
+ }
90
+ action = "opened";
91
+ opened = await openHome(cdpPort, extensionId) || opened;
92
+ await sleep(1500);
93
+ } else {
94
+ action = "reopened";
95
+ for (const h of homes) {
96
+ await closeTab(cdpPort, String(h.id));
97
+ closed += 1;
98
+ }
99
+ await sleep(500);
100
+ opened = await openHome(cdpPort, extensionId) || opened;
101
+ await sleep(1500);
72
102
  }
73
- await sleep(500);
74
- opened = await openHome(cdpPort, extensionId);
75
- await sleep(1500);
76
- } else if (homes.length === 0) {
77
- opened = await openHome(cdpPort, extensionId);
78
- await sleep(1500);
103
+ const next = await jsonList(cdpPort);
104
+ if (!next.ok) {
105
+ return base({
106
+ extensionId,
107
+ opened,
108
+ action,
109
+ homeTabs: { before, closed, after: homePages(targets, extensionId).length },
110
+ reasonCode: "cdp-unreachable"
111
+ });
112
+ }
113
+ targets = next.targets;
79
114
  }
80
115
  const converged = await jsonList(cdpPort);
81
- const homesNow = homePages(converged, extensionId);
116
+ const convergedTargets = converged.ok ? converged.targets : targets;
117
+ const homesNow = homePages(convergedTargets, extensionId);
82
118
  if (homesNow.length >= 1) {
83
- for (const t of converged) {
119
+ for (const t of convergedTargets) {
84
120
  if (t.type === "page" && t.id && isStrayTab(t.url)) await closeTab(cdpPort, String(t.id));
85
121
  }
86
122
  if (homesNow[0]?.id) await activateTab(cdpPort, String(homesNow[0].id));
87
123
  }
88
- const after = homePages(await jsonList(cdpPort), extensionId).length;
124
+ const finalListing = await jsonList(cdpPort);
125
+ if (!finalListing.ok) {
126
+ return base({ extensionId, opened, action, homeTabs: { before, closed, after: homesNow.length }, reasonCode: "cdp-unreachable" });
127
+ }
128
+ const after = homePages(finalListing.targets, extensionId).length;
89
129
  let health = { status: "unknown", findings: [] };
90
130
  try {
91
131
  const { checkExtensionRuntimeHealth } = await import("./runtime.js");
@@ -98,6 +138,7 @@ async function ensureExtensionReady(target, options) {
98
138
  return base({
99
139
  extensionId,
100
140
  opened,
141
+ action,
101
142
  homeTabs: { before, closed, after },
102
143
  ready,
103
144
  reasonCode: ready ? "ready" : after !== 1 ? "tab-count" : "unhealthy",
@@ -13,6 +13,7 @@ const SPEC = {
13
13
  { name: "sync", desc: "Refresh harness + canonicalize wallet fixture", flags: ["--json"] },
14
14
  { name: "logs", aliases: ["tail"], desc: "Compact build events or full log", flags: ["--full", "-f", "--window", "--events", "--source", "--json"] },
15
15
  { name: "debug", aliases: ["devtools", "inspect"], desc: "Open DevTools UI", flags: ["--json", "--no-open"] },
16
+ { name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--json"] },
16
17
  { name: "actions", desc: "List runnable recipe actions", flags: ["--json"] },
17
18
  { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--cdp-port"] },
18
19
  { name: "run", desc: "Execute a proof recipe", args: ["recipe.json"], flags: ["--list"] },
package/dist/cli.js CHANGED
@@ -45,8 +45,8 @@ DAILY LOOP \u2014 what a teammate runs many times a day:
45
45
  mm-harness logs
46
46
  debug Open the debug console (extension DevTools / mobile RN).
47
47
  mm-harness debug
48
- fixtures Sync fixture files + set up the wallet (SRP/password).
49
- mm-harness fixtures sync # or: mm-harness fixtures set
48
+ fixtures Sync files + set the wallet + generate fixture-state + finalize labels over CDP.
49
+ mm-harness fixtures sync # or: set | generate --fixture <f> --out <o> | finalize \u2026
50
50
 
51
51
  PROVE \u2014 run recipes and inspect capabilities:
52
52
  run Run a recipe and write evidence (summary/trace/artifacts).
@@ -16,7 +16,7 @@ async function handleEnsureReady({ options }) {
16
16
  );
17
17
  const result = await ensureExtensionReady(target, { cdpPort });
18
18
  if (optionFlag(options, "json")) console.log(JSON.stringify(result, null, 2));
19
- else console.log(`${result.ready ? "READY" : "NOT-READY"} ${result.reasonCode} \u2014 id=${result.extensionId} homeTabs ${result.homeTabs.before}\u2192${result.homeTabs.after} (closed ${result.homeTabs.closed})`);
19
+ else console.log(`${result.ready ? "READY" : "NOT-READY"} ${result.reasonCode} (${result.action}) \u2014 id=${result.extensionId} homeTabs ${result.homeTabs.before}\u2192${result.homeTabs.after} (closed ${result.homeTabs.closed})`);
20
20
  return result.ready ? 0 : 1;
21
21
  }
22
22
  export {
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { runnerDir, walletFixturePath } from "../paths.js";
4
4
  import { getAdapterSurface } from "../adapters/surface.js";
5
- import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, spawnScriptStreaming, str, targetOf, usageOut } from "./shared.js";
5
+ import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, scriptOverride, spawnScript, spawnScriptStreaming, str, targetOf, usageOut } from "./shared.js";
6
6
  const FIXTURES_BOOLEANS = /* @__PURE__ */ new Set(["json"]);
7
7
  const RECOVERABLE_SETUP_WALLET_PATTERNS = [
8
8
  "CDP not reachable",
@@ -23,14 +23,16 @@ async function handleFixtures(argv, deps) {
23
23
  const { positional, options } = parseFlags(argv, FIXTURES_BOOLEANS);
24
24
  const json = flag(options, "json");
25
25
  const sub = positional[0];
26
- if (sub !== "sync" && sub !== "set") {
27
- return usageOut(json, "fixtures", "fixtures requires a subcommand: mm-harness fixtures <sync|set>", "mm-harness fixtures sync or mm-harness fixtures set");
26
+ if (sub !== "sync" && sub !== "set" && sub !== "generate" && sub !== "finalize") {
27
+ return usageOut(json, "fixtures", "fixtures requires a subcommand: mm-harness fixtures <sync|set|generate|finalize>", "mm-harness fixtures sync or mm-harness fixtures set or mm-harness fixtures generate or mm-harness fixtures finalize");
28
28
  }
29
29
  const target = targetOf(options);
30
30
  const adapter = resolveAdapter(options, target);
31
31
  if (!adapter) {
32
32
  return usageOut(json, "fixtures", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
33
33
  }
34
+ if (sub === "generate") return fixturesGenerate(adapter, target, options, json);
35
+ if (sub === "finalize") return fixturesFinalize(adapter, target, options, json);
34
36
  const surface = getAdapterSurface(adapter);
35
37
  if (surface.headless) return usageOut(json, "fixtures", "core is headless; it has no wallet fixture.", surface.hints.launch);
36
38
  const canonicalFixture = walletFixturePath(target);
@@ -128,6 +130,160 @@ async function handleFixtures(argv, deps) {
128
130
  }
129
131
  return status === "pass" ? EXIT.ok : EXIT.runtime;
130
132
  }
133
+ const EXT_FIXTURE_LEAF = "adapters/extension/wallet-fixture-state.cjs";
134
+ function emitLeafMissing(json, action, fields, leaf) {
135
+ const message = `leaf could not start: ${path.basename(leaf)} (ENOENT).`;
136
+ const userAction = "reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the fixture-state leaf is missing";
137
+ if (json) {
138
+ console.log(
139
+ JSON.stringify(
140
+ { schemaVersion: 1, command: "fixtures", action, ...fields, status: "fail", exitCode: EXIT.runtime, error: { code: "LEAF_MISSING", message, userAction } },
141
+ null,
142
+ 2
143
+ )
144
+ );
145
+ } else {
146
+ console.error(`\u2717 mm-harness fixtures ${action}: ${message}
147
+ Next: ${userAction}`);
148
+ }
149
+ return EXIT.runtime;
150
+ }
151
+ function fixturesGenerate(adapter, target, options, json) {
152
+ if (adapter !== "extension") {
153
+ return usageOut(
154
+ json,
155
+ "fixtures",
156
+ `generate builds the extension wallet fixture-state and is extension-only (detected ${adapter}).`,
157
+ "run it inside a metamask-extension checkout, or pass --adapter extension"
158
+ );
159
+ }
160
+ const fixture = str(options, "fixture");
161
+ const out = str(options, "out");
162
+ if (!fixture || !out) {
163
+ return usageOut(
164
+ json,
165
+ "fixtures",
166
+ "generate requires --fixture <wallet-fixture.json> and --out <fixture-state.json>.",
167
+ "mm-harness fixtures generate --fixture <wallet-fixture.json> --out <fixture-state.json>"
168
+ );
169
+ }
170
+ const fixturePath = path.resolve(fixture);
171
+ const outPath = path.resolve(out);
172
+ const leaf = path.join(runnerDir, EXT_FIXTURE_LEAF);
173
+ if (!fs.existsSync(scriptOverride(leaf) ?? leaf)) {
174
+ return emitLeafMissing(json, "generate", { adapter, fixture: fixturePath, out: outPath }, leaf);
175
+ }
176
+ const result = spawnScript(
177
+ process.execPath,
178
+ [leaf, "generate", "--target", target, "--fixture", fixturePath, "--out", outPath],
179
+ target,
180
+ json
181
+ );
182
+ const status = result.status === 0 ? "pass" : "fail";
183
+ const exitCode = result.status === 0 ? EXIT.ok : EXIT.runtime;
184
+ const retry = `mm-harness fixtures generate --fixture ${fixturePath} --out ${outPath}`;
185
+ if (json) {
186
+ console.log(
187
+ JSON.stringify(
188
+ {
189
+ schemaVersion: 1,
190
+ command: "fixtures",
191
+ action: "generate",
192
+ adapter,
193
+ fixture: fixturePath,
194
+ out: outPath,
195
+ status,
196
+ exitCode,
197
+ error: status === "fail" ? {
198
+ code: "FIXTURE_GENERATE_FAILED",
199
+ message: "wallet fixture-state generation failed",
200
+ userAction: `check the wallet fixture at ${fixturePath} and the extension checkout at ${target}, then re-run: ${retry}`
201
+ } : null
202
+ },
203
+ null,
204
+ 2
205
+ )
206
+ );
207
+ } else if (status === "fail") {
208
+ console.error(`\u2717 mm-harness fixtures generate failed.
209
+ Next: check the wallet fixture at ${fixturePath} and the extension checkout, then re-run: ${retry}`);
210
+ } else {
211
+ console.error(`Wallet fixture-state written: ${outPath}`);
212
+ }
213
+ return exitCode;
214
+ }
215
+ async function fixturesFinalize(adapter, target, options, json) {
216
+ if (adapter !== "extension") {
217
+ return usageOut(
218
+ json,
219
+ "fixtures",
220
+ `finalize seeds account labels into the live extension over CDP and is extension-only (detected ${adapter}).`,
221
+ "run it inside a metamask-extension checkout, or pass --adapter extension"
222
+ );
223
+ }
224
+ const fixture = str(options, "fixture");
225
+ const state = str(options, "state");
226
+ const cdpPort = str(options, "cdpPort");
227
+ const extensionDir = str(options, "extensionDir");
228
+ if (!fixture || !state || !cdpPort || !extensionDir) {
229
+ return usageOut(
230
+ json,
231
+ "fixtures",
232
+ "finalize requires --fixture <wallet-fixture.json>, --state <fixture-state.json>, --cdp-port <port>, and --extension-dir <dist/chrome>.",
233
+ "mm-harness fixtures finalize --fixture <wallet-fixture.json> --state <fixture-state.json> --cdp-port <port> --extension-dir <dist/chrome> [--extension-id-file <path>] [--out <report.json>]"
234
+ );
235
+ }
236
+ const fixturePath = path.resolve(fixture);
237
+ const statePath = path.resolve(state);
238
+ const extensionDirPath = path.resolve(extensionDir);
239
+ const extensionIdFile = str(options, "extensionIdFile");
240
+ const out = str(options, "out");
241
+ const outPath = out ? path.resolve(out) : void 0;
242
+ const leaf = path.join(runnerDir, EXT_FIXTURE_LEAF);
243
+ const echo = { adapter, fixture: fixturePath, state: statePath, cdpPort, extensionDir: extensionDirPath };
244
+ if (!fs.existsSync(scriptOverride(leaf) ?? leaf)) {
245
+ return emitLeafMissing(json, "finalize", echo, leaf);
246
+ }
247
+ const leafArgs = [leaf, "seed-cdp", "--target", target, "--fixture", fixturePath, "--state", statePath, "--cdp-port", cdpPort, "--extension-dir", extensionDirPath];
248
+ if (extensionIdFile) leafArgs.push("--extension-id-file", path.resolve(extensionIdFile));
249
+ if (outPath) leafArgs.push("--out", outPath);
250
+ process.stderr.write(`\u2192 fixtures finalize extension \u2014 seeding account labels via CDP on port ${cdpPort} (can take ~30s)\u2026
251
+ `);
252
+ const result = await spawnScriptStreaming(process.execPath, leafArgs, target);
253
+ const status = result.status === 0 ? "pass" : "fail";
254
+ const exitCode = result.status === 0 ? EXIT.ok : EXIT.runtime;
255
+ const retry = `mm-harness fixtures finalize --fixture ${fixturePath} --state ${statePath} --cdp-port ${cdpPort} --extension-dir ${extensionDirPath}`;
256
+ if (json) {
257
+ console.log(
258
+ JSON.stringify(
259
+ {
260
+ schemaVersion: 1,
261
+ command: "fixtures",
262
+ action: "finalize",
263
+ adapter,
264
+ fixture: fixturePath,
265
+ state: statePath,
266
+ cdpPort,
267
+ extensionDir: extensionDirPath,
268
+ out: outPath ?? null,
269
+ status,
270
+ exitCode,
271
+ error: status === "fail" ? {
272
+ code: "FIXTURE_FINALIZE_FAILED",
273
+ message: "wallet fixture CDP finalization failed",
274
+ userAction: `confirm the extension is running with CDP on port ${cdpPort} (relaunch if not), then re-run: ${retry}`
275
+ } : null
276
+ },
277
+ null,
278
+ 2
279
+ )
280
+ );
281
+ } else if (status === "fail") {
282
+ console.error(`\u2717 mm-harness fixtures finalize failed.
283
+ Next: confirm the extension is running with CDP on port ${cdpPort} (relaunch if not), then re-run: ${retry}`);
284
+ }
285
+ return exitCode;
286
+ }
131
287
  function fixturesSync(adapter, target, json) {
132
288
  const mmHarnessBin = path.join(runnerDir, "bin/mm-harness");
133
289
  const installResult = spawnScript(
@@ -52,6 +52,10 @@ function resolveAdapter(options, target, hint) {
52
52
  if (hint) return hint;
53
53
  return detectAdapter(target);
54
54
  }
55
+ function scriptOverride(scriptPath) {
56
+ const stem = path.basename(scriptPath).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase();
57
+ return process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
58
+ }
55
59
  function spawnScript(script, args, cwd, json, env) {
56
60
  const isNodeScript = script === process.execPath && args.length > 0;
57
61
  const stem = isNodeScript ? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase() : path.basename(script).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase();
@@ -170,6 +174,7 @@ export {
170
174
  flag,
171
175
  parseFlags,
172
176
  resolveAdapter,
177
+ scriptOverride,
173
178
  spawnInherit,
174
179
  spawnScript,
175
180
  spawnScriptStreaming,
@@ -350,25 +350,37 @@ Example:
350
350
  },
351
351
  {
352
352
  name: "fixtures",
353
- summary: "Manage the canonical wallet fixture (wallet DATA only) \u2014 sync files / set the wallet.",
353
+ summary: "Manage the canonical wallet fixture (wallet DATA only) \u2014 sync / set / generate / finalize.",
354
354
  example: "mm-harness fixtures set",
355
- helpText: `mm-harness fixtures <sync|set> [flags]
355
+ helpText: `mm-harness fixtures <sync|set|generate|finalize> [flags]
356
356
 
357
357
  Manage the ONE canonical wallet fixture per checkout \u2014 wallet DATA only.
358
- sync Refresh the wallet fixture files on the target.
359
- set Apply the canonical fixture (SRP/password/accounts); the password is read
360
- FROM the fixture, never typed.
358
+ sync Refresh the wallet fixture files on the target.
359
+ set Apply the canonical fixture (SRP/password/accounts); the password is
360
+ read FROM the fixture, never typed.
361
+ generate Render the extension fixture-state.json from a wallet fixture, for
362
+ pre-launch profile prefill (extension-only). Requires --fixture and --out.
363
+ finalize Seed account labels/selection into the LIVE extension over CDP and
364
+ validate parity (extension-only, post-launch). Requires --fixture,
365
+ --state, --cdp-port, --extension-dir.
361
366
  Want different accounts? Edit the fixture file directly:
362
367
  <checkout>/temp/recipe/runtime/wallet-fixture.json
363
368
 
364
- --fixture <path> Override the fixture path (agent form; env: RECIPE_WALLET_FIXTURE)
369
+ --fixture <path> Wallet fixture path (generate/finalize input; agent override for set/sync \u2014 env: RECIPE_WALLET_FIXTURE)
370
+ --out <path> generate: output fixture-state.json; finalize: optional validation report
371
+ --state <path> finalize: the pre-launch fixture-state.json to seed
372
+ --cdp-port <port> finalize: CDP port of the running extension
373
+ --extension-dir <path> finalize: loaded extension dist (e.g. dist/chrome)
374
+ --extension-id-file <path> finalize: optional file to read/write the resolved extension id
365
375
  --adapter <mobile|extension> Target adapter (auto-detected inside a checkout)
366
376
  --target <path> Checkout path (default: cwd)
367
377
  --json Machine-readable output
368
378
 
369
379
  Example:
370
380
  mm-harness fixtures sync
371
- mm-harness fixtures set`
381
+ mm-harness fixtures set
382
+ mm-harness fixtures generate --fixture wallet-fixture.json --out fixture-state.json
383
+ mm-harness fixtures finalize --fixture wallet-fixture.json --state fixture-state.json --cdp-port 6661 --extension-dir dist/chrome`
372
384
  }
373
385
  ];
374
386
  const RETIRED_INTERNAL = [
package/docs/CLI-SPEC.md CHANGED
@@ -287,21 +287,30 @@ Recovery is silent in human mode. With `--json`: `"recovered": ["metro.restarted
287
287
 
288
288
  **One canonical wallet fixture per checkout** — same SSOT model as the farm installer. There is no fixture selection, listing, or bare status verb. **Fixture status is reported by `doctor` automatically** (grounded: `doctor.ts` `fixtureSummary()` already returns `{ status: 'missing'|'ready'|'incomplete'|'invalid', path, accountCount, hasPassword }` and is included in `createDoctorReport()` at line 125). The human edits `wallet-fixture.json` directly; `fixtures set` always teaches where.
289
289
 
290
- **Synopsis:** `mm-harness fixtures <sync|set> [flags]` — subcommand required.
290
+ **Synopsis:** `mm-harness fixtures <sync|set|generate|finalize> [flags]` — subcommand required.
291
291
 
292
292
  | Subcommand | Behavior |
293
293
  |---|---|
294
294
  | `sync` | Refresh **wallet fixture files** on the target (wallet DATA only — overlay refresh is `--heal` / `doctor --fix` territory, NOT `fixtures`; they are separate concerns) |
295
295
  | `set` | Apply THE canonical fixture (SRP/password/accounts) to the running slot — password read FROM the fixture, never typed; always prints "Want different accounts? Edit: <absolute-path>/wallet-fixture.json" |
296
+ | `generate` | Render the extension `fixture-state.json` from a wallet fixture for pre-launch profile prefill (fronts `adapters/extension/wallet-fixture-state.cjs generate`). **Extension-only** (mobile/core teach). Requires `--fixture <wallet-fixture.json>` and `--out <fixture-state.json>`. This is the public surface for farm packs that need the pre-launch fixture-state file, replacing direct reach-in to the internal leaf. |
297
+ | `finalize` | Seed account labels/selection into the **live** extension over CDP and validate import parity (fronts `adapters/extension/wallet-fixture-state.cjs seed-cdp`). **Extension-only, post-launch** (mobile/core teach). Requires `--fixture`, `--state`, `--cdp-port`, `--extension-dir`. This is the public surface for farm packs that finalize account labels after launch, replacing the last direct reach-in to the internal leaf. |
296
298
 
297
299
  | Flag | Type | Default | ENV (agent) | Audience | Description |
298
300
  |---|---|---|---|---|---|
299
- | `--fixture <json>` | path | slot's `wallet-fixture.json` | `RECIPE_WALLET_FIXTURE` | **agent only** | Override fixture path; human form = edit the canonical file directly |
301
+ | `--fixture <json>` | path | slot's `wallet-fixture.json` (`set`/`sync`); **required** for `generate`/`finalize` (input wallet fixture) | `RECIPE_WALLET_FIXTURE` | **agent** (`set`/`sync`); both (`generate`/`finalize`) | `set`/`sync`: override fixture path (human form = edit the canonical file directly). `generate`/`finalize`: the input wallet fixture. |
302
+ | `--out <json>` | path | `generate`: **required**; `finalize`: leaf default report path | — | both | `generate`: output `fixture-state.json`. `finalize`: optional validation report path. |
303
+ | `--state <json>` | path | — (**required** for `finalize`) | — | both | `finalize` only: the pre-launch `fixture-state.json` to seed into the live wallet |
304
+ | `--cdp-port <port>` | number | — (**required** for `finalize`) | — | both | `finalize` only: CDP port of the running extension |
305
+ | `--extension-dir <path>` | path | — (**required** for `finalize`) | — | both | `finalize` only: the loaded extension dist (e.g. `dist/chrome`) |
306
+ | `--extension-id-file <path>` | path | — | — | agent | `finalize` only: file to read/write the resolved extension id |
300
307
  | `--platform <p>` | mobile\|extension | auto | `PLATFORM` | agent | Force platform |
301
308
 
302
- **Exit:** 0 · 1 apply failed · 2 bad args.
309
+ **Exit:** 0 · 1 apply/generate/finalize failed · 2 bad args (incl. missing required flags, or `generate`/`finalize` on a non-extension adapter).
303
310
  **`set` teaching output:** always ends with: `Wallet fixture applied. Want different accounts? Edit: <absolute-path>/wallet-fixture.json`.
304
- **Maps-to:** `sync` C:`sync`, D:`sync`,`update\|sync-runtime`; `set` C:`setup-wallet\|wallet-setup`,`unlock`,`setup:ios/android`(wallet half).
311
+ **`generate` --json:** `{ schemaVersion, command:"fixtures", action:"generate", adapter:"extension", fixture, out, status, exitCode, error }` — leaf output stays on stderr; stdout carries only the envelope.
312
+ **`finalize` --json:** `{ schemaVersion, command:"fixtures", action:"finalize", adapter:"extension", fixture, state, cdpPort, extensionDir, out, status, exitCode, error }` — leaf progress streams to stderr; stdout carries only the envelope. A missing packed leaf yields `error.code:"LEAF_MISSING"` (exit 1), matching `generate`.
313
+ **Maps-to:** `sync` ← C:`sync`, D:`sync`,`update\|sync-runtime`; `set` ← C:`setup-wallet\|wallet-setup`,`unlock`,`setup:ios/android`(wallet half); `generate` ← Extension farm `setup/generate-fixture.cjs` (retires the pack's internal-leaf resolver); `finalize` ← Extension farm `setup/preflight.sh` seed-cdp finalization (retires the pack's last `--resolve-script` reach-in).
305
314
  **Extension `set`:** [GAP] no standalone path today — the first-run wallet is seeded by the legacy `mme-recipe up`; the end-state home is `fixtures set` (launch / `launch --verify` never seed).
306
315
 
307
316
 
@@ -620,6 +629,16 @@ per-checkout runtime overlay. Final home is top-level (no `harness` prefix). `li
620
629
  **JSON:** `{ schemaVersion, command:"harness", action, adapter, target, autoDetected, status, exitCode }`.
621
630
  **Maps-to:** A:`harness <verb>` (ROUTES-NOW); E: skill (REMOVE); F: orchestration entry points (KEEP-INTERNAL); C/D:`prepare`,`ready\|ensure-ready` (ABSORB-LATER).
622
631
 
632
+ **Core-runner install — the public surface for farm packs.** `mm-harness install
633
+ --adapter core --target <repo>` is the pack-facing way to install the headless
634
+ core runner into a slot: it fronts `adapters/core/inject.sh` (the same leaf a farm
635
+ pack used to clone-and-run itself) and produces the runner delegate at
636
+ `<harness>/core/runner/bin/mm-harness`. Because the inject leaf ships inside the
637
+ published package, packs no longer clone a runner checkout — they call this command
638
+ and inherit `RECIPE_HARNESS_ROOT` / `FARMSLOT_ROOT` through the environment. There
639
+ is no separate top-level `install-core` command; `--adapter core` on the existing
640
+ `install` is the surface (grounded: `tests/contract/core-install-cli.test.sh`).
641
+
623
642
  ## Advanced (collapsed → KEEP-INTERNAL)
624
643
 
625
644
  **All six ADVANCED verbs are removed from the CLI surface and `--help`** — their logic
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"