@deeeed/metamask-harness 0.14.7 → 0.15.1

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 (101) hide show
  1. package/CHANGELOG.md +87 -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/bin/mm-harness +7 -0
  55. package/dist/adapters/core/surface.js +3 -0
  56. package/dist/adapters/extension/ensure-ready.js +0 -7
  57. package/dist/adapters/extension/runtime-decision.js +93 -3
  58. package/dist/adapters/extension/surface.js +17 -6
  59. package/dist/adapters/mobile/metro-env.js +67 -0
  60. package/dist/adapters/mobile/perps-env.js +101 -0
  61. package/dist/adapters/mobile/prepare.js +32 -14
  62. package/dist/adapters/mobile/runtime-decision.js +21 -2
  63. package/dist/adapters/mobile/source-freshness.js +116 -0
  64. package/dist/adapters/mobile/surface.js +3 -0
  65. package/dist/adapters/resolve-slot-ports.js +4 -0
  66. package/dist/adapters/slot-ports.js +131 -49
  67. package/dist/adapters.js +7 -2
  68. package/dist/checkout-lock.js +72 -0
  69. package/dist/cli.js +2 -0
  70. package/dist/commands/call.js +91 -54
  71. package/dist/commands/check.js +266 -46
  72. package/dist/commands/checklist.js +136 -0
  73. package/dist/commands/debug.js +21 -2
  74. package/dist/commands/doctor.js +196 -13
  75. package/dist/commands/fixtures.js +177 -42
  76. package/dist/commands/launch/extension.js +6 -1
  77. package/dist/commands/launch/index.js +54 -22
  78. package/dist/commands/logs.js +24 -4
  79. package/dist/commands/run-engine.js +223 -7
  80. package/dist/commands/run.js +67 -50
  81. package/dist/commands/shared.js +86 -5
  82. package/dist/commands/stop.js +1 -2
  83. package/dist/doctor.js +39 -17
  84. package/dist/harness.js +5 -2
  85. package/dist/heal-bounds.js +1 -1
  86. package/dist/mm-harness-cli.js +37 -9
  87. package/dist/runner.js +35 -5
  88. package/dist/runtime-context.js +228 -0
  89. package/docs/CLI-SPEC.md +15 -11
  90. package/docs/MENTAL-MODEL.md +6 -6
  91. package/library/actions/extension/perps/perps.mjs +29 -8
  92. package/library/actions/extension/platform/cdp.mjs +128 -28
  93. package/library/actions/extension/ui/navigate.mjs +1 -1
  94. package/library/actions/mobile/perps/perps.mjs +13 -1
  95. package/library/actions/mobile/platform/bridge.mjs +302 -29
  96. package/library/actions/mobile/wallet/ensure_unlocked.mjs +7 -2
  97. package/library/manifests/extension.action-manifest.json +32 -12
  98. package/library/manifests/mobile.action-manifest.json +25 -3
  99. package/package.json +5 -5
  100. package/scripts/completions.sh +7 -4
  101. package/scripts/install-completions.sh +0 -0
@@ -1,10 +1,13 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs";
2
4
  import path from "node:path";
3
5
  import { color } from "../cli-color.js";
4
- import { createDoctorReport, renderRuntimeContext } from "../doctor.js";
5
- import { detectAdapter } from "../harness.js";
6
+ import { createDoctorReport, fixtureSummary, renderRuntimeContext, requiredDoctorCheckSummary } from "../doctor.js";
7
+ import { detectAdapter, resolveRuntimeContextPath } from "../harness.js";
6
8
  import { assertAdapter, runnerDir } from "../paths.js";
7
9
  import { getAdapterSurface } from "../adapters/surface.js";
10
+ import { ensureMobilePerpsEnvironment, mobilePerpsEnvironment } from "../adapters/mobile/perps-env.js";
8
11
  import { loadActionManifest, validateManifest } from "../manifest.js";
9
12
  import { ensureOverlay, newHealState, recipeRunning } from "../heal-bounds.js";
10
13
  import { applyDeviceTargeting } from "./device-target.js";
@@ -16,12 +19,15 @@ import {
16
19
  renderMobileLiveBlock
17
20
  } from "./mobile-device-view.js";
18
21
  import { farmslotReadyIndicator } from "./farmslot-ready.js";
19
- import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
22
+ import { ensureRuntimeContext } from "../runtime-context.js";
23
+ import { acquireCheckoutLock } from "../checkout-lock.js";
24
+ import { ADAPTER_DETECT_NEXT, checkoutBusyOut, EXIT, usageOut } from "./shared.js";
20
25
  import {
21
26
  actionManifestPathOption,
22
27
  applyRuntimeDirOption,
23
28
  optionFlag,
24
29
  optionString,
30
+ shellQuote,
25
31
  targetPath
26
32
  } from "./parse-args.js";
