diff --git a/README.md b/README.md index 5be6848..f18ff53 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ hono build -m --optimize **Output:** -The result is JSON. All Hono CLI commands use the same envelope: `ok` and `data` on success, `ok: false` and `error` (with `code`, `message`, and `hint`) on failure with exit code 1. +The result is JSON. All Hono CLI commands use the same envelope: `ok` and `data` on success, `ok: false` and `error` on failure with exit code 1. The error has a machine-readable `code`, a `message`, `suggestions` to try in order, and sometimes a `docs` link. ```json { @@ -183,7 +183,10 @@ The result is JSON. All Hono CLI commands use the same envelope: `ok` and `data` "error": { "code": "ENTRY_NOT_FOUND", "message": "Entry file missing.ts does not exist", - "hint": "Pass an existing entry file: hono build src/index.ts" + "suggestions": [ + "Pass the entry file: hono build src/app.ts", + "Default candidates are src/index.ts, src/index.tsx, src/index.js, and src/index.jsx" + ] } } ``` diff --git a/src/commands/agent-context/document.ts b/src/commands/agent-context/document.ts index 3c6bd72..df2e0fb 100644 --- a/src/commands/agent-context/document.ts +++ b/src/commands/agent-context/document.ts @@ -57,10 +57,11 @@ export const renderAgentContext = (program: Command): string => 'Every command prints JSON to stdout:', bullets([ 'Success: `{ "ok": true, "data": ... }` with exit code 0', - 'Failure: `{ "ok": false, "error": { "code", "message", "hint" } }` with exit code 1', + 'Failure: `{ "ok": false, "error": { "code", "message", "suggestions", "docs" } }` with exit code 1', ]), - 'Follow `error.hint` when a command fails. Logs go to stderr. Add `--plain` ' + - 'when a human wants to read the output.' + 'On failure, try `error.suggestions` in order. `error.docs` is a hono.dev page ' + + 'for the error — fetch it with the `Accept: text/markdown` header. Logs go to ' + + 'stderr. Add `--plain` when a human wants to read the output.' ), section( 2, diff --git a/src/commands/build/index.test.ts b/src/commands/build/index.test.ts index 9b3f32e..b824765 100644 --- a/src/commands/build/index.test.ts +++ b/src/commands/build/index.test.ts @@ -42,7 +42,7 @@ describe('buildCommand', () => { const parsed = JSON.parse(log.mock.calls[0][0] as string) expect(parsed.ok).toBe(false) expect(parsed.error.code).toBe('ENTRY_NOT_FOUND') - expect(parsed.error.hint).toBeDefined() + expect(parsed.error.suggestions.length).toBeGreaterThan(0) expect(process.exitCode).toBe(1) process.exitCode = undefined log.mockRestore() diff --git a/src/commands/build/index.ts b/src/commands/build/index.ts index 8403975..879bda9 100644 --- a/src/commands/build/index.ts +++ b/src/commands/build/index.ts @@ -85,7 +85,7 @@ export function buildCommand(program: Command) { throw new CliError( 'INVALID_OPTION', `Invalid mode for --request-body-api-removal: ${options.requestBodyApiRemoval}`, - 'Use one of: auto, force, disable' + { suggestions: ['Use one of: auto, force, disable'] } ) } if (!entry) { @@ -97,11 +97,12 @@ export function buildCommand(program: Command) { const appPath = resolve(process.cwd(), entry) if (!existsSync(appPath)) { - throw new CliError( - 'ENTRY_NOT_FOUND', - `Entry file ${entry} does not exist`, - 'Pass an existing entry file: hono build src/index.ts' - ) + throw new CliError('ENTRY_NOT_FOUND', `Entry file ${entry} does not exist`, { + suggestions: [ + 'Pass the entry file: hono build src/app.ts', + 'Default candidates are src/index.ts, src/index.tsx, src/index.js, and src/index.jsx', + ], + }) } const appFilePath = realpathSync(appPath) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 7777052..f1fd0a1 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -948,7 +948,7 @@ describe('requestCommand', () => { const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0]) expect(parsed.ok).toBe(false) expect(parsed.error.code).toBe('ENTRY_NOT_FOUND') - expect(parsed.error.hint).toBeDefined() + expect(parsed.error.suggestions.length).toBeGreaterThan(0) expect(process.exitCode).toBe(1) process.exitCode = undefined }) diff --git a/src/commands/routes/index.ts b/src/commands/routes/index.ts index a6282b1..ddf6bc7 100644 --- a/src/commands/routes/index.ts +++ b/src/commands/routes/index.ts @@ -42,11 +42,10 @@ export function routesCommand(program: Command) { const app = (await buildIterator.next()).value if (!app || !Array.isArray(app.routes)) { - throw new CliError( - 'INVALID_APP', - 'The app does not expose routes', - 'Export the Hono instance as the default export' - ) + throw new CliError('INVALID_APP', 'The app does not expose routes', { + suggestions: ['Export the Hono instance as the default export'], + docs: 'https://hono.dev/docs/api/hono', + }) } const routes = inspectRoutes(app).filter( diff --git a/src/commands/ssg/index.ts b/src/commands/ssg/index.ts index 3841f17..6a7d0bc 100644 --- a/src/commands/ssg/index.ts +++ b/src/commands/ssg/index.ts @@ -64,7 +64,10 @@ export function ssgCommand(program: Command) { throw new CliError( 'SSG_FAILED', result.error?.message ?? 'Failed to generate static files', - 'Check the routes with: hono routes' + { + suggestions: ['Check the routes with: hono routes'], + docs: 'https://hono.dev/docs/helpers/ssg', + } ) } diff --git a/src/utils/load-app.ts b/src/utils/load-app.ts index c459619..42ef269 100644 --- a/src/utils/load-app.ts +++ b/src/utils/load-app.ts @@ -30,11 +30,12 @@ export function getBuildIterator( } if (!existsSync(resolvedAppPath)) { - throw new CliError( - 'ENTRY_NOT_FOUND', - `Entry file ${entry} does not exist`, - 'Pass an existing app file: hono request src/index.ts' - ) + throw new CliError('ENTRY_NOT_FOUND', `Entry file ${entry} does not exist`, { + suggestions: [ + 'Pass the app file: hono routes src/app.ts', + 'Default candidates are src/index.ts, src/index.tsx, src/index.js, and src/index.jsx', + ], + }) } const appFilePath = realpathSync(resolvedAppPath) diff --git a/src/utils/output.test.ts b/src/utils/output.test.ts index 657e6bd..617a775 100644 --- a/src/utils/output.test.ts +++ b/src/utils/output.test.ts @@ -9,19 +9,23 @@ describe('formatResult', () => { }) describe('formatError', () => { - it('should include code, message, and hint', () => { - const error = new CliError('ENTRY_NOT_FOUND', 'src/index.ts does not exist', 'Pass a file') + it('should include code, message, suggestions, and docs', () => { + const error = new CliError('ENTRY_NOT_FOUND', 'src/index.ts does not exist', { + suggestions: ['Pass a file', 'Check the candidates'], + docs: 'https://hono.dev/docs', + }) expect(JSON.parse(formatError(error))).toEqual({ ok: false, error: { code: 'ENTRY_NOT_FOUND', message: 'src/index.ts does not exist', - hint: 'Pass a file', + suggestions: ['Pass a file', 'Check the candidates'], + docs: 'https://hono.dev/docs', }, }) }) - it('should omit hint when not set', () => { + it('should omit suggestions and docs when not set', () => { const error = new CliError('UNEXPECTED_ERROR', 'boom') expect(JSON.parse(formatError(error))).toEqual({ ok: false, diff --git a/src/utils/output.ts b/src/utils/output.ts index cb5ed89..28a185a 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -1,11 +1,18 @@ +interface CliErrorOptions { + suggestions?: string[] + docs?: string +} + export class CliError extends Error { code: string - hint?: string + suggestions?: string[] + docs?: string - constructor(code: string, message: string, hint?: string) { + constructor(code: string, message: string, options: CliErrorOptions = {}) { super(message) this.code = code - this.hint = hint + this.suggestions = options.suggestions + this.docs = options.docs } } @@ -18,7 +25,8 @@ export const formatError = (error: CliError): string => error: { code: error.code, message: error.message, - ...(error.hint ? { hint: error.hint } : {}), + ...(error.suggestions?.length ? { suggestions: error.suggestions } : {}), + ...(error.docs ? { docs: error.docs } : {}), }, }, null,