@deeeed/metamask-harness 0.3.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/dist/adapters/core/surface.js +53 -0
  3. package/dist/adapters/extension/ensure-ready.js +109 -0
  4. package/dist/adapters/extension/extension-id.js +62 -0
  5. package/dist/adapters/extension/runtime-decision.js +305 -0
  6. package/dist/adapters/extension/runtime.js +324 -0
  7. package/dist/adapters/extension/surface.js +69 -0
  8. package/dist/adapters/mobile/deps-markers.js +22 -0
  9. package/dist/adapters/mobile/prepare.js +146 -0
  10. package/dist/adapters/mobile/provision.js +465 -0
  11. package/dist/adapters/mobile/runtime-decision.js +315 -0
  12. package/dist/adapters/mobile/surface.js +54 -0
  13. package/dist/adapters/slot-ports.js +146 -0
  14. package/dist/adapters/surface.js +14 -0
  15. package/dist/adapters.js +485 -0
  16. package/dist/cli-color.js +79 -0
  17. package/dist/cli-commands.js +224 -0
  18. package/dist/cli-version.js +111 -0
  19. package/dist/cli.js +1571 -0
  20. package/dist/commands/debug.js +56 -0
  21. package/dist/commands/fixtures.js +153 -0
  22. package/dist/commands/launch.js +325 -0
  23. package/dist/commands/logs.js +73 -0
  24. package/dist/commands/shared.js +157 -0
  25. package/dist/commands/update.js +243 -0
  26. package/dist/completions-cache.js +53 -0
  27. package/dist/doctor.js +169 -0
  28. package/dist/harness.js +627 -0
  29. package/dist/heal-bounds.js +120 -0
  30. package/dist/index.js +25 -0
  31. package/dist/leaf-invoke.js +19 -0
  32. package/dist/live-adapter-contract.js +240 -0
  33. package/dist/manifest.js +37 -0
  34. package/dist/mm-harness-cli.js +521 -0
  35. package/dist/paths.js +179 -0
  36. package/dist/progress.js +94 -0
  37. package/dist/recording-target.js +133 -0
  38. package/dist/run-recording.js +271 -0
  39. package/dist/runner.js +88 -0
  40. package/dist/types.js +0 -0
  41. package/docs/ADAPTER-SURFACE.md +119 -0
  42. package/docs/CLI-SPEC.md +26 -3
  43. package/docs/UX-PRINCIPLES.md +3 -0
  44. package/package.json +10 -2
  45. package/src/adapters/core/surface.ts +71 -0
  46. package/src/adapters/extension/surface.ts +88 -0
  47. package/src/adapters/mobile/provision.ts +594 -0
  48. package/src/adapters/mobile/surface.ts +71 -0
  49. package/src/adapters/slot-ports.ts +165 -0
  50. package/src/adapters/surface.ts +117 -0
  51. package/src/cli-commands.ts +1 -1
  52. package/src/cli.ts +239 -49
  53. package/src/commands/debug.ts +3 -1
  54. package/src/commands/fixtures.ts +13 -8
  55. package/src/commands/launch.ts +7 -156
  56. package/src/commands/logs.ts +29 -13
  57. package/src/harness.ts +140 -3
  58. package/src/mm-harness-cli.ts +71 -18
package/src/cli.ts CHANGED
@@ -28,6 +28,8 @@ import { ensureExtensionReady } from './adapters/extension/ensure-ready.ts';
28
28
  import { resolveExtensionId } from './adapters/extension/extension-id.ts';
29
29
  import { decideExtensionReadiness } from './adapters/extension/runtime-decision.ts';
30
30
  import { decideMobileReadiness } from './adapters/mobile/runtime-decision.ts';
31
+ import { getAdapterSurface } from './adapters/surface.ts';
32
+ import type { AdapterRuntimeStatus } from './adapters/surface.ts';
31
33
  // NOTE: extension-runtime.ts loads the recipe harness at module scope, so it
