feat(nx): add option validation to @nrwl/tao/generate with bonus levenshtein lookup
This commit is contained in:
parent
01c88fdb5b
commit
5a398a6870
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
59
packages/tao/src/shared/detect-package-manager.ts
Normal file
59
packages/tao/src/shared/detect-package-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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: []
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@ -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=
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user