hanzio 3.1.1 → 3.3.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 +8 -1
- package/dist/cli/fields.d.ts +16 -6
- package/dist/cli/index.d.ts +57 -4
- package/dist/crypto/node.d.ts +17 -0
- package/dist/crypto/sealer.d.ts +34 -2
- package/dist/format/index.d.ts +6 -3
- package/dist/{index-nfvq3zax.js → index-818f7jz4.js} +228 -88
- package/dist/index-zz773050.js +135 -0
- package/dist/redis/concurrency-limit.d.ts +17 -7
- package/dist/src/cli/index.js +3 -1
- package/dist/src/crypto/index.js +15 -106
- package/dist/src/crypto/node.js +67 -0
- package/dist/src/format/index.js +4 -3
- package/dist/src/i18n/cli.js +33 -1
- package/dist/src/redis/index.js +5 -1
- package/dist/src/testing/index.js +23 -4
- package/dist/string/index.d.ts +1 -1
- package/dist/testing/module-mocker.d.ts +9 -9
- package/dist/testing/prisma.d.ts +5 -1
- package/dist/typedswitch/index.d.ts +26 -1
- package/package.json +8 -2
- package/src/cli/README.md +63 -6
- package/src/cli/fields.ts +95 -38
- package/src/cli/index.ts +302 -91
- package/src/crypto/README.md +21 -7
- package/src/crypto/node.ts +90 -0
- package/src/crypto/sealer.ts +120 -62
- package/src/format/README.md +7 -0
- package/src/format/index.ts +11 -5
- package/src/i18n/README.md +3 -1
- package/src/i18n/cli.ts +40 -0
- package/src/redis/README.md +9 -7
- package/src/redis/concurrency-limit.ts +26 -8
- package/src/string/index.ts +1 -1
- package/src/testing/README.md +12 -3
- package/src/testing/module-mocker.ts +40 -10
- package/src/testing/prisma.ts +6 -1
- package/src/typedswitch/README.md +20 -9
- package/src/typedswitch/index.ts +84 -8
package/src/cli/README.md
CHANGED
|
@@ -6,7 +6,8 @@ description: Typed CLIs from Zod object schemas, parsed with node:util parseArgs
|
|
|
6
6
|
|
|
7
7
|
Each command's `input` is a Zod object: its keys become flags, Zod validates
|
|
8
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
|
|
9
|
+
there is no extra dependency. Runs in Bun and Node 22.4+. Commands with an
|
|
10
|
+
`input` need `zod` 4; a CLI made only of `raw` commands loads without it.
|
|
10
11
|
|
|
11
12
|
```ts
|
|
12
13
|
// src/cli.ts
|
|
@@ -51,20 +52,27 @@ handles would keep the process alive.
|
|
|
51
52
|
| `z.string()`, `z.enum([...])` | `--name value` or `--name=value` |
|
|
52
53
|
| `z.number()` | `--repeats 3`, coerced before validation |
|
|
53
54
|
| `z.boolean()` | `--json`; `--no-json` sets `false`; missing is `false` |
|
|
55
|
+
| `noWatch: z.boolean()` | its own flag `--no-watch`, not `--watch` negated |
|
|
54
56
|
| `z.array(z.string())` | repeatable: `--model a --model b`; missing is `[]` |
|
|
57
|
+
| `z.union([z.boolean(), z.string()])` | `--expose` is `true`, `--expose=api,web` is the string |
|
|
55
58
|
| `.optional()`, `.default(x)` | may be left out |
|
|
56
59
|
| `.describe('…')` | help text |
|
|
57
60
|
| `.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 |
|
|
61
|
+
| `.meta({ alias: 'r' })` | `-r value`; longer aliases are flags: `alias: ['r', 'region-name']` |
|
|
62
|
+
| `.meta({ env: 'APP_REGION' })` | read from the environment when the flag is missing; a list is tried in order, and blank values count as unset |
|
|
60
63
|
|
|
61
64
|
Keys are camelCase in the schema and kebab-case on the command line (`dryRun`
|
|
62
65
|
→ `--dry-run`). These are the same `.meta()` keys as `trpc-cli`, plus `env`;
|
|
63
66
|
importing `hanzio/cli` types them on Zod's `GlobalMeta`.
|
|
64
67
|
Pipes read their input side, so `z.stringbool()` takes `--submit true`.
|
|
65
68
|
|
|
66
|
-
Every CLI gets `-h`/`--help` (root, groups and commands), `--version`
|
|
67
|
-
pass `version`, and "Did you mean …?" for mistyped commands and
|
|
69
|
+
Every CLI gets `-h`/`--help` (root, groups and commands), `--version` and
|
|
70
|
+
`-v` when you pass `version`, and "Did you mean …?" for mistyped commands and
|
|
71
|
+
flags. A root command named `version` also answers `--version` and `-v`, so it
|
|
72
|
+
decides the format (`tool v1.2.3`).
|
|
73
|
+
|
|
74
|
+
`defineCommand({ strict: false })` ignores unknown flags and extra
|
|
75
|
+
positionals instead of exiting 2.
|
|
68
76
|
|
|
69
77
|
## Commands
|
|
70
78
|
|
|
@@ -82,6 +90,30 @@ A command is one of:
|
|
|
82
90
|
- `defineCommand({ raw: true, run })`, which skips parsing: every argument
|
|
83
91
|
after the command name is in `ctx.argv` (for `tool prisma migrate dev`).
|
|
84
92
|
|
|
93
|
+
The first line of `description` is the command's summary in command lists;
|
|
94
|
+
its help shows all of it. `usage: '[name...] [--all]'` replaces the generated
|
|
95
|
+
arguments in both, and `examples: ['tool stop api']` adds an Examples section
|
|
96
|
+
to its help.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
defineCli({
|
|
100
|
+
name: 'evals',
|
|
101
|
+
defaultCommand: 'run', // `evals` and `evals --case x` run `evals run`
|
|
102
|
+
commands: { run, inspect }
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`defaultCommand` runs when no command name is given, or when the first
|
|
107
|
+
argument is a flag that isn't a global. Pass a function to read it from the
|
|
108
|
+
environment: `defaultCommand: (env) => env.RELEASE_COMMAND`.
|
|
109
|
+
|
|
110
|
+
`unknownCommand: (name, path) => node` resolves a name the group doesn't
|
|
111
|
+
have, for commands that come from config. Return `undefined` for the usual
|
|
112
|
+
"Unknown command" error.
|
|
113
|
+
|
|
114
|
+
`help: (text, { path }) => text` rewrites every help screen (`path` is `[]`
|
|
115
|
+
for the root), to add sections such as the tasks a config defines.
|
|
116
|
+
|
|
85
117
|
`run(input, ctx)` gets:
|
|
86
118
|
|
|
87
119
|
| `ctx` | |
|
|
@@ -118,7 +150,9 @@ const cli = defineCli({
|
|
|
118
150
|
})
|
|
119
151
|
```
|
|
120
152
|
|
|
121
|
-
Globals are accepted before or after the command names.
|
|
153
|
+
Globals are accepted before or after the command names. A positional global
|
|
154
|
+
comes before the first command name, so `globals: z.object({ module:
|
|
155
|
+
z.string().meta({ positional: true }) })` reads `tool ./app.ts list`. `context` runs after
|
|
122
156
|
parsing, so `--help` never connects anywhere. `guard` runs before every
|
|
123
157
|
command. A command defined in its own file types its context by annotating
|
|
124
158
|
`ctx`:
|
|
@@ -151,6 +185,29 @@ message is printed without a stack. Other errors print their stack. With
|
|
|
151
185
|
Run "better-mastra-evals run --help" for usage.
|
|
152
186
|
```
|
|
153
187
|
|
|
188
|
+
`onError(error, io)` sees every error first. Return the exit code, or
|
|
189
|
+
`undefined` for the default output; `io.print()` writes that output and
|
|
190
|
+
returns its code. Usage errors are `CliUsageError`s with a `kind`
|
|
191
|
+
(`missing-command`, `unknown-command`, `invalid-arguments`), the command
|
|
192
|
+
`path`, and the `help` for it:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
defineCli({
|
|
196
|
+
name: 'tool',
|
|
197
|
+
commands,
|
|
198
|
+
onError: (error, io) => {
|
|
199
|
+
// Help instead of an error when run without a command.
|
|
200
|
+
if (error instanceof CliUsageError && error.kind === 'missing-command') {
|
|
201
|
+
io.stdout.write(error.help)
|
|
202
|
+
return 0
|
|
203
|
+
}
|
|
204
|
+
// Keep the message, but exit 1 on bad arguments.
|
|
205
|
+
const code = io.print()
|
|
206
|
+
return code === 2 ? 1 : code
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
```
|
|
210
|
+
|
|
154
211
|
## Shell completion
|
|
155
212
|
|
|
156
213
|
A CLI with commands also gets `tool completion bash` and `tool completion
|
package/src/cli/fields.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import { z } from 'zod'
|
|
1
|
+
import type { z } from 'zod'
|
|
2
2
|
import { unwrapOnce } from '../zod/introspect'
|
|
3
|
-
import { defOf, type Schema } from '../zod/zodToTypeString'
|
|
3
|
+
import { type Def, defOf, type Schema } from '../zod/zodToTypeString'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// No runtime `zod` import: a CLI made only of raw commands loads without it.
|
|
6
|
+
|
|
7
|
+
/** Thrown for bad argv; `cli.run()` turns it into a `CliUsageError`. */
|
|
8
|
+
export class UsageError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
message: string,
|
|
11
|
+
readonly kind:
|
|
12
|
+
| 'missing-command'
|
|
13
|
+
| 'unknown-command'
|
|
14
|
+
| 'invalid-arguments' = 'invalid-arguments'
|
|
15
|
+
) {
|
|
16
|
+
super(message)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
7
19
|
|
|
8
20
|
/** One key of an input or globals object, read as a flag or a positional. */
|
|
9
21
|
export type Field = {
|
|
@@ -16,11 +28,53 @@ export type Field = {
|
|
|
16
28
|
hasDefault: boolean
|
|
17
29
|
defaultValue: unknown
|
|
18
30
|
description?: string
|
|
31
|
+
/** One-letter short flag. */
|
|
19
32
|
alias?: string
|
|
20
|
-
|
|
33
|
+
/** Extra long flag names, from multi-character aliases. */
|
|
34
|
+
aliases: string[]
|
|
35
|
+
env: string[]
|
|
36
|
+
/** `--name` alone is `true`, `--name=value` is the value. */
|
|
37
|
+
optionalValue: boolean
|
|
21
38
|
choices?: string[]
|
|
22
39
|
}
|
|
23
40
|
|
|
41
|
+
/** The `parseArgs` option name. A `noX` key is its own flag, never `--x` negated. */
|
|
42
|
+
export const optionName = (field: Field) =>
|
|
43
|
+
field.flag.startsWith('no-') ? field.key : field.flag
|
|
44
|
+
|
|
45
|
+
const strings = (value: unknown, what: string, key: string): string[] => {
|
|
46
|
+
const list = value === undefined ? [] : Array.isArray(value) ? value : [value]
|
|
47
|
+
if (list.some((item) => typeof item !== 'string' || !item))
|
|
48
|
+
throw new Error(
|
|
49
|
+
`hanzio/cli: ${what} ${JSON.stringify(value)} of "${key}" must be a string or string[]`
|
|
50
|
+
)
|
|
51
|
+
return list
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const kindOf = (def: Def) =>
|
|
55
|
+
def.type === 'boolean'
|
|
56
|
+
? 'boolean'
|
|
57
|
+
: def.type === 'number'
|
|
58
|
+
? 'number'
|
|
59
|
+
: 'string'
|
|
60
|
+
|
|
61
|
+
function choicesOf(def: Def): string[] | undefined {
|
|
62
|
+
if (def.type === 'enum') return Object.values(def.entries).map(String)
|
|
63
|
+
if (def.type === 'literal') return def.values.map(String)
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `z.union([z.boolean(), z.string()])` (or `z.literal(true)`): an optional value.
|
|
68
|
+
function optionalValueOf(def: Def): Def | undefined {
|
|
69
|
+
if (def.type !== 'union' || def.options.length !== 2) return undefined
|
|
70
|
+
const [a, b] = def.options.map(defOf) as [Def, Def]
|
|
71
|
+
const flag = (d: Def) =>
|
|
72
|
+
d.type === 'boolean' || (d.type === 'literal' && d.values.includes(true))
|
|
73
|
+
if (flag(a) && kindOf(b) === 'string' && b.type !== 'literal') return b
|
|
74
|
+
if (flag(b) && kindOf(a) === 'string' && a.type !== 'literal') return a
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
24
78
|
const kebab = (key: string) =>
|
|
25
79
|
key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
|
|
26
80
|
|
|
@@ -37,7 +91,10 @@ function toField(key: string, schema: Schema): Field {
|
|
|
37
91
|
let defaultValue: unknown
|
|
38
92
|
let current = schema
|
|
39
93
|
for (;;) {
|
|
40
|
-
|
|
94
|
+
// `.meta()` with no argument reads Zod's global registry.
|
|
95
|
+
const meta = (
|
|
96
|
+
current as { meta?: () => Record<string, unknown> | undefined }
|
|
97
|
+
).meta?.()
|
|
41
98
|
if (meta) metas.unshift(meta)
|
|
42
99
|
const def = defOf(current)
|
|
43
100
|
if (def.type === 'optional') optional = true
|
|
@@ -61,20 +118,12 @@ function toField(key: string, schema: Schema): Field {
|
|
|
61
118
|
}
|
|
62
119
|
def = defOf(element)
|
|
63
120
|
}
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
throw new Error(
|
|
67
|
-
`hanzio/cli: alias ${JSON.stringify(alias)} of "${key}" must be one character`
|
|
68
|
-
)
|
|
121
|
+
const valueDef = multiple ? undefined : optionalValueOf(def)
|
|
122
|
+
const names = strings(meta.alias, 'alias', key)
|
|
69
123
|
return {
|
|
70
124
|
key,
|
|
71
125
|
flag: kebab(key),
|
|
72
|
-
kind:
|
|
73
|
-
def.type === 'boolean'
|
|
74
|
-
? 'boolean'
|
|
75
|
-
: def.type === 'number'
|
|
76
|
-
? 'number'
|
|
77
|
-
: 'string',
|
|
126
|
+
kind: valueDef ? 'string' : kindOf(def),
|
|
78
127
|
multiple,
|
|
79
128
|
positional: meta.positional === true,
|
|
80
129
|
optional,
|
|
@@ -82,14 +131,11 @@ function toField(key: string, schema: Schema): Field {
|
|
|
82
131
|
defaultValue,
|
|
83
132
|
description:
|
|
84
133
|
typeof meta.description === 'string' ? meta.description : undefined,
|
|
85
|
-
alias,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
: def.type === 'literal'
|
|
91
|
-
? def.values.map(String)
|
|
92
|
-
: undefined
|
|
134
|
+
alias: names.find((name) => name.length === 1),
|
|
135
|
+
aliases: names.filter((name) => name.length > 1).map(kebab),
|
|
136
|
+
env: strings(meta.env, 'env', key),
|
|
137
|
+
optionalValue: valueDef !== undefined,
|
|
138
|
+
choices: choicesOf(valueDef ?? def)
|
|
93
139
|
}
|
|
94
140
|
}
|
|
95
141
|
|
|
@@ -108,14 +154,16 @@ function coerce(field: Field, value: string | boolean): unknown {
|
|
|
108
154
|
|
|
109
155
|
/**
|
|
110
156
|
* Builds the raw object Zod validates: flag values and positionals by key,
|
|
111
|
-
* numbers coerced, `.meta({ env })` as fallback
|
|
112
|
-
* boolean or array read as `false` or `[]`.
|
|
157
|
+
* numbers coerced, `.meta({ env })` as fallback (blank variables count as
|
|
158
|
+
* unset), and a missing required boolean or array read as `false` or `[]`.
|
|
159
|
+
* Extra positionals are a usage error unless `strict` is false.
|
|
113
160
|
*/
|
|
114
161
|
export function readFields(
|
|
115
162
|
fields: Field[],
|
|
116
163
|
values: Record<string, string | boolean | (string | boolean)[] | undefined>,
|
|
117
164
|
positionals: string[],
|
|
118
|
-
env: Record<string, string | undefined
|
|
165
|
+
env: Record<string, string | undefined>,
|
|
166
|
+
strict = true
|
|
119
167
|
): Record<string, unknown> {
|
|
120
168
|
const result: Record<string, unknown> = {}
|
|
121
169
|
const queue = [...positionals]
|
|
@@ -127,11 +175,13 @@ export function readFields(
|
|
|
127
175
|
? queue.splice(0)
|
|
128
176
|
: undefined
|
|
129
177
|
: queue.shift()
|
|
130
|
-
else raw = values[field
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
raw
|
|
134
|
-
|
|
178
|
+
else raw = values[optionName(field)]
|
|
179
|
+
if (field.optionalValue && raw === '') raw = true
|
|
180
|
+
const value =
|
|
181
|
+
raw === undefined
|
|
182
|
+
? field.env.map((name) => env[name]).find((value) => value?.trim())
|
|
183
|
+
: undefined
|
|
184
|
+
if (value !== undefined) raw = field.multiple ? [value] : value
|
|
135
185
|
if (raw === undefined) {
|
|
136
186
|
if (field.optional || field.hasDefault) continue
|
|
137
187
|
if (field.multiple) raw = []
|
|
@@ -142,7 +192,8 @@ export function readFields(
|
|
|
142
192
|
? raw.map((item) => coerce(field, item))
|
|
143
193
|
: coerce(field, raw)
|
|
144
194
|
}
|
|
145
|
-
if (queue.length)
|
|
195
|
+
if (strict && queue.length)
|
|
196
|
+
throw new UsageError(`Unexpected argument "${queue[0]}"`)
|
|
146
197
|
return result
|
|
147
198
|
}
|
|
148
199
|
|
|
@@ -154,14 +205,20 @@ export function fieldLabel(field: Field): string {
|
|
|
154
205
|
? `[${field.flag}]`
|
|
155
206
|
: `<${field.flag}>`
|
|
156
207
|
const name =
|
|
157
|
-
field.kind === 'boolean' &&
|
|
208
|
+
field.kind === 'boolean' &&
|
|
209
|
+
field.defaultValue === true &&
|
|
210
|
+
!field.flag.startsWith('no-')
|
|
158
211
|
? `--[no-]${field.flag}`
|
|
159
212
|
: `--${field.flag}`
|
|
213
|
+
const type = `<${field.choices?.join('|') ?? field.kind}>`
|
|
160
214
|
const value =
|
|
161
215
|
field.kind === 'boolean'
|
|
162
216
|
? ''
|
|
163
|
-
:
|
|
164
|
-
|
|
217
|
+
: field.optionalValue
|
|
218
|
+
? `[=${type}]`
|
|
219
|
+
: ` ${type}${field.multiple ? '...' : ''}`
|
|
220
|
+
const aliases = field.aliases.map((alias) => `, --${alias}`).join('')
|
|
221
|
+
return `${field.alias ? `-${field.alias}, ` : ''}${name}${aliases}${value}`
|
|
165
222
|
}
|
|
166
223
|
|
|
167
224
|
export function fieldHelp(field: Field): string {
|
|
@@ -172,7 +229,7 @@ export function fieldHelp(field: Field): string {
|
|
|
172
229
|
return [
|
|
173
230
|
field.description,
|
|
174
231
|
showDefault && `(default: ${JSON.stringify(field.defaultValue)})`,
|
|
175
|
-
field.env && `[env: ${field.env}]`
|
|
232
|
+
field.env.length > 0 && `[env: ${field.env.join(', ')}]`
|
|
176
233
|
]
|
|
177
234
|
.filter(Boolean)
|
|
178
235
|
.join(' ')
|