Browse Source

Update CLI package

Improve interactive mode for CLI.
master
TheoryOfNekomata 11 months ago
parent
commit
f28ad630e8
8 changed files with 325 additions and 59 deletions
  1. +2
    -2
      packages/cli/package.json
  2. +237
    -2
      packages/cli/src/cli.ts
  3. +4
    -3
      packages/cli/src/commands.ts
  4. +12
    -0
      packages/cli/src/common.ts
  5. +3
    -3
      packages/cli/src/index.ts
  6. +23
    -15
      packages/cli/src/modules/adder/adder.controller.ts
  7. +31
    -31
      packages/cli/test/index.test.ts
  8. +13
    -3
      pnpm-lock.yaml

+ 2
- 2
packages/cli/package.json View File

@@ -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"
}
}

+ 237
- 2
packages/cli/src/cli.ts View File

@@ -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);
};

+ 4
- 3
packages/cli/src/commands.ts View File

@@ -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,
});


+ 12
- 0
packages/cli/src/common.ts View File

@@ -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>;

+ 3
- 3
packages/cli/src/index.ts View File

@@ -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));

+ 23
- 15
packages/cli/src/modules/adder/adder.controller.ts View File

@@ -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;
}
}

+ 31
- 31
packages/cli/test/index.test.ts View File

@@ -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);
});
});
});

+ 13
- 3
pnpm-lock.yaml View File

@@ -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'}


Loading…
Cancel
Save