@deeeed/metamask-harness 0.8.0 → 0.9.1
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 +14 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +23 -5
- package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -2
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +107 -19
- package/adapters/mobile/start-metro.sh +23 -6
- package/dist/cli-commands.js +1 -1
- package/dist/commands/parse-args.js +1 -0
- package/dist/commands/status-probe.js +167 -0
- package/dist/commands/status.js +58 -1
- package/dist/mm-harness-cli.js +18 -5
- package/docs/CLI-SPEC.md +42 -5
- package/library/actions/mobile/platform/bridge.mjs +81 -4
- package/library/actions/mobile/wallet/setup.mjs +5 -2
- 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,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1 - 2026-07-07
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Metro survives the launching shell.** `start-metro.sh` spawned Metro without SIGHUP protection, so closing the launching tmux window/shell killed it, leaving a stale `metro.pid` and hanging every subsequent bridge/fixtures call. Metro now starts under `nohup` (argv spawn, no `bash -c` string interpolation), and a stale `metro.pid` naming a dead process is detected and cleaned before start.
|
|
7
|
+
- **`--device <adb serial>` reaches CDP target selection.** The runner's ambiguity gate resolved the serial, but target discovery compared it against Metro's `deviceName` ("Pixel 6 - 16 - API 36") — never a serial — so with an iOS simulator also attached, a recipe pinned to the physical Android device could silently drive the simulator. The serial is now mapped to the Metro identity via `adb -s <serial> shell getprop ro.product.model` with model-prefix matching (scoped to non-simulator targets); an unmatchable or ambiguous pin (two same-model devices) fails fast listing every Metro `/json/list` candidate instead of silently picking one. `--device <serial>` remains the only thing users pass.
|
|
8
|
+
- **Bridge commands always emit valid JSON.** `get-route` printed the literal string `undefined` when the route was transiently unavailable mid-navigation, so `bridgeCommand()` threw on parse and `waitForRoute()` aborted instead of polling. `get-route`/`navigate`/`go-back` now normalise a missing route to `null`, `bridgeCommand()` treats `''`/`undefined` stdout as not-settled-yet only for transient-legitimate commands, and `waitForRoute()` polls through `null` until timeout — the timeout error carries the expected route, last parsed route, last bridge reply and the device pin.
|
|
9
|
+
- **Wallet setup env propagation.** `bridgeEnv()` became async with the serial mapping; the wallet setup action now awaits it instead of spreading a Promise, which would have handed `setup-wallet.sh` an almost-empty environment.
|
|
10
|
+
|
|
11
|
+
## 0.9.0 - 2026-07-07
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **`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.
|
|
15
|
+
- **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.
|
|
16
|
+
|
|
3
17
|
## 0.8.0 - 2026-07-07
|
|
4
18
|
|
|
5
19
|
### Added
|
|
@@ -104,7 +104,17 @@ const COMMANDS = {
|
|
|
104
104
|
client,
|
|
105
105
|
'globalThis.__AGENTIC__?.getRoute()',
|
|
106
106
|
);
|
|
107
|
-
|
|
107
|
+
// Normalize route values: cdpEval returns undefined when the optional-chain
|
|
108
|
+
// short-circuits (mid-navigation transient or bridge not yet installed).
|
|
109
|
+
// null is valid JSON; undefined produces the literal string "undefined".
|
|
110
|
+
return {
|
|
111
|
+
navigated: routeName,
|
|
112
|
+
params,
|
|
113
|
+
previousRoute: previousRoute ?? null,
|
|
114
|
+
currentRoute: currentRoute ?? null,
|
|
115
|
+
deviceName,
|
|
116
|
+
platform,
|
|
117
|
+
};
|
|
108
118
|
},
|
|
109
119
|
|
|
110
120
|
async 'get-route'(client) {
|
|
@@ -112,7 +122,10 @@ const COMMANDS = {
|
|
|
112
122
|
client,
|
|
113
123
|
'globalThis.__AGENTIC__?.getRoute()',
|
|
114
124
|
);
|
|
115
|
-
|
|
125
|
+
// cdpEval returns undefined when the optional-chain short-circuits (bridge not
|
|
126
|
+
// yet installed, mid-navigation, or route state transiently missing). Return null
|
|
127
|
+
// so JSON.stringify produces valid JSON ("null") instead of literal "undefined".
|
|
128
|
+
return route ?? null;
|
|
116
129
|
},
|
|
117
130
|
|
|
118
131
|
async 'get-state'(client, args) {
|
|
@@ -168,15 +181,18 @@ const COMMANDS = {
|
|
|
168
181
|
client,
|
|
169
182
|
'globalThis.__AGENTIC__?.getRoute()',
|
|
170
183
|
);
|
|
171
|
-
|
|
184
|
+
// Same normalization as navigate/get-route: a transiently-missing route must
|
|
185
|
+
// serialize as "currentRoute": null, not have the key silently omitted.
|
|
186
|
+
return { currentRoute: route ?? null, deviceName, platform };
|
|
172
187
|
},
|
|
173
188
|
|
|
174
189
|
async status(client, _args, { deviceName, platform } = {}) {
|
|
175
190
|
const expr = `(function() {
|
|
191
|
+
var agenticPresent = typeof globalThis.__AGENTIC__ !== 'undefined';
|
|
176
192
|
var route = globalThis.__AGENTIC__?.getRoute() || null;
|
|
177
193
|
var account = null;
|
|
178
194
|
try { account = globalThis.__AGENTIC__?.getSelectedAccount() || null; } catch(e) {}
|
|
179
|
-
return { route: route, account: account };
|
|
195
|
+
return { route: route, account: account, agenticPresent: agenticPresent };
|
|
180
196
|
})()`;
|
|
181
197
|
const snapshot = await cdpEval(client, expr);
|
|
182
198
|
return { ...snapshot, deviceName: deviceName || '', platform: platform || '' };
|
|
@@ -658,7 +674,9 @@ Environment:
|
|
|
658
674
|
const result = await handler(client, args.slice(1), { deviceName: target.deviceName, platform });
|
|
659
675
|
results.push(result);
|
|
660
676
|
} catch {
|
|
661
|
-
//
|
|
677
|
+
// Recovery is correct: status probes every target advisory; an
|
|
678
|
+
// unresponsive one degrades to absent-from-the-listing rather than
|
|
679
|
+
// failing the whole multi-target command.
|
|
662
680
|
} finally {
|
|
663
681
|
if (client) client.close();
|
|
664
682
|
}
|
|
@@ -30,10 +30,22 @@ function loadSimulatorName() {
|
|
|
30
30
|
return loadEnvValue('IOS_SIMULATOR') || '';
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** Read ANDROID_DEVICE
|
|
33
|
+
/** Read ANDROID_DEVICE from .js.env or env (default: none — accept any device) */
|
|
34
34
|
function loadAndroidDevice() {
|
|
35
35
|
if ('ANDROID_DEVICE' in process.env) return process.env.ANDROID_DEVICE;
|
|
36
36
|
return loadEnvValue('ANDROID_DEVICE') || '';
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Read ANDROID_TARGET_DEVICE_NAME from env — the Metro-compatible model prefix
|
|
41
|
+
* resolved from an adb serial by bridge.mjs. Used by target-discovery to select
|
|
42
|
+
* the correct Metro CDP target when multiple devices are connected.
|
|
43
|
+
*
|
|
44
|
+
* Set automatically by the bridge when --device <serial> is used. Users never
|
|
45
|
+
* need to set this; pass --device <adb-serial> and the harness maps it internally.
|
|
46
|
+
*/
|
|
47
|
+
function loadAndroidTargetDeviceName() {
|
|
48
|
+
return process.env.ANDROID_TARGET_DEVICE_NAME || '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { loadEnvValue, loadPort, loadSimulatorName, loadAndroidDevice, loadAndroidTargetDeviceName };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const http = require('node:http');
|
|
4
|
-
const { loadSimulatorName, loadAndroidDevice } = require('./config.cjs');
|
|
4
|
+
const { loadSimulatorName, loadAndroidDevice, loadAndroidTargetDeviceName } = require('./config.cjs');
|
|
5
5
|
const { createWSClient } = require('./ws-client.cjs');
|
|
6
6
|
|
|
7
7
|
const FETCH_TIMEOUT_MS = Number.parseInt(process.env.CDP_TIMEOUT || '30000', 10);
|
|
@@ -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
|
|
|
@@ -113,14 +125,76 @@ async function discoverTarget(port) {
|
|
|
113
125
|
}
|
|
114
126
|
}
|
|
115
127
|
|
|
116
|
-
//
|
|
128
|
+
// Android device filtering: support two identity layers.
|
|
129
|
+
// ANDROID_TARGET_DEVICE_NAME — Metro-compatible model prefix resolved by bridge.mjs
|
|
130
|
+
// from the adb serial via `adb -s <serial> shell getprop ro.product.model`.
|
|
131
|
+
// Example: "Pixel 6" matches Metro deviceName "Pixel 6 - 16 - API 36".
|
|
132
|
+
// ANDROID_DEVICE — exact Metro deviceName (backward compat / user-specified).
|
|
133
|
+
//
|
|
134
|
+
// When a device is pinned and multiple candidates remain, we MUST NOT silently fall
|
|
135
|
+
// back to another target (e.g. an iOS simulator). If the pin cannot be matched,
|
|
136
|
+
// fail fast with diagnostics so the operator can diagnose the mismatch.
|
|
137
|
+
const androidTargetName = loadAndroidTargetDeviceName();
|
|
117
138
|
const androidDevice = loadAndroidDevice();
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
139
|
+
const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
|
|
140
|
+
|
|
141
|
+
if (candidates.length > 1) {
|
|
142
|
+
let androidFiltered = [];
|
|
143
|
+
|
|
144
|
+
if (androidTargetName) {
|
|
145
|
+
// Model prefix match: "Pixel 6" matches "Pixel 6 - 16 - API 36". Metro's
|
|
146
|
+
// /json/list has no explicit platform field, so scope the ANDROID pin by
|
|
147
|
+
// excluding the one iOS identity we do know (the pinned simulator name) —
|
|
148
|
+
// a simulator named to shadow a model string must not satisfy an android pin.
|
|
149
|
+
androidFiltered = candidates.filter(
|
|
150
|
+
(t) => t.deviceName !== simName &&
|
|
151
|
+
(t.deviceName === androidTargetName ||
|
|
152
|
+
(t.deviceName != null && t.deviceName.startsWith(androidTargetName))),
|
|
153
|
+
);
|
|
154
|
+
if (androidFiltered.length > 1) {
|
|
155
|
+
// Two same-model devices produce identical Metro model prefixes — the pin
|
|
156
|
+
// is genuinely ambiguous; picking one silently is exactly the bug this
|
|
157
|
+
// path exists to prevent.
|
|
158
|
+
const ambiguousList = androidFiltered
|
|
159
|
+
.map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
|
|
160
|
+
.join('\n');
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Pinned Android device is ambiguous: model '${androidTargetName}' matches ${androidFiltered.length} Metro targets.\n` +
|
|
163
|
+
` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
|
|
164
|
+
` Matching Metro targets:\n${ambiguousList}\n` +
|
|
165
|
+
` Set ANDROID_DEVICE to the exact Metro deviceName to disambiguate.`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (androidFiltered.length === 0) {
|
|
169
|
+
// Pinned device could not be matched — never silently pick another target.
|
|
170
|
+
const candidateList = targets
|
|
171
|
+
.map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
|
|
172
|
+
.join('\n');
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Pinned Android device did not match any Metro target.\n` +
|
|
175
|
+
` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
|
|
176
|
+
` Resolved model (ANDROID_TARGET_DEVICE_NAME): ${androidTargetName}\n` +
|
|
177
|
+
` ANDROID_DEVICE: ${androidDevice || '(not set)'}\n` +
|
|
178
|
+
` Metro /json/list candidates:\n${candidateList}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
candidates = androidFiltered;
|
|
182
|
+
} else if (androidDevice) {
|
|
183
|
+
// Exact Metro deviceName match (user set ANDROID_DEVICE to the Metro name, or
|
|
184
|
+
// legacy path where ANDROID_DEVICE was not resolved from serial).
|
|
185
|
+
androidFiltered = candidates.filter((t) => t.deviceName === androidDevice);
|
|
186
|
+
if (androidFiltered.length === 0) {
|
|
187
|
+
// Pinned by exact Metro name but no candidate matched — fail fast.
|
|
188
|
+
const candidateList = targets
|
|
189
|
+
.map((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
|
|
190
|
+
.join('\n');
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Pinned Android device (ANDROID_DEVICE='${androidDevice}') did not match any Metro target.\n` +
|
|
193
|
+
` ADB_SERIAL: ${adbSerial || '(not set)'}\n` +
|
|
194
|
+
` Metro /json/list candidates:\n${candidateList}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
candidates = androidFiltered;
|
|
124
198
|
}
|
|
125
199
|
}
|
|
126
200
|
|
|
@@ -186,18 +260,32 @@ async function discoverAllTargets(port) {
|
|
|
186
260
|
return bPage - aPage;
|
|
187
261
|
});
|
|
188
262
|
|
|
189
|
-
// Group by deviceName, probe each to find the JS runtime target
|
|
190
|
-
|
|
191
|
-
|
|
263
|
+
// Group by deviceName, probe each to find the JS runtime target. Prefer the
|
|
264
|
+
// device's __AGENTIC__-bearing target; when a device has none, keep its first
|
|
265
|
+
// RESPONSIVE candidate (candidates are sorted JS-runtime-first) so a build
|
|
266
|
+
// that predates the bridge still surfaces — the status consumer reports it as
|
|
267
|
+
// bridge-absent instead of the device silently vanishing from discovery.
|
|
268
|
+
// Known ambiguity: when a device's JS runtime is attached-but-busy while its
|
|
269
|
+
// native C++ Hermes page answers, that page is also 'responsive' (no
|
|
270
|
+
// __AGENTIC__ in the native context), so the device reports bridge-absent even
|
|
271
|
+
// though the app build may carry the bridge. Low probability, and the degraded
|
|
272
|
+
// report is informative rather than harmful.
|
|
273
|
+
const agenticByDevice = new Map();
|
|
274
|
+
const responsiveByDevice = new Map();
|
|
192
275
|
for (const candidate of candidates) {
|
|
193
276
|
const device = candidate.deviceName || candidate.id || candidate.webSocketDebuggerUrl;
|
|
194
|
-
if (
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
277
|
+
if (agenticByDevice.has(device)) continue;
|
|
278
|
+
const state = await probeTargetDetailed(candidate.webSocketDebuggerUrl);
|
|
279
|
+
if (state === 'agentic') {
|
|
280
|
+
agenticByDevice.set(device, { wsUrl: candidate.webSocketDebuggerUrl, deviceName: device });
|
|
281
|
+
} else if (state === 'responsive' && !responsiveByDevice.has(device)) {
|
|
282
|
+
responsiveByDevice.set(device, { wsUrl: candidate.webSocketDebuggerUrl, deviceName: device });
|
|
199
283
|
}
|
|
200
284
|
}
|
|
285
|
+
const results = [...agenticByDevice.values()];
|
|
286
|
+
for (const [device, entry] of responsiveByDevice) {
|
|
287
|
+
if (!agenticByDevice.has(device)) results.push(entry);
|
|
288
|
+
}
|
|
201
289
|
return results;
|
|
202
290
|
}
|
|
203
291
|
|
|
@@ -79,6 +79,18 @@ default_metro_workers() {
|
|
|
79
79
|
|
|
80
80
|
# --- main ---------------------------------------------------------------------
|
|
81
81
|
|
|
82
|
+
# Detect stale metro.pid: if the pid file names a dead process, clean it up.
|
|
83
|
+
# A stale file survives when Metro is killed externally (session close, SIGKILL)
|
|
84
|
+
# and the normal stop path never ran. Cleaning it here keeps metro_ready and the
|
|
85
|
+
# log from attributing a new start to the old process id.
|
|
86
|
+
if [ -f "$PID_FILE" ]; then
|
|
87
|
+
stale_pid="$(cat "$PID_FILE" 2>/dev/null || true)"
|
|
88
|
+
if [ -n "$stale_pid" ] && ! kill -0 "$stale_pid" 2>/dev/null; then
|
|
89
|
+
printf 'start-metro: stale metro.pid (pid %s no longer alive); cleaning up\n' "$stale_pid" >&2
|
|
90
|
+
rm -f "$PID_FILE"
|
|
91
|
+
fi
|
|
92
|
+
fi
|
|
93
|
+
|
|
82
94
|
# Decide whether to (re)start or skip.
|
|
83
95
|
if metro_ready; then
|
|
84
96
|
if [ "$CLEAR" = true ]; then
|
|
@@ -97,11 +109,11 @@ fi
|
|
|
97
109
|
stop_metro_listener || exit 1
|
|
98
110
|
|
|
99
111
|
METRO_WORKERS="${METRO_MAX_WORKERS:-$(default_metro_workers)}"
|
|
100
|
-
|
|
101
|
-
[ "$CLEAR" = true ] &&
|
|
112
|
+
CLEAR_LABEL=""
|
|
113
|
+
[ "$CLEAR" = true ] && CLEAR_LABEL=", clear"
|
|
102
114
|
|
|
103
115
|
printf 'Starting Metro on port %s (workers=%s%s)\n' \
|
|
104
|
-
"$PORT" "$METRO_WORKERS" "$
|
|
116
|
+
"$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
|
|
105
117
|
printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FILE" >&2
|
|
106
118
|
|
|
107
119
|
# Metro runs detached, writing to the log. The tmux window is a read-only tail.
|
|
@@ -127,12 +139,17 @@ printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FIL
|
|
|
127
139
|
export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
|
|
128
140
|
export METRO_MAX_WORKERS="${METRO_WORKERS}"
|
|
129
141
|
# shellcheck disable=SC2094
|
|
130
|
-
|
|
142
|
+
# nohup sets SIGHUP to ignored (inherited across exec), so Metro survives the
|
|
143
|
+
# launching shell or tmux window closing; when this subshell exits the process
|
|
144
|
+
# is reparented to init. That alone is the detach guarantee — Metro was never
|
|
145
|
+
# in the parent shell's job table, so there is nothing to disown.
|
|
146
|
+
# nohup.out is suppressed (stdout/stderr both go to $LOG_FILE via >>).
|
|
147
|
+
metro_args=(expo start --port "$PORT")
|
|
148
|
+
[ "$CLEAR" = true ] && metro_args+=(--clear)
|
|
149
|
+
nohup yarn "${metro_args[@]}" \
|
|
131
150
|
</dev/null >> "$LOG_FILE" 2>&1 &
|
|
132
151
|
echo "$!" > "$PID_FILE"
|
|
133
152
|
)
|
|
134
|
-
# Disown the background process so it outlives this script.
|
|
135
|
-
disown "$(cat "$PID_FILE" 2>/dev/null || true)" 2>/dev/null || true
|
|
136
153
|
|
|
137
154
|
start_viewer_window "$LOG_FILE"
|
|
138
155
|
|
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"] },
|
|
@@ -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
|
+
};
|
package/dist/commands/status.js
CHANGED
|
@@ -4,11 +4,13 @@ import { assertAdapter } from "../paths.js";
|
|
|
4
4
|
import { getAdapterSurface } from "../adapters/surface.js";
|
|
5
5
|
import { listConnectedDevices } from "../devices.js";
|
|
6
6
|
import { deviceSelected, renderDeviceList } from "./device-target.js";
|
|
7
|
+
import { probeMobileLiveState } from "./status-probe.js";
|
|
7
8
|
import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
|
|
8
9
|
import { optionFlag, targetPath } from "./parse-args.js";
|
|
9
10
|
async function handleStatus({ options }) {
|
|
10
11
|
const target = targetPath(options);
|
|
11
12
|
const json = optionFlag(options, "json");
|
|
13
|
+
const fast = optionFlag(options, "fast");
|
|
12
14
|
const adapter = detectAdapter(target);
|
|
13
15
|
if (!adapter) {
|
|
14
16
|
return usageOut(json, "status", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
|
|
@@ -17,15 +19,70 @@ async function handleStatus({ options }) {
|
|
|
17
19
|
const devices = adapter === "mobile" ? listConnectedDevices().map((device) => ({ ...device, selected: deviceSelected(device) })) : [];
|
|
18
20
|
const next = getAdapterSurface(adapter).hints.relaunch;
|
|
19
21
|
if (json) {
|
|
20
|
-
|
|
22
|
+
const doProbe = !fast && adapter === "mobile" && devices.length > 0;
|
|
23
|
+
if (doProbe) {
|
|
24
|
+
const liveMap = await probeMobileLiveState(target, devices);
|
|
25
|
+
const devicesWithLive = devices.map((d) => mergeDeviceLive(d, liveMap.get(d.id)));
|
|
26
|
+
console.log(
|
|
27
|
+
JSON.stringify(
|
|
28
|
+
{ schemaVersion: 1, command: "status", adapter, target, devices: devicesWithLive, next },
|
|
29
|
+
null,
|
|
30
|
+
2
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
} else {
|
|
34
|
+
console.log(
|
|
35
|
+
JSON.stringify({ schemaVersion: 1, command: "status", adapter, target, devices, next }, null, 2)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
21
38
|
return EXIT.ok;
|
|
22
39
|
}
|
|
23
40
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
24
41
|
console.log(`${out("label", "status")} ${out("bold", adapter)} ${out("dim", target)}`);
|
|
25
42
|
if (adapter === "mobile") renderDeviceList(devices, out);
|
|
26
43
|
console.log(`${out("label", "Next:")} ${out("cmd", next)}`);
|
|
44
|
+
if (!fast && adapter === "mobile" && devices.length > 0) {
|
|
45
|
+
const liveMap = await probeMobileLiveState(target, devices);
|
|
46
|
+
renderLiveBlock(devices, liveMap, out);
|
|
47
|
+
}
|
|
27
48
|
return EXIT.ok;
|
|
28
49
|
}
|
|
50
|
+
function mergeDeviceLive(device, live) {
|
|
51
|
+
if (!live) return device;
|
|
52
|
+
return {
|
|
53
|
+
...device,
|
|
54
|
+
fixtureStatus: live.fixtureStatus,
|
|
55
|
+
...live.liveState !== void 0 ? { liveState: live.liveState } : {},
|
|
56
|
+
...live.currentScreen !== void 0 ? { currentScreen: live.currentScreen } : {},
|
|
57
|
+
...live.walletState !== void 0 ? { walletState: live.walletState } : {},
|
|
58
|
+
...live.selectedAccount !== void 0 ? { selectedAccount: live.selectedAccount } : {}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function renderLiveBlock(devices, liveMap, out) {
|
|
62
|
+
console.log(`${out("label", "live:")}`);
|
|
63
|
+
for (const device of devices) {
|
|
64
|
+
const live = liveMap.get(device.id);
|
|
65
|
+
const prefix = ` ${device.platform} ${out("dim", device.id)}${device.name ? ` (${device.name})` : ""}`;
|
|
66
|
+
if (!live || live.liveState === "no-bridge") {
|
|
67
|
+
console.log(`${prefix}: ${out("dim", "(no-bridge)")}`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (live.liveState === "bridge-absent") {
|
|
71
|
+
console.log(`${prefix}: ${out("warn", "(bridge-absent \u2014 app attached, build lacks __AGENTIC__; rebuild/reinstall a dev build)")}`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const parts = [];
|
|
75
|
+
if (live.currentScreen !== void 0) parts.push(`screen=${out("cmd", live.currentScreen)}`);
|
|
76
|
+
if (live.walletState !== void 0) parts.push(`wallet=${out(live.walletState === "unlocked" ? "ok" : "warn", live.walletState)}`);
|
|
77
|
+
if (live.selectedAccount !== void 0) {
|
|
78
|
+
parts.push(`account=${live.selectedAccount.label} ${out("dim", `(${live.selectedAccount.address})`)}`);
|
|
79
|
+
}
|
|
80
|
+
if (live.fixtureStatus !== void 0) {
|
|
81
|
+
parts.push(`fixture=${out(live.fixtureStatus === "READY" ? "ok" : "warn", live.fixtureStatus)}`);
|
|
82
|
+
}
|
|
83
|
+
console.log(`${prefix}: ${parts.join(" ")}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
29
86
|
export {
|
|
30
87
|
handleStatus
|
|
31
88
|
};
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -18,18 +18,31 @@ const REAL = [
|
|
|
18
18
|
helpText: `mm-harness status [flags]
|
|
19
19
|
|
|
20
20
|
Home dashboard for the current checkout \u2014 the detected adapter, the next command
|
|
21
|
-
to run, and (mobile only) the connected devices with
|
|
22
|
-
target (matches ADB_SERIAL / IOS_SIMULATOR). Aliases: health, home.
|
|
21
|
+
to run, and (mobile only) the connected devices with live app state. Aliases: health, home.
|
|
23
22
|
|
|
24
23
|
--target <path> Checkout path (default: cwd)
|
|
25
24
|
--json Machine-readable envelope { adapter, target, devices[], next }
|
|
25
|
+
--fast Skip all live-state probes (instant output, safe for scripts)
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
Human output: static device list prints immediately; live-state lines append
|
|
28
|
+
after the ~2s probe window (screen, wallet, account, fixture per device).
|
|
29
|
+
|
|
30
|
+
The devices[] section carries per device:
|
|
31
|
+
{ platform, id, name, state, selected } always present
|
|
32
|
+
{ currentScreen, walletState, selectedAccount, fixtureStatus } when bridge reachable
|
|
33
|
+
{ liveState: 'no-bridge' } when bridge unreachable or --fast
|
|
34
|
+
|
|
35
|
+
All RN targets attached to the checkout's Metro are probed and matched to their
|
|
36
|
+
devices; a device whose target cannot be confidently matched (Metro's device
|
|
37
|
+
description matches neither the serial nor the model name) degrades to
|
|
38
|
+
liveState:'no-bridge'. An attached target whose build predates __AGENTIC__
|
|
39
|
+
reports liveState:'bridge-absent'.
|
|
40
|
+
Extension/core live-state probing is V1-pending (use doctor for runtime status).
|
|
29
41
|
|
|
30
42
|
Example:
|
|
31
43
|
mm-harness status
|
|
32
|
-
mm-harness status --json
|
|
44
|
+
mm-harness status --json
|
|
45
|
+
mm-harness status --fast`
|
|
33
46
|
},
|
|
34
47
|
{
|
|
35
48
|
name: "actions",
|