@deeeed/metamask-harness 0.17.4 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +775 -0
  2. package/README.md +65 -71
  3. package/adapters/extension/ensure-browser.sh +12 -1
  4. package/adapters/extension/inject.mjs +7 -0
  5. package/adapters/extension/launch-browser.cjs +10 -3
  6. package/adapters/extension/lib/chrome-args.cjs +31 -0
  7. package/adapters/extension/lib/macos-focus.cjs +32 -0
  8. package/adapters/extension/live.sh +10 -20
  9. package/adapters/manifest.json +8 -0
  10. package/adapters/mobile/open-device.sh +45 -7
  11. package/adapters/mobile/verify.sh +15 -3
  12. package/adapters/shared/harness-source-fingerprint.mjs +49 -0
  13. package/adapters/shared/install-repo-deps.sh +1 -5
  14. package/adapters/shared/open-debug.mjs +184 -103
  15. package/adapters/shared/resolve-slot-ports-core.mjs +23 -6
  16. package/adapters/shared/resolve-slot-ports.sh +22 -5
  17. package/bin/mm-harness +15 -3
  18. package/dist/adapters/core/surface.js +4 -1
  19. package/dist/adapters/extension/console-capture.js +3 -1
  20. package/dist/adapters/extension/harness-freshness.js +39 -0
  21. package/dist/adapters/extension/product-config.js +110 -0
  22. package/dist/adapters/extension/runtime-decision.js +20 -71
  23. package/dist/adapters/extension/surface.js +19 -1
  24. package/dist/adapters/mobile/prepare.js +17 -0
  25. package/dist/adapters/mobile/source-freshness.js +26 -41
  26. package/dist/adapters/mobile/surface.js +4 -1
  27. package/dist/adapters/resolve-slot-ports.js +2 -0
  28. package/dist/adapters/slot-ports.js +13 -32
  29. package/dist/adapters.js +50 -17
  30. package/dist/checkout-lock.js +27 -2
  31. package/dist/cli-color.js +19 -0
  32. package/dist/cli-commands.js +1 -1
  33. package/dist/cli.js +2 -3
  34. package/dist/command-contract.js +13 -3
  35. package/dist/commands/call.js +115 -29
  36. package/dist/commands/checklist.js +4 -1
  37. package/dist/commands/completion-candidates.js +20 -13
  38. package/dist/commands/debug.js +31 -38
  39. package/dist/commands/doctor.js +33 -6
  40. package/dist/commands/fixtures.js +65 -17
  41. package/dist/commands/flows.js +39 -10
  42. package/dist/commands/launch/extension.js +40 -15
  43. package/dist/commands/launch/index.js +41 -5
  44. package/dist/commands/list-executables.js +151 -29
  45. package/dist/commands/manifest.js +127 -18
  46. package/dist/commands/parse-args.js +11 -1
  47. package/dist/commands/run-engine.js +384 -56
  48. package/dist/commands/run.js +112 -17
  49. package/dist/commands/shared.js +22 -2
  50. package/dist/commands/status-probe.js +3 -0
  51. package/dist/commands/status.js +1 -0
  52. package/dist/completions-cache.js +1 -1
  53. package/dist/doctor.js +56 -6
  54. package/dist/harness.js +6 -5
  55. package/dist/heal-bounds.js +1 -1
  56. package/dist/live-adapter-contract.js +132 -12
  57. package/dist/manifest.js +161 -1
  58. package/dist/mm-harness-cli.js +13 -7
  59. package/dist/recipe-security.js +178 -0
  60. package/dist/runner.js +87 -13
  61. package/dist/runtime-context.js +8 -26
  62. package/docs/CONTRIBUTING.md +137 -0
  63. package/docs/QA.md +185 -0
  64. package/docs/RECIPES.md +161 -0
  65. package/docs/SECURITY.md +88 -0
  66. package/library/actions/core/perps/read_account.mjs +2 -2
  67. package/library/actions/core/perps/read_orders.mjs +2 -1
  68. package/library/actions/core/perps/read_positions.mjs +2 -1
  69. package/library/actions/core/wallet/list_accounts.mjs +95 -0
  70. package/library/actions/extension/platform/cdp.mjs +1 -0
  71. package/library/actions/extension/wallet/list_accounts.mjs +41 -0
  72. package/library/actions/mobile/platform/bridge.mjs +1 -5
  73. package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
  74. package/library/manifests/core.action-manifest.json +61 -0
  75. package/library/manifests/extension.action-manifest.json +53 -0
  76. package/library/manifests/mobile.action-manifest.json +53 -0
  77. package/library/recipes/runner/action-validation.extension.recipe.json +8 -1
  78. package/library/recipes/runner/action-validation.mobile.recipe.json +8 -1
  79. package/package.json +10 -6
  80. package/scripts/completions.sh +7 -7
  81. package/docs/ADAPTER-SURFACE.md +0 -26
  82. package/docs/CHEATSHEET.md +0 -48
  83. package/docs/CLI-ERGONOMICS-AUDIT.md +0 -32
  84. package/docs/CLI-ERGONOMICS-HUMAN-QA.md +0 -64
  85. package/docs/CODE-MAP.md +0 -62
  86. package/docs/UX-PRINCIPLES.md +0 -66
  87. package/docs/VIDEO-DEMO-VALIDATION.md +0 -74
  88. package/docs/architecture.md +0 -88
  89. package/docs/live-adapter-contract.md +0 -190
  90. package/docs/package-boundaries.md +0 -47
  91. package/docs/perps-flow-catalog.md +0 -235
  92. package/docs/recipe-libraries.md +0 -71
  93. package/docs/runtime-file-conventions.md +0 -36
