@deeeed/metamask-harness 0.17.5 → 0.19.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 (169) hide show
  1. package/CHANGELOG.md +1059 -0
  2. package/README.md +91 -140
  3. package/adapters/core/inject.sh +1 -6
  4. package/adapters/extension/console-tail.mjs +55 -24
  5. package/adapters/extension/ensure-browser.sh +6 -1
  6. package/adapters/extension/inject.mjs +7 -13
  7. package/adapters/extension/launch-browser.cjs +7 -1
  8. package/adapters/extension/launch-webpack.cjs +83 -0
  9. package/adapters/extension/launch.sh +8 -11
  10. package/adapters/extension/lib/chrome-args.cjs +8 -1
  11. package/adapters/extension/lib/macos-focus.cjs +32 -0
  12. package/adapters/extension/live.sh +12 -21
  13. package/adapters/extension/reattach.sh +1 -0
  14. package/adapters/extension/seed-fixture.sh +4 -12
  15. package/adapters/extension/sidepanel-toggle.sh +4 -1
  16. package/adapters/extension/stamp-runtime-title.cjs +58 -0
  17. package/adapters/extension/start-watch.sh +17 -4
  18. package/adapters/extension/stop-viewers.sh +1 -1
  19. package/adapters/extension/sync-webpack-dist.cjs +107 -0
  20. package/adapters/extension/verify.sh +2 -2
  21. package/adapters/manifest.json +53 -37
  22. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  23. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +1 -1
  24. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +3 -1
  25. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +2 -3
  26. package/adapters/mobile/cleanup.sh +16 -176
  27. package/adapters/mobile/inject.sh +91 -671
  28. package/adapters/mobile/launch-console-forwarder.cjs +39 -0
  29. package/adapters/mobile/open-device.sh +45 -7
  30. package/adapters/mobile/start-console-forwarder.sh +70 -0
  31. package/adapters/mobile/start-metro.sh +0 -41
  32. package/adapters/mobile/verify.sh +34 -83
  33. package/adapters/mobile/wait-for-bridge.sh +22 -8
  34. package/adapters/shared/harness-source-fingerprint.mjs +14 -12
  35. package/adapters/shared/resolve-slot-ports-core.mjs +17 -6
  36. package/adapters/shared/resolve-slot-ports.sh +17 -5
  37. package/adapters/shared/update-check-worker.mjs +43 -0
  38. package/bin/mm-harness +21 -2
  39. package/dist/adapters/core/surface.js +4 -1
  40. package/dist/adapters/extension/console-capture.js +95 -0
  41. package/dist/adapters/extension/product-config.js +110 -0
  42. package/dist/adapters/extension/runtime-decision.js +20 -2
  43. package/dist/adapters/extension/surface.js +22 -3
  44. package/dist/adapters/harness-freshness.js +49 -0
  45. package/dist/adapters/mobile/perps-env.js +43 -3
  46. package/dist/adapters/mobile/prepare.js +39 -7
  47. package/dist/adapters/mobile/surface.js +5 -2
  48. package/dist/adapters/resolve-slot-ports.js +2 -2
  49. package/dist/adapters/slot-ports.js +13 -16
  50. package/dist/adapters.js +56 -16
  51. package/dist/checkout-lock.js +27 -2
  52. package/dist/cli-color.js +19 -0
  53. package/dist/cli-commands.js +1 -1
  54. package/dist/cli.js +7 -18
  55. package/dist/command-contract.js +462 -0
  56. package/dist/command-journal.js +263 -0
  57. package/dist/commands/call.js +252 -57
  58. package/dist/commands/check.js +10 -4
  59. package/dist/commands/completion-candidates.js +17 -22
  60. package/dist/commands/device-target.js +27 -12
  61. package/dist/commands/doctor.js +106 -25
  62. package/dist/commands/fixtures.js +142 -35
  63. package/dist/commands/last.js +60 -0
  64. package/dist/commands/launch/extension.js +40 -17
  65. package/dist/commands/launch/index.js +207 -63
  66. package/dist/commands/launch/mobile.js +2 -0
  67. package/dist/commands/list-executables.js +187 -29
  68. package/dist/commands/logs.js +8 -6
  69. package/dist/commands/manifest.js +286 -35
  70. package/dist/commands/parse-args.js +68 -4
  71. package/dist/commands/provision.js +10 -4
  72. package/dist/commands/run-engine.js +772 -264
  73. package/dist/commands/run-report.js +12 -3
  74. package/dist/commands/run.js +378 -85
  75. package/dist/commands/shared.js +76 -4
  76. package/dist/commands/status-probe.js +4 -1
  77. package/dist/commands/status.js +2 -1
  78. package/dist/commands/stop.js +7 -2
  79. package/dist/commands/update.js +16 -28
  80. package/dist/completions-cache.js +1 -1
  81. package/dist/doctor.js +57 -10
  82. package/dist/harness.js +47 -137
  83. package/dist/heal-bounds.js +7 -2
  84. package/dist/json-stream.js +57 -0
  85. package/dist/live-adapter-contract.js +138 -32
  86. package/dist/manifest.js +161 -1
  87. package/dist/mm-harness-cli.js +146 -73
  88. package/dist/paths.js +2 -5
  89. package/dist/recipe-security.js +178 -0
  90. package/dist/run-diagnostics.js +261 -0
  91. package/dist/run-recording.js +1 -1
  92. package/dist/runner.js +140 -9
  93. package/docs/CONTRIBUTING.md +136 -0
  94. package/docs/QA.md +184 -0
  95. package/docs/RECIPES.md +122 -0
  96. package/docs/SECURITY.md +88 -0
  97. package/library/README.md +7 -3
  98. package/library/actions/core/perps/_controller.mjs +43 -56
  99. package/library/actions/core/perps/assert_orders.mjs +6 -7
  100. package/library/actions/core/perps/assert_positions.mjs +6 -7
  101. package/library/actions/core/perps/close_orders.mjs +2 -0
  102. package/library/actions/core/perps/close_positions.mjs +2 -0
  103. package/library/actions/core/perps/ensure_orders.mjs +4 -2
  104. package/library/actions/core/perps/ensure_positions.mjs +4 -2
  105. package/library/actions/core/perps/place_order.mjs +7 -3
  106. package/library/actions/core/perps/read_account.mjs +2 -2
  107. package/library/actions/core/perps/read_orders.mjs +2 -1
  108. package/library/actions/core/perps/read_positions.mjs +2 -1
  109. package/library/actions/core/wallet/list_accounts.mjs +95 -0
  110. package/library/actions/extension/perps/assert_orders.mjs +2 -1
  111. package/library/actions/extension/perps/assert_positions.mjs +2 -1
  112. package/library/actions/extension/perps/perps.mjs +43 -14
  113. package/library/actions/extension/platform/cdp.mjs +1 -0
  114. package/library/actions/extension/wallet/list_accounts.mjs +41 -0
  115. package/library/actions/mobile/perps/assert_orders.mjs +2 -1
  116. package/library/actions/mobile/perps/assert_positions.mjs +2 -1
  117. package/library/actions/mobile/perps/perps.mjs +40 -12
  118. package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
  119. package/library/library.json +1 -1
  120. package/library/manifests/core.action-manifest.json +1214 -396
  121. package/library/manifests/extension.action-manifest.json +1635 -728
  122. package/library/manifests/mobile.action-manifest.json +1740 -727
  123. package/library/recipes/app/lifecycle.android-smoke.mobile.recipe.json +63 -81
  124. package/library/recipes/perps/clean-market-testnet.core.recipe.json +44 -0
  125. package/library/recipes/perps/clean-market-testnet.recipe.json +49 -0
  126. package/library/recipes/perps/lifecycle.recipe.json +136 -180
  127. package/library/recipes/perps/order-lifecycle.core.recipe.json +71 -67
  128. package/library/recipes/perps/performance.background-resume.mobile.recipe.json +51 -67
  129. package/library/recipes/perps/performance.cold-start.mobile.recipe.json +51 -67
  130. package/library/recipes/perps/performance.mobile.recipe.json +37 -51
  131. package/library/recipes/perps/performance.warm-start.mobile.recipe.json +44 -59
  132. package/library/recipes/perps/read-markets.core.recipe.json +29 -31
  133. package/library/recipes/perps/smoke.core.recipe.json +29 -32
  134. package/library/recipes/perps/smoke.extension.recipe.json +41 -44
  135. package/library/recipes/perps/smoke.mobile.recipe.json +42 -44
  136. package/library/recipes/perps/trading-lifecycle.core.recipe.json +69 -65
  137. package/library/recipes/runner/action-validation.extension.recipe.json +312 -398
  138. package/library/recipes/runner/action-validation.mobile.recipe.json +316 -402
  139. package/library/recipes/runner/smoke.core.recipe.json +25 -0
  140. package/library/recipes/runner/smoke.extension.recipe.json +23 -24
  141. package/library/recipes/runner/smoke.mobile.recipe.json +23 -24
  142. package/library/recipes/wallet/smoke.extension.recipe.json +40 -0
  143. package/library/recipes/wallet/smoke.mobile.recipe.json +40 -0
  144. package/package.json +12 -5
  145. package/scripts/completions.sh +8 -11
  146. package/scripts/validate-human-outcomes.mjs +169 -0
  147. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -185
  148. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -1662
  149. package/adapters/mobile/overlay/compat/README.md +0 -28
  150. package/adapters/mobile/overlay/compat/rn81-message-event-source.patch +0 -42
  151. package/adapters/shared/resolve-farmslot-ports-core.mjs +0 -3
  152. package/adapters/shared/resolve-farmslot-ports.mjs +0 -5
  153. package/adapters/shared/resolve-farmslot-ports.sh +0 -7
  154. package/dist/adapters/extension/harness-freshness.js +0 -39
  155. package/dist/commands/flows.js +0 -62
  156. package/docs/ADAPTER-SURFACE.md +0 -119
  157. package/docs/CHEATSHEET.md +0 -61
  158. package/docs/CLI-SPEC.md +0 -1098
  159. package/docs/CODE-MAP.md +0 -62
  160. package/docs/DEBUG-HANDOVER.md +0 -36
  161. package/docs/MENTAL-MODEL.md +0 -295
  162. package/docs/UX-PRINCIPLES.md +0 -64
  163. package/docs/architecture.md +0 -398
  164. package/docs/live-adapter-contract.md +0 -188
  165. package/docs/package-boundaries.md +0 -47
  166. package/docs/perps-flow-catalog.md +0 -235
  167. package/docs/recipe-libraries.md +0 -301
  168. package/docs/runtime-file-conventions.md +0 -36
  169. package/library/flows/perps.flows.json +0 -64
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const [cacheFile, timeoutText, endpoint] = process.argv.slice(2);
6
+ const timeoutMs = Number.parseInt(timeoutText ?? '', 10);
7
+
8
+ function stableVersion(value) {
9
+ const match = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})$/u.exec(value);
10
+ if (!match) return null;
11
+ return `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}`;
12
+ }
13
+
14
+ async function main() {
15
+ if (!cacheFile || !endpoint || !Number.isInteger(timeoutMs) || timeoutMs < 1) return;
16
+ const controller = new AbortController();
17
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
18
+ try {
19
+ const response = await fetch(endpoint, { signal: controller.signal });
20
+ if (!response.ok) return;
21
+ const data = await response.json();
22
+ if (!data || typeof data.latest !== 'string' || data.latest.length === 0) return;
23
+ const latest = stableVersion(data.latest);
24
+ if (!latest) return;
25
+ let lastCheck = Date.now();
26
+ try {
27
+ const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
28
+ if (typeof cached.lastCheck === 'number') lastCheck = cached.lastCheck;
29
+ } catch {
30
+ // The parent may have exited before creating the cache; recreate it below.
31
+ }
32
+ fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
33
+ const temporary = `${cacheFile}.${process.pid}.tmp`;
34
+ fs.writeFileSync(temporary, JSON.stringify({ lastCheck, latest }));
35
+ fs.renameSync(temporary, cacheFile);
36
+ } catch {
37
+ // Passive checks are intentionally silent; the explicit update command teaches failures.
38
+ } finally {
39
+ clearTimeout(timer);
40
+ }
41
+ }
42
+
43
+ await main();
package/bin/mm-harness CHANGED
@@ -5,6 +5,17 @@
5
5
  # end-state surface; it resolves node/tsx/dist the same way whether the package is
