commit 399e93f714c52a2859dc5ee5f8adfb66cc3bf3df Author: Harvmaster Date: Thu Jan 29 07:13:33 2026 +0000 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0eec1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__sysdb__.sqlite +Electrum.sqlite +XO.sqlite +node_modules/ +dist/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..deab4d0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1817 @@ +{ + "name": "@xo-cash/cli", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@xo-cash/cli", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "@xo-cash/engine": "file:../engine", + "@xo-cash/state": "file:../state", + "@xo-cash/templates": "file:../templates", + "@xo-cash/types": "file:../types", + "clipboardy": "^5.1.0", + "ink": "^5.1.0", + "ink-select-input": "^6.0.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", + "react": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^25.0.10", + "@types/react": "^18.3.18", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } + }, + "../engine": { + "name": "@xo-cash/engine", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8", + "@electrum-cash/application": "^0.2.2-development.11641378031", + "@electrum-cash/network": "^4.1.4", + "@electrum-cash/protocol": "^2.3.0-development.12185537348", + "@xo-cash/crypto": "0.0.1", + "@xo-cash/primitives": "0.0.1", + "@xo-cash/state": "0.0.1", + "@xo-cash/templates": "0.0.1", + "@xo-cash/types": "0.0.1", + "@xo-cash/utils": "0.0.1", + "eventemitter3": "^5.0.1" + }, + "devDependencies": { + "@generalprotocols/eslint-config": "^1.0.1", + "@types/node": "^20.10.0", + "del-cli": "^7.0.0", + "eslint": "^8.57.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "prettier": "^3.6.2", + "typedoc": "^0.28.15", + "typescript": "^5.3.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "../state": { + "name": "@xo-cash/state", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8", + "@xo-cash/types": "0.0.1", + "@xo-cash/utils": "0.0.1", + "better-sqlite3": "^12.5.0", + "idb": "^8.0.3", + "indexeddbshim": "^16.1.0" + }, + "devDependencies": { + "@chalp/eslint-airbnb": "^1.3.0", + "@generalprotocols/cspell-dictionary": "^1.0.1", + "@stylistic/eslint-plugin": "^5.7.0", + "@types/node": "^20.10.0", + "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/parser": "^8.53.1", + "@vitest/coverage-v8": "^4.0.17", + "@viz-kit/esbuild-analyzer": "^1.0.0", + "@xo-cash/eslint-config": "1.0.1", + "cspell": "^9.6.0", + "eslint": "^9.39.2", + "prettier": "^3.6.2", + "tsdown": "^0.20.0-beta.4", + "typedoc": "^0.28.16", + "typedoc-plugin-coverage": "^4.0.2", + "typescript": "^5.3.2", + "typescript-eslint": "^8.53.1", + "vitest": "^4.0.17" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "../templates": { + "name": "@xo-cash/templates", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@xo-cash/types": "0.0.1" + }, + "devDependencies": { + "@chalp/eslint-airbnb": "^1.3.0", + "@generalprotocols/cspell-dictionary": "^1.0.1", + "@stylistic/eslint-plugin": "^5.7.0", + "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/parser": "^8.53.1", + "@vitest/coverage-v8": "^4.0.17", + "@viz-kit/esbuild-analyzer": "^1.0.0", + "@xo-cash/eslint-config": "1.0.1", + "cspell": "^9.6.0", + "eslint": "^9.39.2", + "prettier": "^3.6.2", + "tsdown": "^0.20.0-beta.4", + "typedoc": "^0.28.16", + "typedoc-plugin-coverage": "^4.0.2", + "typescript": "^5.3.2", + "typescript-eslint": "^8.53.1", + "vitest": "^4.0.17" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "../types": { + "name": "@xo-cash/types", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8" + }, + "devDependencies": { + "@chalp/eslint-airbnb": "^1.3.0", + "@generalprotocols/cspell-dictionary": "^1.0.1", + "@stylistic/eslint-plugin": "^5.7.0", + "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/parser": "^8.53.1", + "@vitest/coverage-v8": "^4.0.17", + "@viz-kit/esbuild-analyzer": "^1.0.0", + "@xo-cash/eslint-config": "1.0.1", + "cspell": "^9.6.0", + "eslint": "^9.39.2", + "prettier": "^3.6.2", + "tsdown": "^0.20.0-beta.4", + "typedoc": "^0.28.16", + "typedoc-plugin-coverage": "^4.0.2", + "typescript": "^5.3.2", + "typescript-eslint": "^8.53.1", + "vitest": "^4.0.17" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@bitauth/libauth": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.0.0.tgz", + "integrity": "sha512-3yoL31XpnhAnf5nDVMFk4xPqebxDwXrgYAwpa31ARJnV5A/eXWlpNYvCd6FTZPFM4VvKfjCBi+jRCrw1hOZ0Jg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@xo-cash/engine": { + "resolved": "../engine", + "link": true + }, + "node_modules/@xo-cash/state": { + "resolved": "../state", + "link": true + }, + "node_modules/@xo-cash/templates": { + "resolved": "../templates", + "link": true + }, + "node_modules/@xo-cash/types": { + "resolved": "../types", + "link": true + }, + "node_modules/ansi-escapes": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", + "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/clipboardy": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.1.0.tgz", + "integrity": "sha512-w8Faf7egtk+6eZ+QJSYhCc8W5GKKd36Et6Qtl+c/dOFtPSjgCkJn9+QHr7D3EbdAO6rJb8I76sizRQAJpwOoLg==", + "license": "MIT", + "dependencies": { + "execa": "^9.6.1", + "is-wayland": "^0.1.0", + "is-wsl": "^3.1.0", + "is64bit": "^2.0.0", + "powershell-utils": "^0.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-select-input": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz", + "integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==", + "license": "MIT", + "dependencies": { + "figures": "^6.1.0", + "to-rotated": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/ink-spinner": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", + "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", + "license": "MIT", + "dependencies": { + "cli-spinners": "^2.7.0" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "ink": ">=4.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/ink/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wayland": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-wayland/-/is-wayland-0.1.0.tgz", + "integrity": "sha512-QkbMsWkIfkrzOPxenwye0h56iAXirZYHG9eHVPb22fO9y+wPbaX/CHacOWBa/I++4ohTcByimhM1/nyCsH8KNA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is64bit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is64bit/-/is64bit-2.0.0.tgz", + "integrity": "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==", + "license": "MIT", + "dependencies": { + "system-architecture": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/powershell-utils": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz", + "integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/system-architecture": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", + "integrity": "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/to-rotated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz", + "integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..23cfaae --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "@xo-cash/cli", + "version": "1.0.0", + "main": "dist/index.js", + "type": "module", + "scripts": { + "dev": "tsx src/index.ts", + "build": "tsc", + "start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "crypto", + "wallet", + "cli", + "tui" + ], + "author": "General Protocols", + "license": "ISC", + "description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet", + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "@xo-cash/engine": "file:../engine", + "@xo-cash/state": "file:../state", + "@xo-cash/templates": "file:../templates", + "@xo-cash/types": "file:../types", + "clipboardy": "^5.1.0", + "ink": "^5.1.0", + "ink-select-input": "^6.0.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", + "react": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^25.0.10", + "@types/react": "^18.3.18", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..9244b79 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,134 @@ +/** + * Application bootstrap and lifecycle management. + * Coordinates initialization of all CLI components. + */ + +import React from 'react'; +import { render, type Instance } from 'ink'; +import { App as AppComponent } from './tui/App.js'; +import { WalletController } from './controllers/wallet-controller.js'; +import { InvitationController } from './controllers/invitation-controller.js'; +import { SyncClient } from './services/sync-client.js'; + +/** + * Configuration options for the CLI application. + */ +export interface AppConfig { + /** URL of the sync server (default: http://localhost:3000) */ + syncServerUrl?: string; + /** Database path for wallet state storage */ + databasePath?: string; + /** Database filename */ + databaseFilename?: string; +} + +/** + * Main application class that orchestrates all CLI components. + */ +export class App { + /** Ink render instance */ + private inkInstance: Instance | null = null; + + /** Wallet controller for engine operations */ + private walletController: WalletController; + + /** Invitation controller for collaborative transactions */ + private invitationController: InvitationController; + + /** HTTP client for sync server communication */ + private syncClient: SyncClient; + + /** Application configuration */ + private config: Required; + + /** + * Creates a new App instance. + * @param config - Application configuration options + */ + private constructor(config: AppConfig = {}) { + // Set default configuration + this.config = { + syncServerUrl: config.syncServerUrl ?? 'http://localhost:3000', + databasePath: config.databasePath ?? './', + databaseFilename: config.databaseFilename ?? 'xo-wallet', + }; + + // Initialize sync client + this.syncClient = new SyncClient(this.config.syncServerUrl); + + // Initialize wallet controller (engine will be created when seed is provided) + this.walletController = new WalletController({ + databasePath: this.config.databasePath, + databaseFilename: this.config.databaseFilename, + }); + + // Initialize invitation controller + this.invitationController = new InvitationController( + this.walletController, + this.syncClient, + ); + } + + /** + * Factory method to create and start the application. + * @param config - Application configuration options + * @returns Running App instance + */ + static async create(config: AppConfig = {}): Promise { + const app = new App(config); + await app.start(); + return app; + } + + /** + * Starts the application. + * Renders the Ink-based TUI. + */ + async start(): Promise { + // Render the Ink app + this.inkInstance = render( + React.createElement(AppComponent, { + walletController: this.walletController, + invitationController: this.invitationController, + }) + ); + + // Wait for the app to exit + await this.inkInstance.waitUntilExit(); + } + + /** + * Stops the application and cleans up resources. + */ + async stop(): Promise { + // Stop the wallet engine if running + await this.walletController.stop(); + + // Unmount Ink app + if (this.inkInstance) { + this.inkInstance.unmount(); + this.inkInstance = null; + } + } + + /** + * Gets the wallet controller for external access. + */ + getWalletController(): WalletController { + return this.walletController; + } + + /** + * Gets the invitation controller for external access. + */ + getInvitationController(): InvitationController { + return this.invitationController; + } + + /** + * Gets the sync client for external access. + */ + getSyncClient(): SyncClient { + return this.syncClient; + } +} diff --git a/src/controllers/invitation-controller.ts b/src/controllers/invitation-controller.ts new file mode 100644 index 0000000..a1c91ff --- /dev/null +++ b/src/controllers/invitation-controller.ts @@ -0,0 +1,292 @@ +/** + * Invitation Controller - High-level interface for invitation management. + * + * Provides a simplified API for the TUI to interact with invitations, + * wrapping the InvitationFlowManager and coordinating with the WalletController. + */ + +import { EventEmitter } from 'events'; +import type { XOInvitation } from '@xo-cash/types'; +import { InvitationFlowManager, type TrackedInvitation, type InvitationState } from '../services/invitation-flow.js'; +import type { WalletController } from './wallet-controller.js'; +import type { SyncClient } from '../services/sync-client.js'; + +/** + * Events emitted by the invitation controller. + */ +export interface InvitationControllerEvents { + 'invitation-created': (invitationId: string) => void; + 'invitation-updated': (invitationId: string) => void; + 'invitation-state-changed': (invitationId: string, state: InvitationState) => void; + 'error': (error: Error) => void; +} + +/** + * Controller for managing invitations in the TUI. + */ +export class InvitationController extends EventEmitter { + /** Flow manager for invitation lifecycle */ + private flowManager: InvitationFlowManager; + + /** Wallet controller reference */ + private walletController: WalletController; + + /** Sync client reference */ + private syncClient: SyncClient; + + /** + * Creates a new invitation controller. + * @param walletController - Wallet controller instance + * @param syncClient - Sync client instance + */ + constructor(walletController: WalletController, syncClient: SyncClient) { + super(); + + this.walletController = walletController; + this.syncClient = syncClient; + this.flowManager = new InvitationFlowManager(walletController, syncClient); + + // Forward events from flow manager + this.flowManager.on('invitation-created', (invitation: XOInvitation) => { + this.emit('invitation-created', invitation.invitationIdentifier); + }); + + this.flowManager.on('invitation-updated', (invitationId: string) => { + this.emit('invitation-updated', invitationId); + }); + + this.flowManager.on('invitation-state-changed', (invitationId: string, state: InvitationState) => { + this.emit('invitation-state-changed', invitationId, state); + }); + + this.flowManager.on('error', (_invitationId: string, error: Error) => { + this.emit('error', error); + }); + } + + // ============================================================================ + // Invitation Creation Flow + // ============================================================================ + + /** + * Creates a new invitation from a template action. + * @param templateIdentifier - Template ID + * @param actionIdentifier - Action ID + * @returns Created tracked invitation + */ + async createInvitation( + templateIdentifier: string, + actionIdentifier: string, + ): Promise { + return this.flowManager.createInvitation(templateIdentifier, actionIdentifier); + } + + /** + * Publishes an invitation to the sync server and starts listening for updates. + * @param invitationId - Invitation ID to publish + * @returns The invitation ID for sharing + */ + async publishAndSubscribe(invitationId: string): Promise { + // Publish to sync server + await this.flowManager.publishInvitation(invitationId); + + // Subscribe to SSE updates + await this.flowManager.subscribeToUpdates(invitationId); + + return invitationId; + } + + // ============================================================================ + // Invitation Import Flow + // ============================================================================ + + /** + * Imports an invitation by ID from the sync server. + * @param invitationId - Invitation ID to import + * @returns Imported tracked invitation + */ + async importInvitation(invitationId: string): Promise { + return this.flowManager.importInvitation(invitationId); + } + + /** + * Accepts an imported invitation (joins as participant). + * @param invitationId - Invitation ID to accept + * @returns Updated tracked invitation + */ + async acceptInvitation(invitationId: string): Promise { + return this.flowManager.acceptInvitation(invitationId); + } + + // ============================================================================ + // Invitation Data Operations + // ============================================================================ + + /** + * Appends inputs to an invitation. + * @param invitationId - Invitation ID + * @param inputs - Inputs to add + */ + async addInputs( + invitationId: string, + inputs: Array<{ + outpointTransactionHash: string; + outpointIndex: number; + sequenceNumber?: number; + }>, + ): Promise { + return this.flowManager.appendToInvitation(invitationId, { inputs }); + } + + /** + * Appends outputs to an invitation. + * @param invitationId - Invitation ID + * @param outputs - Outputs to add + */ + async addOutputs( + invitationId: string, + outputs: Array<{ + valueSatoshis?: bigint; + lockingBytecode?: Uint8Array; + outputIdentifier?: string; + roleIdentifier?: string; + }>, + ): Promise { + return this.flowManager.appendToInvitation(invitationId, { outputs }); + } + + /** + * Appends variables to an invitation. + * @param invitationId - Invitation ID + * @param variables - Variables to add + */ + async addVariables( + invitationId: string, + variables: Array<{ + variableIdentifier: string; + value: bigint | boolean | number | string; + roleIdentifier?: string; + }>, + ): Promise { + return this.flowManager.appendToInvitation(invitationId, { variables }); + } + + // ============================================================================ + // Signing & Broadcasting + // ============================================================================ + + /** + * Signs an invitation. + * @param invitationId - Invitation ID to sign + * @returns Updated tracked invitation + */ + async signInvitation(invitationId: string): Promise { + return this.flowManager.signInvitation(invitationId); + } + + /** + * Broadcasts the transaction for an invitation. + * @param invitationId - Invitation ID + * @returns Transaction hash + */ + async broadcastTransaction(invitationId: string): Promise { + return this.flowManager.broadcastTransaction(invitationId); + } + + // ============================================================================ + // Queries + // ============================================================================ + + /** + * Gets a tracked invitation by ID. + * @param invitationId - Invitation ID + * @returns Tracked invitation or undefined + */ + getInvitation(invitationId: string): TrackedInvitation | undefined { + return this.flowManager.get(invitationId); + } + + /** + * Gets all tracked invitations. + * @returns Array of tracked invitations + */ + getAllInvitations(): TrackedInvitation[] { + return this.flowManager.getAll(); + } + + /** + * Gets the invitation data. + * @param invitationId - Invitation ID + * @returns The XOInvitation or undefined + */ + getInvitationData(invitationId: string): XOInvitation | undefined { + return this.flowManager.get(invitationId)?.invitation; + } + + /** + * Gets the state of an invitation. + * @param invitationId - Invitation ID + * @returns Invitation state or undefined + */ + getInvitationState(invitationId: string): InvitationState | undefined { + return this.flowManager.get(invitationId)?.state; + } + + /** + * Gets available roles for an invitation. + * @param invitationId - Invitation ID + * @returns Array of available role identifiers + */ + async getAvailableRoles(invitationId: string): Promise { + const tracked = this.flowManager.get(invitationId); + if (!tracked) { + throw new Error(`Invitation not found: ${invitationId}`); + } + return this.walletController.getAvailableRoles(tracked.invitation); + } + + /** + * Gets missing requirements for an invitation. + * @param invitationId - Invitation ID + * @returns Missing requirements + */ + async getMissingRequirements(invitationId: string) { + const tracked = this.flowManager.get(invitationId); + if (!tracked) { + throw new Error(`Invitation not found: ${invitationId}`); + } + return this.walletController.getMissingRequirements(tracked.invitation); + } + + /** + * Gets requirements for an invitation. + * @param invitationId - Invitation ID + * @returns Requirements + */ + async getRequirements(invitationId: string) { + const tracked = this.flowManager.get(invitationId); + if (!tracked) { + throw new Error(`Invitation not found: ${invitationId}`); + } + return this.walletController.getRequirements(tracked.invitation); + } + + // ============================================================================ + // Cleanup + // ============================================================================ + + /** + * Stops tracking an invitation. + * @param invitationId - Invitation ID to stop tracking + */ + stopTracking(invitationId: string): void { + this.flowManager.untrack(invitationId); + } + + /** + * Cleans up all resources. + */ + destroy(): void { + this.flowManager.destroy(); + } +} diff --git a/src/controllers/wallet-controller.ts b/src/controllers/wallet-controller.ts new file mode 100644 index 0000000..9de10fc --- /dev/null +++ b/src/controllers/wallet-controller.ts @@ -0,0 +1,408 @@ +/** + * Wallet Controller - Orchestrates wallet operations via the XO Engine. + * + * Responsibilities: + * - Initializes Engine with user seed + * - Exposes wallet state queries (balances, UTXOs) + * - Delegates template/invitation operations to Engine + * - Emits state change events for UI updates + */ + +import { EventEmitter } from 'events'; +import { Engine } from '@xo-cash/engine'; +import type { XOInvitation, XOTemplate, XOTemplateStartingActions } from '@xo-cash/types'; +import type { UnspentOutputData, LockingBytecodeData } from '@xo-cash/state'; +import { p2pkhTemplate } from '@xo-cash/templates'; + +/** + * Configuration options for the wallet controller. + */ +export interface WalletControllerConfig { + /** Path for database storage */ + databasePath?: string; + /** Database filename */ + databaseFilename?: string; + /** Electrum application identifier */ + electrumApplicationIdentifier?: string; +} + +/** + * Balance information for display. + */ +export interface WalletBalance { + /** Total satoshis across all UTXOs */ + totalSatoshis: bigint; + /** Number of UTXOs */ + utxoCount: number; +} + +/** + * Events emitted by the wallet controller. + */ +export interface WalletControllerEvents { + 'initialized': () => void; + 'state-updated': () => void; + 'error': (error: Error) => void; +} + +/** + * Controller for wallet operations. + */ +export class WalletController extends EventEmitter { + /** The XO Engine instance */ + private engine: Engine | null = null; + + /** Controller configuration */ + private config: WalletControllerConfig; + + /** Whether the wallet is initialized */ + private initialized: boolean = false; + + /** + * Creates a new wallet controller. + * @param config - Controller configuration options + */ + constructor(config: WalletControllerConfig = {}) { + super(); + this.config = config; + } + + /** + * Checks if the wallet is initialized. + */ + isInitialized(): boolean { + return this.initialized && this.engine !== null; + } + + /** + * Initializes the wallet with a seed phrase. + * @param seed - BIP39 seed phrase + */ + async initialize(seed: string): Promise { + try { + // Create the engine with the provided seed + this.engine = await Engine.create(seed, { + databasePath: this.config.databasePath ?? './', + databaseFilename: this.config.databaseFilename ?? 'xo-wallet', + electrumApplicationIdentifier: this.config.electrumApplicationIdentifier ?? 'xo-wallet-cli', + }); + + // Import the default P2PKH template + await this.engine.importTemplate(p2pkhTemplate); + + // Set default locking parameters for P2PKH + await this.engine.setDefaultLockingParameters( + await this.getTemplateIdentifier(p2pkhTemplate), + 'receiveOutput', + 'receiver', + ); + + // Generate an initial receiving address + const templateId = await this.getTemplateIdentifier(p2pkhTemplate); + await this.engine.generateLockingBytecode(templateId, 'receiveOutput', 'receiver'); + + this.initialized = true; + this.emit('initialized'); + } catch (error) { + this.emit('error', error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + + /** + * Gets the template identifier from a template. + * @param template - The XO template + * @returns The template identifier + */ + private async getTemplateIdentifier(template: XOTemplate): Promise { + // Import the utility to generate template identifier + const { generateTemplateIdentifier } = await import('@xo-cash/engine'); + return generateTemplateIdentifier(template); + } + + /** + * Stops the wallet engine and cleans up resources. + */ + async stop(): Promise { + if (this.engine) { + await this.engine.stop(); + this.engine = null; + this.initialized = false; + } + } + + /** + * Gets the engine instance. + * @throws Error if engine is not initialized + */ + getEngine(): Engine { + if (!this.engine) { + throw new Error('Wallet not initialized. Please enter your seed phrase first.'); + } + return this.engine; + } + + // ============================================================================ + // Balance & UTXO Operations + // ============================================================================ + + /** + * Gets the wallet balance. + * @returns Wallet balance information + */ + async getBalance(): Promise { + const engine = this.getEngine(); + const utxos = await engine.listUnspentOutputsData(); + + const totalSatoshis = utxos.reduce( + (sum, utxo) => sum + BigInt(utxo.valueSatoshis), + BigInt(0), + ); + + return { + totalSatoshis, + utxoCount: utxos.length, + }; + } + + /** + * Gets all unspent outputs. + * @returns Array of unspent output data + */ + async getUnspentOutputs(): Promise { + const engine = this.getEngine(); + return engine.listUnspentOutputsData(); + } + + /** + * Gets locking bytecodes for a template. + * @param templateIdentifier - Template identifier + * @returns Array of locking bytecode data + */ + async getLockingBytecodes(templateIdentifier: string): Promise { + const engine = this.getEngine(); + return engine.listLockingBytecodesForTemplate(templateIdentifier); + } + + // ============================================================================ + // Template Operations + // ============================================================================ + + /** + * Gets all imported templates. + * @returns Array of templates + */ + async getTemplates(): Promise { + const engine = this.getEngine(); + return engine.listImportedTemplates(); + } + + /** + * Gets a specific template by identifier. + * @param templateIdentifier - Template identifier + * @returns The template or undefined + */ + async getTemplate(templateIdentifier: string): Promise { + const engine = this.getEngine(); + return engine.getTemplate(templateIdentifier); + } + + /** + * Gets starting actions for a template. + * @param templateIdentifier - Template identifier + * @returns Starting actions + */ + async getStartingActions(templateIdentifier: string): Promise { + const engine = this.getEngine(); + return engine.listStartingActions(templateIdentifier); + } + + /** + * Imports a template into the wallet. + * @param template - Template to import (JSON or object) + */ + async importTemplate(template: unknown): Promise { + const engine = this.getEngine(); + await engine.importTemplate(template); + this.emit('state-updated'); + } + + /** + * Generates a new locking bytecode (receiving address). + * @param templateIdentifier - Template identifier + * @param outputIdentifier - Output identifier + * @param roleIdentifier - Role identifier + * @returns Generated locking bytecode as hex + */ + async generateLockingBytecode( + templateIdentifier: string, + outputIdentifier: string, + roleIdentifier?: string, + ): Promise { + const engine = this.getEngine(); + const lockingBytecode = await engine.generateLockingBytecode( + templateIdentifier, + outputIdentifier, + roleIdentifier, + ); + this.emit('state-updated'); + return lockingBytecode; + } + + // ============================================================================ + // Invitation Operations + // ============================================================================ + + /** + * Creates a new invitation. + * @param templateIdentifier - Template identifier + * @param actionIdentifier - Action identifier + * @returns Created invitation + */ + async createInvitation( + templateIdentifier: string, + actionIdentifier: string, + ): Promise { + const engine = this.getEngine(); + return engine.createInvitation({ + templateIdentifier, + actionIdentifier, + }); + } + + /** + * Accepts an invitation. + * @param invitation - Invitation to accept + * @returns Updated invitation + */ + async acceptInvitation(invitation: XOInvitation): Promise { + const engine = this.getEngine(); + return engine.acceptInvitation(invitation); + } + + /** + * Appends data to an invitation. + * @param invitation - Invitation to append to + * @param params - Data to append + * @returns Updated invitation + */ + async appendInvitation( + invitation: XOInvitation, + params: { + inputs?: Array<{ + outpointTransactionHash?: string; + outpointIndex?: number; + sequenceNumber?: number; + mergesWith?: { commitIdentifier: string; index: number }; + unlockingBytecode?: Uint8Array; + }>; + outputs?: Array<{ + valueSatoshis?: bigint; + lockingBytecode?: Uint8Array; + outputIdentifier?: string; + roleIdentifier?: string; + mergesWith?: { commitIdentifier: string; index: number }; + }>; + variables?: Array<{ + variableIdentifier: string; + value: bigint | boolean | number | string; + roleIdentifier?: string; + }>; + }, + ): Promise { + const engine = this.getEngine(); + // Cast through unknown to handle strict type checking from engine's AppendInvitationParameters + // The engine expects Uint8Array for outpointTransactionHash but we accept string for convenience + return engine.appendInvitation(invitation, params as unknown as Parameters[1]); + } + + /** + * Signs an invitation. + * @param invitation - Invitation to sign + * @returns Signed invitation + */ + async signInvitation(invitation: XOInvitation): Promise { + const engine = this.getEngine(); + return engine.signInvitation(invitation); + } + + /** + * Validates an invitation. + * @param invitation - Invitation to validate + * @returns Whether the invitation is valid + */ + async isInvitationValid(invitation: XOInvitation): Promise { + const engine = this.getEngine(); + return engine.isInvitationValid(invitation); + } + + /** + * Gets available roles for an invitation. + * @param invitation - Invitation to check + * @returns Array of available role identifiers + */ + async getAvailableRoles(invitation: XOInvitation): Promise { + const engine = this.getEngine(); + return engine.listAvailableRoles(invitation); + } + + /** + * Gets requirements for an invitation. + * @param invitation - Invitation to check + * @returns Requirements information + */ + async getRequirements(invitation: XOInvitation) { + const engine = this.getEngine(); + return engine.listRequirements(invitation); + } + + /** + * Gets missing requirements for an invitation. + * @param invitation - Invitation to check + * @returns Missing requirements information + */ + async getMissingRequirements(invitation: XOInvitation) { + const engine = this.getEngine(); + return engine.listMissingRequirements(invitation); + } + + /** + * Finds suitable UTXOs for an invitation. + * @param invitation - Invitation to find resources for + * @param options - Search options + * @returns Suitable unspent outputs + */ + async findSuitableResources( + invitation: XOInvitation, + options: { templateIdentifier: string; outputIdentifier: string }, + ) { + const engine = this.getEngine(); + return engine.findSuitableResources(invitation, options); + } + + // ============================================================================ + // Transaction Operations + // ============================================================================ + + /** + * Executes an action (broadcasts transaction). + * @param invitation - Invitation with completed transaction + * @param options - Execution options + * @returns Transaction hash + */ + async executeAction( + invitation: XOInvitation, + options: { broadcastTransaction?: boolean } = { broadcastTransaction: true }, + ): Promise { + const engine = this.getEngine(); + const txHash = await engine.executeAction(invitation, { + broadcastTransaction: options.broadcastTransaction ?? true, + }); + + if (options.broadcastTransaction) { + this.emit('state-updated'); + } + + return txHash; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..dec599c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,32 @@ +/** + * XO Wallet CLI - Terminal User Interface for XO crypto wallet. + * + * Features: + * 1. View wallet state and balance + * 2. Create invitations for P2PKH transactions + * 3. Import and accept invitations + * 4. Sign and broadcast transactions + * 5. Real-time updates via SSE + */ + +import { App } from './app.js'; + +/** + * Main entry point. + */ +async function main(): Promise { + try { + // Create and start the application + await App.create({ + syncServerUrl: process.env['SYNC_SERVER_URL'] ?? 'http://localhost:3000', + databasePath: process.env['DB_PATH'] ?? './', + databaseFilename: process.env['DB_FILENAME'] ?? 'xo-wallet', + }); + } catch (error) { + console.error('Failed to start XO Wallet CLI:', error); + process.exit(1); + } +} + +// Run the application +main(); diff --git a/src/services/invitation-flow.ts b/src/services/invitation-flow.ts new file mode 100644 index 0000000..529efc0 --- /dev/null +++ b/src/services/invitation-flow.ts @@ -0,0 +1,435 @@ +/** + * Invitation Flow Manager - Manages the collaborative invitation lifecycle. + * + * Responsibilities: + * - Coordinates between local Engine and remote sync-server + * - Subscribes to SSE for real-time updates + * - Tracks invitation state machine + */ + +import { EventEmitter } from 'events'; +import type { XOInvitation } from '@xo-cash/types'; +import { SSESession } from '../utils/sse-client.js'; +import type { SyncClient } from './sync-client.js'; +import type { WalletController } from '../controllers/wallet-controller.js'; +import { decodeExtendedJsonObject } from '../utils/ext-json.js'; + +/** + * States an invitation can be in. + */ +export type InvitationState = + | 'created' // Just created locally + | 'published' // Published to sync server + | 'pending' // Waiting for other party + | 'ready' // All requirements met, ready to sign + | 'signed' // Signed and ready to broadcast + | 'broadcast' // Transaction broadcast + | 'completed' // Transaction confirmed + | 'expired' // Invitation expired + | 'error'; // Error state + +/** + * Tracked invitation with state information. + */ +export interface TrackedInvitation { + /** The invitation data */ + invitation: XOInvitation; + /** Current state */ + state: InvitationState; + /** SSE session for updates (if subscribed) */ + sseSession?: SSESession; + /** Timestamp when tracking started */ + trackedAt: number; + /** Last update timestamp */ + lastUpdatedAt: number; + /** Error message if in error state */ + error?: string; +} + +/** + * Events emitted by the invitation flow manager. + */ +export interface InvitationFlowEvents { + 'invitation-created': (invitation: XOInvitation) => void; + 'invitation-updated': (invitationId: string, invitation: XOInvitation) => void; + 'invitation-state-changed': (invitationId: string, state: InvitationState) => void; + 'error': (invitationId: string, error: Error) => void; +} + +/** + * Manages the invitation workflow. + */ +export class InvitationFlowManager extends EventEmitter { + /** Map of tracked invitations by ID */ + private trackedInvitations: Map = new Map(); + + /** Wallet controller reference */ + private walletController: WalletController; + + /** Sync client reference */ + private syncClient: SyncClient; + + /** + * Creates a new invitation flow manager. + * @param walletController - Wallet controller instance + * @param syncClient - Sync client instance + */ + constructor(walletController: WalletController, syncClient: SyncClient) { + super(); + this.walletController = walletController; + this.syncClient = syncClient; + } + + // ============================================================================ + // Invitation Tracking + // ============================================================================ + + /** + * Starts tracking an invitation. + * @param invitation - Invitation to track + * @param initialState - Initial state (default: 'created') + */ + track(invitation: XOInvitation, initialState: InvitationState = 'created'): TrackedInvitation { + const tracked: TrackedInvitation = { + invitation, + state: initialState, + trackedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + + this.trackedInvitations.set(invitation.invitationIdentifier, tracked); + return tracked; + } + + /** + * Gets a tracked invitation by ID. + * @param invitationId - Invitation ID + * @returns Tracked invitation or undefined + */ + get(invitationId: string): TrackedInvitation | undefined { + return this.trackedInvitations.get(invitationId); + } + + /** + * Gets all tracked invitations. + * @returns Array of tracked invitations + */ + getAll(): TrackedInvitation[] { + return Array.from(this.trackedInvitations.values()); + } + + /** + * Updates the state of a tracked invitation. + * @param invitationId - Invitation ID + * @param state - New state + */ + private updateState(invitationId: string, state: InvitationState): void { + const tracked = this.trackedInvitations.get(invitationId); + if (tracked) { + tracked.state = state; + tracked.lastUpdatedAt = Date.now(); + this.emit('invitation-state-changed', invitationId, state); + } + } + + /** + * Updates a tracked invitation with new data. + * @param invitation - Updated invitation + */ + private updateInvitation(invitation: XOInvitation): void { + const tracked = this.trackedInvitations.get(invitation.invitationIdentifier); + if (tracked) { + tracked.invitation = invitation; + tracked.lastUpdatedAt = Date.now(); + this.emit('invitation-updated', invitation.invitationIdentifier, invitation); + } + } + + /** + * Stops tracking an invitation. + * @param invitationId - Invitation ID + */ + untrack(invitationId: string): void { + const tracked = this.trackedInvitations.get(invitationId); + if (tracked?.sseSession) { + tracked.sseSession.close(); + } + this.trackedInvitations.delete(invitationId); + } + + // ============================================================================ + // Flow Operations + // ============================================================================ + + /** + * Creates a new invitation and starts tracking it. + * @param templateIdentifier - Template ID + * @param actionIdentifier - Action ID + * @returns Created and tracked invitation + */ + async createInvitation( + templateIdentifier: string, + actionIdentifier: string, + ): Promise { + // Create invitation via wallet controller + const invitation = await this.walletController.createInvitation( + templateIdentifier, + actionIdentifier, + ); + + // Track the invitation + const tracked = this.track(invitation, 'created'); + this.emit('invitation-created', invitation); + + return tracked; + } + + /** + * Publishes an invitation to the sync server. + * @param invitationId - Invitation ID to publish + * @returns Updated tracked invitation + */ + async publishInvitation(invitationId: string): Promise { + const tracked = this.trackedInvitations.get(invitationId); + if (!tracked) { + throw new Error(`Invitation not found: ${invitationId}`); + } + + try { + // Post to sync server + await this.syncClient.postInvitation(tracked.invitation); + + // Update state + this.updateState(invitationId, 'published'); + + return tracked; + } catch (error) { + tracked.state = 'error'; + tracked.error = error instanceof Error ? error.message : String(error); + this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + + /** + * Subscribes to SSE updates for an invitation. + * @param invitationId - Invitation ID to subscribe to + */ + async subscribeToUpdates(invitationId: string): Promise { + const tracked = this.trackedInvitations.get(invitationId); + if (!tracked) { + throw new Error(`Invitation not found: ${invitationId}`); + } + + // Close existing SSE session if any + if (tracked.sseSession) { + tracked.sseSession.close(); + } + + // Create new SSE session + const sseUrl = this.syncClient.getSSEUrl(invitationId); + + tracked.sseSession = await SSESession.from(sseUrl, { + method: 'GET', + headers: { + 'Accept': 'text/event-stream', + }, + onMessage: (event) => { + this.handleSSEMessage(invitationId, event); + }, + onError: (error) => { + console.error(`SSE error for ${invitationId}:`, error); + this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error))); + }, + onConnected: () => { + console.log(`SSE connected for invitation: ${invitationId}`); + }, + onDisconnected: () => { + console.log(`SSE disconnected for invitation: ${invitationId}`); + }, + attemptReconnect: true, + persistent: true, + retryDelay: 3000, + }); + + // Update state to pending (waiting for updates) + this.updateState(invitationId, 'pending'); + } + + /** + * Handles an SSE message for an invitation. + * @param invitationId - Invitation ID + * @param event - SSE event + */ + private handleSSEMessage(invitationId: string, event: { data: string; event?: string }): void { + try { + // Parse the event data + const parsed = JSON.parse(event.data) as { topic?: string; data?: unknown }; + + if (event.event === 'invitation-updated' || parsed.topic === 'invitation-updated') { + // Decode the invitation data (handles ExtJSON) + const invitationData = decodeExtendedJsonObject(parsed.data ?? parsed); + const invitation = invitationData as XOInvitation; + + // Update tracked invitation + this.updateInvitation(invitation); + + // Check if all requirements are met + this.checkInvitationState(invitationId); + } + } catch (error) { + console.error(`Error parsing SSE message for ${invitationId}:`, error); + } + } + + /** + * Checks and updates the state of an invitation based on its data. + * @param invitationId - Invitation ID to check + */ + private async checkInvitationState(invitationId: string): Promise { + const tracked = this.trackedInvitations.get(invitationId); + if (!tracked) return; + + try { + // Check missing requirements + const missing = await this.walletController.getMissingRequirements(tracked.invitation); + + // If no missing inputs/outputs, it's ready to sign + const hasNoMissingInputs = !missing.inputs || missing.inputs.length === 0; + const hasNoMissingOutputs = !missing.outputs || missing.outputs.length === 0; + + if (hasNoMissingInputs && hasNoMissingOutputs) { + this.updateState(invitationId, 'ready'); + } + } catch (error) { + // Ignore errors during state check + console.error(`Error checking invitation state: ${error}`); + } + } + + /** + * Imports an invitation from the sync server. + * @param invitationId - Invitation ID to import + * @returns Tracked invitation + */ + async importInvitation(invitationId: string): Promise { + // Fetch from sync server + const invitation = await this.syncClient.getInvitation(invitationId); + if (!invitation) { + throw new Error(`Invitation not found on server: ${invitationId}`); + } + + // Track the invitation + const tracked = this.track(invitation, 'pending'); + + return tracked; + } + + /** + * Accepts an invitation (joins as a participant). + * @param invitationId - Invitation ID to accept + * @returns Updated tracked invitation + */ + async acceptInvitation(invitationId: string): Promise { + const tracked = this.trackedInvitations.get(invitationId); + if (!tracked) { + throw new Error(`Invitation not found: ${invitationId}`); + } + + // Accept via wallet controller + const updatedInvitation = await this.walletController.acceptInvitation(tracked.invitation); + this.updateInvitation(updatedInvitation); + + return tracked; + } + + /** + * Appends data to an invitation. + * @param invitationId - Invitation ID + * @param params - Data to append + * @returns Updated tracked invitation + */ + async appendToInvitation( + invitationId: string, + params: Parameters[1], + ): Promise { + const tracked = this.trackedInvitations.get(invitationId); + if (!tracked) { + throw new Error(`Invitation not found: ${invitationId}`); + } + + // Append via wallet controller + const updatedInvitation = await this.walletController.appendInvitation( + tracked.invitation, + params, + ); + this.updateInvitation(updatedInvitation); + + // Publish update to sync server + await this.syncClient.updateInvitation(updatedInvitation); + + return tracked; + } + + /** + * Signs an invitation. + * @param invitationId - Invitation ID + * @returns Updated tracked invitation + */ + async signInvitation(invitationId: string): Promise { + const tracked = this.trackedInvitations.get(invitationId); + if (!tracked) { + throw new Error(`Invitation not found: ${invitationId}`); + } + + // Sign via wallet controller + const signedInvitation = await this.walletController.signInvitation(tracked.invitation); + this.updateInvitation(signedInvitation); + this.updateState(invitationId, 'signed'); + + // Publish signed invitation to sync server + await this.syncClient.updateInvitation(signedInvitation); + + return tracked; + } + + /** + * Broadcasts the transaction for an invitation. + * @param invitationId - Invitation ID + * @returns Transaction hash + */ + async broadcastTransaction(invitationId: string): Promise { + const tracked = this.trackedInvitations.get(invitationId); + if (!tracked) { + throw new Error(`Invitation not found: ${invitationId}`); + } + + // Execute action (broadcast) + const txHash = await this.walletController.executeAction(tracked.invitation, { + broadcastTransaction: true, + }); + + this.updateState(invitationId, 'broadcast'); + + // Close SSE session since we're done + if (tracked.sseSession) { + tracked.sseSession.close(); + delete tracked.sseSession; + } + + return txHash; + } + + /** + * Cleans up all resources. + */ + destroy(): void { + // Close all SSE sessions + for (const tracked of this.trackedInvitations.values()) { + if (tracked.sseSession) { + tracked.sseSession.close(); + } + } + this.trackedInvitations.clear(); + } +} diff --git a/src/services/sync-client.ts b/src/services/sync-client.ts new file mode 100644 index 0000000..91b35bb --- /dev/null +++ b/src/services/sync-client.ts @@ -0,0 +1,162 @@ +/** + * Sync Server Client - HTTP client for sync-server communication. + * + * Handles: + * - Creating/updating invitations on the server + * - Fetching invitations by ID + * - ExtJSON encoding/decoding for data transfer + */ + +import type { XOInvitation } from '@xo-cash/types'; +import { encodeExtendedJson, decodeExtendedJson } from '../utils/ext-json.js'; + +/** + * Response from the sync server. + */ +export interface SyncServerResponse { + success: boolean; + data?: T; + error?: string; +} + +/** + * HTTP client for sync-server communication. + */ +export class SyncClient { + /** Base URL of the sync server */ + private baseUrl: string; + + /** + * Creates a new sync client. + * @param baseUrl - Base URL of the sync server (e.g., http://localhost:3000) + */ + constructor(baseUrl: string) { + // Remove trailing slash if present + this.baseUrl = baseUrl.replace(/\/$/, ''); + } + + /** + * Makes an HTTP request to the sync server. + * @param method - HTTP method + * @param path - Request path + * @param body - Optional request body + * @returns Response data + */ + private async request( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}${path}`; + + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + const options: RequestInit = { + method, + headers, + }; + + if (body !== undefined) { + // Encode body using ExtJSON for proper BigInt and Uint8Array serialization + options.body = encodeExtendedJson(body); + } + + const response = await fetch(url, options); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const responseText = await response.text(); + + // Return empty object if no response body + if (!responseText) { + return {} as T; + } + + // Decode response using ExtJSON + return decodeExtendedJson(responseText) as T; + } + + // ============================================================================ + // Invitation Operations + // ============================================================================ + + /** + * Posts an invitation to the sync server (create or update). + * @param invitation - Invitation to post + * @returns The stored invitation + */ + async postInvitation(invitation: XOInvitation): Promise { + return this.request('POST', '/invitations', invitation); + } + + /** + * Gets an invitation from the sync server. + * @param invitationIdentifier - Invitation ID to fetch + * @returns The invitation or undefined if not found + */ + async getInvitation(invitationIdentifier: string): Promise { + try { + // Use query parameter for GET request (can't have body) + const response = await this.request( + 'GET', + `/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}` + ); + return response; + } catch (error) { + // Return undefined if not found (404) + if (error instanceof Error && error.message.includes('404')) { + return undefined; + } + throw error; + } + } + + /** + * Updates an invitation on the sync server. + * @param invitation - Updated invitation + * @returns The updated invitation + */ + async updateInvitation(invitation: XOInvitation): Promise { + // Uses the same POST endpoint which handles both create and update + return this.postInvitation(invitation); + } + + // ============================================================================ + // Health Check + // ============================================================================ + + /** + * Checks if the sync server is healthy. + * @returns True if server is healthy + */ + async isHealthy(): Promise { + try { + const response = await this.request<{ status: string }>('GET', '/health'); + return response.status === 'ok'; + } catch { + return false; + } + } + + /** + * Gets the base URL of the sync server. + */ + getBaseUrl(): string { + return this.baseUrl; + } + + /** + * Gets the SSE endpoint URL for an invitation. + * @param invitationId - Invitation ID to subscribe to + * @returns SSE endpoint URL + */ + getSSEUrl(invitationIdentifier: string): string { + return `${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`; + } +} diff --git a/src/tui/App.tsx b/src/tui/App.tsx new file mode 100644 index 0000000..d93a89a --- /dev/null +++ b/src/tui/App.tsx @@ -0,0 +1,204 @@ +/** + * Main App component for the XO Wallet CLI. + * Uses Ink for terminal rendering with React components. + */ + +import React from 'react'; +import { Box, Text, useApp, useInput } from 'ink'; +import { NavigationProvider, useNavigation } from './hooks/useNavigation.js'; +import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js'; +import type { WalletController } from '../controllers/wallet-controller.js'; +import type { InvitationController } from '../controllers/invitation-controller.js'; +import { colors, logoSmall } from './theme.js'; + +// Screen imports (will be created) +import { SeedInputScreen } from './screens/SeedInput.js'; +import { WalletStateScreen } from './screens/WalletState.js'; +import { TemplateListScreen } from './screens/TemplateList.js'; +import { ActionWizardScreen } from './screens/ActionWizard.js'; +import { InvitationScreen } from './screens/Invitation.js'; +import { TransactionScreen } from './screens/Transaction.js'; + +/** + * Props for the App component. + */ +interface AppProps { + walletController: WalletController; + invitationController: InvitationController; +} + +/** + * Router component that renders the current screen. + */ +function Router(): React.ReactElement { + const { screen } = useNavigation(); + + switch (screen) { + case 'seed-input': + return ; + case 'wallet': + return ; + case 'templates': + return ; + case 'wizard': + return ; + case 'invitations': + return ; + case 'transaction': + return ; + default: + return Unknown screen: {screen}; + } +} + +/** + * Status bar component shown at the bottom of the screen. + */ +function StatusBar(): React.ReactElement { + const { status } = useStatus(); + const { screen, canGoBack } = useNavigation(); + + return ( + + {logoSmall} + {status} + + {canGoBack ? 'ESC: Back | ' : ''}q: Quit + + + ); +} + +/** + * Dialog overlay component for modals. + */ +function DialogOverlay(): React.ReactElement | null { + const { dialog, setDialog } = useDialog(); + + useInput((input, key) => { + if (!dialog?.visible) return; + + if (key.return || input === 'y' || input === 'Y') { + if (dialog.type === 'confirm' && dialog.onConfirm) { + dialog.onConfirm(); + } else { + dialog.onCancel?.(); + } + } else if (key.escape || input === 'n' || input === 'N') { + dialog.onCancel?.(); + } + }, { isActive: dialog?.visible ?? false }); + + if (!dialog?.visible) return null; + + const borderColor = dialog.type === 'error' ? colors.error : + dialog.type === 'confirm' ? colors.warning : + colors.info; + + return ( + + + + {dialog.type === 'error' ? '✗ Error' : + dialog.type === 'confirm' ? '? Confirm' : + 'ℹ Info'} + + + {dialog.message} + + + {dialog.type === 'confirm' + ? 'Press Y to confirm, N or ESC to cancel' + : 'Press Enter or ESC to close'} + + + + ); +} + +/** + * Main content wrapper with global keybindings. + */ +function MainContent(): React.ReactElement { + const { exit } = useApp(); + const { goBack, canGoBack } = useNavigation(); + const { dialog } = useDialog(); + const appContext = useAppContext(); + + // Global keybindings (disabled when dialog is shown) + useInput((input, key) => { + // Don't handle global keys when dialog is shown + if (dialog?.visible) return; + + // Quit on 'q' or Ctrl+C + if (input === 'q' || (key.ctrl && input === 'c')) { + appContext.exit(); + exit(); + } + + // Go back on Escape + if (key.escape && canGoBack) { + goBack(); + } + }); + + return ( + + {/* Main content area */} + + + + + {/* Status bar */} + + + {/* Dialog overlay */} + + + ); +} + +/** + * Main App component. + * Sets up providers and renders the main content. + */ +export function App({ walletController, invitationController }: AppProps): React.ReactElement { + const { exit } = useApp(); + + const handleExit = () => { + // Cleanup controllers if needed + walletController.stop(); + exit(); + }; + + return ( + + + + + + ); +} diff --git a/src/tui/components/Button.tsx b/src/tui/components/Button.tsx new file mode 100644 index 0000000..6a11197 --- /dev/null +++ b/src/tui/components/Button.tsx @@ -0,0 +1,75 @@ +/** + * Button component with focus styling. + */ + +import React from 'react'; +import { Box, Text, useFocus } from 'ink'; +import { colors } from '../theme.js'; + +/** + * Props for the Button component. + */ +interface ButtonProps { + /** Button label */ + label: string; + /** Whether button is focused */ + focused?: boolean; + /** Whether button is disabled */ + disabled?: boolean; + /** Optional keyboard shortcut hint */ + shortcut?: string; +} + +/** + * Button component with focus highlighting. + */ +export function Button({ + label, + focused = false, + disabled = false, + shortcut, +}: ButtonProps): React.ReactElement { + const bgColor = disabled + ? colors.textMuted + : focused + ? colors.focus + : colors.secondary; + + const textColor = disabled + ? colors.bg + : focused + ? colors.bg + : colors.text; + + return ( + + + + {` ${label} `} + + + {shortcut && ( + ({shortcut}) + )} + + ); +} + +/** + * Button row component for multiple buttons. + */ +interface ButtonRowProps { + children: React.ReactNode; +} + +export function ButtonRow({ children }: ButtonRowProps): React.ReactElement { + return ( + + {children} + + ); +} diff --git a/src/tui/components/Dialog.tsx b/src/tui/components/Dialog.tsx new file mode 100644 index 0000000..16194f2 --- /dev/null +++ b/src/tui/components/Dialog.tsx @@ -0,0 +1,243 @@ +/** + * Dialog components for modals, confirmations, and input dialogs. + */ + +import React, { useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import TextInput from 'ink-text-input'; +import { colors } from '../theme.js'; + +/** + * Base dialog wrapper props. + */ +interface DialogWrapperProps { + /** Dialog title */ + title: string; + /** Border color */ + borderColor?: string; + /** Dialog content */ + children: React.ReactNode; + /** Dialog width */ + width?: number; +} + +/** + * Base dialog wrapper component. + */ +function DialogWrapper({ + title, + borderColor = colors.primary, + children, + width = 60, +}: DialogWrapperProps): React.ReactElement { + return ( + + {title} + + {children} + + + ); +} + +/** + * Props for InputDialog component. + */ +interface InputDialogProps { + /** Dialog title */ + title: string; + /** Input prompt/label */ + prompt: string; + /** Initial value */ + initialValue?: string; + /** Placeholder text */ + placeholder?: string; + /** Submit handler */ + onSubmit: (value: string) => void; + /** Cancel handler */ + onCancel: () => void; + /** Whether dialog is visible/active */ + isActive?: boolean; +} + +/** + * Input dialog for getting text input from user. + */ +export function InputDialog({ + title, + prompt, + initialValue = '', + placeholder, + onSubmit, + onCancel, + isActive = true, +}: InputDialogProps): React.ReactElement { + const [value, setValue] = useState(initialValue); + + useInput((input, key) => { + if (!isActive) return; + + if (key.escape) { + onCancel(); + } + }, { isActive }); + + const handleSubmit = (val: string) => { + onSubmit(val); + }; + + return ( + + {prompt} + + + + + Enter to submit • Esc to cancel + + + ); +} + +/** + * Props for ConfirmDialog component. + */ +interface ConfirmDialogProps { + /** Dialog title */ + title: string; + /** Confirmation message */ + message: string; + /** Confirm handler */ + onConfirm: () => void; + /** Cancel handler */ + onCancel: () => void; + /** Whether dialog is visible/active */ + isActive?: boolean; + /** Confirm button label */ + confirmLabel?: string; + /** Cancel button label */ + cancelLabel?: string; +} + +/** + * Confirmation dialog with Yes/No options. + */ +export function ConfirmDialog({ + title, + message, + onConfirm, + onCancel, + isActive = true, + confirmLabel = 'Yes', + cancelLabel = 'No', +}: ConfirmDialogProps): React.ReactElement { + const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm'); + + useInput((input, key) => { + if (!isActive) return; + + if (key.leftArrow || key.rightArrow || key.tab) { + setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm'); + } else if (key.return) { + if (selected === 'confirm') { + onConfirm(); + } else { + onCancel(); + } + } else if (key.escape || input === 'n' || input === 'N') { + onCancel(); + } else if (input === 'y' || input === 'Y') { + onConfirm(); + } + }, { isActive }); + + return ( + + {message} + + + {` ${confirmLabel} `} + + + {` ${cancelLabel} `} + + + + Y/N or Tab to switch • Enter to select + + + ); +} + +/** + * Props for MessageDialog component. + */ +interface MessageDialogProps { + /** Dialog title */ + title: string; + /** Message content */ + message: string; + /** Close handler */ + onClose: () => void; + /** Dialog type for styling */ + type?: 'info' | 'error' | 'success'; + /** Whether dialog is visible/active */ + isActive?: boolean; +} + +/** + * Simple message dialog (info, error, success). + */ +export function MessageDialog({ + title, + message, + onClose, + type = 'info', + isActive = true, +}: MessageDialogProps): React.ReactElement { + useInput((input, key) => { + if (!isActive) return; + + if (key.return || key.escape) { + onClose(); + } + }, { isActive }); + + const borderColor = type === 'error' ? colors.error : + type === 'success' ? colors.success : + colors.info; + + const icon = type === 'error' ? '✗' : + type === 'success' ? '✓' : + 'ℹ'; + + return ( + + {message} + + Press Enter or Esc to close + + + ); +} diff --git a/src/tui/components/Input.tsx b/src/tui/components/Input.tsx new file mode 100644 index 0000000..0eab123 --- /dev/null +++ b/src/tui/components/Input.tsx @@ -0,0 +1,109 @@ +/** + * Text input component with focus styling. + */ + +import React from 'react'; +import { Box, Text } from 'ink'; +import TextInput from 'ink-text-input'; +import { colors } from '../theme.js'; + +/** + * Props for the Input component. + */ +interface InputProps { + /** Current value */ + value: string; + /** Change handler */ + onChange: (value: string) => void; + /** Submit handler (Enter key) */ + onSubmit?: (value: string) => void; + /** Placeholder text */ + placeholder?: string; + /** Label shown above input */ + label?: string; + /** Whether input is focused */ + focus?: boolean; + /** Whether to mask input (for passwords) */ + mask?: string; + /** Whether input is disabled */ + disabled?: boolean; +} + +/** + * Text input component with label and focus styling. + */ +export function Input({ + value, + onChange, + onSubmit, + placeholder, + label, + focus = true, + mask, + disabled = false, +}: InputProps): React.ReactElement { + const borderColor = focus ? colors.focus : colors.border; + + return ( + + {label && ( + {label} + )} + + {disabled ? ( + {value || placeholder || ''} + ) : ( + + )} + + + ); +} + +/** + * Multi-line text display (read-only, styled like input). + */ +interface TextDisplayProps { + /** Text content */ + content: string; + /** Label shown above */ + label?: string; + /** Whether to show border */ + border?: boolean; +} + +export function TextDisplay({ + content, + label, + border = true +}: TextDisplayProps): React.ReactElement { + return ( + + {label && ( + {label} + )} + {border ? ( + + {content} + + ) : ( + {content} + )} + + ); +} diff --git a/src/tui/components/List.tsx b/src/tui/components/List.tsx new file mode 100644 index 0000000..1627e92 --- /dev/null +++ b/src/tui/components/List.tsx @@ -0,0 +1,159 @@ +/** + * Selectable list component with keyboard navigation. + */ + +import React from 'react'; +import { Box, Text, useInput } from 'ink'; +import { colors } from '../theme.js'; + +/** + * List item type. + */ +export interface ListItem { + /** Unique key for the item */ + key: string; + /** Display label */ + label: string; + /** Optional secondary text */ + description?: string; + /** Optional value associated with item */ + value?: T; + /** Whether item is disabled */ + disabled?: boolean; +} + +/** + * Props for the List component. + */ +interface ListProps { + /** List items */ + items: ListItem[]; + /** Currently selected index */ + selectedIndex: number; + /** Selection change handler */ + onSelect: (index: number) => void; + /** Item activation handler (Enter key) */ + onActivate?: (item: ListItem, index: number) => void; + /** Whether list is focused */ + focus?: boolean; + /** Maximum visible items (for scrolling) */ + maxVisible?: number; + /** Optional label */ + label?: string; + /** Show border */ + border?: boolean; +} + +/** + * Selectable list with keyboard navigation. + */ +export function List({ + items, + selectedIndex, + onSelect, + onActivate, + focus = true, + maxVisible = 10, + label, + border = true, +}: ListProps): React.ReactElement { + // Handle keyboard input + useInput((input, key) => { + if (!focus) return; + + if (key.upArrow || input === 'k') { + const newIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1; + onSelect(newIndex); + } else if (key.downArrow || input === 'j') { + const newIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0; + onSelect(newIndex); + } else if (key.return && onActivate && items[selectedIndex]) { + const item = items[selectedIndex]; + if (item && !item.disabled) { + onActivate(item, selectedIndex); + } + } + }, { isActive: focus }); + + // Calculate visible range for scrolling + const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), items.length - maxVisible)); + const visibleItems = items.slice(startIndex, startIndex + maxVisible); + + const borderColor = focus ? colors.focus : colors.border; + + const content = ( + + {visibleItems.map((item, visibleIndex) => { + const actualIndex = startIndex + visibleIndex; + const isSelected = actualIndex === selectedIndex; + + return ( + + + {isSelected ? '▸ ' : ' '} + {item.label} + + {item.description && ( + - {item.description} + )} + + ); + })} + {items.length === 0 && ( + No items + )} + + ); + + return ( + + {label && {label}} + {border ? ( + + {content} + + ) : content} + {items.length > maxVisible && ( + + {startIndex + 1}-{Math.min(startIndex + maxVisible, items.length)} of {items.length} + + )} + + ); +} + +/** + * Simple inline list for displaying items without selection. + */ +interface SimpleListProps { + items: string[]; + label?: string; + bullet?: string; +} + +export function SimpleList({ + items, + label, + bullet = '•' +}: SimpleListProps): React.ReactElement { + return ( + + {label && {label}} + {items.map((item, index) => ( + + {bullet} {item} + + ))} + + ); +} diff --git a/src/tui/components/ProgressBar.tsx b/src/tui/components/ProgressBar.tsx new file mode 100644 index 0000000..4bf8a12 --- /dev/null +++ b/src/tui/components/ProgressBar.tsx @@ -0,0 +1,130 @@ +/** + * Progress bar and step indicator components. + */ + +import React from 'react'; +import { Box, Text } from 'ink'; +import { colors } from '../theme.js'; + +/** + * Props for ProgressBar component. + */ +interface ProgressBarProps { + /** Current progress (0-100) */ + percent: number; + /** Bar width in characters */ + width?: number; + /** Show percentage text */ + showPercent?: boolean; + /** Bar character */ + character?: string; +} + +/** + * Simple progress bar component. + */ +export function ProgressBar({ + percent, + width = 40, + showPercent = true, + character = '█', +}: ProgressBarProps): React.ReactElement { + const clampedPercent = Math.max(0, Math.min(100, percent)); + const filled = Math.round((clampedPercent / 100) * width); + const empty = width - filled; + + return ( + + {character.repeat(filled)} + {'░'.repeat(empty)} + {showPercent && ( + {Math.round(clampedPercent)}% + )} + + ); +} + +/** + * Step definition for StepIndicator. + */ +export interface Step { + /** Step label */ + label: string; + /** Whether step is completed */ + completed?: boolean; + /** Whether step is current */ + current?: boolean; +} + +/** + * Props for StepIndicator component. + */ +interface StepIndicatorProps { + /** Steps to display */ + steps: Step[]; + /** Current step index (0-based) */ + currentStep: number; +} + +/** + * Step indicator showing progress through a multi-step wizard. + */ +export function StepIndicator({ + steps, + currentStep, +}: StepIndicatorProps): React.ReactElement { + return ( + + + {steps.map((step, index) => { + const isCompleted = index < currentStep; + const isCurrent = index === currentStep; + + const color = isCompleted ? colors.success : + isCurrent ? colors.focus : + colors.textMuted; + + const icon = isCompleted ? '✓' : + isCurrent ? '▸' : + '○'; + + return ( + + + {icon} {step.label} + + {index < steps.length - 1 && ( + + )} + + ); + })} + + + Step {currentStep + 1} of {steps.length} + + + ); +} + +/** + * Loading spinner with optional message. + */ +interface LoadingProps { + /** Loading message */ + message?: string; +} + +export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement { + // Simple spinner using Ink's spinner component + const Spinner = require('ink-spinner').default; + + return ( + + + + + {message} + + ); +} diff --git a/src/tui/components/Screen.tsx b/src/tui/components/Screen.tsx new file mode 100644 index 0000000..2df2c4c --- /dev/null +++ b/src/tui/components/Screen.tsx @@ -0,0 +1,71 @@ +/** + * Screen wrapper component providing consistent layout. + */ + +import React, { type ReactNode } from 'react'; +import { Box, Text } from 'ink'; +import { colors } from '../theme.js'; + +/** + * Props for the Screen component. + */ +interface ScreenProps { + /** Screen title displayed in header */ + title: string; + /** Optional subtitle */ + subtitle?: string; + /** Screen content */ + children: ReactNode; + /** Optional footer content */ + footer?: ReactNode; + /** Optional help text shown at bottom */ + helpText?: string; +} + +/** + * Screen wrapper component. + * Provides consistent header, content area, and footer layout. + */ +export function Screen({ + title, + subtitle, + children, + footer, + helpText +}: ScreenProps): React.ReactElement { + return ( + + {/* Header */} + + + {title} + {subtitle && {subtitle}} + + + + {/* Content */} + + {children} + + + {/* Help text */} + {helpText && ( + + {helpText} + + )} + + {/* Footer */} + {footer && ( + + {footer} + + )} + + ); +} diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts new file mode 100644 index 0000000..a61142e --- /dev/null +++ b/src/tui/components/index.ts @@ -0,0 +1,10 @@ +/** + * Export all shared components. + */ + +export { Screen } from './Screen.js'; +export { Input, TextDisplay } from './Input.js'; +export { Button, ButtonRow } from './Button.js'; +export { List, SimpleList, type ListItem } from './List.js'; +export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js'; +export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js'; diff --git a/src/tui/hooks/index.ts b/src/tui/hooks/index.ts new file mode 100644 index 0000000..bc7ec2b --- /dev/null +++ b/src/tui/hooks/index.ts @@ -0,0 +1,6 @@ +/** + * Export all hooks. + */ + +export { NavigationProvider, useNavigation } from './useNavigation.js'; +export { AppProvider, useAppContext, useDialog, useStatus } from './useAppContext.js'; diff --git a/src/tui/hooks/useAppContext.tsx b/src/tui/hooks/useAppContext.tsx new file mode 100644 index 0000000..a84d892 --- /dev/null +++ b/src/tui/hooks/useAppContext.tsx @@ -0,0 +1,183 @@ +/** + * App context hook for accessing controllers and app-level functions. + */ + +import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; +import type { WalletController } from '../../controllers/wallet-controller.js'; +import type { InvitationController } from '../../controllers/invitation-controller.js'; +import type { AppContextType, DialogState } from '../types.js'; + +/** + * App context. + */ +const AppContext = createContext(null); + +/** + * Dialog context for managing modal dialogs. + */ +interface DialogContextType { + dialog: DialogState | null; + setDialog: (dialog: DialogState | null) => void; +} + +const DialogContext = createContext(null); + +/** + * Status context for managing status bar. + */ +interface StatusContextType { + status: string; + setStatus: (status: string) => void; +} + +const StatusContext = createContext(null); + +/** + * App provider props. + */ +interface AppProviderProps { + children: ReactNode; + walletController: WalletController; + invitationController: InvitationController; + onExit: () => void; +} + +/** + * App provider component. + * Provides controllers, dialog management, and app-level functions to children. + */ +export function AppProvider({ + children, + walletController, + invitationController, + onExit, +}: AppProviderProps): React.ReactElement { + const [dialog, setDialog] = useState(null); + const [status, setStatusState] = useState('Ready'); + const [isWalletInitialized, setWalletInitialized] = useState(false); + + // Promise resolver for confirm dialogs + const [confirmResolver, setConfirmResolver] = useState<((value: boolean) => void) | null>(null); + + /** + * Show an error dialog. + */ + const showError = useCallback((message: string) => { + setDialog({ + visible: true, + type: 'error', + message, + onCancel: () => setDialog(null), + }); + }, []); + + /** + * Show an info dialog. + */ + const showInfo = useCallback((message: string) => { + setDialog({ + visible: true, + type: 'info', + message, + onCancel: () => setDialog(null), + }); + }, []); + + /** + * Show a confirmation dialog and wait for user response. + */ + const confirm = useCallback((message: string): Promise => { + return new Promise((resolve) => { + setConfirmResolver(() => resolve); + setDialog({ + visible: true, + type: 'confirm', + message, + onConfirm: () => { + setDialog(null); + resolve(true); + }, + onCancel: () => { + setDialog(null); + resolve(false); + }, + }); + }); + }, []); + + /** + * Update status bar message. + */ + const setStatus = useCallback((message: string) => { + setStatusState(message); + }, []); + + const appValue: AppContextType = { + walletController, + invitationController, + showError, + showInfo, + confirm, + exit: onExit, + setStatus, + isWalletInitialized, + setWalletInitialized, + }; + + const dialogValue: DialogContextType = { + dialog, + setDialog, + }; + + const statusValue: StatusContextType = { + status, + setStatus, + }; + + return ( + + + + {children} + + + + ); +} + +/** + * Hook to access app context. + * @returns App context + * @throws Error if used outside AppProvider + */ +export function useAppContext(): AppContextType { + const context = useContext(AppContext); + if (!context) { + throw new Error('useAppContext must be used within an AppProvider'); + } + return context; +} + +/** + * Hook to access dialog context. + * @returns Dialog context + */ +export function useDialog(): DialogContextType { + const context = useContext(DialogContext); + if (!context) { + throw new Error('useDialog must be used within an AppProvider'); + } + return context; +} + +/** + * Hook to access status context. + * @returns Status context + */ +export function useStatus(): StatusContextType { + const context = useContext(StatusContext); + if (!context) { + throw new Error('useStatus must be used within an AppProvider'); + } + return context; +} diff --git a/src/tui/hooks/useNavigation.tsx b/src/tui/hooks/useNavigation.tsx new file mode 100644 index 0000000..5846647 --- /dev/null +++ b/src/tui/hooks/useNavigation.tsx @@ -0,0 +1,87 @@ +/** + * Navigation hook for managing screen navigation with history. + */ + +import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; +import type { ScreenName, NavigationData, NavigationContextType } from '../types.js'; + +/** + * Navigation context. + */ +const NavigationContext = createContext(null); + +/** + * Navigation provider props. + */ +interface NavigationProviderProps { + children: ReactNode; + initialScreen?: ScreenName; +} + +/** + * Navigation provider component. + * Manages navigation state and provides navigation functions to children. + */ +export function NavigationProvider({ + children, + initialScreen = 'seed-input' +}: NavigationProviderProps): React.ReactElement { + const [screen, setScreen] = useState(initialScreen); + const [data, setData] = useState({}); + const [history, setHistory] = useState([]); + + /** + * Navigate to a new screen, optionally with data. + */ + const navigate = useCallback((newScreen: ScreenName, newData?: NavigationData) => { + // Add current screen to history + setHistory(prev => [...prev, screen]); + // Set new screen and data + setScreen(newScreen); + setData(newData ?? {}); + }, [screen]); + + /** + * Go back to the previous screen. + */ + const goBack = useCallback(() => { + if (history.length === 0) return; + + const newHistory = [...history]; + const previousScreen = newHistory.pop(); + + if (previousScreen) { + setHistory(newHistory); + setScreen(previousScreen); + setData({}); + } + }, [history]); + + const value: NavigationContextType = { + screen, + data, + history, + navigate, + goBack, + canGoBack: history.length > 0, + }; + + return ( + + {children} + + ); +} + +/** + * Hook to access navigation context. + * @returns Navigation context + * @throws Error if used outside NavigationProvider + */ +export function useNavigation(): NavigationContextType { + const context = useContext(NavigationContext); + if (!context) { + throw new Error('useNavigation must be used within a NavigationProvider'); + } + return context; +} diff --git a/src/tui/screens/ActionWizard.tsx b/src/tui/screens/ActionWizard.tsx new file mode 100644 index 0000000..0bd89a7 --- /dev/null +++ b/src/tui/screens/ActionWizard.tsx @@ -0,0 +1,497 @@ +/** + * Action Wizard Screen - Step-by-step walkthrough for template actions. + * + * Guides users through: + * - Reviewing action requirements + * - Entering variables (e.g., requestedSatoshis) + * - Reviewing outputs + * - Creating and publishing invitation + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { Box, Text, useInput } from 'ink'; +import TextInput from 'ink-text-input'; +import { StepIndicator, type Step } from '../components/ProgressBar.js'; +import { Button, ButtonRow } from '../components/Button.js'; +import { useNavigation } from '../hooks/useNavigation.js'; +import { useAppContext, useStatus } from '../hooks/useAppContext.js'; +import { colors, logoSmall, formatSatoshis } from '../theme.js'; +import { copyToClipboard } from '../utils/clipboard.js'; +import type { XOTemplate } from '@xo-cash/types'; + +/** + * Wizard step types. + */ +type StepType = 'info' | 'variables' | 'review' | 'publish'; + +/** + * Wizard step definition. + */ +interface WizardStep { + name: string; + type: StepType; +} + +/** + * Variable input state. + */ +interface VariableInput { + id: string; + name: string; + type: string; + hint?: string; + value: string; +} + +/** + * Action Wizard Screen Component. + */ +export function ActionWizardScreen(): React.ReactElement { + const { navigate, goBack, data: navData } = useNavigation(); + const { walletController, invitationController, showError, showInfo } = useAppContext(); + const { setStatus } = useStatus(); + + // Extract navigation data + const templateIdentifier = navData.templateIdentifier as string | undefined; + const actionIdentifier = navData.actionIdentifier as string | undefined; + const roleIdentifier = navData.roleIdentifier as string | undefined; + const template = navData.template as XOTemplate | undefined; + + // State + const [steps, setSteps] = useState([]); + const [currentStep, setCurrentStep] = useState(0); + const [variables, setVariables] = useState([]); + const [focusedInput, setFocusedInput] = useState(0); + const [focusedButton, setFocusedButton] = useState<'back' | 'cancel' | 'next'>('next'); + const [focusArea, setFocusArea] = useState<'content' | 'buttons'>('content'); + const [invitationId, setInvitationId] = useState(null); + const [isCreating, setIsCreating] = useState(false); + + /** + * Initialize wizard on mount. + */ + useEffect(() => { + if (!template || !actionIdentifier || !roleIdentifier) { + showError('Missing wizard data'); + goBack(); + return; + } + + // Build steps based on template + const action = template.actions?.[actionIdentifier]; + const role = action?.roles?.[roleIdentifier]; + const requirements = role?.requirements; + + const wizardSteps: WizardStep[] = [ + { name: 'Welcome', type: 'info' }, + ]; + + // Add variables step if needed + if (requirements?.variables && requirements.variables.length > 0) { + wizardSteps.push({ name: 'Variables', type: 'variables' }); + + // Initialize variable inputs + const varInputs = requirements.variables.map(varId => { + const varDef = template.variables?.[varId]; + return { + id: varId, + name: varDef?.name || varId, + type: varDef?.type || 'string', + hint: varDef?.hint, + value: '', + }; + }); + setVariables(varInputs); + } + + wizardSteps.push({ name: 'Review', type: 'review' }); + wizardSteps.push({ name: 'Publish', type: 'publish' }); + + setSteps(wizardSteps); + setStatus(`${actionIdentifier}/${roleIdentifier}`); + }, [template, actionIdentifier, roleIdentifier, showError, goBack, setStatus]); + + /** + * Get current step data. + */ + const currentStepData = steps[currentStep]; + + /** + * Navigate to next step. + */ + const nextStep = useCallback(async () => { + if (currentStep >= steps.length - 1) return; + + // If on review step, create invitation + if (currentStepData?.type === 'review') { + await createInvitation(); + return; + } + + setCurrentStep(prev => prev + 1); + setFocusArea('content'); + setFocusedInput(0); + }, [currentStep, steps.length, currentStepData]); + + /** + * Navigate to previous step. + */ + const previousStep = useCallback(() => { + if (currentStep <= 0) { + goBack(); + return; + } + setCurrentStep(prev => prev - 1); + setFocusArea('content'); + setFocusedInput(0); + }, [currentStep, goBack]); + + /** + * Cancel wizard. + */ + const cancel = useCallback(() => { + goBack(); + }, [goBack]); + + /** + * Create invitation. + */ + const createInvitation = useCallback(async () => { + if (!templateIdentifier || !actionIdentifier || !roleIdentifier) return; + + setIsCreating(true); + setStatus('Creating invitation...'); + + try { + // Create invitation + const tracked = await invitationController.createInvitation( + templateIdentifier, + actionIdentifier, + ); + + const invId = tracked.invitation.invitationIdentifier; + setInvitationId(invId); + + // Add variables if any + if (variables.length > 0) { + const variableData = variables.map(v => ({ + variableIdentifier: v.id, + value: v.type === 'number' || v.type === 'satoshis' + ? BigInt(v.value || '0') + : v.value, + })); + await invitationController.addVariables(invId, variableData); + } + + // Publish to sync server + await invitationController.publishAndSubscribe(invId); + + setCurrentStep(prev => prev + 1); + setStatus('Invitation created'); + } catch (error) { + showError(`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsCreating(false); + } + }, [templateIdentifier, actionIdentifier, roleIdentifier, variables, invitationController, showError, setStatus]); + + /** + * Copy invitation ID to clipboard. + */ + const copyId = useCallback(async () => { + if (!invitationId) return; + + try { + await copyToClipboard(invitationId); + showInfo(`Copied to clipboard!\n\n${invitationId}`); + } catch (error) { + showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`); + } + }, [invitationId, showInfo, showError]); + + /** + * Update variable value. + */ + const updateVariable = useCallback((index: number, value: string) => { + setVariables(prev => { + const updated = [...prev]; + const variable = updated[index]; + if (variable) { + updated[index] = { ...variable, value }; + } + return updated; + }); + }, []); + + // Handle keyboard navigation + useInput((input, key) => { + // Tab to switch between content and buttons + if (key.tab) { + if (focusArea === 'content') { + // In variables step, tab cycles through inputs first + if (currentStepData?.type === 'variables' && variables.length > 0) { + if (focusedInput < variables.length - 1) { + setFocusedInput(prev => prev + 1); + return; + } + } + setFocusArea('buttons'); + setFocusedButton('next'); + } else { + // Cycle through buttons + if (focusedButton === 'back') { + setFocusedButton('cancel'); + } else if (focusedButton === 'cancel') { + setFocusedButton('next'); + } else { + setFocusArea('content'); + setFocusedInput(0); + } + } + return; + } + + // Shift+Tab + if (key.shift && key.tab) { + if (focusArea === 'buttons') { + if (focusedButton === 'next') { + setFocusedButton('cancel'); + } else if (focusedButton === 'cancel') { + setFocusedButton('back'); + } else { + setFocusArea('content'); + if (currentStepData?.type === 'variables' && variables.length > 0) { + setFocusedInput(variables.length - 1); + } + } + } else { + if (focusedInput > 0) { + setFocusedInput(prev => prev - 1); + } else { + setFocusArea('buttons'); + setFocusedButton('back'); + } + } + return; + } + + // Arrow keys in buttons area + if (focusArea === 'buttons') { + if (key.leftArrow) { + setFocusedButton(prev => + prev === 'next' ? 'cancel' : prev === 'cancel' ? 'back' : 'back' + ); + } else if (key.rightArrow) { + setFocusedButton(prev => + prev === 'back' ? 'cancel' : prev === 'cancel' ? 'next' : 'next' + ); + } + } + + // Enter on buttons + if (key.return && focusArea === 'buttons') { + if (focusedButton === 'back') previousStep(); + else if (focusedButton === 'cancel') cancel(); + else if (focusedButton === 'next') nextStep(); + } + + // 'c' to copy on publish step + if (input === 'c' && currentStepData?.type === 'publish' && invitationId) { + copyId(); + } + }); + + // Get action details + const action = template?.actions?.[actionIdentifier ?? '']; + const actionName = action?.name || actionIdentifier || 'Unknown'; + + // Render step content + const renderStepContent = () => { + if (!currentStepData) return null; + + switch (currentStepData.type) { + case 'info': + return ( + + Action: {actionName} + {action?.description || 'No description'} + + Your Role: + {roleIdentifier} + + + {/* Show requirements */} + {action?.roles?.[roleIdentifier ?? '']?.requirements && ( + + Requirements: + {action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => ( + • Variable: {v} + ))} + + )} + + ); + + case 'variables': + return ( + + Enter required values: + + {variables.map((variable, index) => ( + + {variable.name} + {variable.hint && ( + ({variable.hint}) + )} + + updateVariable(index, value)} + focus={focusArea === 'content' && focusedInput === index} + placeholder={`Enter ${variable.name}...`} + /> + + + ))} + + + ); + + case 'review': + return ( + + Review your invitation: + + Template: {template?.name} + Action: {actionName} + Role: {roleIdentifier} + + {variables.length > 0 && ( + + Variables: + {variables.map(v => ( + + {' '}{v.name}: {v.value || '(empty)'} + + ))} + + )} + + + + + Press Next to create and publish the invitation. + + + + ); + + case 'publish': + return ( + + ✓ Invitation Created! + + Invitation ID: + + {invitationId} + + + + + + Share this ID with the other party to complete the transaction. + + + + + Press 'c' to copy ID to clipboard + + + ); + + default: + return null; + } + }; + + // Convert steps to StepIndicator format + const stepIndicatorSteps: Step[] = steps.map(s => ({ label: s.name })); + + return ( + + {/* Header */} + + {logoSmall} - Action Wizard + + {template?.name} {'>'} {actionName} (as {roleIdentifier}) + + + + {/* Progress indicator */} + + + + + {/* Content area */} + + + {' '}{currentStepData?.name} ({currentStep + 1}/{steps.length}){' '} + + + {isCreating ? ( + Creating invitation... + ) : ( + renderStepContent() + )} + + + + {/* Buttons */} + + +