diff --git a/.gitignore b/.gitignore index 1b993bd..d3e662f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ .vscode templates/temp-clone-folder build/ +package-lock.json diff --git a/README.md b/README.md index 2574a67..4fb9061 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,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 ``` @@ -130,8 +131,13 @@ You can also start a proxy RPC server for public networks: ```sh 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 7d99e9d..556aaca 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.0', + }, }, }; diff --git a/src/cli.ts b/src/cli.ts index 011e934..4e5c4a3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,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; @@ -154,6 +155,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 0000000..502e89e --- /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 0000000..2f8826a --- /dev/null +++ b/src/tools/ckb-tui.ts @@ -0,0 +1,154 @@ +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') { + assetName = `ckb-tui-with-node-macos-aarch64.tar.gz`; + } else if (arch === 'x64') { + assetName = `ckb-tui-with-node-macos-amd64.tar.gz`; + } else { + throw new Error(`Unsupported architecture for macOS: ${arch}`); + } + } else if (platform === 'linux') { + if (arch === 'x64') { + assetName = `ckb-tui-with-node-linux-amd64.tar.gz`; + } else { + throw new Error(`Unsupported architecture for Linux: ${arch}`); + } + } else if (platform === 'win32') { + if (arch === 'x64') { + assetName = `ckb-tui-with-node-windows-amd64.zip`; + } else { + throw new Error(`Unsupported architecture for Windows: ${arch}`); + } + } 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); + + 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' }); + } + + // Set the correct binary name based on platform and architecture + let extractedBinary: string; + if (platform === 'win32') { + extractedBinary = 'ckb-tui.exe'; + } else if (platform === 'darwin') { + if (arch === 'arm64') { + extractedBinary = 'ckb-tui-macos-aarch64'; + } else { + extractedBinary = 'ckb-tui-macos-amd64'; + } + } else if (platform === 'linux') { + if (arch === 'x64') { + extractedBinary = 'ckb-tui-linux-amd64'; + } else { + throw new Error(`Unsupported architecture for Linux: ${arch}`); + } + } else { + throw new Error(`Unsupported platform: ${platform}`); + } + const extractedPath = path.join(binDir, extractedBinary); + if (fs.existsSync(extractedPath)) { + fs.renameSync(extractedPath, this.binaryPath!); + } else { + // If in a subfolder, find it + const files = fs.readdirSync(binDir); + for (const file of files) { + const filePath = path.join(binDir, file); + if (fs.statSync(filePath).isDirectory()) { + const candidate = path.join(filePath, extractedBinary); + if (fs.existsSync(candidate)) { + fs.renameSync(candidate, this.binaryPath!); + break; + } + } + } + } + + // Check that the binary was successfully extracted and moved + if (!fs.existsSync(this.binaryPath!)) { + logger.error(`ckb-tui binary was not found after extraction. Expected at: ${this.binaryPath}`); + throw new Error('Failed to extract and locate ckb-tui binary.'); + } + + // 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); + } + } + } + } + + static isInstalled(): boolean { + try { + const path = this.getBinaryPath(); + return fs.existsSync(path); + } catch { + return false; + } + } + + static run(args: string[] = []) { + const binaryPath = this.getBinaryPath(); + return spawnSync(binaryPath, args, { stdio: 'inherit' }); + } +}