incur 0.1.17 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -9
- package/SKILL.md +173 -0
- package/dist/Cli.d.ts +39 -6
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +536 -43
- package/dist/Cli.js.map +1 -1
- package/dist/Errors.d.ts +4 -0
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +3 -0
- package/dist/Errors.js.map +1 -1
- package/dist/Fetch.d.ts +26 -0
- package/dist/Fetch.d.ts.map +1 -0
- package/dist/Fetch.js +150 -0
- package/dist/Fetch.js.map +1 -0
- package/dist/Filter.d.ts +14 -0
- package/dist/Filter.d.ts.map +1 -0
- package/dist/Filter.js +134 -0
- package/dist/Filter.js.map +1 -0
- package/dist/Help.js +2 -0
- package/dist/Help.js.map +1 -1
- package/dist/Mcp.d.ts +26 -0
- package/dist/Mcp.d.ts.map +1 -1
- package/dist/Mcp.js +2 -2
- package/dist/Mcp.js.map +1 -1
- package/dist/Openapi.d.ts +20 -0
- package/dist/Openapi.d.ts.map +1 -0
- package/dist/Openapi.js +136 -0
- package/dist/Openapi.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +8 -2
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js.map +1 -1
- package/package.json +4 -1
- package/src/Cli.test-d.ts +27 -2
- package/src/Cli.test.ts +1007 -0
- package/src/Cli.ts +676 -47
- package/src/Errors.ts +5 -0
- package/src/Fetch.test.ts +274 -0
- package/src/Fetch.ts +170 -0
- package/src/Filter.test.ts +237 -0
- package/src/Filter.ts +139 -0
- package/src/Help.test.ts +14 -0
- package/src/Help.ts +2 -0
- package/src/Mcp.ts +3 -3
- package/src/Openapi.test.ts +320 -0
- package/src/Openapi.ts +196 -0
- package/src/e2e.test.ts +778 -0
- package/src/index.ts +3 -0
- package/src/middleware.ts +9 -2
package/src/Filter.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** A single segment in a filter path: either a string key or an array slice. */
|
|
2
|
+
export type Segment = { key: string } | { start: number; end: number }
|
|
3
|
+
|
|
4
|
+
/** A filter path is an ordered list of segments to traverse. */
|
|
5
|
+
export type FilterPath = Segment[]
|
|
6
|
+
|
|
7
|
+
/** Parses a filter expression string into structured filter paths. */
|
|
8
|
+
export function parse(expression: string): FilterPath[] {
|
|
9
|
+
const paths: FilterPath[] = []
|
|
10
|
+
const tokens: string[] = []
|
|
11
|
+
let current = ''
|
|
12
|
+
let depth = 0
|
|
13
|
+
|
|
14
|
+
// Split on commas, but commas inside [...] are part of a slice
|
|
15
|
+
for (let i = 0; i < expression.length; i++) {
|
|
16
|
+
const ch = expression[i]!
|
|
17
|
+
if (ch === '[') depth++
|
|
18
|
+
else if (ch === ']') depth--
|
|
19
|
+
|
|
20
|
+
if (ch === ',' && depth === 0) {
|
|
21
|
+
tokens.push(current)
|
|
22
|
+
current = ''
|
|
23
|
+
} else current += ch
|
|
24
|
+
}
|
|
25
|
+
if (current) tokens.push(current)
|
|
26
|
+
|
|
27
|
+
for (const token of tokens) {
|
|
28
|
+
const path: FilterPath = []
|
|
29
|
+
let remaining = token
|
|
30
|
+
|
|
31
|
+
while (remaining.length > 0) {
|
|
32
|
+
const bracketIdx = remaining.indexOf('[')
|
|
33
|
+
|
|
34
|
+
if (bracketIdx === -1) {
|
|
35
|
+
// No more slices — split remaining by dots
|
|
36
|
+
for (const part of remaining.split('.')) if (part) path.push({ key: part })
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Parse dot-separated keys before the bracket
|
|
41
|
+
const before = remaining.slice(0, bracketIdx)
|
|
42
|
+
for (const part of before.split('.')) if (part) path.push({ key: part })
|
|
43
|
+
|
|
44
|
+
// Parse the slice [start,end]
|
|
45
|
+
const closeBracket = remaining.indexOf(']', bracketIdx)
|
|
46
|
+
const inner = remaining.slice(bracketIdx + 1, closeBracket)
|
|
47
|
+
const [startStr, endStr] = inner.split(',')
|
|
48
|
+
path.push({ start: Number(startStr), end: Number(endStr) })
|
|
49
|
+
|
|
50
|
+
remaining = remaining.slice(closeBracket + 1)
|
|
51
|
+
if (remaining.startsWith('.')) remaining = remaining.slice(1)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
paths.push(path)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return paths
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Applies parsed filter paths to a data value, returning a filtered copy. */
|
|
61
|
+
export function apply(data: unknown, paths: FilterPath[]): unknown {
|
|
62
|
+
if (paths.length === 0) return data
|
|
63
|
+
|
|
64
|
+
// Single key selecting a scalar → return the scalar directly
|
|
65
|
+
if (paths.length === 1 && paths[0]!.length === 1 && 'key' in paths[0]![0]!) {
|
|
66
|
+
const key = paths[0]![0]!.key
|
|
67
|
+
if (Array.isArray(data)) return data.map((item) => apply(item, paths))
|
|
68
|
+
if (typeof data === 'object' && data !== null) {
|
|
69
|
+
const val = (data as Record<string, unknown>)[key]
|
|
70
|
+
if (typeof val !== 'object' || val === null) return val
|
|
71
|
+
return { [key]: val }
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (Array.isArray(data)) return data.map((item) => apply(item, paths))
|
|
77
|
+
|
|
78
|
+
const result: Record<string, unknown> = {}
|
|
79
|
+
for (const path of paths) merge(result, data, path, 0)
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolve(data: unknown, segments: Segment[], index: number): unknown {
|
|
84
|
+
if (index >= segments.length) return data
|
|
85
|
+
const segment = segments[index]!
|
|
86
|
+
|
|
87
|
+
if ('key' in segment) {
|
|
88
|
+
if (typeof data !== 'object' || data === null) return undefined
|
|
89
|
+
const val = (data as Record<string, unknown>)[segment.key]
|
|
90
|
+
return resolve(val, segments, index + 1)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// slice segment
|
|
94
|
+
if (!Array.isArray(data)) return undefined
|
|
95
|
+
const sliced = data.slice(segment.start, segment.end)
|
|
96
|
+
if (index + 1 >= segments.length) return sliced
|
|
97
|
+
return sliced.map((item) => resolve(item, segments, index + 1))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function merge(target: Record<string, unknown>, data: unknown, segments: Segment[], index: number): void {
|
|
101
|
+
if (index >= segments.length || typeof data !== 'object' || data === null) return
|
|
102
|
+
const segment = segments[index]!
|
|
103
|
+
|
|
104
|
+
if ('key' in segment) {
|
|
105
|
+
const val = (data as Record<string, unknown>)[segment.key]
|
|
106
|
+
if (val === undefined) return
|
|
107
|
+
|
|
108
|
+
if (index + 1 >= segments.length) {
|
|
109
|
+
target[segment.key] = val
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const next = segments[index + 1]!
|
|
114
|
+
if ('start' in next) {
|
|
115
|
+
// Next segment is a slice
|
|
116
|
+
if (!Array.isArray(val)) return
|
|
117
|
+
const sliced = val.slice(next.start, next.end)
|
|
118
|
+
if (index + 2 >= segments.length) {
|
|
119
|
+
target[segment.key] = sliced
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
target[segment.key] = sliced.map((item) => {
|
|
123
|
+
const sub: Record<string, unknown> = {}
|
|
124
|
+
merge(sub, item, segments, index + 2)
|
|
125
|
+
return sub
|
|
126
|
+
})
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Next segment is a key — recurse into nested object
|
|
131
|
+
if (typeof val !== 'object' || val === null) return
|
|
132
|
+
if (!target[segment.key] || typeof target[segment.key] !== 'object')
|
|
133
|
+
target[segment.key] = {}
|
|
134
|
+
merge(target[segment.key] as Record<string, unknown>, val, segments, index + 1)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// slice at root level — shouldn't happen in merge (merge starts from object keys)
|
|
139
|
+
}
|
package/src/Help.test.ts
CHANGED
|
@@ -25,9 +25,11 @@ describe('formatCommand', () => {
|
|
|
25
25
|
--limit <number> Max PRs to return (default: 30)
|
|
26
26
|
|
|
27
27
|
Global Options:
|
|
28
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
28
29
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
29
30
|
--help Show help
|
|
30
31
|
--llms Print LLM-readable manifest
|
|
32
|
+
--schema Show JSON Schema for a command
|
|
31
33
|
--verbose Show full output envelope"
|
|
32
34
|
`)
|
|
33
35
|
})
|
|
@@ -42,9 +44,11 @@ describe('formatCommand', () => {
|
|
|
42
44
|
Usage: tool ping
|
|
43
45
|
|
|
44
46
|
Global Options:
|
|
47
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
45
48
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
46
49
|
--help Show help
|
|
47
50
|
--llms Print LLM-readable manifest
|
|
51
|
+
--schema Show JSON Schema for a command
|
|
48
52
|
--verbose Show full output envelope"
|
|
49
53
|
`)
|
|
50
54
|
})
|
|
@@ -66,9 +70,11 @@ describe('formatCommand', () => {
|
|
|
66
70
|
title Title
|
|
67
71
|
|
|
68
72
|
Global Options:
|
|
73
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
69
74
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
70
75
|
--help Show help
|
|
71
76
|
--llms Print LLM-readable manifest
|
|
77
|
+
--schema Show JSON Schema for a command
|
|
72
78
|
--verbose Show full output envelope"
|
|
73
79
|
`)
|
|
74
80
|
})
|
|
@@ -107,9 +113,11 @@ describe('formatRoot', () => {
|
|
|
107
113
|
issue list List issues
|
|
108
114
|
|
|
109
115
|
Global Options:
|
|
116
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
110
117
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
111
118
|
--help Show help
|
|
112
119
|
--llms Print LLM-readable manifest
|
|
120
|
+
--schema Show JSON Schema for a command
|
|
113
121
|
--verbose Show full output envelope"
|
|
114
122
|
`)
|
|
115
123
|
})
|
|
@@ -127,9 +135,11 @@ describe('formatRoot', () => {
|
|
|
127
135
|
ping Health check
|
|
128
136
|
|
|
129
137
|
Global Options:
|
|
138
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
130
139
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
131
140
|
--help Show help
|
|
132
141
|
--llms Print LLM-readable manifest
|
|
142
|
+
--schema Show JSON Schema for a command
|
|
133
143
|
--verbose Show full output envelope"
|
|
134
144
|
`)
|
|
135
145
|
})
|
|
@@ -151,9 +161,11 @@ describe('formatRoot', () => {
|
|
|
151
161
|
fetch Fetch a URL
|
|
152
162
|
|
|
153
163
|
Global Options:
|
|
164
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
154
165
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
155
166
|
--help Show help
|
|
156
167
|
--llms Print LLM-readable manifest
|
|
168
|
+
--schema Show JSON Schema for a command
|
|
157
169
|
--verbose Show full output envelope"
|
|
158
170
|
`)
|
|
159
171
|
})
|
|
@@ -175,9 +187,11 @@ describe('formatRoot', () => {
|
|
|
175
187
|
url URL to fetch
|
|
176
188
|
|
|
177
189
|
Global Options:
|
|
190
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
178
191
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
179
192
|
--help Show help
|
|
180
193
|
--llms Print LLM-readable manifest
|
|
194
|
+
--schema Show JSON Schema for a command
|
|
181
195
|
--verbose Show full output envelope"
|
|
182
196
|
`)
|
|
183
197
|
})
|
package/src/Help.ts
CHANGED
|
@@ -326,10 +326,12 @@ function globalOptionsLines(root = false): string[] {
|
|
|
326
326
|
}
|
|
327
327
|
|
|
328
328
|
const flags = [
|
|
329
|
+
{ flag: '--filter-output <keys>', desc: 'Filter output by key paths (e.g. foo,bar.baz,a[0,3])' },
|
|
329
330
|
{ flag: '--format <toon|json|yaml|md|jsonl>', desc: 'Output format' },
|
|
330
331
|
{ flag: '--help', desc: 'Show help' },
|
|
331
332
|
{ flag: '--llms', desc: 'Print LLM-readable manifest' },
|
|
332
333
|
...(root ? [{ flag: '--mcp', desc: 'Start as MCP stdio server' }] : []),
|
|
334
|
+
{ flag: '--schema', desc: 'Show JSON Schema for a command' },
|
|
333
335
|
{ flag: '--verbose', desc: 'Show full output envelope' },
|
|
334
336
|
...(root ? [{ flag: '--version', desc: 'Show version' }] : []),
|
|
335
337
|
]
|
package/src/Mcp.ts
CHANGED
|
@@ -52,7 +52,7 @@ export declare namespace serve {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/** @internal Executes a tool call and returns a CallToolResult. */
|
|
55
|
-
async function callTool(
|
|
55
|
+
export async function callTool(
|
|
56
56
|
tool: ToolEntry,
|
|
57
57
|
params: Record<string, unknown>,
|
|
58
58
|
extra?: {
|
|
@@ -135,7 +135,7 @@ function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unkn
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/** @internal A resolved tool entry from the command tree. */
|
|
138
|
-
type ToolEntry = {
|
|
138
|
+
export type ToolEntry = {
|
|
139
139
|
name: string
|
|
140
140
|
description?: string | undefined
|
|
141
141
|
inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[] }
|
|
@@ -143,7 +143,7 @@ type ToolEntry = {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/** @internal Recursively collects leaf commands as tool entries. */
|
|
146
|
-
function collectTools(commands: Map<string, any>, prefix: string[]): ToolEntry[] {
|
|
146
|
+
export function collectTools(commands: Map<string, any>, prefix: string[]): ToolEntry[] {
|
|
147
147
|
const result: ToolEntry[] = []
|
|
148
148
|
for (const [name, entry] of commands) {
|
|
149
149
|
const path = [...prefix, name]
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import * as Cli from './Cli.js'
|
|
4
|
+
import * as Openapi from './Openapi.js'
|
|
5
|
+
import { app } from '../test/fixtures/hono-api.js'
|
|
6
|
+
import { app as prefixedApp } from '../test/fixtures/hono-api-prefixed.js'
|
|
7
|
+
import { spec } from '../test/fixtures/openapi-spec.js'
|
|
8
|
+
import { app as openapiApp, spec as openapiSpec } from '../test/fixtures/hono-openapi-app.js'
|
|
9
|
+
|
|
10
|
+
function serve(cli: { serve: Cli.Cli['serve'] }, argv: string[]) {
|
|
11
|
+
let output = ''
|
|
12
|
+
let exitCode: number | undefined
|
|
13
|
+
return cli
|
|
14
|
+
.serve(argv, {
|
|
15
|
+
stdout: (s) => (output += s),
|
|
16
|
+
exit: (c) => { exitCode = c },
|
|
17
|
+
})
|
|
18
|
+
.then(() => ({
|
|
19
|
+
output,
|
|
20
|
+
exitCode,
|
|
21
|
+
}))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function json(output: string) {
|
|
25
|
+
return JSON.parse(
|
|
26
|
+
output.replace(/"duration": "[^"]+"/g, '"duration": "<stripped>"'),
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('generateCommands', () => {
|
|
31
|
+
test('generates command entries from spec', async () => {
|
|
32
|
+
const commands = await Openapi.generateCommands(spec, app.fetch)
|
|
33
|
+
expect(commands.has('listUsers')).toBe(true)
|
|
34
|
+
expect(commands.has('createUser')).toBe(true)
|
|
35
|
+
expect(commands.has('getUser')).toBe(true)
|
|
36
|
+
expect(commands.has('deleteUser')).toBe(true)
|
|
37
|
+
expect(commands.has('healthCheck')).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('command has description from summary', async () => {
|
|
41
|
+
const commands = await Openapi.generateCommands(spec, app.fetch)
|
|
42
|
+
const cmd = commands.get('listUsers')!
|
|
43
|
+
expect(cmd.description).toBe('List users')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('cli integration', () => {
|
|
48
|
+
function createCli() {
|
|
49
|
+
return Cli.create('test', { description: 'test' })
|
|
50
|
+
.command('api', { fetch: app.fetch, openapi: spec })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('GET /users via operationId', async () => {
|
|
54
|
+
const { output } = await serve(createCli(), ['api', 'listUsers'])
|
|
55
|
+
expect(output).toContain('Alice')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('GET /users?limit=5 via options', async () => {
|
|
59
|
+
const { output } = await serve(createCli(), [
|
|
60
|
+
'api', 'listUsers', '--limit', '5', '--format', 'json',
|
|
61
|
+
])
|
|
62
|
+
expect(json(output).limit).toBe(5)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('GET /users/:id via positional arg', async () => {
|
|
66
|
+
const { output } = await serve(createCli(), ['api', 'getUser', '42'])
|
|
67
|
+
expect(output).toMatchInlineSnapshot(`
|
|
68
|
+
"id: 42
|
|
69
|
+
name: Alice
|
|
70
|
+
"
|
|
71
|
+
`)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('POST /users via createUser with body options', async () => {
|
|
75
|
+
const { output } = await serve(createCli(), ['api', 'createUser', '--name', 'Bob'])
|
|
76
|
+
expect(output).toMatchInlineSnapshot(`
|
|
77
|
+
"created: true
|
|
78
|
+
name: Bob
|
|
79
|
+
"
|
|
80
|
+
`)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('DELETE /users/:id via deleteUser', async () => {
|
|
84
|
+
const { output } = await serve(createCli(), ['api', 'deleteUser', '1'])
|
|
85
|
+
expect(output).toMatchInlineSnapshot(`
|
|
86
|
+
"deleted: true
|
|
87
|
+
id: 1
|
|
88
|
+
"
|
|
89
|
+
`)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('GET /health via healthCheck', async () => {
|
|
93
|
+
const { output } = await serve(createCli(), ['api', 'healthCheck'])
|
|
94
|
+
expect(output).toMatchInlineSnapshot(`
|
|
95
|
+
"ok: true
|
|
96
|
+
"
|
|
97
|
+
`)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('--help on api shows subcommands', async () => {
|
|
101
|
+
const { output } = await serve(createCli(), ['api', '--help'])
|
|
102
|
+
expect(output).toContain('listUsers')
|
|
103
|
+
expect(output).toContain('createUser')
|
|
104
|
+
expect(output).toContain('getUser')
|
|
105
|
+
expect(output).toContain('deleteUser')
|
|
106
|
+
expect(output).toContain('healthCheck')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('--help on specific command shows typed args/options', async () => {
|
|
110
|
+
const { output } = await serve(createCli(), ['api', 'getUser', '--help'])
|
|
111
|
+
expect(output).toContain('id')
|
|
112
|
+
expect(output).toContain('Get a user by ID')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('--help on createUser shows body options', async () => {
|
|
116
|
+
const { output } = await serve(createCli(), ['api', 'createUser', '--help'])
|
|
117
|
+
expect(output).toContain('name')
|
|
118
|
+
expect(output).toContain('Create a user')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('--format json', async () => {
|
|
122
|
+
const { output } = await serve(createCli(), ['api', 'healthCheck', '--format', 'json'])
|
|
123
|
+
expect(json(output)).toEqual({ ok: true })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('--verbose wraps in envelope', async () => {
|
|
127
|
+
const { output } = await serve(createCli(), [
|
|
128
|
+
'api', 'healthCheck', '--verbose', '--format', 'json',
|
|
129
|
+
])
|
|
130
|
+
const parsed = json(output)
|
|
131
|
+
expect(parsed.ok).toBe(true)
|
|
132
|
+
expect(parsed.data).toEqual({ ok: true })
|
|
133
|
+
expect(parsed.meta.command).toContain('api')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('missing required path param shows validation error', async () => {
|
|
137
|
+
const { exitCode } = await serve(createCli(), ['api', 'getUser'])
|
|
138
|
+
expect(exitCode).toBe(1)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('@hono/zod-openapi integration', () => {
|
|
143
|
+
function createCli() {
|
|
144
|
+
return Cli.create('test', { description: 'test' })
|
|
145
|
+
.command('api', { fetch: openapiApp.fetch, openapi: openapiSpec })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
test('GET /users via listUsers', async () => {
|
|
149
|
+
const { output } = await serve(createCli(), ['api', 'listUsers'])
|
|
150
|
+
expect(output).toContain('Alice')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('GET /users?limit=5', async () => {
|
|
154
|
+
const { output } = await serve(createCli(), ['api', 'listUsers', '--limit', '5', '--format', 'json'])
|
|
155
|
+
expect(json(output).limit).toBe(5)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('GET /users/:id via getUser', async () => {
|
|
159
|
+
const { output } = await serve(createCli(), ['api', 'getUser', '42'])
|
|
160
|
+
expect(output).toMatchInlineSnapshot(`
|
|
161
|
+
"id: 42
|
|
162
|
+
name: Alice
|
|
163
|
+
"
|
|
164
|
+
`)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('POST /users via createUser', async () => {
|
|
168
|
+
const { output } = await serve(createCli(), ['api', 'createUser', '--name', 'Bob'])
|
|
169
|
+
expect(output).toMatchInlineSnapshot(`
|
|
170
|
+
"created: true
|
|
171
|
+
name: Bob
|
|
172
|
+
"
|
|
173
|
+
`)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('DELETE /users/:id via deleteUser', async () => {
|
|
177
|
+
const { output } = await serve(createCli(), ['api', 'deleteUser', '1'])
|
|
178
|
+
expect(output).toMatchInlineSnapshot(`
|
|
179
|
+
"deleted: true
|
|
180
|
+
id: 1
|
|
181
|
+
"
|
|
182
|
+
`)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('GET /health via healthCheck', async () => {
|
|
186
|
+
const { output } = await serve(createCli(), ['api', 'healthCheck'])
|
|
187
|
+
expect(output).toMatchInlineSnapshot(`
|
|
188
|
+
"ok: true
|
|
189
|
+
"
|
|
190
|
+
`)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('--help shows operationId commands', async () => {
|
|
194
|
+
const { output } = await serve(createCli(), ['api', '--help'])
|
|
195
|
+
expect(output).toContain('listUsers')
|
|
196
|
+
expect(output).toContain('getUser')
|
|
197
|
+
expect(output).toContain('createUser')
|
|
198
|
+
expect(output).toContain('deleteUser')
|
|
199
|
+
expect(output).toContain('healthCheck')
|
|
200
|
+
expect(output).toContain('updateUser')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('--help on getUser shows path param', async () => {
|
|
204
|
+
const { output } = await serve(createCli(), ['api', 'getUser', '--help'])
|
|
205
|
+
expect(output).toContain('id')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('--help on createUser shows body options', async () => {
|
|
209
|
+
const { output } = await serve(createCli(), ['api', 'createUser', '--help'])
|
|
210
|
+
expect(output).toContain('name')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('--help on updateUser shows path param and body options', async () => {
|
|
214
|
+
const { output } = await serve(createCli(), ['api', 'updateUser', '--help'])
|
|
215
|
+
expect(output).toContain('id')
|
|
216
|
+
expect(output).toContain('name')
|
|
217
|
+
expect(output).toContain('Update a user')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('--format json', async () => {
|
|
221
|
+
const { output } = await serve(createCli(), ['api', 'healthCheck', '--format', 'json'])
|
|
222
|
+
expect(json(output)).toEqual({ ok: true })
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('--verbose wraps in envelope', async () => {
|
|
226
|
+
const { output } = await serve(createCli(), [
|
|
227
|
+
'api', 'healthCheck', '--verbose', '--format', 'json',
|
|
228
|
+
])
|
|
229
|
+
const parsed = json(output)
|
|
230
|
+
expect(parsed.ok).toBe(true)
|
|
231
|
+
expect(parsed.data).toEqual({ ok: true })
|
|
232
|
+
expect(parsed.meta.command).toContain('api')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('missing required path param shows validation error', async () => {
|
|
236
|
+
const { exitCode } = await serve(createCli(), ['api', 'getUser'])
|
|
237
|
+
expect(exitCode).toBe(1)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('PUT /users/:id with path param + body options', async () => {
|
|
241
|
+
const { output } = await serve(createCli(), ['api', 'updateUser', '1', '--name', 'Updated'])
|
|
242
|
+
expect(output).toMatchInlineSnapshot(`
|
|
243
|
+
"id: 1
|
|
244
|
+
name: Updated
|
|
245
|
+
"
|
|
246
|
+
`)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('PUT /users/:id with optional boolean body option', async () => {
|
|
250
|
+
const { output } = await serve(createCli(), [
|
|
251
|
+
'api', 'updateUser', '1', '--name', 'Updated', '--active', 'true', '--format', 'json',
|
|
252
|
+
])
|
|
253
|
+
const parsed = json(output)
|
|
254
|
+
expect(parsed.id).toBe(1)
|
|
255
|
+
expect(parsed.name).toBe('Updated')
|
|
256
|
+
expect(parsed.active).toBe(true)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('query param coercion with zod-openapi generated spec', async () => {
|
|
260
|
+
const { output } = await serve(createCli(), ['api', 'listUsers', '--limit', '3', '--format', 'json'])
|
|
261
|
+
expect(json(output).limit).toBe(3)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe('basePath', () => {
|
|
266
|
+
test('fetch gateway prepends basePath to request path', async () => {
|
|
267
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
268
|
+
.command('api', { fetch: prefixedApp.fetch, basePath: '/api' })
|
|
269
|
+
const { output } = await serve(cli, ['api', 'users'])
|
|
270
|
+
expect(output).toContain('Alice')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('fetch gateway basePath with query params', async () => {
|
|
274
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
275
|
+
.command('api', { fetch: prefixedApp.fetch, basePath: '/api' })
|
|
276
|
+
const { output } = await serve(cli, ['api', 'users', '--limit', '5', '--format', 'json'])
|
|
277
|
+
expect(json(output).limit).toBe(5)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('fetch gateway basePath with POST', async () => {
|
|
281
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
282
|
+
.command('api', { fetch: prefixedApp.fetch, basePath: '/api' })
|
|
283
|
+
const { output } = await serve(cli, ['api', 'users', '-X', 'POST', '-d', '{"name":"Bob"}'])
|
|
284
|
+
expect(output).toContain('Bob')
|
|
285
|
+
expect(output).toContain('created')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('openapi with basePath prepends to spec paths', async () => {
|
|
289
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
290
|
+
.command('api', { fetch: prefixedApp.fetch, openapi: spec, basePath: '/api' })
|
|
291
|
+
const { output } = await serve(cli, ['api', 'listUsers'])
|
|
292
|
+
expect(output).toContain('Alice')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('openapi basePath with path params', async () => {
|
|
296
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
297
|
+
.command('api', { fetch: prefixedApp.fetch, openapi: spec, basePath: '/api' })
|
|
298
|
+
const { output } = await serve(cli, ['api', 'getUser', '42'])
|
|
299
|
+
expect(output).toMatchInlineSnapshot(`
|
|
300
|
+
"id: 42
|
|
301
|
+
name: Alice
|
|
302
|
+
"
|
|
303
|
+
`)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('openapi basePath with body options', async () => {
|
|
307
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
308
|
+
.command('api', { fetch: prefixedApp.fetch, openapi: spec, basePath: '/api' })
|
|
309
|
+
const { output } = await serve(cli, ['api', 'createUser', '--name', 'Bob'])
|
|
310
|
+
expect(output).toContain('created')
|
|
311
|
+
expect(output).toContain('Bob')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('openapi basePath with health check', async () => {
|
|
315
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
316
|
+
.command('api', { fetch: prefixedApp.fetch, openapi: spec, basePath: '/api' })
|
|
317
|
+
const { output } = await serve(cli, ['api', 'healthCheck', '--format', 'json'])
|
|
318
|
+
expect(json(output)).toEqual({ ok: true })
|
|
319
|
+
})
|
|
320
|
+
})
|