@onekeyfe/hardware-cli 1.1.26-alpha.106 → 1.1.26-alpha.4

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.
Files changed (41) hide show
  1. package/.eslintignore +4 -0
  2. package/dist/chains.d.ts +6 -0
  3. package/dist/chains.js +191 -87
  4. package/dist/cli.js +615 -496
  5. package/dist/index.d.ts +16 -89
  6. package/dist/index.js +1 -2
  7. package/dist/sdk.d.ts +15 -5
  8. package/dist/sdk.js +237 -131
  9. package/dist/session.d.ts +22 -0
  10. package/dist/session.js +83 -0
  11. package/dist/storage/index.d.ts +2 -0
  12. package/dist/storage/index.js +5 -0
  13. package/dist/storage/process-utils.d.ts +2 -0
  14. package/dist/storage/process-utils.js +44 -0
  15. package/dist/storage/secure-storage.linux.d.ts +11 -0
  16. package/dist/storage/secure-storage.linux.js +59 -0
  17. package/dist/storage/secure-storage.macos.d.ts +11 -0
  18. package/dist/storage/secure-storage.macos.js +65 -0
  19. package/dist/storage/storage-factory.d.ts +3 -0
  20. package/dist/storage/storage-factory.js +14 -0
  21. package/dist/storage/types.d.ts +18 -0
  22. package/dist/storage/types.js +2 -0
  23. package/package.json +15 -13
  24. package/src/chains.ts +229 -85
  25. package/src/cli.ts +620 -297
  26. package/src/sdk.ts +244 -125
  27. package/src/session.ts +89 -0
  28. package/src/storage/index.ts +2 -0
  29. package/src/storage/process-utils.ts +50 -0
  30. package/src/storage/secure-storage.linux.ts +68 -0
  31. package/src/storage/secure-storage.macos.ts +68 -0
  32. package/src/storage/storage-factory.ts +13 -0
  33. package/src/storage/types.ts +17 -0
  34. package/tsconfig.json +5 -7
  35. package/.claude-plugin/plugin.json +0 -14
  36. package/AGENTS.md +0 -40
  37. package/CLAUDE.md +0 -40
  38. package/README.md +0 -112
  39. package/evals/cases.json +0 -373
  40. package/evals/run-evals.sh +0 -136
  41. package/rollup.config.js +0 -28
package/src/cli.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { Command } from 'commander';
2
2
 
3
- import { createSDK } from './sdk';
3
+ import { createSDK, disposeSDK } from './sdk';
4
+ import {
5
+ clearSessionFromKeychain,
6
+ preloadSessionFromKeychain,
7
+ saveSessionToKeychain,
8
+ } from './session';
4
9
  import {
5
10
  resolveBatchGetAddress,
6
11
  resolveGetAddress,
@@ -9,12 +14,23 @@ import {
9
14
  resolveSignTransaction,
10
15
  } from './chains';
11
16
 
17
+ import type {
18
+ EthereumSignTypedDataMessage,
19
+ EthereumSignTypedDataTypes,
20
+ Features,
21
+ IDeviceType,
22
+ SearchDevice,
23
+ } from '@onekeyfe/hd-core';
24
+
25
+ /** SearchDevice enriched with features fetched after discovery */
26
+ type EnrichedSearchDevice = SearchDevice & { features?: Features };
27
+
12
28
  const program = new Command();
13
29
 
14
30
  program
15
31
  .name('onekey-hw')
16
32
  .description('OneKey hardware wallet CLI for AI agent integration')
17
- .version('1.1.25-alpha.1');
33
+ .version('1.1.26-alpha.1');
18
34
 
19
35
  // ============================================================
20
36
  // Global Options
@@ -35,23 +51,23 @@ program.option('--use-empty-passphrase', 'Use standard wallet (skip passphrase p
35
51
  program
36
52
  .command('search')
37
53
  .description('Search for connected OneKey hardware wallet devices')
38
- .action(async () => {
39
- const globalOpts = program.opts();
40
- const sdk = await createSDK(globalOpts);
41
- try {
54
+ .action(() =>
55
+ runCommand({}, async ({ sdk, globalOpts }) => {
42
56
  const result = await sdk.searchDevices();
43
57
 
44
58
  // Auto-fetch features for each discovered device (doesn't require PIN)
45
59
  if (result?.success && Array.isArray(result.payload)) {
46
- for (const device of result.payload) {
60
+ for (const device of result.payload as EnrichedSearchDevice[]) {
47
61
  if (device.connectId) {
48
62
  try {
49
63
  const features = await sdk.getFeatures(device.connectId);
50
64
  if (features?.success && features.payload) {
51
65
  device.features = features.payload;
52
66
  device.name = features.payload.label || features.payload.ble_name || device.name;
53
- device.deviceType =
54
- features.payload.onekey_device_type?.toLowerCase() || device.deviceType;
67
+ const devType = features.payload.onekey_device_type?.toLowerCase();
68
+ if (devType) {
69
+ device.deviceType = devType as IDeviceType;
70
+ }
55
71
  }
56
72
  } catch {
57
73
  // Features fetch failed — device may need PIN, continue with basic info
@@ -61,10 +77,35 @@ program
61
77
  }
62
78
 
63
79
  outputResult(globalOpts, result);
64
- } finally {
65
- sdk.dispose();
66
- }
67
- });
80
+ })
81
+ );
82
+
83
+ program
84
+ .command('get-features')
85
+ .description('Get device features (firmware, unlock state, passphrase protection, etc.)')
86
+ .action(() =>
87
+ runCommand({}, async ({ sdk, globalOpts }) => {
88
+ // Resolve connectId: explicit flag wins, else pick the first attached device
89
+ let { connectId } = globalOpts as { connectId?: string };
90
+ if (!connectId) {
91
+ const searchResult = await sdk.searchDevices();
92
+ if (
93
+ !searchResult?.success ||
94
+ !Array.isArray(searchResult.payload) ||
95
+ searchResult.payload.length === 0
96
+ ) {
97
+ outputResult(globalOpts, {
98
+ success: false,
99
+ payload: { error: 'No device found', code: 'NO_DEVICE' },
100
+ });
101
+ return;
102
+ }
103
+ connectId = (searchResult.payload[0] as EnrichedSearchDevice).connectId ?? undefined;
104
+ }
105
+ const result = await sdk.getFeatures(connectId || '');
106
+ outputResult(globalOpts, result);
107
+ })
108
+ );
68
109
 
69
110
  // ============================================================
70
111
  // Signing Commands
@@ -76,41 +117,33 @@ program
76
117
  .requiredOption('--chain <chain>', 'Target blockchain (evm, btc, sol, ...)')
77
118
  .option('--path <path>', 'BIP44 derivation path')
78
119
  .option('--show-on-device <bool>', 'Display address on device for verification', 'true')
79
- .action(async opts => {
80
- const globalOpts = program.opts();
81
- const sdk = await createSDK(globalOpts);
82
- try {
120
+ .action(opts =>
121
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
83
122
  const result = await resolveGetAddress(sdk, {
84
123
  chain: opts.chain,
85
124
  path: opts.path,
86
125
  showOnDevice: opts.showOnDevice === 'true',
87
- ...getCommonParams(globalOpts),
126
+ ...params,
88
127
  });
89
128
  outputResult(globalOpts, result);
90
- } finally {
91
- sdk.dispose();
92
- }
93
- });
129
+ })
130
+ );
94
131
 
95
132
  program
96
133
  .command('get-public-key')
97
134
  .description('Get public key from the hardware wallet')
98
135
  .requiredOption('--chain <chain>', 'Target blockchain')
99
136
  .option('--path <path>', 'BIP44 derivation path')
100
- .action(async opts => {
101
- const globalOpts = program.opts();
102
- const sdk = await createSDK(globalOpts);
103
- try {
137
+ .action(opts =>
138
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
104
139
  const result = await resolveGetPublicKey(sdk, {
105
140
  chain: opts.chain,
106
141
  path: opts.path,
107
- ...getCommonParams(globalOpts),
142
+ ...params,
108
143
  });
109
144
  outputResult(globalOpts, result);
110
- } finally {
111
- sdk.dispose();
112
- }
113
- });
145
+ })
146
+ );
114
147
 
115
148
  program
116
149
  .command('sign-transaction')
@@ -118,22 +151,18 @@ program
118
151
  .requiredOption('--chain <chain>', 'Target blockchain')
119
152
  .requiredOption('--tx <json>', 'Transaction data (JSON)')
120
153
  .option('--path <path>', 'BIP44 derivation path')
121
- .action(async opts => {
122
- const globalOpts = program.opts();
123
- const sdk = await createSDK(globalOpts);
124
- try {
154
+ .action(opts =>
155
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
125
156
  const tx = safeJsonParse(opts.tx, '--tx') as Record<string, unknown>;
126
157
  const result = await resolveSignTransaction(sdk, {
127
158
  chain: opts.chain,
128
159
  path: opts.path,
129
160
  transaction: tx,
130
- ...getCommonParams(globalOpts),
161
+ ...params,
131
162
  });
132
163
  outputResult(globalOpts, result);
133
- } finally {
134
- sdk.dispose();
135
- }
136
- });
164
+ })
165
+ );
137
166
 
