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/index.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  fieldHelp,
7
7
  fieldLabel,
8
8
  fieldsOf,
9
+ optionName,
9
10
  readFields,
10
11
  UsageError
11
12
  } from './fields'
@@ -14,10 +15,16 @@ declare module 'zod' {
14
15
  interface GlobalMeta {
15
16
  /** hanzio/cli: read this key from positional arguments, in key order. */
16
17
  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
18
+ /**
19
+ * hanzio/cli: other names for the flag: one letter for `-r`, longer for
20
+ * `--region-name`.
21
+ */
22
+ alias?: string | readonly string[]
23
+ /**
24
+ * hanzio/cli: environment variables read in order when the flag is
25
+ * missing. Blank values count as unset.
26
+ */
27
+ env?: string | readonly string[]
21
28
  }
22
29
  }
23
30
 
@@ -52,6 +59,37 @@ export type CliIO = {
52
59
  env?: Record<string, string | undefined>
53
60
  }
54
61
 
62
+ /**
63
+ * Bad arguments, passed to `defineCli({ onError })`. `help` is the help of
64
+ * the group or command the arguments were for, ending in a newline.
65
+ */
66
+ export class CliUsageError extends CliError {
67
+ readonly kind: 'missing-command' | 'unknown-command' | 'invalid-arguments'
68
+ readonly path: readonly string[]
69
+ readonly help: string
70
+
71
+ constructor(
72
+ message: string,
73
+ details: Pick<CliUsageError, 'kind' | 'path' | 'help'>
74
+ ) {
75
+ super(message, { exitCode: 2 })
76
+ this.name = 'CliUsageError'
77
+ this.kind = details.kind
78
+ this.path = details.path
79
+ this.help = details.help
80
+ }
81
+ }
82
+
83
+ /** What `defineCli({ onError })` gets besides the error. */
84
+ export type CliErrorIO = {
85
+ readonly stdout: CliWriter
86
+ readonly stderr: CliWriter
87
+ readonly env: Record<string, string | undefined>
88
+ readonly json: boolean
89
+ /** Prints the error the default way and returns its exit code. */
90
+ print(): number
91
+ }
92
+
55
93
  export type CommandContext<C = unknown> = {
56
94
  /** What `defineCli({ context })` returned. */
57
95
  readonly context: C
@@ -75,8 +113,11 @@ export type CommandContext<C = unknown> = {
75
113
  export type Command<C = unknown> = {
76
114
  readonly [commandKey]: true
77
115
  readonly description?: string
116
+ readonly usage?: string
117
+ readonly examples?: readonly string[]
78
118
  readonly input?: AnyObject
79
119
  readonly raw?: boolean
120
+ readonly strict?: boolean
80
121
  readonly run?: (input: never, ctx: CommandContext<C>) => unknown
81
122
  readonly output?: (result: never, ctx: CommandContext<C>) => unknown
82
123
  readonly load?: LazyCommand<C>
@@ -120,6 +161,31 @@ export type CliConfig<G extends AnyObject, C> = {
120
161
  }) => unknown
121
162
  /** A command (single-command CLI) or nested groups of commands. */
122
163
  commands: CommandNode<C>
164
+ /**
165
+ * The root command to run when no command name is given, or when the first
166
+ * argument is a flag that isn't a global. A function reads it from `env`.
167
+ */
168
+ defaultCommand?:
169
+ | string
170
+ | ((env: Record<string, string | undefined>) => string | undefined)
171
+ /** Resolves a command name the group doesn't have, e.g. from config. */
172
+ unknownCommand?: (
173
+ name: string,
174
+ path: readonly string[]
175
+ ) => CommandNode<C> | undefined | Promise<CommandNode<C> | undefined>
176
+ /** Rewrites a help screen; `path` is `[]` for the root. */
177
+ help?: (
178
+ text: string,
179
+ info: { path: readonly string[] }
180
+ ) => string | Promise<string>
181
+ /**
182
+ * Handles every error, usage errors included (`CliUsageError`). Return the
183
+ * exit code, or `undefined` for `io.print()`.
184
+ */
185
+ onError?: (
186
+ error: unknown,
187
+ io: CliErrorIO
188
+ ) => number | undefined | Promise<number | undefined>
123
189
  } & (undefined extends C
124
190
  ? { context?: ContextFunction<G, C> }
125
191
  : { context: ContextFunction<G, C> })
@@ -139,14 +205,22 @@ export function defineCommand<
139
205
  R = unknown,
140
206
  C = unknown
141
207
  >(definition: {
208
+ /** The first line is the summary in command lists; help shows it all. */
142
209
  description?: string
210
+ /** Replaces the generated arguments in usage lines, e.g. `'[name...] [--all]'`. */
211
+ usage?: string
212
+ /** Command lines shown under "Examples:" in the command's help. */
213
+ examples?: readonly string[]
143
214
  input?: S
215
+ /** `false` ignores unknown flags and extra positionals. */
216
+ strict?: boolean
144
217
  run: (input: z.output<S>, ctx: CommandContext<C>) => R
145
218
  output?: (result: Awaited<R>, ctx: CommandContext<C>) => unknown
146
219
  }): Command<C>
147
220
  /** A command that skips parsing: everything after its name is in `ctx.argv`. */
148
221
  export function defineCommand<R = unknown, C = unknown>(definition: {
149
222
  description?: string
223
+ usage?: string
150
224
  raw: true
151
225
  run: (input: Record<string, never>, ctx: CommandContext<C>) => R
152
226
  output?: (result: Awaited<R>, ctx: CommandContext<C>) => unknown
@@ -154,6 +228,7 @@ export function defineCommand<R = unknown, C = unknown>(definition: {
154
228
  /** A lazy command whose description shows in help before it loads. */
155
229
  export function defineCommand<C = unknown>(definition: {
156
230
  description?: string
231
+ usage?: string
157
232
  load: LazyCommand<C>
158
233
  }): Command<C>
159
234
  export function defineCommand(definition: object): Command {
@@ -174,21 +249,18 @@ async function loadCommand(node: Command | LazyCommand): Promise<Command> {
174
249
  return loadCommand(command)
175
250
  }
176
251
 
177
- /** Leaf commands under a group, by path, without loading lazy ones. */
252
+ /** Leaf commands under a group as help rows, without loading lazy ones. */
178
253
  function listCommands(
179
254
  group: CommandGroup,
180
255
  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
- )
256
+ ): [string, string | undefined][] {
257
+ return Object.entries(group).flatMap(([name, node]) => {
258
+ if (typeof node !== 'function' && !isCommand(node))
259
+ return listCommands(node, [...prefix, name])
260
+ const command = typeof node === 'function' ? undefined : node
261
+ const names = [...prefix, name, command?.usage].filter(Boolean).join(' ')
262
+ return [[names, command?.description?.split('\n')[0]]]
263
+ })
192
264
  }
193
265
 
194
266
  function table(rows: [string, string | undefined][]): string {
@@ -208,21 +280,29 @@ export function defineCli<G extends AnyObject = EmptyObject, C = undefined>(
208
280
  ): Cli {
209
281
  // The context type only matters at the call site; internally any command runs.
210
282
  const root = config.commands as unknown as CommandNode
283
+ const unknownCommand = config.unknownCommand as
284
+ | ((name: string, path: readonly string[]) => unknown)
285
+ | undefined
211
286
  const builtIn = (group: CommandGroup) =>
212
287
  group === root && !Object.hasOwn(group, 'completion')
213
288
 
214
289
  const versionRow: [string, string][] = config.version
215
290
  ? [['--version', 'Show version']]
216
291
  : []
292
+ const section = (title: string, fields: Field[]) =>
293
+ fields.length ? [`${title}:\n${table(optionRows(fields))}`] : []
294
+ const positionalsOf = (fields: Field[]) =>
295
+ fields.filter((field) => field.positional)
296
+ const optionsOf = (fields: Field[]) =>
297
+ fields.filter((field) => !field.positional)
217
298
 
218
299
  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
- )
300
+ const usage = [
301
+ config.name,
302
+ ...positionalsOf(globals).map(fieldLabel),
303
+ ...path
304
+ ].join(' ')
305
+ const commands = listCommands(group)
226
306
  if (builtIn(group))
227
307
  commands.push([
228
308
  'completion <bash|zsh>',
@@ -237,13 +317,14 @@ export function defineCli<G extends AnyObject = EmptyObject, C = undefined>(
237
317
  ''
238
318
  ]),
239
319
  `Usage: ${usage} <command> [options]`,
320
+ ...section('\nArguments', positionalsOf(globals)),
240
321
  '',
241
322
  'Commands:',
242
323
  table(commands),
243
324
  '',
244
325
  'Options:',
245
326
  table([
246
- ...optionRows(globals),
327
+ ...optionRows(optionsOf(globals)),
247
328
  helpRow,
248
329
  ...(path.length ? [] : versionRow)
249
330
  ]),
@@ -252,39 +333,49 @@ export function defineCli<G extends AnyObject = EmptyObject, C = undefined>(
252
333
  ].join('\n')
253
334
  }
254
335
 
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]`
336
+ function commandHelp(command: Command, path: string[], globals: Field[]) {
337
+ const fields = command.raw ? [] : fieldsOf(command.input)
338
+ const positionals = [...positionalsOf(globals), ...positionalsOf(fields)]
339
+ const usage = [
340
+ config.name,
341
+ ...positionalsOf(globals).map(fieldLabel),
342
+ ...path,
343
+ command.usage ?? [...positionalsOf(fields).map(fieldLabel), '[options]']
265
344
  ]
345
+ const sections = [`Usage: ${usage.flat().join(' ')}`]
266
346
  const description = command.description ?? config.description
267
347
  if (description) sections.push(description)
268
- if (positionals.length)
269
- sections.push(`Arguments:\n${table(optionRows(positionals))}`)
348
+ sections.push(...section('Arguments', positionals))
270
349
  sections.push(
271
350
  `Options:\n${table([
272
- ...optionRows(options),
351
+ ...optionRows(optionsOf(fields)),
273
352
  helpRow,
274
353
  ...(path.length ? [] : versionRow)
275
354
  ])}`
276
355
  )
277
- if (globals.length)
278
- sections.push(`Global options:\n${table(optionRows(globals))}`)
356
+ sections.push(...section('Global options', optionsOf(globals)))
357
+ if (command.examples?.length)
358
+ sections.push(
359
+ `Examples:\n${command.examples.map((line) => ` ${line}`).join('\n')}`
360
+ )
279
361
  return sections.join('\n\n')
280
362
  }
281
363
 
364
+ async function helpText(
365
+ node: CommandGroup | Command,
366
+ path: string[],
367
+ globals: Field[]
368
+ ) {
369
+ const text = isCommand(node)
370
+ ? commandHelp(node, path, globals)
371
+ : groupHelp(node, path, globals)
372
+ return config.help ? config.help(text, { path }) : text
373
+ }
374
+
282
375
  async function completion(shell: string, globals: Field[]) {
283
376
  const fn = `_${config.name.replace(/\W/g, '_')}_completion`
284
377
  const flags = (fields: Field[]) =>
285
- [...fields, ...globals]
286
- .filter((field) => !field.positional)
287
- .map((field) => `--${field.flag}`)
378
+ optionsOf([...fields, ...globals]).map((field) => `--${field.flag}`)
288
379
  const cases: [string, string[]][] = []
289
380
  const visit = async (node: CommandNode, path: string[]) => {
290
381
  if (typeof node === 'function' || isCommand(node)) {
@@ -334,63 +425,131 @@ complete -F ${fn} ${config.name}
334
425
  const env = io.env ?? process.env
335
426
  let usage = config.name
336
427
  let json = false
428
+ let globalFields: Field[] = []
429
+ // The group or command a usage error's help describes.
430
+ let current: { node: CommandGroup | Command; path: string[] } = {
431
+ node: {},
432
+ path: []
433
+ }
337
434
  try {
338
- const globalFields = fieldsOf(config.globals)
339
- const positional = globalFields.find((field) => field.positional)
340
- if (positional)
435
+ globalFields = fieldsOf(config.globals)
436
+ const globalPositionals = positionalsOf(globalFields)
437
+ const array = globalPositionals.find((field) => field.multiple)
438
+ if (array)
341
439
  throw new Error(
342
- `hanzio/cli: global "${positional.key}" can't be positional`
440
+ `hanzio/cli: positional global "${array.key}" can't be an array`
343
441
  )
442
+ const longNames = (field: Field) => [
443
+ `--${field.flag}`,
444
+ ...field.aliases.map((alias) => `--${alias}`)
445
+ ]
446
+ const names = (field: Field) => [
447
+ ...longNames(field),
448
+ ...(field.alias ? [`-${field.alias}`] : [])
449
+ ]
450
+ const globalOptions = optionsOf(globalFields)
344
451
  const valueFlags = new Set(
345
- globalFields
346
- .filter((field) => field.kind !== 'boolean')
347
- .flatMap((field) => [
348
- `--${field.flag}`,
349
- ...(field.alias ? [`-${field.alias}`] : [])
350
- ])
452
+ globalOptions
453
+ .filter((field) => field.kind !== 'boolean' && !field.optionalValue)
454
+ .flatMap(names)
455
+ )
456
+ const globalFlags = new Set(
457
+ globalOptions.flatMap((field) => [
458
+ ...names(field),
459
+ `--no-${field.flag}`
460
+ ])
351
461
  )
352
462
  const isHelp = (token: string) => token === '--help' || token === '-h'
353
-
354
- // Walk the command names; options before and between them are globals.
463
+ const isVersion = (token: string) =>
464
+ token === '--version' ||
465
+ (token === '-v' &&
466
+ !globalOptions.some((field) => field.alias === 'v'))
467
+ const isFlag = (token: string) => token.startsWith('-') && token !== '-'
468
+
469
+ // Walk the command names; options before and between them are globals,
470
+ // and positional globals come before the first command name.
471
+ const args = [...argv]
472
+ let defaultCommand =
473
+ typeof config.defaultCommand === 'function'
474
+ ? config.defaultCommand(env)
475
+ : config.defaultCommand
476
+ let injected = -1
355
477
  let node: CommandNode = root
356
478
  const path: string[] = []
357
479
  const pre: string[] = []
480
+ const positionals: string[] = []
358
481
  let index = 0
359
482
  while (typeof node !== 'function' && !isCommand(node)) {
360
483
  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
484
+ current = { node: group, path: [...path] }
485
+ const token = args[index]
486
+ const end = token === undefined || token === '--'
487
+ if (end && pre.some(isHelp)) {
488
+ stdout.write(`${await helpText(group, path, globalFields)}\n`)
489
+ return 0
490
+ }
491
+ if (end && !path.length && pre.some(isVersion)) {
492
+ if (Object.hasOwn(group, 'version')) {
493
+ pre.splice(0, pre.length, ...pre.filter((t) => !isVersion(t)))
494
+ args.splice(index, 0, 'version')
495
+ injected = index
496
+ continue
366
497
  }
367
- if (!path.length && config.version && pre.includes('--version')) {
498
+ if (config.version) {
368
499
  stdout.write(`${config.version}\n`)
369
500
  return 0
370
501
  }
502
+ }
503
+ if (
504
+ !path.length &&
505
+ defaultCommand !== undefined &&
506
+ (end ||
507
+ (isFlag(token) &&
508
+ !isHelp(token) &&
509
+ !isVersion(token) &&
510
+ !globalFlags.has(token.split('=')[0] as string)))
511
+ ) {
512
+ args.splice(index, 0, defaultCommand)
513
+ injected = index
514
+ defaultCommand = undefined
515
+ continue
516
+ }
517
+ if (end)
371
518
  throw new UsageError(
372
- `${path.length ? `${usage}: missing` : 'Missing'} command\n\n${groupHelp(group, path, globalFields)}`
519
+ `${path.length ? `${usage}: missing` : 'Missing'} command\n\n${await helpText(group, path, globalFields)}`,
520
+ 'missing-command'
373
521
  )
374
- }
375
522
  index++
376
- if (token.startsWith('-') && token !== '-') {
523
+ if (isFlag(token)) {
377
524
  pre.push(token)
378
- if (valueFlags.has(token) && argv[index] !== undefined)
379
- pre.push(argv[index++] as string)
525
+ if (valueFlags.has(token) && args[index] !== undefined)
526
+ pre.push(args[index++] as string)
380
527
  continue
381
528
  }
382
- if (token === 'completion' && builtIn(group)) {
383
- const [shell = 'bash'] = argv.slice(index)
529
+ if (token === 'completion' && builtIn(group) && !positionals.length) {
530
+ const [shell = 'bash'] = args.slice(index)
384
531
  stdout.write(await completion(shell, globalFields))
385
532
  return 0
386
533
  }
387
- const child = Object.hasOwn(group, token) ? group[token] : undefined
534
+ if (
535
+ !path.length &&
536
+ index - 1 !== injected &&
537
+ positionals.length < globalPositionals.length
538
+ ) {
539
+ positionals.push(token)
540
+ usage = `${usage} ${token}`
541
+ continue
542
+ }
543
+ const child =
544
+ (Object.hasOwn(group, token) ? group[token] : undefined) ??
545
+ ((await unknownCommand?.(token, path)) as CommandNode | undefined)
388
546
  if (!child) {
389
547
  const names = Object.keys(group)
390
548
  if (builtIn(group)) names.push('completion')
391
549
  const guess = closest(token, names)
392
550
  throw new UsageError(
393
- `Unknown command "${[...path, token].join(' ')}".${guess ? ` Did you mean "${[...path, guess].join(' ')}"?` : ` Commands: ${names.join(', ')}`}`
551
+ `Unknown command "${[...path, token].join(' ')}".${guess ? ` Did you mean "${[...path, guess].join(' ')}"?` : ` Commands: ${names.join(', ')}`}`,
552
+ 'unknown-command'
394
553
  )
395
554
  }
396
555
  node = child
@@ -399,14 +558,15 @@ complete -F ${fn} ${config.name}
399
558
  }
400
559
 
401
560
  const command = await loadCommand(node)
402
- const rest = argv.slice(index)
561
+ current = { node: command, path }
562
+ const rest = args.slice(index)
403
563
  const fields = command.raw ? [] : fieldsOf(command.input)
404
564
  const dash = rest.indexOf('--')
405
565
  const tokens = command.raw
406
566
  ? pre
407
567
  : [...pre, ...(dash < 0 ? rest : rest.slice(0, dash))]
408
568
  if (!command.raw && tokens.some(isHelp)) {
409
- stdout.write(`${commandHelp(command, path, fields, globalFields)}\n`)
569
+ stdout.write(`${await helpText(command, path, globalFields)}\n`)
410
570
  return 0
411
571
  }
412
572
  if (!path.length && config.version && tokens.includes('--version')) {
@@ -418,24 +578,41 @@ complete -F ${fn} ${config.name}
418
578
  string,
419
579
  { type: 'string' | 'boolean'; multiple: boolean; short?: string }
420
580
  > = {}
421
- for (const field of [...globalFields, ...fields]) {
422
- if (field.positional) continue
423
- if (field.flag === 'help' || Object.hasOwn(options, field.flag))
424
- throw new Error(`hanzio/cli: --${field.flag} is declared twice`)
425
- options[field.flag] = {
581
+ // Every long name a user may type, mapped to its parseArgs option.
582
+ const canonical = new Map<string, Field>()
583
+ const all = optionsOf([...globalFields, ...fields])
584
+ for (const field of all) {
585
+ const name = optionName(field)
586
+ for (const long of longNames(field)) {
587
+ if (long === '--help' || canonical.has(long))
588
+ throw new Error(`hanzio/cli: ${long} is declared twice`)
589
+ canonical.set(long, field)
590
+ }
591
+ options[name] = {
426
592
  type: field.kind === 'boolean' ? 'boolean' : 'string',
427
593
  multiple: field.multiple,
428
594
  ...(field.alias ? { short: field.alias } : {})
429
595
  }
430
596
  }
597
+ // Aliases, `noX` flags and bare optional-value flags become the
598
+ // option's own name before parsing.
599
+ const rewritten = tokens.map((token) => {
600
+ const eq = token.indexOf('=')
601
+ const field = canonical.get(eq < 0 ? token : token.slice(0, eq))
602
+ if (!field) return token
603
+ const value =
604
+ eq < 0 ? (field.optionalValue ? '=' : '') : token.slice(eq)
605
+ return `--${optionName(field)}${value}`
606
+ })
607
+ const strict = command.strict !== false
431
608
  let parsed: ReturnType<typeof parseArgs>
432
609
  try {
433
610
  parsed = parseArgs({
434
- args: tokens,
611
+ args: rewritten,
435
612
  options,
436
613
  allowPositionals: true,
437
614
  allowNegative: true,
438
- strict: true
615
+ strict
439
616
  })
440
617
  } catch (error) {
441
618
  const message = error instanceof Error ? error.message : String(error)
@@ -443,7 +620,7 @@ complete -F ${fn} ${config.name}
443
620
  if (!unknown) throw new UsageError(message.split('. ')[0] ?? message)
444
621
  const guess = closest(
445
622
  unknown.replace(/^--?/, ''),
446
- Object.keys(options)
623
+ all.flatMap((field) => [field.flag, ...field.aliases])
447
624
  )
448
625
  throw new UsageError(
449
626
  `Unknown option ${unknown}.${guess ? ` Did you mean --${guess}?` : ''}`
@@ -456,7 +633,13 @@ complete -F ${fn} ${config.name}
456
633
  fields: Field[],
457
634
  positionals: string[]
458
635
  ) => {
459
- const raw = readFields(fields, parsed.values, positionals, env)
636
+ const raw = readFields(
637
+ fields,
638
+ parsed.values,
639
+ positionals,
640
+ env,
641
+ strict
642
+ )
460
643
  if (!schema) return raw
461
644
  const result = await schema.safeParseAsync(raw)
462
645
  if (result.success) return result.data as Record<string, unknown>
@@ -471,7 +654,11 @@ complete -F ${fn} ${config.name}
471
654
  }
472
655
  return raw
473
656
  }
474
- const globals = await validate(config.globals, globalFields, [])
657
+ const globals = await validate(
658
+ config.globals,
659
+ globalFields,
660
+ positionals
661
+ )
475
662
  const input = await validate(
476
663
  command.raw ? undefined : command.input,
477
664
  fields,
@@ -508,17 +695,41 @@ complete -F ${fn} ${config.name}
508
695
  await output(result, ctx)
509
696
  return ctx.exitCode
510
697
  } catch (error) {
511
- if (error instanceof UsageError) {
512
- stderr.write(`${error.message}\nRun "${usage} --help" for usage.\n`)
513
- return 2
698
+ const failure =
699
+ error instanceof UsageError
700
+ ? new CliUsageError(error.message, {
701
+ kind: error.kind,
702
+ path: current.path,
703
+ help: config.onError
704
+ ? `${await helpText(current.node, current.path, globalFields)}\n`
705
+ : ''
706
+ })
707
+ : error
708
+ let printed: number | undefined
709
+ const print = () => {
710
+ if (printed !== undefined) return printed
711
+ const message =
712
+ failure instanceof Error ? failure.message : String(failure)
713
+ if (failure instanceof CliUsageError)
714
+ stderr.write(`${message}\nRun "${usage} --help" for usage.\n`)
715
+ else if (json) stderr.write(`${JSON.stringify({ error: message })}\n`)
716
+ else
717
+ stderr.write(
718
+ `${failure instanceof Error && !(failure instanceof CliError) ? (failure.stack ?? message) : message}\n`
719
+ )
720
+ printed = failure instanceof CliError ? failure.exitCode : 1
721
+ return printed
514
722
  }
515
- const message = error instanceof Error ? error.message : String(error)
516
- if (json) stderr.write(`${JSON.stringify({ error: message })}\n`)
517
- else
518
- stderr.write(
519
- `${error instanceof Error && !(error instanceof CliError) ? (error.stack ?? message) : message}\n`
520
- )
521
- return error instanceof CliError ? error.exitCode : 1
723
+ if (!config.onError) return print()
724
+ return (
725
+ (await config.onError(failure, {
726
+ stdout,
727
+ stderr,
728
+ env,
729
+ json,
730
+ print
731
+ })) ?? print()
732
+ )
522
733
  }
523
734
  }
524
735
  }
@@ -127,18 +127,32 @@ await sealer.open(sealed)
127
127
  - `sealJson` stores `JSON.stringify(value)`. It throws for `undefined` and for
128
128
  functions.
129
129
 
130
- ### Migrating lullu `secret-json.ts`
130
+ - `fallbacks: [{ salt, info }]`: older contexts `open` also tries, so values
131
+ sealed before a rename stay readable. `seal` always uses `salt` and `info`.
132
+ - `passthroughUnsealed: true`: `open` and `openJson` return a value that was
133
+ never sealed (`{}`, a legacy plaintext) unchanged instead of throwing.
134
+ Malformed `enc:v1:` values and other envelope versions still throw.
131
135
 
132
- The envelope and key derivation match `apps/backend/src/lib/crypto/secret-json.ts`,
133
- so values already stored keep opening and don't need to be re-encrypted:
136
+ ### Synchronous sealing (`hanzio/crypto/node`)
137
+
138
+ `createSealerSync` takes the same options and writes the same envelope on
139
+ `node:crypto`, for code that cannot await (formatters, getters, Prisma result
140
+ extensions). Either sealer opens what the other sealed.
134
141
 
135
142
  ```ts
136
- const sealer = createSealer({
143
+ import { createSealerSync } from 'hanzio/crypto/node'
144
+
145
+ const secrets = createSealerSync({
137
146
  secret: getSecret('APP_SECRET'),
138
147
  salt: 'lullu.secret-json.salt.v1',
139
- info: 'lullu.secret-json.aes-256-gcm.v1'
148
+ info: 'lullu.secret-json.aes-256-gcm.v1',
149
+ fallbacks: [{ salt: 'hellu.secret-json.salt.v1', info: 'hellu.secret-json.aes-256-gcm.v1' }],
150
+ passthroughUnsealed: true
140
151
  })
152
+
153
+ const stored = secrets.sealJson(credentials)
154
+ const credentials = secrets.openJson<Credentials>(row.credentials)
141
155
  ```
142
156
 
143
- lullu's `openSecretJson` returns non-sealed values unchanged. `open` throws on
144
- them instead, so keep the `value.startsWith('enc:')` check in the app.
157
+ The envelope and key derivation match lullu/hellu's `secret-json.ts`, so values
158
+ already stored keep opening without re-encryption.