@deeeed/metamask-harness 0.17.5 → 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 (132) hide show
  1. package/CHANGELOG.md +872 -0
  2. package/README.md +94 -140
  3. package/adapters/extension/console-tail.mjs +55 -24
  4. package/adapters/extension/ensure-browser.sh +6 -1
  5. package/adapters/extension/inject.mjs +6 -9
  6. package/adapters/extension/launch-browser.cjs +7 -1
  7. package/adapters/extension/launch-webpack.cjs +83 -0
  8. package/adapters/extension/launch.sh +8 -11
  9. package/adapters/extension/lib/chrome-args.cjs +8 -1
  10. package/adapters/extension/lib/macos-focus.cjs +32 -0
  11. package/adapters/extension/live.sh +12 -21
  12. package/adapters/extension/reattach.sh +1 -0
  13. package/adapters/extension/seed-fixture.sh +4 -12
  14. package/adapters/extension/sidepanel-toggle.sh +4 -1
  15. package/adapters/extension/stamp-runtime-title.cjs +58 -0
  16. package/adapters/extension/start-watch.sh +17 -4
  17. package/adapters/extension/stop-viewers.sh +1 -1
  18. package/adapters/extension/sync-webpack-dist.cjs +107 -0
  19. package/adapters/extension/verify.sh +1 -1
  20. package/adapters/manifest.json +53 -37
  21. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  22. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +1 -1
  23. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +3 -1
  24. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +2 -3
  25. package/adapters/mobile/cleanup.sh +16 -176
  26. package/adapters/mobile/inject.sh +90 -671
  27. package/adapters/mobile/launch-console-forwarder.cjs +39 -0
  28. package/adapters/mobile/open-device.sh +45 -7
  29. package/adapters/mobile/start-console-forwarder.sh +70 -0
  30. package/adapters/mobile/start-metro.sh +0 -41
  31. package/adapters/mobile/verify.sh +19 -64
  32. package/adapters/mobile/wait-for-bridge.sh +22 -8
  33. package/adapters/shared/resolve-slot-ports-core.mjs +17 -6
  34. package/adapters/shared/resolve-slot-ports.sh +17 -5
  35. package/adapters/shared/update-check-worker.mjs +43 -0
  36. package/bin/mm-harness +21 -2
  37. package/dist/adapters/core/surface.js +4 -1
  38. package/dist/adapters/extension/console-capture.js +95 -0
  39. package/dist/adapters/extension/product-config.js +110 -0
  40. package/dist/adapters/extension/runtime-decision.js +20 -2
  41. package/dist/adapters/extension/surface.js +22 -3
  42. package/dist/adapters/mobile/perps-env.js +43 -3
  43. package/dist/adapters/mobile/prepare.js +39 -7
  44. package/dist/adapters/mobile/surface.js +5 -2
  45. package/dist/adapters/resolve-slot-ports.js +2 -2
  46. package/dist/adapters/slot-ports.js +13 -16
  47. package/dist/adapters.js +56 -16
  48. package/dist/checkout-lock.js +27 -2
  49. package/dist/cli-color.js +19 -0
  50. package/dist/cli-commands.js +1 -1
  51. package/dist/cli.js +7 -14
  52. package/dist/command-contract.js +451 -0
  53. package/dist/command-journal.js +225 -0
  54. package/dist/commands/call.js +170 -50
  55. package/dist/commands/check.js +9 -3
  56. package/dist/commands/completion-candidates.js +20 -13
  57. package/dist/commands/device-target.js +27 -12
  58. package/dist/commands/doctor.js +106 -25
  59. package/dist/commands/fixtures.js +92 -34
  60. package/dist/commands/flows.js +39 -10
  61. package/dist/commands/last.js +52 -0
  62. package/dist/commands/launch/extension.js +38 -15
  63. package/dist/commands/launch/index.js +207 -63
  64. package/dist/commands/list-executables.js +151 -29
  65. package/dist/commands/logs.js +8 -6
  66. package/dist/commands/manifest.js +270 -35
  67. package/dist/commands/parse-args.js +13 -1
  68. package/dist/commands/provision.js +10 -3
  69. package/dist/commands/run-engine.js +435 -105
  70. package/dist/commands/run-report.js +12 -3
  71. package/dist/commands/run.js +355 -50
  72. package/dist/commands/shared.js +75 -3
  73. package/dist/commands/status-probe.js +4 -1
  74. package/dist/commands/status.js +2 -1
  75. package/dist/commands/stop.js +7 -2
  76. package/dist/commands/update.js +16 -28
  77. package/dist/completions-cache.js +1 -1
  78. package/dist/doctor.js +57 -10
  79. package/dist/harness.js +47 -137
  80. package/dist/heal-bounds.js +2 -2
  81. package/dist/json-stream.js +57 -0
  82. package/dist/live-adapter-contract.js +138 -32
  83. package/dist/manifest.js +161 -1
  84. package/dist/mm-harness-cli.js +135 -54
  85. package/dist/paths.js +2 -5
  86. package/dist/recipe-security.js +178 -0
  87. package/dist/run-diagnostics.js +261 -0
  88. package/dist/runner.js +117 -8
  89. package/docs/CONTRIBUTING.md +137 -0
  90. package/docs/QA.md +185 -0
  91. package/docs/RECIPES.md +161 -0
  92. package/docs/SECURITY.md +88 -0
  93. package/library/README.md +4 -0
  94. package/library/actions/core/perps/_controller.mjs +10 -55
  95. package/library/actions/core/perps/read_account.mjs +2 -2
  96. package/library/actions/core/perps/read_orders.mjs +2 -1
  97. package/library/actions/core/perps/read_positions.mjs +2 -1
  98. package/library/actions/core/wallet/list_accounts.mjs +95 -0
  99. package/library/actions/extension/platform/cdp.mjs +1 -0
  100. package/library/actions/extension/wallet/list_accounts.mjs +41 -0
  101. package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
  102. package/library/manifests/core.action-manifest.json +68 -7
  103. package/library/manifests/extension.action-manifest.json +53 -0
  104. package/library/manifests/mobile.action-manifest.json +81 -3
  105. package/library/recipes/runner/action-validation.extension.recipe.json +8 -1
  106. package/library/recipes/runner/action-validation.mobile.recipe.json +8 -1
  107. package/library/recipes/runner/smoke.core.recipe.json +27 -0
  108. package/library/recipes/wallet/smoke.extension.recipe.json +42 -0
  109. package/library/recipes/wallet/smoke.mobile.recipe.json +42 -0
  110. package/package.json +12 -5
  111. package/scripts/completions.sh +7 -7
  112. package/scripts/validate-human-outcomes.mjs +169 -0
  113. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -185
  114. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -1662
  115. package/adapters/mobile/overlay/compat/README.md +0 -28
  116. package/adapters/mobile/overlay/compat/rn81-message-event-source.patch +0 -42
  117. package/adapters/shared/resolve-farmslot-ports-core.mjs +0 -3
  118. package/adapters/shared/resolve-farmslot-ports.mjs +0 -5
  119. package/adapters/shared/resolve-farmslot-ports.sh +0 -7
  120. package/docs/ADAPTER-SURFACE.md +0 -119
  121. package/docs/CHEATSHEET.md +0 -61
  122. package/docs/CLI-SPEC.md +0 -1098
  123. package/docs/CODE-MAP.md +0 -62
  124. package/docs/DEBUG-HANDOVER.md +0 -36
  125. package/docs/MENTAL-MODEL.md +0 -295
  126. package/docs/UX-PRINCIPLES.md +0 -64
  127. package/docs/architecture.md +0 -398
  128. package/docs/live-adapter-contract.md +0 -188
  129. package/docs/package-boundaries.md +0 -47
  130. package/docs/perps-flow-catalog.md +0 -235
  131. package/docs/recipe-libraries.md +0 -301
  132. package/docs/runtime-file-conventions.md +0 -36
