From 2c2acf031202ba7c3dbad75f934ba8b89d493eb4 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 13 Aug 2026 17:08:59 +0800 Subject: [PATCH 1/9] feat(ts): standalone --- .github/workflows/ts-ci.yml | 63 ++++ .github/workflows/ts-standalone-release.yml | 228 ++++++++++++ ts/package-lock.json | 338 ++++++++++++++---- ts/package.json | 4 +- ts/scripts/build-standalone.mjs | 181 ++++++++++ ts/scripts/standalone/node-hid-runtime.ts | 84 +++++ ts/scripts/standalone/node-hid.ts | 8 + .../standalone/resolve-node-hid-addon.ts | 57 +++ ts/scripts/verify-standalone.mjs | 68 ++++ ts/tsup.config.ts | 4 +- 10 files changed, 961 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/ts-ci.yml create mode 100644 .github/workflows/ts-standalone-release.yml create mode 100644 ts/scripts/build-standalone.mjs create mode 100644 ts/scripts/standalone/node-hid-runtime.ts create mode 100644 ts/scripts/standalone/node-hid.ts create mode 100644 ts/scripts/standalone/resolve-node-hid-addon.ts create mode 100644 ts/scripts/verify-standalone.mjs diff --git a/.github/workflows/ts-ci.yml b/.github/workflows/ts-ci.yml new file mode 100644 index 000000000..4da3f44a6 --- /dev/null +++ b/.github/workflows/ts-ci.yml @@ -0,0 +1,63 @@ +name: TypeScript CI + +on: + pull_request: + branches: + - develop + push: + branches: + - develop + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ts-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Quality / Node ${{ matrix.node }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + strategy: + fail-fast: false + matrix: + node: + - "22" + - "24" + + defaults: + run: + working-directory: ts + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: ts/package-lock.json + + - name: Install locked dependencies + run: npm ci + + - name: Check architecture boundaries + run: npm run depcruise + + - name: Type-check + run: npm run typecheck + + - name: Run tests + run: npm test + + - name: Build npm bundle + run: npm run build + + - name: Validate npm package contents + run: npm pack --dry-run diff --git a/.github/workflows/ts-standalone-release.yml b/.github/workflows/ts-standalone-release.yml new file mode 100644 index 000000000..fca464bf5 --- /dev/null +++ b/.github/workflows/ts-standalone-release.yml @@ -0,0 +1,228 @@ +name: TypeScript Standalone Artifacts + +on: + push: + branches: + - develop + workflow_dispatch: + +permissions: + contents: read + +env: + NODE_VERSION: "20" + +jobs: + quality: + name: TypeScript quality gates + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: ts + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: ts/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Check architecture boundaries + run: npm run depcruise + + - name: Type-check + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build npm bundle + run: npm run build + + binaries: + name: ${{ matrix.asset }} + needs: quality + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + target: bun-linux-x64-baseline + asset: linux-x64 + executable: wallet-cli + archive: tar.gz + glibc_max: "2.35" + - os: ubuntu-22.04-arm + target: bun-linux-arm64 + asset: linux-arm64 + executable: wallet-cli + archive: tar.gz + glibc_max: "2.35" + - os: macos-15-intel + target: bun-darwin-x64 + asset: macos-x64 + executable: wallet-cli + archive: tar.gz + - os: macos-15 + target: bun-darwin-arm64 + asset: macos-arm64 + executable: wallet-cli + archive: tar.gz + - os: windows-2022 + target: bun-windows-x64-baseline + asset: windows-x64 + executable: wallet-cli.exe + archive: zip + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: ts + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: ts/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build standalone executable + run: >- + npm run build:standalone -- + --target ${{ matrix.target }} + --outfile standalone/${{ matrix.executable }} + + - name: Verify executable and embedded Ledger addon + run: node scripts/verify-standalone.mjs standalone/${{ matrix.executable }} + + - name: Verify Linux ABI baseline + if: runner.os == 'Linux' + shell: bash + env: + MAX_GLIBC: ${{ matrix.glibc_max }} + run: | + set -euo pipefail + executable="standalone/${{ matrix.executable }}" + native_addon="$( + ./node_modules/.bin/bun -e \ + 'import { resolveNodeHidAddon } from "./scripts/standalone/resolve-node-hid-addon.ts"; console.log(resolveNodeHidAddon().nativeAddon)' + )" + host_glibc="$(getconf GNU_LIBC_VERSION | awk '{print $2}')" + + if [[ "${host_glibc}" != "${MAX_GLIBC}" ]]; then + echo "::error::runner glibc ${host_glibc} does not match the supported baseline ${MAX_GLIBC}" + exit 1 + fi + + required_versions="$( + { + readelf --version-info --wide "${executable}" + readelf --version-info --wide "${native_addon}" + } | grep -oE 'GLIBC_[0-9]+(\.[0-9]+)*' | sed 's/^GLIBC_//' | sort -Vu + )" + highest_required="$(printf '%s\n' "${required_versions}" | tail -n 1)" + highest_version="$(printf '%s\n%s\n' "${MAX_GLIBC}" "${highest_required}" | sort -V | tail -n 1)" + + if [[ "${highest_version}" != "${MAX_GLIBC}" ]]; then + echo "::error::Linux artifact requires GLIBC_${highest_required}; maximum allowed is GLIBC_${MAX_GLIBC}" + exit 1 + fi + + dynamic_dependencies="$(readelf --dynamic --wide "${native_addon}")" + if ! grep -Fq 'Shared library: [libudev.so.1]' <<< "${dynamic_dependencies}"; then + echo "::error::Ledger native addon no longer declares its documented libudev.so.1 dependency" + exit 1 + fi + + echo "Verified GLIBC_${highest_required} <= GLIBC_${MAX_GLIBC} and libudev.so.1 dependency" + + - name: Verify macOS code signature + if: runner.os == 'macOS' + run: codesign --verify --verbose standalone/${{ matrix.executable }} + + - name: Package Unix archive + if: matrix.archive == 'tar.gz' + shell: bash + run: | + set -euo pipefail + version="$(node -p "require('./package.json').version")" + package="wallet-cli-${version}-${{ matrix.asset }}" + mkdir -p "standalone-assets/${package}" + cp "standalone/${{ matrix.executable }}" "standalone-assets/${package}/wallet-cli" + cp LICENSE "standalone-assets/${package}/LICENSE" + tar -C standalone-assets -czf "standalone-assets/${package}.tar.gz" "${package}" + rm -r "standalone-assets/${package}" + + - name: Package Windows archive + if: matrix.archive == 'zip' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $version = node -p "require('./package.json').version" + $package = "wallet-cli-$version-${{ matrix.asset }}" + New-Item -ItemType Directory -Force "standalone-assets/$package" | Out-Null + Copy-Item "standalone/${{ matrix.executable }}" "standalone-assets/$package/wallet-cli.exe" + Copy-Item "LICENSE" "standalone-assets/$package/LICENSE" + Compress-Archive -Path "standalone-assets/$package" -DestinationPath "standalone-assets/$package.zip" + Remove-Item -Recurse "standalone-assets/$package" + + - name: Upload platform artifact + uses: actions/upload-artifact@v4 + with: + name: wallet-cli-${{ matrix.asset }} + path: ts/standalone-assets/* + if-no-files-found: error + retention-days: 1 + + artifacts: + name: Publish Actions artifact + needs: binaries + runs-on: ubuntu-24.04 + permissions: + artifact-metadata: write + attestations: write + contents: read + id-token: write + + steps: + - name: Download binaries + uses: actions/download-artifact@v4 + with: + pattern: wallet-cli-* + path: standalone-assets + merge-multiple: true + + - name: Create checksums and metadata + shell: bash + run: | + set -euo pipefail + cd standalone-assets + printf 'commit=%s\nref=%s\nrun_id=%s\n' \ + "${GITHUB_SHA}" "${GITHUB_REF}" "${GITHUB_RUN_ID}" > BUILD_METADATA.txt + sha256sum BUILD_METADATA.txt wallet-cli-* > SHA256SUMS.txt + + - name: Attest standalone archives + uses: actions/attest@v4 + with: + subject-checksums: standalone-assets/SHA256SUMS.txt + + - name: Upload standalone bundle + uses: actions/upload-artifact@v4 + with: + name: wallet-cli-standalone-${{ github.sha }} + path: standalone-assets/* + if-no-files-found: error + retention-days: 30 diff --git a/ts/package-lock.json b/ts/package-lock.json index 8358f4d74..56ac316b1 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -10,7 +10,7 @@ "license": "LGPL-3.0-or-later", "dependencies": { "@ledgerhq/hw-app-trx": "^6.36.3", - "@ledgerhq/hw-transport-node-hid": "^6.33.4", + "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", "@ledgerhq/hw-transport-node-speculos-http": "^6.36.4", "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", @@ -36,6 +36,7 @@ "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", + "bun": "1.3.14", "dependency-cruiser": "^17.4.3", "eslint": "^10.8.1", "eslint-config-prettier": "^10.1.8", @@ -816,22 +817,6 @@ "events": "^3.3.0" } }, - "node_modules/@ledgerhq/hw-transport-node-hid": { - "version": "6.33.4", - "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid/-/hw-transport-node-hid-6.33.4.tgz", - "integrity": "sha512-/k0rH+wTpTmUifdxWuOER/dM3vPxW7RQIF0tByGC6noszVC3SGOZVcskBqJZ6xwIs1TvAHvZlEFvZWbnUJMBiw==", - "license": "Apache-2.0", - "dependencies": { - "@ledgerhq/devices": "8.15.1", - "@ledgerhq/errors": "^6.36.0", - "@ledgerhq/hw-transport": "6.35.4", - "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", - "@ledgerhq/logs": "^6.17.0", - "lodash": "^4.17.21", - "node-hid": "2.1.2", - "usb": "2.9.0" - } - }, "node_modules/@ledgerhq/hw-transport-node-hid-noevents": { "version": "6.35.4", "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid-noevents/-/hw-transport-node-hid-noevents-6.35.4.tgz", @@ -922,6 +907,230 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@oven/bun-darwin-aarch64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.14.tgz", + "integrity": "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.14.tgz", + "integrity": "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64-baseline": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.14.tgz", + "integrity": "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-freebsd-aarch64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-freebsd-aarch64/-/bun-freebsd-aarch64-1.3.14.tgz", + "integrity": "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oven/bun-freebsd-x64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-freebsd-x64/-/bun-freebsd-x64-1.3.14.tgz", + "integrity": "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oven/bun-linux-aarch64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.14.tgz", + "integrity": "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-aarch64-android": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-android/-/bun-linux-aarch64-android-1.3.14.tgz", + "integrity": "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oven/bun-linux-aarch64-musl": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.14.tgz", + "integrity": "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.14.tgz", + "integrity": "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-android": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-android/-/bun-linux-x64-android-1.3.14.tgz", + "integrity": "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oven/bun-linux-x64-baseline": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.14.tgz", + "integrity": "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.14.tgz", + "integrity": "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl-baseline": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.14.tgz", + "integrity": "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-windows-aarch64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.14.tgz", + "integrity": "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.14.tgz", + "integrity": "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64-baseline": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.14.tgz", + "integrity": "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -1716,12 +1925,6 @@ "@types/node": "*" } }, - "node_modules/@types/w3c-web-usb": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", - "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", - "license": "MIT" - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2331,6 +2534,47 @@ "ieee754": "^1.1.13" } }, + "node_modules/bun": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.14.tgz", + "integrity": "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg==", + "cpu": [ + "arm64", + "x64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "android", + "freebsd", + "win32" + ], + "bin": { + "bun": "bin/bun.exe", + "bunx": "bin/bunx.exe" + }, + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "1.3.14", + "@oven/bun-darwin-x64": "1.3.14", + "@oven/bun-darwin-x64-baseline": "1.3.14", + "@oven/bun-freebsd-aarch64": "1.3.14", + "@oven/bun-freebsd-x64": "1.3.14", + "@oven/bun-linux-aarch64": "1.3.14", + "@oven/bun-linux-aarch64-android": "1.3.14", + "@oven/bun-linux-aarch64-musl": "1.3.14", + "@oven/bun-linux-x64": "1.3.14", + "@oven/bun-linux-x64-android": "1.3.14", + "@oven/bun-linux-x64-baseline": "1.3.14", + "@oven/bun-linux-x64-musl": "1.3.14", + "@oven/bun-linux-x64-musl-baseline": "1.3.14", + "@oven/bun-windows-aarch64": "1.3.14", + "@oven/bun-windows-x64": "1.3.14", + "@oven/bun-windows-x64-baseline": "1.3.14" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -4141,12 +4385,6 @@ "node": ">=8" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, "node_modules/lossless-json": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz", @@ -4317,17 +4555,6 @@ "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", "license": "MIT" }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-hid": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/node-hid/-/node-hid-2.1.2.tgz", @@ -5656,37 +5883,6 @@ "dev": true, "license": "MIT" }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/usb": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/usb/-/usb-2.9.0.tgz", - "integrity": "sha512-G0I/fPgfHUzWH8xo2KkDxTTFruUWfppgSFJ+bQxz/kVY2x15EQ/XDB7dqD1G432G4gBG4jYQuF3U7j/orSs5nw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@types/w3c-web-usb": "^1.0.6", - "node-addon-api": "^6.0.0", - "node-gyp-build": "^4.5.0" - }, - "engines": { - "node": ">=10.20.0 <11.x || >=12.17.0 <13.0 || >=14.0.0" - } - }, - "node_modules/usb/node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "license": "MIT" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/ts/package.json b/ts/package.json index 5a5f59d25..e90075e35 100644 --- a/ts/package.json +++ b/ts/package.json @@ -41,6 +41,7 @@ }, "scripts": { "build": "tsup", + "build:standalone": "bun run scripts/build-standalone.mjs", "dev": "tsx src/index.ts", "typecheck": "tsc --noEmit", "depcruise": "depcruise src", @@ -55,7 +56,7 @@ "license": "LGPL-3.0-or-later", "dependencies": { "@ledgerhq/hw-app-trx": "^6.36.3", - "@ledgerhq/hw-transport-node-hid": "^6.33.4", + "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", "@ledgerhq/hw-transport-node-speculos-http": "^6.36.4", "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", @@ -83,6 +84,7 @@ "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", + "bun": "1.3.14", "dependency-cruiser": "^17.4.3", "eslint": "^10.8.1", "eslint-config-prettier": "^10.1.8", diff --git a/ts/scripts/build-standalone.mjs b/ts/scripts/build-standalone.mjs new file mode 100644 index 000000000..afda2673d --- /dev/null +++ b/ts/scripts/build-standalone.mjs @@ -0,0 +1,181 @@ +import { existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs"; +import { dirname, extname, join, resolve } from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { resolveNodeHidAddon } from "./standalone/resolve-node-hid-addon.js"; + +const targetByHost = { + "darwin-arm64": "bun-darwin-arm64", + "darwin-x64": "bun-darwin-x64", + "linux-arm64": "bun-linux-arm64", + "linux-x64": "bun-linux-x64-baseline", + "win32-x64": "bun-windows-x64-baseline", +}; + +const pinnedCompilerByTarget = { + "bun-linux-x64-baseline": "node_modules/@oven/bun-linux-x64-baseline/bin/bun", + "bun-windows-x64-baseline": + "node_modules/@oven/bun-windows-x64-baseline/bin/bun.exe", +}; + +function option(name) { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +} + +const host = `${process.platform}-${process.arch}`; +const expectedTarget = targetByHost[host]; +if (!expectedTarget) { + throw new Error(`standalone builds are not supported on ${host}`); +} + +const target = option("--target") ?? expectedTarget; +if (target !== expectedTarget) { + throw new Error( + `refusing to combine ${host} native HID code with ${target}; build each target on its native runner`, + ); +} + +const defaultName = process.platform === "win32" ? "wallet-cli.exe" : "wallet-cli"; +const requestedOutfile = resolve(option("--outfile") ?? join("standalone", defaultName)); +const outfile = + process.platform === "win32" && extname(requestedOutfile).toLowerCase() !== ".exe" + ? `${requestedOutfile}.exe` + : requestedOutfile; +const outfileExt = extname(outfile); +const outfileStem = outfileExt ? outfile.slice(0, -outfileExt.length) : outfile; +const stagedOutfile = `${outfileStem}.${process.pid}.${Date.now()}.building${outfileExt}`; +const compileExecutable = resolveCompileExecutable(target); +const { nativeAddon, nodeHidVersion, transportPackageJson } = resolveNodeHidAddon(); +console.log( + `using node-hid ${nodeHidVersion} resolved from ${transportPackageJson}: ${nativeAddon}`, +); + +mkdirSync(dirname(outfile), { recursive: true }); + +const compile = { + target, + outfile: stagedOutfile, + autoloadDotenv: false, + autoloadBunfig: false, +}; +if (compileExecutable) { + compile.executablePath = compileExecutable; + console.log(`using pinned Bun compiler executable ${compileExecutable}`); +} + +const hidShim = resolve("scripts/standalone/node-hid.ts"); + +try { + const result = await Bun.build({ + entrypoints: [resolve("src/index.ts")], + compile, + minify: true, + plugins: [ + { + name: "standalone-node-hid", + setup(build) { + build.onResolve({ filter: /^node-hid$/ }, () => ({ path: hidShim })); + build.onResolve({ filter: /^wallet-cli-native-node-hid$/ }, () => ({ + path: nativeAddon, + })); + }, + }, + ], + }); + + if (!result.success) { + for (const log of result.logs) console.error(log); + process.exitCode = 1; + } else { + // Bun emits an ad-hoc-signed Mach-O, but embedding the native HID payload can leave that + // signature invalid. Sign the staged bytes before publishing so the previous executable remains + // usable if signing fails. + if (process.platform === "darwin") { + const signed = spawnSync("/usr/bin/codesign", ["--force", "--sign", "-", stagedOutfile], { + stdio: "inherit", + }); + if (signed.error) throw signed.error; + if (signed.status !== 0) { + throw new Error(`codesign failed for ${stagedOutfile} with status ${signed.status}`); + } + } + await publishExecutable(stagedOutfile, outfile); + console.log(`built ${outfile} (${target})`); + } +} finally { + // Failed compile/sign/publish attempts must not accumulate large embedded-runtime executables. + try { + if (existsSync(stagedOutfile)) unlinkSync(stagedOutfile); + } catch { + // Preserve the primary build error; a stale, uniquely named staging file is safe to remove later. + } +} + +function resolveCompileExecutable(target) { + const configured = process.env.BUN_COMPILE_EXECUTABLE?.trim(); + if (configured) { + const executable = resolve(configured); + if (!existsSync(executable)) { + throw new Error(`BUN_COMPILE_EXECUTABLE does not exist: ${executable}`); + } + return executable; + } + + const pinnedCompiler = pinnedCompilerByTarget[target]; + if (!pinnedCompiler) return undefined; + + const executable = resolve(pinnedCompiler); + if (!existsSync(executable)) { + throw new Error( + `missing pinned compiler ${pinnedCompiler}; run npm ci without --omit=optional`, + ); + } + return executable; +} + +async function publishExecutable(staged, destination) { + const attempts = process.platform === "win32" ? 12 : 1; + let lastError; + + for (let attempt = 0; attempt < attempts; attempt++) { + try { + renameSync(staged, destination); + return; + } catch (error) { + lastError = error; + } + + // Windows cannot rename over an existing executable on every filesystem/runtime combination. + // Remove only after the new executable has built successfully; a running image remains locked + // and leaves the old destination intact. + if (process.platform === "win32" && existsSync(destination)) { + try { + unlinkSync(destination); + renameSync(staged, destination); + return; + } catch (error) { + lastError = error; + } + } + + if (!isRetryableWindowsLock(lastError) || attempt === attempts - 1) break; + await new Promise((resolveDelay) => setTimeout(resolveDelay, Math.min(50 * 2 ** attempt, 500))); + } + + const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; + const executableName = destination.split(/[\\/]/).at(-1); + const hint = + process.platform === "win32" + ? ` Close every running ${executableName} process and retry; ` + + `inspect locks with: tasklist /FI "IMAGENAME eq ${executableName}".` + : ""; + throw new Error(`failed to publish standalone executable to ${destination}${detail}.${hint}`, { + cause: lastError, + }); +} + +function isRetryableWindowsLock(error) { + if (process.platform !== "win32" || typeof error !== "object" || error === null) return false; + return new Set(["EPERM", "EACCES", "EBUSY", "EEXIST", "ENOTEMPTY"]).has(error.code); +} diff --git a/ts/scripts/standalone/node-hid-runtime.ts b/ts/scripts/standalone/node-hid-runtime.ts new file mode 100644 index 000000000..9119fb1bd --- /dev/null +++ b/ts/scripts/standalone/node-hid-runtime.ts @@ -0,0 +1,84 @@ +import { EventEmitter } from "node:events"; + +interface NativeHid { + close(): void; + read(callback: (error: Error | null, data?: number[]) => void): void; +} + +interface NativeBinding { + HID: new (...args: unknown[]) => NativeHid; + devices(...args: unknown[]): unknown[]; +} + +/** + * Build the small JavaScript facade exposed by `node-hid`. + * + * `node-hid` normally locates its N-API addon through the dynamic `bindings()` helper. A Bun + * executable has no package directory at runtime, so that lookup cannot work. The build plugin + * resolves the addon from Ledger transport's package location and redirects the shim's static + * native import to that exact file; Bun can then embed and extract it with the executable. + */ +export function createNodeHid(binding: NativeBinding) { + class HID extends EventEmitter { + private readonly raw: NativeHid; + private paused = true; + private closing = false; + private closed = false; + + constructor(...args: unknown[]) { + super(); + this.raw = new binding.HID(...args); + + // Preserve node-hid's public surface without maintaining a second list of native methods. + for (const method in binding.HID.prototype) { + if (method in this) continue; + (this as Record)[method] = ( + binding.HID.prototype as unknown as Record unknown> + )[method]!.bind(this.raw); + } + + this.on("newListener", (eventName) => { + if (eventName === "data") process.nextTick(() => this.resume()); + }); + } + + close(): void { + this.closing = true; + this.removeAllListeners(); + this.raw.close(); + this.closed = true; + } + + pause(): void { + this.paused = true; + } + + read(callback: (error: Error | null, data?: number[]) => void): void { + if (this.closed) throw new Error("Unable to read from a closed HID device"); + this.raw.read(callback); + } + + resume(): void { + if (!this.paused || this.listenerCount("data") === 0) return; + this.paused = false; + const readNext = (error: Error | null, data?: number[]): void => { + if (error) { + this.paused = true; + if (!this.closing) this.emit("error", error); + return; + } + if (this.listenerCount("data") === 0) this.paused = true; + if (!this.paused) this.read(readNext); + this.emit("data", data); + }; + this.read(readNext); + } + } + + return { + HID, + devices: (...args: unknown[]) => binding.devices(...args), + // The standalone Linux build statically selects hidraw; keep this method for API compatibility. + setDriverType: (_type: string) => {}, + }; +} diff --git a/ts/scripts/standalone/node-hid.ts b/ts/scripts/standalone/node-hid.ts new file mode 100644 index 000000000..be9d5d366 --- /dev/null +++ b/ts/scripts/standalone/node-hid.ts @@ -0,0 +1,8 @@ +import { createNodeHid } from "./node-hid-runtime.js"; + +// The standalone build plugin resolves this static import from Ledger transport's package path. +// Bun sees the resulting `.node` file during bundling and embeds the exact addon used by transport. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const binding = require("wallet-cli-native-node-hid"); + +export default createNodeHid(binding); diff --git a/ts/scripts/standalone/resolve-node-hid-addon.ts b/ts/scripts/standalone/resolve-node-hid-addon.ts new file mode 100644 index 000000000..cfa46f721 --- /dev/null +++ b/ts/scripts/standalone/resolve-node-hid-addon.ts @@ -0,0 +1,57 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; + +const TRANSPORT_PACKAGE = "@ledgerhq/hw-transport-node-hid-noevents"; +const SUPPORTED_NODE_HID_MAJOR = 2; + +interface ResolveNodeHidAddonOptions { + platform?: NodeJS.Platform; + rootUrl?: string | URL; +} + +interface ResolvedNodeHidAddon { + nativeAddon: string; + nodeHidVersion: string; + transportPackageJson: string; +} + +/** Resolve the native addon through the exact dependency tree visible to Ledger transport. */ +export function resolveNodeHidAddon( + options: ResolveNodeHidAddonOptions = {}, +): ResolvedNodeHidAddon { + const { platform = process.platform, rootUrl = import.meta.url } = options; + const rootRequire = createRequire(rootUrl); + const transportPackageJson = rootRequire.resolve(`${TRANSPORT_PACKAGE}/package.json`); + const transportRequire = createRequire(transportPackageJson); + const nodeHidPackageJson = transportRequire.resolve("node-hid/package.json"); + const packageJson = JSON.parse(readFileSync(nodeHidPackageJson, "utf8")) as { + version?: unknown; + }; + + if (typeof packageJson.version !== "string") { + throw new Error(`node-hid package has no valid version: ${nodeHidPackageJson}`); + } + const nodeHidVersion = packageJson.version; + const nodeHidMajor = Number.parseInt(nodeHidVersion.split(".", 1)[0]!, 10); + if (nodeHidMajor !== SUPPORTED_NODE_HID_MAJOR) { + throw new Error( + `${TRANSPORT_PACKAGE} resolved unsupported node-hid ${nodeHidVersion}; ` + + `review scripts/standalone/node-hid-runtime.ts before updating the native addon`, + ); + } + + const addonName = platform === "linux" ? "HID_hidraw.node" : "HID.node"; + const addonRequest = `node-hid/build/Release/${addonName}`; + let nativeAddon: string; + try { + nativeAddon = transportRequire.resolve(addonRequest); + } catch (cause) { + throw new Error( + `unable to resolve ${addonRequest} from ${transportPackageJson}; ` + + `run npm ci on the target platform without --omit=optional`, + { cause }, + ); + } + + return { nativeAddon, nodeHidVersion, transportPackageJson }; +} diff --git a/ts/scripts/verify-standalone.mjs b/ts/scripts/verify-standalone.mjs new file mode 100644 index 000000000..ff2b4678a --- /dev/null +++ b/ts/scripts/verify-standalone.mjs @@ -0,0 +1,68 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; + +const executable = process.argv[2] ? resolve(process.argv[2]) : undefined; +const expectedVersion = + process.argv[3] ?? JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; + +if (!executable) { + throw new Error("usage: node scripts/verify-standalone.mjs EXECUTABLE [EXPECTED_VERSION]"); +} + +function run(args, env = process.env) { + return spawnSync(executable, args, { + encoding: "utf8", + env, + windowsHide: true, + }); +} + +const version = run(["--version"]); +if (version.status !== 0 || version.stdout.trim() !== expectedVersion) { + throw new Error( + `version smoke test failed: status=${version.status}, stdout=${JSON.stringify(version.stdout)}, stderr=${JSON.stringify(version.stderr)}`, + ); +} + +const help = run(["--help"]); +if (help.status !== 0 || !help.stdout.includes("Usage: wallet-cli")) { + throw new Error( + `help smoke test failed: status=${help.status}, stdout=${JSON.stringify(help.stdout)}, stderr=${JSON.stringify(help.stderr)}`, + ); +} + +// Loading the Ledger command forces the embedded node-hid N-API addon to load. CI normally reaches +// `NoDevice`; a developer machine may instead enumerate a busy Ledger or complete the import. All +// three outcomes prove the addon loaded, while binding/dynamic-library failures remain rejected. +const isolatedHome = mkdtempSync(join(tmpdir(), "wallet-cli-standalone-")); +try { + const ledger = run( + ["import", "ledger", "--app", "tron", "--index", "0", "--output", "json"], + { ...process.env, WALLET_CLI_HOME: isolatedHome }, + ); + const output = `${ledger.stdout}\n${ledger.stderr}`; + let result; + try { + result = JSON.parse(ledger.stdout); + } catch { + // The failure below includes the original output for diagnosis. + } + const imported = ledger.status === 0 && result?.success === true; + const expectedDeviceError = + ledger.status === 1 && + result?.success === false && + typeof result?.error?.message === "string" && + /NoDevice|cannot open device with path/.test(result.error.message); + if (!imported && !expectedDeviceError) { + throw new Error( + `Ledger native-addon smoke test failed: status=${ledger.status}, output=${JSON.stringify(output)}`, + ); + } +} finally { + rmSync(isolatedHome, { recursive: true, force: true }); +} + +console.log(`verified ${executable} (${expectedVersion})`); diff --git a/ts/tsup.config.ts b/ts/tsup.config.ts index 24e666a7f..8dbc768fa 100644 --- a/ts/tsup.config.ts +++ b/ts/tsup.config.ts @@ -12,12 +12,12 @@ export default defineConfig({ // through esbuild, which rewrites those specifiers. noExternal: [/@ledgerhq\//], // Kept external, resolved from node_modules at runtime: - // - node-hid / usb: native .node addons esbuild cannot bundle. + // - node-hid: native .node addon esbuild cannot bundle. // - axios: a CJS dep dragged in by @ledgerhq's Speculos transport. Bundling it // into ESM turns its require("util")/require("http") into esbuild's __require // shim, which throws (`Dynamic require of "util" is not supported`) because an // ESM module has no `require`. Left external, Node loads it natively (real // require), so no shim/banner is needed. Declared in dependencies + pinned via // overrides to ^1.18.1 (post-2026-03 supply-chain-safe). - external: ["node-hid", "usb", "axios"], + external: ["node-hid", "axios"], }); From a8fb2d29e0ca6392a1c5f63d60bfa708fa1adb50 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 13 Aug 2026 19:10:09 +0800 Subject: [PATCH 2/9] fix: use declared ledger HID transport --- ts/src/adapters/outbound/ledger/index.test.ts | 2 +- ts/src/adapters/outbound/ledger/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/src/adapters/outbound/ledger/index.test.ts b/ts/src/adapters/outbound/ledger/index.test.ts index d25551bab..49e7b9909 100644 --- a/ts/src/adapters/outbound/ledger/index.test.ts +++ b/ts/src/adapters/outbound/ledger/index.test.ts @@ -10,7 +10,7 @@ const { closeSpy, tip712Calls, failures } = vi.hoisted(() => ({ tip712Calls: [] as Array<{ path: string; domainHash: string; messageHash: string }>, failures: { tip712: undefined as Error | undefined, tip712Hang: false }, })); -vi.mock("@ledgerhq/hw-transport-node-hid", () => ({ +vi.mock("@ledgerhq/hw-transport-node-hid-noevents", () => ({ default: { open: async () => ({ close: closeSpy }) }, })); // Every device APDU never resolves — models an on-device prompt that is never tapped. diff --git a/ts/src/adapters/outbound/ledger/index.ts b/ts/src/adapters/outbound/ledger/index.ts index b89cf23a7..08854ed73 100644 --- a/ts/src/adapters/outbound/ledger/index.ts +++ b/ts/src/adapters/outbound/ledger/index.ts @@ -70,7 +70,7 @@ async function openTransport(): Promise<{ transport: unknown; close: () => Promi }); return { transport, close: () => transport.close() }; } - const Hid = unwrap(await import("@ledgerhq/hw-transport-node-hid")); + const Hid = unwrap(await import("@ledgerhq/hw-transport-node-hid-noevents")); const transport = await Hid.open(""); return { transport, close: () => transport.close() }; } From 9c15048dea235f9b6c67459422d84d6c24cd046a Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 13 Aug 2026 20:54:13 +0800 Subject: [PATCH 3/9] test: stabilize cli golden timeouts --- ts/src/adapters/inbound/cli/input/prompt/index.ts | 4 ++++ .../inbound/cli/input/prompt/prompter.test.ts | 15 ++++++++++++++- .../inbound/cli/shell/positional-contract.test.ts | 15 ++++++++++++++- ts/test/contract-deploy.test.ts | 4 ++-- ts/test/golden.test.ts | 2 +- ts/vitest.config.ts | 10 ++++++---- 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/ts/src/adapters/inbound/cli/input/prompt/index.ts b/ts/src/adapters/inbound/cli/input/prompt/index.ts index a80befb53..0c01925b9 100644 --- a/ts/src/adapters/inbound/cli/input/prompt/index.ts +++ b/ts/src/adapters/inbound/cli/input/prompt/index.ts @@ -161,6 +161,10 @@ export class TtyBackend implements PromptBackend { #pendingKey?: (key: KeyEvent) => void; #keyListener?: (s: string, key: KeyEvent) => void; constructor() { + if (process.env.WALLET_CLI_NO_TTY === "1") { + this.#tty = false; + return; + } // Probe for a controlling terminal without holding the fd; the real stream opens on first prompt. try { closeSync(openSync("/dev/tty", "r")); diff --git a/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts b/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts index be4d34b77..a13f3d08a 100644 --- a/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts +++ b/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { Prompter, type PromptBackend, type KeyEvent } from "./index.js"; +import { createPrompter, Prompter, type PromptBackend, type KeyEvent } from "./index.js"; class FakeBackend implements PromptBackend { out = ""; @@ -37,6 +37,19 @@ describe("Prompter.setInteractive", () => { }); }); +describe("createPrompter", () => { + it("can force non-interactive mode for subprocess tests", () => { + const previous = process.env.WALLET_CLI_NO_TTY; + process.env.WALLET_CLI_NO_TTY = "1"; + try { + expect(createPrompter().isTTY()).toBe(false); + } finally { + if (previous === undefined) delete process.env.WALLET_CLI_NO_TTY; + else process.env.WALLET_CLI_NO_TTY = previous; + } + }); +}); + describe("Prompter.text", () => { it("re-prompts until validate passes", async () => { const be = new FakeBackend(["", " ", "ok"]); diff --git a/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts index 8a89cf774..49ba00890 100644 --- a/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts +++ b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts @@ -5,6 +5,8 @@ import { tmpdir } from "node:os"; import { buildCli, type ShellOptions } from "./index.js"; import { isChainCommand, type SessionRef } from "../contracts/index.js"; import { composeCliRuntime } from "../../../../bootstrap/composition.js"; +import { Prompter } from "../input/prompt/index.js"; +import { SecretResolver } from "../input/secret/index.js"; /** * The other shell tests drive synthetic command definitions, which proves the mechanism but not @@ -31,11 +33,22 @@ describe("every registered positional command rejects its -- spelling", ( // fresh per invocation: StreamManager permits one result emission, so a runtime cannot be shared // across commands that actually run. function newRuntime() { - return composeCliRuntime({ + const runtime = composeCliRuntime({ globals: { output: "json", verbose: false }, secretPaths: {}, startedAt: Date.now(), }); + const prompter = new Prompter({ + isTTY: () => false, + async question() { return ""; }, + async readKey() { return { name: "return" }; }, + write() {}, + beginRaw() {}, + endRaw() {}, + }); + runtime.deps.prompter = prompter; + runtime.deps.secrets = new SecretResolver(runtime.streams, {}, prompter); + return runtime; } function shellOpts(): ShellOptions { diff --git a/ts/test/contract-deploy.test.ts b/ts/test/contract-deploy.test.ts index ab5a79eb7..08fd3a737 100644 --- a/ts/test/contract-deploy.test.ts +++ b/ts/test/contract-deploy.test.ts @@ -80,8 +80,8 @@ function deploy( const r = spawnSync(process.execPath, ["--import", "tsx", ENTRY, ...globals, ...local], { input: PW + "\n", encoding: "utf8", - env: { ...process.env, WALLET_CLI_HOME: HOME, NO_COLOR: "1" }, - timeout: opts.timeoutMs ?? 30_000, + env: { ...process.env, WALLET_CLI_HOME: HOME, NO_COLOR: "1", WALLET_CLI_NO_TTY: "1" }, + timeout: opts.timeoutMs ?? 18_000, }); return JSON.parse(r.stdout); } diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 129ade369..181987c32 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -33,7 +33,7 @@ function run(args: string[], opts: { input?: string; password?: string | null } finalArgs.push("--password-stdin"); stdin = (opts.password ?? DEFAULT_PW) + "\n"; } - // 25s < the suite's 30s testTimeout: a genuinely hung subprocess errors here with a clear + // 18s < the suite's 20s testTimeout: a genuinely hung subprocess errors here with a clear // signal instead of silently eating the whole test budget. // `node --import tsx` executes the same TypeScript entry without the tsx CLI's IPC control // socket, so black-box tests also run in restricted CI/sandbox environments. diff --git a/ts/vitest.config.ts b/ts/vitest.config.ts index d7334be5b..9c8dafd18 100644 --- a/ts/vitest.config.ts +++ b/ts/vitest.config.ts @@ -9,19 +9,21 @@ export default defineConfig({ name: "unit", environment: "node", include: ["src/**/*.test.ts"], + testTimeout: 20_000, + hookTimeout: 20_000, }, }, { - // Golden tests spawn a fresh `tsx src/index.ts` per case, which cold-transpiles the + // Golden tests spawn a fresh `node --import tsx src/index.ts` per case, which cold-transpiles the // whole CLI import graph each time. Under parallel CPU load a single spawn can take far // longer than vitest's default 5s testTimeout, causing intermittent timeout failures. - // Give this suite generous timeouts so transient slowness doesn't flake the run. + // Keep this suite above the child-process guard so hangs fail with subprocess details. test: { name: "golden", environment: "node", include: ["test/**/*.test.ts"], - testTimeout: 30_000, - hookTimeout: 30_000, + testTimeout: 20_000, + hookTimeout: 20_000, }, }, ], From 37b56862b1a273e5ed2b25f9a28226beef43f0de Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 13 Aug 2026 21:21:15 +0800 Subject: [PATCH 4/9] ci: update typescript workflow triggers --- .github/workflows/ts-ci.yml | 2 +- .github/workflows/ts-standalone-release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ts-ci.yml b/.github/workflows/ts-ci.yml index 4da3f44a6..204744d20 100644 --- a/.github/workflows/ts-ci.yml +++ b/.github/workflows/ts-ci.yml @@ -6,7 +6,7 @@ on: - develop push: branches: - - develop + - "**" workflow_dispatch: permissions: diff --git a/.github/workflows/ts-standalone-release.yml b/.github/workflows/ts-standalone-release.yml index fca464bf5..4d31ad40b 100644 --- a/.github/workflows/ts-standalone-release.yml +++ b/.github/workflows/ts-standalone-release.yml @@ -3,7 +3,7 @@ name: TypeScript Standalone Artifacts on: push: branches: - - develop + - master workflow_dispatch: permissions: From 11e3b7146635d7170e4ab9cca68208a2d0608f2e Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 13 Aug 2026 21:24:09 +0800 Subject: [PATCH 5/9] ci: run typescript checks for all PRs --- .github/workflows/ts-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ts-ci.yml b/.github/workflows/ts-ci.yml index 204744d20..7898a64a1 100644 --- a/.github/workflows/ts-ci.yml +++ b/.github/workflows/ts-ci.yml @@ -3,7 +3,7 @@ name: TypeScript CI on: pull_request: branches: - - develop + - "**" push: branches: - "**" From cd73cf49ce25ab7f04c7e88d3471de3775d82fca Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 17 Aug 2026 18:01:36 +0800 Subject: [PATCH 6/9] test: drop the tty env flag for a detached spawn --- .../adapters/inbound/cli/input/prompt/index.ts | 4 ---- .../inbound/cli/input/prompt/prompter.test.ts | 15 +-------------- .../cli/shell/positional-contract.test.ts | 16 +++++++++++++--- ts/test/contract-deploy.test.ts | 8 +++++--- ts/test/detached.ts | 17 +++++++++++++++++ ts/test/golden.test.ts | 8 +++++--- 6 files changed, 41 insertions(+), 27 deletions(-) create mode 100644 ts/test/detached.ts diff --git a/ts/src/adapters/inbound/cli/input/prompt/index.ts b/ts/src/adapters/inbound/cli/input/prompt/index.ts index 0c01925b9..a80befb53 100644 --- a/ts/src/adapters/inbound/cli/input/prompt/index.ts +++ b/ts/src/adapters/inbound/cli/input/prompt/index.ts @@ -161,10 +161,6 @@ export class TtyBackend implements PromptBackend { #pendingKey?: (key: KeyEvent) => void; #keyListener?: (s: string, key: KeyEvent) => void; constructor() { - if (process.env.WALLET_CLI_NO_TTY === "1") { - this.#tty = false; - return; - } // Probe for a controlling terminal without holding the fd; the real stream opens on first prompt. try { closeSync(openSync("/dev/tty", "r")); diff --git a/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts b/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts index a13f3d08a..be4d34b77 100644 --- a/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts +++ b/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { createPrompter, Prompter, type PromptBackend, type KeyEvent } from "./index.js"; +import { Prompter, type PromptBackend, type KeyEvent } from "./index.js"; class FakeBackend implements PromptBackend { out = ""; @@ -37,19 +37,6 @@ describe("Prompter.setInteractive", () => { }); }); -describe("createPrompter", () => { - it("can force non-interactive mode for subprocess tests", () => { - const previous = process.env.WALLET_CLI_NO_TTY; - process.env.WALLET_CLI_NO_TTY = "1"; - try { - expect(createPrompter().isTTY()).toBe(false); - } finally { - if (previous === undefined) delete process.env.WALLET_CLI_NO_TTY; - else process.env.WALLET_CLI_NO_TTY = previous; - } - }); -}); - describe("Prompter.text", () => { it("re-prompts until validate passes", async () => { const be = new FakeBackend(["", " ", "ok"]); diff --git a/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts index 49ba00890..90fb71ece 100644 --- a/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts +++ b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts @@ -38,12 +38,22 @@ describe("every registered positional command rejects its -- spelling", ( secretPaths: {}, startedAt: Date.now(), }); + // These two overrides are load-bearing, and only on a developer machine: `composeCliRuntime` + // wires the real TtyBackend, which finds the controlling terminal and prompts on /dev/tty. The + // `--account` case below dispatches `backup`/`delete`, whose passwordMode primes the master + // password — with a real TTY that blocks forever (CI has no terminal, so it passes there). + // Both lines are needed: dispatch prompts through deps.prompter, priming through deps.secrets' + // own prompter reference. Any prompt reaching the backend is a bug in this test's assumptions, + // so the backend throws rather than silently answering "" and passing for the wrong reason. + const unreachable = (site: string) => () => { + throw new Error(`positional-contract test must not prompt (${site})`); + }; const prompter = new Prompter({ isTTY: () => false, - async question() { return ""; }, - async readKey() { return { name: "return" }; }, + question: unreachable("question"), + readKey: unreachable("readKey"), write() {}, - beginRaw() {}, + beginRaw: unreachable("beginRaw"), endRaw() {}, }); runtime.deps.prompter = prompter; diff --git a/ts/test/contract-deploy.test.ts b/ts/test/contract-deploy.test.ts index 08fd3a737..1febb1094 100644 --- a/ts/test/contract-deploy.test.ts +++ b/ts/test/contract-deploy.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { spawnSync } from "node:child_process"; +import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process"; import { randomBytes } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -7,6 +7,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Keystore } from "../src/adapters/outbound/keystore/index.js"; import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js"; +import { DETACHED } from "./detached.js"; // Regression coverage for issue #2: `contract deploy` constructor params. // • --constructor-sig was a dead flag (types come from the ABI); it was removed. @@ -80,9 +81,10 @@ function deploy( const r = spawnSync(process.execPath, ["--import", "tsx", ENTRY, ...globals, ...local], { input: PW + "\n", encoding: "utf8", - env: { ...process.env, WALLET_CLI_HOME: HOME, NO_COLOR: "1", WALLET_CLI_NO_TTY: "1" }, + env: { ...process.env, WALLET_CLI_HOME: HOME, NO_COLOR: "1" }, timeout: opts.timeoutMs ?? 18_000, - }); + ...DETACHED, + } as SpawnSyncOptionsWithStringEncoding); return JSON.parse(r.stdout); } diff --git a/ts/test/detached.ts b/ts/test/detached.ts new file mode 100644 index 000000000..e63e58ce5 --- /dev/null +++ b/ts/test/detached.ts @@ -0,0 +1,17 @@ +/** + * Spawn options every black-box CLI test must use, or the suite hangs on a developer machine. + * + * The CLI decides whether it MAY prompt by probing /dev/tty (`TtyBackend`), not by inspecting + * stdin — prompts are written to and read from the terminal directly so they can never pollute a + * piped result envelope. A child inherits its parent's controlling terminal even when all three + * stdio streams are pipes, so on a workstation any command that leaves a promptable field off argv + * hits gap-fill, writes `? Label …` to /dev/tty and waits forever: the piped stdin cannot answer + * it. CI has no controlling terminal, so the very same suite passes there — a local-only hang. + * + * `detached` makes the child a session leader (setsid), which drops the controlling terminal and + * reproduces the CI condition exactly. spawnSync honors it at runtime but @types/node declares it + * only for spawn(), hence the `as SpawnSyncOptionsWithStringEncoding` at the call sites. The + * failure mode is safe: if a future Node stopped honoring it, the hang comes back as a loud spawn + * timeout — it cannot silently turn into a passing test. + */ +export const DETACHED = { detached: true } as const; diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 181987c32..ef861435c 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { spawnSync } from "node:child_process"; +import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process"; import { mkdtempSync, readFileSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,6 +7,7 @@ import { Keystore } from "../src/adapters/outbound/keystore/index.js"; import { TokenBook } from "../src/adapters/outbound/tokenbook/index.js"; import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js"; import type { TokenEntry } from "../src/domain/types/index.js"; +import { DETACHED } from "./detached.js"; const ENTRY = join(process.cwd(), "src", "index.ts"); const MNEMONIC = "test test test test test test test test test test test junk"; @@ -41,8 +42,9 @@ function run(args: string[], opts: { input?: string; password?: string | null } input: stdin, encoding: "utf8", env, - timeout: 25_000, - }); + timeout: 18_000, + ...DETACHED, + } as SpawnSyncOptionsWithStringEncoding); let json: any; try { json = JSON.parse(r.stdout); From 1e6aa46ec317a6da3f05786d4e94bd8e76f53170 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 17 Aug 2026 18:01:36 +0800 Subject: [PATCH 7/9] ci: limit typescript push triggers to develop, master and release branches --- .github/workflows/ts-ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ts-ci.yml b/.github/workflows/ts-ci.yml index 7898a64a1..ba2d0f513 100644 --- a/.github/workflows/ts-ci.yml +++ b/.github/workflows/ts-ci.yml @@ -4,9 +4,13 @@ on: pull_request: branches: - "**" + # Topic branches are covered by the pull_request trigger above; pushing to one would otherwise + # start a second, identical run (a different concurrency key, so neither cancels the other). push: branches: - - "**" + - develop + - master + - "release_*" workflow_dispatch: permissions: From f54e37d280f197a9565744dd96f70f0d919f3f25 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 17 Aug 2026 18:01:36 +0800 Subject: [PATCH 8/9] chore: record the uri-js lockfile entry --- ts/package-lock.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ts/package-lock.json b/ts/package-lock.json index 56ac316b1..37e73310a 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -5883,6 +5883,16 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", From dbe71b7ad95de7a9581925672cf94a21fed56aba Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 18 Aug 2026 01:21:30 +0800 Subject: [PATCH 9/9] docs: de-Chinese the help and layout comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate the two comments whose Chinese carried information the English did not: the root overview's three-group rationale, and layout's "§0.4 one field per line" section title (the §0.4 pointer stays — render/tx.ts, account.ts and wallet.ts cite the same spec section). Drop the four where the Chinese only restated the adjacent English: "群组层"/"叶子层" next to "group skeleton"/"leaf skeleton", and the duplicated "描述统一在 Usage 之后" clause. CJK in *.test.ts is left alone: those fixtures cover fullwidth normalization and character- rather than byte-counted length limits. --- ts/src/adapters/inbound/cli/help/index.ts | 11 ++++++----- ts/src/adapters/inbound/cli/render/layout.ts | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index 4b876e815..9b977386a 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -100,7 +100,8 @@ export class HelpService { } /** top-level overview: first release presents TRON as the product surface. - * Docker-style three groups: Common (高频入口) / Management (链上资源名词) / Commands (本机治理). */ + * Docker-style three groups: Common (high-frequency entry points) / Management (on-chain resource + * nouns) / Commands (local governance). */ #renderRoot(): string { const common = [ ["create", "Create a new HD wallet (BIP39 seed)", ""], @@ -165,7 +166,7 @@ export class HelpService { ] as const; const optionRow = row(Math.max(...optionRows.map(([name]) => name.length)) + 2); - // Usage first, description after (: 描述统一在 Usage 之后); root Usage is the inline form. + // Usage first, description after; root Usage is the inline form. const lines = [ `${bold("Usage:")} wallet-cli [OPTIONS] COMMAND`, "", @@ -202,7 +203,7 @@ export class HelpService { return this.#renderGroup(group, rows); } - /** shared group skeleton (群组层): inline Usage → description → verb list → footer. */ + /** shared group skeleton: inline Usage → description → verb list → footer. */ #renderGroup(group: string, rows: ReadonlyArray): string { // Width is the longest verb, uncapped: a cap cannot shorten an over-long verb, it only stops // padEnd from reaching it — so every summary in the group loses its column the moment one verb @@ -257,7 +258,7 @@ export class HelpService { }); } - /** shared leaf skeleton (叶子层): Usage → description → Requires → Options (incl. stdin channel) → Global options → Examples. */ + /** shared leaf skeleton: Usage → description → Requires → Options (incl. stdin channel) → Global options → Examples. */ #renderLeaf(c: { path: string[]; summary?: string; @@ -526,7 +527,7 @@ function globalFlagLine(g: GlobalFlag): string { return ` ${globalFlagHead(g).padEnd(26)} ${g.description}${g.description && tag ? " " : ""}${tag}`.trimEnd(); } -// Group (群组层) descriptions, keyed by the registry group head. Usually one line; a group whose +// Group descriptions, keyed by the registry group head. Usually one line; a group whose // behavior warrants it may span multiple lines (embed "\n"). Only groups that surface a // ` --help` page need an entry; absent → the description line is omitted. const GROUP_DESCRIPTIONS: Record = { diff --git a/ts/src/adapters/inbound/cli/render/layout.ts b/ts/src/adapters/inbound/cli/render/layout.ts index 60b8dab54..174fc3d28 100644 --- a/ts/src/adapters/inbound/cli/render/layout.ts +++ b/ts/src/adapters/inbound/cli/render/layout.ts @@ -1,6 +1,6 @@ /** * Layout primitives — structural composition of label/value blocks and tables, - * plus status glyphs. The "§0.4 字段独占一行" vocabulary; no scalar or domain knowledge. + * plus status glyphs. The "§0.4 one field per line" vocabulary; no scalar or domain knowledge. */ export type Obj = Record; export type Pair = [string, string];