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/Help.test.ts
CHANGED
|
@@ -141,6 +141,36 @@ describe('formatCommand', () => {
|
|
|
141
141
|
expect(result).toContain('[deprecated] Availability zone')
|
|
142
142
|
expect(result).not.toContain('[deprecated] Target region')
|
|
143
143
|
})
|
|
144
|
+
|
|
145
|
+
test('shows config global options when flag name is set', () => {
|
|
146
|
+
const result = Help.formatCommand('tool deploy', {
|
|
147
|
+
configFlag: 'config',
|
|
148
|
+
options: z.object({
|
|
149
|
+
env: z.enum(['staging', 'production']).describe('Target environment'),
|
|
150
|
+
}),
|
|
151
|
+
})
|
|
152
|
+
expect(result).toMatchInlineSnapshot(`
|
|
153
|
+
"tool deploy
|
|
154
|
+
|
|
155
|
+
Usage: tool deploy [options]
|
|
156
|
+
|
|
157
|
+
Options:
|
|
158
|
+
--env <staging|production> Target environment
|
|
159
|
+
|
|
160
|
+
Global Options:
|
|
161
|
+
--config <path> Load JSON option defaults from a file
|
|
162
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
163
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
164
|
+
--help Show help
|
|
165
|
+
--llms, --llms-full Print LLM-readable manifest
|
|
166
|
+
--no-config Disable JSON option defaults for this run
|
|
167
|
+
--schema Show JSON Schema for command
|
|
168
|
+
--token-count Print token count of output (instead of output)
|
|
169
|
+
--token-limit <n> Limit output to n tokens
|
|
170
|
+
--token-offset <n> Skip first n tokens of output
|
|
171
|
+
--verbose Show full output envelope"
|
|
172
|
+
`)
|
|
173
|
+
})
|
|
144
174
|
})
|
|
145
175
|
|
|
146
176
|
describe('formatRoot', () => {
|
|
@@ -258,4 +288,40 @@ describe('formatRoot', () => {
|
|
|
258
288
|
--verbose Show full output envelope"
|
|
259
289
|
`)
|
|
260
290
|
})
|
|
291
|
+
|
|
292
|
+
test('formatRoot shows config global options when flag name is set', () => {
|
|
293
|
+
const result = Help.formatRoot('tool', {
|
|
294
|
+
configFlag: 'config',
|
|
295
|
+
root: true,
|
|
296
|
+
commands: [{ name: 'ping', description: 'Health check' }],
|
|
297
|
+
})
|
|
298
|
+
expect(result).toMatchInlineSnapshot(`
|
|
299
|
+
"tool
|
|
300
|
+
|
|
301
|
+
Usage: tool <command>
|
|
302
|
+
|
|
303
|
+
Commands:
|
|
304
|
+
ping Health check
|
|
305
|
+
|
|
306
|
+
Integrations:
|
|
307
|
+
completions Generate shell completion script
|
|
308
|
+
mcp add Register as MCP server
|
|
309
|
+
skills add Sync skill files to agents
|
|
310
|
+
|
|
311
|
+
Global Options:
|
|
312
|
+
--config <path> Load JSON option defaults from a file
|
|
313
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
314
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
315
|
+
--help Show help
|
|
316
|
+
--llms, --llms-full Print LLM-readable manifest
|
|
317
|
+
--mcp Start as MCP stdio server
|
|
318
|
+
--no-config Disable JSON option defaults for this run
|
|
319
|
+
--schema Show JSON Schema for command
|
|
320
|
+
--token-count Print token count of output (instead of output)
|
|
321
|
+
--token-limit <n> Limit output to n tokens
|
|
322
|
+
--token-offset <n> Skip first n tokens of output
|
|
323
|
+
--verbose Show full output envelope
|
|
324
|
+
--version Show version"
|
|
325
|
+
`)
|
|
326
|
+
})
|
|
261
327
|
})
|
package/src/Help.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
3
|
import { builtinCommands } from './internal/command.js'
|
|
4
|
+
import { toKebab } from './internal/helpers.js'
|
|
4
5
|
|
|
5
6
|
/** Formats help text for a router CLI or command group. */
|
|
6
7
|
export function formatRoot(name: string, options: formatRoot.Options = {}): string {
|
|
7
|
-
const { aliases, description, version, commands = [], root = false } = options
|
|
8
|
+
const { aliases, configFlag, description, version, commands = [], root = false } = options
|
|
8
9
|
const lines: string[] = []
|
|
9
10
|
|
|
10
11
|
// Header
|
|
@@ -29,7 +30,7 @@ export function formatRoot(name: string, options: formatRoot.Options = {}): stri
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
lines.push(...globalOptionsLines(root))
|
|
33
|
+
lines.push(...globalOptionsLines(root, configFlag))
|
|
33
34
|
|
|
34
35
|
return lines.join('\n')
|
|
35
36
|
}
|
|
@@ -38,6 +39,8 @@ export declare namespace formatRoot {
|
|
|
38
39
|
type Options = {
|
|
39
40
|
/** Alternative binary names for this CLI. */
|
|
40
41
|
aliases?: string[] | undefined
|
|
42
|
+
/** Flag name for config file path (e.g. `'config'` renders `--config <path>`). */
|
|
43
|
+
configFlag?: string | undefined
|
|
41
44
|
/** Commands to list. */
|
|
42
45
|
commands?: { name: string; description?: string | undefined }[] | undefined
|
|
43
46
|
/** A short description of the CLI or group. */
|
|
@@ -57,6 +60,8 @@ export declare namespace formatCommand {
|
|
|
57
60
|
aliases?: string[] | undefined
|
|
58
61
|
/** Zod schema for positional arguments. */
|
|
59
62
|
args?: z.ZodObject<any> | undefined
|
|
63
|
+
/** Flag name for config file path (e.g. `'config'` renders `--config <path>`). */
|
|
64
|
+
configFlag?: string | undefined
|
|
60
65
|
/** Subcommands to list (for CLIs with both a root handler and subcommands). */
|
|
61
66
|
commands?: { name: string; description?: string | undefined }[] | undefined
|
|
62
67
|
/** A short description of what the command does. */
|
|
@@ -94,6 +99,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
|
|
|
94
99
|
const {
|
|
95
100
|
alias,
|
|
96
101
|
aliases,
|
|
102
|
+
configFlag,
|
|
97
103
|
description,
|
|
98
104
|
version,
|
|
99
105
|
args,
|
|
@@ -199,7 +205,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
|
|
|
199
205
|
}
|
|
200
206
|
}
|
|
201
207
|
|
|
202
|
-
if (!options.hideGlobalOptions) lines.push(...globalOptionsLines(root))
|
|
208
|
+
if (!options.hideGlobalOptions) lines.push(...globalOptionsLines(root, configFlag))
|
|
203
209
|
|
|
204
210
|
// Environment Variables
|
|
205
211
|
if (env) {
|
|
@@ -327,13 +333,8 @@ function extractDeprecated(schema: unknown): boolean | undefined {
|
|
|
327
333
|
return meta?.deprecated === true ? true : undefined
|
|
328
334
|
}
|
|
329
335
|
|
|
330
|
-
/** Converts a camelCase string to kebab-case. */
|
|
331
|
-
function toKebab(str: string): string {
|
|
332
|
-
return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
|
|
333
|
-
}
|
|
334
|
-
|
|
335
336
|
/** Renders the built-in commands and global options block. Root-only items are hidden for subcommands. */
|
|
336
|
-
function globalOptionsLines(root = false): string[] {
|
|
337
|
+
function globalOptionsLines(root = false, configFlag?: string): string[] {
|
|
337
338
|
const lines: string[] = []
|
|
338
339
|
|
|
339
340
|
if (root) {
|
|
@@ -355,6 +356,9 @@ function globalOptionsLines(root = false): string[] {
|
|
|
355
356
|
}
|
|
356
357
|
|
|
357
358
|
const flags = [
|
|
359
|
+
...(configFlag
|
|
360
|
+
? [{ flag: `--${configFlag} <path>`, desc: 'Load JSON option defaults from a file' }]
|
|
361
|
+
: []),
|
|
358
362
|
{
|
|
359
363
|
flag: '--filter-output <keys>',
|
|
360
364
|
desc: 'Filter output by key paths (e.g. foo,bar.baz,a[0,3])',
|
|
@@ -363,13 +367,16 @@ function globalOptionsLines(root = false): string[] {
|
|
|
363
367
|
{ flag: '--help', desc: 'Show help' },
|
|
364
368
|
{ flag: '--llms, --llms-full', desc: 'Print LLM-readable manifest' },
|
|
365
369
|
...(root ? [{ flag: '--mcp', desc: 'Start as MCP stdio server' }] : []),
|
|
370
|
+
...(configFlag
|
|
371
|
+
? [{ flag: `--no-${configFlag}`, desc: 'Disable JSON option defaults for this run' }]
|
|
372
|
+
: []),
|
|
366
373
|
{ flag: '--schema', desc: 'Show JSON Schema for command' },
|
|
367
374
|
{ flag: '--token-count', desc: 'Print token count of output (instead of output)' },
|
|
368
375
|
{ flag: '--token-limit <n>', desc: 'Limit output to n tokens' },
|
|
369
376
|
{ flag: '--token-offset <n>', desc: 'Skip first n tokens of output' },
|
|
370
377
|
{ flag: '--verbose', desc: 'Show full output envelope' },
|
|
371
378
|
...(root ? [{ flag: '--version', desc: 'Show version' }] : []),
|
|
372
|
-
]
|
|
379
|
+
].sort((a, b) => a.flag.localeCompare(b.flag))
|
|
373
380
|
const maxLen = Math.max(...flags.map((f) => f.flag.length))
|
|
374
381
|
lines.push(
|
|
375
382
|
'',
|
|
@@ -380,8 +387,8 @@ function globalOptionsLines(root = false): string[] {
|
|
|
380
387
|
return lines
|
|
381
388
|
}
|
|
382
389
|
|
|
383
|
-
/** Redacts a value, showing only the last
|
|
390
|
+
/** Redacts a value, showing only the last 4 characters. */
|
|
384
391
|
function redact(value: string): string {
|
|
385
|
-
if (value.length <=
|
|
386
|
-
return
|
|
392
|
+
if (value.length <= 4) return '****'
|
|
393
|
+
return `****${value.slice(-4)}`
|
|
387
394
|
}
|
package/src/Openapi.test.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import { describe, expect, test } from 'vitest'
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('./SyncSkills.js', async (importOriginal) => {
|
|
4
|
+
const actual = await importOriginal<typeof import('./SyncSkills.js')>()
|
|
5
|
+
return { ...actual, readHash: () => undefined }
|
|
6
|
+
})
|
|
2
7
|
|
|
3
8
|
import { app as prefixedApp } from '../test/fixtures/hono-api-prefixed.js'
|
|
4
9
|
import { app } from '../test/fixtures/hono-api.js'
|
package/src/Parser.test-d.ts
CHANGED
|
@@ -43,3 +43,25 @@ test('narrows both args and options together', () => {
|
|
|
43
43
|
expectTypeOf(result.args).toEqualTypeOf<{ repo: string }>()
|
|
44
44
|
expectTypeOf(result.options).toEqualTypeOf<{ limit: number }>()
|
|
45
45
|
})
|
|
46
|
+
|
|
47
|
+
test('defaults are typed from z.input of the options schema', () => {
|
|
48
|
+
const result = Parser.parse([], {
|
|
49
|
+
defaults: { limit: '5' },
|
|
50
|
+
options: z.object({ limit: z.coerce.number().default(30) }),
|
|
51
|
+
})
|
|
52
|
+
expectTypeOf(result.options).toEqualTypeOf<{ limit: number }>()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('defaults do not leak any', () => {
|
|
56
|
+
type Options = z.ZodObject<{
|
|
57
|
+
limit: z.ZodDefault<z.ZodNumber>
|
|
58
|
+
saveDev: z.ZodOptional<z.ZodBoolean>
|
|
59
|
+
}>
|
|
60
|
+
|
|
61
|
+
expectTypeOf<Parser.parse.Options<undefined, Options>>().toEqualTypeOf<{
|
|
62
|
+
args?: undefined
|
|
63
|
+
alias?: Record<string, string> | undefined
|
|
64
|
+
defaults?: Partial<z.input<Options>> | undefined
|
|
65
|
+
options?: Options
|
|
66
|
+
}>()
|
|
67
|
+
})
|
package/src/Parser.test.ts
CHANGED
|
@@ -252,4 +252,93 @@ describe('parse', () => {
|
|
|
252
252
|
expect(result.args).toEqual({ repo: 'myrepo' })
|
|
253
253
|
expect(result.options).toEqual({ limit: 5 })
|
|
254
254
|
})
|
|
255
|
+
|
|
256
|
+
test('applies config defaults when argv omits an option', () => {
|
|
257
|
+
const result = Parser.parse([], {
|
|
258
|
+
defaults: { limit: 10 },
|
|
259
|
+
options: z.object({ limit: z.number().default(30) }),
|
|
260
|
+
})
|
|
261
|
+
expect(result.options).toEqual({ limit: 10 })
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('argv overrides config defaults', () => {
|
|
265
|
+
const result = Parser.parse(['--limit', '5'], {
|
|
266
|
+
defaults: { limit: 10 },
|
|
267
|
+
options: z.object({ limit: z.number().default(30) }),
|
|
268
|
+
})
|
|
269
|
+
expect(result.options).toEqual({ limit: 5 })
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('argv arrays replace config arrays', () => {
|
|
273
|
+
const result = Parser.parse(['--label', 'bug', '--label', 'feature'], {
|
|
274
|
+
defaults: { label: ['ops'] },
|
|
275
|
+
options: z.object({ label: z.array(z.string()).default([]) }),
|
|
276
|
+
})
|
|
277
|
+
expect(result.options).toEqual({ label: ['bug', 'feature'] })
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('kebab-case config keys map to camelCase schema names', () => {
|
|
281
|
+
const result = Parser.parse([], {
|
|
282
|
+
defaults: { 'save-dev': true } as any,
|
|
283
|
+
options: z.object({ saveDev: z.boolean().default(false) }),
|
|
284
|
+
})
|
|
285
|
+
expect(result.options).toEqual({ saveDev: true })
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('throws ParseError on unknown config option keys', () => {
|
|
289
|
+
expect(() =>
|
|
290
|
+
Parser.parse([], {
|
|
291
|
+
defaults: { missing: true } as any,
|
|
292
|
+
options: z.object({ saveDev: z.boolean().default(false) }),
|
|
293
|
+
}),
|
|
294
|
+
).toThrow(expect.objectContaining({ name: 'Incur.ParseError' }))
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('throws ValidationError for invalid config defaults when argv does not override them', () => {
|
|
298
|
+
expect(() =>
|
|
299
|
+
Parser.parse([], {
|
|
300
|
+
defaults: { limit: 'oops' } as any,
|
|
301
|
+
options: z.object({ limit: z.number() }),
|
|
302
|
+
}),
|
|
303
|
+
).toThrow(expect.objectContaining({ name: 'Incur.ValidationError' }))
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('argv overrides invalid config defaults', () => {
|
|
307
|
+
const result = Parser.parse(['--limit', '5'], {
|
|
308
|
+
defaults: { limit: 'oops' } as any,
|
|
309
|
+
options: z.object({ limit: z.number() }),
|
|
310
|
+
})
|
|
311
|
+
expect(result.options).toEqual({ limit: 5 })
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('defaults with no options schema throws on non-empty defaults', () => {
|
|
315
|
+
expect(() =>
|
|
316
|
+
Parser.parse([], {
|
|
317
|
+
defaults: { limit: 10 } as any,
|
|
318
|
+
}),
|
|
319
|
+
).toThrow(expect.objectContaining({ name: 'Incur.ParseError' }))
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test('defaults with no options schema and empty defaults is a no-op', () => {
|
|
323
|
+
const result = Parser.parse([], { defaults: {} as any })
|
|
324
|
+
expect(result.options).toEqual({})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test('config array defaults are used when argv omits the option', () => {
|
|
328
|
+
const result = Parser.parse([], {
|
|
329
|
+
defaults: { label: ['bug', 'feature'] },
|
|
330
|
+
options: z.object({ label: z.array(z.string()).default([]) }),
|
|
331
|
+
})
|
|
332
|
+
expect(result.options).toEqual({ label: ['bug', 'feature'] })
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('refined option schemas validate only the merged winning values', () => {
|
|
336
|
+
const result = Parser.parse(['--min', '1', '--max', '3'], {
|
|
337
|
+
defaults: { min: 'oops' } as any,
|
|
338
|
+
options: z
|
|
339
|
+
.object({ min: z.number(), max: z.number() })
|
|
340
|
+
.refine((value) => value.min < value.max, { message: 'min must be less than max' }),
|
|
341
|
+
})
|
|
342
|
+
expect(result.options).toEqual({ min: 1, max: 3 })
|
|
343
|
+
})
|
|
255
344
|
})
|
package/src/Parser.ts
CHANGED
|
@@ -2,29 +2,20 @@ import type { z } from 'zod'
|
|
|
2
2
|
|
|
3
3
|
import type { FieldError } from './Errors.js'
|
|
4
4
|
import { ParseError, ValidationError } from './Errors.js'
|
|
5
|
+
import { isRecord, toKebab } from './internal/helpers.js'
|
|
5
6
|
|
|
6
7
|
/** Parses raw argv tokens against Zod schemas for args and options. */
|
|
7
8
|
export function parse<
|
|
8
9
|
const args extends z.ZodObject<any> | undefined = undefined,
|
|
9
10
|
const options extends z.ZodObject<any> | undefined = undefined,
|
|
10
11
|
>(argv: string[], options: parse.Options<args, options> = {}): parse.ReturnType<args, options> {
|
|
11
|
-
const { args: argsSchema, options: optionsSchema, alias } = options
|
|
12
|
+
const { args: argsSchema, options: optionsSchema, alias, defaults } = options
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
const aliasToName = new Map<string, string>()
|
|
15
|
-
if (alias) for (const [name, short] of Object.entries(alias)) aliasToName.set(short, name)
|
|
16
|
-
|
|
17
|
-
// Known option names from schema, plus kebab-case → camelCase map
|
|
18
|
-
const knownOptions = new Set(optionsSchema ? Object.keys(optionsSchema.shape) : [])
|
|
19
|
-
const kebabToCamel = new Map<string, string>()
|
|
20
|
-
for (const name of knownOptions) {
|
|
21
|
-
const kebab = name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
|
|
22
|
-
if (kebab !== name) kebabToCamel.set(kebab, name)
|
|
23
|
-
}
|
|
14
|
+
const optionNames = createOptionNames(optionsSchema, alias)
|
|
24
15
|
|
|
25
16
|
// First pass: split argv into positional tokens and raw option values
|
|
26
17
|
const positionals: string[] = []
|
|
27
|
-
const
|
|
18
|
+
const rawArgvOptions: Record<string, unknown> = {}
|
|
28
19
|
|
|
29
20
|
let i = 0
|
|
30
21
|
while (i < argv.length) {
|
|
@@ -32,36 +23,34 @@ export function parse<
|
|
|
32
23
|
|
|
33
24
|
if (token.startsWith('--no-') && token.length > 5) {
|
|
34
25
|
// --no-flag negation
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
rawOptions[name] = false
|
|
26
|
+
const name = normalizeOptionName(token.slice(5), optionNames)
|
|
27
|
+
if (!name) throw new ParseError({ message: `Unknown flag: ${token}` })
|
|
28
|
+
rawArgvOptions[name] = false
|
|
39
29
|
i++
|
|
40
30
|
} else if (token.startsWith('--')) {
|
|
41
31
|
const eqIdx = token.indexOf('=')
|
|
42
32
|
if (eqIdx !== -1) {
|
|
43
33
|
// --flag=value
|
|
44
34
|
const raw = token.slice(2, eqIdx)
|
|
45
|
-
const name =
|
|
46
|
-
if (!
|
|
47
|
-
setOption(
|
|
35
|
+
const name = normalizeOptionName(raw, optionNames)
|
|
36
|
+
if (!name) throw new ParseError({ message: `Unknown flag: --${raw}` })
|
|
37
|
+
setOption(rawArgvOptions, name, token.slice(eqIdx + 1), optionsSchema)
|
|
48
38
|
i++
|
|
49
39
|
} else {
|
|
50
40
|
// --flag [value]
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
if (!knownOptions.has(name)) throw new ParseError({ message: `Unknown flag: ${token}` })
|
|
41
|
+
const name = normalizeOptionName(token.slice(2), optionNames)
|
|
42
|
+
if (!name) throw new ParseError({ message: `Unknown flag: ${token}` })
|
|
54
43
|
if (isCountOption(name, optionsSchema)) {
|
|
55
|
-
|
|
44
|
+
rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
|
|
56
45
|
i++
|
|
57
46
|
} else if (isBooleanOption(name, optionsSchema)) {
|
|
58
|
-
|
|
47
|
+
rawArgvOptions[name] = true
|
|
59
48
|
i++
|
|
60
49
|
} else {
|
|
61
50
|
const value = argv[i + 1]
|
|
62
51
|
if (value === undefined)
|
|
63
52
|
throw new ParseError({ message: `Missing value for flag: ${token}` })
|
|
64
|
-
setOption(
|
|
53
|
+
setOption(rawArgvOptions, name, value, optionsSchema)
|
|
65
54
|
i += 2
|
|
66
55
|
}
|
|
67
56
|
}
|
|
@@ -70,28 +59,28 @@ export function parse<
|
|
|
70
59
|
const chars = token.slice(1)
|
|
71
60
|
for (let j = 0; j < chars.length; j++) {
|
|
72
61
|
const short = chars[j]!
|
|
73
|
-
const name = aliasToName.get(short)
|
|
62
|
+
const name = optionNames.aliasToName.get(short)
|
|
74
63
|
if (!name) throw new ParseError({ message: `Unknown flag: -${short}` })
|
|
75
64
|
const isLast = j === chars.length - 1
|
|
76
65
|
if (!isLast) {
|
|
77
66
|
if (isCountOption(name, optionsSchema)) {
|
|
78
|
-
|
|
67
|
+
rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
|
|
79
68
|
} else if (isBooleanOption(name, optionsSchema)) {
|
|
80
|
-
|
|
69
|
+
rawArgvOptions[name] = true
|
|
81
70
|
} else {
|
|
82
71
|
throw new ParseError({
|
|
83
72
|
message: `Non-boolean flag -${short} must be last in a stacked alias`,
|
|
84
73
|
})
|
|
85
74
|
}
|
|
86
75
|
} else if (isCountOption(name, optionsSchema)) {
|
|
87
|
-
|
|
76
|
+
rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
|
|
88
77
|
} else if (isBooleanOption(name, optionsSchema)) {
|
|
89
|
-
|
|
78
|
+
rawArgvOptions[name] = true
|
|
90
79
|
} else {
|
|
91
80
|
const value = argv[i + 1]
|
|
92
81
|
if (value === undefined)
|
|
93
82
|
throw new ParseError({ message: `Missing value for flag: -${short}` })
|
|
94
|
-
setOption(
|
|
83
|
+
setOption(rawArgvOptions, name, value, optionsSchema)
|
|
95
84
|
i++
|
|
96
85
|
}
|
|
97
86
|
}
|
|
@@ -117,15 +106,19 @@ export function parse<
|
|
|
117
106
|
// Validate args through zod
|
|
118
107
|
const args = argsSchema ? zodParse(argsSchema, rawArgs) : {}
|
|
119
108
|
|
|
109
|
+
const rawDefaults = normalizeOptionDefaults(defaults, optionsSchema, optionNames)
|
|
110
|
+
|
|
120
111
|
// Coerce raw option values before zod validation
|
|
121
112
|
if (optionsSchema) {
|
|
122
|
-
for (const [name, value] of Object.entries(
|
|
123
|
-
|
|
113
|
+
for (const [name, value] of Object.entries(rawArgvOptions)) {
|
|
114
|
+
rawArgvOptions[name] = coerce(value, name, optionsSchema)
|
|
124
115
|
}
|
|
125
116
|
}
|
|
126
117
|
|
|
118
|
+
const mergedOptions = { ...rawDefaults, ...rawArgvOptions }
|
|
119
|
+
|
|
127
120
|
// Validate options through zod
|
|
128
|
-
const parsedOptions = optionsSchema ? zodParse(optionsSchema,
|
|
121
|
+
const parsedOptions = optionsSchema ? zodParse(optionsSchema, mergedOptions) : {}
|
|
129
122
|
|
|
130
123
|
return { args, options: parsedOptions } as parse.ReturnType<args, options>
|
|
131
124
|
}
|
|
@@ -138,6 +131,8 @@ export declare namespace parse {
|
|
|
138
131
|
> = {
|
|
139
132
|
/** Zod schema for positional arguments. Keys define order. */
|
|
140
133
|
args?: args
|
|
134
|
+
/** Config-backed option defaults merged before argv parsing. */
|
|
135
|
+
defaults?: options extends z.ZodObject<any> ? Partial<z.input<options>> | undefined : undefined
|
|
141
136
|
/** Zod schema for named options/flags. */
|
|
142
137
|
options?: options
|
|
143
138
|
/** Map of option names to single-char aliases. */
|
|
@@ -155,6 +150,62 @@ export declare namespace parse {
|
|
|
155
150
|
}
|
|
156
151
|
}
|
|
157
152
|
|
|
153
|
+
type OptionNames = {
|
|
154
|
+
aliasToName: Map<string, string>
|
|
155
|
+
kebabToCamel: Map<string, string>
|
|
156
|
+
knownOptions: Set<string>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Builds lookup tables for option names and short aliases. */
|
|
160
|
+
function createOptionNames(
|
|
161
|
+
schema: z.ZodObject<any> | undefined,
|
|
162
|
+
alias: Record<string, string> | undefined,
|
|
163
|
+
): OptionNames {
|
|
164
|
+
const aliasToName = new Map<string, string>()
|
|
165
|
+
if (alias) for (const [name, short] of Object.entries(alias)) aliasToName.set(short, name)
|
|
166
|
+
|
|
167
|
+
const knownOptions = new Set(schema ? Object.keys(schema.shape) : [])
|
|
168
|
+
const kebabToCamel = new Map<string, string>()
|
|
169
|
+
for (const name of knownOptions) {
|
|
170
|
+
const kebab = toKebab(name)
|
|
171
|
+
if (kebab !== name) kebabToCamel.set(kebab, name)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { aliasToName, kebabToCamel, knownOptions }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Normalizes a long option name, accepting kebab-case aliases for camelCase schema keys. */
|
|
178
|
+
function normalizeOptionName(raw: string, options: OptionNames): string | undefined {
|
|
179
|
+
const name = options.kebabToCamel.get(raw) ?? raw
|
|
180
|
+
return options.knownOptions.has(name) ? name : undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Normalizes config-backed defaults and validates config structure/key names. */
|
|
184
|
+
function normalizeOptionDefaults(
|
|
185
|
+
defaults: unknown,
|
|
186
|
+
schema: z.ZodObject<any> | undefined,
|
|
187
|
+
optionNames: OptionNames,
|
|
188
|
+
): Record<string, unknown> {
|
|
189
|
+
if (defaults === undefined) return {}
|
|
190
|
+
if (!isRecord(defaults))
|
|
191
|
+
throw new ParseError({
|
|
192
|
+
message: 'Invalid config section: expected an object of option defaults',
|
|
193
|
+
})
|
|
194
|
+
if (!schema) {
|
|
195
|
+
const [first] = Object.keys(defaults)
|
|
196
|
+
if (first) throw new ParseError({ message: `Unknown config option: ${first}` })
|
|
197
|
+
return {}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const normalized: Record<string, unknown> = {}
|
|
201
|
+
for (const [rawName, value] of Object.entries(defaults)) {
|
|
202
|
+
const name = normalizeOptionName(rawName, optionNames)
|
|
203
|
+
if (!name) throw new ParseError({ message: `Unknown config option: ${rawName}` })
|
|
204
|
+
normalized[name] = value
|
|
205
|
+
}
|
|
206
|
+
return normalized
|
|
207
|
+
}
|
|
208
|
+
|
|
158
209
|
/** Unwraps ZodDefault/ZodOptional to get the inner type. */
|
|
159
210
|
function unwrap(schema: z.ZodType): z.ZodType {
|
|
160
211
|
let s = schema as any
|
package/src/bin.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import { z } from 'zod'
|
|
4
5
|
|
|
5
6
|
import * as Cli from './Cli.js'
|
|
7
|
+
import * as ConfigSchema from './internal/configSchema.js'
|
|
8
|
+
import { importCli } from './internal/utils.js'
|
|
6
9
|
import * as Typegen from './Typegen.js'
|
|
7
10
|
|
|
8
11
|
const cli = Cli.create('incur', {
|
|
@@ -15,6 +18,10 @@ const cli = Cli.create('incur', {
|
|
|
15
18
|
}).command('gen', {
|
|
16
19
|
description: 'Generate type definitions for development.',
|
|
17
20
|
options: z.object({
|
|
21
|
+
configSchema: z
|
|
22
|
+
.boolean()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe('Generate config JSON Schema (auto-detected by default)'),
|
|
18
25
|
dir: z.string().optional().describe('Project root directory'),
|
|
19
26
|
entry: z.string().optional().describe('Entrypoint path (absolute)'),
|
|
20
27
|
output: z.string().optional().describe('Output path (absolute)'),
|
|
@@ -23,8 +30,20 @@ const cli = Cli.create('incur', {
|
|
|
23
30
|
const dir = c.options.dir ?? '.'
|
|
24
31
|
const entry = c.options.entry ?? dir
|
|
25
32
|
const output = c.options.output ?? path.join(dir, 'incur.generated.ts')
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
|
|
34
|
+
const cli = await importCli(entry)
|
|
35
|
+
await fs.writeFile(output, Typegen.fromCli(cli))
|
|
36
|
+
|
|
37
|
+
const result: Record<string, unknown> = { dir, entry, output }
|
|
38
|
+
|
|
39
|
+
const configSchema = c.options.configSchema ?? ConfigSchema.hasConfig(cli)
|
|
40
|
+
if (configSchema) {
|
|
41
|
+
const schemaOutput = path.join(path.dirname(output), 'config.schema.json')
|
|
42
|
+
await fs.writeFile(schemaOutput, JSON.stringify(ConfigSchema.fromCli(cli), null, 2) + '\n')
|
|
43
|
+
result.configSchema = schemaOutput
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return result
|
|
28
47
|
},
|
|
29
48
|
})
|
|
30
49
|
|