446 lines
11 KiB
TypeScript
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';
|
|
}
|