@deeeed/metamask-harness 0.7.5 → 0.9.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 +13 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +5 -2
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +37 -11
- package/dist/cli-commands.js +3 -3
- package/dist/cli.js +4 -0
- package/dist/commands/call.js +7 -0
- package/dist/commands/device-target.js +105 -0
- package/dist/commands/doctor.js +17 -4
- package/dist/commands/parse-args.js +1 -0
- package/dist/commands/run-engine.js +41 -3
- package/dist/commands/run.js +8 -1
- package/dist/commands/status-probe.js +167 -0
- package/dist/commands/status.js +88 -0
- package/dist/devices.js +66 -0
- package/dist/mm-harness-cli.js +41 -2
- package/docs/CLI-SPEC.md +70 -0
- package/library/manifests/core.action-manifest.json +1465 -1225
- package/library/manifests/extension.action-manifest.json +324 -5
- package/library/manifests/mobile.action-manifest.json +321 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 - 2026-07-07
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **`status` auto-probes live app state per device** — the `yarn a:status` successor. Static info prints instantly; within a strict 2s window each device line is enriched with `screen=` (active route via the `__AGENTIC__` bridge, Route objects normalised), `wallet=locked|unlocked|onboarding`, `account=<label> (0x1234…abcd)` and `fixture=READY|missing`. `--fast` skips all probes (guaranteed-instant for scripts); `--json` carries the enriched `devices[]` in one envelope. All RN targets attached to the checkout's Metro are probed and matched per device (exact id → exact name → platform-uniqueness → leftover 1:1), so android + ios side by side on one Metro both report. Honest degradation: `no-bridge` (nothing matched/answering) vs `bridge-absent` (target attached but the installed build predates `__AGENTIC__`, rendered with a rebuild hint) — surfacing pre-bridge builds required the target discovery probe to become three-state (agentic/responsive/unreachable) instead of silently dropping them.
|
|
7
|
+
- **Every action now carries self-discovery metadata.** `action_metadata` (description + example recipe node, derived from the real handler implementations) covers all official and custom actions across the mobile, extension and core manifests — previously `app.status`, `cdp.target` and all 16 `metamask.wallet.*`/`metamask.perps.*` actions were bare names to agents. A new contract guard enforces coverage (including example structure: `node.action` must match), and locks mobile ↔ extension action-set parity with an explicit platform-only allowlist.
|
|
8
|
+
|
|
9
|
+
## 0.8.0 - 2026-07-07
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **`run <name>` — the recipe positional resolves packaged-library recipe names.** An existing file path (absolute or cwd-relative) wins; otherwise the arg is a library recipe name, adapter-scoped first (`run smoke` on mobile → `smoke.mobile.recipe.json`), then adapterless (`run perps-lifecycle`), then the exact library filename. A miss teaches `RECIPE_NOT_FOUND` naming the library recipes available for the adapter — no more `$LIB`-style path prefixes in docs or demo scripts. A directory shadowing a library name never wins, and path-shaped args (containing a separator) never fall through to the library.
|
|
13
|
+
- **`--device <udid|serial|name>` — first-class mobile device targeting on `run`, `call`, and `doctor`.** Resolves against connected devices (adb + booted simulators; exact id first, then exact name with an ambiguity teaching error) and sets the same env `launch --device` does. Mobile `run`/`call` **without** `--device` while more than one targetable device is connected (across android + ios; android state `device`, iOS `Booted`) fail fast listing the devices and the `--device` hint, so a recipe never lands on the wrong device silently. `run --plan`/`--list` are exempt; extension/core teach. Errors honor `--json` with structured envelopes.
|
|
14
|
+
- **`status` gains `devices[]`** (additive): `{platform, id, name, state, selected}` for connected android devices and booted iOS simulators on mobile checkouts — the `yarn a:status` successor; `selected` reflects the current env pinning.
|
|
15
|
+
|
|
3
16
|
## 0.7.5 - 2026-07-07
|
|
4
17
|
|
|
5
18
|
### Fixed
|
|
@@ -173,10 +173,11 @@ const COMMANDS = {
|
|
|
173
173
|
|
|
174
174
|
async status(client, _args, { deviceName, platform } = {}) {
|
|
175
175
|
const expr = `(function() {
|
|
176
|
+
var agenticPresent = typeof globalThis.__AGENTIC__ !== 'undefined';
|
|
176
177
|
var route = globalThis.__AGENTIC__?.getRoute() || null;
|
|
177
178
|
var account = null;
|
|
178
179
|
try { account = globalThis.__AGENTIC__?.getSelectedAccount() || null; } catch(e) {}
|
|
179
|
-
return { route: route, account: account };
|
|
180
|
+
return { route: route, account: account, agenticPresent: agenticPresent };
|
|
180
181
|
})()`;
|
|
181
182
|
const snapshot = await cdpEval(client, expr);
|
|
182
183
|
return { ...snapshot, deviceName: deviceName || '', platform: platform || '' };
|
|
@@ -658,7 +659,9 @@ Environment:
|
|
|
658
659
|
const result = await handler(client, args.slice(1), { deviceName: target.deviceName, platform });
|
|
659
660
|
results.push(result);
|
|
660
661
|
} catch {
|
|
661
|
-
//
|
|
662
|
+
// Recovery is correct: status probes every target advisory; an
|
|
663
|
+
// unresponsive one degrades to absent-from-the-listing rather than
|
|
664
|
+
// failing the whole multi-target command.
|
|
662
665
|
} finally {
|
|
663
666
|
if (client) client.close();
|
|
664
667
|
}
|
|
@@ -48,6 +48,18 @@ async function fetchJSON(url) {
|
|
|
48
48
|
* Returns true if __AGENTIC__ is installed, false otherwise.
|
|
49
49
|
*/
|
|
50
50
|
async function probeTarget(wsUrl) {
|
|
51
|
+
return (await probeTargetDetailed(wsUrl)) === 'agentic';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Three-state probe so callers can tell a pre-__AGENTIC__ build apart from a
|
|
56
|
+
* dead target:
|
|
57
|
+
* 'agentic' — JS runtime answered and __AGENTIC__ is installed.
|
|
58
|
+
* 'responsive' — the target evaluated JS but has no __AGENTIC__ (an app build
|
|
59
|
+
* that predates the bridge, or a non-runtime page).
|
|
60
|
+
* 'unreachable' — connect/eval failed.
|
|
61
|
+
*/
|
|
62
|
+
async function probeTargetDetailed(wsUrl) {
|
|
51
63
|
try {
|
|
52
64
|
const client = await createWSClient(wsUrl, 3000);
|
|
53
65
|
try {
|
|
@@ -56,13 +68,13 @@ async function probeTarget(wsUrl) {
|
|
|
56
68
|
returnByValue: true,
|
|
57
69
|
awaitPromise: false,
|
|
58
70
|
});
|
|
59
|
-
return result?.result?.value === 'object';
|
|
71
|
+
return result?.result?.value === 'object' ? 'agentic' : 'responsive';
|
|
60
72
|
} finally {
|
|
61
73
|
client.close();
|
|
62
74
|
}
|
|
63
75
|
} catch {
|
|
64
|
-
// Connection failed — target is not
|
|
65
|
-
return
|
|
76
|
+
// Connection failed — target is not usable at all
|
|
77
|
+
return 'unreachable';
|
|
66
78
|
}
|
|
67
79
|
}
|
|
68
80
|
|
|
@@ -186,18 +198,32 @@ async function discoverAllTargets(port) {
|
|
|
186
198
|
return bPage - aPage;
|
|
187
199
|
});
|
|
188
200
|
|
|
189
|
-
// Group by deviceName, probe each to find the JS runtime target
|
|
190
|
-
|
|
191
|
-
|
|
201
|
+
// Group by deviceName, probe each to find the JS runtime target. Prefer the
|
|
202
|
+
// device's __AGENTIC__-bearing target; when a device has none, keep its first
|
|
203
|
+
// RESPONSIVE candidate (candidates are sorted JS-runtime-first) so a build
|
|
204
|
+
// that predates the bridge still surfaces — the status consumer reports it as
|
|
205
|
+
// bridge-absent instead of the device silently vanishing from discovery.
|
|
206
|
+
// Known ambiguity: when a device's JS runtime is attached-but-busy while its
|
|
207
|
+
// native C++ Hermes page answers, that page is also 'responsive' (no
|
|
208
|
+
// __AGENTIC__ in the native context), so the device reports bridge-absent even
|
|
209
|
+
// though the app build may carry the bridge. Low probability, and the degraded
|
|
210
|
+
// report is informative rather than harmful.
|
|
211
|
+
const agenticByDevice = new Map();
|
|
212
|
+
const responsiveByDevice = new Map();
|
|
192
213
|
for (const candidate of candidates) {
|
|
193
214
|
const device = candidate.deviceName || candidate.id || candidate.webSocketDebuggerUrl;
|
|
194
|
-
if (
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
215
|
+
if (agenticByDevice.has(device)) continue;
|
|
216
|
+
const state = await probeTargetDetailed(candidate.webSocketDebuggerUrl);
|
|
217
|
+
if (state === 'agentic') {
|
|
218
|
+
agenticByDevice.set(device, { wsUrl: candidate.webSocketDebuggerUrl, deviceName: device });
|
|
219
|
+
} else if (state === 'responsive' && !responsiveByDevice.has(device)) {
|
|
220
|
+
responsiveByDevice.set(device, { wsUrl: candidate.webSocketDebuggerUrl, deviceName: device });
|
|
199
221
|
}
|
|
200
222
|
}
|
|
223
|
+
const results = [...agenticByDevice.values()];
|
|
224
|
+
for (const [device, entry] of responsiveByDevice) {
|
|
225
|
+
if (!agenticByDevice.has(device)) results.push(entry);
|
|
226
|
+
}
|
|
201
227
|
return results;
|
|
202
228
|
}
|
|
203
229
|
|
package/dist/cli-commands.js
CHANGED
|
@@ -7,7 +7,7 @@ const SPEC = {
|
|
|
7
7
|
{ name: "help", aliases: ["-h", "--help"], desc: "Show usage" }
|
|
8
8
|
],
|
|
9
9
|
shared: [
|
|
10
|
-
{ name: "status", aliases: ["health", "home"], desc: "Home status + next commands", flags: ["--json"] },
|
|
10
|
+
{ name: "status", aliases: ["health", "home"], desc: "Home status + next commands", flags: ["--json", "--fast"] },
|
|
11
11
|
{ name: "ports", desc: "Slot ports and runtime paths", flags: ["--json"] },
|
|
12
12
|
{ name: "up", desc: "Decide + run minimum work to reach ready", flags: ["--json", "--dry-run"] },
|
|
13
13
|
{ name: "sync", desc: "Refresh harness + canonicalize wallet fixture", flags: ["--json"] },
|
|
@@ -15,8 +15,8 @@ const SPEC = {
|
|
|
15
15
|
{ name: "debug", aliases: ["devtools", "inspect"], desc: "Open DevTools UI", flags: ["--json", "--no-open"] },
|
|
16
16
|
{ name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--json"] },
|
|
17
17
|
{ name: "actions", desc: "List runnable recipe actions", flags: ["--json"] },
|
|
18
|
-
{ name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--cdp-port"] },
|
|
19
|
-
{ name: "run", desc: "Execute a proof recipe", args: ["recipe.json"], flags: ["--list"] },
|
|
18
|
+
{ name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--cdp-port", "--device"] },
|
|
19
|
+
{ name: "run", desc: "Execute a proof recipe (path or library name, e.g. run smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
|
|
20
20
|
{ name: "recipe-quality", desc: "Build the recipe-quality artifact from compact JSON", args: ["build"], flags: ["--input", "--output", "--json"] },
|
|
21
21
|
{ name: "interactive", aliases: ["menu"], desc: "Interactive command menu" },
|
|
22
22
|
{ name: "prepare", desc: "Install harness (+ optional validate)", flags: ["--target", "--runtime-dir", "--json"] },
|
package/dist/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ import { handleLogs } from "./commands/logs.js";
|
|
|
18
18
|
import { handleDebug } from "./commands/debug.js";
|
|
19
19
|
import { handleFixtures } from "./commands/fixtures.js";
|
|
20
20
|
import { handleRecipeQuality } from "./commands/recipe-quality.js";
|
|
21
|
+
import { handleStatus } from "./commands/status.js";
|
|
21
22
|
import { parseArgs, targetPath } from "./commands/parse-args.js";
|
|
22
23
|
import { runOneNode } from "./commands/run-engine.js";
|
|
23
24
|
const COMMANDS = {
|
|
@@ -92,6 +93,9 @@ async function main(argv) {
|
|
|
92
93
|
usage();
|
|
93
94
|
return command ? 0 : 2;
|
|
94
95
|
}
|
|
96
|
+
if (command === "status" || command === "health" || command === "home") {
|
|
97
|
+
return handleStatus(parseArgs(argv.slice(1), "status"));
|
|
98
|
+
}
|
|
95
99
|
if (command === "flows") return handleFlows(argv.slice(1));
|
|
96
100
|
if (command === "call") return handleCall(argv.slice(1));
|
|
97
101
|
if (command === "completion-candidates") return handleCompletionCandidates(argv.slice(1));
|
package/dist/commands/call.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
usageError
|
|
16
16
|
} from "./parse-args.js";
|
|
17
17
|
import { handleListExecutables } from "./list-executables.js";
|
|
18
|
+
import { applyDeviceTargeting } from "./device-target.js";
|
|
18
19
|
import {
|
|
19
20
|
emitHealViolation,
|
|
20
21
|
executeWithHealBounds,
|
|
@@ -57,6 +58,12 @@ async function handleCall(argv) {
|
|
|
57
58
|
return EXIT.usage;
|
|
58
59
|
}
|
|
59
60
|
const { adapter, target } = resolveAdapter(options);
|
|
61
|
+
const dtResult = applyDeviceTargeting("call", adapter, options, { gate: true, rerun: "" });
|
|
62
|
+
if ("code" in dtResult) {
|
|
63
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
|
|
64
|
+
else console.error(`\u2717 call: ${dtResult.message}`);
|
|
65
|
+
return EXIT.usage;
|
|
66
|
+
}
|
|
60
67
|
if (recipeRunning(target)) {
|
|
61
68
|
const msg = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
|
|
62
69
|
if (json) {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { listConnectedDevices } from "../devices.js";
|
|
2
|
+
import { optionString } from "./parse-args.js";
|
|
3
|
+
function setAndroidDeviceEnv(id) {
|
|
4
|
+
process.env.ADB_SERIAL = id;
|
|
5
|
+
process.env.ANDROID_SERIAL = id;
|
|
6
|
+
process.env.ANDROID_DEVICE = id;
|
|
7
|
+
}
|
|
8
|
+
function setIosDeviceEnv(id) {
|
|
9
|
+
process.env.IOS_SIMULATOR = id;
|
|
10
|
+
}
|
|
11
|
+
function formatConnectedDevices(devices) {
|
|
12
|
+
return devices.map((d) => ` - ${d.id}${d.name ? ` (${d.name})` : ""} [${d.state}] ${d.platform}`).join("\n");
|
|
13
|
+
}
|
|
14
|
+
function renderDeviceList(devices, out) {
|
|
15
|
+
console.log(
|
|
16
|
+
`${out("label", "devices:")} ${devices.length === 0 ? out("dim", "none connected (adb devices / booted iOS simulators)") : `${devices.length} connected`}`
|
|
17
|
+
);
|
|
18
|
+
for (const device of devices) {
|
|
19
|
+
const marker = device.selected ? out("ok", "\u25CF selected") : out("dim", "\u25CB");
|
|
20
|
+
console.log(
|
|
21
|
+
` ${marker} ${device.id}${device.name ? ` (${device.name})` : ""} ${out("dim", `[${device.state}] ${device.platform}`)}`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function deviceSelected(device) {
|
|
26
|
+
if (device.platform === "android") {
|
|
27
|
+
return process.env.ADB_SERIAL === device.id || process.env.ANDROID_SERIAL === device.id;
|
|
28
|
+
}
|
|
29
|
+
return process.env.IOS_SIMULATOR === device.id;
|
|
30
|
+
}
|
|
31
|
+
function isTargetable(device) {
|
|
32
|
+
if (device.platform === "android") return device.state === "device";
|
|
33
|
+
if (device.platform === "ios") return device.state === "Booted";
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
function applyDeviceTargeting(command, adapter, options, opts) {
|
|
37
|
+
const device = optionString(options, "device");
|
|
38
|
+
if (adapter !== "mobile") {
|
|
39
|
+
if (device !== void 0) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
code: "DEVICE_WRONG_ADAPTER",
|
|
43
|
+
message: `--device is only supported on the mobile adapter (the ${adapter} adapter has no device to target).
|
|
44
|
+
Drop --device, or run this ${command} inside a metamask-mobile checkout.`
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { ok: true };
|
|
48
|
+
}
|
|
49
|
+
if (device !== void 0) {
|
|
50
|
+
const connected2 = listConnectedDevices();
|
|
51
|
+
const androidById = connected2.find((d) => d.platform === "android" && d.id === device);
|
|
52
|
+
if (androidById) {
|
|
53
|
+
setAndroidDeviceEnv(device);
|
|
54
|
+
return { ok: true };
|
|
55
|
+
}
|
|
56
|
+
const iosById = connected2.find((d) => d.platform === "ios" && d.id === device);
|
|
57
|
+
if (iosById) {
|
|
58
|
+
setIosDeviceEnv(device);
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
const byName = connected2.filter((d) => d.name === device);
|
|
62
|
+
if (byName.length === 1) {
|
|
63
|
+
const matched = byName[0];
|
|
64
|
+
if (matched.platform === "android") setAndroidDeviceEnv(matched.id);
|
|
65
|
+
else setIosDeviceEnv(matched.id);
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
if (byName.length > 1) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
code: "DEVICE_NAME_AMBIGUOUS",
|
|
72
|
+
message: `device name '${device}' is ambiguous; use the id:
|
|
73
|
+
` + byName.map((d) => ` - ${d.id}${d.name ? ` (${d.name})` : ""} [${d.state}] ${d.platform}`).join("\n")
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
code: "DEVICE_NOT_FOUND",
|
|
79
|
+
message: `--device ${device} did not match any connected device.
|
|
80
|
+
` + (connected2.length > 0 ? ` Connected devices:
|
|
81
|
+
${formatConnectedDevices(connected2)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (!opts.gate) return { ok: true };
|
|
85
|
+
const connected = listConnectedDevices();
|
|
86
|
+
const targetable = connected.filter(isTargetable);
|
|
87
|
+
if (targetable.length > 1) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
code: "DEVICE_AMBIGUOUS",
|
|
91
|
+
message: `${targetable.length} mobile devices available \u2014 ${command} needs exactly one target.
|
|
92
|
+
Connected devices:
|
|
93
|
+
${formatConnectedDevices(connected)}
|
|
94
|
+
Add --device <id> to disambiguate, e.g.:${targetable.map((d) => `
|
|
95
|
+
--device ${d.id}`).join("")}`
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { ok: true };
|
|
99
|
+
}
|
|
100
|
+
export {
|
|
101
|
+
applyDeviceTargeting,
|
|
102
|
+
deviceSelected,
|
|
103
|
+
formatConnectedDevices,
|
|
104
|
+
renderDeviceList
|
|
105
|
+
};
|
package/dist/commands/doctor.js
CHANGED
|
@@ -7,6 +7,8 @@ import { assertAdapter, runnerDir } from "../paths.js";
|
|
|
7
7
|
import { getAdapterSurface } from "../adapters/surface.js";
|
|
8
8
|
import { loadActionManifest, validateManifest } from "../manifest.js";
|
|
9
9
|
import { ensureOverlay, newHealState, recipeRunning } from "../heal-bounds.js";
|
|
10
|
+
import { listConnectedDevices } from "../devices.js";
|
|
11
|
+
import { applyDeviceTargeting, deviceSelected, renderDeviceList } from "./device-target.js";
|
|
10
12
|
import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
|
|
11
13
|
import {
|
|
12
14
|
actionManifestPathOption,
|
|
@@ -39,6 +41,12 @@ async function handleDoctor({ options }) {
|
|
|
39
41
|
return usageOut(json, "doctor", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
|
|
40
42
|
}
|
|
41
43
|
assertAdapter(adapter);
|
|
44
|
+
const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
|
|
45
|
+
if ("code" in dtResult) {
|
|
46
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
|
|
47
|
+
else console.error(`\u2717 doctor: ${dtResult.message}`);
|
|
48
|
+
return EXIT.usage;
|
|
49
|
+
}
|
|
42
50
|
const actionManifestPath = actionManifestPathOption(options, adapter);
|
|
43
51
|
const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
|
|
44
52
|
const manifestValidation = await validateManifest(manifest);
|
|
@@ -60,8 +68,9 @@ async function handleDoctor({ options }) {
|
|
|
60
68
|
}
|
|
61
69
|
const orphanMetros = adapter === "mobile" ? detectOrphanMetros(target, process.env.WATCHER_PORT) : [];
|
|
62
70
|
const capture = captureHelperHealth();
|
|
63
|
-
|
|
64
|
-
if (
|
|
71
|
+
const devices = adapter === "mobile" ? enumerateSelectedDevices() : [];
|
|
72
|
+
if (expectLive) return emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, json);
|
|
73
|
+
if (json) console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices }, null, 2));
|
|
65
74
|
else {
|
|
66
75
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
67
76
|
const stateStyle = (value, good) => value === good ? "ok" : "warn";
|
|
@@ -91,13 +100,17 @@ async function handleDoctor({ options }) {
|
|
|
91
100
|
console.log(` ${out("dim", "Next: grant Screen Recording (System Settings \u2192 Privacy & Security \u2192 Screen Recording), or run: capture-helper doctor --open-permissions")}`);
|
|
92
101
|
}
|
|
93
102
|
}
|
|
103
|
+
if (adapter === "mobile") renderDeviceList(devices, out);
|
|
94
104
|
}
|
|
95
105
|
return result.status === "pass" ? 0 : 1;
|
|
96
106
|
}
|
|
97
|
-
function
|
|
107
|
+
function enumerateSelectedDevices() {
|
|
108
|
+
return listConnectedDevices().map((device) => ({ ...device, selected: deviceSelected(device) }));
|
|
109
|
+
}
|
|
110
|
+
function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, json) {
|
|
98
111
|
const live = runtime?.decision === "ready";
|
|
99
112
|
if (json) {
|
|
100
|
-
console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture }, null, 2));
|
|
113
|
+
console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices }, null, 2));
|
|
101
114
|
return live ? EXIT.ok : EXIT.runtime;
|
|
102
115
|
}
|
|
103
116
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
@@ -14,6 +14,7 @@ import { getAdapterSurface } from "../adapters/surface.js";
|
|
|
14
14
|
import {
|
|
15
15
|
importRecipeHarness,
|
|
16
16
|
importRecipeProtocol,
|
|
17
|
+
recipePath,
|
|
17
18
|
runnerDir
|
|
18
19
|
} from "../paths.js";
|
|
19
20
|
import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
|
|
@@ -131,11 +132,47 @@ async function validateRecipeAdapterAware(recipe, manifest, librarySources) {
|
|
|
131
132
|
const warnings = findings.length - errors;
|
|
132
133
|
return { status: errors > 0 ? "invalid" : "valid", findings, summary: { errors, warnings } };
|
|
133
134
|
}
|
|
135
|
+
function isRecipeFile(p) {
|
|
136
|
+
try {
|
|
137
|
+
return fs.statSync(p).isFile();
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function resolveRunRecipeArg(recipeArg, adapter) {
|
|
143
|
+
const direct = path.resolve(recipeArg);
|
|
144
|
+
if (isRecipeFile(direct)) return { recipeFile: direct };
|
|
145
|
+
if (!recipeArg.includes("/") && !recipeArg.includes(path.sep)) {
|
|
146
|
+
for (const name of [`${recipeArg}.${adapter}.recipe.json`, `${recipeArg}.recipe.json`, recipeArg]) {
|
|
147
|
+
const file = recipePath(name);
|
|
148
|
+
if (isRecipeFile(file)) return { recipeFile: file };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const names = libraryRecipeNames(adapter);
|
|
152
|
+
return {
|
|
153
|
+
notFound: `recipe not found: ${recipeArg} \u2014 not a file, and no packaged library recipe matched. ` + (names.length > 0 ? `Library recipes for ${adapter}: ${names.join(", ")} (mm-harness run <name>).` : `The packaged library has no recipes for ${adapter}.`)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function libraryRecipeNames(adapter) {
|
|
157
|
+
let entries;
|
|
158
|
+
try {
|
|
159
|
+
entries = fs.readdirSync(recipePath(""));
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
const scopes = ["mobile", "extension", "core"];
|
|
164
|
+
const names = entries.filter((f) => f.endsWith(".recipe.json")).map((f) => f.slice(0, -".recipe.json".length)).filter((n) => {
|
|
165
|
+
const scope = n.split(".").pop() ?? "";
|
|
166
|
+
return !scopes.includes(scope) || scope === adapter;
|
|
167
|
+
}).map((n) => n.endsWith(`.${adapter}`) ? n.slice(0, -(adapter.length + 1)) : n);
|
|
168
|
+
return [...new Set(names)].sort();
|
|
169
|
+
}
|
|
134
170
|
async function validateRunRecipeStatic(recipeArg, adapter, options) {
|
|
135
|
-
const
|
|
171
|
+
const resolved = resolveRunRecipeArg(recipeArg, adapter);
|
|
172
|
+
const recipeFile = "recipeFile" in resolved ? resolved.recipeFile : path.resolve(recipeArg);
|
|
136
173
|
const empty = { recipe: void 0, recipeFile, findings: [], errorCount: 0, manifestOk: false, schemaValid: false };
|
|
137
|
-
if (
|
|
138
|
-
return { ...empty, usageError: { code: "RECIPE_NOT_FOUND", message:
|
|
174
|
+
if ("notFound" in resolved) {
|
|
175
|
+
return { ...empty, usageError: { code: "RECIPE_NOT_FOUND", message: resolved.notFound } };
|
|
139
176
|
}
|
|
140
177
|
let recipe;
|
|
141
178
|
try {
|
|
@@ -333,6 +370,7 @@ export {
|
|
|
333
370
|
executeWithHealBounds,
|
|
334
371
|
prepareHeal,
|
|
335
372
|
resolveMetaMaskLibrarySources,
|
|
373
|
+
resolveRunRecipeArg,
|
|
336
374
|
runOneNode,
|
|
337
375
|
runRecipe,
|
|
338
376
|
synthesizeOneNodeRecipe,
|
package/dist/commands/run.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
validateRunRecipeStatic
|
|
21
21
|
} from "./run-engine.js";
|
|
22
22
|
import { handleListExecutables } from "./list-executables.js";
|
|
23
|
+
import { applyDeviceTargeting } from "./device-target.js";
|
|
23
24
|
async function handleRun({ positional, options }) {
|
|
24
25
|
if (optionFlag(options, "list")) return handleListExecutables("run", options);
|
|
25
26
|
const targetRecipe = positional[0];
|
|
@@ -27,6 +28,10 @@ async function handleRun({ positional, options }) {
|
|
|
27
28
|
if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options);
|
|
28
29
|
const { adapter, target } = resolveAdapter(options);
|
|
29
30
|
const json = optionFlag(options, "json");
|
|
31
|
+
const dtResult = applyDeviceTargeting("run", adapter, options, { gate: true, rerun: "" });
|
|
32
|
+
if ("code" in dtResult) {
|
|
33
|
+
return emitRunUsageError(json, adapter, targetRecipe, dtResult.code, dtResult.message);
|
|
34
|
+
}
|
|
30
35
|
const artifactsDir = requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
|
|
31
36
|
const prepared = await prepareHeal(adapter, target, options, json);
|
|
32
37
|
if (typeof prepared === "number") return prepared;
|
|
@@ -45,7 +50,9 @@ async function handleRun({ positional, options }) {
|
|
|
45
50
|
stdoutIsMachineContract: json
|
|
46
51
|
};
|
|
47
52
|
const { result, violation } = await executeWithHealBounds(
|
|
48
|
-
|
|
53
|
+
// validated.recipeFile, not the raw arg: the arg may be a library recipe NAME
|
|
54
|
+
// that only the resolver knows how to turn into a file.
|
|
55
|
+
() => runRecipe(adapter, validated.recipeFile, artifactsDir, target, optionString(options, "actionManifest"), runtimeOptions),
|
|
49
56
|
adapter,
|
|
50
57
|
target,
|
|
51
58
|
heal,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fixtureSummary } from "../doctor.js";
|
|
5
|
+
import { runnerDir } from "../paths.js";
|
|
6
|
+
const PROBE_TIMEOUT_MS = 2e3;
|
|
7
|
+
function bridgeScriptPath() {
|
|
8
|
+
return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
|
|
9
|
+
}
|
|
10
|
+
function truncateAddress(address) {
|
|
11
|
+
if (!address || address.length <= 10) return address;
|
|
12
|
+
return `${address.slice(0, 6)}\u2026${address.slice(-4)}`;
|
|
13
|
+
}
|
|
14
|
+
function routeToString(route) {
|
|
15
|
+
if (typeof route === "string") return route;
|
|
16
|
+
if (route && typeof route === "object" && "name" in route) {
|
|
17
|
+
const n = route.name;
|
|
18
|
+
if (typeof n === "string") return n;
|
|
19
|
+
}
|
|
20
|
+
return void 0;
|
|
21
|
+
}
|
|
22
|
+
function deriveWalletState(account, route) {
|
|
23
|
+
const routeStr = (routeToString(route) ?? "").toLowerCase();
|
|
24
|
+
if (routeStr.includes("onboard")) return "onboarding";
|
|
25
|
+
if (account !== null && account !== void 0 && typeof account === "object") return "unlocked";
|
|
26
|
+
return "locked";
|
|
27
|
+
}
|
|
28
|
+
function parseBridgeEntries(raw) {
|
|
29
|
+
if (!raw || typeof raw !== "object") return [];
|
|
30
|
+
const items = Array.isArray(raw) ? raw : [raw];
|
|
31
|
+
return items.filter((e) => Boolean(e && typeof e === "object" && !Array.isArray(e))).map((e) => ({
|
|
32
|
+
route: e.route,
|
|
33
|
+
account: e.account,
|
|
34
|
+
deviceName: typeof e.deviceName === "string" ? e.deviceName : "",
|
|
35
|
+
platform: typeof e.platform === "string" ? e.platform : "",
|
|
36
|
+
// agenticPresent is only set when the bridge explicitly sends it (false = absent).
|
|
37
|
+
// Omitted by older bridge versions → leave undefined so caller treats as present.
|
|
38
|
+
...typeof e.agenticPresent === "boolean" ? { agenticPresent: e.agenticPresent } : {}
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
function matchBridgeEntry(device, entries) {
|
|
42
|
+
const byId = entries.find((e) => e.deviceName === device.id);
|
|
43
|
+
if (byId) return byId;
|
|
44
|
+
if (device.name) {
|
|
45
|
+
const byName = entries.find((e) => e.deviceName === device.name);
|
|
46
|
+
if (byName) return byName;
|
|
47
|
+
}
|
|
48
|
+
const platformEntries = entries.filter((e) => e.platform === device.platform);
|
|
49
|
+
if (platformEntries.length === 1) return platformEntries[0];
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function probeMobileBridge(target) {
|
|
53
|
+
const script = bridgeScriptPath();
|
|
54
|
+
if (!fs.existsSync(script)) {
|
|
55
|
+
return Promise.resolve({ ok: false, liveState: "no-bridge" });
|
|
56
|
+
}
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const env = {
|
|
59
|
+
...process.env,
|
|
60
|
+
APP_ROOT: target,
|
|
61
|
+
CDP_TIMEOUT: String(PROBE_TIMEOUT_MS)
|
|
62
|
+
};
|
|
63
|
+
let settled = false;
|
|
64
|
+
let stdout = "";
|
|
65
|
+
const child = spawn(process.execPath, [script, "status"], {
|
|
66
|
+
cwd: target,
|
|
67
|
+
env,
|
|
68
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
69
|
+
});
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
if (settled) return;
|
|
72
|
+
settled = true;
|
|
73
|
+
child.kill("SIGTERM");
|
|
74
|
+
const killTimer = setTimeout(() => child.kill("SIGKILL"), 500);
|
|
75
|
+
child.once("close", () => clearTimeout(killTimer));
|
|
76
|
+
resolve({ ok: false, liveState: "no-bridge" });
|
|
77
|
+
}, PROBE_TIMEOUT_MS);
|
|
78
|
+
child.stdout.on("data", (chunk) => {
|
|
79
|
+
stdout += chunk;
|
|
80
|
+
});
|
|
81
|
+
child.on("close", (code) => {
|
|
82
|
+
if (settled) return;
|
|
83
|
+
settled = true;
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
if (code !== 0) {
|
|
86
|
+
resolve({ ok: false, liveState: "no-bridge" });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const raw = JSON.parse(stdout);
|
|
91
|
+
const entries = parseBridgeEntries(raw);
|
|
92
|
+
if (entries.length === 0) {
|
|
93
|
+
resolve({ ok: false, liveState: "no-bridge" });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
resolve({ ok: true, entries });
|
|
97
|
+
} catch {
|
|
98
|
+
resolve({ ok: false, liveState: "no-bridge" });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
child.on("error", () => {
|
|
102
|
+
if (settled) return;
|
|
103
|
+
settled = true;
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
resolve({ ok: false, liveState: "no-bridge" });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function resolveFixtureStatus(target) {
|
|
110
|
+
const summary = fixtureSummary(target);
|
|
111
|
+
return summary.status === "ready" ? "READY" : "missing";
|
|
112
|
+
}
|
|
113
|
+
async function probeMobileLiveState(target, devices) {
|
|
114
|
+
const result = /* @__PURE__ */ new Map();
|
|
115
|
+
if (devices.length === 0) return result;
|
|
116
|
+
const fixtureStatus = resolveFixtureStatus(target);
|
|
117
|
+
const probeResult = await probeMobileBridge(target);
|
|
118
|
+
if (!probeResult.ok) {
|
|
119
|
+
for (const device of devices) result.set(device.id, { fixtureStatus, liveState: "no-bridge" });
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
const { entries } = probeResult;
|
|
123
|
+
const matched = /* @__PURE__ */ new Map();
|
|
124
|
+
const usedEntries = /* @__PURE__ */ new Set();
|
|
125
|
+
for (const device of devices) {
|
|
126
|
+
const entry = matchBridgeEntry(device, entries.filter((e) => !usedEntries.has(e)));
|
|
127
|
+
if (entry) {
|
|
128
|
+
matched.set(device.id, entry);
|
|
129
|
+
usedEntries.add(entry);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const leftoverEntries = entries.filter(
|
|
133
|
+
(e) => !usedEntries.has(e) && e.platform === "" && e.agenticPresent === false
|
|
134
|
+
);
|
|
135
|
+
const unmatchedDevices = devices.filter((d) => !matched.has(d.id));
|
|
136
|
+
if (leftoverEntries.length === 1 && unmatchedDevices.length === 1) {
|
|
137
|
+
matched.set(unmatchedDevices[0].id, leftoverEntries[0]);
|
|
138
|
+
}
|
|
139
|
+
for (const device of devices) {
|
|
140
|
+
const entry = matched.get(device.id);
|
|
141
|
+
if (!entry) {
|
|
142
|
+
result.set(device.id, { fixtureStatus, liveState: "no-bridge" });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (entry.agenticPresent === false) {
|
|
146
|
+
result.set(device.id, { fixtureStatus, liveState: "bridge-absent" });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const account = entry.account;
|
|
150
|
+
const walletState = deriveWalletState(account, entry.route);
|
|
151
|
+
const currentScreen = routeToString(entry.route);
|
|
152
|
+
const addrRaw = typeof account?.address === "string" ? account.address : void 0;
|
|
153
|
+
const labelRaw = typeof account?.name === "string" ? account.name : void 0;
|
|
154
|
+
const liveEntry = {
|
|
155
|
+
fixtureStatus,
|
|
156
|
+
walletState,
|
|
157
|
+
...currentScreen !== void 0 ? { currentScreen } : {},
|
|
158
|
+
...labelRaw !== void 0 && addrRaw !== void 0 ? { selectedAccount: { label: labelRaw, address: truncateAddress(addrRaw) } } : {}
|
|
159
|
+
};
|
|
160
|
+
result.set(device.id, liveEntry);
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
export {
|
|
165
|
+
probeMobileLiveState,
|
|
166
|
+
resolveFixtureStatus
|
|
167
|
+
};
|