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,532 @@
1
+ import { parseArgs } from 'node:util'
2
+ import type { z } from 'zod'
3
+ import {
4
+ closest,
5
+ type Field,
6
+ fieldHelp,
7
+ fieldLabel,
8
+ fieldsOf,
9
+ readFields,
10
+ UsageError
11
+ } from './fields'
12
+
13
+ declare module 'zod' {
14
+ interface GlobalMeta {
15
+ /** hanzio/cli: read this key from positional arguments, in key order. */
16
+ positional?: boolean
17
+ /** hanzio/cli: one-letter short flag, e.g. `'r'` for `-r`. */
18
+ alias?: string
19
+ /** hanzio/cli: environment variable read when the flag is missing. */
20
+ env?: string
21
+ }
22
+ }
23
+
24
+ const commandKey: unique symbol = Symbol.for('hanzio/cli/command')
25
+
26
+ /**
27
+ * An expected failure: the CLI prints the message without a stack trace and
28
+ * exits with `exitCode` (default 1).
29
+ */
30
+ export class CliError extends Error {
31
+ readonly exitCode: number
32
+
33
+ constructor(
34
+ message: string,
35
+ options?: { exitCode?: number; cause?: unknown }
36
+ ) {
37
+ super(
38
+ message,
39
+ options?.cause === undefined ? undefined : { cause: options.cause }
40
+ )
41
+ this.name = 'CliError'
42
+ this.exitCode = options?.exitCode ?? 1
43
+ }
44
+ }
45
+
46
+ export type CliWriter = { write(text: string): unknown }
47
+
48
+ /** Where `cli.run()` writes and reads env fallbacks; defaults to `process`. */
49
+ export type CliIO = {
50
+ stdout?: CliWriter
51
+ stderr?: CliWriter
52
+ env?: Record<string, string | undefined>
53
+ }
54
+
55
+ export type CommandContext<C = unknown> = {
56
+ /** What `defineCli({ context })` returned. */
57
+ readonly context: C
58
+ /** The validated `defineCli({ globals })` values. */
59
+ readonly globals: Record<string, unknown>
60
+ /** Command names from the root, e.g. `['deal', 'list']`. */
61
+ readonly path: readonly string[]
62
+ /** Every argument after the command names, unparsed. */
63
+ readonly argv: readonly string[]
64
+ /** Arguments after `--`. */
65
+ readonly passthrough: readonly string[]
66
+ /** `--json` was passed (as a global or a command option). */
67
+ readonly json: boolean
68
+ readonly stdout: CliWriter
69
+ readonly stderr: CliWriter
70
+ readonly env: Record<string, string | undefined>
71
+ /** Set to exit non-zero without printing an error. */
72
+ exitCode: number
73
+ }
74
+
75
+ export type Command<C = unknown> = {
76
+ readonly [commandKey]: true
77
+ readonly description?: string
78
+ readonly input?: AnyObject
79
+ readonly raw?: boolean
80
+ readonly run?: (input: never, ctx: CommandContext<C>) => unknown
81
+ readonly output?: (result: never, ctx: CommandContext<C>) => unknown
82
+ readonly load?: LazyCommand<C>
83
+ }
84
+
85
+ /** `() => import('./commands/heavy')`: loaded only when the command runs. */
86
+ export type LazyCommand<C = unknown> = () => Promise<
87
+ Command<C> | { default: Command<C> }
88
+ >
89
+
90
+ export type CommandGroup<C = unknown> = {
91
+ readonly [name: string]: CommandNode<C>
92
+ }
93
+
94
+ export type CommandNode<C = unknown> =
95
+ | Command<C>
96
+ | LazyCommand<C>
97
+ | CommandGroup<C>
98
+
99
+ type AnyObject = z.ZodObject<z.core.$ZodShape, z.core.$ZodObjectConfig>
100
+ type EmptyObject = z.ZodObject<Record<never, never>>
101
+
102
+ export type Cli = {
103
+ readonly name: string
104
+ /** Runs the CLI and resolves to its exit code: 0 ok, 1 failure, 2 usage error. */
105
+ run(argv?: readonly string[], io?: CliIO): Promise<number>
106
+ }
107
+
108
+ export type CliConfig<G extends AnyObject, C> = {
109
+ name: string
110
+ version?: string
111
+ description?: string
112
+ /** Options accepted before or after any command, e.g. `--org`. */
113
+ globals?: G
114
+ /** Runs before every command; throw a `CliError` to refuse it. */
115
+ guard?: (call: {
116
+ path: readonly string[]
117
+ input: Record<string, unknown>
118
+ globals: z.output<G>
119
+ context: C
120
+ }) => unknown
121
+ /** A command (single-command CLI) or nested groups of commands. */
122
+ commands: CommandNode<C>
123
+ } & (undefined extends C
124
+ ? { context?: ContextFunction<G, C> }
125
+ : { context: ContextFunction<G, C> })
126
+
127
+ /** Builds the `ctx.context` every command receives. */
128
+ type ContextFunction<G extends AnyObject, C> = (
129
+ globals: z.output<G>
130
+ ) => C | Promise<C>
131
+
132
+ /**
133
+ * A command. `input` is a Zod object whose keys become `--kebab-case` flags,
134
+ * or positionals with `.meta({ positional: true })`. `run`'s return value is
135
+ * printed by `output` (default: strings as-is, anything else as JSON).
136
+ */
137
+ export function defineCommand<
138
+ S extends AnyObject = EmptyObject,
139
+ R = unknown,
140
+ C = unknown
141
+ >(definition: {
142
+ description?: string
143
+ input?: S
144
+ run: (input: z.output<S>, ctx: CommandContext<C>) => R
145
+ output?: (result: Awaited<R>, ctx: CommandContext<C>) => unknown
146
+ }): Command<C>
147
+ /** A command that skips parsing: everything after its name is in `ctx.argv`. */
148
+ export function defineCommand<R = unknown, C = unknown>(definition: {
149
+ description?: string
150
+ raw: true
151
+ run: (input: Record<string, never>, ctx: CommandContext<C>) => R
152
+ output?: (result: Awaited<R>, ctx: CommandContext<C>) => unknown
153
+ }): Command<C>
154
+ /** A lazy command whose description shows in help before it loads. */
155
+ export function defineCommand<C = unknown>(definition: {
156
+ description?: string
157
+ load: LazyCommand<C>
158
+ }): Command<C>
159
+ export function defineCommand(definition: object): Command {
160
+ return { ...definition, [commandKey]: true }
161
+ }
162
+
163
+ const isCommand = (node: unknown): node is Command =>
164
+ typeof node === 'object' && node !== null && commandKey in node
165
+
166
+ async function loadCommand(node: Command | LazyCommand): Promise<Command> {
167
+ if (typeof node !== 'function' && !node.load) return node
168
+ const loaded = await (typeof node === 'function' ? node() : node.load?.())
169
+ const command = isCommand(loaded) ? loaded : loaded?.default
170
+ if (!isCommand(command))
171
+ throw new Error(
172
+ 'hanzio/cli: a lazy command must resolve to defineCommand() or a module that default-exports one'
173
+ )
174
+ return loadCommand(command)
175
+ }
176
+
177
+ /** Leaf commands under a group, by path, without loading lazy ones. */
178
+ function listCommands(
179
+ group: CommandGroup,
180
+ prefix: string[] = []
181
+ ): [string[], string | undefined][] {
182
+ return Object.entries(group).flatMap(([name, node]) =>
183
+ typeof node === 'function' || isCommand(node)
184
+ ? [
185
+ [
186
+ [...prefix, name],
187
+ typeof node === 'function' ? undefined : node.description
188
+ ]
189
+ ]
190
+ : listCommands(node, [...prefix, name])
191
+ )
192
+ }
193
+
194
+ function table(rows: [string, string | undefined][]): string {
195
+ const width = Math.max(...rows.map(([left]) => left.length))
196
+ return rows
197
+ .map(([left, right]) => ` ${left.padEnd(width)} ${right ?? ''}`.trimEnd())
198
+ .join('\n')
199
+ }
200
+
201
+ const optionRows = (fields: Field[]): [string, string][] =>
202
+ fields.map((field) => [fieldLabel(field), fieldHelp(field)])
203
+
204
+ const helpRow: [string, string] = ['-h, --help', 'Show help']
205
+
206
+ export function defineCli<G extends AnyObject = EmptyObject, C = undefined>(
207
+ config: CliConfig<G, C>
208
+ ): Cli {
209
+ // The context type only matters at the call site; internally any command runs.
210
+ const root = config.commands as unknown as CommandNode
211
+ const builtIn = (group: CommandGroup) =>
212
+ group === root && !Object.hasOwn(group, 'completion')
213
+
214
+ const versionRow: [string, string][] = config.version
215
+ ? [['--version', 'Show version']]
216
+ : []
217
+
218
+ function groupHelp(group: CommandGroup, path: string[], globals: Field[]) {
219
+ const usage = [config.name, ...path].join(' ')
220
+ const commands = listCommands(group).map(
221
+ ([names, description]): [string, string | undefined] => [
222
+ names.join(' '),
223
+ description
224
+ ]
225
+ )
226
+ if (builtIn(group))
227
+ commands.push([
228
+ 'completion <bash|zsh>',
229
+ 'Print a shell completion script'
230
+ ])
231
+ return [
232
+ ...(path.length
233
+ ? []
234
+ : [
235
+ [config.name, config.version].filter(Boolean).join(' '),
236
+ ...(config.description ? [config.description] : []),
237
+ ''
238
+ ]),
239
+ `Usage: ${usage} <command> [options]`,
240
+ '',
241
+ 'Commands:',
242
+ table(commands),
243
+ '',
244
+ 'Options:',
245
+ table([
246
+ ...optionRows(globals),
247
+ helpRow,
248
+ ...(path.length ? [] : versionRow)
249
+ ]),
250
+ '',
251
+ `Run "${usage} <command> --help" for a command's options.`
252
+ ].join('\n')
253
+ }
254
+
255
+ function commandHelp(
256
+ command: Command,
257
+ path: string[],
258
+ fields: Field[],
259
+ globals: Field[]
260
+ ) {
261
+ const positionals = fields.filter((field) => field.positional)
262
+ const options = fields.filter((field) => !field.positional)
263
+ const sections = [
264
+ `Usage: ${[config.name, ...path, ...positionals.map(fieldLabel)].join(' ')} [options]`
265
+ ]
266
+ const description = command.description ?? config.description
267
+ if (description) sections.push(description)
268
+ if (positionals.length)
269
+ sections.push(`Arguments:\n${table(optionRows(positionals))}`)
270
+ sections.push(
271
+ `Options:\n${table([
272
+ ...optionRows(options),
273
+ helpRow,
274
+ ...(path.length ? [] : versionRow)
275
+ ])}`
276
+ )
277
+ if (globals.length)
278
+ sections.push(`Global options:\n${table(optionRows(globals))}`)
279
+ return sections.join('\n\n')
280
+ }
281
+
282
+ async function completion(shell: string, globals: Field[]) {
283
+ const fn = `_${config.name.replace(/\W/g, '_')}_completion`
284
+ const flags = (fields: Field[]) =>
285
+ [...fields, ...globals]
286
+ .filter((field) => !field.positional)
287
+ .map((field) => `--${field.flag}`)
288
+ const cases: [string, string[]][] = []
289
+ const visit = async (node: CommandNode, path: string[]) => {
290
+ if (typeof node === 'function' || isCommand(node)) {
291
+ const command = await loadCommand(node)
292
+ cases.push([
293
+ path.join(' '),
294
+ command.raw ? [] : [...flags(fieldsOf(command.input)), '--help']
295
+ ])
296
+ return
297
+ }
298
+ for (const [name, child] of Object.entries(node))
299
+ await visit(child, [...path, name])
300
+ const names = Object.keys(node)
301
+ if (builtIn(node)) names.push('completion')
302
+ cases.push([path.join(' '), [...names, ...flags([]), '--help']])
303
+ }
304
+ await visit(root, [])
305
+ cases.sort(([a], [b]) => b.length - a.length)
306
+ const branches = cases.map(([path, words]) =>
307
+ path
308
+ ? `\t\t"${path}"|"${path} "*) opts="${words.join(' ')}" ;;`
309
+ : `\t\t*) opts="${words.join(' ')}" ;;`
310
+ )
311
+ const script = `${fn}() {
312
+ local cur="\${COMP_WORDS[COMP_CWORD]}" words="" word opts
313
+ for word in "\${COMP_WORDS[@]:1:COMP_CWORD-1}"; do
314
+ [[ $word == -* ]] || words="$words $word"
315
+ done
316
+ case "\${words# }" in
317
+ ${branches.join('\n')}
318
+ esac
319
+ COMPREPLY=($(compgen -W "$opts" -- "$cur"))
320
+ }
321
+ complete -F ${fn} ${config.name}
322
+ `
323
+ if (shell === 'bash') return script
324
+ if (shell === 'zsh')
325
+ return `autoload -U +X bashcompinit && bashcompinit\n${script}`
326
+ throw new UsageError(`Unknown shell "${shell}": use bash or zsh`)
327
+ }
328
+
329
+ return {
330
+ name: config.name,
331
+ async run(argv = process.argv.slice(2), io = {}) {
332
+ const stdout = io.stdout ?? process.stdout
333
+ const stderr = io.stderr ?? process.stderr
334
+ const env = io.env ?? process.env
335
+ let usage = config.name
336
+ let json = false
337
+ try {
338
+ const globalFields = fieldsOf(config.globals)
339
+ const positional = globalFields.find((field) => field.positional)
340
+ if (positional)
341
+ throw new Error(
342
+ `hanzio/cli: global "${positional.key}" can't be positional`
343
+ )
344
+ const valueFlags = new Set(
345
+ globalFields
346
+ .filter((field) => field.kind !== 'boolean')
347
+ .flatMap((field) => [
348
+ `--${field.flag}`,
349
+ ...(field.alias ? [`-${field.alias}`] : [])
350
+ ])
351
+ )
352
+ const isHelp = (token: string) => token === '--help' || token === '-h'
353
+
354
+ // Walk the command names; options before and between them are globals.
355
+ let node: CommandNode = root
356
+ const path: string[] = []
357
+ const pre: string[] = []
358
+ let index = 0
359
+ while (typeof node !== 'function' && !isCommand(node)) {
360
+ const group: CommandGroup = node
361
+ const token = argv[index]
362
+ if (token === undefined || token === '--') {
363
+ if (pre.some(isHelp)) {
364
+ stdout.write(`${groupHelp(group, path, globalFields)}\n`)
365
+ return 0
366
+ }
367
+ if (!path.length && config.version && pre.includes('--version')) {
368
+ stdout.write(`${config.version}\n`)
369
+ return 0
370
+ }
371
+ throw new UsageError(
372
+ `${path.length ? `${usage}: missing` : 'Missing'} command\n\n${groupHelp(group, path, globalFields)}`
373
+ )
374
+ }
375
+ index++
376
+ if (token.startsWith('-') && token !== '-') {
377
+ pre.push(token)
378
+ if (valueFlags.has(token) && argv[index] !== undefined)
379
+ pre.push(argv[index++] as string)
380
+ continue
381
+ }
382
+ if (token === 'completion' && builtIn(group)) {
383
+ const [shell = 'bash'] = argv.slice(index)
384
+ stdout.write(await completion(shell, globalFields))
385
+ return 0
386
+ }
387
+ const child = Object.hasOwn(group, token) ? group[token] : undefined
388
+ if (!child) {
389
+ const names = Object.keys(group)
390
+ const guess = closest(token, names)
391
+ throw new UsageError(
392
+ `Unknown command "${[...path, token].join(' ')}".${guess ? ` Did you mean "${[...path, guess].join(' ')}"?` : ` Commands: ${names.join(', ')}`}`
393
+ )
394
+ }
395
+ node = child
396
+ path.push(token)
397
+ usage = `${usage} ${token}`
398
+ }
399
+
400
+ const command = await loadCommand(node)
401
+ const rest = argv.slice(index)
402
+ const fields = command.raw ? [] : fieldsOf(command.input)
403
+ const dash = rest.indexOf('--')
404
+ const tokens = command.raw
405
+ ? pre
406
+ : [...pre, ...(dash < 0 ? rest : rest.slice(0, dash))]
407
+ if (!command.raw && tokens.some(isHelp)) {
408
+ stdout.write(`${commandHelp(command, path, fields, globalFields)}\n`)
409
+ return 0
410
+ }
411
+ if (!path.length && config.version && tokens.includes('--version')) {
412
+ stdout.write(`${config.version}\n`)
413
+ return 0
414
+ }
415
+
416
+ const options: Record<
417
+ string,
418
+ { type: 'string' | 'boolean'; multiple: boolean; short?: string }
419
+ > = {}
420
+ for (const field of [...globalFields, ...fields]) {
421
+ if (field.positional) continue
422
+ if (field.flag === 'help' || Object.hasOwn(options, field.flag))
423
+ throw new Error(`hanzio/cli: --${field.flag} is declared twice`)
424
+ options[field.flag] = {
425
+ type: field.kind === 'boolean' ? 'boolean' : 'string',
426
+ multiple: field.multiple,
427
+ ...(field.alias ? { short: field.alias } : {})
428
+ }
429
+ }
430
+ let parsed: ReturnType<typeof parseArgs>
431
+ try {
432
+ parsed = parseArgs({
433
+ args: tokens,
434
+ options,
435
+ allowPositionals: true,
436
+ allowNegative: true,
437
+ strict: true
438
+ })
439
+ } catch (error) {
440
+ const message = error instanceof Error ? error.message : String(error)
441
+ const unknown = /^Unknown option '(-[^']*)'/.exec(message)?.[1]
442
+ if (!unknown) throw new UsageError(message.split('. ')[0] ?? message)
443
+ const guess = closest(
444
+ unknown.replace(/^--?/, ''),
445
+ Object.keys(options)
446
+ )
447
+ throw new UsageError(
448
+ `Unknown option ${unknown}.${guess ? ` Did you mean --${guess}?` : ''}`
449
+ )
450
+ }
451
+
452
+ const issues: string[] = []
453
+ const validate = async (
454
+ schema: AnyObject | undefined,
455
+ fields: Field[],
456
+ positionals: string[]
457
+ ) => {
458
+ const raw = readFields(fields, parsed.values, positionals, env)
459
+ if (!schema) return raw
460
+ const result = await schema.safeParseAsync(raw)
461
+ if (result.success) return result.data as Record<string, unknown>
462
+ for (const issue of result.error.issues) {
463
+ const field = fields.find((field) => field.key === issue.path[0])
464
+ const label = field
465
+ ? field.positional
466
+ ? `<${field.flag}>`
467
+ : `--${field.flag}`
468
+ : issue.path.join('.')
469
+ issues.push(label ? `${label}: ${issue.message}` : issue.message)
470
+ }
471
+ return raw
472
+ }
473
+ const globals = await validate(config.globals, globalFields, [])
474
+ const input = await validate(
475
+ command.raw ? undefined : command.input,
476
+ fields,
477
+ parsed.positionals
478
+ )
479
+ if (issues.length) throw new UsageError(issues.join('\n'))
480
+ json = input.json === true || globals.json === true
481
+
482
+ const context = (await config.context?.(globals as z.output<G>)) as C
483
+ await config.guard?.({
484
+ path,
485
+ input,
486
+ globals: globals as z.output<G>,
487
+ context
488
+ })
489
+ const ctx: CommandContext = {
490
+ context,
491
+ globals,
492
+ path,
493
+ argv: rest,
494
+ passthrough: dash < 0 ? [] : rest.slice(dash + 1),
495
+ json,
496
+ stdout,
497
+ stderr,
498
+ env,
499
+ exitCode: 0
500
+ }
501
+ const run = command.run as (input: unknown, ctx: unknown) => unknown
502
+ const result = await run(input, ctx)
503
+ const output =
504
+ (command.output as
505
+ | ((result: unknown, ctx: unknown) => unknown)
506
+ | undefined) ?? printResult
507
+ await output(result, ctx)
508
+ return ctx.exitCode
509
+ } catch (error) {
510
+ if (error instanceof UsageError) {
511
+ stderr.write(`${error.message}\nRun "${usage} --help" for usage.\n`)
512
+ return 2
513
+ }
514
+ const message = error instanceof Error ? error.message : String(error)
515
+ if (json) stderr.write(`${JSON.stringify({ error: message })}\n`)
516
+ else
517
+ stderr.write(
518
+ `${error instanceof Error && !(error instanceof CliError) ? (error.stack ?? message) : message}\n`
519
+ )
520
+ return error instanceof CliError ? error.exitCode : 1
521
+ }
522
+ }
523
+ }
524
+ }
525
+
526
+ /** The default `output`: nothing for `undefined`, strings as-is, JSON otherwise. */
527
+ function printResult(result: unknown, ctx: CommandContext<unknown>) {
528
+ if (result === undefined) return
529
+ ctx.stdout.write(
530
+ `${ctx.json ? JSON.stringify(result) : typeof result === 'string' ? result : JSON.stringify(result, null, 2)}\n`
531
+ )
532
+ }
@@ -42,6 +42,16 @@ decimal from MB up, unless `decimals` is set. `locale` defaults to `'en-US'`.
42
42
  formatBytes(3.45 * 1024 * 1024) // '3.5 MB'
