@deeeed/metamask-harness 0.46.0 → 0.47.1

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 (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +1 -1
  3. package/adapters/manifest.json +12 -4
  4. package/adapters/mobile/wait-for-bridge.cjs +550 -0
  5. package/adapters/mobile/wait-for-bridge.sh +11 -148
  6. package/bin/mm-harness +7 -2
  7. package/dist/adapters/mobile/prepare.js +26 -18
  8. package/dist/adapters/mobile/runtime-decision.js +1 -0
  9. package/dist/cli-commands.js +2 -0
  10. package/dist/command-contract.js +7 -0
  11. package/dist/commands/checklist.js +1 -0
  12. package/dist/commands/help.js +76 -0
  13. package/dist/commands/parse-args.js +2 -1
  14. package/dist/commands/recipe-quality.js +1 -1
  15. package/dist/commands/tutorial.js +46 -0
  16. package/dist/mm-harness-cli.js +45 -6
  17. package/docs/CONTRIBUTING.md +2 -0
  18. package/docs/QA.md +2 -2
  19. package/library/actions/mobile/perps/perps.mjs +1 -1
  20. package/package.json +2 -2
  21. package/scripts/site-contrast.mjs +6 -2
  22. package/site/assets/help-recipes.json +113 -0
  23. package/site/architecture.html +0 -497
  24. package/site/assets/metamask-fox.svg +0 -24
  25. package/site/assets/progress.mjs +0 -323
  26. package/site/assets/style.css +0 -1066
  27. package/site/cheatsheet.html +0 -307
  28. package/site/ecosystem.html +0 -162
  29. package/site/how-it-works.html +0 -692
  30. package/site/index.html +0 -184
  31. package/site/perps-advanced-orders-qa.html +0 -96
  32. package/site/perps.html +0 -265
  33. package/site/recipes.html +0 -423
  34. package/site/reviewers.html +0 -375
  35. package/site/tutorials/index.html +0 -181
  36. package/site/tutorials/v1.html +0 -212
  37. package/site/tutorials/v2.html +0 -207
  38. package/site/tutorials/v3.html +0 -258
  39. package/site/tutorials/v4.html +0 -196
  40. package/site/tutorials/v5.html +0 -164
  41. package/site/tutorials/v6.html +0 -166
  42. package/site/tutorials/v7.html +0 -185
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.47.1 - 2026-09-04
6
+
7
+ ### Fixed
8
+
9
+ - Open the tutorial on the protected MetaMask GitHub Pages site instead of the public unpkg CDN.
10
+ - Stop shipping the static HTML site in the public npm package; retain only the recipe-help JSON required by the CLI.
11
+
12
+ ## 0.47.0 - 2026-09-04
13
+
14
+ ### Added
15
+
16
+ - Add `mm-harness help` for the full human recipe guide and `mm-harness help --json` for the same content as structured agent context. The recipe tutorial renders the same versioned source.
17
+ - Add `mm-harness tutorial` to open the visual tutorial published with the installed harness version.
18
+ - Identify Perps as the most optimized recipe library and invite other teams to add composable domain recipes with support from Arthur.
19
+ - Show that recipes cover functional, visual, analytics, log, and performance proof across development, PR review, and release testing.
20
+ - Add a Performance path to the landing-page work prompts and install the matching Recipe Performance skill during setup.
21
+ - Describe the landing page as MetaMask's trust layer for agentic development instead of using a generic confidence slogan.
22
+ - Keep the still-experimental release validation flow out of the landing-page setup and work prompts.
23
+ - Explain that the pre-1.0 package uses Arthur's npm scope and will move to a MetaMask-owned package at 1.0.
24
+ - Link Farmslot references to `https://farmslot.io` in the site and contributor documentation.
25
+ - Order the landing-page work prompts around the common development flow and label the combined workflow as `QA + Review PR`.
26
+
27
+ ### Fixed
28
+
29
+ - Replace the checkout-owned Mobile console forwarder before bridge readiness checks so `launch` recovers from a live but unresponsive broker left across Metro or Hermes runtime generations.
30
+ - Bound Mobile bridge readiness by one absolute deadline and fail early when a visible React Native target repeatedly cannot answer CDP or never installs `__AGENTIC__`.
31
+ - Preserve provider readiness diagnostics in Mobile Perps start-state failures so authentication and network errors are reported instead of collapsing to a generic not-ready result.
32
+
5
33
  ## 0.46.0 - 2026-09-04
6
34
 
7
35
  ### Added
package/README.md CHANGED
@@ -7,7 +7,7 @@ and runtime paths are detected automatically.
7
7
  - **Action:** one typed operation.
8
8
  - **Recipe:** a reusable, parameterized graph of actions and called recipes.
9
9
 
10
- The generic graph engine and evidence schemas live in Farmslot packages.
10
+ The generic graph engine and evidence schemas live in [Farmslot](https://farmslot.io) packages.
11
11
  `mm-harness` owns MetaMask runtime control and domain capabilities.
12
12
 
13
13
  ## Getting started from zero
@@ -22,7 +22,7 @@
22
22
  "id": "mobile/start-console-forwarder",
23
23
  "entry": "adapters/mobile/start-console-forwarder.sh",
24
24
  "kind": "bash",
25
- "purpose": "Ensure one runner-owned Mobile application console collector is alive after bridge readiness.",
25
+ "purpose": "Ensure one runner-owned Mobile application console collector and broker is alive before bridge readiness depends on it.",
26
26
  "inputs": "--target --port --quiet; env WATCHER_PORT, METRO_PORT, METAMASK_RECIPE_CONSOLE_FORWARD",
27
27
  "outputs": "app-console.log + collector PID file; exit 0 ready / 1 startup failed / 2 bad args"
28
28
  },
@@ -110,9 +110,17 @@
110
110
  "id": "mobile/wait-for-bridge",
111
111
  "entry": "adapters/mobile/wait-for-bridge.sh",
112
112
  "kind": "bash",
113
- "purpose": "Poll the CDP bridge (cdp-bridge.cjs status) until a route target registers; progressive diagnostics on failure.",
114
- "inputs": "--target --port --max-polls; env WATCHER_PORT, MOBILE_BRIDGE_READY_POLLS",
115
- "outputs": "progress on stderr; exit 0 bridge ready / 1 timeout"
113
+ "purpose": "Wait until a platform-bound CDP bridge route is stable under one absolute readiness deadline; classify absent and unresponsive runtimes.",
114
+ "inputs": "--target --port --platform --timeout-ms --max-polls; env WATCHER_PORT, MOBILE_BRIDGE_READY_TIMEOUT_MS, MOBILE_BRIDGE_READY_POLLS",
115
+ "outputs": "progress on stderr; exit 0 bridge ready / 1 classified readiness failure / 2 bad args"
116
+ },
117
+ {
118
+ "id": "mobile/wait-for-bridge-runtime",
119
+ "entry": "adapters/mobile/wait-for-bridge.cjs",
120
+ "kind": "node",
121
+ "purpose": "Apply the bounded Mobile bridge readiness deadline across Metro HTTP probes, CDP status requests, and retry delays.",
122
+ "inputs": "--target --port --platform --timeout-ms --max-polls; env WATCHER_PORT, MOBILE_BRIDGE_READY_TIMEOUT_MS, MOBILE_BRIDGE_READY_POLLS",
123
+ "outputs": "progress on stderr; exit 0 bridge ready / 1 classified readiness failure / 2 bad args"
116
124
  },
