Parcourir la source

Fix command help

Display help when supplied with no arguments.
master
TheoryOfNekomata il y a 1 an
Parent
révision
b074fcf232
6 fichiers modifiés avec 243 ajouts et 138 suppressions
  1. +181
    -124
      packages/cli/src/cli.ts
  2. +15
    -7
      packages/cli/src/commands.ts
  3. +8
    -4
      packages/cli/src/common.ts
  4. +3
    -1
      packages/cli/src/index.ts
  5. +33
    -1
      packages/cli/src/modules/adder/adder.controller.ts
  6. +3
    -1
      packages/cli/test/index.test.ts

+ 181
- 124
packages/cli/src/cli.ts Voir le fichier

@@ -1,10 +1,10 @@
import yargs from 'yargs';
import yargs, {Argv} from 'yargs';
import * as clack from '@clack/prompts';
import {CommandHandler} from './common';
import * as tty from 'tty';
import { CommandHandler } from './common';

export interface CommandArgs {
aliases: string[];
aliases?: string[];
command: string;
parameters: string[];
describe: string;
@@ -41,15 +41,18 @@ export interface TestModeResult {
stderr: DummyWriteStream;
}

interface InteractiveModeOptions {
option: string;
alias: string;
intro: string;
cancelled: string;
outro: string;
describe: string;
}

export interface CliOptions {
interactiveMode?: {
option?: string;
alias?: string;
intro?: string;
cancelled?: string;
outro?: string;
describe?: string;
}
name: string;
interactiveMode?: Partial<InteractiveModeOptions>;
}

export class Cli {
@@ -63,122 +66,172 @@ export class Cli {
stderr: new DummyWriteStream(),
};

private readonly cli = yargs()
.help()
.fail(false)
.strict(false);
private readonly cli: Argv;

constructor(private readonly options: CliOptions) {
// noop
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<string, string>, 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<string, unknown>,
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<string, string>, optional: { cmd: string[], describe: string }) => ({
...group,
[optional.cmd[0]]: optional.describe ?? optional.cmd[0],
}),
{} as Record<string, string>,
);


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

command({ parameters, command, interactiveMode = true, ...args }: CommandArgs): Cli {
private buildHandler(handlerFn: Function, interactiveModeOptions: InteractiveModeOptions) {
const thisCli = this;
const { interactiveMode: theInteractiveMode = {} } = this.options;
return async function handler(this: any, commandArgs: Record<string, unknown>) {
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!',
describe = 'Interactive mode',
} = theInteractiveMode;
} = this.options?.interactiveMode ?? {};

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;
handler: this.buildHandler(handler, {
option,
alias,
intro,
cancelled,
outro,
describe,
}),
} as Record<string, unknown>

if (interactiveMode) {
commandArgs.options = {
@@ -193,48 +246,52 @@ export class Cli {
};
}

this.cli.command(commandArgs);
this.cli.command(commandArgs as unknown as Parameters<typeof this.cli.command>[0]);
return this;
}

async run(args: string[]): Promise<void> {
await this.cli.parse(args);
if (args.length > 0) {
await this.cli.parse(args);
return;
}
this.cli.showHelp();
}

test() {
this.testMode = true;
this.promptValues = {};
const testDelegate = {
promptValue: (...args: PromptValueArgs) => {
const thisCli = this;
return {
promptValue(...args: PromptValueArgs) {
const [testArg] = args;

if (typeof testArg === 'string') {
const [key, value] = args as [string, unknown];
this.promptValues[key] = this.promptValues[key] ?? value;
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value;
}

if (Array.isArray(testArg)) {
const [key, value] = testArg as [string, unknown];
this.promptValues[key] = this.promptValues[key] ?? value;
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value;
}

if (typeof testArg === 'object' && testArg !== null) {
Object.entries(testArg).forEach(([key, value]) => {
this.promptValues[key] = this.promptValues[key] ?? value;
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value;
});
}

return testDelegate;
return this;
},
run: async (args: string[]): Promise<TestModeResult> => {
await this.cli.parse(args);
return this.testModeResult;
async run(args: string[]): Promise<TestModeResult> {
await thisCli.cli.parse(args);
return thisCli.testModeResult;
},
};
return testDelegate;
}
}

export const createCli = (options = {} as CliOptions) => {
export const createCli = (options: CliOptions) => {
return new Cli(options);
};

+ 15
- 7
packages/cli/src/commands.ts Voir le fichier

@@ -4,13 +4,21 @@ import { AdderController, AdderControllerImpl } from './modules/adder';
export const addCommands = (cli: Cli) => {
const adderController: AdderController = new AdderControllerImpl();

cli.command({
aliases: ['a'],
command: 'add',
parameters: ['<a> <b>'],
describe: 'Add two numbers',
handler: adderController.addNumbers,
});
cli
.command({
aliases: ['a'],
command: 'add',
parameters: ['<a> <b>'],
describe: 'Add two numbers',
handler: adderController.addNumbers,
})
.command({
aliases: ['s'],
command: 'subtract',
parameters: ['<a> <b>'],
describe: 'Subtract two numbers',
handler: adderController.subtractNumbers,
});

return cli;
};

+ 8
- 4
packages/cli/src/common.ts Voir le fichier

@@ -1,11 +1,15 @@
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: {
log: (message: unknown) => void;
error: (message: unknown) => void;
};
logger: Logger;
args: any;
}



+ 3
- 1
packages/cli/src/index.ts Voir le fichier

@@ -2,7 +2,9 @@ import { addCommands } from '@/commands';
import { createCli } from '@/cli';
import { hideBin } from 'yargs/helpers';

const cli = createCli();
const cli = createCli({
name: 'cli',
});

addCommands(cli);



+ 33
- 1
packages/cli/src/modules/adder/adder.controller.ts Voir le fichier

@@ -7,7 +7,8 @@ import {
import {CommandHandler} from '@/common';

export interface AdderController {
addNumbers: (args: any) => any;
addNumbers: CommandHandler;
subtractNumbers: CommandHandler;
}

export class AdderControllerImpl implements AdderController {
@@ -47,4 +48,35 @@ export class AdderControllerImpl implements AdderController {
}
return 0;
}

readonly subtractNumbers: 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: 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) {
return -1;
}
if (error instanceof ArgumentOutOfRangeError) {
return -2;
}
return -3;
}
return 0;
}
}

+ 3
- 1
packages/cli/test/index.test.ts Voir le fichier

@@ -8,7 +8,9 @@ vi.mock('process');
describe('blah', () => {
let cli: Cli;
beforeAll(() => {
cli = createCli();
cli = createCli({
name: 'cli-test',
});
addCommands(cli);
});



Chargement…
Annuler
Enregistrer