diff --git a/packages/cli/package.json b/packages/cli/package.json index c5004dd..64637a8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -65,8 +65,8 @@ "*": {} }, "dependencies": { - "@clack/core": "^0.3.2", - "@modal-sh/core": "workspace:*", + "@clack/prompts": "^0.6.3", + "@modal-sh/core": "workspace:*", "yargs": "^17.7.2" } } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 79e7082..c35e2f3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,5 +1,240 @@ import yargs from 'yargs'; +import * as clack from '@clack/prompts'; +import {CommandHandler} from './common'; +import * as tty from 'tty'; -export const createCli = (argv: typeof process.argv) => { - return yargs(argv); +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; +} + +export interface CliOptions { + interactiveMode?: { + option?: string; + alias?: string; + intro?: string; + cancelled?: string; + outro?: string; + describe?: string; + } +} + +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 = yargs() + .help() + .fail(false) + .strict(false); + + constructor(private readonly options: CliOptions) { + // noop + } + + command({ parameters, command, interactiveMode = true, ...args }: CommandArgs): Cli { + const thisCli = this; + const { interactiveMode: theInteractiveMode = {} } = this.options; + const { + option = 'interactive', + alias = 'i', + intro = 'Please provide the following values:', + cancelled = 'Operation cancelled.', + outro = 'Thank you!', + describe = 'Interactive mode', + } = theInteractiveMode; + + 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; + + if (interactiveMode) { + commandArgs.options = { + ...(commandArgs.options ?? {}), + [option]: { + alias, + describe, + type: 'boolean', + default: false, + hidden: true, + }, + }; + } + + this.cli.command(commandArgs); + return this; + } + + async run(args: string[]): Promise { + await this.cli.parse(args); + } + + test() { + this.testMode = true; + this.promptValues = {}; + const testDelegate = { + promptValue: (...args: PromptValueArgs) => { + const [testArg] = args; + + if (typeof testArg === 'string') { + const [key, value] = args as [string, unknown]; + this.promptValues[key] = this.promptValues[key] ?? value; + } + + if (Array.isArray(testArg)) { + const [key, value] = testArg as [string, unknown]; + this.promptValues[key] = this.promptValues[key] ?? value; + } + + if (typeof testArg === 'object' && testArg !== null) { + Object.entries(testArg).forEach(([key, value]) => { + this.promptValues[key] = this.promptValues[key] ?? value; + }); + } + + return testDelegate; + }, + run: async (args: string[]): Promise => { + await this.cli.parse(args); + return this.testModeResult; + }, + }; + return testDelegate; + } +} + +export const createCli = (options = {} as CliOptions) => { + return new Cli(options); }; diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 7eaf8ae..a11bf4e 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,12 +1,13 @@ -import yargs from 'yargs'; +import { Cli } from './cli'; import { AdderController, AdderControllerImpl } from './modules/adder'; -export const addCommands = (cli: yargs.Argv) => { +export const addCommands = (cli: Cli) => { const adderController: AdderController = new AdderControllerImpl(); cli.command({ aliases: ['a'], - command: 'add ', + command: 'add', + parameters: [' '], describe: 'Add two numbers', handler: adderController.addNumbers, }); diff --git a/packages/cli/src/common.ts b/packages/cli/src/common.ts new file mode 100644 index 0000000..e404ebf --- /dev/null +++ b/packages/cli/src/common.ts @@ -0,0 +1,12 @@ +export interface CommandHandlerArgs { + self: any; + interactive: boolean; + send: (message: number) => void; + logger: { + log: (message: unknown) => void; + error: (message: unknown) => void; + }; + args: any; +} + +export type CommandHandler = (args: CommandHandlerArgs) => void | number | Promise | Promise; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2ce560d..f53024f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,8 +2,8 @@ import { addCommands } from '@/commands'; import { createCli } from '@/cli'; import { hideBin } from 'yargs/helpers'; -const args = createCli(hideBin(process.argv)); +const cli = createCli(); -addCommands(args); +addCommands(cli); -args.parse(); +cli.run(hideBin(process.argv)); diff --git a/packages/cli/src/modules/adder/adder.controller.ts b/packages/cli/src/modules/adder/adder.controller.ts index dba879c..5d135df 100644 --- a/packages/cli/src/modules/adder/adder.controller.ts +++ b/packages/cli/src/modules/adder/adder.controller.ts @@ -1,13 +1,13 @@ -import { ArgumentsCamelCase } from 'yargs'; import { AdderService, AdderServiceImpl, ArgumentOutOfRangeError, InvalidArgumentTypeError, } from './adder.service'; +import {CommandHandler} from '@/common'; export interface AdderController { - addNumbers: (args: ArgumentsCamelCase<{ a: number, b: number }>) => void | Promise; + addNumbers: (args: any) => any; } export class AdderControllerImpl implements AdderController { @@ -17,26 +17,34 @@ export class AdderControllerImpl implements AdderController { // noop } - readonly addNumbers = async (args: ArgumentsCamelCase<{ a: number, b: number }>) => { - const { a, b } = args; + readonly addNumbers: 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, b}); - process.stdout.write(`${response}\n`); - process.exit(0); + 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) { - process.stderr.write(`${error.message}\n`); - process.exit(-1); - return; + return -1; } if (error instanceof ArgumentOutOfRangeError) { - process.stderr.write(`${error.message}\n`); - process.exit(-2); - return; + return -2; } - process.stderr.write(`${error.message}\n`); - process.exit(-3); + return -3; } + return 0; } } diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 33c0af2..ee721a0 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -1,32 +1,20 @@ -import {describe, it, expect, vi, beforeAll, afterAll} from 'vitest'; - -import { createCli } from '../src/cli'; +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { Cli, createCli } from '../src/cli'; import { addCommands } from '../src/commands'; -import yargs from 'yargs'; import { AdderServiceImpl, InvalidArgumentTypeError, ArgumentOutOfRangeError } from '../src/modules/adder'; vi.mock('process'); describe('blah', () => { - let cli: yargs.Argv; - let exit: vi.Mock; - let exitReal: typeof process.exit; - + let cli: Cli; beforeAll(() => { - exitReal = process.exit.bind(process); - const processMut = process as unknown as Record; - processMut.exit = exit = vi.fn(); - }); - - afterAll(() => { - process.exit = exitReal = process.exit.bind(process); + cli = createCli(); + addCommands(cli); }); it('returns result when successful', async () => { - cli = createCli(['add', '1', '2']); - addCommands(cli); - await cli.parse(); - expect(exit).toHaveBeenCalledWith(0); + const response = await cli.test().run(['add', '1', '2']); + expect(response.exitCode).toBe(0); }); it('returns error when given invalid inputs', async () => { @@ -34,10 +22,8 @@ describe('blah', () => { throw new InvalidArgumentTypeError('Invalid input'); }); - cli = createCli(['add', '1', '2']); - addCommands(cli); - await cli.parse(); - expect(exit).toHaveBeenCalledWith(-1); + const response = await cli.test().run(['add', '1', '2']); + expect(response.exitCode).toBe(-1); }); it('returns error when given out-of-range inputs', async () => { @@ -45,10 +31,8 @@ describe('blah', () => { throw new ArgumentOutOfRangeError('Out of range'); }); - cli = createCli(['add', '1', '2']); - addCommands(cli); - await cli.parse(); - expect(exit).toHaveBeenCalledWith(-2); + const response = await cli.test().run(['add', '1', '2']); + expect(response.exitCode).toBe(-2); }); it('returns error when an unexpected error occurs', async () => { @@ -56,9 +40,25 @@ describe('blah', () => { throw new Error('Unexpected error'); }); - cli = createCli(['add', '1', '2']); - addCommands(cli); - await cli.parse(); - expect(exit).toHaveBeenCalledWith(-2); + const response = await cli.test().run(['add', '1', '2']); + expect(response.exitCode).toBe(-3); + }); + + it('returns error when given insufficient arguments', async () => { + const response = await cli.test().run(['add', '1']); + expect(response.exitCode).toBe(-1); + }); + + describe('on interactive mode', () => { + it('prompts values for insufficient arguments', async () => { + const response = await cli + .test() + .promptValue({ + a: '1', + b: '2', + }) + .run(['add', '-i']); + expect(response.exitCode).not.toBe(-1); + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac04070..04ed2fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,9 @@ importers: packages/cli: dependencies: - '@clack/core': - specifier: ^0.3.2 - version: 0.3.2 + '@clack/prompts': + specifier: ^0.6.3 + version: 0.6.3 '@modal-sh/core': specifier: workspace:* version: link:../core @@ -376,6 +376,16 @@ packages: sisteransi: 1.0.5 dev: false + /@clack/prompts@0.6.3: + resolution: {integrity: sha512-AM+kFmAHawpUQv2q9+mcB6jLKxXGjgu/r2EQjEwujgpCdzrST6BJqYw00GRn56/L/Izw5U7ImoLmy00X/r80Pw==} + dependencies: + '@clack/core': 0.3.2 + picocolors: 1.0.0 + sisteransi: 1.0.5 + dev: false + bundledDependencies: + - is-unicode-supported + /@esbuild/android-arm64@0.17.19: resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} engines: {node: '>=12'}