feat(nx): add option validation to @nrwl/tao/generate with bonus levenshtein lookup

This commit is contained in:
Jo Hanna Pearce 2019-10-10 14:16:20 +01:00 committed by Victor Savkin
parent 01c88fdb5b
commit 5a398a6870
7 changed files with 269 additions and 103 deletions

View File

@ -60,6 +60,7 @@
"@schematics/angular": "8.3.3",
"@testing-library/react": "9.2.0",
"@types/express": "4.17.0",
"@types/fast-levenshtein": "^0.0.1",
"@types/jasmine": "~2.8.6",
"@types/jasminewd2": "~2.0.3",
"@types/jest": "24.0.9",
@ -101,6 +102,7 @@
"eslint-plugin-react": "^7.14.3",
"eslint-plugin-react-hooks": "^1.7.0",
"express": "4.17.1",
"fast-levenshtein": "^2.0.6",
"fork-ts-checker-webpack-plugin": "^0.4.9",
"fs-extra": "7.0.1",
"html-webpack-plugin": "^3.2.0",

View File

@ -34,6 +34,7 @@
"@angular-devkit/architect": "0.803.3",
"inquirer": "^6.3.1",
"minimist": "^1.2.0",
"strip-json-comments": "2.0.1"
"strip-json-comments": "2.0.1",
"fast-levenshtein": "2.0.6"
}
}

View File

@ -19,16 +19,15 @@ import {
NodeWorkflow,
validateOptionsWithSchema
} from '@angular-devkit/schematics/tools';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as inquirer from 'inquirer';
import { detectPackageManager } from '../shared/detect-package-manager';
import { getLogger } from '../shared/logger';
import {
coerceTypes,
convertAliases,
convertToCamelCase,
handleErrors,
Schema
Schema,
validateOptions
} from '../shared/params';
import { commandName, printHelp } from '../shared/print-help';
import minimist = require('minimist');
@ -148,61 +147,6 @@ function createRecorder(record: any, logger: logging.Logger) {
};
}
async function detectPackageManager(
host: virtualFs.Host<any>
): Promise<string> {
const hostTree = new HostTree(host);
if (hostTree.get('workspace.json')) {
const workspaceJson: { cli: { packageManager: string } } = JSON.parse(
hostTree.read('workspace.json')!.toString()
);
if (workspaceJson.cli && workspaceJson.cli.packageManager) {
return workspaceJson.cli.packageManager;
}
}
if (await fileExists(host, 'yarn.lock')) {
return 'yarn';
}
if (await fileExists(host, 'pnpm-lock.yaml')) {
return 'pnpm';
}
if (await fileExists(host, 'package-lock.json')) {
return 'npm';
}
// If we get here, there are no lock files, so lets check for package managers in our preferred order
if (isPackageManagerInstalled('yarn')) {
return 'yarn';
}
if (isPackageManagerInstalled('pnpm')) {
return 'pnpm';
}
return 'npm';
}
function fileExists(
host: virtualFs.Host<any>,
fileName: string
): Promise<boolean> {
return host.exists(fileName as any).toPromise();
}
function isPackageManagerInstalled(packageManager: string) {
try {
execSync(`${packageManager} --version`, {
stdio: ['ignore', 'ignore', 'ignore']
});
return true;
} catch (e) {
return false;
}
}
async function createWorkflow(
fsHost: virtualFs.Host<fs.Stats>,
root: string,
@ -343,44 +287,63 @@ async function runSchematic(
workflow: NodeWorkflow,
logger: logging.Logger,
opts: GenerateOptions,
schematic: Schematic<any, any>
schematic: Schematic<any, any>,
allowAdditionalArgs: boolean = false
): Promise<number> {
const flattenedSchema = await workflow.registry
const flattenedSchema = (await workflow.registry
.flatten(schematic.description.schemaJson!)
.toPromise();
.toPromise()) as Schema;
if (opts.help) {
printGenHelp(opts, flattenedSchema as any, logger);
} else {
const defaults =
opts.schematicName === 'tao-new'
? {}
: await getSchematicDefaults(
root,
opts.collectionName,
opts.schematicName
);
const record = { loggingQueue: [] as string[], error: false };
workflow.reporter.subscribe(createRecorder(record, logger));
const schematicOptions = convertAliases(
coerceTypes(opts.schematicOptions, flattenedSchema as any),
flattenedSchema as any
);
await workflow
.execute({
collection: opts.collectionName,
schematic: opts.schematicName,
options: { ...defaults, ...schematicOptions },
debug: opts.debug,
logger
})
.toPromise();
if (!record.error) {
record.loggingQueue.forEach(log => logger.info(log));
}
if (opts.dryRun) {
logger.warn(`\nNOTE: The "dryRun" flag means no changes were made.`);
}
return 0;
}
const defaults =
opts.schematicName === 'tao-new'
? {}
: await getSchematicDefaults(
root,
opts.collectionName,
opts.schematicName
);
const record = { loggingQueue: [] as string[], error: false };
workflow.reporter.subscribe(createRecorder(record, logger));
const schematicOptions = validateOptions(
opts.schematicOptions,
flattenedSchema
);
if (schematicOptions['--'] && !allowAdditionalArgs) {
schematicOptions['--'].forEach(unmatched => {
const message =
`Could not match option '${unmatched.name}' to the ${opts.collectionName}:${opts.schematicName} schema.` +
(unmatched.possible.length > 0
? ` Possible matches : ${unmatched.possible.join()}`
: '');
logger.fatal(message);
});
return 1;
}
await workflow
.execute({
collection: opts.collectionName,
schematic: opts.schematicName,
options: { ...defaults, ...schematicOptions },
debug: opts.debug,
logger
})
.toPromise();
if (!record.error) {
record.loggingQueue.forEach(log => logger.info(log));
}
if (opts.dryRun) {
logger.warn(`\nNOTE: The "dryRun" flag means no changes were made.`);
}
return 0;
}
@ -402,6 +365,7 @@ export async function generate(
'generate',
await readDefaultCollection(fsHost)
);
const workflow = await createWorkflow(fsHost, root, opts);
const collection = getCollection(workflow, opts.collectionName);
const schematic = collection.createSchematic(opts.schematicName, true);
@ -438,12 +402,14 @@ export async function taoNew(
const workflow = await createWorkflow(fsHost, root, opts);
const collection = getCollection(workflow, opts.collectionName);
const schematic = collection.createSchematic('tao-new', true);
const allowAdditionalArgs = true; // tao-new is a special case, we can't yet know the schema to validate against
return runSchematic(
root,
workflow,
logger,
{ ...opts, schematicName: schematic.description.name },
schematic
schematic,
allowAdditionalArgs
);
});
}

