@deeeed/metamask-harness 0.3.8 → 0.4.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.
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,
@@ -27,6 +28,8 @@ import { ensureExtensionReady } from './adapters/extension/ensure-ready.ts';
27
28
  import { resolveExtensionId } from './adapters/extension/extension-id.ts';
28
29
  import { decideExtensionReadiness } from './adapters/extension/runtime-decision.ts';
29
30
  import { decideMobileReadiness } from './adapters/mobile/runtime-decision.ts';
31
+ import { getAdapterSurface } from './adapters/surface.ts';
32
+ import type { AdapterRuntimeStatus } from './adapters/surface.ts';
30
33
  // NOTE: extension-runtime.ts loads the recipe harness at module scope, so it
31
34
  // is imported LAZILY (dynamic import) only inside the handlers that drive a live
32
35
  // runtime. Static-import it here and every command — manifest, doctor,
@@ -420,7 +423,7 @@ async function runSelfTest(options: CliOptions) {
420
423
  }
421
424
 
422
425
  async function handleManifest({ options }: ParsedArgs): Promise<number> {
423
- const adapter = adapterOption(options);
426
+ const { adapter } = resolveAdapter(options);
424
427
  const actionManifestPath = actionManifestPathOption(options, adapter);
425
428
  const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
426
429
  await validateManifest(manifest);
@@ -430,7 +433,7 @@ async function handleManifest({ options }: ParsedArgs): Promise<number> {
430
433
  }
431
434
 
432
435
  async function handleActions({ options }: ParsedArgs): Promise<number> {
433
- const adapter = adapterOption(options);
436
+ const { adapter } = resolveAdapter(options);
434
437
  const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
435
438
  await validateManifest(manifest);
436
439
  const action = optionString(options, 'action');
@@ -475,9 +478,31 @@ async function handleDoctor({ options }: ParsedArgs): Promise<number> {
475
478
  }
476
479
 
477
480
  const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
478
- if (json) console.log(JSON.stringify(result, null, 2));
481
+ // Live runtime state, per platform, via the adapter surface: every adapter
482
+ // reports the same read-only readiness shape (decision + reasons, deps currency,
483
+ // and its dev server where it has one) so doctor renders one line the same way
484
+ // for mobile, extension, and core. doctor never branches on adapter for this.
485
+ let runtime: AdapterRuntimeStatus | undefined;
486
+ try {
487
+ const surface = getAdapterSurface(adapter);
488
+ surface.resolveSlotPorts(target);
489
+ runtime = await surface.runtimeStatus(target);
490
+ } catch { /* readiness probe unavailable — doctor stays useful without it */ }
491
+ if (json) console.log(JSON.stringify({ ...result, runtime }, null, 2));
479
492
  else {
480
- console.log(`${result.status} ${adapter} ${result.compatibilityMode} manifest=${actionManifestPath}`);
493
+ const out = (style: string, text: string) => color(style, text, { stream: process.stdout });
494
+ const stateStyle = (value: string | undefined, good: string) => (value === good ? 'ok' : 'warn');
495
+ console.log(`${out(result.status === 'pass' ? 'ok' : 'err', result.status)} ${out('bold', adapter)} ${result.compatibilityMode} ${out('dim', `manifest=${actionManifestPath}`)}`);
496
+ if (runtime) {
497
+ const decisionStyle = runtime.decision === 'ready' ? 'ok' : runtime.decision === 'blocked' ? 'err' : 'warn';
498
+ const devServer = runtime.devServer
499
+ ? ` ${runtime.devServer.label}=${out(stateStyle(runtime.devServer.status, 'up'), runtime.devServer.status)}`
500
+ : '';
501
+ console.log(
502
+ `${out('label', 'runtime:')} decision=${out(decisionStyle, runtime.decision)}${runtime.reasonCode ? ` ${out('dim', `(${runtime.reasonCode})`)}` : ''} deps=${out(stateStyle(runtime.deps, 'current'), runtime.deps ?? 'unknown')}${devServer}`,
503
+ );
504
+ for (const reason of runtime.reasons) console.log(` ${out('dim', reason)}`);
505
+ }
481
506
  console.log(renderRuntimeContext(result.runtimeContext));
482
507
  }
483
508
  return result.status === 'pass' ? 0 : 1;
@@ -758,6 +783,7 @@ async function handleRuntimeDecision({ options }: ParsedArgs): Promise<number> {
758
783
  metroLog: optionString(options, 'metroLog'),
759
784
  platform: optionString(options, 'platform') ?? process.env.PLATFORM ?? process.env.RECIPE_HARNESS_PLATFORM,
760
785
  record: optionFlag(options, 'record'),
786
+ preflightMode: optionString(options, 'preflightMode'),
761
787
  });
762
788
  if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
763
789
  else console.log(`${report.decision} ${report.reasonCode} — ${report.reasons[0] ?? ''}`);
@@ -1082,6 +1108,51 @@ function emitPlanUsageError(
1082
1108
  // hand it to runRecipe). One execution path, two doors: `call` = one node, `run` =
1083
1109
  // a graph. Inherits run semantics: always-validates (adapter-aware, exit 5), same
1084
1110
  // trace/evidence artifacts, same --json contract.
1111
+ async function handleStop(argv: string[]): Promise<number> {
1112
+ const { options } = parseArgs(argv, 'stop');
1113
+ const json = optionFlag(options, 'json');
1114
+ const { adapter, target } = resolveAdapter(options);
1115
+ const surface = getAdapterSurface(adapter);
1116
+ // Slot-scope the stop: an explicit --port wins, else resolve the checkout's
1117
+ // own dev-server port so a concurrent slot's server is never signalled.
1118
+ const explicitPort = optionString(options, 'port') ?? optionString(options, 'watcherPort');
1119
+ if (explicitPort) {
1120
+ process.env.WATCHER_PORT = explicitPort;
1121
+ process.env.METRO_PORT = explicitPort;
1122
+ } else {
1123
+ surface.resolveSlotPorts(target);
1124
+ }
1125
+ const stop = surface.devServer.stop(target);
1126
+ if (stop.kind === 'headless') {
1127
+ // core has no dev server — teach the reachable headless path (exit 2).
1128
+ return usageOut(json, 'stop', stop.message, stop.userAction);
1129
+ }
1130
+ if (json) {
1131
+ console.log(
1132
+ JSON.stringify(
1133
+ {
1134
+ schemaVersion: 1,
1135
+ command: 'stop',
1136
+ adapter,
1137
+ target,
1138
+ status: stop.status === 0 ? 'pass' : 'fail',
1139
+ ...(stop.signalled !== undefined ? { signalled: stop.signalled } : {}),
1140
+ exitCode: stop.status,
1141
+ ...(stop.output ? { output: stop.output } : {}),
1142
+ },
1143
+ null,
1144
+ 2,
1145
+ ),
1146
+ );
1147
+ } else {
1148
+ // The leaf may have captured its own detail; surface it, then the uniform
1149
+ // outcome line the same way for every platform.
1150
+ if (stop.output) process.stderr.write(`${stop.output}\n`);
1151
+ console.error(`${color(stop.status === 0 ? 'ok' : 'err', stop.status === 0 ? '✓' : '✗')} ${stop.summary}`);
1152
+ }
1153
+ return stop.status;
1154
+ }
1155
+
1085
1156
  async function handleCall(argv: string[]): Promise<number> {
1086
1157
  // Grammar: mm-harness call <action> [--arg k=v ...] [flags].
1087
1158
  // If the first token is a flag, the action positional is missing — parseCallArgs
@@ -1096,9 +1167,23 @@ async function handleCall(argv: string[]): Promise<number> {
1096
1167
  const { options } = parseArgs(rest, 'call');
1097
1168
  const json = optionFlag(options, 'json');
1098
1169
  if (!shortName) {
1099
- const message = 'call requires <action>. Example: mm-harness call unlock --adapter extension';
1100
- if (json) console.log(JSON.stringify({ schemaVersion: 1, command: 'call', error: { code: 'USAGE', message } }, null, 2));
1101
- else console.error(message);
1170
+ // Context-aware usage: the example uses the DETECTED adapter and a REAL action
1171
+ // from its manifest never a hardcoded action/adapter that may not exist in
1172
+ // this checkout — and points at the scoped discovery command.
1173
+ let example = 'mm-harness call <action>';
1174
+ let discovery = 'mm-harness actions';
1175
+ try {
1176
+ const { adapter } = resolveAdapter(options);
1177
+ const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
1178
+ const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
1179
+ const exampleAction = pickCallExampleAction(getRecipeActionManifestActionNames(manifest));
1180
+ example = `mm-harness call ${exampleAction} --adapter ${adapter}`;
1181
+ discovery = `mm-harness actions --adapter ${adapter}`;
1182
+ } catch { /* adapter/manifest unavailable — keep the generic example */ }
1183
+ const message = `call requires <action>. Example: ${example}`;
1184
+ const userAction = `${example} # see the vocabulary: ${discovery}`;
1185
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: 'call', error: { code: 'USAGE', message, userAction } }, null, 2));
1186
+ else console.error(`${message}\n See the vocabulary: ${discovery}`);
1102
1187
  return EXIT.usage;
1103
1188
  }
1104
1189
 
@@ -1268,6 +1353,16 @@ function resolveActionName(shortName: string, names: string[]): ActionResolution
1268
1353
  return { status: 'unknown', resolved: '', candidates: [] };
1269
1354
  }