32
34
  // is imported LAZILY (dynamic import) only inside the handlers that drive a live
33
35
  // runtime. Static-import it here and every command — manifest, doctor,
@@ -66,6 +68,7 @@ type CliOptions = Record<string, CliOptionValue>;
66
68
  interface ParsedArgs {
67
69
  positional: string[];
68
70
  options: CliOptions;
71
+ rawArgv: string[];
69
72
  }
70
73
 
71
74
  interface RuntimeOptions {
@@ -103,6 +106,7 @@ const COMMANDS: Record<string, (args: ParsedArgs) => Promise<number>> = {
103
106
  'runtime-health': handleRuntimeHealth,
104
107
  'runtime-decision': handleRuntimeDecision,
105
108
  'runtime-launch': handleRuntimeLaunch,
109
+ provision: handleProvision,
106
110
  'resolve-extension': handleResolveExtension,
107
111
  'ensure-ready': handleEnsureReady,
108
112
  run: handleRun,
@@ -112,7 +116,7 @@ const COMMANDS: Record<string, (args: ParsedArgs) => Promise<number>> = {
112
116
  // Top-level runtime-overlay commands (the tool installs a runtime overlay).
113
117
  // Top-level overlay commands route to handleHarness; the `harness <sub>`
114
118
  // subcommand form has been removed from the CLI surface.
115
- const OVERLAY_COMMANDS: readonly string[] = ['install', 'verify', 'cleanup', 'live'];
119
+ const OVERLAY_COMMANDS: readonly string[] = ['install', 'provision', 'verify', 'cleanup', 'live'];
116
120
 
117
121
  function usage() {
118
122
  // Help is organized by the harness mental model: mm-harness IS the tool; a
@@ -174,7 +178,7 @@ See docs/MENTAL-MODEL.md (overview) and docs/CLI-SPEC.md (full contract).
174
178
  function parseArgs(argv: string[], command?: string): ParsedArgs {
175
179
  const positional: string[] = [];
176
180
  const options: CliOptions = {};
177
- const booleanOptions = new Set(['json', 'launchExistingDist', 'startWatch', 'record', 'plan', 'raw', 'fix']);
181
+ const booleanOptions = new Set(['json', 'launchExistingDist', 'startWatch', 'record', 'plan', 'raw', 'fix', 'force', 'resolveOnly']);
178
182
  for (let i = 0; i < argv.length; i += 1) {
179
183
  const arg = argv[i];
180
184
  if (!arg.startsWith('--')) {
@@ -214,7 +218,7 @@ function parseArgs(argv: string[], command?: string): ParsedArgs {
214
218
  options[key] = argv[i + 1];
215
219
  i += 1;
216
220
  }
217
- return { positional, options };
221
+ return { positional, options, rawArgv: [...argv] };
218
222
  }
219
223
 
220
224
  function parseRecordVideoMode(value: string | undefined): false | 'full-run' {
@@ -247,6 +251,20 @@ function optionFlag(options: CliOptions, key: string): boolean {
247
251
  return value === true;
248
252
  }
249
253
 
254
+
255
+ function applyRuntimeDirOption(options: CliOptions): void {
256
+ const runtimeDir = optionString(options, 'runtimeDir');
257
+ if (runtimeDir) process.env.RECIPE_RUNTIME_DIR = runtimeDir;
258
+ }
259
+
260
+ function applyWatcherPortOption(options: CliOptions): void {
261
+ const watcherPort = optionString(options, 'watcherPort');
262
+ if (!watcherPort) return;
263
+ process.env.WATCHER_PORT = watcherPort;
264
+ process.env.RECIPE_WATCHER_PORT = watcherPort;
265
+ process.env.METRO_PORT = watcherPort;
266
+ }
267
+
250
268
  function requiredOption(options: CliOptions, key: string, message: string): string {
251
269
  const value = optionString(options, key);
252
270
  if (!value) throw usageError(message);
@@ -448,6 +466,7 @@ async function handleActions({ options }: ParsedArgs): Promise<number> {
448
466
  }
449
467
 
450
468
  async function handleDoctor({ options }: ParsedArgs): Promise<number> {
469
+ applyRuntimeDirOption(options);
451
470
  const target = targetPath(options);
452
471
  // Share the exact detect-from-target logic the overlay commands (verify/install/
453
472
  // cleanup) use: when --adapter/--platform is omitted, auto-detect from the
@@ -476,35 +495,33 @@ async function handleDoctor({ options }: ParsedArgs): Promise<number> {
476
495
  }
477
496
 
478
497
  const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
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
- }
498
+ // Live runtime state, per platform, via the adapter surface: every adapter
499
+ // reports the same read-only readiness shape (decision + reasons, deps currency,
500
+ // and its dev server where it has one) so doctor renders one line the same way
501
+ // for mobile, extension, and core. doctor never branches on adapter for this.
502
+ let runtime: AdapterRuntimeStatus | undefined;
503
+ try {
504
+ const surface = getAdapterSurface(adapter);
505
+ surface.resolveSlotPorts(target);
506
+ runtime = await surface.runtimeStatus(target);
507
+ } catch { /* readiness probe unavailable doctor stays useful without it */ }
499
508
  if (json) console.log(JSON.stringify({ ...result, runtime }, null, 2));
500
509
  else {
501
510
  const out = (style: string, text: string) => color(style, text, { stream: process.stdout });
502
511
  const stateStyle = (value: string | undefined, good: string) => (value === good ? 'ok' : 'warn');
503
512
  console.log(`${out(result.status === 'pass' ? 'ok' : 'err', result.status)} ${out('bold', adapter)} ${result.compatibilityMode} ${out('dim', `manifest=${actionManifestPath}`)}`);
504
513
  if (runtime) {
505
- const decisionStyle = runtime.decision === 'ready' ? 'ok' : runtime.decision === 'blocked' ? 'err' : 'warn';
514
+ const provisionedDepsPending = runtime.reasonCode === 'app-installed-deps-pending';
515
+ const decisionStyle = provisionedDepsPending
516
+ ? 'info'
517
+ : runtime.decision === 'ready' ? 'ok' : runtime.decision === 'blocked' ? 'err' : 'warn';
518
+ const depsStyle = provisionedDepsPending ? 'info' : stateStyle(runtime.deps, 'current');
519
+ const devServerStyle = provisionedDepsPending ? 'info' : undefined;
520
+ const devServer = runtime.devServer
521
+ ? ` ${runtime.devServer.label}=${out(devServerStyle ?? stateStyle(runtime.devServer.status, 'up'), runtime.devServer.status)}`
522
+ : '';
506
523
  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')}`,
524
+ `${out('label', 'runtime:')} decision=${out(decisionStyle, runtime.decision)}${runtime.reasonCode ? ` ${out('dim', `(${runtime.reasonCode})`)}` : ''} deps=${out(depsStyle, runtime.deps ?? 'unknown')}${devServer}`,
508
525
  );
509
526
  for (const reason of runtime.reasons) console.log(` ${out('dim', reason)}`);
510
527
  }
@@ -614,6 +631,126 @@ function isRecord(value: unknown): value is Record<string, unknown> {
614
631
  return typeof value === 'object' && value !== null && !Array.isArray(value);
615
632
  }
616
633
 
634
+ async function handleProvision({ positional, options, rawArgv }: ParsedArgs): Promise<number> {
635
+ applyWatcherPortOption(options);
636
+ const json = optionFlag(options, 'json');
637
+ const { adapter, target } = resolveProvisionAdapter(options);
638
+ const surface = getAdapterSurface(adapter);
639
+ const rerunCommand = provisionRerunCommand(rawArgv, options, adapter, target);
640
+ const result = await surface.runwayProvision.run(target, {
641
+ json,
642
+ platform: optionString(options, 'platform') ?? optionString(options, 'devicePlatform') ?? (positional[0] === 'runway' ? positional[1] : positional[0]) ?? 'ios',
643
+ branch: optionString(options, 'branch'),
644
+ defaultBranch: optionString(options, 'defaultBranch'),
645
+ run: optionString(options, 'run'),
646
+ cacheRoot: optionString(options, 'cacheRoot'),
647
+ simulator: optionString(options, 'simulator') ?? optionString(options, 'device'),
648
+ runtime: optionString(options, 'runtime'),
649
+ deviceType: optionString(options, 'deviceType'),
650
+ slot: optionString(options, 'slot'),
651
+ watcherPort: optionString(options, 'watcherPort'),
652
+ runtimeDir: optionString(options, 'runtimeDir'),
653
+ force: optionFlag(options, 'force'),
654
+ resolveOnly: optionFlag(options, 'resolveOnly'),
655
+ rerunCommand,
656
+ });
657
+ if (json) {
658
+ console.log(JSON.stringify(result, null, 2));
659
+ } else if (result.status === 'pass') {
660
+ const cache = typeof result.cache === 'object' && result.cache ? result.cache as Record<string, unknown> : undefined;
661
+ const simulator = typeof result.simulator === 'object' && result.simulator ? result.simulator as Record<string, unknown> : undefined;
662
+ const artifact = typeof result.artifact === 'object' && result.artifact ? result.artifact as Record<string, unknown> : undefined;
663
+ if (result.resolveOnly) {
664
+ console.error(`✓ resolved ${adapter} ${result.platform ?? ''} run=${artifact?.runId ?? 'unknown'} revision=${artifact?.revision ?? 'unknown'} artifact=${artifact?.artifactName ?? 'unknown'}`);
665
+ } else {
666
+ const action = result.skipped ? 'already provisioned' : 'provisioned';
667
+ console.error(`✓ ${action} ${adapter} ${result.platform ?? ''} simulator=${simulator?.name ?? 'unknown'} cache=${cache?.status ?? 'unknown'}`);
668
+ }
669
+ } else {
670
+ console.error(`✗ mm-harness provision: ${result.error?.message ?? 'provision failed'}\n Next: ${result.error?.userAction ?? rerunCommand}`);
671
+ }
672
+ return result.exitCode;
673
+ }
674
+
675
+ function resolveProvisionAdapter(options: CliOptions): { adapter: MetaMaskRecipeAdapter; target: string } {
676
+ const target = targetPath(options);
677
+ const adapter = optionString(options, 'adapter') ?? detectAdapter(target);
678
+ if (!adapter) {
679
+ throw usageError(`could not detect the MetaMask repo type for ${target}\n Next: ${ADAPTER_DETECT_NEXT}`);
680
+ }
681
+ try {
682
+ assertAdapter(adapter);
683
+ } catch (error) {
684
+ throw usageError(error instanceof Error ? error.message : String(error));
685
+ }
686
+ return { adapter, target };
687
+ }
688
+
689
+ function provisionRerunCommand(rawArgv: string[], options: CliOptions, adapter: MetaMaskRecipeAdapter, target: string): string {
690
+ const parts = ['mm-harness', 'provision'];
691
+ for (const positional of provisionPositionals(rawArgv)) parts.push(shellQuoteArg(positional));
692
+ parts.push('--adapter', adapter, '--target', shellQuote(target));
693
+ const aliases: Array<[string, string[]]> = [
694
+ ['platform', ['--platform', '--device-platform']],
695
+ ['branch', ['--branch']],
696
+ ['defaultBranch', ['--default-branch']],
697
+ ['run', ['--run']],
698
+ ['cacheRoot', ['--cache-root']],
699
+ ['simulator', ['--simulator', '--device']],
700
+ ['runtime', ['--runtime']],
701
+ ['deviceType', ['--device-type']],
702
+ ['slot', ['--slot']],
703
+ ['watcherPort', ['--watcher-port']],
704
+ ['runtimeDir', ['--runtime-dir']],
705
+ ];
706
+ for (const [key, flags] of aliases) {
707
+ const found = findRawOption(rawArgv, flags);
708
+ const value = found?.value ?? optionString(options, key);
709
+ if (value) parts.push(found?.flag ?? flags[0], shellQuoteArg(value));
710
+ }
711
+ if (optionFlag(options, 'force')) parts.push('--force');
712
+ if (optionFlag(options, 'resolveOnly')) parts.push('--resolve-only');
713
+ if (optionFlag(options, 'json')) parts.push('--json');
714
+ return parts.join(' ');
715
+ }
716
+
717
+ function provisionPositionals(rawArgv: string[]): string[] {
718
+ const positionals: string[] = [];
719
+ const valueFlags = new Set([
720
+ '--adapter', '--target', '--project-root', '--platform', '--device-platform', '--branch', '--default-branch',
721
+ '--run', '--cache-root', '--simulator', '--device', '--runtime', '--device-type', '--slot', '--watcher-port',
722
+ '--runtime-dir',
723
+ ]);
724
+ for (let i = 0; i < rawArgv.length; i += 1) {
725
+ const arg = rawArgv[i];
726
+ if (!arg.startsWith('--')) {
727
+ positionals.push(arg);
728
+ continue;
729
+ }
730
+ const key = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
731
+ if (!arg.includes('=') && valueFlags.has(key)) i += 1;
732
+ }
733
+ return positionals;
734
+ }
735
+
736
+ function findRawOption(rawArgv: string[], flags: string[]): { flag: string; value: string } | undefined {
737
+ for (let i = 0; i < rawArgv.length; i += 1) {
738
+ const arg = rawArgv[i];
739
+ for (const flag of flags) {
740
+ if (arg === flag) {
741
+ const value = rawArgv[i + 1];
742
+ return value && !value.startsWith('--') ? { flag, value } : undefined;
743
+ }
744
+ if (arg.startsWith(`${flag}=`)) return { flag, value: arg.slice(flag.length + 1) };
745
+ }
746
+ }
747
+ return undefined;
748
+ }
749
+
750
+ function shellQuoteArg(value: string): string {
751
+ return /^[A-Za-z0-9_./:=@+-]+$/u.test(value) ? value : shellQuote(value);
752
+ }
753
+
617
754
  async function handleRuntimeHealth({ options }: ParsedArgs): Promise<number> {
618
755
  const adapter = adapterOption(options);
619
756
  if (adapter !== 'extension') throw new Error('runtime-health currently applies to the extension adapter.');
@@ -774,6 +911,7 @@ function readJsonIfExists(file: string): unknown {
774
911
  }
775
912
 
776
913
  async function handleRuntimeDecision({ options }: ParsedArgs): Promise<number> {
914
+ applyRuntimeDirOption(options);
777
915
  const adapter = adapterOption(options);
778
916
  const target = targetPath(options);
779
917
  if (adapter === 'mobile') {
@@ -1117,23 +1255,21 @@ async function handleStop(argv: string[]): Promise<number> {
1117
1255
  const { options } = parseArgs(argv, 'stop');
1118
1256
  const json = optionFlag(options, 'json');
1119
1257
  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
- );
1258
+ const surface = getAdapterSurface(adapter);
1259
+ // Slot-scope the stop: an explicit --port wins, else resolve the checkout's
1260
+ // own dev-server port so a concurrent slot's server is never signalled.
1261
+ const explicitPort = optionString(options, 'port') ?? optionString(options, 'watcherPort');
1262
+ if (explicitPort) {
1263
+ process.env.WATCHER_PORT = explicitPort;
1264
+ process.env.METRO_PORT = explicitPort;
1265
+ } else {
1266
+ surface.resolveSlotPorts(target);
1267
+ }
1268
+ const stop = surface.devServer.stop(target);
1269
+ if (stop.kind === 'headless') {
1270
+ // core has no dev server — teach the reachable headless path (exit 2).
1271
+ return usageOut(json, 'stop', stop.message, stop.userAction);
1127
1272
  }
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
1273
  if (json) {
1138
1274
  console.log(
1139
1275
  JSON.stringify(
@@ -1142,16 +1278,22 @@ async function handleStop(argv: string[]): Promise<number> {
1142
1278
  command: 'stop',
1143
1279
  adapter,
1144
1280
  target,
1145
- status: exitCode === 0 ? 'pass' : 'fail',
1146
- exitCode,
1147
- output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(),
1281
+ status: stop.status === 0 ? 'pass' : 'fail',
1282
+ ...(stop.signalled !== undefined ? { signalled: stop.signalled } : {}),
1283
+ exitCode: stop.status,
1284
+ ...(stop.output ? { output: stop.output } : {}),
1148
1285
  },
1149
1286
  null,
1150
1287
  2,
1151
1288
  ),
1152
1289
  );
1290
+ } else {
1291
+ // The leaf may have captured its own detail; surface it, then the uniform
1292
+ // outcome line the same way for every platform.
1293
+ if (stop.output) process.stderr.write(`${stop.output}\n`);
1294
+ console.error(`${color(stop.status === 0 ? 'ok' : 'err', stop.status === 0 ? '✓' : '✗')} ${stop.summary}`);
1153
1295
  }
1154
- return exitCode;
1296
+ return stop.status;
1155
1297
  }
1156
1298
 
1157
1299
  async function handleCall(argv: string[]): Promise<number> {
@@ -1168,9 +1310,23 @@ async function handleCall(argv: string[]): Promise<number> {
1168
1310
  const { options } = parseArgs(rest, 'call');
1169
1311
  const json = optionFlag(options, 'json');
1170
1312
  if (!shortName) {
1171
- const message = 'call requires <action>. Example: mm-harness call unlock --adapter extension';
1172
- if (json) console.log(JSON.stringify({ schemaVersion: 1, command: 'call', error: { code: 'USAGE', message } }, null, 2));
1173
- else console.error(message);
1313
+ // Context-aware usage: the example uses the DETECTED adapter and a REAL action
1314
+ // from its manifest never a hardcoded action/adapter that may not exist in
1315
+ // this checkout — and points at the scoped discovery command.
1316
+ let example = 'mm-harness call <action>';
1317
+ let discovery = 'mm-harness actions';
1318
+ try {
1319
+ const { adapter } = resolveAdapter(options);
1320
+ const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
1321
+ const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
1322
+ const exampleAction = pickCallExampleAction(getRecipeActionManifestActionNames(manifest));
1323
+ example = `mm-harness call ${exampleAction} --adapter ${adapter}`;
1324
+ discovery = `mm-harness actions --adapter ${adapter}`;
1325
+ } catch { /* adapter/manifest unavailable — keep the generic example */ }
1326
+ const message = `call requires <action>. Example: ${example}`;
1327
+ const userAction = `${example} # see the vocabulary: ${discovery}`;
1328
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: 'call', error: { code: 'USAGE', message, userAction } }, null, 2));
1329
+ else console.error(`${message}\n See the vocabulary: ${discovery}`);
1174
1330
  return EXIT.usage;
1175
1331
  }
1176
1332
 
@@ -1340,6 +1496,16 @@ function resolveActionName(shortName: string, names: string[]): ActionResolution
1340
1496
  return { status: 'unknown', resolved: '', candidates: [] };
1341
1497
  }
1342
1498
 
1499
+ // A real action name to show in the `call` usage example, from THIS adapter's
1500
+ // manifest: a wallet action when the platform has one, else the universal
1501
+ // `command` action, else the first declared name. Never a hardcoded guess.
1502
+ function pickCallExampleAction(names: string[]): string {
1503
+ const walletish = names.find((name) => /wallet|unlock/u.test(name));
1504
+ if (walletish) return walletish;
1505
+ if (names.includes('command')) return 'command';
1506
+ return names[0] ?? 'command';
1507
+ }
1508
+
1343
1509
  function synthesizeOneNodeRecipe(action: string, args: Record<string, string>): Record<string, unknown> {
1344
1510
  return {
1345
1511
  schema_version: 1,
@@ -1794,6 +1960,16 @@ function serializeLibrarySources(sources: MetaMaskLibrarySource[]): string {
1794
1960
  .join(':');
1795
1961
  }
1796
1962
 
1963
+ // Map a resolved library source to its precedence tier for the flows legend, from
1964
+ // the ACTUAL resolution rather than an invented label: the canonical MetaMask
1965
+ // library this runner appends last, the personal library the harness names
1966
+ // 'personal', and any other configured library in between as the team/shared tier.
1967
+ function flowSourceTier(source: MetaMaskLibrarySource, index: number, total: number): string {
1968
+ if (index === total - 1 && source.name === 'metamask') return 'canonical';
1969
+ if (source.name === 'personal') return 'personal';
1970
+ return 'team';
1971
+ }
1972
+
1797
1973
  // The engine's flows subcommands (grounded in registerFlowsCommand): a bare
1798
1974
  // `flows` (or one that leads with a flag) means list.
1799
1975
  const FLOWS_SUBCOMMANDS: readonly string[] = ['list', 'promote'];
@@ -1824,11 +2000,25 @@ async function handleFlows(argv: string[]): Promise<number> {
1824
2000
  const sources = await resolveMetaMaskLibrarySources(undefined);
1825
2001
  if (!sources) {
1826
2002
  throw usageError(
1827
- 'flows requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support). ' +
2003
+ 'flows requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support; ' +
2004
+ 'flows resolve personal > team > canonical). ' +
1828
2005
  'Next: update the dependency and run yarn install.',
1829
2006
  );
1830
2007
  }
1831
2008
  const forwarded = stripTargetFlag(argv);
2009
+ const jsonMode = forwarded.includes('--json');
2010
+ // Provenance: flows resolve by precedence — the highest-precedence copy of a ref
2011
+ // wins and shadows lower ones, so a LOCAL library outranks canonical. The listing
2012
+ // below prints `source=<name>` per flow; this legend maps each resolved source to
2013
+ // its tier so that source is decodable. --json reserves stdout for the engine
2014
+ // envelope, so the legend rides stderr only.
2015
+ if (!jsonMode) {
2016
+ console.error(color('label', 'flows resolve by precedence (highest wins; local shadows canonical):'));
2017
+ sources.forEach((source, index) => {
2018
+ const name = source.name ?? path.basename(source.root);
2019
+ console.error(` ${index + 1}. ${color('cmd', name)} ${color('dim', `[${flowSourceTier(source, index, sources.length)}]`)}`);
2020
+ });
2021
+ }
1832
2022
  // The engine requires an explicit subcommand. Inspect only the FIRST token: a
1833
2023
  // real subcommand there is passed through; anything else (empty, or a leading
1834
2024
  // flag whose VALUE must not be mistaken for a subcommand) defaults to list.
@@ -5,6 +5,7 @@
5
5
  import path from 'node:path';
6
6
 
7
7
  import { runnerDir } from '../paths.ts';
8
+ import { getAdapterSurface } from '../adapters/surface.ts';
8
9
  import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, targetOf, usageOut } from './shared.ts';
9
10
 
10
11
  const DEBUG_BOOLEANS = new Set(['worker', 'devMenu', 'json']);
@@ -16,7 +17,8 @@ export async function handleDebug(argv: string[]): Promise<number> {
16
17
  const adapter = resolveAdapter(options, target);
17
18
 
18
19
  if (!adapter) return usageOut(json, 'debug', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
19
- if (adapter === 'core') return usageOut(json, 'debug', 'core is headless; there is no debug console.', 'mm-harness verify');
20
+ const surface = getAdapterSurface(adapter);
21
+ if (surface.headless) return usageOut(json, 'debug', 'core is headless; there is no debug console.', surface.hints.relaunch);
20
22
 
21
23
  const worker = flag(options, 'worker');
22
24
  const devMenu = flag(options, 'devMenu');
@@ -6,6 +6,7 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
8
  import { runnerDir, walletFixturePath } from '../paths.ts';
9
+ import { getAdapterSurface } from '../adapters/surface.ts';
9
10
  import type { MetaMaskRecipeAdapter } from '../types.ts';
10
11
  import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, str, targetOf, usageOut } from './shared.ts';
11
12
 
@@ -55,9 +56,13 @@ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise
55
56
  if (!adapter) {
56
57
  return usageOut(json, 'fixtures', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
57
58
  }
58
- if (adapter === 'core') return usageOut(json, 'fixtures', 'core is headless; it has no wallet fixture.', 'mm-harness run <recipe> # run recipes against the headless core');
59
+ const surface = getAdapterSurface(adapter);
60
+ if (surface.headless) return usageOut(json, 'fixtures', 'core is headless; it has no wallet fixture.', surface.hints.launch);
59
61
 
60
62
  const canonicalFixture = walletFixturePath(target);
63
+ // Platform-phrased retry hint on a setup-wallet failure: the surface owns the
64
+ // (re)build/relaunch command; this command never spells out another platform's.
65
+ const retryHint = `${surface.hints.relaunch} # relaunch, then retry: mm-harness fixtures set`;
61
66
 
62
67
  if (sub === 'sync') {
63
68
  const exitCode = fixturesSync(adapter, target, json);
@@ -104,7 +109,7 @@ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise
104
109
  else process.env.APP_ROOT = previousAppRoot;
105
110
  }
106
111
  if (!json && status === 'fail') {
107
- console.error(' Next: mm-harness launch ios # relaunch the dev client, then retry: mm-harness fixtures set');
112
+ console.error(` Next: ${retryHint}`);
108
113
  }
109
114
  } else {
110
115
  // Extension has no standalone set arm — reuse call's one-node machinery via
@@ -127,7 +132,7 @@ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise
127
132
  else process.env.RECIPE_WALLET_FIXTURE = previousFixtureEnv;
128
133
  }
129
134
  if (!json && status === 'fail') {
130
- console.error(' Next: mm-harness launch --build # rebuild and relaunch, then retry: mm-harness fixtures set');
135
+ console.error(` Next: ${retryHint}`);
131
136
  }
132
137
  }
133
138
 
@@ -149,9 +154,7 @@ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise
149
154
  error: status === 'fail' ? {
150
155
  code: 'SETUP_WALLET_FAILED',
151
156
  message: 'wallet fixture setup failed',
152
- userAction: adapter === 'mobile'
153
- ? 'mm-harness launch ios # relaunch the dev client, then retry: mm-harness fixtures set'
154
- : 'mm-harness launch --build # rebuild and relaunch, then retry: mm-harness fixtures set',
157
+ userAction: retryHint,
155
158
  } : null,
156
159
  },
157
160
  null,
@@ -166,8 +169,10 @@ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise
166
169
 
167
170
  // Sync the overlay installation + wallet fixture for the checkout.
168
171
  // Re-runs the adapter inject to refresh patched files, then copies the wallet
169
- // fixture to the canonical runtime location via sync-wallet-fixture.sh.
170
- function fixturesSync(adapter: 'mobile' | 'extension', target: string, json: boolean): number {
172
+ // fixture to the canonical runtime location via sync-wallet-fixture.sh. Reached
173
+ // only for device adapters (headless core returns earlier); the adapter is
174
+ // forwarded verbatim to `install`.
175
+ function fixturesSync(adapter: MetaMaskRecipeAdapter, target: string, json: boolean): number {
171
176
  const mmHarnessBin = path.join(runnerDir, 'bin/mm-harness');
172
177
  const installResult = spawnScript(
173
178
  mmHarnessBin,