Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/integrate-ckb-tui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@offckb/cli": minor
---

Add `status` command to launch ckb-tui for monitoring CKB network from your node
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <action> [item] [value] do a configuration action
help [command] display help for command
```
Expand Down Expand Up @@ -169,6 +170,10 @@ offckb node --network <testnet or mainnet>
```
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:
Expand Down
61 changes: 58 additions & 3 deletions src/cfg/setting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,13 @@ export interface Settings {
transactionsPath: string;
};
tools: {
rootFolder: string;
ckbDebugger: {
minVersion: string;
};
ckbTui: {
version: string;
};
};
}

Expand Down Expand Up @@ -88,17 +92,24 @@ 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',
},
},
};

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;
}
Expand Down Expand Up @@ -129,10 +140,27 @@ export function getCKBBinaryPath(version: string) {
return path.join(getCKBBinaryInstallPath(version), binaryName);
}

function deepClone<T>(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<string, unknown> = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
clone[key] = deepClone((obj as Record<string, unknown>)[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]);
Expand All @@ -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<string, unknown>;

if (obj.tools && typeof obj.tools === 'object') {
const tools = obj.tools as Record<string, unknown>;
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<string, unknown>;
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');
}
}
}
11 changes: 11 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -205,6 +207,15 @@ program
return CKBDebugger.runWithArgs(process.argv.slice(2));
});

program
.command('status')
.description('Show ckb-tui status interface')
.option('--network <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 <action> [item] [value]')
.description('do a configuration action')
Expand Down
80 changes: 80 additions & 0 deletions src/cmd/status.ts
Original file line number Diff line number Diff line change
@@ -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, NetworkSettingsKey> = {
[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<boolean> {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return false;
}
const client = new net.Socket();
return new Promise<boolean>((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');
});
}
Loading
Loading