import commandLineArgs, { OptionDefinition, ParseOptions } from 'command-line-args' import commandLineUsage, { Section, OptionDefinition as UsageOptionDefinition } from 'command-line-usage' import { isTruthy } from '@spicy-hooks/utils' import { bold } from 'chalk' interface ExtendedUsageOptionDefinition extends UsageOptionDefinition { required?: boolean } export interface BaseTypedOptionDefinition extends Omit { description?: string typeLabel?: string required?: boolean } export interface TypedOptionDefinitionMultiple extends BaseTypedOptionDefinition { multiple: true type: (input: string) => T defaultValue?: T[] } export interface TypedOptionDefinitionSingle extends BaseTypedOptionDefinition { multiple?: false type: (input: string) => T defaultValue?: T } export type TypedOptionDefinitions = { [K in keyof T]: T[K] extends any[] ? TypedOptionDefinitionMultiple : TypedOptionDefinitionSingle } export type WithUnknown = T & { _unknown?: string[] } export function typedCommandLineArgs (typedDefinition: TypedOptionDefinitions, parseOptions?: ParseOptions): WithUnknown { const optionDefinitions: OptionDefinition[] = Object.entries(typedDefinition as TypedOptionDefinitions).map(([name, definition]) => ({ name, ...definition })) return commandLineArgs(optionDefinitions, parseOptions) as WithUnknown } export function isRequired (option: BaseTypedOptionDefinition) { return option.required ?? (option.defaultValue === undefined && option.type !== Boolean) } function formatSynopsisOption (option: ExtendedUsageOptionDefinition) { const label = `{bold --${option.name}}` const optionalLabel = option.defaultOption === true ? `[${label}]` : label const type = option.typeLabel != null ? option.typeLabel : `{underline ${option.type.name.toLowerCase()}}` const multiType = option.multiple === true || option.lazyMultiple === true ? `${type} ...` : type const labelWithType = option.type === Boolean ? optionalLabel : `${optionalLabel} ${multiType}` return isRequired(option) ? labelWithType : `[${labelWithType}]` } export type Synopsis = Array export interface UsageGuideOptions { command: string typedDefinition: TypedOptionDefinitions precedingSections?: Section[] followingSections?: Section[] synopses?: Array> } export function generateUsageGuide ( { command, typedDefinition, precedingSections = [], followingSections = [], synopses = [Object.keys(typedDefinition) as Array] }: UsageGuideOptions ) { const optionList: UsageOptionDefinition[] = Object.entries(typedDefinition as TypedOptionDefinitions).map(([name, definition]) => ({ name, ...definition, typeLabel: definition.typeLabel })) const formattedSynopses = synopses.map(synopsis => synopsis.map(key => formatSynopsisOption({ name: key as string, ...typedDefinition[key] }))) const concatenatedSynopses = formattedSynopses.map(synopsis => `${command} ${synopsis.join(' ')}`) return commandLineUsage([ ...precedingSections, { header: 'Synopsis', content: concatenatedSynopses }, { header: 'Options', optionList }, ...followingSections ].filter(isTruthy)) } export function validateOptions (definitions: TypedOptionDefinitions, options: T): boolean { const missingOptionNames = Object.entries(definitions as TypedOptionDefinitions) .filter(([name, definition]) => isRequired(definition) && options[name as keyof T] === undefined ) .map(([name]) => name) if (missingOptionNames.length > 0) { console.error(`Missing required option${missingOptionNames.length > 1 ? 's' : ''}: ${missingOptionNames.map(name => bold(`--${name}`)).join(', ')}`) return false } return true }