packages/@aws-cdk/user-input-gen/lib/yargs-gen.ts (124 lines of code) (raw):

import type { IScope, Statement } from '@cdklabs/typewriter'; import { $E, Expression, ExternalModule, FreeFunction, Module, SelectiveModuleImport, ThingSymbol, Type, TypeScriptRenderer, code, expr } from '@cdklabs/typewriter'; import { EsLintRules } from '@cdklabs/typewriter/lib/eslint-rules'; import * as prettier from 'prettier'; import { lit, SOURCE_OF_TRUTH } from './util'; import type { CliConfig, CliOption, YargsOption } from './yargs-types'; // to import lodash.clonedeep properly, we would need to set esModuleInterop: true // however that setting does not work in the CLI, so we fudge it. // eslint-disable-next-line @typescript-eslint/no-require-imports const cloneDeep = require('lodash.clonedeep'); export class CliHelpers extends ExternalModule { public readonly browserForPlatform = makeCallableExpr(this, 'browserForPlatform'); public readonly cliVersion = makeCallableExpr(this, 'cliVersion'); public readonly isCI = makeCallableExpr(this, 'isCI'); public readonly yargsNegativeAlias = makeCallableExpr(this, 'yargsNegativeAlias'); } function makeCallableExpr(scope: IScope, name: string) { return $E(expr.sym(new ThingSymbol(name, scope))); } export async function renderYargs(config: CliConfig, helpers: CliHelpers): Promise<string> { const scope = new Module('aws-cdk'); scope.documentation.push( '-------------------------------------------------------------------------------------------'); scope.documentation.push(`GENERATED FROM ${SOURCE_OF_TRUTH}.`); scope.documentation.push('Do not edit by hand; all changes will be overwritten at build time from the config file.'); scope.documentation.push('-------------------------------------------------------------------------------------------'); scope.addImport(new SelectiveModuleImport(scope, 'yargs', ['Argv'])); helpers.import(scope, 'helpers'); // 'https://github.com/yargs/yargs/issues/1929', // 'https://github.com/evanw/esbuild/issues/1492', scope.addInitialization(code.comment('eslint-disable-next-line @typescript-eslint/no-require-imports')); scope.addInitialization(code.stmt.constVar(code.expr.ident('yargs'), code.expr.directCode("require('yargs')"))); const parseCommandLineArguments = new FreeFunction(scope, { name: 'parseCommandLineArguments', export: true, returnType: Type.ANY, parameters: [ { name: 'args', type: Type.arrayOf(Type.STRING) }, ], }); parseCommandLineArguments.addBody(makeYargs(config, helpers)); const ts = new TypeScriptRenderer({ disabledEsLintRules: [ EsLintRules.MAX_LEN, // the default disabled rules result in 'Definition for rule 'prettier/prettier' was not found '@typescript-eslint/consistent-type-imports', // (ironically) typewriter does not support type imports ], }).render(scope); return prettier.format(ts, { parser: 'typescript', printWidth: 150, singleQuote: true, trailingComma: 'all', }); } // Use the following configuration for array arguments: // // { type: 'array', default: [], nargs: 1, requiresArg: true } // // The default behavior of yargs is to eat all strings following an array argument: // // ./prog --arg one two positional => will parse to { arg: ['one', 'two', 'positional'], _: [] } (so no positional arguments) // ./prog --arg one two -- positional => does not help, for reasons that I can't understand. Still gets parsed incorrectly. // // By using the config above, every --arg will only consume one argument, so you can do the following: // // ./prog --arg one --arg two position => will parse to { arg: ['one', 'two'], _: ['positional'] }. function makeYargs(config: CliConfig, helpers: CliHelpers): Statement { let yargsExpr: Expression = code.expr.ident('yargs'); yargsExpr = yargsExpr .callMethod('env', lit('CDK')) .callMethod('usage', lit('Usage: cdk -a <cdk-app> COMMAND')); // we must compute global options first, as they are not part of an argument to a command call yargsExpr = makeOptions(yargsExpr, config.globalOptions, helpers); for (const command of Object.keys(config.commands)) { const commandFacts = config.commands[command]; const commandArg = commandFacts.arg ? ` [${commandFacts.arg?.name}${commandFacts.arg?.variadic ? '..' : ''}]` : ''; const aliases = commandFacts.aliases ? commandFacts.aliases.map((alias) => `, '${alias}${commandArg}'`) : ''; // must compute options before we compute the full command, because in yargs, the options are an argument to the command call. let optionsExpr: Expression = code.expr.directCode('(yargs: Argv) => yargs'); optionsExpr = makeOptions(optionsExpr, commandFacts.options ?? {}, helpers); const commandCallArgs: Array<Expression> = []; if (aliases) { commandCallArgs.push(code.expr.directCode(`['${command}${commandArg}'${aliases}]`)); } else { commandCallArgs.push(code.expr.directCode(`'${command}${commandArg}'`)); } commandCallArgs.push(lit(commandFacts.description)); if (commandFacts.options) { commandCallArgs.push(optionsExpr); } yargsExpr = yargsExpr.callMethod('command', ...commandCallArgs); } return code.stmt.ret(makeEpilogue(yargsExpr, helpers)); } function makeOptions(prefix: Expression, options: { [optionName: string]: CliOption }, helpers: CliHelpers) { let optionsExpr = prefix; for (const option of Object.keys(options)) { const theOption: CliOption = { // https://github.com/yargs/yargs/issues/2443 prevents us from supplying 'undefined' as the default // for array types, because this turns into ['undefined']. The only way to achieve yargs' default is // to provide no default. ...(options[option].type == 'array' ? {} : { default: undefined }), ...options[option], }; const optionProps: YargsOption = cloneDeep(theOption); const optionArgs: { [key: string]: Expression } = {}; // Array defaults if (optionProps.type === 'array') { optionProps.nargs = 1; optionProps.requiresArg = true; } for (const optionProp of Object.keys(optionProps).filter(opt => !['negativeAlias'].includes(opt))) { const optionValue = (optionProps as any)[optionProp]; if (optionValue instanceof Expression) { optionArgs[optionProp] = optionValue; } else { optionArgs[optionProp] = lit(optionValue); } } // Register the option with yargs optionsExpr = optionsExpr.callMethod('option', lit(option), code.expr.object(optionArgs)); // Special case for negativeAlias // We need an additional option and a middleware: // .option('R', { type: 'boolean', hidden: true }).middleware(yargsNegativeAlias('R', 'rollback'), true) if (theOption.negativeAlias) { const middleware = helpers.yargsNegativeAlias.call(lit(theOption.negativeAlias), lit(option)); optionsExpr = optionsExpr.callMethod('option', lit(theOption.negativeAlias), code.expr.lit({ type: 'boolean', hidden: true, })); optionsExpr = optionsExpr.callMethod('middleware', middleware, lit(true)); } } return optionsExpr; } function makeEpilogue(prefix: Expression, helpers: CliHelpers) { let completeDefinition = prefix.callMethod('version', helpers.cliVersion()); completeDefinition = completeDefinition.callMethod('demandCommand', lit(1), lit('')); // just print help completeDefinition = completeDefinition.callMethod('recommendCommands'); completeDefinition = completeDefinition.callMethod('help'); completeDefinition = completeDefinition.callMethod('alias', lit('h'), lit('help')); completeDefinition = completeDefinition.callMethod('epilogue', lit([ 'If your app has a single stack, there is no need to specify the stack name', 'If one of cdk.json or ~/.cdk.json exists, options specified there will be used as defaults. Settings in cdk.json take precedence.', ].join('\n\n'))); completeDefinition = completeDefinition.callMethod('parse', code.expr.ident('args')); return completeDefinition; }