diff --git a/package.json b/package.json index 2fcccfc4d5..72aac224b2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/tao/package.json b/packages/tao/package.json index f547a9ffc1..200ab51b37 100644 --- a/packages/tao/package.json +++ b/packages/tao/package.json @@ -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" } } diff --git a/packages/tao/src/commands/generate.ts b/packages/tao/src/commands/generate.ts index 0934d5ce13..84dd5e9bed 100644 --- a/packages/tao/src/commands/generate.ts +++ b/packages/tao/src/commands/generate.ts @@ -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 -): Promise { - 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, - fileName: string -): Promise { - 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, root: string, @@ -343,44 +287,63 @@ async function runSchematic( workflow: NodeWorkflow, logger: logging.Logger, opts: GenerateOptions, - schematic: Schematic + schematic: Schematic, + allowAdditionalArgs: boolean = false ): Promise { - 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 ); }); } diff --git a/packages/tao/src/shared/detect-package-manager.ts b/packages/tao/src/shared/detect-package-manager.ts new file mode 100644 index 0000000000..2ee76eeaab --- /dev/null +++ b/packages/tao/src/shared/detect-package-manager.ts @@ -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 +): Promise { + 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, + fileName: string +): Promise { + 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; + } +} diff --git a/packages/tao/src/shared/params.spec.ts b/packages/tao/src/shared/params.spec.ts index 3382eeab9a..fa88b8abff 100644 --- a/packages/tao/src/shared/params.spec.ts +++ b/packages/tao/src/shared/params.spec.ts @@ -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: [] + } + ] + }); }); }); }); diff --git a/packages/tao/src/shared/params.ts b/packages/tao/src/shared/params.ts index 5a6ba06568..f46d8e72ea 100644 --- a/packages/tao/src/shared/params.ts +++ b/packages/tao/src/shared/params.ts @@ -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 + ); +} diff --git a/yarn.lock b/yarn.lock index 2bfc7ccf26..cbb4ab0db2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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=