@deeeed/metamask-harness 0.3.9 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-07-04
4
+
5
+ Minor release: the adapter-surface architecture plus the day's feature wave (0.3.6-0.3.9 shipped fixes as they were validated live; this cut marks the surface additions honestly).
6
+
7
+ ### Added
8
+ - **AdapterSurface** — one per-platform surface behind a common interface (`src/adapters/surface.ts`: resolveSlotPorts, runtimeStatus, devServer, logSources, hints); doctor/launch/stop/logs/debug/fixtures are surface-backed, so no verb hand-rolls platform branching. Contract: `docs/ADAPTER-SURFACE.md` (never-branch rule, add-a-platform checklist, migration table).
9
+ - **Platform-adaptive `stop` on every adapter** — extension: kills this checkout's webpack watcher (pid file + orphan scan, slot-scoped) and closes its tail window; mobile: port-scoped Metro; core: headless teaching error. Idempotent, outcome-stating summaries.
10
+ - **Doctor reports live runtime state on every adapter** — mobile Metro, extension webpack (via the extension readiness decision), core deps-only — one uniform shape, adapter-scoped colored context rows.
11
+ - **`logs` is multi-source per adapter** — sources and the `--source` default come from the adapter's logSources (extension: webpack/watcher/rebuild/app).
12
+ - **Context-aware examples and tier-visible flows** — `call` usage/help examples use the detected adapter's real manifest actions; `flows` prints a precedence legend decoding each source into personal > team > canonical.
13
+
14
+ ### Changed
15
+ - **BREAKING (agent contract)**: `doctor --json` `runtime.metro` (string) is now `runtime.devServer` `{label, status}` and is absent for core.
16
+
17
+ ### Feature wave recap (0.3.6-0.3.9)
18
+ - `mm-harness stop`; live-streamed mobile AND extension launches with upfront intent lines; presence-authoritative readiness (no setup-on-every-launch) with the dev-client running probe; slot-context-first device/port resolution; UTF-8 locale for pod spawns; DEV OVERRIDE banner + SLOT line + colored help; `mmdev-harness` completions; hermetic tmux-stubbed contract suite (now gating CI) + ESLint in `npm run check`; `docs/UX-PRINCIPLES.md` interaction contract.
19
+
3
20
  ## 0.3.9 - 2026-07-04
4
21
 
5
22
  ### Fixed