View File

@ -0,0 +1,59 @@
import { virtualFs } from '@angular-devkit/core';
import { HostTree } from '@angular-devkit/schematics';
import { execSync } from 'child_process';
export async function detectPackageManager(
host: virtualFs.Host<any>
): Promise<string> {
const hostTree = new HostTree(host);
if (hostTree.get('workspace.json')) {
const workspaceJson: { cli: { packageManager: string } } = JSON.parse(
hostTree.read('workspace.json')!.toString()
);
if (workspaceJson.cli && workspaceJson.cli.packageManager) {
return workspaceJson.cli.packageManager;
}
}
if (await fileExists(host, 'yarn.lock')) {
return 'yarn';
}
if (await fileExists(host, 'pnpm-lock.yaml')) {
return 'pnpm';
}
if (await fileExists(host, 'package-lock.json')) {
return 'npm';
}
// If we get here, there are no lock files,
// so lets check for package managers in our preferred order
if (isPackageManagerInstalled('yarn')) {
return 'yarn';
}
if (isPackageManagerInstalled('pnpm')) {
return 'pnpm';
}
return 'npm';
}
function fileExists(
host: virtualFs.Host<any>,
fileName: string
): Promise<boolean> {
return host.exists(fileName as any).toPromise();
}
function isPackageManagerInstalled(packageManager: string) {
try {
execSync(`${packageManager} --version`, {
stdio: ['ignore', 'ignore', 'ignore']
});
return true;
} catch (e) {
return false;
}
}

View File

