@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 +26 -1
- package/docs/ADAPTER-SURFACE.md +119 -0
- package/docs/UX-PRINCIPLES.md +3 -0
- package/package.json +6 -2
- package/src/adapters/core/surface.ts +56 -0
- package/src/adapters/extension/surface.ts +71 -0
- package/src/adapters/mobile/surface.ts +59 -0
- package/src/adapters/slot-ports.ts +165 -0
- package/src/adapters/surface.ts +82 -0
- package/src/cli.ts +92 -45
- package/src/commands/debug.ts +3 -1
- package/src/commands/fixtures.ts +13 -8
- package/src/commands/launch.ts +7 -156
- package/src/commands/logs.ts +29 -13
- package/src/mm-harness-cli.ts +19 -13
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
|
-
-
|
|
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.
|
package/docs/UX-PRINCIPLES.md
CHANGED
|
@@ -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
|
+
"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
|
-
"
|
|
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
|
|
480
|
-
// the
|
|
481
|
-
//
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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')}
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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:
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
|
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
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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.
|
package/src/commands/debug.ts
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/src/commands/fixtures.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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,
|
package/src/commands/launch.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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 });
|
package/src/commands/logs.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
|
-
import {
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
43
|
-
// signal that
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 ${
|
|
51
|
-
|
|
66
|
+
`nothing running for this checkout (no ${names}).`,
|
|
67
|
+
surface.hints.launch,
|
|
52
68
|
);
|
|
53
69
|
}
|
|
54
70
|
|
package/src/mm-harness-cli.ts
CHANGED
|
@@ -61,16 +61,18 @@ Example:
|
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
name: 'stop',
|
|
64
|
-
summary: 'Stop the dev server this checkout owns (mobile
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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>
|
|
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
|
|
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: '
|
|
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
|
|
100
|
-
mm-harness call command --arg cmd="echo hi" --adapter core
|
|
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 <
|
|
245
|
-
|
|
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
|
|