incur 0.3.5 → 0.3.7
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 +61 -0
- package/dist/Cli.d.ts +15 -0
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +300 -25
- package/dist/Cli.js.map +1 -1
- package/dist/Filter.js +0 -18
- package/dist/Filter.js.map +1 -1
- package/dist/Help.d.ts +4 -0
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +17 -14
- package/dist/Help.js.map +1 -1
- package/dist/Parser.d.ts +2 -0
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +69 -37
- package/dist/Parser.js.map +1 -1
- package/dist/bin.d.ts +1 -0
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +17 -2
- package/dist/bin.js.map +1 -1
- package/dist/internal/command.d.ts +2 -0
- package/dist/internal/command.d.ts.map +1 -1
- package/dist/internal/command.js +1 -0
- package/dist/internal/command.js.map +1 -1
- package/dist/internal/configSchema.d.ts +8 -0
- package/dist/internal/configSchema.d.ts.map +1 -0
- package/dist/internal/configSchema.js +57 -0
- package/dist/internal/configSchema.js.map +1 -0
- package/dist/internal/helpers.d.ts +9 -0
- package/dist/internal/helpers.d.ts.map +1 -0
- package/dist/internal/helpers.js +39 -0
- package/dist/internal/helpers.js.map +1 -0
- package/examples/npm/.npmrc.json +21 -0
- package/examples/npm/config.schema.json +137 -0
- package/package.json +1 -1
- package/src/Cli.test-d.ts +39 -0
- package/src/Cli.test.ts +714 -25
- package/src/Cli.ts +353 -27
- package/src/Filter.ts +0 -17
- package/src/Help.test.ts +66 -0
- package/src/Help.ts +20 -13
- package/src/Openapi.test.ts +6 -1
- package/src/Parser.test-d.ts +22 -0
- package/src/Parser.test.ts +89 -0
- package/src/Parser.ts +86 -35
- package/src/bin.ts +21 -2
- package/src/e2e.test.ts +22 -19
- package/src/internal/command.ts +3 -0
- package/src/internal/configSchema.test.ts +193 -0
- package/src/internal/configSchema.ts +66 -0
- package/src/internal/helpers.test.ts +54 -0
- package/src/internal/helpers.ts +41 -0
package/src/Cli.test.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { Cli, Errors, z } from 'incur'
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir, tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
2
5
|
|
|
3
6
|
const originalIsTTY = process.stdout.isTTY
|
|
4
7
|
beforeAll(() => {
|
|
@@ -37,6 +40,42 @@ async function serve(
|
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
function createConfigCli(flag?: string) {
|
|
44
|
+
const project = Cli.create('project').command('list', {
|
|
45
|
+
options: z.object({
|
|
46
|
+
label: z.array(z.string()).default([]),
|
|
47
|
+
limit: z.number().default(10),
|
|
48
|
+
}),
|
|
49
|
+
run(c) {
|
|
50
|
+
return c.options
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const cli = Cli.create('test', {
|
|
55
|
+
config: flag !== undefined ? { flag } : {},
|
|
56
|
+
options: z.object({
|
|
57
|
+
rootValue: z.string().default('root-default'),
|
|
58
|
+
}),
|
|
59
|
+
run(c) {
|
|
60
|
+
return c.options
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
cli.command('echo', {
|
|
65
|
+
options: z.object({
|
|
66
|
+
prefix: z.string().default(''),
|
|
67
|
+
upper: z.boolean().default(false),
|
|
68
|
+
}),
|
|
69
|
+
run(c) {
|
|
70
|
+
return c.options
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
cli.command(project)
|
|
75
|
+
|
|
76
|
+
return cli
|
|
77
|
+
}
|
|
78
|
+
|
|
40
79
|
describe('create', () => {
|
|
41
80
|
test('returns cli instance with name', () => {
|
|
42
81
|
const cli = Cli.create('test')
|
|
@@ -62,6 +101,518 @@ describe('command', () => {
|
|
|
62
101
|
})
|
|
63
102
|
})
|
|
64
103
|
|
|
104
|
+
describe('config defaults', () => {
|
|
105
|
+
let cwd: string
|
|
106
|
+
let dir: string
|
|
107
|
+
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
cwd = process.cwd()
|
|
110
|
+
dir = await mkdtemp(join(tmpdir(), 'incur-config-'))
|
|
111
|
+
process.chdir(dir)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
afterEach(async () => {
|
|
115
|
+
process.chdir(cwd)
|
|
116
|
+
await rm(dir, { force: true, recursive: true })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('auto-loads <cli>.json for leaf commands', async () => {
|
|
120
|
+
await writeFile(
|
|
121
|
+
join(dir, 'test.json'),
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
commands: {
|
|
124
|
+
echo: {
|
|
125
|
+
options: {
|
|
126
|
+
prefix: 'cfg',
|
|
127
|
+
upper: true,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const { output } = await serve(createConfigCli(), ['echo', '--json'])
|
|
135
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'cfg', upper: true })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('ignores a missing auto config file', async () => {
|
|
139
|
+
const { output } = await serve(createConfigCli(), ['echo', '--json'])
|
|
140
|
+
expect(JSON.parse(output)).toEqual({ prefix: '', upper: false })
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('root options coexist with subcommand keys', async () => {
|
|
144
|
+
await writeFile(
|
|
145
|
+
join(dir, 'test.json'),
|
|
146
|
+
JSON.stringify({
|
|
147
|
+
options: { rootValue: 'cfg-root' },
|
|
148
|
+
commands: {
|
|
149
|
+
echo: { options: { prefix: 'cfg' } },
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const rootResult = await serve(createConfigCli(), ['--json'])
|
|
155
|
+
expect(JSON.parse(rootResult.output)).toEqual({ rootValue: 'cfg-root' })
|
|
156
|
+
|
|
157
|
+
const echoResult = await serve(createConfigCli(), ['echo', '--json'])
|
|
158
|
+
expect(JSON.parse(echoResult.output)).toEqual({ prefix: 'cfg', upper: false })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('walks nested command sections in config tree', async () => {
|
|
162
|
+
await writeFile(
|
|
163
|
+
join(dir, 'test.json'),
|
|
164
|
+
JSON.stringify({
|
|
165
|
+
commands: {
|
|
166
|
+
project: {
|
|
167
|
+
commands: {
|
|
168
|
+
list: {
|
|
169
|
+
options: {
|
|
170
|
+
label: ['cfg'],
|
|
171
|
+
limit: 25,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const { output } = await serve(createConfigCli(), ['project', 'list', '--json'])
|
|
181
|
+
expect(JSON.parse(output)).toEqual({ label: ['cfg'], limit: 25 })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('uses an explicit --config path instead of the auto file', async () => {
|
|
185
|
+
await writeFile(
|
|
186
|
+
join(dir, 'test.json'),
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
commands: { echo: { options: { prefix: 'auto' } } },
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
await writeFile(
|
|
192
|
+
join(dir, 'custom.json'),
|
|
193
|
+
JSON.stringify({
|
|
194
|
+
commands: { echo: { options: { prefix: 'custom', upper: true } } },
|
|
195
|
+
}),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
const { output } = await serve(createConfigCli('config'), [
|
|
199
|
+
'echo',
|
|
200
|
+
'--config',
|
|
201
|
+
'custom.json',
|
|
202
|
+
'--json',
|
|
203
|
+
])
|
|
204
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'custom', upper: true })
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('--no-config disables earlier config flags, and a later --config wins again', async () => {
|
|
208
|
+
await writeFile(
|
|
209
|
+
join(dir, 'one.json'),
|
|
210
|
+
JSON.stringify({
|
|
211
|
+
commands: { echo: { options: { prefix: 'one' } } },
|
|
212
|
+
}),
|
|
213
|
+
)
|
|
214
|
+
await writeFile(
|
|
215
|
+
join(dir, 'two.json'),
|
|
216
|
+
JSON.stringify({
|
|
217
|
+
commands: { echo: { options: { prefix: 'two' } } },
|
|
218
|
+
}),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const first = await serve(createConfigCli('config'), [
|
|
222
|
+
'echo',
|
|
223
|
+
'--config',
|
|
224
|
+
'one.json',
|
|
225
|
+
'--no-config',
|
|
226
|
+
'--json',
|
|
227
|
+
])
|
|
228
|
+
expect(JSON.parse(first.output)).toEqual({ prefix: '', upper: false })
|
|
229
|
+
|
|
230
|
+
const second = await serve(createConfigCli('config'), [
|
|
231
|
+
'echo',
|
|
232
|
+
'--config',
|
|
233
|
+
'one.json',
|
|
234
|
+
'--no-config',
|
|
235
|
+
'--config=two.json',
|
|
236
|
+
'--json',
|
|
237
|
+
])
|
|
238
|
+
expect(JSON.parse(second.output)).toEqual({ prefix: 'two', upper: false })
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('fails when an explicit config file is missing', async () => {
|
|
242
|
+
const { exitCode, output } = await serve(createConfigCli('config'), [
|
|
243
|
+
'echo',
|
|
244
|
+
'--config',
|
|
245
|
+
'missing.json',
|
|
246
|
+
])
|
|
247
|
+
expect(exitCode).toBe(1)
|
|
248
|
+
expect(output).toContain('Config file not found')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('fails on invalid JSON config files', async () => {
|
|
252
|
+
await writeFile(join(dir, 'test.json'), '{ invalid')
|
|
253
|
+
|
|
254
|
+
const { exitCode, output } = await serve(createConfigCli(), ['echo'])
|
|
255
|
+
expect(exitCode).toBe(1)
|
|
256
|
+
expect(output).toContain('Invalid JSON config file')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('fails when the config file top level is not an object', async () => {
|
|
260
|
+
await writeFile(join(dir, 'test.json'), JSON.stringify(['bad']))
|
|
261
|
+
|
|
262
|
+
const { exitCode, output } = await serve(createConfigCli(), ['echo'])
|
|
263
|
+
expect(exitCode).toBe(1)
|
|
264
|
+
expect(output).toContain('expected a top-level object')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('fails when the selected config section is not an object', async () => {
|
|
268
|
+
await writeFile(join(dir, 'test.json'), JSON.stringify({ commands: { echo: true } }))
|
|
269
|
+
|
|
270
|
+
const { exitCode, output } = await serve(createConfigCli(), ['echo'])
|
|
271
|
+
expect(exitCode).toBe(1)
|
|
272
|
+
expect(output).toContain("Invalid config section for 'echo'")
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('fails validation when config option values are invalid', async () => {
|
|
276
|
+
await writeFile(
|
|
277
|
+
join(dir, 'test.json'),
|
|
278
|
+
JSON.stringify({
|
|
279
|
+
commands: { echo: { options: { upper: 'nope' } } },
|
|
280
|
+
}),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
const { exitCode, output } = await serve(createConfigCli(), ['echo'])
|
|
284
|
+
expect(exitCode).toBe(1)
|
|
285
|
+
expect(output).toContain('VALIDATION_ERROR')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('argv overrides invalid config values at the CLI layer', async () => {
|
|
289
|
+
await writeFile(
|
|
290
|
+
join(dir, 'test.json'),
|
|
291
|
+
JSON.stringify({
|
|
292
|
+
commands: { echo: { options: { prefix: 123 } } },
|
|
293
|
+
}),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const { output } = await serve(createConfigCli(), ['echo', '--prefix', 'cli', '--json'])
|
|
297
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'cli', upper: false })
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('built-in commands ignore config loading', async () => {
|
|
301
|
+
await writeFile(join(dir, 'test.json'), '{ invalid')
|
|
302
|
+
|
|
303
|
+
const { output, exitCode } = await serve(createConfigCli(), ['--help'])
|
|
304
|
+
expect(exitCode).toBeUndefined()
|
|
305
|
+
expect(output).toContain('Global Options:')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('config without flag does not reserve --config', async () => {
|
|
309
|
+
const cli = Cli.create('test', { config: {} })
|
|
310
|
+
cli.command('echo', {
|
|
311
|
+
options: z.object({ config: z.string().default('') }),
|
|
312
|
+
run(c) {
|
|
313
|
+
return c.options
|
|
314
|
+
},
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
const { output } = await serve(cli, ['echo', '--config', 'my-value', '--json'])
|
|
318
|
+
expect(JSON.parse(output)).toEqual({ config: 'my-value' })
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('--help shows config flags only when flag name is set', async () => {
|
|
322
|
+
const { output } = await serve(createConfigCli('config'), ['--help'])
|
|
323
|
+
expect(output).toContain('--config <path>')
|
|
324
|
+
expect(output).toContain('--no-config')
|
|
325
|
+
|
|
326
|
+
const { output: noFlagOutput } = await serve(createConfigCli(), ['--help'])
|
|
327
|
+
expect(noFlagOutput).not.toContain('--config')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('custom flag name is used for config path override', async () => {
|
|
331
|
+
await writeFile(
|
|
332
|
+
join(dir, 'custom.json'),
|
|
333
|
+
JSON.stringify({
|
|
334
|
+
commands: { echo: { options: { prefix: 'custom' } } },
|
|
335
|
+
}),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
const { output } = await serve(createConfigCli('settings'), [
|
|
339
|
+
'echo',
|
|
340
|
+
'--settings',
|
|
341
|
+
'custom.json',
|
|
342
|
+
'--json',
|
|
343
|
+
])
|
|
344
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'custom', upper: false })
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('searches files list in order, first match wins', async () => {
|
|
348
|
+
await writeFile(
|
|
349
|
+
join(dir, '.testrc.json'),
|
|
350
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'rc' } } } }),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
const cli = Cli.create('test', {
|
|
354
|
+
config: { files: ['test.json', '.testrc.json'] },
|
|
355
|
+
})
|
|
356
|
+
cli.command('echo', {
|
|
357
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
358
|
+
run: (c) => c.options,
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
362
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'rc' })
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('files: [] disables auto-discovery', async () => {
|
|
366
|
+
await writeFile(
|
|
367
|
+
join(dir, 'test.json'),
|
|
368
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'should-not-load' } } } }),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
const cli = Cli.create('test', {
|
|
372
|
+
config: { files: [] },
|
|
373
|
+
})
|
|
374
|
+
cli.command('echo', {
|
|
375
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
376
|
+
run: (c) => c.options,
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
380
|
+
expect(JSON.parse(output)).toEqual({ prefix: '' })
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('files supports ~ for home directory', async () => {
|
|
384
|
+
const configDir = join(homedir(), '.config', 'test-incur-files-tilde')
|
|
385
|
+
await mkdir(configDir, { recursive: true })
|
|
386
|
+
try {
|
|
387
|
+
await writeFile(
|
|
388
|
+
join(configDir, 'config.json'),
|
|
389
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'home' } } } }),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
const cli = Cli.create('test', {
|
|
393
|
+
config: { files: ['test.json', '~/.config/test-incur-files-tilde/config.json'] },
|
|
394
|
+
})
|
|
395
|
+
cli.command('echo', {
|
|
396
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
397
|
+
run: (c) => c.options,
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
401
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'home' })
|
|
402
|
+
} finally {
|
|
403
|
+
await rm(configDir, { recursive: true, force: true })
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('explicit --flag overrides files list', async () => {
|
|
408
|
+
await writeFile(
|
|
409
|
+
join(dir, '.testrc.json'),
|
|
410
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'rc' } } } }),
|
|
411
|
+
)
|
|
412
|
+
await writeFile(
|
|
413
|
+
join(dir, 'override.json'),
|
|
414
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'override' } } } }),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
const cli = Cli.create('test', {
|
|
418
|
+
config: { flag: 'config', files: ['.testrc.json'] },
|
|
419
|
+
})
|
|
420
|
+
cli.command('echo', {
|
|
421
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
422
|
+
run: (c) => c.options,
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
const { output } = await serve(cli, ['echo', '--config', 'override.json', '--json'])
|
|
426
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'override' })
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
test('custom loader replaces JSON parsing', async () => {
|
|
430
|
+
await writeFile(join(dir, 'test.ini'), 'prefix=ini-value')
|
|
431
|
+
|
|
432
|
+
const cli = Cli.create('test', {
|
|
433
|
+
config: {
|
|
434
|
+
files: ['test.ini'],
|
|
435
|
+
async loader(path) {
|
|
436
|
+
if (!path) return undefined
|
|
437
|
+
const raw = await readFile(path, 'utf8')
|
|
438
|
+
const obj: Record<string, unknown> = {}
|
|
439
|
+
for (const line of raw.split('\n')) {
|
|
440
|
+
const eq = line.indexOf('=')
|
|
441
|
+
if (eq !== -1) obj[line.slice(0, eq).trim()] = line.slice(eq + 1).trim()
|
|
442
|
+
}
|
|
443
|
+
return { commands: { echo: { options: obj } } }
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
})
|
|
447
|
+
cli.command('echo', {
|
|
448
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
449
|
+
run: (c) => c.options,
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
453
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'ini-value' })
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
test('loader with files: [] receives undefined path', async () => {
|
|
457
|
+
const cli = Cli.create('test', {
|
|
458
|
+
config: {
|
|
459
|
+
files: [],
|
|
460
|
+
loader: async (path) => {
|
|
461
|
+
expect(path).toBeUndefined()
|
|
462
|
+
return { commands: { echo: { options: { prefix: 'from-loader' } } } }
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
})
|
|
466
|
+
cli.command('echo', {
|
|
467
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
468
|
+
run: (c) => c.options,
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
472
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'from-loader' })
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test('loader returning undefined applies no defaults', async () => {
|
|
476
|
+
const cli = Cli.create('test', {
|
|
477
|
+
config: { files: [], loader: async () => undefined },
|
|
478
|
+
})
|
|
479
|
+
cli.command('echo', {
|
|
480
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
481
|
+
run: (c) => c.options,
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
485
|
+
expect(JSON.parse(output)).toEqual({ prefix: '' })
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
test('--no-flag skips loader entirely', async () => {
|
|
489
|
+
let loaderCalled = false
|
|
490
|
+
const cli = Cli.create('test', {
|
|
491
|
+
config: {
|
|
492
|
+
flag: 'config',
|
|
493
|
+
files: [],
|
|
494
|
+
loader: async () => {
|
|
495
|
+
loaderCalled = true
|
|
496
|
+
return { commands: { echo: { options: { prefix: 'should-not-load' } } } }
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
})
|
|
500
|
+
cli.command('echo', {
|
|
501
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
502
|
+
run: (c) => c.options,
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
const { output } = await serve(cli, ['echo', '--no-config', '--json'])
|
|
506
|
+
expect(JSON.parse(output)).toEqual({ prefix: '' })
|
|
507
|
+
expect(loaderCalled).toBe(false)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
test('loader errors propagate', async () => {
|
|
511
|
+
const cli = Cli.create('test', {
|
|
512
|
+
config: {
|
|
513
|
+
files: [],
|
|
514
|
+
loader: async () => {
|
|
515
|
+
throw new Error('Remote config server unreachable')
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
})
|
|
519
|
+
cli.command('echo', {
|
|
520
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
521
|
+
run: (c) => c.options,
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
const { exitCode, output } = await serve(cli, ['echo'])
|
|
525
|
+
expect(exitCode).toBe(1)
|
|
526
|
+
expect(output).toContain('Remote config server unreachable')
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
test('--no-flag disables auto-discovery without prior --flag', async () => {
|
|
530
|
+
await writeFile(
|
|
531
|
+
join(dir, 'test.json'),
|
|
532
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'auto-loaded' } } } }),
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
const { output } = await serve(createConfigCli('config'), ['echo', '--no-config', '--json'])
|
|
536
|
+
expect(JSON.parse(output)).toEqual({ prefix: '', upper: false })
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
test('--config without a value produces an error', async () => {
|
|
540
|
+
const { exitCode, output } = await serve(createConfigCli('config'), ['echo', '--config'])
|
|
541
|
+
expect(exitCode).toBe(1)
|
|
542
|
+
expect(output).toContain('Missing value for flag')
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test('--config= (empty value) produces an error', async () => {
|
|
546
|
+
const { exitCode, output } = await serve(createConfigCli('config'), ['echo', '--config='])
|
|
547
|
+
expect(exitCode).toBe(1)
|
|
548
|
+
expect(output).toContain('Missing value for flag')
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
test('--no-settings works with custom flag name', async () => {
|
|
552
|
+
await writeFile(
|
|
553
|
+
join(dir, 'test.json'),
|
|
554
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'auto' } } } }),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
const { output } = await serve(createConfigCli('settings'), ['echo', '--no-settings', '--json'])
|
|
558
|
+
expect(JSON.parse(output)).toEqual({ prefix: '', upper: false })
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
test('camelCase config keys are accepted at cli level', async () => {
|
|
562
|
+
const cli = Cli.create('test', { config: {} })
|
|
563
|
+
cli.command('echo', {
|
|
564
|
+
options: z.object({ saveDev: z.boolean().default(false) }),
|
|
565
|
+
run: (c) => c.options,
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
await writeFile(
|
|
569
|
+
join(dir, 'test.json'),
|
|
570
|
+
JSON.stringify({ commands: { echo: { options: { 'save-dev': true } } } }),
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
574
|
+
expect(JSON.parse(output)).toEqual({ saveDev: true })
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
test('config defaults with only subcommand namespaces yields no option defaults', async () => {
|
|
578
|
+
await writeFile(
|
|
579
|
+
join(dir, 'test.json'),
|
|
580
|
+
JSON.stringify({
|
|
581
|
+
commands: {
|
|
582
|
+
echo: { options: { prefix: 'child' } },
|
|
583
|
+
project: { commands: { list: { options: { limit: 50 } } } },
|
|
584
|
+
},
|
|
585
|
+
}),
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
const rootResult = await serve(createConfigCli(), ['--json'])
|
|
589
|
+
expect(JSON.parse(rootResult.output)).toEqual({ rootValue: 'root-default' })
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
test('explicit --flag path is forwarded to custom loader', async () => {
|
|
593
|
+
await writeFile(join(dir, 'custom.dat'), 'prefix=custom-loader')
|
|
594
|
+
|
|
595
|
+
const cli = Cli.create('test', {
|
|
596
|
+
config: {
|
|
597
|
+
flag: 'config',
|
|
598
|
+
async loader(path) {
|
|
599
|
+
if (!path) return undefined
|
|
600
|
+
const raw = await readFile(path, 'utf8')
|
|
601
|
+
const [, value] = raw.split('=')
|
|
602
|
+
return { commands: { echo: { options: { prefix: value!.trim() } } } }
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
})
|
|
606
|
+
cli.command('echo', {
|
|
607
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
608
|
+
run: (c) => c.options,
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
const { output } = await serve(cli, ['echo', '--config', 'custom.dat', '--json'])
|
|
612
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'custom-loader' })
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
|
|
65
616
|
describe('serve', () => {
|
|
66
617
|
test('outputs data only by default', async () => {
|
|
67
618
|
const cli = Cli.create('test')
|
|
@@ -140,9 +691,9 @@ describe('serve', () => {
|
|
|
140
691
|
"code: COMMAND_NOT_FOUND
|
|
141
692
|
message: 'nonexistent' is not a command for 'test'.
|
|
142
693
|
cta:
|
|
143
|
-
description: "
|
|
144
|
-
commands[1]{command}:
|
|
145
|
-
test --help
|
|
694
|
+
description: "Next steps:"
|
|
695
|
+
commands[1]{command,description}:
|
|
696
|
+
test --help,see all available commands
|
|
146
697
|
"
|
|
147
698
|
`)
|
|
148
699
|
})
|
|
@@ -157,8 +708,8 @@ describe('serve', () => {
|
|
|
157
708
|
expect(output).toMatchInlineSnapshot(`
|
|
158
709
|
"Error: 'nonexistent' is not a command for 'test'.
|
|
159
710
|
|
|
160
|
-
|
|
161
|
-
test --help
|
|
711
|
+
Next steps:
|
|
712
|
+
test --help # see all available commands
|
|
162
713
|
"
|
|
163
714
|
`)
|
|
164
715
|
})
|
|
@@ -176,14 +727,100 @@ describe('serve', () => {
|
|
|
176
727
|
meta:
|
|
177
728
|
command: nonexistent
|
|
178
729
|
cta:
|
|
179
|
-
description: "
|
|
180
|
-
commands[1]{command}:
|
|
181
|
-
test --help
|
|
730
|
+
description: "Next steps:"
|
|
731
|
+
commands[1]{command,description}:
|
|
732
|
+
test --help,see all available commands
|
|
182
733
|
duration: <stripped>
|
|
183
734
|
"
|
|
184
735
|
`)
|
|
185
736
|
})
|
|
186
737
|
|
|
738
|
+
test('suggests similar command for typos', async () => {
|
|
739
|
+
const cli = Cli.create('test')
|
|
740
|
+
cli.command('deploy', { run: () => ({}) })
|
|
741
|
+
cli.command('status', { run: () => ({}) })
|
|
742
|
+
|
|
743
|
+
const { output, exitCode } = await serve(cli, ['deplyo'])
|
|
744
|
+
expect(exitCode).toBe(1)
|
|
745
|
+
expect(output).toMatchInlineSnapshot(`
|
|
746
|
+
"code: COMMAND_NOT_FOUND
|
|
747
|
+
message: 'deplyo' is not a command for 'test'. Did you mean 'deploy'?
|
|
748
|
+
cta:
|
|
749
|
+
description: "Next steps:"
|
|
750
|
+
commands[2]:
|
|
751
|
+
- command: test deploy
|
|
752
|
+
- command: test --help
|
|
753
|
+
description: see all available commands
|
|
754
|
+
"
|
|
755
|
+
`)
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
test('suggests similar command for typos in TTY', async () => {
|
|
759
|
+
;(process.stdout as any).isTTY = true
|
|
760
|
+
const cli = Cli.create('test')
|
|
761
|
+
cli.command('deploy', { run: () => ({}) })
|
|
762
|
+
|
|
763
|
+
const { output, exitCode } = await serve(cli, ['deplyo'])
|
|
764
|
+
;(process.stdout as any).isTTY = false
|
|
765
|
+
expect(exitCode).toBe(1)
|
|
766
|
+
expect(output).toMatchInlineSnapshot(`
|
|
767
|
+
"Error: 'deplyo' is not a command for 'test'. Did you mean 'deploy'?
|
|
768
|
+
|
|
769
|
+
Next steps:
|
|
770
|
+
test deploy
|
|
771
|
+
test --help # see all available commands
|
|
772
|
+
"
|
|
773
|
+
`)
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
test('suggests builtin commands for typos', async () => {
|
|
777
|
+
const cli = Cli.create('test')
|
|
778
|
+
cli.command('ping', { run: () => ({}) })
|
|
779
|
+
|
|
780
|
+
const { output, exitCode } = await serve(cli, ['mpc'])
|
|
781
|
+
expect(exitCode).toBe(1)
|
|
782
|
+
expect(output).toContain("Did you mean 'mcp'?")
|
|
783
|
+
expect(output).toContain('test mcp')
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
test('preserves flags in suggestion CTA', async () => {
|
|
787
|
+
const cli = Cli.create('test')
|
|
788
|
+
cli.command('deploy', { run: () => ({}) })
|
|
789
|
+
|
|
790
|
+
const { output } = await serve(cli, ['deplyo', '--verbose'])
|
|
791
|
+
expect(output).toContain('test deploy --verbose')
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
test('no suggestion when input is too far from any command', async () => {
|
|
795
|
+
const cli = Cli.create('test')
|
|
796
|
+
cli.command('deploy', { run: () => ({}) })
|
|
797
|
+
|
|
798
|
+
const { output } = await serve(cli, ['xyz'])
|
|
799
|
+
expect(output).not.toContain('Did you mean')
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
test('suggests similar subcommand for typos', async () => {
|
|
803
|
+
const cli = Cli.create('test')
|
|
804
|
+
const pr = Cli.create('pr')
|
|
805
|
+
.command('list', { run: () => ({}) })
|
|
806
|
+
.command('create', { run: () => ({}) })
|
|
807
|
+
cli.command(pr)
|
|
808
|
+
|
|
809
|
+
const { output, exitCode } = await serve(cli, ['pr', 'craete'])
|
|
810
|
+
expect(exitCode).toBe(1)
|
|
811
|
+
expect(output).toMatchInlineSnapshot(`
|
|
812
|
+
"code: COMMAND_NOT_FOUND
|
|
813
|
+
message: 'craete' is not a command for 'test pr'. Did you mean 'create'?
|
|
814
|
+
cta:
|
|
815
|
+
description: "Next steps:"
|
|
816
|
+
commands[2]:
|
|
817
|
+
- command: test pr create
|
|
818
|
+
- command: test pr --help
|
|
819
|
+
description: see all available commands
|
|
820
|
+
"
|
|
821
|
+
`)
|
|
822
|
+
})
|
|
823
|
+
|
|
187
824
|
test('wraps handler errors in error output', async () => {
|
|
188
825
|
const cli = Cli.create('test')
|
|
189
826
|
cli.command('fail', {
|
|
@@ -738,6 +1375,14 @@ describe('--schema', () => {
|
|
|
738
1375
|
expect(exitCode).toBe(1)
|
|
739
1376
|
})
|
|
740
1377
|
|
|
1378
|
+
test('on unknown command suggests similar', async () => {
|
|
1379
|
+
const cli = Cli.create('test')
|
|
1380
|
+
cli.command('greet', { run: () => ({}) })
|
|
1381
|
+
const { output, exitCode } = await serve(cli, ['grete', '--schema'])
|
|
1382
|
+
expect(output).toContain("Did you mean 'greet'?")
|
|
1383
|
+
expect(exitCode).toBe(1)
|
|
1384
|
+
})
|
|
1385
|
+
|
|
741
1386
|
test('on group shows available commands', async () => {
|
|
742
1387
|
const cli = Cli.create('test')
|
|
743
1388
|
const pr = Cli.create('pr', { description: 'PR management' }).command('list', {
|
|
@@ -871,9 +1516,9 @@ describe('subcommands', () => {
|
|
|
871
1516
|
"code: COMMAND_NOT_FOUND
|
|
872
1517
|
message: 'unknown' is not a command for 'test pr'.
|
|
873
1518
|
cta:
|
|
874
|
-
description: "
|
|
875
|
-
commands[1]{command}:
|
|
876
|
-
test pr --help
|
|
1519
|
+
description: "Next steps:"
|
|
1520
|
+
commands[1]{command,description}:
|
|
1521
|
+
test pr --help,see all available commands
|
|
877
1522
|
"
|
|
878
1523
|
`)
|
|
879
1524
|
})
|
|
@@ -892,8 +1537,8 @@ describe('subcommands', () => {
|
|
|
892
1537
|
expect(output).toMatchInlineSnapshot(`
|
|
893
1538
|
"Error: 'unknown' is not a command for 'test pr'.
|
|
894
1539
|
|
|
895
|
-
|
|
896
|
-
test pr --help
|
|
1540
|
+
Next steps:
|
|
1541
|
+
test pr --help # see all available commands
|
|
897
1542
|
"
|
|
898
1543
|
`)
|
|
899
1544
|
})
|
|
@@ -1733,7 +2378,7 @@ describe('env', () => {
|
|
|
1733
2378
|
--verbose Show full output envelope
|
|
1734
2379
|
|
|
1735
2380
|
Environment Variables:
|
|
1736
|
-
API_TOKEN Auth token (set:
|
|
2381
|
+
API_TOKEN Auth token (set: ****cret)
|
|
1737
2382
|
API_URL API URL (default: https://api.example.com)
|
|
1738
2383
|
"
|
|
1739
2384
|
`)
|
|
@@ -1742,7 +2387,7 @@ describe('env', () => {
|
|
|
1742
2387
|
process.env.API_URL = 'https://custom.example.com'
|
|
1743
2388
|
const { output: output2 } = await serve(cli, ['deploy', '--help'])
|
|
1744
2389
|
expect(output2).toContain(
|
|
1745
|
-
'API_URL API URL (set:
|
|
2390
|
+
'API_URL API URL (set: ****.com, default: https://api.example.com)',
|
|
1746
2391
|
)
|
|
1747
2392
|
} finally {
|
|
1748
2393
|
delete process.env.API_TOKEN
|
|
@@ -1770,7 +2415,7 @@ describe('env', () => {
|
|
|
1770
2415
|
const { output: output2 } = await serve(cli, ['deploy', '--help'], {
|
|
1771
2416
|
env: { API_TOKEN: 'secret' },
|
|
1772
2417
|
})
|
|
1773
|
-
expect(output2).toContain('set:
|
|
2418
|
+
expect(output2).toContain('set: ****cret')
|
|
1774
2419
|
})
|
|
1775
2420
|
|
|
1776
2421
|
test('--llms json includes schema.env', async () => {
|
|
@@ -1898,6 +2543,25 @@ describe('built-in commands', () => {
|
|
|
1898
2543
|
expect(output).toContain('add')
|
|
1899
2544
|
})
|
|
1900
2545
|
|
|
2546
|
+
test('skills typo suggests add', async () => {
|
|
2547
|
+
const cli = Cli.create('test')
|
|
2548
|
+
cli.command('ping', { run: () => ({}) })
|
|
2549
|
+
const { output, exitCode } = await serve(cli, ['skills', 'addd'])
|
|
2550
|
+
expect(exitCode).toBe(1)
|
|
2551
|
+
expect(output).toContain("Did you mean 'add'?")
|
|
2552
|
+
expect(output).toContain('test skills add')
|
|
2553
|
+
expect(output).toContain('test skills --help')
|
|
2554
|
+
})
|
|
2555
|
+
|
|
2556
|
+
test('mcp typo suggests add', async () => {
|
|
2557
|
+
const cli = Cli.create('test')
|
|
2558
|
+
cli.command('ping', { run: () => ({}) })
|
|
2559
|
+
const { output, exitCode } = await serve(cli, ['mcp', 'addd'])
|
|
2560
|
+
expect(exitCode).toBe(1)
|
|
2561
|
+
expect(output).toContain("Did you mean 'add'?")
|
|
2562
|
+
expect(output).toContain('test mcp add')
|
|
2563
|
+
})
|
|
2564
|
+
|
|
1901
2565
|
test('skills add --help shows options', async () => {
|
|
1902
2566
|
const cli = Cli.create('test')
|
|
1903
2567
|
cli.command('ping', { run: () => ({ pong: true }) })
|
|
@@ -1918,15 +2582,32 @@ describe('skills staleness', () => {
|
|
|
1918
2582
|
|
|
1919
2583
|
afterEach(() => {
|
|
1920
2584
|
stderrSpy.mockRestore()
|
|
2585
|
+
__mockSkillsHash = undefined
|
|
1921
2586
|
})
|
|
1922
2587
|
|
|
1923
|
-
test('
|
|
2588
|
+
test('includes skills CTA when stale', async () => {
|
|
1924
2589
|
__mockSkillsHash = '0000000000000000'
|
|
1925
2590
|
const cli = Cli.create('test')
|
|
1926
2591
|
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
|
|
1927
2592
|
|
|
1928
|
-
await serve(cli, ['ping'])
|
|
1929
|
-
expect(
|
|
2593
|
+
const { output } = await serve(cli, ['ping'])
|
|
2594
|
+
expect(output).toContain('Skills are out of date:')
|
|
2595
|
+
expect(output).toContain('skills add')
|
|
2596
|
+
})
|
|
2597
|
+
|
|
2598
|
+
test('merges skills CTA with command CTA', async () => {
|
|
2599
|
+
__mockSkillsHash = '0000000000000000'
|
|
2600
|
+
;(process.stdout as any).isTTY = true
|
|
2601
|
+
const cli = Cli.create('test')
|
|
2602
|
+
cli.command('ping', {
|
|
2603
|
+
description: 'Health check',
|
|
2604
|
+
run: (c) => c.ok({ pong: true }, { cta: { commands: ['status'] } }),
|
|
2605
|
+
})
|
|
2606
|
+
|
|
2607
|
+
const { output } = await serve(cli, ['ping'])
|
|
2608
|
+
;(process.stdout as any).isTTY = false
|
|
2609
|
+
expect(output).toContain('status')
|
|
2610
|
+
expect(output).toContain('skills add')
|
|
1930
2611
|
})
|
|
1931
2612
|
|
|
1932
2613
|
test('does not warn when hash matches', async () => {
|
|
@@ -1935,8 +2616,8 @@ describe('skills staleness', () => {
|
|
|
1935
2616
|
const cli = Cli.create('test')
|
|
1936
2617
|
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
|
|
1937
2618
|
|
|
1938
|
-
await serve(cli, ['ping'])
|
|
1939
|
-
expect(
|
|
2619
|
+
const { output } = await serve(cli, ['ping'])
|
|
2620
|
+
expect(output).not.toContain('Skills are out of date')
|
|
1940
2621
|
})
|
|
1941
2622
|
|
|
1942
2623
|
test('does not warn when no hash stored', async () => {
|
|
@@ -1944,8 +2625,8 @@ describe('skills staleness', () => {
|
|
|
1944
2625
|
const cli = Cli.create('test')
|
|
1945
2626
|
cli.command('ping', { run: () => ({ pong: true }) })
|
|
1946
2627
|
|
|
1947
|
-
await serve(cli, ['ping'])
|
|
1948
|
-
expect(
|
|
2628
|
+
const { output } = await serve(cli, ['ping'])
|
|
2629
|
+
expect(output).not.toContain('Skills are out of date')
|
|
1949
2630
|
})
|
|
1950
2631
|
|
|
1951
2632
|
test('does not warn for skills add', async () => {
|
|
@@ -1962,8 +2643,8 @@ describe('skills staleness', () => {
|
|
|
1962
2643
|
const cli = Cli.create('test')
|
|
1963
2644
|
cli.command('ping', { run: () => ({ pong: true }) })
|
|
1964
2645
|
|
|
1965
|
-
await serve(cli, ['--help'])
|
|
1966
|
-
expect(
|
|
2646
|
+
const { output } = await serve(cli, ['--help'])
|
|
2647
|
+
expect(output).not.toContain('Skills are out of date')
|
|
1967
2648
|
})
|
|
1968
2649
|
})
|
|
1969
2650
|
|
|
@@ -3157,6 +3838,14 @@ describe('fetch', () => {
|
|
|
3157
3838
|
`)
|
|
3158
3839
|
})
|
|
3159
3840
|
|
|
3841
|
+
test('GET /helath → 404 with suggestion', async () => {
|
|
3842
|
+
const cli = Cli.create('test')
|
|
3843
|
+
cli.command('health', { run: () => ({}) })
|
|
3844
|
+
const res = await fetchJson(cli, new Request('http://localhost/helath'))
|
|
3845
|
+
expect(res.status).toBe(404)
|
|
3846
|
+
expect(res.body.error.message).toContain("Did you mean 'health'?")
|
|
3847
|
+
})
|
|
3848
|
+
|
|
3160
3849
|
test('GET / with root command → 200', async () => {
|
|
3161
3850
|
const cli = Cli.create('test', { run: () => ({ root: true }) })
|
|
3162
3851
|
expect(await fetchJson(cli, new Request('http://localhost/'))).toMatchInlineSnapshot(`
|