138
167
  program
139
168
  .command('sign-message')
@@ -141,67 +170,56 @@ program
141
170
  .requiredOption('--chain <chain>', 'Target blockchain')
142
171
  .requiredOption('--message <msg>', 'Message to sign')
143
172
  .option('--path <path>', 'BIP44 derivation path')
144
- .action(async opts => {
145
- const globalOpts = program.opts();
146
- const sdk = await createSDK(globalOpts);
147
- try {
173
+ .action(opts =>
174
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
148
175
  const result = await resolveSignMessage(sdk, {
149
176
  chain: opts.chain,
150
177
  path: opts.path,
151
178
  message: opts.message,
152
- ...getCommonParams(globalOpts),
179
+ ...params,
153
180
  });
154
181
  outputResult(globalOpts, result);
155
- } finally {
156
- sdk.dispose();
157
- }
158
- });
182
+ })
183
+ );
159
184
 
160
185
  program
161
186
  .command('sign-typed-data')
162
187
  .description('Sign EIP-712 typed data (EVM only, requires device confirmation)')
163
188
  .requiredOption('--data <json>', 'EIP-712 typed data JSON')
164
189
  .option('--path <path>', 'BIP44 derivation path')
165
- .option('--metamask-v4-compat', 'Use MetaMask V4 compatibility mode', true)
166
- .action(async opts => {
167
- const globalOpts = program.opts();
168
- const sdk = await createSDK(globalOpts);
169
- try {
170
- const data = safeJsonParse(opts.data, '--data');
171
- const params = getCommonParams(globalOpts);
190
+ .option('--no-metamask-v4-compat', 'Disable MetaMask V4 compatibility mode')
191
+ .action(opts =>
192
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
193
+ const data = safeJsonParse(
194
+ opts.data,
195
+ '--data'
196
+ ) as EthereumSignTypedDataMessage<EthereumSignTypedDataTypes>;
172
197
  const path = opts.path || "m/44'/60'/0'/0/0";
173
198
  const result = await sdk.evmSignTypedData(params.connectId || '', params.deviceId || '', {
174
199
  path,
175
200
  metamaskV4Compat: opts.metamaskV4Compat,
176
201
  data,
177
202
  ...params,
178
- } as any);
203
+ });
179
204
  outputResult(globalOpts, result);
180
- } finally {
181
- sdk.dispose();
182
- }
183
- });
205
+ })
206
+ );
184
207
 
185
208
  program
186
209
  .command('sign-psbt')
187
210
  .description('Sign a Bitcoin PSBT (Pro/Classic1s only, requires device confirmation)')
188
211
  .requiredOption('--psbt <hex>', 'Hex-encoded PSBT data')
189
212
  .option('--coin <coin>', 'Bitcoin network: btc, ltc, etc.', 'btc')
190
- .action(async opts => {
191
- const globalOpts = program.opts();
192
- const sdk = await createSDK(globalOpts);
193
- try {
194
- const params = getCommonParams(globalOpts);
213
+ .action(opts =>
214
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
195
215
  const result = await sdk.btcSignPsbt(params.connectId || '', params.deviceId || '', {
196
216
  psbt: opts.psbt,
197
217
  coin: opts.coin,
198
218
  ...params,
199
- } as any);
219
+ });
200
220
  outputResult(globalOpts, result);
201
- } finally {
202
- sdk.dispose();
203
- }
204
- });
221
+ })
222
+ );
205
223
 
206
224
  program
207
225
  .command('verify-message')
@@ -210,11 +228,8 @@ program
210
228
  .requiredOption('--address <addr>', 'Signer address')
211
229
  .requiredOption('--message <msg>', 'Original message')
212
230
  .requiredOption('--signature <sig>', 'Signature to verify')
