@deeeed/metamask-harness 0.3.8 → 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 +35 -11
- package/adapters/extension/start-watch.sh +8 -1
- package/adapters/manifest.json +8 -0
- package/adapters/mobile/lib/tmux-viewer.sh +38 -0
- package/adapters/mobile/start-metro.sh +4 -18
- package/adapters/mobile/stop-metro.sh +66 -0
- package/adapters/mobile/yarn-setup.sh +14 -2
- package/adapters/shared/resolve-farmslot-ports.sh +21 -0
- package/docs/UX-PRINCIPLES.md +61 -0
- package/package.json +8 -3
- package/scripts/completions.sh +6 -3
- package/src/adapters/mobile/prepare.ts +22 -3
- package/src/adapters/mobile/runtime-decision.ts +103 -42
- package/src/cli.ts +77 -4
- package/src/commands/launch.ts +87 -11
- package/src/doctor.ts +25 -13
- package/src/mm-harness-cli.ts +59 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,19 +1,43 @@
|
|
|
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
|
+
|
|
3
37
|
## 0.3.8 - 2026-07-04
|
|
4
38
|
|
|
5
39
|
### Fixed
|
|
6
|
-
- **Mobile launch streams progress in
|
|
7
|
-
"schemaVersion": 1,
|
|
8
|
-
"command": "launch",
|
|
9
|
-
"status": "fail",
|
|
10
|
-
"exitCode": 2,
|
|
11
|
-
"error": {
|
|
12
|
-
"code": "USAGE",
|
|
13
|
-
"message": "could not detect the MetaMask repo type for /private/tmp/claude-501/-Users-deeeed-dev-metamask-principal-pitch-2025/4645d206-3e7f-482d-a5c1-2274f26a7411/scratchpad/emh-review",
|
|
14
|
-
"userAction": "cd into a MetaMask checkout or pass --target <path>, or force it with --adapter <mobile|extension|core>"
|
|
15
|
-
}
|
|
16
|
-
} is no longer silent (previously buffered leaf output and suppressed it entirely under ). Contract test asserts leaf progress reaches stderr, the envelope stays clean, and the streaming conversion preserves the exit-code contract.
|
|
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.
|
|
17
41
|
|
|
18
42
|
## 0.3.7 - 2026-07-04
|
|
19
43
|
|
|
@@ -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
|
-
[ -
|
|
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}"
|
package/adapters/manifest.json
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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"
|
|
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.
|
|
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.
|
|
21
|
-
"viem": "^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",
|
package/scripts/completions.sh
CHANGED
|
@@ -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,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
|
|
10
|
+
import { recordDepsBaseline } from '@farmslot/recipe-harness/runtime/deps-readiness';
|
|
11
|
+
|
|
10
12
|
import { EXIT, spawnScriptStreaming } from '../../commands/shared.ts';
|
|
11
13
|
import type { ScriptResult } from '../../commands/shared.ts';
|
|
12
14
|
import { runnerDir } from '../../paths.ts';
|
|
@@ -24,7 +26,16 @@ 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
|
-
|
|
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
41
|
/**
|
|
@@ -59,6 +70,7 @@ export async function mobileRuntimeStatus(
|
|
|
59
70
|
metroLog: opts.metroLog,
|
|
60
71
|
platform: opts.platform,
|
|
61
72
|
record: opts.record,
|
|
73
|
+
preflightMode: opts.preflightMode,
|
|
62
74
|
});
|
|
63
75
|
}
|
|
64
76
|
|
|
@@ -81,11 +93,13 @@ export async function prepareMobile(
|
|
|
81
93
|
metroLog: opts.metroLog,
|
|
82
94
|
platform,
|
|
83
95
|
record: opts.record,
|
|
96
|
+
preflightMode,
|
|
84
97
|
});
|
|
85
98
|
|
|
86
99
|
if (report.decision === 'blocked') {
|
|
87
100
|
const reasons = report.reasons.join(' ');
|
|
88
|
-
const
|
|
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}`;
|
|
89
103
|
if (!json) process.stderr.write(`${msg}\n`);
|
|
90
104
|
return { status: EXIT.runtime, output: msg };
|
|
91
105
|
}
|
|
@@ -112,6 +126,11 @@ export async function prepareMobile(
|
|
|
112
126
|
for (const action of actions) {
|
|
113
127
|
const result = await dispatchAction(action, target, platform, json, preflightMode);
|
|
114
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));
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
// After install: re-decide and run appropriate actions.
|
|
@@ -122,7 +141,7 @@ export async function prepareMobile(
|
|
|
122
141
|
watcherPort: opts.watcherPort,
|
|
123
142
|
metroLog: opts.metroLog,
|
|
124
143
|
platform,
|
|
125
|
-
|
|
144
|
+
preflightMode,
|
|
126
145
|
});
|
|
127
146
|
switch (postInstall.decision) {
|
|
128
147
|
case 'install': {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
|
|
@@ -43,6 +44,9 @@ export interface MobileRuntimeDecisionReport {
|
|
|
43
44
|
decision: MobileReadinessDecision;
|
|
44
45
|
reasonCode: string;
|
|
45
46
|
reasons: string[];
|
|
47
|
+
// A single teaching escape for the caller's actual situation, set on decisions
|
|
48
|
+
// the runner deliberately refuses to auto-resolve (e.g. fast-mode deps gaps).
|
|
49
|
+
userAction?: string;
|
|
46
50
|
checks: {
|
|
47
51
|
deps: DepsCheck;
|
|
48
52
|
metroLog: MetroLogCheck;
|
|
@@ -56,6 +60,9 @@ export interface MobileRuntimeDecisionOptions {
|
|
|
56
60
|
metroLog?: string;
|
|
57
61
|
platform?: string;
|
|
58
62
|
record?: boolean;
|
|
63
|
+
// 'fast' (quick launch / no --build) makes deps readiness a presence check only:
|
|
64
|
+
// freshness is the orchestrator's deps-phase contract, never re-decided here.
|
|
65
|
+
preflightMode?: string;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
const BUNDLE_ERR = /Bundling failed|Unable to resolve /u;
|
|
@@ -128,38 +135,38 @@ function missingRequiredDeps(target: string): string[] {
|
|
|
128
135
|
}
|
|
129
136
|
}
|
|
130
137
|
|
|
131
|
-
// Returns true when any manifest input (package.json or yarn.lock) is newer than
|
|
132
|
-
// the install markers written by yarn on a successful install. A reliable signal
|
|
133
|
-
// that deps changed since the last install, even when depsCheck found no recorded
|
|
134
|
-
// baseline drift (e.g. git checkout preserved an old author-date mtime on the
|
|
135
|
-
// manifest, then a new dep was committed, making the manifest newer than markers).
|
|
136
|
-
function depsStaleByMtime(target: string): boolean {
|
|
137
|
-
// Use the same install markers as depsCheck: the files yarn writes on install.
|
|
138
|
-
const INSTALL_MARKERS = ['node_modules/.yarn-state.yml', '.yarn/install-state.gz'];
|
|
139
|
-
let markerMtime = 0;
|
|
140
|
-
for (const rel of INSTALL_MARKERS) {
|
|
141
|
-
try {
|
|
142
|
-
const mt = fs.statSync(path.join(target, rel)).mtimeMs;
|
|
143
|
-
if (mt > markerMtime) markerMtime = mt;
|
|
144
|
-
} catch {
|
|
145
|
-
// marker absent; keep searching
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
if (markerMtime === 0) return false; // no markers → depsCheck already handles via 'missing'
|
|
149
|
-
for (const file of ['package.json', 'yarn.lock']) {
|
|
150
|
-
try {
|
|
151
|
-
if (fs.statSync(path.join(target, file)).mtimeMs > markerMtime) return true;
|
|
152
|
-
} catch {
|
|
153
|
-
// manifest absent; skip
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
return false;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
138
|
const installActions = (target: string): MobileRuntimeDecisionAction[] => [
|
|
160
139
|
{ id: 'yarn-setup', argv: ['yarn', 'setup'], cwd: target },
|
|
161
140
|
];
|
|
162
141
|
|
|
142
|
+
// True when a MetaMask dev client process is alive on the target device
|
|
143
|
+
// (IOS_SIMULATOR / ADB_SERIAL, falling back to the booted simulator). Probe
|
|
144
|
+
// failures count as not-running: the worst case is an idempotent relaunch,
|
|
145
|
+
// while failing open would declare a dead app "ready".
|
|
146
|
+
function appRunningOnDevice(platform?: string): boolean {
|
|
147
|
+
try {
|
|
148
|
+
if (platform === 'android') {
|
|
149
|
+
const serial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL;
|
|
150
|
+
const args = serial ? ['-s', serial] : [];
|
|
151
|
+
const out = execFileSync('adb', [...args, 'shell', 'ps', '-A'], {
|
|
152
|
+
encoding: 'utf8',
|
|
153
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
154
|
+
timeout: 5_000,
|
|
155
|
+
});
|
|
156
|
+
return out.includes('io.metamask');
|
|
157
|
+
}
|
|
158
|
+
const device = process.env.IOS_SIMULATOR || 'booted';
|
|
159
|
+
const out = execFileSync('xcrun', ['simctl', 'spawn', device, 'launchctl', 'list'], {
|
|
160
|
+
encoding: 'utf8',
|
|
161
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
162
|
+
timeout: 5_000,
|
|
163
|
+
});
|
|
164
|
+
return out.toLowerCase().includes('io.metamask');
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
163
170
|
const launchActions = (target: string, clearMetro = false): MobileRuntimeDecisionAction[] => {
|
|
164
171
|
const actions: MobileRuntimeDecisionAction[] = [];
|
|
165
172
|
// start-metro ensures Metro is running; --clear resets the bundle cache.
|
|
@@ -184,22 +191,60 @@ export async function decideMobileReadiness(
|
|
|
184
191
|
const resolved = path.resolve(target);
|
|
185
192
|
if (options.record) recordDepsBaseline(resolved);
|
|
186
193
|
|
|
194
|
+
const fast = options.preflightMode === 'fast';
|
|
195
|
+
const report = await computeMobileReadiness(resolved, options, fast);
|
|
196
|
+
|
|
197
|
+
// mtime churn is already neutralized above, so a fast-mode 'install' verdict
|
|
198
|
+
// means deps genuinely need work (absent markers, an absent required package, a
|
|
199
|
+
// fingerprint-baseline drift, or Metro unable to resolve a module). In an
|
|
200
|
+
// orchestrated run the deps phase owns installation; having the runner install
|
|
201
|
+
// mid-launch is inverted authority. Surface a teaching block, not an implicit
|
|
202
|
+
// setup, so a runway preflight never re-does the orchestrator's deps work.
|
|
203
|
+
if (fast && report.decision === 'install') {
|
|
204
|
+
return {
|
|
205
|
+
...report,
|
|
206
|
+
decision: 'blocked',
|
|
207
|
+
reasonCode: 'deps-not-ready',
|
|
208
|
+
reasons: [
|
|
209
|
+
'Fast preflight found dependencies not ready and does not install them (the orchestrator deps phase owns installation).',
|
|
210
|
+
...report.reasons,
|
|
211
|
+
],
|
|
212
|
+
userAction:
|
|
213
|
+
'run the slot deps/prepare phase; standalone: `yarn setup:expo --no-build-ios --no-build-android` in the checkout, or `mm-harness launch <platform> --build` to install and build',
|
|
214
|
+
actions: [],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return report;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function computeMobileReadiness(
|
|
221
|
+
resolved: string,
|
|
222
|
+
options: MobileRuntimeDecisionOptions,
|
|
223
|
+
fast: boolean,
|
|
224
|
+
): Promise<MobileRuntimeDecisionReport> {
|
|
187
225
|
const rawDeps = depsCheck(resolved, {
|
|
188
226
|
productMarkers: mobileProductMarkers(options.platform),
|
|
189
227
|
});
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
228
|
+
// Presence is authoritative; mtime is only a hint. Without a recorded baseline
|
|
229
|
+
// the only way depsCheck reports 'stale' is mtime drift (a manifest newer than
|
|
230
|
+
// the install markers). In a managed checkout the orchestrator's git phase
|
|
231
|
+
// refreshes tracked-file mtimes on every sync while its deps phase leaves
|
|
232
|
+
// node_modules untouched, so "manifest newer than markers" is normal and NOT
|
|
233
|
+
// proof of drift — treating it as stale re-ran the full yarn setup inside every
|
|
234
|
+
// launch. Trust the installed node_modules and warn; genuine drift is caught by
|
|
235
|
+
// the recorded-baseline fingerprint when one exists (hasBaseline=true stays 'stale').
|
|
236
|
+
let deps: DepsCheck = rawDeps;
|
|
237
|
+
if (rawDeps.status === 'stale' && !rawDeps.hasBaseline) {
|
|
238
|
+
deps = { installed: rawDeps.installed, status: 'current', hasBaseline: false };
|
|
239
|
+
process.stderr.write(
|
|
240
|
+
'[runtime-decision] manifest is newer than install markers (no recorded baseline); ' +
|
|
241
|
+
'trusting installed node_modules — run yarn setup manually if deps truly changed.\n',
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
// Required-dep absence is a presence check (both modes): a top-level dependency
|
|
245
|
+
// can be absent from node_modules even when markers + fingerprint report
|
|
246
|
+
// 'current' (e.g. added in a branch merge but install not re-run). Catch at
|
|
247
|
+
// pre-flight before Metro discovers it during a long bundle run.
|
|
203
248
|
if (deps.status === 'current') {
|
|
204
249
|
const absent = missingRequiredDeps(resolved);
|
|
205
250
|
if (absent.length > 0) {
|
|
@@ -373,13 +418,29 @@ export async function decideMobileReadiness(
|
|
|
373
418
|
}
|
|
374
419
|
|
|
375
420
|
if (metro.status === 'up' && metroLog.status === 'ok') {
|
|
421
|
+
// Metro health alone is not runtime readiness: the dev client may be
|
|
422
|
+
// installed but not running on the target device (launch must launch).
|
|
423
|
+
// Probe the device before declaring ready; a healthy Metro with a dead
|
|
424
|
+
// app relaunches the client without touching Metro.
|
|
425
|
+
if (!appRunningOnDevice(options.platform)) {
|
|
426
|
+
return {
|
|
427
|
+
schemaVersion: 1,
|
|
428
|
+
adapter: 'mobile',
|
|
429
|
+
target: resolved,
|
|
430
|
+
decision: 'launch',
|
|
431
|
+
reasonCode: 'app-not-running',
|
|
432
|
+
reasons: ['Metro is healthy but the dev client is not running on the target device; launching it.'],
|
|
433
|
+
checks,
|
|
434
|
+
actions: launchActions(resolved),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
376
437
|
return {
|
|
377
438
|
schemaVersion: 1,
|
|
378
439
|
adapter: 'mobile',
|
|
379
440
|
target: resolved,
|
|
380
441
|
decision: 'ready',
|
|
381
442
|
reasonCode: 'healthy',
|
|
382
|
-
reasons: ['Dependencies are current
|
|
443
|
+
reasons: ['Dependencies are current, Metro reports a successful bundle, and the dev client is running. Verify bridge before replay.'],
|
|
383
444
|
checks,
|
|
384
445
|
actions: [],
|
|
385
446
|
};
|
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
+
import { color } from './cli-color.ts';
|
|
7
8
|
import { createDoctorReport, renderRuntimeContext } from './doctor.ts';
|
|
8
9
|
import {
|
|
9
10
|
invalidateCompletionCache,
|
|
@@ -420,7 +421,7 @@ async function runSelfTest(options: CliOptions) {
|
|
|
420
421
|
}
|
|
421
422
|
|
|
422
423
|
async function handleManifest({ options }: ParsedArgs): Promise<number> {
|
|
423
|
-
const adapter =
|
|
424
|
+
const { adapter } = resolveAdapter(options);
|
|
424
425
|
const actionManifestPath = actionManifestPathOption(options, adapter);
|
|
425
426
|
const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
|
|
426
427
|
await validateManifest(manifest);
|
|
@@ -430,7 +431,7 @@ async function handleManifest({ options }: ParsedArgs): Promise<number> {
|
|
|
430
431
|
}
|
|
431
432
|
|
|
432
433
|
async function handleActions({ options }: ParsedArgs): Promise<number> {
|
|
433
|
-
const adapter =
|
|
434
|
+
const { adapter } = resolveAdapter(options);
|
|
434
435
|
const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
|
|
435
436
|
await validateManifest(manifest);
|
|
436
437
|
const action = optionString(options, 'action');
|
|
@@ -475,9 +476,38 @@ async function handleDoctor({ options }: ParsedArgs): Promise<number> {
|
|
|
475
476
|
}
|
|
476
477
|
|
|
477
478
|
const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
|
|
478
|
-
|
|
479
|
+
// Live runtime state, per platform: mobile reports the same read-only readiness
|
|
480
|
+
// the launch decision uses (deps currency, Metro on the slot's port, decision +
|
|
481
|
+
// reasons). Extension/core gain their sections when their readiness probes exist.
|
|
482
|
+
let runtime: { decision: string; reasonCode?: string; reasons: string[]; deps?: string; metro?: string } | undefined;
|
|
483
|
+
if (adapter === 'mobile') {
|
|
484
|
+
try {
|
|
485
|
+
const { mobileRuntimeStatus } = await import('./adapters/mobile/prepare.ts');
|
|
486
|
+
const { resolveMobileSlotPorts } = await import('./commands/launch.ts');
|
|
487
|
+
resolveMobileSlotPorts(target);
|
|
488
|
+
const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : undefined;
|
|
489
|
+
const report = await mobileRuntimeStatus(target, { watcherPort });
|
|
490
|
+
runtime = {
|
|
491
|
+
decision: report.decision,
|
|
492
|
+
reasonCode: report.reasonCode,
|
|
493
|
+
reasons: report.reasons,
|
|
494
|
+
deps: report.checks?.deps?.status,
|
|
495
|
+
metro: report.checks?.metro?.status,
|
|
496
|
+
};
|
|
497
|
+
} catch { /* readiness probe unavailable — doctor stays useful without it */ }
|
|
498
|
+
}
|
|
499
|
+
if (json) console.log(JSON.stringify({ ...result, runtime }, null, 2));
|
|
479
500
|
else {
|
|
480
|
-
|
|
501
|
+
const out = (style: string, text: string) => color(style, text, { stream: process.stdout });
|
|
502
|
+
const stateStyle = (value: string | undefined, good: string) => (value === good ? 'ok' : 'warn');
|
|
503
|
+
console.log(`${out(result.status === 'pass' ? 'ok' : 'err', result.status)} ${out('bold', adapter)} ${result.compatibilityMode} ${out('dim', `manifest=${actionManifestPath}`)}`);
|
|
504
|
+
if (runtime) {
|
|
505
|
+
const decisionStyle = runtime.decision === 'ready' ? 'ok' : runtime.decision === 'blocked' ? 'err' : 'warn';
|
|
506
|
+
console.log(
|
|
507
|
+
`${out('label', 'runtime:')} decision=${out(decisionStyle, runtime.decision)}${runtime.reasonCode ? ` ${out('dim', `(${runtime.reasonCode})`)}` : ''} deps=${out(stateStyle(runtime.deps, 'current'), runtime.deps ?? 'unknown')} metro=${out(stateStyle(runtime.metro, 'up'), runtime.metro ?? 'unprobed')}`,
|
|
508
|
+
);
|
|
509
|
+
for (const reason of runtime.reasons) console.log(` ${out('dim', reason)}`);
|
|
510
|
+
}
|
|
481
511
|
console.log(renderRuntimeContext(result.runtimeContext));
|
|
482
512
|
}
|
|
483
513
|
return result.status === 'pass' ? 0 : 1;
|
|
@@ -758,6 +788,7 @@ async function handleRuntimeDecision({ options }: ParsedArgs): Promise<number> {
|
|
|
758
788
|
metroLog: optionString(options, 'metroLog'),
|
|
759
789
|
platform: optionString(options, 'platform') ?? process.env.PLATFORM ?? process.env.RECIPE_HARNESS_PLATFORM,
|
|
760
790
|
record: optionFlag(options, 'record'),
|
|
791
|
+
preflightMode: optionString(options, 'preflightMode'),
|
|
761
792
|
});
|
|
762
793
|
if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
|
|
763
794
|
else console.log(`${report.decision} ${report.reasonCode} — ${report.reasons[0] ?? ''}`);
|
|
@@ -1082,6 +1113,47 @@ function emitPlanUsageError(
|
|
|
1082
1113
|
// hand it to runRecipe). One execution path, two doors: `call` = one node, `run` =
|
|
1083
1114
|
// a graph. Inherits run semantics: always-validates (adapter-aware, exit 5), same
|
|
1084
1115
|
// trace/evidence artifacts, same --json contract.
|
|
1116
|
+
async function handleStop(argv: string[]): Promise<number> {
|
|
1117
|
+
const { options } = parseArgs(argv, 'stop');
|
|
1118
|
+
const json = optionFlag(options, 'json');
|
|
1119
|
+
const { adapter, target } = resolveAdapter(options);
|
|
1120
|
+
if (adapter !== 'mobile') {
|
|
1121
|
+
return usageOut(
|
|
1122
|
+
json,
|
|
1123
|
+
'stop',
|
|
1124
|
+
`stop owns the mobile Metro dev server; the ${adapter} dev server is slot-managed`,
|
|
1125
|
+
'stop the extension watcher via its runtime pids: kill $(cat <runtime_dir>/webpack.pid); core runs no dev server',
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
const leaf = path.join(runnerDir, 'adapters', 'mobile', 'stop-metro.sh');
|
|
1129
|
+
const args = ['--target', target];
|
|
1130
|
+
const port = optionString(options, 'port') ?? optionString(options, 'watcherPort');
|
|
1131
|
+
if (port) args.push('--port', port);
|
|
1132
|
+
const result = spawnSync('bash', [leaf, ...args], {
|
|
1133
|
+
stdio: json ? 'pipe' : 'inherit',
|
|
1134
|
+
encoding: 'utf8',
|
|
1135
|
+
});
|
|
1136
|
+
const exitCode = result.status ?? 1;
|
|
1137
|
+
if (json) {
|
|
1138
|
+
console.log(
|
|
1139
|
+
JSON.stringify(
|
|
1140
|
+
{
|
|
1141
|
+
schemaVersion: 1,
|
|
1142
|
+
command: 'stop',
|
|
1143
|
+
adapter,
|
|
1144
|
+
target,
|
|
1145
|
+
status: exitCode === 0 ? 'pass' : 'fail',
|
|
1146
|
+
exitCode,
|
|
1147
|
+
output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(),
|
|
1148
|
+
},
|
|
1149
|
+
null,
|
|
1150
|
+
2,
|
|
1151
|
+
),
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
return exitCode;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1085
1157
|
async function handleCall(argv: string[]): Promise<number> {
|
|
1086
1158
|
// Grammar: mm-harness call <action> [--arg k=v ...] [flags].
|
|
1087
1159
|
// If the first token is a flag, the action positional is missing — parseCallArgs
|
|
@@ -1803,6 +1875,7 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
1803
1875
|
// launch/logs/debug/fixtures compose adapter scripts directly and own policy,
|
|
1804
1876
|
// healing, teaching, and the --json contract.
|
|
1805
1877
|
if (command === 'launch') return handleLaunch(argv.slice(1));
|
|
1878
|
+
if (command === 'stop') return handleStop(argv.slice(1));
|
|
1806
1879
|
if (command === 'logs') return handleLogs(argv.slice(1));
|
|
1807
1880
|
if (command === 'debug') return handleDebug(argv.slice(1));
|
|
1808
1881
|
if (command === 'fixtures') return handleFixtures(argv.slice(1), { runOneNode });
|
package/src/commands/launch.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { execFileSync } from 'node:child_process';
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { color } from '../cli-color.ts';
|
|
12
|
+
import { handleHarness, readRuntimeContextField, resolveRuntimeContextPath } from '../harness.ts';
|
|
12
13
|
import { recipeHarnessPath, recipeRuntimeDir, recipeRuntimePath, runnerDir } from '../paths.ts';
|
|
13
14
|
import type { MetaMaskRecipeAdapter } from '../types.ts';
|
|
14
15
|
import { prepareMobile } from '../adapters/mobile/prepare.ts';
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
parseFlags,
|
|
20
21
|
resolveAdapter,
|
|
21
22
|
spawnScript,
|
|
23
|
+
spawnScriptStreaming,
|
|
22
24
|
str,
|
|
23
25
|
targetOf,
|
|
24
26
|
usageOut,
|
|
@@ -227,15 +229,33 @@ function applyKVLines(output: string, overwrite: boolean): void {
|
|
|
227
229
|
case 'SLOT_ID':
|
|
228
230
|
if (overwrite || !process.env['RECIPE_SLOT_ID']) process.env['RECIPE_SLOT_ID'] = val;
|
|
229
231
|
break;
|
|
232
|
+
case 'CDP_PORT':
|
|
233
|
+
if (overwrite || !process.env['CDP_PORT']) {
|
|
234
|
+
process.env['CDP_PORT'] = val;
|
|
235
|
+
process.env['RECIPE_CDP_PORT'] = val;
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
230
238
|
}
|
|
231
239
|
}
|
|
232
240
|
}
|
|
233
241
|
|
|
234
|
-
// Resolve mobile slot port/simulator
|
|
235
|
-
//
|
|
242
|
+
// Resolve mobile slot port/simulator: the slot context the orchestrator wrote
|
|
243
|
+
// into the checkout wins first, then the farmslot pool (both overwrite env),
|
|
244
|
+
// then the slot-suffix formula (only fills unset vars). Called before explicit
|
|
236
245
|
// CLI flag overrides so flags always win at the top.
|
|
237
|
-
function resolveMobileSlotPorts(target: string): void {
|
|
246
|
+
export function resolveMobileSlotPorts(target: string): void {
|
|
238
247
|
const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
|
|
248
|
+
try {
|
|
249
|
+
// The checkout's own runtime context is authoritative — it names the exact
|
|
250
|
+
// simulator/port this slot was prepared with, surviving pool renames.
|
|
251
|
+
const ctxOut = execFileSync('bash', [
|
|
252
|
+
'-c', `source "${resolveScript}" && resolve_mobile_runtime_context "${target}"`,
|
|
253
|
+
], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
254
|
+
if (ctxOut.trim()) {
|
|
255
|
+
applyKVLines(ctxOut, true);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
} catch { /* no runtime context — fall through to pool */ }
|
|
239
259
|
try {
|
|
240
260
|
// Pool match always wins — overwrite whatever is in the environment.
|
|
241
261
|
const poolOut = execFileSync('bash', [
|
|
@@ -255,6 +275,43 @@ function resolveMobileSlotPorts(target: string): void {
|
|
|
255
275
|
} catch { /* no slot suffix in dir name — stays at env defaults */ }
|
|
256
276
|
}
|
|
257
277
|
|
|
278
|
+
// Resolve extension slot ports the same way as mobile: the checkout's runtime
|
|
279
|
+
// context first (cdpPort/devServerPort written by the orchestrator's prepare),
|
|
280
|
+
// then the farmslot pool, then the directory-suffix formula (fills unset only).
|
|
281
|
+
function resolveExtensionSlotPorts(target: string): void {
|
|
282
|
+
// The prepared checkout's context OVERWRITES inherited env (same authority as
|
|
283
|
+
// mobile's context/pool resolution): a stale CDP_PORT from the shell must not
|
|
284
|
+
// hijack the slot's browser. Explicit CLI flags are applied after and win.
|
|
285
|
+
const contextPath = resolveRuntimeContextPath(target);
|
|
286
|
+
const cdp = readRuntimeContextField(contextPath, 'cdpPort');
|
|
287
|
+
if (cdp) {
|
|
288
|
+
process.env['CDP_PORT'] = cdp;
|
|
289
|
+
process.env['RECIPE_CDP_PORT'] = cdp;
|
|
290
|
+
}
|
|
291
|
+
const dev = readRuntimeContextField(contextPath, 'devServerPort');
|
|
292
|
+
if (dev) {
|
|
293
|
+
process.env['WATCHER_PORT'] = dev;
|
|
294
|
+
process.env['RECIPE_WATCHER_PORT'] = dev;
|
|
295
|
+
}
|
|
296
|
+
if (process.env['CDP_PORT']) return;
|
|
297
|
+
const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
|
|
298
|
+
try {
|
|
299
|
+
const poolOut = execFileSync('bash', [
|
|
300
|
+
'-c', `source "${resolveScript}" && resolve_farmslot_ports_by_repo "${target}"`,
|
|
301
|
+
], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
302
|
+
if (poolOut.trim()) {
|
|
303
|
+
applyKVLines(poolOut, true);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
} catch { /* no pool match — fall through to formula */ }
|
|
307
|
+
try {
|
|
308
|
+
const defOut = execFileSync('bash', [
|
|
309
|
+
'-c', `source "${resolveScript}" && resolve_default_extension_ports "${target}"`,
|
|
310
|
+
], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
311
|
+
if (defOut.trim()) applyKVLines(defOut, false);
|
|
312
|
+
} catch { /* no slot suffix in dir name — stays at env defaults */ }
|
|
313
|
+
}
|
|
314
|
+
|
|
258
315
|
// Map the env-gap flags onto the env vars the leaf scripts read. For mobile, slot
|
|
259
316
|
// port/simulator defaults are resolved first (pool-wins, then formula-fills-empty)
|
|
260
317
|
// so that the harness respects slot isolation; explicit CLI flags applied below
|
|
@@ -265,9 +322,12 @@ function applyLaunchEnvOverrides(
|
|
|
265
322
|
mobileTarget: string | undefined,
|
|
266
323
|
target: string,
|
|
267
324
|
): void {
|
|
268
|
-
//
|
|
269
|
-
// the farmslot pool or directory-suffix formula BEFORE applying explicit
|
|
325
|
+
// Slot isolation: resolve ports/device from the checkout's own slot context,
|
|
326
|
+
// the farmslot pool, or the directory-suffix formula BEFORE applying explicit
|
|
327
|
+
// flags — extension needs this as much as mobile (CDP_PORT), so neither
|
|
328
|
+
// adapter hard-fails on a value its slot already knows.
|
|
270
329
|
if (adapter === 'mobile') resolveMobileSlotPorts(target);
|
|
330
|
+
if (adapter === 'extension') resolveExtensionSlotPorts(target);
|
|
271
331
|
|
|
272
332
|
const device = str(options, 'device');
|
|
273
333
|
if (device && adapter === 'mobile') {
|
|
@@ -346,17 +406,19 @@ async function executeComposition(
|
|
|
346
406
|
return extensionRebuild(target, json);
|
|
347
407
|
}
|
|
348
408
|
|
|
349
|
-
// Extension build or watch: full webpack compile via start-watch.sh.
|
|
409
|
+
// Extension build or watch: full webpack compile via start-watch.sh. Streams
|
|
410
|
+
// live — a webpack compile is minutes long and must never look hung.
|
|
350
411
|
const startWatchSh = path.join(runnerDir, 'adapters/extension/start-watch.sh');
|
|
351
412
|
const watchArgs = ['--target', target];
|
|
352
413
|
if (process.env.WATCHER_PORT) watchArgs.push('--watcher-port', process.env.WATCHER_PORT);
|
|
353
|
-
|
|
414
|
+
console.error(`→ extension ${wantWatch ? 'watch' : 'build'} — webpack :${process.env.WATCHER_PORT ?? 'default'} (output streams below)`);
|
|
415
|
+
return spawnScriptStreaming(startWatchSh, watchArgs, target);
|
|
354
416
|
}
|
|
355
417
|
|
|
356
418
|
// Extension rebuild: kill the harness-owned webpack watcher (pid file + ps-scan
|
|
357
419
|
// for orphans), clear the rebuild log, then drive the installed overlay's
|
|
358
420
|
// live.sh --start-watch and tee its output into the rebuild log.
|
|
359
|
-
function extensionRebuild(target: string, json: boolean): ScriptResult {
|
|
421
|
+
async function extensionRebuild(target: string, json: boolean): Promise<ScriptResult> {
|
|
360
422
|
const runtimeDirRel = recipeRuntimeDir();
|
|
361
423
|
const runtimeAbs = path.join(target, runtimeDirRel);
|
|
362
424
|
const webpackPidFile = path.join(runtimeAbs, 'recipe-harness-webpack.pid');
|
|
@@ -418,7 +480,10 @@ function extensionRebuild(target: string, json: boolean): ScriptResult {
|
|
|
418
480
|
const liveArgs = ['--target', target, '--start-watch'];
|
|
419
481
|
if (process.env.CDP_PORT) liveArgs.push('--cdp-port', process.env.CDP_PORT);
|
|
420
482
|
if (process.env.WATCHER_PORT) liveArgs.push('--watcher-port', process.env.WATCHER_PORT);
|
|
421
|
-
|
|
483
|
+
// Streams live: the watcher restart + Chromium boot runs for minutes and the
|
|
484
|
+
// command must show progress immediately, not a silent prompt.
|
|
485
|
+
console.error(`→ extension quick relaunch — webpack :${process.env.WATCHER_PORT ?? 'default'} · CDP :${process.env.CDP_PORT ?? 'default'} (output streams below)`);
|
|
486
|
+
const result = await spawnScriptStreaming(liveScript, liveArgs, target);
|
|
422
487
|
// Tee live.sh output into rebuild.log for post-mortem diagnosis.
|
|
423
488
|
if (result.output) {
|
|
424
489
|
try { fs.appendFileSync(rebuildLog, result.output); } catch { /* best-effort */ }
|
|
@@ -489,7 +554,18 @@ function launchPass(
|
|
|
489
554
|
),
|
|
490
555
|
);
|
|
491
556
|
} else {
|
|
492
|
-
|
|
557
|
+
// The summary must say what actually happened where: the target device,
|
|
558
|
+
// what the tier meant, and that the app+bridge are up — "(quick) ready"
|
|
559
|
+
// alone reads as a no-op even when a launch occurred.
|
|
560
|
+
const device =
|
|
561
|
+
adapter === 'mobile'
|
|
562
|
+
? process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || 'booted device'
|
|
563
|
+
: displayMode;
|
|
564
|
+
const tierNote = tier === 'quick' ? 'quick relaunch, no native build' : tier;
|
|
565
|
+
const devNote = process.env.MM_HARNESS_BIN ? ` ${color('dim', '[dev: MM_HARNESS_BIN]')}` : '';
|
|
566
|
+
console.error(
|
|
567
|
+
`${color('ok', '✓')} launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ''} — ${color('ok', String(device))} · ${tierNote} · app + bridge ready${devNote}`,
|
|
568
|
+
);
|
|
493
569
|
}
|
|
494
570
|
return EXIT.ok;
|
|
495
571
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
import { color } from './cli-color.ts';
|
|
4
5
|
import { readRuntimeContextField, resolveRuntimeContextPath } from './harness.ts';
|
|
5
6
|
import { manifestPath, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from './paths.ts';
|
|
6
7
|
import type {
|
|
@@ -100,26 +101,32 @@ const RUNTIME_CONTEXT_FIELDS: ReadonlyArray<{
|
|
|
100
101
|
envVars: readonly string[];
|
|
101
102
|
envVar: string | null;
|
|
102
103
|
customize: string;
|
|
104
|
+
adapters: readonly MetaMaskRecipeAdapter[];
|
|
103
105
|
}> = [
|
|
104
|
-
{ key: 'slotId', envVars: ['RECIPE_SLOT_ID'], envVar: 'RECIPE_SLOT_ID', customize: 'farmslot dispatch writes this' },
|
|
105
|
-
{ key: 'extensionId', envVars: ['RECIPE_HARNESS_EXTENSION_ID'], envVar: 'RECIPE_HARNESS_EXTENSION_ID', customize: 'auto-resolved; edit file to pin' },
|
|
106
|
-
{ key: 'cdpPort', envVars: ['RECIPE_CDP_PORT', 'CDP_PORT'], envVar: 'CDP_PORT', customize: 'edit file or pass --cdp-port' },
|
|
107
|
-
{ key: 'runtimeStart.approved', envVars: ['RECIPE_RUNTIME_START_APPROVED'], envVar: 'RECIPE_RUNTIME_START_APPROVED', customize: 'edit file (true/false)' },
|
|
108
|
-
{ key: 'runtimeStart.command', envVars: [], envVar: null, customize: 'edit file' },
|
|
109
|
-
{ key: 'runtimeStart.readyUrl', envVars: ['RECIPE_RUNTIME_READY_URL'], envVar: 'RECIPE_RUNTIME_READY_URL', customize: 'edit file' },
|
|
106
|
+
{ key: 'slotId', envVars: ['RECIPE_SLOT_ID'], envVar: 'RECIPE_SLOT_ID', customize: 'farmslot dispatch writes this', adapters: ['mobile', 'extension', 'core'] },
|
|
107
|
+
{ key: 'extensionId', envVars: ['RECIPE_HARNESS_EXTENSION_ID'], envVar: 'RECIPE_HARNESS_EXTENSION_ID', customize: 'auto-resolved; edit file to pin', adapters: ['extension'] },
|
|
108
|
+
{ key: 'cdpPort', envVars: ['RECIPE_CDP_PORT', 'CDP_PORT'], envVar: 'CDP_PORT', customize: 'edit file or pass --cdp-port', adapters: ['extension'] },
|
|
109
|
+
{ key: 'runtimeStart.approved', envVars: ['RECIPE_RUNTIME_START_APPROVED'], envVar: 'RECIPE_RUNTIME_START_APPROVED', customize: 'edit file (true/false)', adapters: ['mobile', 'extension'] },
|
|
110
|
+
{ key: 'runtimeStart.command', envVars: [], envVar: null, customize: 'edit file', adapters: ['mobile', 'extension'] },
|
|
111
|
+
{ key: 'runtimeStart.readyUrl', envVars: ['RECIPE_RUNTIME_READY_URL'], envVar: 'RECIPE_RUNTIME_READY_URL', customize: 'edit file', adapters: ['mobile', 'extension'] },
|
|
110
112
|
];
|
|
111
113
|
|
|
112
114
|
// Report every runtime-context field with its current value and where it came from
|
|
113
115
|
// (live env override > file > unset default), reusing the harness's own file reader
|
|
114
116
|
// so doctor and dispatch agree on resolution. Absent file → fileExists:false and
|
|
115
117
|
// every field falls back to env or default; the path shows where it WOULD live.
|
|
116
|
-
|
|
118
|
+
// An adapter scopes the report to that platform's fields — an extension-only row
|
|
119
|
+
// (cdpPort, extensionId) is noise on a mobile or core slot.
|
|
120
|
+
export function runtimeContextSummary(target: string, adapter?: MetaMaskRecipeAdapter): MetaMaskRuntimeContextReport {
|
|
117
121
|
const contextPath = resolveRuntimeContextPath(target);
|
|
118
122
|
const envOverride = process.env.RECIPE_RUNTIME_CONTEXT ?? null;
|
|
119
123
|
const fileExists = fs.existsSync(contextPath);
|
|
120
124
|
const file = envOverride ?? path.relative(target, contextPath);
|
|
121
125
|
const fields: Record<string, MetaMaskRuntimeContextField> = {};
|
|
122
|
-
|
|
126
|
+
const specs = adapter
|
|
127
|
+
? RUNTIME_CONTEXT_FIELDS.filter((spec) => spec.adapters.includes(adapter))
|
|
128
|
+
: RUNTIME_CONTEXT_FIELDS;
|
|
129
|
+
for (const spec of specs) {
|
|
123
130
|
const envValue = spec.envVars
|
|
124
131
|
.map((name) => process.env[name])
|
|
125
132
|
.find((value) => value !== undefined && value !== '');
|
|
@@ -137,16 +144,21 @@ export function runtimeContextSummary(target: string): MetaMaskRuntimeContextRep
|
|
|
137
144
|
|
|
138
145
|
// Human-readable runtime-context section for `doctor` without --json.
|
|
139
146
|
export function renderRuntimeContext(runtimeContext: MetaMaskRuntimeContextReport): string {
|
|
147
|
+
// Human render: set values read bright with their provenance highlighted;
|
|
148
|
+
// unset defaults read dim so the eye lands on what is actually configured.
|
|
149
|
+
const out = (style: string, text: string) => color(style, text, { stream: process.stdout });
|
|
140
150
|
const lines: string[] = [];
|
|
141
151
|
lines.push(
|
|
142
152
|
runtimeContext.fileExists
|
|
143
|
-
?
|
|
144
|
-
:
|
|
153
|
+
? `${out('label', 'runtime-context:')} ${runtimeContext.file} ${out('ok', '(present)')}`
|
|
154
|
+
: `${out('label', 'runtime-context:')} ${runtimeContext.file} ${out('dim', '(absent — written by farmslot prepare/dispatch)')}`,
|
|
145
155
|
);
|
|
146
156
|
for (const [key, field] of Object.entries(runtimeContext.fields)) {
|
|
147
|
-
const
|
|
157
|
+
const isSet = field.value !== undefined && field.value !== null && field.value !== '';
|
|
158
|
+
const value = isSet ? out('ok', String(field.value)) : out('dim', '(unset)');
|
|
148
159
|
const origin = field.source === 'env' && field.envVar ? `env ${field.envVar}` : field.source;
|
|
149
|
-
|
|
160
|
+
const originTag = isSet ? out('accent', `[${origin}]`) : out('dim', `[${origin}]`);
|
|
161
|
+
lines.push(` ${key.padEnd(22)} ${value} ${originTag} ${out('dim', `— ${field.customize}`)}`);
|
|
150
162
|
}
|
|
151
163
|
return lines.join('\n');
|
|
152
164
|
}
|
|
@@ -189,7 +201,7 @@ export function createDoctorReport(
|
|
|
189
201
|
compatibilityMode: mode,
|
|
190
202
|
shape: repoShape(target),
|
|
191
203
|
fixture: fixtureSummary(target),
|
|
192
|
-
runtimeContext: runtimeContextSummary(target),
|
|
204
|
+
runtimeContext: runtimeContextSummary(target, adapter),
|
|
193
205
|
manifestValidation: manifestValidation.summary,
|
|
194
206
|
};
|
|
195
207
|
}
|
package/src/mm-harness-cli.ts
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
// teach; genuinely-unknown commands fall to commander's default.
|
|
8
8
|
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
import { fileURLToPath } from 'node:url';
|
|
12
13
|
|
|
13
14
|
import { Command } from 'commander';
|
|
14
15
|
|
|
16
|
+
import { color } from './cli-color.ts';
|
|
15
17
|
import { handleUpdate, maybeNudge } from './commands/update.ts';
|
|
16
18
|
|
|
17
19
|
// …/src → its parent is the package root that holds scripts/.
|
|
@@ -57,6 +59,25 @@ Example:
|
|
|
57
59
|
mm-harness actions --adapter mobile
|
|
58
60
|
mm-harness actions --adapter extension --raw`,
|
|
59
61
|
},
|
|
62
|
+
{
|
|
63
|
+
name: 'stop',
|
|
64
|
+
summary: 'Stop the dev server this checkout owns (mobile: port-scoped Metro) and close its log window.',
|
|
65
|
+
example: 'mm-harness stop',
|
|
66
|
+
helpText: `mm-harness stop [flags]
|
|
67
|
+
|
|
68
|
+
Stop the Metro dev server this checkout owns and close its tmux log-tail window.
|
|
69
|
+
Port-scoped: only the listener on the checkout's resolved port is signalled, so
|
|
70
|
+
concurrent slots are untouched. Nothing running = success (idempotent).
|
|
71
|
+
Extension/core: teaching error (their dev servers are slot-managed).
|
|
72
|
+
|
|
73
|
+
--port <port> Metro port (default: the checkout's slot context)
|
|
74
|
+
--target <path> Checkout path (default: cwd)
|
|
75
|
+
--json Machine-readable output
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
mm-harness stop
|
|
79
|
+
mm-harness stop --port 8061`,
|
|
80
|
+
},
|
|
60
81
|
{
|
|
61
82
|
name: 'call',
|
|
62
83
|
summary: 'Run one action in isolation as a one-node recipe through the real engine path (fuzzy short names; --arg k=v; same trace/evidence as run).',
|
|
@@ -345,7 +366,7 @@ const HELP_GROUPS: HelpGroup[] = [
|
|
|
345
366
|
{
|
|
346
367
|
title: 'DAILY LOOP',
|
|
347
368
|
blurb: 'what a teammate runs many times a day (auto-ensures the overlay; --heal owns recovery)',
|
|
348
|
-
commands: ['launch', 'logs', 'debug', 'fixtures'],
|
|
369
|
+
commands: ['launch', 'stop', 'logs', 'debug', 'fixtures'],
|
|
349
370
|
},
|
|
350
371
|
{
|
|
351
372
|
title: 'DISCOVER',
|
|
@@ -375,19 +396,51 @@ function commandMeta(name: string): { summary: string; example: string; planned:
|
|
|
375
396
|
return { summary: '', example: '', planned: false };
|
|
376
397
|
}
|
|
377
398
|
|
|
399
|
+
// Slot context the orchestrator's prepare wrote into the checkout, when the
|
|
400
|
+
// help is run from inside one. Presence-gated: no context, no line.
|
|
401
|
+
function detectedSlotLine(out: (style: string, text: string) => string): string | null {
|
|
402
|
+
const ctxPath = path.join(
|
|
403
|
+
process.cwd(),
|
|
404
|
+
process.env.RECIPE_RUNTIME_DIR || 'temp/recipe/runtime',
|
|
405
|
+
'agentic-runtime.json',
|
|
406
|
+
);
|
|
407
|
+
try {
|
|
408
|
+
const ctx = JSON.parse(fs.readFileSync(ctxPath, 'utf8')) as Record<string, unknown>;
|
|
409
|
+
const parts: string[] = [];
|
|
410
|
+
if (ctx.slotId) parts.push(`slot ${out('ok', String(ctx.slotId))}`);
|
|
411
|
+
if (ctx.simulator) parts.push(`device ${out('ok', String(ctx.simulator))}`);
|
|
412
|
+
if (ctx.metroPort) parts.push(`metro :${out('ok', String(ctx.metroPort))}`);
|
|
413
|
+
if (ctx.gitBranch) parts.push(`branch ${out('info', String(ctx.gitBranch))}`);
|
|
414
|
+
if (parts.length === 0) return null;
|
|
415
|
+
return `${out('label', 'SLOT')} — this checkout is a prepared slot: ${parts.join(' · ')}`;
|
|
416
|
+
} catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
378
421
|
function groupedHelp(): string {
|
|
422
|
+
const out = (style: string, text: string) => color(style, text, { stream: process.stdout });
|
|
379
423
|
const lines: string[] = [];
|
|
380
|
-
lines.push('mm-harness — one front door for the MetaMask recipe loop: launch the app, prove behavior, manage the runtime overlay
|
|
424
|
+
lines.push(`${out('bold', 'mm-harness')} — one front door for the MetaMask recipe loop: launch the app, prove behavior, manage the runtime overlay.`);
|
|
381
425
|
lines.push('Run it from inside a MetaMask checkout; the platform (mobile | extension | core) is auto-detected.');
|
|
382
|
-
lines.push(
|
|
426
|
+
lines.push(`Grammar: ${out('cmd', 'mm-harness <command> [target] [flags]')} (target is a positional: ios | android; flags add agent depth; --json is the agent contract)`);
|
|
427
|
+
if (process.env.MM_HARNESS_BIN) {
|
|
428
|
+
lines.push('');
|
|
429
|
+
lines.push(`${out('warn', 'DEV OVERRIDE ACTIVE')} — this run is served by MM_HARNESS_BIN=${out('path', process.env.MM_HARNESS_BIN)} (unset it to return to the installed/global bin).`);
|
|
430
|
+
}
|
|
431
|
+
const slotLine = detectedSlotLine(out);
|
|
432
|
+
if (slotLine) {
|
|
433
|
+
lines.push('');
|
|
434
|
+
lines.push(slotLine);
|
|
435
|
+
}
|
|
383
436
|
for (const group of HELP_GROUPS) {
|
|
384
437
|
lines.push('');
|
|
385
|
-
lines.push(`${group.title} — ${group.blurb}:`);
|
|
438
|
+
lines.push(`${out('label', group.title)} — ${group.blurb}:`);
|
|
386
439
|
for (const name of group.commands) {
|
|
387
440
|
const meta = commandMeta(name);
|
|
388
441
|
const planned = meta.planned ? ' (planned)' : '';
|
|
389
|
-
lines.push(` ${name.padEnd(10)} ${meta.summary}${planned}`);
|
|
390
|
-
lines.push(` ${meta.example}`);
|
|
442
|
+
lines.push(` ${out('cmd', name.padEnd(10))} ${meta.summary}${planned}`);
|
|
443
|
+
lines.push(` ${out('comment', meta.example)}`);
|
|
391
444
|
}
|
|
392
445
|
}
|
|
393
446
|
lines.push('');
|