@deeeed/metamask-harness 0.3.7 → 0.3.9

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.9 - 2026-07-04
4
+
5
+ ### Fixed
6
+ - **Readiness is presence-authoritative** — a no-baseline mtime-only stale verdict no longer re-runs `yarn setup` inside every launch (warning instead; fingerprint-verified drift still installs), the deps baseline is recorded after any successful setup, and the `ready` decision probes that the dev client is actually running on the target device before doing nothing (`launch` launches).
7
+ - **Slot-context-first resolution** — device/ports resolve from the checkout's `agentic-runtime.json` before pool/formula fallbacks, for mobile (simulator, Metro port) and extension (CDP + watcher ports, overwriting stale inherited env); naming formulas no longer hardcode pool prefixes.
8
+ - **UTF-8 locale for pod-triggering spawns** — CocoaPods no longer dies on multibyte podspecs under locale-less orchestrator environments.
9
+ - **Hermetic contract tests** — tmux/xcrun/adb are stubbed; the Metro log-window opens only in a run-owned session, never an unrelated attached one.
10
+
11
+ ### Added
12
+ - **`mm-harness stop`** — stop the port-scoped Metro this checkout owns and close its log-tail window; idempotent; teaching error on extension/core.
13
+ - **Extension launch streams live** with an upfront intent line (ports named); mobile and extension long operations are never silent.
14
+ - **Doctor reports live runtime state per platform** (deps currency, Metro on the slot's resolved port, dev-client presence) with adapter-scoped, colored runtime-context rows.
15
+ - **Colored grouped help with DEV OVERRIDE banner and SLOT line**; `mmdev-harness` completions; outcome-stating launch summaries with a `[dev]` marker.
16
+ - **docs/UX-PRINCIPLES.md** — the CLI interaction contract (human surface rules; `--json` envelope purity is exempt and inviolable).
17
+
18
+ ## Unreleased
19
+
20
+ ### Fixed
21
+ - **Mobile preflight no longer re-runs `yarn setup` on every launch** — the deps readiness decision escalated an mtime hint to `stale` and ran the FULL `yarn setup` (submodules + inpage bridge + jetify + pods) inside the launch phase on every invocation in orchestrated (farmslot) contexts. `depsCheck` reports `stale` for a no-baseline checkout whenever a manifest is newer than the install markers; the orchestrator's git phase refreshes tracked-file mtimes on every sync while its deps phase leaves `node_modules` untouched, so "manifest newer than markers" is normal and not proof of drift — yet it duplicated the orchestrator's deps work and turned a fast preflight into 15+ minutes (the behavior that also looked like a hang before 0.3.8's streaming). **Presence is now authoritative:** without a recorded baseline an mtime-only `stale` is trusted as `current` (with a one-line stderr warning), so a churned-but-present checkout launches with zero `yarn` in both tiers. Genuine drift is still caught by the recorded-baseline fingerprint. `mm-harness launch` (quick tier / no `--build`) also dropped `preflightMode` before deciding, so the fast contract never took effect; it is threaded through now.
22
+ - **Fast tier never installs; genuine gaps teach the orchestrator** — in the fast tier (quick launch / orchestrated), deps that genuinely need work (absent markers, an absent required package, a fingerprint drift, or Metro unable to resolve a module) return a teaching block naming the orchestrator deps/prepare phase (plus the standalone `yarn setup:expo` / `launch --build` remedies) instead of an implicit setup — deps are the orchestrator's contract. The standalone/full tier still installs.
23
+ - **Deps baseline recorded the instant a setup leaf succeeds** — `prepareMobile` records the deps fingerprint baseline immediately after any successful `yarn-setup`, unconditionally (previously only a later `record: true` re-decide did, gated on the original decision being `install` and on `RECIPE_UP_INSTALL_ATTEMPTED`). Genuine-drift detection is fingerprint-based and self-sustaining after one good install.
24
+ - **CocoaPods no longer crashes on non-UTF-8 podspecs under a locale-less env** — pod-triggering spawns (`yarn setup` / native `yarn start:*`) now pin a UTF-8 `LANG`/`LC_ALL` when the caller has none. A gateway/tmux parent env without a locale made Ruby parse podspecs as US-ASCII and die on the first multibyte byte (`Invalid react-native-mmkv.podspec: "\xE2" on US-ASCII`).
25
+ - **`launch` no longer reports a dead app as ready** — the mobile readiness decision treated a healthy Metro + successful bundle as `ready` without checking the target device, so a dev client that had crashed or was never opened produced `✓ ready` while nothing was running. The decision now probes the device (`xcrun simctl spawn … launchctl list` / `adb shell ps -A`, fail-closed) and returns `launch`/`app-not-running` (relaunch the client) unless the app is actually alive.
26
+ - **Metro log-tail window no longer leaks into an unrelated tmux session** — running `start-metro` outside a tmux client fell back to `tmux display-message` (the last-attached session, e.g. the user's own) and created its `metro-<port>` window — with a `tail -F` — there, outliving the run. The viewer now opens a window only in a session that belongs to this run (a named slot session that exists, or the current session when actually inside tmux) and otherwise skips it; Metro still runs detached to the log. Extracted to `adapters/mobile/lib/tmux-viewer.sh` so the rule is unit-tested; contract tests stub tmux hermetically so no suite run can create a real window.
27
+
28
+ ### Added
29
+ - **`mm-harness stop`** — stop the port-scoped Metro dev server this mobile checkout owns and close its log-tail window (idempotent; extension/core are slot-managed and get a teaching error). Slot-context-aware port resolution.
30
+ - **Slot-context device/port detection** — `resolve-farmslot-ports.sh` reads `agentic-runtime.json` (the mapping the orchestrator's prepare wrote into the checkout) first, so device/port resolution survives pool renames that break any naming formula.
31
+ - **CLI help surface** — grouped/colored `mm-harness` help, a `DEV OVERRIDE ACTIVE` banner when `MM_HARNESS_BIN` is set, and a `SLOT` line when the checkout is a prepared slot; `mmdev-harness` completion alias.
32
+
33
+ ### Changed
34
+ - **Deps install uses a scoped `setup:expo` variant when native isn't wanted** — `adapters/mobile/yarn-setup.sh` gains `--mode full|expo`. `expo` runs `yarn setup:expo --no-build-ios --no-build-android` (JS deps + prebuild, no native — native builds come from `open-device.sh`, never doubled); the full `yarn setup` runs only for a native build (`launch --build`). This is also the standalone Metro-only remedy taught by the fast-tier deps block.
35
+ - **`mm-harness runtime-decision --adapter mobile` accepts `--preflight-mode`** — surfaces the fast (presence, blocks on a genuine gap) vs full (installs) decision for inspection and callers.
36
+
37
+ ## 0.3.8 - 2026-07-04
38
+
39
+ ### Fixed
40
+ - **Mobile launch streams progress in `--json` mode** — mobile prepare leaves now run through `spawnScriptStreaming`, which tees the child's stdout+stderr to the parent's stderr live while stdout stays reserved for the JSON envelope. A multi-minute `mm-harness launch --json` is no longer silent (previously `spawnSync` buffered leaf output and suppressed it entirely under `--json`). Contract test asserts leaf progress reaches stderr, the envelope stays clean, and the streaming conversion preserves the exit-code contract.
41
+
3
42
  ## 0.3.7 - 2026-07-04
4
43
 
5
44
  ### Fixed
@@ -243,7 +243,14 @@ printf '%s\n' "$started_pid" > "$watch_pid_file"
243
243
  tmux_session="${RECIPE_TMUX_SESSION:-}"
244
244
  if [ -z "$tmux_session" ]; then
245
245
  slot="$(slot_number || true)"
246
- [ -z "$slot" ] || tmux_session="mme-$slot"
246
+ if [ -n "$slot" ] && tmux has-session -t "=mme-$slot" 2>/dev/null; then
247
+ tmux_session="mme-$slot"
248
+ fi
249
+ fi
250
+ # Fall back to the session this run is inside: renamed pools and manual runs in
251
+ # any tmux still get their watcher window; outside tmux there is nowhere to put one.
252
+ if [ -z "$tmux_session" ]; then
253
+ tmux_session="$(tmux display-message -p '#S' 2>/dev/null || true)"
247
254
  fi
248
255
  if [ -n "$tmux_session" ] && command -v tmux >/dev/null 2>&1 && tmux has-session -t "$tmux_session" 2>/dev/null; then
249
256
  tmux_window="webpack-${WATCHER_PORT:-default}"
@@ -18,6 +18,14 @@
18
18
  "inputs": "--target --port --clear; env WATCHER_PORT, METRO_PORT, MOBILE_METRO_REQUIRED_ENV, METRO_MAX_WORKERS",
19
19
  "outputs": "Metro PID file + log; exit 0 ready / 1 timeout / 2 bad args"
20
20
  },
21
+ {
22
+ "id": "mobile/stop-metro",
23
+ "entry": "adapters/mobile/stop-metro.sh",
24
+ "kind": "bash",
25
+ "purpose": "Stop the port-scoped Metro bundler this checkout owns and close its log-tail window (idempotent).",
26
+ "inputs": "--target --port; env WATCHER_PORT",
27
+ "outputs": "progress on stderr; exit 0 stopped-or-nothing / 1 stop failed / 2 bad args"
28
+ },
21
29
  {
22
30
  "id": "mobile/prewarm-bundle",
23
31
  "entry": "adapters/mobile/prewarm-bundle.sh",
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ # tmux-viewer — open a read-only tmux window tailing the Metro log, but ONLY in a
3
+ # session that belongs to this run. Sourced by start-metro.sh; kept separate so
4
+ # the session-resolution rule (the part that must never leak into a user session)
5
+ # is unit-testable in isolation.
6
+ #
7
+ # Contract: never create a window in "whatever session happens to be attached".
8
+ # Outside a tmux client `tmux display-message -p '#S'` returns the last-attached
9
+ # session (e.g. the user's own), which would land the window — and its `tail -F` —
10
+ # in an unrelated session that outlives the run. When no run-owned session can be
11
+ # resolved, skip the viewer entirely (Metro still runs detached to the log).
12
+ #
13
+ # Inputs (env/args): $1 = metro log path; PORT, LOG_DIR, and optional `slot` from
14
+ # the caller; TMUX indicates we are inside a tmux client.
15
+
16
+ # shellcheck disable=SC2329 # sourced by start-metro.sh; also sourced by tests.
17
+ start_viewer_window() {
18
+ local metro_log="$1"
19
+ command -v tmux >/dev/null 2>&1 || return 0
20
+ local window="metro-${PORT}"
21
+ local metro_tmux="$LOG_DIR/metro.tmux"
22
+ local session=""
23
+ # (1) the slot session, when one is named and actually exists. The mm-<slot>
24
+ # formula predates the mmdev rename; a session named otherwise simply falls
25
+ # through — has-session gates it, so a wrong name never creates a window.
26
+ if [ -n "${slot:-}" ] && tmux has-session -t "=mm-${slot}" 2>/dev/null; then
27
+ session="mm-${slot}"
28
+ # (2) the current session, but ONLY when we are actually inside a tmux client.
29
+ elif [ -n "${TMUX:-}" ]; then
30
+ session="$(tmux display-message -p '#S' 2>/dev/null || true)"
31
+ fi
32
+ # No run-owned session → skip the viewer; no orphaned tail-window is left behind.
33
+ { [ -n "$session" ] && tmux has-session -t "=$session" 2>/dev/null; } || return 0
34
+ tmux kill-window -t "${session}:${window}" >/dev/null 2>&1 || true
35
+ tmux new-window -d -t "$session" -n "$window" "exec tail -n +1 -F $(printf '%q' "$metro_log")"
36
+ printf '%s:%s\n' "$session" "$window" > "$metro_tmux"
37
+ printf 'Metro log viewer → tmux %s:%s\n' "$session" "$window" >&2
38
+ }
@@ -72,24 +72,10 @@ default_metro_workers() {
72
72
  fi
73
73
  }
74
74
 
75
- start_viewer_window() {
76
- local metro_log="$1"
77
- command -v tmux >/dev/null 2>&1 || return 0
78
- local window="metro-${PORT}"
79
- local metro_tmux="$LOG_DIR/metro.tmux"
80
- local session=""
81
- if [ -n "${slot:-}" ] && tmux has-session -t "=mm-${slot}" 2>/dev/null; then
82
- session="mm-${slot}"
83
- fi
84
- if [ -z "$session" ]; then
85
- session="$(tmux display-message -p '#S' 2>/dev/null || true)"
86
- fi
87
- [ -n "$session" ] || return 0
88
- tmux kill-window -t "${session}:${window}" >/dev/null 2>&1 || true
89
- tmux new-window -d -t "$session" -n "$window" "exec tail -n +1 -F $(printf '%q' "$metro_log")"
90
- printf '%s:%s\n' "$session" "$window" > "$metro_tmux"
91
- printf 'Metro log viewer → tmux %s:%s\n' "$session" "$window" >&2
92
- }
75
+ # Log-tail viewer window (session-safe): sourced so its run-owned-session rule is
76
+ # unit-testable and can never leak a window into an unrelated user session.
77
+ # shellcheck disable=SC1091
78
+ . "$SCRIPT_DIR/lib/tmux-viewer.sh"
93
79
 
94
80
  # --- main ---------------------------------------------------------------------
95
81
 
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # stop-metro.sh — stop the Metro dev server this checkout owns and close its
3
+ # log-tail window. Port-scoped like start-metro: only the listener on the
4
+ # resolved port is signalled, so concurrent slots on other ports are untouched.
5
+ # Idempotent: nothing running is a success, not an error.
6
+ set -euo pipefail
7
+
8
+ TARGET="."
9
+ PORT="${WATCHER_PORT:-}"
10
+ while [ $# -gt 0 ]; do
11
+ case "$1" in
12
+ --target) TARGET="$2"; shift 2 ;;
13
+ --port) PORT="$2"; shift 2 ;;
14
+ -h|--help)
15
+ printf 'Usage: stop-metro.sh [--target <dir>] [--port <port>]\n'
16
+ printf ' --target MetaMask Mobile checkout directory\n'
17
+ printf ' --port Metro port (default: WATCHER_PORT env, else the slot context, else 8081)\n'
18
+ exit 0
19
+ ;;
20
+ *) printf 'stop-metro: unknown arg: %s\n' "$1" >&2; exit 2 ;;
21
+ esac
22
+ done
23
+
24
+ TARGET="$(cd "$TARGET" && pwd)"
25
+
26
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27
+ # shellcheck disable=SC1091
28
+ . "$SCRIPT_DIR/../shared/harness-path.sh"
29
+ if ! command -v recipe_runtime_dir >/dev/null 2>&1; then
30
+ echo "stop-metro: shared lib adapters/shared/harness-path.sh not found; reinstall the runner." >&2
31
+ exit 1
32
+ fi
33
+
34
+ # No explicit port: resolve the checkout's own (slot context first, then the
35
+ # pool/formula fallbacks) so we stop THIS slot's Metro, not the 8081 default.
36
+ if [ -z "$PORT" ]; then
37
+ # shellcheck disable=SC1091
38
+ . "$SCRIPT_DIR/../shared/resolve-farmslot-ports.sh"
39
+ resolved_port="$(resolve_mobile_runtime_ports "$TARGET" 2>/dev/null | sed -n 's/^WATCHER_PORT=//p' | head -1 || true)"
40
+ PORT="${resolved_port:-8081}"
41
+ fi
42
+
43
+ LOG_DIR="$TARGET/$(recipe_runtime_dir)"
44
+ PID_FILE="$LOG_DIR/metro.pid"
45
+ TMUX_FILE="$LOG_DIR/metro.tmux"
46
+
47
+ # shellcheck disable=SC1091
48
+ . "$SCRIPT_DIR/lib/metro-listener.sh"
49
+
50
+ pids="$(metro_listener_pids 2>/dev/null || true)"
51
+ if [ -n "$pids" ]; then
52
+ stop_metro_listener || exit 1
53
+ printf 'Stopped Metro on port %s (pid %s)\n' "$PORT" "$pids" >&2
54
+ else
55
+ printf 'Metro not running on port %s — nothing to stop\n' "$PORT" >&2
56
+ fi
57
+ rm -f "$PID_FILE"
58
+
59
+ # Close the read-only log-tail window start-metro opened, if it is still there.
60
+ if [ -f "$TMUX_FILE" ] && command -v tmux >/dev/null 2>&1; then
61
+ win="$(cat "$TMUX_FILE" 2>/dev/null || true)"
62
+ if [ -n "$win" ] && tmux kill-window -t "$win" 2>/dev/null; then
63
+ printf 'Closed Metro log window %s\n' "$win" >&2
64
+ fi
65
+ fi
66
+ rm -f "$TMUX_FILE"
@@ -2,19 +2,31 @@
2
2
  # yarn-setup — install node_modules for MetaMask Mobile.
3
3
  #
4
4
  # Inputs: --target <metamask-mobile dir> (default $PWD)
5
+ # --mode full|expo (default full)
5
6
  # Outputs: yarn install output on stderr; exit 0 pass, 1 install failed, 2 bad args.
6
7
  #
7
8
  # Single device op: install node_modules. No other sequencing.
9
+ # full — `yarn setup` (JS deps + expo prebuild + native pods/jetify). Use only
10
+ # when a native build is wanted (launch --build / rebuild-native).
11
+ # expo — `yarn setup:expo --no-build-ios --no-build-android` (JS deps + prebuild,
12
+ # no native). The Metro-only remedy: native builds come from open-device.
8
13
  set -euo pipefail
9
14
 
10
15
  TARGET="$PWD"
16
+ MODE="full"
11
17
  while [ "$#" -gt 0 ]; do
12
18
  case "$1" in
13
19
  --target) TARGET="$2"; shift 2 ;;
