diff --git a/README.md b/README.md index 164f98c..d575e6d 100644 --- a/README.md +++ b/README.md @@ -276,8 +276,38 @@ offckb system-scripts --output By default, OffCKB use a fixed Devnet config. You can customize it, for example by modifying the default log level (`warn,ckb-script=debug`). -1. Locate your Devnet config folder: - +1. Open the interactive Devnet config editor: + +```sh +offckb devnet config +``` + +The editor uses a three-column layout: first-column file switcher (`ckb.toml` / `ckb-miner.toml`), a middle primary editing pane, and a smaller right read-only reference pane that shows the full built-in template for the currently selected file. + +The left editing pane supports full key browsing/editing, including primitive value edits, object key add, array append/insert/move, search filter, and path delete. + +Common shortcuts: `Enter` edit primitive, `a` add key/item, `i` insert array item, `m` move array item, `d` delete path, `/` search filter, `n`/`N` next/previous search match, `c` add custom value in fixed-array dialog (when allowed), `s` save, `q` quit. + +Note: saving rewrites `ckb.toml` / `ckb-miner.toml` into canonical TOML format; upstream comments and original formatting are not preserved after save. + +You can also update the same fields non-interactively (useful for scripts/CI): + +```sh +offckb devnet config --set ckb.logger.filter=info +offckb devnet config --set ckb.rpc.enable_deprecated_rpc=true --set miner.client.poll_interval=1500 +``` + +If your terminal is non-interactive (no TTY, e.g. CI/remote pipeline), use `--set` mode directly instead of the full-screen editor. + +1. Save changes and restart devnet: + +```sh +offckb clean -d +offckb node +``` + +1. (Advanced) Locate your Devnet config folder for manual edits: + ```sh offckb config list ``` @@ -293,12 +323,12 @@ Example result: } } ``` + Pay attention to the `devnet.configPath` and `devnet.dataPath`. - -2. `cd` into the `devnet.configPath` . Modify the config files as needed. See [Custom Devnet Setup](https://docs.nervos.org/docs/node/run-devnet-node#custom-devnet-setup) and [Configure CKB](https://github.com/nervosnetwork/ckb/blob/develop/docs/configure.md) for details. -3. After modifications, run `offckb clean -d` to remove the chain data if needed while keeping the updated config files. -4. Restart local blockchain by running `offckb node` +1. `cd` into the `devnet.configPath` . Modify the config files as needed. See [Custom Devnet Setup](https://docs.nervos.org/docs/node/run-devnet-node#custom-devnet-setup) and [Configure CKB](https://github.com/nervosnetwork/ckb/blob/develop/docs/configure.md) for details. +2. After modifications, run `offckb clean -d` to remove the chain data if needed while keeping the updated config files. +3. Restart local blockchain by running `offckb node` ## Config Setting diff --git a/package.json b/package.json index f10bb31..e199324 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ }, "devDependencies": { "@types/adm-zip": "^0.5.5", + "@types/blessed": "0.1.27", "@types/jest": "^30.0.0", "@types/node": "^20.17.24", "@types/node-fetch": "^2.6.11", @@ -80,6 +81,7 @@ "@inquirer/prompts": "^7.8.6", "@types/http-proxy": "^1.17.15", "adm-zip": "^0.5.10", + "blessed": "0.1.81", "chalk": "4.1.2", "child_process": "^1.0.2", "ckb-transaction-dumper": "^0.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30e1002..282e098 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: adm-zip: specifier: ^0.5.10 version: 0.5.16 + blessed: + specifier: 0.1.81 + version: 0.1.81 chalk: specifier: 4.1.2 version: 4.1.2 @@ -57,6 +60,9 @@ importers: '@types/adm-zip': specifier: ^0.5.5 version: 0.5.7 + '@types/blessed': + specifier: 0.1.27 + version: 0.1.27 '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -719,6 +725,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/blessed@0.1.27': + resolution: {integrity: sha512-ZOQGjLvWDclAXp0rW5iuUBXeD6Gr1PkitN7tj7/G8FCoSzTsij6OhXusOzMKhwrZ9YlL2Pmu0d6xJ9zVvk+Hsg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -873,41 +882,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1114,6 +1131,11 @@ packages: resolution: {integrity: sha512-103Wy3xg8Y9o+pdhGP4M3/mtQQuUWs6sPuOp1mYphSUoSMHjHTlkj32K4zxU8qMH0Ckv23emfkGlFWtoWZ7YFA==} engines: {node: '>=0.10'} + blessed@0.1.81: + resolution: {integrity: sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==} + engines: {node: '>= 0.8.0'} + hasBin: true + bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} @@ -3860,6 +3882,10 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@types/blessed@0.1.27': + dependencies: + '@types/node': 20.17.24 + '@types/estree@1.0.8': {} '@types/http-proxy@1.17.16': @@ -4244,6 +4270,8 @@ snapshots: secp256k1: 3.8.1 varuint-bitcoin: 1.1.2 + blessed@0.1.81: {} + bn.js@4.12.3: {} body-parser@2.2.2: diff --git a/src/cli.ts b/src/cli.ts index 5a24f9a..dc82791 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { TransferOptions, transfer } from './cmd/transfer'; import { BalanceOption, balanceOf } from './cmd/balance'; import { createScriptProject, CreateScriptProjectOptions } from './cmd/create'; import { Config, ConfigItem } from './cmd/config'; +import { devnetConfig } from './cmd/devnet-config'; import { debugSingleScript, debugTransaction, parseSingleScriptOption } from './cmd/debug'; import { printSystemScripts } from './cmd/system-scripts'; import { transferAll } from './cmd/transfer-all'; @@ -164,6 +165,19 @@ program .description('do a configuration action') .action((action, item, value) => Config(action, item as ConfigItem, value)); +const devnetCommand = program.command('devnet').description('Devnet utility commands'); + +devnetCommand + .command('config') + .description('Open a full-screen editor to tweak devnet config files') + .option( + '-s, --set ', + 'Set a devnet config field non-interactively (repeatable), e.g. --set ckb.logger.filter=info', + (value: string, previous: string[] = []) => [...previous, value], + [], + ) + .action(devnetConfig); + program.parse(process.argv); // If no command is specified, display help diff --git a/src/cmd/devnet-config.ts b/src/cmd/devnet-config.ts new file mode 100644 index 0000000..cc8b5d3 --- /dev/null +++ b/src/cmd/devnet-config.ts @@ -0,0 +1,79 @@ +import { readSettings } from '../cfg/setting'; +import { logger } from '../util/logger'; +import { createDevnetConfigEditor } from '../devnet/config-editor'; +import { runDevnetConfigTui } from '../tui/devnet-config-tui'; + +export interface DevnetConfigOptions { + set?: string[]; +} + +export interface ParsedSetItem { + key: string; + value: string; +} + +export function parseSetItem(item: string): ParsedSetItem { + const separator = item.indexOf('='); + if (separator <= 0 || separator === item.length - 1) { + throw new Error(`Invalid --set item '${item}'. Use key=value format.`); + } + + const key = item.slice(0, separator).trim(); + const value = item.slice(separator + 1).trim(); + if (!key || !value) { + throw new Error(`Invalid --set item '${item}'. Key and value must not be empty.`); + } + + return { key, value }; +} + +export function applySetItems(editor: ReturnType, items: string[]): ParsedSetItem[] { + const parsedItems = items.map(parseSetItem); + for (const parsedItem of parsedItems) { + editor.setFieldValue(parsedItem.key, parsedItem.value); + } + editor.save(); + return parsedItems; +} + +export async function devnetConfig(options: DevnetConfigOptions = {}) { + const settings = readSettings(); + const configPath = settings.devnet.configPath; + + try { + const editor = createDevnetConfigEditor(configPath); + + if (options.set && options.set.length > 0) { + const parsedItems = applySetItems(editor, options.set); + logger.success(`Devnet config updated at: ${configPath}`); + logger.info( + `Applied ${parsedItems.length} setting(s): ${parsedItems.map((item) => `${item.key}=${item.value}`).join(', ')}`, + ); + logger.info('Restart devnet to apply changes: offckb clean -d && offckb node'); + return; + } + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + logger.error('Interactive devnet config editor requires a TTY terminal.'); + logger.info('Use non-interactive mode instead, e.g.:'); + logger.info(' offckb devnet config --set ckb.logger.filter=info'); + logger.info(' offckb devnet config --set miner.client.poll_interval=1500'); + process.exitCode = 1; + return; + } + + const isSaved = await runDevnetConfigTui(editor, configPath); + + if (isSaved) { + logger.success(`Devnet config updated at: ${configPath}`); + logger.info('Restart devnet to apply changes: offckb clean -d && offckb node'); + return; + } + + logger.info('No changes saved.'); + } catch (error) { + logger.error((error as Error).message); + logger.info('Tip: run `offckb node` once to initialize devnet config files first.'); + process.exitCode = 1; + } +} diff --git a/src/devnet/config-editor.ts b/src/devnet/config-editor.ts new file mode 100644 index 0000000..5c280dc --- /dev/null +++ b/src/devnet/config-editor.ts @@ -0,0 +1,695 @@ +import fs from 'fs'; +import path from 'path'; +import toml, { JsonMap } from '@iarna/toml'; + +type FieldType = 'string' | 'number' | 'boolean'; + +type EditableFieldValue = string | number | boolean; + +export type TomlPrimitive = string | number | boolean; +export type TomlValue = TomlPrimitive | TomlObject | TomlValue[]; +export interface TomlObject { + [key: string]: TomlValue; +} + +interface EditableFieldDefinition { + id: string; + file: 'ckb' | 'miner'; + label: string; + description: string; + type: FieldType; + path: Array; +} + +export interface EditableField extends EditableFieldDefinition { + value: EditableFieldValue; +} + +export interface TomlDocument { + id: 'ckb' | 'miner'; + title: string; + filePath: string; + data: TomlObject; +} + +export interface TomlEntry { + documentId: 'ckb' | 'miner'; + path: string[]; + pathText: string; + type: 'object' | 'array' | 'string' | 'number' | 'boolean'; + valuePreview: string; + editable: boolean; +} + +const editableFieldDefinitions: EditableFieldDefinition[] = [ + { + id: 'ckb.logger.filter', + file: 'ckb', + label: 'Logger filter', + description: 'CKB log filter string', + type: 'string', + path: ['logger', 'filter'], + }, + { + id: 'ckb.logger.color', + file: 'ckb', + label: 'Logger color output', + description: 'Enable colorful logs', + type: 'boolean', + path: ['logger', 'color'], + }, + { + id: 'ckb.logger.log_to_file', + file: 'ckb', + label: 'Logger output to file', + description: 'Write logs to file', + type: 'boolean', + path: ['logger', 'log_to_file'], + }, + { + id: 'ckb.logger.log_to_stdout', + file: 'ckb', + label: 'Logger output to stdout', + description: 'Write logs to stdout', + type: 'boolean', + path: ['logger', 'log_to_stdout'], + }, + { + id: 'ckb.rpc.listen_address', + file: 'ckb', + label: 'RPC listen address', + description: 'Host:port for CKB RPC', + type: 'string', + path: ['rpc', 'listen_address'], + }, + { + id: 'ckb.rpc.max_request_body_size', + file: 'ckb', + label: 'RPC max request body size', + description: 'Maximum request body size in bytes', + type: 'number', + path: ['rpc', 'max_request_body_size'], + }, + { + id: 'ckb.rpc.enable_deprecated_rpc', + file: 'ckb', + label: 'Enable deprecated RPC', + description: 'Allow deprecated CKB RPC methods', + type: 'boolean', + path: ['rpc', 'enable_deprecated_rpc'], + }, + { + id: 'miner.client.rpc_url', + file: 'miner', + label: 'Miner RPC URL', + description: 'CKB node URL used by miner', + type: 'string', + path: ['miner', 'client', 'rpc_url'], + }, + { + id: 'miner.client.block_on_submit', + file: 'miner', + label: 'Miner block on submit', + description: 'Wait for submit result before next request', + type: 'boolean', + path: ['miner', 'client', 'block_on_submit'], + }, + { + id: 'miner.client.poll_interval', + file: 'miner', + label: 'Miner poll interval', + description: 'Block template polling interval in milliseconds', + type: 'number', + path: ['miner', 'client', 'poll_interval'], + }, +]; + +const safeFieldDefinitionMap = new Map( + editableFieldDefinitions.map((definition) => [ + `${definition.file}:${definition.path.map((item) => String(item)).join('.')}`, + definition, + ]), +); + +function getByPath(target: Record, keyPath: Array): unknown { + let current: unknown = target; + for (const key of keyPath) { + if (current == null || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[String(key)]; + } + return current; +} + +function setByPath(target: Record, keyPath: Array, value: unknown): void { + if (keyPath.length === 0) { + return; + } + + let current: Record = target; + for (let i = 0; i < keyPath.length - 1; i++) { + const part = String(keyPath[i]); + const next = current[part]; + if (next == null || typeof next !== 'object') { + current[part] = {}; + } + current = current[part] as Record; + } + + current[String(keyPath[keyPath.length - 1])] = value; +} + +function isPlainObject(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function isTomlPrimitive(value: unknown): value is TomlPrimitive { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; +} + +function getTypeOfTomlValue(value: unknown): TomlEntry['type'] { + if (Array.isArray(value)) { + return 'array'; + } + if (isPlainObject(value)) { + return 'object'; + } + if (typeof value === 'string') { + return 'string'; + } + if (typeof value === 'number') { + return 'number'; + } + return 'boolean'; +} + +function valuePreview(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.length}]`; + } + if (isPlainObject(value)) { + return `[${Object.keys(value).length} keys]`; + } + if (typeof value === 'string') { + if (value.length > 80) { + return `${value.slice(0, 80)}...`; + } + return value; + } + return String(value); +} + +function validateHostPort(value: string): boolean { + const trimmed = value.trim(); + const separator = trimmed.lastIndexOf(':'); + if (separator <= 0 || separator === trimmed.length - 1) { + return false; + } + + const port = Number(trimmed.slice(separator + 1)); + return Number.isInteger(port) && port > 0 && port <= 65535; +} + +function validateHttpUrl(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +function normalizeBooleanInput(value: string): boolean | null { + const normalized = value.trim().toLowerCase(); + if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + if (['false', '0', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + return null; +} + +function parseValueByExistingType(rawInput: string, existingValue: unknown): TomlPrimitive { + if (typeof existingValue === 'boolean') { + const parsedBoolean = normalizeBooleanInput(rawInput); + if (parsedBoolean == null) { + throw new Error('Boolean value must be one of: true/false/yes/no/1/0.'); + } + return parsedBoolean; + } + + if (typeof existingValue === 'number') { + const parsedNumber = Number(rawInput.trim()); + if (!Number.isFinite(parsedNumber)) { + throw new Error('Number value must be finite.'); + } + return parsedNumber; + } + + const trimmed = rawInput.trim(); + if (!trimmed) { + throw new Error('Value cannot be empty.'); + } + return trimmed; +} + +function parseInputAsTomlPrimitive(rawInput: string): TomlPrimitive { + const trimmed = rawInput.trim(); + if (!trimmed) { + throw new Error('Value cannot be empty.'); + } + + const parsedBoolean = normalizeBooleanInput(trimmed); + if (parsedBoolean != null) { + return parsedBoolean; + } + + const parsedNumber = Number(trimmed); + if (!Number.isNaN(parsedNumber) && Number.isFinite(parsedNumber)) { + return parsedNumber; + } + + return trimmed; +} + +function readTomlFile(filePath: string): Record { + const text = fs.readFileSync(filePath, 'utf8'); + return toml.parse(text) as unknown as Record; +} + +function writeTomlFileAtomic(filePath: string, data: Record) { + const tempFilePath = `${filePath}.tmp`; + const text = toml.stringify(data as unknown as JsonMap); + fs.writeFileSync(tempFilePath, text, 'utf8'); + fs.renameSync(tempFilePath, filePath); +} + +function requirePath( + target: Record, + pathParts: string[], + guard: (value: unknown) => value is T, + message: string, +): T { + const value = getByPath(target, pathParts); + if (!guard(value)) { + throw new Error(message); + } + return value; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +export class DevnetConfigEditor { + readonly configPath: string; + readonly ckbTomlPath: string; + readonly minerTomlPath: string; + + private ckbConfig: Record; + private minerConfig: Record; + private values: Record; + private documents: Record<'ckb' | 'miner', TomlDocument>; + + constructor(configPath: string, ckbConfig: Record, minerConfig: Record) { + this.configPath = configPath; + this.ckbTomlPath = path.join(configPath, 'ckb.toml'); + this.minerTomlPath = path.join(configPath, 'ckb-miner.toml'); + this.ckbConfig = ckbConfig; + this.minerConfig = minerConfig; + + this.values = {}; + for (const definition of editableFieldDefinitions) { + const source = definition.file === 'ckb' ? this.ckbConfig : this.minerConfig; + const value = getByPath(source, definition.path); + if (value == null) { + throw new Error(`Unsupported config layout: missing field '${definition.id}' in devnet config files.`); + } + + if (definition.type === 'string' && typeof value !== 'string') { + throw new Error(`Unexpected type for '${definition.id}', expected string.`); + } + if (definition.type === 'number' && typeof value !== 'number') { + throw new Error(`Unexpected type for '${definition.id}', expected number.`); + } + if (definition.type === 'boolean' && typeof value !== 'boolean') { + throw new Error(`Unexpected type for '${definition.id}', expected boolean.`); + } + + this.values[definition.id] = value as EditableFieldValue; + } + + this.documents = { + ckb: { + id: 'ckb', + title: 'ckb.toml', + filePath: this.ckbTomlPath, + data: this.ckbConfig as TomlObject, + }, + miner: { + id: 'miner', + title: 'ckb-miner.toml', + filePath: this.minerTomlPath, + data: this.minerConfig as TomlObject, + }, + }; + } + + getDocuments(): TomlDocument[] { + return [this.documents.ckb, this.documents.miner]; + } + + getDocument(documentId: 'ckb' | 'miner'): TomlDocument { + return this.documents[documentId]; + } + + getEntriesForDocument(documentId: 'ckb' | 'miner'): TomlEntry[] { + const entries: TomlEntry[] = []; + const document = this.getDocument(documentId); + + const walk = (value: unknown, currentPath: string[]) => { + if (currentPath.length > 0) { + const entryType = getTypeOfTomlValue(value); + entries.push({ + documentId, + path: currentPath, + pathText: currentPath.join('.'), + type: entryType, + valuePreview: entryType === 'object' ? '' : valuePreview(value), + editable: entryType === 'string' || entryType === 'number' || entryType === 'boolean', + }); + } + + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, [...currentPath, String(index)])); + return; + } + + if (isPlainObject(value)) { + for (const key of Object.keys(value)) { + walk(value[key], [...currentPath, key]); + } + } + }; + + walk(document.data, []); + return entries; + } + + getEntryValue(documentId: 'ckb' | 'miner', pathParts: string[]): unknown { + return getByPath(this.getDocument(documentId).data as Record, pathParts); + } + + getFields(): EditableField[] { + return editableFieldDefinitions.map((definition) => ({ + ...definition, + value: this.values[definition.id], + })); + } + + getField(fieldId: string): EditableField { + const definition = editableFieldDefinitions.find((item) => item.id === fieldId); + if (definition == null) { + throw new Error(`Unknown field '${fieldId}'.`); + } + + return { + ...definition, + value: this.values[definition.id], + }; + } + + setFieldValue(fieldId: string, rawInput: string): EditableFieldValue { + const definition = editableFieldDefinitions.find((item) => item.id === fieldId); + if (definition == null) { + throw new Error(`Unknown field '${fieldId}'.`); + } + + const trimmed = rawInput.trim(); + if (definition.type === 'string') { + if (!trimmed) { + throw new Error('Value cannot be empty.'); + } + + if (definition.id === 'ckb.rpc.listen_address' && !validateHostPort(trimmed)) { + throw new Error('RPC listen address must be in host:port format.'); + } + + if (definition.id === 'miner.client.rpc_url' && !validateHttpUrl(trimmed)) { + throw new Error('Miner RPC URL must be a valid HTTP/HTTPS URL.'); + } + + this.values[fieldId] = trimmed; + setByPath( + this.getDocument(definition.file).data as Record, + definition.path, + this.values[fieldId], + ); + return this.values[fieldId]; + } + + if (definition.type === 'number') { + const parsed = Number(trimmed); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error('Value must be a positive integer.'); + } + + this.values[fieldId] = parsed; + setByPath( + this.getDocument(definition.file).data as Record, + definition.path, + this.values[fieldId], + ); + return this.values[fieldId]; + } + + const parsedBoolean = normalizeBooleanInput(trimmed); + if (parsedBoolean == null) { + throw new Error('Boolean value must be one of: true/false/yes/no/1/0.'); + } + + this.values[fieldId] = parsedBoolean; + setByPath(this.getDocument(definition.file).data as Record, definition.path, this.values[fieldId]); + return this.values[fieldId]; + } + + setDocumentValue(documentId: 'ckb' | 'miner', pathParts: string[], rawInput: string): TomlPrimitive { + if (pathParts.length === 0) { + throw new Error('Cannot edit the root node.'); + } + + const fileKey = `${documentId}:${pathParts.join('.')}`; + const safeDefinition = safeFieldDefinitionMap.get(fileKey); + if (safeDefinition != null) { + return this.setFieldValue(safeDefinition.id, rawInput); + } + + const document = this.getDocument(documentId); + const currentValue = getByPath(document.data as Record, pathParts); + if (!isTomlPrimitive(currentValue)) { + throw new Error('Only primitive values can be edited directly.'); + } + + const parsedValue = parseValueByExistingType(rawInput, currentValue); + setByPath(document.data as Record, pathParts, parsedValue); + return parsedValue; + } + + addObjectEntry(documentId: 'ckb' | 'miner', pathParts: string[], key: string, rawValue: string): TomlPrimitive { + const target = getByPath(this.getDocument(documentId).data as Record, pathParts); + if (!isPlainObject(target)) { + throw new Error('Target path is not an object.'); + } + + const normalizedKey = key.trim(); + if (!normalizedKey) { + throw new Error('Key cannot be empty.'); + } + if (normalizedKey in target) { + throw new Error(`Key '${normalizedKey}' already exists.`); + } + + const parsedValue = parseInputAsTomlPrimitive(rawValue); + target[normalizedKey] = parsedValue; + return parsedValue; + } + + appendArrayEntry(documentId: 'ckb' | 'miner', pathParts: string[], rawValue: string): TomlPrimitive { + const target = getByPath(this.getDocument(documentId).data as Record, pathParts); + if (!Array.isArray(target)) { + throw new Error('Target path is not an array.'); + } + + const parsedValue = parseInputAsTomlPrimitive(rawValue); + target.push(parsedValue); + return parsedValue; + } + + insertArrayEntry(documentId: 'ckb' | 'miner', pathParts: string[], index: number, rawValue: string): TomlPrimitive { + const target = getByPath(this.getDocument(documentId).data as Record, pathParts); + if (!Array.isArray(target)) { + throw new Error('Target path is not an array.'); + } + + if (!Number.isInteger(index) || index < 0 || index > target.length) { + throw new Error(`Insert index must be between 0 and ${target.length}.`); + } + + const parsedValue = parseInputAsTomlPrimitive(rawValue); + target.splice(index, 0, parsedValue); + return parsedValue; + } + + moveArrayEntry(documentId: 'ckb' | 'miner', pathParts: string[], fromIndex: number, toIndex: number): void { + const target = getByPath(this.getDocument(documentId).data as Record, pathParts); + if (!Array.isArray(target)) { + throw new Error('Target path is not an array.'); + } + + if (!Number.isInteger(fromIndex) || fromIndex < 0 || fromIndex >= target.length) { + throw new Error(`Source index must be between 0 and ${Math.max(0, target.length - 1)}.`); + } + if (!Number.isInteger(toIndex) || toIndex < 0 || toIndex >= target.length) { + throw new Error(`Target index must be between 0 and ${Math.max(0, target.length - 1)}.`); + } + + if (fromIndex === toIndex) { + return; + } + + const [item] = target.splice(fromIndex, 1); + target.splice(toIndex, 0, item); + } + + setArrayValues(documentId: 'ckb' | 'miner', pathParts: string[], values: string[]): void { + const target = getByPath(this.getDocument(documentId).data as Record, pathParts); + if (!Array.isArray(target)) { + throw new Error('Target path is not an array.'); + } + target.splice(0, target.length, ...values); + } + + deleteDocumentPath(documentId: 'ckb' | 'miner', pathParts: string[]): void { + if (pathParts.length === 0) { + throw new Error('Cannot delete the root node.'); + } + + const parentPath = pathParts.slice(0, -1); + const key = pathParts[pathParts.length - 1]; + const parent = getByPath(this.getDocument(documentId).data as Record, parentPath); + + if (Array.isArray(parent)) { + const index = Number(key); + if (!Number.isInteger(index) || index < 0 || index >= parent.length) { + throw new Error('Invalid array index for deletion.'); + } + parent.splice(index, 1); + return; + } + + if (isPlainObject(parent)) { + if (!(key in parent)) { + throw new Error(`Key '${key}' does not exist.`); + } + delete parent[key]; + return; + } + + throw new Error('Target path cannot be deleted.'); + } + + toggleBooleanField(fieldId: string): EditableFieldValue { + const field = this.getField(fieldId); + if (field.type !== 'boolean') { + throw new Error(`Field '${fieldId}' is not boolean.`); + } + + const nextValue = !field.value; + this.values[fieldId] = nextValue; + setByPath(this.getDocument(field.file).data as Record, field.path, nextValue); + return nextValue; + } + + private validateBeforeSave(): void { + const ckb = this.documents.ckb.data as unknown as Record; + const miner = this.documents.miner.data as unknown as Record; + + const listenAddress = requirePath( + ckb, + ['rpc', 'listen_address'], + isNonEmptyString, + 'Invalid config: rpc.listen_address must be a non-empty string.', + ); + if (!validateHostPort(listenAddress)) { + throw new Error('Invalid config: rpc.listen_address must be in host:port format.'); + } + + const rpcModules = requirePath( + ckb, + ['rpc', 'modules'], + isStringArray, + 'Invalid config: rpc.modules must be an array of strings.', + ); + if (rpcModules.length === 0) { + throw new Error('Invalid config: rpc.modules must include at least one module.'); + } + if (rpcModules.some((moduleName) => moduleName.trim().length === 0)) { + throw new Error('Invalid config: rpc.modules must not contain empty module names.'); + } + + const supportProtocols = requirePath( + ckb, + ['network', 'support_protocols'], + isStringArray, + 'Invalid config: network.support_protocols must be an array of strings.', + ); + if (!supportProtocols.includes('Sync') || !supportProtocols.includes('Identify')) { + throw new Error('Invalid config: network.support_protocols must include both Sync and Identify.'); + } + if (supportProtocols.some((protocol) => protocol.trim().length === 0)) { + throw new Error('Invalid config: network.support_protocols must not contain empty protocol names.'); + } + + const minerRpcUrl = requirePath( + miner, + ['miner', 'client', 'rpc_url'], + isNonEmptyString, + 'Invalid config: miner.client.rpc_url must be a non-empty string.', + ); + if (!validateHttpUrl(minerRpcUrl)) { + throw new Error('Invalid config: miner.client.rpc_url must be a valid HTTP/HTTPS URL.'); + } + } + + save(): void { + this.validateBeforeSave(); + writeTomlFileAtomic(this.ckbTomlPath, this.documents.ckb.data as unknown as Record); + writeTomlFileAtomic(this.minerTomlPath, this.documents.miner.data as unknown as Record); + } +} + +export function createDevnetConfigEditor(configPath: string): DevnetConfigEditor { + const ckbTomlPath = path.join(configPath, 'ckb.toml'); + const minerTomlPath = path.join(configPath, 'ckb-miner.toml'); + + if (!fs.existsSync(configPath)) { + throw new Error(`Devnet config path does not exist: ${configPath}`); + } + if (!fs.existsSync(ckbTomlPath)) { + throw new Error(`Missing file: ${ckbTomlPath}`); + } + if (!fs.existsSync(minerTomlPath)) { + throw new Error(`Missing file: ${minerTomlPath}`); + } + + const ckbConfig = readTomlFile(ckbTomlPath); + const minerConfig = readTomlFile(minerTomlPath); + + return new DevnetConfigEditor(configPath, ckbConfig, minerConfig); +} diff --git a/src/tui/actions.ts b/src/tui/actions.ts new file mode 100644 index 0000000..073aa91 --- /dev/null +++ b/src/tui/actions.ts @@ -0,0 +1,489 @@ +import { TomlEntry } from '../devnet/config-editor'; +import { getFixedArraySpecFromEntryPath, FixedArraySpec } from './devnet-config-metadata'; +import { waitForInput, waitForConfirm, waitForFixedArraySelection, waitForArrayValue } from './dialogs'; +import { TuiState, TuiWidgets } from './tui-state'; + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +export function parseNonNegativeInteger(value: string, fieldName: string): number { + const parsed = Number(value.trim()); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${fieldName} must be a non-negative integer.`); + } + return parsed; +} + +export function resolveArrayTarget( + selectedEntry: TomlEntry, +): { arrayPath: string[]; suggestedIndex: number | null } | null { + if (selectedEntry.type === 'array') { + return { arrayPath: selectedEntry.path, suggestedIndex: null }; + } + + const lastPart = selectedEntry.path[selectedEntry.path.length - 1]; + const parsedIndex = Number(lastPart); + if (Number.isInteger(parsedIndex) && parsedIndex >= 0) { + return { arrayPath: selectedEntry.path.slice(0, -1), suggestedIndex: parsedIndex }; + } + + return null; +} + +export function resolveFixedArrayTarget( + selectedEntry: TomlEntry, +): { arrayPath: string[]; spec: FixedArraySpec } | null { + const spec = getFixedArraySpecFromEntryPath(selectedEntry.path); + if (spec == null) return null; + + if (selectedEntry.type === 'array') { + return { arrayPath: selectedEntry.path, spec }; + } + + const lastPart = selectedEntry.path[selectedEntry.path.length - 1]; + if (/^\d+$/.test(lastPart)) { + return { arrayPath: selectedEntry.path.slice(0, -1), spec }; + } + + return { arrayPath: selectedEntry.path, spec }; +} + +// --------------------------------------------------------------------------- +// Shared sub-action: edit a fixed-array via multi-select dialog +// --------------------------------------------------------------------------- + +export async function editFixedArraySelection( + state: TuiState, + screen: import('blessed').Widgets.Screen, + documentId: 'ckb' | 'miner', + arrayPath: string[], + spec: FixedArraySpec, + refreshUi: () => void, +): Promise { + const rawArrayValue = state.editor.getEntryValue(documentId, arrayPath); + if (!Array.isArray(rawArrayValue)) { + state.statusMessage = `Path ${arrayPath.join('.')} is not an array.`; + refreshUi(); + return; + } + + const currentValues = rawArrayValue.map((item) => String(item)); + const selectedValues = await waitForFixedArraySelection(screen, `Edit ${spec.label}`, spec, currentValues); + if (selectedValues == null) { + state.statusMessage = 'Edit canceled.'; + refreshUi(); + return; + } + + const nextValues = spec.unique ? Array.from(new Set(selectedValues)) : selectedValues; + state.editor.setArrayValues(documentId, arrayPath, nextValues); + state.hasUnsavedChanges = true; + state.statusMessage = `Updated ${arrayPath.join('.')} (${nextValues.length} selected).`; + refreshUi(); +} + +// --------------------------------------------------------------------------- +// Action context: common args passed to every action +// --------------------------------------------------------------------------- + +export interface ActionContext { + state: TuiState; + widgets: TuiWidgets; + refreshUi: () => void; +} + +function currentDocument(ctx: ActionContext) { + return ctx.state.documents[ctx.state.selectedDocumentIndex]; +} + +function currentEntry(ctx: ActionContext): TomlEntry | null { + if (ctx.state.visibleEntries.length === 0) return null; + return ctx.state.visibleEntries[ctx.state.selectedEntryIndex] ?? null; +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +export async function editCurrentEntry(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + + if (state.focusPane !== 'entries') { + state.focusPane = 'entries'; + refreshUi(); + return; + } + + const doc = currentDocument(ctx); + const entry = currentEntry(ctx); + if (entry == null) return; + + const fixedTarget = resolveFixedArrayTarget(entry); + if (fixedTarget != null) { + await editFixedArraySelection(state, widgets.screen, doc.id, fixedTarget.arrayPath, fixedTarget.spec, refreshUi); + return; + } + + if (!entry.editable) { + state.statusMessage = `Path ${entry.pathText} is not primitive-editable yet.`; + refreshUi(); + return; + } + + const value = state.editor.getEntryValue(entry.documentId, entry.path); + const valueText = value == null ? '' : String(value); + const answer = await waitForArrayValue(widgets.screen, null, 'Edit Value', entry.pathText, valueText); + if (answer == null) { + state.statusMessage = 'Edit canceled.'; + refreshUi(); + return; + } + + try { + state.editor.setDocumentValue(entry.documentId, entry.path, answer); + state.hasUnsavedChanges = true; + state.statusMessage = `Updated ${entry.pathText}.`; + } catch (error) { + state.statusMessage = `Validation error: ${(error as Error).message}`; + } + + refreshUi(); +} + +export async function searchEntries(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + + const answer = await waitForInput( + widgets.screen, + 'Search', + 'Path/type/value filter (empty clears):', + state.searchTerm, + ); + if (answer == null) { + state.statusMessage = 'Search canceled.'; + refreshUi(); + return; + } + + state.searchTerm = answer.trim(); + state.selectedEntryIndex = 0; + state.statusMessage = state.searchTerm ? `Filter applied: ${state.searchTerm}` : 'Search filter cleared.'; + refreshUi(); +} + +export function jumpSearchMatch(ctx: ActionContext, direction: 'next' | 'prev'): void { + const { state, refreshUi } = ctx; + + if (state.visibleEntries.length === 0) { + state.statusMessage = state.searchTerm ? 'No search matches to jump.' : 'Set search filter first with /.'; + refreshUi(); + return; + } + + if (direction === 'next') { + state.selectedEntryIndex = (state.selectedEntryIndex + 1) % state.visibleEntries.length; + state.statusMessage = `Jumped to next match (${state.selectedEntryIndex + 1}/${state.visibleEntries.length}).`; + } else { + state.selectedEntryIndex = + (state.selectedEntryIndex - 1 + state.visibleEntries.length) % state.visibleEntries.length; + state.statusMessage = `Jumped to previous match (${state.selectedEntryIndex + 1}/${state.visibleEntries.length}).`; + } + + refreshUi(); +} + +export async function addEntry(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + const doc = currentDocument(ctx); + + const targetEntry = + state.visibleEntries.length > 0 + ? state.visibleEntries[Math.min(state.selectedEntryIndex, state.visibleEntries.length - 1)] + : null; + + const targetPath = targetEntry?.path ?? []; + const targetValue = state.editor.getEntryValue(doc.id, targetPath); + + if (targetEntry == null && !Array.isArray(targetValue) && (targetValue == null || typeof targetValue !== 'object')) { + state.statusMessage = 'No target object/array selected for add.'; + refreshUi(); + return; + } + + if ( + targetEntry?.type === 'object' || + (targetEntry == null && targetValue != null && typeof targetValue === 'object') + ) { + const keyAnswer = await waitForInput(widgets.screen, 'Add Object Key', 'New key name:', ''); + if (keyAnswer == null) { + state.statusMessage = 'Add canceled.'; + refreshUi(); + return; + } + + const valueAnswer = await waitForInput( + widgets.screen, + 'Add Object Key', + `Value for ${keyAnswer.trim()} (auto parse bool/number):`, + '', + ); + if (valueAnswer == null) { + state.statusMessage = 'Add canceled.'; + refreshUi(); + return; + } + + try { + state.editor.addObjectEntry(doc.id, targetPath, keyAnswer, valueAnswer); + state.hasUnsavedChanges = true; + state.statusMessage = `Added key '${keyAnswer.trim()}' under ${targetPath.join('.') || ''}.`; + } catch (error) { + state.statusMessage = `Add failed: ${(error as Error).message}`; + } + refreshUi(); + return; + } + + if (targetEntry?.type === 'array') { + const fixedTarget = resolveFixedArrayTarget(targetEntry); + if (fixedTarget != null) { + await editFixedArraySelection(state, widgets.screen, doc.id, fixedTarget.arrayPath, fixedTarget.spec, refreshUi); + return; + } + + const valueAnswer = await waitForArrayValue( + widgets.screen, + null, + 'Append Array Item', + `Append value to ${targetEntry.pathText}:`, + '', + ); + if (valueAnswer == null) { + state.statusMessage = 'Append canceled.'; + refreshUi(); + return; + } + + try { + state.editor.appendArrayEntry(doc.id, targetEntry.path, valueAnswer); + state.hasUnsavedChanges = true; + state.statusMessage = `Appended value to ${targetEntry.pathText}.`; + } catch (error) { + state.statusMessage = `Append failed: ${(error as Error).message}`; + } + refreshUi(); + return; + } + + state.statusMessage = 'Select an object or array node to add items.'; + refreshUi(); +} + +export async function insertArrayEntry(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + + const entry = currentEntry(ctx); + if (entry == null) { + state.statusMessage = 'No selected entry for insert.'; + refreshUi(); + return; + } + + const doc = currentDocument(ctx); + const target = resolveArrayTarget(entry); + if (target == null) { + state.statusMessage = 'Select an array or array item to insert.'; + refreshUi(); + return; + } + + const fixedSpec = getFixedArraySpecFromEntryPath(target.arrayPath); + if (fixedSpec != null) { + await editFixedArraySelection(state, widgets.screen, doc.id, target.arrayPath, fixedSpec, refreshUi); + return; + } + + const arrayValue = state.editor.getEntryValue(doc.id, target.arrayPath); + const arrayLength = Array.isArray(arrayValue) ? arrayValue.length : 0; + const indexAnswer = await waitForInput( + widgets.screen, + 'Insert Array Item', + `Insert index (0-${arrayLength}${target.suggestedIndex != null ? `, default ${target.suggestedIndex}` : ''}):`, + target.suggestedIndex != null ? String(target.suggestedIndex) : String(arrayLength), + ); + if (indexAnswer == null) { + state.statusMessage = 'Insert canceled.'; + refreshUi(); + return; + } + + const valueAnswer = await waitForArrayValue( + widgets.screen, + null, + 'Insert Array Item', + `Value to insert at ${target.arrayPath.join('.')} (auto parse bool/number):`, + '', + ); + if (valueAnswer == null) { + state.statusMessage = 'Insert canceled.'; + refreshUi(); + return; + } + + try { + const indexInput = indexAnswer.trim(); + const insertIndex = + indexInput === '' && target.suggestedIndex != null + ? target.suggestedIndex + : parseNonNegativeInteger(indexAnswer, 'Insert index'); + + state.editor.insertArrayEntry(doc.id, target.arrayPath, insertIndex, valueAnswer); + state.hasUnsavedChanges = true; + state.statusMessage = `Inserted array item at ${target.arrayPath.join('.')}[${insertIndex}].`; + } catch (error) { + state.statusMessage = `Insert failed: ${(error as Error).message}`; + } + + refreshUi(); +} + +export async function moveArrayEntry(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + + const entry = currentEntry(ctx); + if (entry == null) { + state.statusMessage = 'No selected entry for move.'; + refreshUi(); + return; + } + + const doc = currentDocument(ctx); + const target = resolveArrayTarget(entry); + if (target == null) { + state.statusMessage = 'Select an array or array item to move.'; + refreshUi(); + return; + } + + const fixedSpec = getFixedArraySpecFromEntryPath(target.arrayPath); + if (fixedSpec != null) { + await editFixedArraySelection(state, widgets.screen, doc.id, target.arrayPath, fixedSpec, refreshUi); + return; + } + + const arrayValue = state.editor.getEntryValue(doc.id, target.arrayPath); + const arrayLength = Array.isArray(arrayValue) ? arrayValue.length : 0; + + const fromAnswer = await waitForInput( + widgets.screen, + 'Move Array Item', + `Move from index (0-${Math.max(0, arrayLength - 1)}${target.suggestedIndex != null ? `, default ${target.suggestedIndex}` : ''}):`, + target.suggestedIndex != null ? String(target.suggestedIndex) : '0', + ); + if (fromAnswer == null) { + state.statusMessage = 'Move canceled.'; + refreshUi(); + return; + } + + const toAnswer = await waitForInput( + widgets.screen, + 'Move Array Item', + `Move to index (0-${Math.max(0, arrayLength - 1)}):`, + '0', + ); + if (toAnswer == null) { + state.statusMessage = 'Move canceled.'; + refreshUi(); + return; + } + + try { + const fromInput = fromAnswer.trim(); + const fromIndex = + fromInput === '' && target.suggestedIndex != null + ? target.suggestedIndex + : parseNonNegativeInteger(fromAnswer, 'Source index'); + const toIndex = parseNonNegativeInteger(toAnswer, 'Target index'); + + state.editor.moveArrayEntry(doc.id, target.arrayPath, fromIndex, toIndex); + state.hasUnsavedChanges = true; + state.statusMessage = `Moved item in ${target.arrayPath.join('.')} from ${fromIndex} to ${toIndex}.`; + } catch (error) { + state.statusMessage = `Move failed: ${(error as Error).message}`; + } + + refreshUi(); +} + +export async function deleteEntry(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + const doc = currentDocument(ctx); + + const entry = currentEntry(ctx); + if (entry == null) { + state.statusMessage = 'No selected entry to delete.'; + refreshUi(); + return; + } + + const fixedTarget = resolveFixedArrayTarget(entry); + if (fixedTarget != null) { + await editFixedArraySelection(state, widgets.screen, doc.id, fixedTarget.arrayPath, fixedTarget.spec, refreshUi); + return; + } + + const confirmed = await waitForConfirm(widgets.screen, 'Delete Path', `Delete ${entry.pathText}?`); + if (!confirmed) { + state.statusMessage = 'Delete canceled.'; + refreshUi(); + return; + } + + try { + state.editor.deleteDocumentPath(doc.id, entry.path); + state.hasUnsavedChanges = true; + state.selectedEntryIndex = Math.max(0, state.selectedEntryIndex - 1); + state.statusMessage = `Deleted ${entry.pathText}.`; + } catch (error) { + state.statusMessage = `Delete failed: ${(error as Error).message}`; + } + refreshUi(); +} + +export async function quitFlow(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + + if (!state.hasUnsavedChanges) { + widgets.screen.destroy(); + return; + } + + const shouldDiscard = await waitForConfirm( + widgets.screen, + 'Unsaved Changes', + 'You have unsaved changes. Press S to save and exit, or discard changes and exit.', + { + confirmLabel: 'Discard & Exit', + cancelLabel: 'Keep Editing', + defaultFocus: 'cancel', + }, + ); + if (shouldDiscard) { + widgets.screen.destroy(); + return; + } + + state.statusMessage = 'Continue editing.'; + refreshUi(); +} + +export function saveAndExit(ctx: ActionContext): void { + const { state, widgets } = ctx; + state.editor.save(); + state.hasUnsavedChanges = false; + state.didSave = true; + state.statusMessage = 'Saved.'; + widgets.screen.destroy(); +} diff --git a/src/tui/blessed-helpers.ts b/src/tui/blessed-helpers.ts new file mode 100644 index 0000000..2fac76b --- /dev/null +++ b/src/tui/blessed-helpers.ts @@ -0,0 +1,9 @@ +import { Widgets } from 'blessed'; + +/** + * Safely read the `selected` index from a blessed list element. + * Blessed exposes `.selected` at runtime but the type declarations omit it. + */ +export function getListSelected(list: Widgets.ListElement): number { + return (list as unknown as { selected?: number }).selected ?? 0; +} diff --git a/src/tui/devnet-config-metadata.ts b/src/tui/devnet-config-metadata.ts new file mode 100644 index 0000000..5fa300e --- /dev/null +++ b/src/tui/devnet-config-metadata.ts @@ -0,0 +1,237 @@ +export interface ConfigDoc { + summary: string; + source: string; +} + +interface ConfigDocRule { + pathPattern: string; + doc: ConfigDoc; +} + +export interface FixedArraySpec { + pathPattern: string; + label: string; + options: string[]; + unique: boolean; + allowCustom: boolean; + source: string; +} + +const CONFIG_DOCS: ConfigDocRule[] = [ + { + pathPattern: 'logger.filter', + doc: { + summary: 'Rust log filter directive list used by CKB logger.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'rpc.listen_address', + doc: { + summary: 'Address for JSON-RPC server binding (host:port).', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'rpc.max_request_body_size', + doc: { + summary: 'Maximum HTTP RPC request body size in bytes.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'rpc.modules', + doc: { + summary: 'Enabled RPC modules; known defaults plus custom module names if supported.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'rpc.modules.#', + doc: { + summary: 'One enabled RPC module entry.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'network.listen_addresses', + doc: { + summary: 'Node P2P listen multiaddr list.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'network.listen_addresses.#', + doc: { + summary: 'One P2P listen multiaddr entry.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'network.bootnodes', + doc: { + summary: 'Seed peers used for bootstrap discovery.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'network.bootnodes.#', + doc: { + summary: 'One bootnode multiaddr entry.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'network.max_peers', + doc: { + summary: 'Maximum connected peers (inbound + outbound).', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'network.support_protocols', + doc: { + summary: 'Enabled CKB p2p protocols; must include Sync and Identify.', + source: 'CKB configure docs / CKB protocol definitions', + }, + }, + { + pathPattern: 'network.support_protocols.#', + doc: { + summary: 'One enabled p2p protocol name.', + source: 'CKB protocol definitions', + }, + }, + { + pathPattern: 'miner.client.rpc_url', + doc: { + summary: 'CKB RPC endpoint used by ckb-miner.', + source: 'CKB miner configure docs', + }, + }, + { + pathPattern: 'miner.client.poll_interval', + doc: { + summary: 'Polling interval for new block templates (ms).', + source: 'CKB miner configure docs', + }, + }, + { + pathPattern: 'miner.client.block_on_submit', + doc: { + summary: 'Wait for submit completion before next loop.', + source: 'CKB miner configure docs', + }, + }, +]; + +const FIXED_ARRAY_SPECS: FixedArraySpec[] = [ + { + pathPattern: 'network.support_protocols', + label: 'Network Protocols', + options: [ + 'Ping', + 'Discovery', + 'Identify', + 'Feeler', + 'DisconnectMessage', + 'Sync', + 'Relay', + 'Time', + 'Alert', + 'LightClient', + 'Filter', + ], + unique: true, + allowCustom: false, + source: 'CKB protocol definitions', + }, + { + pathPattern: 'rpc.modules', + label: 'RPC Modules', + options: [ + 'Net', + 'Pool', + 'Miner', + 'Chain', + 'Stats', + 'Experiment', + 'Debug', + 'IntegrationTest', + 'Indexer', + 'Subscription', + ], + unique: true, + allowCustom: true, + source: 'CKB configure docs', + }, +]; + +function splitPattern(pattern: string): string[] { + return pattern.split('.').filter(Boolean); +} + +function isNumericSegment(value: string): boolean { + return /^\d+$/.test(value); +} + +function matchPattern(pathSegments: string[], patternSegments: string[]): boolean { + if (pathSegments.length !== patternSegments.length) { + return false; + } + + return patternSegments.every((patternSegment, index) => { + if (patternSegment === '*') { + return true; + } + + if (patternSegment === '#') { + return isNumericSegment(pathSegments[index]); + } + + return pathSegments[index] === patternSegment; + }); +} + +function wildcardScore(patternSegments: string[]): number { + return patternSegments.filter((segment) => segment === '*' || segment === '#').length; +} + +export function getConfigDoc(pathSegments: string[]): ConfigDoc | null { + const matches = CONFIG_DOCS.filter((rule) => matchPattern(pathSegments, splitPattern(rule.pathPattern))).sort( + (a, b) => { + const aScore = wildcardScore(splitPattern(a.pathPattern)); + const bScore = wildcardScore(splitPattern(b.pathPattern)); + return aScore - bScore; + }, + ); + + if (matches.length === 0) { + return null; + } + + return matches[0].doc; +} + +export function getFixedArraySpec(pathSegments: string[]): FixedArraySpec | null { + const match = FIXED_ARRAY_SPECS.find((spec) => matchPattern(pathSegments, splitPattern(spec.pathPattern))); + return match ?? null; +} + +export function getFixedArraySpecFromEntryPath(pathSegments: string[]): FixedArraySpec | null { + const direct = getFixedArraySpec(pathSegments); + if (direct != null) { + return direct; + } + + if (pathSegments.length === 0) { + return null; + } + + const last = pathSegments[pathSegments.length - 1]; + if (!isNumericSegment(last)) { + return null; + } + + return getFixedArraySpec(pathSegments.slice(0, -1)); +} diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts new file mode 100644 index 0000000..523efe6 --- /dev/null +++ b/src/tui/devnet-config-tui.ts @@ -0,0 +1,421 @@ +import blessed from 'blessed'; +import path from 'path'; +import { DevnetConfigEditor, TomlEntry } from '../devnet/config-editor'; +import { getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; +import { getEmbeddedReferenceTemplate } from './devnet-reference-templates'; +import { formatEntryLine, formatFixedArrayDetailLine } from './format'; +import { createTuiState, TuiWidgets } from './tui-state'; +import { getListSelected } from './blessed-helpers'; +import { + ActionContext, + editCurrentEntry, + searchEntries, + jumpSearchMatch, + addEntry, + deleteEntry, + insertArrayEntry, + moveArrayEntry, + quitFlow, + saveAndExit, +} from './actions'; + +interface EntryRenderRow { + text: string; + entryIndex: number; + selectable: boolean; +} + +// --------------------------------------------------------------------------- +// Visible-entry filter (compact fixed-array items + search term) +// --------------------------------------------------------------------------- + +function getVisibleEntries(entries: TomlEntry[], searchTerm: string): TomlEntry[] { + const compactEntries = entries.filter((entry) => { + if (entry.path.length === 0) return true; + const lastPathPart = entry.path[entry.path.length - 1]; + const isArrayItem = /^\d+$/.test(lastPathPart); + if (!isArrayItem) return true; + return getFixedArraySpecFromEntryPath(entry.path) == null; + }); + + const term = searchTerm.trim().toLowerCase(); + if (!term) return compactEntries; + return compactEntries.filter((entry) => { + const text = `${entry.pathText} ${entry.valuePreview} ${entry.type}`.toLowerCase(); + return text.includes(term); + }); +} + +function escapeBlessedTags(text: string): string { + return text.replace(/\{/g, '\\{').replace(/\}/g, '\\}'); +} + +function styleTomlReferenceLine(line: string): string { + const escapedLine = escapeBlessedTags(line); + const trimmed = line.trim(); + + if (trimmed.length === 0) { + return escapedLine; + } + + if (/^\[\[[^\]]+\]\]$/.test(trimmed)) { + return `{magenta-fg}{bold}${escapedLine}{/bold}{/magenta-fg}`; + } + + if (/^\[[^\]]+\]$/.test(trimmed)) { + return `{cyan-fg}{bold}${escapedLine}{/bold}{/cyan-fg}`; + } + + if (trimmed.startsWith('#')) { + return `{250-fg}${escapedLine}{/250-fg}`; + } + + const keyValueMatch = line.match(/^(\s*[^=\s][^=]*?\s*=\s*)(.*)$/); + if (keyValueMatch != null) { + const keyPart = escapeBlessedTags(keyValueMatch[1]); + const rawValuePart = keyValueMatch[2] ?? ''; + + const inlineCommentStart = rawValuePart.indexOf(' #'); + if (inlineCommentStart >= 0) { + const valuePart = escapeBlessedTags(rawValuePart.slice(0, inlineCommentStart)); + const commentPart = escapeBlessedTags(rawValuePart.slice(inlineCommentStart)); + return `{yellow-fg}${keyPart}{/yellow-fg}{green-fg}${valuePart}{/green-fg}{250-fg}${commentPart}{/250-fg}`; + } + + const valuePart = escapeBlessedTags(rawValuePart); + return `{yellow-fg}${keyPart}{/yellow-fg}{green-fg}${valuePart}{/green-fg}`; + } + + return escapedLine; +} + +function styleTomlReference(source: string): string { + return source.split('\n').map(styleTomlReferenceLine).join('\n'); +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: string): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error('Interactive TUI requires a TTY terminal.'); + } + + // ---- state ---- + const state = createTuiState(editor, configPath); + + // ---- widgets ---- + const screen = blessed.screen({ + smartCSR: true, + title: 'OffCKB Devnet Config Editor', + fullUnicode: true, + terminal: 'xterm', + }); + + const filesList = blessed.list({ + parent: screen, + label: ' Files ', + top: 0, + left: 0, + width: '20%', + height: '100%-4', + border: 'line', + keys: true, + vi: true, + style: { selected: { bg: 'blue' }, border: { fg: 'gray' } }, + tags: true, + }); + + const entriesList = blessed.list({ + parent: screen, + label: ' Config ', + top: 0, + left: '20%', + width: '55%', + height: '100%-4', + border: 'line', + keys: true, + vi: true, + style: { selected: { bg: 'blue' }, border: { fg: 'gray' } }, + tags: true, + }); + + const referenceBox = blessed.box({ + parent: screen, + label: ' Reference (Read-Only) ', + top: 0, + left: '75%', + width: '25%', + height: '100%-4', + border: 'line', + scrollable: true, + alwaysScroll: true, + keys: true, + vi: true, + mouse: true, + tags: true, + style: { border: { fg: 'gray' } }, + content: '', + }); + + const statusBar = blessed.box({ + parent: screen, + bottom: 0, + left: 0, + width: '100%', + height: 4, + border: 'line', + tags: true, + content: '', + style: { border: { fg: 'gray' } }, + }); + + const widgets: TuiWidgets = { screen, filesList, entriesList, referenceBox, statusBar }; + let renderedRows: EntryRenderRow[] = []; + let entryToRowIndex: number[] = []; + + // ---- refresh ---- + const refreshUi = () => { + const fileItems = state.documents.map((d) => d.title); + filesList.setItems(fileItems); + filesList.select(state.selectedDocumentIndex); + + const doc = state.documents[state.selectedDocumentIndex]; + const entries = state.editor.getEntriesForDocument(doc.id); + state.visibleEntries = getVisibleEntries(entries, state.searchTerm); + + renderedRows = []; + entryToRowIndex = []; + let hasSeenTopLevelSection = false; + + state.visibleEntries.forEach((entry, entryIndex) => { + if (entry.path.length === 1 && hasSeenTopLevelSection) { + renderedRows.push({ text: ' ', entryIndex: -1, selectable: false }); + } + if (entry.path.length === 1) { + hasSeenTopLevelSection = true; + } + + const entryValue = state.editor.getEntryValue(doc.id, entry.path); + entryToRowIndex[entryIndex] = renderedRows.length; + renderedRows.push({ + text: formatEntryLine(entry, entryValue), + entryIndex, + selectable: true, + }); + + const fixedArraySpec = entry.type === 'array' ? getFixedArraySpecFromEntryPath(entry.path) : null; + if (fixedArraySpec != null && Array.isArray(entryValue)) { + renderedRows.push({ + text: formatFixedArrayDetailLine( + Math.max(0, entry.path.length - 1), + entryValue.map((value) => String(value)), + ), + entryIndex, + selectable: false, + }); + } + }); + + entriesList.setItems(renderedRows.map((row) => row.text)); + + filesList.style.border = { fg: state.focusPane === 'files' ? 'cyan' : 'gray' }; + entriesList.style.border = { fg: state.focusPane === 'entries' ? 'cyan' : 'gray' }; + referenceBox.style.border = { fg: state.focusPane === 'reference' ? 'cyan' : 'gray' }; + + const referenceContent = styleTomlReference(getEmbeddedReferenceTemplate(doc.id)); + referenceBox.setContent(referenceContent); + + if (state.visibleEntries.length === 0 || renderedRows.length === 0) { + state.selectedEntryIndex = 0; + } else { + if (state.selectedEntryIndex >= state.visibleEntries.length) { + state.selectedEntryIndex = state.visibleEntries.length - 1; + } + const selectedRowIndex = entryToRowIndex[state.selectedEntryIndex] ?? 0; + entriesList.select(selectedRowIndex); + } + + const dirtyText = state.hasUnsavedChanges ? '{yellow-fg}yes{/yellow-fg}' : '{green-fg}no{/green-fg}'; + const selectedFilePath = path.join(state.configPath, doc.title); + statusBar.setContent( + [ + `Path: ${selectedFilePath}`, + `Keys: Enter Edit | a Add | d Delete | i Insert | m Move | / Search | n/N Jump | s Save | q Quit | Unsaved: ${dirtyText}`, + ].join('\n'), + ); + + if (state.focusPane === 'files') { + filesList.focus(); + } else if (state.focusPane === 'entries') { + entriesList.focus(); + } else { + referenceBox.focus(); + } + + screen.render(); + }; + + // ---- helpers ---- + const withDialogLock = (fn: () => Promise) => { + if (state.dialogLock) return; + state.dialogLock = true; + fn().finally(() => { + state.dialogLock = false; + }); + }; + + const ctx: ActionContext = { state, widgets, refreshUi }; + + const syncDocumentSelectionFromFilesList = () => { + const listIndex = getListSelected(filesList); + if (listIndex < 0 || listIndex >= state.documents.length) return; + if (listIndex !== state.selectedDocumentIndex) { + state.selectedDocumentIndex = listIndex; + state.selectedEntryIndex = 0; + referenceBox.setScroll(0); + state.statusMessage = `Switched to ${state.documents[state.selectedDocumentIndex].title}.`; + refreshUi(); + } + }; + + const syncEntrySelectionFromEntriesList = () => { + const rowIndex = getListSelected(entriesList); + if (rowIndex < 0 || rowIndex >= renderedRows.length) return; + + let mappedEntryIndex = renderedRows[rowIndex]?.entryIndex ?? -1; + if (mappedEntryIndex < 0) { + for (let i = rowIndex - 1; i >= 0; i--) { + if (renderedRows[i].selectable) { + mappedEntryIndex = renderedRows[i].entryIndex; + break; + } + } + } + if (mappedEntryIndex < 0) { + for (let i = rowIndex + 1; i < renderedRows.length; i++) { + if (renderedRows[i].selectable) { + mappedEntryIndex = renderedRows[i].entryIndex; + break; + } + } + } + + if (mappedEntryIndex < 0 || mappedEntryIndex >= state.visibleEntries.length) return; + if (mappedEntryIndex !== state.selectedEntryIndex) { + state.selectedEntryIndex = mappedEntryIndex; + refreshUi(); + } + }; + + // ---- list events ---- + filesList.on('select', (_: unknown, index: number) => { + if (index == null) return; + state.selectedDocumentIndex = index; + state.selectedEntryIndex = 0; + referenceBox.setScroll(0); + refreshUi(); + }); + + entriesList.on('select', (_: unknown, index: number) => { + if (index == null) return; + syncEntrySelectionFromEntriesList(); + }); + + const NAV_KEYS = ['up', 'down', 'k', 'j', 'pageup', 'pagedown', 'home', 'end']; + + entriesList.on('keypress', (_: unknown, key: { name?: string }) => { + if (!key?.name || !NAV_KEYS.includes(key.name)) return; + setTimeout(() => syncEntrySelectionFromEntriesList(), 0); + }); + + filesList.on('keypress', (_: unknown, key: { name?: string }) => { + if (!key?.name || !NAV_KEYS.includes(key.name)) return; + setTimeout(() => syncDocumentSelectionFromFilesList(), 0); + }); + + // ---- guarded key helpers ---- + const guardedKey = (keys: string[], fn: () => void) => { + screen.key(keys, () => { + if (!state.dialogLock) fn(); + }); + }; + + const guardedKeyAsync = (keys: string[], fn: () => Promise) => { + screen.key(keys, () => { + if (!state.dialogLock) withDialogLock(fn); + }); + }; + + // ---- key bindings ---- + guardedKey(['tab'], () => { + state.focusPane = state.focusPane === 'files' ? 'entries' : state.focusPane === 'entries' ? 'reference' : 'files'; + refreshUi(); + }); + + guardedKey(['left', 'h'], () => { + if (state.focusPane === 'entries') { + state.focusPane = 'files'; + refreshUi(); + return; + } + if (state.focusPane === 'reference') { + state.focusPane = 'entries'; + refreshUi(); + } + }); + + guardedKey(['right', 'l'], () => { + if (state.focusPane === 'files') { + state.focusPane = 'entries'; + refreshUi(); + return; + } + if (state.focusPane === 'entries') { + state.focusPane = 'reference'; + refreshUi(); + } + }); + + guardedKey(['s'], () => saveAndExit(ctx)); + + guardedKeyAsync(['q', 'C-c', 'escape'], () => quitFlow(ctx)); + + guardedKeyAsync(['enter'], () => { + syncEntrySelectionFromEntriesList(); + return editCurrentEntry(ctx); + }); + + guardedKeyAsync(['/'], () => searchEntries(ctx)); + + guardedKeyAsync(['a'], () => { + syncEntrySelectionFromEntriesList(); + return addEntry(ctx); + }); + + guardedKeyAsync(['d'], () => { + syncEntrySelectionFromEntriesList(); + return deleteEntry(ctx); + }); + + guardedKeyAsync(['i'], () => { + syncEntrySelectionFromEntriesList(); + return insertArrayEntry(ctx); + }); + + guardedKeyAsync(['m'], () => { + syncEntrySelectionFromEntriesList(); + return moveArrayEntry(ctx); + }); + + guardedKey(['n'], () => jumpSearchMatch(ctx, 'next')); + guardedKey(['N'], () => jumpSearchMatch(ctx, 'prev')); + + // ---- start ---- + refreshUi(); + + return new Promise((resolve) => { + screen.once('destroy', () => resolve(state.didSave)); + }); +} diff --git a/src/tui/devnet-reference-templates.ts b/src/tui/devnet-reference-templates.ts new file mode 100644 index 0000000..1133ae5 --- /dev/null +++ b/src/tui/devnet-reference-templates.ts @@ -0,0 +1,228 @@ +const CKB_REFERENCE_TEMPLATE = `# Config generated by \`ckb init --chain dev\` + +data_dir = "data" + +[chain] +# Choose the kind of chains to run, possible values: +# - { file = "specs/dev.toml" } +# - { bundled = "specs/testnet.toml" } +# - { bundled = "specs/mainnet.toml" } +spec = { file = "specs/dev.toml" } + +[logger] +filter = "warn,ckb-script=debug" +color = true +log_to_file = true +log_to_stdout = true + +[sentry] +# set to blank to disable sentry error collection +dsn = "" +# if you are willing to help us to improve, +# please leave a way to contact you when we have troubles to reproduce the errors. +# org_contact = "" + +# # **Experimental** Monitor memory changes. +# [memory_tracker] +# # Seconds between checking the process, 0 is disable, default is 0. +# interval = 600 + +[db] +# The capacity of RocksDB cache, which caches uncompressed data blocks, indexes and filters, default is 256MB. +# Rocksdb will automatically create and use an 32MB internal cache for each column family by default if you set this value to 0. +# To turning off cache, you need to set this value to 0 and set \`no_block_cache = true\` in the options_file, +# however, we strongly discourage this setting, it may lead to severe performance degradation. +cache_size = 268435456 + +# Provide an options file to tune RocksDB for your workload and your system configuration. +# More details can be found in [the official tuning guide](https://github.com/facebook/rocksdb/wiki/RocksDB-Tuning-Guide). +options_file = "default.db-options" + +[network] +listen_addresses = ["/ip4/0.0.0.0/tcp/8115"] +### Specify the public and routable network addresses +# public_addresses = [] + +# Node connects to nodes listed here to discovery other peers when there's no local stored peers. +# When chain.spec is changed, this usually should also be changed to the bootnodes in the new chain. +bootnodes = [] + +### Whitelist-only mode +# whitelist_only = false +### Whitelist peers connecting from the given IP addresses +# whitelist_peers = [] +### Enable \`SO_REUSEPORT\` feature to reuse port on Linux, not supported on other OS yet +# reuse_port_on_linux = true + +max_peers = 125 +max_outbound_peers = 8 +# 2 minutes +ping_interval_secs = 120 +# 20 minutes +ping_timeout_secs = 1200 +connect_outbound_interval_secs = 15 +# If set to true, try to register upnp +upnp = false +# If set to true, network service will add discovered local address to peer store, it's helpful for private net development +discovery_local_address = true +# If set to true, random cleanup when there are too many inbound nodes +# Ensure that itself can continue to serve as a bootnode node +bootnode_mode = false + +# Supported protocols list, only "Sync" and "Identify" are mandatory, others are optional +support_protocols = ["Ping", "Discovery", "Identify", "Feeler", "DisconnectMessage", "Sync", "Relay", "Time", "Alert", "LightClient", "Filter"] + +# [network.sync.header_map] +# memory_limit = "256MB" + +[rpc] +# By default RPC only binds to localhost, thus it only allows accessing from the same machine. +# +# Allowing arbitrary machines to access the JSON-RPC port is dangerous and strongly discouraged. +# Please strictly limit the access to only trusted machines. +listen_address = "0.0.0.0:8114" + +# Default is 10MiB = 10 * 1024 * 1024 +max_request_body_size = 10485760 + +# 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 + +# By default deprecated rpc methods are disabled. +enable_deprecated_rpc = false + +[tx_pool] +max_tx_pool_size = 180_000_000 # 180mb +min_fee_rate = 1_000 # Here fee_rate are calculated directly using size in units of shannons/KB +# min_rbf_rate > min_fee_rate means RBF is enabled +min_rbf_rate = 1_500 # Here fee_rate are calculated directly using size in units of shannons/KB +max_tx_verify_cycles = 70_000_000 +max_ancestors_count = 25 + +[store] +header_cache_size = 4096 +cell_data_cache_size = 128 +block_proposals_cache_size = 30 +block_tx_hashes_cache_size = 30 +block_uncles_cache_size = 30 + +# [notifier] +# # Execute command when the new tip block changes, first arg is block hash. +# new_block_notify_script = "your_new_block_notify_script.sh" +# # Execute command when node received an network alert, first arg is alert message string. +# network_alert_notify_script = "your_network_alert_notify_script.sh" + +# Set the lock script to protect mined CKB. +# +# CKB uses CS architecture for miner. Miner process (ckb miner) gets block +# template from the Node process (ckb run) via RPC. Thus the lock script is +# configured in ckb.toml instead of ckb-miner.toml, and the config takes effect +# after restarting Node process. +# +# The \`code_hash\` identifies different cryptography algorithm. Read the manual +# of the lock script provider about how to generate this config. +# +# CKB provides an secp256k1 implementation, it requires a hash on the +# compressed public key. The hash algorithm is blake2b, with personal +# "ckb-default-hash". The first 160 bits (20 bytes) are used as the only arg. +# +# You can use any tool you trust to generate a Bitcoin private key and public +# key pair, which can be used in CKB as well. CKB CLI provides the function for +# you to convert the public key into block assembler configuration parameters. +# +# Here is an example using ckb-cli to generate an account, this command will +# print the block assembler args(lock_arg) to screen: +# +# ckb-cli account new +# +# If you already have a raw secp256k1 private key, you can get the lock_arg by: +# +# ckb-cli util key-info --privkey-path +# +# The command \`ckb init\` also accepts options to generate the block assembler +# directly. See \`ckb init --help\` for details. +# +# ckb init +# +# secp256k1_blake160_sighash_all example: +# [block_assembler] +# code_hash = "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8" +# args = "ckb-cli util blake2b --prefix-160 " +# hash_type = "type" +# message = "A 0x-prefixed hex string" +# # +# # CKB will prepend the binary version to message, to identify the block miner client. (default true, false to disable it) +# use_binary_version_as_message_prefix = true +# # +# # Block assembler will notify new block template through http post to specified endpoints when update +# notify = ["http://127.0.0.1:8888"] +# # Or you may want use more flexible scripts, block template as arg. +# notify_scripts = ["{cmd} {blocktemplate}"] +# +# [indexer_v2] +# # Indexing the pending txs in the ckb tx-pool +# index_tx_pool = false + +# ckb miner / ckb faucet +# private key: 0x650e256211f5e0beee9084596aa2cb84d11eb033cced5e2d5b191593a9f9f1d4 +# private key path: accounts/ckb-miner-and-faucet.key +[block_assembler] +code_hash = "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8" +args = "0xa1db2eef3f29f3ef6f86c8d2a0772c705c449f4a" +hash_type = "type" +message = "0x" +`; + +const MINER_REFERENCE_TEMPLATE = `# Config generated by \`ckb init --chain dev\` + +data_dir = "data" + +[chain] +# Choose the kind of chains to run, possible values: +# - { file = "specs/dev.toml" } +# - { bundled = "specs/testnet.toml" } +# - { bundled = "specs/mainnet.toml" } +spec = { file = "specs/dev.toml" } + +[logger] +filter = "warn,ckb-script=debug" +color = true +log_to_file = true +log_to_stdout = true + +[sentry] +# set to blank to disable sentry error collection +dsn = "" +# if you are willing to help us to improve, +# please leave a way to contact you when we have troubles to reproduce the errors. +# org_contact = "" + +# # **Experimental** Monitor memory changes. +# [memory_tracker] +# # Seconds between checking the process, 0 is disable, default is 0. +# interval = 600 + +[miner.client] +rpc_url = "http://ckb:8114/" +block_on_submit = true + +# block template polling interval in milliseconds +poll_interval = 1000 + +# enable listen notify mode +# listen = "127.0.0.1:8888" + +[[miner.workers]] +worker_type = "Dummy" +delay_type = "Constant" +value = 5000 +`; + +export function getEmbeddedReferenceTemplate(documentId: 'ckb' | 'miner'): string { + return documentId === 'ckb' ? CKB_REFERENCE_TEMPLATE : MINER_REFERENCE_TEMPLATE; +} diff --git a/src/tui/dialogs.ts b/src/tui/dialogs.ts new file mode 100644 index 0000000..448faab --- /dev/null +++ b/src/tui/dialogs.ts @@ -0,0 +1,550 @@ +import blessed, { Widgets } from 'blessed'; +import { FixedArraySpec } from './devnet-config-metadata'; +import { getListSelected } from './blessed-helpers'; + +// --------------------------------------------------------------------------- +// Fixed-array multi-select dialog +// --------------------------------------------------------------------------- + +export async function waitForFixedArraySelection( + screen: Widgets.Screen, + title: string, + spec: FixedArraySpec, + currentValues: string[], +): Promise { + return new Promise((resolve) => { + const knownOptions = [...spec.options]; + const customCurrentValues = currentValues.filter((value) => !knownOptions.includes(value)); + const optionList = [...knownOptions, ...customCurrentValues]; + + const maxVisibleRows = 14; + const visibleRows = Math.min(Math.max(optionList.length, 1), maxVisibleRows); + const listHeight = visibleRows + 2; + const dialogHeight = listHeight + 6; + + const overlay = blessed.box({ + parent: screen, + top: 0, + left: 0, + width: '100%', + height: '100%', + mouse: true, + keys: true, + tags: true, + }); + + const dialog = blessed.box({ + parent: overlay, + label: ` ${title} `, + border: 'line', + top: 'center', + left: 'center', + width: '62%', + height: dialogHeight, + mouse: true, + keys: true, + tags: true, + style: { border: { fg: 'cyan' } }, + }); + + const selectedValues = new Set(currentValues); + + const list = blessed.list({ + parent: dialog, + top: 1, + left: 1, + width: '100%-2', + height: listHeight, + border: 'line', + mouse: true, + keys: true, + vi: true, + tags: true, + style: { + selected: { bg: 'blue' }, + border: { fg: 'gray' }, + }, + }); + + blessed.box({ + parent: dialog, + top: listHeight + 1, + left: 2, + width: '100%-4', + height: 2, + tags: true, + content: `Space toggle Enter apply Esc cancel Ctrl/Alt+a all Ctrl/Alt+d none${spec.allowCustom ? ' c add custom' : ''}`, + }); + + const renderList = () => { + const items = optionList.map((option) => { + const checked = selectedValues.has(option) ? 'x' : ' '; + const suffix = knownOptions.includes(option) ? '' : ' (custom)'; + return `[${checked}] ${option}${suffix}`; + }); + list.setItems(items); + }; + + renderList(); + list.select(0); + + const screenWithGrab = screen as unknown as { grabKeys?: boolean; grabMouse?: boolean }; + const previousGrabKeys = screenWithGrab.grabKeys; + const previousGrabMouse = screenWithGrab.grabMouse; + screenWithGrab.grabKeys = true; + screenWithGrab.grabMouse = true; + + let resolved = false; + const cleanup = (value: string[] | null) => { + if (resolved) return; + resolved = true; + screenWithGrab.grabKeys = previousGrabKeys; + screenWithGrab.grabMouse = previousGrabMouse; + overlay.destroy(); + screen.render(); + resolve(value); + }; + + const selectedOption = () => { + const selectedIndex = getListSelected(list); + return optionList[selectedIndex] ?? null; + }; + + const applySelection = () => { + const values = optionList.filter((option) => selectedValues.has(option)); + cleanup(values); + }; + + const toggleSelectedOption = () => { + const option = selectedOption(); + if (option == null) return; + + if (selectedValues.has(option)) { + selectedValues.delete(option); + } else { + selectedValues.add(option); + } + renderList(); + list.select(getListSelected(list)); + screen.render(); + }; + + list.key(['enter'], () => applySelection()); + list.key(['space'], () => toggleSelectedOption()); + list.key(['escape'], () => cleanup(null)); + list.key(['C-a', 'A-a', 'M-a'], () => { + optionList.forEach((option) => selectedValues.add(option)); + renderList(); + screen.render(); + }); + list.key(['C-d', 'A-d', 'M-d'], () => { + selectedValues.clear(); + renderList(); + screen.render(); + }); + + dialog.key(['escape'], () => cleanup(null)); + dialog.key(['C-a', 'A-a', 'M-a'], () => { + optionList.forEach((option) => selectedValues.add(option)); + renderList(); + screen.render(); + }); + dialog.key(['C-d', 'A-d', 'M-d'], () => { + selectedValues.clear(); + renderList(); + screen.render(); + }); + + const addCustomValue = async () => { + if (!spec.allowCustom) return; + + const answer = await waitForInput(screen, `${title} (${spec.label})`, 'Custom value:', ''); + if (answer == null) { + list.focus(); + screen.render(); + return; + } + + const customValue = answer.trim(); + if (!customValue) { + list.focus(); + screen.render(); + return; + } + + if (!optionList.includes(customValue)) { + optionList.push(customValue); + } + selectedValues.add(customValue); + + renderList(); + const index = optionList.findIndex((option) => option === customValue); + if (index >= 0) { + list.select(index); + } + list.focus(); + screen.render(); + }; + + list.key(['c'], () => { + void addCustomValue(); + }); + + dialog.key(['c'], () => { + void addCustomValue(); + }); + + list.focus(); + screen.render(); + }); +} + +// --------------------------------------------------------------------------- +// Text input dialog +// --------------------------------------------------------------------------- + +export function waitForInput( + screen: Widgets.Screen, + title: string, + questionText: string, + initialValue: string, +): Promise { + return new Promise((resolve) => { + const dialog = blessed.box({ + parent: screen, + label: ` ${title} `, + border: 'line', + top: 'center', + left: 'center', + width: '70%', + height: 13, + keys: true, + tags: true, + style: { border: { fg: 'cyan' } }, + }); + + blessed.box({ + parent: dialog, + top: 1, + left: 2, + width: '95%-4', + height: 2, + tags: true, + content: questionText, + }); + + const input = blessed.textbox({ + parent: dialog, + top: 3, + left: 2, + width: '100%-4', + height: 3, + border: 'line', + inputOnFocus: true, + keys: true, + vi: true, + style: { border: { fg: 'gray' } }, + }); + + const okButton = blessed.button({ + parent: dialog, + mouse: true, + keys: true, + shrink: true, + top: 8, + left: '40%-8', + height: 1, + content: ' OK ', + style: { bg: 'blue', focus: { bg: 'blue' } }, + }); + + const cancelButton = blessed.button({ + parent: dialog, + mouse: true, + keys: true, + shrink: true, + top: 8, + left: '40%+4', + height: 1, + content: ' Cancel ', + style: { bg: 'gray', focus: { bg: 'gray' } }, + }); + + type InputDialogFocus = 'input' | 'ok' | 'cancel'; + let currentFocus: InputDialogFocus = 'input'; + + let resolved = false; + const cleanup = (value: string | null) => { + if (resolved) return; + resolved = true; + dialog.destroy(); + screen.render(); + resolve(value); + }; + + const setFocus = (nextFocus: InputDialogFocus) => { + if (resolved) return; + currentFocus = nextFocus; + if (nextFocus === 'input') { + input.style.border = { fg: 'cyan' }; + okButton.style.bg = 'blue'; + cancelButton.style.bg = 'gray'; + input.focus(); + } else if (nextFocus === 'ok') { + input.style.border = { fg: 'gray' }; + okButton.style.bg = 'cyan'; + cancelButton.style.bg = 'gray'; + okButton.focus(); + } else { + input.style.border = { fg: 'gray' }; + okButton.style.bg = 'blue'; + cancelButton.style.bg = 'cyan'; + cancelButton.focus(); + } + screen.render(); + }; + + const nextFocus = () => { + if (currentFocus === 'input') { + setFocus('ok'); + return; + } + if (currentFocus === 'ok') { + setFocus('cancel'); + return; + } + setFocus('input'); + }; + + const prevFocus = () => { + if (currentFocus === 'cancel') { + setFocus('ok'); + return; + } + if (currentFocus === 'ok') { + setFocus('input'); + return; + } + setFocus('cancel'); + }; + + const getInputValue = () => input.getValue() ?? ''; + + okButton.on('press', () => cleanup(getInputValue())); + cancelButton.on('press', () => cleanup(null)); + + dialog.key(['escape'], () => cleanup(null)); + dialog.key(['tab', 'down'], () => nextFocus()); + dialog.key(['S-tab', 'up'], () => prevFocus()); + dialog.key(['left'], () => { + if (currentFocus !== 'input') prevFocus(); + }); + dialog.key(['right'], () => { + if (currentFocus !== 'input') nextFocus(); + }); + dialog.key(['enter'], () => { + if (currentFocus === 'input') { + cleanup(getInputValue()); + return; + } + if (currentFocus === 'ok') { + cleanup(getInputValue()); + return; + } + cleanup(null); + }); + + input.key(['enter'], () => cleanup(getInputValue())); + + input.setValue(initialValue); + setFocus('input'); + input.readInput(); + screen.render(); + }); +} + +// --------------------------------------------------------------------------- +// Confirmation dialog +// --------------------------------------------------------------------------- + +export function waitForConfirm( + screen: Widgets.Screen, + title: string, + text: string, + options?: { + confirmLabel?: string; + cancelLabel?: string; + defaultFocus?: 'confirm' | 'cancel'; + }, +): Promise { + return new Promise((resolve) => { + const confirmLabel = options?.confirmLabel ?? 'OK'; + const cancelLabel = options?.cancelLabel ?? 'Cancel'; + const defaultFocus: 'ok' | 'cancel' = options?.defaultFocus === 'confirm' ? 'ok' : 'cancel'; + const buttonGap = 3; + const buttonWidth = Math.max(confirmLabel.length, cancelLabel.length) + 4; + const leftHalfOffset = buttonWidth + Math.ceil(buttonGap / 2); + const rightHalfOffset = Math.floor(buttonGap / 2); + + const dialog = blessed.box({ + parent: screen, + label: ` ${title} `, + border: 'line', + top: 'center', + left: 'center', + width: '60%', + height: 10, + keys: true, + tags: true, + style: { border: { fg: 'cyan' } }, + }); + + blessed.box({ + parent: dialog, + top: 2, + left: 2, + width: '100%-4', + height: 2, + tags: true, + content: text, + }); + + const okButton = blessed.button({ + parent: dialog, + mouse: true, + keys: true, + shrink: false, + top: 5, + left: `50%-${leftHalfOffset}`, + width: buttonWidth, + height: 1, + content: ` ${confirmLabel} `, + style: { bg: 'blue', focus: { bg: 'blue' } }, + }); + + const cancelButton = blessed.button({ + parent: dialog, + mouse: true, + keys: true, + shrink: false, + top: 5, + left: `50%+${rightHalfOffset}`, + width: buttonWidth, + height: 1, + content: ` ${cancelLabel} `, + style: { bg: 'gray', focus: { bg: 'gray' } }, + }); + + const focusNavKeys = ['tab', 'right', 'left', 'S-tab']; + const confirmKeys = ['enter', 'return', 'C-m']; + + let focusButton: 'ok' | 'cancel' = 'cancel'; + + let resolved = false; + const cleanup = (answer: boolean) => { + if (resolved) return; + resolved = true; + dialog.destroy(); + screen.render(); + resolve(answer); + }; + + const applyFocusStyles = (focus: 'ok' | 'cancel') => { + if (focus === 'ok') { + okButton.style.bg = 'cyan'; + cancelButton.style.bg = 'gray'; + } else { + okButton.style.bg = 'blue'; + cancelButton.style.bg = 'cyan'; + } + }; + + const setFocus = (focus: 'ok' | 'cancel') => { + if (resolved) return; + focusButton = focus; + applyFocusStyles(focus); + if (focus === 'ok') { + okButton.focus(); + } else { + cancelButton.focus(); + } + screen.render(); + }; + + const toggleFocus = () => { + setFocus(focusButton === 'ok' ? 'cancel' : 'ok'); + }; + + okButton.on('focus', () => { + if (resolved) return; + focusButton = 'ok'; + applyFocusStyles('ok'); + screen.render(); + }); + + cancelButton.on('focus', () => { + if (resolved) return; + focusButton = 'cancel'; + applyFocusStyles('cancel'); + screen.render(); + }); + + const accept = () => cleanup(true); + const cancel = () => cleanup(false); + + okButton.on('press', () => accept()); + cancelButton.on('press', () => cancel()); + + dialog.key(['escape'], () => cancel()); + dialog.key(['tab', 'left', 'right'], () => toggleFocus()); + dialog.key(['S-tab'], () => toggleFocus()); + dialog.key(confirmKeys, () => { + if (focusButton === 'ok') { + accept(); + } else { + cancel(); + } + }); + + const buttons = [okButton, cancelButton]; + buttons.forEach((button) => { + button.key(focusNavKeys, () => toggleFocus()); + button.key(['escape'], () => cancel()); + }); + okButton.key(confirmKeys, () => accept()); + cancelButton.key(confirmKeys, () => cancel()); + + setFocus(defaultFocus); + screen.render(); + }); +} + +// --------------------------------------------------------------------------- +// Array-value input (routes to fixed-array dialog or text input) +// --------------------------------------------------------------------------- + +export async function waitForArrayValue( + screen: Widgets.Screen, + spec: FixedArraySpec | null, + title: string, + questionText: string, + initialValue: string, +): Promise { + if (spec == null) { + return waitForInput(screen, title, questionText, initialValue); + } + + if (spec.options.includes(initialValue)) { + const selected = await waitForFixedArraySelection(screen, `${title} (${spec.label})`, spec, [initialValue]); + if (selected == null || selected.length === 0) return null; + return selected[0]; + } + + if (!spec.allowCustom) { + const selected = await waitForFixedArraySelection(screen, `${title} (${spec.label})`, spec, []); + if (selected == null || selected.length === 0) return null; + return selected[0]; + } + + return waitForInput(screen, title, questionText, initialValue); +} diff --git a/src/tui/format.ts b/src/tui/format.ts new file mode 100644 index 0000000..6053c0d --- /dev/null +++ b/src/tui/format.ts @@ -0,0 +1,37 @@ +import { TomlEntry } from '../devnet/config-editor'; +import { getConfigDoc } from './devnet-config-metadata'; + +function formatFixedArrayInline(values: string[]): string { + if (values.length === 0) { + return '{light-cyan-fg}[]{/light-cyan-fg}'; + } + + return `{light-cyan-fg}[${values.join(', ')}]{/light-cyan-fg}`; +} + +export function formatFixedArrayDetailLine(depth: number, values: string[]): string { + const detailIndent = `${'│ '.repeat(Math.max(0, depth))} `; + return `${detailIndent}${formatFixedArrayInline(values)}`; +} + +export function formatEntryLine(entry: TomlEntry, _entryValue?: unknown): string { + const depth = Math.max(0, entry.path.length - 1); + const lastPathPart = entry.path[entry.path.length - 1] ?? ''; + const nodeName = /^\d+$/.test(lastPathPart) ? `[${lastPathPart}]` : lastPathPart; + const treeIndent = depth === 0 ? '' : `${'│ '.repeat(Math.max(0, depth - 1))}`; + const branch = depth === 0 ? '' : '├─ '; + const keyDoc = getConfigDoc(entry.path); + const docText = keyDoc != null ? ` {243-fg}// ${keyDoc.summary}{/243-fg}` : ''; + const valueColor = entry.type === 'string' ? 'green' : entry.type === 'number' ? 'yellow' : 'magenta'; + const keyColor = depth === 0 ? 'cyan' : 'white'; + + if (entry.type === 'object') { + return `${treeIndent}${branch}{cyan-fg}▸ ${nodeName}{/cyan-fg} {gray-fg}${entry.valuePreview}{/gray-fg}${docText}`; + } + + if (entry.type === 'array') { + return `${treeIndent}${branch}{magenta-fg}▾ ${nodeName}{/magenta-fg} {white-fg}${entry.valuePreview}{/white-fg}${docText}`; + } + + return `${treeIndent}${branch}{${keyColor}-fg}${nodeName}{/${keyColor}-fg} = {${valueColor}-fg}${entry.valuePreview}{/${valueColor}-fg}${docText}`; +} diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts new file mode 100644 index 0000000..db85986 --- /dev/null +++ b/src/tui/tui-state.ts @@ -0,0 +1,44 @@ +import { Widgets } from 'blessed'; +import { DevnetConfigEditor, TomlDocument, TomlEntry } from '../devnet/config-editor'; + +export type FocusPane = 'files' | 'entries' | 'reference'; + +export interface TuiState { + readonly editor: DevnetConfigEditor; + readonly configPath: string; + readonly documents: TomlDocument[]; + selectedDocumentIndex: number; + selectedEntryIndex: number; + focusPane: FocusPane; + hasUnsavedChanges: boolean; + didSave: boolean; + searchTerm: string; + statusMessage: string; + visibleEntries: TomlEntry[]; + dialogLock: boolean; +} + +export interface TuiWidgets { + screen: Widgets.Screen; + filesList: Widgets.ListElement; + entriesList: Widgets.ListElement; + referenceBox: Widgets.BoxElement; + statusBar: Widgets.BoxElement; +} + +export function createTuiState(editor: DevnetConfigEditor, configPath: string): TuiState { + return { + editor, + configPath, + documents: editor.getDocuments(), + selectedDocumentIndex: 0, + selectedEntryIndex: 0, + focusPane: 'files', + hasUnsavedChanges: false, + didSave: false, + searchTerm: '', + statusMessage: 'Ready', + visibleEntries: [], + dialogLock: false, + }; +} diff --git a/tests/devnet-config-command.test.ts b/tests/devnet-config-command.test.ts new file mode 100644 index 0000000..c976e9a --- /dev/null +++ b/tests/devnet-config-command.test.ts @@ -0,0 +1,80 @@ +import { devnetConfig } from '../src/cmd/devnet-config'; +import { createDevnetConfigEditor } from '../src/devnet/config-editor'; +import { runDevnetConfigTui } from '../src/tui/devnet-config-tui'; +import { logger } from '../src/util/logger'; + +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ + devnet: { + configPath: '/tmp/offckb-devnet-config', + }, + }), +})); + +jest.mock('../src/devnet/config-editor', () => ({ + createDevnetConfigEditor: jest.fn(), +})); + +jest.mock('../src/tui/devnet-config-tui', () => ({ + runDevnetConfigTui: jest.fn(), +})); + +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); + +describe('devnet config command fallback behavior', () => { + const originalStdinTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + const originalStdoutTty = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + + beforeEach(() => { + jest.clearAllMocks(); + process.exitCode = undefined; + + (createDevnetConfigEditor as jest.Mock).mockReturnValue({ + setFieldValue: jest.fn(), + save: jest.fn(), + }); + + (runDevnetConfigTui as jest.Mock).mockResolvedValue(false); + }); + + afterEach(() => { + if (originalStdinTty != null) { + Object.defineProperty(process.stdin, 'isTTY', originalStdinTty); + } + if (originalStdoutTty != null) { + Object.defineProperty(process.stdout, 'isTTY', originalStdoutTty); + } + process.exitCode = undefined; + }); + + it('uses --set mode in non-TTY terminals without launching TUI', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + + await devnetConfig({ set: ['ckb.logger.filter=info'] }); + + expect(runDevnetConfigTui).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.success).toHaveBeenCalledWith('Devnet config updated at: /tmp/offckb-devnet-config'); + expect(process.exitCode).toBeUndefined(); + }); + + it('prints actionable fallback guidance when TTY is unavailable', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + + await devnetConfig(); + + expect(runDevnetConfigTui).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith('Interactive devnet config editor requires a TTY terminal.'); + expect(logger.info).toHaveBeenCalledWith('Use non-interactive mode instead, e.g.:'); + expect(logger.info).toHaveBeenCalledWith(' offckb devnet config --set ckb.logger.filter=info'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tests/devnet-config-editor.test.ts b/tests/devnet-config-editor.test.ts new file mode 100644 index 0000000..b3bb147 --- /dev/null +++ b/tests/devnet-config-editor.test.ts @@ -0,0 +1,218 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import toml from '@iarna/toml'; +import { createDevnetConfigEditor } from '../src/devnet/config-editor'; +import { applySetItems, parseSetItem } from '../src/cmd/devnet-config'; + +function writeFixtureConfig(configPath: string) { + fs.mkdirSync(configPath, { recursive: true }); + + fs.writeFileSync( + path.join(configPath, 'ckb.toml'), + toml.stringify({ + logger: { + filter: 'warn,ckb-script=debug', + color: true, + log_to_file: true, + log_to_stdout: true, + }, + rpc: { + listen_address: '0.0.0.0:8114', + max_request_body_size: 10_485_760, + enable_deprecated_rpc: false, + modules: ['Net', 'Pool', 'Miner', 'Chain', 'Stats', 'Subscription', 'Experiment', 'Debug', 'Indexer'], + }, + network: { + max_peers: 125, + bootnodes: ['node-a', 'node-b'], + support_protocols: ['Ping', 'Discovery', 'Identify', 'Sync'], + }, + }), + 'utf8', + ); + + fs.writeFileSync( + path.join(configPath, 'ckb-miner.toml'), + toml.stringify({ + miner: { + client: { + rpc_url: 'http://ckb:8114/', + block_on_submit: true, + poll_interval: 1000, + }, + }, + }), + 'utf8', + ); +} + +describe('DevnetConfigEditor', () => { + let tempRoot: string; + let configPath: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-devnet-config-')); + configPath = path.join(tempRoot, 'devnet'); + writeFixtureConfig(configPath); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('loads editable safe-subset fields', () => { + const editor = createDevnetConfigEditor(configPath); + const fields = editor.getFields(); + + expect(fields.length).toBe(10); + expect(fields.find((field) => field.id === 'ckb.rpc.listen_address')?.value).toBe('0.0.0.0:8114'); + expect(fields.find((field) => field.id === 'miner.client.poll_interval')?.value).toBe(1000); + }); + + it('validates updated values', () => { + const editor = createDevnetConfigEditor(configPath); + + expect(() => editor.setFieldValue('ckb.rpc.listen_address', 'invalid')).toThrow( + 'RPC listen address must be in host:port format.', + ); + + expect(() => editor.setFieldValue('miner.client.rpc_url', 'ftp://ckb:8114/')).toThrow( + 'Miner RPC URL must be a valid HTTP/HTTPS URL.', + ); + + expect(() => editor.setFieldValue('miner.client.poll_interval', '0')).toThrow('Value must be a positive integer.'); + }); + + it('saves edited values and keeps unrelated keys', () => { + const editor = createDevnetConfigEditor(configPath); + + editor.setFieldValue('ckb.logger.filter', 'info'); + editor.setFieldValue('ckb.rpc.listen_address', '127.0.0.1:18114'); + editor.setFieldValue('miner.client.poll_interval', '500'); + editor.toggleBooleanField('ckb.rpc.enable_deprecated_rpc'); + editor.save(); + + const ckbToml = toml.parse(fs.readFileSync(path.join(configPath, 'ckb.toml'), 'utf8')) as unknown as Record< + string, + any + >; + const minerToml = toml.parse( + fs.readFileSync(path.join(configPath, 'ckb-miner.toml'), 'utf8'), + ) as unknown as Record; + + expect(ckbToml.logger.filter).toBe('info'); + expect(ckbToml.rpc.listen_address).toBe('127.0.0.1:18114'); + expect(ckbToml.rpc.enable_deprecated_rpc).toBe(true); + expect(ckbToml.network.max_peers).toBe(125); + expect(minerToml.miner.client.poll_interval).toBe(500); + }); + + it('throws when config files are missing', () => { + fs.rmSync(path.join(configPath, 'ckb-miner.toml')); + expect(() => createDevnetConfigEditor(configPath)).toThrow('Missing file'); + }); + + it('parses --set key=value items', () => { + expect(parseSetItem('ckb.logger.filter=info')).toEqual({ + key: 'ckb.logger.filter', + value: 'info', + }); + + expect(() => parseSetItem('ckb.logger.filter')).toThrow('Invalid --set item'); + expect(() => parseSetItem('=info')).toThrow('Invalid --set item'); + }); + + it('applies repeatable --set items and persists files', () => { + const editor = createDevnetConfigEditor(configPath); + + const applied = applySetItems(editor, [ + 'ckb.logger.filter=info', + 'ckb.rpc.enable_deprecated_rpc=true', + 'miner.client.poll_interval=1500', + ]); + + expect(applied).toHaveLength(3); + + const ckbToml = toml.parse(fs.readFileSync(path.join(configPath, 'ckb.toml'), 'utf8')) as unknown as Record< + string, + any + >; + const minerToml = toml.parse( + fs.readFileSync(path.join(configPath, 'ckb-miner.toml'), 'utf8'), + ) as unknown as Record; + + expect(ckbToml.logger.filter).toBe('info'); + expect(ckbToml.rpc.enable_deprecated_rpc).toBe(true); + expect(minerToml.miner.client.poll_interval).toBe(1500); + }); + + it('provides full TOML document and flattened entries', () => { + const editor = createDevnetConfigEditor(configPath); + + const documents = editor.getDocuments(); + expect(documents.map((document) => document.id)).toEqual(['ckb', 'miner']); + + const ckbEntries = editor.getEntriesForDocument('ckb'); + expect(ckbEntries.some((entry) => entry.pathText === 'logger.filter')).toBe(true); + expect(ckbEntries.some((entry) => entry.pathText === 'network.max_peers')).toBe(true); + }); + + it('edits primitive value via generic document path api', () => { + const editor = createDevnetConfigEditor(configPath); + + editor.setDocumentValue('ckb', ['network', 'max_peers'], '256'); + editor.save(); + + const ckbToml = toml.parse(fs.readFileSync(path.join(configPath, 'ckb.toml'), 'utf8')) as unknown as Record< + string, + any + >; + expect(ckbToml.network.max_peers).toBe(256); + }); + + it('inserts and moves array entries via document path api', () => { + const editor = createDevnetConfigEditor(configPath); + + editor.insertArrayEntry('ckb', ['network', 'bootnodes'], 1, 'node-x'); + editor.moveArrayEntry('ckb', ['network', 'bootnodes'], 2, 0); + editor.save(); + + const ckbToml = toml.parse(fs.readFileSync(path.join(configPath, 'ckb.toml'), 'utf8')) as unknown as Record< + string, + any + >; + + expect(ckbToml.network.bootnodes).toEqual(['node-b', 'node-a', 'node-x']); + }); + + it('allows custom rpc modules and preserves them on save', () => { + const editor = createDevnetConfigEditor(configPath); + + editor.setArrayValues('ckb', ['rpc', 'modules'], ['Net', 'Indexer', 'CustomModuleX']); + editor.save(); + + const ckbToml = toml.parse(fs.readFileSync(path.join(configPath, 'ckb.toml'), 'utf8')) as unknown as Record< + string, + any + >; + + expect(ckbToml.rpc.modules).toEqual(['Net', 'Indexer', 'CustomModuleX']); + }); + + it('rejects saving when support_protocols misses mandatory protocols', () => { + const editor = createDevnetConfigEditor(configPath); + + editor.setArrayValues('ckb', ['network', 'support_protocols'], ['Ping', 'Discovery']); + + expect(() => editor.save()).toThrow('network.support_protocols must include both Sync and Identify'); + }); + + it('rejects saving when rpc.modules is empty', () => { + const editor = createDevnetConfigEditor(configPath); + + editor.setArrayValues('ckb', ['rpc', 'modules'], []); + + expect(() => editor.save()).toThrow('rpc.modules must include at least one module'); + }); +});