27
33
  function applyDoctorRuntimePorts(options) {
@@ -51,6 +57,14 @@ async function handleDoctor({ options }) {
51
57
  return usageOut(json, "doctor", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
52
58
  }
53
59
  assertAdapter(adapter);
60
+ if (!fs.existsSync(target)) {
61
+ return usageOut(
62
+ json,
63
+ "doctor",
64
+ `target does not exist: ${target}`,
65
+ "pass --target <metamask-checkout> pointing to an existing checkout"
66
+ );
67
+ }
54
68
  if (printReady && json) {
55
69
  return usageOut(
56
70
  json,
@@ -63,14 +77,37 @@ async function handleDoctor({ options }) {
63
77
  const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
64
78
  const manifestValidation = await validateManifest(manifest);
65
79
  if (optionFlag(options, "fix")) {
66
- const { fixed, failed } = await runDoctorFix(adapter, target, manifestValidation, json);
80
+ const surface = getAdapterSurface(adapter);
81
+ surface.resolveSlotPorts(target);
82
+ const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
83
+ if ("code" in dtResult) {
84
+ return usageOut(json, "doctor", dtResult.message, "mm-harness doctor --fix --adapter mobile --device <adb-serial|simulator-udid>");
85
+ }
86
+ applyDoctorRuntimePorts(options);
87
+ const lock = acquireCheckoutLock(target, "doctor-fix");
88
+ if ("message" in lock) {
89
+ return checkoutBusyOut(json, "doctor", lock.message, lock.path);
90
+ }
91
+ let fixed;
92
+ let failed;
93
+ try {
94
+ ({ fixed, failed } = await runDoctorFix(adapter, target, manifestValidation, json));
95
+ } finally {
96
+ lock.release();
97
+ }
67
98
  const result2 = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
68
- if (json) console.log(JSON.stringify({ ...result2, fixed, failed }, null, 2));
69
- else console.log(`${result2.status} ${adapter} ${result2.compatibilityMode} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
70
- return failed.length === 0 ? 0 : 1;
99
+ const nextActions = doctorFixNextActions(failed, result2.fixture, adapter, target);
100
+ const status = result2.status === "pass" && failed.length === 0 ? "pass" : "fail";
101
+ if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, nextActions }, null, 2));
102
+ else {
103
+ console.log(`${status} ${adapter} ${result2.compatibilityMode} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
104
+ for (const action of nextActions) console.error(` Next: ${action}`);
105
+ }
106
+ return status === "pass" ? 0 : 1;
71
107
  }
72
108
  const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
73
109
  let runtime;
110
+ let runtimeProbeError;
74
111
  try {
75
112
  const surface = getAdapterSurface(adapter);
76
113
  surface.resolveSlotPorts(target);
@@ -82,8 +119,18 @@ async function handleDoctor({ options }) {
82
119
  }
83
120
  applyDoctorRuntimePorts(options);
84
121
  runtime = await surface.runtimeStatus(target);
85
- } catch {
122
+ } catch (error) {
123
+ runtimeProbeError = error instanceof Error ? error.message : String(error);
86
124
  }
125
+ const runtimeCheck = doctorRuntimeCheck(runtime, runtimeProbeError, expectLive);
126
+ const checks = [...result.checks, runtimeCheck];
127
+ const requiredChecks = requiredDoctorCheckSummary(checks);
128
+ const doctorResult = {
129
+ ...result,
130
+ status: requiredChecks.status,
131
+ checks,
132
+ requiredChecks
133
+ };
87
134
  const orphanMetros = adapter === "mobile" ? detectOrphanMetros(target, process.env.WATCHER_PORT) : [];
88
135
  const capture = captureHelperHealth();
89
136
  const deviceView = adapter === "mobile" ? mobileDeviceView(allDevices) : null;
@@ -95,7 +142,7 @@ async function handleDoctor({ options }) {
95
142
  adapter,
96
143
  target,
97
144
  runtime,
98
- result,
145
+ doctorResult,
99
146
  orphanMetros,
100
147
  capture,
101
148
  devices,
@@ -104,11 +151,11 @@ async function handleDoctor({ options }) {
104
151
  printReady
105
152
  );
106
153
  }
107
- if (json) console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
154
+ if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
108
155
  else {
109
156
  const out = (style, text) => color(style, text, { stream: process.stdout });
110
157
  const stateStyle = (value, good) => value === good ? "ok" : "warn";
111
- console.log(`${out(result.status === "pass" ? "ok" : "err", result.status)} ${out("bold", adapter)} ${result.compatibilityMode} ${out("dim", `manifest=${actionManifestPath}`)}`);
158
+ console.log(`${out(doctorResult.status === "pass" ? "ok" : "err", doctorResult.status)} ${out("bold", adapter)} ${doctorResult.compatibilityMode} ${out("dim", `manifest=${actionManifestPath}`)}`);
112
159
  if (runtime) {
113
160
  const provisionedDepsPending = runtime.reasonCode === "app-installed-deps-pending";
114
161
  const decisionStyle = provisionedDepsPending ? "info" : runtime.decision === "ready" ? "ok" : runtime.decision === "blocked" ? "err" : "warn";
@@ -119,8 +166,16 @@ async function handleDoctor({ options }) {
119
166
  `${out("label", "runtime:")} decision=${out(decisionStyle, runtime.decision)}${runtime.reasonCode ? ` ${out("dim", `(${runtime.reasonCode})`)}` : ""} deps=${out(depsStyle, runtime.deps ?? "unknown")}${devServer}`
120
167
  );
121
168
  for (const reason of runtime.reasons) console.log(` ${out("dim", reason)}`);
169
+ } else if (runtimeProbeError) {
170
+ console.log(`${out("err", "runtime probe failed:")} ${out("dim", runtimeProbeError)}`);
122
171
  }
123
172
  console.log(renderRuntimeContext(result.runtimeContext));
173
+ if (adapter === "mobile") {
174
+ const perps = mobilePerpsEnvironment(target);
175
+ console.log(
176
+ `mobile-perps: ${perps.status} (${perps.file}; MM_PERPS_ENABLED=${perps.perpsEnabled.value ?? "missing"}; OVERRIDE_REMOTE_FEATURE_FLAGS=${perps.remoteFeatureFlagOverride.value ?? "missing"})`
177
+ );
178
+ }
124
179
  if (orphanMetros.length > 0) {
125
180
  console.log(
126
181
  `${out("warn", "orphan Metro:")} ${orphanMetros.length} leaked bundler(s) for this checkout (pids ${orphanMetros.join(", ")})`
@@ -140,7 +195,43 @@ async function handleDoctor({ options }) {
140
195
  if (liveView) renderMobileLiveBlock(deviceView?.devices ?? [], liveView.liveMap, out);
141
196
  }
142
197
  }
143
- return result.status === "pass" ? 0 : 1;
198
+ return doctorResult.status === "pass" ? 0 : 1;
199
+ }
200
+ function doctorRuntimeCheck(runtime, error, expectLive) {
201
+ if (error) {
202
+ return {
203
+ id: "runtime",
204
+ status: "fail",
205
+ required: true,
206
+ message: "Runtime readiness probe failed.",
207
+ detail: error
208
+ };
209
+ }
210
+ const ready = runtime?.decision === "ready";
211
+ return {
212
+ id: "runtime",
213
+ status: ready ? "pass" : "fail",
214
+ required: expectLive,
215
+ message: ready ? "Runtime is ready." : `Runtime is not ready${runtime?.reasonCode ? ` (${runtime.reasonCode})` : "."}`,
216
+ detail: runtime?.reasons.join(" | ") || "Runtime readiness probe returned no result."
217
+ };
218
+ }
219
+ function doctorFixNextActions(failed, fixture, adapter, target) {
220
+ const actions = [];
221
+ const executable = process.env.MM_HARNESS_EXECUTABLE ? shellQuote(process.env.MM_HARNESS_EXECUTABLE) : "mm-harness";
222
+ const scope = `--adapter ${adapter} --target ${shellQuote(target)}`;
223
+ if (failed.includes("wallet-fixture")) {
224
+ const force = fixture.status === "invalid" || fixture.status === "incomplete" ? " --force" : "";
225
+ actions.push(`1) ${executable} fixtures init --from <path>${force} ${scope} # existing team/test fixture`);
226
+ actions.push(`2) ${executable} fixtures init --dev${force} ${scope} # disposable public test wallet; never real funds`);
227
+ }
228
+ if (failed.includes("runtime-context")) {
229
+ actions.push(
230
+ `inspect ${shellQuote(resolveRuntimeContextPath(target))}, then retry ${executable} doctor --fix ${scope}`
231
+ );
232
+ }
233
+ if (failed.includes("overlay")) actions.push(`${executable} install ${scope}`);
234
+ return actions;
144
235
  }
145
236
  function emitExpectLiveVerdict(adapter, target, runtime, verdict) {
146
237
  const out = (style, text) => color(style, text, { stream: process.stderr });
@@ -165,7 +256,7 @@ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture,
165
256
  return EXIT.runtime;
166
257
  }
167
258
  if (json) {
168
- console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
259
+ console.log(JSON.stringify({ ...result, ready: live, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
169
260
  return live ? EXIT.ok : EXIT.runtime;
170
261
  }
171
262
  const out = (style, text) => color(style, text, { stream: process.stdout });
@@ -208,12 +299,104 @@ async function runDoctorFix(adapter, target, manifestValidation, json) {
208
299
  return { fixed, failed };
209
300
  }
210
301
  if (Number(manifestValidation.summary?.errors ?? 0) > 0) failed.push("manifest");
302
+ try {
303
+ const surface = getAdapterSurface(adapter);
304
+ surface.resolveSlotPorts(target);
305
+ const runtimeContext = ensureRuntimeContext(target, adapter);
306
+ if (runtimeContext.created) fixed.push("runtime-context");
307
+ } catch {
308
+ failed.push("runtime-context");
309
+ }
310
+ if (adapter === "extension") {
311
+ try {
312
+ if (ensureExtensionPerpsBuildFlag(target)) fixed.push("extension-perps-build");
313
+ } catch {
314
+ failed.push("extension-perps-build");
315
+ }
316
+ }
317
+ if (adapter === "mobile") {
318
+ try {
319
+ if (ensureMobilePerpsEnvironment(target).length > 0) fixed.push("mobile-perps-env");
320
+ } catch {
321
+ failed.push("mobile-perps-env");
322
+ }
323
+ }
211
324
  const state = newHealState();
212
325
  const ensured = await ensureOverlay(adapter, target, "infra-only", state, json);
213
326
  if (!ensured.ok) failed.push("overlay");
214
327
  else if (state.mutations.length > 0) fixed.push("overlay");
328
+ let fixture = fixtureSummary(target, adapter);
329
+ if (fixture.status !== "ready") {
330
+ const syncFixture = path.join(runnerDir, "adapters/shared/sync-wallet-fixture.sh");
331
+ try {
332
+ execFileSync("bash", [syncFixture, "--target", target], {
333
+ encoding: "utf8",
334
+ stdio: json ? ["ignore", "ignore", "ignore"] : "inherit",
335
+ timeout: 3e4
336
+ });
337
+ } catch {
338
+ }
339
+ fixture = fixtureSummary(target, adapter);
340
+ if (fixture.status === "ready") fixed.push("wallet-fixture");
341
+ else failed.push("wallet-fixture");
342
+ }
215
343
  return { fixed, failed };
216
344
  }
345
+ function ensureExtensionPerpsBuildFlag(target) {
346
+ const configPath = path.join(target, ".metamaskrc");
347
+ const templatePath = path.join(target, ".metamaskrc.dist");
348
+ const config = readTextSnapshot(configPath);
349
+ const template = config.kind === "file" ? null : readTextSnapshot(templatePath);
350
+ const source = config.kind === "file" ? config : template?.kind === "file" ? template : config.kind === "symlink" ? { kind: "file", text: "", mode: 438 } : null;
351
+ if (!source) return false;
352
+ const before = source.text;
353
+ if (/^\s*PERPS_ENABLED\s*=\s*(['"]?)true\1\s*$/mu.test(before)) return false;
354
+ const line = "PERPS_ENABLED='true'";
355
+ const after = /^\s*PERPS_ENABLED\s*=.*$/mu.test(before) ? before.replace(/^\s*PERPS_ENABLED\s*=.*$/mu, line) : `${before.replace(/\s*$/u, "")}
356
+ ${line}
357
+ `;
358
+ const temporary = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
359
+ let descriptor = null;
360
+ try {
361
+ const mode = config.kind === "file" ? config.mode : 438;
362
+ descriptor = fs.openSync(
363
+ temporary,
364
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
365
+ mode
366
+ );
367
+ fs.writeFileSync(descriptor, after);
368
+ if (config.kind === "file") fs.fchmodSync(descriptor, config.mode);
369
+ fs.closeSync(descriptor);
370
+ descriptor = null;
371
+ fs.renameSync(temporary, configPath);
372
+ } finally {
373
+ if (descriptor !== null) fs.closeSync(descriptor);
374
+ fs.rmSync(temporary, { force: true });
375
+ }
376
+ return true;
377
+ }
378
+ function readTextSnapshot(file) {
379
+ let descriptor;
380
+ try {
381
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
382
+ } catch (error) {
383
+ const code = error.code;
384
+ if (code === "ENOENT") return { kind: "missing" };
385
+ if (code === "ELOOP") return { kind: "symlink" };
386
+ throw error;
387
+ }
388
+ try {
389
+ return {
390
+ kind: "file",
391
+ text: fs.readFileSync(descriptor, "utf8"),
392
+ mode: fs.fstatSync(descriptor).mode & 511
393
+ };
394
+ } finally {
395
+ fs.closeSync(descriptor);
396
+ }
397
+ }
217
398
  export {
399
+ doctorFixNextActions,
400
+ ensureExtensionPerpsBuildFlag,
218
401
  handleDoctor
219
402
  };
@@ -1,38 +1,60 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { runnerDir, walletFixturePath } from "../paths.js";
4
5
  import { getAdapterSurface } from "../adapters/surface.js";
6
+ import { fixtureFileSummary, fixtureSummary } from "../doctor.js";
7
+ import { acquireCheckoutLock } from "../checkout-lock.js";
5
8
  import { applyDeviceTargeting } from "./device-target.js";
6
- import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, scriptOverride, spawnScript, spawnScriptStreaming, str, targetOf, usageOut } from "./shared.js";
7
- const FIXTURES_BOOLEANS = /* @__PURE__ */ new Set(["json"]);
8
- const RECOVERABLE_SETUP_WALLET_PATTERNS = [
9
- "CDP not reachable",
10
- "CDP bridge",
11
- "is not installed",
12
- "Engine.context.KeyringController not available",
13
- "Engine does not exist",
14
- "Cannot read property 'transactions'",
15
- "bridge-not-ready",
16
- "debug-target-missing",
17
- "CDP eval failed",
18
- "CDP eval-async failed"
19
- ];
20
- function isRecoverableSetupWalletFailure(output) {
21
- return RECOVERABLE_SETUP_WALLET_PATTERNS.some((p) => output.includes(p));
9
+ import { ADAPTER_DETECT_NEXT, checkoutBusyOut, EXIT, flag, parseFlags, resolveAdapter, scriptOverride, spawnScript, spawnScriptStreaming, str, targetOf, usageOut } from "./shared.js";
10
+ const FIXTURES_BOOLEANS = /* @__PURE__ */ new Set(["dev", "force", "json"]);
11
+ const FIXTURE_STREAM_TIMEOUT_MS = 12e4;
12
+ async function handleFixtures(argv, deps) {
13
+ const { options } = parseFlags(argv, FIXTURES_BOOLEANS);
14
+ const json = flag(options, "json");
15
+ const target = targetOf(options);
16
+ if (!fs.existsSync(target)) return handleFixturesLocked(argv, deps);
17
+ const lock = acquireCheckoutLock(target, "fixtures");
18
+ if ("message" in lock) {
19
+ return checkoutBusyOut(json, "fixtures", lock.message, lock.path);
20
+ }
21
+ try {
22
+ return await handleFixturesLocked(argv, deps);
23
+ } finally {
24
+ lock.release();
25
+ }
22
26
  }
23
- function resolveMobileFixturePlatform(options) {
24
- const explicit = str(options, "platform") ?? process.env["MOBILE_PLATFORM"];
25
- if (explicit === "android" || explicit === "ios") return explicit;
26
- if (process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE) return "android";
27
- if (process.env.IOS_SIMULATOR) return "ios";
28
- return "ios";
27
+ function ensureFixturePublicationParent(target) {
28
+ const repoRoot = fs.realpathSync(target);
29
+ const parent = path.dirname(walletFixturePath(repoRoot));
30
+ const relative = path.relative(repoRoot, parent);
31
+ if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
32
+ return `wallet fixture runtime path escapes the checkout: ${parent}`;
33
+ }
34
+ let current = repoRoot;
35
+ for (const part of relative.split(path.sep)) {
36
+ current = path.join(current, part);
37
+ try {
38
+ const entry = fs.lstatSync(current);
39
+ if (entry.isSymbolicLink()) {
40
+ return `wallet fixture runtime path must not contain symlinks: ${current}`;
41
+ }
42
+ if (!entry.isDirectory()) {
43
+ return `wallet fixture runtime path component is not a directory: ${current}`;
44
+ }
45
+ } catch (error) {
46
+ if (error.code !== "ENOENT") throw error;
47
+ fs.mkdirSync(current);
48
+ }
49
+ }
50
+ return fs.realpathSync(parent) === parent ? null : `wallet fixture runtime path escapes the checkout: ${parent}`;
29
51
  }
30
- async function handleFixtures(argv, deps) {
52
+ async function handleFixturesLocked(argv, deps) {
31
53
  const { positional, options } = parseFlags(argv, FIXTURES_BOOLEANS);
32
54
  const json = flag(options, "json");
33
55
  const sub = positional[0];
34
- if (sub !== "sync" && sub !== "set" && sub !== "generate" && sub !== "finalize") {
35
- 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");
56
+ if (sub !== "init" && sub !== "sync" && sub !== "set" && sub !== "generate" && sub !== "finalize") {
57
+ return usageOut(json, "fixtures", "fixtures requires a subcommand: mm-harness fixtures <init|sync|set|generate|finalize>", "mm-harness fixtures init --from <path> or mm-harness fixtures init --dev");
36
58
  }
37
59
  const target = targetOf(options);
38
60
  const adapter = resolveAdapter(options, target);
@@ -47,8 +69,18 @@ async function handleFixtures(argv, deps) {
47
69
  }
48
70
  if (sub === "generate") return fixturesGenerate(adapter, target, options, json);
49
71
  if (sub === "finalize") return fixturesFinalize(adapter, target, options, json);
50
- if (surface.headless) return usageOut(json, "fixtures", "core is headless; it has no wallet fixture.", surface.hints.launch);
51
72
  const canonicalFixture = walletFixturePath(target);
73
+ if (sub === "init") return fixturesInit(adapter, target, options, json);
74
+ if (surface.headless && sub === "set") {
75
+ const fixture = fixtureSummary(target, adapter);
76
+ if (fixture.status !== "ready") {
77
+ return usageOut(json, "fixtures", `core wallet fixture is ${String(fixture.status)} at ${canonicalFixture}.`, "mm-harness fixtures init --from <path> or mm-harness fixtures init --dev");
78
+ }
79
+ const message = `Core reads the canonical signing fixture directly: ${canonicalFixture}`;
80
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "fixtures", action: "set", adapter, fixture: canonicalFixture, status: "pass", exitCode: EXIT.ok, message }, null, 2));
81
+ else console.error(message);
82
+ return EXIT.ok;
83
+ }
52
84
  const retryHint = `${surface.hints.relaunch} # relaunch, then retry: mm-harness fixtures set`;
53
85
  if (sub === "sync") {
54
86
  const exitCode = fixturesSync(adapter, target, json);
@@ -67,25 +99,29 @@ async function handleFixtures(argv, deps) {
67
99
  process.stderr.write(`\u2192 fixtures set ${adapter} \u2014 connecting bridge + applying wallet fixture (can take ~30s)\u2026
68
100
  `);
69
101
  let status;
102
+ let mobileSetupResult;
70
103
  if (adapter === "mobile") {
71
104
  const setupWalletSh = path.join(runnerDir, "adapters/mobile/bridge-runtime/setup-wallet.sh");
72
105
  const previousAppRoot = process.env.APP_ROOT;
73
106
  process.env.APP_ROOT = target;
74
107
  try {
75
- let result = await spawnScriptStreaming(setupWalletSh, ["--fixture", fixturePath], target);
76
- if (result.status !== 0 && isRecoverableSetupWalletFailure(result.output) && !process.env["RECIPE_SETUP_WALLET_RETRIED"]) {
77
- process.env["RECIPE_SETUP_WALLET_RETRIED"] = "1";
78
- if (!json) process.stderr.write(" setup-wallet: recoverable failure \u2014 restarting Metro and retrying\n Next: wait for relaunch, then wallet setup will retry automatically\n");
79
- const { prepareMobile } = await import("../adapters/mobile/prepare.js");
80
- const platform = resolveMobileFixturePlatform(options);
81
- const relaunchResult = await prepareMobile(target, { platform, json, preflightMode: "auto", clearMetro: true });
82
- if (relaunchResult.status === 0) {
83
- result = await spawnScriptStreaming(setupWalletSh, ["--fixture", fixturePath], target);
108
+ const configuredTimeout = Number(process.env.MM_HARNESS_FIXTURES_SET_TIMEOUT_MS ?? FIXTURE_STREAM_TIMEOUT_MS);
109
+ const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : FIXTURE_STREAM_TIMEOUT_MS;
110
+ mobileSetupResult = await spawnScriptStreaming(
111
+ setupWalletSh,
112
+ ["--fixture", fixturePath],
113
+ target,
114
+ {
115
+ env: {
116
+ APP_ROOT: target,
117
+ WATCHER_PORT: process.env.WATCHER_PORT ?? "8081",
118
+ METRO_PORT: process.env.METRO_PORT ?? process.env.WATCHER_PORT ?? "8081"
119
+ },
120
+ timeoutMs
84
121
  }
85
- }
86
- status = result.status === 0 ? "pass" : "fail";
122
+ );
123
+ status = mobileSetupResult.status === 0 ? "pass" : "fail";
87
124
  } finally {
88
- delete process.env["RECIPE_SETUP_WALLET_RETRIED"];
89
125
  if (previousAppRoot === void 0) delete process.env.APP_ROOT;
90
126
  else process.env.APP_ROOT = previousAppRoot;
91
127
  }
@@ -129,8 +165,8 @@ async function handleFixtures(argv, deps) {
129
165
  exitCode: status === "pass" ? EXIT.ok : EXIT.runtime,
130
166
  message: teaching,
131
167
  error: status === "fail" ? {
132
- code: "SETUP_WALLET_FAILED",
133
- message: "wallet fixture setup failed",
168
+ code: mobileSetupResult?.timedOut ? "SETUP_WALLET_TIMEOUT" : "SETUP_WALLET_FAILED",
169
+ message: mobileSetupResult?.timedOut ? `wallet fixture setup exceeded its ${String(mobileSetupResult.timeoutMs)}ms bound on Metro port ${process.env.WATCHER_PORT ?? "8081"}` : `wallet fixture setup failed on ${process.env.WATCHER_PORT ?? "8081"}`,
134
170
  userAction: retryHint
135
171
  } : null
136
172
  },
@@ -262,7 +298,9 @@ async function fixturesFinalize(adapter, target, options, json) {
262
298
  if (outPath) leafArgs.push("--out", outPath);
263
299
  process.stderr.write(`\u2192 fixtures finalize extension \u2014 seeding account labels via CDP on port ${cdpPort} (can take ~30s)\u2026
264
300
  `);
265
- const result = await spawnScriptStreaming(process.execPath, leafArgs, target);
301
+ const result = await spawnScriptStreaming(process.execPath, leafArgs, target, {
302
+ timeoutMs: FIXTURE_STREAM_TIMEOUT_MS
303
+ });
266
304
  const status = result.status === 0 ? "pass" : "fail";
267
305
  const exitCode = result.status === 0 ? EXIT.ok : EXIT.runtime;
268
306
  const retry = `mm-harness fixtures finalize --fixture ${fixturePath} --state ${statePath} --cdp-port ${cdpPort} --extension-dir ${extensionDirPath}`;
@@ -316,7 +354,104 @@ function fixturesSync(adapter, target, json) {
316
354
  const syncArgs = ["--target", target];
317
355
  if (process.env.CDP_PORT) syncArgs.push("--cdp-port", process.env.CDP_PORT);
318
356
  if (process.env.RECIPE_SLOT_ID) syncArgs.push("--slot-id", process.env.RECIPE_SLOT_ID);
319
- spawnScript(syncFixtureSh, syncArgs, target, json);
357
+ const syncResult = spawnScript(syncFixtureSh, syncArgs, target, json);
358
+ if (syncResult.status !== 0 || fixtureSummary(target, adapter).status !== "ready") {
359
+ if (!json) console.error("\u2717 no usable wallet fixture source was found.\n Next: mm-harness fixtures init --from <path> or mm-harness fixtures init --dev");
360
+ return EXIT.runtime;
361
+ }
362
+ return EXIT.ok;
363
+ }
364
+ function fixturesInit(adapter, target, options, json) {
365
+ const canonical = walletFixturePath(target);
366
+ const containmentError = ensureFixturePublicationParent(target);
367
+ if (containmentError) {
368
+ return usageOut(json, "fixtures", containmentError, "replace the runtime path with checkout-contained directories, then retry");
369
+ }
370
+ const source = str(options, "from");
371
+ const dev = flag(options, "dev");
372
+ const force = flag(options, "force");
373
+ if (!source && !dev) {
374
+ return usageOut(
375
+ json,
376
+ "fixtures",
377
+ "choose a wallet fixture source: 1) --from <path> for an existing team/test fixture; 2) --dev for a disposable public test wallet.",
378
+ "mm-harness fixtures init --from <path> or mm-harness fixtures init --dev"
379
+ );
380
+ }
381
+ if (source && dev) {
382
+ return usageOut(json, "fixtures", "--from and --dev are mutually exclusive.", "choose exactly one fixture source");
383
+ }
384
+ if (!force) {
385
+ try {
386
+ fs.lstatSync(canonical);
387
+ return usageOut(json, "fixtures", `wallet fixture already exists: ${canonical}`, "re-run with --force only when replacement is intentional");
388
+ } catch (error) {
389
+ if (error.code !== "ENOENT") throw error;
390
+ }
391
+ }
392
+ fs.mkdirSync(path.dirname(canonical), { recursive: true });
393
+ let fixtureBytes;
394
+ if (source) {
395
+ const resolvedSource = path.resolve(source);
396
+ try {
397
+ fixtureBytes = fs.readFileSync(resolvedSource);
398
+ } catch (error) {
399
+ if (error.code === "ENOENT") {
400
+ return usageOut(json, "fixtures", `wallet fixture source does not exist: ${resolvedSource}`, "pass an existing JSON file to --from");
401
+ }
402
+ throw error;
403
+ }
404
+ } else {
405
+ const fixture = {
406
+ password: "metamask",
407
+ accounts: [{
408
+ type: "mnemonic",
409
+ value: "test test test test test test test test test test test junk",
410
+ name: "dev1",
411
+ count: 1,
412
+ names: ["dev1"]
413
+ }],
414
+ settings: { skipPerpsTutorial: true, autoLockNever: true }
415
+ };
416
+ fixtureBytes = `${JSON.stringify(fixture, null, 2)}
417
+ `;
418
+ }
419
+ const temporary = `${canonical}.${process.pid}.${randomUUID()}.tmp`;
420
+ let descriptor = null;
421
+ try {
422
+ descriptor = fs.openSync(
423
+ temporary,
424
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
425
+ 384
426
+ );
427
+ fs.writeFileSync(descriptor, fixtureBytes);
428
+ fs.fchmodSync(descriptor, 384);
429
+ fs.closeSync(descriptor);
430
+ descriptor = null;
431
+ const fixture = fixtureFileSummary(temporary, adapter, canonical);
432
+ if (fixture.status !== "ready") {
433
+ return usageOut(json, "fixtures", "wallet fixture is incomplete or invalid for this adapter.", "provide accounts and, for Mobile/Extension, a non-empty password");
434
+ }
435
+ if (force) {
436
+ fs.renameSync(temporary, canonical);
437
+ } else {
438
+ try {
439
+ fs.linkSync(temporary, canonical);
440
+ } catch (error) {
441
+ if (error.code === "EEXIST") {
442
+ return usageOut(json, "fixtures", `wallet fixture already exists: ${canonical}`, "re-run with --force only when replacement is intentional");
443
+ }
444
+ throw error;
445
+ }
446
+ }
447
+ } finally {
448
+ if (descriptor !== null) fs.closeSync(descriptor);
449
+ fs.rmSync(temporary, { force: true });
450
+ }
451
+ const warning = dev ? "Disposable public test wallet created; never use or fund it with real assets." : "Wallet fixture copied.";
452
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "fixtures", action: "init", adapter, fixture: canonical, status: "pass", warning, exitCode: EXIT.ok }, null, 2));
453
+ else console.error(`${warning}
454
+ Wallet fixture: ${canonical}`);
320
455
  return EXIT.ok;
321
456
  }
322
457
  export {
@@ -12,7 +12,7 @@ import { spawnScriptStreaming } from "../shared.js";
12
12
  function extensionDepsBlock(target) {
13
13
  if (!fs.existsSync(path.join(target, "package.json"))) return null;
14
14
  const deps = depsCheck(target);
15
- const userAction = "yarn install --immutable # then re-run: mm-harness launch";
15
+ const userAction = "mm-harness launch --heal auto";
16
16
  if (deps.status === "missing") {
17
17
  return { message: "extension dependencies are not installed (no yarn install-state markers).", userAction };
18
18
  }
@@ -24,6 +24,10 @@ function extensionDepsBlock(target) {
24
24
  }
25
25
  return null;
26
26
  }
27
+ function installExtensionDeps(target) {
28
+ const installScript = path.join(runnerDir, "adapters/shared/install-repo-deps.sh");
29
+ return spawnScriptStreaming(installScript, ["--target", target], target);
30
+ }
27
31
  async function launchExtension(target, tier, wantWatch, displayMode = "fullscreen") {
28
32
  if (wantWatch) {
29
33
  const startWatchSh = path.join(runnerDir, "adapters/extension/start-watch.sh");
@@ -206,5 +210,6 @@ export {
206
210
  extensionReattach,
207
211
  extensionRebuild,
208
212
  extensionRuntimeReusable,
213
+ installExtensionDeps,
209
214
  launchExtension
210
215
  };