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.
Files changed (116) hide show
  1. package/README.md +133 -7
  2. package/dist/api-wrapper/connection-auth.d.ts +61 -0
  3. package/dist/cli/fields.d.ts +30 -0
  4. package/dist/cli/index.d.ts +122 -0
  5. package/dist/format/index.d.ts +91 -0
  6. package/dist/format/phone.d.ts +19 -0
  7. package/dist/http/index.d.ts +14 -0
  8. package/dist/i18n/catalog.d.ts +47 -0
  9. package/dist/i18n/check.d.ts +45 -0
  10. package/dist/i18n/cli.d.ts +2 -0
  11. package/dist/i18n/content-rules.d.ts +55 -0
  12. package/dist/i18n/i18next.d.ts +39 -0
  13. package/dist/i18n/index.d.ts +16 -1
  14. package/dist/i18n/locales.d.ts +62 -0
  15. package/dist/i18n/vite.d.ts +16 -0
  16. package/dist/i18n/vue.d.ts +93 -0
  17. package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
  18. package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
  19. package/dist/index-5n2e326q.js +409 -0
  20. package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
  21. package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
  22. package/dist/index-6ragggbs.js +473 -0
  23. package/dist/index-mq2qgc20.js +116 -0
  24. package/dist/index-najbyzpr.js +99 -0
  25. package/dist/index-ww09bm49.js +250 -0
  26. package/dist/iso/countries.d.ts +262 -0
  27. package/dist/iso/currencies.d.ts +165 -0
  28. package/dist/iso/index.d.ts +4 -0
  29. package/dist/iso/languages.d.ts +195 -0
  30. package/dist/iso/phone-language.d.ts +5 -0
  31. package/dist/promise/index.d.ts +31 -0
  32. package/dist/redis/client.d.ts +49 -0
  33. package/dist/redis/concurrency-limit.d.ts +63 -0
  34. package/dist/redis/index.d.ts +3 -0
  35. package/dist/redis/lua-script.d.ts +13 -0
  36. package/dist/secrets/infisical-identity-file.d.ts +12 -0
  37. package/dist/src/api-wrapper/connection-auth.js +82 -0
  38. package/dist/src/api-wrapper/index.js +1 -1
  39. package/dist/src/cli/index.js +10 -0
  40. package/dist/src/format/index.js +79 -2
  41. package/dist/src/format/phone.js +21 -0
  42. package/dist/src/http/index.js +69 -0
  43. package/dist/src/i18n/check.js +15 -0
  44. package/dist/src/i18n/cli.js +71 -0
  45. package/dist/src/i18n/i18next.js +48 -0
  46. package/dist/src/i18n/index.js +16 -34
  47. package/dist/src/i18n/vite.js +25 -0
  48. package/dist/src/i18n/vue.js +51 -0
  49. package/dist/src/index.js +22 -6
  50. package/dist/src/iso/index.js +655 -0
  51. package/dist/src/redis/index.js +577 -0
  52. package/dist/src/secrets/index.js +78 -35
  53. package/dist/src/sitemap/index.js +1 -1
  54. package/dist/src/storage/index.js +8 -117
  55. package/dist/src/testing/index.js +348 -0
  56. package/dist/src/zod/index.js +20 -253
  57. package/dist/string/index.d.ts +21 -0
  58. package/dist/testing/connection-auth-contract.d.ts +43 -0
  59. package/dist/testing/helpers.d.ts +26 -0
  60. package/dist/testing/index.d.ts +5 -0
  61. package/dist/testing/module-mocker.d.ts +30 -0
  62. package/dist/testing/prisma.d.ts +30 -0
  63. package/dist/testing/window.d.ts +18 -0
  64. package/dist/typedswitch/index.d.ts +55 -34
  65. package/dist/zod/index.d.ts +10 -0
  66. package/dist/zod/introspect.d.ts +2 -1
  67. package/package.json +97 -2
  68. package/src/api-wrapper/README.md +57 -2
  69. package/src/api-wrapper/SKILL.md +256 -0
  70. package/src/api-wrapper/connection-auth.ts +142 -0
  71. package/src/cli/README.md +220 -0
  72. package/src/cli/fields.ts +213 -0
  73. package/src/cli/index.ts +532 -0
  74. package/src/format/README.md +82 -2
  75. package/src/format/index.ts +148 -0
  76. package/src/format/phone.ts +43 -0
  77. package/src/http/README.md +25 -0
  78. package/src/http/index.ts +94 -0
  79. package/src/i18n/README.md +206 -3
  80. package/src/i18n/catalog.ts +139 -0
  81. package/src/i18n/check.ts +376 -0
  82. package/src/i18n/cli.ts +90 -0
  83. package/src/i18n/content-rules.ts +179 -0
  84. package/src/i18n/i18next.ts +89 -0
  85. package/src/i18n/index.ts +34 -0
  86. package/src/i18n/locales.ts +135 -0
  87. package/src/i18n/vite.ts +40 -0
  88. package/src/i18n/vue.ts +82 -0
  89. package/src/index.ts +1 -1
  90. package/src/iso/README.md +44 -0
  91. package/src/iso/countries.ts +272 -0
  92. package/src/iso/currencies.ts +172 -0
  93. package/src/iso/index.ts +8 -0
  94. package/src/iso/languages.ts +208 -0
  95. package/src/iso/phone-language.ts +46 -0
  96. package/src/promise/index.ts +100 -0
  97. package/src/redis/README.md +112 -0
  98. package/src/redis/client.ts +239 -0
  99. package/src/redis/concurrency-limit.ts +591 -0
  100. package/src/redis/index.ts +15 -0
  101. package/src/redis/lua-script.ts +43 -0
  102. package/src/secrets/README.md +5 -1
  103. package/src/secrets/infisical-identity-file.ts +69 -0
  104. package/src/secrets/infisical.ts +18 -3
  105. package/src/string/index.ts +59 -0
  106. package/src/testing/README.md +119 -0
  107. package/src/testing/connection-auth-contract.ts +303 -0
  108. package/src/testing/helpers.ts +41 -0
  109. package/src/testing/index.ts +12 -0
  110. package/src/testing/module-mocker.ts +50 -0
  111. package/src/testing/prisma.ts +78 -0
  112. package/src/testing/window.ts +119 -0
  113. package/src/typedswitch/README.md +55 -39
  114. package/src/typedswitch/index.ts +97 -83
  115. package/src/zod/index.ts +20 -0
  116. 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
+ }