@deeeed/metamask-harness 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +140 -0
  3. package/bin/mm-harness +99 -0
  4. package/docs/CHEATSHEET.md +61 -0
  5. package/docs/CLI-SPEC.md +915 -0
  6. package/docs/MENTAL-MODEL.md +295 -0
  7. package/docs/architecture.md +367 -0
  8. package/docs/extension-runtime-commands.md +60 -0
  9. package/docs/harness-cli.md +43 -0
  10. package/docs/live-adapter-contract.md +188 -0
  11. package/docs/package-boundaries.md +47 -0
  12. package/docs/perps-flow-catalog.md +235 -0
  13. package/docs/recipe-libraries.md +95 -0
  14. package/docs/runtime-file-conventions.md +36 -0
  15. package/library/actions/core/perps/_controller.mjs +727 -0
  16. package/library/actions/core/perps/assert_orders.mjs +53 -0
  17. package/library/actions/core/perps/assert_positions.mjs +52 -0
  18. package/library/actions/core/perps/close_orders.mjs +97 -0
  19. package/library/actions/core/perps/close_positions.mjs +118 -0
  20. package/library/actions/core/perps/ensure_orders.mjs +40 -0
  21. package/library/actions/core/perps/ensure_positions.mjs +37 -0
  22. package/library/actions/core/perps/place_order.mjs +201 -0
  23. package/library/actions/core/perps/read_account.mjs +30 -0
  24. package/library/actions/core/perps/read_orders.mjs +27 -0
  25. package/library/actions/core/perps/read_positions.mjs +27 -0
  26. package/library/actions/core/perps/start_state.mjs +92 -0
  27. package/library/actions/core/perps/teardown_state.mjs +86 -0
  28. package/library/actions/extension/perps/assert_orders.mjs +11 -0
  29. package/library/actions/extension/perps/assert_positions.mjs +11 -0
  30. package/library/actions/extension/perps/close_orders.mjs +8 -0
  31. package/library/actions/extension/perps/close_positions.mjs +8 -0
  32. package/library/actions/extension/perps/ensure_orders.mjs +4 -0
  33. package/library/actions/extension/perps/ensure_positions.mjs +4 -0
  34. package/library/actions/extension/perps/perps.mjs +730 -0
  35. package/library/actions/extension/perps/place_order.mjs +7 -0
  36. package/library/actions/extension/perps/read_orders.mjs +4 -0
  37. package/library/actions/extension/perps/read_positions.mjs +3 -0
  38. package/library/actions/extension/platform/cdp.mjs +541 -0
  39. package/library/actions/extension/ui/navigate.mjs +44 -0
  40. package/library/actions/extension/wallet/ensure_unlocked.mjs +36 -0
  41. package/library/actions/extension/wallet/read_state.mjs +27 -0
  42. package/library/actions/extension/wallet/select_account.mjs +48 -0
  43. package/library/actions/extension/wallet/setup.mjs +35 -0
  44. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +185 -0
  45. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +1662 -0
  46. package/library/actions/mobile/bridge-runtime/cdp-bridge.cjs +686 -0
  47. package/library/actions/mobile/bridge-runtime/lib/cdp-eval.cjs +110 -0
  48. package/library/actions/mobile/bridge-runtime/lib/config.cjs +39 -0
  49. package/library/actions/mobile/bridge-runtime/lib/issue-capture.cjs +446 -0
  50. package/library/actions/mobile/bridge-runtime/lib/target-discovery.cjs +204 -0
  51. package/library/actions/mobile/bridge-runtime/lib/ws-client.cjs +108 -0
  52. package/library/actions/mobile/bridge-runtime/setup-wallet.sh +442 -0
  53. package/library/actions/mobile/perps/assert_orders.mjs +11 -0
  54. package/library/actions/mobile/perps/assert_positions.mjs +11 -0
  55. package/library/actions/mobile/perps/close_orders.mjs +8 -0
  56. package/library/actions/mobile/perps/close_positions.mjs +8 -0
  57. package/library/actions/mobile/perps/ensure_orders.mjs +4 -0
  58. package/library/actions/mobile/perps/ensure_positions.mjs +4 -0
  59. package/library/actions/mobile/perps/perps.mjs +709 -0
  60. package/library/actions/mobile/perps/place_order.mjs +7 -0
  61. package/library/actions/mobile/perps/read_orders.mjs +4 -0
  62. package/library/actions/mobile/perps/read_positions.mjs +3 -0
  63. package/library/actions/mobile/platform/bridge.mjs +283 -0
  64. package/library/actions/mobile/ui/navigate.mjs +38 -0
  65. package/library/actions/mobile/wallet/ensure_unlocked.mjs +107 -0
  66. package/library/actions/mobile/wallet/home.mjs +35 -0
  67. package/library/actions/mobile/wallet/read_state.mjs +40 -0
  68. package/library/actions/mobile/wallet/select_account.mjs +48 -0
  69. package/library/actions/mobile/wallet/setup.mjs +220 -0
  70. package/library/flows/perps.flows.json +64 -0
  71. package/library/library.json +7 -0
  72. package/library/manifests/core.action-manifest.json +1282 -0
  73. package/library/manifests/extension.action-manifest.json +1749 -0
  74. package/library/manifests/mobile.action-manifest.json +1753 -0
  75. package/library/recipes/action-validation.extension.recipe.json +417 -0
  76. package/library/recipes/action-validation.mobile.recipe.json +422 -0
  77. package/library/recipes/order-lifecycle.core.recipe.json +78 -0
  78. package/library/recipes/perps-lifecycle.recipe.json +194 -0
  79. package/library/recipes/read-markets.core.recipe.json +38 -0
  80. package/library/recipes/smoke.extension.recipe.json +31 -0
  81. package/library/recipes/smoke.mobile.recipe.json +31 -0
  82. package/library/recipes/trading-lifecycle.core.recipe.json +76 -0
  83. package/orchestration/compat-overlays/README.md +19 -0
  84. package/orchestration/compat-overlays/mobile/README.md +13 -0
  85. package/orchestration/compat-overlays/mobile/rn81-message-event-source.patch +42 -0
  86. package/orchestration/core/cleanup.sh +37 -0
  87. package/orchestration/core/inject.sh +154 -0
  88. package/orchestration/doctor.mjs +72 -0
  89. package/orchestration/extension/cleanup.mjs +60 -0
  90. package/orchestration/extension/console-tail.mjs +228 -0
  91. package/orchestration/extension/ensure-browser.sh +416 -0
  92. package/orchestration/extension/ensure-ready.ts +185 -0
  93. package/orchestration/extension/extension-id.ts +107 -0
  94. package/orchestration/extension/inject.mjs +266 -0
  95. package/orchestration/extension/launch-browser.cjs +216 -0
  96. package/orchestration/extension/launch.sh +175 -0
  97. package/orchestration/extension/live.sh +320 -0
  98. package/orchestration/extension/pin-remote-flags.cjs +45 -0
  99. package/orchestration/extension/readiness.mjs +414 -0
  100. package/orchestration/extension/refresh-build.sh +190 -0
  101. package/orchestration/extension/runtime-decision.ts +445 -0
  102. package/orchestration/extension/runtime.ts +407 -0
  103. package/orchestration/extension/seed-fixture.sh +177 -0
  104. package/orchestration/extension/sidepanel-toggle.sh +291 -0
  105. package/orchestration/extension/snapshot-dist.sh +84 -0
  106. package/orchestration/extension/start-watch.sh +339 -0
  107. package/orchestration/extension/wallet-fixture-state.cjs +1086 -0
  108. package/orchestration/lib/activate-repo-node.sh +144 -0
  109. package/orchestration/lib/cli-color.mjs +84 -0
  110. package/orchestration/lib/cli-commands.mjs +243 -0
  111. package/orchestration/lib/cli-home.mjs +354 -0
  112. package/orchestration/lib/cli-ux.sh +252 -0
  113. package/orchestration/lib/cli-version.mjs +123 -0
  114. package/orchestration/lib/ensure-runner-deps.sh +56 -0
  115. package/orchestration/lib/harness-path.sh +55 -0
  116. package/orchestration/lib/hash-helpers.sh +44 -0
  117. package/orchestration/lib/json-field.sh +23 -0
  118. package/orchestration/lib/log-tui.mjs +304 -0
  119. package/orchestration/lib/open-debug.mjs +317 -0
  120. package/orchestration/lib/path-defaults.json +4 -0
  121. package/orchestration/lib/progress.mjs +107 -0
  122. package/orchestration/lib/recipe-paths.mjs +26 -0
  123. package/orchestration/lib/resolve-farmslot-ports.sh +144 -0
  124. package/orchestration/manifest.json +358 -0
  125. package/orchestration/mobile/cleanup.sh +192 -0
  126. package/orchestration/mobile/deps-markers.ts +21 -0
  127. package/orchestration/mobile/inject.sh +681 -0
  128. package/orchestration/mobile/launch.sh +137 -0
  129. package/orchestration/mobile/live.sh +125 -0
  130. package/orchestration/mobile/runtime-decision.ts +292 -0
  131. package/orchestration/porcelain/metamask-recipe +99 -0
  132. package/orchestration/porcelain/mm-recipe +1591 -0
  133. package/orchestration/porcelain/mme-recipe +1181 -0
  134. package/package.json +59 -0
  135. package/runner/extension/verify.sh +511 -0
  136. package/runner/mobile/verify.sh +501 -0
  137. package/runner/src/adapters.ts +601 -0
  138. package/runner/src/cli.ts +1820 -0
  139. package/runner/src/commands/debug.ts +44 -0
  140. package/runner/src/commands/fixtures.ts +99 -0
  141. package/runner/src/commands/launch.ts +397 -0
  142. package/runner/src/commands/logs.ts +60 -0
  143. package/runner/src/commands/shared.ts +138 -0
  144. package/runner/src/completions-cache.ts +86 -0
  145. package/runner/src/doctor.ts +203 -0
  146. package/runner/src/harness.ts +516 -0
  147. package/runner/src/heal-bounds.ts +179 -0
  148. package/runner/src/index.ts +6 -0
  149. package/runner/src/live-adapter-contract.ts +274 -0
  150. package/runner/src/manifest.ts +47 -0
  151. package/runner/src/mm-harness-cli.ts +488 -0
  152. package/runner/src/paths.ts +198 -0
  153. package/runner/src/recording-target.ts +147 -0
  154. package/runner/src/run-recording.ts +329 -0
  155. package/runner/src/runner.ts +108 -0
  156. package/runner/src/types.ts +57 -0
  157. package/scripts/completions.sh +125 -0
  158. package/scripts/install-completions.sh +62 -0
