@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 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('1.1.26-alpha.1');
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
- // Resolve connectId: explicit flag wins, else pick the first attached device
88
- let { connectId } = globalOpts;
89
- if (!connectId) {
90
- const searchResult = await sdk.searchDevices();
91
- if (!searchResult?.success ||
92
- !Array.isArray(searchResult.payload) ||
93
- searchResult.payload.length === 0) {
94
- outputResult(globalOpts, {
95
- success: false,
96
- payload: { error: 'No device found', code: 'NO_DEVICE' },
97
- });
98
- return;
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.getFeatures(connectId || '');
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 = (0, hd_core_1.getDeviceType)(selectedDevice.features);
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() below handles dispose + exit so SDK
951
- // async cleanup (USB release, event listener teardown) finishes first.
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
- // SDK event listeners can keep the event loop alive after dispose.
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 ? `BLE device not found by name: ${deviceName}` : 'No Classic/Pure BLE device found');
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[0].connectId;
1012
+ const [{ connectId, name }] = matches;
1032
1013
  if (!connectId)
1033
- throw new Error(`BLE device has no connect ID: ${matches[0].name}`);
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.19",
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.19",
34
- "@onekeyfe/hd-core": "1.2.0-alpha.19",
35
- "@onekeyfe/hd-shared": "1.2.0-alpha.19",
36
- "@onekeyfe/hd-transport-usb": "1.2.0-alpha.19",
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": "4e79f6a400463f342285b9a761b6b84523f1d6b9"
41
+ "gitHead": "5fbc1ada90fd3cfda7e5ef08be368cb452018733"
41
42
  }
@@ -0,0 +1,8 @@
1
+ import packageJson from '../../package.json';
2
+ import { program } from '../cli';
3
+
4
+ describe('CLI 版本', () => {
5
+ test('与发布包 package.json 使用同一个版本来源', () => {
6
+ expect(program.version()).toBe(packageJson.version);
7
+ });
8
+ });
@@ -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('1.1.26-alpha.1');
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
- // Resolve connectId: explicit flag wins, else pick the first attached device
130
- let { connectId } = globalOpts as { connectId?: string };
131
- if (!connectId) {
132
- const searchResult = await sdk.searchDevices();
133
- if (
134
- !searchResult?.success ||
135
- !Array.isArray(searchResult.payload) ||
136
- searchResult.payload.length === 0
137
- ) {
138
- outputResult(globalOpts, {
139
- success: false,
140
- payload: { error: 'No device found', code: 'NO_DEVICE' },
141
- });
142
- return;
143
- }
144
- connectId = (searchResult.payload[0] as EnrichedSearchDevice).connectId ?? undefined;
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 sdk.getFeatures(connectId || '');
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] as EnrichedSearchDevice;
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 = getDeviceType(selectedDevice.features as Features | undefined);
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() below handles dispose + exit so SDK
1211
- // async cleanup (USB release, event listener teardown) finishes first.
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. drain event loop and process.exit with the right code
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
- // SDK event listeners can keep the event loop alive after dispose.
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 ? `BLE device not found by name: ${deviceName}` : 'No Classic/Pure BLE device found'
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[0].connectId;
1337
- if (!connectId) throw new Error(`BLE device has no connect ID: ${matches[0].name}`);
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: any) => {
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: any) => {
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 {