213
- .action(async opts => {
214
- const globalOpts = program.opts();
215
- const sdk = await createSDK(globalOpts);
216
- try {
217
- const params = getCommonParams(globalOpts);
231
+ .action(opts =>
232
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
218
233
  const cid = params.connectId || '';
219
234
  const did = params.deviceId || '';
220
235
  let result: unknown;
@@ -226,23 +241,26 @@ program
226
241
  address: opts.address,
227
242
  messageHex: opts.message,
228
243
  signature: opts.signature,
244
+ ...params,
229
245
  });
230
246
  break;
231
247
  case 'btc':
232
248
  case 'bitcoin':
233
249
  result = await sdk.btcVerifyMessage(cid, did, {
234
250
  address: opts.address,
235
- message: opts.message,
251
+ messageHex: opts.message,
236
252
  signature: opts.signature,
237
253
  coin: 'btc',
254
+ ...params,
238
255
  });
239
256
  break;
240
257
  case 'starcoin':
241
258
  case 'stc':
242
259
  result = await sdk.starcoinVerifyMessage(cid, did, {
243
260
  publicKey: opts.address,
244
- message: opts.message,
261
+ messageHex: opts.message,
245
262
  signature: opts.signature,
263
+ ...params,
246
264
  });
247
265
  break;
248
266
  default:
@@ -251,19 +269,15 @@ program
251
269
  );
252
270
  }
253
271
  outputResult(globalOpts, result);
254
- } finally {
255
- sdk.dispose();
256
- }
257
- });
272
+ })
273
+ );
258
274
 
259
275
  program
260
276
  .command('batch-get-address')
261
277
  .description('Get addresses for multiple chains/paths in a single session')
262
278
  .requiredOption('--bundle <json>', 'JSON array of {chain, path, showOnDevice}')
263
- .action(async opts => {
264
- const globalOpts = program.opts();
265
- const sdk = await createSDK(globalOpts);
266
- try {
279
+ .action(opts =>
280
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
267
281
  const bundle = safeJsonParse(opts.bundle, '--bundle') as Array<{
268
282
  chain: string;
269
283
  path?: string;
@@ -271,13 +285,11 @@ program
271
285
  }>;
272
286
  const result = await resolveBatchGetAddress(sdk, {
273
287
  bundle,
274
- ...getCommonParams(globalOpts),
288
+ ...params,
275
289
  });
276
290
  outputResult(globalOpts, result);
277
- } finally {
278
- sdk.dispose();
279
- }
280
- });
291
+ })
292
+ );
281
293
 
282
294
  // ============================================================
283
295
  // Chain-Specific Commands
@@ -289,41 +301,37 @@ program
289
301
  .requiredOption('--domain-hash <hex>', 'EIP-712 domain separator hash')
290
302
  .requiredOption('--message-hash <hex>', 'EIP-712 message hash')
291
303
  .option('--path <path>', 'BIP44 derivation path', "m/44'/60'/0'/0/0")
292
- .action(async opts => {
293
- const globalOpts = program.opts();
294
- const sdk = await createSDK(globalOpts);
295
- try {
296
- const p = getCommonParams(globalOpts);
297
- const result = await sdk.evmSignMessageEIP712(p.connectId || '', p.deviceId || '', {
304
+ .action(opts =>
305
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
306
+ const result = await sdk.evmSignMessageEIP712(params.connectId || '', params.deviceId || '', {
298
307
  path: opts.path,
299
308
  domainHash: opts.domainHash,
300
309
  messageHash: opts.messageHash,
310
+ ...params,
301
311
  });
302
312
  outputResult(globalOpts, result);
303
- } finally {
304
- sdk.dispose();
305
- }
306
- });
313
+ })
314
+ );
307
315
 
308
316
  program
309
317
  .command('sol-sign-offchain')
310
318
  .description('Sign a Solana off-chain message (requires device confirmation)')
311
319
  .requiredOption('--message-hex <hex>', 'Off-chain message as hex')
312
320
  .option('--path <path>', 'BIP44 derivation path', "m/44'/501'/0'/0'")
313
- .action(async opts => {
314
- const globalOpts = program.opts();
315
- const sdk = await createSDK(globalOpts);
316
- try {
317
- const p = getCommonParams(globalOpts);
318
- const result = await sdk.solSignOffchainMessage(p.connectId || '', p.deviceId || '', {
319
- path: opts.path,
320
- messageHex: opts.messageHex,
321
- });
321
+ .action(opts =>
322
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
323
+ const result = await sdk.solSignOffchainMessage(
324
+ params.connectId || '',
325
+ params.deviceId || '',
326
+ {
327
+ path: opts.path,
328
+ messageHex: opts.messageHex,
329
+ ...params,
330
+ }
331
+ );
322
332
  outputResult(globalOpts, result);
323
- } finally {
324
- sdk.dispose();
325
- }
326
- });
333
+ })
334
+ );
327
335
 
328
336
  program
329
337
  .command('nostr-encrypt')
@@ -332,22 +340,18 @@ program
332
340
  .requiredOption('--plaintext <text>', 'Message to encrypt')
333
341
  .option('--path <path>', 'BIP44 derivation path', "m/44'/1237'/0'/0/0")
334
342
  .option('--show-on-device <bool>', 'Display on device', 'false')
335
- .action(async opts => {
336
- const globalOpts = program.opts();
337
- const sdk = await createSDK(globalOpts);
338
- try {
339
- const p = getCommonParams(globalOpts);
340
- const result = await sdk.nostrEncryptMessage(p.connectId || '', p.deviceId || '', {
343
+ .action(opts =>
344
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
345
+ const result = await sdk.nostrEncryptMessage(params.connectId || '', params.deviceId || '', {
341
346
  path: opts.path,
342
347
  pubkey: opts.pubkey,
343
348
  plaintext: opts.plaintext,
344
349
  showOnOneKey: opts.showOnDevice === 'true',
350
+ ...params,
345
351
  });
346
352
  outputResult(globalOpts, result);
347
- } finally {
348
- sdk.dispose();
349
- }
350
- });
353
+ })
354
+ );
351
355
 
352
356
  program
353
357
  .command('nostr-decrypt')
@@ -356,62 +360,50 @@ program
356
360
  .requiredOption('--ciphertext <text>', 'Encrypted message')
357
361
  .option('--path <path>', 'BIP44 derivation path', "m/44'/1237'/0'/0/0")
358
362
  .option('--show-on-device <bool>', 'Display on device', 'false')
359
- .action(async opts => {
360
- const globalOpts = program.opts();
361
- const sdk = await createSDK(globalOpts);
362
- try {
363
- const p = getCommonParams(globalOpts);
364
- const result = await sdk.nostrDecryptMessage(p.connectId || '', p.deviceId || '', {
363
+ .action(opts =>
364
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
365
+ const result = await sdk.nostrDecryptMessage(params.connectId || '', params.deviceId || '', {
365
366
  path: opts.path,
366
367
  pubkey: opts.pubkey,
367
368
  ciphertext: opts.ciphertext,
368
369
  showOnOneKey: opts.showOnDevice === 'true',
370
+ ...params,
369
371
  });
370
372
  outputResult(globalOpts, result);
371
- } finally {
372
- sdk.dispose();
373
- }
374
- });
373
+ })
374
+ );
375
375
 