43
43
  ```
44
44
 
45
+ ### `formatPrice(amount, currency, locale)`
46
+
47
+ A price: whole amounts drop the minor units, fractional ones keep the
48
+ currency's usual digits instead of rounding into a price nobody charges.
49
+
50
+ ```ts
51
+ formatPrice(2500, 'DKK', 'da-DK') // '2.500 kr.'
52
+ formatPrice(199.95, 'DKK', 'da-DK') // '199,95 kr.'
53
+ ```
54
+
45
55
  ## Durations
46
56
 
47
57
  ```ts
@@ -61,6 +71,38 @@ formatDuration(3_930_000, 'en-US', { style: 'narrow' }) // '1h 5m'
61
71
  formatDuration(3_930_000, 'en-US', { style: 'long' }) // '1 hour, 5 minutes'
62
72
  ```
63
73
 
74
+ ## Date presets
75
+
76
+ `formatDate(date, preset, locale)` formats a date in local time with a named
77
+ layout. The names read like dayjs tokens, but the order follows the locale.
78
+ The `HH` presets always use a 24-hour clock.
79
+
80
+ ```ts
81
+ formatDate(date, 'D MMM', 'en-GB') // '5 Mar'
82
+ formatDate(date, 'D MMM', 'en-US') // 'Mar 5'
83
+ formatDate(date, 'HH:mm', 'en-US') // '07:04'
84
+ formatDateTime(date, 'en-GB') // '5 Mar 2026, 07:04' (newer ICU: '5 Mar 2026 at 07:04')
85
+ ```
86
+
87
+ Presets: `'DD/MM/YYYY'`, `'DD/MM/YYYY HH:mm'`, `'HH:mm'`, `'ddd'`, `'D MMM'`,
88
+ `'D MMM YYYY'`, `'dddd, MMMM D'`, `'MMMM YYYY'`.
89
+
90
+ `formatCompactTime(date, locale, { now? })` is an activity timestamp for list
91
+ rows: the time today, "yesterday", else the date (with the year when it is not
92
+ this year). `formatDayDivider(date, locale, { now? })` labels a chat timeline:
93
+ "today", "yesterday", else weekday and date. Both return `''` for unparseable
94
+ input.
95
+
96
+ ### `withLocale(getLocale)`
97
+
98
+ The date, number and price formatters with the locale read on every call, for
99
+ apps that keep the active locale in their i18n runtime:
100
+
101
+ ```ts
102
+ export const fmt = withLocale(() => uiLocales.tag(appI18n.locale()))
103
+ fmt.formatCompactTime(message.createdAt)
104
+ ```
105
+
64
106
  ## Relative time
65
107
 
66
108
  ### `formatRelativeTime(date, locale, { now?, numeric?, style?, withSuffix? })`
@@ -102,7 +144,7 @@ formatDateRange('2026-08-24', '2026-08-30', 'en-GB', { timeZone: 'UTC' }) // '24
102
144
  formatList(['a', 'b', 'c'], 'en-US') // 'a, b, and c'
103
145
  ```
