Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
70 changes: 70 additions & 0 deletions src/cmd/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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) {
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