14
- -h|--help) printf 'Usage: yarn-setup.sh [--target <dir>]\n'; exit 0 ;;
20
+ --mode) MODE="$2"; shift 2 ;;
21
+ -h|--help) printf 'Usage: yarn-setup.sh [--target <dir>] [--mode full|expo]\n'; exit 0 ;;
15
22
  *) printf 'yarn-setup: unknown arg: %s\n' "$1" >&2; exit 2 ;;
16
23
  esac
17
24
  done
18
25
 
19
26
  TARGET="$(cd "$TARGET" && pwd)"
20
- cd "$TARGET" && exec yarn setup
27
+ cd "$TARGET"
28
+ case "$MODE" in
29
+ full) exec yarn setup ;;
30
+ expo) exec yarn setup:expo --no-build-ios --no-build-android ;;
31
+ *) printf 'yarn-setup: unknown --mode: %s (want full|expo)\n' "$MODE" >&2; exit 2 ;;
32
+ esac
@@ -155,8 +155,29 @@ resolve_mobile_slot_defaults() {
155
155
  printf 'SLOT_ID=local-mobile-%s\n' "$n"
156
156
  }
157
157
 
158
+ # Slot context the orchestrator's prepare wrote INTO the checkout — the
159
+ # authoritative device/port mapping for this repo (survives pool renames that
160
+ # break any naming formula). Highest-priority detection source.
161
+ resolve_mobile_runtime_context() {
162
+ local repo="$1" ctx
163
+ ctx="$repo/${RECIPE_RUNTIME_DIR:-temp/recipe/runtime}/agentic-runtime.json"
164
+ [ -f "$ctx" ] || return 1
165
+ node -e '
166
+ try {
167
+ const c = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
168
+ if (!c.simulator && !c.metroPort) process.exit(1);
169
+ if (c.metroPort) console.log(`WATCHER_PORT=${c.metroPort}`);
170
+ if (c.simulator) console.log(`IOS_SIMULATOR=${c.simulator}`);
171
+ if (c.slotId) console.log(`SLOT_ID=${c.slotId}`);
172
+ } catch { process.exit(1); }
173
+ ' "$ctx" 2>/dev/null
174
+ }
175
+
158
176
  resolve_mobile_runtime_ports() {
159
177
  local repo="${1:-.}"
178
+ if resolve_mobile_runtime_context "$repo"; then
179
+ return 0
180
+ fi
160
181
  if resolve_farmslot_ports_by_repo "$repo"; then
161
182
  return 0
162
183
  fi
@@ -0,0 +1,61 @@
1
+ # UX principles — the mm-harness interaction contract
2
+
3
+ Every command surface follows these rules. They are review criteria, not aspirations:
4
+ a PR that adds a command or output path is checked against each one.
5
+
6
+ **Scope: the HUMAN surface.** The agent contract is exempt and inviolable: under
7
+ `--json`, stdout carries exactly one machine-readable envelope — no color, no
8
+ banners, no decorative lines, schema per CLI-SPEC.md. Progress/streaming exists
9
+ for agents too, but only ever on stderr. When a rule below conflicts with envelope
10
+ purity, the envelope wins.
11
+
12
+ ## 1. Context-aware by default
13
+ The CLI answers relative to the checkout it is standing in.
14
+ - Adapter is auto-detected everywhere (`--adapter` overrides; a bare command never
15
+ fails on a value detection can supply).
16
+ - The slot context the orchestrator wrote into the checkout
17
+ (`<runtime_dir>/agentic-runtime.json`) is the authoritative source for device,
18
+ ports, and slot identity — naming formulas are last-resort fallbacks and never
19
+ hardcode pool prefixes (they go stale on rename).
20
+ - Output is scoped to the platform: an extension-only field is noise on a mobile
21
+ slot; help, flag lists, and completion candidates shrink to what applies here.
22
+
23
+ ## 2. Never silent, never opaque
24
+ Long operations show intent immediately and progress continuously.
25
+ - First line within milliseconds: what is being done, where (device/port), and
26
+ where the full log lives.
27
+ - Leaf output streams live to stderr — `--json` reserves stdout for the envelope,
28
+ it never buys silence. Quiet stretches get heartbeats with elapsed time.
29
+ - Each dev server a command starts gets its tmux log-tail window at launch time
30
+ (window creation is launch-coupled; users re-create separately when they close one).
31
+
32
+ ## 3. Summaries state outcomes
33
+ The final line says what actually happened: target device, what the tier meant,
34
+ resulting state — never a bare status word. Dev-override runs are marked
35
+ (`[dev: MM_HARNESS_BIN]`) so it is always evident which bin served.
36
+
37
+ ## 4. Dev and prod are explicit
38
+ The global install is prod (`mm-harness`); a dev checkout runs via the documented
39
+ `MM_HARNESS_BIN` switch (conventionally aliased `mmdev-harness`). The dev override
40
+ announces itself in help and summaries. Completions cover both names.
41
+
42
+ ## 5. Errors teach the exact escape
43
+ Every failure states the next command for the caller's actual situation. A wrong
44
+ hint (an escape for a different failure mode) is a bug of the same severity as
45
+ the failure itself.
46
+
47
+ ## 6. Lifecycle verbs are symmetric and idempotent
48
+ What a command starts, a sibling verb stops (`launch`/`stop`), scoped to what this
49
+ checkout owns (port-scoped, session-scoped) — never "whatever is running".
50
+ Nothing-to-do is success. Tests never leak into the operator's real tmux.
51
+
52
+ ## 7. Color is consistent and optional
53
+ All human output styles through `cli-color.ts` (headers, ok/warn/err states,
54
+ provenance, "Next:" hints); `NO_COLOR`/non-TTY degrade to plain text. No surface
55
+ invents its own ANSI.
56
+
57
+ ## 8. Doctor is the status surface
58
+ `doctor` reports, per platform: live runtime state (deps currency, dev-server
59
+ up/stale on the slot's resolved port, app/browser presence), the scoped runtime
60
+ context with provenance (`[file]`/`[env]`/`[default]`), and the exact customize
61
+ hint per row. Read-only unless `--fix`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -17,8 +17,13 @@
17
17
  "@farmslot/protocol": "^0.7.2",
18
18
  "@farmslot/recipe-harness": "^0.3.3",
19
19
  "commander": "^12.0.0",
20
- "tsx": "^4.19.0",
21
- "viem": "^2.52.2"
20
+ "tsx": "^4.23.0",
21
+ "viem": "^2.54.3"
22
+ },
23
+ "resolutions": {
24
+ "esbuild": "0.28.1",
25
+ "undici": "6.27.0",
26
+ "ws": "8.21.0"
22
27
  },
23
28
  "devDependencies": {
24
29
  "@types/node": "^22.0.0",
@@ -29,12 +29,13 @@ _mmh_bin() {
29
29
  return 1
30
30
  }
31
31
 
32
- _mmh_commands="launch logs debug fixtures actions call flows run doctor install verify cleanup completions"
32
+ _mmh_commands="launch stop logs debug fixtures actions call flows run doctor install verify cleanup completions"
33
33
 
34
34
  # Per-command flags (static, from the mm-harness surface).
35
35
  _mmh_flags_for() {
36
36
  case "$1" in
37
37
  launch) printf '%s' "--build --watch --verify --sidepanel --fullscreen --runway --device --cdp-port --watcher-port --heal --adapter --target --json" ;;
38
+ stop) printf '%s' "--port --target --json" ;;
38
39
  logs) printf '%s' "--full --events --source --adapter --target --json" ;;
