@onekeyfe/hardware-cli 1.1.26-alpha.105 → 1.1.26-alpha.11

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 (46) 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/pinentry.d.ts +24 -0
  8. package/dist/pinentry.js +113 -0
  9. package/dist/sdk.d.ts +15 -5
  10. package/dist/sdk.js +175 -131
  11. package/dist/session.d.ts +22 -0
  12. package/dist/session.js +83 -0
  13. package/dist/storage/index.d.ts +2 -0
  14. package/dist/storage/index.js +5 -0
  15. package/dist/storage/process-utils.d.ts +2 -0
  16. package/dist/storage/process-utils.js +44 -0
  17. package/dist/storage/secure-storage.linux.d.ts +11 -0
  18. package/dist/storage/secure-storage.linux.js +59 -0
  19. package/dist/storage/secure-storage.macos.d.ts +11 -0
  20. package/dist/storage/secure-storage.macos.js +65 -0
  21. package/dist/storage/storage-factory.d.ts +3 -0
  22. package/dist/storage/storage-factory.js +14 -0
  23. package/dist/storage/types.d.ts +18 -0
  24. package/dist/storage/types.js +2 -0
  25. package/jest.config.js +4 -0
  26. package/package.json +17 -14
  27. package/src/__tests__/pinentry.test.ts +181 -0
  28. package/src/chains.ts +229 -85
  29. package/src/cli.ts +620 -297
  30. package/src/pinentry.ts +138 -0
  31. package/src/sdk.ts +159 -125
  32. package/src/session.ts +89 -0
  33. package/src/storage/index.ts +2 -0
  34. package/src/storage/process-utils.ts +50 -0
  35. package/src/storage/secure-storage.linux.ts +68 -0
  36. package/src/storage/secure-storage.macos.ts +68 -0
  37. package/src/storage/storage-factory.ts +13 -0
  38. package/src/storage/types.ts +17 -0
  39. package/tsconfig.json +5 -7
  40. package/.claude-plugin/plugin.json +0 -14
  41. package/AGENTS.md +0 -40
  42. package/CLAUDE.md +0 -40
  43. package/README.md +0 -112
  44. package/evals/cases.json +0 -373
  45. package/evals/run-evals.sh +0 -136
  46. package/rollup.config.js +0 -28
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Passphrase session management for hd-cli.
3
+ *
4
+ * Aligns with app-monorepo's CLI pattern:
5
+ * Login: getPassphraseState → passphraseState + sessionId → keychain
6
+ * Command: keychain → preloadSessionCache → SDK call (no passphrase prompt)
7
+ * Stale: error 112 → clear keychain → re-prompt → retry
8
+ * Logout: keychain delete
9
+ */
10
+ /**
11
+ * Load passphraseState + sessionId from keychain and call preloadSessionCache.
12
+ * Non-fatal: returns the loaded passphraseState or undefined.
13
+ */
14
+ export declare function preloadSessionFromKeychain(deviceId: string): Promise<string | undefined>;
15
+ /**
16
+ * Save passphraseState + sessionId to keychain for next CLI invocation.
17
+ */
18
+ export declare function saveSessionToKeychain(deviceId: string, passphraseState: string, sessionId: string): Promise<void>;
19
+ /**
20
+ * Clear cached session from keychain.
21
+ */
22
+ export declare function clearSessionFromKeychain(deviceId?: string): Promise<void>;
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ /**
3
+ * Passphrase session management for hd-cli.
4
+ *
5
+ * Aligns with app-monorepo's CLI pattern:
6
+ * Login: getPassphraseState → passphraseState + sessionId → keychain
7
+ * Command: keychain → preloadSessionCache → SDK call (no passphrase prompt)
8
+ * Stale: error 112 → clear keychain → re-prompt → retry
9
+ * Logout: keychain delete
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.clearSessionFromKeychain = exports.saveSessionToKeychain = exports.preloadSessionFromKeychain = void 0;
13
+ const hd_core_1 = require("@onekeyfe/hd-core");
14
+ const storage_1 = require("./storage");
15
+ // Keychain key format: scoped by deviceId for multi-device support
16
+ function psKey(deviceId) {
17
+ return `onekey-hw:${deviceId}/passphrase-state`;
18
+ }
19
+ function sidKey(deviceId) {
20
+ return `onekey-hw:${deviceId}/session-id`;
21
+ }
22
+ let storageInstance = null;
23
+ function getStorage() {
24
+ if (!storageInstance) {
25
+ storageInstance = (0, storage_1.createSecureStorage)();
26
+ }
27
+ return storageInstance;
28
+ }
29
+ /**
30
+ * Load passphraseState + sessionId from keychain and call preloadSessionCache.
31
+ * Non-fatal: returns the loaded passphraseState or undefined.
32
+ */
33
+ async function preloadSessionFromKeychain(deviceId) {
34
+ try {
35
+ const storage = getStorage();
36
+ const [psBuf, sidBuf] = await Promise.all([
37
+ storage.get(psKey(deviceId)),
38
+ storage.get(sidKey(deviceId)),
39
+ ]);
40
+ if (psBuf && sidBuf) {
41
+ const passphraseState = psBuf.toString('utf-8');
42
+ const sessionId = sidBuf.toString('utf-8');
43
+ (0, hd_core_1.preloadSessionCache)(deviceId, passphraseState, sessionId);
44
+ return passphraseState;
45
+ }
46
+ }
47
+ catch {
48
+ // Non-fatal — fall through to passphrase prompt
49
+ }
50
+ return undefined;
51
+ }
52
+ exports.preloadSessionFromKeychain = preloadSessionFromKeychain;
53
+ /**
54
+ * Save passphraseState + sessionId to keychain for next CLI invocation.
55
+ */
56
+ async function saveSessionToKeychain(deviceId, passphraseState, sessionId) {
57
+ try {
58
+ const storage = getStorage();
59
+ await Promise.all([
60
+ storage.set(psKey(deviceId), Buffer.from(passphraseState, 'utf-8')),
61
+ storage.set(sidKey(deviceId), Buffer.from(sessionId, 'utf-8')),
62
+ ]);
63
+ }
64
+ catch {
65
+ // Non-fatal — session still works in-memory for this invocation
66
+ }
67
+ }
68
+ exports.saveSessionToKeychain = saveSessionToKeychain;
69
+ /**
70
+ * Clear cached session from keychain.
71
+ */
72
+ async function clearSessionFromKeychain(deviceId) {
73
+ try {
74
+ const storage = getStorage();
75
+ if (deviceId) {
76
+ await Promise.allSettled([storage.delete(psKey(deviceId)), storage.delete(sidKey(deviceId))]);
77
+ }
78
+ }
79
+ catch {
80
+ // Non-fatal
81
+ }
82
+ }
83
+ exports.clearSessionFromKeychain = clearSessionFromKeychain;
@@ -0,0 +1,2 @@
1
+ export { createSecureStorage } from './storage-factory';
2
+ export type { ISecureStorage, SecureStorageBackend } from './types';
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSecureStorage = void 0;
4
+ var storage_factory_1 = require("./storage-factory");
5
+ Object.defineProperty(exports, "createSecureStorage", { enumerable: true, get: function () { return storage_factory_1.createSecureStorage; } });
@@ -0,0 +1,2 @@
1
+ import type { IProcessRunner } from './types';
2
+ export declare const defaultProcessRunner: IProcessRunner;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultProcessRunner = void 0;
4
+ const node_child_process_1 = require("node:child_process");
5
+ exports.defaultProcessRunner = {
6
+ execFileAsync(cmd, args) {
7
+ return new Promise((resolve, reject) => {
8
+ (0, node_child_process_1.execFile)(cmd, args, (error, stdout, stderr) => {
9
+ if (error) {
10
+ error.stderr = stderr;
11
+ reject(error);
12
+ return;
13
+ }
14
+ resolve({ stdout, stderr });
15
+ });
16
+ });
17
+ },
18
+ spawnWithStdin(cmd, args, input) {
19
+ return new Promise((resolve, reject) => {
20
+ const child = (0, node_child_process_1.spawn)(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
21
+ let stdout = '';
22
+ let stderr = '';
23
+ child.stdout.on('data', (data) => {
24
+ stdout += data.toString();
25
+ });
26
+ child.stderr.on('data', (data) => {
27
+ stderr += data.toString();
28
+ });
29
+ child.on('error', reject);
30
+ child.on('close', code => {
31
+ if (code !== 0) {
32
+ const error = new Error(`Process exited with code ${code}`);
33
+ error.code = code ?? 1;
34
+ error.stderr = stderr;
35
+ reject(error);
36
+ return;
37
+ }
38
+ resolve({ stdout, stderr });
39
+ });
40
+ child.stdin.write(`${input}\n`);
41
+ child.stdin.end();
42
+ });
43
+ },
44
+ };
@@ -0,0 +1,11 @@
1
+ /// <reference types="node" />
2
+ import type { IProcessRunner, ISecureStorage, SecureStorageBackend } from './types';
3
+ export declare class LinuxSecureStorage implements ISecureStorage {
4
+ private readonly runner;
5
+ constructor(runner?: IProcessRunner);
6
+ getBackendType(): SecureStorageBackend;
7
+ set(key: string, value: Buffer): Promise<void>;
8
+ get(key: string): Promise<Buffer | null>;
9
+ delete(key: string): Promise<void>;
10
+ private isItemNotFound;
11
+ }
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LinuxSecureStorage = void 0;
4
+ const process_utils_1 = require("./process-utils");
5
+ const SERVICE_NAME = 'onekey-hw-cli';
6
+ const SECRET_LABEL = 'OneKey HW CLI Secret';
7
+ class LinuxSecureStorage {
8
+ constructor(runner = process_utils_1.defaultProcessRunner) {
9
+ this.runner = runner;
10
+ }
11
+ getBackendType() {
12
+ return 'linux-secret-service';
13
+ }
14
+ async set(key, value) {
15
+ await this.runner.spawnWithStdin('secret-tool', ['store', '--label', SECRET_LABEL, 'service', SERVICE_NAME, 'account', key], value.toString('hex'));
16
+ }
17
+ async get(key) {
18
+ try {
19
+ const { stdout } = await this.runner.execFileAsync('secret-tool', [
20
+ 'lookup',
21
+ 'service',
22
+ SERVICE_NAME,
23
+ 'account',
24
+ key,
25
+ ]);
26
+ const hex = stdout.trim();
27
+ return hex ? Buffer.from(hex, 'hex') : null;
28
+ }
29
+ catch (error) {
30
+ if (this.isItemNotFound(error))
31
+ return null;
32
+ throw error;
33
+ }
34
+ }
35
+ async delete(key) {
36
+ try {
37
+ await this.runner.execFileAsync('secret-tool', [
38
+ 'clear',
39
+ 'service',
40
+ SERVICE_NAME,
41
+ 'account',
42
+ key,
43
+ ]);
44
+ }
45
+ catch (error) {
46
+ if (this.isItemNotFound(error))
47
+ return;
48
+ throw error;
49
+ }
50
+ }
51
+ isItemNotFound(error) {
52
+ const err = error;
53
+ const stderr = err.stderr ?? err.message ?? '';
54
+ return (stderr.includes('No such secret item') ||
55
+ stderr.includes('Object does not exist') ||
56
+ stderr.includes('could not find'));
57
+ }
58
+ }
59
+ exports.LinuxSecureStorage = LinuxSecureStorage;
@@ -0,0 +1,11 @@
1
+ /// <reference types="node" />
2
+ import type { IProcessRunner, ISecureStorage, SecureStorageBackend } from './types';
3
+ export declare class MacOSSecureStorage implements ISecureStorage {
4
+ private readonly runner;
5
+ constructor(runner?: IProcessRunner);
6
+ getBackendType(): SecureStorageBackend;
7
+ set(key: string, value: Buffer): Promise<void>;
8
+ get(key: string): Promise<Buffer | null>;
9
+ delete(key: string): Promise<void>;
10
+ private isItemNotFound;
11
+ }
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MacOSSecureStorage = void 0;
4
+ const process_utils_1 = require("./process-utils");
5
+ const SERVICE_NAME = 'onekey-hw-cli';
6
+ class MacOSSecureStorage {
7
+ constructor(runner = process_utils_1.defaultProcessRunner) {
8
+ this.runner = runner;
9
+ }
10
+ getBackendType() {
11
+ return 'macos-keychain';
12
+ }
13
+ async set(key, value) {
14
+ const hex = value.toString('hex');
15
+ // Use `security -i` (interactive/batch mode): the tool parses commands
16
+ // from stdin internally instead of re-spawning a sub-process per command,
17
+ // so the password argument never appears in /proc or `ps aux` output.
18
+ //
19
+ // Keys are of the form `onekey-hw:<deviceId>/<slot>` and hex values only
20
+ // contain 0-9a-f, so neither contains shell metacharacters that would
21
+ // break the simple quoting security's parser expects.
22
+ const cmd = `add-generic-password -s "${SERVICE_NAME}" -a "${key}" -w "${hex}" -U`;
23
+ await this.runner.spawnWithStdin('security', ['-i'], cmd);
24
+ }
25
+ async get(key) {
26
+ try {
27
+ const { stdout } = await this.runner.execFileAsync('security', [
28
+ 'find-generic-password',
29
+ '-s',
30
+ SERVICE_NAME,
31
+ '-a',
32
+ key,
33
+ '-w',
34
+ ]);
35
+ const hex = stdout.trim();
36
+ return hex ? Buffer.from(hex, 'hex') : null;
37
+ }
38
+ catch (error) {
39
+ if (this.isItemNotFound(error))
40
+ return null;
41
+ throw error;
42
+ }
43
+ }
44
+ async delete(key) {
45
+ try {
46
+ await this.runner.execFileAsync('security', [
47
+ 'delete-generic-password',
48
+ '-s',
49
+ SERVICE_NAME,
50
+ '-a',
51
+ key,
52
+ ]);
53
+ }
54
+ catch (error) {
55
+ if (this.isItemNotFound(error))
56
+ return;
57
+ throw error;
58
+ }
59
+ }
60
+ isItemNotFound(error) {
61
+ const err = error;
62
+ return err.code === 44 || err.stderr?.includes('could not be found') === true;
63
+ }
64
+ }
65
+ exports.MacOSSecureStorage = MacOSSecureStorage;
@@ -0,0 +1,3 @@
1
+ /// <reference types="node" />
2
+ import type { ISecureStorage } from './types';
3
+ export declare function createSecureStorage(platform?: NodeJS.Platform): ISecureStorage;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSecureStorage = void 0;
4
+ const secure_storage_linux_1 = require("./secure-storage.linux");
5
+ const secure_storage_macos_1 = require("./secure-storage.macos");
6
+ function createSecureStorage(platform = process.platform) {
7
+ if (platform === 'darwin')
8
+ return new secure_storage_macos_1.MacOSSecureStorage();
9
+ if (platform === 'linux')
10
+ return new secure_storage_linux_1.LinuxSecureStorage();
11
+ throw new Error(`Secure storage is not supported on platform "${platform}". ` +
12
+ 'Use macOS Keychain or Linux Secret Service.');
13
+ }
14
+ exports.createSecureStorage = createSecureStorage;
@@ -0,0 +1,18 @@
1
+ /// <reference types="node" />
2
+ export type SecureStorageBackend = 'macos-keychain' | 'linux-secret-service';
3
+ export interface ISecureStorage {
4
+ getBackendType(): SecureStorageBackend;
5
+ get(key: string): Promise<Buffer | null>;
6
+ set(key: string, value: Buffer): Promise<void>;
7
+ delete(key: string): Promise<void>;
8
+ }
9
+ export interface IProcessRunner {
10
+ execFileAsync(cmd: string, args: string[]): Promise<{
11
+ stdout: string;
12
+ stderr: string;
13
+ }>;
14
+ spawnWithStdin(cmd: string, args: string[], input: string): Promise<{
15
+ stdout: string;
16
+ stderr: string;
17
+ }>;
18
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/jest.config.js ADDED
@@ -0,0 +1,4 @@
1
+ module.exports = {
2
+ preset: '../../jest.config.js',
3
+ testEnvironment: 'node',
4
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@onekeyfe/hardware-cli",
3
- "version": "1.1.26-alpha.105",
4
- "description": "OneKey hardware wallet CLI for AI agent integration",
3
+ "version": "1.1.26-alpha.11",
4
+ "description": "OneKey hardware wallet CLI for testing device communication",
5
5
  "author": "OneKey",
6
6
  "license": "Apache-2.0",
7
7
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
@@ -18,20 +18,23 @@
18
18
  "access": "public"
19
19
  },
20
20
  "scripts": {
21
- "dev": "rimraf dist && rollup -c rollup.config.js -w",
22
- "build": "rimraf dist && rollup -c rollup.config.js && node -e \"const f='dist/cli.js';const c=require('fs').readFileSync(f,'utf8');require('fs').writeFileSync(f,'#!/usr/bin/env node\\n'+c)\"",
21
+ "build": "tsc && node -e \"const f='dist/cli.js';const c=require('fs').readFileSync(f,'utf8');if(!c.startsWith('#!'))require('fs').writeFileSync(f,'#!/usr/bin/env node\\n'+c)\"",
22
+ "prestart": "tsc",
23
+ "start": "node dist/cli.js",
24
+ "search": "node dist/cli.js search",
25
+ "get-features": "node dist/cli.js get-features",
26
+ "get-address": "node dist/cli.js get-address",
27
+ "ping": "node dist/cli.js ping",
23
28
  "lint": "eslint .",
24
- "lint:fix": "eslint . --fix"
29
+ "lint:fix": "eslint . --fix",
30
+ "test": "jest"
25
31
  },
26
32
  "dependencies": {
27
- "@onekeyfe/hd-common-connect-sdk": "1.1.26-alpha.105",
28
- "@onekeyfe/hd-core": "1.1.26-alpha.105",
29
- "@onekeyfe/hd-shared": "1.1.26-alpha.105",
30
- "@onekeyfe/hd-transport-usb": "1.1.26-alpha.105",
31
- "commander": "^12.0.0",
32
- "eventemitter2": "^6.4.9",
33
- "lodash": "^4.17.21",
34
- "tslib": "^2.6.0"
33
+ "@onekeyfe/hd-common-connect-sdk": "1.1.26-alpha.11",
34
+ "@onekeyfe/hd-core": "1.1.26-alpha.11",
35
+ "@onekeyfe/hd-shared": "1.1.26-alpha.11",
36
+ "@onekeyfe/hd-transport-usb": "1.1.26-alpha.11",
37
+ "commander": "^12.0.0"
35
38
  },
36
- "gitHead": "9a2f397bad3fec371e92c2f58d0152c3f2f34802"
39
+ "gitHead": "b0246304f70392245b98f176bf4d5b86d406a3a9"
37
40
  }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Unit tests for pinentry stdout parsing.
3
+ *
4
+ * These tests exercise the pure Assuan-protocol parser — no hardware,
5
+ * no child process, no keychain. They catch the class of bugs that
6
+ * silently corrupt a user's passphrase (e.g. losing `%` characters,
7
+ * mis-splitting multi-line responses, trailing CR from CRLF shells).
8
+ *
9
+ * Mirrors app-monorepo/apps/cli/src/__tests__/pinentry.test.ts so both
10
+ * CLIs stay aligned on the same decoding semantics.
11
+ */
12
+
13
+ import { decodeAssuanData, parsePinentryStdout } from '../pinentry';
14
+
15
+ describe('decodeAssuanData', () => {
16
+ it('passes plain ASCII through unchanged', () => {
17
+ expect(decodeAssuanData('hello-world')).toBe('hello-world');
18
+ });
19
+
20
+ it('decodes a literal percent (%25 -> %)', () => {
21
+ // pinentry sends `D a%25b%25c` for the user input `a%b%c`
22
+ expect(decodeAssuanData('a%25b%25c')).toBe('a%b%c');
23
+ });
24
+
25
+ it('decodes CR (%0D) and LF (%0A)', () => {
26
+ expect(decodeAssuanData('line1%0Dline2%0Aline3')).toBe('line1\rline2\nline3');
27
+ });
28
+
29
+ it('handles trailing percent encoding', () => {
30
+ expect(decodeAssuanData('end%25')).toBe('end%');
31
+ });
32
+
33
+ it('handles uppercase and lowercase hex', () => {
34
+ expect(decodeAssuanData('%2a%2A')).toBe('**');
35
+ });
36
+
37
+ it('leaves a bare % (not followed by 2 hex chars) untouched', () => {
38
+ // pinentry never produces this, but the decoder must not corrupt it.
39
+ expect(decodeAssuanData('100%')).toBe('100%');
40
+ expect(decodeAssuanData('%2')).toBe('%2');
41
+ expect(decodeAssuanData('%zz')).toBe('%zz');
42
+ });
43
+
44
+ it('leaves an empty string alone', () => {
45
+ expect(decodeAssuanData('')).toBe('');
46
+ });
47
+
48
+ // Assuan only percent-encodes %, CR, and LF. Every other byte — including
49
+ // multi-byte UTF-8 sequences, extended ASCII, spaces, symbols — goes over
50
+ // the D line untouched. These tests mirror the real passphrases exercised
51
+ // by expo-example/TestSpecialPassphraseWallet, so a decoder change that
52
+ // accidentally mangles non-ASCII input fails here instead of only in a
53
+ // hardware-required integration run.
54
+ it('passes UTF-8 multi-byte scripts through unchanged', () => {
55
+ expect(decodeAssuanData('你好passphrase')).toBe('你好passphrase');
56
+ expect(decodeAssuanData('私のパスワード')).toBe('私のパスワード');
57
+ expect(decodeAssuanData('myسياسةpassphrase')).toBe('myسياسةpassphrase');
58
+ });
59
+
60
+ it('passes extended ASCII and accented chars unchanged', () => {
61
+ expect(decodeAssuanData('¥Øÿ')).toBe('¥Øÿ');
62
+ expect(decodeAssuanData('P@sswôrd€')).toBe('P@sswôrd€');
63
+ expect(decodeAssuanData('mi política de frase de contraseña')).toBe(
64
+ 'mi política de frase de contraseña'
65
+ );
66
+ });
67
+
68
+ it('preserves leading and trailing spaces', () => {
69
+ // Regression guard: a .trim() added "defensively" anywhere in the pipe
70
+ // would derive the wrong passphraseState for Wallet-4 and silently
71
+ // unlock a different hidden wallet. expo-example Wallet-4 exercises
72
+ // exactly this passphrase.
73
+ expect(decodeAssuanData(' My Passphrase ')).toBe(' My Passphrase ');
74
+ });
75
+
76
+ it('passes a multi-script mixed passphrase through unchanged', () => {
77
+ // Matches expo-example Wallet-1 — the "everything all at once" case.
78
+ const mixed = 'Aa0!)_+맪Ӎ¬}¨¥ϸΔѭЧゞく6鼵';
79
+ expect(decodeAssuanData(mixed)).toBe(mixed);
80
+ });
81
+ });
82
+
83
+ describe('parsePinentryStdout', () => {
84
+ it('parses a real pinentry-mac success response', () => {
85
+ const stdout = 'OK Pleased to meet you, process 38946\nOK\nOK\nD a%25b%25c\nOK\n';
86
+ expect(parsePinentryStdout(stdout)).toEqual({
87
+ data: 'a%b%c',
88
+ cancelled: false,
89
+ });
90
+ });
91
+
92
+ it('returns no data and cancelled=false when user clicks OK on empty', () => {
93
+ // Pinentry omits the D line entirely when the input is empty.
94
+ const stdout = 'OK Pleased to meet you\nOK\nOK\nOK\n';
95
+ expect(parsePinentryStdout(stdout)).toEqual({ cancelled: false });
96
+ });
97
+
98
+ it('flags cancellation via ERR 83886179', () => {
99
+ const stdout = 'OK Pleased to meet you\nOK\nOK\nERR 83886179 Operation cancelled\n';
100
+ expect(parsePinentryStdout(stdout)).toEqual({ cancelled: true });
101
+ });
102
+
103
+ it('concatenates multi-line D responses before decoding', () => {
104
+ // User typed `first-half-with-%-end` (literal `%`). Pinentry encoded
105
+ // `%` as `%25` and the line-length limit happened to split right inside
106
+ // that triple — `%2` ends line 1, `5` starts line 2. We must concat
107
+ // raw chunks *first*, then decode — otherwise `%2` alone is not a
108
+ // valid `%XX` triple and the `%` would be permanently lost.
109
+ const stdout = ['OK', 'D first-half-with-%2', 'D 5-end', 'OK'].join('\n');
110
+ expect(parsePinentryStdout(stdout)).toEqual({
111
+ data: 'first-half-with-%-end',
112
+ cancelled: false,
113
+ });
114
+ });
115
+
116
+ it('also concatenates D chunks that do not split inside %XX', () => {
117
+ const stdout = ['OK', 'D part-one-', 'D part-two', 'OK'].join('\n');
118
+ expect(parsePinentryStdout(stdout)).toEqual({
119
+ data: 'part-one-part-two',
120
+ cancelled: false,
121
+ });
122
+ });
123
+
124
+ it('handles CRLF line endings (pinentry-gnome3/qt)', () => {
125
+ const stdout = 'OK Pleased\r\nOK\r\nOK\r\nD secret%25here\r\nOK\r\n';
126
+ expect(parsePinentryStdout(stdout)).toEqual({
127
+ data: 'secret%here',
128
+ cancelled: false,
129
+ });
130
+ });
131
+
132
+ it('decodes CR/LF inside the passphrase (theoretical)', () => {
133
+ const stdout = 'OK\nD line1%0Dline2%0Aline3\nOK\n';
134
+ expect(parsePinentryStdout(stdout)).toEqual({
135
+ data: 'line1\rline2\nline3',
136
+ cancelled: false,
137
+ });
138
+ });
139
+
140
+ it('flags cancellation via "Operation cancelled" message', () => {
141
+ // Some pinentry variants surface cancellation without the canonical
142
+ // error code — just the human-readable string.
143
+ const stdout = 'OK\nOK\nERR Operation cancelled\n';
144
+ expect(parsePinentryStdout(stdout)).toEqual({ cancelled: true });
145
+ });
146
+
147
+ it('handles empty stdout without throwing', () => {
148
+ expect(parsePinentryStdout('')).toEqual({ cancelled: false });
149
+ });
150
+
151
+ // End-to-end sanity over a full stdout frame carrying the real-world
152
+ // passphrases exercised by expo-example. Guards the `D ` prefix strip
153
+ // (`.slice(2)`) against any future refactor that would drop a byte from
154
+ // the leading space (e.g. switching to `.slice(1).trimStart()`).
155
+ it('parses a stdout frame carrying a UTF-8 passphrase', () => {
156
+ const stdout = 'OK Pleased to meet you\nOK\nOK\nD 你好passphrase\nOK\n';
157
+ expect(parsePinentryStdout(stdout)).toEqual({
158
+ data: '你好passphrase',
159
+ cancelled: false,
160
+ });
161
+ });
162
+
163
+ it('parses a stdout frame with surrounding spaces in the passphrase', () => {
164
+ const stdout = 'OK\nOK\nOK\nD My Passphrase \nOK\n';
165
+ expect(parsePinentryStdout(stdout)).toEqual({
166
+ data: ' My Passphrase ',
167
+ cancelled: false,
168
+ });
169
+ });
170
+
171
+ it('parses a stdout frame with a mixed-script passphrase', () => {
172
+ // expo-example Wallet-1 — guards that neither split nor slice corrupts
173
+ // surrogate pairs (e.g. `鼵` U+9E35 fits in a single UTF-16 unit, but
174
+ // `맪` U+B9AA also does; kept together here as a smoke test).
175
+ const stdout = 'OK\nD Aa0!)_+맪Ӎ¬}¨¥ϸΔѭЧゞく6鼵\nOK\n';
176
+ expect(parsePinentryStdout(stdout)).toEqual({
177
+ data: 'Aa0!)_+맪Ӎ¬}¨¥ϸΔѭЧゞく6鼵',
178
+ cancelled: false,
179
+ });
180
+ });
181
+ });