@@ -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
+ };
@@ -163,81 +163,37 @@ function runtimeDistCheck(target) {
163
163
  let output;
164
164
  try {
165
165
  output = execFileSync("rsync", [
166
- "-rni",
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}`
172
176
  ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
173
- } catch (error) {
174
- if (error.code === "ENOENT") {
175
- try {
176
- const modified2 = filesystemSnapshotDiff(dist, runtimeDist).filter((relative) => !slotTitleOnlyDifference(dist, runtimeDist, relative)).slice(0, 10);
177
- return modified2.length > 0 ? { status: "stale", comparison: "filesystem-quick", modified: modified2 } : { status: "fresh", comparison: "filesystem-quick" };
178
- } catch (fallbackError) {
179
- return {
180
- status: "unknown",
181
- error: `snapshot comparison failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`
182
- };
183
- }
184
- }
185
- const status = error.status;
186
- return {
187
- status: "unknown",
188
- error: `rsync snapshot comparison failed${typeof status === "number" ? ` with exit ${status}` : ""}`
189
- };
190
- }
191
- const modified = output.split("\n").map((line) => line.trim()).filter(Boolean).filter((relative) => !slotTitleOnlyDifference(dist, runtimeDist, relative)).slice(0, 10);
192
- return modified.length > 0 ? { status: "stale", comparison: "rsync-quick", modified } : { status: "fresh", comparison: "rsync-quick" };
193
- }
194
- function slotTitleOnlyDifference(dist, runtimeDist, relative) {
195
- if (relative !== "home.html" && relative !== "sidepanel.html") return false;
196
- try {
197
- const normalize = (source) => source.replace(
198
- /<title>([^<]*)<\/title>/iu,
199
- (_match, title) => `<title>${String(title || "MetaMask").replace(/^.+?\s+[—–-]\s+/u, "") || "MetaMask"}</title>`
200
- );
201
- return normalize(fs.readFileSync(path.join(dist, relative), "utf8")) === normalize(fs.readFileSync(path.join(runtimeDist, relative), "utf8"));
202
177
  } catch {
203
- return false;
178
+ return { status: "stale", modified: ["<snapshot comparison failed>"] };
204
179
  }
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" };
205
188
  }
206
- function filesystemSnapshotDiff(source, snapshot) {
207
- const sourceEntries = snapshotEntries(source);
208
- const snapshotEntriesByPath = snapshotEntries(snapshot);
209
- const paths = [.../* @__PURE__ */ new Set([...sourceEntries.keys(), ...snapshotEntriesByPath.keys()])].sort();
210
- return paths.filter((relative) => {
211
- const left = sourceEntries.get(relative);
212
- const right = snapshotEntriesByPath.get(relative);
213
- return left === void 0 || right === void 0 || JSON.stringify(left) !== JSON.stringify(right);
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>`;
214
195
  });
215
196
  }