@@ -26,11 +43,19 @@
26
43
  - **Metro log-tail window no longer leaks into an unrelated tmux session** — running `start-metro` outside a tmux client fell back to `tmux display-message` (the last-attached session, e.g. the user's own) and created its `metro-<port>` window — with a `tail -F` — there, outliving the run. The viewer now opens a window only in a session that belongs to this run (a named slot session that exists, or the current session when actually inside tmux) and otherwise skips it; Metro still runs detached to the log. Extracted to `adapters/mobile/lib/tmux-viewer.sh` so the rule is unit-tested; contract tests stub tmux hermetically so no suite run can create a real window.
27
44
 
28
45
  ### Added
29
- - **`mm-harness stop`**stop the port-scoped Metro dev server this mobile checkout owns and close its log-tail window (idempotent; extension/core are slot-managed and get a teaching error). Slot-context-aware port resolution.
46
+ - **Adapter surface — one seam for per-platform behavior**`src/adapters/surface.ts` defines `AdapterSurface` + a `getAdapterSurface(adapter)` registry that owns the behavior commands used to hand-roll with `if (adapter === …)` ladders (slot-port resolution, read-only runtime status, dev-server describe/stop, log sources, and platform-phrased Next: hints), with thin `mobile`/`extension`/`core` implementations that delegate to the existing readiness/port plumbing (re-homed to `src/adapters/slot-ports.ts`). `doctor`, `launch`, `stop`, `logs`, `debug`, and `fixtures` now resolve platform behavior through the surface instead of branching. This closes the platform-blind class of bug (a platform handled in one command but forgotten in another). Documented in `docs/ADAPTER-SURFACE.md`; enforces `docs/UX-PRINCIPLES.md` principle 1.
47
+ - **`doctor` reports a runtime section for every adapter** — the live runtime readiness line (decision + reasons, deps currency, dev server) now renders for extension (webpack watcher via `decideExtensionReadiness`) and core (deps-presence-only), not just mobile; the render is one uniform shape sourced from the surface.
48
+ - **`mm-harness stop` is adaptive per platform** — mobile stops its port-scoped Metro and closes the log-tail window; extension stops the checkout's webpack watcher (pid file + orphan ps-scan, slot-scoped) and closes its tail window; core teaches the headless path. All idempotent (nothing-to-stop is success), with a uniform summary and `--json` envelope.
49
+ - **`logs` resolves dev-server log sources through the surface** — extension now recognizes the webpack, watcher, and rebuild logs (ordered), not only `webpack.log`; each platform's "nothing running" teaches its own launch hint. `--source` default and valid set come from the adapter's own log sources (extension defaults to `webpack`, not `metro`), and core teaches with platform-neutral wording (no Metro vocabulary). `--source <label>` now prefers that dev-server log.
50
+ - **`call` with no action gives a context-aware example** — the usage error shows the DETECTED adapter and a REAL action from its manifest (a wallet action where one exists, else the universal `command`) plus the scoped `mm-harness actions --adapter <detected>` discovery hint. The static `call --help` example now uses real actions (`ensure_unlocked` / `command`) and points at `mm-harness actions`, instead of a hardcoded `call unlock --adapter extension` naming a non-existent `metamask.wallet.unlock`.
51
+ - **`flows` surfaces the resolution tier per source** — `flows` prints a precedence legend mapping each resolved library source to its tier (`personal` / `team` / `canonical`), derived from the actual resolution, so the engine's `source=<name>` per flow is decodable and LOCAL libraries read as first-class; the tier is also named in the recipe-library-unavailable teaching error.
52
+ - **`stop --help` matches the adaptive behavior** — the help text now describes mobile Metro / extension webpack watcher / core headless, instead of the stale "Metro dev server … extension/core: teaching error".
30
53
  - **Slot-context device/port detection** — `resolve-farmslot-ports.sh` reads `agentic-runtime.json` (the mapping the orchestrator's prepare wrote into the checkout) first, so device/port resolution survives pool renames that break any naming formula.
31
54
  - **CLI help surface** — grouped/colored `mm-harness` help, a `DEV OVERRIDE ACTIVE` banner when `MM_HARNESS_BIN` is set, and a `SLOT` line when the checkout is a prepared slot; `mmdev-harness` completion alias.
32
55
 
33
56
  ### Changed
57
+ - **`doctor --json` runtime dev-server shape is normalized** — the mobile-only `runtime.metro` string is replaced by a platform-labelled `runtime.devServer: { label, status }` (`metro` for mobile, `webpack` for extension; absent for headless core), so agents read one shape across platforms.
58
+ - **`yarn check` runs ESLint** — flat-config ESLint (typescript-eslint recommended on `src/**/*.ts`, `@eslint/js` recommended on the `.mjs`/`.cjs` library) runs after typecheck and comment-hygiene. Dev-only (not in the published `files`); the baseline is zero-warning by config scoping.
34
59
  - **Deps install uses a scoped `setup:expo` variant when native isn't wanted** — `adapters/mobile/yarn-setup.sh` gains `--mode full|expo`. `expo` runs `yarn setup:expo --no-build-ios --no-build-android` (JS deps + prebuild, no native — native builds come from `open-device.sh`, never doubled); the full `yarn setup` runs only for a native build (`launch --build`). This is also the standalone Metro-only remedy taught by the fast-tier deps block.
35
60
  - **`mm-harness runtime-decision --adapter mobile` accepts `--preflight-mode`** — surfaces the fast (presence, blocks on a genuine gap) vs full (installs) decision for inspection and callers.
36
61
 
@@ -0,0 +1,119 @@
1
+ # The adapter surface — one seam for per-platform behavior
2
+
3
+ `mm-harness` runs against three platforms (mobile | extension | core). The
4
+ danger is that every command re-derives "what does this platform do?" with its
5
+ own `if (adapter === 'mobile') … else if (adapter === 'extension') …` ladder.
6
+ When it does, a platform gets forgotten in one command but not another — the
7
+ class of bug where `doctor` reported a live runtime section for mobile only,
8
+ `stop` refused every non-mobile adapter, and `logs` knew a single extension log
9
+ file instead of all three. Those are *platform-blind* commands.
10
+
11
+ The **adapter surface** is the fix: one interface per platform, resolved through
12
+ a registry, that owns the behavior a command would otherwise branch on.
13
+
14
+ ## The interface
15
+
16
+ `src/adapters/surface.ts` defines `AdapterSurface` and the registry
17
+ `getAdapterSurface(adapter)`. Each platform ships one implementation:
18
+
19
+ - `src/adapters/mobile/surface.ts`
20
+ - `src/adapters/extension/surface.ts`
21
+ - `src/adapters/core/surface.ts`
22
+
23
+ ```ts
24
+ interface AdapterSurface {
25
+ readonly adapter: MetaMaskRecipeAdapter;
26
+ readonly headless: boolean; // core runs no app/dev server
27
+ resolveSlotPorts(target: string): void; // context > pool > formula (no-op for core)
28
+ runtimeStatus(target: string): Promise<AdapterRuntimeStatus>; // read-only readiness for doctor
29
+ devServer: {
30
+ describe(): string; // "Metro" | "webpack watcher" | "no dev server (headless)"
31
+ stop(target: string): AdapterDevServerStop; // idempotent, slot-scoped; headless = teach
32
+ };
33
+ logSources(target: string): AdapterLogSource[]; // ordered candidate log files (empty for core)
34
+ hints: { launch: string; relaunch: string }; // platform-phrased Next: hints
35
+ }
36
+ ```
37
+
38
+ `runtimeStatus` returns a normalized shape so `doctor` renders one line the same
39
+ way for every platform:
40
+
41
+ ```ts
42
+ interface AdapterRuntimeStatus {
43
+ decision: string;
44
+ reasonCode?: string;
45
+ reasons: string[];
46
+ deps?: string;
47
+ devServer?: { label: string; status: string }; // absent for headless core
48
+ }
49
+ ```
50
+
51
+ The implementations are thin: they delegate to the readiness/port plumbing that
52
+ already existed (`decideExtensionReadiness`, `mobileRuntimeStatus`, the slot-port
53
+ resolvers and the webpack-watcher stop, all re-homed to
54
+ `src/adapters/slot-ports.ts`). The surface is an organizing seam, not a rewrite.
55
+
56
+ ## The rule
57
+
58
+ **A command never branches on adapter for behavior the surface owns.**
59
+
60
+ - Do not write `if (adapter === 'core')` — ask `surface.headless`.
61
+ - Do not write `adapter === 'mobile' ? metroPorts() : extensionPorts()` — call
62
+ `surface.resolveSlotPorts(target)`.
63
+ - Do not print `adapter === 'mobile' ? 'mm-harness launch ios' : 'mm-harness
64
+ launch'` — use `surface.hints.launch`.
65
+
66
+ A new platform behavior is added by **extending the surface** (a new member on
67
+ the interface plus its three implementations), never by adding another branch to
68
+ a command. TypeScript then makes it impossible to ship a platform that forgot the
69
+ new member, because each implementation is annotated `: AdapterSurface`.
70
+
71
+ What the surface deliberately does **not** own: a command may still branch on
72
+ adapter for a *mechanism* that is genuinely platform-specific and not part of the
73
+ interface — e.g. `fixtures set` uses a shell arm on mobile and the engine path on
74
+ extension. Those branches select a mechanism; they never re-derive readiness,
75
+ ports, log locations, dev-server lifecycle, or Next: phrasing, which are the
76
+ surface's responsibility.
77
+
78
+ ## Migration status
79
+
80
+ Every command that used to branch on adapter now resolves through the surface:
81
+
82
+ | command | surface-backed | uses |
83
+ | --- | --- | --- |
84
+ | `doctor` | yes | `resolveSlotPorts` + `runtimeStatus` (mobile/extension/core runtime section) |
85
+ | `launch` | yes | `resolveSlotPorts` |
86
+ | `stop` | yes | `resolveSlotPorts` + `devServer.stop` (+ headless teaching) |
87
+ | `logs` | yes | `logSources` + `hints.launch` + `headless` |
88
+ | `debug` | yes | `headless` + `hints.relaunch` (core teaching); flag semantics stay per-command |
89
+ | `fixtures` | yes | `headless` + `hints` (retry/launch); the set *mechanism* stays per-platform |
90
+ | `run` / `call` | n/a | engine path; core-headless is handled by the heal contract, not the surface |
91
+ | `flows` | no (by design) | recipe-library flows are adapter-global; not a platform-owned behavior |
92
+ | `completion-candidates` | partial | `actions` scope to the detected checkout adapter; `flows` are adapter-global |
93
+
94
+ `flows` is intentionally not surface-backed: a recipe-library flow can compose
95
+ actions across platforms, so flows are adapter-global by design rather than a
96
+ platform-blind gap. `completion-candidates actions` already resolves the adapter
97
+ from the checkout context (cwd/`--target`); only the adapter-global `flows`
98
+ candidates are unscoped, matching the flows model.
99
+
100
+ ## How this composes with the UX principles
101
+
102
+ `docs/UX-PRINCIPLES.md` principle 1 (*Context-aware by default* — "output is
103
+ scoped to the platform… help, flag lists, and completion candidates shrink to
104
+ what applies here") states the intent. The adapter surface is its **enforcement
105
+ mechanism**: a command that resolves platform behavior through the surface is
106
+ context-aware by construction, and one that hand-rolls an adapter ladder is the
107
+ exact failure principle 1 warns against. When adding a command or output path,
108
+ satisfy principle 1 by going through the surface.
109
+
110
+ ## Adding a platform behavior — checklist
111
+
112
+ 1. Add the member to `AdapterSurface` in `src/adapters/surface.ts`.
113
+ 2. Implement it in all three `src/adapters/*/surface.ts` (TypeScript will not
114
+ compile until you do).
115
+ 3. Have the command call `getAdapterSurface(adapter).<member>` instead of
116
+ branching.
117
+ 4. Cover the closed blind spot with a contract test (see
118
+ `tests/contract/adapter-surface.test.sh`), and keep the registry-completeness
119
+ assertion green.
@@ -19,6 +19,9 @@ The CLI answers relative to the checkout it is standing in.
19
19
  hardcode pool prefixes (they go stale on rename).
20
20
  - Output is scoped to the platform: an extension-only field is noise on a mobile
21
21
  slot; help, flag lists, and completion candidates shrink to what applies here.
22
+ - Enforcement: platform-specific behavior is resolved through the adapter surface
23
+ (`getAdapterSurface(adapter)`), never a per-command `if (adapter === …)` ladder —
24
+ that ladder is how commands go platform-blind. See docs/ADAPTER-SURFACE.md.
22
25
 
23
26
  ## 2. Never silent, never opaque
24
27
  Long operations show intent immediately and progress continuously.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -26,8 +26,12 @@
26
26
  "ws": "8.21.0"
27
27
  },
28
28
  "devDependencies": {
29
+ "@eslint/js": "^9",
29
30
  "@types/node": "^22.0.0",
30
- "typescript": "^5.6.0"
31
+ "eslint": "^9",
32
+ "globals": "^17.7.0",
33
+ "typescript": "^5.6.0",
34
+ "typescript-eslint": "^8"
31
35
  },
32
36
  "main": "./src/index.ts",
33
37
  "types": "./src/index.ts",
@@ -0,0 +1,56 @@
1
+ // Core surface: headless. No app, no dev server — dependency presence is the
2
+ // only runtime signal, and lifecycle commands teach the reachable headless path.
3
+ import path from 'node:path';
4
+
5
+ import { depsCheck } from '@farmslot/recipe-harness/runtime/deps-readiness';
6
+
7
+ import type {
8
+ AdapterDevServerStop,
9
+ AdapterLogSource,
10
+ AdapterRuntimeStatus,
11
+ AdapterSurface,
12
+ } from '../surface.ts';
13
+
14
+ export const coreSurface: AdapterSurface = {
15
+ adapter: 'core',
16
+ headless: true,
17
+
18
+ resolveSlotPorts(): void {
19
+ // Headless: no ports or device to resolve.
20
+ },
21
+
22
+ async runtimeStatus(target: string): Promise<AdapterRuntimeStatus> {
23
+ const deps = depsCheck(path.resolve(target));
24
+ const ready = deps.status === 'current';
25
+ return {
26
+ decision: ready ? 'ready' : 'install',
27
+ reasonCode: ready ? 'deps-present' : `deps-${deps.status}`,
28
+ reasons: [
29
+ ready
30
+ ? 'Core is headless; dependencies are installed. Run recipes with mm-harness run.'
31
+ : 'Core is headless; dependencies are not fully installed.',
32
+ ],
33
+ deps: deps.status,
34
+ };
35
+ },
36
+
37
+ devServer: {
38
+ describe: () => 'no dev server (headless)',
39
+ stop(): AdapterDevServerStop {
40
+ return {
41
+ kind: 'headless',
42
+ message: 'core is headless — no dev server runs for a core checkout',
43
+ userAction: 'use mm-harness verify (readiness) or mm-harness run (execute a recipe)',
44
+ };
45
+ },
46
+ },
47
+
48
+ logSources(): AdapterLogSource[] {
49
+ return [];
50
+ },
51
+
52
+ hints: {
53
+ launch: 'mm-harness run <recipe> # run recipes against the headless core',
54
+ relaunch: 'mm-harness verify',
55
+ },
56
+ };
@@ -0,0 +1,71 @@
1
+ // Extension surface: delegates to the existing extension readiness/port plumbing
2
+ // and the webpack watcher stop.
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ import { recipeRuntimePath } from '../../paths.ts';
6
+ import { resolveExtensionSlotPorts, stopExtensionWatcher } from '../slot-ports.ts';
7
+ import { decideExtensionReadiness } from './runtime-decision.ts';
8
+ import type {
9
+ AdapterDevServerStop,
10
+ AdapterLogSource,
11
+ AdapterRuntimeStatus,
12
+ AdapterSurface,
13
+ } from '../surface.ts';
14
+
15
+ // Map the webpack watch-log health onto a dev-server up/down/building/errors
16
+ // status doctor renders the same way as mobile's Metro line.
17
+ function watcherStatus(buildLog: string): string {
18
+ if (buildLog === 'ok') return 'up';
19
+ if (buildLog === 'no-watch') return 'down';
20
+ return buildLog; // 'building' | 'errors'
21
+ }
22
+
23
+ export const extensionSurface: AdapterSurface = {
24
+ adapter: 'extension',
25
+ headless: false,
26
+
27
+ resolveSlotPorts(target: string): void {
28
+ resolveExtensionSlotPorts(target);
29
+ },
30
+
31
+ async runtimeStatus(target: string): Promise<AdapterRuntimeStatus> {
32
+ const cdpPort = process.env.CDP_PORT ? parseInt(process.env.CDP_PORT, 10) : undefined;
33
+ const report = await decideExtensionReadiness(target, { cdpPort });
34
+ return {
35
+ decision: report.decision,
36
+ reasonCode: report.reasonCode,
37
+ reasons: report.reasons,
38
+ deps: report.checks.deps.status,
39
+ devServer: { label: 'webpack', status: watcherStatus(report.checks.buildLog.status) },
40
+ };
41
+ },
42
+
43
+ devServer: {
44
+ describe: () => 'webpack watcher',
45
+ stop(target: string): AdapterDevServerStop {
46
+ const signalled = stopExtensionWatcher(target);
47
+ // Best-effort: close the webpack tail window this checkout's watcher owned.
48
+ const port = process.env.WATCHER_PORT ?? 'default';
49
+ spawnSync('tmux', ['kill-window', '-t', `webpack-${port}`], { stdio: 'ignore', timeout: 2000 });
50
+ const summary = signalled > 0
51
+ ? `stopped webpack watcher (${signalled} process${signalled === 1 ? '' : 'es'}) for ${target}`
52
+ : `webpack watcher not running for ${target} — nothing to stop`;
53
+ return { kind: 'stopped', status: 0, summary, signalled };
54
+ },
55
+ },
56
+
57
+ logSources(target: string): AdapterLogSource[] {
58
+ // Most-relevant first: the live webpack log, the harness-owned watcher log,
59
+ // then the quick-relaunch rebuild log. `logs` tails the first that exists.
60
+ return [
61
+ { label: 'webpack', path: recipeRuntimePath(target, 'webpack.log') },
62
+ { label: 'watcher', path: recipeRuntimePath(target, 'recipe-harness-webpack.log') },
63
+ { label: 'rebuild', path: recipeRuntimePath(target, 'rebuild.log') },
64
+ ];
65
+ },
66
+
67
+ hints: {
68
+ launch: 'mm-harness launch',
69
+ relaunch: 'mm-harness launch --build',
70
+ },
71
+ };
@@ -0,0 +1,59 @@
1
+ // Mobile surface: delegates to the existing mobile readiness/port plumbing.
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+
5
+ import { recipeRuntimePath, runnerDir } from '../../paths.ts';
6
+ import { resolveMobileSlotPorts } from '../slot-ports.ts';
7
+ import { mobileRuntimeStatus } from './prepare.ts';
8
+ import type {
9
+ AdapterDevServerStop,
10
+ AdapterLogSource,
11
+ AdapterRuntimeStatus,
12
+ AdapterSurface,
13
+ } from '../surface.ts';
14
+
15
+ export const mobileSurface: AdapterSurface = {
16
+ adapter: 'mobile',
17
+ headless: false,
18
+
19
+ resolveSlotPorts(target: string): void {
20
+ resolveMobileSlotPorts(target);
21
+ },
22
+
23
+ async runtimeStatus(target: string): Promise<AdapterRuntimeStatus> {
24
+ const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : undefined;
25
+ const report = await mobileRuntimeStatus(target, { watcherPort });
26
+ return {
27
+ decision: report.decision,
28
+ reasonCode: report.reasonCode,
29
+ reasons: report.reasons,
30
+ deps: report.checks?.deps?.status,
31
+ devServer: { label: 'metro', status: report.checks?.metro?.status ?? 'unprobed' },
32
+ };
33
+ },
34
+
35
+ devServer: {
36
+ describe: () => 'Metro dev server',
37
+ stop(target: string): AdapterDevServerStop {
38
+ const leaf = path.join(runnerDir, 'adapters', 'mobile', 'stop-metro.sh');
39
+ const args = ['--target', target];
40
+ if (process.env.WATCHER_PORT) args.push('--port', process.env.WATCHER_PORT);
41
+ const result = spawnSync('bash', [leaf, ...args], { encoding: 'utf8' });
42
+ const status = result.status ?? 1;
43
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
44
+ const summary = status === 0
45
+ ? `stopped Metro dev server for ${target}`
46
+ : `failed to stop Metro for ${target}`;
47
+ return { kind: 'stopped', status, summary, output };
48
+ },
49
+ },
50
+
51
+ logSources(target: string): AdapterLogSource[] {
52
+ return [{ label: 'metro', path: recipeRuntimePath(target, 'metro.log') }];
53
+ },
54
+
55
+ hints: {
56
+ launch: 'mm-harness launch ios',
57
+ relaunch: 'mm-harness launch ios',
58
+ },
59
+ };
@@ -0,0 +1,165 @@
1
+ // Slot-port + dev-server process plumbing shared by the per-platform surfaces.
2
+ // Re-homed here (out of commands/launch.ts) so the surface implementations own
3
+ // port/device resolution and the extension watcher-stop without launch.ts and
4
+ // the surface registry forming an import cycle.
5
+
6
+ import { execFileSync, spawnSync } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ import { readRuntimeContextField, resolveRuntimeContextPath } from '../harness.ts';
11
+ import { recipeRuntimeDir, runnerDir } from '../paths.ts';
12
+
13
+ // Apply KEY=VALUE lines from slot resolution to process.env.
14
+ // overwrite=true → pool/context match, always overrides existing env.
15
+ // overwrite=false → formula match, only fills vars that are unset.
16
+ export function applyKVLines(output: string, overwrite: boolean): void {
17
+ for (const line of output.split('\n')) {
18
+ const m = /^([A-Z_]+)=(.+)$/u.exec(line.trim());
19
+ if (!m) continue;
20
+ const [, key, val] = m;
21
+ switch (key) {
22
+ case 'WATCHER_PORT':
23
+ if (overwrite || !process.env['WATCHER_PORT']) {
24
+ process.env['WATCHER_PORT'] = val;
25
+ process.env['METRO_PORT'] = val;
26
+ process.env['RECIPE_WATCHER_PORT'] = val;
27
+ }
28
+ break;
29
+ case 'IOS_SIMULATOR':
30
+ if (overwrite || !process.env['IOS_SIMULATOR']) process.env['IOS_SIMULATOR'] = val;
31
+ break;
32
+ case 'SLOT_ID':
33
+ if (overwrite || !process.env['RECIPE_SLOT_ID']) process.env['RECIPE_SLOT_ID'] = val;
34
+ break;
35
+ case 'CDP_PORT':
36
+ if (overwrite || !process.env['CDP_PORT']) {
37
+ process.env['CDP_PORT'] = val;
38
+ process.env['RECIPE_CDP_PORT'] = val;
39
+ }
40
+ break;
41
+ }
42
+ }
43
+ }
44
+
45
+ function sourceResolve(fn: string, target: string): string {
46
+ const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
47
+ try {
48
+ return execFileSync('bash', ['-c', `source "${resolveScript}" && ${fn} "${target}"`], {
49
+ encoding: 'utf8',
50
+ timeout: 5000,
51
+ stdio: ['ignore', 'pipe', 'ignore'],
52
+ });
53
+ } catch {
54
+ return '';
55
+ }
56
+ }
57
+
58
+ // Resolve mobile slot port/simulator: the slot context the orchestrator wrote
59
+ // into the checkout wins first, then the farmslot pool (both overwrite env),
60
+ // then the slot-suffix formula (only fills unset vars). Called before explicit
61
+ // CLI flag overrides so flags always win at the top.
62
+ export function resolveMobileSlotPorts(target: string): void {
63
+ // The checkout's own runtime context is authoritative — it names the exact
64
+ // simulator/port this slot was prepared with, surviving pool renames.
65
+ const ctxOut = sourceResolve('resolve_mobile_runtime_context', target);
66
+ if (ctxOut.trim()) {
67
+ applyKVLines(ctxOut, true);
68
+ return;
69
+ }
70
+ // Pool match always wins — overwrite whatever is in the environment.
71
+ const poolOut = sourceResolve('resolve_farmslot_ports_by_repo', target);
72
+ if (poolOut.trim()) {
73
+ applyKVLines(poolOut, true);
74
+ return;
75
+ }
76
+ // Formula match only fills unset vars (never overrides explicit env/pool).
77
+ const defOut = sourceResolve('resolve_mobile_slot_defaults', target);
78
+ if (defOut.trim()) applyKVLines(defOut, false);
79
+ }
80
+
81
+ // Resolve extension slot ports the same way as mobile: the checkout's runtime
82
+ // context first (cdpPort/devServerPort written by the orchestrator's prepare),
83
+ // then the farmslot pool, then the directory-suffix formula (fills unset only).
84
+ export function resolveExtensionSlotPorts(target: string): void {
85
+ // The prepared checkout's context OVERWRITES inherited env (same authority as
86
+ // mobile's context/pool resolution): a stale CDP_PORT from the shell must not
87
+ // hijack the slot's browser. Explicit CLI flags are applied after and win.
88
+ const contextPath = resolveRuntimeContextPath(target);
89
+ const cdp = readRuntimeContextField(contextPath, 'cdpPort');
90
+ if (cdp) {
91
+ process.env['CDP_PORT'] = cdp;
92
+ process.env['RECIPE_CDP_PORT'] = cdp;
93
+ }
94
+ const dev = readRuntimeContextField(contextPath, 'devServerPort');
95
+ if (dev) {
96
+ process.env['WATCHER_PORT'] = dev;
97
+ process.env['RECIPE_WATCHER_PORT'] = dev;
98
+ }
99
+ if (process.env['CDP_PORT']) return;
100
+ const poolOut = sourceResolve('resolve_farmslot_ports_by_repo', target);
101
+ if (poolOut.trim()) {
102
+ applyKVLines(poolOut, true);
103
+ return;
104
+ }
105
+ const defOut = sourceResolve('resolve_default_extension_ports', target);
106
+ if (defOut.trim()) applyKVLines(defOut, false);
107
+ }
108
+
109
+ // Kill the harness-owned webpack watcher for this checkout: pid file first,
110
+ // then a ps-scan for orphans (argv or lsof-cwd match), TERM then KILL. Scoped
111
+ // to the target checkout — watchers of other slots are never touched. Returns
112
+ // how many processes were signalled so callers can state the outcome.
113
+ export function stopExtensionWatcher(target: string): number {
114
+ const runtimeAbs = path.join(target, recipeRuntimeDir());
115
+ const webpackPidFile = path.join(runtimeAbs, 'recipe-harness-webpack.pid');
116
+ let signalled = 0;
117
+ try {
118
+ const pid = fs.readFileSync(webpackPidFile, 'utf8').trim();
119
+ if (/^\d+$/u.test(pid)) {
120
+ try { process.kill(Number(pid), 'SIGTERM'); signalled += 1; } catch { /* already dead */ }
121
+ }
122
+ fs.rmSync(webpackPidFile, { force: true });
123
+ } catch { /* no pid file */ }
124
+ // Scan for any remaining orphan webpack/yarn-start processes in this checkout.
125
+ try {
126
+ const psOut = execFileSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' });
127
+ const orphanPids: number[] = [];
128
+ for (const line of psOut.split('\n')) {
129
+ const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
130
+ if (!match) continue;
131
+ const [, pidStr, cmd] = match;
132
+ const isWatcher =
133
+ cmd.includes('yarn start') ||
134
+ cmd.includes('webpack --watch') ||
135
+ cmd.includes('development/webpack/launch.ts --watch');
136
+ if (!isWatcher) continue;
137
+ if (cmd.includes(target)) {
138
+ orphanPids.push(Number(pidStr));
139
+ continue;
140
+ }
141
+ // lsof cwd fallback for processes that don't embed the path in argv.
142
+ try {
143
+ const cwd = execFileSync('lsof', ['-a', `-p${pidStr}`, '-dcwd', '-Fn'], {
144
+ encoding: 'utf8',
145
+ timeout: 2000,
146
+ });
147
+ if (cwd.split('\n').some((l) => l.startsWith('n') && l.slice(1) === target)) {
148
+ orphanPids.push(Number(pidStr));
149
+ }
150
+ } catch { /* lsof unavailable or permission denied */ }
151
+ }
152
+ if (orphanPids.length > 0) {
153
+ for (const pid of orphanPids) {
154
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
155
+ }
156
+ // Brief pause then force-kill survivors.
157
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
158
+ for (const pid of orphanPids) {
159
+ try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
160
+ }
161
+ signalled += orphanPids.length;
162
+ }
163
+ } catch { /* ps not available */ }
164
+ return signalled;
165
+ }
@@ -0,0 +1,82 @@
1
+ // The per-platform surface: one interface every command resolves platform
2
+ // behavior through, so no command hand-rolls `if (adapter === …)` for behavior a
3
+ // platform owns. A command NEVER branches on adapter for behavior this interface
4
+ // owns; a new platform behavior extends the surface (a new member here + its
5
+ // three implementations), not the command. This is the enforcement mechanism for
6
+ // UX-PRINCIPLES.md principle 1 (context-aware by default). See
7
+ // docs/ADAPTER-SURFACE.md.
8
+
9
+ import type { MetaMaskRecipeAdapter } from '../types.ts';
10
+ import { coreSurface } from './core/surface.ts';
11
+ import { extensionSurface } from './extension/surface.ts';
12
+ import { mobileSurface } from './mobile/surface.ts';
13
+
14
+ // Read-only runtime readiness, normalized across platforms so `doctor` renders
15
+ // one line the same way regardless of adapter. Device platforms fill deps +
16
+ // devServer; core (headless) reports deps presence only (no devServer).
17
+ export interface AdapterRuntimeStatus {
18
+ decision: string;
19
+ reasonCode?: string;
20
+ reasons: string[];
21
+ deps?: string;
22
+ // The platform's dev server (Metro for mobile, webpack watcher for extension),
23
+ // labelled so the render names the right thing. Absent for headless core.
24
+ devServer?: { label: string; status: string };
25
+ }
26
+
27
+ // One log file a platform writes, most-relevant first. `logs` tails the first
28
+ // candidate that exists.
29
+ export interface AdapterLogSource {
30
+ label: string;
31
+ path: string;
32
+ }
33
+
34
+ // Result of stopping the platform's dev server. `headless` is the core case —
35
+ // there is nothing to stop, so `stop` teaches instead. `stopped` carries the
36
+ // exit status plus, when the platform counts them, how many processes were
37
+ // signalled and the raw leaf output for the --json envelope.
38
+ export type AdapterDevServerStop =
39
+ | { kind: 'headless'; message: string; userAction: string }
40
+ | { kind: 'stopped'; status: number; summary: string; signalled?: number; output?: string };
41
+
42
+ export interface AdapterDevServer {
43
+ // One noun for the dev server this platform runs ("Metro", "webpack watcher").
44
+ describe(): string;
45
+ // Stop the dev server this checkout owns, port/pid-scoped. Idempotent:
46
+ // nothing-to-stop is success, never an error.
47
+ stop(target: string): AdapterDevServerStop;
48
+ }
49
+
50
+ // Platform-phrased Next: hints so no command prints another platform's vocabulary.
51
+ // `launch` (re)starts the app + dev server; `relaunch` rebuilds first (the
52
+ // fixtures-set retry). core has no app, so its hints teach the headless path.
53
+ export interface AdapterHints {
54
+ launch: string;
55
+ relaunch: string;
56
+ }
57
+
58
+ export interface AdapterSurface {
59
+ readonly adapter: MetaMaskRecipeAdapter;
60
+ // core runs no app/dev server; device adapters (mobile/extension) do. Commands
61
+ // ask this instead of testing `adapter === 'core'`.
62
+ readonly headless: boolean;
63
+ // Resolve slot ports/device into the environment (checkout context > pool >
64
+ // formula). No-op for core.
65
+ resolveSlotPorts(target: string): void;
66
+ // Read-only readiness for `doctor`. Never launches or mutates.
67
+ runtimeStatus(target: string): Promise<AdapterRuntimeStatus>;
68
+ devServer: AdapterDevServer;
69
+ // Ordered candidate log files for `logs`. Empty for core.
70
+ logSources(target: string): AdapterLogSource[];
71
+ hints: AdapterHints;
72
+ }
73
+
74
+ const SURFACES: Record<MetaMaskRecipeAdapter, AdapterSurface> = {
75
+ mobile: mobileSurface,
76
+ extension: extensionSurface,
77
+ core: coreSurface,
78
+ };
79
+
80
+ export function getAdapterSurface(adapter: MetaMaskRecipeAdapter): AdapterSurface {
81
+ return SURFACES[adapter];
82
+ }
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,
@@ -476,26 +478,16 @@ async function handleDoctor({ options }: ParsedArgs): Promise<number> {
476
478
  }
477
479
 
478
480
  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
- }
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 */ }
499
491
  if (json) console.log(JSON.stringify({ ...result, runtime }, null, 2));
