@onekeyfe/hardware-cli 1.2.0-alpha.7 → 1.2.0-alpha.71

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/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';
@@ -10,65 +11,31 @@ import {
10
11
  resolveSignMessage,
11
12
  resolveSignTransaction,
12
13
  } from './chains';
14
+ import { selectSearchDevice } from './deviceSelection';
15
+ import { getCanonicalDeviceState, getCompatibleFeatures } from './deviceStateCommands';
13
16
  import { createSDK, disposeSDK } from './sdk';
14
- import {
15
- clearSessionFromKeychain,
16
- preloadSessionFromKeychain,
17
- saveSessionToKeychain,
18
- } from './session';
17
+ import { clearSessionFromKeychain, preloadSessionFromKeychain } from './session';
19
18
 
20
19
  import type {
20
+ DeviceStateScope,
21
21
  EthereumSignTypedDataMessage,
22
22
  EthereumSignTypedDataTypes,
23
23
  Features,
24
- IDeviceType,
25
24
  SearchDevice,
26
25
  } from '@onekeyfe/hd-core';
27
26
 
28
27
  /** SearchDevice enriched with features fetched after discovery */
29
28
  type EnrichedSearchDevice = SearchDevice & { features?: Features };
30
29
 
31
- function extractPassphraseSession(payload: unknown): {
32
- passphraseState?: string;
33
- sessionId?: string;
34
- } {
35
- if (typeof payload === 'string') {
36
- return { passphraseState: payload };
37
- }
38
- if (!payload || typeof payload !== 'object') {
39
- return {};
40
- }
41
-
42
- const statePayload = payload as {
43
- passphrase_state?: unknown;
44
- passphraseState?: unknown;
45
- session_id?: unknown;
46
- sessionId?: unknown;
47
- };
48
-
49
- let passphraseState: string | undefined;
50
- if (typeof statePayload.passphrase_state === 'string') {
51
- passphraseState = statePayload.passphrase_state;
52
- } else if (typeof statePayload.passphraseState === 'string') {
53
- passphraseState = statePayload.passphraseState;
54
- }
55
-
56
- let sessionId: string | undefined;
57
- if (typeof statePayload.session_id === 'string') {
58
- sessionId = statePayload.session_id;
59
- } else if (typeof statePayload.sessionId === 'string') {
60
- sessionId = statePayload.sessionId;
61
- }
62
-
63
- return { passphraseState, sessionId };
64
- }
65
-
66
30
  const program = new Command();
31
+ const { version: cliVersion } = JSON.parse(
32
+ readFileSync(resolve(__dirname, '../package.json'), 'utf8')
33
+ ) as { version: string };
67
34
 
68
35
  program
69
36
  .name('onekey-hw')
70
37
  .description('OneKey hardware wallet CLI for AI agent integration')
71
- .version('1.1.26-alpha.1');
38
+ .version(cliVersion);
72
39
 
73
40
  // ============================================================
74
41
  // Global Options
@@ -94,28 +61,6 @@ program
94
61
  .action(() =>
95
62
  runCommand({}, async ({ sdk, globalOpts }) => {
96
63
  const result = await sdk.searchDevices();
97
-
98
- // USB 下自动读取 features 成本低;BLE 搜索阶段只做枚举,避免批量连接导致超时。
99
- if (globalOpts.transport !== 'ble' && result?.success && Array.isArray(result.payload)) {
100
- for (const device of result.payload as EnrichedSearchDevice[]) {
101
- if (device.connectId) {
102
- try {
103
- const features = await sdk.getFeatures(device.connectId);
104
- if (features?.success && features.payload) {
105
- device.features = features.payload;
106
- device.name = features.payload.label || features.payload.bleName || device.name;
107
- const devType = features.payload.deviceType?.toLowerCase();
108
- if (devType) {
109
- device.deviceType = devType as IDeviceType;
110
- }
111
- }
112
- } catch {
113
- // Features fetch failed — device may need PIN, continue with basic info
114
- }
115
- }
116
- }
117
- }
118
-
119
64
  outputResult(globalOpts, result);
120
65
  })
121
66
  );
@@ -125,28 +70,120 @@ program
125
70
  .description('Get device features (firmware, unlock state, passphrase protection, etc.)')
