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

827 lines
21 KiB
TypeScript

import type { Arguments } from 'yargs-parser';
import { TargetConfiguration, WorkspaceJsonConfiguration } from './workspace';
import { logger } from './logger';
type PropertyDescription = {
type?: string;
required?: string[];
enum?: string[];
properties?: any;
oneOf?: PropertyDescription[];
anyOf?: PropertyDescription[];
allOf?: PropertyDescription[];
items?: any;
alias?: string;
aliases?: string[];
description?: string;
format?: string;
visible?: boolean;
default?:
| string
| number
| boolean
| string[]
| { [key: string]: string | number | boolean | string[] };
$ref?: string;
$default?: { $source: 'argv'; index: number } | { $source: 'projectName' };
additionalProperties?: boolean;
'x-prompt'?:
| string
| { message: string; type: string; items: any[]; multiselect?: boolean };
'x-deprecated'?: boolean | string;
// Numbers Only
multipleOf?: number;
minimum?: number;
exclusiveMinimum?: number;
maximum?: number;
exclusiveMaximum?: number;
// Strings Only
pattern?: string;
minLength?: number;
maxLength?: number;
};
type Properties = {
[p: string]: PropertyDescription;
};
export type Schema = {
properties: Properties;
required?: string[];
description?: string;
definitions?: Properties;
additionalProperties?: boolean;
};
export type Unmatched = {
name: string;
possible: string[];
};
export type Options = {
'--'?: Unmatched[];
[k: string]: string | number | boolean | string[] | Unmatched[];
};
export async function handleErrors(isVerbose: boolean, fn: Function) {
try {
return await fn();
} catch (err) {
if (err.constructor.name === 'UnsuccessfulWorkflowExecution') {
logger.error('The generator workflow failed. See above.');
} else if (err.message) {
logger.error(err.message);
} else {
logger.error(err);
}
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: Arguments): 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) => {
const prop = findSchemaForProperty(k, schema);
opts[k] = coerceType(prop?.description, opts[k]);
});
return opts;
}
function coerceType(prop: PropertyDescription | undefined, value: any) {
if (!prop) return value;
if (typeof value !== 'string' && value !== undefined) return value;
if (prop.oneOf) {
for (let i = 0; i < prop.oneOf.length; ++i) {
const coerced = coerceType(prop.oneOf[i], value);
if (coerced !== value) {
return coerced;
}
}
return value;
} else if (
normalizedPrimitiveType(prop.type) == 'boolean' &&
isConvertibleToBoolean(value)
) {
return value === true || value == 'true';
} else if (
normalizedPrimitiveType(prop.type) == 'number' &&
isConvertibleToNumber(value)
) {
return Number(value);
} else if (prop.type == 'array') {
return value.split(',').map((v) => coerceType(prop.items, v));
} 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) => {
const prop = findSchemaForProperty(k, schema);
if (prop) {
acc[prop.name] = 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.additionalProperties,
schema.definitions || {}
);
}
export function validateObject(
opts: { [k: string]: any },
properties: Properties,
required: string[],
additionalProperties: boolean | undefined,
definitions: Properties
) {
required.forEach((p) => {
if (opts[p] === undefined) {
throw new SchemaError(`Required property '${p}' is missing`);
}
});
if (additionalProperties === false) {
Object.keys(opts).find((p) => {
if (Object.keys(properties).indexOf(p) === -1) {
throw new SchemaError(`'${p}' is not found in schema`);
}
});
}
Object.keys(opts).forEach((p) => {
validateProperty(p, opts[p], properties[p], definitions);
});
}
function validateProperty(
propName: string,
value: any,
schema: PropertyDescription,
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.`);
const passes =
schema.oneOf.filter((r) => {
try {
const rule = { type: schema.type, ...r };
validateProperty(propName, value, rule, definitions);
return true;
} catch (e) {
return false;
}
}).length === 1;
if (!passes) throwInvalidSchema(propName, schema);
return;
}
if (schema.anyOf) {
if (!Array.isArray(schema.anyOf))
throw new Error(`Invalid schema file. anyOf must be an array.`);
let passes = false;
schema.anyOf.forEach((r) => {
try {
const rule = { type: schema.type, ...r };
validateProperty(propName, value, rule, definitions);
passes = true;
} catch (e) {}
});
if (!passes) throwInvalidSchema(propName, schema);
return;
}
if (schema.allOf) {
if (!Array.isArray(schema.allOf))
throw new Error(`Invalid schema file. anyOf must be an array.`);
if (
!schema.allOf.every((r) => {
try {
const rule = { type: schema.type, ...r };
validateProperty(propName, value, rule, definitions);
return true;
} catch (e) {
return false;
}
})
) {
throwInvalidSchema(propName, schema);
}
return;
}
const isPrimitive = typeof value !== 'object';
if (isPrimitive) {
if (schema.type && typeof value !== normalizedPrimitiveType(schema.type)) {
throw new SchemaError(
`Property '${propName}' does not match the schema. '${value}' should be a '${schema.type}'.`
);
}
if (schema.enum && !schema.enum.includes(value)) {
throw new SchemaError(
`Property '${propName}' does not match the schema. '${value}' should be one of ${schema.enum.join(
','
)}.`
);
}
if (schema.type === 'number') {
if (
typeof schema.multipleOf === 'number' &&
value % schema.multipleOf !== 0
) {
throw new SchemaError(
`Property '${propName}' does not match the schema. ${value} should be a multiple of ${schema.multipleOf}.`
);
}
if (typeof schema.minimum === 'number' && value < schema.minimum) {
throw new SchemaError(
`Property '${propName}' does not match the schema. ${value} should be at least ${schema.minimum}`
);
}
if (
typeof schema.exclusiveMinimum === 'number' &&
value <= schema.exclusiveMinimum
) {
throw new SchemaError(
`Property '${propName}' does not match the schema. ${value} should be greater than ${schema.exclusiveMinimum}`
);
}
if (typeof schema.maximum === 'number' && value > schema.maximum) {
throw new SchemaError(
`Property '${propName}' does not match the schema. ${value} should be at most ${schema.maximum}`
);
}
if (
typeof schema.exclusiveMaximum === 'number' &&
value >= schema.exclusiveMaximum
) {
throw new SchemaError(
`Property '${propName}' does not match the schema. ${value} should be less than ${schema.exclusiveMaximum}`
);
}
}
if (schema.type === 'string') {
if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
throw new SchemaError(
`Property '${propName}' does not match the schema. '${value}' should match the pattern '${schema.pattern}'.`
);
}
if (
typeof schema.minLength === 'number' &&
value.length < schema.minLength
) {
throw new SchemaError(
`Property '${propName}' does not match the schema. '${value}' (${value.length} character(s)) should have at least ${schema.minLength} character(s).`
);
}
if (
typeof schema.maxLength === 'number' &&
value.length > schema.maxLength
) {
throw new SchemaError(
`Property '${propName}' does not match the schema. '${value}' (${value.length} character(s)) should have at most ${schema.maxLength} character(s).`
);
}
}
} 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 || [],
schema.additionalProperties,
definitions
);
}
}
/**
* Unfortunately, due to use supporting Angular Devkit, we have to do the following
* conversions.
*/
function normalizedPrimitiveType(type: string) {
if (type === 'integer') return 'number';
return type;
}
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 {
const wasUndefined = opts[propName] === undefined;
if (wasUndefined) {
// We need an object to set values onto
opts[propName] = {};
}
setDefaultsInObject(opts[propName], schema.properties || {}, definitions);
// If the property was initially undefined but no properties were added, we remove it again instead of having an {}
if (wasUndefined && Object.keys(opts[propName]).length === 0) {
delete opts[propName];
}
}
}
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 applyVerbosity(
options: Record<string, unknown>,
schema: Schema,
isVerbose: boolean
) {
if (
(schema.additionalProperties || 'verbose' in schema.properties) &&
isVerbose
) {
options['verbose'] = true;
}
}
export function combineOptionsForExecutor(
commandLineOpts: Options,
config: string,
target: TargetConfiguration,
schema: Schema,
defaultProjectName: string | null,
relativeCwd: string | null,
isVerbose = false
) {
const r = convertAliases(
coerceTypesInOptions(commandLineOpts, schema),
schema,
false
);
let combined = target.options || {};
if (config && target.configurations && target.configurations[config]) {
Object.assign(combined, target.configurations[config]);
}
combined = convertAliases(combined, schema, false);
Object.assign(combined, r);
convertSmartDefaultsIntoNamedParams(
combined,
schema,
(commandLineOpts['_'] as string[]) || [],
defaultProjectName,
relativeCwd
);
warnDeprecations(combined, schema);
setDefaults(combined, schema);
validateOptsAgainstSchema(combined, schema);
applyVerbosity(combined, schema, isVerbose);
return combined;
}
export async function combineOptionsForGenerator(
commandLineOpts: Options,
collectionName: string,
generatorName: string,
wc: WorkspaceJsonConfiguration | null,
schema: Schema,
isInteractive: boolean,
defaultProjectName: string | null,
relativeCwd: string | null,
isVerbose = false
) {
const generatorDefaults = wc
? getGeneratorDefaults(
defaultProjectName,
wc,
collectionName,
generatorName
)
: {};
let combined = convertAliases(
coerceTypesInOptions({ ...generatorDefaults, ...commandLineOpts }, schema),
schema,
false
);
convertSmartDefaultsIntoNamedParams(
combined,
schema,
(commandLineOpts['_'] as string[]) || [],
defaultProjectName,
relativeCwd
);
if (isInteractive && isTTY()) {
combined = await promptForValues(combined, schema);
}
warnDeprecations(combined, schema);
setDefaults(combined, schema);
validateOptsAgainstSchema(combined, schema);
applyVerbosity(combined, schema, isVerbose);
return combined;
}
export function warnDeprecations(
opts: { [k: string]: any },
schema: Schema
): void {
Object.keys(opts).forEach((option) => {
const deprecated = schema.properties[option]?.['x-deprecated'];
if (deprecated) {
logger.warn(
`Option "${option}" is deprecated${
typeof deprecated == 'string' ? ': ' + deprecated : '.'
}`
);
}
});
}
export function convertSmartDefaultsIntoNamedParams(
opts: { [k: string]: any },
schema: Schema,
argv: string[],
defaultProjectName: string | null,
relativeCwd: string | null
) {
Object.entries(schema.properties).forEach(([k, v]) => {
if (
opts[k] === undefined &&
v.$default !== undefined &&
v.$default.$source === 'argv' &&
argv[v.$default.index]
) {
opts[k] = coerceType(v, argv[v.$default.index]);
} else if (
opts[k] === undefined &&
v.$default !== undefined &&
v.$default.$source === 'projectName' &&
defaultProjectName
) {
opts[k] = defaultProjectName;
} else if (
opts[k] === undefined &&
v.format === 'path' &&
v.visible === false &&
relativeCwd
) {
opts[k] = relativeCwd.replace(/\\/g, '/');
}
});
delete opts['_'];
}
function getGeneratorDefaults(
projectName: string | null,
wc: WorkspaceJsonConfiguration,
collectionName: string,
generatorName: string
) {
let defaults = {};
if (wc.generators) {
if (
wc.generators[collectionName] &&
wc.generators[collectionName][generatorName]
) {
defaults = {
...defaults,
...wc.generators[collectionName][generatorName],
};
}
if (wc.generators[`${collectionName}:${generatorName}`]) {
defaults = {
...defaults,
...wc.generators[`${collectionName}:${generatorName}`],
};
}
}
if (
projectName &&
wc.projects[projectName] &&
wc.projects[projectName].generators
) {
const g = wc.projects[projectName].generators;
if (g[collectionName] && g[collectionName][generatorName]) {
defaults = { ...defaults, ...g[collectionName][generatorName] };
}
if (g[`${collectionName}:${generatorName}`]) {
defaults = {
...defaults,
...g[`${collectionName}:${generatorName}`],
};
}
}
return defaults;
}
async function promptForValues(opts: Options, schema: Schema) {
interface Prompt {
name: string;
type: 'input' | 'select' | 'multiselect' | 'confirm' | 'numeral';
message: string;
initial?: any;
choices?: (string | { name: string; message: string })[];
}
const prompts: Prompt[] = [];
Object.entries(schema.properties).forEach(([k, v]) => {
if (v['x-prompt'] && opts[k] === undefined) {
const question: Prompt = {
name: k,
} as any;
if (v.default) {
question.initial = v.default;
}
if (typeof v['x-prompt'] === 'string') {
question.message = v['x-prompt'];
if (v.type === 'string' && v.enum && Array.isArray(v.enum)) {
question.type = 'select';
question.choices = [...v.enum];
} else {
question.type = v.type === 'boolean' ? 'confirm' : 'input';
}
} else if (v['x-prompt'].type == 'number') {
question.message = v['x-prompt'].message;
question.type = 'numeral';
} 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 = v['x-prompt'].multiselect ? 'multiselect' : 'select';
question.choices =
v['x-prompt'].items &&
v['x-prompt'].items.map((item) => {
if (typeof item == 'string') {
return item;
} else {
return {
message: item.label,
name: item.value,
};
}
});
}
prompts.push(question);
}
});
return await (
await import('enquirer')
)
.prompt(prompts)
.then((values) => ({ ...opts, ...values }))
.catch((e) => {
console.error(e);
process.exit(0);
});
}
/**
* 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) => levenshtein(p, unmatched.name) < 3
);
});
}
return opts;
}
function findSchemaForProperty(
propName: string,
schema: Schema
): { name: string; description: PropertyDescription } | null {
if (propName in schema.properties) {
return {
name: propName,
description: schema.properties[propName],
};
}
const found = Object.entries(schema.properties).find(
([_, d]) =>
d.alias === propName ||
(Array.isArray(d.aliases) && d.aliases.includes(propName))
);
if (found) {
const [name, description] = found;
return { name, description };
}
return null;
}
function levenshtein(a: string, b: string) {
if (a.length == 0) {
return b.length;
}
if (b.length == 0) {
return a.length;
}
const matrix = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) == a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j] + 1
);
}
}
}
return matrix[b.length][a.length];
}
function isTTY(): boolean {
return !!process.stdout.isTTY && process.env['CI'] !== 'true';
}
/**
* Verifies whether the given value can be converted to a boolean
* @param value
*/
function isConvertibleToBoolean(value) {
if ('boolean' === typeof value) {
return true;
}
if ('string' === typeof value && /true|false/.test(value)) {
return true;
}
return false;
}
/**
* Verifies whether the given value can be converted to a number
* @param value
*/
function isConvertibleToNumber(value) {
// exclude booleans explicitly
if ('boolean' === typeof value) {
return false;
}
return !isNaN(+value);
}