@deeeed/metamask-harness 0.14.5 → 0.15.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 (73) hide show
  1. package/CHANGELOG.md +96 -6
  2. package/adapters/extension/ensure-browser.sh +4 -2
  3. package/adapters/extension/launch-browser.cjs +73 -34
  4. package/adapters/extension/live.sh +21 -10
  5. package/adapters/extension/reattach.sh +3 -3
  6. package/adapters/extension/sidepanel-toggle.sh +10 -4
  7. package/adapters/extension/start-watch.sh +2 -2
  8. package/adapters/extension/verify.sh +19 -2
  9. package/adapters/extension/wallet-fixture-state.cjs +14 -1
  10. package/adapters/manifest.json +19 -3
  11. package/adapters/mobile/bridge-runtime/setup-wallet.sh +6 -4
  12. package/adapters/mobile/launch-metro.cjs +74 -0
  13. package/adapters/mobile/lib/tmux-viewer.sh +4 -4
  14. package/adapters/mobile/open-device.sh +3 -1
  15. package/adapters/mobile/start-metro.sh +12 -18
  16. package/adapters/mobile/stop-metro.sh +1 -0
  17. package/adapters/mobile/verify.sh +5 -5
  18. package/adapters/shared/install-repo-deps.sh +29 -0
  19. package/adapters/shared/open-debug.mjs +35 -4
  20. package/adapters/shared/open-log-window.sh +1 -1
  21. package/adapters/shared/resolve-slot-ports-core.mjs +72 -1
  22. package/adapters/shared/resolve-slot-ports.sh +21 -17
  23. package/adapters/shared/tmux-session.sh +0 -5
  24. package/adapters/shared/tmux-viewer.sh +7 -2
  25. package/dist/adapters/core/surface.js +3 -0
  26. package/dist/adapters/extension/ensure-ready.js +0 -7
  27. package/dist/adapters/extension/runtime-decision.js +93 -3
  28. package/dist/adapters/extension/surface.js +17 -6
  29. package/dist/adapters/mobile/metro-env.js +67 -0
  30. package/dist/adapters/mobile/perps-env.js +101 -0
  31. package/dist/adapters/mobile/prepare.js +32 -14
  32. package/dist/adapters/mobile/runtime-decision.js +21 -2
  33. package/dist/adapters/mobile/source-freshness.js +116 -0
  34. package/dist/adapters/mobile/surface.js +5 -1
  35. package/dist/adapters/resolve-slot-ports.js +4 -0
  36. package/dist/adapters/slot-ports.js +131 -49
  37. package/dist/adapters.js +7 -2
  38. package/dist/checkout-lock.js +72 -0
  39. package/dist/cli-commands.js +1 -1
  40. package/dist/cli.js +2 -0
  41. package/dist/commands/call.js +91 -54
  42. package/dist/commands/check.js +266 -46
  43. package/dist/commands/checklist.js +136 -0
  44. package/dist/commands/debug.js +21 -2
  45. package/dist/commands/doctor.js +233 -18
  46. package/dist/commands/farmslot-ready.js +25 -0
  47. package/dist/commands/fixtures.js +177 -42
  48. package/dist/commands/launch/extension.js +6 -1
  49. package/dist/commands/launch/index.js +54 -22
  50. package/dist/commands/logs.js +24 -4
  51. package/dist/commands/parse-args.js +1 -0
  52. package/dist/commands/run-engine.js +223 -7
  53. package/dist/commands/run.js +67 -50
  54. package/dist/commands/shared.js +86 -5
  55. package/dist/commands/stop.js +1 -2
  56. package/dist/doctor.js +39 -17
  57. package/dist/harness.js +5 -2
  58. package/dist/heal-bounds.js +1 -1
  59. package/dist/mm-harness-cli.js +41 -12
  60. package/dist/runner.js +35 -5
  61. package/dist/runtime-context.js +228 -0
  62. package/docs/CLI-SPEC.md +15 -11
  63. package/docs/MENTAL-MODEL.md +6 -6
  64. package/library/actions/extension/perps/perps.mjs +29 -8
  65. package/library/actions/extension/platform/cdp.mjs +128 -28
  66. package/library/actions/extension/ui/navigate.mjs +1 -1
  67. package/library/actions/mobile/perps/perps.mjs +13 -1
  68. package/library/actions/mobile/platform/bridge.mjs +302 -29
  69. package/library/actions/mobile/wallet/ensure_unlocked.mjs +7 -2
  70. package/library/manifests/extension.action-manifest.json +32 -12
  71. package/library/manifests/mobile.action-manifest.json +25 -3
  72. package/package.json +4 -4
  73. package/scripts/completions.sh +7 -4
