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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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"
]
}
}
```
Expand Down
7 changes: 4 additions & 3 deletions src/commands/agent-context/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/build/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
13 changes: 7 additions & 6 deletions src/commands/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/commands/request/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down
9 changes: 4 additions & 5 deletions src/commands/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion src/commands/ssg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
)
}

Expand Down
11 changes: 6 additions & 5 deletions src/utils/load-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 8 additions & 4 deletions src/utils/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 12 additions & 4 deletions src/utils/output.ts
Original file line number Diff line number Diff line change
@@ -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
}
}

Expand All @@ -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,
Expand Down
Loading