From ea81c876c967f8615934c2ea9c523186d8b8c78d Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Fri, 10 Jul 2026 05:45:52 +0000 Subject: [PATCH 1/3] feat(cmd): add status command with ckb-tui v0.1.3 Integrates ckb-tui to provide a terminal UI for monitoring CKB network status from a local node. Changes: - Add CKBTui class with automatic binary download/install for v0.1.3 - Add status command with RPC port connectivity check - Update settings schema with tools.rootFolder and ckbTui.version - Register status command in CLI with network validation Closes RET-161 --- .changeset/integrate-ckb-tui.md | 5 ++ README.md | 5 ++ src/cfg/setting.ts | 8 ++ src/cli.ts | 14 ++++ src/cmd/status.ts | 59 ++++++++++++++ src/tools/ckb-tui.ts | 137 ++++++++++++++++++++++++++++++++ 6 files changed, 228 insertions(+) create mode 100644 .changeset/integrate-ckb-tui.md create mode 100644 src/cmd/status.ts create mode 100644 src/tools/ckb-tui.ts diff --git a/.changeset/integrate-ckb-tui.md b/.changeset/integrate-ckb-tui.md new file mode 100644 index 00000000..899a71d7 --- /dev/null +++ b/.changeset/integrate-ckb-tui.md @@ -0,0 +1,5 @@ +--- +"@offckb/cli": minor +--- + +Add `status` command to launch ckb-tui for monitoring CKB network from your node diff --git a/README.md b/README.md index aca9c155..82680adc 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Commands: transfer-all [options] [toAddress] Transfer All CKB tokens to address, only devnet and testnet balance [options] [toAddress] Check account balance, only devnet and testnet debugger Port of the raw CKB Standalone Debugger + status [options] Show ckb-tui status interface config [item] [value] do a configuration action help [command] display help for command ``` @@ -169,6 +170,10 @@ offckb node --network ``` Using a proxy RPC server for Testnet/Mainnet is especially helpful for debugging transactions, since failed transactions are dumped automatically. +**Watch Network with TUI** + +Once you start the CKB Node, you can use `offckb status --network devnet/testnet/mainnet` to start a CKB-TUI interface to monitor the CKB network from your node. + ### 2. Create a New Contract Project {#create-project} Generate a ready-to-use smart-contract project in JS/TS using templates: diff --git a/src/cfg/setting.ts b/src/cfg/setting.ts index 5c41d5d9..966e790e 100644 --- a/src/cfg/setting.ts +++ b/src/cfg/setting.ts @@ -52,9 +52,13 @@ export interface Settings { transactionsPath: string; }; tools: { + rootFolder: string; ckbDebugger: { minVersion: string; }; + ckbTui: { + version: string; + }; }; } @@ -88,9 +92,13 @@ export const defaultSettings: Settings = { transactionsPath: path.resolve(dataPath, 'mainnet/transactions'), }, tools: { + rootFolder: path.resolve(dataPath, 'tools'), ckbDebugger: { minVersion: '0.200.0', }, + ckbTui: { + version: 'v0.1.3', + }, }, }; diff --git a/src/cli.ts b/src/cli.ts index 2fe5d984..0b17ffea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ import { genSystemScriptsJsonFile } from './scripts/gen'; import { CKBDebugger } from './tools/ckb-debugger'; import { logger } from './util/logger'; import { Network } from './type/base'; +import { status } from './cmd/status'; const version = require('../package.json').version; const description = require('../package.json').description; @@ -205,6 +206,19 @@ program return CKBDebugger.runWithArgs(process.argv.slice(2)); }); +program + .command('status') + .description('Show ckb-tui status interface') + .option('--network ', 'Specify the network to deploy to', 'devnet') + .action(async (option) => { + const validNetworks = Object.values(Network); + if (!validNetworks.includes(option.network)) { + logger.error(`Invalid network: ${option.network}. Must be one of: ${validNetworks.join(', ')}`); + process.exit(1); + } + return await status({ network: option.network }); + }); + program .command('config [item] [value]') .description('do a configuration action') diff --git a/src/cmd/status.ts b/src/cmd/status.ts new file mode 100644 index 00000000..502e89e2 --- /dev/null +++ b/src/cmd/status.ts @@ -0,0 +1,59 @@ +import { readSettings } from '../cfg/setting'; +import { CKBTui } from '../tools/ckb-tui'; +import { Network } from '../type/base'; +import { logger } from '../util/logger'; +import * as net from 'net'; + +export interface StatusOptions { + network?: Network; +} + +export async function status({ network }: StatusOptions) { + const settings = readSettings(); + const port = + network === Network.devnet + ? settings.devnet.rpcProxyPort + : network === Network.testnet + ? settings.testnet.rpcProxyPort + : settings.mainnet.rpcProxyPort; + const url = `http://127.0.0.1:${port}`; + const isListening = await isRPCPortListening(port); + if (!isListening) { + logger.error( + `RPC port ${port} is not listening. Please make sure the ${network} node is running and Proxy RPC is enabled.`, + ); + return; + } + CKBTui.run(['-r', url]); +} + +async function isRPCPortListening(port: number): Promise { + const client = new net.Socket(); + return new Promise((resolve) => { + let settled = false; + const TIMEOUT_MS = 5000; + const timeout = setTimeout(() => { + if (!settled) { + settled = true; + client.destroy(); + resolve(false); + } + }, TIMEOUT_MS); + client.once('error', () => { + if (!settled) { + settled = true; + clearTimeout(timeout); + resolve(false); + } + }); + client.once('connect', () => { + if (!settled) { + settled = true; + clearTimeout(timeout); + client.end(); + resolve(true); + } + }); + client.connect(port, '127.0.0.1'); + }); +} diff --git a/src/tools/ckb-tui.ts b/src/tools/ckb-tui.ts new file mode 100644 index 00000000..1133dee1 --- /dev/null +++ b/src/tools/ckb-tui.ts @@ -0,0 +1,137 @@ +import { spawnSync, execSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { readSettings } from '../cfg/setting'; +import { logger } from '../util/logger'; + +export class CKBTui { + private static binaryPath: string | null = null; + + private static getBinaryPath(): string { + if (!this.binaryPath) { + const settings = readSettings(); + const binDir = settings.tools.rootFolder; + const version = settings.tools.ckbTui.version; + this.binaryPath = path.join(binDir, 'ckb-tui'); + + if (!fs.existsSync(this.binaryPath)) { + this.downloadBinary(version); + } + } + return this.binaryPath; + } + + private static downloadBinary(version: string) { + // Validate version format to prevent URL manipulation + if (!/^v\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Invalid version format: ${version}. Expected format: vX.Y.Z`); + } + + const platform = process.platform; + const arch = process.arch; + let assetName: string; + + if (platform === 'darwin') { + if (arch !== 'arm64') { + throw new Error(`Unsupported architecture for macOS: ${arch}. Only Apple Silicon (arm64) is supported.`); + } + assetName = `ckb-tui-with-node-macos-aarch64.tar.gz`; + } else if (platform === 'linux') { + if (arch !== 'x64') { + throw new Error(`Unsupported architecture for Linux: ${arch}`); + } + assetName = `ckb-tui-with-node-linux-amd64.tar.gz`; + } else if (platform === 'win32') { + if (arch !== 'x64') { + throw new Error(`Unsupported architecture for Windows: ${arch}`); + } + assetName = `ckb-tui-with-node-windows-amd64.zip`; + } else { + throw new Error(`Unsupported platform: ${platform}`); + } + + const downloadUrl = `https://github.com/Officeyutong/ckb-tui/releases/download/${version}/${assetName}`; + const binDir = path.dirname(this.binaryPath!); + const archivePath = path.join(binDir, assetName); + const binaryName = platform === 'win32' ? 'ckb-tui.exe' : 'ckb-tui'; + + try { + logger.info(`Downloading ckb-tui from ${downloadUrl}...`); + execSync(`curl -L -o "${archivePath}" "${downloadUrl}"`, { stdio: 'inherit' }); + + logger.info('Extracting...'); + if (assetName.endsWith('.tar.gz')) { + execSync(`tar -xzf "${archivePath}" -C "${binDir}"`, { stdio: 'inherit' }); + } else if (assetName.endsWith('.zip')) { + execSync(`unzip "${archivePath}" -d "${binDir}"`, { stdio: 'inherit' }); + } + + const extractedBinary = this.findBinary(binDir, binaryName); + if (!extractedBinary) { + logger.error(`ckb-tui binary was not found after extraction. Expected: ${binaryName}`); + throw new Error('Failed to extract and locate ckb-tui binary.'); + } + + fs.renameSync(extractedBinary, this.binaryPath!); + + // Make executable on Unix + if (platform !== 'win32') { + execSync(`chmod +x "${this.binaryPath}"`); + } + + logger.info('ckb-tui installed successfully.'); + } catch (error) { + logger.error( + 'Failed to download/install ckb-tui:', + (error as Error).message, + '\nPlease check your network connectivity, verify that the specified version exists in the releases, and ensure you have sufficient file system permissions.', + ); + throw error; + } finally { + // Clean up archive even if error occurs + if (fs.existsSync(archivePath)) { + try { + fs.unlinkSync(archivePath); + } catch (cleanupError) { + logger.warn('Failed to clean up archive file:', (cleanupError as Error).message); + } + } + } + } + + private static findBinary(dir: string, binaryName: string): string | null { + // Check direct path first + const directPath = path.join(dir, binaryName); + if (fs.existsSync(directPath)) { + return directPath; + } + + // Search in subdirectories + const entries = fs.readdirSync(dir); + for (const entry of entries) { + const entryPath = path.join(dir, entry); + if (fs.statSync(entryPath).isDirectory()) { + const candidate = path.join(entryPath, binaryName); + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + + return null; + } + + static isInstalled(): boolean { + try { + const binPath = this.getBinaryPath(); + return fs.existsSync(binPath); + } catch { + return false; + } + } + + static run(args: string[] = []) { + const binaryPath = this.getBinaryPath(); + return spawnSync(binaryPath, args, { stdio: 'inherit' }); + } +} From 7619bae063332bd07c4eacd851b0129344e85df3 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Sat, 11 Jul 2026 09:15:19 +0000 Subject: [PATCH 2/3] fix: address review feedback (round 1) - Replace execSync shell interpolation with spawnSync array args (CRIT #1) - Add path validation for tools.rootFolder bounded to dataPath (CRIT #3) - Add SHA-256 checksum verification for downloaded binaries (CRIT #2) - Use -fsSL flags on curl, add timeouts, use fs.chmodSync, findFileInFolder - Fix deepMerge mutation by cloning defaultSettings before merge - Add settings validation for tools.rootFolder, ckbTui.version, proxy types - Fix status help text typo and use validateNetworkOpt() consistently - Replace nested ternary with lookup table, propagate exit code --- src/cfg/setting.ts | 53 +++++++- src/cli.ts | 9 +- src/cmd/status.ts | 29 ++-- src/tools/ckb-tui.ts | 309 +++++++++++++++++++++++++++++++++---------- 4 files changed, 310 insertions(+), 90 deletions(-) diff --git a/src/cfg/setting.ts b/src/cfg/setting.ts index 966e790e..113f17ab 100644 --- a/src/cfg/setting.ts +++ b/src/cfg/setting.ts @@ -106,7 +106,10 @@ export function readSettings(): Settings { try { if (fs.existsSync(configPath)) { const data = fs.readFileSync(configPath, 'utf8'); - return deepMerge(defaultSettings, JSON.parse(data)) as Settings; + const parsed = JSON.parse(data); + validateSettings(parsed); + // Deep-clone defaults before merging to prevent mutation of the shared default + return deepMerge(deepClone(defaultSettings), parsed) as Settings; } else { return defaultSettings; } @@ -137,10 +140,27 @@ export function getCKBBinaryPath(version: string) { return path.join(getCKBBinaryInstallPath(version), binaryName); } +function deepClone(obj: T): T { + if (obj === null || typeof obj !== 'object') { + return obj; + } + if (Array.isArray(obj)) { + return obj.map(deepClone) as unknown as T; + } + const clone: Record = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + clone[key] = deepClone((obj as Record)[key]); + } + } + return clone as T; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any function deepMerge(target: any, source: any): any { for (const key in source) { - if (source[key] && typeof source[key] === 'object') { - if (!target[key]) { + if (source[key] !== null && typeof source[key] === 'object' && !Array.isArray(source[key])) { + if (!target[key] || typeof target[key] !== 'object') { target[key] = {}; } deepMerge(target[key], source[key]); @@ -150,3 +170,30 @@ function deepMerge(target: any, source: any): any { } return target; } + +function validateSettings(raw: unknown): void { + if (!raw || typeof raw !== 'object') { + throw new Error('Settings must be a JSON object'); + } + + const obj = raw as Record; + + if (obj.tools && typeof obj.tools === 'object') { + const tools = obj.tools as Record; + if (tools.rootFolder !== undefined && typeof tools.rootFolder !== 'string') { + throw new Error('tools.rootFolder must be a string path'); + } + if (tools.ckbTui && typeof tools.ckbTui === 'object') { + const ckbTui = tools.ckbTui as Record; + if (ckbTui.version !== undefined && typeof ckbTui.version !== 'string') { + throw new Error('tools.ckbTui.version must be a string'); + } + } + } + + if (obj.proxy !== undefined && obj.proxy !== null) { + if (typeof obj.proxy !== 'object') { + throw new Error('proxy must be an object'); + } + } +} diff --git a/src/cli.ts b/src/cli.ts index 0b17ffea..f5f0b25a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { CKBDebugger } from './tools/ckb-debugger'; import { logger } from './util/logger'; import { Network } from './type/base'; import { status } from './cmd/status'; +import { validateNetworkOpt } from './util/validator'; const version = require('../package.json').version; const description = require('../package.json').description; @@ -209,13 +210,9 @@ program program .command('status') .description('Show ckb-tui status interface') - .option('--network ', 'Specify the network to deploy to', 'devnet') + .option('--network ', 'Specify the network whose node status to monitor', 'devnet') .action(async (option) => { - const validNetworks = Object.values(Network); - if (!validNetworks.includes(option.network)) { - logger.error(`Invalid network: ${option.network}. Must be one of: ${validNetworks.join(', ')}`); - process.exit(1); - } + validateNetworkOpt(option.network); return await status({ network: option.network }); }); diff --git a/src/cmd/status.ts b/src/cmd/status.ts index 502e89e2..740a5a34 100644 --- a/src/cmd/status.ts +++ b/src/cmd/status.ts @@ -5,17 +5,21 @@ import { logger } from '../util/logger'; import * as net from 'net'; export interface StatusOptions { - network?: Network; + network: Network; } +type NetworkSettingsKey = 'devnet' | 'testnet' | 'mainnet'; + +const NETWORK_SETTINGS_KEY: Record = { + [Network.devnet]: 'devnet', + [Network.testnet]: 'testnet', + [Network.mainnet]: 'mainnet', +}; + export async function status({ network }: StatusOptions) { const settings = readSettings(); - const port = - network === Network.devnet - ? settings.devnet.rpcProxyPort - : network === Network.testnet - ? settings.testnet.rpcProxyPort - : settings.mainnet.rpcProxyPort; + const networkKey = NETWORK_SETTINGS_KEY[network]; + const port = settings[networkKey].rpcProxyPort; const url = `http://127.0.0.1:${port}`; const isListening = await isRPCPortListening(port); if (!isListening) { @@ -24,14 +28,21 @@ export async function status({ network }: StatusOptions) { ); return; } - CKBTui.run(['-r', url]); + const result = CKBTui.run(['-r', url]); + // Propagate ckb-tui exit code so scripts can detect TUI failure + if (result.status !== 0) { + process.exitCode = result.status ?? 1; + } } async function isRPCPortListening(port: number): Promise { + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return false; + } const client = new net.Socket(); return new Promise((resolve) => { let settled = false; - const TIMEOUT_MS = 5000; + const TIMEOUT_MS = 2000; const timeout = setTimeout(() => { if (!settled) { settled = true; diff --git a/src/tools/ckb-tui.ts b/src/tools/ckb-tui.ts index 1133dee1..8b3ba785 100644 --- a/src/tools/ckb-tui.ts +++ b/src/tools/ckb-tui.ts @@ -1,137 +1,302 @@ -import { spawnSync, execSync } from 'child_process'; +import { spawnSync } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; -import { readSettings } from '../cfg/setting'; +import * as os from 'os'; +import * as crypto from 'crypto'; +import AdmZip from 'adm-zip'; +import { readSettings, dataPath } from '../cfg/setting'; import { logger } from '../util/logger'; +import { findFileInFolder } from '../util/fs'; + +const DOWNLOAD_TIMEOUT_MS = 120_000; +const EXTRACT_TIMEOUT_MS = 60_000; + +// Strict semver regex: v.. (no leading zeros on digits) +const STRICT_VERSION_REGEX = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; export class CKBTui { private static binaryPath: string | null = null; - private static getBinaryPath(): string { + /** + * Pure lookup — returns the expected binary path without triggering any + * download or installation side effects. May return null if not yet computed. + */ + static getBinaryPath(): string | null { if (!this.binaryPath) { const settings = readSettings(); - const binDir = settings.tools.rootFolder; - const version = settings.tools.ckbTui.version; - this.binaryPath = path.join(binDir, 'ckb-tui'); - - if (!fs.existsSync(this.binaryPath)) { - this.downloadBinary(version); - } + const binDir = this.resolveAndValidateBinDir(settings.tools.rootFolder); + const binaryName = process.platform === 'win32' ? 'ckb-tui.exe' : 'ckb-tui'; + this.binaryPath = path.join(binDir, binaryName); } return this.binaryPath; } - private static downloadBinary(version: string) { - // Validate version format to prevent URL manipulation - if (!/^v\d+\.\d+\.\d+$/.test(version)) { - throw new Error(`Invalid version format: ${version}. Expected format: vX.Y.Z`); + /** + * Returns the binary path, downloading and installing if the binary + * does not already exist. + */ + static ensureInstalled(): string { + const binaryPath = this.getBinaryPath(); + if (binaryPath && fs.existsSync(binaryPath)) { + return binaryPath; } + // Reset and re-install + this.binaryPath = null; + this.installSync(); + return this.binaryPath!; + } + + static isInstalled(): boolean { + try { + const binPath = this.getBinaryPath(); + return binPath !== null && fs.existsSync(binPath); + } catch { + return false; + } + } + + static run(args: string[] = []) { + const binaryPath = this.ensureInstalled(); + return spawnSync(binaryPath, args, { stdio: 'inherit' }); + } + + // --- private helpers --- + + /** + * Resolve and validate that the configured rootFolder is under the + * OffCKB data directory. Rejects paths that resolve outside. + */ + private static resolveAndValidateBinDir(configuredRoot: string): string { + const resolved = path.resolve(configuredRoot); + const resolvedData = path.resolve(dataPath); + + // Require the resolved path to be within the resolved data directory + 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; + } + + private static validateVersion(version: string): void { + if (!STRICT_VERSION_REGEX.test(version)) { + throw new Error(`Invalid version format: "${version}". Expected format: vX.Y.Z (e.g., v0.1.3)`); + } + } + + private static getAssetName(): string { const platform = process.platform; const arch = process.arch; - let assetName: string; if (platform === 'darwin') { if (arch !== 'arm64') { throw new Error(`Unsupported architecture for macOS: ${arch}. Only Apple Silicon (arm64) is supported.`); } - assetName = `ckb-tui-with-node-macos-aarch64.tar.gz`; + return 'ckb-tui-with-node-macos-aarch64.tar.gz'; } else if (platform === 'linux') { if (arch !== 'x64') { - throw new Error(`Unsupported architecture for Linux: ${arch}`); + throw new Error(`Unsupported architecture for Linux: ${arch}. Only x86_64 is supported.`); } - assetName = `ckb-tui-with-node-linux-amd64.tar.gz`; + return 'ckb-tui-with-node-linux-amd64.tar.gz'; } else if (platform === 'win32') { if (arch !== 'x64') { - throw new Error(`Unsupported architecture for Windows: ${arch}`); + throw new Error(`Unsupported architecture for Windows: ${arch}. Only x86_64 is supported.`); } - assetName = `ckb-tui-with-node-windows-amd64.zip`; - } else { - throw new Error(`Unsupported platform: ${platform}`); + return 'ckb-tui-with-node-windows-amd64.zip'; } + throw new Error(`Unsupported platform: ${platform}`); + } + + /** + * Synchronously download and install the ckb-tui binary. + * Uses spawnSync with array arguments (no shell interpolation) for security. + */ + private static installSync(): void { + const settings = readSettings(); + const version = settings.tools.ckbTui.version; + + this.validateVersion(version); + + const assetName = this.getAssetName(); + const binDir = this.resolveAndValidateBinDir(settings.tools.rootFolder); + 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 binDir = path.dirname(this.binaryPath!); - const archivePath = path.join(binDir, assetName); - const binaryName = platform === 'win32' ? 'ckb-tui.exe' : 'ckb-tui'; + + // Ensure the target directory exists + fs.mkdirSync(binDir, { recursive: true }); + + // Use a temp directory for atomic download & extraction + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-ckb-tui-')); + const archivePath = path.join(tempDir, assetName); try { + // 1. Download logger.info(`Downloading ckb-tui from ${downloadUrl}...`); - execSync(`curl -L -o "${archivePath}" "${downloadUrl}"`, { stdio: 'inherit' }); + const curlResult = spawnSync('curl', ['-fsSL', '--max-time', '300', '-o', archivePath, downloadUrl], { + stdio: 'inherit', + timeout: DOWNLOAD_TIMEOUT_MS, + }); - logger.info('Extracting...'); - if (assetName.endsWith('.tar.gz')) { - execSync(`tar -xzf "${archivePath}" -C "${binDir}"`, { stdio: 'inherit' }); - } else if (assetName.endsWith('.zip')) { - execSync(`unzip "${archivePath}" -d "${binDir}"`, { stdio: 'inherit' }); + if (curlResult.error) { + throw new Error(`Failed to download ckb-tui: ${curlResult.error.message}`); + } + if (curlResult.status !== 0) { + throw new Error(`curl exited with code ${curlResult.status}`); } - const extractedBinary = this.findBinary(binDir, binaryName); + // 2. Verify checksum (best-effort: warns if checksum file is unavailable) + this.verifyChecksum(version, assetName, archivePath); + + // 3. Extract to temp directory + logger.info('Extracting...'); + const extractDir = path.join(tempDir, 'extracted'); + fs.mkdirSync(extractDir, { recursive: true }); + + this.extractArchive(archivePath, extractDir); + + // 4. Locate the extracted binary + const extractedBinary = findFileInFolder(extractDir, binaryName); if (!extractedBinary) { - logger.error(`ckb-tui binary was not found after extraction. Expected: ${binaryName}`); - throw new Error('Failed to extract and locate ckb-tui binary.'); + throw new Error(`ckb-tui binary ("${binaryName}") was not found after extraction.`); } - fs.renameSync(extractedBinary, this.binaryPath!); + // 5. Atomically move to the final location + fs.renameSync(extractedBinary, this.binaryPath); - // Make executable on Unix - if (platform !== 'win32') { - execSync(`chmod +x "${this.binaryPath}"`); + // 6. Make executable on Unix + if (process.platform !== 'win32') { + fs.chmodSync(this.binaryPath, 0o755); } logger.info('ckb-tui installed successfully.'); } catch (error) { + // Reset cached path on failure so a subsequent call retries + this.binaryPath = null; + + const message = error instanceof Error ? error.message : String(error); logger.error( 'Failed to download/install ckb-tui:', - (error as Error).message, - '\nPlease check your network connectivity, verify that the specified version exists in the releases, and ensure you have sufficient file system permissions.', + message, + '\nPlease check your network connectivity, verify that the specified version exists in the releases, ' + + 'and ensure you have sufficient file system permissions.', ); throw error; } finally { - // Clean up archive even if error occurs - if (fs.existsSync(archivePath)) { - try { - fs.unlinkSync(archivePath); - } catch (cleanupError) { - logger.warn('Failed to clean up archive file:', (cleanupError as Error).message); - } + // Clean up the temp directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup — temp dir will be cleaned by the OS eventually } } } - private static findBinary(dir: string, binaryName: string): string | null { - // Check direct path first - const directPath = path.join(dir, binaryName); - if (fs.existsSync(directPath)) { - return directPath; + /** + * Best-effort SHA-256 checksum verification. + * Downloads the checksum file published alongside the release asset and + * verifies the downloaded archive. Logs a warning (but does not fail) if + * the checksum file is unavailable — this maintains compatibility while + * the upstream project adopts checksum publishing. + */ + private static verifyChecksum(version: string, assetName: string, archivePath: string): void { + const checksumUrl = `https://github.com/Officeyutong/ckb-tui/releases/download/${version}/checksums-sha256.txt`; + + const checksumPath = path.join(path.dirname(archivePath), 'checksums-sha256.txt'); + + const fetchResult = spawnSync('curl', ['-fsSL', '--max-time', '30', '-o', checksumPath, checksumUrl], { + stdio: 'pipe', + timeout: 30_000, + }); + + if (fetchResult.status !== 0) { + logger.warn( + `SHA-256 checksum file not available for version ${version}. ` + + 'Skipping integrity verification. Consider asking the upstream maintainer to publish checksum files.', + ); + return; } - // Search in subdirectories - const entries = fs.readdirSync(dir); - for (const entry of entries) { - const entryPath = path.join(dir, entry); - if (fs.statSync(entryPath).isDirectory()) { - const candidate = path.join(entryPath, binaryName); - if (fs.existsSync(candidate)) { - return candidate; - } + try { + const checksumContent = fs.readFileSync(checksumPath, 'utf8'); + const expectedHash = this.parseChecksumFile(checksumContent, assetName); + + if (!expectedHash) { + logger.warn(`Checksum entry for "${assetName}" not found in checksums file. Skipping verification.`); + return; } - } - return null; + const actualHash = crypto.createHash('sha256').update(fs.readFileSync(archivePath)).digest('hex'); + + if (actualHash !== expectedHash) { + throw new Error( + `SHA-256 checksum mismatch for ${assetName}.\n` + + `Expected: ${expectedHash}\nActual: ${actualHash}\n` + + 'The downloaded file may be corrupted or tampered with.', + ); + } + + logger.info('SHA-256 checksum verified successfully.'); + } finally { + try { + fs.unlinkSync(checksumPath); + } catch { + // Best effort + } + } } - static isInstalled(): boolean { - try { - const binPath = this.getBinaryPath(); - return fs.existsSync(binPath); - } catch { - return false; + /** + * Parse a standard SHA-256 checksum file (format: " " per line) + * and return the hex hash for the given asset name, or null if not found. + */ + private static parseChecksumFile(content: string, assetName: string): string | null { + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const match = trimmed.match(/^([0-9a-fA-F]{64})\s+[*]?(.+)$/); + if (match && match[2] === assetName) { + return match[1].toLowerCase(); + } } + return null; } - static run(args: string[] = []) { - const binaryPath = this.getBinaryPath(); - return spawnSync(binaryPath, args, { stdio: 'inherit' }); + /** + * Extract a downloaded archive to the given directory. + * Uses AdmZip for .zip files (Node-native, no shell) and spawnSync with array + * arguments for .tar.gz (no shell interpolation). + */ + private static extractArchive(archivePath: string, extractDir: string): void { + if (archivePath.endsWith('.tar.gz')) { + const result = spawnSync('tar', ['-xzf', archivePath, '-C', extractDir], { + stdio: 'inherit', + timeout: EXTRACT_TIMEOUT_MS, + }); + if (result.error) { + throw new Error(`tar extraction failed: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(`tar exited with code ${result.status}`); + } + } else if (archivePath.endsWith('.zip')) { + try { + const zip = new AdmZip(archivePath); + zip.extractAllTo(extractDir, true); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`ZIP extraction failed: ${message}`); + } + } else { + throw new Error(`Unsupported archive format: ${path.extname(archivePath)}`); + } } } From 59ef32f6f11b2d1612ecdc472367be371975ad27 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Sat, 11 Jul 2026 09:35:08 +0000 Subject: [PATCH 3/3] fix: add non-TTY guard to status command to prevent CI/pipe hangs (round 2) --- src/cmd/status.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/cmd/status.ts b/src/cmd/status.ts index 740a5a34..881effea 100644 --- a/src/cmd/status.ts +++ b/src/cmd/status.ts @@ -17,6 +17,16 @@ const NETWORK_SETTINGS_KEY: Record = { }; export async function status({ network }: StatusOptions) { + // ckb-tui is an interactive terminal UI. Running it without a TTY + // (pipe, redirect, CI) would hang or produce garbage output. + if (!process.stdout.isTTY || !process.stdin.isTTY) { + logger.error( + 'The status command requires an interactive terminal (TTY). ' + + 'It cannot be used in pipes, redirects, or non-interactive environments like CI.', + ); + process.exit(1); + } + const settings = readSettings(); const networkKey = NETWORK_SETTINGS_KEY[network]; const port = settings[networkKey].rpcProxyPort;