From 00b0f09d75d0fe231766ff188121bd8dccad0906 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 13 Aug 2026 17:08:59 +0800 Subject: [PATCH] feat(ts): standalone --- .github/workflows/ts-ci.yml | 63 ++++ .github/workflows/ts-standalone-release.yml | 228 ++++++++++++ ts/package-lock.json | 328 ++++++++++++++---- 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(+), 64 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 16a9c2092..b6343c2cd 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", @@ -35,6 +35,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", "tsup": "^8.5.1", "tsx": "^4.22.4", @@ -617,22 +618,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", @@ -723,6 +708,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", @@ -1503,12 +1712,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", @@ -1858,6 +2061,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", @@ -3234,12 +3478,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", @@ -3387,17 +3625,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", @@ -4589,27 +4816,6 @@ "dev": true, "license": "MIT" }, - "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 d92364000..b32ba5a48 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", @@ -51,7 +52,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", @@ -78,6 +79,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", "tsup": "^8.5.1", "tsx": "^4.22.4", 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"], });