6
6
  # installed as a dependency or run from a source checkout.
7
7
  set -euo pipefail
8
+ INVOKED_AS="$0"
9
+ if [[ "$INVOKED_AS" == */* ]]; then
10
+ INVOKED_AS="$(cd "$(dirname "$INVOKED_AS")" && pwd -L)/${INVOKED_AS##*/}"
11
+ else
12
+ RESOLVED_INVOKED_AS="$(command -v "$INVOKED_AS" 2>/dev/null || true)"
13
+ if [[ -z "$RESOLVED_INVOKED_AS" ]]; then
14
+ echo "ERROR: Cannot resolve the invoked mm-harness executable: $INVOKED_AS" >&2
15
+ exit 1
16
+ fi
17
+ INVOKED_AS="$RESOLVED_INVOKED_AS"
18
+ fi
8
19
  SOURCE="${BASH_SOURCE[0]}"
9
20
  while [ -L "$SOURCE" ]; do
10
21
  SOURCE_DIR="$(cd "$(dirname "$SOURCE")" && pwd -P)"
@@ -26,7 +37,7 @@ RUNNER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
26
37
  # already points at this script — including via a symlink (both sides are resolved
27
38
  # to their real paths before comparing).
28
39
  if [ -n "${MM_HARNESS_BIN:-}" ]; then
29
- self_real="$SCRIPT_DIR/$(basename "$SOURCE")"
40
+ self_real="$SCRIPT_DIR/${SOURCE##*/}"
30
41
  # Resolve MM_HARNESS_BIN through any symlink chain so a symlink-to-self is caught.
31
42
  mm_bin_src="$MM_HARNESS_BIN"
32
43
  while [ -L "$mm_bin_src" ]; do
@@ -39,7 +50,7 @@ if [ -n "${MM_HARNESS_BIN:-}" ]; then
39
50
  fi
40
51
  done
41
52
  bin_real_dir="$(cd "$(dirname "$mm_bin_src")" 2>/dev/null && pwd -P || true)"
42
- bin_real="${bin_real_dir:+$bin_real_dir/$(basename "$mm_bin_src")}"
53
+ bin_real="${bin_real_dir:+$bin_real_dir/${mm_bin_src##*/}}"
43
54
  if [ "$bin_real" != "$self_real" ]; then
44
55
  if [ ! -x "$MM_HARNESS_BIN" ]; then
45
56
  echo "mm-harness: MM_HARNESS_BIN is not an executable: $MM_HARNESS_BIN" >&2
@@ -50,6 +61,14 @@ if [ -n "${MM_HARNESS_BIN:-}" ]; then
50
61
  fi
51
62
  fi
52
63
 
64
+ # Internal executable identity (not part of the public env surface): the CLI
65
+ # embeds this in emitted recovery commands so they run verbatim even when
66
+ # mm-harness is not on PATH (task-local installs). Set AFTER the MM_HARNESS_BIN
67
+ # delegation above so the final executable entered always overwrites any
68
+ # inherited/stale value with its own resolved self path.
69
+ export MM_HARNESS_EXECUTABLE="$SCRIPT_DIR/${SOURCE##*/}"
70
+ export MM_HARNESS_INVOKED_AS="$INVOKED_AS"
71
+
53
72
  ENTRY_TS="$RUNNER_DIR/src/mm-harness-cli.ts"
54
73
  ENTRY_DIST="$RUNNER_DIR/dist/mm-harness-cli.js"
55
74
 
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
3
+ import { shellQuote } from "../../commands/parse-args.js";
3
4
  const coreSurface = {
4
5
  adapter: "core",
5
6
  headless: true,
@@ -14,6 +15,7 @@ const coreSurface = {
14
15
  reasons: [
15
16
  ready ? "Core is headless; dependencies are installed. Run recipes with mm-harness run." : "Core is headless; dependencies are not fully installed."
16
17
  ],
18
+ nextAction: ready ? void 0 : `cd ${shellQuote(path.resolve(target))} && yarn install --immutable`,
17
19
  deps: deps.status
18
20
  };
19
21
  },
@@ -49,7 +51,8 @@ const coreSurface = {
49
51
  },
50
52
  hints: {
51
53
  launch: "mm-harness run <recipe> # run recipes against the headless core",
52
- relaunch: "mm-harness verify"
54
+ relaunch: "mm-harness verify",
55
+ runtimeProbeRecovery: (target) => `mm-harness verify --adapter core --target ${shellQuote(path.resolve(target))}`
53
56
  }
54
57
  };
