diff --git a/packages/angular_devkit/benchmark/BUILD b/packages/angular_devkit/benchmark/BUILD index 3d914cf54edb..6069d782dc0a 100644 --- a/packages/angular_devkit/benchmark/BUILD +++ b/packages/angular_devkit/benchmark/BUILD @@ -24,6 +24,7 @@ ts_library( module_root = "src/index.d.ts", deps = [ "//packages/angular_devkit/core", + "//packages/angular_devkit/core:node", "@rxjs", "@rxjs//operators", # @typings: node @@ -46,6 +47,7 @@ ts_library( deps = [ ":benchmark", "//packages/angular_devkit/core", + "//packages/angular_devkit/core:node", "@rxjs", "@rxjs//operators", # @typings: jasmine diff --git a/packages/angular_devkit/benchmark/src/main.ts b/packages/angular_devkit/benchmark/src/main.ts index edd39881b35e..2723728c6bc8 100644 --- a/packages/angular_devkit/benchmark/src/main.ts +++ b/packages/angular_devkit/benchmark/src/main.ts @@ -8,6 +8,7 @@ */ import { logging, tags, terminal } from '@angular-devkit/core'; +import { ProcessOutput } from '@angular-devkit/core/node'; import { appendFileSync, writeFileSync } from 'fs'; import * as minimist from 'minimist'; import { filter, map, toArray } from 'rxjs/operators'; @@ -19,8 +20,8 @@ import { runBenchmark } from '../src/run-benchmark'; export interface MainOptions { args: string[]; - stdout?: { write(buffer: string | Buffer): boolean }; - stderr?: { write(buffer: string | Buffer): boolean }; + stdout?: ProcessOutput; + stderr?: ProcessOutput; } export async function main({ diff --git a/packages/angular_devkit/core/node/cli-logger.ts b/packages/angular_devkit/core/node/cli-logger.ts index 8459fd936fa6..0cbbd4c1059e 100644 --- a/packages/angular_devkit/core/node/cli-logger.ts +++ b/packages/angular_devkit/core/node/cli-logger.ts @@ -8,18 +8,25 @@ import { filter } from 'rxjs/operators'; import { logging, terminal } from '../src'; +export interface ProcessOutput { + write(buffer: string | Buffer): boolean; +} /** * A Logger that sends information to STDOUT and STDERR. */ -export function createConsoleLogger(verbose = false): logging.Logger { +export function createConsoleLogger( + verbose = false, + stdout: ProcessOutput = process.stdout, + stderr: ProcessOutput = process.stderr, +): logging.Logger { const logger = new logging.IndentLogger('cling'); logger .pipe(filter(entry => (entry.level != 'debug' || verbose))) .subscribe(entry => { let color: (s: string) => string = x => terminal.dim(terminal.white(x)); - let output = process.stdout; + let output = stdout; switch (entry.level) { case 'info': color = terminal.white; @@ -29,11 +36,11 @@ export function createConsoleLogger(verbose = false): logging.Logger { break; case 'error': color = terminal.red; - output = process.stderr; + output = stderr; break; case 'fatal': color = (x: string) => terminal.bold(terminal.red(x)); - output = process.stderr; + output = stderr; break; } diff --git a/packages/angular_devkit/schematics/src/engine/engine.ts b/packages/angular_devkit/schematics/src/engine/engine.ts index 4222a904ce11..0db56c78c11a 100644 --- a/packages/angular_devkit/schematics/src/engine/engine.ts +++ b/packages/angular_devkit/schematics/src/engine/engine.ts @@ -329,7 +329,7 @@ export class SchematicEngine( diff --git a/packages/angular_devkit/schematics_cli/bin/schematics.ts b/packages/angular_devkit/schematics_cli/bin/schematics.ts index d61b2b5b3269..fd68ef8f95ca 100644 --- a/packages/angular_devkit/schematics_cli/bin/schematics.ts +++ b/packages/angular_devkit/schematics_cli/bin/schematics.ts @@ -17,43 +17,20 @@ import { terminal, virtualFs, } from '@angular-devkit/core'; -import { NodeJsSyncHost, createConsoleLogger } from '@angular-devkit/core/node'; -import { DryRunEvent, UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics'; -import { NodeWorkflow } from '@angular-devkit/schematics/tools'; +import { NodeJsSyncHost, ProcessOutput, createConsoleLogger } from '@angular-devkit/core/node'; +import { + DryRunEvent, + SchematicEngine, + UnsuccessfulWorkflowExecution, +} from '@angular-devkit/schematics'; +import { + FileSystemEngineHost, + NodeModulesEngineHost, + NodeWorkflow, +} from '@angular-devkit/schematics/tools'; import * as minimist from 'minimist'; -/** - * Show usage of the CLI tool, and exit the process. - */ -function usage(exitCode = 0): never { - logger.info(tags.stripIndent` - schematics [CollectionName:]SchematicName [options, ...] - - By default, if the collection name is not specified, use the internal collection provided - by the Schematics CLI. - - Options: - --debug Debug mode. This is true by default if the collection is a relative - path (in that case, turn off with --debug=false). - --allowPrivate Allow private schematics to be run from the command line. Default to - false. - --dry-run Do not output anything, but instead just show what actions would be - performed. Default to true if debug is also true. - --force Force overwriting files that would otherwise be an error. - --list-schematics List all schematics from the collection, by name. - --verbose Show more information. - - --help Show this message. - - Any additional option is passed to the Schematics depending on - `); - - process.exit(exitCode); - throw 0; // The node typing sometimes don't have a never type for process.exit(). -} - - /** * Parse the name of schematic passed in argument, and return a {collection, schematic} named * tuple. The user can pass in `collection-name:schematic-name`, and this function will either @@ -68,192 +45,216 @@ function usage(exitCode = 0): never { * @param str The argument to parse. * @return {{collection: string, schematic: (string)}} */ -function parseSchematicName(str: string | null): { collection: string, schematic: string } { +function parseSchematicName(str: string | null): { collection: string, schematic: string | null } { let collection = '@schematics/schematics'; - if (!str || str === null) { - usage(1); - } - - let schematic: string = str as string; - if (schematic.indexOf(':') != -1) { + let schematic = str; + if (schematic && schematic.indexOf(':') != -1) { [collection, schematic] = schematic.split(':', 2); - - if (!schematic) { - usage(2); - } } return { collection, schematic }; } -/** Parse the command line. */ -const booleanArgs = [ - 'allowPrivate', - 'debug', - 'dry-run', - 'force', - 'help', - 'list-schematics', - 'verbose', -]; -const argv = minimist(process.argv.slice(2), { - boolean: booleanArgs, - default: { - 'debug': null, - 'dry-run': null, - }, - '--': true, -}); - -/** Create the DevKit Logger used through the CLI. */ -const logger = createConsoleLogger(argv['verbose']); - -if (argv.help) { - usage(); +export interface MainOptions { + args: string[]; + stdout?: ProcessOutput; + stderr?: ProcessOutput; } -/** Get the collection an schematic name from the first argument. */ -const { - collection: collectionName, - schematic: schematicName, -} = parseSchematicName(argv._.shift() || null); -const isLocalCollection = collectionName.startsWith('.') || collectionName.startsWith('/'); - +export async function main({ + args, + stdout = process.stdout, + stderr = process.stderr, +}: MainOptions): Promise<0 | 1> { + + /** Parse the command line. */ + const booleanArgs = [ + 'allowPrivate', + 'debug', + 'dry-run', + 'force', + 'help', + 'list-schematics', + 'verbose', + ]; + const argv = minimist(args, { + boolean: booleanArgs, + default: { + 'debug': null, + 'dry-run': null, + }, + '--': true, + }); + + /** Create the DevKit Logger used through the CLI. */ + const logger = createConsoleLogger(argv['verbose'], stdout, stderr); + + if (argv.help) { + logger.info(getUsage()); + + return 0; + } -/** If the user wants to list schematics, we simply show all the schematic names. */ -if (argv['list-schematics']) { - // logger.info(engine.listSchematicNames(collection).join('\n')); - process.exit(0); - throw 0; // TypeScript doesn't know that process.exit() never returns. -} + /** Get the collection an schematic name from the first argument. */ + const { + collection: collectionName, + schematic: schematicName, + } = parseSchematicName(argv._.shift() || null); + const isLocalCollection = collectionName.startsWith('.') || collectionName.startsWith('/'); -/** Gather the arguments for later use. */ -const debug: boolean = argv.debug === null ? isLocalCollection : argv.debug; -const dryRun: boolean = argv['dry-run'] === null ? debug : argv['dry-run']; -const force = argv['force']; -const allowPrivate = argv['allowPrivate']; + /** If the user wants to list schematics, we simply show all the schematic names. */ + if (argv['list-schematics']) { + const engineHost = isLocalCollection + ? new FileSystemEngineHost(normalize(process.cwd())) + : new NodeModulesEngineHost(); -/** Create a Virtual FS Host scoped to where the process is being run. **/ -const fsHost = new virtualFs.ScopedHost(new NodeJsSyncHost(), normalize(process.cwd())); + const engine = new SchematicEngine(engineHost); + const collection = engine.createCollection(collectionName); + logger.info(engine.listSchematicNames(collection).join('\n')); -/** Create the workflow that will be executed with this run. */ -const workflow = new NodeWorkflow(fsHost, { force, dryRun }); + return 0; + } -// Indicate to the user when nothing has been done. This is automatically set to off when there's -// a new DryRunEvent. -let nothingDone = true; + if (!schematicName) { + logger.info(getUsage()); -// Logging queue that receives all the messages to show the users. This only get shown when no -// errors happened. -let loggingQueue: string[] = []; -let error = false; + return 1; + } -/** - * Logs out dry run events. - * - * All events will always be executed here, in order of discovery. That means that an error would - * be shown along other events when it happens. Since errors in workflows will stop the Observable - * from completing successfully, we record any events other than errors, then on completion we - * show them. - * - * This is a simple way to only show errors when an error occur. - */ -workflow.reporter.subscribe((event: DryRunEvent) => { - nothingDone = false; - - switch (event.kind) { - case 'error': - error = true; - - const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist'; - logger.warn(`ERROR! ${event.path} ${desc}.`); - break; - case 'update': - loggingQueue.push(tags.oneLine` + /** Gather the arguments for later use. */ + const debug: boolean = argv.debug === null ? isLocalCollection : argv.debug; + const dryRun: boolean = argv['dry-run'] === null ? debug : argv['dry-run']; + const force = argv['force']; + const allowPrivate = argv['allowPrivate']; + + /** Create a Virtual FS Host scoped to where the process is being run. **/ + const fsHost = new virtualFs.ScopedHost(new NodeJsSyncHost(), normalize(process.cwd())); + + /** Create the workflow that will be executed with this run. */ + const workflow = new NodeWorkflow(fsHost, { force, dryRun }); + + // Indicate to the user when nothing has been done. This is automatically set to off when there's + // a new DryRunEvent. + let nothingDone = true; + + // Logging queue that receives all the messages to show the users. This only get shown when no + // errors happened. + let loggingQueue: string[] = []; + let error = false; + + /** + * Logs out dry run events. + * + * All events will always be executed here, in order of discovery. That means that an error would + * be shown along other events when it happens. Since errors in workflows will stop the Observable + * from completing successfully, we record any events other than errors, then on completion we + * show them. + * + * This is a simple way to only show errors when an error occur. + */ + workflow.reporter.subscribe((event: DryRunEvent) => { + nothingDone = false; + + switch (event.kind) { + case 'error': + error = true; + + const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist'; + logger.warn(`ERROR! ${event.path} ${desc}.`); + break; + case 'update': + loggingQueue.push(tags.oneLine` ${terminal.white('UPDATE')} ${event.path} (${event.content.length} bytes) `); - break; - case 'create': - loggingQueue.push(tags.oneLine` + break; + case 'create': + loggingQueue.push(tags.oneLine` ${terminal.green('CREATE')} ${event.path} (${event.content.length} bytes) `); - break; - case 'delete': - loggingQueue.push(`${terminal.yellow('DELETE')} ${event.path}`); - break; - case 'rename': - loggingQueue.push(`${terminal.blue('RENAME')} ${event.path} => ${event.to}`); - break; - } -}); + break; + case 'delete': + loggingQueue.push(`${terminal.yellow('DELETE')} ${event.path}`); + break; + case 'rename': + loggingQueue.push(`${terminal.blue('RENAME')} ${event.path} => ${event.to}`); + break; + } + }); -/** - * Listen to lifecycle events of the workflow to flush the logs between each phases. - */ -workflow.lifeCycle.subscribe(event => { - if (event.kind == 'workflow-end' || event.kind == 'post-tasks-start') { - if (!error) { - // Flush the log queue and clean the error state. - loggingQueue.forEach(log => logger.info(log)); + /** + * Listen to lifecycle events of the workflow to flush the logs between each phases. + */ + workflow.lifeCycle.subscribe(event => { + if (event.kind == 'workflow-end' || event.kind == 'post-tasks-start') { + if (!error) { + // Flush the log queue and clean the error state. + loggingQueue.forEach(log => logger.info(log)); + } + + loggingQueue = []; + error = false; } + }); - loggingQueue = []; - error = false; - } -}); + /** + * Remove every options from argv that we support in schematics itself. + */ + const parsedArgs = Object.assign({}, argv); + delete parsedArgs['--']; + for (const key of booleanArgs) { + delete parsedArgs[key]; + } -/** - * Remove every options from argv that we support in schematics itself. - */ -const args = Object.assign({}, argv); -delete args['--']; -for (const key of booleanArgs) { - delete args[key]; -} + /** + * Add options from `--` to args. + */ + const argv2 = minimist(argv['--']); + for (const key of Object.keys(argv2)) { + parsedArgs[key] = argv2[key]; + } -/** - * Add options from `--` to args. - */ -const argv2 = minimist(argv['--']); -for (const key of Object.keys(argv2)) { - args[key] = argv2[key]; -} + // Pass the rest of the arguments as the smart default "argv". Then delete it. + workflow.registry.addSmartDefaultProvider('argv', (schema: JsonObject) => { + if ('index' in schema) { + return argv._[Number(schema['index'])]; + } else { + return argv._; + } + }); + delete parsedArgs._; + + + /** + * Execute the workflow, which will report the dry run events, run the tasks, and complete + * after all is done. + * + * The Observable returned will properly cancel the workflow if unsubscribed, error out if ANY + * step of the workflow failed (sink or task), with details included, and will only complete + * when everything is done. + */ + try { + await workflow.execute({ + collection: collectionName, + schematic: schematicName, + options: parsedArgs, + allowPrivate: allowPrivate, + debug: debug, + logger: logger, + }) + .toPromise(); -// Pass the rest of the arguments as the smart default "argv". Then delete it. -workflow.registry.addSmartDefaultProvider('argv', (schema: JsonObject) => { - if ('index' in schema) { - return argv._[Number(schema['index'])]; - } else { - return argv._; - } -}); -delete args._; + if (nothingDone) { + logger.info('Nothing to be done.'); + } + return 0; -/** - * Execute the workflow, which will report the dry run events, run the tasks, and complete - * after all is done. - * - * The Observable returned will properly cancel the workflow if unsubscribed, error out if ANY - * step of the workflow failed (sink or task), with details included, and will only complete - * when everything is done. - */ -workflow.execute({ - collection: collectionName, - schematic: schematicName, - options: args, - allowPrivate: allowPrivate, - debug: debug, - logger: logger, -}) -.subscribe({ - error(err: Error) { - // In case the workflow was not successful, show an appropriate error message. + } catch (err) { if (err instanceof UnsuccessfulWorkflowExecution) { // "See above" because we already printed the error. logger.fatal('The Schematic workflow failed. See above.'); @@ -263,11 +264,46 @@ workflow.execute({ logger.fatal(err.stack || err.message); } - process.exit(1); - }, - complete() { - if (nothingDone) { - logger.info('Nothing to be done.'); - } - }, -}); + return 1; + } +} + + /** + * Get usage of the CLI tool. + */ +function getUsage(): string { + return tags.stripIndent` + schematics [CollectionName:]SchematicName [options, ...] + + By default, if the collection name is not specified, use the internal collection provided + by the Schematics CLI. + + Options: + --debug Debug mode. This is true by default if the collection is a relative + path (in that case, turn off with --debug=false). + + --allowPrivate Allow private schematics to be run from the command line. Default to + false. + + --dry-run Do not output anything, but instead just show what actions would be + performed. Default to true if debug is also true. + + --force Force overwriting files that would otherwise be an error. + + --list-schematics List all schematics from the collection, by name. A collection name + should be suffixed by a colon. Example: '@schematics/schematics:'. + + --verbose Show more information. + + --help Show this message. + + Any additional option is passed to the Schematics depending on + `; +} + +if (require.main === module) { + const args = process.argv.slice(2); + main({ args }) + .then(exitCode => process.exitCode = exitCode) + .catch(e => { throw (e); }); +} diff --git a/packages/angular_devkit/schematics_cli/bin/schematics_spec.ts b/packages/angular_devkit/schematics_cli/bin/schematics_spec.ts new file mode 100644 index 000000000000..905e8831f603 --- /dev/null +++ b/packages/angular_devkit/schematics_cli/bin/schematics_spec.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { main } from './schematics'; + +// We only care about the write method in these mocks of NodeJS.WriteStream. +class MockWriteStream { + lines: string[] = []; + write(str: string) { + // Strip color control characters. + this.lines.push(str.replace(/[^\x20-\x7F]\[\d+m/g, '')); + + return true; + } +} + +describe('schematics-cli binary', () => { + let stdout: MockWriteStream, stderr: MockWriteStream; + + beforeEach(() => { + stdout = new MockWriteStream(); + stderr = new MockWriteStream(); + }); + + it('list-schematics works', async () => { + const args = ['--list-schematics']; + const res = await main({ args, stdout, stderr }); + expect(stdout.lines).toMatch(/blank/); + expect(stdout.lines).toMatch(/schematic/); + expect(res).toEqual(0); + }); + + it('dry-run works', async () => { + const args = ['blank', 'foo', '--dry-run']; + const res = await main({ args, stdout, stderr }); + expect(stdout.lines).toMatch(/CREATE \/foo\/README.md/); + expect(stdout.lines).toMatch(/CREATE \/foo\/.gitignore/); + expect(stdout.lines).toMatch(/CREATE \/foo\/src\/foo\/index.ts/); + expect(stdout.lines).toMatch(/CREATE \/foo\/src\/foo\/index_spec.ts/); + expect(res).toEqual(0); + }); + + it('error when no name is provided', async () => { + const args = ['blank']; + const res = await main({ args, stdout, stderr }); + expect(stderr.lines).toMatch(/Error: name option is required/); + expect(res).toEqual(1); + }); +});