Skip to content

Commit 43ce3a3

Browse files
feat(cmd): add status command with ckb-tui v0.1.3 (#449)
* 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 * 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 * fix: add non-TTY guard to status command to prevent CI/pipe hangs (round 2)
1 parent 463b2ff commit 43ce3a3

6 files changed

Lines changed: 461 additions & 3 deletions

File tree

.changeset/integrate-ckb-tui.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@offckb/cli": minor
3+
---
4+
5+
Add `status` command to launch ckb-tui for monitoring CKB network from your node

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ Commands:
8282
transfer-all [options] [toAddress] Transfer All CKB tokens to address, only devnet and testnet
8383
balance [options] [toAddress] Check account balance, only devnet and testnet
8484
debugger Port of the raw CKB Standalone Debugger
85+
status [options] Show ckb-tui status interface
8586
config <action> [item] [value] do a configuration action
8687
help [command] display help for command
8788
```
@@ -169,6 +170,10 @@ offckb node --network <testnet or mainnet>
169170
```
170171
Using a proxy RPC server for Testnet/Mainnet is especially helpful for debugging transactions, since failed transactions are dumped automatically.
171172

173+
**Watch Network with TUI**
174+
175+
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.
176+
172177
### 2. Create a New Contract Project {#create-project}
173178

174179
Generate a ready-to-use smart-contract project in JS/TS using templates:

src/cfg/setting.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,13 @@ export interface Settings {
5252
transactionsPath: string;
5353
};
5454
tools: {
55+
rootFolder: string;
5556
ckbDebugger: {
5657
minVersion: string;
5758
};
59+
ckbTui: {
60+
version: string;
61+
};
5862
};
5963
}
6064

@@ -88,17 +92,24 @@ export const defaultSettings: Settings = {
8892
transactionsPath: path.resolve(dataPath, 'mainnet/transactions'),
8993
},
9094
tools: {
95+
rootFolder: path.resolve(dataPath, 'tools'),
9196
ckbDebugger: {
9297
minVersion: '0.200.0',
9398
},
99+
ckbTui: {
100+
version: 'v0.1.3',
101+
},
94102
},
95103
};
96104

