import yargs, { Argv } from 'yargs'; import * as clack from '@clack/prompts'; import { DummyWriteStream } from './write-stream'; import pino, {LogFn} from 'pino'; import * as util from 'util'; export interface Logger extends pino.BaseLogger {} export interface CommandHandlerArgs { self: any; interactive: boolean; send: (message: number) => void; logger: Logger; args: any; } export type CommandHandler = (args: CommandHandlerArgs) => void | number | Promise | Promise; export interface CommandArgs { aliases?: string[]; command: string; parameters: string[]; describe: string; handler: CommandHandler; interactiveMode?: boolean; } type PromptValueArgs = [Record, ...never[]] | [string, string] | [[string, string], ...never[]] | [[string, string][], ...never[]]; export interface TestModeResult { exitCode?: number; stdout: DummyWriteStream; stderr: DummyWriteStream; } interface InteractiveModeOptions { option: string; alias: string; intro: string; cancelled: string; outro: string; describe: string; } export interface CliOptions { name: string; interactiveMode?: Partial; logger?: Logger | boolean; } export class Cli { private testMode = false; private promptValues = {} as Record; private testModeResult: TestModeResult = { exitCode: undefined as number | undefined, stdout: new DummyWriteStream(), stderr: new DummyWriteStream(), }; private readonly cli: Argv; constructor(private readonly options: CliOptions) { this.cli = yargs() .scriptName(options.name) .help() .fail(false) .strict(false); } private exit(code: number) { if (this.testMode) { this.testModeResult.exitCode = code; return; } process.exit(code); } private async prompt(messages: Record, interactiveModeOptions: InteractiveModeOptions) { const { intro, cancelled, outro, } = interactiveModeOptions; const messagesEntries = Object.entries(messages); if (messagesEntries.length > 0) { clack.intro(intro); const values = await clack.group( Object.fromEntries( messagesEntries.map(([key, value]) => [key, () => clack.text({ message: value })]) ), { onCancel: () => { clack.cancel(cancelled); this.exit(1); } } ); clack.outro(outro); return values; } return {}; } private async generateCommandArgs( context: any, originalCommandArgs: Record, interactive: boolean, interactiveModeOptions: InteractiveModeOptions, ) { if (!interactive) { return originalCommandArgs; } // TODO properly filter attributes const clackGroup = context.optional .filter((optional: { cmd: string[] }) => { const { '$0': _$0, i: _i, interactive: _interactive, test: _test, _: __, ...rest } = originalCommandArgs; return !Object.keys(rest).includes(optional.cmd[0]); }) .reduce( (group: Record, optional: { cmd: string[], describe: string }) => ({ ...group, [optional.cmd[0]]: optional.describe ?? optional.cmd[0], }), {} as Record, ); const promptedArgs = this.testMode ? this.promptValues : await this.prompt(clackGroup, interactiveModeOptions); return { ...originalCommandArgs, ...promptedArgs, }; } private buildHandler(handlerFn: Function, interactiveModeOptions: InteractiveModeOptions) { const thisCli = this; return async function handler(this: any, commandArgs: Record) { const self = this; const { option, alias } = interactiveModeOptions; const interactiveLong = option in commandArgs ? Boolean(commandArgs.interactive) : false; const interactiveShort = alias in commandArgs ? Boolean(commandArgs.i) : false; const interactive = interactiveLong || interactiveShort; const buildArgs = await thisCli.generateCommandArgs( self, commandArgs, interactive, interactiveModeOptions, ); const stdoutLogFn: LogFn = (...args: unknown[]) => { const stream = thisCli.testMode ? thisCli.testModeResult.stdout : process.stdout; const [arg0, arg1, ...etcArgs] = args; if (typeof arg0 === 'string' || typeof arg0 === 'number') { if (arg1) { if (etcArgs.length > 0) { stream.write(util.format(`${arg0}\n`, arg1, ...etcArgs)); } else { stream.write(util.format(`${arg0}\n`, arg1)); } } else { stream.write(util.format(`${arg0}\n`)); } } else if (typeof arg0 === 'object' && (typeof arg1 === 'string' || typeof arg1 === 'number')) { if (etcArgs.length > 0) { stream.write(util.format(`${arg1}\n`, ...etcArgs)); } else { stream.write(util.format(`${arg1}\n`)); } } }; const stderrLogFn: LogFn = (...args: unknown[]) => { const stream = thisCli.testMode ? thisCli.testModeResult.stderr : process.stderr; const [arg0, arg1, ...etcArgs] = args; if (typeof arg0 === 'string' || typeof arg0 === 'number') { if (arg1) { if (etcArgs.length > 0) { stream.write(util.format(`${arg0}\n`, arg1, ...etcArgs)); } else { stream.write(util.format(`${arg0}\n`, arg1)); } } else { stream.write(util.format(`${arg0}\n`)); } } else if (typeof arg0 === 'object' && (typeof arg1 === 'string' || typeof arg1 === 'number')) { if (etcArgs.length > 0) { stream.write(util.format(`${arg1}\n`, ...etcArgs)); } else { stream.write(util.format(`${arg1}\n`)); } } }; const loggerFn = thisCli.options.logger; const defaultLogger = { level: 'info', enabled: typeof loggerFn === 'boolean' ? loggerFn : true, debug: stdoutLogFn, info: stdoutLogFn, warn: stdoutLogFn, error: stderrLogFn, fatal: stderrLogFn, trace: stdoutLogFn, silent: () => {}, } as Logger; const loggerBooleanFn = typeof loggerFn === 'boolean' && loggerFn ? defaultLogger : { level: 'silent', debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {}, trace: () => {}, silent: () => {}, } as Logger; const logger = typeof loggerFn === 'undefined' ? defaultLogger : (typeof loggerFn === 'function' ? loggerFn : loggerBooleanFn); let exited = false; const returnCode = await handlerFn({ self, interactive, logger, send: (code: number) => { exited = true; thisCli.exit(code); }, args: buildArgs, }); if (!exited) { thisCli.exit(returnCode ? returnCode : 0); } }; } command({ parameters, command, interactiveMode = true, handler, ...args }: CommandArgs): Cli { // TODO type-safe declaration of positional/optional arguments const { option = 'interactive', alias = 'i', describe = 'Interactive mode', intro = 'Please provide the following values:', cancelled = 'Operation cancelled.', outro = 'Thank you!', } = this.options?.interactiveMode ?? {}; const commandArgs = { ...args, command: `${command} ${parameters.join(' ').replace(//g, ']')}`, usage: parameters.map((p) => `${command} ${p}`).join('\n'), handler: this.buildHandler(handler, { option, alias, intro, cancelled, outro, describe, }), } as Record if (interactiveMode) { commandArgs.options = { ...(commandArgs.options ?? {}), [option]: { alias, describe, type: 'boolean', default: false, hidden: true, }, }; } this.cli.command(commandArgs as unknown as Parameters[0]); return this; } async run(args: string[]): Promise { if (args.length > 0) { await this.cli.parse(args); return; } this.cli.showHelp(); } test() { this.testMode = true; this.promptValues = {}; const thisCli = this; return { promptValue(...args: PromptValueArgs) { const [testArg] = args; if (typeof testArg === 'string') { const [key, value] = args as [string, unknown]; thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; } if (Array.isArray(testArg)) { const [key, value] = testArg as [string, unknown]; thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; } if (typeof testArg === 'object' && testArg !== null) { Object.entries(testArg).forEach(([key, value]) => { thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; }); } return this; }, async run(args: string[]): Promise { await thisCli.cli.parse(args); return thisCli.testModeResult; }, }; } }