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/logs-command-quiet-node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@offckb/cli': minor
---

Unify devnet logging around the node's log files and add `offckb logs`. A foreground `offckb node` no longer relays the raw node/miner stdout: the console now shows lifecycle events, live contract script debug output (`debug!` in scripts, streamed over the node's TCP log subscription), submitted transaction hashes, and RPC errors — the full node log stays in `data/logs/run.log` as always, and `--verbose` restores the old firehose. The new `offckb logs [node|script|miner|rpc] [-f] [--grep] [--tail]` command reads those log files (`docker logs` style), so logs are reachable in every run mode — foreground, daemon, or while `offckb status` is attached — and pipe/agent friendly. The RPC proxy is quieter too: per-request lines moved from info to debug, JSON-RPC errors in responses now surface as warnings, and everything the proxy sees is appended to `data/logs/proxy.log` (viewable via `offckb logs rpc`).
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Commands:
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
logs [options] [target] Show devnet logs: node (default), contract script debug output, miner, or RPC proxy events
config <action> [item] [value] do a configuration action
devnet config Edit devnet configuration
devnet info Show fork metadata and node/indexer readiness
Expand Down Expand Up @@ -144,6 +145,21 @@ Stop the daemon later with:
offckb node stop
```

**View Logs**

A foreground `offckb node` stays quiet by default: it prints lifecycle events, contract script debug output (`debug!` in your scripts), submitted transaction hashes, and RPC errors. The node, miner, and RPC proxy always write full logs to files under the devnet data folder, and `offckb logs` reads them in any run mode (foreground, daemon, or while `offckb status` is attached):

```sh
offckb logs # node log (default)
offckb logs script # contract script debug output only
offckb logs miner # miner log
offckb logs rpc # RPC requests, transaction hashes, RPC errors
offckb logs -f # stream new lines, tail -f style
offckb logs --tail 200 --grep ERROR
```

Use `offckb node --verbose` to restore the old behavior of printing the full raw node/miner output to the terminal.

**Agent-Friendly JSON Output**

For programmatic consumption or agent integration, add `--json` before or after the command:
Expand Down
41 changes: 37 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { Command, CommanderError, Option } from 'commander';
import { Command, CommanderError, InvalidArgumentError, Option, Argument } from 'commander';
import { startNode, stopNode } from './cmd/node';
import { accounts } from './cmd/accounts';
import { clean } from './cmd/clean';
Expand All @@ -15,6 +15,7 @@ import { devnetConfig } from './cmd/devnet-config';
import { devnetFork } from './cmd/devnet-fork';
import { devnetInfo } from './cmd/devnet-info';
import { debugSingleScript, debugTransaction, parseSingleScriptOption } from './cmd/debug';
import { logsCommand, LogsOptions } from './cmd/logs';
import { printSystemScripts } from './cmd/system-scripts';
import { transferAll } from './cmd/transfer-all';
import { genSystemScriptsJsonFile } from './scripts/gen';
Expand Down Expand Up @@ -76,15 +77,47 @@ const nodeCommand = program
'Specify the CKB binary path to use, only for devnet, when set, will ignore version and network',
)
.option('--daemon', 'Run the node in the background as a daemon (devnet only)')
.action(async (version: string, options: { network: Network; binaryPath?: string; daemon?: boolean }) => {
return startNode({ version, network: options.network, binaryPath: options.binaryPath, daemon: options.daemon });
});
.option(
'--verbose',
'Print the full raw node/miner output (default shows lifecycle events, script output, tx hashes, and RPC errors)',
)
.action(
async (
version: string,
options: { network: Network; binaryPath?: string; daemon?: boolean; verbose?: boolean },
) => {
return startNode({
version,
network: options.network,
binaryPath: options.binaryPath,
daemon: options.daemon,
verbose: options.verbose,
});
},
);

nodeCommand
.command('stop')
.description('Stop the running CKB devnet daemon')
.action(async () => stopNode());

program
.command('logs')
.description('Show devnet logs: node (default), contract script debug output, miner, or RPC proxy events')
.addArgument(
new Argument('[target]', 'Which logs to show').choices(['node', 'script', 'miner', 'rpc']).default('node'),
)
.option('-f, --follow', 'Stream new log lines as they are written (like tail -f)')
.option('--grep <pattern>', 'Only show lines containing the given text')
.option('--tail <lines>', 'Show the last N lines before following', (value: string) => {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new InvalidArgumentError('--tail must be a non-negative integer');
}
return parsed;
})
.action((target: string, options: LogsOptions) => logsCommand(target, options));

program
.command('create [project-name]')
.description('Create a new CKB Smart Contract project in JavaScript.')
Expand Down
57 changes: 57 additions & 0 deletions src/cmd/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
LOG_TARGETS,
LogTarget,
SCRIPT_LOG_TARGET,
filterLinesByTarget,
followLogFile,
grepLines,
parseCkbLogLine,
readLogTail,
resolveLogPath,
} from '../devnet/log-file';
import { readSettings, Settings } from '../cfg/setting';
import { logger as defaultLogger, UnifiedLogger } from '../util/logger';

export interface LogsOptions {
follow?: boolean;
grep?: string;
tail?: number;
}

const DEFAULT_TAIL = 100;

/**
* Print (and optionally follow) a devnet log file. The core is synchronous so
* it can be unit tested with temp files; --follow hands control to
* followLogFile and keeps the process alive until Ctrl-C.
*/
export function showLogs(target: LogTarget, options: LogsOptions, settings: Settings, logger: UnifiedLogger): void {
const filePath = resolveLogPath(target, settings);
const tail = options.tail ?? DEFAULT_TAIL;

let lines = readLogTail(filePath, tail);
const scriptOnly = target === 'script';
if (scriptOnly) lines = filterLinesByTarget(lines, SCRIPT_LOG_TARGET);
if (options.grep) lines = grepLines(lines, options.grep);
for (const line of lines) logger.info(line);

if (!options.follow) return;

let inScriptEntry = false;
followLogFile(filePath, (line) => {
let show = true;
if (scriptOnly) {
// Unparsable lines are continuations of the previous entry.
const parsed = parseCkbLogLine(line);
if (parsed) inScriptEntry = parsed.target === SCRIPT_LOG_TARGET;
show = inScriptEntry;
}
if (show && options.grep && !line.includes(options.grep)) show = false;
if (show) logger.info(line);
});
}

export function logsCommand(target: string | undefined, options: LogsOptions): void {
const resolved: LogTarget = LOG_TARGETS.includes(target as LogTarget) ? (target as LogTarget) : 'node';
showLogs(resolved, options, readSettings(), defaultLogger);
}
69 changes: 58 additions & 11 deletions src/cmd/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ import { callJsonRpc } from '../util/json-rpc';
import { Network } from '../type/base';
import { logger } from '../util/logger';
import { checkNodeReadiness, waitForNodeReady } from '../devnet/readiness';
import { devnetTcpListenAddress, subscribeToNodeLogs, SubscriptionHandle } from '../devnet/log-subscription';
import { SCRIPT_LOG_TARGET } from '../devnet/log-file';

export interface NodeProp {
version?: string;
network?: Network;
binaryPath?: string;
daemon?: boolean;
verbose?: boolean;
}

interface PidMetadata {
Expand All @@ -38,12 +41,18 @@ const NODE_READY_TIMEOUT_MS = 90_000;
const FORK_NODE_READY_TIMEOUT_MS = 10 * 60_000;

function cleanChildOutput(data: unknown): string {
// CKB colors its output even when it is redirected. Strip ANSI control
// sequences so JSON logs stay machine-readable.
return String(data).replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '');
// CKB colors its output even when it is redirected, and log text relayed
// from the node (including contract debug! messages) is untrusted terminal
// input. Strip ANSI CSI/OSC sequences and C0/C1 control characters (keeping
// \n and \t) so JSON logs stay machine-readable and a crafted script log
// cannot inject terminal control sequences.
return String(data)
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '')
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '');
}

export function startNode({ version, network = Network.devnet, binaryPath, daemon }: NodeProp) {
export function startNode({ version, network = Network.devnet, binaryPath, daemon, verbose }: NodeProp) {
if (binaryPath && network !== Network.devnet) {
logger.warn('Custom binaryPath is only supported for devnet. The provided binaryPath will be ignored.');
}
Expand All @@ -53,7 +62,7 @@ export function startNode({ version, network = Network.devnet, binaryPath, daemo

switch (network) {
case Network.devnet:
return nodeDevnet({ version, binaryPath, daemon });
return nodeDevnet({ version, binaryPath, daemon, verbose });
case Network.testnet:
return nodeTestnet();
case Network.mainnet:
Expand All @@ -63,7 +72,7 @@ export function startNode({ version, network = Network.devnet, binaryPath, daemo
}
}

export async function nodeDevnet({ version, binaryPath, daemon }: NodeProp) {
export async function nodeDevnet({ version, binaryPath, daemon, verbose }: NodeProp) {
if (daemon) {
return startDaemon();
}
Expand Down Expand Up @@ -119,14 +128,23 @@ export async function nodeDevnet({ version, binaryPath, daemon }: NodeProp) {
const runArgs = ['run', '-C', devnetConfigPath];
if (firstRunFlags) runArgs.push('--skip-spec-check', '--overwrite-spec');
const ckbProcess = spawn(ckbBinPath, runArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
ckbProcess.stdout?.on('data', (data) => logger.info(['CKB:', cleanChildOutput(data)]));
// Quiet by default: the node keeps its full log in data/logs/run.log
// (see `offckb logs`), and contract script debug output streams over the
// TCP log subscription below. --verbose restores the raw stdout/stderr relay.
// stdout must be drained either way or the child blocks on a full pipe
// buffer once the OS pipe fills.
if (verbose) {
ckbProcess.stdout?.on('data', (data) => logger.info(['CKB:', cleanChildOutput(data)]));
} else {
ckbProcess.stdout?.on('data', () => {});
}
// Keep a bounded stderr tail so a startup crash can be translated into an
// actionable error below (CKB's own config errors are notoriously opaque).
let ckbStderrTail = '';
ckbProcess.stderr?.on('data', (data) => {
const text = cleanChildOutput(data);
ckbStderrTail = (ckbStderrTail + text).slice(-4096);
logger.error(['CKB error:', text]);
if (verbose) logger.error(['CKB error:', text]);
});

let ckbExited = false;
Expand All @@ -142,7 +160,10 @@ export async function nodeDevnet({ version, binaryPath, daemon }: NodeProp) {
if (!readiness.ready) {
if (!ckbExited) ckbProcess.kill('SIGTERM');
const hint = terminalRpcUnknownVariantHint(ckbStderrTail, devnetConfigPath);
throw new Error(`CKB devnet failed to become ready: ${readiness.error ?? 'CKB process exited'}${hint ?? ''}`);
throw new Error(
`CKB devnet failed to become ready: ${readiness.error ?? 'CKB process exited'}${hint ?? ''} ` +
'Check the node log with `offckb logs` or rerun with --verbose for full output.',
);
}
if (ckbExited) {
throw new Error('CKB devnet exited immediately after its readiness check.');
Expand All @@ -164,8 +185,13 @@ export async function nodeDevnet({ version, binaryPath, daemon }: NodeProp) {
ckbProcess.kill('SIGTERM');
throw new Error(`CKB miner failed to start: ${(error as Error).message}`);
}
minerProcess.stdout?.on('data', (data) => logger.info(['CKB-Miner:', cleanChildOutput(data)]));
minerProcess.stderr?.on('data', (data) => logger.error(['CKB-Miner error:', cleanChildOutput(data)]));
if (verbose) {
minerProcess.stdout?.on('data', (data) => logger.info(['CKB-Miner:', cleanChildOutput(data)]));
minerProcess.stderr?.on('data', (data) => logger.error(['CKB-Miner error:', cleanChildOutput(data)]));
} else {
minerProcess.stdout?.on('data', () => {});
minerProcess.stderr?.on('data', () => {});
}
try {
await waitForChildSpawn(minerProcess, 'CKB miner');
} catch (error) {
Expand All @@ -179,7 +205,27 @@ export async function nodeDevnet({ version, binaryPath, daemon }: NodeProp) {

const proxy = createRPCProxy(Network.devnet, settings.devnet.rpcUrl, settings.devnet.rpcProxyPort);
proxy.start();

// Contract script debug output (debug! in scripts) streams live over the
// node's TCP log subscription; everything else stays in the log files.
let logSubscription: SubscriptionHandle | null = null;
const tcpAddress = devnetTcpListenAddress();
if (tcpAddress) {
logSubscription = subscribeToNodeLogs(
tcpAddress,
(entry) => {
if (entry.target === SCRIPT_LOG_TARGET) logger.info(['CKB-Script:', cleanChildOutput(entry.message)]);
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
(error) => logger.warn(`${error.message} Full logs remain available via: offckb logs -f`),
);
} else if (!verbose) {
logger.debug('No tcp_listen_address in ckb.toml; script debug output will not stream live.');
}

logger.success(`CKB devnet is ready at ${settings.devnet.rpcUrl}.`);
if (!verbose) {
logger.info('Follow the full node log with: offckb logs -f');
}
logger.result({
command: 'node',
network: Network.devnet,
Expand All @@ -194,6 +240,7 @@ export async function nodeDevnet({ version, binaryPath, daemon }: NodeProp) {
const stopService = (component: 'CKB node' | 'CKB miner', code: number | null, signal: NodeJS.Signals | null) => {
if (serviceStopping) return;
serviceStopping = true;
logSubscription?.close();
if (component !== 'CKB node' && !ckbProcess.killed) ckbProcess.kill('SIGTERM');
if (component !== 'CKB miner' && !minerProcess.killed) minerProcess.kill('SIGTERM');
proxy.stop();
Expand Down
26 changes: 1 addition & 25 deletions src/cmd/status.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import fs from 'fs';
import path from 'path';
import toml, { JsonMap } from '@iarna/toml';
import { readSettings } from '../cfg/setting';
import { CKBTui } from '../tools/ckb-tui';
import { Network } from '../type/base';
import { checkNodeReadiness } from '../devnet/readiness';
import { devnetTcpListenAddress } from '../devnet/log-subscription';

export interface StatusOptions {
network: Network;
Expand All @@ -18,28 +16,6 @@ const NETWORK_SETTINGS_KEY: Record<Network, NetworkSettingsKey> = {
[Network.mainnet]: 'mainnet',
};

/**
* Best-effort lookup of the devnet node's TCP subscription endpoint from its
* ckb.toml. ckb-tui connects to it directly (the OffCKB proxy is HTTP-only) to
* stream new/rejected transactions and logs; when absent, those dashboards
* simply stay empty, so any failure here is non-fatal.
*/
function devnetTcpListenAddress(): string | undefined {
try {
const settings = readSettings();
const ckbTomlPath = path.join(settings.devnet.configPath, 'ckb.toml');
if (!fs.existsSync(ckbTomlPath)) return undefined;
const parsed = toml.parse(fs.readFileSync(ckbTomlPath, 'utf8'));
const rpc = parsed.rpc as JsonMap | undefined;
const address = rpc?.tcp_listen_address;
if (typeof address !== 'string' || address.trim().length === 0) return undefined;
// A wildcard bind is not a dialable address; the node runs on this host.
return address.trim().replace(/^0\.0\.0\.0:/, '127.0.0.1:');
} catch {
return undefined;
}
}

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.
Expand Down
Loading
Loading