104
146
 
105
- ## Counts
147
+ ## Counts and empty values
106
148
 
107
149
  ```ts
108
150
  formatCappedCount(150) // '99+' (a negative number or NaN gives '0')
@@ -112,9 +154,47 @@ pluralize(1234, { one: '{count} item', other: '{count} items' }, 'en-US') // '1,
112
154
 
113
155
  `pluralize` picks the form with `Intl.PluralRules` and falls back to `other`.
114
156
 
157
+ `EM_DASH` (`'—'`) stands for "no value" in a table cell or detail row that must
158
+ keep its height, so a hyphen and an em dash never end up in the same column.
159
+
160
+ ## Phone numbers
161
+
162
+ `hanzio/format/phone` needs `libphonenumber-js`, so it is a separate import.
163
+ `formatPhone(value, country?)` prints a stored number as a person reads it.
164
+ `country` is the reader's ISO 3166-1 alpha-2 country: a number from that
165
+ country prints the local way, any other with its country code.
166
+
167
+ ```ts
168
+ import { formatPhone } from 'hanzio/format/phone'
169
+
170
+ formatPhone('+4520304050', 'DK') // '20 30 40 50'
171
+ formatPhone('+4520304050') // '+45 20 30 40 50'
172
+ formatPhone('+4512345678', 'DK') // '+45 12345678' (not a valid DK number)
173
+ formatPhone('extension 4021') // 'extension 4021'
174
+ ```
175
+
176
+ Nothing is lost: an unparseable value comes back trimmed, and the national form
177
+ is used only for a valid number. Empty input returns `''`. Keep the raw E.164
178
+ value for `tel:` links.
179
+
115
180
  ## Runtime support
116
181
 
117
182
  Works in browsers, Node, Bun, Workers and React Native. On Hermes, polyfill
118
183
  `Intl.RelativeTimeFormat` and `Intl.PluralRules` if your Hermes build does not
119
- have them. `formatList`, `formatDuration` and `formatDateRange` fall back to
184
+ have them. Load the FormatJS polyfills before any app code; the locale-data
185
+ imports must be literal paths for Metro, so list your locales by hand:
186
+
187
+ ```ts
188
+ // intl-polyfill.ts, imported first in the app entry
189
+ import '@formatjs/intl-getcanonicallocales/polyfill'
190
+ import '@formatjs/intl-locale/polyfill'
191
+ import '@formatjs/intl-pluralrules/polyfill'
192
+ import '@formatjs/intl-pluralrules/locale-data/en'
193
+ import '@formatjs/intl-pluralrules/locale-data/da'
194
+ import '@formatjs/intl-relativetimeformat/polyfill'
195
+ import '@formatjs/intl-relativetimeformat/locale-data/en'
196
+ import '@formatjs/intl-relativetimeformat/locale-data/da'
197
+ ```
198
+
199
+ The non-`force` polyfills skip APIs Hermes already has. `formatList`, `formatDuration` and `formatDateRange` fall back to
120
200
  plain separators when `Intl.ListFormat` or `formatRange` is missing.