39
40
  debug) printf '%s' "--worker --dev-menu --adapter --target --json" ;;
40
41
  fixtures) printf '%s' "--fixture --adapter --target --json" ;;
@@ -83,7 +84,9 @@ if [ -n "${BASH_VERSION:-}" ]; then
83
84
  *) COMPREPLY=($(compgen -W "$(_mmh_flags_for "$cmd")" -- "$cur")) ;;
84
85
  esac
85
86
  }
87
+ # mmdev-harness is the documented dev-checkout alias; complete it identically.
86
88
  complete -F _mmh_complete_bash mm-harness 2>/dev/null || true
89
+ complete -F _mmh_complete_bash mmdev-harness 2>/dev/null || true
87
90
  fi
88
91
 
89
92
  # ── Zsh ─────────────────────────────────────────────────────────────────
@@ -117,9 +120,9 @@ if [ -n "${ZSH_VERSION:-}" ]; then
117
120
  }
118
121
 
119
122
  if (( $+functions[compdef] )); then
120
- compdef _mmh_complete_zsh mm-harness
123
+ compdef _mmh_complete_zsh mm-harness mmdev-harness
121
124
  else
122
125
  autoload -Uz compinit 2>/dev/null && compinit -u 2>/dev/null
123
- (( $+functions[compdef] )) && compdef _mmh_complete_zsh mm-harness
126
+ (( $+functions[compdef] )) && compdef _mmh_complete_zsh mm-harness mmdev-harness
124
127
  fi
