jackspeak 1.4.1 → 2.0.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.
@@ -0,0 +1,627 @@
1
+ import { inspect } from 'node:util';
2
+ import { parseArgs } from './parse-args.js';
3
+ // it's a tiny API, just cast it inline, it's fine
4
+ //@ts-ignore
5
+ import _cliui from 'cliui';
6
+ import { basename } from 'node:path';
7
+ const cliui = (o = {}) => _cliui(o);
8
+ const width = Math.min((process && process.stdout && process.stdout.columns) || 80, 80);
9
+ const toEnvKey = (pref, key) => {
10
+ return [pref, key.replace(/[^a-zA-Z0-9]+/g, ' ')]
11
+ .join(' ')
12
+ .trim()
13
+ .toUpperCase()
14
+ .replace(/ /g, '_');
15
+ };
16
+ const toEnvVal = (value, delim = '\n') => {
17
+ const str = typeof value === 'string'
18
+ ? value
19
+ : typeof value === 'boolean'
20
+ ? value
21
+ ? '1'
22
+ : '0'
23
+ : typeof value === 'number'
24
+ ? String(value)
25
+ : Array.isArray(value)
26
+ ? value
27
+ .map((v) => toEnvVal(v))
28
+ .join(delim)
29
+ : /* c8 ignore start */
30
+ undefined;
31
+ if (typeof str !== 'string') {
32
+ throw new Error(`could not serialize value to environment: ${JSON.stringify(value)}`);
33
+ }
34
+ /* c8 ignore stop */
35
+ return str;
36
+ };
37
+ const fromEnvVal = (env, type, multiple, delim = '\n') => (multiple
38
+ ? env.split(delim).map(v => fromEnvVal(v, type, false))
39
+ : type === 'string'
40
+ ? env
41
+ : type === 'boolean'
42
+ ? env === '1'
43
+ : +env.trim());
44
+ const isConfigType = (t) => typeof t === 'string' &&
45
+ (t === 'string' || t === 'number' || t === 'boolean');
46
+ const undefOrType = (v, t) => v === undefined || typeof v === t;
47
+ const isValidValue = (v, type, multi) => {
48
+ if (multi) {
49
+ if (!Array.isArray(v))
50
+ return false;
51
+ return !v.some((v) => !isValidValue(v, type, false));
52
+ }
53
+ if (Array.isArray(v))
54
+ return false;
55
+ return typeof v === type;
56
+ };
57
+ const isConfigOption = (o, type, multi) => !!o &&
58
+ typeof o === 'object' &&
59
+ isConfigType(o.type) &&
60
+ o.type === type &&
61
+ undefOrType(o.short, 'string') &&
62
+ undefOrType(o.description, 'string') &&
63
+ undefOrType(o.hint, 'string') &&
64
+ undefOrType(o.validate, 'function') &&
65
+ (o.default === undefined || isValidValue(o.default, type, multi)) &&
66
+ !!o.multiple === multi;
67
+ function num(o = {}) {
68
+ const { default: def, validate: val, ...rest } = o;
69
+ if (def !== undefined && !isValidValue(def, 'number', false)) {
70
+ throw new TypeError('invalid default value');
71
+ }
72
+ const validate = val
73
+ ? val
74
+ : undefined;
75
+ return {
76
+ ...rest,
77
+ default: def,
78
+ validate,
79
+ type: 'number',
80
+ multiple: false,
81
+ };
82
+ }
83
+ function numList(o = {}) {
84
+ const { default: def, validate: val, ...rest } = o;
85
+ if (def !== undefined && !isValidValue(def, 'number', true)) {
86
+ throw new TypeError('invalid default value');
87
+ }
88
+ const validate = val
89
+ ? val
90
+ : undefined;
91
+ return {
92
+ ...rest,
93
+ default: def,
94
+ validate,
95
+ type: 'number',
96
+ multiple: true,
97
+ };
98
+ }
99
+ function opt(o = {}) {
100
+ const { default: def, validate: val, ...rest } = o;
101
+ if (def !== undefined && !isValidValue(def, 'string', false)) {
102
+ throw new TypeError('invalid default value');
103
+ }
104
+ const validate = val
105
+ ? val
106
+ : undefined;
107
+ return {
108
+ ...rest,
109
+ default: def,
110
+ validate,
111
+ type: 'string',
112
+ multiple: false,
113
+ };
114
+ }
115
+ function optList(o = {}) {
116
+ const { default: def, validate: val, ...rest } = o;
117
+ if (def !== undefined && !isValidValue(def, 'string', true)) {
118
+ throw new TypeError('invalid default value');
119
+ }
120
+ const validate = val
121
+ ? val
122
+ : undefined;
123
+ return {
124
+ ...rest,
125
+ default: def,
126
+ validate,
127
+ type: 'string',
128
+ multiple: true,
129
+ };
130
+ }
131
+ function flag(o = {}) {
132
+ const { hint, default: def, validate: val, ...rest } = o;
133
+ if (def !== undefined && !isValidValue(def, 'boolean', false)) {
134
+ throw new TypeError('invalid default value');
135
+ }
136
+ const validate = val
137
+ ? val
138
+ : undefined;
139
+ if (hint !== undefined) {
140
+ throw new TypeError('cannot provide hint for flag');
141
+ }
142
+ return {
143
+ ...rest,
144
+ default: def,
145
+ validate,
146
+ type: 'boolean',
147
+ multiple: false,
148
+ };
149
+ }
150
+ function flagList(o = {}) {
151
+ const { hint, default: def, validate: val, ...rest } = o;
152
+ if (def !== undefined && !isValidValue(def, 'boolean', true)) {
153
+ throw new TypeError('invalid default value');
154
+ }
155
+ const validate = val
156
+ ? val
157
+ : undefined;
158
+ if (hint !== undefined) {
159
+ throw new TypeError('cannot provide hint for flag list');
160
+ }
161
+ return {
162
+ ...rest,
163
+ default: def,
164
+ validate,
165
+ type: 'boolean',
166
+ multiple: true,
167
+ };
168
+ }
169
+ const toParseArgsOptionsConfig = (options) => {
170
+ const c = {};
171
+ for (const longOption in options) {
172
+ const config = options[longOption];
173
+ if (isConfigOption(config, 'number', true)) {
174
+ c[longOption] = {
175
+ type: 'string',
176
+ multiple: true,
177
+ default: config.default?.map(c => String(c)),
178
+ };
179
+ }
180
+ else if (isConfigOption(config, 'number', false)) {
181
+ c[longOption] = {
182
+ type: 'string',
183
+ multiple: false,
184
+ default: config.default === undefined
185
+ ? undefined
186
+ : String(config.default),
187
+ };
188
+ }
189
+ else {
190
+ const conf = config;
191
+ c[longOption] = {
192
+ type: conf.type,
193
+ multiple: conf.multiple,
194
+ default: conf.default,
195
+ };
196
+ }
197
+ if (typeof config.short === 'string') {
198
+ c[longOption].short = config.short;
199
+ }
200
+ if (config.type === 'boolean' &&
201
+ !longOption.startsWith('no-') &&
202
+ !options[`no-${longOption}`]) {
203
+ c[`no-${longOption}`] = {
204
+ type: 'boolean',
205
+ multiple: config.multiple,
206
+ };
207
+ }
208
+ }
209
+ return c;
210
+ };
211
+ export class Jack {
212
+ #configSet;
213
+ #shorts;
214
+ #options;
215
+ #fields = [];
216
+ #env;
217
+ #envPrefix;
218
+ #allowPositionals;
219
+ #usage;
220
+ constructor(options = {}) {
221
+ this.#options = options;
222
+ this.#allowPositionals = options.allowPositionals !== false;
223
+ this.#env =
224
+ this.#options.env === undefined ? process.env : this.#options.env;
225
+ this.#envPrefix = options.envPrefix;
226
+ // We need to fib a little, because it's always the same object, but it
227
+ // starts out as having an empty config set. Then each method that adds
228
+ // fields returns `new Jack<C & { ...newConfigs }>()`
229
+ this.#configSet = Object.create(null);
230
+ this.#shorts = Object.create(null);
231
+ }
232
+ parse(args = process.argv) {
233
+ if (args === process.argv) {
234
+ args = args.slice(process._eval !== undefined ? 1 : 2);
235
+ }
236
+ if (this.#envPrefix) {
237
+ for (const [field, my] of Object.entries(this.#configSet)) {
238
+ const ek = toEnvKey(this.#envPrefix, field);
239
+ const env = this.#env[ek];
240
+ if (env !== undefined) {
241
+ my.default = fromEnvVal(env, my.type, !!my.multiple, my.delim);
242
+ }
243
+ }
244
+ }
245
+ const options = toParseArgsOptionsConfig(this.#configSet);
246
+ const result = parseArgs({
247
+ args,
248
+ options,
249
+ // always strict, but using our own logic
250
+ strict: false,
251
+ allowPositionals: this.#allowPositionals,
252
+ tokens: true,
253
+ });
254
+ const p = {
255
+ values: {},
256
+ positionals: [],
257
+ };
258
+ for (const token of result.tokens) {
259
+ if (token.kind === 'positional') {
260
+ p.positionals.push(token.value);
261
+ if (this.#options.stopAtPositional) {
262
+ p.positionals.push(...args.slice(token.index + 1));
263
+ return p;
264
+ }
265
+ }
266
+ else if (token.kind === 'option') {
267
+ let value = undefined;
268
+ if (token.name.startsWith('no-')) {
269
+ const my = this.#configSet[token.name];
270
+ const pname = token.name.substring('no-'.length);
271
+ const pos = this.#configSet[pname];
272
+ if (pos &&
273
+ pos.type === 'boolean' &&
274
+ (!my ||
275
+ (my.type === 'boolean' && !!my.multiple === !!pos.multiple))) {
276
+ value = false;
277
+ token.name = pname;
278
+ }
279
+ }
280
+ const my = this.#configSet[token.name];
281
+ if (!my) {
282
+ throw new Error(`Unknown option '${token.rawName}'. ` +
283
+ `To specify a positional argument starting with a '-', ` +
284
+ `place it at the end of the command after '--', as in ` +
285
+ `'-- ${token.rawName}'`);
286
+ }
287
+ if (value === undefined) {
288
+ if (token.value === undefined) {
289
+ if (my.type !== 'boolean') {
290
+ throw new Error(`No value provided for ${token.rawName}, expected ${my.type}`);
291
+ }
292
+ value = true;
293
+ }
294
+ else {
295
+ if (my.type === 'boolean') {
296
+ throw new Error(`Flag ${token.rawName} does not take a value, received '${token.value}'`);
297
+ }
298
+ if (my.type === 'string') {
299
+ value = token.value;
300
+ }
301
+ else {
302
+ value = +token.value;
303
+ if (value !== value) {
304
+ throw new Error(`Invalid value '${token.value}' provided for ` +
305
+ `'${token.rawName}' option, expected number`);
306
+ }
307
+ }
308
+ }
309
+ }
310
+ if (my.multiple) {
311
+ const pv = p.values;
312
+ pv[token.name] = pv[token.name] ?? [];
313
+ pv[token.name].push(value);
314
+ }
315
+ else {
316
+ const pv = p.values;
317
+ pv[token.name] = value;
318
+ }
319
+ }
320
+ }
321
+ for (const [field, c] of Object.entries(this.#configSet)) {
322
+ if (c.default !== undefined && !(field in p.values)) {
323
+ //@ts-ignore
324
+ p.values[field] = c.default;
325
+ }
326
+ }
327
+ for (const [field, value] of Object.entries(p.values)) {
328
+ const valid = this.#configSet[field].validate;
329
+ if (valid && !valid(value)) {
330
+ throw new Error(`Invalid value provided for --${field}: ${JSON.stringify(value)}`);
331
+ }
332
+ }
333
+ this.#writeEnv(p);
334
+ return p;
335
+ }
336
+ validate(o) {
337
+ if (!o || typeof o !== 'object') {
338
+ throw new Error('Invalid config: not an object');
339
+ }
340
+ for (const field in o) {
341
+ const config = this.#configSet[field];
342
+ if (!config) {
343
+ throw new Error(`Unknown config option: ${field}`);
344
+ }
345
+ if (!isValidValue(o[field], config.type, !!config.multiple)) {
346
+ throw new Error(`Invalid type for ${field} option, expected` +
347
+ `${config.type}${config.multiple ? '[]' : ''}`);
348
+ }
349
+ if (config.validate && !config.validate(o[field])) {
350
+ throw new Error(`Invalid config value for ${field}: ${o[field]}`);
351
+ }
352
+ }
353
+ }
354
+ #writeEnv(p) {
355
+ if (!this.#env || !this.#envPrefix)
356
+ return;
357
+ for (const [field, value] of Object.entries(p.values)) {
358
+ const my = this.#configSet[field];
359
+ this.#env[toEnvKey(this.#envPrefix, field)] = toEnvVal(value, my.delim);
360
+ }
361
+ }
362
+ heading(text) {
363
+ this.#fields.push({ type: 'heading', text });
364
+ return this;
365
+ }
366
+ /**
367
+ * Add a long-form description to the usage output at this position.
368
+ */
369
+ description(text) {
370
+ this.#fields.push({ type: 'description', text });
371
+ return this;
372
+ }
373
+ /**
374
+ * Add one or more number fields.
375
+ */
376
+ num(fields) {
377
+ return this.#addFields(fields, num);
378
+ }
379
+ /**
380
+ * Add one or more multiple number fields.
381
+ */
382
+ numList(fields) {
383
+ return this.#addFields(fields, numList);
384
+ }
385
+ /**
386
+ * Add one or more string option fields.
387
+ */
388
+ opt(fields) {
389
+ return this.#addFields(fields, opt);
390
+ }
391
+ /**
392
+ * Add one or more multiple string option fields.
393
+ */
394
+ optList(fields) {
395
+ return this.#addFields(fields, optList);
396
+ }
397
+ /**
398
+ * Add one or more flag fields.
399
+ */
400
+ flag(fields) {
401
+ return this.#addFields(fields, flag);
402
+ }
403
+ /**
404
+ * Add one or more multiple flag fields.
405
+ */
406
+ flagList(fields) {
407
+ return this.#addFields(fields, flagList);
408
+ }
409
+ /**
410
+ * Generic field definition method. Similar to flag/flagList/number/etc,
411
+ * but you must specify the `type` (and optionally `multiple` and `delim`)
412
+ * fields on each one, or Jack won't know how to define them.
413
+ */
414
+ addFields(fields) {
415
+ const next = this;
416
+ for (const [name, field] of Object.entries(fields)) {
417
+ this.#validateName(name, field);
418
+ next.#fields.push({
419
+ type: 'config',
420
+ name,
421
+ value: field,
422
+ });
423
+ }
424
+ Object.assign(next.#configSet, fields);
425
+ return next;
426
+ }
427
+ #addFields(fields, fn) {
428
+ const next = this;
429
+ Object.assign(next.#configSet, Object.fromEntries(Object.entries(fields).map(([name, field]) => {
430
+ this.#validateName(name, field);
431
+ const option = fn(field);
432
+ next.#fields.push({
433
+ type: 'config',
434
+ name,
435
+ value: option,
436
+ });
437
+ return [name, option];
438
+ })));
439
+ return next;
440
+ }
441
+ #validateName(name, field) {
442
+ if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(name)) {
443
+ throw new TypeError(`Invalid option name: ${name}, ` +
444
+ `must be '-' delimited ASCII alphanumeric`);
445
+ }
446
+ if (this.#configSet[name]) {
447
+ throw new TypeError(`Cannot redefine option ${field}`);
448
+ }
449
+ if (this.#shorts[name]) {
450
+ throw new TypeError(`Cannot redefine option ${name}, already ` +
451
+ `in use for ${this.#shorts[name]}`);
452
+ }
453
+ if (field.short) {
454
+ if (!/^[a-zA-Z0-9]$/.test(field.short)) {
455
+ throw new TypeError(`Invalid ${name} short option: ${field.short}, ` +
456
+ 'must be 1 ASCII alphanumeric character');
457
+ }
458
+ if (this.#shorts[field.short]) {
459
+ throw new TypeError(`Invalid ${name} short option: ${field.short}, ` +
460
+ `already in use for ${this.#shorts[field.short]}`);
461
+ }
462
+ this.#shorts[field.short] = name;
463
+ this.#shorts[name] = name;
464
+ }
465
+ }
466
+ usage() {
467
+ if (this.#usage)
468
+ return this.#usage;
469
+ const ui = cliui({ width });
470
+ let start = this.#fields[0]?.type === 'heading' ? 1 : 0;
471
+ if (this.#fields[0]?.type === 'heading') {
472
+ ui.div({ text: normalize(this.#fields[0].text) });
473
+ }
474
+ ui.div({ text: 'Usage:' });
475
+ if (this.#options.usage) {
476
+ ui.div({
477
+ text: this.#options.usage,
478
+ padding: [0, 0, 0, 2],
479
+ });
480
+ }
481
+ else {
482
+ const cmd = basename(process.argv[1]);
483
+ const shortFlags = [];
484
+ const shorts = [];
485
+ const flags = [];
486
+ const opts = [];
487
+ for (const [field, config] of Object.entries(this.#configSet)) {
488
+ if (config.short) {
489
+ if (config.type === 'boolean')
490
+ shortFlags.push(config.short);
491
+ else
492
+ shorts.push([config.short, config.hint || field]);
493
+ }
494
+ else {
495
+ if (config.type === 'boolean')
496
+ flags.push(field);
497
+ else
498
+ opts.push([field, config.hint || field]);
499
+ }
500
+ }
501
+ const sf = shortFlags.length ? ' -' + shortFlags.join('') : '';
502
+ const so = shorts.map(([k, v]) => ` --${k}=<${v}>`).join('');
503
+ const lf = flags.map(k => ` --${k}`).join('');
504
+ const lo = opts.map(([k, v]) => ` --${k}=<${v}>`).join('');
505
+ const usage = `${cmd}${sf}${so}${lf}${lo}`.trim();
506
+ ui.div({
507
+ text: usage,
508
+ padding: [0, 0, 0, 2],
509
+ });
510
+ }
511
+ ui.div({ text: '' });
512
+ const maybeDesc = this.#fields[start];
513
+ if (maybeDesc?.type === 'description') {
514
+ start++;
515
+ ui.div({
516
+ text: normalize(maybeDesc.text),
517
+ });
518
+ ui.div({ text: '' });
519
+ }
520
+ // turn each config type into a row, and figure out the width of the
521
+ // left hand indentation for the option descriptions.
522
+ let maxMax = Math.max(12, Math.min(26, Math.floor(width / 3)));
523
+ let maxWidth = 8;
524
+ let prev = undefined;
525
+ const rows = [];
526
+ for (const field of this.#fields.slice(start)) {
527
+ if (field.type !== 'config') {
528
+ if (prev)
529
+ prev.skipLine = true;
530
+ prev = undefined;
531
+ field.text = normalize(field.text);
532
+ rows.push(field);
533
+ continue;
534
+ }
535
+ const { value } = field;
536
+ const desc = value.description || '';
537
+ const mult = value.multiple ? 'Can be set multiple times' : '';
538
+ const dmDelim = mult && (desc.includes('\n') ? '\n\n' : '\n');
539
+ const text = normalize(desc + dmDelim + mult);
540
+ const hint = value.hint ||
541
+ (value.type === 'number'
542
+ ? 'n'
543
+ : value.type === 'string'
544
+ ? field.name
545
+ : undefined);
546
+ const short = !value.short
547
+ ? ''
548
+ : value.type === 'boolean'
549
+ ? `-${value.short} `
550
+ : `-${value.short}<${hint}> `;
551
+ const left = value.type === 'boolean'
552
+ ? `${short}--${field.name}`
553
+ : `${short}--${field.name}=<${hint}>`;
554
+ const row = { text, left };
555
+ if (text.length > width - maxMax) {
556
+ row.skipLine = true;
557
+ }
558
+ if (prev && left.length > maxMax)
559
+ prev.skipLine = true;
560
+ prev = row;
561
+ const len = left.length + 4;
562
+ if (len > maxWidth && len < maxMax) {
563
+ maxWidth = len;
564
+ }
565
+ rows.push(row);
566
+ }
567
+ for (const row of rows) {
568
+ if (row.left) {
569
+ // If the row is too long, don't wrap it
570
+ // Bump the right-hand side down a line to make room
571
+ if (row.left.length > maxWidth - 2) {
572
+ ui.div({ text: row.left, padding: [0, 0, 0, 2] });
573
+ ui.div({ text: row.text, padding: [0, 0, 0, maxWidth] });
574
+ }
575
+ else {
576
+ ui.div({
577
+ text: row.left,
578
+ padding: [0, 1, 0, 2],
579
+ width: maxWidth,
580
+ }, { text: row.text });
581
+ }
582
+ if (row.skipLine) {
583
+ ui.div({ text: '' });
584
+ }
585
+ }
586
+ else {
587
+ if (row.type === 'heading') {
588
+ ui.div(row);
589
+ }
590
+ else {
591
+ ui.div({ ...row, padding: [1, 0, 1, 2] });
592
+ }
593
+ ui.div();
594
+ }
595
+ }
596
+ return (this.#usage = ui.toString());
597
+ }
598
+ toJSON() {
599
+ return Object.fromEntries(Object.entries(this.#configSet).map(([field, def]) => [
600
+ field,
601
+ {
602
+ type: def.type,
603
+ ...(def.multiple ? { multiple: true } : {}),
604
+ ...(def.delim ? { delim: def.delim } : {}),
605
+ ...(def.short ? { short: def.short } : {}),
606
+ ...(def.description ? { description: def.description } : {}),
607
+ ...(def.validate ? { validate: def.validate } : {}),
608
+ ...(def.default !== undefined ? { default: def.default } : {}),
609
+ },
610
+ ]));
611
+ }
612
+ [inspect.custom](_, options) {
613
+ return `Jack ${inspect(this.toJSON(), options)}`;
614
+ }
615
+ }
616
+ // Unwrap and un-indent, so we can wrap description
617
+ // strings however makes them look nice in the code.
618
+ const normalize = (s) => s
619
+ // remove single line breaks
620
+ .replace(/([^\n])\n[ \t]*([^\n])/g, '$1 $2')
621
+ // normalize mid-line whitespace
622
+ .replace(/([^\n])[ \t]+([^\n])/g, '$1 $2')
623
+ // two line breaks are enough
624
+ .replace(/\n{3,}/g, '\n\n')
625
+ .trim();
626
+ export const jack = (options = {}) => new Jack(options);
627
+ //# sourceMappingURL=index.js.map