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.
Files changed (39) hide show
  1. package/README.md +8 -1
  2. package/dist/cli/fields.d.ts +16 -6
  3. package/dist/cli/index.d.ts +57 -4
  4. package/dist/crypto/node.d.ts +17 -0
  5. package/dist/crypto/sealer.d.ts +34 -2
  6. package/dist/format/index.d.ts +6 -3
  7. package/dist/{index-nfvq3zax.js → index-818f7jz4.js} +228 -88
  8. package/dist/index-zz773050.js +135 -0
  9. package/dist/redis/concurrency-limit.d.ts +17 -7
  10. package/dist/src/cli/index.js +3 -1
  11. package/dist/src/crypto/index.js +15 -106
  12. package/dist/src/crypto/node.js +67 -0
  13. package/dist/src/format/index.js +4 -3
  14. package/dist/src/i18n/cli.js +33 -1
  15. package/dist/src/redis/index.js +5 -1
  16. package/dist/src/testing/index.js +23 -4
  17. package/dist/string/index.d.ts +1 -1
  18. package/dist/testing/module-mocker.d.ts +9 -9
  19. package/dist/testing/prisma.d.ts +5 -1
  20. package/dist/typedswitch/index.d.ts +26 -1
  21. package/package.json +8 -2
  22. package/src/cli/README.md +63 -6
  23. package/src/cli/fields.ts +95 -38
  24. package/src/cli/index.ts +302 -91
  25. package/src/crypto/README.md +21 -7
  26. package/src/crypto/node.ts +90 -0
  27. package/src/crypto/sealer.ts +120 -62
  28. package/src/format/README.md +7 -0
  29. package/src/format/index.ts +11 -5
  30. package/src/i18n/README.md +3 -1
  31. package/src/i18n/cli.ts +40 -0
  32. package/src/redis/README.md +9 -7
  33. package/src/redis/concurrency-limit.ts +26 -8
  34. package/src/string/index.ts +1 -1
  35. package/src/testing/README.md +12 -3
  36. package/src/testing/module-mocker.ts +40 -10
  37. package/src/testing/prisma.ts +6 -1
  38. package/src/typedswitch/README.md +20 -9
  39. 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+; needs `zod` 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` when you
67
- pass `version`, and "Did you mean …?" for mistyped commands and flags.
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. `context` runs after
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
- /** Thrown for bad argv. The CLI prints it with a `--help` hint and exits 2. */
6
- export class UsageError extends Error {}
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
- env?: string
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
- const meta = z.globalRegistry.get(current)
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 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
- )
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
- 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
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, and a missing required
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.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
- }
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) throw new UsageError(`Unexpected argument "${queue[0]}"`)
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' && field.defaultValue === true
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
- : ` <${field.choices?.join('|') ?? field.kind}>${field.multiple ? '...' : ''}`
164
- return `${field.alias ? `-${field.alias}, ` : ''}${name}${value}`
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(' ')