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