From b074fcf2320cee0cd46ad53792686552c27a9f15 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Fri, 14 Jul 2023 21:24:01 +0800 Subject: [PATCH] Fix command help Display help when supplied with no arguments. --- packages/cli/src/cli.ts | 305 +++++++++++------- packages/cli/src/commands.ts | 22 +- packages/cli/src/common.ts | 12 +- packages/cli/src/index.ts | 4 +- .../cli/src/modules/adder/adder.controller.ts | 34 +- packages/cli/test/index.test.ts | 4 +- 6 files changed, 243 insertions(+), 138 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index c35e2f3..9641930 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,10 +1,10 @@ -import yargs from 'yargs'; +import yargs, {Argv} from 'yargs'; import * as clack from '@clack/prompts'; -import {CommandHandler} from './common'; import * as tty from 'tty'; +import { CommandHandler } from './common'; export interface CommandArgs { - aliases: string[]; + aliases?: string[]; command: string; parameters: string[]; describe: string; @@ -41,15 +41,18 @@ export interface TestModeResult { stderr: DummyWriteStream; } +interface InteractiveModeOptions { + option: string; + alias: string; + intro: string; + cancelled: string; + outro: string; + describe: string; +} + export interface CliOptions { - interactiveMode?: { - option?: string; - alias?: string; - intro?: string; - cancelled?: string; - outro?: string; - describe?: string; - } + name: string; + interactiveMode?: Partial; } export class Cli { @@ -63,122 +66,172 @@ export class Cli { stderr: new DummyWriteStream(), }; - private readonly cli = yargs() - .help() - .fail(false) - .strict(false); + private readonly cli: Argv; constructor(private readonly options: CliOptions) { - // noop + 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 generateLogger() { + return { + log: (message: unknown) => { + const stdout = this.testMode ? this.testModeResult.stdout : process.stdout; + stdout.write(`${message?.toString()}\n`); + }, + warn: (message: unknown) => { + const stdout = this.testMode ? this.testModeResult.stdout : process.stdout; + stdout.write(`WARN: ${message?.toString()}\n`); + }, + debug: (message: unknown) => { + const stdout = this.testMode ? this.testModeResult.stdout : process.stdout; + stdout.write(`DEBUG: ${message?.toString()}\n`); + }, + error: (message: unknown) => { + const stderr = this.testMode ? this.testModeResult.stderr : process.stderr; + stderr.write(`${message?.toString()}\n`); + }, + }; } - command({ parameters, command, interactiveMode = true, ...args }: CommandArgs): Cli { + private buildHandler(handlerFn: Function, interactiveModeOptions: InteractiveModeOptions) { const thisCli = this; - const { interactiveMode: theInteractiveMode = {} } = this.options; + 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, + ); + + let exited = false; + const returnCode = await handlerFn({ + self, + interactive, + logger: thisCli.generateLogger(), + 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 { const { option = 'interactive', alias = 'i', + describe = 'Interactive mode', intro = 'Please provide the following values:', cancelled = 'Operation cancelled.', outro = 'Thank you!', - describe = 'Interactive mode', - } = theInteractiveMode; + } = this.options?.interactiveMode ?? {}; const commandArgs = { ...args, command: `${command} ${parameters.join(' ').replace(//g, ']')}`, usage: parameters.map((p) => `${command} ${p}`).join('\n'), - handler: async function handler(commandArgs: Record) { - const self = this as any; - - const interactiveLong = option in commandArgs ? Boolean(commandArgs.interactive) : false; - const interactiveShort = alias in commandArgs ? Boolean(commandArgs.i) : false; - const interactive = interactiveLong || interactiveShort; - let buildArgs = { - ...commandArgs, - }; - - const exit = (code: number) => { - if (thisCli.testMode) { - thisCli.testModeResult.exitCode = code; - return; - } - process.exit(code); - } - - if (interactive) { - // TODO properly filter attributes - const clackGroup = self.optional - .filter((optional: { cmd: string[] }) => { - const { - '$0': _$0, - i: _i, - interactive: _interactive, - test: _test, - _: __, - ...rest - } = buildArgs; - - 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 prompt = async (messages: Record) => { - 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); - exit(1); - } - } - ); - clack.outro(outro); - return values; - } - - return {}; - }; - - const promptedArgs = thisCli.testMode ? thisCli.promptValues : await prompt(clackGroup); - buildArgs = { - ...buildArgs, - ...promptedArgs, - }; - } - - const returnCode = await args.handler({ - self, - interactive, - logger: { - log: (message: unknown) => { - const stdout = thisCli.testMode ? thisCli.testModeResult.stdout : process.stdout; - stdout.write(`${message?.toString()}\n`); - }, - error: (message: unknown) => { - const stderr = thisCli.testMode ? thisCli.testModeResult.stderr : process.stderr; - stderr.write(`${message?.toString()}\n`); - }, - }, - send: exit, - args: buildArgs, - }); - - exit(returnCode ? returnCode : 0); - }, - } as any; + handler: this.buildHandler(handler, { + option, + alias, + intro, + cancelled, + outro, + describe, + }), + } as Record if (interactiveMode) { commandArgs.options = { @@ -193,48 +246,52 @@ export class Cli { }; } - this.cli.command(commandArgs); + this.cli.command(commandArgs as unknown as Parameters[0]); return this; } async run(args: string[]): Promise { - await this.cli.parse(args); + if (args.length > 0) { + await this.cli.parse(args); + return; + } + this.cli.showHelp(); } test() { this.testMode = true; this.promptValues = {}; - const testDelegate = { - promptValue: (...args: PromptValueArgs) => { + const thisCli = this; + return { + promptValue(...args: PromptValueArgs) { const [testArg] = args; if (typeof testArg === 'string') { const [key, value] = args as [string, unknown]; - this.promptValues[key] = this.promptValues[key] ?? value; + thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; } if (Array.isArray(testArg)) { const [key, value] = testArg as [string, unknown]; - this.promptValues[key] = this.promptValues[key] ?? value; + thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; } if (typeof testArg === 'object' && testArg !== null) { Object.entries(testArg).forEach(([key, value]) => { - this.promptValues[key] = this.promptValues[key] ?? value; + thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; }); } - return testDelegate; + return this; }, - run: async (args: string[]): Promise => { - await this.cli.parse(args); - return this.testModeResult; + async run(args: string[]): Promise { + await thisCli.cli.parse(args); + return thisCli.testModeResult; }, }; - return testDelegate; } } -export const createCli = (options = {} as CliOptions) => { +export const createCli = (options: CliOptions) => { return new Cli(options); }; diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index a11bf4e..592ff8c 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -4,13 +4,21 @@ import { AdderController, AdderControllerImpl } from './modules/adder'; export const addCommands = (cli: Cli) => { const adderController: AdderController = new AdderControllerImpl(); - cli.command({ - aliases: ['a'], - command: 'add', - parameters: [' '], - describe: 'Add two numbers', - handler: adderController.addNumbers, - }); + cli + .command({ + aliases: ['a'], + command: 'add', + parameters: [' '], + describe: 'Add two numbers', + handler: adderController.addNumbers, + }) + .command({ + aliases: ['s'], + command: 'subtract', + parameters: [' '], + describe: 'Subtract two numbers', + handler: adderController.subtractNumbers, + }); return cli; }; diff --git a/packages/cli/src/common.ts b/packages/cli/src/common.ts index e404ebf..a08e0b4 100644 --- a/packages/cli/src/common.ts +++ b/packages/cli/src/common.ts @@ -1,11 +1,15 @@ +export interface Logger { + log: (message: unknown) => void; + error: (message: unknown) => void; + warn: (message: unknown) => void; + debug: (message: unknown) => void; +} + export interface CommandHandlerArgs { self: any; interactive: boolean; send: (message: number) => void; - logger: { - log: (message: unknown) => void; - error: (message: unknown) => void; - }; + logger: Logger; args: any; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f53024f..995b06e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,7 +2,9 @@ import { addCommands } from '@/commands'; import { createCli } from '@/cli'; import { hideBin } from 'yargs/helpers'; -const cli = createCli(); +const cli = createCli({ + name: 'cli', +}); addCommands(cli); diff --git a/packages/cli/src/modules/adder/adder.controller.ts b/packages/cli/src/modules/adder/adder.controller.ts index 5d135df..99e8e70 100644 --- a/packages/cli/src/modules/adder/adder.controller.ts +++ b/packages/cli/src/modules/adder/adder.controller.ts @@ -7,7 +7,8 @@ import { import {CommandHandler} from '@/common'; export interface AdderController { - addNumbers: (args: any) => any; + addNumbers: CommandHandler; + subtractNumbers: CommandHandler; } export class AdderControllerImpl implements AdderController { @@ -47,4 +48,35 @@ export class AdderControllerImpl implements AdderController { } return 0; } + + readonly subtractNumbers: CommandHandler = (params) => { + if (!params.interactive) { + const checkArgs = params.args as Record; + if (typeof checkArgs.a === 'undefined') { + params.logger.error('Missing required argument: a'); + return -1; + } + if (typeof checkArgs.b === 'undefined') { + params.logger.error('Missing required argument: b'); + return -1; + } + } + + const { a, b } = params.args; + try { + const response = this.adderService.addNumbers({ a: Number(a), b: -(Number(b)) }); + params.logger.log(response); + } catch (errorRaw) { + const error = errorRaw as Error; + params.logger.error(error.message); + if (error instanceof InvalidArgumentTypeError) { + return -1; + } + if (error instanceof ArgumentOutOfRangeError) { + return -2; + } + return -3; + } + return 0; + } } diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index ee721a0..69e50c1 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -8,7 +8,9 @@ vi.mock('process'); describe('blah', () => { let cli: Cli; beforeAll(() => { - cli = createCli(); + cli = createCli({ + name: 'cli-test', + }); addCommands(cli); });