@deeeed/metamask-harness 0.2.0 → 0.3.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 (110) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +47 -17
  3. package/{orchestration → adapters}/core/cleanup.sh +2 -2
  4. package/{orchestration → adapters}/core/inject.sh +8 -8
  5. package/{orchestration → adapters}/extension/cleanup.mjs +1 -1
  6. package/{orchestration → adapters}/extension/console-tail.mjs +3 -0
  7. package/{orchestration → adapters}/extension/ensure-browser.sh +4 -4
  8. package/{orchestration → adapters}/extension/inject.mjs +27 -31
  9. package/{orchestration → adapters}/extension/launch.sh +4 -4
  10. package/{orchestration → adapters}/extension/live.sh +5 -5
  11. package/{orchestration → adapters}/extension/readiness.mjs +12 -2
  12. package/{orchestration → adapters}/extension/refresh-build.sh +1 -1
  13. package/{orchestration → adapters}/extension/seed-fixture.sh +1 -1
  14. package/{orchestration → adapters}/extension/start-watch.sh +7 -7
  15. package/{runner → adapters}/extension/verify.sh +11 -11
  16. package/{orchestration → adapters}/extension/wallet-fixture-state.cjs +2 -2
  17. package/{orchestration → adapters}/manifest.json +96 -96
  18. package/{library/actions → adapters}/mobile/bridge-runtime/cdp-bridge.cjs +5 -5
  19. package/{orchestration → adapters}/mobile/cleanup.sh +3 -3
  20. package/{orchestration → adapters}/mobile/inject.sh +22 -24
  21. package/adapters/mobile/open-device.sh +260 -0
  22. package/{orchestration/compat-overlays → adapters/mobile/overlay/compat}/README.md +13 -4
  23. package/adapters/mobile/prewarm-bundle.sh +95 -0
  24. package/adapters/mobile/start-metro.sh +228 -0
  25. package/{runner → adapters}/mobile/verify.sh +6 -6
  26. package/adapters/mobile/wait-for-bridge.sh +108 -0
  27. package/adapters/mobile/yarn-setup.sh +20 -0
  28. package/{orchestration/lib → adapters/shared}/cli-ux.sh +5 -9
  29. package/{orchestration/lib → adapters/shared}/log-tui.mjs +57 -1
  30. package/{orchestration/lib → adapters/shared}/open-debug.mjs +32 -10
  31. package/{orchestration/lib → adapters/shared}/resolve-farmslot-ports.sh +47 -0
  32. package/adapters/shared/sync-wallet-fixture.sh +78 -0
  33. package/bin/mm-harness +37 -6
  34. package/docs/CLI-SPEC.md +41 -2
  35. package/docs/architecture.md +69 -38
  36. package/docs/runtime-file-conventions.md +3 -3
  37. package/library/actions/core/perps/_controller.mjs +1 -1
  38. package/library/actions/extension/platform/cdp.mjs +2 -2
  39. package/library/actions/extension/wallet/ensure_unlocked.mjs +1 -1
  40. package/library/actions/mobile/platform/bridge.mjs +1 -1
  41. package/library/actions/mobile/wallet/ensure_unlocked.mjs +1 -1
  42. package/library/actions/mobile/wallet/setup.mjs +2 -2
  43. package/library/library.json +1 -1
  44. package/package.json +8 -8
  45. package/{orchestration → src/adapters}/extension/runtime-decision.ts +5 -5
  46. package/{orchestration → src/adapters}/extension/runtime.ts +1 -1
  47. package/src/adapters/mobile/prepare.ts +210 -0
  48. package/{orchestration → src/adapters}/mobile/runtime-decision.ts +105 -5
  49. package/{runner/src → src}/adapters.ts +3 -3
  50. package/{orchestration/lib/cli-color.mjs → src/cli-color.ts} +21 -13
  51. package/{orchestration/lib/cli-commands.mjs → src/cli-commands.ts} +52 -45
  52. package/{orchestration/lib/cli-version.mjs → src/cli-version.ts} +35 -17
  53. package/{runner/src → src}/cli.ts +48 -40
  54. package/src/commands/debug.ts +63 -0
  55. package/src/commands/fixtures.ts +193 -0
  56. package/{runner/src → src}/commands/launch.ts +200 -54
  57. package/src/commands/logs.ts +83 -0
  58. package/{runner/src → src}/commands/shared.ts +40 -30
  59. package/src/commands/update.ts +316 -0
  60. package/{runner/src → src}/harness.ts +119 -16
  61. package/{runner/src → src}/heal-bounds.ts +20 -1
  62. package/src/index.ts +15 -0
  63. package/{runner/src → src}/live-adapter-contract.ts +1 -1
  64. package/{runner/src → src}/mm-harness-cli.ts +64 -3
  65. package/{runner/src → src}/paths.ts +2 -2
  66. package/{orchestration/lib/progress.mjs → src/progress.ts} +31 -21
  67. package/docs/extension-runtime-commands.md +0 -60
  68. package/docs/harness-cli.md +0 -43
  69. package/orchestration/compat-overlays/mobile/README.md +0 -13
  70. package/orchestration/doctor.mjs +0 -72
  71. package/orchestration/lib/cli-home.mjs +0 -354
  72. package/orchestration/lib/recipe-paths.mjs +0 -26
  73. package/orchestration/mobile/launch.sh +0 -137
  74. package/orchestration/mobile/live.sh +0 -125
  75. package/orchestration/porcelain/metamask-recipe +0 -99
  76. package/orchestration/porcelain/mm-recipe +0 -1591
  77. package/orchestration/porcelain/mme-recipe +0 -1181
  78. package/runner/src/commands/debug.ts +0 -44
  79. package/runner/src/commands/fixtures.ts +0 -99
  80. package/runner/src/commands/logs.ts +0 -60
  81. package/runner/src/index.ts +0 -6
  82. /package/{orchestration → adapters}/extension/launch-browser.cjs +0 -0
  83. /package/{orchestration → adapters}/extension/pin-remote-flags.cjs +0 -0
  84. /package/{orchestration → adapters}/extension/sidepanel-toggle.sh +0 -0
  85. /package/{orchestration → adapters}/extension/snapshot-dist.sh +0 -0
  86. /package/{library/actions → adapters}/mobile/bridge-runtime/lib/cdp-eval.cjs +0 -0
  87. /package/{library/actions → adapters}/mobile/bridge-runtime/lib/config.cjs +0 -0
  88. /package/{library/actions → adapters}/mobile/bridge-runtime/lib/issue-capture.cjs +0 -0
  89. /package/{library/actions → adapters}/mobile/bridge-runtime/lib/target-discovery.cjs +0 -0
  90. /package/{library/actions → adapters}/mobile/bridge-runtime/lib/ws-client.cjs +0 -0
  91. /package/{library/actions → adapters}/mobile/bridge-runtime/setup-wallet.sh +0 -0
  92. /package/{library/actions/mobile/app-overlay → adapters/mobile/overlay}/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -0
  93. /package/{library/actions/mobile/app-overlay → adapters/mobile/overlay}/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -0
  94. /package/{orchestration/compat-overlays/mobile → adapters/mobile/overlay/compat}/rn81-message-event-source.patch +0 -0
  95. /package/{orchestration/lib → adapters/shared}/activate-repo-node.sh +0 -0
  96. /package/{orchestration/lib → adapters/shared}/ensure-runner-deps.sh +0 -0
  97. /package/{orchestration/lib → adapters/shared}/harness-path.sh +0 -0
  98. /package/{orchestration/lib → adapters/shared}/hash-helpers.sh +0 -0
  99. /package/{orchestration/lib → adapters/shared}/json-field.sh +0 -0
  100. /package/{orchestration/lib → adapters/shared}/path-defaults.json +0 -0
  101. /package/{orchestration → src/adapters}/extension/ensure-ready.ts +0 -0
  102. /package/{orchestration → src/adapters}/extension/extension-id.ts +0 -0
  103. /package/{orchestration → src/adapters}/mobile/deps-markers.ts +0 -0
  104. /package/{runner/src → src}/completions-cache.ts +0 -0
  105. /package/{runner/src → src}/doctor.ts +0 -0
  106. /package/{runner/src → src}/manifest.ts +0 -0
  107. /package/{runner/src → src}/recording-target.ts +0 -0
  108. /package/{runner/src → src}/run-recording.ts +0 -0
  109. /package/{runner/src → src}/runner.ts +0 -0
  110. /package/{runner/src → src}/types.ts +0 -0
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
 
