diff --git a/.changeset/integrate-ckb-tui.md b/.changeset/integrate-ckb-tui.md new file mode 100644 index 0000000..899a71d --- /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 aca9c15..82680ad 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 5c41d5d..113f17a 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', + }, }, }; @@ -98,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; } @@ -129,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]); @@ -142,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 2fe5d98..f5f0b25 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,8 @@ 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'; +import { validateNetworkOpt } from './util/validator'; const version = require('../package.json').version; const description = require('../package.json').description; @@ -205,6 +207,15 @@ program return CKBDebugger.runWithArgs(process.argv.slice(2)); }); +program + .command('status') + .description('Show ckb-tui status interface') + .option('--network ', 'Specify the network whose node status to monitor', 'devnet') + .action(async (option) => { + validateNetworkOpt(option.network); + 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 0000000..881effe --- /dev/null +++ b/src/cmd/status.ts @@ -0,0 +1,80 @@ +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; +} + +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) { + // 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; + 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; + } + 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 = 2000; + 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 0000000..8b3ba78 --- /dev/null +++ b/src/tools/ckb-tui.ts @@ -0,0 +1,302 @@ +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 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; + + /** + * 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 = this.resolveAndValidateBinDir(settings.tools.rootFolder); + const binaryName = process.platform === 'win32' ? 'ckb-tui.exe' : 'ckb-tui'; + this.binaryPath = path.join(binDir, binaryName); + } + return this.binaryPath; + } + + /** + * 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; + + if (platform === 'darwin') { + if (arch !== 'arm64') { + throw new Error(`Unsupported architecture for macOS: ${arch}. Only Apple Silicon (arm64) is supported.`); + } + return 'ckb-tui-with-node-macos-aarch64.tar.gz'; + } else if (platform === 'linux') { + if (arch !== 'x64') { + throw new Error(`Unsupported architecture for Linux: ${arch}. Only x86_64 is supported.`); + } + return 'ckb-tui-with-node-linux-amd64.tar.gz'; + } else if (platform === 'win32') { + if (arch !== 'x64') { + throw new Error(`Unsupported architecture for Windows: ${arch}. Only x86_64 is supported.`); + } + 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}`; + + // 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}...`); + const curlResult = spawnSync('curl', ['-fsSL', '--max-time', '300', '-o', archivePath, downloadUrl], { + stdio: 'inherit', + timeout: DOWNLOAD_TIMEOUT_MS, + }); + + 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}`); + } + + // 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) { + throw new Error(`ckb-tui binary ("${binaryName}") was not found after extraction.`); + } + + // 5. Atomically move to the final location + fs.renameSync(extractedBinary, 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:', + 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 the temp directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup — temp dir will be cleaned by the OS eventually + } + } + } + + /** + * 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; + } + + 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; + } + + 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 + } + } + } + + /** + * 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; + } + + /** + * 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)}`); + } + } +}