117
125
  {
118
126
  "id": "mobile/inject",
@@ -0,0 +1,550 @@
1
+ #!/usr/bin/env node
2
+ // Wait for one platform-bound Mobile bridge without letting a slow CDP probe
3
+ // extend the caller's readiness deadline.
4
+
5
+ 'use strict';
6
+
7
+ const { spawnSync } = require('node:child_process');
8
+ const fs = require('node:fs');
9
+ const http = require('node:http');
10
+ const path = require('node:path');
11
+ const { performance } = require('node:perf_hooks');
12
+
13
+ const {
14
+ describeRequested,
15
+ describeTargets,
16
+ hasMatchingRoute,
17
+ } = require('./bridge-runtime/lib/match-bridge-target.cjs');
18
+ const {
19
+ rankRuntimeCandidates,
20
+ } = require('./bridge-runtime/lib/target-discovery.cjs');
21
+
22
+ const DEFAULT_MAX_POLLS = 90;
23
+ const DEFAULT_POLL_INTERVAL_MS = 2_000;
24
+ const DEFAULT_PROBE_TIMEOUT_MS = 1_500;
25
+ const DEFAULT_AGENTIC_GRACE_MS = 15_000;
26
+ const DEFAULT_UNRESPONSIVE_GRACE_MS = 12_000;
27
+ const PROGRESS_INTERVAL_MS = 10_000;
28
+ const MAX_HTTP_BODY_BYTES = 2 * 1024 * 1024;
29
+
30
+ function parsePositiveInteger(value, label, minimum = 1) {
31
+ const parsed = Number(value);
32
+ if (!Number.isInteger(parsed) || parsed < minimum) {
33
+ throw new Error(`${label} must be an integer of at least ${minimum}.`);
34
+ }
35
+ return parsed;
36
+ }
37
+
38
+ function parseArgs(argv, env = process.env) {
39
+ const options = {
40
+ target: process.cwd(),
41
+ port: env.WATCHER_PORT || env.METRO_PORT || '8081',
42
+ platform: '',
43
+ maxPolls: env.MOBILE_BRIDGE_READY_POLLS || String(DEFAULT_MAX_POLLS),
44
+ timeoutMs: env.MOBILE_BRIDGE_READY_TIMEOUT_MS || '',
45
+ help: false,
46
+ };
47
+ for (let index = 0; index < argv.length; index += 1) {
48
+ const arg = argv[index];
49
+ const next = () => {
50
+ if (index + 1 >= argv.length) throw new Error(`Missing value for ${arg}`);
51
+ index += 1;
52
+ return argv[index];
53
+ };
54
+ if (arg === '--target') options.target = next();
55
+ else if (arg === '--port') options.port = next();
56
+ else if (arg === '--platform') options.platform = next();
57
+ else if (arg === '--max-polls') options.maxPolls = next();
58
+ else if (arg === '--timeout-ms') options.timeoutMs = next();
59
+ else if (arg === '--help' || arg === '-h') options.help = true;
60
+ else throw new Error(`unknown arg: ${arg}`);
61
+ }
62
+
63
+ const maxPolls = parsePositiveInteger(
64
+ options.maxPolls,
65
+ '--max-polls',
66
+ 2,
67
+ );
68
+ const pollIntervalMs = parsePositiveInteger(
69
+ env.MOBILE_BRIDGE_POLL_INTERVAL_MS || String(DEFAULT_POLL_INTERVAL_MS),
70
+ 'MOBILE_BRIDGE_POLL_INTERVAL_MS',
71
+ );
72
+ const timeoutMs = options.timeoutMs
73
+ ? parsePositiveInteger(options.timeoutMs, '--timeout-ms', 100)
74
+ : maxPolls * pollIntervalMs;
75
+ const probeTimeoutMs = parsePositiveInteger(
76
+ env.MOBILE_BRIDGE_PROBE_TIMEOUT_MS || String(DEFAULT_PROBE_TIMEOUT_MS),
77
+ 'MOBILE_BRIDGE_PROBE_TIMEOUT_MS',
78
+ );
79
+ const agenticGraceMs = parsePositiveInteger(
80
+ env.MOBILE_BRIDGE_AGENTIC_GRACE_MS || String(DEFAULT_AGENTIC_GRACE_MS),
81
+ 'MOBILE_BRIDGE_AGENTIC_GRACE_MS',
82
+ );
83
+ const unresponsiveGraceMs = parsePositiveInteger(
84
+ env.MOBILE_BRIDGE_UNRESPONSIVE_GRACE_MS ||
85
+ String(DEFAULT_UNRESPONSIVE_GRACE_MS),
86
+ 'MOBILE_BRIDGE_UNRESPONSIVE_GRACE_MS',
87
+ );
88
+
89
+ return {
90
+ ...options,
91
+ target: path.resolve(options.target),
92
+ maxPolls,
93
+ timeoutMs,
94
+ pollIntervalMs,
95
+ probeTimeoutMs,
96
+ agenticGraceMs,
97
+ unresponsiveGraceMs,
98
+ };
99
+ }
100
+
101
+ function fetchJson(port, pathname, timeoutMs) {
102
+ return new Promise((resolve, reject) => {
103
+ const request = http.get(
104
+ { host: 'localhost', port, path: pathname },
105
+ (response) => {
106
+ let body = '';
107
+ response.setEncoding('utf8');
108
+ response.on('data', (chunk) => {
109
+ body += chunk;
110
+ if (Buffer.byteLength(body) > MAX_HTTP_BODY_BYTES) {
111
+ request.destroy(new Error('Metro response exceeded its bound'));
112
+ }
113
+ });
114
+ response.on('end', () => {
115
+ try {
116
+ resolve(JSON.parse(body));
117
+ } catch (error) {
118
+ reject(new Error(`Metro returned invalid JSON: ${error.message}`));
119
+ }
120
+ });
121
+ },
122
+ );
123
+ request.setTimeout(timeoutMs, () => {
124
+ request.destroy(new Error(`Metro request timed out after ${timeoutMs}ms`));
125
+ });
126
+ request.on('error', reject);
127
+ });
128
+ }
129
+
130
+ function runBridgeStatus({ bridgePath, target, timeoutMs, env }) {
131
+ const result = spawnSync(process.execPath, [bridgePath, 'status'], {
132
+ cwd: target,
133
+ env: {
134
+ ...env,
135
+ APP_ROOT: target,
136
+ CDP_TIMEOUT: String(timeoutMs),
137
+ },
138
+ encoding: 'utf8',
139
+ timeout: timeoutMs,
140
+ killSignal: 'SIGTERM',
141
+ maxBuffer: MAX_HTTP_BODY_BYTES,
142
+ });
143
+ const stdout = result.stdout || '';
144
+ const stderr = result.stderr || '';
145
+ let value;
146
+ try {
147
+ value = JSON.parse(stdout);
148
+ } catch {
149
+ value = undefined;
150
+ }
151
+ return {
152
+ value,
153
+ output:
154
+ stdout || stderr
155
+ ? `${stdout}${stderr}`.trimEnd()
156
+ : result.error?.code === 'ETIMEDOUT'
157
+ ? `bridge status probe exceeded ${timeoutMs}ms`
158
+ : '',
159
+ timedOut: result.error?.code === 'ETIMEDOUT',
160
+ };
161
+ }
162
+
163
+ function rawTargetMatchesRequest(target, platform, env) {
164
+ const deviceName = String(target?.deviceName || '');
165
+ if (platform === 'ios') {
166
+ const simulator = env.IOS_SIMULATOR || '';
167
+ return simulator ? deviceName === simulator : true;
168
+ }
169
+ if (platform === 'android') {
170
+ const androidName =
171
+ env.ANDROID_TARGET_DEVICE_NAME || env.ANDROID_DEVICE || '';
172
+ if (!androidName) return true;
173
+ return (
174
+ deviceName === androidName || deviceName.startsWith(`${androidName} -`)
175
+ );
176
+ }
177
+ return true;
178
+ }
179
+
180
+ function snapshotMatchesRequest(snapshot, platform, env) {
181
+ if (!snapshot || typeof snapshot !== 'object') return false;
182
+ const deviceName = String(snapshot.deviceName || '');
183
+ if (platform === 'ios') {
184
+ if (snapshot.platform && snapshot.platform !== 'ios') return false;
185
+ return env.IOS_SIMULATOR
186
+ ? deviceName === env.IOS_SIMULATOR
187
+ : snapshot.platform === 'ios';
188
+ }
189
+ if (platform === 'android') {
190
+ if (snapshot.platform && snapshot.platform !== 'android') return false;
191
+ const androidName =
192
+ env.ANDROID_TARGET_DEVICE_NAME || env.ANDROID_DEVICE || '';
193
+ return androidName
194
+ ? deviceName === androidName || deviceName.startsWith(`${androidName} -`)
195
+ : snapshot.platform === 'android';
196
+ }
197
+ return !platform;
198
+ }
199
+
200
+ function matchingSnapshots(value, platform, env) {
201
+ const snapshots = Array.isArray(value) ? value : [value];
202
+ return snapshots.filter((snapshot) =>
203
+ snapshotMatchesRequest(snapshot, platform, env),
204
+ );
205
+ }
206
+
207
+ async function observeBridge(config, remainingMs, dependencies = {}) {
208
+ const now = dependencies.now || (() => performance.now());
209
+ const fetch = dependencies.fetchJson || fetchJson;
210
+ const statusProbe = dependencies.runBridgeStatus || runBridgeStatus;
211
+ const startedAt = now();
212
+ const httpTimeoutMs = Math.max(
213
+ 1,
214
+ Math.min(1_000, remainingMs, config.probeTimeoutMs),
215
+ );
216
+ let rawTargets;
217
+ try {
218
+ rawTargets = await fetch(config.port, '/json/list', httpTimeoutMs);
219
+ } catch (error) {
220
+ return {
221
+ metro: 'unreachable',
222
+ statusValue: undefined,
223
+ statusOutput: String(error.message || error),
224
+ };
225
+ }
226
+ const runtimeTargets = rankRuntimeCandidates(rawTargets).filter((target) =>
227
+ rawTargetMatchesRequest(target, config.platform, config.env),
228
+ );
229
+ if (runtimeTargets.length === 0) {
230
+ return {
231
+ metro: 'no-target',
232
+ statusValue: undefined,
233
+ statusOutput: '[]',
234
+ };
235
+ }
236
+
237
+ const remainingAfterHttpMs = remainingMs - (now() - startedAt);
238
+ if (remainingAfterHttpMs <= 0) {
239
+ return {
240
+ metro: 'target-visible',
241
+ statusValue: undefined,
242
+ statusOutput:
243
+ 'bridge status probe skipped: readiness deadline exhausted during Metro target discovery',
244
+ statusTimedOut: true,
245
+ };
246
+ }
247
+
248
+ const probeTimeoutMs = Math.max(
249
+ 1,
250
+ Math.min(config.probeTimeoutMs, remainingAfterHttpMs),
251
+ );
252
+ const status = statusProbe({
253
+ bridgePath: config.bridgePath,
254
+ target: config.target,
255
+ timeoutMs: probeTimeoutMs,
256
+ env: config.env,
257
+ });
258
+ return {
259
+ metro: 'target-visible',
260
+ statusValue: status.value,
261
+ statusOutput: status.output,
262
+ statusTimedOut: status.timedOut,
263
+ };
264
+ }
265
+
266
+ function waitReason(observation, state, config) {
267
+ if (observation.metro === 'unreachable') {
268
+ return `Metro not reachable on port ${config.port}`;
269
+ }
270
+ if (observation.metro === 'no-target') {
271
+ return 'Metro running; no React Native debug target yet';
272
+ }
273
+ if (state.agenticMissingSince !== null) {
274
+ return 'React Native target answered; in-app bridge not installed yet';
275
+ }
276
+ if (state.unresponsiveSince !== null) {
277
+ return 'React Native target visible; CDP is not answering';
278
+ }
279
+ return 'React Native target answered; waiting for an in-app route';
280
+ }
281
+
282
+ function failure(kind, startedAt, deadline, now, attempts, lastObservation) {
283
+ return {
284
+ ok: false,
285
+ kind,
286
+ attempts,
287
+ elapsedMs: Math.max(0, now - startedAt),
288
+ remainingMs: Math.max(0, deadline - now),
289
+ lastObservation,
290
+ };
291
+ }
292
+
293
+ async function waitForBridge(config, dependencies = {}) {
294
+ const now = dependencies.now || (() => performance.now());
295
+ const sleep =
296
+ dependencies.sleep ||
297
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
298
+ const observe = dependencies.observe || observeBridge;
299
+ const progress = dependencies.progress || (() => undefined);
300
+ const status = dependencies.status || (() => undefined);
301
+ const startedAt = now();
302
+ const deadline = startedAt + config.timeoutMs;
303
+ const state = {
304
+ readyStreak: 0,
305
+ unresponsiveSince: null,
306
+ agenticMissingSince: null,
307
+ };
308
+ let attempts = 0;
309
+ let nextProgressAt = startedAt;
310
+ let lastObservation = {
311
+ metro: 'unreachable',
312
+ statusValue: undefined,
313
+ statusOutput: '',
314
+ };
315
+
316
+ while (now() < deadline) {
317
+ attempts += 1;
318
+ const beforeProbe = now();
319
+ lastObservation = await observe(config, deadline - beforeProbe);
320
+ status(lastObservation.statusOutput || '');
321
+ const afterProbe = now();
322
+ if (afterProbe > deadline) break;
323
+ const snapshots = matchingSnapshots(
324
+ lastObservation.statusValue,
325
+ config.platform,
326
+ config.env,
327
+ );
328
+
329
+ if (hasMatchingRoute(lastObservation.statusValue, config.env)) {
330
+ state.readyStreak += 1;
331
+ state.unresponsiveSince = null;
332
+ state.agenticMissingSince = null;
333
+ if (state.readyStreak >= 2) {
334
+ return {
335
+ ok: true,
336
+ attempts,
337
+ elapsedMs: Math.max(0, afterProbe - startedAt),
338
+ remainingMs: Math.max(0, deadline - afterProbe),
339
+ lastObservation,
340
+ };
341
+ }
342
+ } else {
343
+ state.readyStreak = 0;
344
+ const agenticMissing = snapshots.some(
345
+ (snapshot) => snapshot.agenticPresent === false,
346
+ );
347
+ if (agenticMissing) {
348
+ state.agenticMissingSince ??= afterProbe;
349
+ state.unresponsiveSince = null;
350
+ if (afterProbe - state.agenticMissingSince >= config.agenticGraceMs) {
351
+ return failure(
352
+ 'agentic-missing',
353
+ startedAt,
354
+ deadline,
355
+ afterProbe,
356
+ attempts,
357
+ lastObservation,
358
+ );
359
+ }
360
+ } else {
361
+ state.agenticMissingSince = null;
362
+ const targetUnresponsive =
363
+ lastObservation.metro === 'target-visible' && snapshots.length === 0;
364
+ if (targetUnresponsive) state.unresponsiveSince ??= afterProbe;
365
+ else state.unresponsiveSince = null;
366
+ if (
367
+ state.unresponsiveSince !== null &&
368
+ afterProbe - state.unresponsiveSince >= config.unresponsiveGraceMs
369
+ ) {
370
+ return failure(
371
+ 'target-unresponsive',
372
+ startedAt,
373
+ deadline,
374
+ afterProbe,
375
+ attempts,
376
+ lastObservation,
377
+ );
378
+ }
379
+ }
380
+ }
381
+
382
+ if (afterProbe >= nextProgressAt) {
383
+ progress({
384
+ attempts,
385
+ elapsedMs: afterProbe - startedAt,
386
+ remainingMs: Math.max(0, deadline - afterProbe),
387
+ reason: waitReason(lastObservation, state, config),
388
+ });
389
+ nextProgressAt = afterProbe + PROGRESS_INTERVAL_MS;
390
+ }
391
+ const remainingMs = deadline - now();
392
+ if (remainingMs <= 0) break;
393
+ await sleep(Math.min(config.pollIntervalMs, remainingMs));
394
+ }
395
+
396
+ const endedAt = now();
397
+ return failure(
398
+ 'deadline',
399
+ startedAt,
400
+ deadline,
401
+ endedAt,
402
+ attempts,
403
+ lastObservation,
404
+ );
405
+ }
406
+
407
+ function bundleProgress(logPath) {
408
+ let source;
409
+ try {
410
+ source = fs.readFileSync(logPath, 'utf8');
411
+ } catch {
412
+ return '';
413
+ }
414
+ const matches = source
415
+ .split(/\r?\n/u)
416
+ .filter((line) => {
417
+ const trimmed = line.trimStart();
418
+ return (
419
+ /^(?:iOS|Android).*index\.(?:js|tsx?).*%/u.test(trimmed) ||
420
+ line.includes('Bundled') ||
421
+ line.includes('Bundling') ||
422
+ line.includes('Finished')
423
+ );
424
+ });
425
+ return matches.length > 0 ? ` (${matches.at(-1)})` : '';
426
+ }
427
+
428
+ function formatSeconds(ms) {
429
+ return `${(Math.max(0, ms) / 1_000).toFixed(1)}s`;
430
+ }
431
+
432
+ async function main() {
433
+ let parsed;
434
+ try {
435
+ parsed = parseArgs(process.argv.slice(2));
436
+ } catch (error) {
437
+ process.stderr.write(`wait-for-bridge: ${error.message}\n`);
438
+ process.exit(2);
439
+ }
440
+ if (parsed.help) {
441
+ process.stdout.write(
442
+ 'Usage: wait-for-bridge.sh [--target <dir>] [--port <n>] [--timeout-ms <n>] [--max-polls <n>] [--platform ios|android]\n',
443
+ );
444
+ return;
445
+ }
446
+ if (!['', 'ios', 'android'].includes(parsed.platform)) {
447
+ process.stderr.write(
448
+ 'wait-for-bridge: --platform must be ios or android.\n',
449
+ );
450
+ process.exit(2);
451
+ }
452
+ if (!fs.existsSync(parsed.target)) {
453
+ process.stderr.write(
454
+ `wait-for-bridge: target does not exist: ${parsed.target}\n`,
455
+ );
456
+ process.exit(2);
457
+ }
458
+
459
+ const runtimeRelative =
460
+ process.env.MM_HARNESS_WAIT_RUNTIME_DIR ||
461
+ process.env.RECIPE_RUNTIME_DIR ||
462
+ path.join('temp', 'recipe', 'runtime');
463
+ const logDir = path.join(parsed.target, runtimeRelative);
464
+ const statusLog = path.join(logDir, 'bridge-status.log');
465
+ const metroLog = path.join(logDir, 'metro.log');
466
+ const bridgePath = path.join(
467
+ __dirname,
468
+ 'bridge-runtime',
469
+ 'cdp-bridge.cjs',
470
+ );
471
+ if (!fs.existsSync(bridgePath)) {
472
+ process.stderr.write(
473
+ `wait-for-bridge: cdp-bridge.cjs not found at ${bridgePath}\n`,
474
+ );
475
+ process.exit(1);
476
+ }
477
+ fs.mkdirSync(logDir, { recursive: true });
478
+
479
+ const env = {
480
+ ...process.env,
481
+ WAIT_FOR_BRIDGE_PLATFORM: parsed.platform,
482
+ };
483
+ const config = {
484
+ ...parsed,
485
+ bridgePath,
486
+ env,
487
+ };
488
+ const result = await waitForBridge(config, {
489
+ progress(update) {
490
+ process.stderr.write(
491
+ `Waiting for Mobile bridge (${formatSeconds(update.elapsedMs)} elapsed, ${formatSeconds(update.remainingMs)} remaining): ${update.reason}${bundleProgress(metroLog)}\n`,
492
+ );
493
+ },
494
+ status(output) {
495
+ fs.writeFileSync(statusLog, output ? `${output}\n` : '[]\n');
496
+ },
497
+ });
498
+
499
+ if (result.ok) {
500
+ process.stderr.write(
501
+ `Mobile bridge ready (${formatSeconds(result.elapsedMs)})\n`,
502
+ );
503
+ return;
504
+ }
505
+
506
+ try {
507
+ process.stderr.write(fs.readFileSync(statusLog, 'utf8'));
508
+ } catch {}
509
+ const requested = describeRequested(env);
510
+ if (result.kind === 'target-unresponsive') {
511
+ process.stderr.write(
512
+ `wait-for-bridge: React Native target for ${requested} was visible but CDP did not answer for ${formatSeconds(parsed.unresponsiveGraceMs)} (${formatSeconds(result.elapsedMs)} elapsed, ${formatSeconds(result.remainingMs)} remaining).\n`,
513
+ );
514
+ } else if (result.kind === 'agentic-missing') {
515
+ process.stderr.write(
516
+ `wait-for-bridge: React Native target for ${requested} answered without __AGENTIC__ for ${formatSeconds(parsed.agenticGraceMs)}.\n`,
517
+ );
518
+ } else {
519
+ process.stderr.write(
520
+ `wait-for-bridge: readiness deadline expired after ${formatSeconds(result.elapsedMs)} on port ${parsed.port}.\n`,
521
+ );
522
+ }
523
+ process.stderr.write(
524
+ `wait-for-bridge: no bridge target matched the requested ${requested} on port ${parsed.port}.\n`,
525
+ );
526
+ process.stderr.write(` requested: ${requested}\n`);
527
+ process.stderr.write(
528
+ ` answered: ${describeTargets(result.lastObservation.statusValue)}\n`,
529
+ );
530
+ process.stderr.write(
531
+ ` Next: check ${statusLog} — or run mm-harness status --json\n`,
532
+ );
533
+ process.exit(1);
534
+ }
535
+
536
+ if (require.main === module) {
537
+ void main().catch((error) => {
538
+ process.stderr.write(`wait-for-bridge: ${error.message || error}\n`);
539
+ process.exit(1);
540
+ });
541
+ }
542
+
543
+ module.exports = {
544
+ matchingSnapshots,
545
+ observeBridge,
546
+ parseArgs,
547
+ rawTargetMatchesRequest,
548
+ runBridgeStatus,
549
+ waitForBridge,
550
+ };