nx/packages/tao/src/shared/params.ts

446 lines
11 KiB
TypeScript

import { strings } from '@angular-devkit/core';
import { ParsedArgs } from 'minimist';
import { TargetConfiguration, WorkspaceConfiguration } from './workspace';
import * as inquirer from 'inquirer';
type Properties = {
[p: string]: {
type?: string;
properties?: any;
oneOf?: any;
items?: any;
alias?: string;
description?: string;
default?: string | number | boolean | string[];
$ref?: string;
$default?: { $source: 'argv'; index: number };
'x-prompt'?: string | { message: string; type: string; items: any[] };
};
};
export type Schema = {
properties: Properties;
required?: string[];
description?: string;
definitions?: Properties;
};
export type Unmatched = {
name: string;
possible: string[];
};
export type Options = {
'--'?: Unmatched[];
[k: string]: string | number | boolean | string[] | Unmatched[];
};
export async function handleErrors(
logger: Console,
isVerbose: boolean,
fn: Function
) {
try {
return await fn();
} catch (err) {
if (err.constructor.name === 'UnsuccessfulWorkflowExecution') {
logger.error('The generator workflow failed. See above.');
} else {
logger.error(err.message);
}
if (isVerbose && err.stack) {
logger.info(err.stack);
}
return 1;
}
}
function camelCase(input: string): string {
if (input.indexOf('-') > 1) {
return input
.toLowerCase()
.replace(/-(.)/g, (match, group1) => group1.toUpperCase());
} else {
return input;
}
}
export function convertToCamelCase(parsed: ParsedArgs): Options {
return Object.keys(parsed).reduce(
(m, c) => ({ ...m, [camelCase(c)]: parsed[c] }),
{}
);
}
/**
* Coerces (and replaces) options identified as 'boolean' or 'number' in the Schema
*
* @param opts The options to check
* @param schema The schema definition with types to check against
*
*/
export function coerceTypesInOptions(opts: Options, schema: Schema): Options {
Object.keys(opts).forEach((k) => {
opts[k] = coerceType(
schema.properties[k] ? schema.properties[k].type : 'unknown',
opts[k]
);
});
return opts;
}
function coerceType(type: string, value: any) {
if (type == 'boolean') {
return value === true || value == 'true';
} else if (type == 'number') {
return Number(value);
} else if (type == 'array') {
return value.toString().split(',');
} else {
return value;
}
}
/**
* Converts any options passed in with short aliases to their full names if found
* Unmatched options are added to opts['--']
*
* @param opts The options passed in by the user
* @param schema The schema definition to check against
*/
export function convertAliases(
opts: Options,
schema: Schema,
excludeUnmatched: boolean
): Options {
return Object.keys(opts).reduce((acc, k) => {
if (schema.properties[k]) {
acc[k] = opts[k];
} else {
const found = Object.entries(schema.properties).find(
([_, d]) => d.alias === k
);
if (found) {
acc[found[0]] = opts[k];
} else if (excludeUnmatched) {
if (!acc['--']) {
acc['--'] = [];
}
acc['--'].push({
name: k,
possible: [],
});
} else {
acc[k] = opts[k];
}
}
return acc;
}, {});
}
export class SchemaError {
constructor(public readonly message: string) {}
}
export function validateOptsAgainstSchema(
opts: { [k: string]: any },
schema: Schema
) {
validateObject(
opts,
schema.properties || {},
schema.required || [],
schema.definitions || {}
);
}
export function validateObject(
opts: { [k: string]: any },
properties: Properties,
required: string[],
definitions: Properties
) {
required.forEach((p) => {
if (opts[p] === undefined) {
throw new SchemaError(`Required property '${p}' is missing`);
}
});
Object.keys(opts).forEach((p) => {
validateProperty(p, opts[p], properties[p], definitions);
});
}
function validateProperty(
propName: string,
value: any,
schema: any,
definitions: Properties
) {
if (!schema) return;
if (schema.$ref) {
schema = resolveDefinition(schema.$ref, definitions);
}
if (schema.oneOf) {
if (!Array.isArray(schema.oneOf))
throw new Error(`Invalid schema file. oneOf must be an array.`);
let passes = false;
schema.oneOf.forEach((r) => {
try {
validateProperty(propName, value, r, definitions);
passes = true;
} catch (e) {}
});
if (!passes) throwInvalidSchema(propName, schema);
return;
}
const isPrimitive = typeof value !== 'object';
if (isPrimitive) {
if (typeof value !== schema.type) {
throw new SchemaError(
`Property '${propName}' does not match the schema. '${value}' should be a '${schema.type}'.`
);
}
} else if (Array.isArray(value)) {
if (schema.type !== 'array') throwInvalidSchema(propName, schema);
value.forEach((valueInArray) =>
validateProperty(propName, valueInArray, schema.items || {}, definitions)
);
} else {
if (schema.type !== 'object') throwInvalidSchema(propName, schema);
validateObject(
value,
schema.properties || {},
schema.required || [],
definitions
);
}
}
function throwInvalidSchema(propName: string, schema: any) {
throw new SchemaError(
`Property '${propName}' does not match the schema.\n${JSON.stringify(
schema,
null,
2
)}'`
);
}
export function setDefaults(opts: { [k: string]: any }, schema: Schema) {
setDefaultsInObject(opts, schema.properties || {}, schema.definitions || {});
return opts;
}
function setDefaultsInObject(
opts: { [k: string]: any },
properties: Properties,
definitions: Properties
) {
Object.keys(properties).forEach((p) => {
setPropertyDefault(opts, p, properties[p], definitions);
});
}
function setPropertyDefault(
opts: { [k: string]: any },
propName: string,
schema: any,
definitions: Properties
) {
if (schema.$ref) {
schema = resolveDefinition(schema.$ref, definitions);
}
if (schema.type !== 'object' && schema.type !== 'array') {
if (opts[propName] === undefined && schema.default !== undefined) {
opts[propName] = schema.default;
}
} else if (schema.type === 'array') {
const items = schema.items || {};
if (
opts[propName] &&
Array.isArray(opts[propName]) &&
items.type === 'object'
) {
opts[propName].forEach((valueInArray) =>
setDefaultsInObject(valueInArray, items.properties || {}, definitions)
);
} else if (!opts[propName] && schema.default) {
opts[propName] = schema.default;
}
} else {
if (!opts[propName]) {
opts[propName] = {};
}
setDefaultsInObject(opts[propName], schema.properties || {}, definitions);
}
}
function resolveDefinition(ref: string, definitions: Properties) {
if (!ref.startsWith('#/definitions/')) {
throw new Error(`$ref should start with "#/definitions/"`);
}
const definition = ref.split('#/definitions/')[1];
if (!definitions[definition]) {
throw new Error(`Cannot resolve ${ref}`);
}
return definitions[definition];
}
export function convertPositionParamsIntoNamedParams(
opts: { [k: string]: any },
schema: Schema,
argv: string[]
) {
Object.entries(schema.properties).forEach(([k, v]) => {
if (
opts[k] === undefined &&
v.$default !== undefined &&
argv[v.$default.index]
) {
opts[k] = coerceType(v.type, argv[v.$default.index]);
}
});
}
export function combineOptionsForExecutor(
commandLineOpts: Options,
config: string,
target: TargetConfiguration,
schema: Schema
) {
const r = convertAliases(
coerceTypesInOptions(commandLineOpts, schema),
schema,
false
);
const configOpts =
config && target.configurations ? target.configurations[config] || {} : {};
const combined = { ...target.options, ...configOpts, ...r };
convertPositionParamsIntoNamedParams(
combined,
schema,
(commandLineOpts['_'] as string[]) || []
);
setDefaults(combined, schema);
validateOptsAgainstSchema(combined, schema);
return combined;
}
export async function combineOptionsForGenerator(
commandLineOpts: Options,
collectionName: string,
generatorName: string,
ws: WorkspaceConfiguration | null,
schema: Schema,
isInteractive: boolean
) {
const generatorDefaults = ws
? getGeneratorDefaults(ws, collectionName, generatorName)
: {};
let combined = convertAliases(
coerceTypesInOptions({ ...generatorDefaults, ...commandLineOpts }, schema),
schema,
false
);
convertPositionParamsIntoNamedParams(
combined,
schema,
(commandLineOpts['_'] as string[]) || []
);
if (isInteractive && isTTY()) {
combined = await promptForValues(combined, schema);
}
setDefaults(combined, schema);
validateOptsAgainstSchema(combined, schema);
return combined;
}
function getGeneratorDefaults(
ws: WorkspaceConfiguration,
collectionName: string,
generatorName: string
) {
if (!ws.generators) return {};
if (
ws.generators[collectionName] &&
ws.generators[collectionName][generatorName]
) {
return ws.generators[collectionName][generatorName];
} else if (ws.generators[`${collectionName}:${generatorName}`]) {
return ws.generators[`${collectionName}:${generatorName}`];
} else {
return {};
}
}
async function promptForValues(opts: Options, schema: Schema) {
const prompts = [];
Object.entries(schema.properties).forEach(([k, v]) => {
if (v['x-prompt'] && opts[k] === undefined) {
const question = {
name: k,
default: v.default,
} as any;
if (typeof v['x-prompt'] === 'string') {
question.message = v['x-prompt'];
question.type = v.type;
} else if (
v['x-prompt'].type == 'confirmation' ||
v['x-prompt'].type == 'confirm'
) {
question.message = v['x-prompt'].message;
question.type = 'confirm';
} else {
question.message = v['x-prompt'].message;
question.type = 'list';
question.choices =
v['x-prompt'].items &&
v['x-prompt'].items.map((item) => {
if (typeof item == 'string') {
return item;
} else {
return {
name: item.label,
value: item.value,
};
}
});
}
prompts.push(question);
}
});
return await inquirer
.prompt(prompts)
.then((values) => ({ ...opts, ...values }));
}
/**
* Tries to find what the user meant by unmatched commands
*
* @param opts The options passed in by the user
* @param schema The schema definition to check against
*
*/
export function lookupUnmatched(opts: Options, schema: Schema): Options {
if (opts['--']) {
const props = Object.keys(schema.properties);
opts['--'].forEach((unmatched) => {
unmatched.possible = props.filter(
(p) => strings.levenshtein(p, unmatched.name) < 3
);
});
}
return opts;
}
function isTTY(): boolean {
return !!process.stdout.isTTY && process.env['CI'] !== 'true';
}