376
376
  program
377
377
  .command('nostr-sign-schnorr')
378
378
  .description('Sign a Schnorr signature for Nostr')
379
379
  .requiredOption('--hash <hex>', 'Hash to sign (hex)')
380
380
  .option('--path <path>', 'BIP44 derivation path', "m/44'/1237'/0'/0/0")
381
- .action(async opts => {
382
- const globalOpts = program.opts();
383
- const sdk = await createSDK(globalOpts);
384
- try {
385
- const p = getCommonParams(globalOpts);
386
- const result = await sdk.nostrSignSchnorr(p.connectId || '', p.deviceId || '', {
381
+ .action(opts =>
382
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
383
+ const result = await sdk.nostrSignSchnorr(params.connectId || '', params.deviceId || '', {
387
384
  path: opts.path,
388
385
  hash: opts.hash,
386
+ ...params,
389
387
  });
390
388
  outputResult(globalOpts, result);
391
- } finally {
392
- sdk.dispose();
393
- }
394
- });
389
+ })
390
+ );
395
391
 
396
392
  program
397
393
  .command('lnurl-auth')
398
394
  .description('Authenticate with LNURL (Lightning Network)')
399
395
  .requiredOption('--domain <domain>', 'Service domain')
400
396
  .requiredOption('--k1 <hex>', 'Challenge k1 parameter')
401
- .action(async opts => {
402
- const globalOpts = program.opts();
403
- const sdk = await createSDK(globalOpts);
404
- try {
405
- const p = getCommonParams(globalOpts);
406
- const result = await sdk.lnurlAuth(p.connectId || '', p.deviceId || '', {
397
+ .action(opts =>
398
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
399
+ const result = await sdk.lnurlAuth(params.connectId || '', params.deviceId || '', {
407
400
  domain: opts.domain,
408
401
  k1: opts.k1,
402
+ ...params,
409
403
  });
410
404
  outputResult(globalOpts, result);
411
- } finally {
412
- sdk.dispose();
413
- }
414
- });
405
+ })
406
+ );
415
407
 
416
408
  program
417
409
  .command('conflux-sign-cip23')
@@ -419,41 +411,37 @@ program
419
411
  .requiredOption('--domain-hash <hex>', 'CIP-23 domain hash')
420
412
  .requiredOption('--message-hash <hex>', 'CIP-23 message hash')
421
413
  .option('--path <path>', 'BIP44 derivation path', "m/44'/503'/0'/0/0")
422
- .action(async opts => {
423
- const globalOpts = program.opts();
424
- const sdk = await createSDK(globalOpts);
425
- try {
426
- const p = getCommonParams(globalOpts);
427
- const result = await sdk.confluxSignMessageCIP23(p.connectId || '', p.deviceId || '', {
428
- path: opts.path,
429
- domainHash: opts.domainHash,
430
- messageHash: opts.messageHash,
431
- });
414
+ .action(opts =>
415
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
416
+ const result = await sdk.confluxSignMessageCIP23(
417
+ params.connectId || '',
418
+ params.deviceId || '',
419
+ {
420
+ path: opts.path,
421
+ domainHash: opts.domainHash,
422
+ messageHash: opts.messageHash,
423
+ ...params,
424
+ }
425
+ );
432
426
  outputResult(globalOpts, result);
433
- } finally {
434
- sdk.dispose();
435
- }
436
- });
427
+ })
428
+ );
437
429
 
438
430
  program
439
431
  .command('aptos-sign-in')
440
432
  .description('Sign an Aptos sign-in message')
441
433
  .requiredOption('--payload <text>', 'Sign-in payload string')
442
434
  .option('--path <path>', 'BIP44 derivation path', "m/44'/637'/0'/0'/0'")
443
- .action(async opts => {
444
- const globalOpts = program.opts();
445
- const sdk = await createSDK(globalOpts);
446
- try {
447
- const p = getCommonParams(globalOpts);
448
- const result = await sdk.aptosSignInMessage(p.connectId || '', p.deviceId || '', {
435
+ .action(opts =>
436
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
437
+ const result = await sdk.aptosSignInMessage(params.connectId || '', params.deviceId || '', {
449
438
  path: opts.path,
450
439
  payload: opts.payload,
440
+ ...params,
451
441
  });
452
442
  outputResult(globalOpts, result);
453
- } finally {
454
- sdk.dispose();
455
- }
456
- });
443
+ })
444
+ );
457
445
 
458
446
  program
459
447
  .command('ton-sign-proof')
@@ -462,22 +450,18 @@ program
462
450
  .requiredOption('--expire-at <timestamp>', 'Proof expiration timestamp')
463
451
  .option('--comment <text>', 'Optional comment')
464
452
  .option('--path <path>', 'BIP44 derivation path', "m/44'/607'/0'")