500
492
  else {
501
493
  const out = (style: string, text: string) => color(style, text, { stream: process.stdout });
@@ -503,8 +495,11 @@ async function handleDoctor({ options }: ParsedArgs): Promise<number> {
503
495
  console.log(`${out(result.status === 'pass' ? 'ok' : 'err', result.status)} ${out('bold', adapter)} ${result.compatibilityMode} ${out('dim', `manifest=${actionManifestPath}`)}`);
504
496
  if (runtime) {
505
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
+ : '';
506
501
  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')}`,
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}`,
508
503
  );
509
504
  for (const reason of runtime.reasons) console.log(` ${out('dim', reason)}`);
510
505
  }
@@ -1117,23 +1112,21 @@ async function handleStop(argv: string[]): Promise<number> {
1117
1112
  const { options } = parseArgs(argv, 'stop');
1118
1113
  const json = optionFlag(options, 'json');
1119
1114
  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
- );
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);
1127
1129
  }
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
1130
  if (json) {
1138
1131
  console.log(
1139
1132
  JSON.stringify(
@@ -1142,16 +1135,22 @@ async function handleStop(argv: string[]): Promise<number> {
1142
1135
  command: 'stop',
1143
1136
  adapter,
1144
1137
  target,
1145
- status: exitCode === 0 ? 'pass' : 'fail',
1146
- exitCode,
1147
- output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(),
1138
+ status: stop.status === 0 ? 'pass' : 'fail',
1139
+ ...(stop.signalled !== undefined ? { signalled: stop.signalled } : {}),
1140
+ exitCode: stop.status,
1141
+ ...(stop.output ? { output: stop.output } : {}),
1148
1142
  },
1149
1143
  null,
1150
1144
  2,
1151
1145
  ),
1152
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}`);
1153
1152
  }
