diff --git a/packages/builders/src/builders.json b/packages/builders/src/builders.json index f208ea649dff7..d8da43c05de0a 100644 --- a/packages/builders/src/builders.json +++ b/packages/builders/src/builders.json @@ -20,6 +20,11 @@ "class": "./cypress/cypress.builder", "schema": "./cypress/schema.json", "description": "Run Cypress e2e tests" + }, + "command-runner": { + "class": "./command-runner/command-runner.builder", + "schema": "./command-runner/schema.json", + "description": "Run a command in a child process" } } } diff --git a/packages/builders/src/run-commands/run-commands.builder.spec.ts b/packages/builders/src/run-commands/run-commands.builder.spec.ts new file mode 100644 index 0000000000000..d45f95897451a --- /dev/null +++ b/packages/builders/src/run-commands/run-commands.builder.spec.ts @@ -0,0 +1,141 @@ +import { normalize } from '@angular-devkit/core'; +import * as path from 'path'; +import RunCommandsBuilder from './run-commands.builder'; +import { fileSync } from 'tmp'; +import { readFileSync } from 'fs'; + +describe('Command Runner Builder', () => { + let builder: RunCommandsBuilder; + + beforeEach(() => { + builder = new RunCommandsBuilder(); + }); + + it('should error when no commands are given', async () => { + const root = normalize('/root'); + try { + const result = await builder + .run({ + root, + builder: '@nrwl/run-commands', + projectType: 'application', + options: {} as any + }) + .toPromise(); + fail('should throw'); + } catch (e) { + expect(e).toEqual( + `ERROR: Bad builder config for @nrwl/run-command - "commands" option is required` + ); + } + }); + + it('should error when no command is given', async () => { + const root = normalize('/root'); + try { + const result = await builder + .run({ + root, + builder: '@nrwl/run-commands', + projectType: 'application', + options: { + commands: [{}] as any + } + }) + .toPromise(); + fail('should throw'); + } catch (e) { + expect(e).toEqual( + `ERROR: Bad builder config for @nrwl/run-command - "command" option is required` + ); + } + }); + + it('should run commands serially', async () => { + const root = normalize('/root'); + const f = fileSync().name; + const result = await builder + .run({ + root, + builder: '@nrwl/run-commands', + projectType: 'application', + options: { + commands: [ + { + command: `sleep 0.2 && echo 1 >> ${f}` + }, + { + command: `sleep 0.1 && echo 2 >> ${f}` + } + ] + } + }) + .toPromise(); + + expect(result).toEqual({ success: true }); + expect( + readFileSync(f) + .toString() + .replace(/\s/g, '') + ).toEqual('12'); + }); + + it('should run commands in parallel', async () => { + const root = normalize('/root'); + const f = fileSync().name; + const result = await builder + .run({ + root, + builder: '@nrwl/run-commands', + projectType: 'application', + options: { + commands: [ + { + command: `sleep 0.2 && echo 1 >> ${f}` + }, + { + command: `sleep 0.1 && echo 2 >> ${f}` + } + ], + parallel: true + } + }) + .toPromise(); + + expect(result).toEqual({ success: true }); + expect( + readFileSync(f) + .toString() + .replace(/\s/g, '') + ).toEqual('21'); + }); + + it('should stop execution when a command fails', async () => { + const root = normalize('/root'); + const f = fileSync().name; + const result = await builder + .run({ + root, + builder: '@nrwl/run-commands', + projectType: 'application', + options: { + commands: [ + { + command: `echo 1 >> ${f} && exit 1` + }, + { + command: `echo 2 >> ${f}` + } + ] + } + }) + .toPromise(); + + expect(result).toEqual({ success: false }); + expect( + readFileSync(f) + .toString() + .replace(/\s/g, '') + ).toEqual('1'); + }); +}); diff --git a/packages/builders/src/run-commands/run-commands.builder.ts b/packages/builders/src/run-commands/run-commands.builder.ts new file mode 100644 index 0000000000000..cfe0291a26088 --- /dev/null +++ b/packages/builders/src/run-commands/run-commands.builder.ts @@ -0,0 +1,108 @@ +import { + Builder, + BuilderConfiguration, + BuildEvent +} from '@angular-devkit/architect'; + +import { Observable } from 'rxjs'; +import { exec } from 'child_process'; + +export interface RunCommandsBuilderOptions { + commands: { command: string }[]; + parallel?: boolean; +} + +export default class RunCommandsBuilder + implements Builder { + run( + config: BuilderConfiguration + ): Observable { + return Observable.create(async observer => { + if (!config || !config.options || !config.options.commands) { + observer.error( + 'ERROR: Bad builder config for @nrwl/run-command - "commands" option is required' + ); + return; + } + + if (config.options.commands.some(c => !c.command)) { + observer.error( + 'ERROR: Bad builder config for @nrwl/run-command - "command" option is required' + ); + return; + } + + try { + const success = config.options.parallel + ? await this.runInParallel(config) + : await this.runSerially(config); + observer.next({ success }); + observer.complete(); + } catch (e) { + observer.error( + `ERROR: Something went wrong in @nrwl/run-command - ${e.message}` + ); + } + }); + } + + private async runInParallel( + config: BuilderConfiguration + ) { + const r = await Promise.all( + config.options.commands.map(c => + this.createProcess(c.command).then(result => ({ + result, + command: c.command + })) + ) + ); + const failed = r.filter(v => !v.result); + if (failed.length > 0) { + failed.forEach(f => { + process.stderr.write( + `Warning: @nrwl/run-command command "${ + f.command + }" exited with non-zero status code` + ); + }); + return false; + } else { + return true; + } + } + + private async runSerially( + config: BuilderConfiguration + ) { + const failedCommand = await config.options.commands.reduce< + Promise + >(async (m, c) => { + if ((await m) === null) { + const success = await this.createProcess(c.command); + return !success ? c.command : null; + } else { + return m; + } + }, Promise.resolve(null)); + + if (failedCommand) { + process.stderr.write( + `Warning: @nrwl/run-command command "${failedCommand}" exited with non-zero status code` + ); + return false; + } + return true; + } + + private createProcess(command: string): Promise { + return new Promise(res => { + const childProcess = exec(command, {}); + childProcess.stdout.on('data', data => process.stdout.write(data)); + childProcess.stderr.on('data', err => process.stderr.write(err)); + childProcess.on('close', code => { + res(code === 0); + }); + }); + } +} diff --git a/packages/builders/src/run-commands/schema.json b/packages/builders/src/run-commands/schema.json new file mode 100644 index 0000000000000..8c2539cba3d8d --- /dev/null +++ b/packages/builders/src/run-commands/schema.json @@ -0,0 +1,27 @@ +{ + "title": "Run Commands", + "description": "Run Commands", + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Command to run in child process" + } + }, + "additionalProperties": false, + "required": ["command"] + } + }, + "parallel": { + "type": "boolean", + "description": "Run commands in parallel", + "default": false + } + }, + "required": ["command"] +} diff --git a/packages/schematics/src/collection/cypress-project/files/src/plugins/index.ts__tmpl__ b/packages/schematics/src/collection/cypress-project/files/src/plugins/index.ts__tmpl__ index dffed2532f2c7..1ed1691a76a53 100644 --- a/packages/schematics/src/collection/cypress-project/files/src/plugins/index.ts__tmpl__ +++ b/packages/schematics/src/collection/cypress-project/files/src/plugins/index.ts__tmpl__ @@ -11,7 +11,7 @@ // This function is called when a project is opened or re-opened (e.g. due to // the project's config changing) -module.exports = (on, config) => { +module.exports = (on: any, config: any) => { // `on` is used to hook into various events Cypress emits // `config` is the resolved Cypress config }; diff --git a/scripts/build.sh b/scripts/build.sh index 24d48ca4cda24..c80533a4ae7b9 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,4 +1,5 @@ -#!/bin/bash +#!/usr/bin/env bash + npx ng-packagr -p packages/nx/ng-package.json rm -rf build