97105
export function readSettings(): Settings {
98106
try {
99107
if (fs.existsSync(configPath)) {
100108
const data = fs.readFileSync(configPath, 'utf8');
101-
return deepMerge(defaultSettings, JSON.parse(data)) as Settings;
109+
const parsed = JSON.parse(data);
110+
validateSettings(parsed);
111+
// Deep-clone defaults before merging to prevent mutation of the shared default
112+
return deepMerge(deepClone(defaultSettings), parsed) as Settings;
102113
} else {
103114
return defaultSettings;
104115
}
@@ -129,10 +140,27 @@ export function getCKBBinaryPath(version: string) {
129140
return path.join(getCKBBinaryInstallPath(version), binaryName);
130141
}
131142

143+
function deepClone<T>(obj: T): T {
144+
if (obj === null || typeof obj !== 'object') {
145+
return obj;
146+
}
147+
if (Array.isArray(obj)) {
148+
return obj.map(deepClone) as unknown as T;
149+
}
150+
const clone: Record<string, unknown> = {};
151+
for (const key in obj) {
152+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
153+
clone[key] = deepClone((obj as Record<string, unknown>)[key]);
154+
}
155+
}
156+
return clone as T;
157+
}
158+
159+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
132160
function deepMerge(target: any, source: any): any {
133161
for (const key in source) {
134-
if (source[key] && typeof source[key] === 'object') {
135-
if (!target[key]) {
162+
if (source[key] !== null && typeof source[key] === 'object' && !Array.isArray(source[key])) {
163+
if (!target[key] || typeof target[key] !== 'object') {
136164
target[key] = {};
137165
}
138166
deepMerge(target[key], source[key]);
@@ -142,3 +170,30 @@ function deepMerge(target: any, source: any): any {
142170
}
143171
return target;
144172
}
173+
174+
function validateSettings(raw: unknown): void {
175+
if (!raw || typeof raw !== 'object') {
176+
throw new Error('Settings must be a JSON object');
177+
}
178+
179+
const obj = raw as Record<string, unknown>;
180+
181+
if (obj.tools && typeof obj.tools === 'object') {
182+
const tools = obj.tools as Record<string, unknown>;
183+
if (tools.rootFolder !== undefined && typeof tools.rootFolder !== 'string') {
184+
throw new Error('tools.rootFolder must be a string path');
185+
}
186+
if (tools.ckbTui && typeof tools.ckbTui === 'object') {
187+
const ckbTui = tools.ckbTui as Record<string, unknown>;
188+
if (ckbTui.version !== undefined && typeof ckbTui.version !== 'string') {
189+
throw new Error('tools.ckbTui.version must be a string');
190+
}
191+
}
192+
}
193+
194+
if (obj.proxy !== undefined && obj.proxy !== null) {
195+
if (typeof obj.proxy !== 'object') {
196+
throw new Error('proxy must be an object');
197+
}
198+
}
199+
}

src/cli.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { genSystemScriptsJsonFile } from './scripts/gen';
1919
import { CKBDebugger } from './tools/ckb-debugger';
2020
import { logger } from './util/logger';
2121
import { Network } from './type/base';
22+
import { status } from './cmd/status';
23+
import { validateNetworkOpt } from './util/validator';
2224

2325
const version = require('../package.json').version;
2426
const description = require('../package.json').description;
@@ -205,6 +207,15 @@ program
205207
return CKBDebugger.runWithArgs(process.argv.slice(2));
206208
});
207209

210+
program
211+
.command('status')
212+
.description('Show ckb-tui status interface')
213+
.option('--network <network>', 'Specify the network whose node status to monitor', 'devnet')
214+
.action(async (option) => {
215+
validateNetworkOpt(option.network);
216+
return await status({ network: option.network });
217+
});
218+
208219
program
209220
.command('config <action> [item] [value]')
210221
.description('do a configuration action')

src/cmd/status.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { readSettings } from '../cfg/setting';
2+
import { CKBTui } from '../tools/ckb-tui';
3+
import { Network } from '../type/base';
4+
import { logger } from '../util/logger';
5+
import * as net from 'net';
6+
7+
export interface StatusOptions {
8+
network: Network;
9+
}
10+
11+
type NetworkSettingsKey = 'devnet' | 'testnet' | 'mainnet';
12+
13+
const NETWORK_SETTINGS_KEY: Record<Network, NetworkSettingsKey> = {
14+
[Network.devnet]: 'devnet',
15+
[Network.testnet]: 'testnet',
16+
[Network.mainnet]: 'mainnet',
17+
};
18+
19+
export async function status({ network }: StatusOptions) {
20+
// ckb-tui is an interactive terminal UI. Running it without a TTY
21+
// (pipe, redirect, CI) would hang or produce garbage output.
22+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
23+
logger.error(
24+
'The status command requires an interactive terminal (TTY). ' +
25+
'It cannot be used in pipes, redirects, or non-interactive environments like CI.',
26+
);
27+
process.exit(1);
28+
}
29+
30+
const settings = readSettings();
31+
const networkKey = NETWORK_SETTINGS_KEY[network];
32+
const port = settings[networkKey].rpcProxyPort;
33+
const url = `http://127.0.0.1:${port}`;
34+
const isListening = await isRPCPortListening(port);
35+
if (!isListening) {
36+
logger.error(
37+
`RPC port ${port} is not listening. Please make sure the ${network} node is running and Proxy RPC is enabled.`,
38+
);
39+
return;
40+
}
41+
const result = CKBTui.run(['-r', url]);
42+
// Propagate ckb-tui exit code so scripts can detect TUI failure
43+
if (result.status !== 0) {
44+
process.exitCode = result.status ?? 1;
45+
}
46+
}
47+
48+
async function isRPCPortListening(port: number): Promise<boolean> {
49+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
50+
return false;
51+
}
52+
const client = new net.Socket();
53+
return new Promise<boolean>((resolve) => {
54+
let settled = false;
55+
const TIMEOUT_MS = 2000;
56+
const timeout = setTimeout(() => {
57+
if (!settled) {
58+
settled = true;
59+
client.destroy();
60+
resolve(false);
61+
}
62+
}, TIMEOUT_MS);
63+
client.once('error', () => {
64+
if (!settled) {
65+
settled = true;
66+
clearTimeout(timeout);
67+
resolve(false);
68+
}
69+
});
70+
client.once('connect', () => {
71+
if (!settled) {
72+
settled = true;
73+
clearTimeout(timeout);
74+
client.end();
75+
resolve(true);
76+
}
77+
});
78+
client.connect(port, '127.0.0.1');
79+
});
80+
}

0 commit comments

Comments
 (0)