diff --git a/.gitmodules b/.gitmodules index ac05c35c..2734029f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,9 +16,6 @@ [submodule "ckb/nostr-binding"] path = ckb/nostr-binding url = https://github.com/cryptape/nostr-binding.git -[submodule "ckb/ckb-standalone-debugger"] - path = ckb/ckb-standalone-debugger - url = https://github.com/nervosnetwork/ckb-standalone-debugger.git [submodule "ckb/pw-lock"] path = ckb/pw-lock url = https://github.com/nervosnetwork/pw-lock.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 07361a5a..2843c1b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # @offckb/cli +## 0.4.12 + +### Patch Changes + +- a591136: Update the ckb-tui download URL to the new `nervosnetwork/ckb-tui` org repository after ckb-tui was transferred out of the original personal repo. Release assets moved with the transfer, so the pinned SHA-256 digests still match byte-for-byte and verified installs are unaffected. +- e156a5a: Fix `offckb debug` failing with `ENOENT` on the tx-file path. The debugger now splits its command line quote-aware, so the quotes `encodeBinPathForTerminal` adds around space-containing paths are stripped instead of becoming part of the file name (a regression from the native ckb-debugger switch, which passes array argv to `execFileSync` instead of a shell string). +- 4f27ae9: Add `offckb install ckb-debugger` to install the native `ckb-debugger` binary. Instead of compiling from source with `cargo install` (which needs a Rust toolchain), offckb now downloads the prebuilt release asset for the current platform from the official ckb-standalone-debugger GitHub releases, verifies its SHA-256 digest, publishes it under the offckb data directory, and puts a `ckb-debugger` shim next to the offckb binary so generated projects can find it on PATH. `offckb create` installs the native debugger automatically when it is missing. The bundled WASM debugger and its fallback logic have been removed — `offckb debug` now requires the native binary. Version checks against the configured minimum now compare numerically, so an installed binary that satisfies `tools.ckbDebugger.minVersion` is correctly recognized. +- 852b68b: Fix review findings from the v0.4.11 release merge. `offckb logs script` now scans a wider window before filtering so sparse script entries are not missed, and an unknown `offckb logs` target is rejected instead of silently reading the node log. `offckb logs -f` detects log rotation by inode change, so a rotated-in file that is already larger than the old one is re-read from the start. `offckb logs --tail` rejects zero and negative values instead of printing the entire filtered log. CKB 0.205.0 prerelease binaries (rc builds), including prereleases of newer versions, are now correctly treated as Terminal-RPC capable during chain init. A ckb-tui binary that lost its execute bit is reinstalled instead of failing at spawn time. The RPC proxy keeps appending events when a proxy.log rollover fails (previously all further events were dropped) and preserves the previous archive when the rollover's rename fails, records batched JSON-RPC requests including batched `send_transaction`, skips malformed batch members without aborting the rest of the batch, and warns instead of misreporting a parse error when `send_transaction` has no usable params. `--verbose` on `offckb node` now also enables the proxy's per-request debug lines. + ## 0.4.11 ### Patch Changes diff --git a/Makefile b/Makefile index 9543ece4..78f34efd 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.PHONY: all omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock ckb-debugger apply-debugger-patches clean-debugger-patches +.PHONY: all omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock pw-lock secp256k1_multisig_v2 -all: omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock pw-lock secp256k1_multisig_v2 ckb-debugger +all: omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock pw-lock secp256k1_multisig_v2 omnilock: @echo "Building omnilock via submodule" @@ -52,25 +52,3 @@ secp256k1_multisig_v2: @echo "Building secp256k1_multisig_v2 via submodule" cd ckb/ckb-system-scripts/ && make all-via-docker cp ckb/ckb-system-scripts/specs/cells/secp256k1_blake160_multisig_all ckb/devnet/specs/secp256k1_blake160_multisig_all_v2 - -ckb-debugger: - @echo "Building ckb-debugger via submodule" - @echo "Applying patches to ckb-standalone-debugger..." - $(MAKE) apply-debugger-patches - cd ckb/ckb-standalone-debugger/ckb-debugger && cargo build --target wasm32-wasip1 --release - cp -r ckb/ckb-standalone-debugger/target/wasm32-wasip1/release/ckb-debugger.wasm src/tools/ckb-debugger.wasm - -apply-debugger-patches: - @echo "Checking if patches need to be applied..." - @cd ckb/ckb-standalone-debugger && \ - if ! git diff --quiet HEAD -- ckb-debugger/src/syscall_file_operation.rs 2>/dev/null; then \ - echo "Patches already applied."; \ - else \ - echo "Applying WASM FileOperation patches..."; \ - git apply ../../patches/0001-Add-WASM-FileOperation-syscalls-implementation.patch; \ - fi - -clean-debugger-patches: - @echo "Reverting ckb-debugger patches..." - @cd ckb/ckb-standalone-debugger && git reset --hard origin/develop && git clean -fd - diff --git a/README.md b/README.md index a3eb7a64..5d99c069 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ Commands: transfer [options] [toAddress] [amountInCKB] Transfer CKB tokens to address, only devnet and testnet transfer-all [options] [toAddress] Transfer All CKB tokens to address, only devnet and testnet balance [options] [toAddress] Check account balance, only devnet and testnet + install Install a tool binary used by offckb (e.g. the native ckb-debugger) debugger Port of the raw CKB Standalone Debugger status [options] Show ckb-tui status interface logs [options] [target] Show devnet logs: node (default), contract script debug output, miner, or RPC proxy events @@ -218,24 +219,18 @@ offckb create -c **Note for Windows Users:** -To run mock tests in the generated project, you need to manually install `ckb-debugger` until [this upstream fix about ckb-testtool wasm](https://github.com/nervosnetwork/ckb-js-vm/pull/98) is applied. +The generated project uses `ckb-testtool` with the WASM debugger by default, which works on all platforms. To run mock tests with the _native_ debugger instead (for example, while debugging upstream WASM issues), install `ckb-debugger` once with offckb: -**Installation Steps:** +```sh +offckb install ckb-debugger +``` -1. Download the latest `ckb-debugger` release for Windows from the [releases page](https://github.com/nervosnetwork/ckb-standalone-debugger/releases) -2. Extract the downloaded archive (e.g., `ckb-debugger-win64.zip`) -3. Add the extracted binary to your system PATH: - - Open "System Properties" → "Environment Variables" - - Under "System variables" or "User variables", find and edit the `Path` variable - - Click "New" and add the full path to the folder containing `ckb-debugger.exe` - - Click "OK" to save -4. Verify installation by opening a new terminal and running: - ```sh - ckb-debugger --version - ``` -5. Disable WASM debugger in your mock test file: - - Open the mock test file (e.g., `.mock.test.ts`) - - Comment out or delete the `verifier.setWasmDebuggerEnabled(true)` line: +This downloads the prebuilt `ckb-debugger` binary for your platform from the official [ckb-standalone-debugger releases](https://github.com/nervosnetwork/ckb-standalone-debugger/releases) and puts a `ckb-debugger` shim next to the offckb binary so it is available on PATH. + +Then disable the WASM debugger in your mock test file: + +1. Open the mock test file (e.g., `.mock.test.ts`) +2. Comment out or delete the `verifier.setWasmDebuggerEnabled(true)` line: ```typescript // When using native ckb-debugger, comment out or delete the following line: // verifier.setWasmDebuggerEnabled(true); @@ -243,6 +238,8 @@ To run mock tests in the generated project, you need to manually install `ckb-de After completing these steps, `npm run test` should pass without mock test failures. +`offckb create` installs `ckb-debugger` for you automatically when it is missing. + ### 3. Deploy Your Contract {#deploy-contract} ```sh @@ -317,7 +314,19 @@ Example: offckb debug --tx-hash --single-script input[0].lock ``` -All debug utilities are powered by [ckb-debugger](https://github.com/nervosnetwork/ckb-standalone-debugger/tree/develop/ckb-debugger). +All debug utilities are powered by [ckb-debugger](https://github.com/nervosnetwork/ckb-standalone-debugger/tree/develop/ckb-debugger). + +**Install the Native ckb-debugger** + +`offckb debug` requires the native `ckb-debugger` binary. Install it once with: + +```sh +offckb install ckb-debugger +``` + +This downloads the prebuilt binary for your platform from the official [ckb-standalone-debugger releases](https://github.com/nervosnetwork/ckb-standalone-debugger/releases) (no Rust toolchain or compilation needed), verifies its checksum, and exposes it on PATH. + +To uninstall, remove the binary under the offckb data directory (see `offckb config list`) and the `ckb-debugger` shim next to the offckb binary. ### 5. Explore Built-in Scripts {#explore-scripts} diff --git a/ckb/ckb-standalone-debugger b/ckb/ckb-standalone-debugger deleted file mode 160000 index 991e8b20..00000000 --- a/ckb/ckb-standalone-debugger +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 991e8b2018a47f9c6e344e67e363552e7a71bae9 diff --git a/docs/develop.md b/docs/develop.md index e8048d36..5cdaba25 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -284,18 +284,3 @@ All script configs are generated by `ckb list-hashes` — you don't need to manu ## Updating Templates Edit files in `templates/v4/`. Template files use `.template` extension and are processed by `src/templates/processor.ts`. - ---- - -## Updating CKB WASM Debugger - -**Prerequisites:** -- rust/cargo -- wasm32-wasip1 target: `rustup target add wasm32-wasip1` -- On Linux: `sudo apt install gcc-multilib` - -Update the `ckb-standalone-debugger` submodule, then run: - -```sh -make ckb-debugger -``` diff --git a/package.json b/package.json index 040a729f..e324a202 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@offckb/cli", - "version": "0.4.11", + "version": "0.4.12", "description": "ckb development network for your first try", "author": "CKB EcoFund", "license": "MIT", diff --git a/patches/0001-Add-WASM-FileOperation-syscalls-implementation.patch b/patches/0001-Add-WASM-FileOperation-syscalls-implementation.patch deleted file mode 100644 index a7c3eaf9..00000000 --- a/patches/0001-Add-WASM-FileOperation-syscalls-implementation.patch +++ /dev/null @@ -1,300 +0,0 @@ -From 6f800e3ac9626931f1bb55f1746477e52e651e55 Mon Sep 17 00:00:00 2001 -From: RetricSu -Date: Tue, 26 Aug 2025 15:26:29 +0800 -Subject: [PATCH] Add WASM FileOperation syscalls implementation - -- Implement all file operation syscalls for WASM target -- Add file handle management with in-memory buffering -- Enable build mode support for WASM debugger -- Files are written to disk when closed (write mode) ---- - ckb-debugger/src/syscall_file_operation.rs | 262 ++++++++++++++++++++- - 1 file changed, 257 insertions(+), 5 deletions(-) - -diff --git a/ckb-debugger/src/syscall_file_operation.rs b/ckb-debugger/src/syscall_file_operation.rs -index 3267947..551029d 100644 ---- a/ckb-debugger/src/syscall_file_operation.rs -+++ b/ckb-debugger/src/syscall_file_operation.rs -@@ -150,13 +150,58 @@ mod arch { - #[cfg(target_family = "wasm")] - mod arch { - use ckb_vm::{Error, SupportMachine, Syscalls}; -+ use ckb_vm::{ -+ Memory, Register, -+ registers::{A0, A1, A2, A3, A7}, -+ }; -+ use std::collections::HashMap; -+ use std::io; - -- #[derive(Default)] -- pub struct FileOperation {} -+ const SYSCALL_NUMBER_FOPEN: u64 = 9003; -+ const SYSCALL_NUMBER_FREOPEN: u64 = 9004; -+ const SYSCALL_NUMBER_FREAD: u64 = 9005; -+ const SYSCALL_NUMBER_FEOF: u64 = 9006; -+ const SYSCALL_NUMBER_FERROR: u64 = 9007; -+ const SYSCALL_NUMBER_FGETC: u64 = 9008; -+ const SYSCALL_NUMBER_FCLOSE: u64 = 9009; -+ const SYSCALL_NUMBER_FTELL: u64 = 9010; -+ const SYSCALL_NUMBER_FSEEK: u64 = 9011; -+ const SYSCALL_NUMBER_FWRITE: u64 = 9012; -+ -+ // Simple file handle implementation for WASM -+ struct FileHandle { -+ content: Vec, -+ position: usize, -+ mode: String, -+ path: Option, -+ } -+ -+ impl FileHandle { -+ fn new(content: Vec, mode: String, path: Option) -> Self { -+ Self { -+ content, -+ position: 0, -+ mode, -+ path, -+ } -+ } -+ } -+ -+ pub struct FileOperation { -+ files: HashMap, -+ next_handle: u64, -+ } - - impl FileOperation { - pub fn new() -> Self { -- FileOperation {} -+ Self { -+ files: HashMap::new(), -+ next_handle: 1, -+ } -+ } -+ -+ fn get_file(&mut self, handle: u64) -> Option<&mut FileHandle> { -+ self.files.get_mut(&handle) - } - } - -@@ -165,8 +210,215 @@ mod arch { - Ok(()) - } - -- fn ecall(&mut self, _machine: &mut Mac) -> Result { -- Ok(false) -+ fn ecall(&mut self, machine: &mut Mac) -> Result { -+ let id = machine.registers()[A7].to_u64(); -+ let arg0 = machine.registers()[A0].to_u64(); -+ let arg1 = machine.registers()[A1].to_u64(); -+ let arg2 = machine.registers()[A2].to_u64(); -+ let arg3 = machine.registers()[A3].to_u64(); -+ -+ match id { -+ SYSCALL_NUMBER_FOPEN => { -+ let path = ckb_vm::memory::load_c_string_byte_by_byte( -+ machine.memory_mut(), -+ &Mac::REG::from_u64(arg0), -+ )?; -+ let path_str = String::from_utf8_lossy(&path); -+ let mode = ckb_vm::memory::load_c_string_byte_by_byte( -+ machine.memory_mut(), -+ &Mac::REG::from_u64(arg1), -+ )?; -+ let mode_str = String::from_utf8_lossy(&mode); -+ -+ // Use the arch module's file_read function for reading -+ let content = if mode_str.contains("r") { -+ match crate::arch::file_read(&path_str) { -+ Ok(data) => data, -+ Err(_) => { -+ // Return NULL pointer for file not found -+ machine.set_register(A0, Mac::REG::from_u64(0)); -+ return Ok(true); -+ } -+ } -+ } else { -+ Vec::new() // Empty content for write mode -+ }; -+ -+ let handle = self.next_handle; -+ self.next_handle += 1; -+ -+ self.files.insert(handle, FileHandle::new(content, mode_str.to_string(), Some(path_str.to_string()))); -+ machine.set_register(A0, Mac::REG::from_u64(handle)); -+ } -+ SYSCALL_NUMBER_FREAD => { -+ let ptr = arg0; -+ let size = arg1; -+ let nitems = arg2; -+ let stream = arg3; -+ let total_size = nitems * size; -+ -+ if let Some(file) = self.get_file(stream) { -+ let remaining = file.content.len().saturating_sub(file.position); -+ let read_size = std::cmp::min(total_size as usize, remaining); -+ -+ if read_size > 0 { -+ let data = &file.content[file.position..file.position + read_size]; -+ machine.memory_mut().store_bytes(ptr, data)?; -+ file.position += read_size; -+ machine.set_register(A0, Mac::REG::from_u64((read_size / size as usize) as u64)); -+ } else { -+ machine.set_register(A0, Mac::REG::from_u64(0)); -+ } -+ } else { -+ machine.set_register(A0, Mac::REG::from_u64(0)); -+ } -+ } -+ SYSCALL_NUMBER_FWRITE => { -+ let ptr = arg0; -+ let size = arg1; -+ let nitems = arg2; -+ let stream = arg3; -+ let total_size = nitems * size; -+ -+ if let Some(file) = self.get_file(stream) { -+ if file.mode.contains("w") || file.mode.contains("a") { -+ let data = machine.memory_mut().load_bytes(ptr, total_size)?; -+ -+ if file.mode.contains("a") { -+ // Append mode -+ file.content.extend_from_slice(&data); -+ file.position = file.content.len(); -+ } else { -+ // Write mode - replace content -+ file.content = data.to_vec(); -+ file.position = file.content.len(); -+ } -+ -+ machine.set_register(A0, Mac::REG::from_u64(nitems)); -+ } else { -+ machine.set_register(A0, Mac::REG::from_u64(0)); -+ } -+ } else { -+ machine.set_register(A0, Mac::REG::from_u64(0)); -+ } -+ } -+ SYSCALL_NUMBER_FCLOSE => { -+ let handle = arg0; -+ if let Some(file) = self.files.remove(&handle) { -+ // If this was a write file and has a path, write it to disk -+ if (file.mode.contains("w") || file.mode.contains("a")) && file.path.is_some() { -+ if let Err(_) = crate::arch::file_write(&file.path.unwrap(), &file.content) { -+ machine.set_register(A0, Mac::REG::from_i32(-1)); -+ return Ok(true); -+ } -+ } -+ machine.set_register(A0, Mac::REG::from_i32(0)); -+ } else { -+ machine.set_register(A0, Mac::REG::from_i32(-1)); -+ } -+ } -+ SYSCALL_NUMBER_FSEEK => { -+ let stream = arg0; -+ let offset = arg1 as i64; -+ let whence = arg2; -+ -+ if let Some(file) = self.get_file(stream) { -+ let new_pos = match whence { -+ 0 => offset as usize, // SEEK_SET -+ 1 => file.position.saturating_add_signed(offset.try_into().unwrap_or(0)), // SEEK_CUR -+ 2 => file.content.len().saturating_add_signed(offset.try_into().unwrap_or(0)), // SEEK_END -+ _ => { -+ machine.set_register(A0, Mac::REG::from_i32(-1)); -+ return Ok(true); -+ } -+ }; -+ -+ if new_pos <= file.content.len() { -+ file.position = new_pos; -+ machine.set_register(A0, Mac::REG::from_i32(0)); -+ } else { -+ machine.set_register(A0, Mac::REG::from_i32(-1)); -+ } -+ } else { -+ machine.set_register(A0, Mac::REG::from_i32(-1)); -+ } -+ } -+ SYSCALL_NUMBER_FTELL => { -+ let stream = arg0; -+ if let Some(file) = self.get_file(stream) { -+ machine.set_register(A0, Mac::REG::from_i64(file.position as i64)); -+ } else { -+ machine.set_register(A0, Mac::REG::from_i64(-1)); -+ } -+ } -+ SYSCALL_NUMBER_FEOF => { -+ let stream = arg0; -+ if let Some(file) = self.get_file(stream) { -+ let eof = if file.position >= file.content.len() { 1 } else { 0 }; -+ machine.set_register(A0, Mac::REG::from_i32(eof)); -+ } else { -+ machine.set_register(A0, Mac::REG::from_i32(1)); -+ } -+ } -+ SYSCALL_NUMBER_FERROR => { -+ // For simplicity, always return no error -+ machine.set_register(A0, Mac::REG::from_i32(0)); -+ } -+ SYSCALL_NUMBER_FGETC => { -+ let stream = arg0; -+ if let Some(file) = self.get_file(stream) { -+ if file.position < file.content.len() { -+ let ch = file.content[file.position] as i32; -+ file.position += 1; -+ machine.set_register(A0, Mac::REG::from_i32(ch)); -+ } else { -+ machine.set_register(A0, Mac::REG::from_i32(-1)); // EOF -+ } -+ } else { -+ machine.set_register(A0, Mac::REG::from_i32(-1)); -+ } -+ } -+ SYSCALL_NUMBER_FREOPEN => { -+ // Simplified implementation - just close and reopen -+ let path = ckb_vm::memory::load_c_string_byte_by_byte( -+ machine.memory_mut(), -+ &Mac::REG::from_u64(arg0), -+ )?; -+ let path_str = String::from_utf8_lossy(&path); -+ let mode = ckb_vm::memory::load_c_string_byte_by_byte( -+ machine.memory_mut(), -+ &Mac::REG::from_u64(arg1), -+ )?; -+ let mode_str = String::from_utf8_lossy(&mode); -+ let old_stream = arg2; -+ -+ // Close old stream -+ self.files.remove(&old_stream); -+ -+ // Open new stream -+ let content = if mode_str.contains("r") { -+ match crate::arch::file_read(&path_str) { -+ Ok(data) => data, -+ Err(_) => { -+ machine.set_register(A0, Mac::REG::from_u64(0)); -+ return Ok(true); -+ } -+ } -+ } else { -+ Vec::new() -+ }; -+ -+ let handle = self.next_handle; -+ self.next_handle += 1; -+ -+ self.files.insert(handle, FileHandle::new(content, mode_str.to_string(), Some(path_str.to_string()))); -+ machine.set_register(A0, Mac::REG::from_u64(handle)); -+ } -+ _ => { -+ return Ok(false); -+ } -+ } -+ Ok(true) - } - } - } --- -2.39.5 (Apple Git-154) - diff --git a/patches/README.md b/patches/README.md deleted file mode 100644 index 9146f8ba..00000000 --- a/patches/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# CKB Standalone Debugger Patches - -This directory contains patches for the `ckb-standalone-debugger` submodule to add functionality that hasn't been merged upstream yet. - -## Current Patches - -### 0001-Add-WASM-FileOperation-syscalls-implementation.patch - -**Purpose:** Adds WASM support for FileOperation syscalls to enable build mode in the WASM debugger. - -**What it does:** -- Implements all file operation syscalls (`fopen`, `fread`, `fwrite`, `fclose`, etc.) for WASM target -- Adds file handle management with in-memory buffering -- Enables the WASM debugger to compile JavaScript to bytecode (build mode) -- Files are written to disk when closed (for write mode) - -**Why needed:** The upstream ckb-standalone-debugger has a stub implementation for FileOperation syscalls in WASM that always returns `false`, preventing the WASM debugger from handling file operations needed for build mode. - -## Usage - -The patches are automatically applied during the build process via the Makefile: - -```bash -# Build ckb-debugger with patches applied -make ckb-debugger - -# Apply patches manually (if needed) -make apply-debugger-patches - -# Clean/revert patches -make clean-debugger-patches -``` - -## Creating New Patches - -If you need to add more changes to the ckb-standalone-debugger: - -1. Make your changes in the `ckb/ckb-standalone-debugger` directory -2. Commit your changes: - ```bash - cd ckb/ckb-standalone-debugger - git add -A - git commit -m "Your change description" - ``` -3. Generate the patch: - ```bash - git format-patch HEAD~1 --output-directory ../../patches/ - ``` -4. Update the Makefile and this README as needed - -## Upstreaming - -These patches should eventually be submitted to the upstream repository: -- Repository: https://github.com/nervosnetwork/ckb-standalone-debugger -- When submitting PRs, reference the specific use case (WASM build mode support) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac67fd19..feba3231 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,17 +6,18 @@ settings: overrides: form-data@>=4.0.0 <4.0.6: 4.0.6 - hono@<4.12.27: 4.12.27 + hono@<4.12.34: 4.12.34 qs@>=6.11.1 <=6.15.1: 6.15.2 - ip-address@<=10.1.0: 10.1.1 - js-yaml@<3.15.0: 3.15.0 - js-yaml@>=4.0.0 <4.3.0: 4.3.0 + ip-address@<=10.3.0: 10.3.1 + js-yaml@<3.15.1: 3.15.1 + js-yaml@>=4.0.0 <4.3.1: 4.3.1 '@babel/core@<=7.29.0': 7.29.7 '@eslint/plugin-kit@<0.3.4': 0.3.4 - brace-expansion@<1.1.16: 1.1.16 - brace-expansion@>=5.0.0 <5.0.7: 5.0.7 - fast-uri@<3.1.4: 3.1.4 + brace-expansion@<1.1.18: 1.1.18 + brace-expansion@>=4.0.0 <5.0.9: 5.0.9 + fast-uri@<3.1.5: 3.1.5 body-parser@>=2.0.0 <2.3.0: 2.3.0 + '@hono/node-server@<2.0.10': 2.0.10 importers: @@ -155,8 +156,8 @@ packages: resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.29.7': @@ -181,6 +182,10 @@ packages: resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -210,8 +215,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} hasBin: true @@ -236,8 +241,8 @@ packages: peerDependencies: '@babel/core': 7.29.7 - '@babel/plugin-syntax-import-attributes@7.28.6': - resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': 7.29.7 @@ -314,16 +319,16 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} '@babel/types@7.28.6': resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@0.2.3': @@ -449,11 +454,11 @@ packages: resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@hono/node-server@1.19.13': - resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.0.10': + resolution: {integrity: sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==} + engines: {node: '>=20'} peerDependencies: - hono: 4.12.27 + hono: 4.12.34 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -620,8 +625,8 @@ packages: resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} engines: {node: '>=8'} '@jest/console@30.2.0': @@ -722,8 +727,8 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -737,8 +742,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/sdk@1.27.1': - resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -799,8 +804,8 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@sinclair/typebox@0.34.47': - resolution: {integrity: sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==} + '@sinclair/typebox@0.34.52': + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -808,8 +813,8 @@ packages: '@sinonjs/fake-timers@13.0.5': resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} - '@tsconfig/node10@1.0.11': - resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} '@tsconfig/node12@1.0.11': resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} @@ -871,6 +876,9 @@ packages: '@types/node@20.17.24': resolution: {integrity: sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@22.7.5': resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} @@ -956,9 +964,8 @@ packages: resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} engines: {node: ^18.18.0 || >=20.0.0} - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - deprecated: Potential CWE-502 - Update to 1.3.1 or higher + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -1085,8 +1092,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} acorn@8.15.0: @@ -1094,6 +1101,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + adm-zip@0.6.0: resolution: {integrity: sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==} engines: {node: '>=14.0'} @@ -1212,8 +1224,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.9.14: - resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} + baseline-browser-mapping@2.11.13: + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + engines: {node: '>=6.0.0'} hasBin: true bech32@2.0.0: @@ -1239,12 +1252,12 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - brace-expansion@1.1.16: - resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1253,8 +1266,8 @@ packages: brorand@1.1.0: resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1305,8 +1318,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001764: - resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==} + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1342,6 +1355,10 @@ packages: resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} engines: {node: '>=8'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} @@ -1510,8 +1527,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.267: - resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + electron-to-chromium@1.5.403: + resolution: {integrity: sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==} elliptic@6.6.1: resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} @@ -1689,8 +1706,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -1873,8 +1890,8 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} - hono@4.12.27: - resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + hono@4.12.34: + resolution: {integrity: sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -1944,8 +1961,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.1.1: - resolution: {integrity: sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==} + ip-address@10.3.1: + resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2178,12 +2195,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.0: - resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + js-yaml@3.15.1: + resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} hasBin: true - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsbi@3.1.3: @@ -2416,8 +2433,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -2551,8 +2569,8 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} pidtree@0.6.0: @@ -2708,6 +2726,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -3011,6 +3034,9 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -3022,8 +3048,8 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.0: + resolution: {integrity: sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -3178,14 +3204,14 @@ snapshots: '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -3203,10 +3229,10 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/generator@7.29.7': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -3215,7 +3241,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.1 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -3223,8 +3249,8 @@ snapshots: '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -3233,12 +3259,14 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.29.7': {} @@ -3252,50 +3280,50 @@ snapshots: '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/parser@7.28.6': dependencies: '@babel/types': 7.28.6 - '@babel/parser@7.29.7': + '@babel/parser@7.29.8': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': dependencies: @@ -3305,42 +3333,42 @@ snapshots: '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': dependencies: @@ -3352,17 +3380,17 @@ snapshots: '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -3372,7 +3400,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 @@ -3488,7 +3516,7 @@ snapshots: '@changesets/parse@0.4.2': dependencies: '@changesets/types': 6.1.0 - js-yaml: 4.3.0 + js-yaml: 4.3.1 '@changesets/pre@2.0.2': dependencies: @@ -3603,7 +3631,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3618,9 +3646,9 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 - '@hono/node-server@1.19.13(hono@4.12.27)': + '@hono/node-server@2.0.10(hono@4.12.34)': dependencies: - hono: 4.12.27 + hono: 4.12.34 '@humanfs/core@0.19.1': {} @@ -3778,10 +3806,10 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.15.0 + js-yaml: 3.15.1 resolve-from: 5.0.0 - '@istanbuljs/schema@0.1.3': {} + '@istanbuljs/schema@0.1.6': {} '@jest/console@30.2.0': dependencies: @@ -3870,7 +3898,7 @@ snapshots: '@jest/pattern@30.0.1': dependencies: - '@types/node': 20.17.24 + '@types/node': 20.19.43 jest-regex-util: 30.0.1 '@jest/reporters@30.2.0': @@ -3903,7 +3931,7 @@ snapshots: '@jest/schemas@30.0.5': dependencies: - '@sinclair/typebox': 0.34.47 + '@sinclair/typebox': 0.34.52 '@jest/snapshot-utils@30.2.0': dependencies: @@ -3958,7 +3986,7 @@ snapshots: '@jest/schemas': 30.0.5 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.17.24 + '@types/node': 20.19.43 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -3983,7 +4011,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -3993,17 +4021,17 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@manypkg/find-root@1.1.0': dependencies: @@ -4021,9 +4049,9 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.13(hono@4.12.27) + '@hono/node-server': 2.0.10(hono@4.12.34) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -4033,7 +4061,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.27 + hono: 4.12.34 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -4093,7 +4121,7 @@ snapshots: '@pkgr/core@0.2.9': {} - '@sinclair/typebox@0.34.47': {} + '@sinclair/typebox@0.34.52': {} '@sinonjs/commons@3.0.1': dependencies: @@ -4103,7 +4131,7 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@tsconfig/node10@1.0.11': {} + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -4122,24 +4150,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@types/blessed@0.1.27': dependencies: @@ -4179,6 +4207,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + '@types/node@22.7.5': dependencies: undici-types: 6.19.8 @@ -4285,7 +4317,7 @@ snapshots: '@typescript-eslint/types': 7.18.0 eslint-visitor-keys: 3.4.3 - '@ungap/structured-clone@1.3.0': {} + '@ungap/structured-clone@1.3.3': {} '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -4363,12 +4395,14 @@ snapshots: dependencies: acorn: 8.15.0 - acorn-walk@8.3.4: + acorn-walk@8.3.5: dependencies: - acorn: 8.15.0 + acorn: 8.18.0 acorn@8.15.0: {} + acorn@8.18.0: {} + adm-zip@0.6.0: {} aes-js@4.0.0-beta.5: {} @@ -4389,7 +4423,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4449,9 +4483,9 @@ snapshots: babel-plugin-istanbul@7.0.1: dependencies: - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.3 + '@istanbuljs/schema': 0.1.6 istanbul-lib-instrument: 6.0.3 test-exclude: 6.0.0 transitivePeerDependencies: @@ -4468,7 +4502,7 @@ snapshots: '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) @@ -4494,7 +4528,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.9.14: {} + baseline-browser-mapping@2.11.13: {} bech32@2.0.0: {} @@ -4522,12 +4556,12 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@1.1.16: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.7: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -4537,13 +4571,13 @@ snapshots: brorand@1.1.0: {} - browserslist@4.28.1: + browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.9.14 - caniuse-lite: 1.0.30001764 - electron-to-chromium: 1.5.267 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.11.13 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.403 + node-releases: 2.0.53 + update-browserslist-db: 1.3.0(browserslist@4.28.8) bs-logger@0.2.6: dependencies: @@ -4590,7 +4624,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001764: {} + caniuse-lite@1.0.30001809: {} chalk@4.1.2: dependencies: @@ -4623,6 +4657,8 @@ snapshots: ci-info@4.3.1: {} + ci-info@4.4.0: {} + cjs-module-lexer@2.2.0: {} cli-cursor@5.0.0: @@ -4760,7 +4796,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.267: {} + electron-to-chromium@1.5.403: {} elliptic@6.6.1: dependencies: @@ -4840,7 +4876,7 @@ snapshots: '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.30.0(zod@3.25.76) '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 ajv: 6.14.0 @@ -4953,7 +4989,7 @@ snapshots: express-rate-limit@8.3.1(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.1.1 + ip-address: 10.3.1 express@5.2.1: dependencies: @@ -5004,7 +5040,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fastq@1.19.1: dependencies: @@ -5201,7 +5237,7 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - hono@4.12.27: {} + hono@4.12.34: {} html-escaper@2.0.2: {} @@ -5267,7 +5303,7 @@ snapshots: inherits@2.0.4: {} - ip-address@10.1.1: {} + ip-address@10.3.1: {} ipaddr.js@1.9.1: {} @@ -5324,10 +5360,10 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.7 - '@istanbuljs/schema': 0.1.3 + '@babel/parser': 7.29.8 + '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 - semver: 7.7.3 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -5472,7 +5508,7 @@ snapshots: jest-haste-map@30.2.0: dependencies: '@jest/types': 30.2.0 - '@types/node': 20.17.24 + '@types/node': 20.19.43 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -5621,11 +5657,11 @@ snapshots: jest-util@30.2.0: dependencies: '@jest/types': 30.2.0 - '@types/node': 20.17.24 + '@types/node': 20.19.43 chalk: 4.1.2 - ci-info: 4.3.1 + ci-info: 4.4.0 graceful-fs: 4.2.11 - picomatch: 4.0.4 + picomatch: 4.0.5 jest-validate@30.2.0: dependencies: @@ -5649,8 +5685,8 @@ snapshots: jest-worker@30.2.0: dependencies: - '@types/node': 20.17.24 - '@ungap/structured-clone': 1.3.0 + '@types/node': 20.19.43 + '@ungap/structured-clone': 1.3.3 jest-util: 30.2.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -5672,12 +5708,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.15.0: + js-yaml@3.15.1: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -5830,11 +5866,11 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.16 + brace-expansion: 1.1.18 minimatch@9.0.8: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 minimist@1.2.8: {} @@ -5871,7 +5907,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.27: {} + node-releases@2.0.53: {} normalize-path@3.0.0: {} @@ -5988,7 +6024,7 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.4: {} + picomatch@4.0.5: {} pidtree@0.6.0: {} @@ -6045,7 +6081,7 @@ snapshots: read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 3.15.0 + js-yaml: 3.15.1 pify: 4.0.1 strip-bom: 3.0.0 @@ -6116,6 +6152,8 @@ snapshots: semver@7.7.3: {} + semver@7.8.5: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -6299,7 +6337,7 @@ snapshots: test-exclude@6.0.0: dependencies: - '@istanbuljs/schema': 0.1.3 + '@istanbuljs/schema': 0.1.6 glob: 7.2.3 minimatch: 3.1.5 @@ -6364,13 +6402,13 @@ snapshots: ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2): dependencies: '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 + '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 '@types/node': 20.17.24 - acorn: 8.15.0 - acorn-walk: 8.3.4 + acorn: 8.18.0 + acorn-walk: 8.3.5 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.4 @@ -6423,6 +6461,8 @@ snapshots: undici-types@6.19.8: {} + undici-types@6.21.0: {} + universalify@0.1.2: {} unpipe@1.0.0: {} @@ -6451,9 +6491,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.3.0(browserslist@4.28.8): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ef8aa1f0..72c91103 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,27 +3,38 @@ overrides: "form-data@>=4.0.0 <4.0.6": "4.0.6" # GHSA-88fw-hqm2-52qc: CORS middleware reflects any origin with credentials. Remove when hono >=4.12.25. # GHSA-xgm2-5f3f-mvvc / GHSA-hvrm-45r6-mjfj / GHSA-w62v-xxxg-mg59: repeated-header drop, JSX context leakage, JSX escaping bypass. Remove when hono >=4.12.27. - "hono@<4.12.27": "4.12.27" + # GHSA-54fx-42gc-7vw4 / GHSA-79qm-7rj5-m7r9 / GHSA-f23p-vx2j-j53r: language-middleware DoS, proxy-helper header handling, memo() cross-request SSR leakage. Remove when hono >=4.12.34. + "hono@<4.12.34": "4.12.34" # GHSA-q8mj-m7cp-5q26: qs.stringify DoS. Remove when direct dependency upgrades qs >=6.15.2. "qs@>=6.11.1 <=6.15.1": "6.15.2" # GHSA-v2v4-37r5-5v8g: XSS in Address6 HTML-emitting methods. Remove when direct dependency upgrades ip-address >=10.1.1. - "ip-address@<=10.1.0": "10.1.1" + # GHSA-22jq-vg5j-6vgg / GHSA-4xrf-jv44-h6hh / GHSA-mwp4-54f8-5fhr: IPv4-mapped/NAT64 misclassification, CIDR-suffix special-use bypass, leading-zero octal SSRF. Remove when direct dependency upgrades ip-address >=10.3.1. + "ip-address@<=10.3.0": "10.3.1" # GHSA-h67p-54hq-rp68: quadratic-complexity DoS in merge key handling. Remove when js-yaml 3.x >=3.15.0. - "js-yaml@<3.15.0": "3.15.0" + # GHSA-5p4m-2wfm-xmqj: quadratic CPU consumption in !!omap resolution. Remove when js-yaml 3.x >=3.15.1. + "js-yaml@<3.15.1": "3.15.1" # GHSA-h67p-54hq-rp68 / GHSA-52cp-r559-cp3m: quadratic-complexity DoS in merge key handling. Remove when js-yaml 4.x >=4.3.0. - "js-yaml@>=4.0.0 <4.3.0": "4.3.0" + # GHSA-5p4m-2wfm-xmqj: quadratic CPU consumption in !!omap resolution. Remove when js-yaml 4.x >=4.3.1. + "js-yaml@>=4.0.0 <4.3.1": "4.3.1" # GHSA-4x5r-pxfx-6jf8: arbitrary file read via sourceMappingURL. Remove when @babel/core >=7.29.6. "@babel/core@<=7.29.0": "7.29.7" # GHSA-xffm-g5w8-qvg7: ReDoS in ConfigCommentParser. Remove when @eslint/plugin-kit >=0.3.4. "@eslint/plugin-kit@<0.3.4": "0.3.4" # GHSA-3jxr-9vmj-r5cp: exponential-time expansion of consecutive non-expanding {} groups. Remove when brace-expansion 1.x >=1.1.16. - "brace-expansion@<1.1.16": "1.1.16" + # GHSA-mh99-v99m-4gvg / GHSA-rgw5-rvv9-x895: DoS via unbounded expansion length / unbounded intermediate arrays. Remove when brace-expansion 1.x >=1.1.18. + "brace-expansion@<1.1.18": "1.1.18" # GHSA-jxxr-4gwj-5jf2 / GHSA-3jxr-9vmj-r5cp: brace range / exponential-time expansion DoS. Remove when brace-expansion >=5.0.7. - "brace-expansion@>=5.0.0 <5.0.7": "5.0.7" + # GHSA-mh99-v99m-4gvg / GHSA-rgw5-rvv9-x895: DoS via unbounded expansion length / unbounded intermediate arrays. Remove when brace-expansion 5.x >=5.0.9. + "brace-expansion@>=4.0.0 <5.0.9": "5.0.9" # GHSA-v2hh-gcrm-f6hx / GHSA-4c8g-83qw-93j6: host confusion via backslash authority / failed IDN canonicalization. Remove when fast-uri >=3.1.4. - "fast-uri@<3.1.4": "3.1.4" + # GHSA-7p8r-x3mc-p8w7: host confusion via backslash authority introducer. Remove when fast-uri >=3.1.5. + "fast-uri@<3.1.5": "3.1.5" # GHSA-v422-hmwv-36x6: invalid limit value silently disables size enforcement. Remove when body-parser 2.x >=2.3.0. "body-parser@>=2.0.0 <2.3.0": "2.3.0" + # GHSA-frvp-7c67-39w9: path traversal in serve-static on Windows via encoded backslash. Remove when @hono/node-server >=2.0.5 + # GHSA-9mqv-5hh9-4cgg: unauthenticated memory-leak DoS via aborted WebSocket handshake. Remove when @hono/node-server >=2.0.10 + # (requires @modelcontextprotocol/sdk >=1.30.0, which declares "^1.19.9 || ^2.0.5"). + "@hono/node-server@<2.0.10": "2.0.10" onlyBuiltDependencies: - secp256k1 @@ -31,14 +42,15 @@ onlyBuiltDependencies: minimumReleaseAgeExclude: # Matches the overrides above; these versions are very recent but required for the security fixes. - "@eslint/plugin-kit@0.3.4" - - "ip-address@10.1.1" + - "ip-address@10.3.1" - "qs@6.15.2" - - "brace-expansion@1.1.16" - - "brace-expansion@5.0.7" + - "brace-expansion@1.1.18" + - "brace-expansion@5.0.9" - "@babel/core@7.29.7" - - "js-yaml@3.15.0" - - "js-yaml@4.3.0" + - "js-yaml@3.15.1" + - "js-yaml@4.3.1" - "tar@7.5.21" - - "hono@4.12.27" - - "fast-uri@3.1.4" + - "hono@4.12.34" + - "fast-uri@3.1.5" - "body-parser@2.3.0" + - "@hono/node-server@2.0.10" diff --git a/scripts/build.js b/scripts/build.js index a53d9801..931186d7 100755 --- a/scripts/build.js +++ b/scripts/build.js @@ -2,7 +2,6 @@ const { execSync } = require('child_process'); const fs = require('fs'); -const path = require('path'); console.log('🏗️ Building offckb CLI...\n'); @@ -12,32 +11,16 @@ try { execSync('tsc', { stdio: 'inherit' }); console.log('✅ TypeScript compilation complete\n'); - // Step 2: Copy WASM debugger to dist directory - console.log('📦 Copying WASM debugger to dist...'); - const wasmSrc = 'src/tools/ckb-debugger.wasm'; - const wasmDist = 'dist/tools/ckb-debugger.wasm'; - - // Ensure tools directory exists - fs.mkdirSync(path.dirname(wasmDist), { recursive: true }); - fs.copyFileSync(wasmSrc, wasmDist); - console.log(`✅ Copied ${wasmSrc} → ${wasmDist}\n`); - - // Step 3: Bundle with NCC + // Step 2: Bundle with NCC console.log('🔗 Bundling with NCC...'); execSync('ncc build dist/cli.js -o build --external cpu-features', { stdio: 'inherit' }); console.log('✅ NCC bundling complete\n'); - // Step 4: Copy WASM debugger to final build directory - console.log('📦 Copying WASM debugger to build...'); - const wasmBuild = 'build/ckb-debugger.wasm'; - fs.copyFileSync(wasmSrc, wasmBuild); - console.log(`✅ Copied ${wasmSrc} → ${wasmBuild}\n`); - - // Step 5: Verify build output + // Step 3: Verify build output console.log('🔍 Verifying build output...'); - const buildFiles = ['build/index.js', 'build/ckb-debugger.wasm']; + const buildFiles = ['build/index.js']; let allFilesExist = true; - + for (const file of buildFiles) { if (fs.existsSync(file)) { const stats = fs.statSync(file); @@ -50,12 +33,10 @@ try { if (allFilesExist) { console.log('\n🎉 Build completed successfully!'); - console.log('📦 The bundled CLI includes WASM debugger fallback'); } else { console.error('\n❌ Build completed with missing files'); process.exit(1); } - } catch (error) { console.error('\n❌ Build failed:', error.message); process.exit(1); diff --git a/src/cli.ts b/src/cli.ts index 5015a66b..d1eb771b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -111,8 +111,8 @@ program .option('--grep ', 'Only show lines containing the given text') .option('--tail ', 'Show the last N lines before following', (value: string) => { const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 0) { - throw new InvalidArgumentError('--tail must be a non-negative integer'); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new InvalidArgumentError('--tail must be a positive integer'); } return parsed; }) @@ -273,6 +273,14 @@ mainnetForkOverrideOption( await udtDestroy(amount, resolveMainnetForkOverride(options)); }); +program + .command('install') + .description('Install a tool binary used by offckb (e.g. the native ckb-debugger)') + .addArgument(new Argument('', 'The tool to install').choices(['ckb-debugger'])) + .action(async () => { + await CKBDebugger.installCKBDebuggerBinary(); + }); + program .command('debugger') .description('Port of the raw CKB Standalone Debugger') diff --git a/src/cmd/create.ts b/src/cmd/create.ts index 0aa2b322..7afbfaae 100644 --- a/src/cmd/create.ts +++ b/src/cmd/create.ts @@ -164,13 +164,18 @@ export async function createScriptProject(name?: string, options: CreateScriptPr logger.info(['', '💡 To add a new contract:', ` ${projectInfo.packageManager} run add-contract `]); - // check if ckb-debugger is installed - if (!CKBDebugger.isBinaryInstalled() || !CKBDebugger.isBinaryVersionValid()) { - logger.info([ - `-----------`, - `Oho! You don't have ckb-debugger installed, let me create a fallback binary for you...`, - ]); - CKBDebugger.createCkbDebuggerFallback(); + // Make sure the native ckb-debugger is present and current so the + // generated project can build and run mock tests. install() is the single + // decision point and is idempotent: it returns immediately when a + // satisfying binary is already in place, otherwise it downloads the + // prebuilt release. On failure (e.g. offline) the user can retry later + // with `offckb install ckb-debugger`. + logger.info('🔧 Checking the ckb-debugger installation...'); + try { + await CKBDebugger.installCKBDebuggerBinary(); + } catch { + // The installer already logged the detailed failure; keep this one brief. + logger.warn('Failed to install ckb-debugger. You can retry later with: offckb install ckb-debugger'); } } catch (error: unknown) { throw new Error(`Failed to create project: ${(error as Error).message}`); diff --git a/src/cmd/debug.ts b/src/cmd/debug.ts index 8024d110..c8a5e8c1 100644 --- a/src/cmd/debug.ts +++ b/src/cmd/debug.ts @@ -185,7 +185,7 @@ export async function buildContract(jsFile: string, outputFile: string, jsVmPath // Use the CKBDebugger to compile JavaScript to bytecode const args = ['--read-file', jsFile, '--bin', ckbJsVmPath, '--', '-c', outputFile]; - await CKBDebugger.runWithArgs(args); + CKBDebugger.runWithArgs(args); logger.success(`✅ Contract built successfully: ${outputFile}`); } catch (error) { throw new Error(`Build failed: ${error instanceof Error ? error.message : String(error)}`); diff --git a/src/cmd/logs.ts b/src/cmd/logs.ts index 09e05303..1885e07e 100644 --- a/src/cmd/logs.ts +++ b/src/cmd/logs.ts @@ -19,6 +19,10 @@ export interface LogsOptions { } const DEFAULT_TAIL = 100; +// Script entries are sparse in run.log (a devnet node writes many non-script +// lines per second), so a script-filtered tail scans a much wider window and +// trims back to `tail` after filtering. +const SCRIPT_SCAN_FACTOR = 100; /** * Print (and optionally follow) a devnet log file. The core is synchronous so @@ -28,10 +32,16 @@ const DEFAULT_TAIL = 100; export function showLogs(target: LogTarget, options: LogsOptions, settings: Settings, logger: UnifiedLogger): void { const filePath = resolveLogPath(target, settings); const tail = options.tail ?? DEFAULT_TAIL; - - let lines = readLogTail(filePath, tail); + // A zero tail is rejected, not "print nothing": slice(-0) is slice(0), so + // script mode would otherwise dump every filtered line it scanned. + if (!Number.isInteger(tail) || tail <= 0) { + throw new Error(`--tail must be a positive integer (got ${options.tail})`); + } const scriptOnly = target === 'script'; - if (scriptOnly) lines = filterLinesByTarget(lines, SCRIPT_LOG_TARGET); + + const scanWindow = scriptOnly ? tail * SCRIPT_SCAN_FACTOR : tail; + let lines = readLogTail(filePath, scanWindow); + if (scriptOnly) lines = filterLinesByTarget(lines, SCRIPT_LOG_TARGET).slice(-tail); if (options.grep) lines = grepLines(lines, options.grep); for (const line of lines) logger.info(line); @@ -52,6 +62,9 @@ export function showLogs(target: LogTarget, options: LogsOptions, settings: Sett } export function logsCommand(target: string | undefined, options: LogsOptions): void { - const resolved: LogTarget = LOG_TARGETS.includes(target as LogTarget) ? (target as LogTarget) : 'node'; + if (target != null && !LOG_TARGETS.includes(target as LogTarget)) { + throw new Error(`Unknown log target '${target}'. Use one of: ${LOG_TARGETS.join(', ')}.`); + } + const resolved: LogTarget = (target as LogTarget) ?? 'node'; showLogs(resolved, options, readSettings(), defaultLogger); } diff --git a/src/cmd/node.ts b/src/cmd/node.ts index 23882bb7..3f743fd9 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -203,7 +203,7 @@ export async function nodeDevnet({ version, binaryPath, daemon, verbose }: NodeP throw new Error('CKB devnet exited while the miner was starting.'); } - const proxy = createRPCProxy(Network.devnet, settings.devnet.rpcUrl, settings.devnet.rpcProxyPort); + const proxy = createRPCProxy(Network.devnet, settings.devnet.rpcUrl, settings.devnet.rpcProxyPort, { verbose }); proxy.start(); // Contract script debug output (debug! in scripts) streams live over the diff --git a/src/devnet/log-file.ts b/src/devnet/log-file.ts index b5bef6ed..ca8a78e9 100644 --- a/src/devnet/log-file.ts +++ b/src/devnet/log-file.ts @@ -122,7 +122,10 @@ export function followLogFile(filePath: string, onLine: (line: string) => void, let decoder = new TextDecoder('utf-8'); const onChange = (curr: fs.Stats, prev: fs.Stats) => { - if (curr.size < prev.size || curr.size < offset) { + // Rotation swaps in a new inode at the same path; a replacement that is + // already larger than the old file fools the size checks below, so the + // inode change must also reset the offset. + if (curr.size < prev.size || curr.size < offset || curr.ino !== prev.ino) { // Truncated or rotated: restart from the beginning. offset = 0; partial = ''; diff --git a/src/node/init-chain.ts b/src/node/init-chain.ts index 94f95349..967ffc0a 100644 --- a/src/node/init-chain.ts +++ b/src/node/init-chain.ts @@ -70,10 +70,14 @@ export const TERMINAL_RPC_MIN_CKB_VERSION = '0.205.0'; // Unknown (null/undefined/unparseable) versions keep the historical behavior // of assuming support — a custom binary whose version cannot be probed must -// not lose functionality it may actually have. +// not lose functionality it may actually have. The `-0` range suffix opts +// 0.205.0 prereleases (rc builds already carry the Terminal module) into the +// match; plain semver.gte would exclude them. includePrerelease extends that +// to prereleases of NEWER versions (0.205.1-rc1, 0.206.0-rc1, ...), which the +// tuple rule would otherwise reject even though they are past the minimum. export function supportsTerminalRpcModule(ckbVersion: string | null | undefined): boolean { if (ckbVersion == null || !semver.valid(ckbVersion)) return true; - return semver.gte(ckbVersion, TERMINAL_RPC_MIN_CKB_VERSION); + return semver.satisfies(ckbVersion, `>=${TERMINAL_RPC_MIN_CKB_VERSION}-0`, { includePrerelease: true }); } // Whether the chain's ckb.toml currently enables the Terminal RPC module. diff --git a/src/tools/ckb-debugger-install.ts b/src/tools/ckb-debugger-install.ts new file mode 100644 index 00000000..dab6e215 --- /dev/null +++ b/src/tools/ckb-debugger-install.ts @@ -0,0 +1,410 @@ +/** + * CKB Debugger Installer + * + * Installs the native `ckb-debugger` binary by downloading the prebuilt + * release asset from the official ckb-standalone-debugger GitHub releases. + * + * Why prebuilt binaries instead of `cargo install`? Installing via cargo + * requires a Rust toolchain and compiles the debugger from source, which is + * slow and error-prone for most users. The upstream repository publishes + * prebuilt binaries for Linux/macOS/Windows on every release, so offckb can + * simply download the one that matches the current platform. + * + * The installer follows the same shape as CKBTui's install flow: + * 1. Resolve the latest release tag via the `releases/latest` redirect + * (no GitHub API involved, so there is no per-IP rate limit to hit). + * 2. Download the matching archive from a deterministic URL (the upstream + * release workflow names assets `ckb-debugger__.tar.gz`). + * 3. Verify its SHA-256 digest against the published `-sha256.txt` (fail closed). + * 4. Extract and publish the binary atomically under tools.rootFolder. + * 5. Put a `ckb-debugger` shim next to the offckb binary so generated + * projects can find it on PATH. + */ + +import { spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as crypto from 'crypto'; +import * as tar from 'tar'; +import { Request } from '../util/request'; +import { readSettings, dataPath } from '../cfg/setting'; +import { logger } from '../util/logger'; +import { findFileInFolder } from '../util/fs'; + +const DEBUGGER_REPO = 'nervosnetwork/ckb-standalone-debugger'; +const RELEASES_URL = `https://github.com/${DEBUGGER_REPO}/releases`; +const LATEST_URL = `${RELEASES_URL}/latest`; + +export interface CkbDebuggerInstallResult { + binaryPath: string; + version: string | null; + /** True when a usable binary was already installed and no download happened. */ + alreadyInstalled: boolean; +} + +function getBinaryName(): string { + return process.platform === 'win32' ? 'ckb-debugger.exe' : 'ckb-debugger'; +} + +/** + * Numeric (not lexicographic) comparison against a required minimum version. + * A pre-release suffix (e.g. `0.200.0-rc1`) compares older than the plain + * release of the same segments. + */ +export function isVersionAtLeast(version: string, minVersion: string): boolean { + const parse = (v: string): { segments: number[]; prerelease: boolean } | null => { + const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.]+)?/.exec(v.trim()); + return match + ? { + segments: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: Boolean(match[4]), + } + : null; + }; + const a = parse(version); + const b = parse(minVersion); + if (!a || !b) { + return false; + } + for (let i = 0; i < 3; i++) { + if (a.segments[i] !== b.segments[i]) { + return a.segments[i] > b.segments[i]; + } + } + // Equal release segments: a pre-release is older than the plain release. + return a.prerelease === b.prerelease || !a.prerelease; +} + +/** + * The `` segment of the upstream release asset name for the current + * platform (assets are named `ckb-debugger__.tar.gz`, per the + * upstream release workflow). Returns null when no prebuilt asset exists. + * + * Note: upstream stopped publishing `x86_64-apple-darwin` assets after v1.0.0 + * (v1.1.0+ ships `aarch64-apple-darwin` only), so an Intel Mac (or Rosetta + * x64 Node on Apple Silicon) has no prebuilt asset and gets the explicit + * "no prebuilt binary" error instead of a misleading 404. + */ +export function getAssetTarget(platform: NodeJS.Platform, arch: string): string | null { + if (platform === 'linux' && arch === 'x64') return 'x86_64-unknown-linux-gnu'; + if (platform === 'linux' && arch === 'arm64') return 'aarch64-unknown-linux-gnu'; + if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin'; + if (platform === 'win32' && arch === 'x64') return 'x86_64-pc-windows-msvc'; + return null; +} + +export class CKBDebuggerInstaller { + /** + * Install (or refresh) the native ckb-debugger binary for this platform. + * Throws on any failure; callers that can degrade (e.g. the create flow) + * should catch the error. + */ + static async install(): Promise { + const binaryPath = this.getInstalledBinaryPath(); + + // Already installed and satisfies the required minimum version: nothing + // to download, just make sure the PATH shim is in place. + if (fs.existsSync(binaryPath)) { + const version = this.getVersionFromBinary(binaryPath); + if (version && this.satisfiesMinVersion(version)) { + this.ensurePathShim(binaryPath); + logger.info(`ckb-debugger ${version} is already installed at ${binaryPath}.`); + return { binaryPath, version, alreadyInstalled: true }; + } + if (version) { + logger.info( + `The installed ckb-debugger (${version}) is older than the required minimum ` + + `(${this.minVersion()}); installing the latest release...`, + ); + } else { + logger.info('The installed ckb-debugger looks stale; installing the latest release...'); + } + } + + const target = getAssetTarget(process.platform, process.arch); + if (!target) { + throw new Error(`ckb-debugger has no prebuilt binary for ${process.platform}/${process.arch}.`); + } + + fs.mkdirSync(this.getInstallDir(), { recursive: true }); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-ckb-debugger-')); + try { + // 1. Resolve the latest release tag (no GitHub API, no rate limit). + logger.info(`Looking up the latest ckb-debugger release from ${DEBUGGER_REPO}...`); + const tag = await this.resolveLatestTag(); + const pkg = `ckb-debugger_${tag}_${target}`; + const archiveUrl = `${RELEASES_URL}/download/${tag}/${pkg}.tar.gz`; + const checksumUrl = `${RELEASES_URL}/download/${tag}/${pkg}-sha256.txt`; + logger.info(`Found ${tag} · ${pkg}.tar.gz`); + + // 2. Download the archive. + const archivePath = path.join(tempDir, `${pkg}.tar.gz`); + await this.download(archiveUrl, archivePath); + + // 3. Verify the SHA-256 digest from the published checksum file. + await this.verifyChecksum(checksumUrl, archivePath); + + // 4. Extract to a temp directory. + const extractDir = path.join(tempDir, 'extracted'); + fs.mkdirSync(extractDir, { recursive: true }); + await this.extractArchive(archivePath, extractDir); + + // 5. Locate the extracted binary. + const extractedBinary = findFileInFolder(extractDir, getBinaryName()); + if (!extractedBinary) { + throw new Error(`ckb-debugger binary ("${getBinaryName()}") was not found after extraction.`); + } + + // 6. Publish atomically so a failed install never leaves a partial binary. + this.publishExtractedBinary(extractedBinary, binaryPath); + + // 7. Verify the binary actually runs before exposing it on PATH. + if (process.platform !== 'win32') { + fs.chmodSync(binaryPath, 0o755); + } + const version = this.getVersionFromBinary(binaryPath); + if (!version) { + // The binary cannot run on this system (e.g. incompatible libc). + // Remove it so the next install does not keep re-downloading the same + // broken artifact, and report failure instead of a bogus success. + fs.rmSync(binaryPath, { force: true }); + throw new Error( + `The installed ckb-debugger binary failed to run \`--version\`. ` + + 'It may be incompatible with this system (e.g. missing glibc).', + ); + } + this.ensurePathShim(binaryPath); + + logger.info(`✅ ckb-debugger installed successfully at ${binaryPath} (version ${version}).`); + logger.info(' To uninstall, remove the binary and the ckb-debugger shim next to the offckb binary.'); + return { binaryPath, version, alreadyInstalled: false }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error( + 'Failed to install ckb-debugger:', + message, + '\nPlease check your network connectivity and try again with: offckb install ckb-debugger', + ); + throw error; + } finally { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup; the OS will reap the temp dir eventually. + } + } + } + + /** Directory that holds the installed ckb-debugger binary. */ + static getInstallDir(): string { + const settings = readSettings(); + // Flat layout under tools.rootFolder, matching CKBTui (e.g. + // /tools/ckb-debugger rather than /tools/ckb-debugger/...). + return this.resolveAndValidateBinDir(settings.tools.rootFolder); + } + + /** Full path of the installed ckb-debugger binary. */ + static getInstalledBinaryPath(): string { + return path.join(this.getInstallDir(), getBinaryName()); + } + + // --- private helpers --- + + private static minVersion(): string { + return readSettings().tools.ckbDebugger.minVersion; + } + + private static satisfiesMinVersion(version: string): boolean { + return isVersionAtLeast(version, this.minVersion()); + } + + private static getVersionFromBinary(binaryPath: string): string | null { + try { + const result = spawnSync(binaryPath, ['--version'], { encoding: 'utf8' }); + if (result.status !== 0) { + return null; + } + const match = /(\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?)/.exec(`${result.stdout}${result.stderr || ''}`); + return match ? match[1] : null; + } catch { + return null; + } + } + + private static async resolveLatestTag(): Promise { + // Follow the /releases/latest redirect (no GitHub API, so no per-IP rate + // limit) and read the tag from the final URL. + const response = await Request.send(LATEST_URL); + const match = /\/releases\/tag\/([^/?#]+)/.exec(response.url); + if (!match) { + throw new Error(`Could not resolve the latest ckb-debugger release from ${LATEST_URL}.`); + } + return match[1]; + } + + private static async download(url: string, targetPath: string): Promise { + logger.info(`Downloading ${url} ...`); + const response = await Request.send(url); + const arrayBuffer = await response.arrayBuffer(); + fs.writeFileSync(targetPath, Buffer.from(arrayBuffer)); + } + + /** Verify the archive against the SHA-256 digest published in the release. */ + private static async verifyChecksum(checksumUrl: string, archivePath: string): Promise { + const response = await Request.send(checksumUrl); + const text = await response.text(); + const match = /([0-9a-f]{64})/i.exec(text); + if (!match) { + throw new Error(`Could not read a SHA-256 digest from ${checksumUrl}.`); + } + const expected = match[1].toLowerCase(); + const actual = crypto.createHash('sha256').update(fs.readFileSync(archivePath)).digest('hex'); + if (actual !== expected) { + throw new Error( + `SHA-256 checksum mismatch.\nExpected: ${expected}\nActual: ${actual}\n` + + 'The downloaded file may be corrupted or tampered with.', + ); + } + logger.info('SHA-256 checksum verified successfully.'); + } + + private static async extractArchive(archivePath: string, extractDir: string): Promise { + // Use the cross-platform `tar` npm package rather than shelling out to the + // system tar binary, whose behavior differs across platforms (GNU tar on + // Linux/Windows, BSD tar on macOS). This matches node/install.ts. + await tar.x({ file: archivePath, cwd: extractDir }); + } + + /** + * Atomically publish the extracted binary to the install path. A directory + * occupying the install path is set aside (never deleted) and restored if + * publishing fails; cross-filesystem temp dirs fall back to a staged copy. + */ + private static publishExtractedBinary(extractedBinary: string, binaryPath: string): void { + let dirBackupPath: string | null = null; + let existing: fs.Stats | null = null; + try { + existing = fs.lstatSync(binaryPath); + } catch { + existing = null; + } + if (existing?.isDirectory()) { + dirBackupPath = `${binaryPath}.backup-${process.pid}-${Date.now()}`; + fs.renameSync(binaryPath, dirBackupPath); + } + + try { + this.renameIntoPlace(extractedBinary, binaryPath); + } catch (error) { + if (dirBackupPath) { + try { + fs.renameSync(dirBackupPath, binaryPath); + } catch { + // Best effort: the original directory remains at its backup path. + } + } + throw error; + } + + if (dirBackupPath) { + logger.info(`A directory unexpectedly occupied ${binaryPath}; moved it aside to ${dirBackupPath}.`); + } + } + + private static renameIntoPlace(source: string, target: string): void { + try { + fs.renameSync(source, target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EXDEV') { + throw error; + } + const stagingPath = path.join(path.dirname(target), `.${path.basename(target)}.staging-${process.pid}`); + try { + fs.copyFileSync(source, stagingPath); + fs.renameSync(stagingPath, target); + } finally { + fs.rmSync(stagingPath, { force: true }); + } + } + } + + /** + * Write a `ckb-debugger` shim next to the offckb binary so generated + * projects that shell out to `ckb-debugger` resolve to the installed + * native binary. When offckb is not on PATH (unusual), skip with a warning. + */ + private static ensurePathShim(binaryPath: string): string | null { + const isWindows = process.platform === 'win32'; + const result = spawnSync(isWindows ? 'where' : 'which', ['offckb'], { encoding: 'utf8' }); + // `which`/`where` failing to spawn leaves stdout null; treat that as "no + // offckb on PATH" rather than crashing the install. + const stdout = result.stdout ?? ''; + const offckbPath = stdout.trim().split('\n')[0]; + if (!offckbPath) { + logger.warn( + 'Could not find the offckb binary in PATH, so no ckb-debugger shim was created. ' + + 'Projects that call `ckb-debugger` directly will need it added to PATH manually.', + ); + return null; + } + + const binName = isWindows ? 'ckb-debugger.cmd' : 'ckb-debugger'; + const targetPath = path.join(path.dirname(offckbPath), binName); + // The marker distinguishes our own shim from a foreign file — e.g. a real + // binary a user installed via cargo — which must never be silently + // clobbered. Legacy v0.4.x fallback shims are also upgraded in place, but + // only when the whole file is exactly the body offckb wrote back then (a + // shebang line plus `exec offckb debugger "$@"` on Unix, `@echo off` plus + // `offckb debugger %*` on Windows), line endings normalized and one + // trailing newline ignored; a mere mention of `offckb debugger`, or a + // legacy body with extra user content, is not enough to claim the file. + const marker = isWindows ? '@rem offckb-managed ckb-debugger shim' : '# offckb-managed ckb-debugger shim'; + const content = isWindows + ? `${marker}\r\n@echo off\r\n"${binaryPath}" %*\r\n` + : `#!/bin/sh\n${marker}\nexec "${binaryPath}" "$@"\n`; + + try { + if (fs.existsSync(targetPath)) { + const existing = fs.readFileSync(targetPath, 'utf8'); + const legacyBody = isWindows ? '@echo off\noffckb debugger %*' : '#!/bin/sh\nexec offckb debugger "$@"'; + const isLegacyShim = existing.replace(/\r\n/g, '\n').replace(/\n$/, '') === legacyBody; + if (!existing.includes(marker) && !isLegacyShim) { + logger.warn( + `A file already exists at ${targetPath} that was not created by offckb; leaving it untouched. ` + + 'Projects that call `ckb-debugger` directly will need it added to PATH manually.', + ); + return null; + } + } + fs.writeFileSync(targetPath, content); + if (!isWindows) { + fs.chmodSync(targetPath, 0o755); + } + logger.info(`✅ ckb-debugger shim updated: ${targetPath}`); + return targetPath; + } catch (error) { + // A missing shim must not fail an otherwise successful install: the + // binary is already in place and offckb can use it directly. + logger.warn( + `Could not write the ckb-debugger shim at ${targetPath}: ${(error as Error).message}. ` + + 'Projects that call `ckb-debugger` directly will need it added to PATH manually.', + ); + return null; + } + } + + /** Resolve and validate that tools.rootFolder stays under the OffCKB data path. */ + private static resolveAndValidateBinDir(configuredRoot: string): string { + const resolved = path.resolve(configuredRoot); + const resolvedData = path.resolve(dataPath); + const relative = path.relative(resolvedData, resolved); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error( + `tools.rootFolder ("${configuredRoot}") resolves outside the OffCKB data directory ` + + `("${resolvedData}"). For security, tool binaries must be stored under the data path.`, + ); + } + return resolved; + } +} diff --git a/src/tools/ckb-debugger-wasm.ts b/src/tools/ckb-debugger-wasm.ts deleted file mode 100644 index dfd1f3a0..00000000 --- a/src/tools/ckb-debugger-wasm.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * CKB Debugger WASM Wrapper - * - * Provides a TypeScript interface for running the CKB standalone debugger compiled to WASM. - * Supports both WASI and native execution modes with proper error handling. - * - * Usage: - * ```typescript - * const debugger = new CkbDebuggerWasi({ - * wasmPath: './path/to/ckb-debugger.wasm', - * captureOutput: true - * }); - * - * // Run a transaction file - * const result = await debugger.runTransaction('tx.json', 'input.0.lock'); - * - * // Run with binary replacement - * const result = await debugger.runBinary('./my-script', 'input.0.lock'); - * - * // Run with specific script hash - * const result = await debugger.runWithScriptHash('tx.json', '0x...hash...', 'lock'); - * ``` - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -/** - * Converts a Windows path to a POSIX-style path for WASI compatibility. - * On non-Windows platforms, returns the path unchanged. - * @param windowsPath - The path to convert - * @returns The POSIX-style path - */ -function toPosixPath(windowsPath: string): string { - if (process.platform !== 'win32') { - return windowsPath; - } - // Convert backslashes to forward slashes and handle drive letters - // e.g., "C:\Users\foo" -> "/c/Users/foo" - return windowsPath.replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`).replace(/\\/g, '/'); -} - -export interface CkbDebuggerResult { - exitCode: number; - output?: string; - error?: string; -} - -export interface CkbDebuggerOptions { - wasmPath?: string; - workingDirectory?: string; - env?: Record; - captureOutput?: boolean; -} - -export class CkbDebuggerWasi { - private wasmPath: string; - private workingDirectory: string; - private env: Record; - private captureOutput: boolean; - private wasm: WebAssembly.Module | null = null; - private static wasiModule: typeof import('node:wasi') | null = null; - - constructor(options: CkbDebuggerOptions = {}) { - this.wasmPath = options.wasmPath || './ckb-debugger.wasm'; - this.workingDirectory = options.workingDirectory || process.cwd(); - this.env = options.env || {}; - this.captureOutput = options.captureOutput || false; - } - - async initialize(): Promise { - if (!this.wasm) { - if (!fs.existsSync(this.wasmPath)) { - throw new Error(`WASM file not found: ${this.wasmPath}`); - } - this.wasm = await WebAssembly.compile(fs.readFileSync(this.wasmPath)); - } - } - - private async getWasiModule(): Promise { - if (!CkbDebuggerWasi.wasiModule) { - try { - CkbDebuggerWasi.wasiModule = await import('node:wasi'); - } catch (_error) { - throw new Error('Failed to load WASI module. Node.js >= 20.0.0 is required for WASM debugger support.'); - } - } - return CkbDebuggerWasi.wasiModule; - } - - private extractFilePathPreopens(args: string[]): Record { - const additionalPreopens: Record = {}; - for (let i = 0; i < args.length - 1; i++) { - if (args[i] === '--tx-file' || args[i] === '--bin' || args[i] === '--read-file') { - const filePath = args[i + 1].replace(/^["']|["']$/g, ''); // Remove quotes - const dir = path.dirname(filePath); - // Use POSIX path for key (WASI compatibility on Windows) - additionalPreopens[toPosixPath(dir)] = dir; - } - // Handle output file for build mode (-c argument) - if (args[i] === '-c' && i + 1 < args.length) { - const filePath = args[i + 1].replace(/^["']|["']$/g, ''); // Remove quotes - const dir = path.dirname(filePath); - // Use POSIX path for key (WASI compatibility on Windows) - additionalPreopens[toPosixPath(dir)] = dir; - } - } - return additionalPreopens; - } - - async run(args: string[] = [], preopens: Record = {}): Promise { - await this.initialize(); - const wasiModule = await this.getWasiModule(); - - return new Promise((resolve, reject) => { - try { - // Extract file paths from arguments to add to preopens - const additionalPreopens = this.extractFilePathPreopens(args); - - // Convert args that contain paths to POSIX style for WASI on Windows - const wasiArgs = args.map((arg) => { - const cleanArg = arg.replace(/^["']|["']$/g, ''); // Remove quotes - // Check if the arg looks like an absolute path (Windows or Unix) - if (path.isAbsolute(cleanArg)) { - return toPosixPath(cleanArg); - } - return cleanArg; - }); - - // Convert user-provided preopens to use POSIX keys - const normalizedPreopens: Record = {}; - for (const [key, value] of Object.entries(preopens)) { - normalizedPreopens[toPosixPath(key)] = value; - } - - // Configure WASI options - const wasiOptions: any = { - version: 'preview1', - args: ['ckb-debugger', ...wasiArgs], - env: this.env, - preopens: { - '/': this.workingDirectory, - ...additionalPreopens, - ...normalizedPreopens, - }, - }; - - // For output capture, we'll handle it differently since WASI expects file descriptors - // We'll use the default stdout/stderr and capture via different means if needed - - const wasihost = new wasiModule.WASI(wasiOptions); - - // Instantiate the WebAssembly module - WebAssembly.instantiate(this.wasm!, wasihost.getImportObject() as WebAssembly.Imports) - .then((instance) => { - try { - wasihost.start(instance); - resolve({ - exitCode: 0, - output: undefined, // Output goes directly to stdout for now - error: undefined, - }); - } catch (e: any) { - // WASI programs exit with a specific exception - if (e.code === 'WASI_EXIT') { - resolve({ - exitCode: e.exitCode || 0, - output: undefined, // Output goes directly to stdout for now - error: undefined, - }); - } else { - reject(new Error(`WASI execution failed: ${e.message}`)); - } - } - }) - .catch(reject); - } catch (e: any) { - reject(new Error(`Failed to run debugger: ${e.message}`)); - } - }); - } - - // Convenience methods for common operations - async help(): Promise { - return this.run(['--help']); - } - - async runTransaction( - txFile: string, - script: string = 'input.0.lock', - maxCycles: string = '1000000000', - ): Promise { - const txPath = path.resolve(this.workingDirectory, txFile); - if (!fs.existsSync(txPath)) { - throw new Error(`Transaction file not found: ${txPath}`); - } - - return this.run(['--tx-file', txFile, '--script', script, '--max-cycles', maxCycles]); - } - - async runBinary( - binFile: string, - script: string = 'input.0.lock', - maxCycles: string = '1000000000', - ): Promise { - const binPath = path.resolve(this.workingDirectory, binFile); - if (!fs.existsSync(binPath)) { - throw new Error(`Binary file not found: ${binPath}`); - } - - return this.run(['--bin', binFile, '--script', script, '--max-cycles', maxCycles], { - [binFile]: binFile, - }); - } - - async runWithMode(mode: string, args: string[] = []): Promise { - const validModes = ['decode-instruction', 'fast', 'full', 'gdb', 'instruction-decode', 'probe']; - if (!validModes.includes(mode)) { - throw new Error(`Invalid mode: ${mode}. Valid modes are: ${validModes.join(', ')}`); - } - - return this.run(['--mode', mode, ...args]); - } - - async runFast( - txFile: string, - script: string = 'input.0.lock', - maxCycles: string = '1000000000', - ): Promise { - return this.run(['--mode', 'fast', '--tx-file', txFile, '--script', script, '--max-cycles', maxCycles]); - } - - async runWithCoverage( - txFile: string, - coverageOutput: string, - script: string = 'input.0.lock', - ): Promise { - return this.run([ - '--tx-file', - txFile, - '--script', - script, - '--enable-coverage', - '--coverage-output', - coverageOutput, - ]); - } - - async runWithStepLog(txFile: string, script: string = 'input.0.lock'): Promise { - return this.run(['--tx-file', txFile, '--script', script, '--enable-steplog']); - } - - async runGdbMode( - txFile: string, - gdbListen: string = '127.0.0.1:9999', - script: string = 'input.0.lock', - ): Promise { - return this.run(['--mode', 'gdb', '--tx-file', txFile, '--script', script, '--gdb-listen', gdbListen]); - } - - async decodeInstruction(instruction: string): Promise { - return this.run(['--mode', 'decode-instruction', instruction]); - } - - async runWithScriptHash( - txFile: string, - scriptHash: string, - scriptGroupType: 'lock' | 'type' = 'lock', - maxCycles: string = '1000000000', - ): Promise { - return this.run([ - '--tx-file', - txFile, - '--script-hash', - scriptHash, - '--script-group-type', - scriptGroupType, - '--max-cycles', - maxCycles, - ]); - } - - async runWithProfile( - txFile: string, - pprofOutput: string, - script: string = 'input.0.lock', - ): Promise { - return this.run(['--tx-file', txFile, '--script', script, '--pprof', pprofOutput]); - } -} diff --git a/src/tools/ckb-debugger.ts b/src/tools/ckb-debugger.ts index e2a3fab8..c4dbae41 100644 --- a/src/tools/ckb-debugger.ts +++ b/src/tools/ckb-debugger.ts @@ -1,8 +1,6 @@ -import { spawnSync, execSync } from 'child_process'; -import * as path from 'path'; -import { readSettings } from '../cfg/setting'; -import { CkbDebuggerWasi } from './ckb-debugger-wasm'; -import { logger } from '../util/logger'; +import { spawnSync, execFileSync } from 'child_process'; +import * as fs from 'fs'; +import { CKBDebuggerInstaller, CkbDebuggerInstallResult } from './ckb-debugger-install'; export interface DebugOption { fullTxJsonFilePath: string; @@ -11,77 +9,74 @@ export interface DebugOption { scriptGroupType: 'lock' | 'type'; } -export class CKBDebugger { - private static wasmDebugger: CkbDebuggerWasi | null = null; - - private static getWasmDebugger(): CkbDebuggerWasi { - if (!this.wasmDebugger) { - const wasmPath = path.join(__dirname, 'ckb-debugger.wasm'); - this.wasmDebugger = new CkbDebuggerWasi({ - wasmPath, - captureOutput: false, // Use stdio directly since custom capture doesn't work with WASI - }); - } - return this.wasmDebugger; - } +/** + * Env var offckb sets on every `ckb-debugger` child it spawns. A stale + * v0.4.x fallback shim (which runs `exec offckb debugger "$@"`) re-enters + * offckb with this var set; resolveBinaryPath then stops instead of spawning + * `ckb-debugger` on PATH again, which would recurse forever. + */ +const RECURSION_GUARD_ENV = 'OFFCKB_DEBUGGER_GUARD'; +export class CKBDebugger { /** - * Check if we're in a recursive call to avoid infinite loops - * This happens when ckb-debugger binary points to offckb CLI + * Resolve the binary to execute: the `ckb-debugger` on PATH (the shim + * written next to offckb) first, then the binary offckb manages under + * tools.rootFolder. Returns null when neither is usable. + * + * The managed-path fallback covers two cases where the PATH probe fails: + * - Windows: a `.cmd` shim cannot be spawned without a shell since Node + * 20.12.2 (CVE-2024-27980), so PATH probing alone always fails there; + * - offckb is not on PATH, so no shim was written, yet the managed binary + * is installed and usable. */ - private static isRecursiveCall(): boolean { - // Check if we're already running as a ckb-debugger process - if (process.argv[1] && process.argv[1].includes('ckb-debugger')) { - logger.debug('Detected ckb-debugger process, using WASM to avoid recursion'); - return true; - } - - // Check if we're running the debugger command (which could be called from ckb-debugger binary) - if (process.argv.includes('debugger')) { - logger.debug('Detected debugger command, using WASM to avoid recursion'); - return true; + private static resolveBinaryPath(): string | null { + // When we are ourselves the child of a stale shim, the guard env var is + // set: skip the PATH probe (it would hit the shim again) and go straight + // to the managed binary. + if (!process.env[RECURSION_GUARD_ENV]) { + const probe = spawnSync('ckb-debugger', ['--version'], { + stdio: 'ignore', + env: { ...process.env, [RECURSION_GUARD_ENV]: '1' }, + }); + if (probe.status === 0) { + return 'ckb-debugger'; + } } - - return false; + const installed = CKBDebuggerInstaller.getInstalledBinaryPath(); + return fs.existsSync(installed) ? installed : null; } - private static shouldUseWasm(): boolean { - if (this.isRecursiveCall()) { - return true; - } - - if (this.isBinaryInstalled() && this.isBinaryVersionValid()) { - logger.debug('Using native ckb-debugger (better performance)'); - return false; - } else { - logger.debug('Native ckb-debugger not available, falling back to WASM version'); - return true; + private static execute(args: string[]): void { + const binary = this.resolveBinaryPath(); + if (!binary) { + throw new Error('ckb-debugger is not installed. Install it once with: offckb install ckb-debugger'); } + // Array argv form: argument splitting and shell injection are impossible. + execFileSync(binary, args, { + stdio: 'inherit', + env: { ...process.env, [RECURSION_GUARD_ENV]: '1' }, + }); } - private static async execute(args: string[]): Promise { - if (this.shouldUseWasm()) { - try { - const result = await this.getWasmDebugger().run(args); - if (result.exitCode !== 0) { - process.exit(result.exitCode); + static runRaw(options: string): void { + // `options` is a space-separated command line whose space-containing + // paths are wrapped in quotes by callers (encodeBinPathForTerminal), + // which also backslash-escapes embedded double quotes. execFileSync's + // array argv does not run through a shell, so a naive split(' ') would + // leave the literal quotes in the argument and break such paths. Split + // quote-aware instead: a quoted segment stays one argv element, the + // surrounding quotes are stripped, and escaped quotes (\") are unescaped. + const args = + options.match(/"(?:[^"\\]|\\.)*"|\S+/g)?.map((arg) => { + if (arg.startsWith('"') && arg.endsWith('"')) { + return arg.slice(1, -1).replace(/\\"/g, '"'); } - } catch (error) { - logger.error('WASM debugger execution failed:', error as Error); - process.exit(1); - } - } else { - const command = `ckb-debugger ${args.join(' ')}`; - execSync(command, { stdio: 'inherit' }); - } - } - - static async runRaw(options: string) { - const args = options.split(' ').filter((arg) => arg.trim()); - await this.execute(args); + return arg; + }) ?? []; + this.execute(args); } - static async runTxCellScript({ fullTxJsonFilePath, cellIndex, cellType, scriptGroupType }: DebugOption) { + static runTxCellScript({ fullTxJsonFilePath, cellIndex, cellType, scriptGroupType }: DebugOption): void { const args = [ '--tx-file', fullTxJsonFilePath, @@ -92,127 +87,19 @@ export class CKBDebugger { '--script-group-type', scriptGroupType, ]; - await this.execute(args); - } - - static isBinaryInstalled() { - const result = spawnSync('ckb-debugger', ['--version'], { stdio: 'ignore' }); - return result.status === 0; - } - - static isBinaryVersionValid() { - const result = spawnSync('ckb-debugger', ['--version']); - if (result.status !== 0) { - return false; - } - try { - const version = result.stdout.toString().split(' ')[1]; - const settings = readSettings(); - if (version < settings.tools.ckbDebugger.minVersion) { - return false; - } - return true; - } catch (error) { - return false; - } - } - - static installCKBDebuggerBinary() { - const command = `cargo install --git https://github.com/nervosnetwork/ckb-standalone-debugger ckb-debugger`; - try { - logger.info('Installing ckb-debugger...'); - execSync(command, { stdio: 'inherit' }); - logger.info('ckb-debugger installed successfully. You can uninstall it by running: cargo uninstall ckb-debugger'); - } catch (error) { - logger.error('Failed to install ckb-debugger:', error as Error); - process.exit(1); - } + this.execute(args); } /** - * Create a ckb-debugger fallback binary that points to offckb debugger - * This creates a shell script that calls: offckb debugger [args] - * Purpose: Reduce user friction by providing a placeholder when native binary is not available - * Users can install the real ckb-debugger binary for better performance if needed + * Install the native ckb-debugger binary by downloading the prebuilt + * release asset for the current platform (see CKBDebuggerInstaller). + * Throws on failure; callers that can degrade should catch the error. */ - static createCkbDebuggerFallback() { - const fs = require('fs'); - const { spawnSync } = require('child_process'); - const isWindows = process.platform === 'win32'; - const binName = isWindows ? 'ckb-debugger.cmd' : 'ckb-debugger'; - - // Find the offckb binary location - let offckbPath: string; - if (isWindows) { - const result = spawnSync('where', ['offckb'], { encoding: 'utf8' }); - offckbPath = result.stdout.trim().split('\n')[0]; - } else { - const result = spawnSync('which', ['offckb'], { encoding: 'utf8' }); - offckbPath = result.stdout.trim(); - } - - if (!offckbPath) { - logger.error('❌ Could not find offckb binary. Please ensure offckb is installed and in your PATH.'); - process.exit(1); - } - - // Get the directory where offckb is located - const offckbDir = path.dirname(offckbPath); - const targetPath = path.join(offckbDir, binName); - - // Create the binary content - let binContent; - if (isWindows) { - // Windows batch file - binContent = `@echo off -offckb debugger %*`; - } else { - // Unix shell script - binContent = `#!/bin/sh -exec offckb debugger "$@"`; - } - - try { - // Ensure directory exists - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - - // Write the binary - fs.writeFileSync(targetPath, binContent); - - // Make executable on Unix systems - if (!isWindows) { - fs.chmodSync(targetPath, '755'); - } - - logger.info(`✅ Created ckb-debugger fallback at: ${targetPath}`); - logger.info(` This fallback uses WASM and calls: offckb debugger [args]`); - logger.info( - ` For better performance, install the real binary: cargo install --git https://github.com/nervosnetwork/ckb-standalone-debugger ckb-debugger`, - ); - logger.info(` To uninstall the fallback binary: rm ${targetPath}`); - } catch (error: unknown) { - logger.error(`❌ Failed to create ckb-debugger fallback: ${(error as Error).message}`); - logger.error(` Make sure you have write permissions to: ${path.dirname(targetPath)}`); - process.exit(1); - } + static installCKBDebuggerBinary(): Promise { + return CKBDebuggerInstaller.install(); } - // Additional convenience methods that work with both native binary CLI and WASM - static async runWithArgs(args: string[]) { - await this.execute(args); - } - - static async version() { - if (this.shouldUseWasm()) { - const result = await this.getWasmDebugger().run(['--version']); - return result; - } else { - const result = spawnSync('ckb-debugger', ['--version'], { encoding: 'utf8' }); - return { - exitCode: result.status || 0, - output: result.stdout, - error: result.stderr, - }; - } + static runWithArgs(args: string[]): void { + this.execute(args); } } diff --git a/src/tools/ckb-debugger.wasm b/src/tools/ckb-debugger.wasm deleted file mode 100755 index e6456e22..00000000 Binary files a/src/tools/ckb-debugger.wasm and /dev/null differ diff --git a/src/tools/ckb-tui.ts b/src/tools/ckb-tui.ts index f04b33c5..07d9a5e1 100644 --- a/src/tools/ckb-tui.ts +++ b/src/tools/ckb-tui.ts @@ -127,6 +127,11 @@ export class CKBTui { // Metadata alone does not prove readability; an unreadable binary must // flow into the reinstall path (which republishes with correct modes). fs.accessSync(binaryPath, fs.constants.R_OK); + // The binary is spawned directly, so a lost execute bit must also count + // as a mismatch. Windows reports X_OK for any readable file. + if (process.platform !== 'win32') { + fs.accessSync(binaryPath, fs.constants.X_OK); + } if (!expected) { return true; } @@ -202,7 +207,7 @@ export class CKBTui { const binaryName = process.platform === 'win32' ? 'ckb-tui.exe' : 'ckb-tui'; this.binaryPath = path.join(binDir, binaryName); - const downloadUrl = `https://github.com/Officeyutong/ckb-tui/releases/download/${version}/${assetName}`; + const downloadUrl = `https://github.com/nervosnetwork/ckb-tui/releases/download/${version}/${assetName}`; // Ensure the target directory exists fs.mkdirSync(binDir, { recursive: true }); diff --git a/src/tools/proxy-events.ts b/src/tools/proxy-events.ts index d84f715c..a3790e0e 100644 --- a/src/tools/proxy-events.ts +++ b/src/tools/proxy-events.ts @@ -63,9 +63,28 @@ export function createProxyEventLog(filePath: string, maxBytes = PROXY_LOG_MAX_B const line = `${new Date().toISOString()} ${sanitizeEventText(text)}\n`; if (size < 0) size = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0; if (size > 0 && size + Buffer.byteLength(line) > maxBytes) { - fs.rmSync(`${filePath}.1`, { force: true }); - fs.renameSync(filePath, `${filePath}.1`); - size = 0; + try { + // The previous archive is moved aside rather than deleted up + // front: if the active-log rename then fails, the archive is + // restored instead of permanently lost. + const archivePath = `${filePath}.1`; + const backupPath = `${filePath}.1.bak`; + fs.rmSync(backupPath, { force: true }); + const hadArchive = fs.existsSync(archivePath); + if (hadArchive) fs.renameSync(archivePath, backupPath); + try { + fs.renameSync(filePath, archivePath); + } catch (error) { + if (hadArchive) fs.renameSync(backupPath, archivePath); + throw error; + } + fs.rmSync(backupPath, { force: true }); + size = 0; + } catch { + // Rollover failed (for example the file is locked). Keep + // appending so events are not lost, and retry on the next event. + size = maxBytes; + } } fs.appendFileSync(filePath, line); size += Buffer.byteLength(line); @@ -87,33 +106,60 @@ export function handleProxyRequestBody(reqData: string, ctx: ProxyEventContext): if (reqData.length === 0) return; try { - const jsonRpcContent = JSON.parse(reqData) as JsonRpcRequestPayload; - const method = jsonRpcContent.method; - const params = jsonRpcContent.params; - ctx.sink.debug('RPC Req: ', method); - if (typeof method === 'string') { - ctx.events.event(`request ${method}`); - } - - if (method === 'send_transaction') { - const tx = (params as unknown[])[0]; - const txHash = ctx.hashTransaction(tx); - if (!fs.existsSync(ctx.transactionsPath)) { - fs.mkdirSync(ctx.transactionsPath, { recursive: true }); + // A JSON-RPC batch request is an array of payloads; normalize so batched + // send_transaction calls are recorded like single ones. + const parsed = JSON.parse(reqData) as JsonRpcRequestPayload | JsonRpcRequestPayload[]; + for (const jsonRpcContent of Array.isArray(parsed) ? parsed : [parsed]) { + // Batch members are user input: JSON.parse happily yields null, strings, + // or nested arrays. Skip anything that is not a plain object so one + // malformed member cannot abort the rest of the batch. + if (jsonRpcContent == null || typeof jsonRpcContent !== 'object' || Array.isArray(jsonRpcContent)) { + ctx.sink.warn('skipping malformed JSON-RPC batch member'); + continue; + } + try { + handleOneRequest(jsonRpcContent, ctx); + } catch (error) { + // Thrown values are not necessarily Error instances (user code can + // throw anything), so normalize before logging; reading .message off + // a non-Error (e.g. null) would itself throw and abort the batch. + const message = error instanceof Error ? error.message : String(error); + ctx.sink.warn(`skipping JSON-RPC request event: ${message}`); } - const txFile = path.resolve(ctx.transactionsPath, `${txHash}.json`); - fs.writeFileSync(txFile, JSON.stringify(tx, null, 2)); - // The hash line mirrors the RPC method name on purpose: at request time - // the proxy does not yet know whether the node will accept the tx, and - // any rejection surfaces separately as an RPC error line. - ctx.sink.info(`send_transaction: ${txHash}`); - ctx.events.event(`send_transaction ${txHash}`); } } catch (err) { ctx.sink.error('Error parsing JSON-RPC req content:', (err as Error).message); } } +function handleOneRequest(jsonRpcContent: JsonRpcRequestPayload, ctx: ProxyEventContext): void { + const method = jsonRpcContent.method; + const params = jsonRpcContent.params; + ctx.sink.debug('RPC Req: ', method); + if (typeof method === 'string') { + ctx.events.event(`request ${method}`); + } + + if (method === 'send_transaction') { + if (!Array.isArray(params) || params.length === 0) { + ctx.sink.warn('send_transaction request has no params; skipping tx dump'); + return; + } + const tx = params[0]; + const txHash = ctx.hashTransaction(tx); + if (!fs.existsSync(ctx.transactionsPath)) { + fs.mkdirSync(ctx.transactionsPath, { recursive: true }); + } + const txFile = path.resolve(ctx.transactionsPath, `${txHash}.json`); + fs.writeFileSync(txFile, JSON.stringify(tx, null, 2)); + // The hash line mirrors the RPC method name on purpose: at request time + // the proxy does not yet know whether the node will accept the tx, and + // any rejection surfaces separately as an RPC error line. + ctx.sink.info(`send_transaction: ${txHash}`); + ctx.events.event(`send_transaction ${txHash}`); + } +} + interface JsonRpcErrorPayload { error?: { code?: unknown; message?: unknown }; } diff --git a/src/tools/rpc-proxy.ts b/src/tools/rpc-proxy.ts index 50304fc6..2b73c75e 100644 --- a/src/tools/rpc-proxy.ts +++ b/src/tools/rpc-proxy.ts @@ -2,18 +2,32 @@ import httpProxy from 'http-proxy'; import http from 'http'; import { Network } from '../type/base'; import { readSettings } from '../cfg/setting'; -import { logger } from '../util/logger'; +import { logger, UnifiedLogger } from '../util/logger'; import { proxyLogPathForNetwork } from '../devnet/log-file'; -import { createProxyEventLog, handleProxyRequestBody, handleProxyResponseBody } from './proxy-events'; +import { + createProxyEventLog, + handleProxyRequestBody, + handleProxyResponseBody, + ProxyEventContext, +} from './proxy-events'; // todo: if we use import this throws error in tsc building const { cccA } = require('@ckb-ccc/core/advanced'); -export function createRPCProxy(network: Network, targetRpcUrl: string, port: number) { +export interface RPCProxyOptions { + // --verbose also lifts the proxy's per-request "RPC Req" lines from debug + // to the console; without it they stay in proxy.log only. + verbose?: boolean; +} + +export function createRPCProxy(network: Network, targetRpcUrl: string, port: number, options: RPCProxyOptions = {}) { const settings = readSettings(); const events = createProxyEventLog(proxyLogPathForNetwork(network, settings)); - const ctx = { - sink: logger, + // The global logger sits at info level, so a debug-level sink is needed for + // verbose runs to actually see the per-request lines. + const sink = options.verbose ? UnifiedLogger.create({ level: 'debug', showLevel: false }) : logger; + const ctx: ProxyEventContext = { + sink, events, transactionsPath: settings[network].transactionsPath, hashTransaction: (tx: unknown) => { diff --git a/src/util/encoding.ts b/src/util/encoding.ts index 305f1223..a22777f0 100644 --- a/src/util/encoding.ts +++ b/src/util/encoding.ts @@ -15,5 +15,8 @@ export function setUTF8EncodingForWindows() { export function encodeBinPathForTerminal(path: string) { // some path contains space in the string // this fix the space in the terminal - return `"${path}"`; + // Escape embedded double quotes: a path containing `"` stays a single + // argument both through shell interpolation (execSync) and through the + // quote-aware split in CKBDebugger.runRaw. + return `"${path.replace(/"/g, '\\"')}"`; } diff --git a/tests/ckb-debugger-install.test.ts b/tests/ckb-debugger-install.test.ts new file mode 100644 index 00000000..6994103d --- /dev/null +++ b/tests/ckb-debugger-install.test.ts @@ -0,0 +1,371 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import crypto from 'crypto'; +import * as tar from 'tar'; +import { Request } from '../src/util/request'; + +const mockSpawnSync = jest.fn(); +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + spawnSync: (...args: unknown[]) => mockSpawnSync(...args), +})); +jest.mock('../src/util/request', () => ({ + Request: { send: jest.fn() }, +})); +jest.mock('../src/util/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +const mockDirs = { dataRoot: '', toolsRoot: '' }; +const mockMinVersion = { current: '0.200.0' }; + +jest.mock('../src/cfg/setting', () => ({ + get dataPath() { + return mockDirs.dataRoot; + }, + readSettings: () => ({ + tools: { + rootFolder: mockDirs.toolsRoot, + ckbDebugger: { minVersion: mockMinVersion.current }, + }, + }), +})); + +import { CKBDebuggerInstaller, isVersionAtLeast, getAssetTarget } from '../src/tools/ckb-debugger-install'; + +const requestSend = Request.send as jest.Mock; + +// Install-flow tests pin the host to linux/x64 so the download/install +// assertions are host-independent: upstream publishes no Intel-macOS asset, +// and a POSIX-shell fixture cannot run on Windows. +const TARGET = 'x86_64-unknown-linux-gnu'; +const BINARY_NAME = 'ckb-debugger'; +const originalPlatform = process.platform; +const originalArch = process.arch; + +function mockHost(platform: NodeJS.Platform, arch: string): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + Object.defineProperty(process, 'arch', { value: arch, configurable: true }); +} + +describe('getAssetTarget', () => { + it('maps known platform/arch combinations to upstream asset targets', () => { + expect(getAssetTarget('linux', 'x64')).toBe('x86_64-unknown-linux-gnu'); + expect(getAssetTarget('linux', 'arm64')).toBe('aarch64-unknown-linux-gnu'); + expect(getAssetTarget('darwin', 'arm64')).toBe('aarch64-apple-darwin'); + expect(getAssetTarget('win32', 'x64')).toBe('x86_64-pc-windows-msvc'); + }); + + it('returns null for platforms without a prebuilt asset (incl. Intel macOS)', () => { + expect(getAssetTarget('darwin', 'x64')).toBeNull(); + expect(getAssetTarget('linux', 'ia32')).toBeNull(); + expect(getAssetTarget('win32', 'arm64')).toBeNull(); + expect(getAssetTarget('freebsd', 'x64')).toBeNull(); + }); +}); + +describe('isVersionAtLeast', () => { + it('compares versions numerically, not lexicographically', () => { + expect(isVersionAtLeast('0.113.1', '0.200.0')).toBe(false); + expect(isVersionAtLeast('0.200.0', '0.200.0')).toBe(true); + expect(isVersionAtLeast('0.208.0', '0.200.0')).toBe(true); + expect(isVersionAtLeast('0.9.0', '0.10.0')).toBe(false); + expect(isVersionAtLeast('0.10.0', '0.9.0')).toBe(true); + }); + + it('treats a pre-release as older than the plain release', () => { + expect(isVersionAtLeast('0.200.0-rc1', '0.200.0')).toBe(false); + expect(isVersionAtLeast('0.200.0', '0.200.0-rc1')).toBe(true); + }); + + it('returns false for unparseable versions', () => { + expect(isVersionAtLeast('unknown', '0.200.0')).toBe(false); + }); +}); + +describe('CKBDebuggerInstaller', () => { + let root: string; + let binaryPath: string; + + beforeEach(() => { + jest.clearAllMocks(); + mockHost('linux', 'x64'); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-debugger-test-')); + mockDirs.dataRoot = root; + mockDirs.toolsRoot = path.join(root, 'tools'); + binaryPath = path.join(mockDirs.toolsRoot, BINARY_NAME); + mockMinVersion.current = '0.200.0'; + // The offckb binary is not on PATH in the test environment: the PATH-shim + // step is skipped with a warning instead of touching the host. + mockSpawnSync.mockImplementation((cmd: string) => { + if (cmd === 'which' || cmd === 'where') { + return { status: 1, stdout: '', stderr: '' }; + } + return { status: 0, stdout: 'ckb-debugger 0.208.0\n', stderr: '' }; + }); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + mockHost(originalPlatform, originalArch); + jest.restoreAllMocks(); + }); + + function buildTarGz(version: string): Promise { + // Use the cross-platform `tar` npm package (not the system tar binary): + // on Windows, GNU tar misreads the "C:\" drive prefix in temp paths as a + // remote host and fails, which breaks test-fixture creation there. + return (async () => { + const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-debugger-stage-')); + try { + fs.writeFileSync(path.join(stage, BINARY_NAME), fakeBinaryPayload(version)); + const archivePath = path.join(stage, 'archive.tar.gz'); + await tar.c({ gzip: true, file: archivePath, cwd: stage }, [BINARY_NAME]); + return fs.readFileSync(archivePath); + } finally { + fs.rmSync(stage, { recursive: true, force: true }); + } + })(); + } + + function fakeBinaryPayload(version: string): string { + return `#!/bin/sh\necho "ckb-debugger ${version}"\n`; + } + + function mockRelease(version: string, buffer: Buffer) { + const tag = `v${version}`; + requestSend.mockImplementation(async (url: string) => { + if (url.includes('/releases/latest')) { + return { + url: `https://github.com/nervosnetwork/ckb-standalone-debugger/releases/tag/${tag}`, + }; + } + if (url.endsWith('-sha256.txt')) { + const digest = crypto.createHash('sha256').update(buffer).digest('hex'); + return { text: async () => `${digest} ckb-debugger_${tag}_${TARGET}.tar.gz` }; + } + return { arrayBuffer: async () => buffer }; + }); + } + + it('downloads, verifies and publishes the latest release binary', async () => { + const buffer = await buildTarGz('0.208.0'); + mockRelease('0.208.0', buffer); + + const result = await CKBDebuggerInstaller.install(); + + expect(result.alreadyInstalled).toBe(false); + expect(result.binaryPath).toBe(binaryPath); + expect(fs.existsSync(binaryPath)).toBe(true); + expect(fs.readFileSync(binaryPath, 'utf-8')).toContain('echo "ckb-debugger 0.208.0"'); + expect(requestSend).toHaveBeenCalledTimes(3); // latest redirect + archive + sha256.txt + }); + + it('skips the download when a valid binary is already installed', async () => { + fs.mkdirSync(path.dirname(binaryPath), { recursive: true }); + fs.writeFileSync(binaryPath, fakeBinaryPayload('0.208.0')); + mockSpawnSync.mockImplementation((cmd: string) => { + if (cmd === 'which' || cmd === 'where') { + return { status: 1, stdout: '', stderr: '' }; + } + return { status: 0, stdout: 'ckb-debugger 0.208.0\n', stderr: '' }; + }); + + const result = await CKBDebuggerInstaller.install(); + + expect(result.alreadyInstalled).toBe(true); + expect(result.version).toBe('0.208.0'); + expect(requestSend).not.toHaveBeenCalled(); + }); + + it('reinstalls when the installed binary is older than the required minimum', async () => { + fs.mkdirSync(path.dirname(binaryPath), { recursive: true }); + fs.writeFileSync(binaryPath, fakeBinaryPayload('0.113.1')); + mockSpawnSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'which' || cmd === 'where') { + return { status: 1, stdout: '', stderr: '' }; + } + if (cmd === binaryPath) { + return { status: 0, stdout: 'ckb-debugger 0.113.1\n', stderr: '' }; + } + return jest.requireActual('child_process').spawnSync(cmd, args, { stdio: 'ignore' }); + }); + + const buffer = await buildTarGz('0.208.0'); + mockRelease('0.208.0', buffer); + + const result = await CKBDebuggerInstaller.install(); + + expect(result.alreadyInstalled).toBe(false); + expect(fs.readFileSync(binaryPath, 'utf-8')).toContain('echo "ckb-debugger 0.208.0"'); + }); + + it('fails closed when the downloaded checksum does not match', async () => { + const buffer = await buildTarGz('0.208.0'); + requestSend.mockImplementation(async (url: string) => { + if (url.includes('/releases/latest')) { + return { + url: 'https://github.com/nervosnetwork/ckb-standalone-debugger/releases/tag/v0.208.0', + }; + } + if (url.endsWith('-sha256.txt')) { + return { text: async () => `${'0'.repeat(64)} archive.tar.gz` }; + } + return { arrayBuffer: async () => buffer }; + }); + + await expect(CKBDebuggerInstaller.install()).rejects.toThrow('checksum mismatch'); + expect(fs.existsSync(binaryPath)).toBe(false); + }); + + it('removes the binary and throws when it cannot run --version after install', async () => { + const buffer = await buildTarGz('0.208.0'); + mockRelease('0.208.0', buffer); + mockSpawnSync.mockImplementation((cmd: string) => { + if (cmd === 'which' || cmd === 'where') { + return { status: 1, stdout: '', stderr: '' }; + } + return { status: 1, stdout: '', stderr: '' }; + }); + + await expect(CKBDebuggerInstaller.install()).rejects.toThrow(/failed to run `--version`/); + expect(fs.existsSync(binaryPath)).toBe(false); + }); + + it('does not clobber a foreign file at the shim location', async () => { + const buffer = await buildTarGz('0.208.0'); + mockRelease('0.208.0', buffer); + const shimPath = path.join(root, 'bin', 'ckb-debugger'); + mockSpawnSync.mockImplementation((cmd: string) => { + if (cmd === 'which') { + return { status: 0, stdout: `${path.join(root, 'bin', 'offckb')}\n`, stderr: '' }; + } + return { status: 0, stdout: 'ckb-debugger 0.208.0\n', stderr: '' }; + }); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.writeFileSync(shimPath, '#!/bin/sh\nexec /usr/bin/ckb-debugger "$@"\n'); + + const result = await CKBDebuggerInstaller.install(); + + expect(result.alreadyInstalled).toBe(false); + expect(fs.existsSync(binaryPath)).toBe(true); + expect(fs.readFileSync(shimPath, 'utf-8')).toBe('#!/bin/sh\nexec /usr/bin/ckb-debugger "$@"\n'); + }); + + it('does not overwrite a user file that merely mentions offckb debugger', async () => { + const buffer = await buildTarGz('0.208.0'); + mockRelease('0.208.0', buffer); + const shimPath = path.join(root, 'bin', 'ckb-debugger'); + mockSpawnSync.mockImplementation((cmd: string) => { + if (cmd === 'which') { + return { status: 0, stdout: `${path.join(root, 'bin', 'offckb')}\n`, stderr: '' }; + } + return { status: 0, stdout: 'ckb-debugger 0.208.0\n', stderr: '' }; + }); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + // A user wrapper that documents offckb debugger without being the exact + // v0.4.x fallback body must stay untouched. + const userScript = '#!/bin/sh\n# How to call: offckb debugger --help\n/usr/bin/ckb-debugger "$@"\n'; + fs.writeFileSync(shimPath, userScript); + + await CKBDebuggerInstaller.install(); + + expect(fs.readFileSync(shimPath, 'utf-8')).toBe(userScript); + }); + + it('upgrades a legacy v0.4.x fallback shim', async () => { + const buffer = await buildTarGz('0.208.0'); + mockRelease('0.208.0', buffer); + const shimPath = path.join(root, 'bin', 'ckb-debugger'); + mockSpawnSync.mockImplementation((cmd: string) => { + if (cmd === 'which') { + return { status: 0, stdout: `${path.join(root, 'bin', 'offckb')}\n`, stderr: '' }; + } + return { status: 0, stdout: 'ckb-debugger 0.208.0\n', stderr: '' }; + }); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.writeFileSync(shimPath, '#!/bin/sh\nexec offckb debugger "$@"\n'); + + await CKBDebuggerInstaller.install(); + + expect(fs.readFileSync(shimPath, 'utf-8')).toContain('offckb-managed'); + expect(fs.readFileSync(shimPath, 'utf-8')).toContain(`exec "${binaryPath}" "$@"`); + }); + + it('does not overwrite a legacy shim that has extra user content', async () => { + const buffer = await buildTarGz('0.208.0'); + mockRelease('0.208.0', buffer); + const shimPath = path.join(root, 'bin', 'ckb-debugger'); + mockSpawnSync.mockImplementation((cmd: string) => { + if (cmd === 'which') { + return { status: 0, stdout: `${path.join(root, 'bin', 'offckb')}\n`, stderr: '' }; + } + return { status: 0, stdout: 'ckb-debugger 0.208.0\n', stderr: '' }; + }); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + // The complete legacy body plus one user line is not an offckb shim: the + // whole file must match exactly, so it stays untouched. + const userScript = '#!/bin/sh\nexec offckb debugger "$@"\n# custom wrapper\n'; + fs.writeFileSync(shimPath, userScript); + + await CKBDebuggerInstaller.install(); + + expect(fs.readFileSync(shimPath, 'utf-8')).toBe(userScript); + }); + + it('upgrades an exact legacy v0.4.x fallback shim on Windows', async () => { + mockHost('win32', 'x64'); + const winBinaryPath = path.join(mockDirs.toolsRoot, 'ckb-debugger.exe'); + fs.mkdirSync(path.dirname(winBinaryPath), { recursive: true }); + fs.writeFileSync(winBinaryPath, fakeBinaryPayload('0.208.0')); + const shimPath = path.join(root, 'bin', 'ckb-debugger.cmd'); + mockSpawnSync.mockImplementation((cmd: string) => { + if (cmd === 'where') { + return { status: 0, stdout: `${path.join(root, 'bin', 'offckb')}\n`, stderr: '' }; + } + return { status: 0, stdout: 'ckb-debugger 0.208.0\n', stderr: '' }; + }); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + // CRLF line endings are normalized before the exact-body comparison. + fs.writeFileSync(shimPath, '@echo off\r\noffckb debugger %*\r\n'); + + const result = await CKBDebuggerInstaller.install(); + + expect(result.alreadyInstalled).toBe(true); + const upgraded = fs.readFileSync(shimPath, 'utf-8'); + expect(upgraded).toContain('offckb-managed'); + expect(upgraded).toContain(`"${winBinaryPath}" %*`); + }); + + it('does not overwrite a legacy shim with extra user content on Windows', async () => { + mockHost('win32', 'x64'); + const winBinaryPath = path.join(mockDirs.toolsRoot, 'ckb-debugger.exe'); + fs.mkdirSync(path.dirname(winBinaryPath), { recursive: true }); + fs.writeFileSync(winBinaryPath, fakeBinaryPayload('0.208.0')); + const shimPath = path.join(root, 'bin', 'ckb-debugger.cmd'); + mockSpawnSync.mockImplementation((cmd: string) => { + if (cmd === 'where') { + return { status: 0, stdout: `${path.join(root, 'bin', 'offckb')}\n`, stderr: '' }; + } + return { status: 0, stdout: 'ckb-debugger 0.208.0\n', stderr: '' }; + }); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + const userScript = '@echo off\r\noffckb debugger %*\r\nREM custom wrapper\r\n'; + fs.writeFileSync(shimPath, userScript); + + await CKBDebuggerInstaller.install(); + + expect(fs.readFileSync(shimPath, 'utf-8')).toBe(userScript); + }); + + it('throws when the platform has no prebuilt asset', async () => { + mockHost('freebsd', 'x64'); + await expect(CKBDebuggerInstaller.install()).rejects.toThrow(/no prebuilt binary/); + }); + + it('throws when the latest release tag cannot be resolved', async () => { + requestSend.mockImplementation(async () => ({ url: 'https://github.com/somewhere/else' })); + await expect(CKBDebuggerInstaller.install()).rejects.toThrow(/Could not resolve the latest/); + }); +}); diff --git a/tests/ckb-debugger.test.ts b/tests/ckb-debugger.test.ts new file mode 100644 index 00000000..3805c06a --- /dev/null +++ b/tests/ckb-debugger.test.ts @@ -0,0 +1,151 @@ +import fs from 'fs'; +import { spawnSync, execFileSync } from 'child_process'; +import { CKBDebuggerInstaller } from '../src/tools/ckb-debugger-install'; +import { CKBDebugger } from '../src/tools/ckb-debugger'; + +jest.mock('child_process', () => ({ + spawnSync: jest.fn(), + execFileSync: jest.fn(), +})); +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: jest.fn(), +})); +jest.mock('../src/tools/ckb-debugger-install', () => ({ + CKBDebuggerInstaller: { getInstalledBinaryPath: jest.fn() }, +})); + +const mockSpawnSync = spawnSync as jest.Mock; +const mockExecFileSync = execFileSync as jest.Mock; +const mockExistsSync = fs.existsSync as jest.Mock; +const mockGetInstalledBinaryPath = CKBDebuggerInstaller.getInstalledBinaryPath as jest.Mock; + +const GUARD = 'OFFCKB_DEBUGGER_GUARD'; +const originalGuard = process.env[GUARD]; + +beforeEach(() => { + jest.clearAllMocks(); + delete process.env[GUARD]; +}); + +afterEach(() => { + if (originalGuard === undefined) { + delete process.env[GUARD]; + } else { + process.env[GUARD] = originalGuard; + } +}); + +describe('CKBDebugger', () => { + it('runs the PATH binary when the PATH probe succeeds', () => { + mockSpawnSync.mockReturnValue({ status: 0 }); + mockGetInstalledBinaryPath.mockReturnValue('/managed/ckb-debugger'); + + CKBDebugger.runWithArgs(['--version']); + + expect(mockSpawnSync).toHaveBeenCalledWith( + 'ckb-debugger', + ['--version'], + expect.objectContaining({ stdio: 'ignore' }), + ); + expect(mockExecFileSync).toHaveBeenCalledWith('ckb-debugger', ['--version'], expect.anything()); + expect(mockGetInstalledBinaryPath).not.toHaveBeenCalled(); + }); + + it('falls back to the managed binary when the PATH probe fails', () => { + mockSpawnSync.mockReturnValue({ status: 1 }); + mockGetInstalledBinaryPath.mockReturnValue('/managed/ckb-debugger'); + mockExistsSync.mockImplementation((p: string) => p === '/managed/ckb-debugger'); + + CKBDebugger.runWithArgs(['--tx-file', 'a b.json']); + + expect(mockExecFileSync).toHaveBeenCalledWith( + '/managed/ckb-debugger', + ['--tx-file', 'a b.json'], + expect.anything(), + ); + }); + + it('throws a clear error when neither PATH nor the managed binary exists', () => { + mockSpawnSync.mockReturnValue({ status: 1 }); + mockGetInstalledBinaryPath.mockReturnValue('/managed/ckb-debugger'); + mockExistsSync.mockReturnValue(false); + + expect(() => CKBDebugger.runWithArgs(['--version'])).toThrow( + /ckb-debugger is not installed. Install it once with: offckb install ckb-debugger/, + ); + }); + + it('does not probe PATH again when the recursion guard env var is set', () => { + process.env[GUARD] = '1'; + mockGetInstalledBinaryPath.mockReturnValue('/managed/ckb-debugger'); + mockExistsSync.mockReturnValue(true); + + CKBDebugger.runWithArgs(['--version']); + + expect(mockSpawnSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).toHaveBeenCalledWith('/managed/ckb-debugger', ['--version'], expect.anything()); + }); + + it('sets the recursion guard env var on the binary it spawns', () => { + mockSpawnSync.mockReturnValue({ status: 0 }); + + CKBDebugger.runWithArgs(['--version']); + + expect(mockExecFileSync).toHaveBeenCalledWith( + 'ckb-debugger', + ['--version'], + expect.objectContaining({ env: expect.objectContaining({ [GUARD]: '1' }) }), + ); + }); +}); + +describe('CKBDebugger.runRaw', () => { + it('strips quotes from a quoted tx-file path instead of passing them to argv', () => { + mockSpawnSync.mockReturnValue({ status: 0 }); + + CKBDebugger.runRaw('--tx-file "/tmp/offckb/devnet/full-transactions/0xabc.json" --cell-index 0'); + + expect(mockExecFileSync).toHaveBeenCalledWith( + 'ckb-debugger', + ['--tx-file', '/tmp/offckb/devnet/full-transactions/0xabc.json', '--cell-index', '0'], + expect.anything(), + ); + }); + + it('keeps a quoted path containing spaces as a single argv element', () => { + mockSpawnSync.mockReturnValue({ status: 0 }); + + CKBDebugger.runRaw('--tx-file "/path/with space/tx.json" --bin "/my bin/ckb"'); + + expect(mockExecFileSync).toHaveBeenCalledWith( + 'ckb-debugger', + ['--tx-file', '/path/with space/tx.json', '--bin', '/my bin/ckb'], + expect.anything(), + ); + }); + + it('unescapes an embedded quote so the path stays a single argv element', () => { + mockSpawnSync.mockReturnValue({ status: 0 }); + + CKBDebugger.runRaw('--tx-file "/path/with\\"quote/tx.json"'); + + expect(mockExecFileSync).toHaveBeenCalledWith( + 'ckb-debugger', + ['--tx-file', '/path/with"quote/tx.json'], + expect.anything(), + ); + }); + + it('passes unquoted arguments through unchanged', () => { + mockSpawnSync.mockReturnValue({ status: 0 }); + + CKBDebugger.runRaw('--tx-file /unquoted/path.json --cell-type input'); + + expect(mockExecFileSync).toHaveBeenCalledWith( + 'ckb-debugger', + ['--tx-file', '/unquoted/path.json', '--cell-type', 'input'], + expect.anything(), + ); + }); +}); diff --git a/tests/ckb-tui-install.test.ts b/tests/ckb-tui-install.test.ts index 4b308c5b..8e9e847c 100644 --- a/tests/ckb-tui-install.test.ts +++ b/tests/ckb-tui-install.test.ts @@ -97,7 +97,9 @@ describe('ckb-tui installed-binary verification', () => { )[assetName()]; it('keeps an existing binary whose digest matches the configured release', () => { - fs.writeFileSync(binaryPath, 'installed ckb-tui'); + // A real install publishes with mode 0o755; the execute bit is part of + // what the verification checks. + fs.writeFileSync(binaryPath, 'installed ckb-tui', { mode: 0o755 }); jest.spyOn(crypto, 'createHash').mockReturnValue({ update: () => ({ digest: () => pinnedDigest() }), } as unknown as crypto.Hash); @@ -145,7 +147,8 @@ describe('ckb-tui installed-binary verification', () => { it('falls back to presence-only detection for a release without a pinned binary digest', () => { mockVersion.current = 'v9.9.9'; - fs.writeFileSync(binaryPath, 'any content at all'); + // Presence-only still means a usable binary: regular, readable, executable. + fs.writeFileSync(binaryPath, 'any content at all', { mode: 0o755 }); expect(CKBTui.ensureInstalled()).toBe(binaryPath); expect(installSpy).not.toHaveBeenCalled(); @@ -181,6 +184,14 @@ describe('ckb-tui installed-binary verification', () => { expect(internals.installedBinaryMatches(binaryPath)).toBe(false); }); + + itPosix('treats a binary without the execute bit as a mismatch', () => { + mockVersion.current = 'v9.9.9'; // Unpinned: presence alone must not suffice. + // No execute bit anywhere, so X_OK fails even for root. + fs.writeFileSync(binaryPath, 'execute-bit-lost ckb-tui', { mode: 0o644 }); + + expect(internals.installedBinaryMatches(binaryPath)).toBe(false); + }); }); describe('ckb-tui binary publishing', () => { diff --git a/tests/init-chain.test.ts b/tests/init-chain.test.ts index d8e7b635..9e8c154d 100644 --- a/tests/init-chain.test.ts +++ b/tests/init-chain.test.ts @@ -223,7 +223,12 @@ describe('Terminal RPC module version gating', () => { expect(supportsTerminalRpcModule('0.207.0')).toBe(true); expect(supportsTerminalRpcModule('0.120.0')).toBe(false); expect(supportsTerminalRpcModule('0.204.9')).toBe(false); - expect(supportsTerminalRpcModule('0.205.0-rc1')).toBe(false); + // 0.205.0 prereleases already ship the Terminal module. + expect(supportsTerminalRpcModule('0.205.0-rc1')).toBe(true); + expect(supportsTerminalRpcModule('0.204.9-rc1')).toBe(false); + // Prereleases of newer versions are past the minimum too. + expect(supportsTerminalRpcModule('0.205.1-rc1')).toBe(true); + expect(supportsTerminalRpcModule('0.206.0-rc1')).toBe(true); }); it('strips Terminal from a freshly initialized template when the binary is too old', async () => { diff --git a/tests/logs-command.test.ts b/tests/logs-command.test.ts index e1e308d9..3c8baf13 100644 --- a/tests/logs-command.test.ts +++ b/tests/logs-command.test.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import winston from 'winston'; -import { showLogs } from '../src/cmd/logs'; +import { logsCommand, showLogs } from '../src/cmd/logs'; import { defaultSettings, Settings } from '../src/cfg/setting'; import { UnifiedLogger } from '../src/util/logger'; @@ -70,6 +70,17 @@ describe('showLogs', () => { expect(transport.logs).toEqual([SCRIPT_LINE]); }); + it('finds sparse script entries beyond the raw tail window', () => { + const { settings, transport } = fixture(); + const runLog = path.join(settings.devnet.dataPath, 'logs', 'run.log'); + // The only script entry sits above the last `tail` lines, so a plain + // filter-after-tail would print nothing. + fs.writeFileSync(runLog, [SCRIPT_LINE, NODE_LINE, ERROR_LINE, NODE_LINE].join('\n') + '\n'); + const log = UnifiedLogger.create({ transports: [transport], showLevel: false }); + showLogs('script', { tail: 2 }, settings, log); + expect(transport.logs).toEqual([SCRIPT_LINE]); + }); + it('reads miner.log for the miner target', () => { const { settings, transport } = fixture(); const log = UnifiedLogger.create({ transports: [transport], showLevel: false }); @@ -91,13 +102,23 @@ describe('showLogs', () => { expect(transport.logs).toEqual([ERROR_LINE]); }); + it('rejects a zero or negative tail instead of dumping the whole filtered log', () => { + const { settings, transport } = fixture(); + const log = UnifiedLogger.create({ transports: [transport], showLevel: false }); + // slice(-0) is slice(0): without validation, script mode would print every + // filtered line it scanned instead of nothing. + expect(() => showLogs('script', { tail: 0 }, settings, log)).toThrow(/positive integer/); + expect(() => showLogs('script', { tail: -0 }, settings, log)).toThrow(/positive integer/); + expect(() => showLogs('node', { tail: -5 }, settings, log)).toThrow(/positive integer/); + expect(transport.logs).toEqual([]); + }); + it('throws a helpful error when the log file does not exist', () => { const settings = JSON.parse(JSON.stringify(defaultSettings)) as Settings; settings.devnet.dataPath = '/nonexistent'; const log = UnifiedLogger.create({ transports: [new CapturingTransport()] }); expect(() => showLogs('node', { tail: 100 }, settings, log)).toThrow(/log file not found/i); }); - it('in follow mode gates script entries and applies grep to streamed lines', () => { const { settings, transport } = fixture(); type StatListener = (curr: fs.Stats, prev: fs.Stats) => void; @@ -131,3 +152,10 @@ describe('showLogs', () => { } }); }); + +describe('logsCommand', () => { + it('rejects an unknown log target instead of falling back to node', () => { + expect(() => logsCommand('scrpit', {})).toThrow(/unknown log target 'scrpit'/i); + expect(() => logsCommand('scrpit', {})).toThrow(/node, script, miner, rpc/); + }); +}); diff --git a/tests/logs.test.ts b/tests/logs.test.ts index ccf90a05..8bfc78f1 100644 --- a/tests/logs.test.ts +++ b/tests/logs.test.ts @@ -194,11 +194,25 @@ describe('followLogFile', () => { listeners[0](grown, grown); expect(seen).toEqual([SCRIPT_LINE]); - // Rotation replaces the file with a shorter one; the next notification - // must reset the offset instead of seeking past the end. + // Truncation shrinks the file in place; the next notification must + // reset the offset instead of seeking past the end. fs.writeFileSync(file, `${ERROR_LINE}\n`); listeners[0](fs.statSync(file), grown); expect(seen).toEqual([SCRIPT_LINE, ERROR_LINE]); + + fs.appendFileSync(file, `${NODE_LINE}\n`); + const regrown = fs.statSync(file); + listeners[0](regrown, regrown); + expect(seen).toEqual([SCRIPT_LINE, ERROR_LINE, NODE_LINE]); + + // Rotation replaces the file: the old path is renamed away and a new + // file appears at the same path, possibly already larger than the old + // one. The size checks alone would keep the stale offset, so the inode + // change must restart the read from offset 0. + fs.renameSync(file, `${file}.1`); + fs.writeFileSync(file, [SCRIPT_LINE, ERROR_LINE, NODE_LINE, SCRIPT_LINE].join('\n') + '\n'); + listeners[0](fs.statSync(file), regrown); + expect(seen).toEqual([SCRIPT_LINE, ERROR_LINE, NODE_LINE, SCRIPT_LINE, ERROR_LINE, NODE_LINE, SCRIPT_LINE]); stop(); } finally { mockWatchFile.mockReset(); diff --git a/tests/proxy-events.test.ts b/tests/proxy-events.test.ts index e5e9f6a1..9baa4cee 100644 --- a/tests/proxy-events.test.ts +++ b/tests/proxy-events.test.ts @@ -3,6 +3,20 @@ import * as os from 'os'; import * as path from 'path'; import { createProxyEventLog, handleProxyRequestBody, handleProxyResponseBody } from '../src/tools/proxy-events'; +// renameSync goes through a mock so rollover failures can be simulated +// deterministically (same pattern as the fs.watchFile stub in +// logs-command.test.ts); the default delegates to the real implementation. +const mockRenameSync = jest.fn(); +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + renameSync: (...args: unknown[]) => mockRenameSync(...args), +})); + +const realFs = jest.requireActual('fs') as typeof fs; +beforeEach(() => { + mockRenameSync.mockImplementation(realFs.renameSync); +}); + function makeSink() { const sink = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }; return sink; @@ -50,6 +64,125 @@ describe('handleProxyRequestBody', () => { expect(content).toMatch(/send_transaction 0xhash/); }); + it('records each entry of a batch request, including a batched send_transaction', () => { + const ctx = makeCtx(transactionsPath); + const tx = { cell_deps: [], inputs: [], outputs: [] }; + handleProxyRequestBody( + JSON.stringify([ + { jsonrpc: '2.0', id: 1, method: 'get_tip_header', params: [] }, + { jsonrpc: '2.0', id: 2, method: 'send_transaction', params: [tx] }, + ]), + ctx, + ); + expect(ctx.sink.info).toHaveBeenCalledWith(expect.stringContaining('0xhash')); + expect(fs.existsSync(path.join(transactionsPath, '0xhash.json'))).toBe(true); + const content = fs.readFileSync(ctx.events.filePath, 'utf8'); + expect(content).toMatch(/request get_tip_header/); + expect(content).toMatch(/send_transaction 0xhash/); + }); + + it('skips malformed batch members and still records the requests after them', () => { + const ctx = makeCtx(transactionsPath); + const tx = { cell_deps: [], inputs: [], outputs: [] }; + handleProxyRequestBody( + JSON.stringify([null, { jsonrpc: '2.0', id: 2, method: 'send_transaction', params: [tx] }]), + ctx, + ); + // The null member is skipped with a warning rather than aborting the batch. + expect(ctx.sink.warn).toHaveBeenCalledWith(expect.stringContaining('malformed')); + expect(ctx.sink.error).not.toHaveBeenCalled(); + // The valid send_transaction after it is still recorded. + expect(ctx.sink.info).toHaveBeenCalledWith(expect.stringContaining('0xhash')); + expect(fs.existsSync(path.join(transactionsPath, '0xhash.json'))).toBe(true); + const content = fs.readFileSync(ctx.events.filePath, 'utf8'); + expect(content).toMatch(/send_transaction 0xhash/); + }); + + it('isolates a failing batch member and still records a valid request after it', () => { + const ctx = makeCtx(transactionsPath); + // Simulate a member whose tx dump blows up mid-processing. + ctx.hashTransaction = jest.fn((tx: unknown) => { + if ((tx as { bad?: boolean }).bad) { + throw new Error('hash boom'); + } + return '0xhash'; + }); + const goodTx = { cell_deps: [], inputs: [], outputs: [] }; + handleProxyRequestBody( + JSON.stringify([ + { jsonrpc: '2.0', id: 1, method: 'send_transaction', params: [{ bad: true }] }, + { jsonrpc: '2.0', id: 2, method: 'send_transaction', params: [goodTx] }, + ]), + ctx, + ); + // The failing member is skipped with a warning instead of aborting the batch. + expect(ctx.sink.warn).toHaveBeenCalledWith(expect.stringContaining('hash boom')); + expect(ctx.sink.error).not.toHaveBeenCalled(); + // The valid send_transaction after it is still recorded. + expect(ctx.sink.info).toHaveBeenCalledWith(expect.stringContaining('0xhash')); + expect(fs.existsSync(path.join(transactionsPath, '0xhash.json'))).toBe(true); + const content = fs.readFileSync(ctx.events.filePath, 'utf8'); + expect(content).toMatch(/send_transaction 0xhash/); + }); + + it('normalizes a non-Error throw when isolating a failing batch member', () => { + const ctx = makeCtx(transactionsPath); + ctx.hashTransaction = jest.fn((tx: unknown) => { + if ((tx as { bad?: boolean }).bad) { + throw 'hash boom'; + } + return '0xhash'; + }); + const goodTx = { cell_deps: [], inputs: [], outputs: [] }; + handleProxyRequestBody( + JSON.stringify([ + { jsonrpc: '2.0', id: 1, method: 'send_transaction', params: [{ bad: true }] }, + { jsonrpc: '2.0', id: 2, method: 'send_transaction', params: [goodTx] }, + ]), + ctx, + ); + // The thrown string is logged as-is instead of rendering as "undefined". + expect(ctx.sink.warn).toHaveBeenCalledWith(expect.stringContaining('hash boom')); + expect(ctx.sink.warn).not.toHaveBeenCalledWith(expect.stringContaining('undefined')); + expect(ctx.sink.error).not.toHaveBeenCalled(); + // The valid send_transaction after it is still recorded. + expect(ctx.sink.info).toHaveBeenCalledWith(expect.stringContaining('0xhash')); + expect(fs.existsSync(path.join(transactionsPath, '0xhash.json'))).toBe(true); + }); + + it('tolerates a null throw when isolating a failing batch member', () => { + const ctx = makeCtx(transactionsPath); + ctx.hashTransaction = jest.fn((tx: unknown) => { + if ((tx as { bad?: boolean }).bad) { + throw null; + } + return '0xhash'; + }); + const goodTx = { cell_deps: [], inputs: [], outputs: [] }; + handleProxyRequestBody( + JSON.stringify([ + { jsonrpc: '2.0', id: 1, method: 'send_transaction', params: [{ bad: true }] }, + { jsonrpc: '2.0', id: 2, method: 'send_transaction', params: [goodTx] }, + ]), + ctx, + ); + // Reading .message off null would itself throw; the warning must not. + expect(ctx.sink.warn).toHaveBeenCalledWith(expect.stringContaining('null')); + expect(ctx.sink.error).not.toHaveBeenCalled(); + // The valid send_transaction after it is still recorded. + expect(ctx.sink.info).toHaveBeenCalledWith(expect.stringContaining('0xhash')); + expect(fs.existsSync(path.join(transactionsPath, '0xhash.json'))).toBe(true); + }); + + it('warns and skips the tx dump when send_transaction has no usable params', () => { + const ctx = makeCtx(transactionsPath); + handleProxyRequestBody(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'send_transaction' }), ctx); + expect(ctx.sink.warn).toHaveBeenCalledWith(expect.stringContaining('no params')); + // A missing-params request is not a parse failure. + expect(ctx.sink.error).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(transactionsPath, '0xhash.json'))).toBe(false); + }); + it('reports malformed JSON-RPC bodies at error level', () => { const ctx = makeCtx(transactionsPath); handleProxyRequestBody('not json', ctx); @@ -161,4 +294,57 @@ describe('createProxyEventLog', () => { expect(current).toMatch(/get_tip_header/); expect(current).not.toMatch(/get_blockchain_info/); }); + + it('keeps appending when a rollover fails and retries on the next event', () => { + const file = path.join(dir, 'logs', 'proxy.log'); + const log = createProxyEventLog(file, 60); + log.event('request get_tip_header'); + log.event('request get_blockchain_info'); + + // Block the rollover: every rename fails, so the active log stays put. + mockRenameSync.mockImplementation(() => { + throw new Error('rename blocked by test'); + }); + log.event('request get_tip_header'); + mockRenameSync.mockImplementation(realFs.renameSync); + + // The event is appended to the live file instead of being dropped. + const current = fs.readFileSync(file, 'utf8'); + expect(current).toMatch(/get_blockchain_info/); + expect(current).toMatch(/get_tip_header/); + + // Once the blockage is gone the next event rolls over normally. + log.event('request get_tip_header'); + const rolled = fs.readFileSync(`${file}.1`, 'utf8'); + expect(rolled).toMatch(/get_blockchain_info/); + expect(rolled).toMatch(/get_tip_header/); + }); + + it('preserves the previous archive when the active-log rename fails', () => { + const file = path.join(dir, 'logs', 'proxy.log'); + const log = createProxyEventLog(file, 60); + log.event('request get_blockchain_info'); + // Second event rolls the log: .1 now holds the first generation. + log.event('request get_tip_header'); + const archiveBefore = fs.readFileSync(`${file}.1`, 'utf8'); + expect(archiveBefore).toMatch(/get_blockchain_info/); + + // Fail only the active-log rename; the archive is moved aside first and + // must be put back when the swap cannot complete. + mockRenameSync.mockImplementation((oldPath: fs.PathLike, newPath: fs.PathLike) => { + if (String(oldPath) === file && String(newPath) === `${file}.1`) { + throw new Error('active-log rename blocked by test'); + } + return realFs.renameSync(oldPath, newPath); + }); + log.event('request get_tip_block_hash'); + + // The prior archive survived intact and no staging file was left behind. + expect(fs.readFileSync(`${file}.1`, 'utf8')).toBe(archiveBefore); + expect(fs.existsSync(`${file}.1.bak`)).toBe(false); + // The event that triggered the failed rollover was appended, not dropped. + const current = fs.readFileSync(file, 'utf8'); + expect(current).toMatch(/get_tip_header/); + expect(current).toMatch(/get_tip_block_hash/); + }); });