Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ dist/
.vscode
templates/temp-clone-folder
build/
package-lock.json
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <action> [item] [value] do a configuration action
help [command] display help for command
```
Expand Down Expand Up @@ -130,8 +131,13 @@ You can also start a proxy RPC server for public networks:
```sh
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
8 changes: 8 additions & 0 deletions src/cfg/setting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,13 @@
transactionsPath: string;
};
tools: {
rootFolder: string;
ckbDebugger: {
minVersion: string;
};
ckbTui: {
version: string;
};
};
}

Expand Down Expand Up @@ -88,9 +92,13 @@
transactionsPath: path.resolve(dataPath, 'mainnet/transactions'),
},
tools: {
rootFolder: path.resolve(dataPath, 'tools'),
ckbDebugger: {
minVersion: '0.200.0',
},
ckbTui: {
version: 'v0.1.0',
},
},
};

Expand Down Expand Up @@ -127,7 +135,7 @@
return `${getCKBBinaryInstallPath(version)}/ckb`;
}

function deepMerge(target: any, source: any): any {

Check warning on line 138 in src/cfg/setting.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

Check warning on line 138 in src/cfg/setting.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

Check warning on line 138 in src/cfg/setting.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

Check warning on line 138 in src/cfg/setting.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

Check warning on line 138 in src/cfg/setting.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

Check warning on line 138 in src/cfg/setting.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
for (const key in source) {
if (source[key] && typeof source[key] === 'object') {
if (!target[key]) {
Expand Down
14 changes: 14 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -154,6 +155,19 @@ program
return CKBDebugger.runWithArgs(process.argv.slice(2));
});

program
.command('status')
.description('Show ckb-tui status interface')
.option('--network <network>', 'Specify the network to deploy to', 'devnet')
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The network option accepts any string value without validation. If a user provides an invalid network name (e.g., "production" or "dev"), it would be passed through without error checking. Consider validating that the network value is one of the valid Network enum values ('devnet', 'testnet', 'mainnet') and providing a helpful error message if it's not.

Copilot uses AI. Check for mistakes.
.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 <action> [item] [value]')
.description('do a configuration action')
Expand Down
59 changes: 59 additions & 0 deletions src/cmd/status.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const client = new net.Socket();
return new Promise<boolean>((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');
});
}
154 changes: 154 additions & 0 deletions src/tools/ckb-tui.ts
Original file line number Diff line number Diff line change
@@ -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}`;
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The download URL is constructed without validating the version string. If the version contains special characters or path traversal sequences (e.g., ../../malicious), it could potentially be used to download from unintended URLs. Consider validating that the version matches a expected pattern (e.g., v\\d+\\.\\d+\\.\\d+).

Copilot uses AI. Check for mistakes.
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;
}
}
}
}

Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the binary is not found after extraction (neither in the bin directory nor in subdirectories), the code silently continues without throwing an error or logging a warning. This could lead to confusing behavior later when trying to execute the binary. Consider adding an error check after the loop at line 84 to verify that the binary was successfully extracted and moved.

Suggested change
// 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.');
}

Copilot uses AI. Check for mistakes.
// 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' });
}
}
Loading