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.
- package/README.md +8 -1
- package/dist/cli/fields.d.ts +16 -6
- package/dist/cli/index.d.ts +57 -4
- package/dist/crypto/node.d.ts +17 -0
- package/dist/crypto/sealer.d.ts +34 -2
- package/dist/format/index.d.ts +6 -3
- package/dist/{index-nfvq3zax.js → index-818f7jz4.js} +228 -88
- package/dist/index-zz773050.js +135 -0
- package/dist/redis/concurrency-limit.d.ts +17 -7
- package/dist/src/cli/index.js +3 -1
- package/dist/src/crypto/index.js +15 -106
- package/dist/src/crypto/node.js +67 -0
- package/dist/src/format/index.js +4 -3
- package/dist/src/i18n/cli.js +33 -1
- package/dist/src/redis/index.js +5 -1
- package/dist/src/testing/index.js +23 -4
- package/dist/string/index.d.ts +1 -1
- package/dist/testing/module-mocker.d.ts +9 -9
- package/dist/testing/prisma.d.ts +5 -1
- package/dist/typedswitch/index.d.ts +26 -1
- package/package.json +8 -2
- package/src/cli/README.md +63 -6
- package/src/cli/fields.ts +95 -38
- package/src/cli/index.ts +302 -91
- package/src/crypto/README.md +21 -7
- package/src/crypto/node.ts +90 -0
- package/src/crypto/sealer.ts +120 -62
- package/src/format/README.md +7 -0
- package/src/format/index.ts +11 -5
- package/src/i18n/README.md +3 -1
- package/src/i18n/cli.ts +40 -0
- package/src/redis/README.md +9 -7
- package/src/redis/concurrency-limit.ts +26 -8
- package/src/string/index.ts +1 -1
- package/src/testing/README.md +12 -3
- package/src/testing/module-mocker.ts +40 -10
- package/src/testing/prisma.ts +6 -1
- package/src/typedswitch/README.md +20 -9
- 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
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
182
|
-
return Object.entries(group).flatMap(([name, node]) =>
|
|
183
|
-
typeof node
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 = [
|
|
220
|
-
|
|
221
|
-
(
|
|
222
|
-
|
|
223
|
-
|
|
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:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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(
|
|
351
|
+
...optionRows(optionsOf(fields)),
|
|
273
352
|
helpRow,
|
|
274
353
|
...(path.length ? [] : versionRow)
|
|
275
354
|
])}`
|
|
276
355
|
)
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
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 "${
|
|
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
|
-
|
|
346
|
-
.filter((field) => field.kind !== 'boolean')
|
|
347
|
-
.flatMap(
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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 (
|
|
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${
|
|
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 (
|
|
523
|
+
if (isFlag(token)) {
|
|
377
524
|
pre.push(token)
|
|
378
|
-
if (valueFlags.has(token) &&
|
|
379
|
-
pre.push(
|
|
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'] =
|
|
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
|
-
|
|
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
|
-
|
|
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(`${
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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:
|
|
611
|
+
args: rewritten,
|
|
435
612
|
options,
|
|
436
613
|
allowPositionals: true,
|
|
437
614
|
allowNegative: true,
|
|
438
|
-
strict
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
}
|
package/src/crypto/README.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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 `
|
|
144
|
-
|
|
157
|
+
The envelope and key derivation match lullu/hellu's `secret-json.ts`, so values
|
|
158
|
+
already stored keep opening without re-encryption.
|