@@ -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" | |||
} | |||
} |
@@ -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<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'; | |||
export const addCommands = (cli: yargs.Argv) => { | |||
export const addCommands = (cli: Cli) => { | |||
const adderController: AdderController = new AdderControllerImpl(); | |||
cli.command({ | |||
aliases: ['a'], | |||
command: 'add <a> <b>', | |||
command: 'add', | |||
parameters: ['<a> <b>'], | |||
describe: 'Add two numbers', | |||
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 { 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 { | |||
AdderService, | |||
AdderServiceImpl, | |||
ArgumentOutOfRangeError, | |||
InvalidArgumentTypeError, | |||
} from './adder.service'; | |||
import {CommandHandler} from '@/common'; | |||
export interface AdderController { | |||
addNumbers: (args: ArgumentsCamelCase<{ a: number, b: number }>) => void | Promise<void>; | |||
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<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 { | |||
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; | |||
} | |||
} |
@@ -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<typeof process.exit>; | |||
let exitReal: typeof process.exit; | |||
let cli: Cli; | |||
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 () => { | |||
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); | |||
}); | |||
}); | |||
}); |
@@ -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'} | |||