hanzio 2.2.0 → 3.1.0
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 +133 -7
- package/dist/api-wrapper/connection-auth.d.ts +61 -0
- package/dist/cli/fields.d.ts +30 -0
- package/dist/cli/index.d.ts +122 -0
- package/dist/format/index.d.ts +91 -0
- package/dist/format/phone.d.ts +19 -0
- package/dist/http/index.d.ts +14 -0
- package/dist/i18n/catalog.d.ts +47 -0
- package/dist/i18n/check.d.ts +45 -0
- package/dist/i18n/cli.d.ts +2 -0
- package/dist/i18n/content-rules.d.ts +55 -0
- package/dist/i18n/i18next.d.ts +39 -0
- package/dist/i18n/index.d.ts +16 -1
- package/dist/i18n/locales.d.ts +62 -0
- package/dist/i18n/vite.d.ts +16 -0
- package/dist/i18n/vue.d.ts +93 -0
- package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
- package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
- package/dist/index-5n2e326q.js +409 -0
- package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
- package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
- package/dist/index-6ragggbs.js +473 -0
- package/dist/index-mq2qgc20.js +116 -0
- package/dist/index-najbyzpr.js +99 -0
- package/dist/index-ww09bm49.js +250 -0
- package/dist/iso/countries.d.ts +262 -0
- package/dist/iso/currencies.d.ts +165 -0
- package/dist/iso/index.d.ts +4 -0
- package/dist/iso/languages.d.ts +195 -0
- package/dist/iso/phone-language.d.ts +5 -0
- package/dist/promise/index.d.ts +31 -0
- package/dist/redis/client.d.ts +49 -0
- package/dist/redis/concurrency-limit.d.ts +63 -0
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/lua-script.d.ts +13 -0
- package/dist/secrets/infisical-identity-file.d.ts +12 -0
- package/dist/src/api-wrapper/connection-auth.js +82 -0
- package/dist/src/api-wrapper/index.js +1 -1
- package/dist/src/cli/index.js +10 -0
- package/dist/src/format/index.js +79 -2
- package/dist/src/format/phone.js +21 -0
- package/dist/src/http/index.js +69 -0
- package/dist/src/i18n/check.js +15 -0
- package/dist/src/i18n/cli.js +71 -0
- package/dist/src/i18n/i18next.js +48 -0
- package/dist/src/i18n/index.js +16 -34
- package/dist/src/i18n/vite.js +25 -0
- package/dist/src/i18n/vue.js +51 -0
- package/dist/src/index.js +22 -6
- package/dist/src/iso/index.js +655 -0
- package/dist/src/redis/index.js +577 -0
- package/dist/src/secrets/index.js +78 -35
- package/dist/src/sitemap/index.js +1 -1
- package/dist/src/storage/index.js +8 -117
- package/dist/src/testing/index.js +348 -0
- package/dist/src/zod/index.js +20 -253
- package/dist/string/index.d.ts +21 -0
- package/dist/testing/connection-auth-contract.d.ts +43 -0
- package/dist/testing/helpers.d.ts +26 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/module-mocker.d.ts +30 -0
- package/dist/testing/prisma.d.ts +30 -0
- package/dist/testing/window.d.ts +18 -0
- package/dist/typedswitch/index.d.ts +55 -34
- package/dist/zod/index.d.ts +10 -0
- package/dist/zod/introspect.d.ts +2 -1
- package/package.json +97 -2
- package/src/api-wrapper/README.md +57 -2
- package/src/api-wrapper/SKILL.md +256 -0
- package/src/api-wrapper/connection-auth.ts +142 -0
- package/src/cli/README.md +220 -0
- package/src/cli/fields.ts +213 -0
- package/src/cli/index.ts +532 -0
- package/src/format/README.md +82 -2
- package/src/format/index.ts +148 -0
- package/src/format/phone.ts +43 -0
- package/src/http/README.md +25 -0
- package/src/http/index.ts +94 -0
- package/src/i18n/README.md +206 -3
- package/src/i18n/catalog.ts +139 -0
- package/src/i18n/check.ts +376 -0
- package/src/i18n/cli.ts +90 -0
- package/src/i18n/content-rules.ts +179 -0
- package/src/i18n/i18next.ts +89 -0
- package/src/i18n/index.ts +34 -0
- package/src/i18n/locales.ts +135 -0
- package/src/i18n/vite.ts +40 -0
- package/src/i18n/vue.ts +82 -0
- package/src/index.ts +1 -1
- package/src/iso/README.md +44 -0
- package/src/iso/countries.ts +272 -0
- package/src/iso/currencies.ts +172 -0
- package/src/iso/index.ts +8 -0
- package/src/iso/languages.ts +208 -0
- package/src/iso/phone-language.ts +46 -0
- package/src/promise/index.ts +100 -0
- package/src/redis/README.md +112 -0
- package/src/redis/client.ts +239 -0
- package/src/redis/concurrency-limit.ts +591 -0
- package/src/redis/index.ts +15 -0
- package/src/redis/lua-script.ts +43 -0
- package/src/secrets/README.md +5 -1
- package/src/secrets/infisical-identity-file.ts +69 -0
- package/src/secrets/infisical.ts +18 -3
- package/src/string/index.ts +59 -0
- package/src/testing/README.md +119 -0
- package/src/testing/connection-auth-contract.ts +303 -0
- package/src/testing/helpers.ts +41 -0
- package/src/testing/index.ts +12 -0
- package/src/testing/module-mocker.ts +50 -0
- package/src/testing/prisma.ts +78 -0
- package/src/testing/window.ts +119 -0
- package/src/typedswitch/README.md +55 -39
- package/src/typedswitch/index.ts +97 -83
- package/src/zod/index.ts +20 -0
- package/src/zod/introspect.ts +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Typed CLIs from Zod object schemas, parsed with node:util parseArgs, with generated help, completion and consistent exit codes.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# hanzio/cli
|
|
6
|
+
|
|
7
|
+
Each command's `input` is a Zod object: its keys become flags, Zod validates
|
|
8
|
+
them, and `run` gets the typed result. Parsing is `node:util` `parseArgs`, so
|
|
9
|
+
there is no extra dependency. Runs in Bun and Node 22.4+; needs `zod` 4.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// src/cli.ts
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
import { defineCli, defineCommand } from 'hanzio/cli'
|
|
15
|
+
|
|
16
|
+
export default defineCli({
|
|
17
|
+
name: 'better-mastra-evals',
|
|
18
|
+
version: '1.0.0',
|
|
19
|
+
commands: {
|
|
20
|
+
run: defineCommand({
|
|
21
|
+
description: 'Run eval suites',
|
|
22
|
+
input: z.object({
|
|
23
|
+
suite: z.string().meta({ positional: true }),
|
|
24
|
+
repeats: z.number().default(1).describe('Runs per case'),
|
|
25
|
+
json: z.boolean().optional()
|
|
26
|
+
}),
|
|
27
|
+
run: async (input, ctx) => {
|
|
28
|
+
// input: { suite: string; repeats: number; json?: boolean }
|
|
29
|
+
}
|
|
30
|
+
}),
|
|
31
|
+
inspect: () => import('./commands/inspect') // loaded only when used
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
#!/usr/bin/env node
|
|
38
|
+
// bin.ts
|
|
39
|
+
import cli from './cli.js'
|
|
40
|
+
|
|
41
|
+
process.exitCode = await cli.run()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Use `process.exit(await cli.run())` instead when open database or Redis
|
|
45
|
+
handles would keep the process alive.
|
|
46
|
+
|
|
47
|
+
## Flags
|
|
48
|
+
|
|
49
|
+
| Schema | Command line |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `z.string()`, `z.enum([...])` | `--name value` or `--name=value` |
|
|
52
|
+
| `z.number()` | `--repeats 3`, coerced before validation |
|
|
53
|
+
| `z.boolean()` | `--json`; `--no-json` sets `false`; missing is `false` |
|
|
54
|
+
| `z.array(z.string())` | repeatable: `--model a --model b`; missing is `[]` |
|
|
55
|
+
| `.optional()`, `.default(x)` | may be left out |
|
|
56
|
+
| `.describe('…')` | help text |
|
|
57
|
+
| `.meta({ positional: true })` | positional, in key order; an array takes the rest |
|
|
58
|
+
| `.meta({ alias: 'r' })` | `-r value` |
|
|
59
|
+
| `.meta({ env: 'APP_REGION' })` | read from the environment when the flag is missing |
|
|
60
|
+
|
|
61
|
+
Keys are camelCase in the schema and kebab-case on the command line (`dryRun`
|
|
62
|
+
→ `--dry-run`). These are the same `.meta()` keys as `trpc-cli`, plus `env`;
|
|
63
|
+
importing `hanzio/cli` types them on Zod's `GlobalMeta`.
|
|
64
|
+
Pipes read their input side, so `z.stringbool()` takes `--submit true`.
|
|
65
|
+
|
|
66
|
+
Every CLI gets `-h`/`--help` (root, groups and commands), `--version` when you
|
|
67
|
+
pass `version`, and "Did you mean …?" for mistyped commands and flags.
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
`commands` is a single command for a one-command CLI, or an object of
|
|
72
|
+
commands and nested groups, so `{ check: { docs } }` runs as `tool check
|
|
73
|
+
docs`. Groups are plain objects, so an adapter can build them from data.
|
|
74
|
+
|
|
75
|
+
A command is one of:
|
|
76
|
+
|
|
77
|
+
- `defineCommand({ description, input, run, output })`.
|
|
78
|
+
- `() => import('./commands/heavy')`, a module whose default export is a
|
|
79
|
+
command. It loads only when it runs.
|
|
80
|
+
- `defineCommand({ description, load: () => import('./commands/heavy') })`,
|
|
81
|
+
the same with a description for the command list.
|
|
82
|
+
- `defineCommand({ raw: true, run })`, which skips parsing: every argument
|
|
83
|
+
after the command name is in `ctx.argv` (for `tool prisma migrate dev`).
|
|
84
|
+
|
|
85
|
+
`run(input, ctx)` gets:
|
|
86
|
+
|
|
87
|
+
| `ctx` | |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `context` | what `defineCli({ context })` returned |
|
|
90
|
+
| `globals` | the parsed global options |
|
|
91
|
+
| `path` | command names, e.g. `['deal', 'list']` |
|
|
92
|
+
| `passthrough` | arguments after `--` (`tool exec -- bun test`) |
|
|
93
|
+
| `argv` | every argument after the command names, unparsed |
|
|
94
|
+
| `json` | `--json` was passed |
|
|
95
|
+
| `stdout`, `stderr`, `env` | the CLI's streams and environment |
|
|
96
|
+
| `exitCode` | set it to fail without an error message |
|
|
97
|
+
|
|
98
|
+
Whatever `run` returns goes to `output(result, ctx)`. The default prints
|
|
99
|
+
strings as they are and anything else as indented JSON, or compact JSON with
|
|
100
|
+
`--json`; `undefined` prints nothing.
|
|
101
|
+
|
|
102
|
+
## Globals, context and guard
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const cli = defineCli({
|
|
106
|
+
name: 'app',
|
|
107
|
+
globals: z.object({
|
|
108
|
+
org: z.string().optional(),
|
|
109
|
+
allowProduction: z.boolean().optional(),
|
|
110
|
+
json: z.boolean().optional()
|
|
111
|
+
}),
|
|
112
|
+
context: async (globals) => ({ db: await connect(), org: globals.org }),
|
|
113
|
+
guard: ({ context, globals }) => {
|
|
114
|
+
if (context.db.isProduction && !globals.allowProduction)
|
|
115
|
+
throw new CliError('Refusing production; pass --allow-production')
|
|
116
|
+
},
|
|
117
|
+
commands: { deal: { list, get } }
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Globals are accepted before or after the command names. `context` runs after
|
|
122
|
+
parsing, so `--help` never connects anywhere. `guard` runs before every
|
|
123
|
+
command. A command defined in its own file types its context by annotating
|
|
124
|
+
`ctx`:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
export const list = defineCommand({
|
|
128
|
+
input: z.object({ limit: z.number().default(20) }),
|
|
129
|
+
run: (input, { context }: CommandContext<AppContext>) =>
|
|
130
|
+
context.db.deal.findMany({ take: input.limit })
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`defineCli` rejects a command whose context type doesn't match `context`, and
|
|
135
|
+
requires `context` once any command needs one.
|
|
136
|
+
|
|
137
|
+
## Errors and exit codes
|
|
138
|
+
|
|
139
|
+
| Exit | When |
|
|
140
|
+
| --- | --- |
|
|
141
|
+
| 0 | the command finished |
|
|
142
|
+
| 1 | `run` threw, or set `ctx.exitCode = 1` |
|
|
143
|
+
| 2 | bad arguments: unknown command or flag, failed validation |
|
|
144
|
+
|
|
145
|
+
Throw `new CliError(message, { exitCode })` for expected failures: the
|
|
146
|
+
message is printed without a stack. Other errors print their stack. With
|
|
147
|
+
`--json`, errors print as `{"error": "…"}`. Usage errors name the flag:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
--repeats: Invalid input: expected number, received string
|
|
151
|
+
Run "better-mastra-evals run --help" for usage.
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Shell completion
|
|
155
|
+
|
|
156
|
+
A CLI with commands also gets `tool completion bash` and `tool completion
|
|
157
|
+
zsh`, which print a script that completes command names and flags:
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
# in ~/.zshrc (or ~/.bashrc with bash)
|
|
161
|
+
source <(tool completion zsh)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Testing
|
|
165
|
+
|
|
166
|
+
`cli.run(argv, { stdout, stderr, env })` resolves to the exit code and never
|
|
167
|
+
touches `process`, so tests don't spawn anything:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
let out = ''
|
|
171
|
+
const code = await cli.run(['run', 'smoke', '--json'], {
|
|
172
|
+
stdout: { write: (text) => (out += text) },
|
|
173
|
+
stderr: { write: () => {} },
|
|
174
|
+
env: {}
|
|
175
|
+
})
|
|
176
|
+
expect(code).toBe(0)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Migrating from `parseArgs`
|
|
180
|
+
|
|
181
|
+
1. Turn the `options` object into a `z.object`: `type: 'string'` becomes
|
|
182
|
+
`z.string()`, `multiple: true` becomes `z.array(...)`, `default` becomes
|
|
183
|
+
`.default()`, `short: 'r'` becomes `.meta({ alias: 'r' })`, and
|
|
184
|
+
positionals become keys with `.meta({ positional: true })`.
|
|
185
|
+
2. Delete the hand-written validation (`Number(values.repeats)`, enum
|
|
186
|
+
checks, "--x required" errors): `z.number()`, `z.enum()` and required keys
|
|
187
|
+
do it, with the flag in the error.
|
|
188
|
+
3. Move each `switch (positionals[0])` branch into a `defineCommand`, and its
|
|
189
|
+
help line into `description` / `.describe()`. Delete the `USAGE` string.
|
|
190
|
+
4. Replace `console.error(...); process.exit(1)` with `throw new CliError(...)`,
|
|
191
|
+
and the `main().catch(...)` wrapper with `process.exitCode = await cli.run()`.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
// before
|
|
195
|
+
const { values, positionals } = parseArgs({
|
|
196
|
+
allowPositionals: true,
|
|
197
|
+
options: { out: { type: 'string', short: 'o' }, force: { type: 'boolean' } }
|
|
198
|
+
})
|
|
199
|
+
if (positionals[0] !== 'add' || !values.out) {
|
|
200
|
+
console.error(USAGE)
|
|
201
|
+
process.exit(2)
|
|
202
|
+
}
|
|
203
|
+
await add(positionals.slice(1), values)
|
|
204
|
+
|
|
205
|
+
// after
|
|
206
|
+
defineCli({
|
|
207
|
+
name: 'tool',
|
|
208
|
+
commands: {
|
|
209
|
+
add: defineCommand({
|
|
210
|
+
description: 'Copy fragments into a schema folder',
|
|
211
|
+
input: z.object({
|
|
212
|
+
fragments: z.array(z.string()).min(1).meta({ positional: true }),
|
|
213
|
+
out: z.string().meta({ alias: 'o' }),
|
|
214
|
+
force: z.boolean()
|
|
215
|
+
}),
|
|
216
|
+
run: (input) => add(input.fragments, input)
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
```
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { unwrapOnce } from '../zod/introspect'
|
|
3
|
+
import { defOf, type Schema } from '../zod/zodToTypeString'
|
|
4
|
+
|
|
5
|
+
/** Thrown for bad argv. The CLI prints it with a `--help` hint and exits 2. */
|
|
6
|
+
export class UsageError extends Error {}
|
|
7
|
+
|
|
8
|
+
/** One key of an input or globals object, read as a flag or a positional. */
|
|
9
|
+
export type Field = {
|
|
10
|
+
key: string
|
|
11
|
+
flag: string
|
|
12
|
+
kind: 'string' | 'number' | 'boolean'
|
|
13
|
+
multiple: boolean
|
|
14
|
+
positional: boolean
|
|
15
|
+
optional: boolean
|
|
16
|
+
hasDefault: boolean
|
|
17
|
+
defaultValue: unknown
|
|
18
|
+
description?: string
|
|
19
|
+
alias?: string
|
|
20
|
+
env?: string
|
|
21
|
+
choices?: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const kebab = (key: string) =>
|
|
25
|
+
key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
|
|
26
|
+
|
|
27
|
+
export function fieldsOf(schema: z.ZodObject | undefined): Field[] {
|
|
28
|
+
if (!schema) return []
|
|
29
|
+
return Object.entries(schema.shape).map(([key, child]) => toField(key, child))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toField(key: string, schema: Schema): Field {
|
|
33
|
+
// Metadata can sit on any wrapper; the outermost `.meta()`/`.describe()` wins.
|
|
34
|
+
const metas: Record<string, unknown>[] = []
|
|
35
|
+
let optional = false
|
|
36
|
+
let hasDefault = false
|
|
37
|
+
let defaultValue: unknown
|
|
38
|
+
let current = schema
|
|
39
|
+
for (;;) {
|
|
40
|
+
const meta = z.globalRegistry.get(current)
|
|
41
|
+
if (meta) metas.unshift(meta)
|
|
42
|
+
const def = defOf(current)
|
|
43
|
+
if (def.type === 'optional') optional = true
|
|
44
|
+
if ((def.type === 'default' || def.type === 'prefault') && !hasDefault) {
|
|
45
|
+
hasDefault = true
|
|
46
|
+
defaultValue = def.defaultValue
|
|
47
|
+
}
|
|
48
|
+
const inner = unwrapOnce(def)
|
|
49
|
+
if (!inner) break
|
|
50
|
+
current = inner
|
|
51
|
+
}
|
|
52
|
+
const meta: Record<string, unknown> = Object.assign({}, ...metas)
|
|
53
|
+
|
|
54
|
+
let def = defOf(current)
|
|
55
|
+
const multiple = def.type === 'array'
|
|
56
|
+
if (def.type === 'array') {
|
|
57
|
+
let element = def.element
|
|
58
|
+
for (let inner = unwrapOnce(defOf(element)); inner; ) {
|
|
59
|
+
element = inner
|
|
60
|
+
inner = unwrapOnce(defOf(element))
|
|
61
|
+
}
|
|
62
|
+
def = defOf(element)
|
|
63
|
+
}
|
|
64
|
+
const alias = meta.alias
|
|
65
|
+
if (alias !== undefined && (typeof alias !== 'string' || alias.length !== 1))
|
|
66
|
+
throw new Error(
|
|
67
|
+
`hanzio/cli: alias ${JSON.stringify(alias)} of "${key}" must be one character`
|
|
68
|
+
)
|
|
69
|
+
return {
|
|
70
|
+
key,
|
|
71
|
+
flag: kebab(key),
|
|
72
|
+
kind:
|
|
73
|
+
def.type === 'boolean'
|
|
74
|
+
? 'boolean'
|
|
75
|
+
: def.type === 'number'
|
|
76
|
+
? 'number'
|
|
77
|
+
: 'string',
|
|
78
|
+
multiple,
|
|
79
|
+
positional: meta.positional === true,
|
|
80
|
+
optional,
|
|
81
|
+
hasDefault,
|
|
82
|
+
defaultValue,
|
|
83
|
+
description:
|
|
84
|
+
typeof meta.description === 'string' ? meta.description : undefined,
|
|
85
|
+
alias,
|
|
86
|
+
env: typeof meta.env === 'string' ? meta.env : undefined,
|
|
87
|
+
choices:
|
|
88
|
+
def.type === 'enum'
|
|
89
|
+
? Object.values(def.entries).map(String)
|
|
90
|
+
: def.type === 'literal'
|
|
91
|
+
? def.values.map(String)
|
|
92
|
+
: undefined
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function coerce(field: Field, value: string | boolean): unknown {
|
|
97
|
+
if (typeof value !== 'string') return value
|
|
98
|
+
if (field.kind === 'number' && value.trim() !== '') {
|
|
99
|
+
const number = Number(value)
|
|
100
|
+
return Number.isNaN(number) ? value : number
|
|
101
|
+
}
|
|
102
|
+
if (field.kind === 'boolean') {
|
|
103
|
+
if (value === 'true' || value === '1') return true
|
|
104
|
+
if (value === 'false' || value === '0' || value === '') return false
|
|
105
|
+
}
|
|
106
|
+
return value
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Builds the raw object Zod validates: flag values and positionals by key,
|
|
111
|
+
* numbers coerced, `.meta({ env })` as fallback, and a missing required
|
|
112
|
+
* boolean or array read as `false` or `[]`.
|
|
113
|
+
*/
|
|
114
|
+
export function readFields(
|
|
115
|
+
fields: Field[],
|
|
116
|
+
values: Record<string, string | boolean | (string | boolean)[] | undefined>,
|
|
117
|
+
positionals: string[],
|
|
118
|
+
env: Record<string, string | undefined>
|
|
119
|
+
): Record<string, unknown> {
|
|
120
|
+
const result: Record<string, unknown> = {}
|
|
121
|
+
const queue = [...positionals]
|
|
122
|
+
for (const field of fields) {
|
|
123
|
+
let raw: string | boolean | (string | boolean)[] | undefined
|
|
124
|
+
if (field.positional)
|
|
125
|
+
raw = field.multiple
|
|
126
|
+
? queue.length
|
|
127
|
+
? queue.splice(0)
|
|
128
|
+
: undefined
|
|
129
|
+
: queue.shift()
|
|
130
|
+
else raw = values[field.flag]
|
|
131
|
+
if (raw === undefined && field.env && env[field.env] !== undefined) {
|
|
132
|
+
const value = env[field.env] as string
|
|
133
|
+
raw = field.multiple ? [value] : value
|
|
134
|
+
}
|
|
135
|
+
if (raw === undefined) {
|
|
136
|
+
if (field.optional || field.hasDefault) continue
|
|
137
|
+
if (field.multiple) raw = []
|
|
138
|
+
else if (field.kind === 'boolean') raw = false
|
|
139
|
+
else continue
|
|
140
|
+
}
|
|
141
|
+
result[field.key] = Array.isArray(raw)
|
|
142
|
+
? raw.map((item) => coerce(field, item))
|
|
143
|
+
: coerce(field, raw)
|
|
144
|
+
}
|
|
145
|
+
if (queue.length) throw new UsageError(`Unexpected argument "${queue[0]}"`)
|
|
146
|
+
return result
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function fieldLabel(field: Field): string {
|
|
150
|
+
if (field.positional)
|
|
151
|
+
return field.multiple
|
|
152
|
+
? `<${field.flag}...>`
|
|
153
|
+
: field.optional || field.hasDefault
|
|
154
|
+
? `[${field.flag}]`
|
|
155
|
+
: `<${field.flag}>`
|
|
156
|
+
const name =
|
|
157
|
+
field.kind === 'boolean' && field.defaultValue === true
|
|
158
|
+
? `--[no-]${field.flag}`
|
|
159
|
+
: `--${field.flag}`
|
|
160
|
+
const value =
|
|
161
|
+
field.kind === 'boolean'
|
|
162
|
+
? ''
|
|
163
|
+
: ` <${field.choices?.join('|') ?? field.kind}>${field.multiple ? '...' : ''}`
|
|
164
|
+
return `${field.alias ? `-${field.alias}, ` : ''}${name}${value}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function fieldHelp(field: Field): string {
|
|
168
|
+
const showDefault =
|
|
169
|
+
field.hasDefault &&
|
|
170
|
+
field.defaultValue !== undefined &&
|
|
171
|
+
!(field.kind === 'boolean' && field.defaultValue === false)
|
|
172
|
+
return [
|
|
173
|
+
field.description,
|
|
174
|
+
showDefault && `(default: ${JSON.stringify(field.defaultValue)})`,
|
|
175
|
+
field.env && `[env: ${field.env}]`
|
|
176
|
+
]
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.join(' ')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Closest candidate within a few edits, for "Did you mean ...?" hints. */
|
|
182
|
+
export function closest(
|
|
183
|
+
input: string,
|
|
184
|
+
candidates: readonly string[]
|
|
185
|
+
): string | undefined {
|
|
186
|
+
let best: string | undefined
|
|
187
|
+
let bestDistance = Math.max(2, Math.floor(input.length / 3)) + 1
|
|
188
|
+
for (const candidate of candidates) {
|
|
189
|
+
const distance = candidate.startsWith(input)
|
|
190
|
+
? 1
|
|
191
|
+
: levenshtein(input, candidate)
|
|
192
|
+
if (distance < bestDistance) {
|
|
193
|
+
best = candidate
|
|
194
|
+
bestDistance = distance
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return best
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function levenshtein(a: string, b: string): number {
|
|
201
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index)
|
|
202
|
+
for (let i = 1; i <= a.length; i++) {
|
|
203
|
+
const current = [i]
|
|
204
|
+
for (let j = 1; j <= b.length; j++)
|
|
205
|
+
current[j] = Math.min(
|
|
206
|
+
(previous[j] ?? 0) + 1,
|
|
207
|
+
(current[j - 1] ?? 0) + 1,
|
|
208
|
+
(previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1)
|
|
209
|
+
)
|
|
210
|
+
previous = current
|
|
211
|
+
}
|
|
212
|
+
return previous[b.length] ?? 0
|
|
213
|
+
}
|