@zhihand/mcp 0.29.0 → 0.32.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/bin/zhihand +448 -212
- package/dist/core/command.d.ts +5 -5
- package/dist/core/command.js +6 -8
- package/dist/core/config.d.ts +48 -21
- package/dist/core/config.js +178 -42
- package/dist/core/device.d.ts +28 -19
- package/dist/core/device.js +168 -145
- package/dist/core/logger.d.ts +17 -0
- package/dist/core/logger.js +32 -0
- package/dist/core/pair.d.ts +39 -31
- package/dist/core/pair.js +205 -77
- package/dist/core/registry.d.ts +60 -0
- package/dist/core/registry.js +415 -0
- package/dist/core/screenshot.d.ts +3 -3
- package/dist/core/screenshot.js +3 -2
- package/dist/core/sse.d.ts +40 -18
- package/dist/core/sse.js +122 -62
- package/dist/core/ws.d.ts +92 -0
- package/dist/core/ws.js +327 -0
- package/dist/daemon/dispatcher.d.ts +3 -1
- package/dist/daemon/dispatcher.js +4 -3
- package/dist/daemon/heartbeat.d.ts +4 -4
- package/dist/daemon/heartbeat.js +1 -1
- package/dist/daemon/index.js +10 -8
- package/dist/daemon/prompt-listener.d.ts +8 -7
- package/dist/daemon/prompt-listener.js +59 -99
- package/dist/index.d.ts +3 -3
- package/dist/index.js +104 -40
- package/dist/openclaw.adapter.js +10 -2
- package/dist/tools/control.d.ts +10 -3
- package/dist/tools/control.js +18 -24
- package/dist/tools/pair.d.ts +1 -1
- package/dist/tools/pair.js +22 -28
- package/dist/tools/resolve.d.ts +7 -0
- package/dist/tools/resolve.js +22 -0
- package/dist/tools/schemas.d.ts +9 -1
- package/dist/tools/schemas.js +10 -8
- package/dist/tools/screenshot.d.ts +3 -2
- package/dist/tools/screenshot.js +2 -2
- package/dist/tools/system.d.ts +3 -5
- package/dist/tools/system.js +19 -6
- package/package.json +3 -1
package/bin/zhihand
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
import fs from "node:fs";
|
|
4
5
|
import { parseArgs } from "node:util";
|
|
5
6
|
import { startStdioServer } from "../dist/index.js";
|
|
6
7
|
import { startDaemon, stopDaemon, isAlreadyRunning } from "../dist/daemon/index.js";
|
|
7
8
|
import { detectCLITools, formatDetectedTools } from "../dist/cli/detect.js";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import {
|
|
10
|
+
loadConfig,
|
|
11
|
+
listUsers,
|
|
12
|
+
getUserRecord,
|
|
13
|
+
removeUser,
|
|
14
|
+
removeDeviceFromUser,
|
|
15
|
+
findDeviceOwner,
|
|
16
|
+
updateDeviceLabel,
|
|
17
|
+
updateControllerToken,
|
|
18
|
+
loadBackendConfig,
|
|
19
|
+
saveBackendConfig,
|
|
20
|
+
addUser,
|
|
21
|
+
DEFAULT_MODELS,
|
|
22
|
+
resolveConfig,
|
|
23
|
+
resolveDefaultEndpoint,
|
|
24
|
+
} from "../dist/core/config.js";
|
|
25
|
+
import {
|
|
26
|
+
executePairingNewUser,
|
|
27
|
+
executePairingAddDevice,
|
|
28
|
+
} from "../dist/core/pair.js";
|
|
29
|
+
import { fetchUserCredentials } from "../dist/core/ws.js";
|
|
11
30
|
import { configureMCP, displayName } from "../dist/cli/mcp-config.js";
|
|
12
31
|
|
|
13
32
|
const DEFAULT_ENDPOINT = "https://api.zhihand.com";
|
|
33
|
+
const VERSION = "0.32.0";
|
|
14
34
|
|
|
15
35
|
const CLI_TOOL_MAP = {
|
|
16
36
|
claude: "claudecode",
|
|
@@ -23,8 +43,10 @@ const { positionals, values } = parseArgs({
|
|
|
23
43
|
strict: false,
|
|
24
44
|
options: {
|
|
25
45
|
device: { type: "string" },
|
|
46
|
+
label: { type: "string" },
|
|
26
47
|
model: { type: "string", short: "m" },
|
|
27
48
|
help: { type: "boolean", short: "h", default: false },
|
|
49
|
+
version: { type: "boolean", short: "v", default: false },
|
|
28
50
|
detach: { type: "boolean", short: "d", default: false },
|
|
29
51
|
debug: { type: "boolean", default: false },
|
|
30
52
|
force: { type: "boolean", default: false },
|
|
@@ -32,42 +54,50 @@ const { positionals, values } = parseArgs({
|
|
|
32
54
|
},
|
|
33
55
|
});
|
|
34
56
|
|
|
35
|
-
const command = positionals[0] ?? "
|
|
57
|
+
const command = positionals[0] ?? "mcp";
|
|
58
|
+
|
|
59
|
+
if (values.version) {
|
|
60
|
+
console.log(VERSION);
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
36
63
|
|
|
37
64
|
if (values.help) {
|
|
38
65
|
console.log(`
|
|
39
|
-
zhihand — MCP Server and Relay for phone control
|
|
66
|
+
zhihand v${VERSION} — MCP Server and Relay for phone control
|
|
40
67
|
|
|
41
68
|
Usage:
|
|
42
69
|
zhihand start Start daemon (MCP Server + Relay, foreground)
|
|
43
70
|
zhihand start -d Start daemon in background (detach)
|
|
44
|
-
zhihand start --debug Start daemon with verbose debug logging
|
|
45
71
|
zhihand stop Stop daemon
|
|
46
72
|
zhihand status Show status (pairing, backend, brain)
|
|
47
73
|
|
|
48
|
-
zhihand gemini Switch backend to Gemini CLI
|
|
49
|
-
zhihand claude Switch backend to Claude Code
|
|
50
|
-
zhihand codex Switch backend to Codex CLI
|
|
51
|
-
zhihand gemini --model pro Switch backend with custom model
|
|
74
|
+
zhihand gemini Switch backend to Gemini CLI
|
|
75
|
+
zhihand claude Switch backend to Claude Code
|
|
76
|
+
zhihand codex Switch backend to Codex CLI
|
|
52
77
|
|
|
53
78
|
zhihand setup Interactive setup: pair + configure + start
|
|
54
|
-
zhihand pair
|
|
79
|
+
zhihand pair [--label X] Pair new user + first device + auto-configure MCP
|
|
80
|
+
zhihand pair <user_id> Add device to existing user
|
|
81
|
+
zhihand list [<user_id>] List users/devices with real-time online status
|
|
82
|
+
zhihand unpair <id> Remove user (usr_*) or device (credential)
|
|
83
|
+
zhihand rename <cred> <n> Rename a device (server-side + local)
|
|
84
|
+
zhihand export <user_id> Export user credentials as JSON to stdout
|
|
85
|
+
zhihand import <file> Import user credentials from JSON file
|
|
86
|
+
zhihand rotate <user_id> Rotate controller token
|
|
55
87
|
zhihand detect Detect available CLI tools
|
|
56
88
|
|
|
57
|
-
zhihand
|
|
58
|
-
zhihand
|
|
59
|
-
zhihand test <ids> Run specific test(s), e.g. 'zhihand test 4' or '4,9,20'
|
|
60
|
-
zhihand test all Run ALL tests (including unsafe, e.g. power button)
|
|
61
|
-
zhihand test --force Bypass capability gates (run anyway even if NOT ready)
|
|
62
|
-
zhihand serve Start MCP Server (stdio mode, backward compat)
|
|
89
|
+
zhihand test [cred] [ids] Run device tests (all, specific ids, or for a credential)
|
|
90
|
+
zhihand mcp Start stdio MCP server (for Claude/Codex/Gemini hosts)
|
|
63
91
|
|
|
64
92
|
Options:
|
|
65
|
-
--device <name> Use a specific paired
|
|
66
|
-
--
|
|
93
|
+
--device <name> Use a specific paired credential_id (test only)
|
|
94
|
+
--label <label> Label for new device (pair only)
|
|
95
|
+
--model, -m <name> Backend model alias
|
|
67
96
|
--port <port> Override daemon port (default: 18686)
|
|
68
97
|
-d, --detach Run daemon in background
|
|
69
|
-
--debug
|
|
98
|
+
--debug Verbose debug logging
|
|
70
99
|
--force (test only) Run tests even if capability not ready
|
|
100
|
+
-v, --version Print version
|
|
71
101
|
-h, --help Show this help
|
|
72
102
|
`);
|
|
73
103
|
process.exit(0);
|
|
@@ -90,7 +120,6 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
90
120
|
console.error(`Warning: ${command} is installed but not logged in.`);
|
|
91
121
|
}
|
|
92
122
|
|
|
93
|
-
// Check if daemon is running — if so, notify it via HTTP
|
|
94
123
|
const daemonPid = isAlreadyRunning();
|
|
95
124
|
const config = loadBackendConfig();
|
|
96
125
|
const previous = config.activeBackend;
|
|
@@ -105,11 +134,9 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
105
134
|
|
|
106
135
|
console.log(`Switching backend to ${displayName(backendName)} (model: ${effectiveModel})...`);
|
|
107
136
|
|
|
108
|
-
// Configure MCP (HTTP transport)
|
|
109
137
|
const { configured, removed } = configureMCP(backendName, previous);
|
|
110
138
|
|
|
111
139
|
if (configured) {
|
|
112
|
-
// Notify daemon if running
|
|
113
140
|
if (daemonPid) {
|
|
114
141
|
try {
|
|
115
142
|
const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
|
|
@@ -123,7 +150,6 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
123
150
|
console.log(`\nDaemon notified. Backend switched to ${displayName(backendName)}.`);
|
|
124
151
|
}
|
|
125
152
|
} catch {
|
|
126
|
-
// Daemon not responding, just save config
|
|
127
153
|
saveBackendConfig({ activeBackend: backendName, model: userModel });
|
|
128
154
|
console.log(`\nBackend config saved. Daemon not responding — restart with 'zhihand start'.`);
|
|
129
155
|
}
|
|
@@ -153,10 +179,8 @@ switch (command) {
|
|
|
153
179
|
|
|
154
180
|
const args = [process.argv[1], "start"];
|
|
155
181
|
if (values.port) args.push("--port", values.port);
|
|
156
|
-
if (values.device) args.push("--device", values.device);
|
|
157
182
|
if (values.debug) args.push("--debug");
|
|
158
183
|
|
|
159
|
-
// Write daemon logs to ~/.zhihand/daemon.log
|
|
160
184
|
const zhihandDir = pathMod.default.join(osMod.default.homedir(), ".zhihand");
|
|
161
185
|
fsSync.default.mkdirSync(zhihandDir, { recursive: true });
|
|
162
186
|
const logPath = pathMod.default.join(zhihandDir, "daemon.log");
|
|
@@ -174,48 +198,327 @@ switch (command) {
|
|
|
174
198
|
process.exit(0);
|
|
175
199
|
}
|
|
176
200
|
const port = values.port ? parseInt(values.port, 10) : undefined;
|
|
177
|
-
await startDaemon({
|
|
178
|
-
port,
|
|
179
|
-
deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
|
|
180
|
-
debug: values.debug,
|
|
181
|
-
});
|
|
201
|
+
await startDaemon({ port, debug: values.debug });
|
|
182
202
|
break;
|
|
183
203
|
}
|
|
184
204
|
|
|
185
205
|
case "stop": {
|
|
186
206
|
const stopped = stopDaemon();
|
|
187
|
-
|
|
188
|
-
console.log("Daemon stopped.");
|
|
189
|
-
} else {
|
|
190
|
-
console.log("No daemon is running.");
|
|
191
|
-
}
|
|
207
|
+
console.log(stopped ? "Daemon stopped." : "No daemon is running.");
|
|
192
208
|
break;
|
|
193
209
|
}
|
|
194
210
|
|
|
211
|
+
case "mcp":
|
|
195
212
|
case "serve": {
|
|
196
|
-
|
|
197
|
-
await startStdioServer(values.device ?? process.env.ZHIHAND_DEVICE);
|
|
213
|
+
await startStdioServer();
|
|
198
214
|
break;
|
|
199
215
|
}
|
|
200
216
|
|
|
201
217
|
case "pair": {
|
|
202
|
-
const
|
|
203
|
-
const
|
|
204
|
-
|
|
218
|
+
const arg1 = positionals[1];
|
|
219
|
+
const label = values.label ?? undefined;
|
|
220
|
+
|
|
221
|
+
if (arg1 && arg1.startsWith("usr_")) {
|
|
222
|
+
// Add device to existing user
|
|
223
|
+
const deviceRecord = await executePairingAddDevice(arg1, label);
|
|
224
|
+
console.log(`\nDevice paired: ${deviceRecord.label} (${deviceRecord.credential_id})`);
|
|
225
|
+
} else {
|
|
226
|
+
// New user + first device
|
|
227
|
+
const { userRecord, deviceRecord } = await executePairingNewUser(label);
|
|
228
|
+
console.log(`\nUser created: ${userRecord.label} (${userRecord.user_id})`);
|
|
229
|
+
console.log(`Device paired: ${deviceRecord.label} (${deviceRecord.credential_id})`);
|
|
230
|
+
console.log(`\nAdd another device: zhihand pair ${userRecord.user_id}`);
|
|
231
|
+
|
|
232
|
+
// Auto-configure MCP hosts
|
|
233
|
+
const tools = await detectCLITools();
|
|
234
|
+
if (tools.length > 0) {
|
|
235
|
+
const best = tools.find((t) => t.loggedIn) ?? tools[0];
|
|
236
|
+
console.log(`\nAuto-configuring MCP for ${displayName(best.name)}...`);
|
|
237
|
+
const backendCfg = loadBackendConfig();
|
|
238
|
+
configureMCP(best.name, backendCfg.activeBackend);
|
|
239
|
+
saveBackendConfig({ activeBackend: best.name });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
case "list": {
|
|
246
|
+
const userIdFilter = positionals[1];
|
|
247
|
+
const users = listUsers();
|
|
248
|
+
const endpoint = resolveDefaultEndpoint();
|
|
249
|
+
|
|
250
|
+
if (users.length === 0) {
|
|
251
|
+
console.log("No users configured. Run: zhihand pair");
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const filteredUsers = userIdFilter
|
|
256
|
+
? users.filter((u) => u.user_id === userIdFilter)
|
|
257
|
+
: users;
|
|
258
|
+
|
|
259
|
+
if (filteredUsers.length === 0) {
|
|
260
|
+
console.error(`User '${userIdFilter}' not found.`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const user of filteredUsers) {
|
|
265
|
+
console.log(`\nUSER: ${user.label} (${user.user_id})`);
|
|
266
|
+
|
|
267
|
+
// Fetch real-time online status from server
|
|
268
|
+
let onlineMap = new Map();
|
|
269
|
+
try {
|
|
270
|
+
const creds = await fetchUserCredentials(endpoint, user.user_id, user.controller_token);
|
|
271
|
+
for (const c of creds) {
|
|
272
|
+
onlineMap.set(c.credential_id, c.online ?? false);
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// Fallback: no online status
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (user.devices.length === 0) {
|
|
279
|
+
console.log(" (no devices)");
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const header = ["DEVICE_ID", "LABEL", "PLATFORM", "ONLINE", "PAIRED"];
|
|
284
|
+
const rows = user.devices.map((d) => [
|
|
285
|
+
d.credential_id,
|
|
286
|
+
d.label,
|
|
287
|
+
d.platform,
|
|
288
|
+
onlineMap.has(d.credential_id)
|
|
289
|
+
? (onlineMap.get(d.credential_id) ? "yes" : "no")
|
|
290
|
+
: "?",
|
|
291
|
+
d.paired_at,
|
|
292
|
+
]);
|
|
293
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
|
|
294
|
+
const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(" ");
|
|
295
|
+
console.log(" " + fmt(header));
|
|
296
|
+
console.log(" " + widths.map((w) => "-".repeat(w)).join(" "));
|
|
297
|
+
for (const r of rows) console.log(" " + fmt(r));
|
|
298
|
+
}
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
case "unpair": {
|
|
303
|
+
const id = positionals[1];
|
|
304
|
+
if (!id) {
|
|
305
|
+
console.error("Usage: zhihand unpair <user_id | credential_id>");
|
|
306
|
+
process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const endpoint = resolveDefaultEndpoint();
|
|
310
|
+
|
|
311
|
+
if (id.startsWith("usr_")) {
|
|
312
|
+
// Delete user (cascade)
|
|
313
|
+
const user = getUserRecord(id);
|
|
314
|
+
if (!user) {
|
|
315
|
+
console.error(`User '${id}' not found.`);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
// Best-effort server-side delete
|
|
319
|
+
try {
|
|
320
|
+
const res = await fetch(`${endpoint}/v1/users/${encodeURIComponent(id)}`, {
|
|
321
|
+
method: "DELETE",
|
|
322
|
+
headers: { "Authorization": `Bearer ${user.controller_token}` },
|
|
323
|
+
signal: AbortSignal.timeout(5000),
|
|
324
|
+
});
|
|
325
|
+
if (!res.ok) {
|
|
326
|
+
console.warn(`Warning: server delete returned ${res.status} (continuing to remove locally)`);
|
|
327
|
+
}
|
|
328
|
+
} catch (err) {
|
|
329
|
+
console.warn(`Warning: server delete failed: ${err.message} (continuing to remove locally)`);
|
|
330
|
+
}
|
|
331
|
+
removeUser(id);
|
|
332
|
+
console.log(`Removed user: ${id} (${user.label})`);
|
|
333
|
+
} else {
|
|
334
|
+
// Delete single credential
|
|
335
|
+
const owner = findDeviceOwner(id);
|
|
336
|
+
if (!owner) {
|
|
337
|
+
console.error(`Device '${id}' not found.`);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
// Best-effort server-side delete
|
|
341
|
+
try {
|
|
342
|
+
const res = await fetch(`${endpoint}/v1/credentials/${encodeURIComponent(id)}`, {
|
|
343
|
+
method: "DELETE",
|
|
344
|
+
headers: { "Authorization": `Bearer ${owner.user.controller_token}` },
|
|
345
|
+
signal: AbortSignal.timeout(5000),
|
|
346
|
+
});
|
|
347
|
+
if (!res.ok) {
|
|
348
|
+
console.warn(`Warning: server delete returned ${res.status} (continuing to remove locally)`);
|
|
349
|
+
}
|
|
350
|
+
} catch (err) {
|
|
351
|
+
console.warn(`Warning: server delete failed: ${err.message} (continuing to remove locally)`);
|
|
352
|
+
}
|
|
353
|
+
removeDeviceFromUser(owner.user.user_id, id);
|
|
354
|
+
console.log(`Removed device: ${id}`);
|
|
355
|
+
}
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
case "rename": {
|
|
360
|
+
const credId = positionals[1];
|
|
361
|
+
const newLabel = positionals[2];
|
|
362
|
+
if (!credId || !newLabel) {
|
|
363
|
+
console.error("Usage: zhihand rename <credential_id> <new_label>");
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
const owner = findDeviceOwner(credId);
|
|
367
|
+
if (!owner) {
|
|
368
|
+
console.error(`Device '${credId}' not found.`);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
// Server-side PATCH
|
|
372
|
+
const endpoint = resolveDefaultEndpoint();
|
|
373
|
+
try {
|
|
374
|
+
const res = await fetch(`${endpoint}/v1/credentials/${encodeURIComponent(credId)}`, {
|
|
375
|
+
method: "PATCH",
|
|
376
|
+
headers: {
|
|
377
|
+
"Content-Type": "application/json",
|
|
378
|
+
"Authorization": `Bearer ${owner.user.controller_token}`,
|
|
379
|
+
},
|
|
380
|
+
body: JSON.stringify({ device_label: newLabel }),
|
|
381
|
+
signal: AbortSignal.timeout(5000),
|
|
382
|
+
});
|
|
383
|
+
if (!res.ok) {
|
|
384
|
+
console.warn(`Warning: server rename returned ${res.status}`);
|
|
385
|
+
}
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.warn(`Warning: server rename failed: ${err.message}`);
|
|
388
|
+
}
|
|
389
|
+
updateDeviceLabel(owner.user.user_id, credId, newLabel);
|
|
390
|
+
console.log(`Renamed ${credId} to '${newLabel}'`);
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
case "export": {
|
|
395
|
+
const userId = positionals[1];
|
|
396
|
+
if (!userId) {
|
|
397
|
+
console.error("Usage: zhihand export <user_id>");
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
const user = getUserRecord(userId);
|
|
401
|
+
if (!user) {
|
|
402
|
+
console.error(`User '${userId}' not found.`);
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
// Plain JSON to stdout
|
|
406
|
+
console.log(JSON.stringify({ user_id: user.user_id, controller_token: user.controller_token }, null, 2));
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
case "import": {
|
|
411
|
+
const filePath = positionals[1];
|
|
412
|
+
if (!filePath) {
|
|
413
|
+
console.error("Usage: zhihand import <file>");
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
let data;
|
|
417
|
+
try {
|
|
418
|
+
data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
419
|
+
} catch (err) {
|
|
420
|
+
console.error(`Error reading file: ${err.message}`);
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
423
|
+
if (!data.user_id || !data.controller_token) {
|
|
424
|
+
console.error("Invalid import file: must contain user_id and controller_token");
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
// Validate by fetching user info from server
|
|
428
|
+
const endpoint = resolveDefaultEndpoint();
|
|
429
|
+
let serverUser;
|
|
430
|
+
try {
|
|
431
|
+
const res = await fetch(`${endpoint}/v1/users/${encodeURIComponent(data.user_id)}`, {
|
|
432
|
+
headers: { "Authorization": `Bearer ${data.controller_token}` },
|
|
433
|
+
signal: AbortSignal.timeout(10000),
|
|
434
|
+
});
|
|
435
|
+
if (!res.ok) {
|
|
436
|
+
console.error(`Server validation failed: ${res.status}`);
|
|
437
|
+
process.exit(1);
|
|
438
|
+
}
|
|
439
|
+
serverUser = await res.json();
|
|
440
|
+
} catch (err) {
|
|
441
|
+
console.error(`Server validation failed: ${err.message}`);
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
// Fetch credentials
|
|
445
|
+
let creds = [];
|
|
446
|
+
try {
|
|
447
|
+
creds = await fetchUserCredentials(endpoint, data.user_id, data.controller_token);
|
|
448
|
+
} catch {
|
|
449
|
+
// Non-fatal
|
|
450
|
+
}
|
|
451
|
+
const devices = creds.map((c) => ({
|
|
452
|
+
credential_id: c.credential_id,
|
|
453
|
+
label: c.label ?? c.credential_id,
|
|
454
|
+
platform: c.platform ?? "unknown",
|
|
455
|
+
paired_at: c.paired_at ?? new Date().toISOString(),
|
|
456
|
+
last_seen_at: c.last_seen_at ?? new Date().toISOString(),
|
|
457
|
+
}));
|
|
458
|
+
const userRecord = {
|
|
459
|
+
user_id: data.user_id,
|
|
460
|
+
controller_token: data.controller_token,
|
|
461
|
+
label: serverUser.label ?? data.user_id,
|
|
462
|
+
created_at: serverUser.created_at ?? new Date().toISOString(),
|
|
463
|
+
devices,
|
|
464
|
+
};
|
|
465
|
+
addUser(userRecord);
|
|
466
|
+
console.log(`Imported user: ${userRecord.label} (${userRecord.user_id}) with ${devices.length} device(s)`);
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
case "rotate": {
|
|
471
|
+
const userId = positionals[1];
|
|
472
|
+
if (!userId) {
|
|
473
|
+
console.error("Usage: zhihand rotate <user_id>");
|
|
474
|
+
process.exit(1);
|
|
475
|
+
}
|
|
476
|
+
const user = getUserRecord(userId);
|
|
477
|
+
if (!user) {
|
|
478
|
+
console.error(`User '${userId}' not found.`);
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
const endpoint = resolveDefaultEndpoint();
|
|
482
|
+
// Find current token ID (we need to pass it in the URL)
|
|
483
|
+
// The API is POST /v1/users/{id}/controller-tokens/{token}/rotate
|
|
484
|
+
try {
|
|
485
|
+
const res = await fetch(
|
|
486
|
+
`${endpoint}/v1/users/${encodeURIComponent(userId)}/controller-tokens/${encodeURIComponent(user.controller_token)}/rotate`,
|
|
487
|
+
{
|
|
488
|
+
method: "POST",
|
|
489
|
+
headers: { "Authorization": `Bearer ${user.controller_token}` },
|
|
490
|
+
signal: AbortSignal.timeout(10000),
|
|
491
|
+
},
|
|
492
|
+
);
|
|
493
|
+
if (!res.ok) {
|
|
494
|
+
console.error(`Rotate failed: ${res.status}`);
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
const result = await res.json();
|
|
498
|
+
updateControllerToken(userId, result.new_token);
|
|
499
|
+
console.log(`Token rotated for ${userId}. New token saved.`);
|
|
500
|
+
} catch (err) {
|
|
501
|
+
console.error(`Rotate failed: ${err.message}`);
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
205
504
|
break;
|
|
206
505
|
}
|
|
207
506
|
|
|
208
507
|
case "status": {
|
|
209
|
-
const
|
|
508
|
+
const users = listUsers();
|
|
210
509
|
const backend = loadBackendConfig();
|
|
211
510
|
const daemonPid = isAlreadyRunning();
|
|
212
511
|
|
|
213
|
-
if (
|
|
214
|
-
console.log(
|
|
215
|
-
console.log(`Credential ID: ${cred.credentialId}`);
|
|
216
|
-
console.log(`Endpoint: ${cred.endpoint}`);
|
|
512
|
+
if (users.length === 0) {
|
|
513
|
+
console.log("No users configured. Run: zhihand setup");
|
|
217
514
|
} else {
|
|
218
|
-
console.log(
|
|
515
|
+
console.log(`Users: ${users.length}`);
|
|
516
|
+
for (const u of users) {
|
|
517
|
+
console.log(` ${u.user_id} (${u.label}) — ${u.devices.length} device(s)`);
|
|
518
|
+
for (const d of u.devices) {
|
|
519
|
+
console.log(` ${d.credential_id} (${d.label}, ${d.platform})`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
219
522
|
}
|
|
220
523
|
const backendLabel = backend.activeBackend ? displayName(backend.activeBackend) : "(none)";
|
|
221
524
|
const modelLabel = backend.activeBackend
|
|
@@ -224,7 +527,6 @@ switch (command) {
|
|
|
224
527
|
console.log(`Active backend: ${backendLabel} (model: ${modelLabel})`);
|
|
225
528
|
console.log(`Daemon: ${daemonPid ? `running (PID ${daemonPid})` : "not running"}`);
|
|
226
529
|
|
|
227
|
-
// If daemon running, get live status
|
|
228
530
|
if (daemonPid) {
|
|
229
531
|
try {
|
|
230
532
|
const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
|
|
@@ -248,20 +550,13 @@ switch (command) {
|
|
|
248
550
|
}
|
|
249
551
|
|
|
250
552
|
case "setup": {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const deviceName = values.device ?? `mcp-${os.hostname()}`;
|
|
257
|
-
await executePairing(DEFAULT_ENDPOINT, edgeId, deviceName);
|
|
258
|
-
cred = loadDefaultCredential();
|
|
259
|
-
}
|
|
260
|
-
if (cred) {
|
|
261
|
-
console.log(`\nPaired: ${cred.deviceName} (${cred.credentialId})\n`);
|
|
553
|
+
const users = listUsers();
|
|
554
|
+
if (users.length === 0) {
|
|
555
|
+
console.log("No users found. Starting pairing...\n");
|
|
556
|
+
const { userRecord } = await executePairingNewUser(values.label);
|
|
557
|
+
console.log(`\nUser created: ${userRecord.label} (${userRecord.user_id})\n`);
|
|
262
558
|
}
|
|
263
559
|
|
|
264
|
-
// 2. Detect tools
|
|
265
560
|
const tools = await detectCLITools();
|
|
266
561
|
console.log(formatDetectedTools(tools));
|
|
267
562
|
|
|
@@ -270,88 +565,51 @@ switch (command) {
|
|
|
270
565
|
break;
|
|
271
566
|
}
|
|
272
567
|
|
|
273
|
-
// 3. Auto-select best tool and configure MCP (HTTP transport)
|
|
274
568
|
const best = tools.find((t) => t.loggedIn) ?? tools[0];
|
|
275
569
|
const config = loadBackendConfig();
|
|
276
570
|
|
|
277
571
|
console.log(`\nAuto-selecting backend: ${displayName(best.name)}...`);
|
|
278
|
-
|
|
279
|
-
if (values.port) {
|
|
280
|
-
process.env.ZHIHAND_PORT = values.port;
|
|
281
|
-
}
|
|
572
|
+
if (values.port) process.env.ZHIHAND_PORT = values.port;
|
|
282
573
|
configureMCP(best.name, config.activeBackend);
|
|
283
574
|
saveBackendConfig({ activeBackend: best.name });
|
|
284
575
|
|
|
285
|
-
// 4. Start daemon
|
|
286
576
|
console.log(`\nStarting daemon...\n`);
|
|
287
577
|
const port = values.port ? parseInt(values.port, 10) : undefined;
|
|
288
|
-
await startDaemon({
|
|
289
|
-
port,
|
|
290
|
-
deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
|
|
291
|
-
});
|
|
578
|
+
await startDaemon({ port });
|
|
292
579
|
break;
|
|
293
580
|
}
|
|
294
581
|
|
|
295
|
-
case "list":
|
|
296
582
|
case "test": {
|
|
297
|
-
const { resolveConfig: resolveTestConfig } = await import("../dist/core/config.js");
|
|
298
583
|
const { createControlCommand, createSystemCommand, enqueueCommand } = await import("../dist/core/command.js");
|
|
299
584
|
const { waitForCommandAck } = await import("../dist/core/sse.js");
|
|
300
585
|
const { fetchScreenshot, getSnapshotStaleThresholdMs } = await import("../dist/core/screenshot.js");
|
|
301
|
-
const {
|
|
302
|
-
|
|
303
|
-
// ── Test Registry ────────────────────────────────────────
|
|
304
|
-
// Kind: "profile" | "status" | "screenshot" | "hid" | "system"
|
|
305
|
-
// - Each kind maps to a required capability (see KIND_CAPABILITY).
|
|
306
|
-
// Platform: undefined | "android" | "ios" (skipped on non-matching)
|
|
307
|
-
// Unsafe: won't run in full-suite unless explicitly requested
|
|
308
|
-
|
|
309
|
-
// Required capability per test kind. Tests whose required capability
|
|
310
|
-
// is not ready are SKIPPED (not failed), unless --force is passed.
|
|
311
|
-
//
|
|
312
|
-
// NOTE: `system` commands (volume, brightness, notification, media,
|
|
313
|
-
// etc.) are executed by the phone app via native OS APIs
|
|
314
|
-
// (AccessibilityService on Android, Shortcuts/system hooks on iOS)
|
|
315
|
-
// and do NOT depend on the BLE HID channel. Only the `hid` kind
|
|
316
|
-
// (click, swipe, type, keycombo — which inject into the paired
|
|
317
|
-
// target via the ZhiHand peripheral) needs the HID capability.
|
|
586
|
+
const { fetchDeviceProfileOnce, extractStatic, computeCapabilities, formatDeviceStatus } = await import("../dist/core/device.js");
|
|
587
|
+
|
|
318
588
|
const KIND_CAPABILITY = {
|
|
319
|
-
profile: "none",
|
|
320
|
-
status: "none",
|
|
321
|
-
screenshot: "screen",
|
|
322
|
-
hid: "hid",
|
|
323
|
-
system: "none",
|
|
589
|
+
profile: "none", status: "none", screenshot: "screen", hid: "hid", system: "none",
|
|
324
590
|
};
|
|
325
591
|
const REGISTRY = [
|
|
326
|
-
// Phase A — Device Info API
|
|
327
592
|
{ id: 1, phase: "Device Info", label: "Fetch device profile", kind: "profile" },
|
|
328
593
|
{ id: 2, phase: "Device Info", label: "Device status fields", kind: "status" },
|
|
329
|
-
// Phase B — Screenshot
|
|
330
594
|
{ id: 3, phase: "Screenshot", label: "Screenshot", kind: "screenshot" },
|
|
331
|
-
// Phase C — Tap / Touch
|
|
332
595
|
{ id: 4, phase: "Tap/Touch", label: "Click center", kind: "hid", params: { action: "click", xRatio: 0.5, yRatio: 0.5 } },
|
|
333
596
|
{ id: 5, phase: "Tap/Touch", label: "Double click", kind: "hid", params: { action: "doubleclick", xRatio: 0.5, yRatio: 0.5 } },
|
|
334
597
|
{ id: 6, phase: "Tap/Touch", label: "Long click (800ms)", kind: "hid", params: { action: "longclick", xRatio: 0.5, yRatio: 0.5, durationMs: 800 } },
|
|
335
598
|
{ id: 7, phase: "Tap/Touch", label: "Right click", kind: "hid", params: { action: "rightclick", xRatio: 0.5, yRatio: 0.5 } },
|
|
336
599
|
{ id: 8, phase: "Tap/Touch", label: "Middle click", kind: "hid", params: { action: "middleclick", xRatio: 0.5, yRatio: 0.5 } },
|
|
337
|
-
// Phase D — Swipe / Scroll
|
|
338
600
|
{ id: 9, phase: "Swipe/Scroll", label: "Swipe up", kind: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.7, endXRatio: 0.5, endYRatio: 0.3, durationMs: 300 } },
|
|
339
601
|
{ id: 10, phase: "Swipe/Scroll", label: "Swipe down", kind: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.3, endXRatio: 0.5, endYRatio: 0.7, durationMs: 300 } },
|
|
340
602
|
{ id: 11, phase: "Swipe/Scroll", label: "Swipe left", kind: "hid", params: { action: "swipe", startXRatio: 0.7, startYRatio: 0.5, endXRatio: 0.3, endYRatio: 0.5, durationMs: 300 } },
|
|
341
603
|
{ id: 12, phase: "Swipe/Scroll", label: "Swipe right", kind: "hid", params: { action: "swipe", startXRatio: 0.3, startYRatio: 0.5, endXRatio: 0.7, endYRatio: 0.5, durationMs: 300 } },
|
|
342
604
|
{ id: 13, phase: "Swipe/Scroll", label: "Scroll down", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "down", amount: 3 } },
|
|
343
605
|
{ id: 14, phase: "Swipe/Scroll", label: "Scroll up", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "up", amount: 3 } },
|
|
344
|
-
// Phase E — Text + Keys
|
|
345
606
|
{ id: 15, phase: "Text+Keys", label: "Type text", kind: "hid", params: { action: "type", text: "zhihand" } },
|
|
346
607
|
{ id: 16, phase: "Text+Keys", label: "Enter key", kind: "hid", params: { action: "enter" } },
|
|
347
608
|
{ id: 17, phase: "Text+Keys", label: "Key combo (select all)", kind: "hid", platformAware: "select_all" },
|
|
348
|
-
// Phase F — App Navigation
|
|
349
609
|
{ id: 18, phase: "Navigation", label: "Press Home", kind: "hid", params: { action: "home" } },
|
|
350
610
|
{ id: 19, phase: "Navigation", label: "Press Back", kind: "hid", params: { action: "back" } },
|
|
351
611
|
{ id: 20, phase: "Navigation", label: "Open WeChat", kind: "hid", platformAware: "open_wechat" },
|
|
352
|
-
// Phase G — Clipboard
|
|
353
612
|
{ id: 21, phase: "Clipboard", label: "Clipboard set", kind: "hid", platformAware: "clipboard_set" },
|
|
354
|
-
// Phase H — System Navigation
|
|
355
613
|
{ id: 22, phase: "System Nav", label: "Notification shade", kind: "system", params: { action: "notification" } },
|
|
356
614
|
{ id: 23, phase: "System Nav", label: "Recent apps", kind: "system", params: { action: "recent" } },
|
|
357
615
|
{ id: 24, phase: "System Nav", label: "Search (query='zhihand')", kind: "system", params: { action: "search", text: "zhihand" } },
|
|
@@ -360,7 +618,6 @@ switch (command) {
|
|
|
360
618
|
{ id: 27, phase: "System Nav", label: "Control Center", kind: "system", params: { action: "control_center" }, platform: "ios" },
|
|
361
619
|
{ id: 28, phase: "System Nav", label: "Open browser", kind: "system", params: { action: "open_browser" }, platform: "android" },
|
|
362
620
|
{ id: 29, phase: "System Nav", label: "Shortcut help", kind: "system", params: { action: "shortcut_help" }, platform: "android" },
|
|
363
|
-
// Phase I — Media
|
|
364
621
|
{ id: 30, phase: "Media", label: "Volume up", kind: "system", params: { action: "volume_up" } },
|
|
365
622
|
{ id: 31, phase: "Media", label: "Volume down", kind: "system", params: { action: "volume_down" } },
|
|
366
623
|
{ id: 32, phase: "Media", label: "Mute toggle", kind: "system", params: { action: "mute" } },
|
|
@@ -370,50 +627,36 @@ switch (command) {
|
|
|
370
627
|
{ id: 36, phase: "Media", label: "Fast forward", kind: "system", params: { action: "fast_forward" } },
|
|
371
628
|
{ id: 37, phase: "Media", label: "Rewind", kind: "system", params: { action: "rewind" } },
|
|
372
629
|
{ id: 38, phase: "Media", label: "Stop", kind: "system", params: { action: "stop" } },
|
|
373
|
-
// Phase J — Hardware
|
|
374
630
|
{ id: 39, phase: "Hardware", label: "Brightness up", kind: "system", params: { action: "brightness_up" } },
|
|
375
631
|
{ id: 40, phase: "Hardware", label: "Brightness down", kind: "system", params: { action: "brightness_down" } },
|
|
376
|
-
{ id: 41, phase: "Hardware", label: "Power button (
|
|
632
|
+
{ id: 41, phase: "Hardware", label: "Power button (may lock screen)", kind: "system", params: { action: "power" }, unsafe: true },
|
|
377
633
|
];
|
|
378
634
|
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (t.unsafe) tags.push("unsafe");
|
|
391
|
-
const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
392
|
-
console.log(` ${String(t.id).padStart(2)}. ${t.label}${tagStr}`);
|
|
635
|
+
// Parse first positional: credential_id (crd_*) or test ids
|
|
636
|
+
let credentialArg = values.device ?? process.env.ZHIHAND_DEVICE;
|
|
637
|
+
let filterArg = null;
|
|
638
|
+
const arg1 = positionals[1];
|
|
639
|
+
const arg2 = positionals[2];
|
|
640
|
+
if (arg1) {
|
|
641
|
+
if (/^crd_/.test(arg1)) {
|
|
642
|
+
credentialArg = arg1;
|
|
643
|
+
filterArg = arg2 ?? null;
|
|
644
|
+
} else {
|
|
645
|
+
filterArg = arg1;
|
|
393
646
|
}
|
|
394
|
-
console.log(`\n Total: ${REGISTRY.length} tests`);
|
|
395
|
-
console.log("\nUsage:");
|
|
396
|
-
console.log(" zhihand test # run all safe tests");
|
|
397
|
-
console.log(" zhihand test 4 # run test #4 only");
|
|
398
|
-
console.log(" zhihand test 4,9,20 # run tests #4, #9, #20");
|
|
399
|
-
console.log(" zhihand test all # run ALL tests (including unsafe)");
|
|
400
|
-
process.exit(0);
|
|
401
647
|
}
|
|
402
648
|
|
|
403
|
-
// ── "test" sub-command ───────────────────────────────────
|
|
404
649
|
let testConfig;
|
|
405
650
|
try {
|
|
406
|
-
testConfig =
|
|
651
|
+
testConfig = resolveConfig(credentialArg);
|
|
407
652
|
} catch (err) {
|
|
408
653
|
console.error(`Error: ${err.message}`);
|
|
409
|
-
console.error("Run 'zhihand
|
|
654
|
+
console.error("Run 'zhihand pair' to add a device first.");
|
|
410
655
|
process.exit(1);
|
|
411
656
|
}
|
|
412
657
|
|
|
413
|
-
// Parse which tests to run from positional args
|
|
414
|
-
const filterArg = positionals[1]; // e.g. "4" or "4,9,20" or "all"
|
|
415
658
|
const forceRun = values.force === true;
|
|
416
|
-
let selectedIds = null;
|
|
659
|
+
let selectedIds = null;
|
|
417
660
|
let includeUnsafe = false;
|
|
418
661
|
if (filterArg) {
|
|
419
662
|
if (filterArg === "all") {
|
|
@@ -423,56 +666,55 @@ switch (command) {
|
|
|
423
666
|
filterArg.split(",").map((s) => {
|
|
424
667
|
const trimmed = s.trim();
|
|
425
668
|
const n = Number(trimmed);
|
|
426
|
-
// Strict: reject ranges like "4-10" (Number returns NaN), floats, empty
|
|
427
669
|
return Number.isInteger(n) && n > 0 ? n : NaN;
|
|
428
670
|
}).filter((n) => !isNaN(n))
|
|
429
671
|
);
|
|
430
672
|
if (selectedIds.size === 0) {
|
|
431
673
|
console.error(`Invalid test IDs: ${filterArg}`);
|
|
432
|
-
console.error("Run 'zhihand list' to see available tests.");
|
|
433
674
|
process.exit(1);
|
|
434
675
|
}
|
|
435
|
-
// Explicit selection implies user knows what they're doing
|
|
436
676
|
includeUnsafe = true;
|
|
437
677
|
}
|
|
438
678
|
}
|
|
439
679
|
|
|
440
|
-
console.log("
|
|
680
|
+
console.log("ZhiHand Device Test");
|
|
441
681
|
console.log(` Device: ${testConfig.credentialId}`);
|
|
442
682
|
console.log(` Endpoint: ${testConfig.controlPlaneEndpoint}\n`);
|
|
443
683
|
|
|
444
|
-
// Pre-fetch device profile
|
|
445
|
-
|
|
446
|
-
|
|
684
|
+
// Pre-fetch device profile
|
|
685
|
+
let currentRawAttrs = {};
|
|
686
|
+
let currentProfile = null;
|
|
687
|
+
let currentCaps = null;
|
|
688
|
+
let profileReceivedAtMs = 0;
|
|
447
689
|
try {
|
|
448
|
-
await
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
690
|
+
const fetched = await fetchDeviceProfileOnce(testConfig);
|
|
691
|
+
if (fetched) {
|
|
692
|
+
currentRawAttrs = fetched.rawAttrs;
|
|
693
|
+
profileReceivedAtMs = fetched.receivedAtMs;
|
|
694
|
+
currentProfile = extractStatic(currentRawAttrs);
|
|
695
|
+
currentCaps = computeCapabilities(currentRawAttrs, profileReceivedAtMs);
|
|
696
|
+
}
|
|
697
|
+
} catch { /* non-fatal */ }
|
|
698
|
+
const getDevicePlatform = () => currentProfile?.platform ?? "unknown";
|
|
699
|
+
|
|
700
|
+
console.log(" -- Capability readiness --");
|
|
701
|
+
if (!currentCaps) {
|
|
702
|
+
console.log(" [!] Device profile not loaded — all capability gates will allow tests through.");
|
|
457
703
|
} else {
|
|
458
|
-
const fmt = (name, cap) => ` ${cap.ready ? "
|
|
459
|
-
console.log(fmt("screen_sharing",
|
|
460
|
-
console.log(fmt("hid",
|
|
461
|
-
console.log(fmt("live_session",
|
|
462
|
-
const ageStr =
|
|
463
|
-
console.log(` ${
|
|
704
|
+
const fmt = (name, cap) => ` ${cap.ready ? "[ok]" : "[!]"} ${name.padEnd(14)} ${cap.ready ? "ready" : "NOT ready"} — ${cap.reason}`;
|
|
705
|
+
console.log(fmt("screen_sharing", currentCaps.screen_sharing));
|
|
706
|
+
console.log(fmt("hid", currentCaps.hid));
|
|
707
|
+
console.log(fmt("live_session", currentCaps.live_session));
|
|
708
|
+
const ageStr = currentCaps.profile.age_ms >= 0 ? `${(currentCaps.profile.age_ms / 1000).toFixed(1)}s` : "unknown";
|
|
709
|
+
console.log(` ${currentCaps.profile.stale ? "[!]" : "[ok]"} profile age=${ageStr}${currentCaps.profile.stale ? " (STALE)" : ""}`);
|
|
464
710
|
if (forceRun) {
|
|
465
711
|
console.log(" --force passed: capability gates disabled.");
|
|
466
712
|
}
|
|
467
713
|
}
|
|
468
714
|
console.log("");
|
|
469
715
|
|
|
470
|
-
let passed = 0;
|
|
471
|
-
let failed = 0;
|
|
472
|
-
let skipped = 0;
|
|
473
|
-
let totalSteps = 0;
|
|
716
|
+
let passed = 0, failed = 0, skipped = 0, totalSteps = 0;
|
|
474
717
|
|
|
475
|
-
// ── Resolve platform-aware params (evaluated at test time) ──
|
|
476
718
|
function resolvePlatformAwareParams(variant) {
|
|
477
719
|
const platform = getDevicePlatform();
|
|
478
720
|
if (variant === "open_wechat") {
|
|
@@ -490,7 +732,6 @@ switch (command) {
|
|
|
490
732
|
return null;
|
|
491
733
|
}
|
|
492
734
|
|
|
493
|
-
// ── Test runners (shared ACK logic) ──
|
|
494
735
|
async function runCommandTest(t, command) {
|
|
495
736
|
totalSteps++;
|
|
496
737
|
process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
|
|
@@ -503,43 +744,36 @@ switch (command) {
|
|
|
503
744
|
const ackStatus = ack.command?.ack_status ?? "ok";
|
|
504
745
|
const resultInfo = ack.command?.ack_result ? ` ${JSON.stringify(ack.command.ack_result)}` : "";
|
|
505
746
|
if (ackStatus === "ok") {
|
|
506
|
-
console.log(
|
|
747
|
+
console.log(`[PASS] (${ms}ms)${resultInfo}`);
|
|
507
748
|
passed++;
|
|
508
749
|
} else {
|
|
509
|
-
console.log(
|
|
750
|
+
console.log(`[FAIL] [${ackStatus}] (${ms}ms)${resultInfo}`);
|
|
510
751
|
failed++;
|
|
511
752
|
}
|
|
512
753
|
} else {
|
|
513
|
-
console.log(
|
|
754
|
+
console.log(`[TIMEOUT] (${ms}ms)`);
|
|
514
755
|
failed++;
|
|
515
756
|
}
|
|
516
757
|
} catch (err) {
|
|
517
|
-
console.log(
|
|
758
|
+
console.log(`[FAIL] ${err.message} (${Date.now() - t0}ms)`);
|
|
518
759
|
failed++;
|
|
519
760
|
}
|
|
520
761
|
}
|
|
521
762
|
|
|
522
763
|
async function runSingleTest(t) {
|
|
523
|
-
// Platform skip (evaluated at test time — Test 1 may have just populated the profile)
|
|
524
764
|
const currentPlatform = getDevicePlatform();
|
|
525
765
|
if (t.platform && t.platform !== currentPlatform) {
|
|
526
|
-
totalSteps++;
|
|
527
|
-
|
|
528
|
-
console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${t.platform}-only, device is ${currentPlatform})`);
|
|
766
|
+
totalSteps++; skipped++;
|
|
767
|
+
console.log(` ${String(t.id).padStart(2)}. ${t.label}... [SKIP] (${t.platform}-only, device is ${currentPlatform})`);
|
|
529
768
|
return;
|
|
530
769
|
}
|
|
531
|
-
|
|
532
|
-
// Capability gate (unless --force) — evaluated per-test so later
|
|
533
|
-
// tests see fresh readiness flags pushed after Test 1's profile fetch.
|
|
534
|
-
if (!forceRun && isDeviceProfileLoaded()) {
|
|
770
|
+
if (!forceRun && currentCaps) {
|
|
535
771
|
const requiredCap = KIND_CAPABILITY[t.kind] ?? "none";
|
|
536
772
|
if (requiredCap !== "none") {
|
|
537
|
-
const
|
|
538
|
-
const gate = requiredCap === "screen" ? caps.screen_sharing : caps.hid;
|
|
773
|
+
const gate = requiredCap === "screen" ? currentCaps.screen_sharing : currentCaps.hid;
|
|
539
774
|
if (!gate.ready) {
|
|
540
|
-
totalSteps++;
|
|
541
|
-
|
|
542
|
-
console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${requiredCap} not ready: ${gate.reason})`);
|
|
775
|
+
totalSteps++; skipped++;
|
|
776
|
+
console.log(` ${String(t.id).padStart(2)}. ${t.label}... [SKIP] (${requiredCap} not ready: ${gate.reason})`);
|
|
543
777
|
return;
|
|
544
778
|
}
|
|
545
779
|
}
|
|
@@ -551,18 +785,22 @@ switch (command) {
|
|
|
551
785
|
process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
|
|
552
786
|
const t0 = Date.now();
|
|
553
787
|
try {
|
|
554
|
-
await
|
|
788
|
+
const fetched = await fetchDeviceProfileOnce(testConfig);
|
|
555
789
|
const ms = Date.now() - t0;
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
|
|
790
|
+
if (fetched) {
|
|
791
|
+
currentRawAttrs = fetched.rawAttrs;
|
|
792
|
+
profileReceivedAtMs = fetched.receivedAtMs;
|
|
793
|
+
currentProfile = extractStatic(currentRawAttrs);
|
|
794
|
+
currentCaps = computeCapabilities(currentRawAttrs, profileReceivedAtMs);
|
|
795
|
+
const s = currentProfile;
|
|
796
|
+
console.log(`[PASS] ${s.platform} ${s.model}, ${s.osVersion}, ${s.screenWidthPx}x${s.screenHeightPx} (${ms}ms)`);
|
|
559
797
|
passed++;
|
|
560
798
|
} else {
|
|
561
|
-
console.log(
|
|
799
|
+
console.log(`[!] Loaded but empty (${ms}ms)`);
|
|
562
800
|
failed++;
|
|
563
801
|
}
|
|
564
802
|
} catch (err) {
|
|
565
|
-
console.log(
|
|
803
|
+
console.log(`[FAIL] ${err.message} (${Date.now() - t0}ms)`);
|
|
566
804
|
failed++;
|
|
567
805
|
}
|
|
568
806
|
break;
|
|
@@ -571,23 +809,33 @@ switch (command) {
|
|
|
571
809
|
totalSteps++;
|
|
572
810
|
process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
|
|
573
811
|
try {
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
812
|
+
const state = {
|
|
813
|
+
credentialId: testConfig.credentialId,
|
|
814
|
+
userId: "",
|
|
815
|
+
userLabel: "",
|
|
816
|
+
label: "(test)",
|
|
817
|
+
platform: getDevicePlatform(),
|
|
818
|
+
online: true,
|
|
819
|
+
lastSeenAtMs: Date.now(),
|
|
820
|
+
profile: currentProfile,
|
|
821
|
+
capabilities: currentCaps,
|
|
822
|
+
profileReceivedAtMs,
|
|
823
|
+
rawAttributes: currentRawAttrs,
|
|
824
|
+
record: { credential_id: testConfig.credentialId, label: "", platform: "unknown", paired_at: "", last_seen_at: "" },
|
|
825
|
+
};
|
|
826
|
+
const status = formatDeviceStatus(state);
|
|
579
827
|
const topLevel = Object.keys(status).filter((k) => k !== "raw" && k !== "capabilities");
|
|
580
828
|
const rawKeys = Object.keys(status.raw ?? {});
|
|
581
829
|
const caps = status.capabilities ?? {};
|
|
582
830
|
const capReadySummary = ["screen_sharing", "hid", "live_session"]
|
|
583
831
|
.map((k) => `${k}=${caps[k]?.ready ? "ready" : "not-ready"}`)
|
|
584
832
|
.join(", ");
|
|
585
|
-
console.log(
|
|
833
|
+
console.log(`[PASS] ${topLevel.length} curated + ${rawKeys.length} raw attributes; ${capReadySummary}`);
|
|
586
834
|
console.log(` curated: ${topLevel.join(", ")}`);
|
|
587
835
|
console.log(` raw: ${rawKeys.join(", ")}`);
|
|
588
836
|
passed++;
|
|
589
837
|
} catch (err) {
|
|
590
|
-
console.log(
|
|
838
|
+
console.log(`[FAIL] ${err.message}`);
|
|
591
839
|
failed++;
|
|
592
840
|
}
|
|
593
841
|
break;
|
|
@@ -601,37 +849,34 @@ switch (command) {
|
|
|
601
849
|
const queued = await enqueueCommand(testConfig, cmd);
|
|
602
850
|
const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
|
|
603
851
|
if (!ack.acked) {
|
|
604
|
-
console.log(
|
|
852
|
+
console.log(`[TIMEOUT] (${Date.now() - t0}ms)`);
|
|
605
853
|
failed++;
|
|
606
854
|
break;
|
|
607
855
|
}
|
|
608
856
|
const shot = await fetchScreenshot(testConfig);
|
|
609
857
|
const kb = (shot.buffer.length / 1024).toFixed(0);
|
|
610
858
|
const ms = Date.now() - t0;
|
|
611
|
-
// The screenshot endpoint returns the last cached frame even
|
|
612
|
-
// when the phone isn't actively sharing — the age header is
|
|
613
|
-
// the only way to tell. Treat stale as failure.
|
|
614
859
|
if (shot.stale) {
|
|
615
860
|
const threshold = getSnapshotStaleThresholdMs();
|
|
616
|
-
console.log(
|
|
861
|
+
console.log(`[FAIL] Stale (${kb}KB, age=${(shot.ageMs / 1000).toFixed(1)}s > ${(threshold / 1000).toFixed(1)}s) ${shot.width}x${shot.height} seq=${shot.sequence} — phone may not be screen-sharing (${ms}ms)`);
|
|
617
862
|
failed++;
|
|
618
863
|
} else {
|
|
619
|
-
console.log(
|
|
864
|
+
console.log(`[PASS] ${kb}KB, ${shot.width}x${shot.height}, age=${shot.ageMs >= 0 ? `${shot.ageMs}ms` : "?"}, seq=${shot.sequence} (${ms}ms)`);
|
|
620
865
|
passed++;
|
|
621
866
|
}
|
|
622
867
|
} catch (err) {
|
|
623
|
-
console.log(
|
|
868
|
+
console.log(`[FAIL] ${err.message} (${Date.now() - t0}ms)`);
|
|
624
869
|
failed++;
|
|
625
870
|
}
|
|
626
871
|
break;
|
|
627
872
|
}
|
|
628
873
|
case "hid": {
|
|
629
874
|
const params = t.platformAware ? resolvePlatformAwareParams(t.platformAware) : t.params;
|
|
630
|
-
await runCommandTest(t, createControlCommand(params));
|
|
875
|
+
await runCommandTest(t, createControlCommand(params, getDevicePlatform()));
|
|
631
876
|
break;
|
|
632
877
|
}
|
|
633
878
|
case "system": {
|
|
634
|
-
await runCommandTest(t, createSystemCommand(t.params));
|
|
879
|
+
await runCommandTest(t, createSystemCommand(t.params, getDevicePlatform()));
|
|
635
880
|
break;
|
|
636
881
|
}
|
|
637
882
|
}
|
|
@@ -639,7 +884,6 @@ switch (command) {
|
|
|
639
884
|
|
|
640
885
|
const pause = () => new Promise((r) => setTimeout(r, 1500));
|
|
641
886
|
|
|
642
|
-
// Select tests to run
|
|
643
887
|
const toRun = REGISTRY.filter((t) => {
|
|
644
888
|
if (selectedIds) return selectedIds.has(t.id);
|
|
645
889
|
if (t.unsafe && !includeUnsafe) return false;
|
|
@@ -648,37 +892,29 @@ switch (command) {
|
|
|
648
892
|
|
|
649
893
|
if (toRun.length === 0) {
|
|
650
894
|
console.error("No matching tests.");
|
|
651
|
-
console.error("Run 'zhihand list' to see available tests.");
|
|
652
895
|
process.exit(1);
|
|
653
896
|
}
|
|
654
897
|
|
|
655
|
-
// Warn about missing IDs
|
|
656
898
|
if (selectedIds) {
|
|
657
899
|
const foundIds = new Set(toRun.map((t) => t.id));
|
|
658
900
|
const missing = [...selectedIds].filter((id) => !foundIds.has(id));
|
|
659
|
-
if (missing.length) {
|
|
660
|
-
console.warn(`⚠️ Unknown test IDs: ${missing.join(", ")}`);
|
|
661
|
-
}
|
|
901
|
+
if (missing.length) console.warn(`[!] Unknown test IDs: ${missing.join(", ")}`);
|
|
662
902
|
}
|
|
663
903
|
|
|
664
904
|
let currentPhase = "";
|
|
665
905
|
for (let i = 0; i < toRun.length; i++) {
|
|
666
906
|
const t = toRun[i];
|
|
667
907
|
if (t.phase !== currentPhase) {
|
|
668
|
-
console.log(`
|
|
908
|
+
console.log(` -- ${t.phase} --`);
|
|
669
909
|
currentPhase = t.phase;
|
|
670
910
|
}
|
|
671
911
|
await runSingleTest(t);
|
|
672
912
|
if (i < toRun.length - 1) await pause();
|
|
673
913
|
}
|
|
674
914
|
|
|
675
|
-
// ── Summary ──────────────────────────────────────────────
|
|
676
915
|
console.log(`\n Result: ${passed}/${totalSteps} passed, ${failed} failed, ${skipped} skipped`);
|
|
677
|
-
if (failed === 0)
|
|
678
|
-
|
|
679
|
-
} else {
|
|
680
|
-
console.log(` ⚠️ ${failed} test(s) failed. Check phone connectivity.`);
|
|
681
|
-
}
|
|
916
|
+
if (failed === 0) console.log(" All tests passed! Device is fully responsive.");
|
|
917
|
+
else console.log(` ${failed} test(s) failed. Check phone connectivity.`);
|
|
682
918
|
process.exit(failed > 0 ? 1 : 0);
|
|
683
919
|
}
|
|
684
920
|
|