125
128
  fi
@@ -7,7 +7,9 @@
7
7
 
8
8
  import path from 'node:path';
9
9
 
10
- import { EXIT, spawnScript } from '../../commands/shared.ts';
10
+ import { recordDepsBaseline } from '@farmslot/recipe-harness/runtime/deps-readiness';
11
+
12
+ import { EXIT, spawnScriptStreaming } from '../../commands/shared.ts';
11
13
  import type { ScriptResult } from '../../commands/shared.ts';
12
14
  import { runnerDir } from '../../paths.ts';
13
15
  import {
@@ -24,10 +26,23 @@ export { type MobileRuntimeDecisionReport };
24
26
  // An inherited FORCE_COLOR makes node emit a colorized `undefined`, so VisionCamera
25
27
  // misdetects the worklets pod, enables FrameProcessors, and fails on the missing
26
28
  // pod. Force plain output for pod-triggering spawns so the probe reads `undefined`.
27
- const POD_PROBE_ENV: Record<string, string> = { FORCE_COLOR: '0', NO_COLOR: '1' };
29
+ // CocoaPods reads UTF-8 podspecs; a parent env without a locale (gateway/tmux
30
+ // spawns) makes Ruby parse them as US-ASCII and die on the first multibyte byte
31
+ // ('"\xE2" on US-ASCII' in react-native-mmkv.podspec). Pin a UTF-8 locale for
32
+ // pod-triggering spawns when the caller has none.
33
+ const POD_PROBE_ENV: Record<string, string> = {
34
+ FORCE_COLOR: '0',
35
+ NO_COLOR: '1',
36
+ LANG: process.env.LANG?.includes('UTF-8') ? process.env.LANG : 'en_US.UTF-8',
37
+ LC_ALL: process.env.LC_ALL?.includes('UTF-8') ? process.env.LC_ALL : 'en_US.UTF-8',
38
+ };
28
39
 
29
40
  export interface PrepareMobileOptions extends MobileRuntimeDecisionOptions {
30
- /** Pass through to leaf-script spawnScript calls so output is suppressed in --json mode. */
41
+ /**
42
+ * Whether the caller emits a --json envelope on stdout. Leaf progress always
43
+ * streams to stderr (see dispatchAction/spawnScriptStreaming); this only gates
44
+ * the in-process teaching messages so they never pollute the stdout envelope.
45
+ */
31
46
  json?: boolean;
32
47
  /**
33
48
  * Passed to open-device.sh --preflight-mode.
@@ -55,6 +70,7 @@ export async function mobileRuntimeStatus(
55
70
  metroLog: opts.metroLog,
56
71
  platform: opts.platform,
57
72
  record: opts.record,
73
+ preflightMode: opts.preflightMode,
58
74
  });
59
75
  }
60
76
 
@@ -77,11 +93,13 @@ export async function prepareMobile(
77
93
  metroLog: opts.metroLog,
78
94
  platform,
79
95
  record: opts.record,
96
+ preflightMode,
80
97
  });
81
98
 
82
99
  if (report.decision === 'blocked') {
83
100
  const reasons = report.reasons.join(' ');
84
- const msg = `mobile prepare blocked: ${reasons}\n Next: fix the bundle error in app code before retrying recipe up.`;
101
+ const next = report.userAction ?? 'fix the bundle error in app code before retrying recipe up.';
102
+ const msg = `mobile prepare blocked: ${reasons}\n Next: ${next}`;
85
103
  if (!json) process.stderr.write(`${msg}\n`);
86
104
  return { status: EXIT.runtime, output: msg };
87
105
  }
@@ -106,8 +124,13 @@ export async function prepareMobile(
106
124
  )
107
125
  : report.actions;
108
126
  for (const action of actions) {
109
- const result = dispatchAction(action, target, platform, json, preflightMode);
127
+ const result = await dispatchAction(action, target, platform, json, preflightMode);
110
128
  if (result.status !== 0) return result;
129
+ // Record the deps baseline the instant a setup/install leaf succeeds, so the
130
+ // mtime freshness fallback stops firing forever after one good install. This
131
+ // is unconditional (not gated on a later re-decide or an env flag), so the
132
+ // baseline lifecycle is self-sustaining regardless of the caller's path.
133
+ if (action.id === 'yarn-setup') recordDepsBaseline(path.resolve(target));
111
134
  }
112
135
 
113
136
  // After install: re-decide and run appropriate actions.
@@ -118,7 +141,7 @@ export async function prepareMobile(
118
141
  watcherPort: opts.watcherPort,
119
142
  metroLog: opts.metroLog,
120
143
  platform,
121
- record: true,
144
+ preflightMode,
122
145
  });
123
146
  switch (postInstall.decision) {
124
147
  case 'install': {
@@ -132,13 +155,13 @@ export async function prepareMobile(
132
155
  }
133
156
  case 'ready': {
134
157
  // Deps resolved and runtime appears ready — still confirm bridge is live.
135
- const bridge = dispatchAction({ id: 'wait-for-bridge', cwd: target }, target, platform, json, preflightMode);
158
+ const bridge = await dispatchAction({ id: 'wait-for-bridge', cwd: target }, target, platform, json, preflightMode);
136
159
  if (bridge.status !== 0) return bridge;
137
160
  break;
138
161
  }
139
162
  case 'launch': {
140
163
  for (const action of postInstall.actions) {
141
- const result = dispatchAction(action, target, platform, json, preflightMode);
164
+ const result = await dispatchAction(action, target, platform, json, preflightMode);
142
165
  if (result.status !== 0) return result;
143
166
  }
144
167
  break;
@@ -157,51 +180,53 @@ export async function prepareMobile(
157
180
  return { status: 0, output: '' };
158
181
  }
159
182
 
160
- function dispatchAction(
183
+ // Long-running leaves stream their output to stderr live (spawnScriptStreaming),
184
+ // so the multi-minute prepare phase is not silent; stdout stays reserved for the
185
+ // caller's --json envelope. Async so the parent can await each leaf in sequence.
186
+ async function dispatchAction(
161
187
  action: { id: string; argv?: string[]; cwd?: string },
162
188
  target: string,
163
189
  platform: string,
164
190
  json: boolean,
165
191
  preflightMode = 'fast',
166
- ): ScriptResult {
192
+ ): Promise<ScriptResult> {
167
193
  const cwd = action.cwd ?? target;
168
194
  switch (action.id) {
169
195
  case 'yarn-setup': {
170
196
  // Leaf: install node_modules (deps missing or stale); `yarn setup` runs pods.
171
197
  const leaf = path.join(runnerDir, 'adapters/mobile/yarn-setup.sh');
172
- return spawnScript(leaf, ['--target', cwd], target, json, POD_PROBE_ENV);
198
+ return spawnScriptStreaming(leaf, ['--target', cwd], target, POD_PROBE_ENV);
173
199
  }
174
200
  case 'start-metro': {
175
201
  // Leaf: ensure Metro is running; argv may carry --clear for cache reset.
176
202
  const leaf = path.join(runnerDir, 'adapters/mobile/start-metro.sh');
177
203
  const extra: string[] = action.argv ?? [];
178
- return spawnScript(leaf, ['--target', cwd, ...extra], target, json);
204
+ return spawnScriptStreaming(leaf, ['--target', cwd, ...extra], target);
179
205
  }
180
206
  case 'prewarm-bundle': {
181
207
  // Leaf: curl the bundle URL before opening the dev client.
182
208
  const leaf = path.join(runnerDir, 'adapters/mobile/prewarm-bundle.sh');
183
- return spawnScript(leaf, ['--platform', platform, '--target', cwd], target, json);
209
+ return spawnScriptStreaming(leaf, ['--platform', platform, '--target', cwd], target);
184
210
  }
185
211
  case 'wait-for-bridge': {
186
212
  // Leaf: poll CDP bridge until a route target registers.
187
213
  const leaf = path.join(runnerDir, 'adapters/mobile/wait-for-bridge.sh');
188
- return spawnScript(leaf, ['--target', cwd], target, json);
214
+ return spawnScriptStreaming(leaf, ['--target', cwd], target);
189
215
  }
190
216
  case 'clear-metro-cache': {
191
217
  // Legacy alias for start-metro --clear (kept for backward compat).
192
218
  const leaf = path.join(runnerDir, 'adapters/mobile/start-metro.sh');
193
- return spawnScript(leaf, ['--target', cwd, '--clear'], target, json);
219
+ return spawnScriptStreaming(leaf, ['--target', cwd, '--clear'], target);
194
220
  }
195
221
  case 'launch-mobile-runtime': {
196
222
  // Leaf: open the MetaMask Mobile dev client on the simulator/device. In
197
223
  // auto/rebuild modes this runs the native build (`yarn start:*`), which
198
224
  // triggers pod install — pass the pod-probe env so it stays FORCE_COLOR-safe.
199
225
  const leaf = path.join(runnerDir, 'adapters/mobile/open-device.sh');
200
- return spawnScript(
226
+ return spawnScriptStreaming(
201
227
  leaf,
202
228
  ['--platform', platform, '--target', cwd, '--preflight-mode', preflightMode],
203
229
  target,
204
- json,
205
230
  POD_PROBE_ENV,
206
231
  );
207
232
  }