@naturalcycles/nodejs-lib 15.114.0 → 15.115.1

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.
@@ -7,7 +7,10 @@ export type CliOptionType = 'string' | 'number' | 'boolean';
7
7
  * yargs `.options()` that we actually use.
8
8
  */
9
9
  export interface CliOption {
10
- type: CliOptionType;
10
+ /**
11
+ * Value type. Defaults to `'string'` when omitted.
12
+ */
13
+ type?: CliOptionType;
11
14
  /**
12
15
  * Accept the flag multiple times, collecting values into an array.
13
16
  * `--id a --id b` => `['a', 'b']`. Replaces yargs `type: 'array'`.
@@ -36,15 +39,34 @@ export interface CliOption {
36
39
  * Single-character alias, e.g. `short: 'v'` enables `-v`.
37
40
  */
38
41
  short?: string;
42
+ /**
43
+ * Transform the raw string value into the final value. The output type is
44
+ * inferred from the function's return type, so this is the way to produce
45
+ * branded types (e.g. `IsoDate`) or richer values (parsed numbers, JSON, ...).
46
+ *
47
+ * Applied per-element for `array` options. Receives the raw string token, so
48
+ * it fully owns conversion - built-in `number` coercion is not applied on top.
49
+ * NOT applied to `default` values: a `default` is taken to be in final form.
50
+ *
51
+ * @example transform: s => s as IsoDate
52
+ */
53
+ transform?: (value: string) => unknown;
39
54
  }
40
55
  export type CliOptions = Record<string, CliOption>;
41
56
  /**
42
- * Element value type of a single option, derived from `choices` (if present)
43
- * or `type`.
57
+ * Element value type of a single option, derived from `transform` (if present,
58
+ * its return type wins), then `choices`, then `type`. Defaults to `string` when
59
+ * none narrow it (so `type` is optional).
44
60
  */
45
61
  type ElemType<O extends CliOption> = O extends {
62
+ transform: (...args: any[]) => infer R;
63
+ } ? R : O extends {
46
64
  choices: readonly (infer C)[];
47
- } ? C : O['type'] extends 'string' ? string : O['type'] extends 'number' ? number : O['type'] extends 'boolean' ? boolean : never;
65
+ } ? C : O extends {
66
+ type: 'number';
67
+ } ? number : O extends {
68
+ type: 'boolean';
69
+ } ? boolean : string;
48
70
  /**
49
71
  * Full value type of a single option, applying `array` on top of the element
50
72
  * type.
@@ -116,11 +138,12 @@ export declare class ParseArgsError extends Error {
116
138
  * top of node's `util.parseArgs`.
117
139
  *
118
140
  * @example
119
- * const { dir, limit } = _parseArgs({
120
- * dir: { type: 'string', desc: 'Output directory' },
141
+ * const { dir, limit, date } = _parseArgs({
142
+ * dir: { desc: 'Output directory' }, // type defaults to 'string'
121
143
  * limit: { type: 'number', default: 100 },
144
+ * date: { transform: s => s as IsoDate }, // inferred + converted via transform
122
145
  * })
123
- * // dir?: string limit: number _: string[]
146
+ * // dir?: string limit: number date?: IsoDate _: string[]
124
147
  */
125
148
  export declare function _parseArgs<const O extends CliOptions>(options: O, opt?: ParseArgsOptions): InferCliArgs<O>;
126
149
  export {};
