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/.changeset/lazy-wasi-warning.md b/.changeset/lazy-wasi-warning.md new file mode 100644 index 0000000..d7b81fb --- /dev/null +++ b/.changeset/lazy-wasi-warning.md @@ -0,0 +1,14 @@ +--- +'@offckb/cli': patch +--- + +fix(ckb-debugger): lazy-load WASI module to suppress ExperimentalWarning + +Convert static import of node:wasi to dynamic import with caching. +This prevents the ExperimentalWarning from being emitted when running +non-debugger commands like 'offckb accounts' or 'offckb config list'. + +The WASI module is now only loaded when debugger functionality is +actually executed. + +Fixes #405 diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index 2564fc1..74e31d5 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -2,12 +2,17 @@ name: Changeset Check on: pull_request: + types: [opened, reopened, synchronize, ready_for_review, labeled] branches: [master, develop] jobs: - changeset: - name: Require changeset + verify: + name: Verify Changeset + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-changeset') && !contains(github.event.pull_request.labels.*.name, 'dependencies') }} runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read steps: - name: Checkout repository @@ -23,14 +28,92 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: '20' - name: Install dependencies run: pnpm install --frozen-lockfile - name: Check for changeset + id: check run: | - echo "Checking for changeset files in this PR..." - echo "If this fails, run 'pnpm changeset' to add a changeset describing your changes." - echo "For changes that don't need a changelog entry (docs, CI, refactoring), use 'pnpm changeset --empty'." - pnpm changeset status --since=origin/${{ github.base_ref }} + if pnpm changeset status --since=origin/${{ github.base_ref }}; then + echo "has_changeset=true" >> $GITHUB_OUTPUT + else + echo "has_changeset=false" >> $GITHUB_OUTPUT + exit 1 + fi + + - name: Comment on PR (success) + if: steps.check.outputs.has_changeset == 'true' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const body = marker + '\n✅ Changeset file detected.'; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }); + } + + - name: Comment on PR (failure) + if: failure() + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const body = [ + marker, + '❌ **Missing Changeset**', + '', + 'Please add a changeset describing your changes:', + '```bash', + 'pnpm changeset', + '```', + '', + 'If your changes do not need a version bump (docs, CI, refactoring),', + 'add the `skip-changeset` label to this PR.', + '', + 'For dependency updates, use the `dependencies` label.' + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); + } + + skipped: + name: Changeset Check (Skipped) + if: ${{ contains(github.event.pull_request.labels.*.name, 'skip-changeset') || contains(github.event.pull_request.labels.*.name, 'dependencies') }} + runs-on: ubuntu-latest + steps: + - run: echo "⏭️ Changeset check skipped via label" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86cced1..2f5f55f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,7 +52,7 @@ importers: version: 7.7.3 tar: specifier: ^7.5.3 - version: 7.5.9 + version: 7.5.11 winston: specifier: ^3.17.0 version: 3.17.0 @@ -409,8 +409,8 @@ packages: resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@hono/node-server@1.19.9': - resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + '@hono/node-server@1.19.10': + resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 @@ -953,49 +953,41 @@ 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==} @@ -1694,8 +1686,8 @@ packages: resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - express-rate-limit@8.2.1: - resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -1914,8 +1906,8 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} - hono@4.12.2: - resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==} + hono@4.12.7: + resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -1985,8 +1977,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.0.1: - resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2417,8 +2409,8 @@ packages: resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} engines: {node: '>=8'} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} minizlib@3.1.0: @@ -2938,8 +2930,8 @@ packages: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} - tar@7.5.9: - resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} + tar@7.5.11: + resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} engines: {node: '>=18'} term-size@2.2.1: @@ -3688,9 +3680,9 @@ snapshots: '@eslint/core': 0.13.0 levn: 0.4.1 - '@hono/node-server@1.19.9(hono@4.12.2)': + '@hono/node-server@1.19.10(hono@4.12.7)': dependencies: - hono: 4.12.2 + hono: 4.12.7 '@humanfs/core@0.19.1': {} @@ -3841,7 +3833,7 @@ snapshots: '@isaacs/fs-minipass@4.0.1': dependencies: - minipass: 7.1.2 + minipass: 7.1.3 '@istanbuljs/load-nyc-config@1.1.0': dependencies: @@ -4093,7 +4085,7 @@ snapshots: '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.9(hono@4.12.2) + '@hono/node-server': 1.19.10(hono@4.12.7) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -4102,8 +4094,8 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.6 express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.12.2 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.7 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -5128,10 +5120,10 @@ snapshots: jest-mock: 30.2.0 jest-util: 30.2.0 - express-rate-limit@8.2.1(express@5.2.1): + express-rate-limit@8.3.1(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.0.1 + ip-address: 10.1.0 express@5.2.1: dependencies: @@ -5319,7 +5311,7 @@ snapshots: foreground-child: 3.3.1 jackspeak: 3.4.3 minimatch: 9.0.8 - minipass: 7.1.2 + minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -5391,7 +5383,7 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - hono@4.12.2: {} + hono@4.12.7: {} html-escaper@2.0.2: {} @@ -5457,7 +5449,7 @@ snapshots: inherits@2.0.4: {} - ip-address@10.0.1: {} + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} @@ -6044,11 +6036,11 @@ snapshots: minipass@4.2.8: {} - minipass@7.1.2: {} + minipass@7.1.3: {} minizlib@3.1.0: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 mkdirp@1.0.4: {} @@ -6181,7 +6173,7 @@ snapshots: path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 7.1.3 path-to-regexp@8.3.0: {} @@ -6523,11 +6515,11 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - tar@7.5.9: + tar@7.5.11: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 - minipass: 7.1.2 + minipass: 7.1.3 minizlib: 3.1.0 yallist: 5.0.0 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/src/tools/ckb-debugger-wasm.ts b/src/tools/ckb-debugger-wasm.ts index dd479a7..dfd1f3a 100644 --- a/src/tools/ckb-debugger-wasm.ts +++ b/src/tools/ckb-debugger-wasm.ts @@ -23,7 +23,6 @@ */ import * as fs from 'node:fs'; -import * as wasi from 'node:wasi'; import * as path from 'node:path'; /** @@ -60,6 +59,7 @@ export class CkbDebuggerWasi { private env: Record; private captureOutput: boolean; private wasm: WebAssembly.Module | null = null; + private static wasiModule: typeof import('node:wasi') | null = null; constructor(options: CkbDebuggerOptions = {}) { this.wasmPath = options.wasmPath || './ckb-debugger.wasm'; @@ -77,6 +77,17 @@ export class CkbDebuggerWasi { } } + private async getWasiModule(): Promise { + if (!CkbDebuggerWasi.wasiModule) { + try { + CkbDebuggerWasi.wasiModule = await import('node:wasi'); + } catch (_error) { + throw new Error('Failed to load WASI module. Node.js >= 20.0.0 is required for WASM debugger support.'); + } + } + return CkbDebuggerWasi.wasiModule; + } + private extractFilePathPreopens(args: string[]): Record { const additionalPreopens: Record = {}; for (let i = 0; i < args.length - 1; i++) { @@ -99,6 +110,7 @@ export class CkbDebuggerWasi { async run(args: string[] = [], preopens: Record = {}): Promise { await this.initialize(); + const wasiModule = await this.getWasiModule(); return new Promise((resolve, reject) => { try { @@ -136,7 +148,7 @@ export class CkbDebuggerWasi { // For output capture, we'll handle it differently since WASI expects file descriptors // We'll use the default stdout/stderr and capture via different means if needed - const wasihost = new wasi.WASI(wasiOptions); + const wasihost = new wasiModule.WASI(wasiOptions); // Instantiate the WebAssembly module WebAssembly.instantiate(this.wasm!, wasihost.getImportObject() as WebAssembly.Imports) 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); + }); +});