From caca1e5b47df14821316b83f1c01bf6e776c177b Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Fri, 14 Jul 2023 21:33:07 +0800 Subject: [PATCH] Make API consistent Make the setup consistent between CLI and API. --- .gitignore | 1 + .idea/.gitignore | 8 - .idea/backend-template.iml | 13 - .idea/modules.xml | 8 - .idea/vcs.xml | 6 - packages/cli/src/cli.ts | 298 +---------------- packages/cli/src/commands.ts | 6 +- packages/cli/src/common.ts | 16 - .../cli/src/modules/adder/adder.controller.ts | 2 +- packages/cli/src/packages/cli-wrapper.ts | 309 ++++++++++++++++++ packages/web-api/src/routes.ts | 13 +- 11 files changed, 324 insertions(+), 356 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/backend-template.iml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml delete mode 100644 packages/cli/src/common.ts create mode 100644 packages/cli/src/packages/cli-wrapper.ts diff --git a/.gitignore b/.gitignore index fd4f2b0..10d9d84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .DS_Store +.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/backend-template.iml b/.idea/backend-template.iml deleted file mode 100644 index 4a10506..0000000 --- a/.idea/backend-template.iml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index e23f359..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9641930..82dcbc0 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,297 +1,9 @@ -import yargs, {Argv} from 'yargs'; -import * as clack from '@clack/prompts'; -import * as tty from 'tty'; -import { CommandHandler } from './common'; +import { Cli, CliOptions } from './packages/cli-wrapper'; -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[]]; - -class DummyWriteStream extends tty.WriteStream { - private bufferInternal = Buffer.from(''); - - constructor() { - super(0); - } - - write(input: Uint8Array | string, _encoding?: BufferEncoding | ((err?: Error) => void), _cb?: (err?: Error) => void): boolean { - this.bufferInternal = Buffer.concat([this.bufferInternal, Buffer.from(input)]); - // noop - return true; - } - - get buffer(): Buffer { - return this.bufferInternal; - } -} - -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; -} - -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 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`); - }, - }; - } - - 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, - ); - - 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!', - } = 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; - }); - } +export const createCli = (options: CliOptions) => { + const cli = new Cli(options); - return this; - }, - async run(args: string[]): Promise { - await thisCli.cli.parse(args); - return thisCli.testModeResult; - }, - }; - } -} + // TODO setup CLI here -export const createCli = (options: CliOptions) => { - return new Cli(options); + return cli; }; diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 592ff8c..549cd6a 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,10 +1,10 @@ -import { Cli } from './cli'; +import { Cli } from './packages/cli-wrapper'; import { AdderController, AdderControllerImpl } from './modules/adder'; export const addCommands = (cli: Cli) => { const adderController: AdderController = new AdderControllerImpl(); - cli + return cli .command({ aliases: ['a'], command: 'add', @@ -19,6 +19,4 @@ export const addCommands = (cli: Cli) => { describe: 'Subtract two numbers', handler: adderController.subtractNumbers, }); - - return cli; }; diff --git a/packages/cli/src/common.ts b/packages/cli/src/common.ts deleted file mode 100644 index a08e0b4..0000000 --- a/packages/cli/src/common.ts +++ /dev/null @@ -1,16 +0,0 @@ -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: Logger; - args: any; -} - -export type CommandHandler = (args: CommandHandlerArgs) => void | number | Promise | Promise; diff --git a/packages/cli/src/modules/adder/adder.controller.ts b/packages/cli/src/modules/adder/adder.controller.ts index 99e8e70..1431117 100644 --- a/packages/cli/src/modules/adder/adder.controller.ts +++ b/packages/cli/src/modules/adder/adder.controller.ts @@ -4,7 +4,7 @@ import { ArgumentOutOfRangeError, InvalidArgumentTypeError, } from './adder.service'; -import {CommandHandler} from '@/common'; +import { CommandHandler } from '../../packages/cli-wrapper'; export interface AdderController { addNumbers: CommandHandler; diff --git a/packages/cli/src/packages/cli-wrapper.ts b/packages/cli/src/packages/cli-wrapper.ts new file mode 100644 index 0000000..cbadfd5 --- /dev/null +++ b/packages/cli/src/packages/cli-wrapper.ts @@ -0,0 +1,309 @@ +import tty from 'tty'; +import yargs, {Argv} from 'yargs'; +import * as clack from '@clack/prompts'; + +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: 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[]]; + +class DummyWriteStream extends tty.WriteStream { + private bufferInternal = Buffer.from(''); + + constructor() { + super(0); + } + + write(input: Uint8Array | string, _encoding?: BufferEncoding | ((err?: Error) => void), _cb?: (err?: Error) => void): boolean { + this.bufferInternal = Buffer.concat([this.bufferInternal, Buffer.from(input)]); + // noop + return true; + } + + get buffer(): Buffer { + return this.bufferInternal; + } +} + +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; +} + +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 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`); + }, + }; + } + + 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, + ); + + 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!', + } = 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; + }, + }; + } +} diff --git a/packages/web-api/src/routes.ts b/packages/web-api/src/routes.ts index 8140ec9..58b7a1e 100644 --- a/packages/web-api/src/routes.ts +++ b/packages/web-api/src/routes.ts @@ -4,11 +4,10 @@ import { AdderController, AdderControllerImpl } from '@/modules/adder'; export const addRoutes = (server: FastifyInstance) => { const adderController: AdderController = new AdderControllerImpl(); - server.route({ - method: 'POST', - url: '/', - handler: adderController.addNumbers, - }); - - return server; + return server + .route({ + method: 'POST', + url: '/', + handler: adderController.addNumbers, + }); };