From e9c19b1704cf8796c7f12bc9f4636b24acfef191 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Fri, 24 Jul 2026 07:49:08 +0000 Subject: [PATCH 1/2] fix(devnet): offer Terminal module in config editor and migrate legacy ckb.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps kept the ckb-tui integration (#463) from reaching users: - The config editor's Edit RPC Modules dialog used a hardcoded option list that predated ckb-tui, so Terminal (and RichIndexer) could not be enabled from the TUI even though the bundled ckb.toml enables them. - initChainIfNeeded only copies the devnet template into fresh config folders, so chains initialized before #463 never received the Terminal RPC module or the enabled tcp_listen_address. Clearing chain data does not help — the config files persist — so offckb status dashboards stayed empty on develop. Add Terminal/RichIndexer to the rpc.modules fixed-array spec, and run a comment-preserving text migration on node start that appends Terminal to rpc.modules and enables tcp_listen_address (127.0.0.1:18114) in existing devnet configs. The migration is a no-op on current configs and never breaks node startup on unparseable files. Co-Authored-By: Claude Fable 5 --- .changeset/tidy-ravens-serve.md | 5 + src/node/init-chain.ts | 131 ++++++++++++++++++++++++++ src/tui/devnet-config-metadata.ts | 4 + tests/devnet-config-metadata.test.ts | 24 +++++ tests/init-chain.test.ts | 135 ++++++++++++++++++++++++++- 5 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 .changeset/tidy-ravens-serve.md create mode 100644 tests/devnet-config-metadata.test.ts diff --git a/.changeset/tidy-ravens-serve.md b/.changeset/tidy-ravens-serve.md new file mode 100644 index 0000000..ccf870d --- /dev/null +++ b/.changeset/tidy-ravens-serve.md @@ -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. diff --git a/src/node/init-chain.ts b/src/node/init-chain.ts index 29a3a66..c30c763 100644 --- a/src/node/init-chain.ts +++ b/src/node/init-chain.ts @@ -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'; @@ -33,4 +34,134 @@ 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 by uncommenting the stock commented line when +// present, otherwise inserting one after the modules array. +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) { + lines[i] = `${commented[1]}${commented[2]}`; + return true; + } + } + + 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; + } } diff --git a/src/tui/devnet-config-metadata.ts b/src/tui/devnet-config-metadata.ts index 5fa300e..65aa0e6 100644 --- a/src/tui/devnet-config-metadata.ts +++ b/src/tui/devnet-config-metadata.ts @@ -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', @@ -159,7 +161,9 @@ const FIXED_ARRAY_SPECS: FixedArraySpec[] = [ 'Debug', 'IntegrationTest', 'Indexer', + 'RichIndexer', 'Subscription', + 'Terminal', ], unique: true, allowCustom: true, diff --git a/tests/devnet-config-metadata.test.ts b/tests/devnet-config-metadata.test.ts new file mode 100644 index 0000000..bfebd1d --- /dev/null +++ b/tests/devnet-config-metadata.test.ts @@ -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); + } + }); +}); diff --git a/tests/init-chain.test.ts b/tests/init-chain.test.ts index f9205c7..284cba7 100644 --- a/tests/init-chain.test.ts +++ b/tests/init-chain.test.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import toml, { JsonMap } from '@iarna/toml'; let mockConfigPath = ''; @@ -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; @@ -59,3 +82,111 @@ 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('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); + }); +}); From 62ec9e8259b19ac1338cac0afb69f533e751a1ba Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Fri, 24 Jul 2026 11:30:21 +0000 Subject: [PATCH 2/2] fix(devnet): only restore stock loopback tcp_listen_address during migration Uncommenting any commented tcp_listen_address could turn a legacy '# tcp_listen_address = "0.0.0.0:18114"' into a public RPC listener. Now only the stock 127.0.0.1:18114 line is restored in place; non-stock commented values stay disabled and an enabled loopback entry is added instead. Addresses CodeRabbit review on #468. Co-Authored-By: Claude Fable 5 --- src/node/init-chain.ts | 10 +++++++--- tests/init-chain.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/node/init-chain.ts b/src/node/init-chain.ts index c30c763..b030cb7 100644 --- a/src/node/init-chain.ts +++ b/src/node/init-chain.ts @@ -90,12 +90,16 @@ function addTerminalModule(lines: string[], section: { start: number; end: numbe return true; } -// Enables rpc.tcp_listen_address by uncommenting the stock commented line when -// present, otherwise inserting one after the modules array. +// 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) { + 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; } diff --git a/tests/init-chain.test.ts b/tests/init-chain.test.ts index 284cba7..c405f7b 100644 --- a/tests/init-chain.test.ts +++ b/tests/init-chain.test.ts @@ -166,6 +166,22 @@ describe('migrateLegacyDevnetRpcConfig', () => { 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"',