|
- import yargs, { Argv } from 'yargs';
- import * as clack from '@clack/prompts';
- import { DummyWriteStream } from './write-stream';
- import pino, {LogFn} from 'pino';
- import * as util from 'util';
-
- export interface Logger extends pino.BaseLogger {}
-
- export interface CommandHandlerArgs {
- self: any;
- interactive: boolean;
- send: (message: number) => void;
- logger: Logger;
- args: any;
- }
-
- export type CommandHandler = (args: CommandHandlerArgs) => void | number | Promise<number> | Promise<void>;
-
- 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[]];
-
- export interface TestModeResult {
- exitCode?: number;
- stdout: DummyWriteStream;
- stderr: DummyWriteStream;
- }
-
- interface InteractiveModeOptions {
- option: string;
- alias: string;
- intro: string;
- cancelled: string;
- outro: string;
- describe: string;
- }
-
- export interface CliOptions {
- name: string;
- interactiveMode?: Partial<InteractiveModeOptions>;
- logger?: Logger | boolean;
- }
-
- 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: Argv;
-
- constructor(private readonly options: CliOptions) {
- 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 buildHandler(handlerFn: Function, interactiveModeOptions: InteractiveModeOptions) {
- const thisCli = this;
- 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,
- );
-
- const stdoutLogFn: LogFn = (...args: unknown[]) => {
- const stream = thisCli.testMode ? thisCli.testModeResult.stdout : process.stdout;
- const [arg0, arg1, ...etcArgs] = args;
- if (typeof arg0 === 'string' || typeof arg0 === 'number') {
- if (arg1) {
- if (etcArgs.length > 0) {
- stream.write(util.format(`${arg0}\n`, arg1, ...etcArgs));
- } else {
- stream.write(util.format(`${arg0}\n`, arg1));
- }
- } else {
- stream.write(util.format(`${arg0}\n`));
- }
- } else if (typeof arg0 === 'object' && (typeof arg1 === 'string' || typeof arg1 === 'number')) {
- if (etcArgs.length > 0) {
- stream.write(util.format(`${arg1}\n`, ...etcArgs));
- } else {
- stream.write(util.format(`${arg1}\n`));
- }
- }
- };
-
- const stderrLogFn: LogFn = (...args: unknown[]) => {
- const stream = thisCli.testMode ? thisCli.testModeResult.stderr : process.stderr;
- const [arg0, arg1, ...etcArgs] = args;
- if (typeof arg0 === 'string' || typeof arg0 === 'number') {
- if (arg1) {
- if (etcArgs.length > 0) {
- stream.write(util.format(`${arg0}\n`, arg1, ...etcArgs));
- } else {
- stream.write(util.format(`${arg0}\n`, arg1));
- }
- } else {
- stream.write(util.format(`${arg0}\n`));
- }
- } else if (typeof arg0 === 'object' && (typeof arg1 === 'string' || typeof arg1 === 'number')) {
- if (etcArgs.length > 0) {
- stream.write(util.format(`${arg1}\n`, ...etcArgs));
- } else {
- stream.write(util.format(`${arg1}\n`));
- }
- }
- };
-
- const loggerFn = thisCli.options.logger;
- const defaultLogger = {
- level: 'info',
- enabled: typeof loggerFn === 'boolean' ? loggerFn : true,
- debug: stdoutLogFn,
- info: stdoutLogFn,
- warn: stdoutLogFn,
- error: stderrLogFn,
- fatal: stderrLogFn,
- trace: stdoutLogFn,
- silent: () => {},
- } as Logger;
- const loggerBooleanFn = typeof loggerFn === 'boolean' && loggerFn ? defaultLogger : {
- level: 'silent',
- debug: () => {},
- info: () => {},
- warn: () => {},
- error: () => {},
- fatal: () => {},
- trace: () => {},
- silent: () => {},
- } as Logger;
- const logger = typeof loggerFn === 'undefined' ? defaultLogger : (typeof loggerFn === 'function' ? loggerFn : loggerBooleanFn);
-
- let exited = false;
- const returnCode = await handlerFn({
- self,
- interactive,
- logger,
- 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 {
- // TODO type-safe declaration of positional/optional arguments
- const {
- option = 'interactive',
- alias = 'i',
- describe = 'Interactive mode',
- intro = 'Please provide the following values:',
- cancelled = 'Operation cancelled.',
- outro = 'Thank you!',
- } = this.options?.interactiveMode ?? {};
-
- const commandArgs = {
- ...args,
- command: `${command} ${parameters.join(' ').replace(/</g, '[').replace(/>/g, ']')}`,
- usage: parameters.map((p) => `${command} ${p}`).join('\n'),
- handler: this.buildHandler(handler, {
- option,
- alias,
- intro,
- cancelled,
- outro,
- describe,
- }),
- } as Record<string, unknown>
-
- if (interactiveMode) {
- commandArgs.options = {
- ...(commandArgs.options ?? {}),
- [option]: {
- alias,
- describe,
- type: 'boolean',
- default: false,
- hidden: true,
- },
- };
- }
-
- this.cli.command(commandArgs as unknown as Parameters<typeof this.cli.command>[0]);
- return this;
- }
-
- async run(args: string[]): Promise<void> {
- if (args.length > 0) {
- await this.cli.parse(args);
- return;
- }
- this.cli.showHelp();
- }
-
- test() {
- this.testMode = true;
- this.promptValues = {};
- const thisCli = this;
- return {
- promptValue(...args: PromptValueArgs) {
- const [testArg] = args;
-
- if (typeof testArg === 'string') {
- const [key, value] = args as [string, unknown];
- thisCli.promptValues[key] = thisCli.promptValues[key] ?? value;
- }
-
- if (Array.isArray(testArg)) {
- const [key, value] = testArg as [string, unknown];
- thisCli.promptValues[key] = thisCli.promptValues[key] ?? value;
- }
-
- if (typeof testArg === 'object' && testArg !== null) {
- Object.entries(testArg).forEach(([key, value]) => {
- thisCli.promptValues[key] = thisCli.promptValues[key] ?? value;
- });
- }
-
- return this;
- },
- async run(args: string[]): Promise<TestModeResult> {
- await thisCli.cli.parse(args);
- return thisCli.testModeResult;
- },
- };
- }
- }
|