@@ -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 {
@@ -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 {
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  formatKvLines,
3
+ formatExtensionIdentityPorts,
3
4
  formatExtensionSlotPorts,
4
5
  inferSlotSuffix,
5
6
  realRepoPath,
@@ -7,12 +8,12 @@ import {
7
8
  resolveExtensionRuntimeContext,
8
9
  resolveExtensionRuntimePorts,
9
10
  resolveSlotPortsByRepo,
10
- resolveFarmslotPortsByRepo,
11
11
  resolveMobileRuntimeContext,
12
12
  resolveMobileRuntimePorts,
13
13
  resolveMobileSlotDefaults
14
14
  } from "../../adapters/shared/resolve-slot-ports-core.mjs";
15
15
  export {
16
+ formatExtensionIdentityPorts,
16
17
  formatExtensionSlotPorts,
17
18
  formatKvLines,
18
19
  inferSlotSuffix,
@@ -20,7 +21,6 @@ export {
20
21
  resolveDefaultExtensionPorts,
21
22
  resolveExtensionRuntimeContext,
22
23
  resolveExtensionRuntimePorts,
23
- resolveFarmslotPortsByRepo,
24
24
  resolveMobileRuntimeContext,
25
25
  resolveMobileRuntimePorts,
26
26
  resolveMobileSlotDefaults,
@@ -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));
@@ -127,9 +127,8 @@ function stopExtensionWatcher(target) {
127
127
  function stopExtensionRuntime(target) {
128
128
  const resolved = path.resolve(target);
129
129
  const runtimeAbs = path.join(resolved, recipeRuntimeDir());
130
- const profile = path.resolve(
131
- process.env.CHROME_USER_DATA_DIR || path.join(runtimeAbs, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile")
132
- );
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));
133
132
  let signalled = stopExtensionWatcher(resolved);
134
133
  const ownedBrowserPids = /* @__PURE__ */ new Set();
135
134
  try {
@@ -138,9 +137,7 @@ function stopExtensionRuntime(target) {
138
137
  const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
139
138
  if (!match) continue;
140
139
  const pid = Number(match[1]);
141
- if (pid !== process.pid && commandHasExactArg(match[2], "--user-data-dir", profile)) {
142
- ownedBrowserPids.add(pid);
143
- }
140
+ if (pid !== process.pid && profiles.some((profile) => commandHasExactArg(match[2], "--user-data-dir", profile))) ownedBrowserPids.add(pid);
144
141
  }
145
142
  } catch {
146
143
  }
@@ -195,8 +192,8 @@ function processAlive(pid) {
195
192
  try {
196
193
  process.kill(pid, 0);
197
194
  return true;
198
- } catch {
199
- return false;
195
+ } catch (error) {
196
+ return error.code === "EPERM";
200
197
  }
201
198
  }
202
199
  function commandHasExactArg(command, flag, value) {