From 8902b9284f03908bbca18a83988015a0d4d0ff66 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 13 Mar 2026 14:33:49 +0800 Subject: [PATCH 1/6] feat(devnet): add InitializationError class for config errors --- src/devnet/config-editor.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/devnet/config-editor.ts b/src/devnet/config-editor.ts index 5c280dc..0abc5b9 100644 --- a/src/devnet/config-editor.ts +++ b/src/devnet/config-editor.ts @@ -693,3 +693,10 @@ export function createDevnetConfigEditor(configPath: string): DevnetConfigEditor return new DevnetConfigEditor(configPath, ckbConfig, minerConfig); } + +export class InitializationError extends Error { + constructor(message: string) { + super(message); + this.name = 'InitializationError'; + } +} From ebe474df624e9489ff2a4498b77a3df3a811fea1 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 13 Mar 2026 14:36:12 +0800 Subject: [PATCH 2/6] refactor(devnet): throw InitializationError for missing config files --- src/devnet/config-editor.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/devnet/config-editor.ts b/src/devnet/config-editor.ts index 0abc5b9..d4ef20e 100644 --- a/src/devnet/config-editor.ts +++ b/src/devnet/config-editor.ts @@ -679,13 +679,13 @@ export function createDevnetConfigEditor(configPath: string): DevnetConfigEditor const minerTomlPath = path.join(configPath, 'ckb-miner.toml'); if (!fs.existsSync(configPath)) { - throw new Error(`Devnet config path does not exist: ${configPath}`); + throw new InitializationError(`Devnet config path does not exist: ${configPath}`); } if (!fs.existsSync(ckbTomlPath)) { - throw new Error(`Missing file: ${ckbTomlPath}`); + throw new InitializationError(`Missing file: ${ckbTomlPath}`); } if (!fs.existsSync(minerTomlPath)) { - throw new Error(`Missing file: ${minerTomlPath}`); + throw new InitializationError(`Missing file: ${minerTomlPath}`); } const ckbConfig = readTomlFile(ckbTomlPath); From 8dfc9b24dc91a0519db76eaaad39cdd6ef5535e6 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 13 Mar 2026 14:39:45 +0800 Subject: [PATCH 3/6] fix(devnet): only show init hint for InitializationError --- src/cmd/devnet-config.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/cmd/devnet-config.ts b/src/cmd/devnet-config.ts index cc8b5d3..8ccfbfe 100644 --- a/src/cmd/devnet-config.ts +++ b/src/cmd/devnet-config.ts @@ -1,6 +1,6 @@ import { readSettings } from '../cfg/setting'; import { logger } from '../util/logger'; -import { createDevnetConfigEditor } from '../devnet/config-editor'; +import { createDevnetConfigEditor, InitializationError } from '../devnet/config-editor'; import { runDevnetConfigTui } from '../tui/devnet-config-tui'; export interface DevnetConfigOptions { @@ -72,8 +72,13 @@ export async function devnetConfig(options: DevnetConfigOptions = {}) { 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.'); + const message = error instanceof Error ? error.message : String(error); + logger.error(message); + + if (error instanceof InitializationError) { + logger.info('Tip: run `offckb node` once to initialize devnet config files first.'); + } + process.exitCode = 1; } } From 8426e0ed693edc15ef8d7f6cbe9280dd5175e56b Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 13 Mar 2026 14:44:07 +0800 Subject: [PATCH 4/6] test(devnet): add tests for error classification --- tests/devnet-config-command.test.ts | 94 +++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/devnet-config-command.test.ts b/tests/devnet-config-command.test.ts index c976e9a..38344a8 100644 --- a/tests/devnet-config-command.test.ts +++ b/tests/devnet-config-command.test.ts @@ -13,6 +13,12 @@ jest.mock('../src/cfg/setting', () => ({ jest.mock('../src/devnet/config-editor', () => ({ createDevnetConfigEditor: jest.fn(), + InitializationError: class InitializationError extends Error { + constructor(message: string) { + super(message); + this.name = 'InitializationError'; + } + }, })); jest.mock('../src/tui/devnet-config-tui', () => ({ @@ -78,3 +84,91 @@ describe('devnet config command fallback behavior', () => { expect(process.exitCode).toBe(1); }); }); + +describe('error handling with init hint', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.exitCode = undefined; + }); + + afterEach(() => { + process.exitCode = undefined; + }); + + it('should NOT show init hint for parse errors (--set invalid)', async () => { + await devnetConfig({ set: ['invalid'] }); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Invalid --set item')); + expect(logger.info).not.toHaveBeenCalledWith( + 'Tip: run `offckb node` once to initialize devnet config files first.', + ); + expect(process.exitCode).toBe(1); + }); + + it('should NOT show init hint for unknown field errors', async () => { + (createDevnetConfigEditor as jest.Mock).mockImplementation(() => { + throw new Error("Unknown field 'unknown.field'."); + }); + + await devnetConfig({ set: ['unknown.field=value'] }); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Unknown field')); + expect(logger.info).not.toHaveBeenCalledWith( + 'Tip: run `offckb node` once to initialize devnet config files first.', + ); + expect(process.exitCode).toBe(1); + }); + + it('should NOT show init hint for validation errors', async () => { + (createDevnetConfigEditor as jest.Mock).mockImplementation(() => { + throw new Error('Value must be a positive integer.'); + }); + + await devnetConfig({ set: ['miner.client.poll_interval=0'] }); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Value must be a positive integer')); + expect(logger.info).not.toHaveBeenCalledWith( + 'Tip: run `offckb node` once to initialize devnet config files first.', + ); + expect(process.exitCode).toBe(1); + }); + + it('should show init hint for missing config path (InitializationError)', async () => { + const { InitializationError } = require('../src/devnet/config-editor'); + (createDevnetConfigEditor as jest.Mock).mockImplementation(() => { + throw new InitializationError('Devnet config path does not exist: /missing/path'); + }); + + await devnetConfig({ set: ['ckb.logger.filter=info'] }); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Devnet config path does not exist')); + expect(logger.info).toHaveBeenCalledWith('Tip: run `offckb node` once to initialize devnet config files first.'); + expect(process.exitCode).toBe(1); + }); + + it('should show init hint for missing ckb.toml (InitializationError)', async () => { + const { InitializationError } = require('../src/devnet/config-editor'); + (createDevnetConfigEditor as jest.Mock).mockImplementation(() => { + throw new InitializationError('Missing file: /path/ckb.toml'); + }); + + await devnetConfig({ set: ['ckb.logger.filter=info'] }); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Missing file')); + expect(logger.info).toHaveBeenCalledWith('Tip: run `offckb node` once to initialize devnet config files first.'); + expect(process.exitCode).toBe(1); + }); + + it('should show init hint for missing miner.toml (InitializationError)', async () => { + const { InitializationError } = require('../src/devnet/config-editor'); + (createDevnetConfigEditor as jest.Mock).mockImplementation(() => { + throw new InitializationError('Missing file: /path/ckb-miner.toml'); + }); + + await devnetConfig({ set: ['ckb.logger.filter=info'] }); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Missing file')); + expect(logger.info).toHaveBeenCalledWith('Tip: run `offckb node` once to initialize devnet config files first.'); + expect(process.exitCode).toBe(1); + }); +}); From e1d77f9e98d3d20422ad8e0f0155e0510c9616c1 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 13 Mar 2026 14:57:42 +0800 Subject: [PATCH 5/6] chore: add changeset for devnet config hint fix --- .changeset/devnet-config-hint-fix.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/devnet-config-hint-fix.md diff --git a/.changeset/devnet-config-hint-fix.md b/.changeset/devnet-config-hint-fix.md new file mode 100644 index 0000000..85cbfca --- /dev/null +++ b/.changeset/devnet-config-hint-fix.md @@ -0,0 +1,16 @@ +--- +'@offckb/cli': patch +--- + +fix(devnet): only show init hint for InitializationError + +The `offckb devnet config` command was showing the "run `offckb node` once to initialize devnet config files first" hint for ALL errors, including user input errors like invalid `--set` syntax or validation failures. + +Now the hint is only shown for actual initialization errors (missing config path, ckb.toml, or miner.toml), making error messages clearer and less misleading. + +- Added `InitializationError` class to distinguish initialization errors from user input errors +- Updated `createDevnetConfigEditor()` to throw `InitializationError` for missing files/paths +- Modified `devnetConfig()` catch block to only show hint for `InitializationError` +- Added type safety guard for error handling + +Fixes #406 From b4b2923d2fea9ba044e1e29d147139415496560c Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 13 Mar 2026 15:04:32 +0800 Subject: [PATCH 6/6] test(devnet): reset mock implementation in beforeEach for test isolation --- tests/devnet-config-command.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/devnet-config-command.test.ts b/tests/devnet-config-command.test.ts index 38344a8..507b764 100644 --- a/tests/devnet-config-command.test.ts +++ b/tests/devnet-config-command.test.ts @@ -89,6 +89,10 @@ describe('error handling with init hint', () => { beforeEach(() => { jest.clearAllMocks(); process.exitCode = undefined; + (createDevnetConfigEditor as jest.Mock).mockReturnValue({ + setFieldValue: jest.fn(), + save: jest.fn(), + }); }); afterEach(() => {