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/tidy-ravens-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@offckb/cli': patch
---

Fix the devnet config editor and `status` for existing installs. The config editor's "Edit RPC Modules" dialog now offers the `Terminal` (and `RichIndexer`) modules — its option list was still the pre-ckb-tui hardcoded set, so there was no way to enable `Terminal` from the TUI even though the bundled `ckb.toml` enables it. In addition, starting the node now upgrades a legacy devnet `ckb.toml` in place: configs initialized before the ckb-tui fix never received the `Terminal` RPC module or the enabled `tcp_listen_address` (the template is only copied into fresh config folders, and clearing chain `data/` does not touch config files), which left `offckb status` dashboards empty. The migration adds `"Terminal"` to `rpc.modules` and enables `tcp_listen_address = "127.0.0.1:18114"` while preserving existing comments and custom settings; restart the node to apply.
135 changes: 135 additions & 0 deletions src/node/init-chain.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'fs';
import path from 'path';
import toml, { JsonMap } from '@iarna/toml';
import { isFolderExists, copyFilesWithExclusion } from '../util/fs';
import { packageRootPath, readSettings } from '../cfg/setting';
import { logger } from '../util/logger';
Expand Down Expand Up @@ -33,4 +34,138 @@ export async function initChainIfNeeded() {
fs.writeFileSync(minerConfigPath, modifiedData, 'utf8');
}
}

migrateLegacyDevnetRpcConfig(devnetConfigPath);
}

const TERMINAL_RPC_MODULE = 'Terminal';
const DEFAULT_TCP_LISTEN_ADDRESS = '127.0.0.1:18114';

function findRpcSection(lines: string[]): { start: number; end: number } | null {
const start = lines.findIndex((line) => /^\s*\[rpc\]\s*$/.test(line));
if (start < 0) return null;
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^\s*\[[^\]]*\]\s*$/.test(lines[i])) {
end = i;
break;
}
}
return { start, end };
}