1270
1355
 
1356
+ // A real action name to show in the `call` usage example, from THIS adapter's
1357
+ // manifest: a wallet action when the platform has one, else the universal
1358
+ // `command` action, else the first declared name. Never a hardcoded guess.
1359
+ function pickCallExampleAction(names: string[]): string {
1360
+ const walletish = names.find((name) => /wallet|unlock/u.test(name));
1361
+ if (walletish) return walletish;
1362
+ if (names.includes('command')) return 'command';
1363
+ return names[0] ?? 'command';
1364
+ }
1365
+
1271
1366
  function synthesizeOneNodeRecipe(action: string, args: Record<string, string>): Record<string, unknown> {
1272
1367
  return {
1273
1368
  schema_version: 1,
@@ -1722,6 +1817,16 @@ function serializeLibrarySources(sources: MetaMaskLibrarySource[]): string {
1722
1817
  .join(':');
1723
1818
  }
1724
1819
 
1820
+ // Map a resolved library source to its precedence tier for the flows legend, from
1821
+ // the ACTUAL resolution rather than an invented label: the canonical MetaMask
1822
+ // library this runner appends last, the personal library the harness names
1823
+ // 'personal', and any other configured library in between as the team/shared tier.
1824
+ function flowSourceTier(source: MetaMaskLibrarySource, index: number, total: number): string {
1825
+ if (index === total - 1 && source.name === 'metamask') return 'canonical';
1826
+ if (source.name === 'personal') return 'personal';
1827
+ return 'team';
1828
+ }
1829
+
1725
1830
  // The engine's flows subcommands (grounded in registerFlowsCommand): a bare
1726
1831
  // `flows` (or one that leads with a flag) means list.
1727
1832
  const FLOWS_SUBCOMMANDS: readonly string[] = ['list', 'promote'];
@@ -1752,11 +1857,25 @@ async function handleFlows(argv: string[]): Promise<number> {
1752
1857
  const sources = await resolveMetaMaskLibrarySources(undefined);
1753
1858
  if (!sources) {
1754
1859
  throw usageError(
1755
- 'flows requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support). ' +
1860
+ 'flows requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support; ' +
1861
+ 'flows resolve personal > team > canonical). ' +
1756
1862
  'Next: update the dependency and run yarn install.',
1757
1863
  );
1758
1864
  }
1759
1865
  const forwarded = stripTargetFlag(argv);
1866
+ const jsonMode = forwarded.includes('--json');
1867
+ // Provenance: flows resolve by precedence — the highest-precedence copy of a ref
1868
+ // wins and shadows lower ones, so a LOCAL library outranks canonical. The listing
1869
+ // below prints `source=<name>` per flow; this legend maps each resolved source to
1870
+ // its tier so that source is decodable. --json reserves stdout for the engine
1871
+ // envelope, so the legend rides stderr only.
1872
+ if (!jsonMode) {
1873
+ console.error(color('label', 'flows resolve by precedence (highest wins; local shadows canonical):'));
1874
+ sources.forEach((source, index) => {
1875
+ const name = source.name ?? path.basename(source.root);
1876
+ console.error(` ${index + 1}. ${color('cmd', name)} ${color('dim', `[${flowSourceTier(source, index, sources.length)}]`)}`);
1877
+ });
1878
+ }
1760
1879
  // The engine requires an explicit subcommand. Inspect only the FIRST token: a
1761
1880
  // real subcommand there is passed through; anything else (empty, or a leading
1762
1881
  // flag whose VALUE must not be mistaken for a subcommand) defaults to list.
@@ -1803,6 +1922,7 @@ export async function main(argv: string[]): Promise<number> {
1803
1922
  // launch/logs/debug/fixtures compose adapter scripts directly and own policy,
1804
1923
  // healing, teaching, and the --json contract.
1805
1924
  if (command === 'launch') return handleLaunch(argv.slice(1));
1925
+ if (command === 'stop') return handleStop(argv.slice(1));
1806
1926
  if (command === 'logs') return handleLogs(argv.slice(1));
1807
1927
  if (command === 'debug') return handleDebug(argv.slice(1));
1808
1928
  if (command === 'fixtures') return handleFixtures(argv.slice(1), { runOneNode });
@@ -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,
@@ -8,10 +8,13 @@ import { execFileSync } from 'node:child_process';
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
 
11
+ import { color } from '../cli-color.ts';
11
12
  import { handleHarness } 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';
16
+ import { getAdapterSurface } from '../adapters/surface.ts';
17
+ import { stopExtensionWatcher } from '../adapters/slot-ports.ts';
15
18
  import {
16
19
  ADAPTER_DETECT_NEXT,
17
20
  EXIT,
@@ -19,6 +22,7 @@ import {
19
22
  parseFlags,
20
23
  resolveAdapter,
21
24
  spawnScript,
25
+ spawnScriptStreaming,
22
26
  str,
23
27
  targetOf,
24
28
  usageOut,
@@ -205,56 +209,6 @@ export async function handleLaunch(argv: string[]): Promise<number> {
205
209
  });
206
210
  }
207
211
 
208
- // Apply KEY=VALUE lines from slot resolution to process.env.
209
- // overwrite=true → pool match, always overrides existing env.
210
- // overwrite=false → formula match, only fills vars that are unset.
211
- function applyKVLines(output: string, overwrite: boolean): void {
212
- for (const line of output.split('\n')) {
213
- const m = /^([A-Z_]+)=(.+)$/u.exec(line.trim());
214
- if (!m) continue;
215
- const [, key, val] = m;
216
- switch (key) {
217
- case 'WATCHER_PORT':
218
- if (overwrite || !process.env['WATCHER_PORT']) {
219
- process.env['WATCHER_PORT'] = val;
220
- process.env['METRO_PORT'] = val;
221
- process.env['RECIPE_WATCHER_PORT'] = val;
222
- }
223
- break;
224
- case 'IOS_SIMULATOR':
225
- if (overwrite || !process.env['IOS_SIMULATOR']) process.env['IOS_SIMULATOR'] = val;
226
- break;
227
- case 'SLOT_ID':
228
- if (overwrite || !process.env['RECIPE_SLOT_ID']) process.env['RECIPE_SLOT_ID'] = val;
229
- break;
230
- }
231
- }
232
- }
233
-
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
236
- // CLI flag overrides so flags always win at the top.
237
- function resolveMobileSlotPorts(target: string): void {
238
- const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
239
- try {
240
- // Pool match always wins — overwrite whatever is in the environment.
241
- const poolOut = execFileSync('bash', [
242
- '-c', `source "${resolveScript}" && resolve_farmslot_ports_by_repo "${target}"`,
243
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
244
- if (poolOut.trim()) {
245
- applyKVLines(poolOut, true);
246
- return;
247
- }
248
- } catch { /* no pool match — fall through to formula */ }
249
- try {
250
- // Formula match only fills unset vars (never overrides explicit env/pool).
251
- const defOut = execFileSync('bash', [
252
- '-c', `source "${resolveScript}" && resolve_mobile_slot_defaults "${target}"`,
253
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
254
- if (defOut.trim()) applyKVLines(defOut, false);
255
- } catch { /* no slot suffix in dir name — stays at env defaults */ }
256
- }
257
-
258
212
  // Map the env-gap flags onto the env vars the leaf scripts read. For mobile, slot
259
213
  // port/simulator defaults are resolved first (pool-wins, then formula-fills-empty)
260
214
  // so that the harness respects slot isolation; explicit CLI flags applied below
@@ -265,9 +219,12 @@ function applyLaunchEnvOverrides(
265
219
  mobileTarget: string | undefined,
266
220
  target: string,
267
221
  ): 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.
270
- if (adapter === 'mobile') resolveMobileSlotPorts(target);
222
+ // Slot isolation: resolve ports/device from the checkout's own slot context,
223
+ // the farmslot pool, or the directory-suffix formula BEFORE applying explicit
224
+ // flags extension needs this as much as mobile (CDP_PORT), so neither
225
+ // adapter hard-fails on a value its slot already knows. The surface owns the
226
+ // per-platform resolution; this command never branches on adapter for it.
227
+ getAdapterSurface(adapter).resolveSlotPorts(target);
271
228
 
272
229
  const device = str(options, 'device');
273
230
  if (device && adapter === 'mobile') {
@@ -346,69 +303,25 @@ async function executeComposition(
346
303
  return extensionRebuild(target, json);
347
304
  }
348
305
 
349
- // Extension build or watch: full webpack compile via start-watch.sh.
306
+ // Extension build or watch: full webpack compile via start-watch.sh. Streams
307
+ // live — a webpack compile is minutes long and must never look hung.
350
308
  const startWatchSh = path.join(runnerDir, 'adapters/extension/start-watch.sh');
351
309
  const watchArgs = ['--target', target];
352
310
  if (process.env.WATCHER_PORT) watchArgs.push('--watcher-port', process.env.WATCHER_PORT);
353
- return spawnScript(startWatchSh, watchArgs, target, json);
311
+ console.error(`→ extension ${wantWatch ? 'watch' : 'build'} — webpack :${process.env.WATCHER_PORT ?? 'default'} (output streams below)`);
312
+ return spawnScriptStreaming(startWatchSh, watchArgs, target);
354
313
  }
355
314
 
356
315
  // Extension rebuild: kill the harness-owned webpack watcher (pid file + ps-scan
357
316
  // for orphans), clear the rebuild log, then drive the installed overlay's
358
317
  // live.sh --start-watch and tee its output into the rebuild log.
359
- function extensionRebuild(target: string, json: boolean): ScriptResult {
318
+ async function extensionRebuild(target: string, json: boolean): Promise<ScriptResult> {
360
319
  const runtimeDirRel = recipeRuntimeDir();
361
320
  const runtimeAbs = path.join(target, runtimeDirRel);
362
- const webpackPidFile = path.join(runtimeAbs, 'recipe-harness-webpack.pid');
363
321
  const rebuildLog = path.join(runtimeAbs, 'rebuild.log');
364
322
 
365
323
  // E1a: Kill harness-owned watcher via pid file, then ps-scan for orphans.
366
- try {
367
- const pid = fs.readFileSync(webpackPidFile, 'utf8').trim();
368
- if (/^\d+$/u.test(pid)) {
369
- try { process.kill(Number(pid), 'SIGTERM'); } catch { /* already dead */ }
370
- }
371
- fs.rmSync(webpackPidFile, { force: true });
372
- } catch { /* no pid file */ }
373
- // Scan for any remaining orphan webpack/yarn-start processes in this checkout.
374
- try {
375
- const psOut = execFileSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' });
376
- const orphanPids: number[] = [];
377
- for (const line of psOut.split('\n')) {
378
- const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
379
- if (!match) continue;
380
- const [, pidStr, cmd] = match;
381
- const isWatcher =
382
- cmd.includes('yarn start') ||
383
- cmd.includes('webpack --watch') ||
384
- cmd.includes('development/webpack/launch.ts --watch');
385
- if (!isWatcher) continue;
386
- if (cmd.includes(target)) {
387
- orphanPids.push(Number(pidStr));
388
- continue;
389
- }
390
- // lsof cwd fallback for processes that don't embed the path in argv.
391
- try {
392
- const cwd = execFileSync('lsof', ['-a', `-p${pidStr}`, '-dcwd', '-Fn'], {
393
- encoding: 'utf8',
394
- timeout: 2000,
395
- });
396
- if (cwd.split('\n').some((l) => l.startsWith('n') && l.slice(1) === target)) {
397
- orphanPids.push(Number(pidStr));
398
- }
399
- } catch { /* lsof unavailable or permission denied */ }
400
- }
401
- if (orphanPids.length > 0) {
402
- for (const pid of orphanPids) {
403
- try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
404
- }
405
- // Brief pause then force-kill survivors.
406
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
407
- for (const pid of orphanPids) {
408
- try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
409
- }
410
- }
411
- } catch { /* ps not available */ }
324
+ stopExtensionWatcher(target);
412
325
 
413
326
  // E1c: Clear the rebuild log (directory must exist for tee).
414
327
  fs.mkdirSync(path.dirname(rebuildLog), { recursive: true });
@@ -418,7 +331,10 @@ function extensionRebuild(target: string, json: boolean): ScriptResult {
418
331
  const liveArgs = ['--target', target, '--start-watch'];
419
332
  if (process.env.CDP_PORT) liveArgs.push('--cdp-port', process.env.CDP_PORT);
420
333
  if (process.env.WATCHER_PORT) liveArgs.push('--watcher-port', process.env.WATCHER_PORT);
421
- const result = spawnScript(liveScript, liveArgs, target, json);
334
+ // Streams live: the watcher restart + Chromium boot runs for minutes and the
335
+ // command must show progress immediately, not a silent prompt.
336
+ console.error(`→ extension quick relaunch — webpack :${process.env.WATCHER_PORT ?? 'default'} · CDP :${process.env.CDP_PORT ?? 'default'} (output streams below)`);
337
+ const result = await spawnScriptStreaming(liveScript, liveArgs, target);
422
338
  // Tee live.sh output into rebuild.log for post-mortem diagnosis.
423
339
  if (result.output) {
424
340
  try { fs.appendFileSync(rebuildLog, result.output); } catch { /* best-effort */ }
@@ -489,7 +405,18 @@ function launchPass(
489
405
  ),
490
406
  );
491
407
  } else {
492
- console.error(`✓ launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ''} (${tier}) ready`);
408
+ // The summary must say what actually happened where: the target device,
409
+ // what the tier meant, and that the app+bridge are up — "(quick) ready"
410
+ // alone reads as a no-op even when a launch occurred.
411
+ const device =
412
+ adapter === 'mobile'
413
+ ? process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || 'booted device'
414
+ : displayMode;
415
+ const tierNote = tier === 'quick' ? 'quick relaunch, no native build' : tier;
416
+ const devNote = process.env.MM_HARNESS_BIN ? ` ${color('dim', '[dev: MM_HARNESS_BIN]')}` : '';
417
+ console.error(
418
+ `${color('ok', '✓')} launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ''} — ${color('ok', String(device))} · ${tierNote} · app + bridge ready${devNote}`,
419
+ );
493
420
  }
494
421
  return EXIT.ok;
495
422
  }
@@ -5,7 +5,8 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
- import { recipeRuntimePath, runnerDir } from '../paths.ts';
8
+ import { runnerDir } from '../paths.ts';
9
+ import { getAdapterSurface } from '../adapters/surface.ts';
9
10
  import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, str, targetOf, usageOut } from './shared.ts';
10
11
 
11
12
  const LOGS_BOOLEANS = new Set(['full', 'json']);
@@ -19,13 +20,23 @@ export async function handleLogs(argv: string[]): Promise<number> {
19
20
  if (!adapter) {
20
21
  return usageOut(json, 'logs', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
21
22
  }
22
- if (adapter === 'core') {
23
- return usageOut(json, 'logs', 'core is headless; there are no app/Metro logs.', 'mm-harness run <recipe> # run recipes against the headless core');
23
+ const surface = getAdapterSurface(adapter);
24
+ if (surface.headless) {
25
+ return usageOut(json, 'logs', 'core is headless; it has no dev server logs.', surface.hints.launch);
24
26
  }
25
27
 
26
- const source = str(options, 'source') ?? 'metro';
27
- if (source !== 'metro' && source !== 'app') {
28
- return usageOut(json, 'logs', '--source must be metro or app.', 'mm-harness logs --source metro or mm-harness logs --source app');
28
+ // Source selection is platform-scoped: the valid names and the default come
29
+ // from THIS adapter's dev-server logs (mobile: metro; extension: webpack), plus
30
+ // the app-log source. No adapter's vocabulary is hardcoded here every
31
+ // non-headless adapter provides at least one log source (headless core returned
32
+ // above), so the default is that adapter's first source.
33
+ const logSources = surface.logSources(target);
34
+ const sourceLabels = logSources.map((entry) => entry.label);
35
+ const defaultSource = sourceLabels[0];
36
+ const source = str(options, 'source') ?? defaultSource;
37
+ const validSources = [...sourceLabels, 'app'];
38
+ if (!validSources.includes(source)) {
39
+ return usageOut(json, 'logs', `--source must be one of: ${validSources.join(', ')}.`, `mm-harness logs --source ${defaultSource}`);
29
40
  }
30
41
 
31
42
  // Env-gap flag (docs/CLI-SPEC.md Part 4): --events sets the compact event count
@@ -39,16 +50,21 @@ export async function handleLogs(argv: string[]): Promise<number> {
39
50
  process.env.RECIPE_LOG_EVENTS = events;
40
51
  }
41
52
 
42
- // Nothing running → teaching error pointing at launch. The log file is the
43
- // signal that Metro/webpack has been started for this checkout.
44
- const logFile = recipeRuntimePath(target, adapter === 'mobile' ? 'metro.log' : 'webpack.log');
45
- if (!fs.existsSync(logFile)) {
46
- const launchHint = adapter === 'mobile' ? 'mm-harness launch ios' : 'mm-harness launch';
53
+ // Nothing running → teaching error pointing at launch. A dev-server log file is
54
+ // the signal that the dev server has been started for this checkout; the surface
55
+ // owns which files a platform writes (mobile: metro.log; extension: webpack +
56
+ // watcher + rebuild logs). A `--source` naming a specific dev-server log is
57
+ // preferred; otherwise the most-relevant existing candidate is tailed.
58
+ const requested = logSources.find((entry) => entry.label === source);
59
+ const ordered = requested ? [requested, ...logSources.filter((entry) => entry !== requested)] : logSources;
60
+ const logFile = ordered.find((entry) => fs.existsSync(entry.path))?.path;
61
+ if (!logFile) {
62
+ const names = logSources.map((entry) => path.basename(entry.path)).join(' / ');
47
63
  return usageOut(
48
64
  json,
49
65
  'logs',
50
- `nothing running for this checkout (no ${path.basename(logFile)}).`,
51
- launchHint,
66
+ `nothing running for this checkout (no ${names}).`,
67
+ surface.hints.launch,
52
68
  );
53
69
  }
54
70