126
71
  .action(() =>
127
72
  runCommand({}, async ({ sdk, globalOpts }) => {
128
- // Resolve connectId: explicit flag wins, else pick the first attached device
129
- let { connectId } = globalOpts as { connectId?: string };
130
- if (!connectId) {
131
- const searchResult = await sdk.searchDevices();
132
- if (
133
- !searchResult?.success ||
134
- !Array.isArray(searchResult.payload) ||
135
- searchResult.payload.length === 0
136
- ) {
137
- outputResult(globalOpts, {
138
- success: false,
139
- payload: { error: 'No device found', code: 'NO_DEVICE' },
140
- });
141
- return;
142
- }
143
- connectId = (searchResult.payload[0] as EnrichedSearchDevice).connectId ?? undefined;
73
+ const result = await getCompatibleFeatures(sdk, globalOpts.connectId);
74
+ outputResult(globalOpts, result);
75
+ })
76
+ );
77
+
78
+ program
79
+ .command('get-state')
80
+ .description('Get canonical device state for Protocol V1 and Protocol V2 devices')
81
+ .option('--scope <scope>', 'State refresh scope: runtime, settings, or firmware', 'runtime')
82
+ .action((opts: { scope: string }) =>
83
+ runCommand({}, async ({ sdk, globalOpts }) => {
84
+ const supportedScopes: DeviceStateScope[] = ['runtime', 'settings', 'firmware'];
85
+ if (!supportedScopes.includes(opts.scope as DeviceStateScope)) {
86
+ const error = new Error(`Unsupported device state scope: ${opts.scope}`);
87
+ (error as Error & { code?: string }).code = 'INVALID_DEVICE_STATE_SCOPE';
88
+ throw error;
144
89
  }
145
- const result = await sdk.getFeatures(connectId || '');
90
+ const result = await getCanonicalDeviceState(
91
+ sdk,
92
+ globalOpts.connectId,
93
+ opts.scope as DeviceStateScope
94
+ );
146
95
  outputResult(globalOpts, result);
147
96
  })
148
97
  );
149
98
 
99
+ program
100
+ .command('upload-wallpaper')
101
+ .description('Upload and activate a Pro2 wallpaper')
102
+ .requiredOption('--rgba <path>', '604x1024 raw RGBA file')
103
+ .option('--file-name <name>', 'Device wallpaper file name', 'wallpaper-cli')
104
+ .option('--chunk-size <bytes>', 'Transfer chunk size in bytes')
105
+ .action(opts =>
106
+ runCommand({}, async ({ sdk, globalOpts, params }) => {
107
+ const rgba = readBinaryParam(opts.rgba);
108
+ const expectedBytes = 604 * 1024 * 4;
109
+ if (rgba.byteLength !== expectedBytes) {
110
+ throw new Error(
111
+ `Invalid RGBA size: expected ${expectedBytes} bytes, received ${rgba.byteLength}`
112
+ );
113
+ }
114
+
115
+ let transferStartedAt: number | undefined;
116
+ let transferEndedAt: number | undefined;
117
+ let lastProgress = -1;
118
+ let lastPrintedProgress = -10;
119
+ let progressTotalBytes = 0;
120
+ let transferredBytes = 0;
121
+ const totalStartedAt = Date.now();
122
+ const onUiEvent = (message: unknown) => {
123
+ if (!message || typeof message !== 'object') return;
124
+ const event = message as {
125
+ type?: string;
126
+ payload?: {
127
+ progress?: number;
128
+ transferredBytes?: number;
129
+ totalBytes?: number;
130
+ rateBytesPerSecond?: number;
131
+ };
132
+ };
133
+ if (event.type !== UI_REQUEST.DEVICE_PROGRESS || !event.payload) return;
134
+ const progress = Number(event.payload.progress);
135
+ if (!Number.isFinite(progress)) return;
136
+ transferStartedAt ??= Date.now();
137
+ lastProgress = Math.max(lastProgress, progress);
138
+ const totalBytes = Number(event.payload.totalBytes);
139
+ if (Number.isFinite(totalBytes) && totalBytes > 0) progressTotalBytes = totalBytes;
140
+ const confirmedBytes = Number(event.payload.transferredBytes);
141
+ if (Number.isFinite(confirmedBytes) && confirmedBytes >= 0) {
142
+ transferredBytes = Math.max(transferredBytes, confirmedBytes);
143
+ }
144
+ const printableProgress = Math.floor(progress / 10) * 10;
145
+ if (printableProgress > lastPrintedProgress || progress >= 100) {
146
+ const rate = Number(event.payload.rateBytesPerSecond);
147
+ const rateText =
148
+ Number.isFinite(rate) && rate > 0 ? ` ${(rate / 1024).toFixed(2)} KiB/s` : '';
149
+ process.stderr.write(
150
+ `[onekey-hw] Wallpaper transfer: ${Math.round(progress)}%${rateText}\n`
151
+ );
152
+ lastPrintedProgress = progress >= 100 ? 100 : printableProgress;
153
+ }
154
+ if (progress >= 100) transferEndedAt ??= Date.now();
155
+ };
156
+
157
+ sdk.on(UI_EVENT, onUiEvent);
158
+ let result: any;
159
+ try {
160
+ result = await sdk.deviceUploadWallpaper(globalOpts.connectId, {
161
+ ...params,
162
+ width: 604,
163
+ height: 1024,
164
+ rgba,
165
+ fileName: opts.fileName,
166
+ chunkSize: opts.chunkSize ? safeParseInt(opts.chunkSize, '--chunk-size') : undefined,
167
+ });
168
+ } finally {
169
+ sdk.off?.(UI_EVENT, onUiEvent);
170
+ }
171
+
172
+ const endedAt = transferEndedAt ?? Date.now();
173
+ const totalBytes = Number(result?.payload?.size) || progressTotalBytes;
174
+ outputResult(globalOpts, {
175
+ ...result,
176
+ metrics: buildWallpaperUploadMetrics({
177
+ totalBytes,
178
+ transferredBytes: result?.success ? totalBytes : transferredBytes,
179
+ startedAt: transferStartedAt ?? totalStartedAt,
180
+ endedAt,
181
+ lastProgress,
182
+ }),
183
+ });
184
+ })
185
+ );
186
+
150
187
  // ============================================================
151
188
  // Signing Commands
152
189
  // ============================================================
@@ -541,6 +578,38 @@ program
541
578
  })
542
579
  );
543
580
 
581
+ program
582
+ .command('firmware-update-legacy')
583
+ .description('Update Classic/Pure firmware through the legacy protocol')
584
+ .requiredOption('--binary <path>', 'Local firmware binary path')
585
+ .option('--device-name <name>', 'BLE advertising name, for example K1514')
586
+ .option('--update-type <type>', 'Firmware component: firmware or ble', 'firmware')
587
+ .option('--no-reboot', 'Do not reboot the device after a successful update')
588
+ .action(opts =>
589
+ runCommand({}, async ({ sdk, globalOpts }) => {
590
+ if (opts.updateType !== 'firmware' && opts.updateType !== 'ble') {
591
+ throw new Error(`Unsupported --update-type: ${opts.updateType}. Use "firmware" or "ble".`);
592
+ }
593
+
594
+ const connectId = await resolveLegacyFirmwareConnectId(
595
+ sdk,
596
+ globalOpts.connectId,
597
+ opts.deviceName
598
+ );
599
+ const result = await sdk.firmwareUpdate(connectId, {
600
+ binary: readBinaryParam(opts.binary),
601
+ updateType: opts.updateType,
602
+ rebootOnSuccess: opts.reboot,
603
+ timeout: getLegacyFirmwareConnectTimeout(globalOpts.transport),
604
+ });
605
+ outputResult(globalOpts, result);
606
+ })
607
+ );
608
+
609
+ export function getLegacyFirmwareConnectTimeout(transport: 'usb' | 'ble') {
610
+ return transport === 'usb' ? 90_000 : undefined;
611
+ }
612
+
544
613
  program
545
614
  .command('firmware-update-ble')
546
615
  .description('Run Protocol V2 firmware update over BLE')
@@ -549,17 +618,20 @@ program
549
618
  success: false,
550
619
  payload: {
551
620
  error:
552
- 'Use `onekey-hw --transport ble firmware-update-v4-debug` for BLE Protocol V2 firmware update debugging.',
553
- code: 'USE_FIRMWARE_UPDATE_V4_DEBUG',
621
+ 'Use `onekey-hw --transport ble firmware-update-v4` for BLE Protocol V2 firmware updates.',
622
+ code: 'USE_FIRMWARE_UPDATE_V4',
554
623
  },
