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 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; } } diff --git a/src/devnet/config-editor.ts b/src/devnet/config-editor.ts index 5c280dc..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); @@ -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'; + } +} diff --git a/tests/devnet-config-command.test.ts b/tests/devnet-config-command.test.ts index c976e9a..507b764 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,95 @@ describe('devnet config command fallback behavior', () => { expect(process.exitCode).toBe(1); }); }); + +describe('error handling with init hint', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.exitCode = undefined; + (createDevnetConfigEditor as jest.Mock).mockReturnValue({ + setFieldValue: jest.fn(), + save: jest.fn(), + }); + }); + + 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); + }); +});