465
- .action(async opts => {
466
- const globalOpts = program.opts();
467
- const sdk = await createSDK(globalOpts);
468
- try {
469
- const p = getCommonParams(globalOpts);
470
- const result = await sdk.tonSignProof(p.connectId || '', p.deviceId || '', {
453
+ .action(opts =>
454
+ runCommand({ needsSession: true }, async ({ sdk, globalOpts, params }) => {
455
+ const result = await sdk.tonSignProof(params.connectId || '', params.deviceId || '', {
471
456
  path: opts.path,
472
457
  appdomain: opts.appdomain,
473
458
  expireAt: safeParseInt(opts.expireAt, '--expire-at'),
474
459
  ...(opts.comment ? { comment: opts.comment } : {}),
460
+ ...params,
475
461
  });
476
462
  outputResult(globalOpts, result);
477
- } finally {
478
- sdk.dispose();
479
- }
480
- });
463
+ })
464
+ );
481
465
 
482
466
  // ============================================================
483
467
  // Firmware Commands
@@ -486,72 +470,60 @@ program
486
470
  program
487
471
  .command('firmware-check')
488
472
  .description('Check if firmware updates are available')
489
- .action(async () => {
490
- const globalOpts = program.opts();
491
- const sdk = await createSDK(globalOpts);
492
- try {
473
+ .action(() =>
474
+ runCommand({}, async ({ sdk, globalOpts }) => {
493
475
  const result = await sdk.checkFirmwareRelease(globalOpts.connectId);
494
476
  outputResult(globalOpts, result);
495
- } finally {
496
- sdk.dispose();
497
- }
498
- });
477
+ })
478
+ );
499
479
 
500
480
  program
501
481
  .command('firmware-check-all')
502
482
  .description('Check all firmware components (system, BLE, bootloader)')
503
- .action(async () => {
504
- const globalOpts = program.opts();
505
- const sdk = await createSDK(globalOpts);
506
- try {
483
+ .action(() =>
484
+ runCommand({}, async ({ sdk, globalOpts }) => {
507
485
  const result = await sdk.checkAllFirmwareRelease(globalOpts.connectId);
508
486
  outputResult(globalOpts, result);
509
- } finally {
510
- sdk.dispose();
511
- }
512
- });
487
+ })
488
+ );
513
489
 
514
490
  program
515
491
  .command('firmware-update')
516
492
  .description('Firmware update is not supported via CLI')
517
- .action(() => {
518
- outputResult(program.opts(), {
493
+ .action(() =>
494
+ respondAndExit({
519
495
  success: false,
520
496
  payload: {
521
497
  error:
522
498
  'Firmware update via CLI is not supported. Please use the OneKey App or https://firmware.onekey.so/ to update firmware.',
523
499
  code: 'FIRMWARE_UPDATE_NOT_SUPPORTED',
524
500
  },
525
- });
526
- });
501
+ })
502
+ );
527
503
 
528
504
  program
529
505
  .command('firmware-update-ble')
530
506
  .description('BLE firmware update is not supported via CLI')
531
- .action(() => {
532
- outputResult(program.opts(), {
507
+ .action(() =>
508
+ respondAndExit({
533
509
  success: false,
534
510
  payload: {
535
511
  error:
536
512
  'BLE firmware update via CLI is not supported. Please use the OneKey App or https://firmware.onekey.so/ to update firmware.',
537
513
  code: 'FIRMWARE_UPDATE_NOT_SUPPORTED',
538
514
  },
539
- });
540
- });
515
+ })
516
+ );
541
517
 
542
518
  program
543
519
  .command('bootloader-check')
544
520
  .description('Check bootloader version and status')
545
- .action(async () => {
546
- const globalOpts = program.opts();
547
- const sdk = await createSDK(globalOpts);
548
- try {
521
+ .action(() =>
522
+ runCommand({}, async ({ sdk, globalOpts }) => {
549
523
  const result = await sdk.checkBootloaderRelease(globalOpts.connectId);
550
524
  outputResult(globalOpts, result);
551
- } finally {
552
- sdk.dispose();
553
- }
554
- });
525
+ })
526
+ );
555
527
 
556
528
  // ============================================================
557
529
  // Security / Management Commands
@@ -561,64 +533,59 @@ program
561
533
  .command('change-pin')
562
534
  .description('Change or set the device PIN code')
563
535
  .option('--remove', 'Remove PIN protection instead of changing')
564
- .action(async opts => {
565
- const globalOpts = program.opts();
566
- const sdk = await createSDK(globalOpts);
567
- try {
536
+ .action(opts =>
537
+ runCommand({}, async ({ sdk, globalOpts }) => {
568
538
  const result = await sdk.deviceChangePin(globalOpts.connectId, {
569
539
  remove: opts.remove ?? false,
570
- } as any);
540
+ });
571
541
  outputResult(globalOpts, result);
572
- } finally {
573
- sdk.dispose();
574
- }
575
- });
542
+ })
543
+ );
576
544
 
577
545
  program
578
546
  .command('passphrase-state')
579
547
  .description('Get current passphrase state (for hidden wallet session management)')
580
- .action(async () => {
581
- const globalOpts = program.opts();
582
- const sdk = await createSDK(globalOpts);
583
- try {
548
+ .action(() =>
549
+ runCommand({}, async ({ sdk, globalOpts }) => {
584
550
  const result = await sdk.getPassphraseState(globalOpts.connectId, {
585
551
  useEmptyPassphrase: globalOpts.useEmptyPassphrase,
586
- } as any);
552
+ });
587
553
  outputResult(globalOpts, result);
588
- } finally {
589
- sdk.dispose();
590
- }
591
- });
554
+ })
555
+ );
592
556
 
593
557
  program
594
558
  .command('toggle-passphrase')
595
559
  .description('Enable or disable passphrase (hidden wallet) protection')
596
560
  .requiredOption('--enable <bool>', 'true to enable, false to disable')
597
- .action(async opts => {
598
- const globalOpts = program.opts();
599
- const sdk = await createSDK(globalOpts);
600
- try {
561
+ .action(opts =>
562
+ runCommand({}, async ({ sdk, globalOpts }) => {
601
563
  const result = await sdk.deviceSettings(globalOpts.connectId, {
602
564
  usePassphrase: opts.enable === 'true',
603
565
  });
604
566
  outputResult(globalOpts, result);
605
- } finally {
606
- sdk.dispose();
607
- }
608
- });
567
+ })
568
+ );
609
569
 
610
570
  program
611
571
  .command('device-wipe')