// Adds "Terminal" to the rpc.modules array, preserving the file's formatting.
// Handles both the single-line layout used by the bundled template and
// hand-formatted multi-line arrays.
function addTerminalModule(lines: string[], section: { start: number; end: number }): boolean {
const modulesStart = lines.findIndex(
(line, index) => index > section.start && index < section.end && /^\s*modules\s*=\s*\[/.test(line),
);
if (modulesStart < 0) return false;

const singleLine = lines[modulesStart].match(/^(\s*modules\s*=\s*\[[^\]]*)\](\s*(?:#.*)?)$/);
if (singleLine) {
lines[modulesStart] = `${singleLine[1]}, "${TERMINAL_RPC_MODULE}"]${singleLine[2]}`;
return true;
}

// Multi-line array: find the line holding the closing bracket, make sure the
// previous entry ends with a comma, then insert the new module before it.
let closingLine = -1;
for (let i = modulesStart + 1; i < section.end; i++) {
if (lines[i].includes(']')) {
closingLine = i;
break;
}
}
if (closingLine < 0) return false;
for (let i = closingLine - 1; i > modulesStart; i--) {
if (lines[i].trim().length === 0) continue;
if (!lines[i].trimEnd().endsWith(',')) {
lines[i] = `${lines[i].trimEnd()},`;
}
break;
}
lines.splice(closingLine, 0, ` "${TERMINAL_RPC_MODULE}",`);
return true;
}

// Enables rpc.tcp_listen_address. Only the stock loopback default is
// uncommented in place — a commented non-loopback value (e.g. 0.0.0.0) stays
// disabled and a fresh loopback entry is inserted after the modules array
// instead, so the migration never turns the RPC into a public listener.
function enableTcpListenAddress(lines: string[], section: { start: number; end: number }): boolean {
for (let i = section.start + 1; i < section.end; i++) {
const commented = lines[i].match(/^(\s*)#\s*(tcp_listen_address\s*=.*)$/);
if (!commented) continue;
const value = commented[2].match(/tcp_listen_address\s*=\s*"([^"]*)"/);
if (value?.[1] === DEFAULT_TCP_LISTEN_ADDRESS) {
lines[i] = `${commented[1]}${commented[2]}`;
return true;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

let insertAt = section.start + 1;
for (let i = section.start + 1; i < section.end; i++) {
if (/^\s*modules\s*=\s*\[/.test(lines[i])) {
insertAt = i + 1;
while (insertAt < section.end && !lines[insertAt - 1].includes(']')) {
insertAt++;
}
break;
}
}
lines.splice(insertAt, 0, `tcp_listen_address = "${DEFAULT_TCP_LISTEN_ADDRESS}"`);
return true;
}

/**
* Upgrades a pre-existing devnet ckb.toml so `offckb status` (ckb-tui) works:
* the bundled template gained the Terminal RPC module and an enabled
* tcp_listen_address, but initChainIfNeeded only copies the template into
* fresh config folders, so chains initialized before that change never picked
* it up. Edits are text-based to keep user comments/formatting intact, and
* any failure is non-fatal — node startup must never break over a migration.
* Returns true when the file was changed.
*/
export function migrateLegacyDevnetRpcConfig(devnetConfigPath: string): boolean {
const ckbTomlPath = path.join(devnetConfigPath, 'ckb.toml');
try {
if (!fs.existsSync(ckbTomlPath)) return false;
const source = fs.readFileSync(ckbTomlPath, 'utf8');

const parsed = toml.parse(source);
const rpc = parsed.rpc as JsonMap | undefined;
if (rpc == null || typeof rpc !== 'object') return false;

const modules = rpc.modules;
const needsTerminal =
Array.isArray(modules) && modules.every((m) => typeof m === 'string') && !modules.includes(TERMINAL_RPC_MODULE);
const tcpAddress = rpc.tcp_listen_address;
const needsTcp = typeof tcpAddress !== 'string' || tcpAddress.trim().length === 0;
if (!needsTerminal && !needsTcp) return false;

const lines = source.split('\n');
const section = findRpcSection(lines);
if (section == null) return false;

const changes: string[] = [];
if (needsTerminal && addTerminalModule(lines, section)) {
changes.push('Terminal RPC module');
}
if (needsTcp && enableTcpListenAddress(lines, findRpcSection(lines) ?? section)) {
changes.push(`tcp_listen_address (${DEFAULT_TCP_LISTEN_ADDRESS})`);
}
if (changes.length === 0) return false;

fs.writeFileSync(ckbTomlPath, lines.join('\n'), 'utf8');
logger.info(
`Upgraded devnet ckb.toml for ckb-tui: enabled ${changes.join(' and ')}. ` +
'Restart the node if it is already running for this to take effect.',
);
return true;
} catch (error) {
logger.debug(`skipping devnet ckb.toml migration: ${(error as Error).message}`);
return false;
}
}
4 changes: 4 additions & 0 deletions src/tui/devnet-config-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ const FIXED_ARRAY_SPECS: FixedArraySpec[] = [
{
pathPattern: 'rpc.modules',
label: 'RPC Modules',
// Keep in sync with the module list in ckb/devnet/ckb.toml; "Terminal"
// powers ckb-tui's get_overview metrics used by `offckb status`.
options: [
'Net',
'Pool',
Expand All @@ -159,7 +161,9 @@ const FIXED_ARRAY_SPECS: FixedArraySpec[] = [
'Debug',
'IntegrationTest',
'Indexer',
'RichIndexer',
'Subscription',
'Terminal',
],
unique: true,
allowCustom: true,
Expand Down
24 changes: 24 additions & 0 deletions tests/devnet-config-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import fs from 'fs';
import path from 'path';
import toml, { JsonMap } from '@iarna/toml';
import { getFixedArraySpec } from '../src/tui/devnet-config-metadata';

describe('devnet-config-metadata rpc.modules spec', () => {
it('offers the Terminal and RichIndexer modules in the config editor', () => {
const spec = getFixedArraySpec(['rpc', 'modules']);
expect(spec).not.toBeNull();
expect(spec!.options).toContain('Terminal');
expect(spec!.options).toContain('RichIndexer');
});

it('covers every module enabled by the bundled devnet ckb.toml', () => {
const templatePath = path.resolve(__dirname, '..', 'ckb', 'devnet', 'ckb.toml');
const parsed = toml.parse(fs.readFileSync(templatePath, 'utf8'));
const templateModules = (parsed.rpc as JsonMap).modules as string[];

const spec = getFixedArraySpec(['rpc', 'modules']);
for (const moduleName of templateModules) {
expect(spec!.options).toContain(moduleName);
}
});
});
151 changes: 149 additions & 2 deletions tests/init-chain.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import toml, { JsonMap } from '@iarna/toml';

let mockConfigPath = '';

Expand All @@ -12,10 +13,32 @@ jest.mock('../src/cfg/setting', () => ({
}));

jest.mock('../src/util/logger', () => ({
logger: { debug: jest.fn(), error: jest.fn() },
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
}));

import { initChainIfNeeded } from '../src/node/init-chain';
import { initChainIfNeeded, migrateLegacyDevnetRpcConfig } from '../src/node/init-chain';

const LEGACY_CKB_TOML = `# legacy devnet config from before the ckb-tui fix
# a custom comment that must survive migration

[rpc]
listen_address = "127.0.0.1:8114"

# List of API modules: ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"]
modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"]

# By default RPC only binds to HTTP service, you can bind it to TCP and WebSocket.
# tcp_listen_address = "127.0.0.1:18114"
# ws_listen_address = "127.0.0.1:28114"
reject_ill_transactions = true

[miner]
# keep this section untouched
`;

function readCkbToml(configPath: string): JsonMap {
return toml.parse(fs.readFileSync(path.join(configPath, 'ckb.toml'), 'utf8'));
}

describe('initChainIfNeeded', () => {
let root: string;
Expand Down Expand Up @@ -59,3 +82,127 @@ describe('initChainIfNeeded', () => {
expect(fs.existsSync(path.join(mockConfigPath, 'specs', 'dev.toml'))).toBe(true);
});
});

describe('migrateLegacyDevnetRpcConfig', () => {
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-migrate-rpc-'));
mockConfigPath = path.join(root, 'devnet');
fs.mkdirSync(mockConfigPath, { recursive: true });
});
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));

it('enables the Terminal module and TCP stream on a legacy config, preserving comments', () => {
fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), LEGACY_CKB_TOML);

expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(true);

const text = fs.readFileSync(path.join(mockConfigPath, 'ckb.toml'), 'utf8');
expect(text).toContain('# a custom comment that must survive migration');
expect(text).not.toContain('# tcp_listen_address');
const parsed = readCkbToml(mockConfigPath);
const rpc = parsed.rpc as JsonMap;
expect(rpc.modules).toContain('Terminal');
expect(rpc.modules).toContain('Indexer');
expect(rpc.tcp_listen_address).toBe('127.0.0.1:18114');
expect(rpc.reject_ill_transactions).toBe(true);
});

it('runs through initChainIfNeeded so existing installs pick it up on node start', async () => {
fs.mkdirSync(path.join(mockConfigPath, 'specs'), { recursive: true });
fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), LEGACY_CKB_TOML);
fs.writeFileSync(path.join(mockConfigPath, 'ckb-miner.toml'), 'custom-miner');
fs.writeFileSync(path.join(mockConfigPath, 'specs', 'dev.toml'), 'custom-spec');

await initChainIfNeeded();

const rpc = readCkbToml(mockConfigPath).rpc as JsonMap;
expect(rpc.modules).toContain('Terminal');
expect(rpc.tcp_listen_address).toBe('127.0.0.1:18114');
expect(fs.readFileSync(path.join(mockConfigPath, 'ckb-miner.toml'), 'utf8')).toBe('custom-miner');
});

it('is a no-op on the current bundled template', async () => {
await initChainIfNeeded();
const before = fs.readFileSync(path.join(mockConfigPath, 'ckb.toml'), 'utf8');

expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(false);
expect(fs.readFileSync(path.join(mockConfigPath, 'ckb.toml'), 'utf8')).toBe(before);
});

it('appends Terminal without dropping a custom module subset', () => {
const custom = LEGACY_CKB_TOML.replace(
'modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"]',
'modules = ["Net", "Chain"]',
);
fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), custom);

expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(true);

const rpc = readCkbToml(mockConfigPath).rpc as JsonMap;
expect(rpc.modules).toEqual(['Net', 'Chain', 'Terminal']);
});

it('handles a hand-formatted multi-line modules array', () => {
const multiline = LEGACY_CKB_TOML.replace(
'modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"]',
'modules = [\n "Net",\n "Indexer"\n]',
);
fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), multiline);

expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(true);

const rpc = readCkbToml(mockConfigPath).rpc as JsonMap;
expect(rpc.modules).toEqual(['Net', 'Indexer', 'Terminal']);
});