555
624
  })
556
625
  );
557
626
 
558
627
  program
559
- .command('firmware-update-v4-debug')
560
- .description('Debug Protocol V2 firmware update through sdk.firmwareUpdateV4')
628
+ .command('firmware-update-v4')
629
+ .description('Run Protocol V2 firmware update through sdk.firmwareUpdateV4')
561
630
  .option('--chunk-size <bytes>', 'Transfer chunk size in bytes')
562
- .option('--resource <path>', 'FW_MGMT_TARGET_CRATE resource package path')
631
+ .option(
632
+ '--resource-file <spec...>',
633
+ 'Resource direct-write spec: <localPath>:<devicePath>, e.g. wallpaper.okpkg:vol0:/bundles/images/wallpaper.okpkg'
634
+ )
563
635
  .option('--romloader <path>', 'FW_MGMT_TARGET_ROMLOADER binary path')
564
636
  .option('--bootloader <path>', 'FW_MGMT_TARGET_BOOTLOADER binary path')
565
637
  .option('--application-p1 <path>', 'FW_MGMT_TARGET_APPLICATION_P1 binary path')
@@ -573,8 +645,8 @@ program
573
645
  .option('--retries <count>', 'Retry count for transient Protocol V2 USB probe failures')
574
646
  .action(opts =>
575
647
  runCommand({}, async ({ sdk, globalOpts }) => {
576
- const params = buildFirmwareUpdateV4DebugParams(opts);
577
- const result = await runFirmwareUpdateV4DebugWithRetry({
648
+ const params = buildFirmwareUpdateV4Params(opts);
649
+ const result = await runFirmwareUpdateV4WithRetry({
578
650
  sdk,
579
651
  globalOpts,
580
652
  params,
@@ -724,7 +796,7 @@ const sessionCmd = program.command('session').description('Manage device passphr
724
796
 
725
797
  sessionCmd
726
798
  .command('connect')
727
- .description('Connect device and establish passphrase session (cached for subsequent commands)')
799
+ .description('Connect device and select a hidden wallet for this invocation')
728
800
  .action(() =>
729
801
  runCommand({}, async ({ sdk, globalOpts }) => {
730
802
  // 1. Search for device
@@ -736,7 +808,17 @@ sessionCmd
736
808
  });
737
809
  return;
738
810
  }
739
- const device = searchResult.payload[0] as EnrichedSearchDevice;
811
+ const device = selectSearchDevice(
812
+ searchResult.payload as Array<SearchDevice & { features?: Features }>,
813
+ globalOpts.connectId
814
+ );
815
+ if (!device) {
816
+ outputResult(globalOpts, {
817
+ success: false,
818
+ payload: { error: 'No matching device found', code: 'NO_DEVICE' },
819
+ });
820
+ return;
821
+ }
740
822
  const connectId = device.connectId || globalOpts.connectId;
741
823
 
742
824
  // 2. Unlock if locked — getPassphraseState below talks to a live
@@ -747,62 +829,35 @@ sessionCmd
747
829
  await unlockWithRetry(sdk, connectId);
748
830
  }
749
831
 
750
- // 3. Get passphraseState (triggers 1/2/3 selection)
751
- const psResult = await sdk.getPassphraseState(connectId, {
752
- initSession: true,
753
- useEmptyPassphrase: false,
832
+ // 3. Open a hidden wallet session (triggers 1/2/3 selection).
833
+ const sessionResult = await sdk.openWalletSession(connectId, {
834
+ mode: 'select-hidden',
754
835
  });
755
- if (!psResult.success) {
756
- outputResult(globalOpts, psResult);
836
+ if (!sessionResult.success) {
837
+ outputResult(globalOpts, sessionResult);
757
838
  return;
758
839
  }
759
- const { passphraseState, sessionId: passphraseSessionId } = extractPassphraseSession(
760
- psResult.payload
761
- );
762
- if (!passphraseState) {
840
+ if (sessionResult.payload.walletType !== 'hidden') {
763
841
  outputResult(globalOpts, {
764
842
  success: false,
765
- payload: { error: 'getPassphraseState did not return passphraseState' },
843
+ payload: { error: 'Hidden wallet selection did not return a hidden wallet session' },
766
844
  });
767
845
  return;
768
846
  }
847
+ const { deviceId, passphraseState } = sessionResult.payload;
769
848
 
770
849
  // 4. Get address to verify + extract deviceId
771
- const addrResult = await sdk.evmGetAddress(connectId, device.deviceId || '', {
850
+ const addrResult = await sdk.evmGetAddress(connectId, deviceId, {
772
851
  path: "m/44'/60'/0'/0/0",
773
852
  showOnOneKey: false,
774
853
  passphraseState,
775
854
  });
776
855
 
777
- // 5. Fetch the now-active session_id via getFeatures.
778
- //
779
- // IMPORTANT: pass `passphraseState` here. Without it, the SDK's
780
- // connectStateChange guard (core/index.ts) would see the payload's
781
- // passphraseState flip from mnNy → undefined, clear the cached Device,
782
- // and call Initialize again with no passphrase_state / no session_id.
783
- // That Initialize resets the device to the standard wallet and returns
784
- // a *standard-wallet* session_id — which we'd then save in the keychain
785
- // paired with the hidden-wallet passphraseState. On the next CLI run
786
- // the mismatch would trigger PassphraseRequest (1/2/3 again).
787
- const featResult = await sdk.getFeatures(connectId, {
788
- passphraseState,
789
- skipPassphraseCheck: true,
790
- });
791
- const featPayload = featResult?.success ? featResult.payload : undefined;
792
- const deviceId = featPayload?.deviceId || device.deviceId || '';
793
- const sessionId = passphraseSessionId || featPayload?.sessionId || '';
794
-
795
- // 6. Save to keychain
796
- if (passphraseState && deviceId && sessionId) {
797
- await saveSessionToKeychain(deviceId, passphraseState, sessionId);
798
- }
799
-
800
856
  outputResult(globalOpts, {
801
857
  success: true,
802
858
  payload: {
803
859
  passphraseState,
804
860
  deviceId,
805
- ...(sessionId ? { sessionId } : {}),
806
861
  ...(addrResult?.success ? { address: addrResult.payload.address } : {}),
807
862
  },
808
863
  });
@@ -815,8 +870,10 @@ sessionCmd
815
870
  .action(() =>
816
871
  runCommand({}, async ({ sdk, globalOpts }) => {
817
872
  const searchResult = await sdk.searchDevices();
818
- const device = // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
819
- (searchResult?.payload as any)?.[0];
873
+ const device = selectSearchDevice(
874
+ (searchResult?.payload as Array<SearchDevice & { features?: Features }>) ?? [],
875
+ globalOpts.connectId
876
+ );
820
877
  const deviceId = device?.deviceId || device?.features?.device_id;
821
878
  if (deviceId) {
822
879
  await clearSessionFromKeychain(deviceId);
@@ -911,12 +968,12 @@ async function unlockWithRetry(
911
968
  * Prepare passphrase session before SDK calls.
912
969
  *
913
970
  * 1. If --use-empty-passphrase or --passphrase-state provided → use as-is
914
- * 2. Try keychain → preloadSessionCache → use cached session
915
- * 3. Keychain miss → getPassphraseState (triggers 1/2/3 prompt) → save to keychain
971
+ * 2. Try a legacy keychain entry → preloadSessionCache → use cached session
972
+ * 3. Keychain miss → openWalletSession (triggers 1/2/3 prompt)
916
973
  *
917
974
  * After this, globalOpts.passphraseState is set and getCommonParams will include it.
918
975
  */
919
- async function prepareSession(
976
+ export async function prepareSession(
920
977
  sdk: typeof import('@onekeyfe/hd-common-connect-sdk').default,
921
978
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
922
979
  globalOpts: Record<string, any>
@@ -927,7 +984,7 @@ async function prepareSession(
927
984
  }
928
985
 
929
986
  // Errors from the SDK calls below (PIN cancelled, transport broken,
930
- // getPassphraseState rejection) intentionally propagate to runCommand's
987
+ // openWalletSession rejection) intentionally propagate to runCommand's
931
988
  // catch block, which renders them as structured `{ success: false,
932
989
  // payload: { error, code } }` output instead of silently falling through
933
990
  // to a confusing downstream error 112 / 114.
@@ -942,9 +999,30 @@ async function prepareSession(
942
999
  return undefined;
943
1000
  }
944
1001
 
945
- const device = searchResult.payload[0] as {
1002
+ const device = selectSearchDevice(
1003
+ searchResult.payload as Array<{
1004
+ connectId?: string;
1005
+ deviceId?: string;
1006
+ deviceType?: string;
1007
+ features?: {
1008
+ deviceId?: string | null;
1009
+ deviceType?: string;
1010
+ sessionId?: string | null;
1011
+ passphraseProtection?: boolean | null;
1012
+ unlocked?: boolean | null;
1013
+ };
1014
+ }>,
1015
+ globalOpts.connectId
1016
+ );
1017
+
1018
+ if (!device) {
1019
+ throw new Error(`未找到指定的 BLE 设备: ${globalOpts.connectId}`);
1020
+ }
1021
+
1022
+ const selectedDevice = device as {
946
1023
  connectId?: string;
947
1024
  deviceId?: string;
1025
+ deviceType?: string;
948
1026
  features?: {
949
1027
  deviceId?: string | null;
950
1028
  deviceType?: string;
@@ -953,7 +1031,7 @@ async function prepareSession(
953
1031
  unlocked?: boolean | null;
954
1032
  };
955
1033
  };
956
- const connectId = device.connectId || globalOpts.connectId || '';
1034
+ const connectId = selectedDevice.connectId || globalOpts.connectId || '';
957
1035
  if (!globalOpts.connectId && connectId) {
958
1036
  globalOpts.connectId = connectId;
959
1037
  }
@@ -961,10 +1039,11 @@ async function prepareSession(
961
1039
  // ── Step 2: Get features if searchDevices didn't populate them ──
962
1040
  // getFeatures failures here are non-fatal — we fall through to Step 3
963
1041
  // which will fail with a clearer error if the device is truly unreachable.
964
- let deviceId = device.features?.deviceId || device.deviceId || '';
965
- let deviceType = getDeviceType(device.features as Features | undefined);
966
- let unlocked = device.features?.unlocked;
967
- let passphraseProtection = device.features?.passphraseProtection;
1042
+ let deviceId = selectedDevice.features?.deviceId || selectedDevice.deviceId || '';
1043
+ let deviceType =
1044
+ selectedDevice.features?.deviceType ?? selectedDevice.deviceType ?? EDeviceType.Unknown;
1045
+ let unlocked = selectedDevice.features?.unlocked;
1046
+ let passphraseProtection = selectedDevice.features?.passphraseProtection;
968
1047
 
969
1048
  if (!deviceId || unlocked == null || passphraseProtection == null) {
970
1049
  try {
@@ -1003,7 +1082,7 @@ async function prepareSession(
1003
1082
  return undefined;
1004
1083
  }
1005
1084
 
1006
- // ── Step 5: Try keychain session reuse ───────────────────────────
1085
+ // ── Step 5: Try legacy keychain session reuse ────────────────────
1007
1086
  // Only attempt if device was already unlocked — locking invalidates
1008
1087
  // all passphrase sessions, so cached session_id is useless after unlock.
1009
1088
  if (!wasLocked && deviceId) {
@@ -1014,40 +1093,19 @@ async function prepareSession(
1014
1093
  }
1015
1094
  }
1016
1095
 
1017
- // ── Step 6: Keychain miss → getPassphraseState (triggers 1/2/3 prompt) ──
1018
- const psResult = await sdk.getPassphraseState(connectId, {
1019
- initSession: true,
1020
- useEmptyPassphrase: false,
1096
+ // ── Step 6: Keychain miss → openWalletSession (triggers 1/2/3 prompt) ──
1097
+ const sessionResult = await sdk.openWalletSession(connectId, {
1098
+ mode: 'select-hidden',
1021
1099
  });
1022
1100
 
1023
- if (psResult.success && psResult.payload) {
1024
- const { passphraseState, sessionId: passphraseSessionId } = extractPassphraseSession(
1025
- psResult.payload
1026
- );
1027
- if (!passphraseState) {
1101
+ if (sessionResult.success && sessionResult.payload) {
1102
+ if (sessionResult.payload.walletType !== 'hidden') {
1028
1103
  return undefined;
1029
1104
  }
1105
+ const { deviceId: sessionDeviceId, passphraseState } = sessionResult.payload;
1106
+ globalOpts.deviceId = sessionDeviceId;
1030
1107
  globalOpts.passphraseState = passphraseState;
1031
1108
 
1032
- // Save session to keychain for next invocation.
1033
- //
1034
- // Pass passphraseState to keep connectStateChange=false — otherwise
1035
- // Initialize would be re-run without passphrase_state, resetting the
1036
- // device to the standard wallet and returning a mismatched session_id.
1037
- // See the matching comment in `session connect`.
1038
- if (deviceId) {
1039
- const featAfter = await sdk.getFeatures(connectId, {
1040
- passphraseState,
1041
- skipPassphraseCheck: true,
1042
- });
1043
- const sessionId =
1044
- passphraseSessionId || (featAfter?.success ? featAfter.payload?.sessionId : undefined);
1045
- if (sessionId) {
1046
- await saveSessionToKeychain(deviceId, passphraseState, sessionId);
1047
- await preloadSessionFromKeychain(deviceId);
1048
- }
1049
- }
1050
-
1051
1109
  return passphraseState;
1052
1110
  }
1053
1111
  return undefined;
@@ -1064,8 +1122,8 @@ function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
1064
1122
  ) {
1065
1123
  process.exitCode = 1;
1066
1124
  }
1067
- // No process.exit here — runCommand() below handles dispose + exit so SDK
1068
- // async cleanup (USB release, event listener teardown) finishes first.
1125
+ // No process.exit here — runCommand() waits for SDK cleanup, then lets Node
1126
+ // exit naturally so leaked USB handles remain observable.
1069
1127
  }
1070
1128
 
1071
1129
  /**
@@ -1077,7 +1135,7 @@ function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
1077
1135
  * 3. run the handler (which calls outputResult on success)
1078
1136
  * 4. report uncaught errors as a structured failure result
1079
1137
  * 5. dispose SDK
1080
- * 6. drain event loop and process.exit with the right code
1138
+ * 6. let Node exit naturally after all SDK resources are released
1081
1139
  *
1082
1140
  * This fixes three previous bugs:
1083
1141
  * - Most signing commands skipped prepareSession, so keychain sessions
@@ -1129,9 +1187,7 @@ async function runCommand(
1129
1187
  // promise reference. Idempotent, safe to call even if init failed.
1130
1188
  await disposeSDK();
1131
1189
  }
1132
- // SDK event listeners can keep the event loop alive after dispose.
1133
- // setImmediate lets any trailing stdout/stderr writes flush first.
1134
- setImmediate(() => process.exit(process.exitCode ?? 0));
1190
+ // disposeSDK awaits transport cleanup; let Node exit naturally so leaks remain visible.
1135
1191
  }
1136
1192
 
1137
1193
  /** For commands that don't touch the SDK at all (e.g. firmware-update stubs). */
@@ -1159,11 +1215,65 @@ function readBinaryParam(path: string): ArrayBuffer {
1159
1215
  return new Uint8Array(buffer).buffer;
1160
1216
  }
1161
1217
 
1162
- function getFirmwareUpdateV4DebugTotalBytes(
1163
- params: ReturnType<typeof buildFirmwareUpdateV4DebugParams>
1164
- ) {
1218
+ async function resolveLegacyFirmwareConnectId(
1219
+ sdk: AnySdk,
1220
+ explicitConnectId?: string,
1221
+ deviceName?: string
1222
+ ): Promise<string> {
1223
+ if (explicitConnectId && !deviceName) return explicitConnectId;
1224
+
1225
+ const searchResult = await sdk.searchDevices();
1226
+ if (!searchResult?.success || !Array.isArray(searchResult.payload)) {
1227
+ throw new Error('Unable to scan BLE devices');
1228
+ }
1229
+
1230
+ const devices = searchResult.payload as EnrichedSearchDevice[];
1231
+ const normalizedName = deviceName?.trim().toLowerCase();
1232
+ const matches = normalizedName
1233
+ ? devices.filter(device => device.name?.trim().toLowerCase() === normalizedName)
1234
+ : devices.filter(device => device.deviceType?.toLowerCase() === 'classic');
1235
+
1236
+ if (matches.length === 0) {
1237
+ throw new Error(
1238
+ normalizedName
1239
+ ? `BLE device not found by name: ${deviceName}`
1240
+ : 'No Classic/Pure BLE device found'
1241
+ );
1242
+ }
1243
+ if (matches.length > 1) {
1244
+ throw new Error(
1245
+ normalizedName
1246
+ ? `Multiple BLE devices found by name: ${deviceName}`
1247
+ : 'Multiple Classic/Pure BLE devices found; specify --device-name'
1248
+ );
1249
+ }
1250
+
1251
+ const [{ connectId, name }] = matches;
1252
+ if (!connectId) throw new Error(`BLE device has no connect ID: ${name}`);
1253
+ return connectId;
1254
+ }
1255
+
1256
+ function parseResourceFileParam(spec: string): { binary: ArrayBuffer; devicePath: string } {
1257
+ const sep = spec.indexOf(':');
1258
+ if (sep <= 0 || sep === spec.length - 1) {
1259
+ throw new Error(`Invalid --resource-file value: "${spec}". Expected <localPath>:<devicePath>`);
1260
+ }
1261
+ const localPath = spec.slice(0, sep);
1262
+ const devicePath = spec.slice(sep + 1);
1263
+ if (!devicePath.startsWith('vol')) {
1264
+ throw new Error(
1265
+ `Invalid --resource-file device path: "${devicePath}". Expected a vol*:/... path`
1266
+ );
1267
+ }
1268
+ return {
1269
+ binary: readBinaryParam(localPath),
1270
+ devicePath,
1271
+ };
1272
+ }
1273
+
1274
+ function getFirmwareUpdateV4TotalBytes(params: ReturnType<typeof buildFirmwareUpdateV4Params>) {
1165
1275
  return [
1166
- ...(params.resourceBinaries ?? []),
1276
+ ...(params.resourceFiles?.map(item => item.binary) ?? []),
1167
1277
  params.bootloaderBinary,
1168
1278
  params.applicationP1Binary,
1169
1279
  params.applicationP2Binary,
@@ -1175,16 +1285,16 @@ function getFirmwareUpdateV4DebugTotalBytes(
1175
1285
  ].reduce((total, binary) => total + (binary?.byteLength ?? 0), 0);
1176
1286
  }
1177
1287
 
1178
- function getFirmwareUpdateV4DebugErrorText(result: unknown) {
1288
+ function getFirmwareUpdateV4ErrorText(result: unknown) {
1179
1289
  if (!result || typeof result !== 'object') return '';
1180
- const payload = (result as { payload?: unknown }).payload;
1290
+ const { payload } = result as { payload?: unknown };
1181
1291
  if (!payload || typeof payload !== 'object') return '';
1182
- const error = (payload as { error?: unknown }).error;
1292
+ const { error } = payload as { error?: unknown };
1183
1293
  return typeof error === 'string' ? error : '';
1184
1294
  }
1185
1295
 
1186
1296
  function isProtocolV2UsbProbeTransientResult(result: unknown) {
1187
- const error = getFirmwareUpdateV4DebugErrorText(result);
1297
+ const error = getFirmwareUpdateV4ErrorText(result);
1188
1298
  return (
1189
1299
  error.includes('Device protocol mismatch') &&
1190
1300
  error.includes('expected V2') &&
@@ -1198,22 +1308,46 @@ function isSuccessResult(result: unknown) {
1198
1308
  );
1199
1309
  }
1200
1310
 
1201
- function getFirmwareDebugPayload(message: unknown) {
1311
+ function getFirmwareUpdatePayload(message: unknown) {
1202
1312
  if (!message || typeof message !== 'object') return undefined;
1203
1313
  return (message as { payload?: Record<string, unknown> }).payload;
1204
1314
  }
1205
1315
 
1206
- function formatFirmwareDebugProgress(progress: number) {
1316
+ function formatFirmwareProgress(progress: number) {
1207
1317
  if (!Number.isFinite(progress)) return '0%';
1208
1318
  return `${Math.min(Math.max(Math.round(progress), 0), 100)}%`;
1209
1319
  }
1210
1320
 
1211
- function formatFirmwareDebugBytes(bytes: number) {
1321
+ function formatFirmwareBytes(bytes: number) {
1212
1322
  if (!Number.isFinite(bytes) || bytes <= 0) return '';
1213
1323
  return `${(bytes / 1024).toFixed(1)} KiB`;
1214
1324
  }
1215
1325
 
1216
- function maybePrintFirmwareDebugProgress({
1326
+ export function buildWallpaperUploadMetrics({
1327
+ totalBytes,
1328
+ transferredBytes,
1329
+ startedAt,
1330
+ endedAt,
1331
+ lastProgress,
1332
+ }: {
1333
+ totalBytes: number;
1334
+ transferredBytes: number;
1335
+ startedAt: number;
1336
+ endedAt: number;
1337
+ lastProgress: number;
1338
+ }) {
1339
+ const elapsedMs = Math.max(endedAt - startedAt, 0);
1340
+ return {
1341
+ totalBytes,
1342
+ transferredBytes,
1343
+ totalSeconds: Number((elapsedMs / 1000).toFixed(2)),
1344
+ transferKiBPerSecond:
1345
+ elapsedMs > 0 ? Number((transferredBytes / 1024 / (elapsedMs / 1000)).toFixed(2)) : null,
1346
+ lastProgress,
1347
+ };
1348
+ }
1349
+
1350
+ function maybePrintFirmwareProgress({
1217
1351
  progressType,
1218
1352
  progress,
1219
1353
  payload,
@@ -1234,7 +1368,7 @@ function maybePrintFirmwareDebugProgress({
1234
1368
  const rateBytesPerSecond = Number(payload.rateBytesPerSecond);
1235
1369
  const sizeText =
1236
1370
  Number.isFinite(transferredBytes) && Number.isFinite(totalBytes) && totalBytes > 0
1237
- ? ` ${formatFirmwareDebugBytes(transferredBytes)}/${formatFirmwareDebugBytes(totalBytes)}`
1371
+ ? ` ${formatFirmwareBytes(transferredBytes)}/${formatFirmwareBytes(totalBytes)}`
1238
1372
  : '';
1239
1373
  const speedText =
1240
1374
  Number.isFinite(rateBytesPerSecond) && rateBytesPerSecond > 0
@@ -1242,14 +1376,14 @@ function maybePrintFirmwareDebugProgress({
1242
1376
  : '';
1243
1377
 
1244
1378
  process.stderr.write(
1245
- `[onekey-hw] Firmware ${progressType}: ${formatFirmwareDebugProgress(
1379
+ `[onekey-hw] Firmware ${progressType}: ${formatFirmwareProgress(
1246
1380
  progress
1247
1381
  )}${sizeText}${speedText}\n`
1248
1382
  );
1249
1383
  return progress >= 100 ? 100 : printableProgress;
1250
1384
  }
1251
1385
 
1252
- function buildFirmwareUpdateV4DebugMetrics({
1386
+ function buildFirmwareUpdateV4Metrics({
1253
1387
  attempt,
1254
1388
  maxAttempts,
1255
1389
  totalBytes,
@@ -1309,7 +1443,7 @@ function buildFirmwareUpdateV4DebugMetrics({
1309
1443
  };
1310
1444
  }
1311
1445
 
1312
- async function runFirmwareUpdateV4DebugWithRetry({
1446
+ export async function runFirmwareUpdateV4WithRetry({
1313
1447
  sdk,
1314
1448
  globalOpts,
1315
1449
  params,
@@ -1317,156 +1451,154 @@ async function runFirmwareUpdateV4DebugWithRetry({
1317
1451
  }: {
1318
1452
  sdk: AnySdk;
1319
1453
  globalOpts: Record<string, any>;
1320
- params: ReturnType<typeof buildFirmwareUpdateV4DebugParams>;
1454
+ params: ReturnType<typeof buildFirmwareUpdateV4Params>;
1321
1455
  retries?: number;
1322
1456
  }) {
1323
- const totalBytes = getFirmwareUpdateV4DebugTotalBytes(params);
1457
+ const totalBytes = getFirmwareUpdateV4TotalBytes(params);
1324
1458
  const maxAttempts = Math.max((retries ?? 2) + 1, 1);
1325
1459
  let currentSdk = sdk;
1326
- let lastResult: unknown;
1327
1460
  let retried = false;
1328
-
1329
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1330
- let progressEvents = 0;
1331
- let lastProgress = -1;
1332
- let transferStartedAt: number | undefined;
1333
- let transferEndedAt: number | undefined;
1334
- let installProgressEvents = 0;
1335
- let lastInstallProgress = -1;
1336
- let installStartedAt: number | undefined;
1337
- let installEndedAt: number | undefined;
1338
- let lastPrintedTransferProgress = -10;
1339
- let lastPrintedInstallProgress = -10;
1340
- const totalStartedAt = Date.now();
1341
- const connectId =
1342
- retried && globalOpts.transport === 'usb' && globalOpts.connectId
1343
- ? undefined
1344
- : globalOpts.connectId;
1345
-
1346
- const onUiEvent = (message: unknown) => {
1347
- if (!message || typeof message !== 'object') return;
1348
- const messageType = (message as { type?: string }).type;
1349
- const payload = getFirmwareDebugPayload(message);
1350
-
1351
- if (messageType === UI_REQUEST.FIRMWARE_TIP) {
1352
- const tipMessage = (payload?.data as { message?: unknown } | undefined)?.message;
1353
- if (typeof tipMessage === 'string') {
1354
- process.stderr.write(`[onekey-hw] Firmware: ${tipMessage}\n`);
1355
- }
1356
- return;
1357
- }
1358
-
1359
- if (messageType === UI_REQUEST.REQUEST_BUTTON) {
1360
- const code = typeof payload?.code === 'string' ? ` (${payload.code})` : '';
1361
- process.stderr.write(
1362
- `[onekey-hw] Please confirm the firmware update on your device${code}.\n`
1363
- );
1364
- return;
1461
+ let attempt = 1;
1462
+ let { connectId } = globalOpts;
1463
+
1464
+ if (globalOpts.transport === 'usb') {
1465
+ for (; attempt <= maxAttempts; attempt += 1) {
1466
+ const probeResult = await currentSdk.getDeviceState(connectId, {
1467
+ scope: 'runtime',
1468
+ connectProtocol: 'V2',
1469
+ retryCount: 0,
1470
+ });
1471
+ if (isSuccessResult(probeResult)) break;
1472
+ if (attempt >= maxAttempts || !isProtocolV2UsbProbeTransientResult(probeResult)) {
1473
+ return probeResult;
1365
1474
  }
1366
1475
 
1367
- if (messageType !== UI_REQUEST.FIRMWARE_PROGRESS || !payload) return;
1368
- const progress = Number(payload.progress);
1369
- if (!Number.isFinite(progress)) return;
1370
-
1371
- if (payload.progressType === 'transferData') {
1372
- progressEvents += 1;
1373
- lastProgress = Math.max(lastProgress, progress);
1374
- transferStartedAt ??= Date.now();
1375
- lastPrintedTransferProgress = maybePrintFirmwareDebugProgress({
1376
- progressType: 'transfer',
1377
- progress,
1378
- payload,
1379
- lastPrintedProgress: lastPrintedTransferProgress,
1380
- });
1381
- if (progress >= 100) {
1382
- transferEndedAt ??= Date.now();
1383
- }
1384
- return;
1385
- }
1476
+ retried = true;
1477
+ process.stderr.write(
1478
+ `[onekey-hw] Protocol V2 USB probe was transient; retrying read-only probe (${attempt}/${maxAttempts})...\n`
1479
+ );
1480
+ await disposeSDK();
1481
+ await new Promise(resolve => {
1482
+ setTimeout(resolve, 3000);
1483
+ });
1484
+ currentSdk = await createSDK(globalOpts);
1485
+ if (globalOpts.connectId) connectId = undefined;
1486
+ }
1487
+ }
1386
1488
 
1387
- if (payload.progressType === 'installingFirmware') {
1388
- installProgressEvents += 1;
1389
- lastInstallProgress = Math.max(lastInstallProgress, progress);
1390
- installStartedAt ??= Date.now();
1391
- lastPrintedInstallProgress = maybePrintFirmwareDebugProgress({
1392
- progressType: 'install',
1393
- progress,
1394
- payload,
1395
- lastPrintedProgress: lastPrintedInstallProgress,
1396
- });
1397
- if (progress >= 100) {
1398
- installEndedAt ??= Date.now();
1399
- }
1489
+ let progressEvents = 0;
1490
+ let lastProgress = -1;
1491
+ let transferStartedAt: number | undefined;
1492
+ let transferEndedAt: number | undefined;
1493
+ let installProgressEvents = 0;
1494
+ let lastInstallProgress = -1;
1495
+ let installStartedAt: number | undefined;
1496
+ let installEndedAt: number | undefined;
1497
+ let lastPrintedTransferProgress = -10;
1498
+ let lastPrintedInstallProgress = -10;
1499
+ const totalStartedAt = Date.now();
1500
+
1501
+ const onUiEvent = (message: unknown) => {
1502
+ if (!message || typeof message !== 'object') return;
1503
+ const messageType = (message as { type?: string }).type;
1504
+ const payload = getFirmwareUpdatePayload(message);
1505
+
1506
+ if (messageType === UI_REQUEST.FIRMWARE_TIP) {
1507
+ const tipMessage = (payload?.data as { message?: unknown } | undefined)?.message;
1508
+ if (typeof tipMessage === 'string') {
1509
+ process.stderr.write(`[onekey-hw] Firmware: ${tipMessage}\n`);
1400
1510
  }
1401
- };
1402
-
1403
- currentSdk.on(UI_EVENT, onUiEvent);
1404
- try {
1405
- lastResult = await currentSdk.firmwareUpdateV4(connectId, params);
1406
- } finally {
1407
- currentSdk.off?.(UI_EVENT, onUiEvent);
1408
- }
1409
- if (installStartedAt !== undefined && installEndedAt === undefined) {
1410
- installEndedAt = Date.now();
1511
+ return;
1411
1512
  }
1412
1513
 
1413
- const debugMetrics = buildFirmwareUpdateV4DebugMetrics({
1414
- attempt,
1415
- maxAttempts,
1416
- totalBytes,
1417
- totalStartedAt,
1418
- transferStartedAt,
1419
- transferEndedAt,
1420
- installStartedAt,
1421
- installEndedAt,
1422
- progressEvents,
1423
- lastProgress,
1424
- installProgressEvents,
1425
- lastInstallProgress,
1426
- retried,
1427
- });
1428
-
1429
- if (lastResult && typeof lastResult === 'object') {
1430
- const payload = ((lastResult as { payload?: unknown }).payload ?? {}) as Record<
1431
- string,
1432
- unknown
1433
- >;
1434
- lastResult = {
1435
- ...(lastResult as Record<string, unknown>),
1436
- payload: {
1437
- ...payload,
1438
- _debug: debugMetrics,
1439
- },
1440
- };
1514
+ if (messageType === UI_REQUEST.REQUEST_BUTTON) {
1515
+ const code = typeof payload?.code === 'string' ? ` (${payload.code})` : '';
1516
+ process.stderr.write(
1517
+ `[onekey-hw] Please confirm the firmware update on your device${code}.\n`
1518
+ );
1519
+ return;
1441
1520
  }
1442
1521
 
1443
- if (isSuccessResult(lastResult)) {
1444
- return lastResult;
1522
+ if (messageType !== UI_REQUEST.FIRMWARE_PROGRESS || !payload) return;
1523
+ const progress = Number(payload.progress);
1524
+ if (!Number.isFinite(progress)) return;
1525
+
1526
+ if (payload.progressType === 'transferData') {
1527
+ progressEvents += 1;
1528
+ lastProgress = Math.max(lastProgress, progress);
1529
+ transferStartedAt ??= Date.now();
1530
+ lastPrintedTransferProgress = maybePrintFirmwareProgress({
1531
+ progressType: 'transfer',
1532
+ progress,
1533
+ payload,
1534
+ lastPrintedProgress: lastPrintedTransferProgress,
1535
+ });
1536
+ if (progress >= 100) {
1537
+ transferEndedAt ??= Date.now();
1538
+ }
1539
+ return;
1445
1540
  }
1446
1541
 
1447
- if (
1448
- attempt >= maxAttempts ||
1449
- globalOpts.transport !== 'usb' ||
1450
- !isProtocolV2UsbProbeTransientResult(lastResult)
1451
- ) {
1452
- return lastResult;
1542
+ if (payload.progressType === 'installingFirmware') {
1543
+ installProgressEvents += 1;
1544
+ lastInstallProgress = Math.max(lastInstallProgress, progress);
1545
+ installStartedAt ??= Date.now();
1546
+ lastPrintedInstallProgress = maybePrintFirmwareProgress({
1547
+ progressType: 'install',
1548
+ progress,
1549
+ payload,
1550
+ lastPrintedProgress: lastPrintedInstallProgress,
1551
+ });
1552
+ if (progress >= 100) {
1553
+ installEndedAt ??= Date.now();
1554
+ }
1453
1555
  }
1556
+ };
1454
1557
 
1455
- retried = true;
1456
- process.stderr.write(
1457
- `[onekey-hw] Protocol V2 USB probe was transient; retrying firmwareUpdateV4 (${attempt}/${maxAttempts})...\n`
1458
- );
1459
- await disposeSDK();
1460
- await new Promise(resolve => setTimeout(resolve, 3000));
1461
- currentSdk = await createSDK(globalOpts);
1558
+ currentSdk.on(UI_EVENT, onUiEvent);
1559
+ let result: unknown;
1560
+ try {
1561
+ result = await currentSdk.firmwareUpdateV4(connectId, params);
1562
+ } finally {
1563
+ currentSdk.off?.(UI_EVENT, onUiEvent);
1564
+ }
1565
+ if (installStartedAt !== undefined && installEndedAt === undefined) {
1566
+ installEndedAt = Date.now();
1567
+ }
1568
+
1569
+ const metrics = buildFirmwareUpdateV4Metrics({
1570
+ attempt,
1571
+ maxAttempts,
1572
+ totalBytes,
1573
+ totalStartedAt,
1574
+ transferStartedAt,
1575
+ transferEndedAt,
1576
+ installStartedAt,
1577
+ installEndedAt,
1578
+ progressEvents,
1579
+ lastProgress,
1580
+ installProgressEvents,
1581
+ lastInstallProgress,
1582
+ retried,
1583
+ });
1584
+
1585
+ if (result && typeof result === 'object') {
1586
+ const payload = ((result as { payload?: unknown }).payload ?? {}) as Record<string, unknown>;
1587
+ return {
1588
+ ...(result as Record<string, unknown>),
1589
+ payload: {
1590
+ ...payload,
1591
+ metrics,
1592
+ },
1593
+ };
1462
1594
  }
1463
1595
 
1464
- return lastResult;
1596
+ return result;
1465
1597
  }
1466
1598
 
1467
- function buildFirmwareUpdateV4DebugParams(opts: {
1599
+ function buildFirmwareUpdateV4Params(opts: {
1468
1600
  chunkSize?: string;
1469
- resource?: string;
1601
+ resourceFile?: string[];
1470
1602
  romloader?: string;
1471
1603
  bootloader?: string;
1472
1604
  applicationP1?: string;
@@ -1483,7 +1615,7 @@ function buildFirmwareUpdateV4DebugParams(opts: {
1483
1615
  connectProtocol: 'V2' as const,
1484
1616
  chunkSize: opts.chunkSize ? safeParseInt(opts.chunkSize, '--chunk-size') : undefined,
1485
1617
  forcedUpdateRes: opts.forcedUpdateRes,
1486
- resourceBinaries: opts.resource ? [readBinaryParam(opts.resource)] : undefined,
1618
+ resourceFiles: opts.resourceFile?.map(parseResourceFileParam),
1487
1619
  romloaderBinary: opts.romloader ? readBinaryParam(opts.romloader) : undefined,
1488
1620
  bootloaderBinary: opts.bootloader ? readBinaryParam(opts.bootloader) : undefined,
1489
1621
  applicationP1Binary: opts.applicationP1 ? readBinaryParam(opts.applicationP1) : undefined,
@@ -1496,7 +1628,7 @@ function buildFirmwareUpdateV4DebugParams(opts: {
1496
1628
  };
1497
1629
 
1498
1630
  const hasPayload = [
1499
- params.resourceBinaries,
1631
+ params.resourceFiles,
1500
1632
  params.romloaderBinary,
1501
1633
  params.bootloaderBinary,
1502
1634
  params.applicationP1Binary,
@@ -1509,7 +1641,7 @@ function buildFirmwareUpdateV4DebugParams(opts: {
1509
1641
  ].some(Boolean);
1510
1642
 
1511
1643
  if (!hasPayload) {
1512
- const err = new Error('firmware-update-v4-debug requires at least one binary path');
1644
+ const err = new Error('firmware-update-v4 requires at least one binary path');
1513
1645
  (err as Error & { code?: string }).code = 'MISSING_FIRMWARE_BINARY';
1514
1646
  throw err;
1515
1647
  }
@@ -1528,4 +1660,8 @@ function safeParseInt(input: string, label: string): number {
1528
1660
  return num;
1529
1661
  }
1530
1662
 
1531
- program.parse();
1663
+ export { program };
1664
+
1665
+ if (require.main === module) {
1666
+ program.parse();
1667
+ }