612
- .description('Factory reset — erase ALL data (IRREVERSIBLE)')
613
- .action(async () => {
614
- const globalOpts = program.opts();
615
- const sdk = await createSDK(globalOpts);
616
- try {
572
+ .description('Factory reset — erase ALL data (IRREVERSIBLE, requires --yes)')
573
+ .option('--yes', 'Confirm factory reset (required)')
574
+ .action(opts => {
575
+ if (!opts.yes) {
576
+ respondAndExit({
577
+ success: false,
578
+ payload: {
579
+ error: 'Factory reset requires --yes flag to confirm. This operation is IRREVERSIBLE.',
580
+ code: 'CONFIRMATION_REQUIRED',
581
+ },
582
+ });
583
+ return;
584
+ }
585
+ return runCommand({}, async ({ sdk, globalOpts }) => {
617
586
  const result = await sdk.deviceWipe(globalOpts.connectId);
618
587
  outputResult(globalOpts, result);
619
- } finally {
620
- sdk.dispose();
621
- }
588
+ });
622
589
  });
623
590
 
624
591
  program
@@ -630,10 +597,8 @@ program
630
597
  .option('--passphrase-always-on-device <bool>', 'Always enter passphrase on device')
631
598
  .option('--haptic-feedback <bool>', 'Enable/disable haptic feedback')
632
599
  .option('--auto-shutdown-delay <seconds>', 'Auto shutdown timeout in seconds')
633
- .action(async opts => {
634
- const globalOpts = program.opts();
635
- const sdk = await createSDK(globalOpts);
636
- try {
600
+ .action(opts =>
601
+ runCommand({}, async ({ sdk, globalOpts }) => {
637
602
  // Map CLI options to SDK param names (camelCase → snake_case handled by SDK)
638
603
  // Reference: packages/core/src/api/device/DeviceSettings.ts
639
604
  const settings: Record<string, unknown> = {};
@@ -659,38 +624,131 @@ program
659
624
 
660
625
  const result = await sdk.deviceSettings(globalOpts.connectId, settings);
661
626
  outputResult(globalOpts, result);
662
- } finally {
663
- sdk.dispose();
664
- }
665
- });
627
+ })
628
+ );
666
629
 
667
630
  program
668
631
  .command('device-verify')
669
632
  .description('Verify device is genuine OneKey hardware')
670
- .action(async () => {
671
- const globalOpts = program.opts();
672
- const sdk = await createSDK(globalOpts);
673
- try {
674
- const result = await sdk.deviceVerify(globalOpts.connectId);
633
+ .action(() =>
634
+ runCommand({}, async ({ sdk, globalOpts }) => {
635
+ const result = await sdk.deviceVerify(globalOpts.connectId, { dataHex: '' });
675
636
  outputResult(globalOpts, result);
676
- } finally {
677
- sdk.dispose();
678
- }
679
- });
637
+ })
638
+ );
680
639
 
681
640
  program
682
641
  .command('lock')
683
642
  .description('Lock the device')
684
- .action(async () => {
685
- const globalOpts = program.opts();
686
- const sdk = await createSDK(globalOpts);
687
- try {
688
- const result = await sdk.deviceLock(globalOpts.connectId);
643
+ .action(() =>
644
+ runCommand({}, async ({ sdk, globalOpts }) => {
645
+ const result = await sdk.deviceLock(globalOpts.connectId, {});
689
646
  outputResult(globalOpts, result);
690
- } finally {
691
- sdk.dispose();
692
- }
693
- });
647
+ })
648
+ );
649
+
650
+ // ============================================================
651
+ // Session Management Commands
652
+ // ============================================================
653
+
654
+ const sessionCmd = program.command('session').description('Manage device passphrase session cache');
655
+
656
+ sessionCmd
657
+ .command('connect')
658
+ .description('Connect device and establish passphrase session (cached for subsequent commands)')
659
+ .action(() =>
660
+ runCommand({}, async ({ sdk, globalOpts }) => {
661
+ // 1. Search for device
662
+ const searchResult = await sdk.searchDevices();
663
+ if (!searchResult?.success || !searchResult.payload?.length) {
664
+ outputResult(globalOpts, {
665
+ success: false,
666
+ payload: { error: 'No device found', code: 'NO_DEVICE' },
667
+ });
668
+ return;
669
+ }
670
+ const device = searchResult.payload[0] as EnrichedSearchDevice;
671
+ const connectId = device.connectId || globalOpts.connectId;
672
+
673
+ // 2. Unlock if locked — getPassphraseState below talks to a live
674
+ // device session, which a locked device will reject with an obscure
675
+ // error. Uses the same PinInvalid retry policy as prepareSession.
676
+ if (device.features?.unlocked === false) {
677
+ process.stderr.write('[onekey-hw] Device is locked. Unlocking (PIN required)...\n');
678
+ await unlockWithRetry(sdk, connectId);
679
+ }
680
+
681
+ // 3. Get passphraseState (triggers 1/2/3 selection)
682
+ const psResult = await sdk.getPassphraseState(connectId, {
683
+ initSession: true,
684
+ useEmptyPassphrase: false,
685
+ });
686
+ if (!psResult.success) {
687
+ outputResult(globalOpts, psResult);
688
+ return;
689
+ }
690
+ const passphraseState = psResult.payload;
691
+
692
+ // 4. Get address to verify + extract deviceId
693
+ const addrResult = await sdk.evmGetAddress(connectId, device.deviceId || '', {
694
+ path: "m/44'/60'/0'/0/0",
695
+ showOnOneKey: false,
696
+ passphraseState,
697
+ });
698
+
699
+ // 5. Fetch the now-active session_id via getFeatures.
700
+ //
701
+ // IMPORTANT: pass `passphraseState` here. Without it, the SDK's
702
+ // connectStateChange guard (core/index.ts) would see the payload's
703
+ // passphraseState flip from mnNy → undefined, clear the cached Device,
704
+ // and call Initialize again with no passphrase_state / no session_id.
705
+ // That Initialize resets the device to the standard wallet and returns
706
+ // a *standard-wallet* session_id — which we'd then save in the keychain
707
+ // paired with the hidden-wallet passphraseState. On the next CLI run
708
+ // the mismatch would trigger PassphraseRequest (1/2/3 again).
709
+ const featResult = await sdk.getFeatures(connectId, {
710
+ passphraseState,
711
+ skipPassphraseCheck: true,
712
+ });
713
+ const featPayload = featResult?.success ? featResult.payload : undefined;
714
+ const deviceId = featPayload?.device_id || device.deviceId || '';
715
+ const sessionId = featPayload?.session_id || '';
716
+
717
+ // 6. Save to keychain
718
+ if (passphraseState && deviceId && sessionId) {
719
+ await saveSessionToKeychain(deviceId, passphraseState, sessionId);
720
+ }
721
+
722
+ outputResult(globalOpts, {
723
+ success: true,
724
+ payload: {
725
+ passphraseState,
726
+ deviceId,
727
+ ...(sessionId ? { sessionId } : {}),
728
+ ...(addrResult?.success ? { address: addrResult.payload.address } : {}),
729
+ },
730
+ });
731
+ })
732
+ );
733
+
734
+ sessionCmd
735
+ .command('disconnect')
736
+ .description('Clear cached device session')
737
+ .action(() =>
738
+ runCommand({}, async ({ sdk, globalOpts }) => {
739
+ const searchResult = await sdk.searchDevices();
740
+ const device = // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
741
+ (searchResult?.payload as any)?.[0];
742
+ const deviceId = device?.features?.device_id || device?.deviceId;
743
+ if (deviceId) {
744
+ await clearSessionFromKeychain(deviceId);
745
+ }
746
+ outputResult(globalOpts, {
747
+ success: true,
748
+ payload: deviceId ? { deviceId } : {},
749
+ });
750
+ })
751
+ );
694
752
 
695
753
  // ============================================================
696
754
  // Helpers
@@ -699,45 +757,310 @@ program
699
757
  /**
700
758
  * Extract common device params from global CLI options.
701
759
  * These are passed to every SDK method call.
760
+ *
761
+ * skipPassphraseCheck is always true because:
762
+ * - prepareSession handles passphrase selection (1/2/3 + pinentry/device)
763
+ * - Without it, SDK's checkPassphraseStateSafety triggers a SECOND
764
+ * REQUEST_PASSPHRASE (double prompt) even when passphraseState is set
765
+ * - Error 114 (no passphrase state) is also bypassed — our interactive
766
+ * handler in REQUEST_PASSPHRASE handles it correctly
702
767
  */
768
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
703
769
  function getCommonParams(globalOpts: Record<string, any>) {
704
770
  return {
705
771
  connectId: globalOpts.connectId,
706
772
  deviceId: globalOpts.deviceId,
707
773
  passphraseState: globalOpts.passphraseState,
708
774
  useEmptyPassphrase: globalOpts.useEmptyPassphrase,
775
+ skipPassphraseCheck: true,
776
+ };
777
+ }
778
+
779
+ /**
780
+ * HardwareErrorCode.PinInvalid from @onekeyfe/hd-shared. Duplicated here
781
+ * to avoid pulling the shared package's runtime just for one enum value.
782
+ * (PinCancelled is 802 — we don't retry it, any non-PinInvalid failure
783
+ * falls through to the "bail out" branch below.)
784
+ */
785
+ const HW_ERR_PIN_INVALID = 801;
786
+
787
+ /**
788
+ * Unlock a locked device, retrying up to `maxAttempts` times on
789
+ * `PinInvalid`. On `PinCancelled` or any other failure, throws immediately
790
+ * so the structured error surfaces to the user via runCommand's catch.
791
+ *
792
+ * With `@@ONEKEY_INPUT_PIN_IN_DEVICE` semantics the device firmware
793
+ * normally loops PIN entry internally and only reports back on success
794
+ * or cancel. Retrying here is defensive: if the device ever surfaces
795
+ * `PinInvalid` directly (older firmware, specific models), give the
796
+ * user another chance without forcing a re-run of the whole command.
797
+ */
798
+ async function unlockWithRetry(
799
+ sdk: typeof import('@onekeyfe/hd-common-connect-sdk').default,
800
+ connectId: string,
801
+ maxAttempts = 3
802
+ ): Promise<{
803
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
804
+ payload: any;
805
+ }> {
806
+ let lastPayload: { error?: string; code?: number | string } = {};
807
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
808
+ const result = await sdk.deviceUnlock(connectId, {});
809
+ if (result?.success && result.payload) {
810
+ return { payload: result.payload };
811
+ }
812
+ lastPayload = (result?.payload ?? {}) as {
813
+ error?: string;
814
+ code?: number | string;
815
+ };
816
+ const { code } = lastPayload;
817
+ const isPinInvalid = code === HW_ERR_PIN_INVALID;
818
+ if (!isPinInvalid || attempt >= maxAttempts) {
819
+ // PinCancelled, other error, or PinInvalid on the last attempt — bail out
820
+ const err = new Error(`Device unlock failed: ${lastPayload.error ?? 'unknown error'}`);
821
+ (err as Error & { code?: number | string }).code = code ?? 'UNLOCK_FAILED';
822
+ throw err;
823
+ }
824
+ process.stderr.write(
825
+ `[onekey-hw] Invalid PIN. Retry on your device (attempt ${attempt + 1}/${maxAttempts}).\n`
826
+ );
827
+ }
828
+ // Unreachable — the loop either returns or throws
829
+ throw new Error('Device unlock failed: retry loop exited without a result');
830
+ }
831
+
832
+ /**
833
+ * Prepare passphrase session before SDK calls.
834
+ *
835
+ * 1. If --use-empty-passphrase or --passphrase-state provided → use as-is
836
+ * 2. Try keychain → preloadSessionCache → use cached session
837
+ * 3. Keychain miss → getPassphraseState (triggers 1/2/3 prompt) → save to keychain
838
+ *
839
+ * After this, globalOpts.passphraseState is set and getCommonParams will include it.
840
+ */
841
+ async function prepareSession(
842
+ sdk: typeof import('@onekeyfe/hd-common-connect-sdk').default,
843
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
844
+ globalOpts: Record<string, any>
845
+ ): Promise<string | undefined> {
846
+ // Skip if standard wallet or passphraseState already provided
847
+ if (globalOpts.useEmptyPassphrase || globalOpts.passphraseState) {
848
+ return globalOpts.passphraseState;
849
+ }
850
+
851
+ // Errors from the SDK calls below (PIN cancelled, transport broken,
852
+ // getPassphraseState rejection) intentionally propagate to runCommand's
853
+ // catch block, which renders them as structured `{ success: false,
854
+ // payload: { error, code } }` output instead of silently falling through
855
+ // to a confusing downstream error 112 / 114.
856
+
857
+ // ── Step 1: Discover device ──────────────────────────────────────
858
+ const searchResult = await sdk.searchDevices();
859
+ if (
860
+ !searchResult?.success ||
861
+ !Array.isArray(searchResult.payload) ||
862
+ searchResult.payload.length === 0
863
+ ) {
864
+ return undefined;
865
+ }
866
+
867
+ const device = searchResult.payload[0] as {
868
+ connectId?: string;
869
+ deviceId?: string;
870
+ features?: {
871
+ device_id?: string;
872
+ session_id?: string;
873
+ passphrase_protection?: boolean | null;
874
+ unlocked?: boolean | null;
875
+ };
709
876
  };
877
+ const connectId = device.connectId || globalOpts.connectId || '';
878
+ if (!globalOpts.connectId && connectId) {
879
+ globalOpts.connectId = connectId;
880
+ }
881
+
882
+ // ── Step 2: Get features if searchDevices didn't populate them ──
883
+ // getFeatures failures here are non-fatal — we fall through to Step 3
884
+ // which will fail with a clearer error if the device is truly unreachable.
885
+ let deviceId = device.features?.device_id || device.deviceId || '';
886
+ let unlocked = device.features?.unlocked;
887
+ let passphraseProtection = device.features?.passphrase_protection;
888
+
889
+ if (!deviceId || unlocked == null || passphraseProtection == null) {
890
+ try {
891
+ const featResult = await sdk.getFeatures(connectId);
892
+ if (featResult?.success && featResult.payload) {
893
+ deviceId = featResult.payload.device_id || deviceId;
894
+ unlocked = featResult.payload.unlocked;
895
+ passphraseProtection = featResult.payload.passphrase_protection;
896
+ }
897
+ } catch {
898
+ /* non-fatal — Step 3 will surface a clear error if device is gone */
899
+ }
900
+ }
901
+
902
+ // ── Step 3: Unlock if locked (matches app-monorepo ServiceHardware flow) ──
903
+ // Track whether device was locked — locking invalidates passphrase sessions,
904
+ // so keychain session reuse is only possible if device was already unlocked.
905
+ // PinInvalid retries inside unlockWithRetry; PinCancelled/other failures
906
+ // throw and propagate to runCommand's catch for structured output.
907
+ const wasLocked = unlocked === false;
908
+ if (wasLocked) {
909
+ process.stderr.write('[onekey-hw] Device is locked. Unlocking (PIN required)...\n');
910
+ const { payload: feat } = await unlockWithRetry(sdk, connectId);
911
+ deviceId = feat.device_id || deviceId;
912
+ unlocked = feat.unlocked;
913
+ passphraseProtection = feat.passphrase_protection;
914
+ }
915
+
916
+ if (!globalOpts.deviceId && deviceId) {
917
+ globalOpts.deviceId = deviceId;
918
+ }
919
+
920
+ // ── Step 4: Check passphrase protection ──────────────────────────
921
+ if (passphraseProtection === false) {
922
+ return undefined;
923
+ }
924
+
925
+ // ── Step 5: Try keychain session reuse ───────────────────────────
926
+ // Only attempt if device was already unlocked — locking invalidates
927
+ // all passphrase sessions, so cached session_id is useless after unlock.
928
+ if (!wasLocked && deviceId) {
929
+ const cached = await preloadSessionFromKeychain(deviceId);
930
+ if (cached) {
931
+ globalOpts.passphraseState = cached;
932
+ return cached;
933
+ }
934
+ }
935
+
936
+ // ── Step 6: Keychain miss → getPassphraseState (triggers 1/2/3 prompt) ──
937
+ const psResult = await sdk.getPassphraseState(connectId, {
938
+ initSession: true,
939
+ useEmptyPassphrase: false,
940
+ });
941
+
942
+ if (psResult.success && psResult.payload) {
943
+ const passphraseState = psResult.payload;
944
+ globalOpts.passphraseState = passphraseState;
945
+
946
+ // Save session to keychain for next invocation.
947
+ //
948
+ // Pass passphraseState to keep connectStateChange=false — otherwise
949
+ // Initialize would be re-run without passphrase_state, resetting the
950
+ // device to the standard wallet and returning a mismatched session_id.
951
+ // See the matching comment in `session connect`.
952
+ if (deviceId) {
953
+ const featAfter = await sdk.getFeatures(connectId, {
954
+ passphraseState,
955
+ skipPassphraseCheck: true,
956
+ });
957
+ const sessionId = featAfter?.success ? featAfter.payload?.session_id : undefined;
958
+ if (sessionId) {
959
+ await saveSessionToKeychain(deviceId, passphraseState, sessionId);
960
+ await preloadSessionFromKeychain(deviceId);
961
+ }
962
+ }
963
+
964
+ return passphraseState;
965
+ }
966
+ return undefined;
710
967
  }
711
968
 
969
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
712
970
  function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
713
- // #10 FIX: Always use JSON.stringify to avoid [Object] truncation
714
971
  console.log(JSON.stringify(result, null, 2));
972
+ if (
973
+ result &&
974
+ typeof result === 'object' &&
975
+ 'success' in result &&
976
+ !(result as { success: boolean }).success
977
+ ) {
978
+ process.exitCode = 1;
979
+ }
980
+ // No process.exit here — runCommand() below handles dispose + exit so SDK
981
+ // async cleanup (USB release, event listener teardown) finishes first.
982
+ }
715
983
 