216
- function snapshotEntries(root) {
217
- const entries = /* @__PURE__ */ new Map();
218
- const walk2 = (directory) => {
219
- for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
220
- if (entry.name === "_metadata") continue;
221
- const absolute = path.join(directory, entry.name);
222
- const relative = path.relative(root, absolute);
223
- const stat = fs.lstatSync(absolute);
224
- if (entry.isDirectory()) {
225
- entries.set(relative, { type: "directory" });
226
- walk2(absolute);
227
- } else if (entry.isSymbolicLink()) {
228
- entries.set(relative, { type: "symlink", link: fs.readlinkSync(absolute) });
229
- } else if (entry.isFile()) {
230
- entries.set(relative, {
231
- type: "file",
232
- size: stat.size,
233
- mtimeSeconds: Math.floor(stat.mtimeMs / 1e3)
234
- });
235
- }
236
- }
237
- };
238
- walk2(root);
239
- return entries;
240
- }
241
197
  function distCheck(target) {
242
198
  const manifestPath = path.join(target, "dist/chrome/manifest.json");
243
199
  if (!fs.existsSync(manifestPath)) return { status: "no-build" };
@@ -346,13 +302,6 @@ async function decideExtensionReadiness(target, options = {}) {
346
302
  reasons: [dist.reason === "uncommitted-source" ? `Uncommitted source since the build (${dist.modified?.length ?? 0} file(s)); rebuild.` : `dist git id ${dist.distGitId} != HEAD ${dist.head}; rebuild.`],
347
303
  actions: buildAction(resolved, false)
348
304
  },
349
- {
350
- when: runtimeDist.status === "unknown",
351
- decision: "blocked",
352
- reasonCode: "runtime-dist-check-failed",
353
- reasons: [runtimeDist.error ?? "The loaded runtime snapshot could not be compared with dist/chrome."],
354
- actions: []
355
- },
356
305
  {
357
306
  when: runtimeDist.status !== "fresh",
358
307
  decision: "relaunch",
@@ -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",
@@ -74,7 +91,8 @@ const extensionSurface = {
74
91
  },
75
92
  hints: {
76
93
  launch: "mm-harness launch",
77
- relaunch: "mm-harness launch --build"
94
+ relaunch: "mm-harness launch --build",
95
+ runtimeProbeRecovery: (target) => `mm-harness launch --adapter extension --target ${shellQuote(path.resolve(target))}`
78
96
  }
79
97
  };
80
98
  export {
@@ -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,
@@ -100,6 +102,8 @@ async function prepareMobile(target, opts = {}) {
100
102
  return { status: EXIT.runtime, output: msg };
101
103
  }
102
104
  case "ready": {
105
+ const surface = await ensureMobileHumanSurface(target, platform, json);
106
+ if (surface.status !== 0) return surface;
103
107
  const bridge = await dispatchAction({ id: "wait-for-bridge", cwd: target }, target, platform, json, preflightMode);
104
108
  if (bridge.status !== 0) return bridge;
105
109
  break;
@@ -128,6 +132,10 @@ function startMobileConsoleForwarder(target, platform, json) {
128
132
  json
129
133
  );
130
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);
138
+ }
131
139
  async function dispatchActionSequence(requestedActions, target, platform, json, preflightMode) {
132
140
  const resolved = path.resolve(target);
133
141
  const actions = applyMobileMetroEnvPolicy(resolved, requestedActions);
@@ -184,6 +192,15 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
184
192
  POD_PROBE_ENV
185
193
  );
186
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
+ }
187
204
  case "rebuild-native-dev-client": {
188
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.";
189
206
  if (!json) process.stderr.write(`${msg}
@@ -20,47 +20,32 @@ const SOURCE_PATHS = [
20
20
  const ENV_INPUTS = [".js.env", ".env", ".env.local"];
21
21
  const BASELINE_FILE = "mobile-source-baseline.json";
22
22
  function mobileSourceFingerprint(target) {
23
- try {
24
- const hash = createHash("sha256");
25
- hash.update(git(target, ["rev-parse", "HEAD"]));
26
- hash.update(git(target, ["diff", "--no-ext-diff", "--binary", "HEAD", "--", ...SOURCE_PATHS]));
27
- const untracked = git(target, [
28
- "ls-files",
29
- "--others",
30
- "--exclude-standard",
31
- "-z",
32
- "--",
33
- ...SOURCE_PATHS
34
- ]).toString("utf8").split("\0").filter(Boolean).sort();
35
- for (const relative of untracked) {
36
- hash.update(`untracked\0${relative}\0`);
37
- hash.update(fs.readFileSync(path.join(target, relative)));
38
- hash.update("\0");
39
- }
40
- for (const relative of ENV_INPUTS) {
41
- const absolute = path.join(target, relative);
42
- hash.update(`env\0${relative}\0`);
43
- hash.update(fs.existsSync(absolute) ? fs.readFileSync(absolute) : "<absent>");
44
- hash.update("\0");
45
- }
46
- return { status: "ok", fingerprint: hash.digest("hex") };
47
- } catch {
48
- const resolved = path.resolve(target);
49
- return {
50
- fingerprint: null,
51
- status: "unavailable",
52
- error: {
53
- code: "MOBILE_SOURCE_UNAVAILABLE",
54
- message: `Mobile source fingerprint requires a readable Git checkout and source inputs: ${resolved}`,
55
- userAction: "mm-harness doctor --adapter mobile --target <metamask-mobile-checkout>"
56
- }
57
- };
23
+ const hash = createHash("sha256");
24
+ hash.update(git(target, ["rev-parse", "HEAD"]));
25
+ hash.update(git(target, ["diff", "--no-ext-diff", "--binary", "HEAD", "--", ...SOURCE_PATHS]));
26
+ const untracked = git(target, [
27
+ "ls-files",
28
+ "--others",
29
+ "--exclude-standard",
30
+ "-z",
31
+ "--",
32
+ ...SOURCE_PATHS
33
+ ]).toString("utf8").split("\0").filter(Boolean).sort();
34
+ for (const relative of untracked) {
35
+ hash.update(`untracked\0${relative}\0`);
36
+ hash.update(fs.readFileSync(path.join(target, relative)));
37
+ hash.update("\0");
38
+ }
39
+ for (const relative of ENV_INPUTS) {
40
+ const absolute = path.join(target, relative);
41
+ hash.update(`env\0${relative}\0`);
42
+ hash.update(fs.existsSync(absolute) ? fs.readFileSync(absolute) : "<absent>");
43
+ hash.update("\0");
58
44
  }
45
+ return hash.digest("hex");
59
46
  }
60
47
  function mobileSourceCheck(target) {
61
- const result = mobileSourceFingerprint(target);
62
- if (result.status === "unavailable") return result;
63
- const { fingerprint } = result;
48
+ const fingerprint = mobileSourceFingerprint(target);
64
49
  const baseline = readBaseline(target);
65
50
  return {
66
51
  fingerprint,
@@ -68,7 +53,7 @@ function mobileSourceCheck(target) {
68
53
  };
69
54
  }
70
55
  function recordMobileSourceBaseline(target, expectedFingerprint) {
71
- if (mobileSourceFingerprint(target).fingerprint !== expectedFingerprint) return false;
56
+ if (mobileSourceFingerprint(target) !== expectedFingerprint) return false;
72
57
  const marker = recipeRuntimePath(target, BASELINE_FILE);
73
58
  const temporary = `${marker}.${process.pid}.${Date.now()}.tmp`;
74
59
  const baseline = {
@@ -80,9 +65,9 @@ function recordMobileSourceBaseline(target, expectedFingerprint) {
80
65
  try {
81
66
  fs.writeFileSync(temporary, `${JSON.stringify(baseline, null, 2)}
82
67
  `, { mode: 384 });
83
- if (mobileSourceFingerprint(target).fingerprint !== expectedFingerprint) return false;
68
+ if (mobileSourceFingerprint(target) !== expectedFingerprint) return false;
84
69
  fs.renameSync(temporary, marker);
85
- if (mobileSourceFingerprint(target).fingerprint !== expectedFingerprint) {
70
+ if (mobileSourceFingerprint(target) !== expectedFingerprint) {
86
71
  if (readBaseline(target)?.fingerprint === expectedFingerprint) fs.rmSync(marker, { force: true });
87
72
  return false;
88
73
  }
@@ -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
  };
@@ -51,7 +53,8 @@ const mobileSurface = {
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 {
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  formatKvLines,
3
+ formatExtensionIdentityPorts,
3
4
  formatExtensionSlotPorts,
4
5
  inferSlotSuffix,
5
6
  realRepoPath,
@@ -12,6 +13,7 @@ import {
12
13
  resolveMobileSlotDefaults
13
14
  } from "../../adapters/shared/resolve-slot-ports-core.mjs";
14
15
  export {
16
+ formatExtensionIdentityPorts,
15
17
  formatExtensionSlotPorts,
16
18
  formatKvLines,
17
19
  inferSlotSuffix,
@@ -3,8 +3,8 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { recipeRuntimeDir } from "../paths.js";
5
5
  import {
6
+ formatExtensionIdentityPorts,
6
7
  resolveDefaultExtensionPorts,
7
- resolveExtensionRuntimeContext,
8
8
  resolveMobileRuntimeContext,
9
9
  resolveSlotPortsByRepo,
10
10
  resolveMobileSlotDefaults
@@ -55,12 +55,10 @@ function resolveMobileSlotPorts(target) {
55
55
  }
56
56
  function resolveExtensionSlotPorts(target) {
57
57
  const resolved = path.resolve(target);
58
- const ctxOut = resolveExtensionRuntimeContext(resolved);
59
- if (ctxOut?.trim()) applyKVLines(ctxOut, true);
60
- const poolOut = resolveSlotPortsByRepo(resolved);
61
- if (poolOut?.trim()) applyKVLines(poolOut, false);
62
- const defOut = resolveDefaultExtensionPorts(resolved);
63
- if (defOut?.trim()) applyKVLines(defOut, false);
58
+ const identity = formatExtensionIdentityPorts(resolved);
59
+ if (identity.trim()) applyKVLines(identity, true);
60
+ const fallback = resolveDefaultExtensionPorts(resolved);
61
+ if (fallback?.trim()) applyKVLines(fallback, false);
64
62
  }
65
63
  function extensionWatcherPids(target) {
66
64
  const resolved = path.resolve(target);
@@ -78,7 +76,9 @@ function extensionWatcherPids(target) {
78
76
  const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
79
77
  if (!match) continue;
80
78
  const [, pidStr, cmd] = match;
81
- const isWatcher = cmd.includes("yarn start") || cmd.includes("webpack --watch") || cmd.includes("development/webpack/launch.ts --watch");
79
+ const executable = path.basename(cmd.trim().split(/\s+/u)[0] ?? "");
80
+ const isNodeProcess = executable === "node" || executable === "nodejs" || executable === "yarn";
81
+ const isWatcher = isNodeProcess && (/(?:^|\/)yarn start(?:\s|$)/u.test(cmd) || /(?:^|\/)yarn\.js webpack --watch(?:\s|$)/u.test(cmd) || /development\/webpack\/launch\.ts --watch(?:\s|$)/u.test(cmd));
82
82
  if (!isWatcher) continue;
83
83
  if (cmd.includes(resolved)) {
84
84
  ownedPids.add(Number(pidStr));
@@ -105,32 +105,17 @@ function isExtensionWatcherLive(target) {
105
105
  function stopExtensionWatcher(target) {
106
106
  const runtimeAbs = path.join(path.resolve(target), recipeRuntimeDir());
107
107
  const webpackPidFile = path.join(runtimeAbs, "recipe-harness-webpack.pid");
108
- const syncPidFile = path.join(runtimeAbs, "recipe-harness-webpack-sync.pid");
109
108
  const ownedPids = extensionWatcherPids(target);
110
- try {
111
- const syncPid = fs.readFileSync(syncPidFile, "utf8").trim();
112
- if (/^\d+$/u.test(syncPid) && processAlive(Number(syncPid))) ownedPids.push(Number(syncPid));
113
- } catch {
114
- }
115
109
  for (const pid of ownedPids) {
116
- try {
117
- process.kill(-pid, "SIGTERM");
118
- } catch {
119
- }
120
110
  try {
121
111
  process.kill(pid, "SIGTERM");
122
112
  } catch {
123
113
  }
124
114
  }
125
115
  fs.rmSync(webpackPidFile, { force: true });
126
- fs.rmSync(syncPidFile, { force: true });
127
116
  if (ownedPids.length > 0) {
128
117
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2e3);
129
118
  for (const pid of ownedPids) {
130
- try {
131
- process.kill(-pid, "SIGKILL");
132
- } catch {
133
- }
134
119
  try {
135
120
  process.kill(pid, "SIGKILL");
136
121
  } catch {
@@ -142,9 +127,8 @@ function stopExtensionWatcher(target) {
142
127
  function stopExtensionRuntime(target) {
143
128
  const resolved = path.resolve(target);
144
129
  const runtimeAbs = path.join(resolved, recipeRuntimeDir());
145
- const profile = path.resolve(
146
- process.env.CHROME_USER_DATA_DIR || path.join(runtimeAbs, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile")
147
- );
130
+ const configuredProfile = process.env.CHROME_USER_DATA_DIR || path.join(runtimeAbs, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile");
131
+ const profiles = process.env.CHROME_USER_DATA_DIR ? [path.resolve(configuredProfile)] : [configuredProfile, path.join(runtimeAbs, "chrome-profile-recipe"), path.join(runtimeAbs, "chrome-profile-pw")].map((value) => path.resolve(value));
148
132
  let signalled = stopExtensionWatcher(resolved);
149
133
  const ownedBrowserPids = /* @__PURE__ */ new Set();
150
134
  try {
@@ -153,9 +137,7 @@ function stopExtensionRuntime(target) {
153
137
  const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
154
138
  if (!match) continue;
155
139
  const pid = Number(match[1]);
156
- if (pid !== process.pid && commandHasExactArg(match[2], "--user-data-dir", profile)) {
157
- ownedBrowserPids.add(pid);
158
- }
140
+ if (pid !== process.pid && profiles.some((profile) => commandHasExactArg(match[2], "--user-data-dir", profile))) ownedBrowserPids.add(pid);
159
141
  }
160
142
  } catch {
161
143
  }
@@ -179,7 +161,6 @@ function stopExtensionRuntime(target) {
179
161
  let removedMarkers = 0;
180
162
  for (const name of [
181
163
  "recipe-harness-webpack.pid",
182
- "recipe-harness-webpack-sync.pid",
183
164
  "webpack.pid",
184
165
  "browser.pid",
185
166
  "chromium.pid",
@@ -211,8 +192,8 @@ function processAlive(pid) {
211
192
  try {
212
193
  process.kill(pid, 0);
213
194
  return true;
214
- } catch {
215
- return false;
195
+ } catch (error) {
196
+ return error.code === "EPERM";
216
197
  }
217
198
  }
218
199
  function commandHasExactArg(command, flag, value) {