55
58
  export {
@@ -0,0 +1,95 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { recipeRuntimePath, runnerDir } from "../../paths.js";
5
+ async function ensureExtensionConsoleCapture(target) {
6
+ const cdpPort = process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
7
+ if (!cdpPort) throw new Error("Extension console capture requires a resolved CDP port.");
8
+ const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
9
+ signal: AbortSignal.timeout(3e3)
10
+ });
11
+ if (!response.ok) throw new Error(`Extension CDP ${cdpPort} is not ready for console capture.`);
12
+ const extensionLog = recipeRuntimePath(target, "extension-console.log");
13
+ const dappLog = recipeRuntimePath(target, "dapp-console.log");
14
+ const collectorLog = recipeRuntimePath(target, "console-tail.log");
15
+ const pidFile = recipeRuntimePath(target, "console-tail.pid");
16
+ fs.mkdirSync(path.dirname(extensionLog), { recursive: true });
17
+ const existingPid = readPid(pidFile);
18
+ if (existingPid && collectorOwnsLog(existingPid, extensionLog)) return;
19
+ if (existingPid) {
20
+ try {
21
+ process.kill(existingPid, "SIGTERM");
22
+ } catch {
23
+ }
24
+ }
25
+ fs.rmSync(pidFile, { force: true });
26
+ fs.writeFileSync(extensionLog, "");
27
+ fs.writeFileSync(dappLog, "");
28
+ const script = path.join(runnerDir, "adapters", "extension", "console-tail.mjs");
29
+ const output = fs.openSync(collectorLog, "a");
30
+ let child;
31
+ try {
32
+ child = spawn(process.execPath, [
33
+ script,
34
+ "--cdp-port",
35
+ cdpPort,
36
+ "--extension-log",
37
+ extensionLog,
38
+ "--dapp-log",
39
+ dappLog
40
+ ], {
41
+ cwd: target,
42
+ detached: true,
43
+ stdio: ["ignore", output, output]
44
+ });
45
+ } finally {
46
+ fs.closeSync(output);
47
+ }
48
+ if (!child.pid) throw new Error("Extension console collector did not start.");
49
+ child.unref();
50
+ fs.writeFileSync(pidFile, `${child.pid}
51
+ `);
52
+ for (let attempt = 0; attempt < 100; attempt += 1) {
53
+ if (!processAlive(child.pid)) break;
54
+ if (collectorOwnsLog(child.pid, extensionLog) && containsAttachment(extensionLog, dappLog)) return;
55
+ await new Promise((resolve) => setTimeout(resolve, 100));
56
+ }
57
+ throw new Error(`Extension console collector ${child.pid} did not attach to CDP ${cdpPort}.`);
58
+ }
59
+ function processAlive(pid) {
60
+ try {
61
+ process.kill(pid, 0);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+ function readPid(pidFile) {
68
+ try {
69
+ const pid = Number.parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
70
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ function collectorOwnsLog(pid, extensionLog) {
76
+ try {
77
+ process.kill(pid, 0);
78
+ } catch {
79
+ return false;
80
+ }
81
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
82
+ return result.status === 0 && result.stdout.includes("console-tail.mjs") && result.stdout.includes(extensionLog);
83
+ }
84
+ function containsAttachment(...logs) {
85
+ return logs.some((log) => {
86
+ try {
87
+ return fs.readFileSync(log, "utf8").includes("[attached]");
88
+ } catch {
89
+ return false;
90
+ }
91
+ });
92
+ }
93
+ export {
94
+ ensureExtensionConsoleCapture
95
+ };
@@ -0,0 +1,110 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { shellQuote } from "../../commands/parse-args.js";
4
+ function isConfiguredInfuraId(value, placeholder) {
5
+ return value.length > 0 && value !== placeholder && value !== "true" && value !== "false" && value !== "null";
6
+ }
7
+ function extensionProductConfigBlock(target) {
8
+ const template = path.join(target, ".metamaskrc.dist");
9
+ const templateSnapshot = readConfigSnapshot(template);
10
+ if (templateSnapshot.kind === "missing") return null;
11
+ if (templateSnapshot.kind === "invalid") {
12
+ return invalidConfigBlock(target, ".metamaskrc.dist", templateSnapshot.reason);
13
+ }
14
+ const placeholder = readInfuraProjectId(templateSnapshot.text) ?? "";
15
+ const configuredFromEnvironment = process.env.INFURA_PROJECT_ID;
16
+ if (configuredFromEnvironment !== void 0) {
17
+ if (isConfiguredInfuraId(configuredFromEnvironment, placeholder)) return null;
18
+ return {
19
+ message: "Extension product configuration is blocked by an unusable exported INFURA_PROJECT_ID (the value is never displayed).",
20
+ userAction: `unset INFURA_PROJECT_ID; cd ${shellQuote(path.resolve(target))} && cp -n .metamaskrc.dist .metamaskrc && \${EDITOR:-vi} .metamaskrc`
21
+ };
22
+ }
23
+ const productionConfig = readConfigSnapshot(path.join(target, ".metamaskprodrc"));
24
+ if (productionConfig.kind === "invalid") {
25
+ return invalidConfigBlock(target, ".metamaskprodrc", productionConfig.reason);
26
+ }
27
+ if (productionConfig.kind === "file") {
28
+ const value2 = readInfuraProjectId(productionConfig.text);
29
+ if (value2 !== void 0) {
30
+ if (isConfiguredInfuraId(value2, placeholder)) return null;
31
+ return {
32
+ message: "Extension product configuration is incomplete: .metamaskprodrc shadows .metamaskrc but does not contain a usable INFURA_PROJECT_ID (the value is never displayed).",
33
+ userAction: `cd ${shellQuote(path.resolve(target))} && \${EDITOR:-vi} .metamaskprodrc`
34
+ };
35
+ }
36
+ }
37
+ const config = path.join(target, ".metamaskrc");
38
+ const configSnapshot = readConfigSnapshot(config);
39
+ if (configSnapshot.kind === "missing") {
40
+ return {
41
+ message: "Extension product configuration is required before launch: create .metamaskrc and set a non-placeholder INFURA_PROJECT_ID (the value is never displayed).",
42
+ userAction: `cd ${shellQuote(path.resolve(target))} && cp .metamaskrc.dist .metamaskrc && \${EDITOR:-vi} .metamaskrc`
43
+ };
44
+ }
45
+ if (configSnapshot.kind === "invalid") {
46
+ return invalidConfigBlock(target, ".metamaskrc", configSnapshot.reason);
47
+ }
48
+ const value = readInfuraProjectId(configSnapshot.text) ?? "";
49
+ if (isConfiguredInfuraId(value, placeholder)) return null;
50
+ return {
51
+ message: "Extension product configuration is incomplete: .metamaskrc must contain a non-placeholder INFURA_PROJECT_ID (the value is never displayed).",
52
+ userAction: `cd ${shellQuote(path.resolve(target))} && \${EDITOR:-vi} .metamaskrc`
53
+ };
54
+ }
55
+ function readConfigSnapshot(file) {
56
+ let descriptor;
57
+ try {
58
+ descriptor = fs.openSync(
59
+ file,
60
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK
61
+ );
62
+ } catch (error) {
63
+ const code = error.code;
64
+ if (code === "ENOENT") return { kind: "missing" };
65
+ if (code === "ELOOP") return { kind: "invalid", reason: "symlink" };
66
+ return { kind: "invalid", reason: "unreadable" };
67
+ }
68
+ try {
69
+ if (!fs.fstatSync(descriptor).isFile()) return { kind: "invalid", reason: "not-regular" };
70
+ return { kind: "file", text: fs.readFileSync(descriptor, "utf8") };
71
+ } finally {
72
+ fs.closeSync(descriptor);
73
+ }
74
+ }
75
+ function invalidConfigBlock(target, name, reason) {
76
+ const resolved = shellQuote(path.resolve(target));
77
+ if (name === ".metamaskrc.dist") {
78
+ return {
79
+ message: `Extension product configuration is invalid: ${name} is ${reason === "symlink" ? "a symbolic link" : reason === "not-regular" ? "not a regular file" : "unreadable"}.`,
80
+ userAction: `cd ${resolved} && ls -l ${name}`
81
+ };
82
+ }
83
+ if (reason === "unreadable") {
84
+ return {
85
+ message: `Extension product configuration could not be read from ${name}.`,
86
+ userAction: `cd ${resolved} && ls -l ${name} && \${EDITOR:-vi} ${name}`
87
+ };
88
+ }
89
+ return {
90
+ message: reason === "symlink" ? `Extension product configuration is invalid: ${name} must not be a symbolic link.` : `Extension product configuration is invalid: ${name} must be a regular file, not its current file type.`,
91
+ userAction: `cd ${resolved} && rm ${name} && ${name === ".metamaskrc" ? "cp .metamaskrc.dist .metamaskrc && " : ""}\${EDITOR:-vi} ${name}`
92
+ };
93
+ }
94
+ function readInfuraProjectId(text) {
95
+ const lines = text.replace(/\r\n?/gu, "\n");
96
+ const pattern = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gmu;
97
+ let result;
98
+ for (const match of lines.matchAll(pattern)) {
99
+ if (match[1] !== "INFURA_PROJECT_ID") continue;
100
+ let value = (match[2] ?? "").trim();
101
+ const quote = value[0];
102
+ value = value.replace(/^(['"`])([\s\S]*)\1$/u, "$2");
103
+ if (quote === '"') value = value.replace(/\\n/gu, "\n").replace(/\\r/gu, "\r");
104
+ result = value;
105
+ }
106
+ return result;
107
+ }
108
+ export {
109
+ extensionProductConfigBlock
110
+ };
@@ -166,6 +166,10 @@ function runtimeDistCheck(target) {
166
166
  "-rnic",
167
167
  "--exclude",
168
168
  "_metadata",
169
+ "--exclude",
170
+ "home.html",
171
+ "--exclude",
172
+ "sidepanel.html",
169
173
  "--out-format=%n",
170
174
  `${dist}${path.sep}`,
171
175
  `${runtimeDist}${path.sep}`
@@ -173,8 +177,22 @@ function runtimeDistCheck(target) {
173
177
  } catch {
174
178
  return { status: "stale", modified: ["<snapshot comparison failed>"] };
175
179
  }
176
- const modified = output.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 10);
177
- return modified.length > 0 ? { status: "stale", modified } : { status: "fresh" };
180
+ const modified = output.split("\n").map((line) => line.trim()).filter(Boolean);
181
+ for (const name of ["home.html", "sidepanel.html"]) {
182
+ const source = path.join(dist, name);
183
+ const loaded = path.join(runtimeDist, name);
184
+ if (normalizedRuntimeHtml(source) !== normalizedRuntimeHtml(loaded)) modified.push(name);
185
+ }
186
+ const boundedModified = [...new Set(modified)].slice(0, 10);
187
+ return boundedModified.length > 0 ? { status: "stale", modified: boundedModified } : { status: "fresh" };
188
+ }
189
+ function normalizedRuntimeHtml(file) {
190
+ if (!fs.existsSync(file)) return null;
191
+ const source = fs.readFileSync(file, "utf8");
192
+ return source.replace(/<title>([^<]*)<\/title>/iu, (_match, title) => {
193
+ const base = String(title || "MetaMask").replace(/^.+?\s+[—–-]\s+/u, "") || "MetaMask";
194
+ return `<title>${base}</title>`;
195
+ });
178
196
  }
179
197
  function distCheck(target) {
180
198
  const manifestPath = path.join(target, "dist/chrome/manifest.json");
@@ -1,12 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import path from "node:path";
3
3
  import { recipeRuntimePath, runnerDir } from "../../paths.js";
4
+ import { shellQuote } from "../../commands/parse-args.js";
4
5
  import {
5
6
  isExtensionWatcherLive,
6
7
  resolveExtensionSlotPorts,
7
8
  stopExtensionRuntime
8
9
  } from "../slot-ports.js";
9
10
  import { decideExtensionReadiness } from "./runtime-decision.js";
11
+ import { extensionProductConfigBlock } from "./product-config.js";
10
12
  function watcherStatus(buildLog) {
11
13
  if (buildLog === "ok") return "up";
12
14
  if (buildLog === "no-watch") return "down";
@@ -22,10 +24,25 @@ const extensionSurface = {
22
24
  const cdpPort = process.env.CDP_PORT ? parseInt(process.env.CDP_PORT, 10) : void 0;
23
25
  const report = await decideExtensionReadiness(target, { cdpPort, pageMode: "home-or-sidepanel" });
24
26
  const watcherLive = isExtensionWatcherLive(target);
27
+ const productConfigBlock = report.decision === "ready" ? null : extensionProductConfigBlock(target);
28
+ if (productConfigBlock) {
29
+ return {
30
+ decision: "blocked",
31
+ reasonCode: "product-config-required",
32
+ reasons: [productConfigBlock.message],
33
+ nextAction: productConfigBlock.userAction,
34
+ deps: report.checks.deps.status,
35
+ devServer: {
36
+ label: "webpack",
37
+ status: watcherLive ? watcherStatus(report.checks.buildLog.status) : "down"
38
+ }
39
+ };
40
+ }
25
41
  return {
26
42
  decision: report.decision,
27
43
  reasonCode: report.reasonCode,
28
44
  reasons: report.reasons,
45
+ nextAction: report.decision === "ready" ? void 0 : `mm-harness launch --adapter extension --target ${shellQuote(path.resolve(target))}`,
29
46
  deps: report.checks.deps.status,
30
47
  devServer: {
31
48
  label: "webpack",
@@ -65,15 +82,17 @@ const extensionSurface = {
65
82
  return [
66
83
  { label: "webpack", path: recipeRuntimePath(target, "webpack.log") },
67
84
  { label: "watcher", path: recipeRuntimePath(target, "recipe-harness-webpack.log") },
68
- { label: "rebuild", path: recipeRuntimePath(target, "rebuild.log") }
85
+ { label: "rebuild", path: recipeRuntimePath(target, "rebuild.log") },
86
+ { label: "dapp", path: recipeRuntimePath(target, "dapp-console.log") }
69
87
  ];
70
88
  },
71
89
  appLogSource(target) {
72
- return { label: "app", path: recipeRuntimePath(target, "extension-console.log") };
90
+ return { label: "extension", path: recipeRuntimePath(target, "extension-console.log") };
73
91
  },
74
92
  hints: {
75
93
  launch: "mm-harness launch",
76
- relaunch: "mm-harness launch --build"
94
+ relaunch: "mm-harness launch --build",
95
+ runtimeProbeRecovery: (target) => `mm-harness launch --adapter extension --target ${shellQuote(path.resolve(target))}`
77
96
  }
78
97
  };
79
98
  export {
@@ -0,0 +1,49 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { recipeHarnessPath, runnerDir } from "../paths.js";
6
+ async function ensureHarnessFresh(target, adapter) {
7
+ if (!fs.existsSync(path.join(target, "package.json")) || !hasCheckoutHead(target)) return;
8
+ let current;
9
+ try {
10
+ const mod = await import(pathToFileURL(path.join(runnerDir, "adapters/shared/harness-source-fingerprint.mjs")).href);
11
+ current = mod.harnessSourceFingerprint(runnerDir, adapter);
12
+ } catch (error) {
13
+ const detail = error instanceof Error ? `: ${error.message}` : "";
14
+ throw new Error(`could not fingerprint the ${adapter} harness source${detail}`);
15
+ }
16
+ const manifestPath = recipeHarnessPath(target, adapter, "manifest.json");
17
+ const installed = readInstalledFingerprint(manifestPath);
18
+ if (installed === current) return;
19
+ const reason = installed === null ? "harness not installed" : "runner changed";
20
+ const injector = path.join(runnerDir, `adapters/${adapter}/inject.${adapter === "extension" ? "mjs" : "sh"}`);
21
+ try {
22
+ execFileSync(adapter === "extension" ? process.execPath : "bash", [injector, "--target", target], {
23
+ stdio: ["ignore", "ignore", "inherit"]
24
+ });
25
+ console.error(`\u2192 refreshed ${adapter} harness overlay (${reason})`);
26
+ } catch (error) {
27
+ const detail = error instanceof Error ? `: ${error.message}` : "";
28
+ throw new Error(`could not refresh the ${adapter} harness overlay (${reason})${detail}`);
29
+ }
30
+ }
31
+ function hasCheckoutHead(target) {
32
+ try {
33
+ execFileSync("git", ["-C", target, "rev-parse", "--verify", "HEAD"], { stdio: "ignore" });
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+ function readInstalledFingerprint(manifestPath) {
40
+ try {
41
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
42
+ return typeof manifest.sourceFingerprint === "string" ? manifest.sourceFingerprint : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ export {
48
+ ensureHarnessFresh
49
+ };
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  const DEFAULTS = {
@@ -24,14 +25,53 @@ function ensureMobilePerpsEnvironment(target) {
24
25
  (key) => settingForKey(current, key).status === "missing"
25
26
  );
26
27
  if (missing.length === 0) return [];
27
- const file = path.join(target, current.file);
28
- const before = current.fileExists ? fs.readFileSync(file, "utf8") : "";
28
+ const file = path.join(fs.realpathSync(target), current.file);
29
+ const snapshot = readEnvironmentSnapshot(file);
30
+ const before = snapshot?.text ?? "";
29
31
  const separator = before.length > 0 && !before.endsWith("\n") ? "\n" : "";
30
32
  const additions = missing.map((key) => `export ${key}="${DEFAULTS[key]}"`).join("\n");
31
- fs.writeFileSync(file, `${before}${separator}${additions}
33
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
34
+ let descriptor = null;
35
+ try {
36
+ descriptor = fs.openSync(
37
+ temporary,
38
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
39
+ snapshot?.mode ?? 384
40
+ );
41
+ fs.writeFileSync(descriptor, `${before}${separator}${additions}
32
42
  `);
43
+ if (snapshot) fs.fchmodSync(descriptor, snapshot.mode);
44
+ fs.closeSync(descriptor);
45
+ descriptor = null;
46
+ fs.renameSync(temporary, file);
47
+ } finally {
48
+ if (descriptor !== null) fs.closeSync(descriptor);
49
+ fs.rmSync(temporary, { force: true });
50
+ }
33
51
  return missing;
34
52
  }
53
+ function readEnvironmentSnapshot(file) {
54
+ let descriptor;
55
+ try {
56
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
57
+ } catch (error) {
58
+ const code = error.code;
59
+ if (code === "ENOENT") return null;
60
+ if (code === "ELOOP") {
61
+ throw new Error(`.js.env must not be a symbolic link. Next: replace ${file} with a regular file.`);
62
+ }
63
+ throw error;
64
+ }
65
+ try {
66
+ const stat = fs.fstatSync(descriptor);
67
+ if (!stat.isFile()) {
68
+ throw new Error(`.js.env must be a regular file. Next: replace ${file} with a regular file.`);
69
+ }
70
+ return { text: fs.readFileSync(descriptor, "utf8"), mode: stat.mode & 511 };
71
+ } finally {
72
+ fs.closeSync(descriptor);
73
+ }
74
+ }
35
75
  function readSetting(source, key) {
36
76
  const assignment = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=(.*)$`, "u");
37
77
  let value = null;
@@ -49,6 +49,8 @@ async function prepareMobile(target, opts = {}) {
49
49
  return { status: EXIT.runtime, output: msg };
50
50
  }
51
51
  if (report.decision === "ready") {
52
+ const surface = await ensureMobileHumanSurface(target, platform, json);
53
+ if (surface.status !== 0) return surface;
52
54
  const confirm = await dispatchAction(
53
55
  { id: "wait-for-bridge", cwd: target, argv: ["--max-polls", String(READY_BRIDGE_CONFIRM_POLLS)] },
54
56
  target,
@@ -56,14 +58,21 @@ async function prepareMobile(target, opts = {}) {
56
58
  json,
57
59
  preflightMode
58
60
  );
59
- if (confirm.status === 0) return { status: 0, output: "" };
61
+ if (confirm.status === 0) return startMobileConsoleForwarder(target, platform, json);
60
62
  if (!json) {
61
63
  process.stderr.write(
62
64
  `launch: runtime looked ready but no ${platform} bridge target answered; launching the app.
63
65
  `
64
66
  );
65
67
  }
66
- return dispatchActionSequence(launchActions(path.resolve(target)), target, platform, json, preflightMode);
68
+ const launch = await dispatchActionSequence(
69
+ launchActions(path.resolve(target)),
70
+ target,
71
+ platform,
72
+ json,
73
+ preflightMode
74
+ );
75
+ return launch.status === 0 ? startMobileConsoleForwarder(target, platform, json) : launch;
67
76
  }
68
77
  if (report.decision === "unknown") {
69
78
  const msg = "mobile prepare: runtime state unknown\n Next: run mm-harness verify --adapter mobile --target <checkout>";
@@ -93,6 +102,8 @@ async function prepareMobile(target, opts = {}) {
93
102
  return { status: EXIT.runtime, output: msg };
94
103
  }
95
104
  case "ready": {
105
+ const surface = await ensureMobileHumanSurface(target, platform, json);
106
+ if (surface.status !== 0) return surface;
96
107
  const bridge = await dispatchAction({ id: "wait-for-bridge", cwd: target }, target, platform, json, preflightMode);
97
108
  if (bridge.status !== 0) return bridge;
98
109
  break;
@@ -111,7 +122,19 @@ async function prepareMobile(target, opts = {}) {
111
122
  }
112
123
  }
113
124
  }
114
- return { status: 0, output: "" };
125
+ return startMobileConsoleForwarder(target, platform, json);
126
+ }
127
+ function startMobileConsoleForwarder(target, platform, json) {
128
+ return dispatchAction(
129
+ { id: "start-console-forwarder", cwd: target, argv: json ? ["--quiet"] : [] },
130
+ target,
131
+ platform,
132
+ json
133
+ );
134
+ }
135
+ function ensureMobileHumanSurface(target, platform, json) {
136
+ if (platform !== "ios") return Promise.resolve({ status: 0, output: "" });
137
+ return dispatchAction({ id: "ensure-device-ui", cwd: target }, target, platform, json);
115
138
  }
116
139
  async function dispatchActionSequence(requestedActions, target, platform, json, preflightMode) {
117
140
  const resolved = path.resolve(target);
@@ -147,6 +170,10 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
147
170
  const extra = action.argv ?? [];
148
171
  return spawnScriptStreaming(leaf, ["--target", cwd, ...extra], target);
149
172
  }
173
+ case "start-console-forwarder": {
174
+ const leaf = path.join(runnerDir, "adapters/mobile/start-console-forwarder.sh");
175
+ return spawnScriptStreaming(leaf, ["--target", cwd, ...action.argv ?? []], target);
176
+ }
150
177
  case "prewarm-bundle": {
151
178
  const leaf = path.join(runnerDir, "adapters/mobile/prewarm-bundle.sh");
152
179
  return spawnScriptStreaming(leaf, ["--platform", platform, "--target", cwd], target);
@@ -156,10 +183,6 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
156
183
  const extra = action.argv ?? [];
157
184
  return spawnScriptStreaming(leaf, ["--target", cwd, "--platform", platform, ...extra], target);
158
185
  }
159
- case "clear-metro-cache": {
160
- const leaf = path.join(runnerDir, "adapters/mobile/start-metro.sh");
161
- return spawnScriptStreaming(leaf, ["--target", cwd, "--clear"], target);
162
- }
163
186
  case "launch-mobile-runtime": {
164
187
  const leaf = path.join(runnerDir, "adapters/mobile/open-device.sh");
165
188
  return spawnScriptStreaming(
@@ -169,6 +192,15 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
169
192
  POD_PROBE_ENV
170
193
  );
171
194
  }
195
+ case "ensure-device-ui": {
196
+ const leaf = path.join(runnerDir, "adapters/mobile/open-device.sh");
197
+ return spawnScriptStreaming(
198
+ leaf,
199
+ ["--platform", platform, "--target", cwd, "--ui-only"],
200
+ target,
201
+ POD_PROBE_ENV
202
+ );
203
+ }
172
204
  case "rebuild-native-dev-client": {
173
205
  const msg = "rebuild-native-dev-client required: native module mismatch detected.\n Next: yarn start:ios or yarn start:android to rebuild the native dev client, then re-run mm-harness launch.";
174
206
  if (!json) process.stderr.write(`${msg}
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import path from "node:path";
3
3
  import { recipeRuntimePath, runnerDir } from "../../paths.js";
4
+ import { shellQuote } from "../../commands/parse-args.js";
4
5
  import { resolveMobileSlotPorts } from "../slot-ports.js";
5
6
  import { mobileRuntimeStatus } from "./prepare.js";
6
7
  import { hasRunwayProvisionBaseline, provisionRunwayMobile } from "./provision.js";
@@ -20,6 +21,7 @@ const mobileSurface = {
20
21
  decision: depsPending ? "launch" : report.decision,
21
22
  reasonCode: depsPending ? "app-installed-deps-pending" : report.reasonCode,
22
23
  reasons: depsPending ? ["Runway app is installed; JavaScript dependencies are pending until dispatch-time launch."] : report.reasons,
24
+ nextAction: report.decision === "ready" && !depsPending ? void 0 : `mm-harness launch ${platform} --adapter mobile --target ${shellQuote(path.resolve(target))}`,
23
25
  deps: runwayProvisioned && report.checks?.deps?.status !== "current" ? "pending" : report.checks?.deps?.status,
24
26
  devServer: { label: "metro", status: report.checks?.metro?.status ?? "unprobed" }
25
27
  };
@@ -47,11 +49,12 @@ const mobileSurface = {
47
49
  return [{ label: "metro", path: recipeRuntimePath(target, "metro.log") }];
48
50
  },
49
51
  appLogSource(target) {
50
- return { label: "app", path: recipeRuntimePath(target, "metro.log") };
52
+ return { label: "app", path: recipeRuntimePath(target, "app-console.log") };
51
53
  },
52
54
  hints: {
53
55
  launch: "mm-harness launch ios",
54
- relaunch: "mm-harness launch ios"
56
+ relaunch: "mm-harness launch ios",
57
+ runtimeProbeRecovery: (target) => `mm-harness status --target ${shellQuote(path.resolve(target))} --json`
55
58
  }
56
59
  };
57
60
  export {