716
- // Exit after output — SDK event listeners keep the process alive otherwise
717
- if (result && typeof result === 'object' && 'success' in result && !(result as any).success) {
718
- process.exit(1);
719
- } else {
720
- process.exit(0);
984
+ /**
985
+ * Unified command runner. All `.action(...)` handlers should wrap their body
986
+ * in this helper so every command goes through the same lifecycle:
987
+ *
988
+ * 1. (optional) create SDK
989
+ * 2. (optional) prepareSession — unlock + keychain preload + passphraseState
990
+ * 3. run the handler (which calls outputResult on success)
991
+ * 4. report uncaught errors as a structured failure result
992
+ * 5. dispose SDK
993
+ * 6. drain event loop and process.exit with the right code
994
+ *
995
+ * This fixes three previous bugs:
996
+ * - Most signing commands skipped prepareSession, so keychain sessions
997
+ * were never actually reused.
998
+ * - outputResult scheduled process.exit(200ms) before dispose completed,
999
+ * racing USB release on slower transports.
1000
+ * - Errors thrown from SDK calls leaked as stack traces instead of the
1001
+ * CLI's structured `{ success: false, payload: { error } }` shape.
1002
+ */
1003
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1004
+ type AnySdk = Awaited<ReturnType<typeof createSDK>>;
1005
+
1006
+ interface CommandContext {
1007
+ sdk: AnySdk;
1008
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1009
+ globalOpts: Record<string, any>;
1010
+ params: ReturnType<typeof getCommonParams>;
1011
+ }
1012
+
1013
+ async function runCommand(
1014
+ options: { needsSession?: boolean },
1015
+ handler: (ctx: CommandContext) => Promise<void>
1016
+ ): Promise<void> {
1017
+ const globalOpts = program.opts();
1018
+ try {
1019
+ const sdk = await createSDK(globalOpts);
1020
+ if (options.needsSession) {
1021
+ await prepareSession(sdk, globalOpts);
1022
+ }
1023
+ await handler({
1024
+ sdk,
1025
+ globalOpts,
1026
+ params: getCommonParams(globalOpts),
1027
+ });
1028
+ } catch (err) {
1029
+ const code = (err as Error & { code?: string }).code || 'COMMAND_FAILED';
1030
+ outputResult(globalOpts, {
1031
+ success: false,
1032
+ payload: {
1033
+ error: err instanceof Error ? err.message : String(err),
1034
+ code,
1035
+ },
1036
+ });
1037
+ } finally {
1038
+ // Singleton disposer — awaits the SDK, calls dispose, clears the
1039
+ // promise reference. Idempotent, safe to call even if init failed.
1040
+ await disposeSDK();
721
1041
  }
1042
+ // SDK event listeners can keep the event loop alive after dispose.
1043
+ // setImmediate lets any trailing stdout/stderr writes flush first.
1044
+ setImmediate(() => process.exit(process.exitCode ?? 0));
1045
+ }
1046
+
1047
+ /** For commands that don't touch the SDK at all (e.g. firmware-update stubs). */
1048
+ function respondAndExit(result: unknown): void {
1049
+ outputResult({}, result);
1050
+ setImmediate(() => process.exit(process.exitCode ?? 0));
722
1051
  }
723
1052
 
724
1053
  /**
725
- * #6 FIX: Safe JSON.parse with structured error output
1054
+ * Safe JSON.parse. Throws on failure — runCommand() catches and renders
1055
+ * the structured error so the SDK still gets disposed cleanly.
726
1056
  */
727
1057
  function safeJsonParse(input: string, label: string): unknown {
728
1058
  try {
729
1059
  return JSON.parse(input);
730
1060
  } catch {
731
- console.error(
732
- JSON.stringify({
733
- success: false,
734
- payload: {
735
- error: `Invalid JSON for ${label}: ${input.slice(0, 100)}`,
736
- code: 'INVALID_JSON',
737
- },
738
- })
739
- );
740
- process.exit(1);
1061
+ const err = new Error(`Invalid JSON for ${label}: ${input.slice(0, 100)}`);
1062
+ (err as Error & { code?: string }).code = 'INVALID_JSON';
1063
+ throw err;
741
1064
  }
742
1065
  }
743
1066