@deeeed/metamask-harness 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
 
@@ -43,6 +44,9 @@ export interface MobileRuntimeDecisionReport {
43
44
  decision: MobileReadinessDecision;
44
45
  reasonCode: string;
45
46
  reasons: string[];
47
+ // A single teaching escape for the caller's actual situation, set on decisions
48
+ // the runner deliberately refuses to auto-resolve (e.g. fast-mode deps gaps).
49
+ userAction?: string;
46
50
  checks: {
47
51
  deps: DepsCheck;
48
52
  metroLog: MetroLogCheck;
@@ -56,6 +60,9 @@ export interface MobileRuntimeDecisionOptions {
56
60
  metroLog?: string;
57
61
  platform?: string;
58
62
  record?: boolean;
63
+ // 'fast' (quick launch / no --build) makes deps readiness a presence check only:
64
+ // freshness is the orchestrator's deps-phase contract, never re-decided here.
65
+ preflightMode?: string;
59
66
  }
60
67
 
61
68
  const BUNDLE_ERR = /Bundling failed|Unable to resolve /u;
@@ -128,38 +135,38 @@ function missingRequiredDeps(target: string): string[] {
128
135
  }
129
136
  }
130
137
 
131
- // Returns true when any manifest input (package.json or yarn.lock) is newer than
132
- // the install markers written by yarn on a successful install. A reliable signal
133
- // that deps changed since the last install, even when depsCheck found no recorded
134
- // baseline drift (e.g. git checkout preserved an old author-date mtime on the
135
- // manifest, then a new dep was committed, making the manifest newer than markers).
136
- function depsStaleByMtime(target: string): boolean {
137
- // Use the same install markers as depsCheck: the files yarn writes on install.
138
- const INSTALL_MARKERS = ['node_modules/.yarn-state.yml', '.yarn/install-state.gz'];
139
- let markerMtime = 0;
140
- for (const rel of INSTALL_MARKERS) {
141
- try {
142
- const mt = fs.statSync(path.join(target, rel)).mtimeMs;
143
- if (mt > markerMtime) markerMtime = mt;
144
- } catch {
145
- // marker absent; keep searching
146
- }
147
- }
148
- if (markerMtime === 0) return false; // no markers → depsCheck already handles via 'missing'
149
- for (const file of ['package.json', 'yarn.lock']) {
150
- try {
151
- if (fs.statSync(path.join(target, file)).mtimeMs > markerMtime) return true;
152
- } catch {
153
- // manifest absent; skip
154
- }
155
- }
156
- return false;
157
- }
158
-
159
138
  const installActions = (target: string): MobileRuntimeDecisionAction[] => [
160
139
  { id: 'yarn-setup', argv: ['yarn', 'setup'], cwd: target },
161
140
  ];
162
141
 
142
+ // True when a MetaMask dev client process is alive on the target device
143
+ // (IOS_SIMULATOR / ADB_SERIAL, falling back to the booted simulator). Probe
144
+ // failures count as not-running: the worst case is an idempotent relaunch,
145
+ // while failing open would declare a dead app "ready".
146
+ function appRunningOnDevice(platform?: string): boolean {
147
+ try {
148
+ if (platform === 'android') {
149
+ const serial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL;
150
+ const args = serial ? ['-s', serial] : [];
151
+ const out = execFileSync('adb', [...args, 'shell', 'ps', '-A'], {
152
+ encoding: 'utf8',
153
+ stdio: ['ignore', 'pipe', 'ignore'],
154
+ timeout: 5_000,
155
+ });
156
+ return out.includes('io.metamask');
157
+ }
158
+ const device = process.env.IOS_SIMULATOR || 'booted';
159
+ const out = execFileSync('xcrun', ['simctl', 'spawn', device, 'launchctl', 'list'], {
160
+ encoding: 'utf8',
161
+ stdio: ['ignore', 'pipe', 'ignore'],
162
+ timeout: 5_000,
163
+ });
164
+ return out.toLowerCase().includes('io.metamask');
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
163
170
  const launchActions = (target: string, clearMetro = false): MobileRuntimeDecisionAction[] => {
164
171
  const actions: MobileRuntimeDecisionAction[] = [];
165
172
  // start-metro ensures Metro is running; --clear resets the bundle cache.
@@ -184,22 +191,60 @@ export async function decideMobileReadiness(
184
191
  const resolved = path.resolve(target);
185
192
  if (options.record) recordDepsBaseline(resolved);
186
193
 
194
+ const fast = options.preflightMode === 'fast';
195
+ const report = await computeMobileReadiness(resolved, options, fast);
196
+
197
+ // mtime churn is already neutralized above, so a fast-mode 'install' verdict
198
+ // means deps genuinely need work (absent markers, an absent required package, a
199
+ // fingerprint-baseline drift, or Metro unable to resolve a module). In an
200
+ // orchestrated run the deps phase owns installation; having the runner install
201
+ // mid-launch is inverted authority. Surface a teaching block, not an implicit
202
+ // setup, so a runway preflight never re-does the orchestrator's deps work.
203
+ if (fast && report.decision === 'install') {
204
+ return {
205
+ ...report,
206
+ decision: 'blocked',
207
+ reasonCode: 'deps-not-ready',
208
+ reasons: [
209
+ 'Fast preflight found dependencies not ready and does not install them (the orchestrator deps phase owns installation).',
210
+ ...report.reasons,
211
+ ],
212
+ userAction:
213
+ 'run the slot deps/prepare phase; standalone: `yarn setup:expo --no-build-ios --no-build-android` in the checkout, or `mm-harness launch <platform> --build` to install and build',
214
+ actions: [],
215
+ };
216
+ }
217
+ return report;
218
+ }
219
+
220
+ async function computeMobileReadiness(
221
+ resolved: string,
222
+ options: MobileRuntimeDecisionOptions,
223
+ fast: boolean,
224
+ ): Promise<MobileRuntimeDecisionReport> {
187
225
  const rawDeps = depsCheck(resolved, {
188
226
  productMarkers: mobileProductMarkers(options.platform),
189
227
  });
190
- // Mtime-based fallback: when depsCheck found no recorded baseline, supplement
191
- // with a direct mtime comparison if package.json or yarn.lock is newer than
192
- // node_modules, a dep change landed after the last install. Fail fast at
193
- // pre-flight rather than launching a doomed Metro bundle. Skip when a baseline
194
- // was found (hasBaseline=true) so fingerprint-verified 'current' is trusted.
195
- let deps: DepsCheck =
196
- rawDeps.status === 'current' && !rawDeps.hasBaseline && depsStaleByMtime(resolved)
197
- ? { installed: rawDeps.installed, status: 'stale', hasBaseline: rawDeps.hasBaseline }
198
- : rawDeps;
199
- // Required-dep absence: even when fingerprint + mtime reports 'current', a
200
- // top-level dependency could be absent from node_modules (e.g. added in a
201
- // branch merge but install not re-run). Catch at pre-flight before Metro
202
- // discovers it during a 249s bundle run.
228
+ // Presence is authoritative; mtime is only a hint. Without a recorded baseline
229
+ // the only way depsCheck reports 'stale' is mtime drift (a manifest newer than
230
+ // the install markers). In a managed checkout the orchestrator's git phase
231
+ // refreshes tracked-file mtimes on every sync while its deps phase leaves
232
+ // node_modules untouched, so "manifest newer than markers" is normal and NOT
233
+ // proof of drift — treating it as stale re-ran the full yarn setup inside every
234
+ // launch. Trust the installed node_modules and warn; genuine drift is caught by
235
+ // the recorded-baseline fingerprint when one exists (hasBaseline=true stays 'stale').
236
+ let deps: DepsCheck = rawDeps;
237
+ if (rawDeps.status === 'stale' && !rawDeps.hasBaseline) {
238
+ deps = { installed: rawDeps.installed, status: 'current', hasBaseline: false };
239
+ process.stderr.write(
240
+ '[runtime-decision] manifest is newer than install markers (no recorded baseline); ' +
241
+ 'trusting installed node_modules — run yarn setup manually if deps truly changed.\n',
242
+ );
243
+ }
244
+ // Required-dep absence is a presence check (both modes): a top-level dependency
245
+ // can be absent from node_modules even when markers + fingerprint report
246
+ // 'current' (e.g. added in a branch merge but install not re-run). Catch at
247
+ // pre-flight before Metro discovers it during a long bundle run.
203
248
  if (deps.status === 'current') {
204
249
  const absent = missingRequiredDeps(resolved);
205
250
  if (absent.length > 0) {
@@ -373,13 +418,29 @@ export async function decideMobileReadiness(
373
418
  }
374
419
 
375
420
  if (metro.status === 'up' && metroLog.status === 'ok') {
421
+ // Metro health alone is not runtime readiness: the dev client may be
422
+ // installed but not running on the target device (launch must launch).
423
+ // Probe the device before declaring ready; a healthy Metro with a dead
424
+ // app relaunches the client without touching Metro.
425
+ if (!appRunningOnDevice(options.platform)) {
426
+ return {
427
+ schemaVersion: 1,
428
+ adapter: 'mobile',
429
+ target: resolved,
430
+ decision: 'launch',
431
+ reasonCode: 'app-not-running',
432
+ reasons: ['Metro is healthy but the dev client is not running on the target device; launching it.'],
433
+ checks,
434
+ actions: launchActions(resolved),
435
+ };
436
+ }
376
437
  return {
377
438
  schemaVersion: 1,
378
439
  adapter: 'mobile',
379
440
  target: resolved,
380
441
  decision: 'ready',
381
442
  reasonCode: 'healthy',
382
- reasons: ['Dependencies are current and Metro reports a successful bundle. Verify bridge before replay.'],
443
+ reasons: ['Dependencies are current, Metro reports a successful bundle, and the dev client is running. Verify bridge before replay.'],
383
444
  checks,
384
445
  actions: [],
385
446
  };
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
 
7
+ import { color } from './cli-color.ts';
7
8
  import { createDoctorReport, renderRuntimeContext } from './doctor.ts';
8
9
  import {
9
10
  invalidateCompletionCache,
@@ -420,7 +421,7 @@ async function runSelfTest(options: CliOptions) {
420
421
  }
421
422
 
422
423
  async function handleManifest({ options }: ParsedArgs): Promise<number> {
423
- const adapter = adapterOption(options);
424
+ const { adapter } = resolveAdapter(options);
424
425
  const actionManifestPath = actionManifestPathOption(options, adapter);
425
426
  const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
426
427
  await validateManifest(manifest);
@@ -430,7 +431,7 @@ async function handleManifest({ options }: ParsedArgs): Promise<number> {
430
431
  }
431
432
 
432
433
  async function handleActions({ options }: ParsedArgs): Promise<number> {
433
- const adapter = adapterOption(options);
434
+ const { adapter } = resolveAdapter(options);
434
435
  const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
435
436
  await validateManifest(manifest);
436
437
  const action = optionString(options, 'action');
@@ -475,9 +476,38 @@ async function handleDoctor({ options }: ParsedArgs): Promise<number> {
475
476
  }
476
477
 
477
478
  const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
478
- if (json) console.log(JSON.stringify(result, null, 2));
479
+ // Live runtime state, per platform: mobile reports the same read-only readiness
480
+ // the launch decision uses (deps currency, Metro on the slot's port, decision +
481
+ // reasons). Extension/core gain their sections when their readiness probes exist.
482
+ let runtime: { decision: string; reasonCode?: string; reasons: string[]; deps?: string; metro?: string } | undefined;
483
+ if (adapter === 'mobile') {
484
+ try {
485
+ const { mobileRuntimeStatus } = await import('./adapters/mobile/prepare.ts');
486
+ const { resolveMobileSlotPorts } = await import('./commands/launch.ts');
487
+ resolveMobileSlotPorts(target);
488
+ const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : undefined;
489
+ const report = await mobileRuntimeStatus(target, { watcherPort });
490
+ runtime = {
491
+ decision: report.decision,
492
+ reasonCode: report.reasonCode,
493
+ reasons: report.reasons,
494
+ deps: report.checks?.deps?.status,
495
+ metro: report.checks?.metro?.status,
496
+ };
497
+ } catch { /* readiness probe unavailable — doctor stays useful without it */ }
498
+ }
499
+ if (json) console.log(JSON.stringify({ ...result, runtime }, null, 2));
479
500
  else {
480
- console.log(`${result.status} ${adapter} ${result.compatibilityMode} manifest=${actionManifestPath}`);
501
+ const out = (style: string, text: string) => color(style, text, { stream: process.stdout });
502
+ const stateStyle = (value: string | undefined, good: string) => (value === good ? 'ok' : 'warn');
503
+ console.log(`${out(result.status === 'pass' ? 'ok' : 'err', result.status)} ${out('bold', adapter)} ${result.compatibilityMode} ${out('dim', `manifest=${actionManifestPath}`)}`);
504
+ if (runtime) {
505
+ const decisionStyle = runtime.decision === 'ready' ? 'ok' : runtime.decision === 'blocked' ? 'err' : 'warn';
506
+ console.log(
507
+ `${out('label', 'runtime:')} decision=${out(decisionStyle, runtime.decision)}${runtime.reasonCode ? ` ${out('dim', `(${runtime.reasonCode})`)}` : ''} deps=${out(stateStyle(runtime.deps, 'current'), runtime.deps ?? 'unknown')} metro=${out(stateStyle(runtime.metro, 'up'), runtime.metro ?? 'unprobed')}`,
508
+ );
509
+ for (const reason of runtime.reasons) console.log(` ${out('dim', reason)}`);
510
+ }
481
511
  console.log(renderRuntimeContext(result.runtimeContext));
482
512
  }
483
513
  return result.status === 'pass' ? 0 : 1;
@@ -758,6 +788,7 @@ async function handleRuntimeDecision({ options }: ParsedArgs): Promise<number> {
758
788
  metroLog: optionString(options, 'metroLog'),
759
789
  platform: optionString(options, 'platform') ?? process.env.PLATFORM ?? process.env.RECIPE_HARNESS_PLATFORM,
760
790
  record: optionFlag(options, 'record'),
791
+ preflightMode: optionString(options, 'preflightMode'),
761
792
  });
762
793
  if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
763
794
  else console.log(`${report.decision} ${report.reasonCode} — ${report.reasons[0] ?? ''}`);
@@ -1082,6 +1113,47 @@ function emitPlanUsageError(
1082
1113
  // hand it to runRecipe). One execution path, two doors: `call` = one node, `run` =
1083
1114
  // a graph. Inherits run semantics: always-validates (adapter-aware, exit 5), same
1084
1115
  // trace/evidence artifacts, same --json contract.
1116
+ async function handleStop(argv: string[]): Promise<number> {
1117
+ const { options } = parseArgs(argv, 'stop');
1118
+ const json = optionFlag(options, 'json');
1119
+ const { adapter, target } = resolveAdapter(options);
1120
+ if (adapter !== 'mobile') {
1121
+ return usageOut(
1122
+ json,
1123
+ 'stop',
1124
+ `stop owns the mobile Metro dev server; the ${adapter} dev server is slot-managed`,
1125
+ 'stop the extension watcher via its runtime pids: kill $(cat <runtime_dir>/webpack.pid); core runs no dev server',
1126
+ );
1127
+ }
1128
+ const leaf = path.join(runnerDir, 'adapters', 'mobile', 'stop-metro.sh');
1129
+ const args = ['--target', target];
1130
+ const port = optionString(options, 'port') ?? optionString(options, 'watcherPort');
1131
+ if (port) args.push('--port', port);
1132
+ const result = spawnSync('bash', [leaf, ...args], {
1133
+ stdio: json ? 'pipe' : 'inherit',
1134
+ encoding: 'utf8',
1135
+ });
1136
+ const exitCode = result.status ?? 1;
1137
+ if (json) {
1138
+ console.log(
1139
+ JSON.stringify(
1140
+ {
1141
+ schemaVersion: 1,
1142
+ command: 'stop',
1143
+ adapter,
1144
+ target,
1145
+ status: exitCode === 0 ? 'pass' : 'fail',
1146
+ exitCode,
1147
+ output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(),
1148
+ },
1149
+ null,
1150
+ 2,
1151
+ ),
1152
+ );
1153
+ }
1154
+ return exitCode;
1155
+ }
1156
+
1085
1157
  async function handleCall(argv: string[]): Promise<number> {
1086
1158
  // Grammar: mm-harness call <action> [--arg k=v ...] [flags].
1087
1159
  // If the first token is a flag, the action positional is missing — parseCallArgs
@@ -1803,6 +1875,7 @@ export async function main(argv: string[]): Promise<number> {
1803
1875
  // launch/logs/debug/fixtures compose adapter scripts directly and own policy,
1804
1876
  // healing, teaching, and the --json contract.
1805
1877
  if (command === 'launch') return handleLaunch(argv.slice(1));
1878
+ if (command === 'stop') return handleStop(argv.slice(1));
1806
1879
  if (command === 'logs') return handleLogs(argv.slice(1));
1807
1880
  if (command === 'debug') return handleDebug(argv.slice(1));
1808
1881
  if (command === 'fixtures') return handleFixtures(argv.slice(1), { runOneNode });
@@ -8,7 +8,8 @@ import { execFileSync } from 'node:child_process';
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
 
11
- import { handleHarness } from '../harness.ts';
11
+ import { color } from '../cli-color.ts';
12
+ import { handleHarness, readRuntimeContextField, resolveRuntimeContextPath } from '../harness.ts';
12
13
  import { recipeHarnessPath, recipeRuntimeDir, recipeRuntimePath, runnerDir } from '../paths.ts';
13
14
  import type { MetaMaskRecipeAdapter } from '../types.ts';
14
15
  import { prepareMobile } from '../adapters/mobile/prepare.ts';
@@ -19,6 +20,7 @@ import {
19
20
  parseFlags,
20
21
  resolveAdapter,
21
22
  spawnScript,
23
+ spawnScriptStreaming,
22
24
  str,
23
25
  targetOf,
24
26
  usageOut,
@@ -227,15 +229,33 @@ function applyKVLines(output: string, overwrite: boolean): void {
227
229
  case 'SLOT_ID':
228
230
  if (overwrite || !process.env['RECIPE_SLOT_ID']) process.env['RECIPE_SLOT_ID'] = val;
229
231
  break;
232
+ case 'CDP_PORT':
233
+ if (overwrite || !process.env['CDP_PORT']) {
234
+ process.env['CDP_PORT'] = val;
235
+ process.env['RECIPE_CDP_PORT'] = val;
236
+ }
237
+ break;
230
238
  }
231
239
  }
232
240
  }
233
241
 
234
- // Resolve mobile slot port/simulator from the farmslot pool (pool-wins-over-env)
235
- // or from the slot-suffix formula (only fills unset vars). Called before explicit
242
+ // Resolve mobile slot port/simulator: the slot context the orchestrator wrote
243
+ // into the checkout wins first, then the farmslot pool (both overwrite env),
244
+ // then the slot-suffix formula (only fills unset vars). Called before explicit
236
245
  // CLI flag overrides so flags always win at the top.
237
- function resolveMobileSlotPorts(target: string): void {
246
+ export function resolveMobileSlotPorts(target: string): void {
238
247
  const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
248
+ try {
249
+ // The checkout's own runtime context is authoritative — it names the exact
250
+ // simulator/port this slot was prepared with, surviving pool renames.
251
+ const ctxOut = execFileSync('bash', [
252
+ '-c', `source "${resolveScript}" && resolve_mobile_runtime_context "${target}"`,
253
+ ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
254
+ if (ctxOut.trim()) {
255
+ applyKVLines(ctxOut, true);
256
+ return;
257
+ }
258
+ } catch { /* no runtime context — fall through to pool */ }
239
259
  try {
240
260
  // Pool match always wins — overwrite whatever is in the environment.
241
261
  const poolOut = execFileSync('bash', [
@@ -255,6 +275,43 @@ function resolveMobileSlotPorts(target: string): void {
255
275
  } catch { /* no slot suffix in dir name — stays at env defaults */ }
256
276
  }
257
277
 
278
+ // Resolve extension slot ports the same way as mobile: the checkout's runtime
279
+ // context first (cdpPort/devServerPort written by the orchestrator's prepare),
280
+ // then the farmslot pool, then the directory-suffix formula (fills unset only).
281
+ function resolveExtensionSlotPorts(target: string): void {
282
+ // The prepared checkout's context OVERWRITES inherited env (same authority as
283
+ // mobile's context/pool resolution): a stale CDP_PORT from the shell must not
284
+ // hijack the slot's browser. Explicit CLI flags are applied after and win.
285
+ const contextPath = resolveRuntimeContextPath(target);
286
+ const cdp = readRuntimeContextField(contextPath, 'cdpPort');
287
+ if (cdp) {
288
+ process.env['CDP_PORT'] = cdp;
289
+ process.env['RECIPE_CDP_PORT'] = cdp;
290
+ }
291
+ const dev = readRuntimeContextField(contextPath, 'devServerPort');
292
+ if (dev) {
293
+ process.env['WATCHER_PORT'] = dev;
294
+ process.env['RECIPE_WATCHER_PORT'] = dev;
295
+ }
296
+ if (process.env['CDP_PORT']) return;
297
+ const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
298
+ try {
299
+ const poolOut = execFileSync('bash', [
300
+ '-c', `source "${resolveScript}" && resolve_farmslot_ports_by_repo "${target}"`,
301
+ ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
302
+ if (poolOut.trim()) {
303
+ applyKVLines(poolOut, true);
304
+ return;
305
+ }
306
+ } catch { /* no pool match — fall through to formula */ }
307
+ try {
308
+ const defOut = execFileSync('bash', [
309
+ '-c', `source "${resolveScript}" && resolve_default_extension_ports "${target}"`,
310
+ ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
311
+ if (defOut.trim()) applyKVLines(defOut, false);
312
+ } catch { /* no slot suffix in dir name — stays at env defaults */ }
313
+ }
314
+
258
315
  // Map the env-gap flags onto the env vars the leaf scripts read. For mobile, slot
259
316
  // port/simulator defaults are resolved first (pool-wins, then formula-fills-empty)
260
317
  // so that the harness respects slot isolation; explicit CLI flags applied below
@@ -265,9 +322,12 @@ function applyLaunchEnvOverrides(
265
322
  mobileTarget: string | undefined,
266
323
  target: string,
267
324
  ): void {
268
- // Mobile slot isolation: resolve WATCHER_PORT / IOS_SIMULATOR / SLOT_ID from
269
- // the farmslot pool or directory-suffix formula BEFORE applying explicit flags.
325
+ // Slot isolation: resolve ports/device from the checkout's own slot context,
326
+ // the farmslot pool, or the directory-suffix formula BEFORE applying explicit
327
+ // flags — extension needs this as much as mobile (CDP_PORT), so neither
328
+ // adapter hard-fails on a value its slot already knows.
270
329
  if (adapter === 'mobile') resolveMobileSlotPorts(target);
330
+ if (adapter === 'extension') resolveExtensionSlotPorts(target);
271
331
 
272
332
  const device = str(options, 'device');
273
333
  if (device && adapter === 'mobile') {
@@ -346,17 +406,19 @@ async function executeComposition(
346
406
  return extensionRebuild(target, json);
347
407
  }
348
408
 
349
- // Extension build or watch: full webpack compile via start-watch.sh.
409
+ // Extension build or watch: full webpack compile via start-watch.sh. Streams
410
+ // live — a webpack compile is minutes long and must never look hung.
350
411
  const startWatchSh = path.join(runnerDir, 'adapters/extension/start-watch.sh');
351
412
  const watchArgs = ['--target', target];
352
413
  if (process.env.WATCHER_PORT) watchArgs.push('--watcher-port', process.env.WATCHER_PORT);
353
- return spawnScript(startWatchSh, watchArgs, target, json);
414
+ console.error(`→ extension ${wantWatch ? 'watch' : 'build'} — webpack :${process.env.WATCHER_PORT ?? 'default'} (output streams below)`);
415
+ return spawnScriptStreaming(startWatchSh, watchArgs, target);
354
416
  }
355
417
 
356
418
  // Extension rebuild: kill the harness-owned webpack watcher (pid file + ps-scan
357
419
  // for orphans), clear the rebuild log, then drive the installed overlay's
358
420
  // live.sh --start-watch and tee its output into the rebuild log.
359
- function extensionRebuild(target: string, json: boolean): ScriptResult {
421
+ async function extensionRebuild(target: string, json: boolean): Promise<ScriptResult> {
360
422
  const runtimeDirRel = recipeRuntimeDir();
361
423
  const runtimeAbs = path.join(target, runtimeDirRel);
362
424
  const webpackPidFile = path.join(runtimeAbs, 'recipe-harness-webpack.pid');
@@ -418,7 +480,10 @@ function extensionRebuild(target: string, json: boolean): ScriptResult {
418
480
  const liveArgs = ['--target', target, '--start-watch'];
419
481
  if (process.env.CDP_PORT) liveArgs.push('--cdp-port', process.env.CDP_PORT);
420
482
  if (process.env.WATCHER_PORT) liveArgs.push('--watcher-port', process.env.WATCHER_PORT);
421
- const result = spawnScript(liveScript, liveArgs, target, json);
483
+ // Streams live: the watcher restart + Chromium boot runs for minutes and the
484
+ // command must show progress immediately, not a silent prompt.
485
+ console.error(`→ extension quick relaunch — webpack :${process.env.WATCHER_PORT ?? 'default'} · CDP :${process.env.CDP_PORT ?? 'default'} (output streams below)`);
486
+ const result = await spawnScriptStreaming(liveScript, liveArgs, target);
422
487
  // Tee live.sh output into rebuild.log for post-mortem diagnosis.
423
488
  if (result.output) {
424
489
  try { fs.appendFileSync(rebuildLog, result.output); } catch { /* best-effort */ }
@@ -489,7 +554,18 @@ function launchPass(
489
554
  ),
490
555
  );
491
556
  } else {
492
- console.error(`✓ launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ''} (${tier}) ready`);
557
+ // The summary must say what actually happened where: the target device,
558
+ // what the tier meant, and that the app+bridge are up — "(quick) ready"
559
+ // alone reads as a no-op even when a launch occurred.
560
+ const device =
561
+ adapter === 'mobile'
562
+ ? process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || 'booted device'
563
+ : displayMode;
564
+ const tierNote = tier === 'quick' ? 'quick relaunch, no native build' : tier;
565
+ const devNote = process.env.MM_HARNESS_BIN ? ` ${color('dim', '[dev: MM_HARNESS_BIN]')}` : '';
566
+ console.error(
567
+ `${color('ok', '✓')} launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ''} — ${color('ok', String(device))} · ${tierNote} · app + bridge ready${devNote}`,
568
+ );
493
569
  }
494
570
  return EXIT.ok;
495
571
  }
@@ -7,7 +7,7 @@
7
7
  // For node invocations (bin === process.execPath) the stem is derived from the
8
8
  // script path in args[0], e.g. MM_HARNESS_SCRIPT_BIN_OPEN_DEBUG_MJS.
9
9
 
10
- import { spawnSync } from 'node:child_process';
10
+ import { spawn, spawnSync } from 'node:child_process';
11
11
  import path from 'node:path';
12
12
 
13
13
  import { detectAdapter } from '../harness.ts';
@@ -151,6 +151,67 @@ export function spawnScript(
151
151
  return { status: result.status ?? 1, output };
152
152
  }
153
153
 
154
+ // Streaming variant of spawnScript for long-running leaves (mobile prepare:
155
+ // native build + Metro + health-bridge poll, minutes long). spawnScript buffers
156
+ // via spawnSync and, in --json mode, suppresses output entirely — so those leaves
157
+ // run with zero feedback until exit. This tees the child's stdout+stderr to the
158
+ // parent's STDERR live (so the --json envelope on stdout stays clean) while still
159
+ // capturing the combined output for heal classification. Same seam, leaf-invoke
160
+ // resolution, missing-leaf pre-check, and spawn-error contract as spawnScript.
161
+ export function spawnScriptStreaming(
162
+ script: string,
163
+ args: string[],
164
+ cwd: string,
165
+ env?: Record<string, string>,
166
+ ): Promise<ScriptResult> {
167
+ const isNodeScript = script === process.execPath && args.length > 0;
168
+ const stem = isNodeScript
169
+ ? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, '_').toUpperCase()
170
+ : path.basename(script).replace(/[^A-Za-z0-9]/gu, '_').toUpperCase();
171
+ const override = process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
172
+ const bin = override ?? script;
173
+ const directArgs = override !== undefined && isNodeScript ? args.slice(1) : args;
174
+
175
+ if (shellLeafMissing(bin)) {
176
+ const message =
177
+ `leaf could not start: ${path.basename(bin)} (ENOENT)\n` +
178
+ ` Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) — the shell leaf is missing or not executable`;
179
+ process.stderr.write(`${message}\n`);
180
+ return Promise.resolve({ status: 1, output: message });
181
+ }
182
+
183
+ const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
184
+ return new Promise<ScriptResult>((resolve) => {
185
+ const child = spawn(invokeBin, spawnArgs, {
186
+ cwd,
187
+ env: env ? { ...process.env, ...env } : process.env,
188
+ stdio: ['ignore', 'pipe', 'pipe'],
189
+ });
190
+ let output = '';
191
+ // Route BOTH child streams to the parent's stderr, live: stdout is reserved
192
+ // for the harness --json envelope, so all human/leaf progress goes to stderr.
193
+ const tee = (chunk: Buffer): void => {
194
+ const text = chunk.toString('utf8');
195
+ output += text;
196
+ process.stderr.write(text);
197
+ };
198
+ child.stdout?.on('data', tee);
199
+ child.stderr?.on('data', tee);
200
+ child.on('error', (error: NodeJS.ErrnoException) => {
201
+ const leaf = isNodeScript ? path.basename(args[0]) : path.basename(script);
202
+ const code = error.code ?? 'ESPAWN';
203
+ const message =
204
+ `leaf could not start: ${leaf} (${code})\n` +
205
+ ` Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) — the shell leaf is missing or not executable`;
206
+ process.stderr.write(`${message}\n`);
207
+ resolve({ status: 1, output: message });
208
+ });
209
+ child.on('close', (status) => {
210
+ resolve({ status: status ?? 1, output });
211
+ });
212
+ });
213
+ }
214
+
154
215
  // Both escapes from a failed repo-type detection (defined at the detection source
155
216
  // in harness.ts): where to run it (checkout/target) and how to force it (adapter).
156
217
  export { ADAPTER_DETECT_NEXT } from '../harness.ts';
package/src/doctor.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
+ import { color } from './cli-color.ts';
4
5
  import { readRuntimeContextField, resolveRuntimeContextPath } from './harness.ts';
5
6
  import { manifestPath, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from './paths.ts';
6
7
  import type {
@@ -100,26 +101,32 @@ const RUNTIME_CONTEXT_FIELDS: ReadonlyArray<{
100
101
  envVars: readonly string[];
101
102
  envVar: string | null;
102
103
  customize: string;
104
+ adapters: readonly MetaMaskRecipeAdapter[];
103
105
  }> = [
104
- { key: 'slotId', envVars: ['RECIPE_SLOT_ID'], envVar: 'RECIPE_SLOT_ID', customize: 'farmslot dispatch writes this' },
105
- { key: 'extensionId', envVars: ['RECIPE_HARNESS_EXTENSION_ID'], envVar: 'RECIPE_HARNESS_EXTENSION_ID', customize: 'auto-resolved; edit file to pin' },
106
- { key: 'cdpPort', envVars: ['RECIPE_CDP_PORT', 'CDP_PORT'], envVar: 'CDP_PORT', customize: 'edit file or pass --cdp-port' },
107
- { key: 'runtimeStart.approved', envVars: ['RECIPE_RUNTIME_START_APPROVED'], envVar: 'RECIPE_RUNTIME_START_APPROVED', customize: 'edit file (true/false)' },
108
- { key: 'runtimeStart.command', envVars: [], envVar: null, customize: 'edit file' },
109
- { key: 'runtimeStart.readyUrl', envVars: ['RECIPE_RUNTIME_READY_URL'], envVar: 'RECIPE_RUNTIME_READY_URL', customize: 'edit file' },
106
+ { key: 'slotId', envVars: ['RECIPE_SLOT_ID'], envVar: 'RECIPE_SLOT_ID', customize: 'farmslot dispatch writes this', adapters: ['mobile', 'extension', 'core'] },
107
+ { key: 'extensionId', envVars: ['RECIPE_HARNESS_EXTENSION_ID'], envVar: 'RECIPE_HARNESS_EXTENSION_ID', customize: 'auto-resolved; edit file to pin', adapters: ['extension'] },
108
+ { key: 'cdpPort', envVars: ['RECIPE_CDP_PORT', 'CDP_PORT'], envVar: 'CDP_PORT', customize: 'edit file or pass --cdp-port', adapters: ['extension'] },
109
+ { key: 'runtimeStart.approved', envVars: ['RECIPE_RUNTIME_START_APPROVED'], envVar: 'RECIPE_RUNTIME_START_APPROVED', customize: 'edit file (true/false)', adapters: ['mobile', 'extension'] },
110
+ { key: 'runtimeStart.command', envVars: [], envVar: null, customize: 'edit file', adapters: ['mobile', 'extension'] },
111
+ { key: 'runtimeStart.readyUrl', envVars: ['RECIPE_RUNTIME_READY_URL'], envVar: 'RECIPE_RUNTIME_READY_URL', customize: 'edit file', adapters: ['mobile', 'extension'] },
110
112
  ];
111
113
 
112
114
  // Report every runtime-context field with its current value and where it came from
113
115
  // (live env override > file > unset default), reusing the harness's own file reader
114
116
  // so doctor and dispatch agree on resolution. Absent file → fileExists:false and
115
117
  // every field falls back to env or default; the path shows where it WOULD live.
116
- export function runtimeContextSummary(target: string): MetaMaskRuntimeContextReport {
118
+ // An adapter scopes the report to that platform's fields — an extension-only row
119
+ // (cdpPort, extensionId) is noise on a mobile or core slot.
120
+ export function runtimeContextSummary(target: string, adapter?: MetaMaskRecipeAdapter): MetaMaskRuntimeContextReport {
117
121
  const contextPath = resolveRuntimeContextPath(target);
118
122
  const envOverride = process.env.RECIPE_RUNTIME_CONTEXT ?? null;
119
123
  const fileExists = fs.existsSync(contextPath);
120
124
  const file = envOverride ?? path.relative(target, contextPath);
121
125
  const fields: Record<string, MetaMaskRuntimeContextField> = {};
122
- for (const spec of RUNTIME_CONTEXT_FIELDS) {
126
+ const specs = adapter
127
+ ? RUNTIME_CONTEXT_FIELDS.filter((spec) => spec.adapters.includes(adapter))
128
+ : RUNTIME_CONTEXT_FIELDS;
129
+ for (const spec of specs) {
123
130
  const envValue = spec.envVars
124
131
  .map((name) => process.env[name])
125
132
  .find((value) => value !== undefined && value !== '');
@@ -137,16 +144,21 @@ export function runtimeContextSummary(target: string): MetaMaskRuntimeContextRep
137
144
 
138
145
  // Human-readable runtime-context section for `doctor` without --json.
139
146
  export function renderRuntimeContext(runtimeContext: MetaMaskRuntimeContextReport): string {
147
+ // Human render: set values read bright with their provenance highlighted;
148
+ // unset defaults read dim so the eye lands on what is actually configured.
149
+ const out = (style: string, text: string) => color(style, text, { stream: process.stdout });
140
150
  const lines: string[] = [];
141
151
  lines.push(
142
152
  runtimeContext.fileExists
143
- ? `runtime-context: ${runtimeContext.file} (present)`
144
- : `runtime-context: ${runtimeContext.file} (absent — written by farmslot prepare/dispatch)`,
153
+ ? `${out('label', 'runtime-context:')} ${runtimeContext.file} ${out('ok', '(present)')}`
154
+ : `${out('label', 'runtime-context:')} ${runtimeContext.file} ${out('dim', '(absent — written by farmslot prepare/dispatch)')}`,
145
155
  );
146
156
  for (const [key, field] of Object.entries(runtimeContext.fields)) {
147
- const value = field.value ?? '(unset)';
157
+ const isSet = field.value !== undefined && field.value !== null && field.value !== '';
158
+ const value = isSet ? out('ok', String(field.value)) : out('dim', '(unset)');
148
159
  const origin = field.source === 'env' && field.envVar ? `env ${field.envVar}` : field.source;
149
- lines.push(` ${key.padEnd(22)} ${value} [${origin}] ${field.customize}`);
160
+ const originTag = isSet ? out('accent', `[${origin}]`) : out('dim', `[${origin}]`);
161
+ lines.push(` ${key.padEnd(22)} ${value} ${originTag} ${out('dim', `— ${field.customize}`)}`);
150
162
  }
151
163
  return lines.join('\n');
152
164
  }
@@ -189,7 +201,7 @@ export function createDoctorReport(
189
201
  compatibilityMode: mode,
190
202
  shape: repoShape(target),
191
203
  fixture: fixtureSummary(target),
192
- runtimeContext: runtimeContextSummary(target),
204
+ runtimeContext: runtimeContextSummary(target, adapter),
193
205
  manifestValidation: manifestValidation.summary,
194
206
  };
195
207
  }