1154
- return exitCode;
1153
+ return stop.status;
1155
1154
  }
1156
1155
 
1157
1156
  async function handleCall(argv: string[]): Promise<number> {
@@ -1168,9 +1167,23 @@ async function handleCall(argv: string[]): Promise<number> {
1168
1167
  const { options } = parseArgs(rest, 'call');
1169
1168
  const json = optionFlag(options, 'json');
1170
1169
  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);
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}`);
1174
1187
  return EXIT.usage;
1175
1188
  }
1176
1189
 
@@ -1340,6 +1353,16 @@ function resolveActionName(shortName: string, names: string[]): ActionResolution
1340
1353
  return { status: 'unknown', resolved: '', candidates: [] };
1341
1354
  }
1342
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
+
1343
1366
  function synthesizeOneNodeRecipe(action: string, args: Record<string, string>): Record<string, unknown> {
1344
1367
  return {
1345
1368
  schema_version: 1,
@@ -1794,6 +1817,16 @@ function serializeLibrarySources(sources: MetaMaskLibrarySource[]): string {
1794
1817
  .join(':');
1795
1818
  }
1796
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
+
1797
1830
  // The engine's flows subcommands (grounded in registerFlowsCommand): a bare
1798
1831
  // `flows` (or one that leads with a flag) means list.
1799
1832
  const FLOWS_SUBCOMMANDS: readonly string[] = ['list', 'promote'];
@@ -1824,11 +1857,25 @@ async function handleFlows(argv: string[]): Promise<number> {
1824
1857
  const sources = await resolveMetaMaskLibrarySources(undefined);
1825
1858
  if (!sources) {
1826
1859
  throw usageError(
1827
- '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). ' +
1828
1862
  'Next: update the dependency and run yarn install.',
1829
1863
  );
1830
1864
  }
1831
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
+ }
1832
1879
  // The engine requires an explicit subcommand. Inspect only the FIRST token: a
1833
1880
  // real subcommand there is passed through; anything else (empty, or a leading
1834
1881
  // 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,
@@ -9,10 +9,12 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
 
11
11
  import { color } from '../cli-color.ts';
12
- import { handleHarness, readRuntimeContextField, resolveRuntimeContextPath } from '../harness.ts';
12
+ import { handleHarness } from '../harness.ts';
13
13
  import { recipeHarnessPath, recipeRuntimeDir, recipeRuntimePath, runnerDir } from '../paths.ts';
14
14
  import type { MetaMaskRecipeAdapter } from '../types.ts';
15
15
  import { prepareMobile } from '../adapters/mobile/prepare.ts';
16
+ import { getAdapterSurface } from '../adapters/surface.ts';
17
+ import { stopExtensionWatcher } from '../adapters/slot-ports.ts';
16
18
  import {
17
19
  ADAPTER_DETECT_NEXT,
18
20
  EXIT,
@@ -207,111 +209,6 @@ export async function handleLaunch(argv: string[]): Promise<number> {
207
209
  });
208
210
  }
209
211
 
210
- // Apply KEY=VALUE lines from slot resolution to process.env.
211
- // overwrite=true → pool match, always overrides existing env.
212
- // overwrite=false → formula match, only fills vars that are unset.
213
- function applyKVLines(output: string, overwrite: boolean): void {
214
- for (const line of output.split('\n')) {
215
- const m = /^([A-Z_]+)=(.+)$/u.exec(line.trim());
216
- if (!m) continue;
217
- const [, key, val] = m;
218
- switch (key) {
219
- case 'WATCHER_PORT':
220
- if (overwrite || !process.env['WATCHER_PORT']) {
221
- process.env['WATCHER_PORT'] = val;
222
- process.env['METRO_PORT'] = val;
223
- process.env['RECIPE_WATCHER_PORT'] = val;
224
- }
225
- break;
226
- case 'IOS_SIMULATOR':
227
- if (overwrite || !process.env['IOS_SIMULATOR']) process.env['IOS_SIMULATOR'] = val;
228
- break;
229
- case 'SLOT_ID':
230
- if (overwrite || !process.env['RECIPE_SLOT_ID']) process.env['RECIPE_SLOT_ID'] = val;
231
- break;
232
- case 'CDP_PORT':
233
- if (overwrite || !process.env['CDP_PORT']) {
234
- process.env['CDP_PORT'] = val;
235
- process.env['RECIPE_CDP_PORT'] = val;
236
- }
237
- break;
238
- }
239
- }
240
- }
241
-
242
- // Resolve mobile slot port/simulator: the slot context the orchestrator wrote
243
- // into the checkout wins first, then the farmslot pool (both overwrite env),
244
- // then the slot-suffix formula (only fills unset vars). Called before explicit
245
- // CLI flag overrides so flags always win at the top.
246
- export function resolveMobileSlotPorts(target: string): void {
247
- const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
248
- try {
249
- // The checkout's own runtime context is authoritative — it names the exact
250
- // simulator/port this slot was prepared with, surviving pool renames.
251
- const ctxOut = execFileSync('bash', [
252
- '-c', `source "${resolveScript}" && resolve_mobile_runtime_context "${target}"`,
253
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
254
- if (ctxOut.trim()) {
255
- applyKVLines(ctxOut, true);
256
- return;
257
- }
258
- } catch { /* no runtime context — fall through to pool */ }
259
- try {
260
- // Pool match always wins — overwrite whatever is in the environment.
261
- const poolOut = execFileSync('bash', [
262
- '-c', `source "${resolveScript}" && resolve_farmslot_ports_by_repo "${target}"`,
263
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
264
- if (poolOut.trim()) {
265
- applyKVLines(poolOut, true);
266
- return;
267
- }
268
- } catch { /* no pool match — fall through to formula */ }
269
- try {
270
- // Formula match only fills unset vars (never overrides explicit env/pool).
271
- const defOut = execFileSync('bash', [
272
- '-c', `source "${resolveScript}" && resolve_mobile_slot_defaults "${target}"`,
273
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
274
- if (defOut.trim()) applyKVLines(defOut, false);
275
- } catch { /* no slot suffix in dir name — stays at env defaults */ }
276
- }
277
-
278
- // Resolve extension slot ports the same way as mobile: the checkout's runtime
279
- // context first (cdpPort/devServerPort written by the orchestrator's prepare),
280
- // then the farmslot pool, then the directory-suffix formula (fills unset only).
281
- function resolveExtensionSlotPorts(target: string): void {
282
- // The prepared checkout's context OVERWRITES inherited env (same authority as
283
- // mobile's context/pool resolution): a stale CDP_PORT from the shell must not
284
- // hijack the slot's browser. Explicit CLI flags are applied after and win.
285
- const contextPath = resolveRuntimeContextPath(target);
286
- const cdp = readRuntimeContextField(contextPath, 'cdpPort');
287
- if (cdp) {
288
- process.env['CDP_PORT'] = cdp;
289
- process.env['RECIPE_CDP_PORT'] = cdp;
290
- }
291
- const dev = readRuntimeContextField(contextPath, 'devServerPort');
292
- if (dev) {
293
- process.env['WATCHER_PORT'] = dev;
294
- process.env['RECIPE_WATCHER_PORT'] = dev;
295
- }
296
- if (process.env['CDP_PORT']) return;
297
- const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
298
- try {
299
- const poolOut = execFileSync('bash', [
300
- '-c', `source "${resolveScript}" && resolve_farmslot_ports_by_repo "${target}"`,
301
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
302
- if (poolOut.trim()) {
303
- applyKVLines(poolOut, true);
304
- return;
305
- }
306
- } catch { /* no pool match — fall through to formula */ }
307
- try {
308
- const defOut = execFileSync('bash', [
309
- '-c', `source "${resolveScript}" && resolve_default_extension_ports "${target}"`,
310
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
311
- if (defOut.trim()) applyKVLines(defOut, false);
312
- } catch { /* no slot suffix in dir name — stays at env defaults */ }
313
- }
314
-
315
212
  // Map the env-gap flags onto the env vars the leaf scripts read. For mobile, slot
316
213
  // port/simulator defaults are resolved first (pool-wins, then formula-fills-empty)
317
214
  // so that the harness respects slot isolation; explicit CLI flags applied below
@@ -325,9 +222,9 @@ function applyLaunchEnvOverrides(
325
222
  // Slot isolation: resolve ports/device from the checkout's own slot context,
326
223
  // the farmslot pool, or the directory-suffix formula BEFORE applying explicit
327
224
  // flags — extension needs this as much as mobile (CDP_PORT), so neither
328
- // adapter hard-fails on a value its slot already knows.
329
- if (adapter === 'mobile') resolveMobileSlotPorts(target);
330
- if (adapter === 'extension') resolveExtensionSlotPorts(target);
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);
331
228
 
332
229
  const device = str(options, 'device');
333
230
  if (device && adapter === 'mobile') {
@@ -421,56 +318,10 @@ async function executeComposition(
421
318
  async function extensionRebuild(target: string, json: boolean): Promise<ScriptResult> {
422
319
  const runtimeDirRel = recipeRuntimeDir();
423
320
  const runtimeAbs = path.join(target, runtimeDirRel);
424
- const webpackPidFile = path.join(runtimeAbs, 'recipe-harness-webpack.pid');
425
321
  const rebuildLog = path.join(runtimeAbs, 'rebuild.log');
426
322
 
427
323
  // E1a: Kill harness-owned watcher via pid file, then ps-scan for orphans.
428
- try {
429
- const pid = fs.readFileSync(webpackPidFile, 'utf8').trim();
430
- if (/^\d+$/u.test(pid)) {
431
- try { process.kill(Number(pid), 'SIGTERM'); } catch { /* already dead */ }
432
- }
433
- fs.rmSync(webpackPidFile, { force: true });
434
- } catch { /* no pid file */ }
435
- // Scan for any remaining orphan webpack/yarn-start processes in this checkout.
436
- try {
437
- const psOut = execFileSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' });
438
- const orphanPids: number[] = [];
439
- for (const line of psOut.split('\n')) {
440
- const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
441
- if (!match) continue;
442
- const [, pidStr, cmd] = match;
443
- const isWatcher =
444
- cmd.includes('yarn start') ||
445
- cmd.includes('webpack --watch') ||
446
- cmd.includes('development/webpack/launch.ts --watch');
447
- if (!isWatcher) continue;
448
- if (cmd.includes(target)) {
449
- orphanPids.push(Number(pidStr));
450
- continue;
451
- }
452
- // lsof cwd fallback for processes that don't embed the path in argv.
453
- try {
454
- const cwd = execFileSync('lsof', ['-a', `-p${pidStr}`, '-dcwd', '-Fn'], {
455
- encoding: 'utf8',
456
- timeout: 2000,
457
- });
458
- if (cwd.split('\n').some((l) => l.startsWith('n') && l.slice(1) === target)) {
459
- orphanPids.push(Number(pidStr));
460
- }
461
- } catch { /* lsof unavailable or permission denied */ }
462
- }
463
- if (orphanPids.length > 0) {
464
- for (const pid of orphanPids) {
465
- try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
466
- }
467
- // Brief pause then force-kill survivors.
468
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
469
- for (const pid of orphanPids) {
470
- try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
471
- }
472
- }
473
- } catch { /* ps not available */ }
324
+ stopExtensionWatcher(target);
474
325
 
475
326
  // E1c: Clear the rebuild log (directory must exist for tee).
476
327
  fs.mkdirSync(path.dirname(rebuildLog), { recursive: true });
@@ -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
 
@@ -61,16 +61,18 @@ Example:
61
61
  },
62
62
  {
63
63
  name: 'stop',
64
- summary: 'Stop the dev server this checkout owns (mobile: port-scoped Metro) and close its log window.',
64
+ summary: 'Stop the dev server this checkout owns (mobile Metro / extension webpack watcher) and close its log window.',
65
65
  example: 'mm-harness stop',
66
66
  helpText: `mm-harness stop [flags]