5
5
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from './paths.ts';
6
6
  import type { MetaMaskRecipeAdapter } from './types.ts';
7
+ import { prepareMobile } from './adapters/mobile/prepare.ts';
7
8
 
8
9
  // The `harness` command moves the recipe-harness skill's runner-locate + dispatch
9
10
  // bash (resolve-runner-source.sh + recipe-harness.sh dispatch_adapter_action)
@@ -155,6 +156,12 @@ function parseHarnessArgs(args: string[]): ParsedHarnessArgs {
155
156
  // Mirror of scripts/lib/cli-common.sh detect_adapter for the auto-detect path.
156
157
  // Exported so `doctor` shares the exact same detect-from-target logic as the
157
158
  // overlay commands (verify/install/cleanup) — one auto-detect story, no drift.
159
+ // Both escapes from a failed repo-type detection: where to run it (checkout/target)
160
+ // and how to force it (adapter). A single source so every detect-failure teaches
161
+ // the same reachable next step.
162
+ export const ADAPTER_DETECT_NEXT =
163
+ 'cd into a MetaMask checkout or pass --target <path>, or force it with --adapter <mobile|extension|core>';
164
+
158
165
  export function detectAdapter(target: string): MetaMaskRecipeAdapter | undefined {
159
166
  let remote = '';
160
167
  try {
@@ -277,11 +284,14 @@ function resolveEntry(base: string, candidates: string[], mode: 'exec' | 'file')
277
284
 
278
285
  // Content of an installed overlay's runner source pointer, if present. Command
279
286
  // substitution in the skill stripped trailing newlines, so trim here too.
287
+ // Validates the path exists so stale stamps (e.g. from a renamed package dir)
288
+ // fall back to the current runnerDir rather than producing ENOENT at cleanup.
280
289
  function installedRunnerSource(target: string, adapter: MetaMaskRecipeAdapter): string | undefined {
281
290
  const pointer = path.join(recipeHarnessPath(target, adapter), 'runner', '.runner-source');
282
291
  if (!fs.existsSync(pointer)) return undefined;
283
292
  const value = fs.readFileSync(pointer, 'utf8').trim();
284
- return value || undefined;
293
+ if (!value || !fs.existsSync(value)) return undefined;
294
+ return value;
285
295
  }
286
296
 
287
297
  function isExecutable(file: string): boolean {
@@ -299,15 +309,15 @@ interface HarnessDispatch {
299
309
  }
300
310
 
301
311
  const INJECT_CANDIDATES: Record<MetaMaskRecipeAdapter, { entry: string; fallback: string }> = {
302
- mobile: { entry: 'orchestration/mobile/inject.sh', fallback: 'scripts/inject-mobile-harness.sh' },
303
- extension: { entry: 'orchestration/extension/inject.mjs', fallback: 'scripts/inject-extension-harness.mjs' },
304
- core: { entry: 'orchestration/core/inject.sh', fallback: 'scripts/inject-core-harness.sh' },
312
+ mobile: { entry: 'adapters/mobile/inject.sh', fallback: 'scripts/inject-mobile-harness.sh' },
313
+ extension: { entry: 'adapters/extension/inject.mjs', fallback: 'scripts/inject-extension-harness.mjs' },
314
+ core: { entry: 'adapters/core/inject.sh', fallback: 'scripts/inject-core-harness.sh' },
305
315
  };
306
316
 
307
317
  const CLEANUP_CANDIDATES: Record<MetaMaskRecipeAdapter, { entry: string; fallback: string }> = {
308
- mobile: { entry: 'orchestration/mobile/cleanup.sh', fallback: 'scripts/cleanup-mobile-harness.sh' },
309
- extension: { entry: 'orchestration/extension/cleanup.mjs', fallback: 'scripts/cleanup-extension-harness.mjs' },
310
- core: { entry: 'orchestration/core/cleanup.sh', fallback: 'scripts/cleanup-core-harness.sh' },
318
+ mobile: { entry: 'adapters/mobile/cleanup.sh', fallback: 'scripts/cleanup-mobile-harness.sh' },
319
+ extension: { entry: 'adapters/extension/cleanup.mjs', fallback: 'scripts/cleanup-extension-harness.mjs' },
320
+ core: { entry: 'adapters/core/cleanup.sh', fallback: 'scripts/cleanup-core-harness.sh' },
311
321
  };
312
322
 
313
323
  // Resolve the exact command + fixed prefix args to exec, mirroring
@@ -337,7 +347,7 @@ function resolveHarnessDispatch(
337
347
  }
338
348
 
339
349
  if (adapter === 'core' && action === 'verify') {
340
- const delegate = path.join(recipeHarnessPath(target, 'core'), 'runner', 'bin', 'metamask-recipe');
350
+ const delegate = path.join(recipeHarnessPath(target, 'core'), 'runner', 'bin', 'mm-harness');
341
351
  if (!isExecutable(delegate)) {
342
352
  return {
343
353
  error:
@@ -351,19 +361,98 @@ function resolveHarnessDispatch(
351
361
  }
352
362
 
353
363
  // mobile/extension live|verify: prefer the installed helper, else the runner's
354
- // own orchestration/runner/scripts helper (first executable candidate wins).
364
+ // own adapters/scripts helper (first executable candidate wins).
355
365
  const installedScript = path.join(recipeHarnessPath(target, adapter), 'scripts', `${action}.sh`);
356
366
  if (isExecutable(installedScript)) {
357
367
  return { command: installedScript, prefixArgs: [] };
358
368
  }
359
369
  const command = resolveEntry(
360
370
  runnerDir,
361
- [`orchestration/${adapter}/${action}.sh`, `runner/${adapter}/${action}.sh`, `scripts/${adapter}/${action}.sh`],
371
+ [`adapters/${adapter}/${action}.sh`, `scripts/${adapter}/${action}.sh`],
362
372
  'exec',
363
373
  );
364
374
  return { command, prefixArgs: [] };
365
375
  }
366
376
 
377
+ // Mobile live: prepare device in-process then run the verify leaf.
378
+ // Replaces the retired live.sh → launch.sh → mm-harness-mobile-prepare chain.
379
+ // Control flows down only: TS calls prepareMobile(), then spawns verify.sh.
380
+ async function handleMobileLive(
381
+ target: string,
382
+ forwardArgs: string[],
383
+ json: boolean,
384
+ autoDetected: boolean,
385
+ ): Promise<number> {
386
+ const platform = argValue(forwardArgs, '--platform') ?? 'ios';
387
+ const watcherPortStr = argValue(forwardArgs, '--watcher-port') ?? process.env.WATCHER_PORT;
388
+ const watcherPort = watcherPortStr ? parseInt(watcherPortStr, 10) : undefined;
389
+ const start = Date.now();
390
+
391
+ if (!json) {
392
+ const detected = autoDetected ? ', auto-detected' : '';
393
+ console.error(`→ live (mobile${detected}) — target: ${target}`);
394
+ }
395
+
396
+ const prepResult = await prepareMobile(target, { platform, json, watcherPort });
397
+ if (prepResult.status !== 0) {
398
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
399
+ if (json) {
400
+ console.log(
401
+ harnessSummary('live', 'mobile', target, 'fail', prepResult.status, autoDetected, {
402
+ code: 'MOBILE_PREPARE_FAILED',
403
+ message: `mobile prepare failed (exit ${prepResult.status})`,
404
+ userAction: failureHint('mobile', 'live'),
405
+ }),
406
+ );
407
+ } else {
408
+ console.error(
409
+ `✗ live mobile failed (exit ${prepResult.status}, ${elapsed}s)\n ${failureHint('mobile', 'live')}`,
410
+ );
411
+ }
412
+ return prepResult.status;
413
+ }
414
+
415
+ // Verify leaf: --no-auto-start because launch already started the app.
416
+ const installedVerify = path.join(recipeHarnessPath(target, 'mobile'), 'scripts', 'verify.sh');
417
+ const verifySh = isExecutable(installedVerify)
418
+ ? installedVerify
419
+ : resolveEntry(runnerDir, ['adapters/mobile/verify.sh', 'scripts/mobile/verify.sh'], 'exec');
420
+
421
+ const verifyArgs = hasArg(forwardArgs, '--no-auto-start')
422
+ ? [...forwardArgs]
423
+ : ['--no-auto-start', ...forwardArgs];
424
+
425
+ const result = spawnSync(verifySh, verifyArgs, {
426
+ stdio: json ? ['inherit', 2, 'inherit'] : 'inherit',
427
+ env: process.env,
428
+ });
429
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
430
+ const exitCode = result.status ?? 1;
431
+
432
+ if (json) {
433
+ console.log(
434
+ harnessSummary(
435
+ 'live',
436
+ 'mobile',
437
+ target,
438
+ exitCode === 0 ? 'pass' : 'fail',
439
+ exitCode,
440
+ autoDetected,
441
+ exitCode === 0
442
+ ? undefined
443
+ : { code: 'MOBILE_VERIFY_FAILED', message: `mobile live verify failed (exit ${exitCode})`, userAction: failureHint('mobile', 'live') },
444
+ ),
445
+ );
446
+ } else if (exitCode === 0) {
447
+ console.error(`✓ live mobile passed (${elapsed}s)`);
448
+ } else {
449
+ console.error(
450
+ `✗ live mobile failed (exit ${exitCode}, ${elapsed}s)\n ${failureHint('mobile', 'live')}`,
451
+ );
452
+ }
453
+ return exitCode;
454
+ }
455
+
367
456
  export async function handleHarness(argv: string[]): Promise<number> {
368
457
  const action = argv[0];
369
458
  if (!action || action === '-h' || action === '--help') {
@@ -388,15 +477,15 @@ export async function handleHarness(argv: string[]): Promise<number> {
388
477
  // parsedAdapter is string|undefined here; not yet validated as a real
389
478
  // adapter, so pass undefined rather than forwarding a garbage string.
390
479
  const detectError = adapter
391
- ? { code: 'UNSUPPORTED_PLATFORM', message: `unsupported platform: ${adapter}` }
392
- : { code: 'ADAPTER_DETECTION_FAILED', message: `could not detect the MetaMask repo type for ${target}` };
480
+ ? { code: 'UNSUPPORTED_PLATFORM', message: `unsupported platform: ${adapter}`, userAction: 'pass --adapter <mobile|extension|core> to specify a supported adapter' }
481
+ : { code: 'ADAPTER_DETECTION_FAILED', message: `could not detect the MetaMask repo type for ${target}`, userAction: ADAPTER_DETECT_NEXT };
393
482
  console.log(harnessSummary(harnessAction, undefined, target, 'fail', 2, false, detectError));
394
483
  } else {
395
484
  harnessUsage();
396
485
  console.error(
397
486
  adapter
398
487
  ? `\n✗ unsupported platform: ${adapter}`
399
- : `\n✗ could not detect the MetaMask repo type for ${target}\n Next: pass --platform mobile|extension|core`,
488
+ : `\n✗ could not detect the MetaMask repo type for ${target}\n Next: ${ADAPTER_DETECT_NEXT}`,
400
489
  );
401
490
  }
402
491
  return 2;
@@ -407,6 +496,7 @@ export async function handleHarness(argv: string[]): Promise<number> {
407
496
  harnessSummary(harnessAction, adapter, target, 'fail', 2, parsedAdapter === undefined, {
408
497
  code: 'UNSUPPORTED_ACTION',
409
498
  message: `${harnessAction} is not supported for ${adapter} (core supports install, verify, cleanup).`,
499
+ userAction: 'run mm-harness doctor to see which actions are supported for this adapter',
410
500
  }),
411
501
  );
412
502
  } else {
@@ -425,6 +515,11 @@ export async function handleHarness(argv: string[]): Promise<number> {
425
515
  forwardArgs = applyExtensionRuntimeEnv(target, harnessAction, forwardArgs);
426
516
  }
427
517
 
518
+ // Mobile live is orchestrated in-process (prepareMobile → verify.sh leaf).
519
+ if (adapter === 'mobile' && harnessAction === 'live') {
520
+ return handleMobileLive(target, forwardArgs, json, autoDetected);
521
+ }
522
+
428
523
  const dispatch = resolveHarnessDispatch(adapter, harnessAction, target);
429
524
  if ('error' in dispatch) {
430
525
  if (json) {
@@ -432,6 +527,9 @@ export async function handleHarness(argv: string[]): Promise<number> {
432
527
  harnessSummary(harnessAction, adapter, target, 'fail', 1, autoDetected, {
433
528
  code: 'DISPATCH_UNAVAILABLE',
434
529
  message: dispatch.error,
530
+ // The dispatch error embeds "Next: <hint>" — extract it so --json consumers
531
+ // get a clean programmatic escape without parsing the human message.
532
+ userAction: dispatch.error.split('\nNext: ')[1] ?? 'run mm-harness install to complete setup, then retry',
435
533
  }),
436
534
  );
437
535
  } else {
@@ -460,6 +558,7 @@ export async function handleHarness(argv: string[]): Promise<number> {
460
558
  harnessSummary(harnessAction, adapter, target, 'fail', 1, autoDetected, {
461
559
  code: 'HARNESS_SPAWN_FAILED',
462
560
  message: `${harnessAction} ${adapter} could not start: ${result.error.message}`,
561
+ userAction: failureHint(adapter, harnessAction),
463
562
  }),
464
563
  );
465
564
  } else {
@@ -480,7 +579,7 @@ export async function handleHarness(argv: string[]): Promise<number> {
480
579
  autoDetected,
481
580
  exitCode === 0
482
581
  ? undefined
483
- : { code: 'HARNESS_FAILED', message: `${harnessAction} ${adapter} failed (exit ${exitCode})` },
582
+ : { code: 'HARNESS_FAILED', message: `${harnessAction} ${adapter} failed (exit ${exitCode})`, userAction: failureHint(adapter, harnessAction) },
484
583
  ),
485
584
  );
486
585
  } else if (exitCode === 0) {
@@ -491,6 +590,9 @@ export async function handleHarness(argv: string[]): Promise<number> {
491
590
  return exitCode;
492
591
  }
493
592
 
593
+ // userAction is required whenever an error object is present so every --json
594
+ // failure carries a machine-readable escape path — parallel to the `usageOut`
595
+ // enforcement on the CLI layer. Omitting userAction is a compile-time error.
494
596
  function harnessSummary(
495
597
  action: HarnessAction,
496
598
  adapter: MetaMaskRecipeAdapter | undefined,
@@ -498,7 +600,7 @@ function harnessSummary(
498
600
  status: 'pass' | 'fail',
499
601
  exitCode: number,
500
602
  autoDetected: boolean,
501
- error?: { code: string; message: string },
603
+ error?: { code: string; message: string; userAction: string },
502
604
  ): string {
503
605
  return JSON.stringify({
504
606
  schemaVersion: 1,
@@ -510,7 +612,8 @@ function harnessSummary(
510
612
  status,
511
613
  exitCode,
512
614
  // Error contract: every --json failure carries a stable machine code + human
513
- // message (CLI-SPEC.md §5.1).
615
+ // message (CLI-SPEC.md §5.1). userAction is included when present so callers
616
+ // can surface the reachable escape without parsing the human message.
514
617
  ...(status === 'fail' && error ? { error } : {}),
515
618
  });
516
619
  }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import fs from 'node:fs';
10
+ import path from 'node:path';
10
11
 
11
12
  import { handleHarness } from './harness.ts';
12
13
  import { recipeHarnessPath, recipeRuntimePath } from './paths.ts';
@@ -54,6 +55,24 @@ function overlayPresent(target: string, adapter: MetaMaskRecipeAdapter): boolean
54
55
  }
55
56
  }
56
57
 
58
+ // Validates that the installed overlay's runner-source stamp points to an existing
59
+ // runner directory whose binary is still present. Two failure modes are detected:
60
+ // 1. Stale stamp — runner dir moved or renamed since install (dir absent).
61
+ // 2. Stale exec target — runner dir exists but its bin/mm-harness was removed
62
+ // (e.g. the package was unlinked/purged inside an otherwise-live dir).
63
+ // Either way the delegate binary will fail to exec; treat the overlay as absent
64
+ // so auto-ensure re-installs it from the current runner.
65
+ function overlayDelegateValid(target: string, adapter: MetaMaskRecipeAdapter): boolean {
66
+ if (adapter === 'core') return true;
67
+ const pointer = path.join(recipeHarnessPath(target, adapter), 'runner', '.runner-source');
68
+ if (!fs.existsSync(pointer)) return true; // no stamp = running from local runner, valid
69
+ const runnerPath = fs.readFileSync(pointer, 'utf8').trim();
70
+ if (!Boolean(runnerPath) || !fs.existsSync(runnerPath)) return false;
71
+ // Runner dir is present; verify the binary inside still exists — the directory
72
+ // can survive a package removal while the binary inside is deleted.
73
+ return fs.existsSync(path.join(runnerPath, 'bin', 'mm-harness'));
74
+ }
75
+
57
76
  // Auto-ensure the runtime overlay (install phase). Missing + heal != off →
58
77
  // install inline, emit a first-install notice to stderr, and record the mutation.
59
78
  // heal === off disables auto-install (repro-preserving); we proceed and let the
@@ -67,7 +86,7 @@ export async function ensureOverlay(
67
86
  ): Promise<{ ok: boolean; error?: string }> {
68
87
  // core is headless — the engine path used by run/call needs no launch overlay.
69
88
  if (adapter === 'core') return { ok: true };
70
- if (overlayPresent(target, adapter)) return { ok: true };
89
+ if (overlayPresent(target, adapter) && overlayDelegateValid(target, adapter)) return { ok: true };
71
90
  if (heal === 'off') return { ok: true };
72
91
 
73
92
  const installBin = process.env.MM_HARNESS_INSTALL_BIN;
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ // Keep the package entrypoint narrow: runner factory, manifest, doctor, and the
2
+ // path/import helpers that library actions are allowed to consume (R9 — no
3
+ // deep-importing src internals past this file).
4
+ export { createDoctorReport } from './doctor.ts';
5
+ export { loadActionManifest, validateManifest } from './manifest.ts';
6
+ export { createMetaMaskExtensionRunner, createMetaMaskMobileRunner, createMetaMaskRunner } from './runner.ts';
7
+ export type { CreateMetaMaskRunnerOptions, MetaMaskDoctorReport, MetaMaskRecipeAdapter } from './types.ts';
8
+ export {
9
+ extensionIdPath,
10
+ importRecipeHarnessRuntimeBrowserExtension,
11
+ importRecipeHarnessRuntimeCdp,
12
+ recipeHarnessPath,
13
+ walletFixturePath,
14
+ } from './paths.ts';
15
+ export { captureActiveRecipeRecordingSnapshot } from './run-recording.ts';
@@ -223,7 +223,7 @@ export async function runLiveAdapterScript({ platform, action, node, context }:
223
223
  const script = await resolveLiveAdapter(platform, action);
224
224
  if (!script) return null;
225
225
 
226
- const tempDir = await mkdtemp(path.join(os.tmpdir(), 'metamask-recipe-live-adapter-'));
226
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'mm-harness-live-adapter-'));
227
227
  const inputPath = path.join(tempDir, 'input.json');
228
228
  const outputPath = path.join(tempDir, 'output.json');
229
229
  const input = {
@@ -12,8 +12,10 @@ import { fileURLToPath } from 'node:url';
12
12
 
13
13
  import { Command } from 'commander';
14
14
 
15
- // runner root (…/runner) its parent is the package root that holds scripts/.
16
- const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
15
+ import { handleUpdate, maybeNudge } from './commands/update.ts';
16
+
17
+ // …/src → its parent is the package root that holds scripts/.
18
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
19
 
18
20
  // Signal cli.ts NOT to auto-run its own dispatch when we import it below: we
19
21
  // reuse its `main` in-process. Must be set BEFORE the dynamic import runs the
@@ -245,6 +247,26 @@ Example:
245
247
  mm-harness debug
246
248
  mm-harness debug --worker`,
247
249
  },
250
+ {
251
+ name: 'update',
252
+ summary: 'Update the installed mm-harness to the published latest (--check reports only; --json = {current, latest, updateAvailable}).',
253
+ example: 'mm-harness update',
254
+ helpText: `mm-harness update [flags]
255
+
256
+ Update the globally-installed mm-harness to the npm registry's latest.
257
+ Bare form upgrades (npm i -g @deeeed/metamask-harness@latest) and prints old → new;
258
+ already-current exits 0.
259
+
260
+ --check Report only — exit 0 up-to-date / exit 1 update available (no install)
261
+ --json Machine-readable { current, latest, updateAvailable }
262
+
263
+ A passive once-a-day nudge also prints one stderr line when a newer version exists.
264
+ Silence it with MM_HARNESS_NO_UPDATE_CHECK=1 (auto-off in CI).
265
+
266
+ Example:
267
+ mm-harness update
268
+ mm-harness update --check --json`,
269
+ },
248
270
  {
249
271
  name: 'fixtures',
250
272
  summary: 'Manage the canonical wallet fixture (wallet DATA only) — sync files / set the wallet.',
@@ -340,6 +362,11 @@ const HELP_GROUPS: HelpGroup[] = [
340
362
  blurb: 'install/verify/clean the per-checkout overlay (the everyday commands auto-ensure it)',
341
363
  commands: ['install', 'verify', 'cleanup'],
342
364
  },
365
+ {
366
+ title: 'MAINTAIN',
367
+ blurb: 'keep the installed harness current with the npm registry',
368
+ commands: ['update'],
369
+ },
343
370
  ];
344
371
 
345
372
  function commandMeta(name: string): { summary: string; example: string; planned: boolean } {
@@ -371,6 +398,9 @@ function groupedHelp(): string {
371
398
  lines.push('SETUP — tab-completion (zsh + bash), sourced from the bundled scripts:');
372
399
  lines.push(' mm-harness completions install # print the lines to add to ~/.zshrc / ~/.bashrc');
373
400
  lines.push('');
401
+ lines.push('DEV/PROD — the global npm install is prod; set MM_HARNESS_BIN to a dev checkout to override:');
402
+ lines.push(' MM_HARNESS_BIN=/path/to/checkout/bin/mm-harness # unset = the installed/global bin');
403
+ lines.push('');
374
404
  lines.push('See docs/MENTAL-MODEL.md (overview) and docs/CLI-SPEC.md (full contract).');
375
405
  return `${lines.join('\n')}\n`;
376
406
  }
@@ -430,6 +460,11 @@ for (const command of REAL) {
430
460
  process.stdout.write(`${command.helpText}\n`);
431
461
  process.exit(0);
432
462
  }
463
+ // `update` is self-contained registry tooling (no engine, no overlay), so it
464
+ // runs directly rather than through the dispatch in cli.ts.
465
+ if (command.name === 'update') {
466
+ process.exit(await handleUpdate(rawArgv.slice(1)));
467
+ }
433
468
  const argv = command.name === 'actions' && rawArgv.includes('--raw') ? translateActionsRaw(rawArgv) : rawArgv;
434
469
  process.exit(await delegate(argv));
435
470
  });
@@ -474,12 +509,38 @@ for (const retired of RETIRED) {
474
509
  .allowUnknownOption()
475
510
  .helpOption(false)
476
511
  .argument('[args...]')
477
- .action(() => {
512
+ .action(async () => {
513
+ // `live --adapter mobile` (or android) is handled in-process via handleMobileLive.
514
+ // All other `live` invocations still teach the replacement.
515
+ if (retired.name === 'live') {
516
+ const adapterIdx = rawArgv.indexOf('--adapter');
517
+ const aliasIdx = rawArgv.indexOf('-a');
518
+ const adapterVal =
519
+ adapterIdx !== -1
520
+ ? rawArgv[adapterIdx + 1]
521
+ : aliasIdx !== -1
522
+ ? rawArgv[aliasIdx + 1]
523
+ : undefined;
524
+ if (adapterVal === 'mobile' || adapterVal === 'android') {
525
+ process.exit(await delegate(rawArgv));
526
+ return;
527
+ }
528
+ }
478
529
  console.error(retired.message);
479
530
  process.exit(2);
480
531
  });
481
532
  }
482
533
 
534
+ // Passive update nudge: one throttled stderr line if a newer version is published.
535
+ // Skipped for update itself (redundant) and for shell/completion tooling (whose
536
+ // stdout is consumed by the shell). stderr-only keeps every --json stdout clean.
537
+ const NUDGE_SKIP: readonly string[] = ['update', 'completions', 'completion-candidates'];
538
+ if (rawArgv.length > 0 && !NUDGE_SKIP.includes(rawArgv[0])) {
539
+ // Fully deferred: no nudge code runs before parseAsync starts. The exit hook
540
+ // registered inside maybeNudge prints the line after command output.
541
+ setImmediate(() => void maybeNudge());
542
+ }
543
+
483
544
  if (rawArgv.length === 0) {
484
545
  process.stdout.write(groupedHelp());
485
546
  process.exit(0);
@@ -11,7 +11,7 @@ import type {
11
11
  MetaMaskRecipeAdapter,
12
12
  } from './types.ts';
13
13
 
14
- export const runnerDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
14
+ export const runnerDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
15
  const pathDefaults = readPathDefaults();
16
16
  export const DEFAULT_RECIPE_RUNTIME_DIR = pathDefaults.recipeRuntimeDir;
17
17
  export const DEFAULT_RECIPE_HARNESS_ROOT = pathDefaults.recipeHarnessRoot;
@@ -26,7 +26,7 @@ export function recipeHarnessRoot() {
26
26
 
27
27
 
28
28
  function readPathDefaults(): { recipeRuntimeDir: string; recipeHarnessRoot: string } {
29
- const defaultsPath = path.join(runnerDir, 'orchestration/lib/path-defaults.json');
29
+ const defaultsPath = path.join(runnerDir, 'adapters/shared/path-defaults.json');
30
30
  const parsed = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')) as Partial<{ recipeRuntimeDir: string; recipeHarnessRoot: string }>;
31
31
  return {
32
32
  recipeRuntimeDir: validateRelativeRecipePath('recipeRuntimeDir', parsed.recipeRuntimeDir || ''),
@@ -1,20 +1,29 @@
1
- #!/usr/bin/env node
2
1
  /**
3
- * progress.mjs — structured progress events for long recipe CLI operations.
2
+ * progress.ts — structured progress events for long recipe CLI operations.
4
3
  *
5
4
  * Human mode: short lines on stderr.
6
5
  * Machine mode: JSONL on stderr when --json or RECIPE_PROGRESS_JSON=1.
7
6
  */
7
+ import path from 'node:path';
8
8
  import { parseArgs } from 'node:util';
9
- import { color } from './cli-color.mjs';
9
+ import { color } from './cli-color.ts';
10
10
 
11
- function shouldEmitJson(explicit) {
11
+ function shouldEmitJson(explicit: boolean): boolean {
12
12
  if (explicit) return true;
13
13
  const env = process.env.RECIPE_PROGRESS_JSON;
14
14
  return env === '1' || env === 'true';
15
15
  }
16
16
 
17
- export function emitProgress(event, { json = false } = {}) {
17
+ interface ProgressEvent {
18
+ command?: string;
19
+ phase?: string;
20
+ message?: string;
21
+ status?: string;
22
+ elapsedMs?: number;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ export function emitProgress(event: ProgressEvent, { json = false }: { json?: boolean } = {}): void {
18
27
  const payload = {
19
28
  schemaVersion: 1,
20
29
  type: 'progress',
@@ -30,28 +39,28 @@ export function emitProgress(event, { json = false } = {}) {
30
39
  if (payload.phase === 'done') {
31
40
  const statusStyle = payload.status === 'pass' ? 'ok' : 'err';
32
41
  const statusWord = payload.status === 'pass' ? 'done' : 'failed';
33
- const verb = payload.verb ? color('cmd', `${payload.verb}`, { stream }) : '';
42
+ const cmd = payload.command ? color('cmd', `${payload.command}`, { stream }) : '';
34
43
  const elapsed = Number.isFinite(payload.elapsedMs)
35
- ? color('comment', ` (${Math.round(payload.elapsedMs / 1000)}s)`, { stream })
44
+ ? color('comment', ` (${Math.round((payload.elapsedMs as number) / 1000)}s)`, { stream })
36
45
  : '';
37
- process.stderr.write(`${prefix}: ${verb} ${color(statusStyle, statusWord, { stream })} ${payload.message || ''}${elapsed}\n`);
46
+ process.stderr.write(`${prefix}: ${cmd} ${color(statusStyle, statusWord, { stream })} ${payload.message || ''}${elapsed}\n`);
38
47
  return;
39
48
  }
40
- const verb = payload.verb ? `${color('cmd', payload.verb, { stream })}: ` : '';
49
+ const cmd = payload.command ? `${color('cmd', payload.command, { stream })}: ` : '';
41
50
  const phase = payload.phase ? `${color('accent', `[${payload.phase}]`, { stream })} ` : '';
42
51
  const elapsed = Number.isFinite(payload.elapsedMs)
43
- ? color('comment', ` (${Math.round(payload.elapsedMs / 1000)}s)`, { stream })
52
+ ? color('comment', ` (${Math.round((payload.elapsedMs as number) / 1000)}s)`, { stream })
44
53
  : '';
45
- process.stderr.write(`${prefix}: ${verb}${phase}${payload.message || ''}${elapsed}\n`);
54
+ process.stderr.write(`${prefix}: ${cmd}${phase}${payload.message || ''}${elapsed}\n`);
46
55
  }
47
56
 
48
- function usage() {
57
+ function usage(): void {
49
58
  console.error(`Usage:
50
- progress.mjs emit --verb <name> --phase <id> --message <text> [--elapsed-ms <n>] [--json]
51
- progress.mjs done --verb <name> --status pass|fail --message <text> [--elapsed-ms <n>] [--json]`);
59
+ progress emit --command <name> --phase <id> --message <text> [--elapsed-ms <n>] [--json]
60
+ progress done --command <name> --status pass|fail --message <text> [--elapsed-ms <n>] [--json]`);
52
61
  }
53
62
 
54
- async function main() {
63
+ async function main(): Promise<void> {
55
64
  const [command, ...rest] = process.argv.slice(2);
56
65
  if (!command || command === '--help' || command === '-h') {
57
66
  usage();
@@ -61,7 +70,7 @@ async function main() {
61
70
  const { values } = parseArgs({
62
71
  args: rest,
63
72
  options: {
64
- verb: { type: 'string' },
73
+ command: { type: 'string' },
65
74
  phase: { type: 'string' },
66
75
  message: { type: 'string' },
67
76
  status: { type: 'string' },
@@ -76,7 +85,7 @@ async function main() {
76
85
 
77
86
  if (command === 'emit') {
78
87
  emitProgress({
79
- verb: values.verb || 'recipe',
88
+ command: values.command || 'recipe',
80
89
  phase: values.phase || 'working',
81
90
  message: values.message || '',
82
91
  elapsedMs: Number.isFinite(elapsedMs) ? elapsedMs : undefined,
@@ -86,7 +95,7 @@ async function main() {
86
95
 
87
96
  if (command === 'done') {
88
97
  emitProgress({
89
- verb: values.verb || 'recipe',
98
+ command: values.command || 'recipe',
90
99
  phase: 'done',
91
100
  status: values.status || 'pass',
92
101
  message: values.message || '',
@@ -99,9 +108,10 @@ async function main() {
99
108
  process.exit(2);
100
109
  }
101
110
 
102
- if (import.meta.url === new URL(process.argv[1], 'file:').href) {
103
- main().catch((error) => {
111
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename ?? '');
112
+ if (isMain) {
113
+ main().catch((error: unknown) => {
104
114
  console.error(error instanceof Error ? error.message : String(error));
105
115
  process.exit(1);
106
116
  });
107
- }
117
+ }
@@ -1,60 +0,0 @@
1
- # Extension runtime lifecycle (internal notes)
2
-
3
- Internal implementation notes for the MetaMask Extension runtime lifecycle — the
4
- self-healing path that gets a checkout from "installed" to "a live, verified
5
- extension under CDP control". These are **not** a public command surface: the
6
- probes below are internal functions, composed for you by the daily-loop commands
7
- (`launch --verify`) and the readiness commands (`doctor --fix`, `verify`). The
8
- public surface is defined in [CLI-SPEC.md](CLI-SPEC.md); this file explains what
9
- happens underneath.
10
-
11
- The runner is the source of truth for Extension harness injection, readiness
12
- decisions, extension-id resolution, and live CDP health checks.
13
-
14
- ## Public entry points
15
-
16
- - **`install`** writes the Extension runtime overlay under the configured harness
17
- root. CI/agents run it explicitly; the daily-loop commands auto-ensure it.
18
- - **`verify`** confirms the overlay is present and healthy — a live CDP health
19
- probe when a runtime is reachable, no launch.
20
- - **`launch --verify`** installs the overlay if missing, launches/attaches the
21
- extension, and polls CDP until the runtime is ready.
22
- - **`doctor` / `doctor --fix`** report readiness and (with `--fix`) run the
23
- healing steps below WITHOUT starting the app.
24
-
25
- ## Internal lifecycle stages
26
-
27
- The healing path composes these internal functions in order. Each is idempotent
28
- and reports the cheapest next action, so the composed path converges rather than
29
- rebuilding from scratch.
30
-
31
- ### Extension-id resolution
32
-
33
- Resolves the deterministic unpacked extension id from
34
- `dist/chrome/manifest.json`, and can verify it against a live CDP target. Every
35
- later stage keys off this id, so it runs first.
36
-
37
- ### Readiness decision
38
-
39
- Inspects the checkout + live browser and returns the cheapest next action:
40
- `install`, `build`, `relaunch`, or `ready`, plus the concrete `actions[]` to
41
- execute. The healing path branches on this decision instead of always launching.
42
-
43
- ### Runtime launch
44
-
45
- Launches Chrome with the installed harness helper, seeds the wallet fixture, and
46
- runs live smoke verification. Two tiers: reuse the existing dist (quick relaunch),
47
- or the clean-build path — clear the webpack cache, start the harness-owned
48
- watcher, wait for a clean compile, then launch and verify.
49
-
50
- ### Convergence
51
-
52
- Converges the live browser to exactly one healthy `home.html` tab (closing
53
- strays, reopening if needed) and confirms it with the health probe. This is the
54
- step that makes "launch" deterministic across a messy pre-existing browser state.
55
-
56
- ### Health probe
57
-
58
- Read-only liveness probe for the running extension over CDP. Used both as the
59
- final gate of the convergence step and standalone by `verify` to answer "is the
60
- runtime healthy right now?" without mutating anything.