hanzio 3.0.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 +114 -3
- 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 +14 -4
- 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/zod/index.d.ts +10 -0
- package/dist/zod/introspect.d.ts +2 -1
- package/package.json +96 -2
- package/src/api-wrapper/README.md +52 -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/zod/index.ts +20 -0
- package/src/zod/introspect.ts +1 -1
package/src/cli/index.ts
ADDED
|
@@ -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
|
+
}
|
package/src/format/README.md
CHANGED
|
@@ -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.
|
|
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.
|