From ec4333046b53eb6dee93ab92ee86bd9b41dfa7c3 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Fri, 14 Aug 2026 10:22:05 +0800 Subject: [PATCH 1/3] chore: version packages for 0.4.12 release (#497) --- .changeset/ckb-tui-org-repo-url.md | 5 ----- .changeset/debug-tx-file-quotes.md | 5 ----- .changeset/install-ckb-debugger.md | 5 ----- .changeset/review-fixes-v0411.md | 5 ----- CHANGELOG.md | 9 +++++++++ package.json | 2 +- 6 files changed, 10 insertions(+), 21 deletions(-) delete mode 100644 .changeset/ckb-tui-org-repo-url.md delete mode 100644 .changeset/debug-tx-file-quotes.md delete mode 100644 .changeset/install-ckb-debugger.md delete mode 100644 .changeset/review-fixes-v0411.md diff --git a/.changeset/ckb-tui-org-repo-url.md b/.changeset/ckb-tui-org-repo-url.md deleted file mode 100644 index b6b9a1a..0000000 --- a/.changeset/ckb-tui-org-repo-url.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@offckb/cli': patch ---- - -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. diff --git a/.changeset/debug-tx-file-quotes.md b/.changeset/debug-tx-file-quotes.md deleted file mode 100644 index c913d86..0000000 --- a/.changeset/debug-tx-file-quotes.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@offckb/cli': patch ---- - -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). diff --git a/.changeset/install-ckb-debugger.md b/.changeset/install-ckb-debugger.md deleted file mode 100644 index 92435e5..0000000 --- a/.changeset/install-ckb-debugger.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@offckb/cli': minor ---- - -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. diff --git a/.changeset/review-fixes-v0411.md b/.changeset/review-fixes-v0411.md deleted file mode 100644 index af9ae68..0000000 --- a/.changeset/review-fixes-v0411.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@offckb/cli': patch ---- - -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 07361a5..2843c1b 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/package.json b/package.json index 040a729..e324a20 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", From e0dfd20dac6df02ab498c29b224e02cf645c0398 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Fri, 14 Aug 2026 12:33:48 +0800 Subject: [PATCH 2/3] fix: address CodeRabbit review findings on the v0.4.12 merge (#500) * fix: address CodeRabbit review findings on the v0.4.12 merge (#499) - Makefile: add pw-lock and secp256k1_multisig_v2 to .PHONY so make all cannot skip their recipes when same-named files or directories exist - README: list the install command in the Usage block - ckb-debugger-install: tighten legacy-shim detection so only the exact v0.4.x fallback body (shebang + exec offckb debugger) is upgraded; a foreign file that merely mentions offckb debugger is left untouched - proxy-events: isolate per-member failures in the JSON-RPC batch loop so one malformed send_transaction cannot prevent later requests from being recorded; add coverage for a failing member followed by a valid one * fix: address CodeRabbit review on #500 - ckb-debugger-install: classify a legacy v0.4.x fallback shim only when the whole file matches its exact body (CRLF-normalized, one trailing newline ignored); a legacy body with extra user content is left alone - proxy-events: normalize non-Error thrown values (Error.message vs String(error)) before logging so a null/string throw cannot abort the batch loop - tests: near-match legacy shims (Unix/Windows) stay untouched, Windows exact legacy shim upgrades, non-Error and null throws are isolated --- Makefile | 2 +- README.md | 1 + src/tools/ckb-debugger-install.ts | 15 ++++-- src/tools/proxy-events.ts | 10 +++- tests/ckb-debugger-install.test.ts | 87 ++++++++++++++++++++++++++++++ tests/proxy-events.test.ts | 76 ++++++++++++++++++++++++++ 6 files changed, 185 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 91da672..78f34ef 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock +.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 diff --git a/README.md b/README.md index 460ac86..5d99c06 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 diff --git a/src/tools/ckb-debugger-install.ts b/src/tools/ckb-debugger-install.ts index 7da4260..dab6e21 100644 --- a/src/tools/ckb-debugger-install.ts +++ b/src/tools/ckb-debugger-install.ts @@ -351,9 +351,14 @@ export class CKBDebuggerInstaller { const binName = isWindows ? 'ckb-debugger.cmd' : 'ckb-debugger'; const targetPath = path.join(path.dirname(offckbPath), binName); - // The marker distinguishes our own shim (and legacy v0.4.x fallback shims - // that `exec offckb debugger`) from a foreign file — e.g. a real binary a - // user installed via cargo — which must never be silently clobbered. + // 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` @@ -362,7 +367,9 @@ export class CKBDebuggerInstaller { try { if (fs.existsSync(targetPath)) { const existing = fs.readFileSync(targetPath, 'utf8'); - if (!existing.includes(marker) && !existing.includes('offckb debugger')) { + 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.', diff --git a/src/tools/proxy-events.ts b/src/tools/proxy-events.ts index 520fe75..a3790e0 100644 --- a/src/tools/proxy-events.ts +++ b/src/tools/proxy-events.ts @@ -117,7 +117,15 @@ export function handleProxyRequestBody(reqData: string, ctx: ProxyEventContext): ctx.sink.warn('skipping malformed JSON-RPC batch member'); continue; } - handleOneRequest(jsonRpcContent, ctx); + 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}`); + } } } catch (err) { ctx.sink.error('Error parsing JSON-RPC req content:', (err as Error).message); diff --git a/tests/ckb-debugger-install.test.ts b/tests/ckb-debugger-install.test.ts index 42c1e82..6994103 100644 --- a/tests/ckb-debugger-install.test.ts +++ b/tests/ckb-debugger-install.test.ts @@ -253,6 +253,27 @@ describe('CKBDebuggerInstaller', () => { 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); @@ -272,6 +293,72 @@ describe('CKBDebuggerInstaller', () => { 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/); diff --git a/tests/proxy-events.test.ts b/tests/proxy-events.test.ts index cd46bb9..9baa4ce 100644 --- a/tests/proxy-events.test.ts +++ b/tests/proxy-events.test.ts @@ -98,6 +98,82 @@ describe('handleProxyRequestBody', () => { 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); From 0923956c8dd5a8297c190e35d63c9c5654e19830 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Mon, 17 Aug 2026 12:13:49 +0800 Subject: [PATCH 3/3] fix: clear error for balance without address; print commander errors once (fixes #498) (#501) - Make the balance address argument required so `offckb balance` prints `error: missing required argument 'toAddress'` instead of leaking the SDK's `Unknown address format undefined`. - Only emit commander errors through logger.failure in JSON mode; in human mode commander's writeErr already printed the line once, so re-emitting duplicated every commander error on stderr. --- .changeset/balance-commander-errors.md | 8 ++ src/cli.ts | 10 +- tests/cli-errors.test.ts | 148 +++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 .changeset/balance-commander-errors.md create mode 100644 tests/cli-errors.test.ts diff --git a/.changeset/balance-commander-errors.md b/.changeset/balance-commander-errors.md new file mode 100644 index 0000000..b410dd8 --- /dev/null +++ b/.changeset/balance-commander-errors.md @@ -0,0 +1,8 @@ +--- +'@offckb/cli': patch +--- + +Fix two CLI UX bugs reported in #498: + +- `offckb balance` without an address no longer leaks the SDK's `Unknown address format undefined`. The address argument is now required, so commander prints a clear `error: missing required argument 'toAddress'`. +- Commander parameter/option errors (unknown option, invalid option value, missing argument) are printed exactly once on stderr instead of twice. diff --git a/src/cli.ts b/src/cli.ts index d1eb771..c02ba16 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -234,7 +234,7 @@ mainnetForkOverrideOption( }); program - .command('balance [toAddress]') + .command('balance ') .description('Check account balance (CKB + detected SUDT/xUDT), only devnet and testnet') .option('--network ', 'Specify the network to check', 'devnet') .addOption(new Option('--udt-kind ', 'Filter by UDT kind').choices(['sudt', 'xudt'])) @@ -387,7 +387,13 @@ export async function runCli(argv: string[] = process.argv): Promise { if (error instanceof CommanderError && error.exitCode === 0) return; const message = error instanceof Error ? error.message : String(error); const code = error instanceof CommanderError ? error.code : 'COMMAND_FAILED'; - logger.failure(code, message); + // Commander errors were already written to stderr once by writeErr in + // configureCommanderErrors (non-JSON mode); re-emitting would duplicate + // the line. In JSON mode writeErr is suppressed, so logger.failure emits + // the single structured record instead. + if (!(error instanceof CommanderError) || logger.isJsonMode()) { + logger.failure(code, message); + } process.exitCode = error instanceof CommanderError ? error.exitCode : 1; } } diff --git a/tests/cli-errors.test.ts b/tests/cli-errors.test.ts new file mode 100644 index 0000000..d457a1a --- /dev/null +++ b/tests/cli-errors.test.ts @@ -0,0 +1,148 @@ +const mockBalanceOf = jest.fn(); +const mockLogsCommand = jest.fn(); +const jsonMode = { value: false }; + +jest.mock('../src/cmd/node', () => ({ startNode: jest.fn(), stopNode: jest.fn() })); +jest.mock('../src/cmd/accounts', () => ({ accounts: jest.fn() })); +jest.mock('../src/cmd/clean', () => ({ clean: jest.fn() })); +jest.mock('../src/cmd/deposit', () => ({ deposit: jest.fn() })); +jest.mock('../src/cmd/deploy', () => ({ deploy: jest.fn() })); +jest.mock('../src/cmd/transfer', () => ({ transfer: jest.fn() })); +jest.mock('../src/cmd/balance', () => ({ balanceOf: (...args: unknown[]) => mockBalanceOf(...args) })); +jest.mock('../src/cmd/udt', () => ({ udtIssue: jest.fn(), udtDestroy: jest.fn() })); +jest.mock('../src/cmd/create', () => ({ createScriptProject: jest.fn() })); +jest.mock('../src/cmd/config', () => ({ Config: jest.fn() })); +jest.mock('../src/cmd/devnet-config', () => ({ devnetConfig: jest.fn() })); +jest.mock('../src/cmd/devnet-fork', () => ({ devnetFork: jest.fn() })); +jest.mock('../src/cmd/devnet-info', () => ({ devnetInfo: jest.fn() })); +jest.mock('../src/cmd/debug', () => ({ + debugSingleScript: jest.fn(), + debugTransaction: jest.fn(), + parseSingleScriptOption: jest.fn(), +})); +jest.mock('../src/cmd/system-scripts', () => ({ printSystemScripts: jest.fn() })); +jest.mock('../src/cmd/transfer-all', () => ({ transferAll: jest.fn() })); +jest.mock('../src/cmd/logs', () => ({ logsCommand: (...args: unknown[]) => mockLogsCommand(...args) })); +jest.mock('../src/cmd/status', () => ({ status: jest.fn() })); +jest.mock('../src/scripts/gen', () => ({ genSystemScriptsJsonFile: jest.fn() })); +jest.mock('../src/tools/ckb-debugger', () => ({ CKBDebugger: { runWithArgs: jest.fn() } })); + +// The logger mock mirrors the real UnifiedLogger: in JSON mode failure() +// writes one structured record to stderr, otherwise it writes the plain +// message to stderr like the winston console transport does. That way the +// tests can assert each commander error reaches stderr exactly once. +const mockFailure = jest.fn((code: string, message: string) => { + process.stderr.write(jsonMode.value ? `${JSON.stringify({ ok: false, code, message })}\n` : `${message}\n`); +}); +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + result: jest.fn(), + failure: (...args: unknown[]) => mockFailure(args[0] as string, args[1] as string), + setJsonMode: (enabled: boolean) => { + jsonMode.value = enabled; + }, + isJsonMode: () => jsonMode.value, + hasResult: () => false, + }, +})); + +function loadCli() { + jest.resetModules(); + const cli = require('../src/cli') as typeof import('../src/cli'); + return { runCli: cli.runCli }; +} + +function captureStderr() { + const writes: string[] = []; + const spy = jest.spyOn(process.stderr, 'write'); + spy.mockImplementation(((chunk: unknown) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + return { + writes, + text: () => writes.join(''), + count: (needle: string) => writes.filter((w) => w.includes(needle)).length, + restore: () => spy.mockRestore(), + }; +} + +describe('CLI error output', () => { + beforeEach(() => { + jest.clearAllMocks(); + jsonMode.value = false; + process.exitCode = undefined; + }); + + afterEach(() => { + process.exitCode = undefined; + }); + + it('balance without an address prints a clear missing-argument error once', async () => { + const { runCli } = loadCli(); + const stderr = captureStderr(); + try { + await runCli(['node', 'offckb', 'balance']); + } finally { + stderr.restore(); + } + + expect(stderr.count("missing required argument 'toAddress'")).toBe(1); + expect(stderr.text()).not.toContain('Unknown address format'); + expect(mockBalanceOf).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('prints an unknown-option error exactly once on stderr', async () => { + const { runCli } = loadCli(); + const stderr = captureStderr(); + try { + await runCli(['node', 'offckb', 'balance', 'ckt1qaddress', '--unknown-flag']); + } finally { + stderr.restore(); + } + + expect(stderr.count("unknown option '--unknown-flag'")).toBe(1); + expect(process.exitCode).toBe(1); + }); + + it('prints an invalid option value error exactly once on stderr', async () => { + const { runCli } = loadCli(); + const stderr = captureStderr(); + try { + await runCli(['node', 'offckb', 'logs', '--tail', 'abc']); + } finally { + stderr.restore(); + } + + expect(stderr.count('--tail must be a positive integer')).toBe(1); + expect(process.exitCode).toBe(1); + }); + + it('emits a single structured record for commander errors in JSON mode', async () => { + const { runCli } = loadCli(); + const stderr = captureStderr(); + try { + await runCli(['node', 'offckb', '--json', 'balance']); + } finally { + stderr.restore(); + } + + expect(stderr.count('commander.missingArgument')).toBe(1); + expect(stderr.text()).toContain('"ok":false'); + expect(stderr.text()).not.toContain("error: missing required argument 'toAddress'\nerror:"); + expect(process.exitCode).toBe(1); + }); + + it('still invokes balanceOf when an address is provided', async () => { + const { runCli } = loadCli(); + await runCli(['node', 'offckb', 'balance', 'ckt1qaddress']); + + expect(mockBalanceOf).toHaveBeenCalledWith('ckt1qaddress', expect.anything()); + }); +});