@onekeyfe/hardware-cli 1.2.0-alpha.19 → 1.2.0-alpha.20
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/dist/cli.js +28 -49
- package/dist/deviceStateCommands.d.ts +19 -0
- package/dist/deviceStateCommands.js +62 -0
- package/dist/sdk.js +3 -3
- package/package.json +7 -6
- package/src/__tests__/cli-version.test.ts +8 -0
- package/src/__tests__/device-state-commands.test.ts +118 -0
- package/src/cli.ts +43 -55
- package/src/deviceStateCommands.ts +71 -0
- package/src/sdk.ts +4 -4
package/dist/cli.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
exports.program = exports.buildWallpaperUploadMetrics = exports.getLegacyFirmwareConnectTimeout = void 0;
|
|
5
5
|
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
6
7
|
const commander_1 = require("commander");
|
|
7
8
|
const hd_core_1 = require("@onekeyfe/hd-core");
|
|
8
9
|
const hd_shared_1 = require("@onekeyfe/hd-shared");
|
|
9
10
|
const chains_1 = require("./chains");
|
|
10
11
|
const deviceSelection_1 = require("./deviceSelection");
|
|
12
|
+
const deviceStateCommands_1 = require("./deviceStateCommands");
|
|
11
13
|
const sdk_1 = require("./sdk");
|
|
12
14
|
const session_1 = require("./session");
|
|
13
15
|
function extractPassphraseSession(payload) {
|
|
@@ -36,10 +38,11 @@ function extractPassphraseSession(payload) {
|
|
|
36
38
|
}
|
|
37
39
|
const program = new commander_1.Command();
|
|
38
40
|
exports.program = program;
|
|
41
|
+
const { version: cliVersion } = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.resolve)(__dirname, '../package.json'), 'utf8'));
|
|
39
42
|
program
|
|
40
43
|
.name('onekey-hw')
|
|
41
44
|
.description('OneKey hardware wallet CLI for AI agent integration')
|
|
42
|
-
.version(
|
|
45
|
+
.version(cliVersion);
|
|
43
46
|
// ============================================================
|
|
44
47
|
// Global Options
|
|
45
48
|
// ============================================================
|
|
@@ -57,49 +60,27 @@ program
|
|
|
57
60
|
.description('Search for connected OneKey hardware wallet devices')
|
|
58
61
|
.action(() => runCommand({}, async ({ sdk, globalOpts }) => {
|
|
59
62
|
const result = await sdk.searchDevices();
|
|
60
|
-
// USB 下自动读取 features 成本低;BLE 搜索阶段只做枚举,避免批量连接导致超时。
|
|
61
|
-
if (globalOpts.transport !== 'ble' && result?.success && Array.isArray(result.payload)) {
|
|
62
|
-
for (const device of result.payload) {
|
|
63
|
-
if (device.connectId) {
|
|
64
|
-
try {
|
|
65
|
-
const features = await sdk.getFeatures(device.connectId);
|
|
66
|
-
if (features?.success && features.payload) {
|
|
67
|
-
device.features = features.payload;
|
|
68
|
-
device.name = features.payload.label || features.payload.bleName || device.name;
|
|
69
|
-
const devType = features.payload.deviceType?.toLowerCase();
|
|
70
|
-
if (devType) {
|
|
71
|
-
device.deviceType = devType;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
// Features fetch failed — device may need PIN, continue with basic info
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
63
|
outputResult(globalOpts, result);
|
|
82
64
|
}));
|
|
83
65
|
program
|
|
84
66
|
.command('get-features')
|
|
85
67
|
.description('Get device features (firmware, unlock state, passphrase protection, etc.)')
|
|
86
68
|
.action(() => runCommand({}, async ({ sdk, globalOpts }) => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
connectId = searchResult.payload[0].connectId ?? undefined;
|
|
69
|
+
const result = await (0, deviceStateCommands_1.getCompatibleFeatures)(sdk, globalOpts.connectId);
|
|
70
|
+
outputResult(globalOpts, result);
|
|
71
|
+
}));
|
|
72
|
+
program
|
|
73
|
+
.command('get-state')
|
|
74
|
+
.description('Get canonical device state for Protocol V1 and Protocol V2 devices')
|
|
75
|
+
.option('--scope <scope>', 'State refresh scope: runtime, settings, or firmware', 'runtime')
|
|
76
|
+
.action((opts) => runCommand({}, async ({ sdk, globalOpts }) => {
|
|
77
|
+
const supportedScopes = ['runtime', 'settings', 'firmware'];
|
|
78
|
+
if (!supportedScopes.includes(opts.scope)) {
|
|
79
|
+
const error = new Error(`Unsupported device state scope: ${opts.scope}`);
|
|
80
|
+
error.code = 'INVALID_DEVICE_STATE_SCOPE';
|
|
81
|
+
throw error;
|
|
101
82
|
}
|
|
102
|
-
const result = await sdk.
|
|
83
|
+
const result = await (0, deviceStateCommands_1.getCanonicalDeviceState)(sdk, globalOpts.connectId, opts.scope);
|
|
103
84
|
outputResult(globalOpts, result);
|
|
104
85
|
}));
|
|
105
86
|
program
|
|
@@ -859,7 +840,7 @@ globalOpts) {
|
|
|
859
840
|
// getFeatures failures here are non-fatal — we fall through to Step 3
|
|
860
841
|
// which will fail with a clearer error if the device is truly unreachable.
|
|
861
842
|
let deviceId = selectedDevice.features?.deviceId || selectedDevice.deviceId || '';
|
|
862
|
-
let deviceType =
|
|
843
|
+
let deviceType = selectedDevice.features?.deviceType ?? selectedDevice.deviceType ?? hd_shared_1.EDeviceType.Unknown;
|
|
863
844
|
let unlocked = selectedDevice.features?.unlocked;
|
|
864
845
|
let passphraseProtection = selectedDevice.features?.passphraseProtection;
|
|
865
846
|
if (!deviceId || unlocked == null || passphraseProtection == null) {
|
|
@@ -947,8 +928,8 @@ function outputResult(_globalOpts, result) {
|
|
|
947
928
|
!result.success) {
|
|
948
929
|
process.exitCode = 1;
|
|
949
930
|
}
|
|
950
|
-
// No process.exit here — runCommand()
|
|
951
|
-
//
|
|
931
|
+
// No process.exit here — runCommand() waits for SDK cleanup, then lets Node
|
|
932
|
+
// exit naturally so leaked USB handles remain observable.
|
|
952
933
|
}
|
|
953
934
|
async function runCommand(options, handler) {
|
|
954
935
|
const globalOpts = program.opts();
|
|
@@ -981,9 +962,7 @@ async function runCommand(options, handler) {
|
|
|
981
962
|
// promise reference. Idempotent, safe to call even if init failed.
|
|
982
963
|
await (0, sdk_1.disposeSDK)();
|
|
983
964
|
}
|
|
984
|
-
//
|
|
985
|
-
// setImmediate lets any trailing stdout/stderr writes flush first.
|
|
986
|
-
setImmediate(() => process.exit(process.exitCode ?? 0));
|
|
965
|
+
// disposeSDK awaits transport cleanup; let Node exit naturally so leaks remain visible.
|
|
987
966
|
}
|
|
988
967
|
/** For commands that don't touch the SDK at all (e.g. firmware-update stubs). */
|
|
989
968
|
function respondAndExit(result) {
|
|
@@ -1021,16 +1000,18 @@ async function resolveLegacyFirmwareConnectId(sdk, explicitConnectId, deviceName
|
|
|
1021
1000
|
? devices.filter(device => device.name?.trim().toLowerCase() === normalizedName)
|
|
1022
1001
|
: devices.filter(device => device.deviceType?.toLowerCase() === 'classic');
|
|
1023
1002
|
if (matches.length === 0) {
|
|
1024
|
-
throw new Error(normalizedName
|
|
1003
|
+
throw new Error(normalizedName
|
|
1004
|
+
? `BLE device not found by name: ${deviceName}`
|
|
1005
|
+
: 'No Classic/Pure BLE device found');
|
|
1025
1006
|
}
|
|
1026
1007
|
if (matches.length > 1) {
|
|
1027
1008
|
throw new Error(normalizedName
|
|
1028
1009
|
? `Multiple BLE devices found by name: ${deviceName}`
|
|
1029
1010
|
: 'Multiple Classic/Pure BLE devices found; specify --device-name');
|
|
1030
1011
|
}
|
|
1031
|
-
const connectId = matches
|
|
1012
|
+
const [{ connectId, name }] = matches;
|
|
1032
1013
|
if (!connectId)
|
|
1033
|
-
throw new Error(`BLE device has no connect ID: ${
|
|
1014
|
+
throw new Error(`BLE device has no connect ID: ${name}`);
|
|
1034
1015
|
return connectId;
|
|
1035
1016
|
}
|
|
1036
1017
|
function parseResourceBundleParam(spec) {
|
|
@@ -1100,9 +1081,7 @@ function buildWallpaperUploadMetrics({ totalBytes, transferredBytes, startedAt,
|
|
|
1100
1081
|
totalBytes,
|
|
1101
1082
|
transferredBytes,
|
|
1102
1083
|
totalSeconds: Number((elapsedMs / 1000).toFixed(2)),
|
|
1103
|
-
transferKiBPerSecond: elapsedMs > 0
|
|
1104
|
-
? Number((transferredBytes / 1024 / (elapsedMs / 1000)).toFixed(2))
|
|
1105
|
-
: null,
|
|
1084
|
+
transferKiBPerSecond: elapsedMs > 0 ? Number((transferredBytes / 1024 / (elapsedMs / 1000)).toFixed(2)) : null,
|
|
1106
1085
|
lastProgress,
|
|
1107
1086
|
};
|
|
1108
1087
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CoreApi, DeviceStateScope } from '@onekeyfe/hd-core';
|
|
2
|
+
type DeviceStateSdk = Pick<CoreApi, 'searchDevices' | 'getDeviceState' | 'getFeatures'>;
|
|
3
|
+
/**
|
|
4
|
+
* Unified state entry for the new CLI. Resolve the user-facing connectId through
|
|
5
|
+
* discovery so V1 serial IDs map to the process-local USB path.
|
|
6
|
+
*/
|
|
7
|
+
export declare const getCanonicalDeviceState: (sdk: DeviceStateSdk, connectId: string | undefined, scope: DeviceStateScope) => Promise<import("@onekeyfe/hd-core").Unsuccessful | {
|
|
8
|
+
success: true;
|
|
9
|
+
payload: import("@onekeyfe/hd-core").DeviceState;
|
|
10
|
+
}>;
|
|
11
|
+
/**
|
|
12
|
+
* Legacy CLI only: retain getFeatures for V1 and reuse the discovery projection for V2.
|
|
13
|
+
* Public SDK.getFeatures remains V1-only.
|
|
14
|
+
*/
|
|
15
|
+
export declare const getCompatibleFeatures: (sdk: DeviceStateSdk, connectId?: string) => Promise<import("@onekeyfe/hd-core").Unsuccessful | {
|
|
16
|
+
success: true;
|
|
17
|
+
payload: import("@onekeyfe/hd-core").Features;
|
|
18
|
+
}>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getCompatibleFeatures = exports.getCanonicalDeviceState = void 0;
|
|
4
|
+
const createDeviceNotFoundResult = (connectId) => ({
|
|
5
|
+
success: false,
|
|
6
|
+
payload: {
|
|
7
|
+
code: 'DEVICE_NOT_FOUND',
|
|
8
|
+
error: connectId ? `Device not found: ${connectId}` : 'No device found',
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
const resolveSearchDevice = async (sdk, connectId) => {
|
|
12
|
+
const searchResult = await sdk.searchDevices();
|
|
13
|
+
if (!searchResult.success)
|
|
14
|
+
return searchResult;
|
|
15
|
+
const devices = searchResult.payload;
|
|
16
|
+
const device = connectId ? devices.find(item => item.connectId === connectId) : devices[0];
|
|
17
|
+
if (!device?.connectId)
|
|
18
|
+
return createDeviceNotFoundResult(connectId);
|
|
19
|
+
return { success: true, payload: device };
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Unified state entry for the new CLI. Resolve the user-facing connectId through
|
|
23
|
+
* discovery so V1 serial IDs map to the process-local USB path.
|
|
24
|
+
*/
|
|
25
|
+
const getCanonicalDeviceState = async (sdk, connectId, scope) => {
|
|
26
|
+
const deviceResult = await resolveSearchDevice(sdk, connectId);
|
|
27
|
+
if (!deviceResult.success)
|
|
28
|
+
return deviceResult;
|
|
29
|
+
if (scope === 'runtime' && deviceResult.payload.state) {
|
|
30
|
+
return { success: true, payload: deviceResult.payload.state };
|
|
31
|
+
}
|
|
32
|
+
const resolvedConnectId = deviceResult.payload.connectId ?? undefined;
|
|
33
|
+
if (!resolvedConnectId)
|
|
34
|
+
return createDeviceNotFoundResult();
|
|
35
|
+
return sdk.getDeviceState(resolvedConnectId, { scope });
|
|
36
|
+
};
|
|
37
|
+
exports.getCanonicalDeviceState = getCanonicalDeviceState;
|
|
38
|
+
/**
|
|
39
|
+
* Legacy CLI only: retain getFeatures for V1 and reuse the discovery projection for V2.
|
|
40
|
+
* Public SDK.getFeatures remains V1-only.
|
|
41
|
+
*/
|
|
42
|
+
const getCompatibleFeatures = async (sdk, connectId) => {
|
|
43
|
+
const deviceResult = await resolveSearchDevice(sdk, connectId);
|
|
44
|
+
if (!deviceResult.success)
|
|
45
|
+
return deviceResult;
|
|
46
|
+
const device = deviceResult.payload;
|
|
47
|
+
const protocol = device.state?.protocol ?? device.features?.protocol;
|
|
48
|
+
if (protocol === 'V2') {
|
|
49
|
+
if (!device.features) {
|
|
50
|
+
return {
|
|
51
|
+
success: false,
|
|
52
|
+
payload: {
|
|
53
|
+
code: 'DEVICE_FEATURES_UNAVAILABLE',
|
|
54
|
+
error: 'Protocol V2 compatibility features are unavailable',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { success: true, payload: device.features };
|
|
59
|
+
}
|
|
60
|
+
return sdk.getFeatures(device.connectId ?? '');
|
|
61
|
+
};
|
|
62
|
+
exports.getCompatibleFeatures = getCompatibleFeatures;
|
package/dist/sdk.js
CHANGED
|
@@ -168,12 +168,12 @@ function registerEventHandlers(sdk) {
|
|
|
168
168
|
process.stderr.write('[onekey-hw] Please confirm the action on your device...\n');
|
|
169
169
|
}
|
|
170
170
|
});
|
|
171
|
-
sdk.on(hd_core_1.DEVICE.CONNECT, (device) => {
|
|
171
|
+
sdk.on(hd_core_1.DEVICE.CONNECT, ({ device }) => {
|
|
172
172
|
const name = device?.label || device?.name;
|
|
173
173
|
if (name)
|
|
174
174
|
process.stderr.write(`[onekey-hw] Device connected: ${name}\n`);
|
|
175
175
|
});
|
|
176
|
-
sdk.on(hd_core_1.DEVICE.DISCONNECT, (device) => {
|
|
176
|
+
sdk.on(hd_core_1.DEVICE.DISCONNECT, ({ device }) => {
|
|
177
177
|
const name = device?.label || device?.name;
|
|
178
178
|
if (name)
|
|
179
179
|
process.stderr.write(`[onekey-hw] Device disconnected: ${name}\n`);
|
|
@@ -222,7 +222,7 @@ async function disposeSDK() {
|
|
|
222
222
|
return;
|
|
223
223
|
try {
|
|
224
224
|
const sdk = await sdkReadyPromise;
|
|
225
|
-
sdk.dispose();
|
|
225
|
+
await Promise.resolve(sdk.dispose());
|
|
226
226
|
}
|
|
227
227
|
catch {
|
|
228
228
|
// ignore errors during cleanup
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onekeyfe/hardware-cli",
|
|
3
|
-
"version": "1.2.0-alpha.
|
|
3
|
+
"version": "1.2.0-alpha.20",
|
|
4
4
|
"description": "OneKey hardware wallet CLI for testing device communication",
|
|
5
5
|
"author": "OneKey",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"start": "node dist/cli.js",
|
|
24
24
|
"search": "node dist/cli.js search",
|
|
25
25
|
"get-features": "node dist/cli.js get-features",
|
|
26
|
+
"get-state": "node dist/cli.js get-state",
|
|
26
27
|
"get-address": "node dist/cli.js get-address",
|
|
27
28
|
"ping": "node dist/cli.js ping",
|
|
28
29
|
"lint": "eslint .",
|
|
@@ -30,12 +31,12 @@
|
|
|
30
31
|
"test": "jest"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.
|
|
34
|
-
"@onekeyfe/hd-core": "1.2.0-alpha.
|
|
35
|
-
"@onekeyfe/hd-shared": "1.2.0-alpha.
|
|
36
|
-
"@onekeyfe/hd-transport-usb": "1.2.0-alpha.
|
|
34
|
+
"@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.20",
|
|
35
|
+
"@onekeyfe/hd-core": "1.2.0-alpha.20",
|
|
36
|
+
"@onekeyfe/hd-shared": "1.2.0-alpha.20",
|
|
37
|
+
"@onekeyfe/hd-transport-usb": "1.2.0-alpha.20",
|
|
37
38
|
"@stoprocent/noble": "2.3.16",
|
|
38
39
|
"commander": "^12.0.0"
|
|
39
40
|
},
|
|
40
|
-
"gitHead": "
|
|
41
|
+
"gitHead": "5fbc1ada90fd3cfda7e5ef08be368cb452018733"
|
|
41
42
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { getCanonicalDeviceState, getCompatibleFeatures } from '../deviceStateCommands';
|
|
2
|
+
|
|
3
|
+
const createSdkMock = () => ({
|
|
4
|
+
searchDevices: jest.fn(),
|
|
5
|
+
getDeviceState: jest.fn(),
|
|
6
|
+
getFeatures: jest.fn(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('设备状态 CLI 兼容层', () => {
|
|
10
|
+
test('Protocol V2 直接返回 SDK 搜索结果中的兼容 features', async () => {
|
|
11
|
+
const sdk = createSdkMock();
|
|
12
|
+
const features = {
|
|
13
|
+
protocol: 'V2',
|
|
14
|
+
deviceType: 'pro2',
|
|
15
|
+
deviceId: 'device-id',
|
|
16
|
+
};
|
|
17
|
+
sdk.searchDevices.mockResolvedValue({
|
|
18
|
+
success: true,
|
|
19
|
+
payload: [
|
|
20
|
+
{
|
|
21
|
+
connectId: 'pro2-connect-id',
|
|
22
|
+
state: { protocol: 'V2' },
|
|
23
|
+
features,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
await expect(getCompatibleFeatures(sdk as never, 'pro2-connect-id')).resolves.toEqual({
|
|
29
|
+
success: true,
|
|
30
|
+
payload: features,
|
|
31
|
+
});
|
|
32
|
+
expect(sdk.getFeatures).not.toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('Protocol V1 继续调用公共 getFeatures 保持旧行为', async () => {
|
|
36
|
+
const sdk = createSdkMock();
|
|
37
|
+
const response = {
|
|
38
|
+
success: true,
|
|
39
|
+
payload: { protocol: 'V1', label: 'Classic' },
|
|
40
|
+
};
|
|
41
|
+
sdk.searchDevices.mockResolvedValue({
|
|
42
|
+
success: true,
|
|
43
|
+
payload: [
|
|
44
|
+
{
|
|
45
|
+
connectId: 'classic-connect-id',
|
|
46
|
+
state: { protocol: 'V1' },
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
sdk.getFeatures.mockResolvedValue(response);
|
|
51
|
+
|
|
52
|
+
await expect(getCompatibleFeatures(sdk as never, 'classic-connect-id')).resolves.toBe(response);
|
|
53
|
+
expect(sdk.getFeatures).toHaveBeenCalledWith('classic-connect-id');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('未显式指定设备时选择搜索结果中的第一台设备', async () => {
|
|
57
|
+
const sdk = createSdkMock();
|
|
58
|
+
const response = { success: true, payload: { protocol: 'V1' } };
|
|
59
|
+
sdk.searchDevices.mockResolvedValue({
|
|
60
|
+
success: true,
|
|
61
|
+
payload: [{ connectId: 'first-device', state: { protocol: 'V1' } }],
|
|
62
|
+
});
|
|
63
|
+
sdk.getFeatures.mockResolvedValue(response);
|
|
64
|
+
|
|
65
|
+
await expect(getCompatibleFeatures(sdk as never)).resolves.toBe(response);
|
|
66
|
+
expect(sdk.getFeatures).toHaveBeenCalledWith('first-device');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('显式 connectId 不在搜索结果时返回结构化错误', async () => {
|
|
70
|
+
const sdk = createSdkMock();
|
|
71
|
+
sdk.searchDevices.mockResolvedValue({
|
|
72
|
+
success: true,
|
|
73
|
+
payload: [{ connectId: 'another-device', state: { protocol: 'V1' } }],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await expect(getCompatibleFeatures(sdk as never, 'missing-device')).resolves.toEqual({
|
|
77
|
+
success: false,
|
|
78
|
+
payload: {
|
|
79
|
+
code: 'DEVICE_NOT_FOUND',
|
|
80
|
+
error: 'Device not found: missing-device',
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
expect(sdk.getFeatures).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('get-state 将 firmware scope 传给 SDK', async () => {
|
|
87
|
+
const sdk = createSdkMock();
|
|
88
|
+
const response = { success: true, payload: { protocol: 'V2' } };
|
|
89
|
+
sdk.searchDevices.mockResolvedValue({
|
|
90
|
+
success: true,
|
|
91
|
+
payload: [{ connectId: 'pro2-connect-id', state: { protocol: 'V2' } }],
|
|
92
|
+
});
|
|
93
|
+
sdk.getDeviceState.mockResolvedValue(response);
|
|
94
|
+
|
|
95
|
+
await expect(
|
|
96
|
+
getCanonicalDeviceState(sdk as never, 'pro2-connect-id', 'firmware')
|
|
97
|
+
).resolves.toBe(response);
|
|
98
|
+
expect(sdk.getDeviceState).toHaveBeenCalledWith('pro2-connect-id', {
|
|
99
|
+
scope: 'firmware',
|
|
100
|
+
});
|
|
101
|
+
expect(sdk.searchDevices).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('get-state 未指定 connectId 时搜索并选择第一台设备', async () => {
|
|
105
|
+
const sdk = createSdkMock();
|
|
106
|
+
const state = { protocol: 'V2', revision: 2 };
|
|
107
|
+
sdk.searchDevices.mockResolvedValue({
|
|
108
|
+
success: true,
|
|
109
|
+
payload: [{ connectId: 'pro2-connect-id', state }],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await expect(getCanonicalDeviceState(sdk as never, undefined, 'runtime')).resolves.toEqual({
|
|
113
|
+
success: true,
|
|
114
|
+
payload: state,
|
|
115
|
+
});
|
|
116
|
+
expect(sdk.getDeviceState).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
2
3
|
import { Command } from 'commander';
|
|
3
4
|
import { UI_EVENT, UI_REQUEST, getDeviceType } from '@onekeyfe/hd-core';
|
|
4
5
|
import { EDeviceType } from '@onekeyfe/hd-shared';
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
resolveSignTransaction,
|
|
12
13
|
} from './chains';
|
|
13
14
|
import { selectSearchDevice } from './deviceSelection';
|
|
15
|
+
import { getCanonicalDeviceState, getCompatibleFeatures } from './deviceStateCommands';
|
|
14
16
|
import { createSDK, disposeSDK } from './sdk';
|
|
15
17
|
import {
|
|
16
18
|
clearSessionFromKeychain,
|
|
@@ -19,10 +21,10 @@ import {
|
|
|
19
21
|
} from './session';
|
|
20
22
|
|
|
21
23
|
import type {
|
|
24
|
+
DeviceStateScope,
|
|
22
25
|
EthereumSignTypedDataMessage,
|
|
23
26
|
EthereumSignTypedDataTypes,
|
|
24
27
|
Features,
|
|
25
|
-
IDeviceType,
|
|
26
28
|
SearchDevice,
|
|
27
29
|
} from '@onekeyfe/hd-core';
|
|
28
30
|
|
|
@@ -65,11 +67,14 @@ function extractPassphraseSession(payload: unknown): {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
const program = new Command();
|
|
70
|
+
const { version: cliVersion } = JSON.parse(
|
|
71
|
+
readFileSync(resolve(__dirname, '../package.json'), 'utf8')
|
|
72
|
+
) as { version: string };
|
|
68
73
|
|
|
69
74
|
program
|
|
70
75
|
.name('onekey-hw')
|
|
71
76
|
.description('OneKey hardware wallet CLI for AI agent integration')
|
|
72
|
-
.version(
|
|
77
|
+
.version(cliVersion);
|
|
73
78
|
|
|
74
79
|
// ============================================================
|
|
75
80
|
// Global Options
|
|
@@ -95,28 +100,6 @@ program
|
|
|
95
100
|
.action(() =>
|
|
96
101
|
runCommand({}, async ({ sdk, globalOpts }) => {
|
|
97
102
|
const result = await sdk.searchDevices();
|
|
98
|
-
|
|
99
|
-
// USB 下自动读取 features 成本低;BLE 搜索阶段只做枚举,避免批量连接导致超时。
|
|
100
|
-
if (globalOpts.transport !== 'ble' && result?.success && Array.isArray(result.payload)) {
|
|
101
|
-
for (const device of result.payload as EnrichedSearchDevice[]) {
|
|
102
|
-
if (device.connectId) {
|
|
103
|
-
try {
|
|
104
|
-
const features = await sdk.getFeatures(device.connectId);
|
|
105
|
-
if (features?.success && features.payload) {
|
|
106
|
-
device.features = features.payload;
|
|
107
|
-
device.name = features.payload.label || features.payload.bleName || device.name;
|
|
108
|
-
const devType = features.payload.deviceType?.toLowerCase();
|
|
109
|
-
if (devType) {
|
|
110
|
-
device.deviceType = devType as IDeviceType;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
} catch {
|
|
114
|
-
// Features fetch failed — device may need PIN, continue with basic info
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
103
|
outputResult(globalOpts, result);
|
|
121
104
|
})
|
|
122
105
|
);
|
|
@@ -126,24 +109,28 @@ program
|
|
|
126
109
|
.description('Get device features (firmware, unlock state, passphrase protection, etc.)')
|
|
127
110
|
.action(() =>
|
|
128
111
|
runCommand({}, async ({ sdk, globalOpts }) => {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
112
|
+
const result = await getCompatibleFeatures(sdk, globalOpts.connectId);
|
|
113
|
+
outputResult(globalOpts, result);
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
program
|
|
118
|
+
.command('get-state')
|
|
119
|
+
.description('Get canonical device state for Protocol V1 and Protocol V2 devices')
|
|
120
|
+
.option('--scope <scope>', 'State refresh scope: runtime, settings, or firmware', 'runtime')
|
|
121
|
+
.action((opts: { scope: string }) =>
|
|
122
|
+
runCommand({}, async ({ sdk, globalOpts }) => {
|
|
123
|
+
const supportedScopes: DeviceStateScope[] = ['runtime', 'settings', 'firmware'];
|
|
124
|
+
if (!supportedScopes.includes(opts.scope as DeviceStateScope)) {
|
|
125
|
+
const error = new Error(`Unsupported device state scope: ${opts.scope}`);
|
|
126
|
+
(error as Error & { code?: string }).code = 'INVALID_DEVICE_STATE_SCOPE';
|
|
127
|
+
throw error;
|
|
145
128
|
}
|
|
146
|
-
const result = await
|
|
129
|
+
const result = await getCanonicalDeviceState(
|
|
130
|
+
sdk,
|
|
131
|
+
globalOpts.connectId,
|
|
132
|
+
opts.scope as DeviceStateScope
|
|
133
|
+
);
|
|
147
134
|
outputResult(globalOpts, result);
|
|
148
135
|
})
|
|
149
136
|
);
|
|
@@ -860,7 +847,7 @@ sessionCmd
|
|
|
860
847
|
});
|
|
861
848
|
return;
|
|
862
849
|
}
|
|
863
|
-
const device = searchResult.payload[0]
|
|
850
|
+
const device: EnrichedSearchDevice = searchResult.payload[0];
|
|
864
851
|
const connectId = device.connectId || globalOpts.connectId;
|
|
865
852
|
|
|
866
853
|
// 2. Unlock if locked — getPassphraseState below talks to a live
|
|
@@ -1070,6 +1057,7 @@ async function prepareSession(
|
|
|
1070
1057
|
searchResult.payload as Array<{
|
|
1071
1058
|
connectId?: string;
|
|
1072
1059
|
deviceId?: string;
|
|
1060
|
+
deviceType?: string;
|
|
1073
1061
|
features?: {
|
|
1074
1062
|
deviceId?: string | null;
|
|
1075
1063
|
deviceType?: string;
|
|
@@ -1088,6 +1076,7 @@ async function prepareSession(
|
|
|
1088
1076
|
const selectedDevice = device as {
|
|
1089
1077
|
connectId?: string;
|
|
1090
1078
|
deviceId?: string;
|
|
1079
|
+
deviceType?: string;
|
|
1091
1080
|
features?: {
|
|
1092
1081
|
deviceId?: string | null;
|
|
1093
1082
|
deviceType?: string;
|
|
@@ -1105,7 +1094,8 @@ async function prepareSession(
|
|
|
1105
1094
|
// getFeatures failures here are non-fatal — we fall through to Step 3
|
|
1106
1095
|
// which will fail with a clearer error if the device is truly unreachable.
|
|
1107
1096
|
let deviceId = selectedDevice.features?.deviceId || selectedDevice.deviceId || '';
|
|
1108
|
-
let deviceType =
|
|
1097
|
+
let deviceType =
|
|
1098
|
+
selectedDevice.features?.deviceType ?? selectedDevice.deviceType ?? EDeviceType.Unknown;
|
|
1109
1099
|
let unlocked = selectedDevice.features?.unlocked;
|
|
1110
1100
|
let passphraseProtection = selectedDevice.features?.passphraseProtection;
|
|
1111
1101
|
|
|
@@ -1207,8 +1197,8 @@ function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
|
|
|
1207
1197
|
) {
|
|
1208
1198
|
process.exitCode = 1;
|
|
1209
1199
|
}
|
|
1210
|
-
// No process.exit here — runCommand()
|
|
1211
|
-
//
|
|
1200
|
+
// No process.exit here — runCommand() waits for SDK cleanup, then lets Node
|
|
1201
|
+
// exit naturally so leaked USB handles remain observable.
|
|
1212
1202
|
}
|
|
1213
1203
|
|
|
1214
1204
|
/**
|
|
@@ -1220,7 +1210,7 @@ function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
|
|
|
1220
1210
|
* 3. run the handler (which calls outputResult on success)
|
|
1221
1211
|
* 4. report uncaught errors as a structured failure result
|
|
1222
1212
|
* 5. dispose SDK
|
|
1223
|
-
* 6.
|
|
1213
|
+
* 6. let Node exit naturally after all SDK resources are released
|
|
1224
1214
|
*
|
|
1225
1215
|
* This fixes three previous bugs:
|
|
1226
1216
|
* - Most signing commands skipped prepareSession, so keychain sessions
|
|
@@ -1272,9 +1262,7 @@ async function runCommand(
|
|
|
1272
1262
|
// promise reference. Idempotent, safe to call even if init failed.
|
|
1273
1263
|
await disposeSDK();
|
|
1274
1264
|
}
|
|
1275
|
-
//
|
|
1276
|
-
// setImmediate lets any trailing stdout/stderr writes flush first.
|
|
1277
|
-
setImmediate(() => process.exit(process.exitCode ?? 0));
|
|
1265
|
+
// disposeSDK awaits transport cleanup; let Node exit naturally so leaks remain visible.
|
|
1278
1266
|
}
|
|
1279
1267
|
|
|
1280
1268
|
/** For commands that don't touch the SDK at all (e.g. firmware-update stubs). */
|
|
@@ -1322,7 +1310,9 @@ async function resolveLegacyFirmwareConnectId(
|
|
|
1322
1310
|
|
|
1323
1311
|
if (matches.length === 0) {
|
|
1324
1312
|
throw new Error(
|
|
1325
|
-
normalizedName
|
|
1313
|
+
normalizedName
|
|
1314
|
+
? `BLE device not found by name: ${deviceName}`
|
|
1315
|
+
: 'No Classic/Pure BLE device found'
|
|
1326
1316
|
);
|
|
1327
1317
|
}
|
|
1328
1318
|
if (matches.length > 1) {
|
|
@@ -1333,8 +1323,8 @@ async function resolveLegacyFirmwareConnectId(
|
|
|
1333
1323
|
);
|
|
1334
1324
|
}
|
|
1335
1325
|
|
|
1336
|
-
const connectId = matches
|
|
1337
|
-
if (!connectId) throw new Error(`BLE device has no connect ID: ${
|
|
1326
|
+
const [{ connectId, name }] = matches;
|
|
1327
|
+
if (!connectId) throw new Error(`BLE device has no connect ID: ${name}`);
|
|
1338
1328
|
return connectId;
|
|
1339
1329
|
}
|
|
1340
1330
|
|
|
@@ -1429,9 +1419,7 @@ export function buildWallpaperUploadMetrics({
|
|
|
1429
1419
|
transferredBytes,
|
|
1430
1420
|
totalSeconds: Number((elapsedMs / 1000).toFixed(2)),
|
|
1431
1421
|
transferKiBPerSecond:
|
|
1432
|
-
elapsedMs > 0
|
|
1433
|
-
? Number((transferredBytes / 1024 / (elapsedMs / 1000)).toFixed(2))
|
|
1434
|
-
: null,
|
|
1422
|
+
elapsedMs > 0 ? Number((transferredBytes / 1024 / (elapsedMs / 1000)).toFixed(2)) : null,
|
|
1435
1423
|
lastProgress,
|
|
1436
1424
|
};
|
|
1437
1425
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CoreApi, DeviceStateScope, KnownDevice, SearchDevice } from '@onekeyfe/hd-core';
|
|
2
|
+
|
|
3
|
+
type DeviceStateSdk = Pick<CoreApi, 'searchDevices' | 'getDeviceState' | 'getFeatures'>;
|
|
4
|
+
|
|
5
|
+
type DiscoveredDevice = SearchDevice & Partial<Pick<KnownDevice, 'features' | 'state'>>;
|
|
6
|
+
|
|
7
|
+
const createDeviceNotFoundResult = (connectId?: string) => ({
|
|
8
|
+
success: false as const,
|
|
9
|
+
payload: {
|
|
10
|
+
code: 'DEVICE_NOT_FOUND',
|
|
11
|
+
error: connectId ? `Device not found: ${connectId}` : 'No device found',
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const resolveSearchDevice = async (sdk: DeviceStateSdk, connectId?: string) => {
|
|
16
|
+
const searchResult = await sdk.searchDevices();
|
|
17
|
+
if (!searchResult.success) return searchResult;
|
|
18
|
+
|
|
19
|
+
const devices = searchResult.payload as DiscoveredDevice[];
|
|
20
|
+
const device = connectId ? devices.find(item => item.connectId === connectId) : devices[0];
|
|
21
|
+
|
|
22
|
+
if (!device?.connectId) return createDeviceNotFoundResult(connectId);
|
|
23
|
+
return { success: true as const, payload: device };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Unified state entry for the new CLI. Resolve the user-facing connectId through
|
|
28
|
+
* discovery so V1 serial IDs map to the process-local USB path.
|
|
29
|
+
*/
|
|
30
|
+
export const getCanonicalDeviceState = async (
|
|
31
|
+
sdk: DeviceStateSdk,
|
|
32
|
+
connectId: string | undefined,
|
|
33
|
+
scope: DeviceStateScope
|
|
34
|
+
) => {
|
|
35
|
+
const deviceResult = await resolveSearchDevice(sdk, connectId);
|
|
36
|
+
if (!deviceResult.success) return deviceResult;
|
|
37
|
+
|
|
38
|
+
if (scope === 'runtime' && deviceResult.payload.state) {
|
|
39
|
+
return { success: true as const, payload: deviceResult.payload.state };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const resolvedConnectId = deviceResult.payload.connectId ?? undefined;
|
|
43
|
+
if (!resolvedConnectId) return createDeviceNotFoundResult();
|
|
44
|
+
return sdk.getDeviceState(resolvedConnectId, { scope });
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Legacy CLI only: retain getFeatures for V1 and reuse the discovery projection for V2.
|
|
49
|
+
* Public SDK.getFeatures remains V1-only.
|
|
50
|
+
*/
|
|
51
|
+
export const getCompatibleFeatures = async (sdk: DeviceStateSdk, connectId?: string) => {
|
|
52
|
+
const deviceResult = await resolveSearchDevice(sdk, connectId);
|
|
53
|
+
if (!deviceResult.success) return deviceResult;
|
|
54
|
+
|
|
55
|
+
const device = deviceResult.payload;
|
|
56
|
+
const protocol = device.state?.protocol ?? device.features?.protocol;
|
|
57
|
+
if (protocol === 'V2') {
|
|
58
|
+
if (!device.features) {
|
|
59
|
+
return {
|
|
60
|
+
success: false as const,
|
|
61
|
+
payload: {
|
|
62
|
+
code: 'DEVICE_FEATURES_UNAVAILABLE',
|
|
63
|
+
error: 'Protocol V2 compatibility features are unavailable',
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { success: true as const, payload: device.features };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return sdk.getFeatures(device.connectId ?? '');
|
|
71
|
+
};
|
package/src/sdk.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { DEVICE, UI_EVENT, UI_REQUEST, UI_RESPONSE } from '@onekeyfe/hd-core';
|
|
|
16
16
|
import { promptPassphraseViaPinentry } from './pinentry';
|
|
17
17
|
import { createNobleBlePlugin } from './transports/nobleBlePlugin';
|
|
18
18
|
|
|
19
|
-
import type { ConnectSettings } from '@onekeyfe/hd-core';
|
|
19
|
+
import type { ConnectSettings, KnownDevice } from '@onekeyfe/hd-core';
|
|
20
20
|
import type { PinentryResult } from './pinentry';
|
|
21
21
|
|
|
22
22
|
export interface SDKOptions {
|
|
@@ -169,12 +169,12 @@ function registerEventHandlers(sdk: typeof HardwareSDK): void {
|
|
|
169
169
|
}
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
-
sdk.on(DEVICE.CONNECT, (device:
|
|
172
|
+
sdk.on(DEVICE.CONNECT, ({ device }: { device: KnownDevice }) => {
|
|
173
173
|
const name = device?.label || device?.name;
|
|
174
174
|
if (name) process.stderr.write(`[onekey-hw] Device connected: ${name}\n`);
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
-
sdk.on(DEVICE.DISCONNECT, (device:
|
|
177
|
+
sdk.on(DEVICE.DISCONNECT, ({ device }: { device: KnownDevice }) => {
|
|
178
178
|
const name = device?.label || device?.name;
|
|
179
179
|
if (name) process.stderr.write(`[onekey-hw] Device disconnected: ${name}\n`);
|
|
180
180
|
});
|
|
@@ -231,7 +231,7 @@ export async function disposeSDK(): Promise<void> {
|
|
|
231
231
|
if (!sdkReadyPromise) return;
|
|
232
232
|
try {
|
|
233
233
|
const sdk = await sdkReadyPromise;
|
|
234
|
-
sdk.dispose();
|
|
234
|
+
await Promise.resolve(sdk.dispose());
|
|
235
235
|
} catch {
|
|
236
236
|
// ignore errors during cleanup
|
|
237
237
|
} finally {
|