@deeeed/metamask-harness 0.6.2 → 0.7.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 (37) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/adapters/extension/inject.mjs +7 -0
  3. package/adapters/extension/reattach.sh +210 -0
  4. package/adapters/extension/start-watch.sh +11 -17
  5. package/adapters/manifest.json +65 -9
  6. package/adapters/mobile/lib/tmux-viewer.sh +31 -10
  7. package/adapters/mobile/open-device.sh +14 -1
  8. package/adapters/mobile/stop-metro.sh +1 -1
  9. package/adapters/mobile/yarn-setup.sh +15 -1
  10. package/adapters/shared/activate-repo-ruby.sh +124 -0
  11. package/adapters/shared/open-log-window.sh +55 -0
  12. package/adapters/shared/resolve-farmslot-ports-core.mjs +3 -205
  13. package/adapters/shared/resolve-farmslot-ports.mjs +4 -19
  14. package/adapters/shared/resolve-farmslot-ports.sh +6 -104
  15. package/adapters/shared/resolve-slot-ports-core.mjs +213 -0
  16. package/adapters/shared/resolve-slot-ports.mjs +20 -0
  17. package/adapters/shared/resolve-slot-ports.sh +110 -0
  18. package/adapters/shared/tmux-session.sh +35 -0
  19. package/dist/adapters/extension/runtime-decision.js +4 -0
  20. package/dist/adapters/mobile/provision.js +25 -2
  21. package/dist/adapters/{resolve-farmslot-ports.js → resolve-slot-ports.js} +4 -2
  22. package/dist/adapters/slot-ports.js +8 -10
  23. package/dist/cli-commands.js +1 -1
  24. package/dist/commands/call.js +5 -0
  25. package/dist/commands/doctor.js +21 -1
  26. package/dist/commands/fixtures.js +5 -3
  27. package/dist/commands/launch/extension.js +80 -6
  28. package/dist/commands/launch/index.js +10 -1
  29. package/dist/commands/list-executables.js +48 -0
  30. package/dist/commands/logs.js +25 -2
  31. package/dist/commands/manifest.js +27 -7
  32. package/dist/commands/parse-args.js +1 -0
  33. package/dist/commands/run.js +3 -4
  34. package/dist/live-adapter-contract.js +30 -9
  35. package/dist/mm-harness-cli.js +3 -1
  36. package/dist/paths.js +4 -1
  37. package/package.json +1 -1
