hanzio 3.2.0 → 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/src/cli/fields.ts CHANGED
@@ -1,9 +1,21 @@
1
- import { z } from 'zod'
1
+ import type { z } from 'zod'
2
2
  import { unwrapOnce } from '../zod/introspect'
3
- import { defOf, type Schema } from '../zod/zodToTypeString'
3
+ import { type Def, defOf, type Schema } from '../zod/zodToTypeString'
4
4
 
5
- /** Thrown for bad argv. The CLI prints it with a `--help` hint and exits 2. */
6
- export class UsageError extends Error {}
5
+ // No runtime `zod` import: a CLI made only of raw commands loads without it.
6
+
7
+ /** Thrown for bad argv; `cli.run()` turns it into a `CliUsageError`. */
8
+ export class UsageError extends Error {
9
+ constructor(
10
+ message: string,
11
+ readonly kind:
12
+ | 'missing-command'
13
+ | 'unknown-command'
14
+ | 'invalid-arguments' = 'invalid-arguments'
15
+ ) {
16
+ super(message)
17
+ }
18
+ }
7
19
 
8
20
  /** One key of an input or globals object, read as a flag or a positional. */
9
21
  export type Field = {
@@ -16,11 +28,53 @@ export type Field = {
16
28
  hasDefault: boolean
17
29
  defaultValue: unknown
18
30
  description?: string
31
+ /** One-letter short flag. */
19
32
  alias?: string
20
- env?: string
33
+ /** Extra long flag names, from multi-character aliases. */
34
+ aliases: string[]
35
+ env: string[]
36
+ /** `--name` alone is `true`, `--name=value` is the value. */
37
+ optionalValue: boolean
21
38
  choices?: string[]
22
39
  }
23
40
 
41
+ /** The `parseArgs` option name. A `noX` key is its own flag, never `--x` negated. */
42
+ export const optionName = (field: Field) =>
43
+ field.flag.startsWith('no-') ? field.key : field.flag
44
+
45
+ const strings = (value: unknown, what: string, key: string): string[] => {
46
+ const list = value === undefined ? [] : Array.isArray(value) ? value : [value]
47
+ if (list.some((item) => typeof item !== 'string' || !item))
48
+ throw new Error(
49
+ `hanzio/cli: ${what} ${JSON.stringify(value)} of "${key}" must be a string or string[]`
50
+ )
51
+ return list
52
+ }
53
+
54
+ const kindOf = (def: Def) =>
55
+ def.type === 'boolean'
56
+ ? 'boolean'
57
+ : def.type === 'number'
58
+ ? 'number'
59
+ : 'string'
60
+
61
+ function choicesOf(def: Def): string[] | undefined {
62
+ if (def.type === 'enum') return Object.values(def.entries).map(String)
63
+ if (def.type === 'literal') return def.values.map(String)
64
+ return undefined
65
+ }
66
+
67
+ // `z.union([z.boolean(), z.string()])` (or `z.literal(true)`): an optional value.
68
+ function optionalValueOf(def: Def): Def | undefined {
69
+ if (def.type !== 'union' || def.options.length !== 2) return undefined
70
+ const [a, b] = def.options.map(defOf) as [Def, Def]
71
+ const flag = (d: Def) =>
72
+ d.type === 'boolean' || (d.type === 'literal' && d.values.includes(true))
73
+ if (flag(a) && kindOf(b) === 'string' && b.type !== 'literal') return b
74
+ if (flag(b) && kindOf(a) === 'string' && a.type !== 'literal') return a
75
+ return undefined
76
+ }
77
+
24
78
  const kebab = (key: string) =>
25
79
  key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
26
80
 
@@ -37,7 +91,10 @@ function toField(key: string, schema: Schema): Field {
37
91
  let defaultValue: unknown
38
92
  let current = schema
39
93
  for (;;) {
40
- const meta = z.globalRegistry.get(current)
94
+ // `.meta()` with no argument reads Zod's global registry.
95
+ const meta = (
96
+ current as { meta?: () => Record<string, unknown> | undefined }
97
+ ).meta?.()
41
98
  if (meta) metas.unshift(meta)
42
99
  const def = defOf(current)
43
100
  if (def.type === 'optional') optional = true
@@ -61,20 +118,12 @@ function toField(key: string, schema: Schema): Field {
61
118
  }
62
119
  def = defOf(element)
63
120
  }
64
- const alias = meta.alias
65
- if (alias !== undefined && (typeof alias !== 'string' || alias.length !== 1))
66
- throw new Error(
67
- `hanzio/cli: alias ${JSON.stringify(alias)} of "${key}" must be one character`
68
- )
121
+ const valueDef = multiple ? undefined : optionalValueOf(def)
122
+ const names = strings(meta.alias, 'alias', key)
69
123
  return {
70
124
  key,
71
125
  flag: kebab(key),
72
- kind:
73
- def.type === 'boolean'
74
- ? 'boolean'
75
- : def.type === 'number'
76
- ? 'number'
77
- : 'string',
126
+ kind: valueDef ? 'string' : kindOf(def),
78
127
  multiple,
79
128
  positional: meta.positional === true,
80
129
  optional,
@@ -82,14 +131,11 @@ function toField(key: string, schema: Schema): Field {
82
131
  defaultValue,
83
132
  description:
84
133
  typeof meta.description === 'string' ? meta.description : undefined,
85
- alias,
86
- env: typeof meta.env === 'string' ? meta.env : undefined,
87
- choices:
88
- def.type === 'enum'
89
- ? Object.values(def.entries).map(String)
90
- : def.type === 'literal'
91
- ? def.values.map(String)
92
- : undefined
134
+ alias: names.find((name) => name.length === 1),
135
+ aliases: names.filter((name) => name.length > 1).map(kebab),
136
+ env: strings(meta.env, 'env', key),
137
+ optionalValue: valueDef !== undefined,
138
+ choices: choicesOf(valueDef ?? def)
93
139
  }
94
140
  }
95
141
 
@@ -108,14 +154,16 @@ function coerce(field: Field, value: string | boolean): unknown {
108
154
 
109
155
  /**
110
156
  * Builds the raw object Zod validates: flag values and positionals by key,
111
- * numbers coerced, `.meta({ env })` as fallback, and a missing required
112
- * boolean or array read as `false` or `[]`.
157
+ * numbers coerced, `.meta({ env })` as fallback (blank variables count as
158
+ * unset), and a missing required boolean or array read as `false` or `[]`.
159
+ * Extra positionals are a usage error unless `strict` is false.
113
160
  */
114
161
  export function readFields(
115
162
  fields: Field[],
116
163
  values: Record<string, string | boolean | (string | boolean)[] | undefined>,
117
164
  positionals: string[],
118
- env: Record<string, string | undefined>
165
+ env: Record<string, string | undefined>,
166
+ strict = true
119
167
  ): Record<string, unknown> {
120
168
  const result: Record<string, unknown> = {}
121
169
  const queue = [...positionals]
@@ -127,11 +175,13 @@ export function readFields(
127
175
  ? queue.splice(0)
128
176
  : undefined
129
177
  : queue.shift()
130
- else raw = values[field.flag]
131
- if (raw === undefined && field.env && env[field.env] !== undefined) {
132
- const value = env[field.env] as string
133
- raw = field.multiple ? [value] : value
134
- }
178
+ else raw = values[optionName(field)]
179
+ if (field.optionalValue && raw === '') raw = true
180
+ const value =
181
+ raw === undefined
182
+ ? field.env.map((name) => env[name]).find((value) => value?.trim())
183
+ : undefined
184
+ if (value !== undefined) raw = field.multiple ? [value] : value
135
185
  if (raw === undefined) {
136
186
  if (field.optional || field.hasDefault) continue
137
187
  if (field.multiple) raw = []
@@ -142,7 +192,8 @@ export function readFields(
142
192
  ? raw.map((item) => coerce(field, item))
143
193
  : coerce(field, raw)
144
194
  }
145
- if (queue.length) throw new UsageError(`Unexpected argument "${queue[0]}"`)
195
+ if (strict && queue.length)
196
+ throw new UsageError(`Unexpected argument "${queue[0]}"`)
146
197
  return result
147
198
  }
148
199
 
@@ -154,14 +205,20 @@ export function fieldLabel(field: Field): string {
154
205
  ? `[${field.flag}]`
155
206
  : `<${field.flag}>`
156
207
  const name =
157
- field.kind === 'boolean' && field.defaultValue === true
208
+ field.kind === 'boolean' &&
209
+ field.defaultValue === true &&
210
+ !field.flag.startsWith('no-')
158
211
  ? `--[no-]${field.flag}`
159
212
  : `--${field.flag}`
213
+ const type = `<${field.choices?.join('|') ?? field.kind}>`
160
214
  const value =
161
215
  field.kind === 'boolean'
162
216
  ? ''
163
- : ` <${field.choices?.join('|') ?? field.kind}>${field.multiple ? '...' : ''}`
164
- return `${field.alias ? `-${field.alias}, ` : ''}${name}${value}`
217
+ : field.optionalValue
218
+ ? `[=${type}]`
219
+ : ` ${type}${field.multiple ? '...' : ''}`
220
+ const aliases = field.aliases.map((alias) => `, --${alias}`).join('')
221
+ return `${field.alias ? `-${field.alias}, ` : ''}${name}${aliases}${value}`
165
222
  }
166
223
 
167
224
  export function fieldHelp(field: Field): string {
@@ -172,7 +229,7 @@ export function fieldHelp(field: Field): string {
172
229
  return [
173
230
  field.description,
174
231
  showDefault && `(default: ${JSON.stringify(field.defaultValue)})`,
175
- field.env && `[env: ${field.env}]`
232
+ field.env.length > 0 && `[env: ${field.env.join(', ')}]`
176
233
  ]
177
234
  .filter(Boolean)
178
235
  .join(' ')