@@ -0,0 +1,1591 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ usage() {
5
+ cat >&2 <<'EOF'
6
+ Usage:
7
+ mm-recipe home status screen (default)
8
+ mm-recipe status [--json] readiness summary + suggested next commands
9
+ mm-recipe ports [--json] slot ports, runtime paths, device hints
10
+ mm-recipe up [--json] [--dry-run] decide + run minimum work to reach ready
11
+ mm-recipe decision [--json] runtime-decision (deps + Metro bundle gate)
12
+ mm-recipe ios start/reuse Metro, launch iOS dev client
13
+ mm-recipe android start/reuse Metro, launch Android dev client
14
+ mm-recipe sync [--json] refresh mobile harness + canonicalize wallet fixture
15
+ mm-recipe completion [install] install shell tab completion (bash + zsh)
16
+ mm-recipe prepare install harness, start/reuse runtime, optionally setup wallet
17
+ --target <repo> --platform ios|android --preflight-mode fast|auto|default|rebuild-native|clean --port <port> --simulator <sim> --adb-serial <serial> --runtime-dir <dir> --wallet-setup [--wallet-fixture <json>]
18
+ mm-recipe setup:ios ios + setup wallet fixture
19
+ mm-recipe setup:android android + setup wallet fixture
20
+ mm-recipe refresh start/reuse Metro, relaunch detected dev client
21
+ mm-recipe reload alias for refresh
22
+ mm-recipe logs [--full] tail last Metro events (default) or full log
23
+ mm-recipe interactive interactive menu
24
+ mm-recipe runtime-status [--json] structured Metro/bundle/bridge status
25
+ mm-recipe route current route
26
+ mm-recipe navigate <Route> [json] navigate by React Navigation route
27
+ mm-recipe unlock [password] unlock with fixture password if omitted
28
+ mm-recipe setup-wallet [fixture] apply wallet fixture
29
+ mm-recipe accounts list accounts
30
+ mm-recipe select-account <addr> switch account
31
+ mm-recipe screenshot [path] simulator/device screenshot
32
+ mm-recipe debug [--json] open React Native DevTools / Hermes debugger
33
+ mm-recipe dev-menu [--json] open the React Native developer menu
34
+ mm-recipe stop stop this slot's Metro
35
+ mm-recipe actions [--json]
36
+ mm-recipe doctor [--json]
37
+ mm-recipe run <recipe.json> [args] run recipe with detected slot env
38
+ mm-recipe <recipe.json> [args] shortcut for run
39
+
40
+ Detects Mobile root, .js.env, WATCHER_PORT, IOS_SIMULATOR/ANDROID_DEVICE.
41
+ EOF
42
+ recipe_speed_ladder
43
+ }
44
+
45
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
46
+ runner="$script_dir/metamask-recipe"
47
+ # This script lives at <root>/orchestration/porcelain/; the runner root is two up.
48
+ runner_root="$(cd "$script_dir/../.." && pwd -P)"
49
+ # shellcheck disable=SC1091
50
+ . "$runner_root/orchestration/lib/harness-path.sh"
51
+ . "$runner_root/orchestration/lib/cli-ux.sh"
52
+ RECIPE_RUNNER_ROOT="$runner_root"
53
+ bridge="$runner_root/library/actions/mobile/bridge-runtime/cdp-bridge.cjs"
54
+ setup_wallet_script="$runner_root/library/actions/mobile/bridge-runtime/setup-wallet.sh"
55
+ open_debug_script="$runner_root/orchestration/lib/open-debug.mjs"
56
+
57
+ find_mobile_root() {
58
+ local dir="$(pwd -P)"
59
+ while [ "$dir" != "/" ]; do
60
+ if [ -f "$dir/package.json" ] && { grep -q 'metamask-mobile' "$dir/package.json" 2>/dev/null || [[ "$(basename "$dir")" == metamask-mobile* ]]; }; then
61
+ printf '%s\n' "$dir"
62
+ return 0
63
+ fi
64
+ dir="$(dirname "$dir")"
65
+ done
66
+ return 1
67
+ }
68
+
69
+ load_env_file() {
70
+ local file="$1"
71
+ [ -f "$file" ] || return 0
72
+ while IFS= read -r line || [ -n "$line" ]; do
73
+ line="${line#export }"
74
+ case "$line" in
75
+ WATCHER_PORT=*|CDP_PORT=*|IOS_SIMULATOR=*|SIM_UDID=*|ANDROID_DEVICE=*|ADB_SERIAL=*|ANDROID_SERIAL=*)
76
+ key="${line%%=*}"
77
+ val="${line#*=}"
78
+ val="${val%%#*}"
79
+ val="$(printf '%s' "$val" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//")"
80
+ [ -n "${!key:-}" ] || export "$key=$val"
81
+ ;;
82
+ esac
83
+ done < "$file"
84
+ }
85
+
86
+ infer_slot_number() {
87
+ local root="$1" base
88
+ base="$(basename "$root")"
89
+ [[ "$base" =~ -([0-9]+)$ ]] && printf '%s\n' "${BASH_REMATCH[1]}"
90
+ }
91
+
92
+ infer_mobile_platform() {
93
+ if [ -n "${ANDROID_DEVICE:-${ADB_SERIAL:-${ANDROID_SERIAL:-}}}" ]; then
94
+ printf 'android\n'
95
+ return 0
96
+ fi
97
+ local platform="${RECIPE_HARNESS_PLATFORM:-${PLATFORM:-}}"
98
+ case "$platform" in
99
+ android|ios) printf '%s\n' "$platform"; return 0 ;;
100
+ esac
101
+ printf 'ios\n'
102
+ }
103
+
104
+ init_context() {
105
+ root="$(find_mobile_root)" || { echo "mm-recipe: run from a metamask-mobile checkout" >&2; exit 2; }
106
+ load_env_file "$root/.js.env"
107
+ load_env_file "$root/.env"
108
+ load_env_file "$root/.env.local"
109
+ slot="$(infer_slot_number "$root" || true)"
110
+ if [ -n "$slot" ]; then
111
+ [ -n "${WATCHER_PORT:-}" ] || export WATCHER_PORT="806$slot"
112
+ [ -n "${IOS_SIMULATOR:-}" ] || export IOS_SIMULATOR="mm-$slot"
113
+ fi
114
+ # Bare (non-slot) checkout: default to Metro's well-known port so the launch
115
+ # path never trips `set -u` on an unbound WATCHER_PORT. Farm dispatch sets the
116
+ # slot-derived port above; both are the single source read everywhere below.
117
+ [ -n "${WATCHER_PORT:-}" ] || export WATCHER_PORT=8081
118
+ [ -n "${CDP_PORT:-}" ] || export CDP_PORT="${WATCHER_PORT:-}"
119
+ runtime_dir="$(recipe_runtime_dir)"
120
+ export RECIPE_RUNTIME_DIR="$runtime_dir"
121
+ mkdir -p "$root/$runtime_dir" "$root/temp/recipe-artifacts"
122
+ }
123
+
124
+
125
+ pretty_json() {
126
+ node -e '
127
+ let d="";
128
+ process.stdin.on("data", c => d += c).on("end", () => {
129
+ const color = process.env.RECIPE_COLOR === "1";
130
+ const c = (code, s) => color ? `\x1b[${code}m${s}\x1b[0m` : s;
131
+ try {
132
+ const v = JSON.parse(d);
133
+ if (v.decision) {
134
+ console.log(`${c("1;33", "Decision")}: ${c("1;32", v.decision)}${v.reasonCode ? ` ${c("2", "-")} ${c("0;37", v.reasonCode)}` : ""}`);
135
+ if (Array.isArray(v.reasons)) for (const r of v.reasons) console.log(` ${c("2", "-")} ${r}`);
136
+ return;
137
+ }
138
+ if (v.adapter && Array.isArray(v.actions) && v.actions.every(a => a && a.name)) {
139
+ console.log(`${c("1;36", "Actions")} ${c("2", `(${v.adapter})`)}:`);
140
+ for (const a of v.actions) {
141
+ console.log(` ${c("1;32", a.name)}${a.description ? ` ${c("2", "-")} ${c("0;37", a.description)}` : ""}`);
142
+ }
143
+ return;
144
+ }
145
+ if (v.status && v.adapter) {
146
+ const status = String(v.status).toUpperCase();
147
+ const code = status === "PASS" || status === "OK" ? "1;32" : status === "FAIL" || status === "ERROR" ? "1;31" : "1;33";
148
+ console.log(`${c(code, status)} ${c("1;36", v.adapter)}${v.compatibilityMode ? ` ${c("2", "-")} ${v.compatibilityMode}` : ""}`);
149
+ return;
150
+ }
151
+ if (v.account || v.route || v.deviceName) {
152
+ console.log(`${c("1;36", "Device")}: ${v.deviceName || "?"}${v.platform ? ` ${c("2", `(${v.platform})`)}` : ""}`);
153
+ console.log(`${c("1;36", "Route")}: ${v.route && v.route.name ? v.route.name : JSON.stringify(v.route || null)}`);
154
+ if (v.account) console.log(`${c("1;36", "Account")}: ${v.account.name || "?"} ${c("2", v.account.address || "")}`);
155
+ return;
156
+ }
157
+ console.log(JSON.stringify(v, null, 2));
158
+ } catch {
159
+ process.stdout.write(d);
160
+ }
161
+ })'
162
+ }
163
+
164
+ json_requested() {
165
+ for arg in "$@"; do [ "$arg" = "--json" ] && return 0; done
166
+ return 1
167
+ }
168
+
169
+ full_logs_requested() {
170
+ for arg in "$@"; do
171
+ case "$arg" in
172
+ --full|-f) return 0 ;;
173
+ esac
174
+ done
175
+ return 1
176
+ }
177
+
178
+ dry_run_requested() {
179
+ for arg in "$@"; do
180
+ case "$arg" in
181
+ --dry-run|--explain|-n) return 0 ;;
182
+ esac
183
+ done
184
+ return 1
185
+ }
186
+
187
+ color_requested() {
188
+ [ "${RECIPE_NO_COLOR:-}" != "1" ] && { [ "${RECIPE_COLOR:-}" = "1" ] || [ "${FORCE_COLOR:-}" = "1" ] || [ -t 1 ]; }
189
+ }
190
+
191
+ pipe_pretty() {
192
+ if color_requested; then (export RECIPE_COLOR=1; pretty_json); else pretty_json; fi
193
+ }
194
+
195
+ run_pretty() {
196
+ if json_requested "$@"; then "$@"; else "$@" | pipe_pretty; fi
197
+ }
198
+
199
+ run_json_pretty() {
200
+ if json_requested "$@"; then "$@"; else "$@" --json | pipe_pretty; fi
201
+ }
202
+ log() { printf 'mm-recipe: %s\n' "$*" >&2; }
203
+
204
+ shell_quote() {
205
+ printf '%q' "$1"
206
+ }
207
+
208
+ resolve_mobile_harness_script() {
209
+ local candidate
210
+
211
+ for candidate in \
212
+ "${FARMSLOT_RECIPE_HARNESS_SCRIPT:-}" \
213
+ "${RECIPE_HARNESS_SCRIPT:-}"; do
214
+ if [ -n "$candidate" ] && [ -x "$candidate" ]; then
215
+ printf '%s\n' "$candidate"
216
+ return 0
217
+ fi
218
+ done
219
+
220
+
221
+
222
+ return 1
223
+ }
224
+
225
+ install_mobile_harness() {
226
+ local harness_script runner_inject
227
+ if harness_script="$(resolve_mobile_harness_script)"; then
228
+ log "Installing mobile recipe harness via ${harness_script}"
229
+ RECIPE_HARNESS_ROOT="$(harness_root)" "$harness_script" mobile install --target "$root"
230
+ return 0
231
+ fi
232
+
233
+ runner_inject="$runner_root/orchestration/mobile/inject.sh"
234
+ if [ -x "$runner_inject" ]; then
235
+ log "Installing mobile recipe harness via ${runner_inject}"
236
+ RECIPE_HARNESS_ROOT="$(harness_root)" "$runner_inject" --target "$root"
237
+ return 0
238
+ fi
239
+
240
+ cat >&2 <<EOF
241
+ mm-recipe: mobile recipe harness installer not found.
242
+
243
+ Prepare requires either this runner's mobile injector or an explicit
244
+ recipe-harness script. Set FARMSLOT_RECIPE_HARNESS_SCRIPT or
245
+ RECIPE_HARNESS_SCRIPT to <recipe-harness>/scripts/recipe-harness.
246
+ EOF
247
+ return 1
248
+ }
249
+
250
+ mobile_harness_patched() {
251
+ [ -f "$root/app/dev-tools/AgenticService/AgenticService.ts" ] \
252
+ && grep -q "AgenticService.install" "$root/app/core/NavigationService/NavigationService.ts" 2>/dev/null \
253
+ && grep -q "AgentStepHud" "$root/app/components/Nav/App/App.tsx" 2>/dev/null
254
+ }
255
+
256
+ ensure_mobile_harness_patched() {
257
+ init_context
258
+ if mobile_harness_patched; then
259
+ return 0
260
+ fi
261
+
262
+ local had_metro=false
263
+ if metro_ready; then
264
+ had_metro=true
265
+ fi
266
+
267
+ log "Mobile recipe harness patches missing or stale; reinstalling before launch"
268
+ install_mobile_harness
269
+
270
+ if ! mobile_harness_patched; then
271
+ echo "mm-recipe: mobile recipe harness install completed but required patch markers are still missing" >&2
272
+ return 1
273
+ fi
274
+
275
+ if [ "$had_metro" = true ]; then
276
+ log "Restarting Metro so the rebuilt graph includes the refreshed harness patches"
277
+ stop_metro
278
+ fi
279
+ }
280
+
281
+ # Fast, bounded teaching failure when dependencies are not installed. Metro/yarn
282
+ # can never come up without them, so launching — or retrying the launch — only
283
+ # loops. Grounded in the primary mobile deps marker runtime-decision reads
284
+ # (node_modules/.yarn-state.yml, orchestration/mobile/deps-markers.ts).
285
+ require_mobile_deps() {
286
+ [ -e "$root/node_modules/.yarn-state.yml" ] && return 0
287
+ log "dependencies not installed — run yarn install / yarn setup first."
288
+ log "Next: cd $(shell_quote "$root") && yarn setup"
289
+ return 2
290
+ }
291
+
292
+ metro_ready() {
293
+ curl -sf --max-time 2 "http://localhost:${WATCHER_PORT:-8081}/status" 2>/dev/null | grep -q 'packager-status:running'
294
+ }
295
+
296
+ metro_listener_pids() {
297
+ lsof -nP -iTCP:${WATCHER_PORT:-8081} -sTCP:LISTEN -t 2>/dev/null | tr '\n' ' ' || true
298
+ }
299
+
300
+ metro_listener_valid() {
301
+ local pids pid command required_var
302
+ pids="$(metro_listener_pids)"
303
+ [ -n "$(printf '%s' "$pids" | tr -d ' ')" ] || return 1
304
+
305
+ for pid in $pids; do
306
+ command="$(ps eww -p "$pid" -o command= 2>/dev/null || true)"
307
+ case "$command" in
308
+ *"$root"*) ;;
309
+ *)
310
+ log "Metro listener on port ${WATCHER_PORT} is not owned by this checkout (pid ${pid}); restarting it"
311
+ return 1
312
+ ;;
313
+ esac
314
+ for required_var in ${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID}; do
315
+ if ! printf '%s\n' "$command" | grep -Eq "(^|[[:space:]])${required_var}="; then
316
+ log "Metro listener on port ${WATCHER_PORT} is missing required env ${required_var} (pid ${pid}); restarting it"
317
+ return 1
318
+ fi
319
+ done
320
+ done
321
+
322
+ return 0
323
+ }
324
+
325
+ metro_start_command() {
326
+ local metro_workers="$1" clear_arg="$2"
327
+ local quoted_port quoted_workers
328
+ quoted_port="$(shell_quote "$WATCHER_PORT")"
329
+ quoted_workers="$(shell_quote "$metro_workers")"
330
+ # No redirect here: the caller redirects the whole command to the log so that
331
+ # pre-exec output (env sourcing, set -e failures) is captured too, not just
332
+ # the bundler's own stdout.
333
+ cat <<EOF
334
+ set -e
335
+ set -a
336
+ [ ! -f .js.env ] || . ./.js.env
337
+ [ ! -f .env ] || . ./.env
338
+ [ ! -f .env.local ] || . ./.env.local
339
+ set +a
340
+ export EXPO_NO_TYPESCRIPT_SETUP=1 WATCHER_PORT=${quoted_port} METRO_PORT=${quoted_port} METRO_MAX_WORKERS=${quoted_workers}
341
+ exec yarn expo start --port ${quoted_port}${clear_arg}
342
+ EOF
343
+ }
344
+
345
+
346
+ clear_stale_metro_listener() {
347
+ local pids
348
+ pids="$(metro_listener_pids)"
349
+ [ -n "$pids" ] || return 0
350
+ log "Clearing stale Metro listener on port ${WATCHER_PORT}: $pids"
351
+ # shellcheck disable=SC2086
352
+ kill $pids 2>/dev/null || true
353
+ for _ in $(seq 1 20); do
354
+ pids="$(metro_listener_pids)"
355
+ [ -z "$pids" ] && return 0
356
+ sleep 0.25
357
+ done
358
+ log "Metro listener still held on port ${WATCHER_PORT}; forcing: $pids"
359
+ # shellcheck disable=SC2086
360
+ kill -KILL $pids 2>/dev/null || true
361
+ for _ in $(seq 1 20); do
362
+ pids="$(metro_listener_pids)"
363
+ [ -z "$pids" ] && return 0
364
+ sleep 0.25
365
+ done
366
+ echo "mm-recipe: port ${WATCHER_PORT:-8081} is still occupied after killing stale Metro: $pids" >&2
367
+ return 1
368
+ }
369
+
370
+
371
+ start_metro_viewer_window() {
372
+ local metro_log_abs="$1"
373
+ local session window metro_tmux
374
+ command -v tmux >/dev/null 2>&1 || return 0
375
+ window="metro-${WATCHER_PORT}"
376
+ metro_tmux="$root/$(recipe_runtime_dir)/metro.tmux"
377
+ session=""
378
+ if [ -n "${slot:-}" ] && tmux has-session -t "=mm-${slot}" 2>/dev/null; then
379
+ session="mm-${slot}"
380
+ fi
381
+ if [ -z "$session" ]; then
382
+ session="$(tmux display-message -p '#S' 2>/dev/null || true)"
383
+ fi
384
+ [ -n "$session" ] || return 0
385
+ tmux kill-window -t "${session}:${window}" >/dev/null 2>&1 || true
386
+ tmux new-window -d -t "$session" -n "$window" "exec tail -n +1 -F $(shell_quote "$metro_log_abs")"
387
+ printf '%s:%s\n' "$session" "$window" > "$metro_tmux"
388
+ log "Metro log viewer in tmux window ${session}:${window}"
389
+ }
390
+
391
+ default_metro_workers() {
392
+ if command -v sysctl >/dev/null 2>&1; then
393
+ local cores
394
+ cores="$(sysctl -n hw.perflevel0.physicalcpu 2>/dev/null || sysctl -n hw.physicalcpu 2>/dev/null || true)"
395
+ if [[ "$cores" =~ ^[0-9]+$ ]] && [ "$cores" -gt 0 ]; then
396
+ if [ "$cores" -gt 6 ]; then printf '6\n'; else printf '%s\n' "$cores"; fi
397
+ return 0
398
+ fi
399
+ fi
400
+ printf '4\n'
401
+ }
402
+
403
+ start_metro() {
404
+ init_context
405
+ if metro_ready; then
406
+ if [ "${MOBILE_METRO_CLEAR:-0}" = "1" ]; then
407
+ log "Restarting Metro on port ${WATCHER_PORT} with cleared cache (MOBILE_METRO_CLEAR=1)"
408
+ stop_metro
409
+ elif metro_listener_valid; then
410
+ log "Metro already running on port ${WATCHER_PORT}"
411
+ return 0
412
+ else
413
+ stop_metro
414
+ fi
415
+ fi
416
+ clear_stale_metro_listener
417
+ local metro_workers="${METRO_MAX_WORKERS:-$(default_metro_workers)}"
418
+ local runtime_dir="$(recipe_runtime_dir)"
419
+ local metro_log="$runtime_dir/metro.log"
420
+ local metro_log_abs="$root/$metro_log"
421
+ local metro_pid="$runtime_dir/metro.pid"
422
+ local tail_pid=""
423
+ local clear_arg=""
424
+ [ "${MOBILE_METRO_CLEAR:-0}" = "1" ] && clear_arg=" --clear"
425
+ local metro_cmd
426
+ metro_cmd="$(metro_start_command "$metro_workers" "$clear_arg")"
427
+ log "Starting Metro on port ${WATCHER_PORT} (METRO_MAX_WORKERS=${metro_workers}${MOBILE_METRO_CLEAR:+, clear=${MOBILE_METRO_CLEAR}})"
428
+ log "compact build events below (full log: ${metro_log}, recipe logs --full)"
429
+ # Metro runs detached writing to the log; the tmux window is a read-only tail viewer.
430
+ (
431
+ cd "$root"
432
+ mkdir -p "$(dirname "$metro_log")"
433
+ : > "$metro_log_abs"
434
+ bash -c "$metro_cmd" </dev/null > "$metro_log_abs" 2>&1 &
435
+ echo $! > "$metro_pid"
436
+ )
437
+ start_metro_viewer_window "$metro_log_abs"
438
+ if [ "$(recipe_log_ui_mode)" != "quiet" ]; then
439
+ node "$(recipe_log_tui_script)" watch \
440
+ --log "$metro_log_abs" \
441
+ --events "$(recipe_log_events_limit)" \
442
+ --mode "$(recipe_log_ui_mode)" \
443
+ --label "Metro bundler" \
444
+ --success-pattern 'Dev server ready' &
445
+ tail_pid=$!
446
+ fi
447
+ for _ in $(seq 1 90); do
448
+ if metro_ready; then
449
+ [ -n "$tail_pid" ] && kill "$tail_pid" 2>/dev/null && wait "$tail_pid" 2>/dev/null || true
450
+ log "Metro ready: http://localhost:${WATCHER_PORT}/status"
451
+ log "Metro log: $metro_log (tail with: recipe logs)"
452
+ return 0
453
+ fi
454
+ sleep 1
455
+ done
456
+ [ -n "$tail_pid" ] && kill "$tail_pid" 2>/dev/null && wait "$tail_pid" 2>/dev/null || true
457
+ node "$(recipe_log_tui_script)" events --log "$metro_log_abs" --events "$(recipe_log_events_limit)" >&2 || true
458
+ recipe_failure_playbook metro "Metro did not become ready on port ${WATCHER_PORT}" \
459
+ --prefix mm-recipe --log "$metro_log"
460
+ return 1
461
+ }
462
+
463
+ bundle_url() {
464
+ local platform="$1"
465
+ printf 'http://localhost:%s/index.bundle?platform=%s&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.bytecode=1&transform.routerRoot=app&unstable_transformProfile=hermes-stable\n' \
466
+ "${WATCHER_PORT:-8081}" \
467
+ "$platform"
468
+ }
469
+
470
+ prewarm_bundle() {
471
+ local platform="$1"
472
+ local timeout="${MOBILE_BUNDLE_PREWARM_TIMEOUT:-900}"
473
+ local runtime_dir="$(recipe_runtime_dir)"
474
+ local metro_log="$root/$runtime_dir/metro.log"
475
+ local url bundle_pid elapsed=0 last_progress=""
476
+ [ "${MOBILE_BUNDLE_PREWARM:-1}" != "0" ] || { log "Skipping ${platform} bundle prewarm (MOBILE_BUNDLE_PREWARM=0)"; return 0; }
477
+ url="$(bundle_url "$platform")"
478
+ log "Prewarming ${platform} bundle before launching dev client (set MOBILE_BUNDLE_PREWARM=0 to skip)"
479
+ curl -sfL --connect-timeout 5 --max-time "$timeout" -o /dev/null "$url" &
480
+ bundle_pid=$!
481
+ while kill -0 "$bundle_pid" 2>/dev/null; do
482
+ local progress=""
483
+ if [ -f "$metro_log" ]; then
484
+ progress="$(grep -E "^[[:space:]]*(iOS|Android).*index\\.(js|tsx?).*%|Bundl(ed|ing)|Finished" "$metro_log" | tail -1 | tr -d '\r' || true)"
485
+ fi
486
+ if [ -n "$progress" ] && [ "$progress" != "$last_progress" ]; then
487
+ log "Bundle progress: $progress"
488
+ last_progress="$progress"
489
+ elif [ $((elapsed % 30)) -eq 0 ]; then
490
+ log "Waiting for ${platform} bundle (${elapsed}s/${timeout}s)${last_progress:+ — $last_progress}"
491
+ fi
492
+ if [ "$elapsed" -ge "$timeout" ]; then
493
+ kill "$bundle_pid" 2>/dev/null || true
494
+ wait "$bundle_pid" 2>/dev/null || true
495
+ echo "mm-recipe: ${platform} bundle did not become ready before launch timeout (${timeout}s)" >&2
496
+ tail -80 "$metro_log" >&2 || true
497
+ return 1
498
+ fi
499
+ sleep 3
500
+ elapsed=$((elapsed + 3))
501
+ done
502
+ if wait "$bundle_pid"; then
503
+ log "${platform} bundle ready"
504
+ return 0
505
+ fi
506
+ echo "mm-recipe: ${platform} bundle did not become ready before launch" >&2
507
+ tail -80 "$metro_log" >&2 || true
508
+ return 1
509
+ }
510
+
511
+ sim_target() { printf '%s\n' "${SIM_UDID:-${IOS_SIMULATOR:-booted}}"; }
512
+
513
+ sim_udid_for_target() {
514
+ local target="$1"
515
+ if [[ "$target" =~ ^[0-9A-Fa-f-]{36}$ ]]; then
516
+ printf '%s\n' "$target"
517
+ return 0
518
+ fi
519
+ xcrun simctl list devices available | sed -n "s/.*${target} (\([0-9A-Fa-f-]*\)) (.*/\1/p" | head -1
520
+ }
521
+
522
+
523
+ ios_app_installed() {
524
+ local target="$1" bundle_id="$2"
525
+ xcrun simctl get_app_container "$target" "$bundle_id" app >/dev/null 2>&1
526
+ }
527
+
528
+ run_ios_native_build() {
529
+ local target="$1" reason="$2"
530
+ log "${reason}; installing with yarn start:ios (WATCHER_PORT=${WATCHER_PORT})"
531
+ (
532
+ cd "$root"
533
+ env WATCHER_PORT="$WATCHER_PORT" METRO_PORT="${METRO_PORT:-$WATCHER_PORT}" IOS_SIMULATOR="$target" EXPO_NO_TYPESCRIPT_SETUP=1 yarn start:ios
534
+ )
535
+ }
536
+
537
+ android_serial_args() {
538
+ if [ -n "${ADB_SERIAL:-${ANDROID_SERIAL:-${ANDROID_DEVICE:-}}}" ]; then
539
+ printf '%s\n' "-s" "${ADB_SERIAL:-${ANDROID_SERIAL:-${ANDROID_DEVICE}}}"
540
+ fi
541
+ }
542
+
543
+ android_app_installed() {
544
+ local package_id="$1"
545
+ local serial_args=()
546
+ # shellcheck disable=SC2207
547
+ serial_args=($(android_serial_args))
548
+ adb "${serial_args[@]}" shell pm path "$package_id" 2>/dev/null | grep -q '^package:'
549
+ }
550
+
551
+ run_android_native_build() {
552
+ local reason="$1"
553
+ local serial="${ADB_SERIAL:-${ANDROID_SERIAL:-${ANDROID_DEVICE:-}}}"
554
+ log "${reason}; installing with yarn start:android (WATCHER_PORT=${WATCHER_PORT}${serial:+, adb=${serial}})"
555
+ (
556
+ cd "$root"
557
+ env WATCHER_PORT="$WATCHER_PORT" METRO_PORT="${METRO_PORT:-$WATCHER_PORT}" \
558
+ ${serial:+ADB_SERIAL="$serial" ANDROID_SERIAL="$serial"} \
559
+ EXPO_NO_TYPESCRIPT_SETUP=1 yarn start:android
560
+ )
561
+ }
562
+
563
+ ensure_android_app_for_mode() {
564
+ local package_id="$1" mode="$2"
565
+ case "$mode" in
566
+ fast)
567
+ if android_app_installed "$package_id"; then
568
+ return 0
569
+ fi
570
+ cat >&2 <<EOF
571
+ mm-recipe: fast mobile prepare requires an installed Android dev client (${package_id}).
572
+
573
+ Fast mode only starts/reuses Metro, prewarms the JS bundle, opens the existing
574
+ dev client, and waits for the bridge. Run with --preflight-mode auto or
575
+ --preflight-mode rebuild-native when the native app needs to be installed.
576
+ EOF
577
+ return 1
578
+ ;;
579
+ auto|default)
580
+ if android_app_installed "$package_id"; then
581
+ return 0
582
+ fi
583
+ run_android_native_build "Android dev client ${package_id} is not installed"
584
+ ;;
585
+ rebuild-native|clean)
586
+ run_android_native_build "Rebuilding Android dev client ${package_id} (--preflight-mode ${mode})"
587
+ ;;
588
+ *)
589
+ echo "mm-recipe: unknown mobile preflight mode: $mode" >&2
590
+ return 2
591
+ ;;
592
+ esac
593
+ }
594
+
595
+ ensure_ios_app_for_mode() {
596
+ local target="$1" bundle_id="$2" mode="$3"
597
+ case "$mode" in
598
+ fast)
599
+ if ios_app_installed "$target" "$bundle_id"; then
600
+ return 0
601
+ fi
602
+ cat >&2 <<EOF
603
+ mm-recipe: fast mobile prepare requires an installed iOS dev client (${bundle_id}) on ${target}.
604
+
605
+ Fast mode only starts/reuses Metro, prewarms the JS bundle, opens the existing
606
+ dev client, and waits for the bridge. Run with --preflight-mode auto or
607
+ --preflight-mode rebuild-native when the native app needs to be installed.
608
+ EOF
609
+ return 1
610
+ ;;
611
+ auto|default)
612
+ if ios_app_installed "$target" "$bundle_id"; then
613
+ return 0
614
+ fi
615
+ run_ios_native_build "$target" "iOS dev client ${bundle_id} is not installed on ${target}"
616
+ ;;
617
+ rebuild-native|clean)
618
+ run_ios_native_build "$target" "Rebuilding iOS dev client ${bundle_id} on ${target} (--preflight-mode ${mode})"
619
+ ;;
620
+ *)
621
+ echo "mm-recipe: unknown mobile preflight mode: $mode" >&2
622
+ return 2
623
+ ;;
624
+ esac
625
+ }
626
+
627
+ show_simulator() {
628
+ local target="$1" udid=""
629
+ if [ "$target" = "booted" ]; then
630
+ log "Opening Simulator UI"
631
+ open -a Simulator >/dev/null 2>&1 || true
632
+ osascript -e 'tell application "Simulator" to activate' >/dev/null 2>&1 || true
633
+ return 0
634
+ fi
635
+ udid="$(sim_udid_for_target "$target")"
636
+ if [ -n "$udid" ]; then
637
+ log "Opening Simulator UI for ${target}"
638
+ open -a Simulator --args -CurrentDeviceUDID "$udid" >/dev/null 2>&1 || true
639
+ else
640
+ log "Opening Simulator UI"
641
+ open -a Simulator >/dev/null 2>&1 || true
642
+ fi
643
+ osascript -e 'tell application "Simulator" to activate' >/dev/null 2>&1 || true
644
+ }
645
+
646
+ boot_simulator_if_needed() {
647
+ local target="$1" state=""
648
+ [ "$target" != "booted" ] || return 0
649
+ local line=""
650
+ line="$(xcrun simctl list devices available | grep -F "${target} (" | head -1 || true)"
651
+ case "$line" in *"(Booted)"*) state="Booted" ;; *"(Shutdown)"*) state="Shutdown" ;; esac
652
+ if [ "$state" = "Booted" ]; then return 0; fi
653
+ log "Booting iOS simulator ${target}${state:+ (${state})}"
654
+ xcrun simctl boot "$target" 2>/dev/null || true
655
+ xcrun simctl bootstatus "$target" -b >/dev/null
656
+ }
657
+
658
+ launch_ios() {
659
+ init_context
660
+ require_mobile_deps || return
661
+ ensure_mobile_harness_patched
662
+ start_metro || return
663
+ prewarm_bundle ios || return 1
664
+ local target url encoded bundle_id
665
+ target="$(sim_target)"
666
+ boot_simulator_if_needed "$target"
667
+ show_simulator "$target"
668
+ bundle_id="${IOS_BUNDLE_ID:-io.metamask.MetaMask}"
669
+ ensure_ios_app_for_mode "$target" "$bundle_id" "${MOBILE_PREFLIGHT_MODE:-fast}" || return $?
670
+ xcrun simctl spawn "$target" defaults write "$bundle_id" EXDevMenuIsOnboardingFinished -bool YES 2>/dev/null || true
671
+ encoded="$(python3 - <<PY
672
+ import urllib.parse
673
+ print(urllib.parse.quote('http://localhost:${WATCHER_PORT}?disableOnboarding=1', safe=''))
674
+ PY
675
+ )"
676
+ url="expo-metamask://expo-development-client/?url=${encoded}"
677
+ log "Launching iOS dev client on ${target}"
678
+ xcrun simctl openurl "$target" "$url"
679
+ log "Next: recipe logs # tail Metro"
680
+ log "Next: recipe status # app route/account once loaded"
681
+ }
682
+
683
+ launch_android() {
684
+ init_context
685
+ require_mobile_deps || return
686
+ ensure_mobile_harness_patched
687
+ start_metro || return
688
+ prewarm_bundle android || return 1
689
+ local serial_args=() package_id encoded url
690
+ package_id="${ANDROID_PACKAGE_ID:-io.metamask}"
691
+ ensure_android_app_for_mode "$package_id" "${MOBILE_PREFLIGHT_MODE:-fast}" || return $?
692
+ # shellcheck disable=SC2207
693
+ serial_args=($(android_serial_args))
694
+ adb "${serial_args[@]}" reverse "tcp:${WATCHER_PORT}" "tcp:${WATCHER_PORT}" >/dev/null 2>&1 || true
695
+ encoded="$(python3 - <<PY
696
+ import urllib.parse
697
+ print(urllib.parse.quote('http://localhost:${WATCHER_PORT}?disableOnboarding=1', safe=''))
698
+ PY
699
+ )"
700
+ url="expo-metamask://expo-development-client/?url=${encoded}"
701
+ log "Launching Android dev client"
702
+ adb "${serial_args[@]}" shell am start -a android.intent.action.VIEW -d "$url" >/dev/null
703
+ log "Next: recipe logs # tail Metro"
704
+ log "Next: recipe status # app route/account once loaded"
705
+ }
706
+
707
+ bridge_cmd() {
708
+ init_context
709
+ (cd "$root" && APP_ROOT="$root" node "$bridge" "$@")
710
+ }
711
+
712
+ report_open_debug() {
713
+ local payload="$1" want_json="$2"
714
+ if [ "$want_json" = true ]; then
715
+ printf '%s\n' "$payload"
716
+ return 0
717
+ fi
718
+ node - "$payload" <<'NODE'
719
+ const [text] = process.argv.slice(2);
720
+ try {
721
+ const v = JSON.parse(text);
722
+ if (v.ok) {
723
+ const detail = v.method ? ` via ${v.method}` : '';
724
+ const where = v.port ? ` (port ${v.port})` : '';
725
+ console.log(`mm-recipe: opened debugger${detail}${where}`);
726
+ if (v.target?.url) console.log(`mm-recipe: target ${v.target.url}`);
727
+ process.exit(0);
728
+ }
729
+ console.error(`mm-recipe: ${v.error || 'failed to open debugger'}`);
730
+ if (v.hint) console.error(`mm-recipe: ${v.hint}`);
731
+ process.exit(1);
732
+ } catch (error) {
733
+ console.error(`mm-recipe: ${error.message}`);
734
+ process.exit(1);
735
+ }
736
+ NODE
737
+ }
738
+
739
+ _open_debug_ui() {
740
+ local action="$1"
741
+ shift
742
+ local want_json=false
743
+ while [ "$#" -gt 0 ]; do
744
+ case "$1" in
745
+ --json) want_json=true; shift ;;
746
+ -h|--help)
747
+ if [ "$action" = "dev-menu" ]; then
748
+ echo "Usage: mm-recipe dev-menu [--json]"
749
+ else
750
+ echo "Usage: mm-recipe debug [--json]"
751
+ fi
752
+ return 0
753
+ ;;
754
+ *) echo "mm-recipe debug: unknown arg: $1" >&2; return 2 ;;
755
+ esac
756
+ done
757
+ init_context
758
+ local payload exit_code=0
759
+ payload="$(node "$open_debug_script" --adapter mobile --port "${WATCHER_PORT:-8081}" --action "$action" --json 2>/dev/null)" || exit_code=$?
760
+ if [ "$exit_code" -eq 0 ]; then
761
+ report_open_debug "$payload" "$want_json"
762
+ return 0
763
+ fi
764
+ local fallback=""
765
+ fallback="$(node - "$payload" <<'NODE'
766
+ const [text] = process.argv.slice(2);
767
+ try {
768
+ const v = JSON.parse(text);
769
+ if (v.fallback) process.stdout.write(String(v.fallback));
770
+ } catch {}
771
+ NODE
772
+ )"
773
+ if [ "$fallback" = "cdp-eval" ]; then
774
+ log "Metro HTTP failed; falling back to CDP eval"
775
+ case "$action" in
776
+ dev-menu)
777
+ if [ "$want_json" = true ]; then bridge_cmd eval "NativeModules?.DevSettings?.show?.() ?? 'not supported'"; else bridge_cmd eval "NativeModules?.DevSettings?.show?.() ?? 'not supported'" | pipe_pretty; fi
778
+ ;;
779
+ *)
780
+ if [ "$want_json" = true ]; then bridge_cmd eval "NativeModules?.DevSettings?.openDebugger?.() ?? 'not supported'"; else bridge_cmd eval "NativeModules?.DevSettings?.openDebugger?.() ?? 'not supported'" | pipe_pretty; fi
781
+ ;;
782
+ esac
783
+ return 0
784
+ fi
785
+ report_open_debug "$payload" "$want_json" || return 1
786
+ }
787
+
788
+ open_debug_ui() {
789
+ _open_debug_ui debug "$@"
790
+ }
791
+
792
+ open_dev_menu() {
793
+ _open_debug_ui dev-menu "$@"
794
+ }
795
+
796
+ fixture_password() {
797
+ init_context
798
+ node - "$root" <<'NODE'
799
+ const fs = require('fs');
800
+ const path = require('path');
801
+ const root = process.argv[2];
802
+ const runtimeDir = process.env.RECIPE_RUNTIME_DIR;
803
+ if (!runtimeDir) throw new Error('RECIPE_RUNTIME_DIR is required');
804
+ for (const rel of [`${runtimeDir}/wallet-fixture.json`]) {
805
+ try {
806
+ const data = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
807
+ if (data.password) { process.stdout.write(String(data.password)); process.exit(0); }
808
+ } catch {}
809
+ }
810
+ process.exit(1);
811
+ NODE
812
+ }
813
+
814
+ setup_wallet() {
815
+ init_context
816
+ local fixture="${1:-$(recipe_runtime_dir)/wallet-fixture.json}"
817
+ (cd "$root" && APP_ROOT="$root" "$setup_wallet_script" --fixture "$fixture")
818
+ }
819
+
820
+ setup_wallet_failure_recoverable() {
821
+ local setup_log="$1"
822
+ local metro_log="$root/$(recipe_runtime_dir)/metro.log"
823
+ grep -Eq 'CDP not reachable|Engine\.context\.KeyringController not available|Engine does not exist|Cannot read property '\''transactions'\''|bridge-not-ready|debug-target-missing' "$setup_log" 2>/dev/null && return 0
824
+ tail -200 "$metro_log" 2>/dev/null | grep -Eq 'Cannot read property '\''transactions'\''|Engine does not exist' && return 0
825
+ return 1
826
+ }
827
+
828
+ setup_wallet_with_runtime_recovery() {
829
+ init_context
830
+ local fixture="${1:-$(recipe_runtime_dir)/wallet-fixture.json}"
831
+ local platform="${2:-}"
832
+ local setup_log="$root/$(recipe_runtime_dir)/setup-wallet.last.log"
833
+ mkdir -p "$(dirname "$setup_log")"
834
+
835
+ if setup_wallet "$fixture" > "$setup_log" 2>&1; then
836
+ cat "$setup_log"
837
+ return 0
838
+ fi
839
+
840
+ local rc=$?
841
+ cat "$setup_log" >&2
842
+ if [ "${MOBILE_SETUP_CLEAN_RETRY:-1}" != "1" ] || ! setup_wallet_failure_recoverable "$setup_log"; then
843
+ return "$rc"
844
+ fi
845
+
846
+ log "Wallet setup failed during runtime startup; retrying once with a clean Metro cache for slot port ${WATCHER_PORT}"
847
+ stop_metro
848
+ case "$platform" in
849
+ ios) MOBILE_METRO_CLEAR=1 launch_ios ;;
850
+ android) MOBILE_METRO_CLEAR=1 launch_android ;;
851
+ *) MOBILE_METRO_CLEAR=1 refresh_mobile ;;
852
+ esac
853
+ if setup_wallet "$fixture" > "$setup_log" 2>&1; then
854
+ cat "$setup_log"
855
+ return 0
856
+ fi
857
+ rc=$?
858
+ cat "$setup_log" >&2
859
+ return "$rc"
860
+ }
861
+
862
+ screenshot() {
863
+ init_context
864
+ local out="${1:-temp/recipe-artifacts/screenshot-$(date -u +%Y%m%dT%H%M%SZ).png}"
865
+ mkdir -p "$root/$(dirname "$out")"
866
+ if [ -n "${ANDROID_DEVICE:-${ADB_SERIAL:-${ANDROID_SERIAL:-}}}" ]; then
867
+ local serial_args=()
868
+ [ -n "${ADB_SERIAL:-${ANDROID_SERIAL:-${ANDROID_DEVICE:-}}}" ] && serial_args=(-s "${ADB_SERIAL:-${ANDROID_SERIAL:-${ANDROID_DEVICE}}}")
869
+ adb "${serial_args[@]}" exec-out screencap -p > "$root/$out"
870
+ else
871
+ xcrun simctl io "$(sim_target)" screenshot "$root/$out" >/dev/null
872
+ fi
873
+ log "screenshot: $root/$out"
874
+ }
875
+
876
+ tail_logs() {
877
+ init_context
878
+ local log_file="$root/$(recipe_runtime_dir)/metro.log"
879
+ [ -f "$log_file" ] || { echo "mm-recipe: Metro log missing: $(recipe_runtime_dir)/metro.log. Start with: recipe ios|android" >&2; return 1; }
880
+ if full_logs_requested "$@"; then
881
+ log "full log: $(recipe_runtime_dir)/metro.log (Ctrl-C to stop)"
882
+ tail -f "$log_file"
883
+ return 0
884
+ fi
885
+ log "compact tail — full stream: recipe logs --full"
886
+ exec node "$(recipe_log_tui_script)" tail \
887
+ --log "$log_file" \
888
+ --events "$(recipe_log_events_limit)" \
889
+ --follow \
890
+ --mode compact
891
+ }
892
+
893
+ refresh_mobile() {
894
+ if [ -n "${ANDROID_DEVICE:-${ADB_SERIAL:-${ANDROID_SERIAL:-}}}" ]; then
895
+ launch_android
896
+ else
897
+ launch_ios
898
+ fi
899
+ }
900
+
901
+ status_home() {
902
+ init_context
903
+ if ! json_requested "$@"; then
904
+ recipe_instant_hint "checking mobile runtime..."
905
+ recipe_progress status start "probing Metro, bridge, and readiness"
906
+ fi
907
+ local rs_file="$root/$(recipe_runtime_dir)/runtime-status.json"
908
+ local harness_stale=false harness_root
909
+ harness_root="$(harness_root)"
910
+ mkdir -p "$root/$(recipe_runtime_dir)"
911
+ recipe_warn_harness_stale mobile "$root" "$harness_root" "$runner_root" && harness_stale=false || harness_stale=true
912
+ runtime_status --json > "$rs_file"
913
+ local home_args=(
914
+ --adapter mobile --target "$root" --runtime-dir "$(recipe_runtime_dir)"
915
+ --watcher-port "${WATCHER_PORT:-8081}"
916
+ --runtime-status-file "$rs_file"
917
+ )
918
+ [ "$harness_stale" = true ] && home_args+=(--harness-stale)
919
+ if json_requested "$@"; then
920
+ node "$(recipe_home_script)" "${home_args[@]}" --json
921
+ else
922
+ node "$(recipe_home_script)" "${home_args[@]}"
923
+ fi
924
+ }
925
+
926
+ show_ports() {
927
+ init_context
928
+ local slot_id="" runtime_json="$root/$(recipe_runtime_dir)/agentic-runtime.json"
929
+ if [ -f "$runtime_json" ]; then
930
+ slot_id="$(node - "$runtime_json" <<'NODE'
931
+ const fs = require('fs');
932
+ try {
933
+ const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
934
+ if (data.slotId) process.stdout.write(String(data.slotId));
935
+ } catch {}
936
+ NODE
937
+ )"
938
+ fi
939
+ local args=(
940
+ --mode ports --adapter mobile --target "$root" --runtime-dir "$(recipe_runtime_dir)"
941
+ --watcher-port "${WATCHER_PORT:-8081}" --slot-id "${slot_id:-}"
942
+ )
943
+ if json_requested "$@"; then
944
+ node "$(recipe_home_script)" "${args[@]}" --json
945
+ else
946
+ node "$(recipe_home_script)" "${args[@]}"
947
+ fi
948
+ }
949
+
950
+ decision_field() {
951
+ node - "$1" "$2" <<'NODE'
952
+ const [text, field] = process.argv.slice(2);
953
+ try {
954
+ const value = JSON.parse(text);
955
+ const result = field.split('.').reduce((node, key) => node?.[key], value);
956
+ if (result !== undefined && result !== null) process.stdout.write(String(result));
957
+ } catch {
958
+ process.exit(1);
959
+ }
960
+ NODE
961
+ }
962
+
963
+ runtime_decision_report() {
964
+ init_context
965
+ local metro_log="$root/$(recipe_runtime_dir)/metro.log"
966
+ "$runner" runtime-decision --adapter mobile --target "$root" \
967
+ --watcher-port "${WATCHER_PORT:-8081}" \
968
+ --metro-log "$metro_log" \
969
+ --platform "$(infer_mobile_platform)" \
970
+ "$@"
971
+ }
972
+
973
+ decision() {
974
+ init_context
975
+ run_json_pretty "$runner" runtime-decision --adapter mobile --target "$root" \
976
+ --watcher-port "${WATCHER_PORT:-8081}" \
977
+ --metro-log "$root/$(recipe_runtime_dir)/metro.log" \
978
+ --platform "$(infer_mobile_platform)" \
979
+ "$@"
980
+ }
981
+
982
+ run_mobile_install() {
983
+ init_context
984
+ log "Installing mobile dependencies (yarn setup)"
985
+ if ! (
986
+ cd "$root"
987
+ export PLATFORM="$(infer_mobile_platform)"
988
+ LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 yarn setup
989
+ ); then
990
+ echo "mm-recipe: yarn setup failed" >&2
991
+ return 1
992
+ fi
993
+ if metro_ready; then
994
+ log "Stopping Metro after yarn setup so the next launch rebundles with fresh deps"
995
+ stop_metro
996
+ fi
997
+ export MOBILE_METRO_CLEAR=1
998
+ }
999
+
1000
+ record_mobile_deps_baseline() {
1001
+ init_context
1002
+ local metro_log="$root/$(recipe_runtime_dir)/metro.log"
1003
+ if ! "$runner" runtime-decision --adapter mobile --target "$root" \
1004
+ --watcher-port "${WATCHER_PORT:-8081}" \
1005
+ --metro-log "$metro_log" \
1006
+ --platform "$(infer_mobile_platform)" \
1007
+ --record-baseline --json >/dev/null; then
1008
+ log "failed to record mobile deps baseline"
1009
+ return 1
1010
+ fi
1011
+ }
1012
+
1013
+ up_emit_json() {
1014
+ local report="$1"
1015
+ shift
1016
+ if json_requested "$@"; then
1017
+ printf '%s\n' "$report"
1018
+ fi
1019
+ }
1020
+
1021
+ up_final_json_report() {
1022
+ runtime_decision_report --json --platform "$(infer_mobile_platform)"
1023
+ }
1024
+
1025
+ do_up() {
1026
+ init_context
1027
+ local start=$SECONDS exit_code=0 report action metro_log
1028
+ metro_log="$root/$(recipe_runtime_dir)/metro.log"
1029
+ recipe_progress up start "evaluating mobile runtime"
1030
+ report="$(runtime_decision_report --json)"
1031
+ action="$(decision_field "$report" decision || true)"
1032
+ if dry_run_requested "$@"; then
1033
+ if json_requested "$@"; then
1034
+ printf '%s\n' "$report"
1035
+ else
1036
+ printf '%s\n' "$report" | pipe_pretty
1037
+ fi
1038
+ case "$action" in
1039
+ ready) log "dry-run: would wait for in-app bridge and show route/account" ;;
1040
+ launch) log "dry-run: would start Metro + launch dev client ($(recipe_runtime_dir)/metro.log)" ;;
1041
+ install) log "dry-run: would run yarn setup, then re-check" ;;
1042
+ blocked|unknown|'') log "dry-run: blocked — inspect recipe status" ;;
1043
+ *) log "dry-run: unsupported decision ($action)" ;;
1044
+ esac
1045
+ return 0
1046
+ fi
1047
+ if ! json_requested "$@"; then
1048
+ printf '%s\n' "$report" | pipe_pretty
1049
+ fi
1050
+ case "$action" in
1051
+ ready)
1052
+ recipe_progress up bridge "waiting for in-app bridge"
1053
+ wait_for_bridge || exit_code=$?
1054
+ if [ "$exit_code" -eq 0 ]; then
1055
+ record_mobile_deps_baseline || log "warning: deps baseline not recorded after bridge ready"
1056
+ recipe_done up pass "mobile runtime ready" $((SECONDS - start))
1057
+ up_emit_json "$(up_final_json_report)" "$@"
1058
+ if ! json_requested "$@"; then
1059
+ bridge_cmd status | pipe_pretty
1060
+ fi
1061
+ return 0
1062
+ fi
1063
+ recipe_done up fail "bridge not ready" $((SECONDS - start))
1064
+ up_emit_json "$(up_final_json_report)" "$@"
1065
+ recipe_failure_playbook up "bridge not ready" \
1066
+ --prefix mm-recipe --log "$(recipe_runtime_dir)/metro.log"
1067
+ return "$exit_code"
1068
+ ;;
1069
+ launch)
1070
+ case "$(decision_field "$report" reasonCode || true)" in
1071
+ native-module-stale) export MOBILE_PREFLIGHT_MODE=rebuild-native MOBILE_METRO_CLEAR=1 ;;
1072
+ metro-stale-log|bundle-errors-recoverable) export MOBILE_METRO_CLEAR=1 ;;
1073
+ esac
1074
+ recipe_progress up metro "starting Metro and dev client"
1075
+ refresh_mobile || {
1076
+ recipe_done up fail "Metro/dev client launch failed" $((SECONDS - start))
1077
+ up_emit_json "$(up_final_json_report)" "$@"
1078
+ recipe_failure_playbook metro "Metro/dev client launch failed" \
1079
+ --prefix mm-recipe --log "$(recipe_runtime_dir)/metro.log"
1080
+ return $?
1081
+ }
1082
+ recipe_progress up bridge "waiting for in-app bridge"
1083
+ wait_for_bridge || exit_code=$?
1084
+ if [ "$exit_code" -eq 0 ]; then
1085
+ record_mobile_deps_baseline || log "warning: deps baseline not recorded after bridge ready"
1086
+ recipe_done up pass "mobile runtime ready" $((SECONDS - start))
1087
+ up_emit_json "$(up_final_json_report)" "$@"
1088
+ if ! json_requested "$@"; then
1089
+ bridge_cmd status | pipe_pretty
1090
+ fi
1091
+ return 0
1092
+ fi
1093
+ recipe_done up fail "bridge not ready" $((SECONDS - start))
1094
+ up_emit_json "$(up_final_json_report)" "$@"
1095
+ recipe_failure_playbook up "bridge not ready" \
1096
+ --prefix mm-recipe --log "$(recipe_runtime_dir)/metro.log"
1097
+ return "$exit_code"
1098
+ ;;
1099
+ install)
1100
+ if [ "${RECIPE_UP_INSTALL_ATTEMPTED:-0}" = "1" ]; then
1101
+ report="$(runtime_decision_report --json)"
1102
+ local reason_code
1103
+ reason_code="$(decision_field "$report" reasonCode || true)"
1104
+ recipe_done up fail "install did not resolve runtime (${reason_code:-install})" $((SECONDS - start))
1105
+ up_emit_json "$report" "$@"
1106
+ if ! json_requested "$@"; then
1107
+ printf '%s\n' "$report" | pipe_pretty
1108
+ fi
1109
+ recipe_failure_playbook up "yarn setup completed but runtime-decision is still install" \
1110
+ --prefix mm-recipe --log "$(recipe_runtime_dir)/metro.log"
1111
+ return 2
1112
+ fi
1113
+ recipe_progress up install "installing dependencies"
1114
+ export RECIPE_UP_INSTALL_ATTEMPTED=1
1115
+ run_mobile_install || {
1116
+ recipe_done up fail "yarn setup failed" $((SECONDS - start))
1117
+ up_emit_json "$(up_final_json_report)" "$@"
1118
+ return 1
1119
+ }
1120
+ record_mobile_deps_baseline || log "warning: deps baseline not recorded after yarn setup"
1121
+ RECIPE_UP_INSTALL_ATTEMPTED=1 do_up "$@"
1122
+ ;;
1123
+ blocked|unknown|'')
1124
+ recipe_done up fail "blocked (${action:-unknown})" $((SECONDS - start))
1125
+ up_emit_json "$report" "$@"
1126
+ recipe_failure_playbook up "blocked (${action:-unknown})" \
1127
+ --prefix mm-recipe --log "$(recipe_runtime_dir)/metro.log"
1128
+ return 2
1129
+ ;;
1130
+ *)
1131
+ recipe_done up fail "unsupported decision: $action" $((SECONDS - start))
1132
+ up_emit_json "$report" "$@"
1133
+ recipe_failure_playbook up "unsupported decision: $action" \
1134
+ --prefix mm-recipe --log "$(recipe_runtime_dir)/metro.log"
1135
+ return 2
1136
+ ;;
1137
+ esac
1138
+ }
1139
+
1140
+ stop_metro() {
1141
+ init_context
1142
+ local pids=""
1143
+ local metro_pid="$root/$(recipe_runtime_dir)/metro.pid"
1144
+ local metro_tmux="$root/$(recipe_runtime_dir)/metro.tmux"
1145
+ local marker=""
1146
+ [ -f "$metro_pid" ] && marker="$(cat "$metro_pid" 2>/dev/null || true)"
1147
+ case "$marker" in
1148
+ tmux:*)
1149
+ # Back-compat: Metro started by a pre-upgrade binary stored the window here.
1150
+ tmux kill-window -t "${marker#tmux:}" >/dev/null 2>&1 || true
1151
+ ;;
1152
+ tmux-session:*)
1153
+ tmux kill-session -t "=${marker#tmux-session:}" >/dev/null 2>&1 || true
1154
+ ;;
1155
+ ''|*[!0-9]*)
1156
+ ;;
1157
+ *)
1158
+ pids="$marker"
1159
+ ;;
1160
+ esac
1161
+ if [ -f "$metro_tmux" ]; then
1162
+ local viewer
1163
+ viewer="$(cat "$metro_tmux" 2>/dev/null || true)"
1164
+ [ -n "$viewer" ] && { log "Closing Metro log viewer ${viewer}"; tmux kill-window -t "$viewer" >/dev/null 2>&1 || true; }
1165
+ rm -f "$metro_tmux"
1166
+ fi
1167
+ pids="$pids $(lsof -nP -iTCP:${WATCHER_PORT:-8081} -sTCP:LISTEN -t 2>/dev/null | tr '\n' ' ' || true)"
1168
+ if [ -n "$(printf '%s' "$pids" | tr -d ' ')" ]; then
1169
+ log "Stopping Metro on port ${WATCHER_PORT}: $pids"
1170
+ kill $pids 2>/dev/null || true
1171
+ for _ in $(seq 1 20); do
1172
+ pids="$(lsof -nP -iTCP:${WATCHER_PORT:-8081} -sTCP:LISTEN -t 2>/dev/null | tr '\n' ' ' || true)"
1173
+ [ -z "$pids" ] && break
1174
+ sleep 0.25
1175
+ done
1176
+ if [ -n "$(printf '%s' "$pids" | tr -d ' ')" ]; then
1177
+ log "Metro listener still held on port ${WATCHER_PORT}; forcing: $pids"
1178
+ kill -KILL $pids 2>/dev/null || true
1179
+ fi
1180
+ else
1181
+ log "No Metro listener on port ${WATCHER_PORT:-8081}"
1182
+ fi
1183
+ rm -f "$metro_pid"
1184
+ log "next: recipe up | recipe ios | recipe android"
1185
+ }
1186
+
1187
+ wait_for_bridge() {
1188
+ init_context
1189
+ local log_file="$root/$(recipe_runtime_dir)/bridge-status.log"
1190
+ local metro_log="$root/$(recipe_runtime_dir)/metro.log"
1191
+ mkdir -p "$(dirname "$log_file")"
1192
+ local attempt max_polls
1193
+ max_polls="${MOBILE_BRIDGE_READY_POLLS:-90}"
1194
+ for attempt in $(seq 1 "$max_polls"); do
1195
+ if bridge_cmd status > "$log_file" 2>&1 && node - "$log_file" <<'NODE'
1196
+ const fs = require('fs');
1197
+ const value = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
1198
+ const targets = Array.isArray(value) ? value : [value];
1199
+ if (!targets.some((target) => target && typeof target === 'object' && target.route)) process.exit(1);
1200
+ NODE
1201
+ then
1202
+ log "Mobile bridge ready"
1203
+ return 0
1204
+ fi
1205
+ if [ "$attempt" = "1" ] || [ $((attempt % 5)) -eq 0 ]; then
1206
+ log "Waiting for Mobile bridge (${attempt}/${max_polls}): $(bridge_wait_reason "$log_file" "$metro_log")"
1207
+ fi
1208
+ sleep 2
1209
+ done
1210
+ cat "$log_file" >&2 || true
1211
+ recipe_failure_playbook up "bridge did not become ready on port ${WATCHER_PORT:-8081}" \
1212
+ --prefix mm-recipe --log "$(recipe_runtime_dir)/metro.log"
1213
+ return 1
1214
+ }
1215
+
1216
+ bridge_wait_reason() {
1217
+ local status_file="$1"
1218
+ local metro_log="$2"
1219
+ if ! metro_ready; then
1220
+ printf 'Metro is not reachable on port %s' "${WATCHER_PORT:-8081}"
1221
+ return 0
1222
+ fi
1223
+ local targets
1224
+ targets="$(curl -sf --max-time 2 "http://localhost:${WATCHER_PORT:-8081}/json/list" 2>/dev/null || true)"
1225
+ if [ "$targets" = "[]" ] || [ -z "$targets" ]; then
1226
+ printf 'Metro is running; no React Native debug target yet'
1227
+ bridge_bundle_progress "$metro_log"
1228
+ return 0
1229
+ fi
1230
+ if [ -s "$status_file" ] && grep -qxF '[]' "$status_file"; then
1231
+ printf 'React Native target is visible; in-app bridge is not ready yet'
1232
+ bridge_bundle_progress "$metro_log"
1233
+ return 0
1234
+ fi
1235
+ printf 'bridge probe failed'
1236
+ bridge_bundle_progress "$metro_log"
1237
+ }
1238
+
1239
+ bridge_bundle_progress() {
1240
+ local metro_log="$1"
1241
+ [ -f "$metro_log" ] || return 0
1242
+ local progress
1243
+ progress="$(grep -E '^[[:space:]]*(iOS|Android).*index\\.(js|tsx?).*%|Bundl(ed|ing)|Finished' "$metro_log" | tail -1 | tr -d '\r' || true)"
1244
+ [ -n "$progress" ] && printf ' (%s)' "$progress"
1245
+ }
1246
+
1247
+ runtime_status() {
1248
+ local target="" port="" runtime_dir_arg=""
1249
+ while [ "$#" -gt 0 ]; do
1250
+ case "$1" in
1251
+ --target) target="$2"; shift 2 ;;
1252
+ --port|--cdp-port) port="$2"; shift 2 ;;
1253
+ --runtime-dir) runtime_dir_arg="$2"; shift 2 ;;
1254
+ --json) shift ;;
1255
+ -h|--help)
1256
+ echo "Usage: mm-recipe runtime-status [--target <repo>] [--port <port>] [--runtime-dir <dir>] [--json]"
1257
+ return 0
1258
+ ;;
1259
+ *) echo "mm-recipe runtime-status: unknown arg: $1" >&2; return 2 ;;
1260
+ esac
1261
+ done
1262
+ [ -n "$target" ] && cd "$target"
1263
+ [ -n "$runtime_dir_arg" ] && export RECIPE_RUNTIME_DIR="$runtime_dir_arg"
1264
+ [ -n "$port" ] && export WATCHER_PORT="$port" CDP_PORT="${CDP_PORT:-$port}"
1265
+ init_context
1266
+ local runtime_dir="$(recipe_runtime_dir)"
1267
+ local runtime_abs="$root/$runtime_dir"
1268
+ local status_file="$runtime_abs/runtime-status.json"
1269
+ local metro_log="$runtime_abs/metro.log"
1270
+ mkdir -p "$runtime_abs"
1271
+ node - "$root" "$runtime_dir" "${WATCHER_PORT:-8081}" "$metro_log" "$bridge" "$status_file" <<'NODE'
1272
+ const fs = require('fs');
1273
+ const http = require('http');
1274
+ const cp = require('child_process');
1275
+ const [target, runtimeDir, port, metroLog, bridge, statusFile] = process.argv.slice(2);
1276
+
1277
+ function get(path) {
1278
+ return new Promise((resolve) => {
1279
+ const req = http.get(`http://localhost:${port}${path}`, { timeout: 1500 }, (res) => {
1280
+ let body = '';
1281
+ res.on('data', (chunk) => { body += chunk; });
1282
+ res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, body }));
1283
+ });
1284
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, body: '' }); });
1285
+ req.on('error', () => resolve({ ok: false, body: '' }));
1286
+ });
1287
+ }
1288
+
1289
+ function bundleStatus() {
1290
+ if (!fs.existsSync(metroLog)) return { phase: 'unknown', source: null };
1291
+ const lines = fs.readFileSync(metroLog, 'utf8').split(/\r?\n/u).filter(Boolean);
1292
+ const latest = [...lines].reverse().find((line) =>
1293
+ /^\s*(iOS|Android).*index\.(js|tsx?).*%/u.test(line) ||
1294
+ /Bundl(ed|ing)|Finished/u.test(line)
1295
+ );
1296
+ if (!latest) return { phase: 'unknown', source: metroLog };
1297
+ const progress = /^(?:\s*)(iOS|Android).*?([0-9]+(?:\.[0-9]+)?)%/u.exec(latest);
1298
+ if (progress) {
1299
+ const percent = Number(progress[2]);
1300
+ return {
1301
+ phase: percent >= 100 ? 'bundled' : 'bundling',
1302
+ platform: progress[1].toLowerCase(),
1303
+ percent,
1304
+ detail: latest.trim(),
1305
+ source: metroLog,
1306
+ };
1307
+ }
1308
+ return { phase: /Finished|Bundled/u.test(latest) ? 'bundled' : 'bundling', detail: latest.trim(), source: metroLog };
1309
+ }
1310
+
1311
+ function bridgeStatus() {
1312
+ try {
1313
+ const out = cp.execFileSync(process.execPath, [bridge, 'status'], {
1314
+ cwd: target,
1315
+ env: { ...process.env, APP_ROOT: target, WATCHER_PORT: port },
1316
+ encoding: 'utf8',
1317
+ stdio: ['ignore', 'pipe', 'pipe'],
1318
+ timeout: 5000,
1319
+ });
1320
+ const value = JSON.parse(out || 'null');
1321
+ const targets = Array.isArray(value) ? value : [value];
1322
+ const readyTarget = targets.find((item) => item && typeof item === 'object' && item.route);
1323
+ return { ready: Boolean(readyTarget), targetCount: targets.filter(Boolean).length, status: value, error: null };
1324
+ } catch (error) {
1325
+ return { ready: false, targetCount: 0, status: null, error: error.stderr?.toString?.() || error.message };
1326
+ }
1327
+ }
1328
+
1329
+ (async () => {
1330
+ const metroRaw = await get('/status');
1331
+ const metroReachable = metroRaw.ok && /packager-status:running/u.test(metroRaw.body);
1332
+ const listRaw = await get('/json/list');
1333
+ let targets = [];
1334
+ let debugTargetError = null;
1335
+ try {
1336
+ targets = JSON.parse(listRaw.body || '[]');
1337
+ } catch (error) {
1338
+ debugTargetError = error.message;
1339
+ targets = [];
1340
+ }
1341
+ if (!Array.isArray(targets)) {
1342
+ debugTargetError = 'Metro /json/list did not return an array';
1343
+ targets = [];
1344
+ }
1345
+ const bridge = bridgeStatus();
1346
+ const bundle = bundleStatus();
1347
+ const reason = !metroReachable
1348
+ ? 'metro-down'
1349
+ : targets.length === 0
1350
+ ? (bundle.phase === 'bundling' ? 'bundle-in-progress' : 'debug-target-missing')
1351
+ : !bridge.ready
1352
+ ? 'bridge-not-ready'
1353
+ : 'ready';
1354
+ const status = {
1355
+ schemaVersion: 1,
1356
+ adapter: 'mobile',
1357
+ target,
1358
+ runtimeDir,
1359
+ updatedAt: new Date().toISOString(),
1360
+ ready: reason === 'ready',
1361
+ reason,
1362
+ metro: { reachable: metroReachable, port: Number(port) },
1363
+ bundle,
1364
+ debugTarget: { present: targets.length > 0, count: targets.length, error: debugTargetError },
1365
+ bridge: { ready: bridge.ready, targetCount: bridge.targetCount, error: bridge.error, status: bridge.status },
1366
+ };
1367
+ fs.writeFileSync(statusFile, `${JSON.stringify(status, null, 2)}\n`);
1368
+ console.log(JSON.stringify(status, null, 2));
1369
+ })();
1370
+ NODE
1371
+ }
1372
+
1373
+ assert_runtime_ready() {
1374
+ init_context
1375
+ local status_json attempt max_polls
1376
+ max_polls="${MOBILE_RUNTIME_READY_POLLS:-60}"
1377
+ for attempt in $(seq 1 "$max_polls"); do
1378
+ status_json="$(runtime_status --target "$root" --port "${WATCHER_PORT:-}" --runtime-dir "$(recipe_runtime_dir)" --json)"
1379
+ if STATUS_JSON="$status_json" python3 -c 'import json, os, sys
1380
+ status = json.loads(os.environ["STATUS_JSON"])
1381
+ if status.get("ready"):
1382
+ sys.exit(0)
1383
+ print(
1384
+ "mm-recipe: runtime is not ready after prepare "
1385
+ f"(reason={status.get('"'"'reason'"'"')}, metro={status.get('"'"'metro'"'"')}, "
1386
+ f"debugTarget={status.get('"'"'debugTarget'"'"')}, bridge={status.get('"'"'bridge'"'"')})",
1387
+ file=sys.stderr,
1388
+ )
1389
+ sys.exit(1)'
1390
+ then
1391
+ log "Runtime ready after prepare"
1392
+ return 0
1393
+ fi
1394
+ if [ "$attempt" = "1" ] || [ $((attempt % 5)) -eq 0 ]; then
1395
+ log "Waiting for runtime ready after prepare (${attempt}/${max_polls})"
1396
+ fi
1397
+ sleep 2
1398
+ done
1399
+ STATUS_JSON="$status_json" python3 -c 'import json, os, sys
1400
+ status = json.loads(os.environ["STATUS_JSON"])
1401
+ print(
1402
+ "mm-recipe: runtime is not ready after prepare "
1403
+ f"(reason={status.get('"'"'reason'"'"')}, metro={status.get('"'"'metro'"'"')}, "
1404
+ f"debugTarget={status.get('"'"'debugTarget'"'"')}, bridge={status.get('"'"'bridge'"'"')})",
1405
+ file=sys.stderr,
1406
+ )'
1407
+ return 1
1408
+ }
1409
+
1410
+ do_completion() {
1411
+ local install_script="$runner_root/scripts/install-completions.sh"
1412
+ bash "$install_script" "${1:-install}" "${@:2}"
1413
+ }
1414
+
1415
+ do_sync() {
1416
+ init_context
1417
+ local start=$SECONDS farmslot_slot="" fixture_ready=false summary_file="" runtime_json=""
1418
+ RECIPE_CLI_PREFIX=mm-recipe
1419
+ recipe_progress sync start "refreshing mobile harness + fixture context"
1420
+ runtime_json="$root/$(recipe_runtime_dir)/agentic-runtime.json"
1421
+ if [ -f "$runtime_json" ]; then
1422
+ farmslot_slot="$(node - "$runtime_json" <<'NODE'
1423
+ const fs = require('fs');
1424
+ try {
1425
+ const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
1426
+ if (data.slotId) process.stdout.write(String(data.slotId));
1427
+ } catch {}
1428
+ NODE
1429
+ )"
1430
+ fi
1431
+ [ -n "$farmslot_slot" ] || { [ -n "${slot:-}" ] && farmslot_slot="local-mobile-${slot}"; }
1432
+ log "sync: refreshing harness scripts"
1433
+ if ! install_mobile_harness; then
1434
+ recipe_done sync fail "harness install failed" $((SECONDS - start))
1435
+ return 1
1436
+ fi
1437
+ log "sync: canonicalizing wallet fixture"
1438
+ if recipe_sync_wallet_fixture "$root" "$(recipe_runtime_dir)" "${CDP_PORT:-}" "" "$farmslot_slot"; then
1439
+ fixture_ready=true
1440
+ log "sync: fixture ready at $(recipe_runtime_dir)/wallet-fixture.json"
1441
+ else
1442
+ log "sync: fixture missing - create $(recipe_runtime_dir)/wallet-fixture.json"
1443
+ fi
1444
+ if recipe_check_harness_stale mobile "$root" "$(harness_root)" "$runner_root"; then
1445
+ log "sync: warning - harness still reports stale after install"
1446
+ else
1447
+ log "sync: harness up to date"
1448
+ fi
1449
+ summary_file="$root/$(recipe_runtime_dir)/sync-summary.json"
1450
+ node - "$summary_file" "$fixture_ready" <<'NODE'
1451
+ const fs = require('fs');
1452
+ const [file, fixtureReady] = process.argv.slice(2);
1453
+ fs.writeFileSync(file, `${JSON.stringify({
1454
+ schemaVersion: 1,
1455
+ adapter: 'mobile',
1456
+ status: 'pass',
1457
+ harnessRefreshed: true,
1458
+ fixtureReady: fixtureReady === 'true',
1459
+ generatedAt: new Date().toISOString(),
1460
+ }, null, 2)}\n`);
1461
+ NODE
1462
+ if json_requested "$@"; then
1463
+ cat "$summary_file"
1464
+ fi
1465
+ if $fixture_ready; then
1466
+ recipe_done sync pass "harness refreshed; fixture ready" $((SECONDS - start))
1467
+ else
1468
+ recipe_done sync pass "harness refreshed; fixture missing" $((SECONDS - start))
1469
+ fi
1470
+ return 0
1471
+ }
1472
+
1473
+ prepare_mobile() {
1474
+ local target="" platform="${PLATFORM:-ios}" port="${WATCHER_PORT:-${METRO_PORT:-}}" simulator="" adb_serial="" wallet_setup=false wallet_fixture="" runtime_dir="$(recipe_runtime_dir)" preflight_mode="${RECIPE_HARNESS_MOBILE_PREFLIGHT_MODE:-fast}"
1475
+ while [ "$#" -gt 0 ]; do
1476
+ case "$1" in
1477
+ --target) target="$2"; shift 2 ;;
1478
+ --platform) platform="$2"; shift 2 ;;
1479
+ --preflight-mode|--mode) preflight_mode="$2"; shift 2 ;;
1480
+ --port) port="$2"; shift 2 ;;
1481
+ --simulator) simulator="$2"; shift 2 ;;
1482
+ --adb-serial) adb_serial="$2"; shift 2 ;;
1483
+ --runtime-dir) runtime_dir="$2"; shift 2 ;;
1484
+ --wallet-setup) wallet_setup=true; shift ;;
1485
+ --wallet-fixture) wallet_fixture="$2"; shift 2 ;;
1486
+ -h|--help)
1487
+ echo "Usage: mm-recipe prepare [--target <repo>] [--platform ios|android] [--preflight-mode fast|auto|default|rebuild-native|clean] [--port <port>] [--simulator <sim>] [--adb-serial <serial>] [--runtime-dir <dir>] [--wallet-setup] [--wallet-fixture <json>]"
1488
+ return 0
1489
+ ;;
1490
+ *) echo "mm-recipe prepare: unknown arg: $1" >&2; return 2 ;;
1491
+ esac
1492
+ done
1493
+ case "$preflight_mode" in fast|auto|default|rebuild-native|clean) ;; *) echo "mm-recipe prepare: unknown --preflight-mode: $preflight_mode" >&2; return 2 ;; esac
1494
+ [ -n "$target" ] && cd "$target"
1495
+ export RECIPE_RUNTIME_DIR="$runtime_dir"
1496
+ export RECIPE_HARNESS_MOBILE_PREFLIGHT_MODE="$preflight_mode"
1497
+ export MOBILE_PREFLIGHT_MODE="$preflight_mode"
1498
+ [ -n "$port" ] && export WATCHER_PORT="$port" METRO_PORT="$port" CDP_PORT="${CDP_PORT:-$port}"
1499
+ [ -n "$simulator" ] && export IOS_SIMULATOR="$simulator"
1500
+ if [ -n "$adb_serial" ] && [ "$adb_serial" != "{{adb_serial}}" ]; then
1501
+ export ADB_SERIAL="$adb_serial" ANDROID_SERIAL="$adb_serial"
1502
+ fi
1503
+ init_context
1504
+ install_mobile_harness
1505
+ case "$platform" in
1506
+ ios) launch_ios ;;
1507
+ android) launch_android ;;
1508
+ *) echo "mm-recipe prepare: unknown platform: $platform" >&2; return 2 ;;
1509
+ esac
1510
+ wait_for_bridge
1511
+ if $wallet_setup; then
1512
+ setup_wallet_with_runtime_recovery "${wallet_fixture:-$(recipe_runtime_dir)/wallet-fixture.json}" "$platform"
1513
+ fi
1514
+ assert_runtime_ready
1515
+ }
1516
+
1517
+ run_recipe() {
1518
+ ensure_mobile_harness_patched
1519
+ local recipe="$1"; shift
1520
+ [ -f "$recipe" ] || { echo "mm-recipe: recipe not found: $recipe" >&2; exit 2; }
1521
+ local out="$root/temp/recipe-artifacts/$(basename "$recipe" .json)-$(date -u +%Y%m%dT%H%M%SZ)"
1522
+ log "root=$root port=${WATCHER_PORT:-?} sim=${IOS_SIMULATOR:-${ANDROID_DEVICE:-${ADB_SERIAL:-?}}} out=$out"
1523
+ exec "$runner" run "$recipe" --adapter mobile --project-root "$root" --watcher-port "$WATCHER_PORT" --artifacts-dir "$out" "$@"
1524
+ }
1525
+
1526
+ interactive() {
1527
+ init_context
1528
+ echo "mm-recipe (${root}, port=${WATCHER_PORT:-?}, sim=${IOS_SIMULATOR:-${ANDROID_DEVICE:-${ADB_SERIAL:-?}}})"
1529
+ PS3='choose> '
1530
+ select choice in status ports up refresh ios android logs runtime-status setup-wallet unlock accounts screenshot debug dev-menu doctor actions stop quit; do
1531
+ case "$choice" in
1532
+ status) status_home ;;
1533
+ ports) show_ports ;;
1534
+ up) do_up ;;
1535
+ refresh) refresh_mobile ;;
1536
+ ios) launch_ios ;;
1537
+ android) launch_android ;;
1538
+ logs) tail_logs ;;
1539
+ runtime-status) runtime_status | pipe_pretty ;;
1540
+ setup-wallet) setup_wallet_with_runtime_recovery ;;
1541
+ unlock) bridge_cmd unlock "$(fixture_password)" ;;
1542
+ accounts) bridge_cmd list-accounts | pipe_pretty ;;
1543
+ screenshot) screenshot ;;
1544
+ debug) open_debug_ui ;;
1545
+ dev-menu) open_dev_menu ;;
1546
+ doctor) run_json_pretty "$runner" doctor --adapter mobile --target "$root" ;;
1547
+ actions) run_json_pretty "$runner" actions --adapter mobile ;;
1548
+ stop) stop_metro ;;
1549
+ quit|'') break ;;
1550
+ esac
1551
+ echo
1552
+ done
1553
+ }
1554
+
1555
+ cmd="${1:-status}"
1556
+ [ "$#" -gt 0 ] && shift
1557
+ case "$cmd" in
1558
+ -h|--help) usage; exit 0 ;;
1559
+ interactive|menu) interactive ;;
1560
+ ports) show_ports "$@" ;;
1561
+ up) do_up "$@" ;;
1562
+ decision) decision "$@" ;;
1563
+ sync) do_sync "$@" ;;
1564
+ completion|complete) do_completion "$@" ;;
1565
+ prepare) prepare_mobile "$@" ;;
1566
+ runtime-status) runtime_status "$@" ;;
1567
+ ios|start) launch_ios ;;
1568
+ android) launch_android ;;
1569
+ setup:ios) launch_ios; setup_wallet_with_runtime_recovery "$(recipe_runtime_dir)/wallet-fixture.json" ios ;;
1570
+ setup:android) launch_android; setup_wallet_with_runtime_recovery "$(recipe_runtime_dir)/wallet-fixture.json" android ;;
1571
+ refresh|reload|relaunch) refresh_mobile ;;
1572
+ logs|tail) tail_logs "$@" ;;
1573
+ status|home) status_home "$@" ;;
1574
+ app-status|bridge-status) bridge_cmd status | pipe_pretty ;;
1575
+ route|get-route) bridge_cmd get-route | pipe_pretty ;;
1576
+ navigate) bridge_cmd navigate "$@" ;;
1577
+ back|go-back) bridge_cmd go-back ;;
1578
+ unlock) bridge_cmd unlock "${1:-$(fixture_password)}" ;;
1579
+ setup-wallet|wallet-setup) setup_wallet_with_runtime_recovery "${1:-$(recipe_runtime_dir)/wallet-fixture.json}" ;;
1580
+ accounts|list-accounts) bridge_cmd list-accounts | pipe_pretty ;;
1581
+ select-account|switch-account) bridge_cmd switch-account "${1:?address required}" ;;
1582
+ screenshot) screenshot "${1:-}" ;;
1583
+ debug) open_debug_ui "$@" ;;
1584
+ dev-menu|devmenu|menu) open_dev_menu "$@" ;;
1585
+ stop) stop_metro ;;
1586
+ actions) init_context; run_json_pretty "$runner" actions --adapter mobile "$@" ;;
1587
+ doctor) init_context; run_json_pretty "$runner" doctor --adapter mobile --target "$root" "$@" ;;
1588
+ run) recipe="${1:-}"; [ -n "$recipe" ] || { usage; exit 2; }; shift; run_recipe "$recipe" "$@" ;;
1589
+ *.json) run_recipe "$cmd" "$@" ;;
1590
+ *) echo "mm-recipe: unknown command or missing recipe: $cmd" >&2; usage; exit 2 ;;
1591
+ esac