UNPKG

minus-h

Version:

Add help generation to APIs created with Node's util.parseArgs function

172 lines (171 loc) 5.76 kB
import { EOL } from 'node:os'; import { LineWrap } from '@cto.af/linewrap'; import { parse } from 'node:path'; import { parseArgs } from 'node:util'; const DEFAULT_ARG_NAME = 'value'; function normalizeOptions(config) { config ??= {}; config.options ??= {}; config.options.help ??= { short: 'h', type: 'boolean', description: 'display help for command', }; config.outputStream ??= process.stderr; config.exit ??= process.exit; return config; } function* generateHelp(config, opts) { const cfg = normalizeOptions(config); const lw = new LineWrap({ width: process.stdout.columns, ...opts, }); const { name } = parse(config.scriptName ?? process.argv[1]); let usg = `Usage: ${name}`; let max = -Infinity; if (cfg.options) { usg += ' [options]'; for (const [long, info] of Object.entries(cfg.options)) { let len = long.length + 2; // -- if (info.short) { len += 3; // ,-s } if (info.type === 'string') { const argName = info.argumentName ?? DEFAULT_ARG_NAME; len += argName.length + 3; } max = Math.max(len, max); } } if (cfg.allowPositionals || (cfg.strict === false)) { const argName = cfg.argumentName ?? 'arguments'; usg += ` [${argName}]`; max = Math.max(argName.length, max); } yield usg; yield ''; if (cfg.description) { yield* lw.lines(cfg.description); yield ''; } max += 4; // 2 on each side const w = opts?.width ?? process.stdout.columns; const indented = new LineWrap({ ...opts, indent: max, indentFirst: false, width: Math.max(max + 2, w), // At least two chars per line }); if (cfg.argumentDescription) { yield 'Arguments:'; const wrapped = indented.lines(cfg.argumentDescription); yield ` ${cfg.argumentName}`.padEnd(max, ' ') + wrapped.next().value; yield* wrapped; yield ''; } if (cfg.options) { yield 'Options:'; const sorted = Object .entries(cfg.options) .sort(([longA, infoA], [longB, infoB]) => { const a = infoA.short ?? longA; const b = infoB.short ?? longB; return a.localeCompare(b); }); for (const [long, info] of sorted) { let param = `--${long}`; if (info.short) { param = `-${info.short},${param}`; } if (info.type === 'string') { const argName = info.argumentName ?? DEFAULT_ARG_NAME; param += ` <${argName}>`; } param = ` ${param}`; let desc = info.description ?? ''; if (info.choices && (info.choices.length > 0)) { if (desc) { desc += ' '; } desc += '(choices: '; desc += info.choices.map(c => JSON.stringify(c)).join(', '); desc += ')'; } if (info.default != null) { if (desc) { desc += ' '; } desc += `Default: ${JSON.stringify(info.default)}`; } if (desc) { const wrapped = indented.lines(desc); yield param.padEnd(max, ' ') + wrapped.next().value; yield* wrapped; } else { yield param; } } } } export function usage(config, options) { const cfg = normalizeOptions(config); for (const line of generateHelp(cfg, options)) { cfg.outputStream?.write(line); cfg.outputStream?.write(options?.newline ?? EOL); } cfg.exit?.(64); } function isCodeError(e) { return (e instanceof Error) && (Object.prototype.hasOwnProperty.call(e, 'code')); } const USAGE_ERRORS = [ 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE', 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL', 'ERR_PARSE_ARGS_UNKNOWN_OPTION', ]; /** * Wrap `util.parseArgs()`, adding a "-h,--help" argument. It takes one or * two arguments. The wrapping width defaults to your terminal width (via * [process.stdout.columns](https://nodejs.org/api/tty.html#writestreamcolumns)). * * @param config An augmented version of the options passed to * `util.parseArgs()`, with descriptions provided for options as well as the * command as a whole. * @param options How to do line wrapping? * @returns The parsed results. */ export function parseArgsWithHelp(config, options) { const cfg = normalizeOptions(config); let results = null; try { results = parseArgs(cfg); } catch (e) { if (isCodeError(e) && USAGE_ERRORS.includes(e.code)) { cfg.outputStream?.write(e.message); cfg.outputStream?.write(EOL); cfg.outputStream?.write(EOL); usage(cfg, options); } throw e; } if (results?.values?.help) { usage(cfg, options); } if (cfg.options) { for (const [long, info] of Object.entries(cfg.options)) { if (info.choices) { const val = results.values[long]; if ((typeof val === 'string') && !info.choices.includes(val)) { cfg.outputStream?.write(`Option '--${long} <${info.argumentName ?? DEFAULT_ARG_NAME}>' argument must be one of ${JSON.stringify(info.choices)}`); cfg.outputStream?.write(EOL); cfg.outputStream?.write(EOL); usage(cfg, options); } } } } return results; }