From 558ef0052344f0950b4978c8ad7e9f3963ff8d3d Mon Sep 17 00:00:00 2001 From: Hans Larsen Date: Wed, 13 Feb 2019 11:58:29 -0800 Subject: [PATCH] feat(@angular-devkit/architect-cli): CLI tool to use new Architect API Move the entire Architect CLI to use the new API, and report progress using a progress bar for each worker currently executing. Shows log at the end of the execution. This is meant to be used as a debugging tool to help people move their builders to the new API. --- package.json | 1 + packages/angular_devkit/architect_cli/BUILD | 4 +- .../architect_cli/bin/architect.ts | 239 ++++++++++++------ .../angular_devkit/architect_cli/package.json | 9 +- .../architect_cli/src/progress.ts | 98 +++++++ yarn.lock | 52 +++- 6 files changed, 320 insertions(+), 83 deletions(-) create mode 100644 packages/angular_devkit/architect_cli/src/progress.ts diff --git a/package.json b/package.json index b58e937118a3..1c9a03e88c25 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ ] }, "dependencies": { + "@types/progress": "^2.0.3", "glob": "^7.0.3", "node-fetch": "^2.2.0", "puppeteer": "1.12.2", diff --git a/packages/angular_devkit/architect_cli/BUILD b/packages/angular_devkit/architect_cli/BUILD index 225116f0cf1d..d7d12f885ea4 100644 --- a/packages/angular_devkit/architect_cli/BUILD +++ b/packages/angular_devkit/architect_cli/BUILD @@ -12,15 +12,17 @@ ts_library( name = "architect_cli", srcs = [ "bin/architect.ts", - ], + ] + glob(["src/**/*.ts"]), module_name = "@angular-devkit/architect-cli", deps = [ "//packages/angular_devkit/architect", + "//packages/angular_devkit/architect:node", "//packages/angular_devkit/core", "//packages/angular_devkit/core:node", "@rxjs", "@rxjs//operators", "@npm//@types/node", "@npm//@types/minimist", + "@npm//@types/progress", ], ) diff --git a/packages/angular_devkit/architect_cli/bin/architect.ts b/packages/angular_devkit/architect_cli/bin/architect.ts index 36aa58b7f53e..4af723751d42 100644 --- a/packages/angular_devkit/architect_cli/bin/architect.ts +++ b/packages/angular_devkit/architect_cli/bin/architect.ts @@ -6,18 +6,22 @@ * 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 'symbol-observable'; -// symbol polyfill must go first -// tslint:disable-next-line:ordered-imports import-groups -import { Architect } from '@angular-devkit/architect'; -import { dirname, experimental, normalize, tags } from '@angular-devkit/core'; +import { index2 } from '@angular-devkit/architect'; +import { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node'; +import { + dirname, + experimental, + json, + logging, + normalize, + schema, + tags, terminal, +} from '@angular-devkit/core'; import { NodeJsSyncHost, createConsoleLogger } from '@angular-devkit/core/node'; import { existsSync, readFileSync } from 'fs'; import * as minimist from 'minimist'; import * as path from 'path'; -import { throwError } from 'rxjs'; -import { concatMap } from 'rxjs/operators'; +import { MultiProgressBar } from '../src/progress'; function findUp(names: string | string[], from: string) { @@ -44,7 +48,7 @@ function findUp(names: string | string[], from: string) { /** * Show usage of the CLI tool, and exit the process. */ -function usage(exitCode = 0): never { +function usage(logger: logging.Logger, exitCode = 0): never { logger.info(tags.stripIndent` architect [project][:target][:configuration] [options, ...] @@ -63,86 +67,165 @@ function usage(exitCode = 0): never { throw 0; // The node typing sometimes don't have a never type for process.exit(). } -/** Parse the command line. */ -const argv = minimist(process.argv.slice(2), { boolean: ['help'] }); +function _targetStringFromTarget({project, target, configuration}: index2.Target) { + return `${project}:${target}${configuration !== undefined ? ':' + configuration : ''}`; +} -/** Create the DevKit Logger used through the CLI. */ -const logger = createConsoleLogger(argv['verbose']); -// Check the target. -const targetStr = argv._.shift(); -if (!targetStr && argv.help) { - // Show architect usage if there's no target. - usage(); +interface BarInfo { + status?: string; + builder: index2.BuilderInfo; + target?: index2.Target; } -// Split a target into its parts. -let project: string, targetName: string, configuration: string; -if (targetStr) { - [project, targetName, configuration] = targetStr.split(':'); -} -// Load workspace configuration file. -const currentPath = process.cwd(); -const configFileNames = [ - 'angular.json', - '.angular.json', - 'workspace.json', - '.workspace.json', -]; - -const configFilePath = findUp(configFileNames, currentPath); - -if (!configFilePath) { - logger.fatal(`Workspace configuration file (${configFileNames.join(', ')}) cannot be found in ` - + `'${currentPath}' or in parent directories.`); - process.exit(3); - throw 3; // TypeScript doesn't know that process.exit() never returns. +async function _executeTarget( + parentLogger: logging.Logger, + workspace: experimental.workspace.Workspace, + root: string, + argv: minimist.ParsedArgs, + registry: json.schema.SchemaRegistry, +) { + const architectHost = new WorkspaceNodeModulesArchitectHost(workspace, root); + const architect = new index2.Architect(architectHost, registry); + + // Split a target into its parts. + const targetStr = argv._.shift() || ''; + const [project, target, configuration] = targetStr.split(':'); + const targetSpec = { project, target, configuration }; + + delete argv['help']; + delete argv['_']; + + const logger = new logging.Logger('jobs'); + const logs: logging.LogEntry[] = []; + logger.subscribe(entry => logs.push({ ...entry, message: `${entry.name}: ` + entry.message })); + + const run = await architect.scheduleTarget(targetSpec, argv, { logger }); + const bars = new MultiProgressBar(':name :bar (:current/:total) :status'); + + run.progress.subscribe( + update => { + const data = bars.get(update.id) || { + id: update.id, + builder: update.builder, + target: update.target, + status: update.status || '', + name: ((update.target ? _targetStringFromTarget(update.target) : update.builder.name) + + ' '.repeat(80) + ).substr(0, 40), + }; + + if (update.status !== undefined) { + data.status = update.status; + } + + switch (update.state) { + case index2.BuilderProgressState.Error: + data.status = 'Error: ' + update.error; + bars.update(update.id, data); + break; + + case index2.BuilderProgressState.Stopped: + data.status = 'Done.'; + bars.complete(update.id); + bars.update(update.id, data, update.total, update.total); + break; + + case index2.BuilderProgressState.Waiting: + bars.update(update.id, data); + break; + + case index2.BuilderProgressState.Running: + bars.update(update.id, data, update.current, update.total); + break; + } + + bars.render(); + }, + ); + + // Wait for full completion of the builder. + try { + const result = await run.result; + + if (result.success) { + parentLogger.info(terminal.green('SUCCESS')); + } else { + parentLogger.info(terminal.yellow('FAILURE')); + } + + parentLogger.info('\nLogs:'); + logs.forEach(l => parentLogger.next(l)); + + await run.stop(); + bars.terminate(); + + return result.success ? 0 : 1; + } catch (err) { + parentLogger.info(terminal.red('ERROR')); + parentLogger.info('\nLogs:'); + logs.forEach(l => parentLogger.next(l)); + + parentLogger.fatal('Exception:'); + parentLogger.fatal(err.stack); + + return 2; + } } -const root = dirname(normalize(configFilePath)); -const configContent = readFileSync(configFilePath, 'utf-8'); -const workspaceJson = JSON.parse(configContent); -const host = new NodeJsSyncHost(); -const workspace = new experimental.workspace.Workspace(root, host); +async function main(args: string[]): Promise { + /** Parse the command line. */ + const argv = minimist(args, { boolean: ['help'] }); -let lastBuildEvent = { success: true }; + /** Create the DevKit Logger used through the CLI. */ + const logger = createConsoleLogger(argv['verbose']); -workspace.loadWorkspaceFromJson(workspaceJson).pipe( - concatMap(ws => new Architect(ws).loadArchitect()), - concatMap(architect => { + // Check the target. + const targetStr = argv._[0] || ''; + if (!targetStr || argv.help) { + // Show architect usage if there's no target. + usage(logger); + } - const overrides = { ...argv }; - delete overrides['help']; - delete overrides['_']; + // Load workspace configuration file. + const currentPath = process.cwd(); + const configFileNames = [ + 'angular.json', + '.angular.json', + 'workspace.json', + '.workspace.json', + ]; - const targetSpec = { - project, - target: targetName, - configuration, - overrides, - }; + const configFilePath = findUp(configFileNames, currentPath); - // TODO: better logging of what's happening. - if (argv.help) { - // TODO: add target help - return throwError('Target help NYI.'); - // architect.help(targetOptions, logger); - } else { - const builderConfig = architect.getBuilderConfiguration(targetSpec); + if (!configFilePath) { + logger.fatal(`Workspace configuration file (${configFileNames.join(', ')}) cannot be found in ` + + `'${currentPath}' or in parent directories.`); - return architect.run(builderConfig, { logger }); - } - }), -).subscribe({ - next: (buildEvent => lastBuildEvent = buildEvent), - complete: () => process.exit(lastBuildEvent.success ? 0 : 1), - error: (err: Error) => { - logger.fatal(err.message); - if (err.stack) { - logger.fatal(err.stack); - } - process.exit(1); - }, -}); + return 3; + } + + const root = dirname(normalize(configFilePath)); + const configContent = readFileSync(configFilePath, 'utf-8'); + const workspaceJson = JSON.parse(configContent); + + const registry = new schema.CoreSchemaRegistry(); + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + + const host = new NodeJsSyncHost(); + const workspace = new experimental.workspace.Workspace(root, host); + + await workspace.loadWorkspaceFromJson(workspaceJson).toPromise(); + + return await _executeTarget(logger, workspace, root, argv, registry); +} + +main(process.argv.slice(2)) + .then(code => { + process.exit(code); + }, err => { + console.error('Error: ' + err.stack || err.message || err); + process.exit(-1); + }); diff --git a/packages/angular_devkit/architect_cli/package.json b/packages/angular_devkit/architect_cli/package.json index 183dee060281..c90812606347 100644 --- a/packages/angular_devkit/architect_cli/package.json +++ b/packages/angular_devkit/architect_cli/package.json @@ -12,10 +12,13 @@ "tooling" ], "dependencies": { - "@angular-devkit/core": "0.0.0", "@angular-devkit/architect": "0.0.0", + "@angular-devkit/core": "0.0.0", + "@types/progress": "^2.0.3", + "ascii-progress": "^1.0.5", "minimist": "1.2.0", - "symbol-observable": "1.2.0", - "rxjs": "6.3.3" + "progress": "^2.0.3", + "rxjs": "6.3.3", + "symbol-observable": "1.2.0" } } diff --git a/packages/angular_devkit/architect_cli/src/progress.ts b/packages/angular_devkit/architect_cli/src/progress.ts new file mode 100644 index 000000000000..d85763ceb56f --- /dev/null +++ b/packages/angular_devkit/architect_cli/src/progress.ts @@ -0,0 +1,98 @@ +/** + * @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 { terminal } from '@angular-devkit/core'; +import * as ProgressBar from 'progress'; +import * as readline from 'readline'; + +export class MultiProgressBar { + private _bars = new Map(); + + constructor(private _status: string, private _stream = process.stderr) {} + private _add(id: Key, data: T): { data: T, bar: ProgressBar } { + const width = Math.min(80, terminal.getCapabilities(this._stream).columns || 80); + const value = { + data, + bar: new ProgressBar(this._status, { + clear: true, + total: 1, + width: width, + complete: '#', + incomplete: '.', + stream: this._stream, + }), + }; + this._bars.set(id, value); + readline.moveCursor(this._stream, 0, 1); + + return value; + } + + complete(id: Key) { + const maybeBar = this._bars.get(id); + if (maybeBar) { + maybeBar.bar.complete = true; + } + } + + add(id: Key, data: T) { + this._add(id, data); + } + + get(key: Key): T | undefined { + const maybeValue = this._bars.get(key); + + return maybeValue && maybeValue.data; + } + has(key: Key) { + return this._bars.has(key); + } + update(key: Key, data: T, current?: number, total?: number) { + let maybeBar = this._bars.get(key); + + if (!maybeBar) { + maybeBar = this._add(key, data); + } + + maybeBar.data = data; + if (total !== undefined) { + maybeBar.bar.total = total; + } + if (current !== undefined) { + maybeBar.bar.curr = Math.max(0, Math.min(current, maybeBar.bar.total)); + } + } + + render(max = Infinity, sort?: (a: T, b: T) => number) { + const stream = this._stream; + + readline.moveCursor(stream, 0, -this._bars.size); + readline.cursorTo(stream, 0); + + let values: Iterable<{ data: T, bar: ProgressBar }> = this._bars.values(); + if (sort) { + values = [...values].sort((a, b) => sort(a.data, b.data)); + } + + for (const { data, bar } of values) { + if (max-- == 0) { + return; + } + + bar.render(data); + readline.moveCursor(stream, 0, 1); + readline.cursorTo(stream, 0); + } + } + + terminate() { + for (const { bar } of this._bars.values()) { + bar.terminate(); + } + this._bars.clear(); + } +} diff --git a/yarn.lock b/yarn.lock index fbb2d72aaa8a..94e9afde1add 100644 --- a/yarn.lock +++ b/yarn.lock @@ -409,6 +409,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-6.14.0.tgz#85c6998293fc6f2945915419296c7fbb63384f66" integrity sha512-6tQyh4Q4B5pECcXBOQDZ5KjyBIxRZGzrweGPM47sAYTdVG4+7R+2EGMTmp0h6ZwgqHrFRCeg2gdhsG9xXEl2Sg== +"@types/progress@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/progress/-/progress-2.0.3.tgz#7ccbd9c6d4d601319126c469e73b5bb90dfc8ccc" + integrity sha512-bPOsfCZ4tsTlKiBjBhKnM8jpY5nmIll166IPD58D92hR7G7kZDfx5iB9wGF4NfZrdKolebjeAr3GouYkSGoJ/A== + dependencies: + "@types/node" "*" + "@types/q@^0.0.32": version "0.0.32" resolved "http://registry.npmjs.org/@types/q/-/q-0.0.32.tgz#bd284e57c84f1325da702babfc82a5328190c0c5" @@ -951,6 +958,13 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" +ansi.js@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/ansi.js/-/ansi.js-0.0.5.tgz#e3e9e45eb6977ba0eeeeed11677d12144675348c" + integrity sha1-4+nkXraXe6Du7u0RZ30SFEZ1NIw= + dependencies: + on-new-line "0.0.1" + anymatch@^1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a" @@ -1109,6 +1123,17 @@ asap@^2.0.0, asap@~2.0.3: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= +ascii-progress@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/ascii-progress/-/ascii-progress-1.0.5.tgz#9610aa127ab794af561e893613c36c906f78d9ee" + integrity sha1-lhCqEnq3lK9WHok2E8NskG942e4= + dependencies: + ansi.js "0.0.5" + end-with "^1.0.2" + get-cursor-position "1.0.3" + on-new-line "1.0.0" + start-with "^1.0.2" + ascli@~1: version "1.0.1" resolved "https://registry.yarnpkg.com/ascli/-/ascli-1.0.1.tgz#bcfa5974a62f18e81cabaeb49732ab4a88f906bc" @@ -3244,6 +3269,11 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0: dependencies: once "^1.4.0" +end-with@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/end-with/-/end-with-1.0.2.tgz#a432755ab4f51e7fc74f3a719c6b81df5d668bdc" + integrity sha1-pDJ1WrT1Hn/HTzpxnGuB311mi9w= + engine.io-client@~3.1.0: version "3.1.6" resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.1.6.tgz#5bdeb130f8b94a50ac5cbeb72583e7a4a063ddfd" @@ -4118,6 +4148,11 @@ get-caller-file@^1.0.1: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== +get-cursor-position@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/get-cursor-position/-/get-cursor-position-1.0.3.tgz#0e41d60343b705836a528d69a5e099e2c5108d63" + integrity sha1-DkHWA0O3BYNqUo1ppeCZ4sUQjWM= + get-pkg-repo@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/get-pkg-repo/-/get-pkg-repo-1.4.0.tgz#c73b489c06d80cc5536c2c853f9e05232056972d" @@ -7139,6 +7174,16 @@ on-headers@~1.0.1: resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.1.tgz#928f5d0f470d49342651ea6794b0857c100693f7" integrity sha1-ko9dD0cNSTQmUepnlLCFfBAGk/c= +on-new-line@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/on-new-line/-/on-new-line-0.0.1.tgz#99339cb06dcfe3e78d6964a2ef2af374a145f8fb" + integrity sha1-mTOcsG3P4+eNaWSi7yrzdKFF+Ps= + +on-new-line@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/on-new-line/-/on-new-line-1.0.0.tgz#8585bc2866c8c0e192e410a6d63bdd1722148ae7" + integrity sha1-hYW8KGbIwOGS5BCm1jvdFyIUiuc= + once@1.x, once@^1.3.0, once@^1.3.1, once@^1.3.3, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -7785,7 +7830,7 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= -progress@^2.0.1: +progress@^2.0.1, progress@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== @@ -9460,6 +9505,11 @@ stack-trace@0.0.x: resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= +start-with@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/start-with/-/start-with-1.0.2.tgz#a069a5f46a95fca7f0874f85a28f653d0095c267" + integrity sha1-oGml9GqV/Kfwh0+Foo9lPQCVwmc= + static-eval@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.0.tgz#0e821f8926847def7b4b50cda5d55c04a9b13864"