@@ -65,8 +65,8 @@ | |||||
"*": {} | "*": {} | ||||
}, | }, | ||||
"dependencies": { | "dependencies": { | ||||
"@clack/core": "^0.3.2", | |||||
"@modal-sh/core": "workspace:*", | |||||
"@clack/prompts": "^0.6.3", | |||||
"@modal-sh/core": "workspace:*", | |||||
"yargs": "^17.7.2" | "yargs": "^17.7.2" | ||||
} | } | ||||
} | } |
@@ -1,5 +1,240 @@ | |||||
import yargs from 'yargs'; | 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<string, string>, ...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<string, unknown>; | |||||
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, '[').replace(/>/g, ']')}`, | |||||
usage: parameters.map((p) => `${command} ${p}`).join('\n'), | |||||
handler: async function handler(commandArgs: Record<string, unknown>) { | |||||
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<string, string>, optional: { cmd: string[], describe: string }) => ({ | |||||
...group, | |||||
[optional.cmd[0]]: optional.describe ?? optional.cmd[0], | |||||
}), | |||||
{} as Record<string, string>, | |||||
); | |||||
const prompt = async (messages: Record<string, string>) => { | |||||
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<void> { | |||||
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<TestModeResult> => { | |||||
await this.cli.parse(args); | |||||
return this.testModeResult; | |||||
}, | |||||
}; | |||||
return testDelegate; | |||||
} | |||||
} | |||||
export const createCli = (options = {} as CliOptions) => { | |||||
return new Cli(options); | |||||
}; | }; |
@@ -1,12 +1,13 @@ | |||||
import yargs from 'yargs'; | |||||
import { Cli } from './cli'; | |||||
import { AdderController, AdderControllerImpl } from './modules/adder'; | import { AdderController, AdderControllerImpl } from './modules/adder'; | ||||
export const addCommands = (cli: yargs.Argv) => { | |||||
export const addCommands = (cli: Cli) => { | |||||
const adderController: AdderController = new AdderControllerImpl(); | const adderController: AdderController = new AdderControllerImpl(); | ||||
cli.command({ | cli.command({ | ||||
aliases: ['a'], | aliases: ['a'], | ||||
command: 'add <a> <b>', | |||||
command: 'add', | |||||
parameters: ['<a> <b>'], | |||||
describe: 'Add two numbers', | describe: 'Add two numbers', | ||||
handler: adderController.addNumbers, | handler: adderController.addNumbers, | ||||
}); | }); | ||||
@@ -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<number> | Promise<void>; |
@@ -2,8 +2,8 @@ import { addCommands } from '@/commands'; | |||||
import { createCli } from '@/cli'; | import { createCli } from '@/cli'; | ||||
import { hideBin } from 'yargs/helpers'; | 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)); |
@@ -1,13 +1,13 @@ | |||||
import { ArgumentsCamelCase } from 'yargs'; | |||||
import { | import { | ||||
AdderService, | AdderService, | ||||
AdderServiceImpl, | AdderServiceImpl, | ||||
ArgumentOutOfRangeError, | ArgumentOutOfRangeError, | ||||
InvalidArgumentTypeError, | InvalidArgumentTypeError, | ||||
} from './adder.service'; | } from './adder.service'; | ||||
import {CommandHandler} from '@/common'; | |||||
export interface AdderController { | export interface AdderController { | ||||
addNumbers: (args: ArgumentsCamelCase<{ a: number, b: number }>) => void | Promise<void>; | |||||
addNumbers: (args: any) => any; | |||||
} | } | ||||
export class AdderControllerImpl implements AdderController { | export class AdderControllerImpl implements AdderController { | ||||
@@ -17,26 +17,34 @@ export class AdderControllerImpl implements AdderController { | |||||
// noop | // 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<string, unknown>; | |||||
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 { | 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) { | } catch (errorRaw) { | ||||
const error = errorRaw as Error; | const error = errorRaw as Error; | ||||
params.logger.error(error.message); | |||||
if (error instanceof InvalidArgumentTypeError) { | if (error instanceof InvalidArgumentTypeError) { | ||||
process.stderr.write(`${error.message}\n`); | |||||
process.exit(-1); | |||||
return; | |||||
return -1; | |||||
} | } | ||||
if (error instanceof ArgumentOutOfRangeError) { | 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; | |||||
} | } | ||||
} | } |
@@ -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 { addCommands } from '../src/commands'; | ||||
import yargs from 'yargs'; | |||||
import { AdderServiceImpl, InvalidArgumentTypeError, ArgumentOutOfRangeError } from '../src/modules/adder'; | import { AdderServiceImpl, InvalidArgumentTypeError, ArgumentOutOfRangeError } from '../src/modules/adder'; | ||||
vi.mock('process'); | vi.mock('process'); | ||||
describe('blah', () => { | describe('blah', () => { | ||||
let cli: yargs.Argv; | |||||
let exit: vi.Mock<typeof process.exit>; | |||||
let exitReal: typeof process.exit; | |||||
let cli: Cli; | |||||
beforeAll(() => { | beforeAll(() => { | ||||
exitReal = process.exit.bind(process); | |||||
const processMut = process as unknown as Record<string, unknown>; | |||||
processMut.exit = exit = vi.fn(); | |||||
}); | |||||
afterAll(() => { | |||||
process.exit = exitReal = process.exit.bind(process); | |||||
cli = createCli(); | |||||
addCommands(cli); | |||||
}); | }); | ||||
it('returns result when successful', async () => { | 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 () => { | it('returns error when given invalid inputs', async () => { | ||||
@@ -34,10 +22,8 @@ describe('blah', () => { | |||||
throw new InvalidArgumentTypeError('Invalid input'); | 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 () => { | it('returns error when given out-of-range inputs', async () => { | ||||
@@ -45,10 +31,8 @@ describe('blah', () => { | |||||
throw new ArgumentOutOfRangeError('Out of range'); | 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 () => { | it('returns error when an unexpected error occurs', async () => { | ||||
@@ -56,9 +40,25 @@ describe('blah', () => { | |||||
throw new Error('Unexpected error'); | 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); | |||||
}); | |||||
}); | }); | ||||
}); | }); |
@@ -8,9 +8,9 @@ importers: | |||||
packages/cli: | packages/cli: | ||||
dependencies: | dependencies: | ||||
'@clack/core': | |||||
specifier: ^0.3.2 | |||||
version: 0.3.2 | |||||
'@clack/prompts': | |||||
specifier: ^0.6.3 | |||||
version: 0.6.3 | |||||
'@modal-sh/core': | '@modal-sh/core': | ||||
specifier: workspace:* | specifier: workspace:* | ||||
version: link:../core | version: link:../core | ||||
@@ -376,6 +376,16 @@ packages: | |||||
sisteransi: 1.0.5 | sisteransi: 1.0.5 | ||||
dev: false | 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: | /@esbuild/android-arm64@0.17.19: | ||||
resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} | resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} | ||||
engines: {node: '>=12'} | engines: {node: '>=12'} | ||||