Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/devnet-config-hint-fix.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 8 additions & 3 deletions src/cmd/devnet-config.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
}
}
13 changes: 10 additions & 3 deletions src/devnet/config-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,17 +679,24 @@ 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);
const minerConfig = readTomlFile(minerTomlPath);

return new DevnetConfigEditor(configPath, ckbConfig, minerConfig);
}

export class InitializationError extends Error {
constructor(message: string) {
super(message);
this.name = 'InitializationError';
}
}
98 changes: 98 additions & 0 deletions tests/devnet-config-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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);
});
});
Loading