@@ -1,7 +1,11 @@
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 { resolveExtensionSlotPorts, stopExtensionWatcher } from "../slot-ports.js";
4
+ import {
5
+ isExtensionWatcherLive,
6
+ resolveExtensionSlotPorts,
7
+ stopExtensionRuntime
8
+ } from "../slot-ports.js";
5
9
  import { decideExtensionReadiness } from "./runtime-decision.js";
6
10
  function watcherStatus(buildLog) {
7
11
  if (buildLog === "ok") return "up";
@@ -17,12 +21,16 @@ const extensionSurface = {
17
21
  async runtimeStatus(target) {
18
22
  const cdpPort = process.env.CDP_PORT ? parseInt(process.env.CDP_PORT, 10) : void 0;
19
23
  const report = await decideExtensionReadiness(target, { cdpPort, pageMode: "home-or-sidepanel" });
24
+ const watcherLive = isExtensionWatcherLive(target);
20
25
  return {
21
26
  decision: report.decision,
22
27
  reasonCode: report.reasonCode,
23
28
  reasons: report.reasons,
24
29
  deps: report.checks.deps.status,
25
- devServer: { label: "webpack", status: watcherStatus(report.checks.buildLog.status) }
30
+ devServer: {
31
+ label: "webpack",
32
+ status: watcherLive ? watcherStatus(report.checks.buildLog.status) : "down"
33
+ }
26
34
  };
27
35
  },
28
36
  runwayProvision: {
@@ -44,13 +52,13 @@ const extensionSurface = {
44
52
  },
45
53
  devServer: {
46
54
  label: "webpack",
47
- describe: () => "webpack watcher",
55
+ describe: () => "Extension runtime",
48
56
  stop(target) {
49
- const signalled = stopExtensionWatcher(target);
57
+ const stopped = stopExtensionRuntime(target);
50
58
  const stopViewers = path.join(runnerDir, "adapters/extension/stop-viewers.sh");
51
59
  spawnSync("bash", [stopViewers, "--target", target], { stdio: "ignore", timeout: 5e3 });
52
- const summary = signalled > 0 ? `stopped webpack watcher (${signalled} process${signalled === 1 ? "" : "es"}) for ${target}` : `webpack watcher not running for ${target} \u2014 nothing to stop`;
53
- return { kind: "stopped", status: 0, summary, signalled };
60
+ const summary = stopped.signalled > 0 || stopped.removedMarkers > 0 ? `stopped Extension runtime (${stopped.signalled} process${stopped.signalled === 1 ? "" : "es"}, ${stopped.removedMarkers} stale marker${stopped.removedMarkers === 1 ? "" : "s"}) for ${target}` : `Extension runtime not running for ${target} \u2014 nothing to stop`;
61
+ return { kind: "stopped", status: 0, summary, signalled: stopped.signalled };
54
62
  }
55
63
  },
56
64
  logSources(target) {
@@ -60,6 +68,9 @@ const extensionSurface = {
60
68
  { label: "rebuild", path: recipeRuntimePath(target, "rebuild.log") }
61
69
  ];
62
70
  },
71
+ appLogSource(target) {
72
+ return { label: "app", path: recipeRuntimePath(target, "extension-console.log") };
73
+ },
63
74
  hints: {
64
75
  launch: "mm-harness launch",
65
76
  relaunch: "mm-harness launch --build"
@@ -0,0 +1,67 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { recipeRuntimePath } from "../../paths.js";
5
+ const ENV_INPUTS = [".js.env", ".env", ".env.local"];
6
+ const BASELINE_FILE = "metro-env-baseline.json";
7
+ function mobileMetroEnvFingerprint(target) {
8
+ const hash = createHash("sha256");
9
+ for (const relative of ENV_INPUTS) {
10
+ const absolute = path.join(target, relative);
11
+ hash.update(`${relative}\0`);
12
+ if (fs.existsSync(absolute)) hash.update(fs.readFileSync(absolute));
13
+ else hash.update("<absent>");
14
+ hash.update("\0");
15
+ }
16
+ return hash.digest("hex");
17
+ }
18
+ function mobileMetroEnvCheck(target) {
19
+ const fingerprint = mobileMetroEnvFingerprint(target);
20
+ const marker = readBaseline(target);
21
+ return {
22
+ fingerprint,
23
+ status: marker === null ? "missing" : marker.fingerprint === fingerprint ? "current" : "changed"
24
+ };
25
+ }
26
+ function applyMobileMetroEnvPolicy(target, actions) {
27
+ if (mobileMetroEnvCheck(target).status === "current") return actions;
28
+ return actions.map((action) => action.id === "start-metro" && !action.argv?.includes("--clear") ? { ...action, argv: [...action.argv ?? [], "--clear"] } : action);
29
+ }
30
+ function recordMobileMetroEnvBaseline(target, expectedFingerprint) {
31
+ if (mobileMetroEnvFingerprint(target) !== expectedFingerprint) return false;
32
+ const marker = recipeRuntimePath(target, BASELINE_FILE);
33
+ const temporary = `${marker}.${process.pid}.${Date.now()}.tmp`;
34
+ const baseline = {
35
+ schemaVersion: 1,
36
+ fingerprint: expectedFingerprint,
37
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
38
+ };
39
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
40
+ try {
41
+ fs.writeFileSync(temporary, `${JSON.stringify(baseline, null, 2)}
42
+ `, { mode: 384 });
43
+ if (mobileMetroEnvFingerprint(target) !== expectedFingerprint) return false;
44
+ fs.renameSync(temporary, marker);
45
+ if (mobileMetroEnvFingerprint(target) !== expectedFingerprint) {
46
+ if (readBaseline(target)?.fingerprint === expectedFingerprint) fs.rmSync(marker, { force: true });
47
+ return false;
48
+ }
49
+ } finally {
50
+ fs.rmSync(temporary, { force: true });
51
+ }
52
+ return true;
53
+ }
54
+ function readBaseline(target) {
55
+ try {
56
+ const parsed = JSON.parse(fs.readFileSync(recipeRuntimePath(target, BASELINE_FILE), "utf8"));
57
+ return parsed.schemaVersion === 1 && typeof parsed.fingerprint === "string" ? parsed : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+ export {
63
+ applyMobileMetroEnvPolicy,
64
+ mobileMetroEnvCheck,
65
+ mobileMetroEnvFingerprint,
66
+ recordMobileMetroEnvBaseline
67
+ };
@@ -0,0 +1,101 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const DEFAULTS = {
5
+ MM_PERPS_ENABLED: "true",
6
+ OVERRIDE_REMOTE_FEATURE_FLAGS: "true"
7
+ };
8
+ function mobilePerpsEnvironment(target) {
9
+ const file = path.join(target, ".js.env");
10
+ const fileExists = fs.existsSync(file);
11
+ const source = fileExists ? fs.readFileSync(file, "utf8") : "";
12
+ const perpsEnabled = readSetting(source, "MM_PERPS_ENABLED");
13
+ const remoteFeatureFlagOverride = readSetting(source, "OVERRIDE_REMOTE_FEATURE_FLAGS");
14
+ return {
15
+ file: ".js.env",
16
+ fileExists,
17
+ status: perpsEnabled.status === "missing" || remoteFeatureFlagOverride.status === "missing" ? "incomplete" : "ready",
18
+ perpsEnabled,
19
+ remoteFeatureFlagOverride
20
+ };
21
+ }
22
+ function ensureMobilePerpsEnvironment(target) {
23
+ const current = mobilePerpsEnvironment(target);
24
+ const missing = Object.keys(DEFAULTS).filter(
25
+ (key) => settingForKey(current, key).status === "missing"
26
+ );
27
+ if (missing.length === 0) return [];
28
+ const file = path.join(fs.realpathSync(target), current.file);
29
+ const snapshot = readEnvironmentSnapshot(file);
30
+ const before = snapshot?.text ?? "";
31
+ const separator = before.length > 0 && !before.endsWith("\n") ? "\n" : "";
32
+ const additions = missing.map((key) => `export ${key}="${DEFAULTS[key]}"`).join("\n");
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}
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
+ }
51
+ return missing;
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
+ }
75
+ function readSetting(source, key) {
76
+ const assignment = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=(.*)$`, "u");
77
+ let value = null;
78
+ for (const line of source.split("\n")) {
79
+ const match = assignment.exec(line);
80
+ if (match) value = normalizeValue(match[1]);
81
+ }
82
+ if (value === null) return { status: "missing", value };
83
+ if (value === "true") return { status: "enabled", value };
84
+ if (value === "false") return { status: "disabled", value };
85
+ return { status: "configured", value };
86
+ }
87
+ function settingForKey(environment, key) {
88
+ return key === "MM_PERPS_ENABLED" ? environment.perpsEnabled : environment.remoteFeatureFlagOverride;
89
+ }
90
+ function normalizeValue(raw) {
91
+ const value = raw.trim();
92
+ if (value.length >= 2) {
93
+ const first = value[0];
94
+ if ((first === '"' || first === "'") && value.at(-1) === first) return value.slice(1, -1);
95
+ }
96
+ return value;
97
+ }
98
+ export {
99
+ ensureMobilePerpsEnvironment,
100
+ mobilePerpsEnvironment
101
+ };
@@ -6,6 +6,11 @@ import {
6
6
  decideMobileReadiness,
7
7
  launchActions
8
8
  } from "./runtime-decision.js";
9
+ import {
10
+ applyMobileMetroEnvPolicy,
11
+ mobileMetroEnvCheck,
12
+ recordMobileMetroEnvBaseline
13
+ } from "./metro-env.js";
9
14
  const READY_BRIDGE_CONFIRM_POLLS = 3;
10
15
  const POD_PROBE_ENV = {
11
16
  FORCE_COLOR: "0",
@@ -58,11 +63,7 @@ async function prepareMobile(target, opts = {}) {
58
63
  `
59
64
  );
60
65
  }
61
- for (const action of launchActions(path.resolve(target))) {
62
- const result = await dispatchAction(action, target, platform, json, preflightMode);
63
- if (result.status !== 0) return result;
64
- }
65
- return { status: 0, output: "" };
66
+ return dispatchActionSequence(launchActions(path.resolve(target)), target, platform, json, preflightMode);
66
67
  }
67
68
  if (report.decision === "unknown") {
68
69
  const msg = "mobile prepare: runtime state unknown\n Next: run mm-harness verify --adapter mobile --target <checkout>";
@@ -73,11 +74,8 @@ async function prepareMobile(target, opts = {}) {
73
74
  const actions = clearMetro ? report.actions.map(
74
75
  (a) => a.id === "start-metro" && !a.argv?.includes("--clear") ? { ...a, argv: [...a.argv ?? [], "--clear"] } : a
75
76
  ) : report.actions;
76
- for (const action of actions) {
77
- const result = await dispatchAction(action, target, platform, json, preflightMode);
78
- if (result.status !== 0) return result;
79
- if (action.id === "yarn-setup") recordDepsBaseline(path.resolve(target));
80
- }
77
+ const actionResult = await dispatchActionSequence(actions, target, platform, json, preflightMode);
78
+ if (actionResult.status !== 0) return actionResult;
81
79
  if (report.decision === "install" && !process.env["RECIPE_UP_INSTALL_ATTEMPTED"]) {
82
80
  process.env["RECIPE_UP_INSTALL_ATTEMPTED"] = "1";
83
81
  const postInstall = await decideMobileReadiness(target, {
@@ -100,10 +98,8 @@ async function prepareMobile(target, opts = {}) {
100
98
  break;
101
99
  }
102
100
  case "launch": {
103
- for (const action of postInstall.actions) {
104
- const result = await dispatchAction(action, target, platform, json, preflightMode);
105
- if (result.status !== 0) return result;
106
- }
101
+ const result = await dispatchActionSequence(postInstall.actions, target, platform, json, preflightMode);
102
+ if (result.status !== 0) return result;
107
103
  break;
108
104
  }
109
105
  default: {
@@ -117,6 +113,28 @@ async function prepareMobile(target, opts = {}) {
117
113
  }
118
114
  return { status: 0, output: "" };
119
115
  }
116
+ async function dispatchActionSequence(requestedActions, target, platform, json, preflightMode) {
117
+ const resolved = path.resolve(target);
118
+ const actions = applyMobileMetroEnvPolicy(resolved, requestedActions);
119
+ let pendingMetroEnvFingerprint;
120
+ for (const action of actions) {
121
+ if (action.id === "start-metro") {
122
+ pendingMetroEnvFingerprint = mobileMetroEnvCheck(resolved).fingerprint;
123
+ }
124
+ const result = await dispatchAction(action, target, platform, json, preflightMode);
125
+ if (result.status !== 0) return result;
126
+ if (action.id === "yarn-setup") recordDepsBaseline(resolved);
127
+ if (action.id === "prewarm-bundle" && pendingMetroEnvFingerprint) {
128
+ if (!recordMobileMetroEnvBaseline(resolved, pendingMetroEnvFingerprint)) {
129
+ const message = "Mobile environment changed while Metro was starting; the stale bundle baseline was not recorded.\n Next: re-run mm-harness launch.";
130
+ if (!json) process.stderr.write(`${message}
131
+ `);
132
+ return { status: EXIT.runtime, output: message };
133
+ }
134
+ }
135
+ }
136
+ return { status: 0, output: "" };
137
+ }
120
138
  async function dispatchAction(action, target, platform, json, preflightMode = "fast") {
121
139
  const cwd = action.cwd ?? target;
122
140
  switch (action.id) {
@@ -14,6 +14,7 @@ import {
14
14
  import { probeMetroPackager } from "@farmslot/recipe-harness/runtime/metro-probe";
15
15
  import { recipeRuntimePath } from "../../paths.js";
16
16
  import { mobileProductMarkers } from "./deps-markers.js";
17
+ import { applyMobileMetroEnvPolicy, mobileMetroEnvCheck } from "./metro-env.js";
17
18
  const BUNDLE_ERR = /Bundling failed|Unable to resolve /u;
18
19
  const BUNDLE_OK = /Bundled \d+ms|iOS Bundled|Android Bundled|Finished bundling/u;
19
20
  const NATIVE_MODULE_STALE = /\[runtime not ready\].*HybridObject "([^"]+)" - It has not yet been registered in the Nitro Modules HybridObjectRegistry/u;
@@ -113,11 +114,29 @@ async function decideMobileReadiness(target, options = {}) {
113
114
  "Fast preflight found dependencies not ready and does not install them (the orchestrator deps phase owns installation).",
114
115
  ...report.reasons
115
116
  ],
116
- userAction: `run the slot deps/prepare phase; standalone: ${standalone} in the checkout, then re-run \`mm-harness launch <platform>\``,
117
+ userAction: `1. Standalone checkout \u2014 run ${standalone} in the checkout.
118
+ 2. Orchestrated slot \u2014 run its deps/prepare phase.
119
+ After the selected action succeeds, re-run \`mm-harness launch <platform>\`.`,
117
120
  actions: []
118
121
  };
119
122
  }
120
- return report;
123
+ const env = mobileMetroEnvCheck(resolved);
124
+ if (env.status === "current" || report.decision !== "launch" && report.decision !== "ready") {
125
+ return report;
126
+ }
127
+ return {
128
+ ...report,
129
+ decision: "launch",
130
+ reasonCode: report.decision === "ready" ? env.status === "missing" ? "metro-env-baseline-missing" : "metro-env-changed" : report.reasonCode,
131
+ reasons: [
132
+ env.status === "missing" ? "Metro environment has no successful bundle baseline; restart once with a cleared transform cache." : "Metro environment inputs changed since the successful bundle baseline; restart once with a cleared transform cache.",
133
+ ...report.reasons
134
+ ],
135
+ actions: applyMobileMetroEnvPolicy(
136
+ resolved,
137
+ report.decision === "ready" ? launchActions(resolved) : report.actions
138
+ )
139
+ };
121
140
  }
122
141
  async function computeMobileReadiness(resolved, options, fast) {
123
142
  const rawDeps = depsCheck(resolved, {
@@ -0,0 +1,116 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { recipeRuntimePath } from "../../paths.js";
6
+ const SOURCE_PATHS = [
7
+ "app",
8
+ "locales",
9
+ "index.js",
10
+ "shim.js",
11
+ "shimPerf.js",
12
+ "ReactotronConfig.js",
13
+ "wdyr.js",
14
+ "app.config.js",
15
+ "babel.config.js",
16
+ "metro.config.js",
17
+ "metro.transform.js",
18
+ "package.json"
19
+ ];
20
+ const ENV_INPUTS = [".js.env", ".env", ".env.local"];
21
+ const BASELINE_FILE = "mobile-source-baseline.json";
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
+ };
58
+ }
59
+ }
60
+ function mobileSourceCheck(target) {
61
+ const result = mobileSourceFingerprint(target);
62
+ if (result.status === "unavailable") return result;
63
+ const { fingerprint } = result;
64
+ const baseline = readBaseline(target);
65
+ return {
66
+ fingerprint,
67
+ status: baseline === null ? "missing" : baseline.fingerprint === fingerprint ? "current" : "changed"
68
+ };
69
+ }
70
+ function recordMobileSourceBaseline(target, expectedFingerprint) {
71
+ if (mobileSourceFingerprint(target).fingerprint !== expectedFingerprint) return false;
72
+ const marker = recipeRuntimePath(target, BASELINE_FILE);
73
+ const temporary = `${marker}.${process.pid}.${Date.now()}.tmp`;
74
+ const baseline = {
75
+ schemaVersion: 1,
76
+ fingerprint: expectedFingerprint,
77
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
78
+ };
79
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
80
+ try {
81
+ fs.writeFileSync(temporary, `${JSON.stringify(baseline, null, 2)}
82
+ `, { mode: 384 });
83
+ if (mobileSourceFingerprint(target).fingerprint !== expectedFingerprint) return false;
84
+ fs.renameSync(temporary, marker);
85
+ if (mobileSourceFingerprint(target).fingerprint !== expectedFingerprint) {
86
+ if (readBaseline(target)?.fingerprint === expectedFingerprint) fs.rmSync(marker, { force: true });
87
+ return false;
88
+ }
89
+ } finally {
90
+ fs.rmSync(temporary, { force: true });
91
+ }
92
+ return true;
93
+ }
94
+ function git(target, args) {
95
+ return execFileSync("git", args, {
96
+ cwd: target,
97
+ encoding: "buffer",
98
+ maxBuffer: 128 * 1024 * 1024,
99
+ stdio: ["ignore", "pipe", "ignore"]
100
+ });
101
+ }
102
+ function readBaseline(target) {
103
+ try {
104
+ const parsed = JSON.parse(
105
+ fs.readFileSync(recipeRuntimePath(target, BASELINE_FILE), "utf8")
106
+ );
107
+ return parsed.schemaVersion === 1 && typeof parsed.fingerprint === "string" ? parsed : null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+ export {
113
+ mobileSourceCheck,
114
+ mobileSourceFingerprint,
115
+ recordMobileSourceBaseline
116
+ };
@@ -12,7 +12,8 @@ const mobileSurface = {
12
12
  },
13
13
  async runtimeStatus(target) {
14
14
  const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : void 0;
15
- const report = await mobileRuntimeStatus(target, { watcherPort });
15
+ const platform = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL ? "android" : "ios";
16
+ const report = await mobileRuntimeStatus(target, { watcherPort, platform });
16
17
  const runwayProvisioned = hasRunwayProvisionBaseline(target);
17
18
  const depsPending = runwayProvisioned && report.checks?.deps?.status !== "current";
18
19
  return {
@@ -45,6 +46,9 @@ const mobileSurface = {
45
46
  logSources(target) {
46
47
  return [{ label: "metro", path: recipeRuntimePath(target, "metro.log") }];
47
48
  },
49
+ appLogSource(target) {
50
+ return { label: "app", path: recipeRuntimePath(target, "metro.log") };
51
+ },
48
52
  hints: {
49
53
  launch: "mm-harness launch ios",
50
54
  relaunch: "mm-harness launch ios"
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  formatKvLines,
3
+ formatExtensionSlotPorts,
3
4
  inferSlotSuffix,
4
5
  realRepoPath,
5
6
  resolveDefaultExtensionPorts,
7
+ resolveExtensionRuntimeContext,
6
8
  resolveExtensionRuntimePorts,
7
9
  resolveSlotPortsByRepo,
8
10
  resolveFarmslotPortsByRepo,
@@ -11,10 +13,12 @@ import {
11
13
  resolveMobileSlotDefaults
12
14
  } from "../../adapters/shared/resolve-slot-ports-core.mjs";
13
15
  export {
16
+ formatExtensionSlotPorts,
14
17
  formatKvLines,
15
18
  inferSlotSuffix,
16
19
  realRepoPath,
17
20
  resolveDefaultExtensionPorts,
21
+ resolveExtensionRuntimeContext,
18
22
  resolveExtensionRuntimePorts,
19
23
  resolveFarmslotPortsByRepo,
20
24
  resolveMobileRuntimeContext,