67
67
 
68
- Stop the Metro dev server this checkout owns and close its tmux log-tail window.
69
- Port-scoped: only the listener on the checkout's resolved port is signalled, so
70
- concurrent slots are untouched. Nothing running = success (idempotent).
71
- Extension/core: teaching error (their dev servers are slot-managed).
68
+ Stop the dev server this checkout owns and close its tmux log-tail window,
69
+ scoped to this checkout so concurrent slots are untouched. Idempotent
70
+ nothing running is success, not an error. Behavior is per platform:
71
+ mobile stop the port-scoped Metro dev server
72
+ extension stop the checkout's webpack watcher (pid file + orphan scan)
73
+ core headless — no dev server to stop (teaching error)
72
74
 
73
- --port <port> Metro port (default: the checkout's slot context)
75
+ --port <port> Dev-server port (default: the checkout's slot context)
74
76
  --target <path> Checkout path (default: cwd)
75
77
  --json Machine-readable output
76
78
 
@@ -81,11 +83,13 @@ Example:
81
83
  {
82
84
  name: 'call',
83
85
  summary: 'Run one action in isolation as a one-node recipe through the real engine path (fuzzy short names; --arg k=v; same trace/evidence as run).',
84
- example: 'mm-harness call unlock',
86
+ example: 'mm-harness call ensure_unlocked',
85
87
  helpText: `mm-harness call <action> [--arg k=v ...] [flags]
86
88
 
87
89
  Run one action in isolation as a one-node recipe through the real engine path.
88
- Fuzzy short-name: 'unlock' resolves to 'metamask.wallet.unlock' if unique; ambiguous = exit 2.
90
+ Fuzzy short-name: 'ensure_unlocked' resolves to 'metamask.wallet.ensure_unlocked'
91
+ if unique; ambiguous = exit 2. Actions differ per adapter — list this checkout's
92
+ with: mm-harness actions.
89
93
 
90
94
  --arg k=v Action field value (repeatable)
91
95
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
@@ -95,9 +99,9 @@ Example:
95
99
  --heal <off|infra-only|auto> Healing policy (default: infra-only); auto-ensures the overlay
96
100
  --json Machine-readable output
97
101
 
98
- Example:
99
- mm-harness call unlock --adapter extension
100
- mm-harness call command --arg cmd="echo hi" --adapter core --json`,
102
+ Example (real actions; run mm-harness actions for this checkout's full set):
103
+ mm-harness call ensure_unlocked --adapter extension # a wallet action (extension/mobile)
104
+ mm-harness call command --arg cmd="echo hi" --adapter core # the universal action (all adapters)`,
101
105
  },
102
106
  {
103
107
  name: 'flows',
@@ -241,8 +245,10 @@ Example:
241
245
 
242
246
  --full Raw log tail (default = compact) (env: RECIPE_LOG_UI)
243
247
  --events <n> Compact event count (default 10) (env: RECIPE_LOG_EVENTS)
244
- --source <metro|app> Log source (default metro)
245
- --adapter <mobile|extension> Target adapter (auto-detected inside a checkout)
248
+ --source <label> Log source per adapter — mobile: metro|app (default metro);
249
+ extension: webpack|watcher|rebuild|app (default webpack).
250
+ Core is headless (teaching error).
251
+ --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
246
252
  --target <path> Checkout path (default: cwd)
247
253
  --json Machine-readable output
248
254