@@ -11,11 +11,12 @@ export class ParseArgsError extends Error {
11
11
  * top of node's `util.parseArgs`.
12
12
  *
13
13
  * @example
14
- * const { dir, limit } = _parseArgs({
15
- * dir: { type: 'string', desc: 'Output directory' },
14
+ * const { dir, limit, date } = _parseArgs({
15
+ * dir: { desc: 'Output directory' }, // type defaults to 'string'
16
16
  * limit: { type: 'number', default: 100 },
17
+ * date: { transform: s => s as IsoDate }, // inferred + converted via transform
17
18
  * })
18
- * // dir?: string limit: number _: string[]
19
+ * // dir?: string limit: number date?: IsoDate _: string[]
19
20
  */
20
21
  export function _parseArgs(options, opt = {}) {
21
22
  const { args, minPositionals = 0, usage, strict = false } = opt;
@@ -42,6 +43,7 @@ export function _parseArgs(options, opt = {}) {
42
43
  options: nodeOptions,
43
44
  allowPositionals: true,
44
45
  allowNegative: true, // native `--no-flag` support for booleans
46
+ tokens: true, // needed to detect the ambiguous `--boolFlag value` space form
45
47
  // In non-strict mode, unknown options are collected into `values` but
46
48
  // ignored below, since we only read declared options. See `strict` docs.
47
49
  strict,
@@ -56,9 +58,13 @@ export function _parseArgs(options, opt = {}) {
56
58
  process.stdout.write(buildHelp(options, usage));
57
59
  process.exit(0);
58
60
  }
61
+ assertNoSpaceValuedBoolean(parsed.tokens, options);
59
62
  const result = { _: parsed.positionals };
60
63
  for (const [name, def] of Object.entries(options)) {
61
64
  let v = values[name];
65
+ // `transform` is only applied to arg-sourced values; a `default` is taken
66
+ // to be in final form (see CliOption.transform docs).
67
+ const fromArgs = v !== undefined;
62
68
  if (v === undefined) {
63
69
  if (def.default !== undefined) {
64
70
  v = def.default;
@@ -74,9 +80,32 @@ export function _parseArgs(options, opt = {}) {
74
80
  if (def.array) {
75
81
  v = Array.isArray(v) ? v : [v];
76
82
  }
77
- if (def.type === 'number') {
83
+ // A non-boolean option passed as a bare flag (`--out` with no value) comes back
84
+ // from node's parseArgs (in non-strict mode) as boolean `true`. Reject it: the
85
+ // user almost certainly forgot the value, and silently coercing `true` (to `1`
86
+ // for numbers, `"true"` for strings, or crashing a `transform`) would hide the
87
+ // mistake. Only arg-sourced values are checked; a boolean `default` is left
88
+ // alone. `def.type === 'boolean'` legitimately produces booleans, so skip it.
89
+ if (fromArgs && def.type !== 'boolean') {
90
+ const bareFlag = Array.isArray(v)
91
+ ? v.some(x => typeof x === 'boolean')
92
+ : typeof v === 'boolean';
93
+ if (bareFlag) {
94
+ throw new ParseArgsError(`Missing value for --${name}`);
95
+ }
96
+ }
97
+ // `transform` owns conversion, so built-in number coercion is skipped for it
98
+ if (def.type === 'number' && !def.transform) {
78
99
  v = Array.isArray(v) ? v.map(x => toNumber(x, name)) : toNumber(v, name);
79
100
  }
101
+ // node's parseArgs (in non-strict mode) captures the inline value of
102
+ // `--flag=value` on a boolean option as a string ("false"/"true"), rather than
103
+ // rejecting it as strict mode does. Coerce known tokens so `--flag=false` means
104
+ // boolean false, not a truthy "false" string. Real booleans produced by
105
+ // `--flag` / `--no-flag` (and boolean defaults) pass through untouched.
106
+ if (def.type === 'boolean') {
107
+ v = Array.isArray(v) ? v.map(x => toBoolean(x, name)) : toBoolean(v, name);
108
+ }
80
109
  if (def.choices) {
81
110
  const list = Array.isArray(v) ? v : [v];
82
111
  for (const x of list) {
@@ -85,6 +114,10 @@ export function _parseArgs(options, opt = {}) {
85
114
  }
86
115
  }
87
116
  }
117
+ if (def.transform && fromArgs) {
118
+ const { transform } = def;
119
+ v = Array.isArray(v) ? v.map(x => transform(x)) : transform(v);
120
+ }
88
121
  result[name] = v;
89
122
  }
90
123
  if (parsed.positionals.length < minPositionals) {
@@ -92,6 +125,28 @@ export function _parseArgs(options, opt = {}) {
92
125
  }
93
126
  return result;
94
127
  }
128
+ /**
129
+ * Reject the ambiguous `--boolFlag value` space form. node never consumes the
130
+ * next token as a boolean's value (getopt convention), so `--arg false` would
131
+ * silently yield `arg: true` and leak "false" into positionals. Unlike the
132
+ * `=value` form (handled by toBoolean) we can't recover the intended value here,
133
+ * so fail loudly. Only `true`/`false` tokens are treated as ambiguous; any other
134
+ * positional (e.g. a filename) is left as a genuine positional.
135
+ */
136
+ function assertNoSpaceValuedBoolean(tokens, options) {
137
+ for (let i = 0; i < tokens.length - 1; i++) {
138
+ const tok = tokens[i];
139
+ // `tok.value === undefined` => bare flag (no inline `=value`); applies to
140
+ // declared boolean options only (unknown options are ignored, see `strict`).
141
+ if (tok.kind !== 'option' || tok.value !== undefined || options[tok.name]?.type !== 'boolean') {
142
+ continue;
143
+ }
144
+ const next = tokens[i + 1];
145
+ if (next.kind === 'positional' && (next.value === 'true' || next.value === 'false')) {
146
+ throw new ParseArgsError(`Boolean option --${tok.name} does not take a space-separated value ("${next.value}"); use --${tok.name}=${next.value} or --${next.value === 'false' ? `no-${tok.name}` : tok.name}`);
147
+ }
148
+ }
149
+ }
95
150
  function toNumber(raw, name) {
96
151
  const n = Number(raw);
97
152
  if (Number.isNaN(n)) {
@@ -99,6 +154,15 @@ function toNumber(raw, name) {
99
154
  }
100
155
  return n;
101
156
  }
157
+ function toBoolean(raw, name) {
158
+ if (typeof raw === 'boolean')
159
+ return raw; // real boolean from --flag / --no-flag / default
160
+ if (raw === 'true')
161
+ return true;
162
+ if (raw === 'false')
163
+ return false;
164
+ throw new ParseArgsError(`Invalid boolean for --${name}: "${raw}"`);
165
+ }
102
166
  function buildHelp(options, usage) {
103
167
  const lines = [];
104
168
  if (usage)
@@ -106,7 +170,8 @@ function buildHelp(options, usage) {
106
170
  lines.push('Options:');
107
171
  for (const [name, def] of Object.entries(options)) {
108
172
  const flag = def.short ? `-${def.short}, --${name}` : `--${name}`;
109
- const meta = [`[${def.array ? `${def.type}[]` : def.type}]`];
173
+ const type = def.type ?? 'string';
174
+ const meta = [`[${def.array ? `${type}[]` : type}]`];
110
175
  if (def.demandOption)
111
176
  meta.push('[required]');
112
177
  if (def.default !== undefined)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.114.0",
4
+ "version": "15.115.1",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@standard-schema/spec": "^1",
@@ -15,8 +15,8 @@
15
15
  "yaml": "^2"
16
16
  },
17
17
  "devDependencies": {
18
- "typescript": "rc",
19
- "@naturalcycles/dev-lib": "20.50.0"
18
+ "typescript": "^7",
19
+ "@naturalcycles/dev-lib": "0.0.0"
20
20
  },
21
21
  "exports": {
22
22
  ".": "./dist/index.js",
@@ -10,7 +10,10 @@ export type CliOptionType = 'string' | 'number' | 'boolean'
10
10
  * yargs `.options()` that we actually use.
11
11
  */
12
12
  export interface CliOption {
13
- type: CliOptionType
13
+ /**
14
+ * Value type. Defaults to `'string'` when omitted.
15
+ */
16
+ type?: CliOptionType
14
17
  /**
15
18
  * Accept the flag multiple times, collecting values into an array.
16
19
  * `--id a --id b` => `['a', 'b']`. Replaces yargs `type: 'array'`.
@@ -39,23 +42,36 @@ export interface CliOption {
39
42
  * Single-character alias, e.g. `short: 'v'` enables `-v`.
40
43
  */
41
44
  short?: string
45
+ /**
46
+ * Transform the raw string value into the final value. The output type is
47
+ * inferred from the function's return type, so this is the way to produce
48
+ * branded types (e.g. `IsoDate`) or richer values (parsed numbers, JSON, ...).
49
+ *
50
+ * Applied per-element for `array` options. Receives the raw string token, so
51
+ * it fully owns conversion - built-in `number` coercion is not applied on top.
52
+ * NOT applied to `default` values: a `default` is taken to be in final form.
53
+ *
54
+ * @example transform: s => s as IsoDate
55
+ */
56
+ transform?: (value: string) => unknown
42
57
  }
43
58
 
44
59
  export type CliOptions = Record<string, CliOption>
45
60
 
46
61
  /**
47
- * Element value type of a single option, derived from `choices` (if present)
48
- * or `type`.
62
+ * Element value type of a single option, derived from `transform` (if present,
63
+ * its return type wins), then `choices`, then `type`. Defaults to `string` when
64
+ * none narrow it (so `type` is optional).
49
65
  */
50
- type ElemType<O extends CliOption> = O extends { choices: readonly (infer C)[] }
51
- ? C
52
- : O['type'] extends 'string'
53
- ? string
54
- : O['type'] extends 'number'
66
+ type ElemType<O extends CliOption> = O extends { transform: (...args: any[]) => infer R }
67
+ ? R
68
+ : O extends { choices: readonly (infer C)[] }
69
+ ? C
70
+ : O extends { type: 'number' }
55
71
  ? number
56
- : O['type'] extends 'boolean'
72
+ : O extends { type: 'boolean' }
57
73
  ? boolean
58
- : never
74
+ : string
59
75
 
60
76
  /**
61
77
  * Full value type of a single option, applying `array` on top of the element
@@ -132,11 +148,12 @@ export class ParseArgsError extends Error {
132
148
  * top of node's `util.parseArgs`.
133
149
  *
134
150
  * @example
135
- * const { dir, limit } = _parseArgs({
136
- * dir: { type: 'string', desc: 'Output directory' },
151
+ * const { dir, limit, date } = _parseArgs({
152
+ * dir: { desc: 'Output directory' }, // type defaults to 'string'
137
153
  * limit: { type: 'number', default: 100 },
154
+ * date: { transform: s => s as IsoDate }, // inferred + converted via transform
138
155
  * })
139
- * // dir?: string limit: number _: string[]
156
+ * // dir?: string limit: number date?: IsoDate _: string[]
140
157
  */
141
158
  export function _parseArgs<const O extends CliOptions>(
142
159
  options: O,
@@ -169,6 +186,7 @@ export function _parseArgs<const O extends CliOptions>(
169
186
  options: nodeOptions,
170
187
  allowPositionals: true,
171
188
  allowNegative: true, // native `--no-flag` support for booleans
189
+ tokens: true, // needed to detect the ambiguous `--boolFlag value` space form
172
190
  // In non-strict mode, unknown options are collected into `values` but
173
191
  // ignored below, since we only read declared options. See `strict` docs.
174
192
  strict,
@@ -185,10 +203,15 @@ export function _parseArgs<const O extends CliOptions>(
185
203
  process.exit(0)
186
204
  }
187
205
 
206
+ assertNoSpaceValuedBoolean(parsed.tokens, options)
207
+
188
208
  const result: Record<string, unknown> = { _: parsed.positionals }
189
209
 
190
210
  for (const [name, def] of Object.entries(options)) {
191
211
  let v = values[name]
212
+ // `transform` is only applied to arg-sourced values; a `default` is taken
213
+ // to be in final form (see CliOption.transform docs).
214
+ const fromArgs = v !== undefined
192
215
 
193
216
  if (v === undefined) {
194
217
  if (def.default !== undefined) {
@@ -205,10 +228,35 @@ export function _parseArgs<const O extends CliOptions>(
205
228
  v = Array.isArray(v) ? v : [v]
206
229
  }
207
230
 
208
- if (def.type === 'number') {
231
+ // A non-boolean option passed as a bare flag (`--out` with no value) comes back
232
+ // from node's parseArgs (in non-strict mode) as boolean `true`. Reject it: the
233
+ // user almost certainly forgot the value, and silently coercing `true` (to `1`
234
+ // for numbers, `"true"` for strings, or crashing a `transform`) would hide the
235
+ // mistake. Only arg-sourced values are checked; a boolean `default` is left
236
+ // alone. `def.type === 'boolean'` legitimately produces booleans, so skip it.
237
+ if (fromArgs && def.type !== 'boolean') {
238
+ const bareFlag = Array.isArray(v)
239
+ ? v.some(x => typeof x === 'boolean')
240
+ : typeof v === 'boolean'
241
+ if (bareFlag) {
242
+ throw new ParseArgsError(`Missing value for --${name}`)
243
+ }
244
+ }
245
+
246
+ // `transform` owns conversion, so built-in number coercion is skipped for it
247
+ if (def.type === 'number' && !def.transform) {
209
248
  v = Array.isArray(v) ? v.map(x => toNumber(x, name)) : toNumber(v, name)
210
249
  }
211
250
 
251
+ // node's parseArgs (in non-strict mode) captures the inline value of
252
+ // `--flag=value` on a boolean option as a string ("false"/"true"), rather than
253
+ // rejecting it as strict mode does. Coerce known tokens so `--flag=false` means
254
+ // boolean false, not a truthy "false" string. Real booleans produced by
255
+ // `--flag` / `--no-flag` (and boolean defaults) pass through untouched.
256
+ if (def.type === 'boolean') {
257
+ v = Array.isArray(v) ? v.map(x => toBoolean(x, name)) : toBoolean(v, name)
258
+ }
259
+
212
260
  if (def.choices) {
213
261
  const list = Array.isArray(v) ? v : [v]
214
262
  for (const x of list) {
@@ -220,6 +268,11 @@ export function _parseArgs<const O extends CliOptions>(
220
268
  }
221
269
  }
222
270
 
271
+ if (def.transform && fromArgs) {
272
+ const { transform } = def
273
+ v = Array.isArray(v) ? v.map(x => transform(x as string)) : transform(v as string)
274
+ }
275
+
223
276
  result[name] = v
224
277
  }
225
278
 
@@ -232,6 +285,34 @@ export function _parseArgs<const O extends CliOptions>(
232
285
  return result as InferCliArgs<O>
233
286
  }
234
287
 
288
+ /**
289
+ * Reject the ambiguous `--boolFlag value` space form. node never consumes the
290
+ * next token as a boolean's value (getopt convention), so `--arg false` would
291
+ * silently yield `arg: true` and leak "false" into positionals. Unlike the
292
+ * `=value` form (handled by toBoolean) we can't recover the intended value here,
293
+ * so fail loudly. Only `true`/`false` tokens are treated as ambiguous; any other
294
+ * positional (e.g. a filename) is left as a genuine positional.
295
+ */
296
+ function assertNoSpaceValuedBoolean(
297
+ tokens: NonNullable<ReturnType<typeof parseArgs>['tokens']>,
298
+ options: CliOptions,
299
+ ): void {
300
+ for (let i = 0; i < tokens.length - 1; i++) {
301
+ const tok = tokens[i]!
302
+ // `tok.value === undefined` => bare flag (no inline `=value`); applies to
303
+ // declared boolean options only (unknown options are ignored, see `strict`).
304
+ if (tok.kind !== 'option' || tok.value !== undefined || options[tok.name]?.type !== 'boolean') {
305
+ continue
306
+ }
307
+ const next = tokens[i + 1]!
308
+ if (next.kind === 'positional' && (next.value === 'true' || next.value === 'false')) {
309
+ throw new ParseArgsError(
310
+ `Boolean option --${tok.name} does not take a space-separated value ("${next.value}"); use --${tok.name}=${next.value} or --${next.value === 'false' ? `no-${tok.name}` : tok.name}`,
311
+ )
312
+ }
313
+ }
314
+ }
315
+
235
316
  function toNumber(raw: unknown, name: string): number {
236
317
  const n = Number(raw)
237
318
  if (Number.isNaN(n)) {
@@ -240,13 +321,21 @@ function toNumber(raw: unknown, name: string): number {
240
321
  return n
241
322
  }
242
323
 
324
+ function toBoolean(raw: unknown, name: string): boolean {
325
+ if (typeof raw === 'boolean') return raw // real boolean from --flag / --no-flag / default
326
+ if (raw === 'true') return true
327
+ if (raw === 'false') return false
328
+ throw new ParseArgsError(`Invalid boolean for --${name}: "${raw}"`)
329
+ }
330
+
243
331
  function buildHelp(options: CliOptions, usage?: string): string {
244
332
  const lines: string[] = []
245
333
  if (usage) lines.push(usage, '')
246
334
  lines.push('Options:')
247
335
  for (const [name, def] of Object.entries(options)) {
248
336
  const flag = def.short ? `-${def.short}, --${name}` : `--${name}`
249
- const meta = [`[${def.array ? `${def.type}[]` : def.type}]`]
337
+ const type = def.type ?? 'string'
338
+ const meta = [`[${def.array ? `${type}[]` : type}]`]
250
339
  if (def.demandOption) meta.push('[required]')
251
340
  if (def.default !== undefined) meta.push(`[default: ${JSON.stringify(def.default)}]`)
252
341
  if (def.choices) meta.push(`[choices: ${def.choices.join(', ')}]`)