it('inserts tcp_listen_address when no commented line exists', () => {
const noTcpStub = LEGACY_CKB_TOML.replace('# tcp_listen_address = "127.0.0.1:18114"\n', '');
fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), noTcpStub);

expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(true);

const rpc = readCkbToml(mockConfigPath).rpc as JsonMap;
expect(rpc.tcp_listen_address).toBe('127.0.0.1:18114');
});

it('never enables a commented non-loopback tcp_listen_address; adds the loopback default instead', () => {
const exposed = LEGACY_CKB_TOML.replace(
'# tcp_listen_address = "127.0.0.1:18114"',
'# tcp_listen_address = "0.0.0.0:18114"',
);
fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), exposed);

expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(true);

const text = fs.readFileSync(path.join(mockConfigPath, 'ckb.toml'), 'utf8');
expect(text).toContain('# tcp_listen_address = "0.0.0.0:18114"');
expect(text).not.toMatch(/^tcp_listen_address\s*=\s*"0\.0\.0\.0/m);
const rpc = readCkbToml(mockConfigPath).rpc as JsonMap;
expect(rpc.tcp_listen_address).toBe('127.0.0.1:18114');
});

it('keeps an explicitly configured tcp_listen_address', () => {
const custom = LEGACY_CKB_TOML.replace(
'# tcp_listen_address = "127.0.0.1:18114"',
'tcp_listen_address = "127.0.0.1:29114"',
);
fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), custom);

expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(true);

const rpc = readCkbToml(mockConfigPath).rpc as JsonMap;
expect(rpc.tcp_listen_address).toBe('127.0.0.1:29114');
expect(rpc.modules).toContain('Terminal');
});

it('ignores unparseable or section-less configs without throwing', () => {
fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), 'custom-ckb');
expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(false);
expect(fs.readFileSync(path.join(mockConfigPath, 'ckb.toml'), 'utf8')).toBe('custom-ckb');
});

it('returns false when ckb.toml does not exist', () => {
expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(false);
});
});
Loading