@ -1,4 +1,4 @@
import { convertToCamelCase, convertAliases } from './params';
import { convertAliases, convertToCamelCase, lookupUnmatched } from './params';
describe('params', () => {
describe('convertToCamelCase', () => {
@ -47,7 +47,7 @@ describe('params', () => {
).toEqual({ directory: 'test' });
});
it('should filter out unknown keys without alias', () => {
it('should filter unknown keys into the leftovers field', () => {
expect(
convertAliases(
{ d: 'test' },
@ -57,7 +57,70 @@ describe('params', () => {
description: ''
}
)
).toEqual({});
).toEqual({
'--': [
{
name: 'd',
possible: []
}
]
});
});
});
describe('lookupUnmatched', () => {
it('should populate the possible array with near matches', () => {
expect(
lookupUnmatched(
{
'--': [
{
name: 'directoy',
possible: []
}
]
},
{
properties: { directory: { type: 'string' } },
required: [],
description: ''
}
)
).toEqual({
'--': [
{
name: 'directoy',
possible: ['directory']
}
]
});
});
it('should NOT populate the possible array with far matches', () => {
expect(
lookupUnmatched(
{
'--': [
{
name: 'directoy',
possible: []
}
]
},
{
properties: { faraway: { type: 'string' } },
required: [],
description: ''
}
)
).toEqual({
'--': [
{
name: 'directoy',
possible: []
}
]
});
});
});
});

View File

@ -1,5 +1,6 @@
import { logging } from '@angular-devkit/core';
import { UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics';
import levenshtein = require('fast-levenshtein');
export type Schema = {
properties: { [p: string]: any };
@ -7,6 +8,16 @@ export type Schema = {
description: string;
};
export type Unmatched = {
name: string;
possible: string[];
};
export type Options = {
'--'?: Unmatched[];
[k: string]: any;
};
export async function handleErrors(
logger: logging.Logger,
isVerbose: boolean,
@ -27,9 +38,7 @@ export async function handleErrors(
}
}
export function convertToCamelCase(parsed: {
[k: string]: any;
}): { [k: string]: any } {
export function convertToCamelCase(parsed: Options): Options {
return Object.keys(parsed).reduce(
(m, c) => ({ ...m, [camelCase(c)]: parsed[c] }),
{}
@ -45,7 +54,14 @@ function camelCase(input: string): string {
}
}
export function coerceTypes(opts: { [k: string]: any }, schema: Schema) {
/**
* 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 coerceTypes(opts: Options, schema: Schema): Options {
Object.keys(opts).forEach(k => {
if (schema.properties[k] && schema.properties[k].type == 'boolean') {
opts[k] = opts[k] === true || opts[k] === 'true';
@ -56,7 +72,14 @@ export function coerceTypes(opts: { [k: string]: any }, schema: Schema) {
return opts;
}
export function convertAliases(opts: { [k: string]: any }, schema: Schema) {
/**
* 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): Options {
return Object.keys(opts).reduce((acc, k) => {
if (schema.properties[k]) {
acc[k] = opts[k];
@ -66,8 +89,55 @@ export function convertAliases(opts: { [k: string]: any }, schema: Schema) {
);
if (found) {
acc[found[0]] = opts[k];
} else {
if (!acc['--']) {
acc['--'] = [];
}
acc['--'].push({
name: k,
possible: []
});
}
}
return acc;
}, {});
}
/**
* 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.get(p, unmatched.name) < 3
);
});
}
return opts;
}
/**
* Converts aliases and coerces types according to the schema
*
* @param opts The options to check
* @param schema The schema definition to validate against
*
* @remarks
*
* Unmatched options are added to opts['--']
* and listed along with possible schema matches
*
*/
export function validateOptions(opts: Options, schema: Schema): Options {
return lookupUnmatched(
convertAliases(coerceTypes(opts, schema), schema),
schema
);
}

View File

@ -1773,6 +1773,11 @@
"@types/express-serve-static-core" "*"
"@types/serve-static" "*"
"@types/fast-levenshtein@^0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@types/fast-levenshtein/-/fast-levenshtein-0.0.1.tgz#3a3615cf173645c8fca58d051e4e32824e4bd286"
integrity sha1-OjYVzxc2Rcj8pY0FHk4ygk5L0oY=
"@types/glob@^7.1.1":
version "7.1.1"
resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575"
@ -6796,7 +6801,7 @@ fast-json-stable-stringify@2.0.0, fast-json-stable-stringify@2.x, fast-json-stab
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I=
fast-levenshtein@~2.0.4:
fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.4:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=