@deeeed/metamask-harness 0.5.0 → 0.6.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 +51 -20
- package/adapters/core/inject.sh +1 -0
- package/adapters/extension/cleanup.mjs +1 -1
- package/adapters/extension/inject.mjs +4 -1
- package/adapters/extension/launch-browser.cjs +18 -4
- package/adapters/extension/live.sh +14 -2
- package/adapters/extension/refresh-build.sh +2 -2
- package/adapters/extension/sidepanel-toggle.sh +33 -12
- package/adapters/extension/start-watch.sh +2 -2
- package/adapters/extension/wallet-fixture-state.cjs +38 -24
- package/adapters/manifest.json +32 -3
- package/adapters/mobile/inject.sh +1 -0
- package/adapters/mobile/start-metro.sh +1 -1
- package/adapters/mobile/stop-metro.sh +20 -0
- package/adapters/mobile/verify.sh +1 -1
- package/adapters/shared/activate-repo-node.sh +1 -1
- package/adapters/shared/cli-ux.sh +44 -25
- package/adapters/shared/ensure-runner-deps.sh +30 -0
- package/adapters/shared/log-tui.mjs +4 -4
- package/adapters/shared/open-debug.mjs +1 -1
- package/adapters/shared/reap-checkout-metros.sh +53 -0
- package/adapters/shared/recipe-harness-root.mjs +23 -0
- package/adapters/shared/resolve-farmslot-ports-core.mjs +205 -0
- package/adapters/shared/resolve-farmslot-ports.mjs +20 -0
- package/adapters/shared/resolve-farmslot-ports.sh +19 -126
- package/bin/mm-harness +45 -4
- package/dist/adapters/extension/runtime.js +3 -1
- package/dist/adapters/mobile/provision.js +34 -1
- package/dist/adapters/mobile/runtime-decision.js +3 -1
- package/dist/adapters/resolve-farmslot-ports.js +22 -0
- package/dist/adapters/slot-ports.js +18 -29
- package/dist/cli.js +32 -1463
- package/dist/commands/call.js +183 -0
- package/dist/commands/completion-candidates.js +58 -0
- package/dist/commands/doctor.js +101 -0
- package/dist/commands/ensure-ready.js +24 -0
- package/dist/commands/flows.js +62 -0
- package/dist/commands/launch/extension.js +40 -0
- package/dist/commands/{launch.js → launch/index.js} +15 -47
- package/dist/commands/launch/mobile.js +10 -0
- package/dist/commands/manifest.js +72 -0
- package/dist/commands/parse-args.js +189 -0
- package/dist/commands/provision.js +136 -0
- package/dist/commands/resolve-extension.js +23 -0
- package/dist/commands/run-engine.js +341 -0
- package/dist/commands/run.js +217 -0
- package/dist/commands/runtime-decision.js +58 -0
- package/dist/commands/runtime-health.js +25 -0
- package/dist/commands/runtime-launch.js +139 -0
- package/dist/commands/self-test.js +52 -0
- package/dist/commands/stop.js +52 -0
- package/dist/harness.js +8 -48
- package/dist/mm-harness-cli.js +13 -8
- package/docs/CLI-SPEC.md +1 -1
- package/docs/CODE-MAP.md +62 -0
- package/library/README.md +14 -0
- package/library/actions/core/perps/_controller.mjs +1 -1
- package/library/actions/extension/platform/cdp.mjs +2 -2
- package/library/actions/extension/wallet/ensure_unlocked.mjs +7 -1
- package/library/actions/harness-exports.mjs +27 -0
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +14 -2
- package/library/actions/mobile/wallet/setup.mjs +1 -1
- package/package.json +6 -7
- package/src/adapters/core/surface.ts +0 -71
- package/src/adapters/extension/ensure-ready.ts +0 -185
- package/src/adapters/extension/extension-id.ts +0 -107
- package/src/adapters/extension/runtime-decision.ts +0 -445
- package/src/adapters/extension/runtime.ts +0 -407
- package/src/adapters/extension/surface.ts +0 -88
- package/src/adapters/mobile/deps-markers.ts +0 -21
- package/src/adapters/mobile/prepare.ts +0 -246
- package/src/adapters/mobile/provision.ts +0 -594
- package/src/adapters/mobile/runtime-decision.ts +0 -459
- package/src/adapters/mobile/surface.ts +0 -71
- package/src/adapters/slot-ports.ts +0 -165
- package/src/adapters/surface.ts +0 -117
- package/src/adapters.ts +0 -601
- package/src/cli-color.ts +0 -92
- package/src/cli-commands.ts +0 -250
- package/src/cli-version.ts +0 -141
- package/src/cli.ts +0 -2091
- package/src/commands/debug.ts +0 -65
- package/src/commands/fixtures.ts +0 -198
- package/src/commands/launch.ts +0 -470
- package/src/commands/logs.ts +0 -99
- package/src/commands/shared.ts +0 -235
- package/src/commands/update.ts +0 -316
- package/src/completions-cache.ts +0 -86
- package/src/doctor.ts +0 -215
- package/src/harness.ts +0 -797
- package/src/heal-bounds.ts +0 -198
- package/src/index.ts +0 -15
- package/src/leaf-invoke.ts +0 -28
- package/src/live-adapter-contract.ts +0 -274
- package/src/manifest.ts +0 -47
- package/src/mm-harness-cli.ts +0 -655
- package/src/paths.ts +0 -198
- package/src/progress.ts +0 -117
- package/src/recording-target.ts +0 -147
- package/src/run-recording.ts +0 -329
- package/src/runner.ts +0 -108
- package/src/types.ts +0 -57
package/dist/mm-harness-cli.js
CHANGED
|
@@ -423,6 +423,9 @@ function groupedHelp() {
|
|
|
423
423
|
lines.push("");
|
|
424
424
|
lines.push(`${out("warn", "DEV OVERRIDE ACTIVE")} \u2014 this run is served by MM_HARNESS_BIN=${out("path", process.env.MM_HARNESS_BIN)} (unset it to return to the installed/global bin).`);
|
|
425
425
|
}
|
|
426
|
+
if (process.env.MM_HARNESS_BIN && process.env.MM_HARNESS_RUN_MODE) {
|
|
427
|
+
lines.push(`${out("label", "running from:")} ${out("path", process.env.MM_HARNESS_RUN_MODE)} (source checkout; dist shadows src when both exist)`);
|
|
428
|
+
}
|
|
426
429
|
const slotLine = detectedSlotLine(out);
|
|
427
430
|
if (slotLine) {
|
|
428
431
|
lines.push("");
|
|
@@ -469,14 +472,8 @@ function translateActionsRaw(argv) {
|
|
|
469
472
|
const program = new Command();
|
|
470
473
|
program.name("mm-harness").description("the MetaMask recipe harness: launch the app, prove behavior, manage the runtime overlay").helpOption("-h, --help", "Show grouped help").showHelpAfterError("(run `mm-harness --help` for the full surface)").configureHelp({ formatHelp: () => groupedHelp() });
|
|
471
474
|
for (const command of REAL) {
|
|
472
|
-
program.command(command.name).description(command.summary).allowUnknownOption().helpOption(
|
|
473
|
-
|
|
474
|
-
const flagsBeforeSep = ddIdx === -1 ? rawArgv : rawArgv.slice(0, ddIdx);
|
|
475
|
-
if (flagsBeforeSep.includes("--help") || flagsBeforeSep.includes("-h")) {
|
|
476
|
-
process.stdout.write(`${command.helpText}
|
|
477
|
-
`);
|
|
478
|
-
process.exit(0);
|
|
479
|
-
}
|
|
475
|
+
program.command(command.name).description(command.summary).allowUnknownOption().helpOption("-h, --help", "Show command help").configureHelp({ formatHelp: () => `${command.helpText}
|
|
476
|
+
` }).argument("[args...]").action(async () => {
|
|
480
477
|
if (command.name === "update") {
|
|
481
478
|
process.exit(await handleUpdate(rawArgv.slice(1)));
|
|
482
479
|
}
|
|
@@ -514,8 +511,16 @@ const NUDGE_SKIP = ["update", "completions", "completion-candidates"];
|
|
|
514
511
|
if (rawArgv.length > 0 && !NUDGE_SKIP.includes(rawArgv[0])) {
|
|
515
512
|
setImmediate(() => void maybeNudge());
|
|
516
513
|
}
|
|
514
|
+
function hasPassthroughHelp(argv) {
|
|
515
|
+
const divider = argv.indexOf("--");
|
|
516
|
+
if (divider === -1) return false;
|
|
517
|
+
return argv.slice(divider + 1).some((arg) => arg === "-h" || arg === "--help");
|
|
518
|
+
}
|
|
517
519
|
if (rawArgv.length === 0) {
|
|
518
520
|
process.stdout.write(groupedHelp());
|
|
519
521
|
process.exit(0);
|
|
520
522
|
}
|
|
523
|
+
if (hasPassthroughHelp(rawArgv)) {
|
|
524
|
+
process.exit(await delegate(rawArgv));
|
|
525
|
+
}
|
|
521
526
|
await program.parseAsync(process.argv);
|
package/docs/CLI-SPEC.md
CHANGED
|
@@ -180,7 +180,7 @@ Audience: **human** (daily, ≤1 flag), **agent** (depth), **both**.
|
|
|
180
180
|
|
|
181
181
|
## `launch` (REAL as of wave 3 → main DAILY LOOP verb)
|
|
182
182
|
|
|
183
|
-
> **Implemented (wave 3):** `handleLaunch` in `
|
|
183
|
+
> **Implemented (wave 3):** `handleLaunch` in `src/commands/launch/`. The typed
|
|
184
184
|
> layer owns grammar (mobile-target mandatory, core/extension refusals), tier
|
|
185
185
|
> selection, quick-launch change detection, overlay auto-ensure, healing policy +
|
|
186
186
|
> bounds, teaching errors, and the `--json` contract. It does NOT rewrite
|
package/docs/CODE-MAP.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Code map — where things live and how a command flows
|
|
2
|
+
|
|
3
|
+
One page for humans. The contracts live in docs/UX-PRINCIPLES.md and
|
|
4
|
+
docs/ADAPTER-SURFACE.md; this is the geography.
|
|
5
|
+
|
|
6
|
+
## How a command flows
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
bin/mm-harness bash front door: MM_HARNESS_BIN dev/prod switch,
|
|
10
|
+
│ dist-vs-src selection (dist wins when present;
|
|
11
|
+
│ stale-dist warning), deps ensure
|
|
12
|
+
▼
|
|
13
|
+
src/mm-harness-cli.ts commander surface: grouped colored help, per-command
|
|
14
|
+
│ --help, retired-name teaching; delegates in-process
|
|
15
|
+
▼
|
|
16
|
+
src/cli.ts parse + dispatch ONLY — no verb logic lives here
|
|
17
|
+
│
|
|
18
|
+
├── src/commands/<verb>.ts one module per verb (doctor, stop, call, run,
|
|
19
|
+
│ │ provision, fixtures, flows, logs, debug, …)
|
|
20
|
+
│ ▼
|
|
21
|
+
│ src/adapters/surface.ts AdapterSurface registry — per-platform behavior
|
|
22
|
+
│ │ (ports, runtime status, dev server, log sources)
|
|
23
|
+
│ ▼
|
|
24
|
+
│ src/adapters/{mobile,extension,core}/ TS logic per platform
|
|
25
|
+
│
|
|
26
|
+
└── src/harness.ts overlay lifecycle (install/verify/cleanup) — writes
|
|
27
|
+
│ the per-checkout overlay + runner delegate
|
|
28
|
+
▼
|
|
29
|
+
adapters/ SHELL LEAVES (repo root — not src/adapters!):
|
|
30
|
+
mobile/ extension/ single-OS-op scripts the TS layer spawns
|
|
31
|
+
core/ shared/ (simctl/adb/tmux/metro/webpack lifecycle)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## The two `adapters/` directories
|
|
35
|
+
|
|
36
|
+
- `adapters/` (repo root) — executable shell leaves. Thin, OS-facing, spawned by TS.
|
|
37
|
+
- `src/adapters/` — TypeScript platform logic behind the AdapterSurface.
|
|
38
|
+
Rule: logic/decisions in TS; a leaf only executes. A leaf converts to TS only when
|
|
39
|
+
it starts making decisions (see backlog ruling), never as a campaign.
|
|
40
|
+
|
|
41
|
+
## Other roots
|
|
42
|
+
|
|
43
|
+
- `library/` — recipe library: actions (`library/actions/<adapter>/…`, executable
|
|
44
|
+
action modules — importing one RUNS it, see library/README.md), flows, recipes.
|
|
45
|
+
- `tests/contract/` — hermetic bash contract tests (PATH-stubbed OS tools). The
|
|
46
|
+
launch-leaf-flags test enforces composer↔leaf flag agreement.
|
|
47
|
+
- `dist/` — esbuild output; the ONLY thing published runs. gitignored; in a source
|
|
48
|
+
checkout it SHADOWS src when present (bin warns when it goes stale).
|
|
49
|
+
- Per-checkout runtime state lives in the TARGET repo: `temp/recipe/runtime/`
|
|
50
|
+
(context, logs, wallet fixture) and `temp/recipe/harness/<adapter>/` (overlay +
|
|
51
|
+
runner delegate pinned to the installing bin via `.runner-source`).
|
|
52
|
+
|
|
53
|
+
## Adding things
|
|
54
|
+
|
|
55
|
+
- **A verb**: module in `src/commands/`, register in `src/cli.ts` dispatch + the
|
|
56
|
+
commander metadata in `src/mm-harness-cli.ts` (help group), contract test.
|
|
57
|
+
- **A flag**: parse in the verb module; if it reaches a leaf, the leaf's parser
|
|
58
|
+
must accept it (launch-leaf-flags test will fail otherwise) — or pass via env.
|
|
59
|
+
- **A leaf**: shell under `adapters/<platform>/`, spawned via the shared spawn
|
|
60
|
+
helpers (never raw exec), contract test with PATH stubs, errors teach escape.
|
|
61
|
+
- **A platform behavior**: extend the AdapterSurface, implement per platform —
|
|
62
|
+
verbs must not branch on adapter for surface-owned behavior.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Recipe library
|
|
2
|
+
|
|
3
|
+
- `actions/<adapter>/<group>/<name>.mjs` — executable action modules.
|
|
4
|
+
**Importing an action module RUNS it**: each ends with `runAdapter(main)` at top
|
|
5
|
+
level, which reads the live-adapter input path from the environment and executes.
|
|
6
|
+
That is the engine contract (the recipe engine spawns/imports them with the
|
|
7
|
+
input env set) — a bare `import` outside the engine throws
|
|
8
|
+
"Missing live adapter input path", which is expected, not a bug.
|
|
9
|
+
- Actions load harness helpers ONLY through `actions/harness-exports.mjs` (the
|
|
10
|
+
dist-preferring bridge) — never deep-import `src/` (published installs have no
|
|
11
|
+
src/, and the entrypoint would close an import cycle through adapters.ts).
|
|
12
|
+
- `recipes/` — runnable smoke/validation recipes (`mm-harness run <file>`).
|
|
13
|
+
- Flow resolution order is personal > team > canonical; shadowing is recorded in
|
|
14
|
+
summary.json (never silent).
|
|
@@ -8,7 +8,7 @@ import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts';
|
|
|
8
8
|
// this adapter under the bundled tsx (see commandFor/importsSourceTypescript in
|
|
9
9
|
// src/live-adapter-contract.ts). The core adapter dynamic-imports the perps
|
|
10
10
|
// controller TypeScript at runtime, so it MUST run under tsx, not plain node.
|
|
11
|
-
import { walletFixturePath } from '
|
|
11
|
+
import { walletFixturePath } from '../../harness-exports.mjs';
|
|
12
12
|
|
|
13
13
|
// Shared headless instantiation for the MetaMask `core` adapter.
|
|
14
14
|
//
|
|
@@ -11,10 +11,10 @@ import {
|
|
|
11
11
|
recipeHarnessPath,
|
|
12
12
|
walletFixturePath,
|
|
13
13
|
captureActiveRecipeRecordingSnapshot,
|
|
14
|
-
} from '
|
|
14
|
+
} from '../../harness-exports.mjs';
|
|
15
15
|
|
|
16
16
|
// Resolve the Farmslot harness through normal package dependencies by default.
|
|
17
|
-
// Local Farmslot source is only a dev override handled by
|
|
17
|
+
// Local Farmslot source is only a dev override handled by the harness paths module.
|
|
18
18
|
const {
|
|
19
19
|
CdpSession,
|
|
20
20
|
CdpWebPage,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
3
|
-
import { walletFixturePath } from '
|
|
3
|
+
import { walletFixturePath } from '../../harness-exports.mjs';
|
|
4
4
|
|
|
5
5
|
async function fixturePassword(projectRoot) {
|
|
6
6
|
const candidates = [
|
|
@@ -19,6 +19,12 @@ async function fixturePassword(projectRoot) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
runAdapter((input) => withExtensionPage(input, async (page) => {
|
|
22
|
+
// A fresh profile shows onboarding, which has no password input — that is not
|
|
23
|
+
// an unlocked wallet, and passing here would be vacuous.
|
|
24
|
+
const onboarding = await page.evaluate(`(() => (window.location.hash || '').includes('onboarding'))()`);
|
|
25
|
+
if (onboarding) {
|
|
26
|
+
throw new Error('No wallet onboarded in this extension profile — unlock has nothing to unlock.\n Next: mm-harness fixtures set # applies the fixture wallet, then re-run: mm-harness call ensure_unlocked');
|
|
27
|
+
}
|
|
22
28
|
const locked = await page.evaluate(`(() => Boolean(document.querySelector('input[type="password"]')))()`);
|
|
23
29
|
if (!locked) {
|
|
24
30
|
return { action: input.action, unlocked: true, alreadyUnlocked: true, proofPath: 'extension-unlocked-state' };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Bridge from library actions to the harness path/recording helpers. Published
|
|
2
|
+
// installs load the compiled dist (node cannot type-strip .ts under
|
|
3
|
+
// node_modules); source checkouts without dist fall back to src. Deliberately
|
|
4
|
+
// narrow — importing the package entry here would close an import cycle
|
|
5
|
+
// through src/adapters.ts (which imports these actions) and deadlock.
|
|
6
|
+
const packageRoot = new URL('../../', import.meta.url);
|
|
7
|
+
|
|
8
|
+
async function load(distRelative, srcRelative) {
|
|
9
|
+
try {
|
|
10
|
+
return await import(new URL(distRelative, packageRoot).href);
|
|
11
|
+
} catch {
|
|
12
|
+
return await import(new URL(srcRelative, packageRoot).href);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const paths = await load('dist/paths.js', 'src/paths.ts');
|
|
17
|
+
const recording = await load('dist/run-recording.js', 'src/run-recording.ts');
|
|
18
|
+
|
|
19
|
+
export const {
|
|
20
|
+
importRecipeHarnessRuntimeBrowserExtension,
|
|
21
|
+
importRecipeHarnessRuntimeCdp,
|
|
22
|
+
extensionIdPath,
|
|
23
|
+
recipeHarnessPath,
|
|
24
|
+
walletFixturePath,
|
|
25
|
+
} = paths;
|
|
26
|
+
|
|
27
|
+
export const { captureActiveRecipeRecordingSnapshot } = recording;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { bridgeCommand, runAdapter } from '../platform/bridge.mjs';
|
|
3
|
-
import { walletFixturePath } from '
|
|
3
|
+
import { walletFixturePath } from '../../harness-exports.mjs';
|
|
4
4
|
|
|
5
5
|
async function fixturePassword(projectRoot) {
|
|
6
6
|
const candidates = [
|
|
@@ -19,7 +19,14 @@ async function fixturePassword(projectRoot) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function selectedAccount(status, input) {
|
|
22
|
-
|
|
22
|
+
// The bridge reports an account OBJECT even pre-onboarding, with every field
|
|
23
|
+
// empty — an empty shell is not a wallet. Only a populated address/id counts,
|
|
24
|
+
// otherwise a fresh app "passes" unlock while sitting on the onboarding screen.
|
|
25
|
+
const account = selectedStatus(status, input)?.account ?? null;
|
|
26
|
+
if (!account || typeof account !== 'object') return null;
|
|
27
|
+
const address = typeof account.address === 'string' ? account.address : '';
|
|
28
|
+
const id = typeof account.id === 'string' ? account.id : '';
|
|
29
|
+
return address.length > 0 || id.length > 0 ? account : null;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
function routeName(status, input) {
|
|
@@ -68,6 +75,11 @@ async function waitForUnlocked(input, timeoutMs = 15000) {
|
|
|
68
75
|
|
|
69
76
|
runAdapter(async (input) => {
|
|
70
77
|
const before = await status(input);
|
|
78
|
+
if (!selectedAccount(before, input) && routeName(before, input) !== 'Login') {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`No wallet onboarded on this device (route ${routeName(before, input) || 'unknown'}, empty wallet status) — unlock has nothing to unlock.\n Next: mm-harness fixtures set # applies the fixture wallet, then re-run: mm-harness call ensure_unlocked`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
71
83
|
if (selectedAccount(before, input) && routeName(before, input) !== 'Login') {
|
|
72
84
|
return {
|
|
73
85
|
action: input.action,
|
|
@@ -3,7 +3,7 @@ import { spawn } from 'node:child_process';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { bridgeCommand, bridgeEnv, runAdapter } from '../platform/bridge.mjs';
|
|
6
|
-
import { walletFixturePath } from '
|
|
6
|
+
import { walletFixturePath } from '../../harness-exports.mjs';
|
|
7
7
|
|
|
8
8
|
async function fixtureProfile(projectRoot) {
|
|
9
9
|
const candidates = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deeeed/metamask-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mm-harness": "bin/mm-harness"
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
"@farmslot/protocol": "^0.7.2",
|
|
20
20
|
"@farmslot/recipe-harness": "^0.3.3",
|
|
21
21
|
"commander": "^12.0.0",
|
|
22
|
-
"tsx": "^4.23.0",
|
|
23
22
|
"viem": "^2.54.3"
|
|
24
23
|
},
|
|
25
24
|
"resolutions": {
|
|
@@ -33,15 +32,16 @@
|
|
|
33
32
|
"esbuild": "0.28.1",
|
|
34
33
|
"eslint": "^9",
|
|
35
34
|
"globals": "^17.7.0",
|
|
35
|
+
"tsx": "^4.23.0",
|
|
36
36
|
"typescript": "^5.6.0",
|
|
37
37
|
"typescript-eslint": "^8"
|
|
38
38
|
},
|
|
39
|
-
"main": "./
|
|
40
|
-
"types": "./src/index.ts",
|
|
39
|
+
"main": "./dist/index.js",
|
|
41
40
|
"exports": {
|
|
42
41
|
".": {
|
|
43
|
-
"
|
|
44
|
-
"import": "./
|
|
42
|
+
"development": "./src/index.ts",
|
|
43
|
+
"import": "./dist/index.js",
|
|
44
|
+
"default": "./dist/index.js"
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
@@ -50,7 +50,6 @@
|
|
|
50
50
|
"files": [
|
|
51
51
|
"bin",
|
|
52
52
|
"dist",
|
|
53
|
-
"src",
|
|
54
53
|
"adapters",
|
|
55
54
|
"library",
|
|
56
55
|
"scripts/completions.sh",
|
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
AdapterRunwayProvisionResult,
|
|
12
|
-
AdapterSurface,
|
|
13
|
-
} from '../surface.ts';
|
|
14
|
-
|
|
15
|
-
export const coreSurface: AdapterSurface = {
|
|
16
|
-
adapter: 'core',
|
|
17
|
-
headless: true,
|
|
18
|
-
|
|
19
|
-
resolveSlotPorts(): void {
|
|
20
|
-
// Headless: no ports or device to resolve.
|
|
21
|
-
},
|
|
22
|
-
|
|
23
|
-
async runtimeStatus(target: string): Promise<AdapterRuntimeStatus> {
|
|
24
|
-
const deps = depsCheck(path.resolve(target));
|
|
25
|
-
const ready = deps.status === 'current';
|
|
26
|
-
return {
|
|
27
|
-
decision: ready ? 'ready' : 'install',
|
|
28
|
-
reasonCode: ready ? 'deps-present' : `deps-${deps.status}`,
|
|
29
|
-
reasons: [
|
|
30
|
-
ready
|
|
31
|
-
? 'Core is headless; dependencies are installed. Run recipes with mm-harness run.'
|
|
32
|
-
: 'Core is headless; dependencies are not fully installed.',
|
|
33
|
-
],
|
|
34
|
-
deps: deps.status,
|
|
35
|
-
};
|
|
36
|
-
},
|
|
37
|
-
|
|
38
|
-
runwayProvision: {
|
|
39
|
-
async run(target, options): Promise<AdapterRunwayProvisionResult> {
|
|
40
|
-
return {
|
|
41
|
-
schemaVersion: 1,
|
|
42
|
-
command: 'provision',
|
|
43
|
-
adapter: 'core',
|
|
44
|
-
target: path.resolve(target),
|
|
45
|
-
status: 'fail',
|
|
46
|
-
exitCode: 2,
|
|
47
|
-
error: { code: 'UNSUPPORTED_ADAPTER', message: 'core is headless and has no mobile simulator to provision.', userAction: options.rerunCommand || 'mm-harness run <recipe> --adapter core' },
|
|
48
|
-
};
|
|
49
|
-
},
|
|
50
|
-
},
|
|
51
|
-
|
|
52
|
-
devServer: {
|
|
53
|
-
describe: () => 'no dev server (headless)',
|
|
54
|
-
stop(): AdapterDevServerStop {
|
|
55
|
-
return {
|
|
56
|
-
kind: 'headless',
|
|
57
|
-
message: 'core is headless — no dev server runs for a core checkout',
|
|
58
|
-
userAction: 'use mm-harness verify (readiness) or mm-harness run (execute a recipe)',
|
|
59
|
-
};
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
|
|
63
|
-
logSources(): AdapterLogSource[] {
|
|
64
|
-
return [];
|
|
65
|
-
},
|
|
66
|
-
|
|
67
|
-
hints: {
|
|
68
|
-
launch: 'mm-harness run <recipe> # run recipes against the headless core',
|
|
69
|
-
relaunch: 'mm-harness verify',
|
|
70
|
-
},
|
|
71
|
-
};
|
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
// ensure-ready.ts — ensure the extension runtime is attachable.
|
|
2
|
-
// runner/extension/extension-ensure-ready.ts) App/instance control.
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
import { resolveExtensionId } from './extension-id.ts';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* "Drive the extension to a ready state" — the single source of truth for the
|
|
9
|
-
* open-home + collapse-to-one-tab + verify-healthy logic that host launchers
|
|
10
|
-
* and reopen helpers previously hand-rolled. They left duplicate home tabs, which trips
|
|
11
|
-
* runtime-health's "exactly one home page" contract and makes agents see a
|
|
12
|
-
* broken-looking runtime.
|
|
13
|
-
*
|
|
14
|
-
* Guarantees, given a live CDP port:
|
|
15
|
-
* - the extension id is resolved deterministically (resolveExtensionId),
|
|
16
|
-
* - exactly ONE chrome-extension home.html page target is open (opened if none,
|
|
17
|
-
* extras closed if several),
|
|
18
|
-
* - the runtime is verified healthy (checkExtensionRuntimeHealth).
|
|
19
|
-
*
|
|
20
|
-
* Pure-ish: it only opens/closes tabs to converge on the single-tab invariant;
|
|
21
|
-
* it does not rebuild or relaunch. Unlocking the wallet remains with the host
|
|
22
|
-
* launcher for now (a future increment can fold ensure_unlocked in here too).
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
export interface EnsureReadyResult {
|
|
26
|
-
schemaVersion: 1;
|
|
27
|
-
adapter: 'extension';
|
|
28
|
-
target: string;
|
|
29
|
-
cdpPort: number;
|
|
30
|
-
extensionId: string | null;
|
|
31
|
-
opened: boolean;
|
|
32
|
-
homeTabs: { before: number; closed: number; after: number };
|
|
33
|
-
ready: boolean;
|
|
34
|
-
reasonCode: string;
|
|
35
|
-
health: { status: 'PASS' | 'FAIL' | 'unknown'; findings: string[] };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface CdpTarget {
|
|
39
|
-
id?: string;
|
|
40
|
-
type?: string;
|
|
41
|
-
url?: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
45
|
-
|
|
46
|
-
async function jsonList(port: number): Promise<CdpTarget[]> {
|
|
47
|
-
try {
|
|
48
|
-
const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5000) });
|
|
49
|
-
if (!res.ok) return [];
|
|
50
|
-
const value = await res.json();
|
|
51
|
-
return Array.isArray(value) ? (value as CdpTarget[]) : [];
|
|
52
|
-
} catch {
|
|
53
|
-
return [];
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function closeTab(port: number, id: string): Promise<void> {
|
|
58
|
-
try {
|
|
59
|
-
await fetch(`http://127.0.0.1:${port}/json/close/${id}`, { signal: AbortSignal.timeout(4000) });
|
|
60
|
-
} catch {
|
|
61
|
-
// best-effort; a tab that won't close is surfaced by the final health check
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function activateTab(port: number, id: string): Promise<void> {
|
|
66
|
-
try {
|
|
67
|
-
await fetch(`http://127.0.0.1:${port}/json/activate/${id}`, { signal: AbortSignal.timeout(4000) });
|
|
68
|
-
} catch {
|
|
69
|
-
// best-effort; foregrounding only steers the headed-window screen capture onto
|
|
70
|
-
// the wallet — the authoritative readiness signal is the health check below
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Blank / New-Tab pages Chrome opens by default. The headed-window screen capture
|
|
75
|
-
// (device-grid stream) follows whatever tab is foreground, so a lingering New Tab
|
|
76
|
-
// makes the stream show chrome://newtab instead of the wallet. Safe to close once
|
|
77
|
-
// a home tab exists.
|
|
78
|
-
function isStrayTab(url?: string): boolean {
|
|
79
|
-
if (typeof url !== 'string') return false;
|
|
80
|
-
return /^chrome:\/\/newtab\/?$/.test(url) || /^chrome:\/\/new-tab-page\b/.test(url) || url === 'about:blank';
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function openHome(port: number, extensionId: string): Promise<boolean> {
|
|
84
|
-
const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(`chrome-extension://${extensionId}/home.html`)}`;
|
|
85
|
-
try {
|
|
86
|
-
let res = await fetch(endpoint, { method: 'PUT', signal: AbortSignal.timeout(8000) });
|
|
87
|
-
if (res.status === 404 || res.status === 405) res = await fetch(endpoint, { signal: AbortSignal.timeout(8000) });
|
|
88
|
-
return res.ok;
|
|
89
|
-
} catch {
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function homePages(targets: CdpTarget[], extensionId: string): CdpTarget[] {
|
|
95
|
-
const prefix = `chrome-extension://${extensionId}`;
|
|
96
|
-
return targets.filter(
|
|
97
|
-
(t) => t.type === 'page' && typeof t.url === 'string' && t.url.startsWith(prefix) && t.url.includes('/home.html') && t.id,
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export async function ensureExtensionReady(
|
|
102
|
-
target: string,
|
|
103
|
-
options: { cdpPort: number },
|
|
104
|
-
): Promise<EnsureReadyResult> {
|
|
105
|
-
const resolved = path.resolve(target);
|
|
106
|
-
const { cdpPort } = options;
|
|
107
|
-
const base = (extra: Partial<EnsureReadyResult>): EnsureReadyResult => ({
|
|
108
|
-
schemaVersion: 1,
|
|
109
|
-
adapter: 'extension',
|
|
110
|
-
target: resolved,
|
|
111
|
-
cdpPort,
|
|
112
|
-
extensionId: null,
|
|
113
|
-
opened: false,
|
|
114
|
-
homeTabs: { before: 0, closed: 0, after: 0 },
|
|
115
|
-
ready: false,
|
|
116
|
-
reasonCode: 'unknown',
|
|
117
|
-
health: { status: 'unknown', findings: [] },
|
|
118
|
-
...extra,
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
const { extensionId } = await resolveExtensionId(resolved, { cdpPort });
|
|
122
|
-
if (!extensionId) return base({ reasonCode: 'no-extension-id' });
|
|
123
|
-
|
|
124
|
-
const homes = homePages(await jsonList(cdpPort), extensionId);
|
|
125
|
-
const before = homes.length;
|
|
126
|
-
|
|
127
|
-
// Converge on exactly one home tab.
|
|
128
|
-
// - 0 tabs → open one.
|
|
129
|
-
// - 1 tab → keep it (cheap; the health check below validates it).
|
|
130
|
-
// - >1 tabs → close ALL and open one fresh. /json/list alone can't tell a
|
|
131
|
-
// healthy tab from a chrome-error one (a crashed page still lists its
|
|
132
|
-
// intended url), so keeping the first could discard the good tab and keep a
|
|
133
|
-
// broken one. A fresh tab is deterministically clean.
|
|
134
|
-
let opened = false;
|
|
135
|
-
let closed = 0;
|
|
136
|
-
if (homes.length > 1) {
|
|
137
|
-
for (const h of homes) {
|
|
138
|
-
await closeTab(cdpPort, String(h.id));
|
|
139
|
-
closed += 1;
|
|
140
|
-
}
|
|
141
|
-
await sleep(500);
|
|
142
|
-
opened = await openHome(cdpPort, extensionId);
|
|
143
|
-
await sleep(1500);
|
|
144
|
-
} else if (homes.length === 0) {
|
|
145
|
-
opened = await openHome(cdpPort, extensionId);
|
|
146
|
-
await sleep(1500);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Drop stray New Tab / blank pages and foreground the wallet so the headed
|
|
150
|
-
// window captured by the device-grid stream shows MetaMask, not chrome://newtab.
|
|
151
|
-
// Only prune once a home tab exists, so the window is never left with zero tabs
|
|
152
|
-
// (which would close it).
|
|
153
|
-
const converged = await jsonList(cdpPort);
|
|
154
|
-
const homesNow = homePages(converged, extensionId);
|
|
155
|
-
if (homesNow.length >= 1) {
|
|
156
|
-
for (const t of converged) {
|
|
157
|
-
if (t.type === 'page' && t.id && isStrayTab(t.url)) await closeTab(cdpPort, String(t.id));
|
|
158
|
-
}
|
|
159
|
-
if (homesNow[0]?.id) await activateTab(cdpPort, String(homesNow[0].id));
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const after = homePages(await jsonList(cdpPort), extensionId).length;
|
|
163
|
-
|
|
164
|
-
// Verify with the single health source. Lazy import: extension-runtime.ts
|
|
165
|
-
// loads the recipe harness at module scope, so it must not be pulled into
|
|
166
|
-
// any no-CDP path.
|
|
167
|
-
let health: EnsureReadyResult['health'] = { status: 'unknown', findings: [] };
|
|
168
|
-
try {
|
|
169
|
-
const { checkExtensionRuntimeHealth } = await import('./runtime.ts');
|
|
170
|
-
const report = await checkExtensionRuntimeHealth(resolved, cdpPort);
|
|
171
|
-
health = { status: report.status, findings: report.findings };
|
|
172
|
-
} catch (error) {
|
|
173
|
-
health = { status: 'FAIL', findings: [error instanceof Error ? error.message : String(error)] };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const ready = health.status === 'PASS' && after === 1;
|
|
177
|
-
return base({
|
|
178
|
-
extensionId,
|
|
179
|
-
opened,
|
|
180
|
-
homeTabs: { before, closed, after },
|
|
181
|
-
ready,
|
|
182
|
-
reasonCode: ready ? 'ready' : after !== 1 ? 'tab-count' : 'unhealthy',
|
|
183
|
-
health,
|
|
184
|
-
});
|
|
185
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
// extension-id.ts — deterministic extension (CRX) id resolution.
|
|
2
|
-
// App control: derives the id from the loaded dist manifest key. Name keeps
|
|
3
|
-
// the extension- prefix: this is the CRX id, not an instance/slot id.
|
|
4
|
-
import crypto from 'node:crypto';
|
|
5
|
-
import fs from 'node:fs';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Single source of truth for "which extension id is this MetaMask build".
|
|
10
|
-
*
|
|
11
|
-
* WHY THIS EXISTS. The id was resolved in three different, drifting places —
|
|
12
|
-
* host `launch-browser.sh` (Playwright candidate loop), `reopen-browser.sh`
|
|
13
|
-
* (`serviceWorkers()[0]`, which grabs Chrome's *component* extensions and broke
|
|
14
|
-
* recovery), and the harness CDP target selector. They disagreed. This module
|
|
15
|
-
* makes the runner the one resolver host launchers defer to.
|
|
16
|
-
*
|
|
17
|
-
* The id is **deterministic**: Chrome derives an unpacked extension's id from
|
|
18
|
-
* the `key` in its manifest (base64-decode → sha256 → first 16 bytes → each
|
|
19
|
-
* nibble mapped to a-p). Since the browser is launched with `--load-extension`
|
|
20
|
-
* pointing at exactly this dist, the computed id IS the loaded extension's id —
|
|
21
|
-
* no service-worker/page guessing, robust even when the MV3 worker is idle.
|
|
22
|
-
*
|
|
23
|
-
* `--cdp-port` (optional) only *verifies* the computed id is actually present in
|
|
24
|
-
* the running browser; it never changes the answer.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
export interface ResolveExtensionResult {
|
|
28
|
-
adapter: 'extension';
|
|
29
|
-
target: string;
|
|
30
|
-
extensionId: string | null;
|
|
31
|
-
source: 'manifest-key' | 'cdp-target' | 'none';
|
|
32
|
-
verified: boolean | null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const DIST_MANIFEST = 'dist/chrome/manifest.json';
|
|
36
|
-
|
|
37
|
-
/** Chrome's unpacked-extension id derivation from a manifest `key` (base64 DER). */
|
|
38
|
-
export function extensionIdFromKey(keyBase64: string): string {
|
|
39
|
-
const der = Buffer.from(keyBase64, 'base64');
|
|
40
|
-
const hash = crypto.createHash('sha256').update(der).digest();
|
|
41
|
-
let id = '';
|
|
42
|
-
for (let i = 0; i < 16; i += 1) {
|
|
43
|
-
id += String.fromCharCode(97 + (hash[i] >> 4));
|
|
44
|
-
id += String.fromCharCode(97 + (hash[i] & 0xf));
|
|
45
|
-
}
|
|
46
|
-
return id;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function idFromDistManifest(target: string): string | null {
|
|
50
|
-
const manifestPath = path.join(target, DIST_MANIFEST);
|
|
51
|
-
if (!fs.existsSync(manifestPath)) return null;
|
|
52
|
-
try {
|
|
53
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { key?: unknown };
|
|
54
|
-
return typeof manifest.key === 'string' && manifest.key.length > 0
|
|
55
|
-
? extensionIdFromKey(manifest.key)
|
|
56
|
-
: null;
|
|
57
|
-
} catch {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** chrome-extension ids visible to CDP (page targets + service workers). */
|
|
63
|
-
async function cdpExtensionIds(cdpPort: number): Promise<string[]> {
|
|
64
|
-
try {
|
|
65
|
-
const res = await fetch(`http://127.0.0.1:${cdpPort}/json/list`, { signal: AbortSignal.timeout(4000) });
|
|
66
|
-
if (!res.ok) return [];
|
|
67
|
-
const targets = (await res.json()) as Array<{ url?: string }>;
|
|
68
|
-
const ids = new Set<string>();
|
|
69
|
-
for (const t of targets) {
|
|
70
|
-
const m = /^chrome-extension:\/\/([^/]+)/u.exec(String(t.url ?? ''));
|
|
71
|
-
if (m) ids.add(m[1]);
|
|
72
|
-
}
|
|
73
|
-
return [...ids];
|
|
74
|
-
} catch {
|
|
75
|
-
return [];
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Resolve the MetaMask extension id for a checkout. Pure file/crypto by default;
|
|
81
|
-
* `--cdp-port` adds a presence check (and a fallback when the dist has no `key`).
|
|
82
|
-
*/
|
|
83
|
-
export async function resolveExtensionId(
|
|
84
|
-
target: string,
|
|
85
|
-
options: { cdpPort?: number } = {},
|
|
86
|
-
): Promise<ResolveExtensionResult> {
|
|
87
|
-
const resolved = path.resolve(target);
|
|
88
|
-
const fromKey = idFromDistManifest(resolved);
|
|
89
|
-
let cdpIds: string[] | null = null;
|
|
90
|
-
if (options.cdpPort) cdpIds = await cdpExtensionIds(options.cdpPort);
|
|
91
|
-
|
|
92
|
-
if (fromKey) {
|
|
93
|
-
return {
|
|
94
|
-
adapter: 'extension',
|
|
95
|
-
target: resolved,
|
|
96
|
-
extensionId: fromKey,
|
|
97
|
-
source: 'manifest-key',
|
|
98
|
-
verified: cdpIds ? cdpIds.includes(fromKey) : null,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
// No `key` in the dist manifest (rare). Fall back to a live CDP id if exactly
|
|
102
|
-
// one extension is loaded; otherwise we cannot disambiguate deterministically.
|
|
103
|
-
if (cdpIds && cdpIds.length === 1) {
|
|
104
|
-
return { adapter: 'extension', target: resolved, extensionId: cdpIds[0], source: 'cdp-target', verified: true };
|
|
105
|
-
}
|
|
106
|
-
return { adapter: 'extension', target: resolved, extensionId: null, source: 'none', verified: cdpIds ? false : null };
|
|
107
|
-
}
|