@@ -0,0 +1,213 @@
1
+ // Slot-pool + slot-suffix port resolution — pure JS for runner and overlay leaves.
2
+ // Reads the orchestrator's pool JSON (the on-disk `pool/` directory) when a slot
3
+ // repo matches, else the local-extension-N suffix formula.
4
+
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const coreDir = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ function pathDefault(key) {
13
+ for (const candidate of [
14
+ path.join(coreDir, 'path-defaults.json'),
15
+ path.join(coreDir, '../../adapters/shared/path-defaults.json'),
16
+ ]) {
17
+ if (!fs.existsSync(candidate)) continue;
18
+ const value = JSON.parse(fs.readFileSync(candidate, 'utf8'))[key];
19
+ if (value) return value;
20
+ }
21
+ throw new Error(`Missing path default: ${key}`);
22
+ }
23
+
24
+ function recipeRuntimeDir() {
25
+ return process.env.RECIPE_RUNTIME_DIR || pathDefault('recipeRuntimeDir');
26
+ }
27
+
28
+ export function formatKvLines(kv) {
29
+ const lines = [];
30
+ if (kv.CDP_PORT !== undefined) lines.push(`CDP_PORT=${kv.CDP_PORT}`);
31
+ if (kv.WATCHER_PORT !== undefined) lines.push(`WATCHER_PORT=${kv.WATCHER_PORT}`);
32
+ if (kv.SLOT_ID) lines.push(`SLOT_ID=${kv.SLOT_ID}`);
33
+ if (kv.IOS_SIMULATOR) lines.push(`IOS_SIMULATOR=${kv.IOS_SIMULATOR}`);
34
+ return lines.length ? `${lines.join('\n')}\n` : '';
35
+ }
36
+
37
+ export function realRepoPath(repo) {
38
+ if (!repo) return null;
39
+ try {
40
+ return fs.realpathSync(repo);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export function inferSlotSuffix(repo) {
47
+ const base = path.basename(repo);
48
+ const m = /-(\d+)$/u.exec(base);
49
+ return m ? m[1] : null;
50
+ }
51
+
52
+ function scoreSlot(slot, slotSuffix) {
53
+ const resources = slot.resources ?? {};
54
+ const cdp = resources.browser?.cdp_port;
55
+ const port = resources['dev-server']?.port;
56
+ const simulator = resources['ios-sim']?.simulator;
57
+ const session = slot.session ?? '';
58
+ const slotId = slot.id ?? '';
59
+ let score = 0;
60
+ if (cdp !== undefined) score += 100;
61
+ if (port !== undefined) score += 10;
62
+ if (slotSuffix) {
63
+ if (session === `mme-${slotSuffix}`) score += 50;
64
+ if (slotId.endsWith(`mme-${slotSuffix}`) || slotId.includes(`-mme-${slotSuffix}`)) score += 40;
65
+ }
66
+ if (!slotId.toLowerCase().includes('demo')) score += 5;
67
+ return [score, cdp, port, slotId, simulator];
68
+ }
69
+
70
+ export function resolveSlotPortsByRepo(repo) {
71
+ const realRepo = realRepoPath(repo);
72
+ if (!realRepo) return null;
73
+
74
+ const slotSuffix = inferSlotSuffix(realRepo);
75
+ const roots = [];
76
+ // A slot checkout lives at <workspace>/repos/<slot>; that workspace's own
77
+ // pool is the authoritative one for this repo — machine-global fallbacks
78
+ // (env root, dev checkout) apply only when the repo is not workspace-shaped.
79
+ if (path.basename(path.dirname(realRepo)) === 'repos') {
80
+ roots.push(path.join(path.dirname(path.dirname(realRepo)), 'farmslot'));
81
+ }
82
+ if (process.env.FARMSLOT_ROOT) roots.push(process.env.FARMSLOT_ROOT);
83
+ roots.push(path.join(os.homedir(), 'dev', 'farmslot'));
84
+
85
+ for (const root of roots) {
86
+ if (!root || !fs.existsSync(path.join(root, 'pool'))) continue;
87
+ const poolDir = path.join(root, 'pool');
88
+ let best = null;
89
+
90
+ for (const name of fs.readdirSync(poolDir).sort()) {
91
+ if (!name.endsWith('.json') || name.includes('.bak.')) continue;
92
+ let data;
93
+ try {
94
+ data = JSON.parse(fs.readFileSync(path.join(poolDir, name), 'utf8'));
95
+ } catch {
96
+ continue;
97
+ }
98
+ for (const slot of data.slots ?? []) {
99
+ const slotRepo = slot.repo ?? '';
100
+ if (!slotRepo) continue;
101
+ let slotReal;
102
+ try {
103
+ slotReal = fs.realpathSync(slotRepo);
104
+ } catch {
105
+ continue;
106
+ }
107
+ if (slotReal !== realRepo) continue;
108
+ const scored = scoreSlot(slot, slotSuffix);
109
+ if (!best || scored[0] > best[0]) best = scored;
110
+ }
111
+ }
112
+
113
+ if (best) {
114
+ const [, cdp, port, slotId, simulator] = best;
115
+ return formatKvLines({
116
+ ...(cdp !== undefined ? { CDP_PORT: cdp } : {}),
117
+ ...(port !== undefined ? { WATCHER_PORT: port } : {}),
118
+ ...(slotId ? { SLOT_ID: slotId } : {}),
119
+ ...(simulator ? { IOS_SIMULATOR: simulator } : {}),
120
+ });
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+
126
+ export function resolveDefaultExtensionPorts(repo) {
127
+ const n = inferSlotSuffix(repo);
128
+ if (!n) return null;
129
+ return formatKvLines({
130
+ CDP_PORT: 6660 + Number(n),
131
+ WATCHER_PORT: 9010 + Number(n),
132
+ SLOT_ID: `local-extension-${n}`,
133
+ });
134
+ }
135
+
136
+ export function resolveExtensionRuntimePorts(repo) {
137
+ return resolveSlotPortsByRepo(repo) ?? resolveDefaultExtensionPorts(repo) ?? '';
138
+ }
139
+
140
+ export function resolveMobileSlotDefaults(repo) {
141
+ const n = inferSlotSuffix(repo);
142
+ if (!n) return null;
143
+ return formatKvLines({
144
+ WATCHER_PORT: 8060 + Number(n),
145
+ IOS_SIMULATOR: `mm-${n}`,
146
+ SLOT_ID: `local-mobile-${n}`,
147
+ });
148
+ }
149
+
150
+ export function resolveMobileRuntimeContext(repo) {
151
+ const ctxPath = path.join(repo, recipeRuntimeDir(), 'agentic-runtime.json');
152
+ if (fs.existsSync(ctxPath)) {
153
+ try {
154
+ const c = JSON.parse(fs.readFileSync(ctxPath, 'utf8'));
155
+ if (c.simulator || (c.metroPort != null && c.metroPort !== '')) {
156
+ return formatKvLines({
157
+ ...(c.metroPort != null && c.metroPort !== '' ? { WATCHER_PORT: c.metroPort } : {}),
158
+ ...(c.simulator ? { IOS_SIMULATOR: c.simulator } : {}),
159
+ ...(c.slotId ? { SLOT_ID: c.slotId } : {}),
160
+ });
161
+ }
162
+ } catch {
163
+ /* unreadable context falls through to the provision baseline */
164
+ }
165
+ }
166
+ return resolveMobileProvisionBaseline(repo);
167
+ }
168
+
169
+ // A provisioned-but-unprepared slot has no agentic-runtime.json yet, but the
170
+ // provision baseline records the same authoritative simulator/port identity —
171
+ // without it, a fresh slot's first launch degrades to the simctl `booted`
172
+ // alias and misses the installed dev client entirely.
173
+ function resolveMobileProvisionBaseline(repo) {
174
+ const basePath = path.join(repo, recipeRuntimeDir(), 'runway-provision.json');
175
+ if (!fs.existsSync(basePath)) return null;
176
+ try {
177
+ const c = JSON.parse(fs.readFileSync(basePath, 'utf8'));
178
+ const simulator = c.simulator?.name || c.simulator?.udid;
179
+ const watcherPort = c.watcherPort != null && c.watcherPort !== '' ? c.watcherPort : undefined;
180
+ if (!simulator && watcherPort === undefined) return null;
181
+ return formatKvLines({
182
+ ...(watcherPort !== undefined ? { WATCHER_PORT: watcherPort } : {}),
183
+ ...(simulator ? { IOS_SIMULATOR: simulator } : {}),
184
+ ...(c.slotId ? { SLOT_ID: c.slotId } : {}),
185
+ });
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ export function resolveMobileRuntimePorts(repo) {
192
+ return (
193
+ resolveMobileRuntimeContext(repo)
194
+ ?? resolveSlotPortsByRepo(repo)
195
+ ?? resolveMobileSlotDefaults(repo)
196
+ ?? ''
197
+ );
198
+ }
199
+
200
+ // Back-compat: the previous name for resolveSlotPortsByRepo, kept as an alias for
201
+ // one release so existing importers keep resolving.
202
+ export const resolveFarmslotPortsByRepo = resolveSlotPortsByRepo;
203
+
204
+ export const cliFns = {
205
+ resolve_slot_ports_by_repo: resolveSlotPortsByRepo,
206
+ // Back-compat cli key for one release.
207
+ resolve_farmslot_ports_by_repo: resolveSlotPortsByRepo,
208
+ resolve_default_extension_ports: resolveDefaultExtensionPorts,
209
+ resolve_extension_runtime_ports: (repo) => resolveExtensionRuntimePorts(repo) || null,
210
+ resolve_mobile_runtime_context: resolveMobileRuntimeContext,
211
+ resolve_mobile_slot_defaults: resolveMobileSlotDefaults,
212
+ resolve_mobile_runtime_ports: (repo) => resolveMobileRuntimePorts(repo) || null,
213
+ };
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // Node leaf for port resolution — self-contained for runner and injected overlay copies.
3
+
4
+ import { cliFns } from './resolve-slot-ports-core.mjs';
5
+
6
+ const [, , fn, repo] = process.argv;
7
+ if (!fn || fn === '-h' || fn === '--help') {
8
+ process.stderr.write('Usage: resolve-slot-ports.mjs <fn> <repo>\n');
9
+ process.exit(fn ? 0 : 2);
10
+ }
11
+
12
+ const handler = cliFns[fn];
13
+ if (!handler) {
14
+ process.stderr.write(`resolve-slot-ports: unknown fn: ${fn}\n`);
15
+ process.exit(2);
16
+ }
17
+
18
+ const out = handler(repo ?? '.');
19
+ if (!out?.trim()) process.exit(1);
20
+ process.stdout.write(out.endsWith('\n') ? out : `${out}\n`);
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env bash
2
+ # resolve-slot-ports.sh — bash-compat wrapper over resolve-slot-ports.mjs
3
+ #
4
+ # The orchestrator's pool JSON is authoritative when a slot repo matches; otherwise
5
+ # fall back to the local-extension-N formula (6660+N / 9010+N).
6
+ #
7
+ # Usage:
8
+ # . resolve-slot-ports.sh
9
+ # resolve_extension_runtime_ports /path/to/metamask-extension-N
10
+ # -> prints CDP_PORT=... WATCHER_PORT=... SLOT_ID=... lines
11
+
12
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ _RESOLVE_PORTS_MJS="$SCRIPT_DIR/resolve-slot-ports.mjs"
14
+
15
+ _resolve_ports_cli() {
16
+ node "$_RESOLVE_PORTS_MJS" "$1" "${2:-.}"
17
+ }
18
+
19
+ resolve_slot_ports_by_repo() {
20
+ _resolve_ports_cli resolve_slot_ports_by_repo "$1"
21
+ }
22
+
23
+ # Back-compat alias for the previous function name (one release).
24
+ resolve_farmslot_ports_by_repo() {
25
+ resolve_slot_ports_by_repo "$1"
26
+ }
27
+
28
+ infer_extension_slot_suffix() {
29
+ local repo="$1" base
30
+ base="$(basename "$repo")"
31
+ if [[ "$base" =~ -([0-9]+)$ ]]; then
32
+ printf '%s' "${BASH_REMATCH[1]}"
33
+ return 0
34
+ fi
35
+ return 1
36
+ }
37
+
38
+ resolve_default_extension_ports() {
39
+ _resolve_ports_cli resolve_default_extension_ports "$1"
40
+ }
41
+
42
+ resolve_extension_runtime_ports() {
43
+ _resolve_ports_cli resolve_extension_runtime_ports "${1:-.}"
44
+ }
45
+
46
+ apply_resolved_extension_ports() {
47
+ local repo="${1:-.}" line key val from_pool=false resolved=""
48
+ if resolved="$(_resolve_ports_cli resolve_slot_ports_by_repo "$repo" 2>/dev/null)"; then
49
+ from_pool=true
50
+ elif resolved="$(_resolve_ports_cli resolve_default_extension_ports "$repo" 2>/dev/null)"; then
51
+ from_pool=false
52
+ else
53
+ return 0
54
+ fi
55
+ while IFS= read -r line; do
56
+ [ -n "$line" ] || continue
57
+ key="${line%%=*}"
58
+ val="${line#*=}"
59
+ case "$key" in
60
+ CDP_PORT)
61
+ if [ "$from_pool" = true ] || [ -z "${CDP_PORT:-}" ]; then CDP_PORT="$val"; fi
62
+ ;;
63
+ WATCHER_PORT)
64
+ if [ "$from_pool" = true ] || [ -z "${WATCHER_PORT:-}" ]; then WATCHER_PORT="$val"; fi
65
+ ;;
66
+ SLOT_ID)
67
+ if [ "$from_pool" = true ] || [ -z "${RECIPE_SLOT_ID:-}" ]; then RECIPE_SLOT_ID="$val"; fi
68
+ ;;
69
+ esac
70
+ done <<< "$resolved"
71
+ }
72
+
73
+ resolve_mobile_slot_defaults() {
74
+ _resolve_ports_cli resolve_mobile_slot_defaults "$1"
75
+ }
76
+
77
+ resolve_mobile_runtime_context() {
78
+ _resolve_ports_cli resolve_mobile_runtime_context "$1"
79
+ }
80
+
81
+ resolve_mobile_runtime_ports() {
82
+ _resolve_ports_cli resolve_mobile_runtime_ports "${1:-.}"
83
+ }
84
+
85
+ apply_resolved_mobile_ports() {
86
+ local repo="${1:-.}" line key val from_pool=false resolved=""
87
+ if resolved="$(_resolve_ports_cli resolve_slot_ports_by_repo "$repo" 2>/dev/null)"; then
88
+ from_pool=true
89
+ elif resolved="$(_resolve_ports_cli resolve_mobile_slot_defaults "$repo" 2>/dev/null)"; then
90
+ from_pool=false
91
+ else
92
+ return 0
93
+ fi
94
+ while IFS= read -r line; do
95
+ [ -n "$line" ] || continue
96
+ key="${line%%=*}"
97
+ val="${line#*=}"
98
+ case "$key" in
99
+ WATCHER_PORT)
100
+ if [ "$from_pool" = true ] || [ -z "${WATCHER_PORT:-}" ]; then WATCHER_PORT="$val"; fi
101
+ ;;
102
+ IOS_SIMULATOR)
103
+ if [ "$from_pool" = true ] || [ -z "${IOS_SIMULATOR:-}" ]; then IOS_SIMULATOR="$val"; fi
104
+ ;;
105
+ SLOT_ID)
106
+ if [ "$from_pool" = true ] || [ -z "${RECIPE_SLOT_ID:-}" ]; then RECIPE_SLOT_ID="$val"; fi
107
+ ;;
108
+ esac
109
+ done <<< "$resolved"
110
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ # tmux-session — resolve the run-owned tmux session a log-tail window belongs in.
3
+ #
4
+ # Responsibility split: the orchestrator (farmslot) NAMES the session; the harness
5
+ # only POPULATES windows inside it. The name is therefore never guessed from the
6
+ # slot number — a renamed pool or a manual run would land the window (and its
7
+ # tail -F) in the wrong place. Resolution ladder, first hit wins:
8
+ # 1. RECIPE_TMUX_SESSION — the orchestrator's explicit hand-off.
9
+ # 2. agentic-runtime.json `session` — the prepared checkout's recorded session.
10
+ # 3. the current session — but ONLY inside a tmux client (never the
11
+ # last-attached session reported outside one, which would be a foreign leak).
12
+ # Prints the resolved name (empty when none). Callers still gate on has-session
13
+ # before creating a window.
14
+
15
+ # shellcheck disable=SC2329 # sourced by leaves and by contract tests.
16
+ resolve_run_tmux_session() {
17
+ local runtime_dir="${1:-}" ctx="" session=""
18
+ if [ -n "${RECIPE_TMUX_SESSION:-}" ]; then
19
+ printf '%s\n' "$RECIPE_TMUX_SESSION"
20
+ return 0
21
+ fi
22
+ ctx="${RECIPE_RUNTIME_CONTEXT:-}"
23
+ if [ -z "$ctx" ] && [ -n "$runtime_dir" ]; then ctx="$runtime_dir/agentic-runtime.json"; fi
24
+ if [ -n "$ctx" ] && [ -f "$ctx" ] && command -v node >/dev/null 2>&1; then
25
+ session="$(RUN_TMUX_CTX="$ctx" node -e 'try{const d=JSON.parse(require("fs").readFileSync(process.env.RUN_TMUX_CTX,"utf8"));const s=d&&d.session;if(s!==undefined&&s!==null&&s!=="")process.stdout.write(String(s));}catch{}' 2>/dev/null || true)"
26
+ if [ -n "$session" ]; then
27
+ printf '%s\n' "$session"
28
+ return 0
29
+ fi
30
+ fi
31
+ if [ -n "${TMUX:-}" ]; then
32
+ tmux display-message -p '#S' 2>/dev/null || true
33
+ fi
34
+ return 0
35
+ }
@@ -181,6 +181,9 @@ function distCheck(target) {
181
181
  }
182
182
  return { status: "fresh", distGitId: distShort, head: headShort };
183
183
  }
184
+ function isExtensionDistStale(target) {
185
+ return distCheck(path.resolve(target)).status === "stale";
186
+ }
184
187
  async function cdpCheck(target, cdpPort) {
185
188
  if (!cdpPort) return { status: "skipped" };
186
189
  try {
@@ -301,5 +304,6 @@ function buildAction(target, clean) {
301
304
  }
302
305
  export {
303
306
  decideExtensionReadiness,
307
+ isExtensionDistStale,
304
308
  recordReadinessBaseline
305
309
  };
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { recipeRuntimeDir } from "../../paths.js";
7
- import { resolveFarmslotPortsByRepo } from "../../../adapters/shared/resolve-farmslot-ports-core.mjs";
7
+ import { resolveSlotPortsByRepo } from "../../../adapters/shared/resolve-slot-ports-core.mjs";
8
8
  const RUNWAY_IOS_METADATA = {
9
9
  artifactName: "ios-app-main-dev-expo",
10
10
  workflow: "expo-dev-build.yml",
@@ -13,7 +13,7 @@ const RUNWAY_IOS_METADATA = {
13
13
  fallbackRepo: "MetaMask/metamask-mobile"
14
14
  };
15
15
  function resolvePoolIdentity(target) {
16
- const out = resolveFarmslotPortsByRepo(target);
16
+ const out = resolveSlotPortsByRepo(target);
17
17
  const id = {};
18
18
  if (!out) return id;
19
19
  for (const line of String(out).split("\n")) {
@@ -95,6 +95,7 @@ async function provisionRunwayMobile(target, options) {
95
95
  log(options, `runway: installing ${cache.artifact.appPath} on ${sim.name}`);
96
96
  bootSimulator(sim.udid ?? sim.name, options);
97
97
  execFileSync("xcrun", ["simctl", "install", sim.udid ?? sim.name, cache.artifact.appPath], { stdio: ["ignore", "ignore", "pipe"] });
98
+ preapproveDeepLinkScheme(sim.udid ?? sim.name, RUNWAY_IOS_METADATA.bundleId, options);
98
99
  const baselinePath = writeRunwayBaseline(resolvedTarget, slot, platform, resolved, cache.artifact, sim, false, options.runtimeDir);
99
100
  return {
100
101
  schemaVersion: 1,
@@ -412,6 +413,28 @@ function bootSimulator(device, options) {
412
413
  log(options, `runway: bootstatus wait for ${device} did not confirm; continuing to install`);
413
414
  }
414
415
  }
416
+ function preapproveDeepLinkScheme(device, bundleId, options) {
417
+ const scheme = process.env.IOS_DEV_CLIENT_SCHEME ?? "expo-metamask";
418
+ try {
419
+ execFileSync(
420
+ "xcrun",
421
+ [
422
+ "simctl",
423
+ "spawn",
424
+ device,
425
+ "defaults",
426
+ "write",
427
+ "com.apple.launchservices.schemeapproval",
428
+ `com.apple.CoreSimulator.CoreSimulatorBridge-->${scheme}`,
429
+ "-string",
430
+ bundleId
431
+ ],
432
+ { stdio: ["ignore", "ignore", "ignore"], timeout: 15e3 }
433
+ );
434
+ log(options, `runway: pre-approved deep-link scheme ${scheme} \u2192 ${bundleId}`);
435
+ } catch {
436
+ }
437
+ }
415
438
  function ensureSimulator(name, runtime, deviceType) {
416
439
  const existing = findSimulator(name);
417
440
  if (existing) return { name, udid: existing, created: false, runtime, deviceType };
@@ -4,11 +4,12 @@ import {
4
4
  realRepoPath,
5
5
  resolveDefaultExtensionPorts,
6
6
  resolveExtensionRuntimePorts,
7
+ resolveSlotPortsByRepo,
7
8
  resolveFarmslotPortsByRepo,
8
9
  resolveMobileRuntimeContext,
9
10
  resolveMobileRuntimePorts,
10
11
  resolveMobileSlotDefaults
11
- } from "../../adapters/shared/resolve-farmslot-ports-core.mjs";
12
+ } from "../../adapters/shared/resolve-slot-ports-core.mjs";
12
13
  export {
13
14
  formatKvLines,
14
15
  inferSlotSuffix,
@@ -18,5 +19,6 @@ export {
18
19
  resolveFarmslotPortsByRepo,
19
20
  resolveMobileRuntimeContext,
20
21
  resolveMobileRuntimePorts,
21
- resolveMobileSlotDefaults
22
+ resolveMobileSlotDefaults,
23
+ resolveSlotPortsByRepo
22
24
  };
@@ -5,10 +5,10 @@ import { readRuntimeContextField, resolveRuntimeContextPath } from "../harness.j
5
5
  import { recipeRuntimeDir } from "../paths.js";
6
6
  import {
7
7
  resolveDefaultExtensionPorts,
8
- resolveFarmslotPortsByRepo,
8
+ resolveSlotPortsByRepo,
9
9
  resolveMobileRuntimeContext,
10
10
  resolveMobileSlotDefaults
11
- } from "./resolve-farmslot-ports.js";
11
+ } from "./resolve-slot-ports.js";
12
12
  function applyKVLines(output, overwrite) {
13
13
  for (const line of output.split("\n")) {
14
14
  const m = /^([A-Z_]+)=(.+)$/u.exec(line.trim());
@@ -41,7 +41,7 @@ function applyKVLines(output, overwrite) {
41
41
  function resolveMobileSlotPorts(target) {
42
42
  const ctxOut = resolveMobileRuntimeContext(target);
43
43
  if (ctxOut?.trim()) applyKVLines(ctxOut, true);
44
- const poolOut = resolveFarmslotPortsByRepo(target);
44
+ const poolOut = resolveSlotPortsByRepo(target);
45
45
  if (poolOut?.trim()) applyKVLines(poolOut, false);
46
46
  const defOut = resolveMobileSlotDefaults(target);
47
47
  if (defOut?.trim()) applyKVLines(defOut, false);
@@ -58,14 +58,12 @@ function resolveExtensionSlotPorts(target) {
58
58
  process.env["WATCHER_PORT"] = dev;
59
59
  process.env["RECIPE_WATCHER_PORT"] = dev;
60
60
  }
61
- if (process.env["CDP_PORT"]) return;
62
- const poolOut = resolveFarmslotPortsByRepo(target);
63
- if (poolOut?.trim()) {
64
- applyKVLines(poolOut, true);
65
- return;
61
+ if (!process.env["CDP_PORT"] || !process.env["WATCHER_PORT"]) {
62
+ const poolOut = resolveSlotPortsByRepo(target);
63
+ if (poolOut?.trim()) applyKVLines(poolOut, false);
64
+ const defOut = resolveDefaultExtensionPorts(target);
65
+ if (defOut?.trim()) applyKVLines(defOut, false);
66
66
  }
67
- const defOut = resolveDefaultExtensionPorts(target);
68
- if (defOut?.trim()) applyKVLines(defOut, false);
69
67
  }
70
68
  function stopExtensionWatcher(target) {
71
69
  const runtimeAbs = path.join(target, recipeRuntimeDir());
@@ -15,7 +15,7 @@ const SPEC = {
15
15
  { name: "debug", aliases: ["devtools", "inspect"], desc: "Open DevTools UI", flags: ["--json", "--no-open"] },
16
16
  { name: "actions", desc: "List runnable recipe actions", flags: ["--json"] },
17
17
  { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir"] },
18
- { name: "run", desc: "Execute a proof recipe", args: ["recipe.json"] },
18
+ { name: "run", desc: "Execute a proof recipe", args: ["recipe.json"], flags: ["--list"] },
19
19
  { name: "interactive", aliases: ["menu"], desc: "Interactive command menu" },
20
20
  { name: "prepare", desc: "Install harness (+ optional validate)", flags: ["--target", "--runtime-dir", "--json"] },
21
21
  { name: "runtime-status", desc: "Structured runtime status JSON", flags: ["--json", "--target", "--cdp-port", "--runtime-dir"] }
@@ -14,6 +14,7 @@ import {
14
14
  runtimeOptionsFromCli,
15
15
  usageError
16
16
  } from "./parse-args.js";
17
+ import { handleListExecutables } from "./list-executables.js";
17
18
  import {
18
19
  emitHealViolation,
19
20
  executeWithHealBounds,
@@ -24,6 +25,10 @@ import {
24
25
  validateRecipeAdapterAware
25
26
  } from "./run-engine.js";
26
27
  async function handleCall(argv) {
28
+ if (argv.includes("--list")) {
29
+ const { options: options2 } = parseArgs(argv, "call");
30
+ return handleListExecutables("call", options2);
31
+ }
27
32
  if (argv.length > 0 && argv[0].startsWith("--")) {
28
33
  const message = "call requires <action> first: mm-harness call <action> [--arg k=v ...] [flags]";
29
34
  console.error(message);
@@ -44,7 +44,8 @@ async function handleDoctor({ options }) {
44
44
  } catch {
45
45
  }
46
46
  const orphanMetros = adapter === "mobile" ? detectOrphanMetros(target, process.env.WATCHER_PORT) : [];
47
- if (json) console.log(JSON.stringify({ ...result, runtime, orphanMetros }, null, 2));
47
+ const capture = captureHelperHealth();
48
+ if (json) console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture }, null, 2));
48
49
  else {
49
50
  const out = (style, text) => color(style, text, { stream: process.stdout });
50
51
  const stateStyle = (value, good) => value === good ? "ok" : "warn";
@@ -67,9 +68,28 @@ async function handleDoctor({ options }) {
67
68
  );
68
69
  console.log(` ${out("dim", "Next: mm-harness stop # reaps every bundler this checkout leaked")}`);
69
70
  }
71
+ if (capture) {
72
+ console.log(`${out("label", "capture:")} ${out(capture.status === "pass" ? "ok" : "warn", capture.status)} ${out("dim", "(capture-helper: screenshots + --record video)")}`);
73
+ if (capture.status !== "pass") {
74
+ if (capture.failing.length > 0) console.log(` ${out("dim", `failing: ${capture.failing.join(", ")}`)}`);
75
+ console.log(` ${out("dim", "Next: grant Screen Recording (System Settings \u2192 Privacy & Security \u2192 Screen Recording), or run: capture-helper doctor --open-permissions")}`);
76
+ }
77
+ }
70
78
  }
71
79
  return result.status === "pass" ? 0 : 1;
72
80
  }
81
+ function captureHelperHealth() {
82
+ if (process.platform !== "darwin") return null;
83
+ const bin = process.env.CAPTURE_HELPER_PATH || "capture-helper";
84
+ try {
85
+ const out = execFileSync(bin, ["doctor", "--json"], { encoding: "utf8", timeout: 1e4, stdio: ["ignore", "pipe", "ignore"] });
86
+ const parsed = JSON.parse(out);
87
+ const failing = (parsed.checks ?? []).filter((c) => c.required === true && c.ok === false).map((c) => c.name ?? c.id ?? "check");
88
+ return { status: parsed.ok === true && failing.length === 0 ? "pass" : "warn", failing };
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
73
93
  function detectOrphanMetros(target, livePort) {
74
94
  try {
75
95
  const script = path.join(runnerDir, "adapters/shared/reap-checkout-metros.sh");
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { runnerDir, walletFixturePath } from "../paths.js";
4
4
  import { getAdapterSurface } from "../adapters/surface.js";
5
- import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, str, targetOf, usageOut } from "./shared.js";
5
+ import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, spawnScriptStreaming, str, targetOf, usageOut } from "./shared.js";
6
6
  const FIXTURES_BOOLEANS = /* @__PURE__ */ new Set(["json"]);
7
7
  const RECOVERABLE_SETUP_WALLET_PATTERNS = [
8
8
  "CDP not reachable",
@@ -49,13 +49,15 @@ async function handleFixtures(argv, deps) {
49
49
  return exitCode;
50
50
  }
51
51
  const fixturePath = path.resolve(str(options, "fixture") ?? process.env.RECIPE_WALLET_FIXTURE ?? canonicalFixture);
52
+ process.stderr.write(`\u2192 fixtures set ${adapter} \u2014 connecting bridge + applying wallet fixture (can take ~30s)\u2026
53
+ `);
52
54
  let status;
53
55
  if (adapter === "mobile") {
54
56
  const setupWalletSh = path.join(runnerDir, "adapters/mobile/bridge-runtime/setup-wallet.sh");
55
57
  const previousAppRoot = process.env.APP_ROOT;
56
58
  process.env.APP_ROOT = target;
57
59
  try {
58
- let result = spawnScript(setupWalletSh, ["--fixture", fixturePath], target, json);
60
+ let result = await spawnScriptStreaming(setupWalletSh, ["--fixture", fixturePath], target);
59
61
  if (result.status !== 0 && isRecoverableSetupWalletFailure(result.output) && !process.env["RECIPE_SETUP_WALLET_RETRIED"]) {
60
62
  process.env["RECIPE_SETUP_WALLET_RETRIED"] = "1";
61
63
  if (!json) process.stderr.write(" setup-wallet: recoverable failure \u2014 restarting Metro and retrying\n Next: wait for relaunch, then wallet setup will retry automatically\n");
@@ -63,7 +65,7 @@ async function handleFixtures(argv, deps) {
63
65
  const platform = str(options, "platform") ?? process.env["MOBILE_PLATFORM"] ?? "ios";
64
66
  const relaunchResult = await prepareMobile(target, { platform, json, preflightMode: "auto", clearMetro: true });
65
67
  if (relaunchResult.status === 0) {
66
- result = spawnScript(setupWalletSh, ["--fixture", fixturePath], target, json);
68
+ result = await spawnScriptStreaming(setupWalletSh, ["--fixture", fixturePath], target);
67
69
  }
68
70
  }
69
71
  status = result.status === 0 ? "pass" : "fail";