From 4a260b5c1ad872d48c35df410ed21e3b8b07f028 Mon Sep 17 00:00:00 2001 From: Toru Nagashima <star.ctor@gmail.com> Date: Sun, 13 May 2018 07:00:16 +0900 Subject: [PATCH] rewrite with TypeScript --- .babelrc | 6 - .eslintignore | 3 + .eslintrc.json | 8 - .eslintrc.yml | 13 + .gitignore | 7 +- .nycrc | 8 +- .vscode/settings.json | 7 + bin/common/bootstrap.js | 51 --- bin/common/parse-cli-args.js | 251 -------------- bin/common/version.js | 25 -- bin/npm-run-all/help.js | 71 ---- bin/npm-run-all/index.js | 13 - bin/npm-run-all/main.js | 77 ----- bin/run-p/index.js | 13 - bin/run-p/main.js | 74 ----- bin/run-s/index.js | 13 - bin/run-s/main.js | 71 ---- jsdoc.json | 10 - lib/create-header.js | 48 --- lib/create-prefix-transform-stream.js | 89 ----- lib/index.js | 287 ---------------- lib/match-tasks.js | 128 ------- lib/npm-run-all-error.js | 47 --- lib/read-package-json.js | 31 -- lib/run-task.js | 190 ----------- lib/run-tasks.js | 177 ---------- lib/spawn-posix.js | 64 ---- lib/spawn-win32.js | 50 --- lib/spawn.js | 20 -- package.json | 68 ++-- rollup.config.js | 52 +++ src/bin/cli-parameter-parser.ts | 193 +++++++++++ src/bin/npm-run-all.ts | 144 ++++++++ src/bin/package.json.d.ts | 8 + bin/run-p/help.js => src/bin/run-p.ts | 66 ++-- bin/run-s/help.js => src/bin/run-s.ts | 62 ++-- src/lib/aggregate-stream.ts | 27 ++ src/lib/index.ts | 366 +++++++++++++++++++++ src/lib/package-info.ts | 37 +++ src/lib/script-error.ts | 22 ++ src/lib/script-match.ts | 115 +++++++ src/lib/script-result.ts | 12 + src/lib/scripts/label.ts | 64 ++++ src/lib/scripts/name.ts | 35 ++ src/lib/scripts/run.ts | 238 ++++++++++++++ src/lib/scripts/spawn.ts | 22 ++ src/lib/scripts/spawn/posix.ts | 41 +++ src/lib/scripts/spawn/win32.ts | 73 ++++ test/.eslintrc.json | 8 - tsconfig.json | 39 +++ typings/abort-controller/index.d.ts | 37 +++ typings/ps-tree/index.d.ts | 16 + typings/string.prototype.padend/index.d.ts | 13 + 53 files changed, 1708 insertions(+), 1902 deletions(-) delete mode 100644 .babelrc create mode 100644 .eslintignore delete mode 100644 .eslintrc.json create mode 100644 .eslintrc.yml create mode 100644 .vscode/settings.json delete mode 100644 bin/common/bootstrap.js delete mode 100644 bin/common/parse-cli-args.js delete mode 100644 bin/common/version.js delete mode 100644 bin/npm-run-all/help.js delete mode 100644 bin/npm-run-all/index.js delete mode 100644 bin/npm-run-all/main.js delete mode 100644 bin/run-p/index.js delete mode 100644 bin/run-p/main.js delete mode 100644 bin/run-s/index.js delete mode 100644 bin/run-s/main.js delete mode 100644 jsdoc.json delete mode 100644 lib/create-header.js delete mode 100644 lib/create-prefix-transform-stream.js delete mode 100644 lib/index.js delete mode 100644 lib/match-tasks.js delete mode 100644 lib/npm-run-all-error.js delete mode 100644 lib/read-package-json.js delete mode 100644 lib/run-task.js delete mode 100644 lib/run-tasks.js delete mode 100644 lib/spawn-posix.js delete mode 100644 lib/spawn-win32.js delete mode 100644 lib/spawn.js create mode 100644 rollup.config.js create mode 100644 src/bin/cli-parameter-parser.ts create mode 100644 src/bin/npm-run-all.ts create mode 100644 src/bin/package.json.d.ts rename bin/run-p/help.js => src/bin/run-p.ts (57%) rename bin/run-s/help.js => src/bin/run-s.ts (51%) create mode 100644 src/lib/aggregate-stream.ts create mode 100644 src/lib/index.ts create mode 100644 src/lib/package-info.ts create mode 100644 src/lib/script-error.ts create mode 100644 src/lib/script-match.ts create mode 100644 src/lib/script-result.ts create mode 100644 src/lib/scripts/label.ts create mode 100644 src/lib/scripts/name.ts create mode 100644 src/lib/scripts/run.ts create mode 100644 src/lib/scripts/spawn.ts create mode 100644 src/lib/scripts/spawn/posix.ts create mode 100644 src/lib/scripts/spawn/win32.ts delete mode 100644 test/.eslintrc.json create mode 100644 tsconfig.json create mode 100644 typings/abort-controller/index.d.ts create mode 100644 typings/ps-tree/index.d.ts create mode 100644 typings/string.prototype.padend/index.d.ts diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 6454fa9..0000000 --- a/.babelrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "presets": ["power-assert"], - "plugins": ["transform-async-to-generator"], - "only": "/test/*.js", - "sourceMaps": "inline" -} diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..dbe7c1f --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +/.temp +/dist +/node_modules diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 592522b..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "root": true, - "extends": ["mysticatea", "mysticatea/node"], - "rules": { - "prefer-rest-params": "off", - "prefer-spread": "off" - } -} diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000..53c9010 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,13 @@ +root: true +extends: + - plugin:mysticatea/es2015 + - plugin:mysticatea/+node +overrides: + - files: ["*.ts"] + rules: + mysticatea/node/no-unsupported-features: + - "error" + - ignores: + - "asyncAwait" + - "modules" + - "trailingCommasInFunctions" diff --git a/.gitignore b/.gitignore index 6779c56..e5a351a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ /.nyc_output +/.temp /coverage -/jsdoc +/dist /node_modules /test-workspace/tasks/lib /test-workspace/test.txt -/test.js -npm-debug.log +/test.* +/npm-debug.log diff --git a/.nycrc b/.nycrc index cf99a56..5142563 100644 --- a/.nycrc +++ b/.nycrc @@ -1,5 +1,5 @@ { - "include": [ - "{bin,lib}/**/*.js" - ] -} \ No newline at end of file + "include": ["src/**/*.js"], + "require": ["ts-node/register"], + "cache": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cac0f9c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "eslint.validate": [ + "javascript", + { "autoFix": true, "language": "typescript" } + ], + "typescript.tsdk": "node_modules\\typescript\\lib" +} diff --git a/bin/common/bootstrap.js b/bin/common/bootstrap.js deleted file mode 100644 index e73b093..0000000 --- a/bin/common/bootstrap.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @author Toru Nagashima - * @copyright 2016 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ -/*eslint-disable no-process-exit */ - -module.exports = function bootstrap(name) { - const argv = process.argv.slice(2) - - switch (argv[0]) { - case undefined: - case "-h": - case "--help": - return require(`../${name}/help`)(process.stdout) - - case "-v": - case "--version": - return require("./version")(process.stdout) - - default: - // https://github.com/mysticatea/npm-run-all/issues/105 - // Avoid MaxListenersExceededWarnings. - process.stdout.setMaxListeners(0) - process.stderr.setMaxListeners(0) - process.stdin.setMaxListeners(0) - - // Main - return require(`../${name}/main`)( - argv, - process.stdout, - process.stderr - ).then( - () => { - // I'm not sure why, but maybe the process never exits - // on Git Bash (MINGW64) - process.exit(0) - }, - () => { - process.exit(1) - } - ) - } -} - -/*eslint-enable */ diff --git a/bin/common/parse-cli-args.js b/bin/common/parse-cli-args.js deleted file mode 100644 index 7f056fc..0000000 --- a/bin/common/parse-cli-args.js +++ /dev/null @@ -1,251 +0,0 @@ -/** - * @author Toru Nagashima - * @copyright 2016 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -/*eslint-disable no-process-env */ - -//------------------------------------------------------------------------------ -// Helpers -//------------------------------------------------------------------------------ - -const OVERWRITE_OPTION = /^--([^:]+?):([^=]+?)(?:=(.+))?$/ -const CONFIG_OPTION = /^--([^=]+?)(?:=(.+))$/ -const PACKAGE_CONFIG_PATTERN = /^npm_package_config_(.+)$/ -const CONCAT_OPTIONS = /^-[clnprs]+$/ - -/** - * Overwrites a specified package config. - * - * @param {object} config - A config object to be overwritten. - * @param {string} packageName - A package name to overwrite. - * @param {string} variable - A variable name to overwrite. - * @param {string} value - A new value to overwrite. - * @returns {void} - */ -function overwriteConfig(config, packageName, variable, value) { - const scope = config[packageName] || (config[packageName] = {}) - scope[variable] = value -} - -/** - * Creates a package config object. - * This checks `process.env` and creates the default value. - * - * @returns {object} Created config object. - */ -function createPackageConfig() { - const retv = {} - const packageName = process.env.npm_package_name - if (!packageName) { - return retv - } - - for (const key of Object.keys(process.env)) { - const m = PACKAGE_CONFIG_PATTERN.exec(key) - if (m != null) { - overwriteConfig(retv, packageName, m[1], process.env[key]) - } - } - - return retv -} - -/** - * Adds a new group into a given list. - * - * @param {object[]} groups - A group list to add. - * @param {object} initialValues - A key-value map for the default of new value. - * @returns {void} - */ -function addGroup(groups, initialValues) { - groups.push(Object.assign( - { parallel: false, patterns: [] }, - initialValues || {} - )) -} - -/** - * ArgumentSet is values of parsed CLI arguments. - * This class provides the getter to get the last group. - */ -class ArgumentSet { - /** - * @param {object} initialValues - A key-value map for the default of new value. - * @param {object} options - A key-value map for the options. - */ - constructor(initialValues, options) { - this.config = {} - this.continueOnError = false - this.groups = [] - this.maxParallel = 0 - this.npmPath = null - this.packageConfig = createPackageConfig() - this.printLabel = false - this.printName = false - this.race = false - this.rest = [] - this.silent = process.env.npm_config_loglevel === "silent" - this.singleMode = Boolean(options && options.singleMode) - - addGroup(this.groups, initialValues) - } - - /** - * Gets the last group. - */ - get lastGroup() { - return this.groups[this.groups.length - 1] - } - - /** - * Gets "parallel" flag. - */ - get parallel() { - return this.groups.some(g => g.parallel) - } -} - -/** - * Parses CLI arguments. - * - * @param {ArgumentSet} set - The parsed CLI arguments. - * @param {string[]} args - CLI arguments. - * @returns {ArgumentSet} set itself. - */ -function parseCLIArgsCore(set, args) { // eslint-disable-line complexity - LOOP: - for (let i = 0; i < args.length; ++i) { - const arg = args[i] - - switch (arg) { - case "--": - set.rest = args.slice(1 + i) - break LOOP - - case "--color": - case "--no-color": - // do nothing. - break - - case "-c": - case "--continue-on-error": - set.continueOnError = true - break - - case "-l": - case "--print-label": - set.printLabel = true - break - - case "-n": - case "--print-name": - set.printName = true - break - - case "-r": - case "--race": - set.race = true - break - - case "--silent": - set.silent = true - break - - case "--max-parallel": - set.maxParallel = parseInt(args[++i], 10) - if (!Number.isFinite(set.maxParallel) || set.maxParallel <= 0) { - throw new Error(`Invalid Option: --max-parallel ${args[i]}`) - } - break - - case "-s": - case "--sequential": - case "--serial": - if (set.singleMode && arg === "-s") { - set.silent = true - break - } - if (set.singleMode) { - throw new Error(`Invalid Option: ${arg}`) - } - addGroup(set.groups) - break - - case "--aggregate-output": - set.aggregateOutput = true - break - - case "-p": - case "--parallel": - if (set.singleMode) { - throw new Error(`Invalid Option: ${arg}`) - } - addGroup(set.groups, { parallel: true }) - break - - case "--npm-path": - set.npmPath = args[++i] || null - break - - default: { - let matched = null - if ((matched = OVERWRITE_OPTION.exec(arg))) { - overwriteConfig( - set.packageConfig, - matched[1], - matched[2], - matched[3] || args[++i] - ) - } - else if ((matched = CONFIG_OPTION.exec(arg))) { - set.config[matched[1]] = matched[2] - } - else if (CONCAT_OPTIONS.test(arg)) { - parseCLIArgsCore( - set, - arg.slice(1).split("").map(c => `-${c}`) - ) - } - else if (arg[0] === "-") { - throw new Error(`Invalid Option: ${arg}`) - } - else { - set.lastGroup.patterns.push(arg) - } - - break - } - } - } - - if (!set.parallel && set.aggregateOutput) { - throw new Error("Invalid Option: --aggregate-output (without parallel)") - } - if (!set.parallel && set.race) { - const race = args.indexOf("--race") !== -1 ? "--race" : "-r" - throw new Error(`Invalid Option: ${race} (without parallel)`) - } - if (!set.parallel && set.maxParallel !== 0) { - throw new Error("Invalid Option: --max-parallel (without parallel)") - } - - return set -} - -/** - * Parses CLI arguments. - * - * @param {string[]} args - CLI arguments. - * @param {object} initialValues - A key-value map for the default of new value. - * @param {object} options - A key-value map for the options. - * @param {boolean} options.singleMode - The flag to be single group mode. - * @returns {ArgumentSet} The parsed CLI arguments. - */ -module.exports = function parseCLIArgs(args, initialValues, options) { - return parseCLIArgsCore(new ArgumentSet(initialValues, options), args) -} - -/*eslint-enable */ diff --git a/bin/common/version.js b/bin/common/version.js deleted file mode 100644 index 06afb8f..0000000 --- a/bin/common/version.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @author Toru Nagashima - * @copyright 2016 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Print a version text. - * - * @param {stream.Writable} output - A writable stream to print. - * @returns {Promise} Always a fulfilled promise. - * @private - */ -module.exports = function printVersion(output) { - const version = require("../../package.json").version - - output.write(`v${version}\n`) - - return Promise.resolve(null) -} diff --git a/bin/npm-run-all/help.js b/bin/npm-run-all/help.js deleted file mode 100644 index 0300bfe..0000000 --- a/bin/npm-run-all/help.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * @author Toru Nagashima - * @copyright 2015 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Print a help text. - * - * @param {stream.Writable} output - A writable stream to print. - * @returns {Promise} Always a fulfilled promise. - * @private - */ -module.exports = function printHelp(output) { - output.write(` -Usage: - $ npm-run-all [--help | -h | --version | -v] - $ npm-run-all [tasks] [OPTIONS] - - Run given npm-scripts in parallel or sequential. - - <tasks> : A list of npm-scripts' names and Glob-like patterns. - -Options: - --aggregate-output - - - Avoid interleaving output by delaying printing of - each command's output until it has finished. - -c, --continue-on-error - Set the flag to continue executing - other/subsequent tasks even if a task threw an - error. 'npm-run-all' itself will exit with - non-zero code if one or more tasks threw error(s) - --max-parallel <number> - Set the maximum number of parallelism. Default is - unlimited. - --npm-path <string> - - - Set the path to npm. Default is the value of - environment variable npm_execpath. - If the variable is not defined, then it's "npm". - In this case, the "npm" command must be found in - environment variable PATH. - -l, --print-label - - - - Set the flag to print the task name as a prefix - on each line of output. Tools in tasks may stop - coloring their output if this option was given. - -n, --print-name - - - - Set the flag to print the task name before - running each task. - -p, --parallel <tasks> - Run a group of tasks in parallel. - e.g. 'npm-run-all -p foo bar' is similar to - 'npm run foo & npm run bar'. - -r, --race - - - - - - - Set the flag to kill all tasks when a task - finished with zero. This option is valid only - with 'parallel' option. - -s, --sequential <tasks> - Run a group of tasks sequentially. - --serial <tasks> e.g. 'npm-run-all -s foo bar' is similar to - 'npm run foo && npm run bar'. - '--serial' is a synonym of '--sequential'. - --silent - - - - - - - - Set 'silent' to the log level of npm. - -Examples: - $ npm-run-all --serial clean lint build:** - $ npm-run-all --parallel watch:** - $ npm-run-all clean lint --parallel "build:** -- --watch" - $ npm-run-all -l -p start-server start-browser start-electron - -See Also: - https://github.com/mysticatea/npm-run-all#readme -`) - - return Promise.resolve(null) -} diff --git a/bin/npm-run-all/index.js b/bin/npm-run-all/index.js deleted file mode 100644 index b405238..0000000 --- a/bin/npm-run-all/index.js +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env node -/** - * @author Toru Nagashima - * @copyright 2015 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Main -//------------------------------------------------------------------------------ - -require("../common/bootstrap")("npm-run-all") diff --git a/bin/npm-run-all/main.js b/bin/npm-run-all/main.js deleted file mode 100644 index 2782468..0000000 --- a/bin/npm-run-all/main.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @author Toru Nagashima - * @copyright 2015 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Requirements -//------------------------------------------------------------------------------ - -const runAll = require("../../lib") -const parseCLIArgs = require("../common/parse-cli-args") - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Parses arguments, then run specified npm-scripts. - * - * @param {string[]} args - Arguments to parse. - * @param {stream.Writable} stdout - A writable stream to print logs. - * @param {stream.Writable} stderr - A writable stream to print errors. - * @returns {Promise} A promise which comes to be fulfilled when all npm-scripts are completed. - * @private - */ -module.exports = function npmRunAll(args, stdout, stderr) { - try { - const stdin = process.stdin - const argv = parseCLIArgs(args) - - const promise = argv.groups.reduce( - (prev, group) => { - if (group.patterns.length === 0) { - return prev - } - return prev.then(() => runAll( - group.patterns, - { - stdout, - stderr, - stdin, - parallel: group.parallel, - maxParallel: group.parallel ? argv.maxParallel : 1, - continueOnError: argv.continueOnError, - printLabel: argv.printLabel, - printName: argv.printName, - config: argv.config, - packageConfig: argv.packageConfig, - silent: argv.silent, - arguments: argv.rest, - race: group.parallel && argv.race, - npmPath: argv.npmPath, - aggregateOutput: group.parallel && argv.aggregateOutput, - } - )) - }, - Promise.resolve(null) - ) - - if (!argv.silent) { - promise.catch(err => { - //eslint-disable-next-line no-console - console.error("ERROR:", err.message) - }) - } - - return promise - } - catch (err) { - //eslint-disable-next-line no-console - console.error("ERROR:", err.message) - - return Promise.reject(err) - } -} diff --git a/bin/run-p/index.js b/bin/run-p/index.js deleted file mode 100644 index b7ca754..0000000 --- a/bin/run-p/index.js +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env node -/** - * @author Toru Nagashima - * @copyright 2015 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Main -//------------------------------------------------------------------------------ - -require("../common/bootstrap")("run-p") diff --git a/bin/run-p/main.js b/bin/run-p/main.js deleted file mode 100644 index e44f2f2..0000000 --- a/bin/run-p/main.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @author Toru Nagashima - * @copyright 2016 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Requirements -//------------------------------------------------------------------------------ - -const runAll = require("../../lib") -const parseCLIArgs = require("../common/parse-cli-args") - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Parses arguments, then run specified npm-scripts. - * - * @param {string[]} args - Arguments to parse. - * @param {stream.Writable} stdout - A writable stream to print logs. - * @param {stream.Writable} stderr - A writable stream to print errors. - * @returns {Promise} A promise which comes to be fulfilled when all npm-scripts are completed. - * @private - */ -module.exports = function npmRunAll(args, stdout, stderr) { - try { - const stdin = process.stdin - const argv = parseCLIArgs(args, { parallel: true }, { singleMode: true }) - const group = argv.lastGroup - - if (group.patterns.length === 0) { - return Promise.resolve(null) - } - - const promise = runAll( - group.patterns, - { - stdout, - stderr, - stdin, - parallel: group.parallel, - maxParallel: argv.maxParallel, - continueOnError: argv.continueOnError, - printLabel: argv.printLabel, - printName: argv.printName, - config: argv.config, - packageConfig: argv.packageConfig, - silent: argv.silent, - arguments: argv.rest, - race: argv.race, - npmPath: argv.npmPath, - aggregateOutput: argv.aggregateOutput, - } - ) - - if (!argv.silent) { - promise.catch(err => { - //eslint-disable-next-line no-console - console.error("ERROR:", err.message) - }) - } - - return promise - } - catch (err) { - //eslint-disable-next-line no-console - console.error("ERROR:", err.message) - - return Promise.reject(err) - } -} diff --git a/bin/run-s/index.js b/bin/run-s/index.js deleted file mode 100644 index f3cf012..0000000 --- a/bin/run-s/index.js +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env node -/** - * @author Toru Nagashima - * @copyright 2015 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Main -//------------------------------------------------------------------------------ - -require("../common/bootstrap")("run-s") diff --git a/bin/run-s/main.js b/bin/run-s/main.js deleted file mode 100644 index d1bd6da..0000000 --- a/bin/run-s/main.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * @author Toru Nagashima - * @copyright 2016 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Requirements -//------------------------------------------------------------------------------ - -const runAll = require("../../lib") -const parseCLIArgs = require("../common/parse-cli-args") - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Parses arguments, then run specified npm-scripts. - * - * @param {string[]} args - Arguments to parse. - * @param {stream.Writable} stdout - A writable stream to print logs. - * @param {stream.Writable} stderr - A writable stream to print errors. - * @returns {Promise} A promise which comes to be fulfilled when all npm-scripts are completed. - * @private - */ -module.exports = function npmRunAll(args, stdout, stderr) { - try { - const stdin = process.stdin - const argv = parseCLIArgs(args, { parallel: false }, { singleMode: true }) - const group = argv.lastGroup - - if (group.patterns.length === 0) { - return Promise.resolve(null) - } - - const promise = runAll( - group.patterns, - { - stdout, - stderr, - stdin, - parallel: group.parallel, - continueOnError: argv.continueOnError, - printLabel: argv.printLabel, - printName: argv.printName, - config: argv.config, - packageConfig: argv.packageConfig, - silent: argv.silent, - arguments: argv.rest, - npmPath: argv.npmPath, - } - ) - - if (!argv.silent) { - promise.catch(err => { - //eslint-disable-next-line no-console - console.error("ERROR:", err.message) - }) - } - - return promise - } - catch (err) { - //eslint-disable-next-line no-console - console.error("ERROR:", err.message) - - return Promise.reject(err) - } -} diff --git a/jsdoc.json b/jsdoc.json deleted file mode 100644 index d813cdb..0000000 --- a/jsdoc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "source": { - "include": ["src/lib", "README.md"] - }, - "opts": { - "destination": "jsdoc", - "encoding": "utf8", - "recurse": true - } -} diff --git a/lib/create-header.js b/lib/create-header.js deleted file mode 100644 index cdf52df..0000000 --- a/lib/create-header.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @module create-header - * @author Toru Nagashima - * @copyright 2016 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Requirements -//------------------------------------------------------------------------------ - -const ansiStyles = require("ansi-styles") - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Creates the header text for a given task. - * - * @param {string} nameAndArgs - A task name and arguments. - * @param {object} packageInfo - A package.json's information. - * @param {object} packageInfo.body - A package.json's JSON object. - * @param {string} packageInfo.path - A package.json's file path. - * @param {boolean} isTTY - The flag to color the header. - * @returns {string} The header of a given task. - */ -module.exports = function createHeader(nameAndArgs, packageInfo, isTTY) { - if (!packageInfo) { - return `\n> ${nameAndArgs}\n\n` - } - - const index = nameAndArgs.indexOf(" ") - const name = (index === -1) ? nameAndArgs : nameAndArgs.slice(0, index) - const args = (index === -1) ? "" : nameAndArgs.slice(index + 1) - const packageName = packageInfo.body.name - const packageVersion = packageInfo.body.version - const scriptBody = packageInfo.body.scripts[name] - const packagePath = packageInfo.path - const color = isTTY ? ansiStyles.gray : { open: "", close: "" } - - return ` -${color.open}> ${packageName}@${packageVersion} ${name} ${packagePath}${color.close} -${color.open}> ${scriptBody} ${args}${color.close} - -` -} diff --git a/lib/create-prefix-transform-stream.js b/lib/create-prefix-transform-stream.js deleted file mode 100644 index cb2c360..0000000 --- a/lib/create-prefix-transform-stream.js +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @module create-prefix-transform-stream - * @author Toru Nagashima - * @copyright 2016 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Requirements -//------------------------------------------------------------------------------ - -const stream = require("stream") - -//------------------------------------------------------------------------------ -// Helpers -//------------------------------------------------------------------------------ - -const ALL_BR = /\n/g - -/** - * The transform stream to insert a specific prefix. - * - * Several streams can exist for the same output stream. - * This stream will insert the prefix if the last output came from other instance. - * To do that, this stream is using a shared state object. - * - * @private - */ -class PrefixTransform extends stream.Transform { - /** - * @param {string} prefix - A prefix text to be inserted. - * @param {object} state - A state object. - * @param {string} state.lastPrefix - The last prefix which is printed. - * @param {boolean} state.lastIsLinebreak -The flag to check whether the last output is a line break or not. - */ - constructor(prefix, state) { - super() - - this.prefix = prefix - this.state = state - } - - /** - * Transforms the output chunk. - * - * @param {string|Buffer} chunk - A chunk to be transformed. - * @param {string} _encoding - The encoding of the chunk. - * @param {function} callback - A callback function that is called when done. - * @returns {void} - */ - _transform(chunk, _encoding, callback) { - const prefix = this.prefix - const nPrefix = `\n${prefix}` - const state = this.state - const firstPrefix = - state.lastIsLinebreak ? prefix : - (state.lastPrefix !== prefix) ? "\n" : - /* otherwise */ "" - const prefixed = `${firstPrefix}${chunk}`.replace(ALL_BR, nPrefix) - const index = prefixed.indexOf(prefix, Math.max(0, prefixed.length - prefix.length)) - - state.lastPrefix = prefix - state.lastIsLinebreak = (index !== -1) - - callback(null, (index !== -1) ? prefixed.slice(0, index) : prefixed) - } -} - -//------------------------------------------------------------------------------ -// Public API -//------------------------------------------------------------------------------ - -/** - * Create a transform stream to insert the specific prefix. - * - * Several streams can exist for the same output stream. - * This stream will insert the prefix if the last output came from other instance. - * To do that, this stream is using a shared state object. - * - * @param {string} prefix - A prefix text to be inserted. - * @param {object} state - A state object. - * @param {string} state.lastPrefix - The last prefix which is printed. - * @param {boolean} state.lastIsLinebreak -The flag to check whether the last output is a line break or not. - * @returns {stream.Transform} The created transform stream. - */ -module.exports = function createPrefixTransform(prefix, state) { - return new PrefixTransform(prefix, state) -} diff --git a/lib/index.js b/lib/index.js deleted file mode 100644 index e36a605..0000000 --- a/lib/index.js +++ /dev/null @@ -1,287 +0,0 @@ -/** - * @module index - * @author Toru Nagashima - * @copyright 2015 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Requirements -//------------------------------------------------------------------------------ - -const shellQuote = require("shell-quote") -const matchTasks = require("./match-tasks") -const readPackageJson = require("./read-package-json") -const runTasks = require("./run-tasks") - -//------------------------------------------------------------------------------ -// Helpers -//------------------------------------------------------------------------------ - -const ARGS_PATTERN = /\{(!)?([*@]|\d+)([^}]+)?}/g - -/** - * Converts a given value to an array. - * - * @param {string|string[]|null|undefined} x - A value to convert. - * @returns {string[]} An array. - */ -function toArray(x) { - if (x == null) { - return [] - } - return Array.isArray(x) ? x : [x] -} - -/** - * Replaces argument placeholders (such as `{1}`) by arguments. - * - * @param {string[]} patterns - Patterns to replace. - * @param {string[]} args - Arguments to replace. - * @returns {string[]} replaced - */ -function applyArguments(patterns, args) { - const defaults = Object.create(null) - - return patterns.map(pattern => pattern.replace(ARGS_PATTERN, (whole, indirectionMark, id, options) => { - if (indirectionMark != null) { - throw Error(`Invalid Placeholder: ${whole}`) - } - if (id === "@") { - return shellQuote.quote(args) - } - if (id === "*") { - return shellQuote.quote([args.join(" ")]) - } - - const position = parseInt(id, 10) - if (position >= 1 && position <= args.length) { - return shellQuote.quote([args[position - 1]]) - } - - // Address default values - if (options != null) { - const prefix = options.slice(0, 2) - - if (prefix === ":=") { - defaults[id] = shellQuote.quote([options.slice(2)]) - return defaults[id] - } - if (prefix === ":-") { - return shellQuote.quote([options.slice(2)]) - } - - throw Error(`Invalid Placeholder: ${whole}`) - } - if (defaults[id] != null) { - return defaults[id] - } - - return "" - })) -} - -/** - * Parse patterns. - * In parsing process, it replaces argument placeholders (such as `{1}`) by arguments. - * - * @param {string|string[]} patternOrPatterns - Patterns to run. - * A pattern is a npm-script name or a Glob-like pattern. - * @param {string[]} args - Arguments to replace placeholders. - * @returns {string[]} Parsed patterns. - */ -function parsePatterns(patternOrPatterns, args) { - const patterns = toArray(patternOrPatterns) - const hasPlaceholder = patterns.some(pattern => ARGS_PATTERN.test(pattern)) - - return hasPlaceholder ? applyArguments(patterns, args) : patterns -} - -/** - * Converts a given config object to an `--:=` style option array. - * - * @param {object|null} config - - * A map-like object to overwrite package configs. - * Keys are package names. - * Every value is a map-like object (Pairs of variable name and value). - * @returns {string[]} `--:=` style options. - */ -function toOverwriteOptions(config) { - const options = [] - - for (const packageName of Object.keys(config)) { - const packageConfig = config[packageName] - - for (const variableName of Object.keys(packageConfig)) { - const value = packageConfig[variableName] - - options.push(`--${packageName}:${variableName}=${value}`) - } - } - - return options -} - -/** - * Converts a given config object to an `--a=b` style option array. - * - * @param {object|null} config - - * A map-like object to set configs. - * @returns {string[]} `--a=b` style options. - */ -function toConfigOptions(config) { - return Object.keys(config).map(key => `--${key}=${config[key]}`) -} - -/** - * Gets the maximum length. - * - * @param {number} length - The current maximum length. - * @param {string} name - A name. - * @returns {number} The maximum length. - */ -function maxLength(length, name) { - return Math.max(name.length, length) -} - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Runs npm-scripts which are matched with given patterns. - * - * @param {string|string[]} patternOrPatterns - Patterns to run. - * A pattern is a npm-script name or a Glob-like pattern. - * @param {object|undefined} [options] Optional. - * @param {boolean} options.parallel - - * If this is `true`, run scripts in parallel. - * Otherwise, run scripts in sequencial. - * Default is `false`. - * @param {stream.Readable|null} options.stdin - - * A readable stream to send messages to stdin of child process. - * If this is `null`, ignores it. - * If this is `process.stdin`, inherits it. - * Otherwise, makes a pipe. - * Default is `null`. - * @param {stream.Writable|null} options.stdout - - * A writable stream to receive messages from stdout of child process. - * If this is `null`, cannot send. - * If this is `process.stdout`, inherits it. - * Otherwise, makes a pipe. - * Default is `null`. - * @param {stream.Writable|null} options.stderr - - * A writable stream to receive messages from stderr of child process. - * If this is `null`, cannot send. - * If this is `process.stderr`, inherits it. - * Otherwise, makes a pipe. - * Default is `null`. - * @param {string[]} options.taskList - - * Actual name list of npm-scripts. - * This function search npm-script names in this list. - * If this is `null`, this function reads `package.json` of current directly. - * @param {object|null} options.packageConfig - - * A map-like object to overwrite package configs. - * Keys are package names. - * Every value is a map-like object (Pairs of variable name and value). - * e.g. `{"npm-run-all": {"test": 777}}` - * Default is `null`. - * @param {boolean} options.silent - - * The flag to set `silent` to the log level of npm. - * Default is `false`. - * @param {boolean} options.continueOnError - - * The flag to ignore errors. - * Default is `false`. - * @param {boolean} options.printLabel - - * The flag to print task names at the head of each line. - * Default is `false`. - * @param {boolean} options.printName - - * The flag to print task names before running each task. - * Default is `false`. - * @param {number} options.maxParallel - - * The maximum number of parallelism. - * Default is unlimited. - * @param {string} options.npmPath - - * The path to npm. - * Default is `process.env.npm_execpath`. - * @returns {Promise} - * A promise object which becomes fullfilled when all npm-scripts are completed. - */ -module.exports = function npmRunAll(patternOrPatterns, options) { //eslint-disable-line complexity - const stdin = (options && options.stdin) || null - const stdout = (options && options.stdout) || null - const stderr = (options && options.stderr) || null - const taskList = (options && options.taskList) || null - const config = (options && options.config) || null - const packageConfig = (options && options.packageConfig) || null - const args = (options && options.arguments) || [] - const parallel = Boolean(options && options.parallel) - const silent = Boolean(options && options.silent) - const continueOnError = Boolean(options && options.continueOnError) - const printLabel = Boolean(options && options.printLabel) - const printName = Boolean(options && options.printName) - const race = Boolean(options && options.race) - const maxParallel = parallel ? ((options && options.maxParallel) || 0) : 1 - const aggregateOutput = Boolean(options && options.aggregateOutput) - const npmPath = options && options.npmPath - try { - const patterns = parsePatterns(patternOrPatterns, args) - if (patterns.length === 0) { - return Promise.resolve(null) - } - if (taskList != null && Array.isArray(taskList) === false) { - throw new Error("Invalid options.taskList") - } - if (typeof maxParallel !== "number" || !(maxParallel >= 0)) { - throw new Error("Invalid options.maxParallel") - } - if (!parallel && aggregateOutput) { - throw new Error("Invalid options.aggregateOutput; It requires options.parallel") - } - if (!parallel && race) { - throw new Error("Invalid options.race; It requires options.parallel") - } - - const prefixOptions = [].concat( - silent ? ["--silent"] : [], - packageConfig ? toOverwriteOptions(packageConfig) : [], - config ? toConfigOptions(config) : [] - ) - - return Promise.resolve() - .then(() => { - if (taskList != null) { - return { taskList, packageInfo: null } - } - return readPackageJson() - }) - .then(x => { - const tasks = matchTasks(x.taskList, patterns) - const labelWidth = tasks.reduce(maxLength, 0) - - return runTasks(tasks, { - stdin, - stdout, - stderr, - prefixOptions, - continueOnError, - labelState: { - enabled: printLabel, - width: labelWidth, - lastPrefix: null, - lastIsLinebreak: true, - }, - printName, - packageInfo: x.packageInfo, - race, - maxParallel, - npmPath, - aggregateOutput, - }) - }) - } - catch (err) { - return Promise.reject(new Error(err.message)) - } -} diff --git a/lib/match-tasks.js b/lib/match-tasks.js deleted file mode 100644 index 63a0de3..0000000 --- a/lib/match-tasks.js +++ /dev/null @@ -1,128 +0,0 @@ -/** - * @module match-tasks - * @author Toru Nagashima - * @copyright 2015 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Requirements -//------------------------------------------------------------------------------ - -const Minimatch = require("minimatch").Minimatch - -//------------------------------------------------------------------------------ -// Helpers -//------------------------------------------------------------------------------ - -const COLON_OR_SLASH = /[:/]/g -const CONVERT_MAP = { ":": "/", "/": ":" } - -/** - * Swaps ":" and "/", in order to use ":" as the separator in minimatch. - * - * @param {string} s - A text to swap. - * @returns {string} The text which was swapped. - */ -function swapColonAndSlash(s) { - return s.replace(COLON_OR_SLASH, (matched) => CONVERT_MAP[matched]) -} - -/** - * Creates a filter from user-specified pattern text. - * - * The task name is the part until the first space. - * The rest part is the arguments for this task. - * - * @param {string} pattern - A pattern to create filter. - * @returns {{match: function, task: string, args: string}} The filter object of the pattern. - */ -function createFilter(pattern) { - const trimmed = pattern.trim() - const spacePos = trimmed.indexOf(" ") - const task = spacePos < 0 ? trimmed : trimmed.slice(0, spacePos) - const args = spacePos < 0 ? "" : trimmed.slice(spacePos) - const matcher = new Minimatch(swapColonAndSlash(task)) - const match = matcher.match.bind(matcher) - - return { match, task, args } -} - -/** - * The set to remove overlapped task. - */ -class TaskSet { - /** - * Creates a instance. - */ - constructor() { - this.result = [] - this.sourceMap = Object.create(null) - } - - /** - * Adds a command (a pattern) into this set if it's not overlapped. - * "Overlapped" is meaning that the command was added from a different source. - * - * @param {string} command - A pattern text to add. - * @param {string} source - A task name to check. - * @returns {void} - */ - add(command, source) { - const sourceList = this.sourceMap[command] || (this.sourceMap[command] = []) - if (sourceList.length === 0 || sourceList.indexOf(source) !== -1) { - this.result.push(command) - } - sourceList.push(source) - } -} - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Enumerates tasks which matches with given patterns. - * - * @param {string[]} taskList - A list of actual task names. - * @param {string[]} patterns - Pattern texts to match. - * @returns {string[]} Tasks which matches with the patterns. - * @private - */ -module.exports = function matchTasks(taskList, patterns) { - const filters = patterns.map(createFilter) - const candidates = taskList.map(swapColonAndSlash) - const taskSet = new TaskSet() - const unknownSet = Object.create(null) - - // Take tasks while keep the order of patterns. - for (const filter of filters) { - let found = false - - for (const candidate of candidates) { - if (filter.match(candidate)) { - found = true - taskSet.add( - swapColonAndSlash(candidate) + filter.args, - filter.task - ) - } - } - - // Built-in tasks should be allowed. - if (!found && (filter.task === "restart" || filter.task === "env")) { - taskSet.add(filter.task + filter.args, filter.task) - found = true - } - if (!found) { - unknownSet[filter.task] = true - } - } - - const unknownTasks = Object.keys(unknownSet) - if (unknownTasks.length > 0) { - throw new Error(`Task not found: "${unknownTasks.join("\", ")}"`) - } - return taskSet.result -} diff --git a/lib/npm-run-all-error.js b/lib/npm-run-all-error.js deleted file mode 100644 index af08b09..0000000 --- a/lib/npm-run-all-error.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @module npm-run-all-error - * @author Toru Nagashima - * @copyright 2016 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Error object with some additional info. - */ -module.exports = class NpmRunAllError extends Error { - /** - * Constructor. - * - * @param {{name: string, code: number}} causeResult - - * The result item of the npm-script which causes an error. - * @param {Array.<{name: string, code: (number|undefined)}>} allResults - - * All result items of npm-scripts. - */ - constructor(causeResult, allResults) { - super(`"${causeResult.task}" exited with ${causeResult.code}.`) - - /** - * The name of a npm-script which exited with a non-zero code. - * @type {string} - */ - this.name = causeResult.name - - /** - * The code of a npm-script which exited with a non-zero code. - * This can be `undefined`. - * @type {number} - */ - this.code = causeResult.code - - /** - * All result items of npm-scripts. - * @type {Array.<{name: string, code: (number|undefined)}>} - */ - this.results = allResults - } -} diff --git a/lib/read-package-json.js b/lib/read-package-json.js deleted file mode 100644 index 1497ebf..0000000 --- a/lib/read-package-json.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @module read-package-json - * @author Toru Nagashima - * @copyright 2016 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Requirements -//------------------------------------------------------------------------------ - -const joinPath = require("path").join -const readPkg = require("read-pkg") - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Reads the package.json in the current directory. - * - * @returns {object} package.json's information. - */ -module.exports = function readPackageJson() { - const path = joinPath(process.cwd(), "package.json") - return readPkg(path).then(body => ({ - taskList: Object.keys(body.scripts || {}), - packageInfo: { path, body }, - })) -} diff --git a/lib/run-task.js b/lib/run-task.js deleted file mode 100644 index ee70000..0000000 --- a/lib/run-task.js +++ /dev/null @@ -1,190 +0,0 @@ -/** - * @module run-task - * @author Toru Nagashima - * @copyright 2015 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Requirements -//------------------------------------------------------------------------------ - -const path = require("path") -const chalk = require("chalk") -const parseArgs = require("shell-quote").parse -const padEnd = require("string.prototype.padend") -const createHeader = require("./create-header") -const createPrefixTransform = require("./create-prefix-transform-stream") -const spawn = require("./spawn") - -//------------------------------------------------------------------------------ -// Helpers -//------------------------------------------------------------------------------ - -const colors = [chalk.cyan, chalk.green, chalk.magenta, chalk.yellow, chalk.red] - -let colorIndex = 0 -const taskNamesToColors = new Map() - -/** - * Select a color from given task name. - * - * @param {string} taskName - The task name. - * @returns {function} A colorize function that provided by `chalk` - */ -function selectColor(taskName) { - let color = taskNamesToColors.get(taskName) - if (!color) { - color = colors[colorIndex] - colorIndex = (colorIndex + 1) % colors.length - taskNamesToColors.set(taskName, color) - } - return color -} - -/** - * Wraps stdout/stderr with a transform stream to add the task name as prefix. - * - * @param {string} taskName - The task name. - * @param {stream.Writable} source - An output stream to be wrapped. - * @param {object} labelState - An label state for the transform stream. - * @returns {stream.Writable} `source` or the created wrapped stream. - */ -function wrapLabeling(taskName, source, labelState) { - if (source == null || !labelState.enabled) { - return source - } - - const label = padEnd(taskName, labelState.width) - const color = source.isTTY ? selectColor(taskName) : (x) => x - const prefix = color(`[${label}] `) - const stream = createPrefixTransform(prefix, labelState) - - stream.pipe(source) - - return stream -} - -/** - * Converts a given stream to an option for `child_process.spawn`. - * - * @param {stream.Readable|stream.Writable|null} stream - An original stream to convert. - * @param {process.stdin|process.stdout|process.stderr} std - A standard stream for this option. - * @returns {string|stream.Readable|stream.Writable} An option for `child_process.spawn`. - */ -function detectStreamKind(stream, std) { - return ( - stream == null ? "ignore" : - // `|| !std.isTTY` is needed for the workaround of https://github.com/nodejs/node/issues/5620 - stream !== std || !std.isTTY ? "pipe" : - /* else */ stream - ) -} - -//------------------------------------------------------------------------------ -// Interface -//------------------------------------------------------------------------------ - -/** - * Run a npm-script of a given name. - * The return value is a promise which has an extra method: `abort()`. - * The `abort()` kills the child process to run the npm-script. - * - * @param {string} task - A npm-script name to run. - * @param {object} options - An option object. - * @param {stream.Readable|null} options.stdin - - * A readable stream to send messages to stdin of child process. - * If this is `null`, ignores it. - * If this is `process.stdin`, inherits it. - * Otherwise, makes a pipe. - * @param {stream.Writable|null} options.stdout - - * A writable stream to receive messages from stdout of child process. - * If this is `null`, cannot send. - * If this is `process.stdout`, inherits it. - * Otherwise, makes a pipe. - * @param {stream.Writable|null} options.stderr - - * A writable stream to receive messages from stderr of child process. - * If this is `null`, cannot send. - * If this is `process.stderr`, inherits it. - * Otherwise, makes a pipe. - * @param {string[]} options.prefixOptions - - * An array of options which are inserted before the task name. - * @param {object} options.labelState - A state object for printing labels. - * @param {boolean} options.printName - The flag to print task names before running each task. - * @returns {Promise} - * A promise object which becomes fullfilled when the npm-script is completed. - * This promise object has an extra method: `abort()`. - * @private - */ -module.exports = function runTask(task, options) { - let cp = null - const promise = new Promise((resolve, reject) => { - const stdin = options.stdin - const stdout = wrapLabeling(task, options.stdout, options.labelState) - const stderr = wrapLabeling(task, options.stderr, options.labelState) - const stdinKind = detectStreamKind(stdin, process.stdin) - const stdoutKind = detectStreamKind(stdout, process.stdout) - const stderrKind = detectStreamKind(stderr, process.stderr) - const spawnOptions = { stdio: [stdinKind, stdoutKind, stderrKind] } - - // Print task name. - if (options.printName && stdout != null) { - stdout.write(createHeader( - task, - options.packageInfo, - options.stdout.isTTY - )) - } - - // Execute. - const npmPath = options.npmPath || process.env.npm_execpath //eslint-disable-line no-process-env - const npmPathIsJs = typeof npmPath === "string" && /\.m?js/.test(path.extname(npmPath)) - const execPath = (npmPathIsJs ? process.execPath : npmPath || "npm") - const isYarn = path.basename(npmPath || "npm").startsWith("yarn") - const spawnArgs = ["run"] - - if (npmPathIsJs) { - spawnArgs.unshift(npmPath) - } - if (!isYarn) { - Array.prototype.push.apply(spawnArgs, options.prefixOptions) - } - else if (options.prefixOptions.indexOf("--silent") !== -1) { - spawnArgs.push("--silent") - } - Array.prototype.push.apply(spawnArgs, parseArgs(task)) - - cp = spawn(execPath, spawnArgs, spawnOptions) - - // Piping stdio. - if (stdinKind === "pipe") { - stdin.pipe(cp.stdin) - } - if (stdoutKind === "pipe") { - cp.stdout.pipe(stdout, { end: false }) - } - if (stderrKind === "pipe") { - cp.stderr.pipe(stderr, { end: false }) - } - - // Register - cp.on("error", (err) => { - cp = null - reject(err) - }) - cp.on("close", (code) => { - cp = null - resolve({ task, code }) - }) - }) - - promise.abort = function abort() { - if (cp != null) { - cp.kill() - cp = null - } - } - - return promise -} diff --git a/lib/run-tasks.js b/lib/run-tasks.js deleted file mode 100644 index 64a4506..0000000 --- a/lib/run-tasks.js +++ /dev/null @@ -1,177 +0,0 @@ -/** - * @module run-tasks-in-parallel - * @author Toru Nagashima - * @copyright 2015 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Requirements -//------------------------------------------------------------------------------ - -const MemoryStream = require("memorystream") -const NpmRunAllError = require("./npm-run-all-error") -const runTask = require("./run-task") - -//------------------------------------------------------------------------------ -// Helpers -//------------------------------------------------------------------------------ - -/** - * Remove the given value from the array. - * @template T - * @param {T[]} array - The array to remove. - * @param {T} x - The item to be removed. - * @returns {void} - */ -function remove(array, x) { - const index = array.indexOf(x) - if (index !== -1) { - array.splice(index, 1) - } -} - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Run npm-scripts of given names in parallel. - * - * If a npm-script exited with a non-zero code, this aborts other all npm-scripts. - * - * @param {string} tasks - A list of npm-script name to run in parallel. - * @param {object} options - An option object. - * @returns {Promise} A promise object which becomes fullfilled when all npm-scripts are completed. - * @private - */ -module.exports = function runTasks(tasks, options) { - return new Promise((resolve, reject) => { - if (tasks.length === 0) { - resolve([]) - return - } - - const results = tasks.map(task => ({ name: task, code: undefined })) - const queue = tasks.map((task, index) => ({ name: task, index })) - const promises = [] - let error = null - let aborted = false - - /** - * Done. - * @returns {void} - */ - function done() { - if (error == null) { - resolve(results) - } - else { - reject(error) - } - } - - /** - * Aborts all tasks. - * @returns {void} - */ - function abort() { - if (aborted) { - return - } - aborted = true - - if (promises.length === 0) { - done() - } - else { - for (const p of promises) { - p.abort() - } - Promise.all(promises).then(done, reject) - } - } - - /** - * Runs a next task. - * @returns {void} - */ - function next() { - if (aborted) { - return - } - if (queue.length === 0) { - if (promises.length === 0) { - done() - } - return - } - - const originalOutputStream = options.stdout - const optionsClone = Object.assign({}, options) - const writer = new MemoryStream(null, { - readable: false, - }) - - if (options.aggregateOutput) { - optionsClone.stdout = writer - } - - const task = queue.shift() - const promise = runTask(task.name, optionsClone) - - promises.push(promise) - promise.then( - (result) => { - remove(promises, promise) - if (aborted) { - return - } - - if (options.aggregateOutput) { - originalOutputStream.write(writer.toString()) - } - - // Save the result. - results[task.index].code = result.code - - // Aborts all tasks if it's an error. - if (result.code) { - error = new NpmRunAllError(result, results) - if (!options.continueOnError) { - abort() - return - } - } - - // Aborts all tasks if options.race is true. - if (options.race && !result.code) { - abort() - return - } - - // Call the next task. - next() - }, - (thisError) => { - remove(promises, promise) - if (!options.continueOnError || options.race) { - error = thisError - abort() - return - } - next() - } - ) - } - - const max = options.maxParallel - const end = (typeof max === "number" && max > 0) - ? Math.min(tasks.length, max) - : tasks.length - for (let i = 0; i < end; ++i) { - next() - } - }) -} diff --git a/lib/spawn-posix.js b/lib/spawn-posix.js deleted file mode 100644 index 1fb5de0..0000000 --- a/lib/spawn-posix.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @module spawn-posix - * @author Toru Nagashima - * @copyright 2015 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Requirements -//------------------------------------------------------------------------------ - -const crossSpawn = require("cross-spawn") -const getDescendentProcessInfo = require("ps-tree") - -//------------------------------------------------------------------------------ -// Helpers -//------------------------------------------------------------------------------ - -/** - * Kills the new process and its sub processes. - * @this ChildProcess - * @returns {void} - */ -function kill() { - getDescendentProcessInfo(this.pid, (err, descendent) => { - if (err) { - return - } - - for (const child of descendent) { - try { - process.kill(child.PID) - } - catch (_err) { - // ignore. - } - } - }) -} - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Launches a new process with the given command. - * This is almost same as `child_process.spawn`. - * - * This returns a `ChildProcess` instance. - * `kill` method of the instance kills the new process and its sub processes. - * - * @param {string} command - The command to run. - * @param {string[]} args - List of string arguments. - * @param {object} options - Options. - * @returns {ChildProcess} A ChildProcess instance of new process. - * @private - */ -module.exports = function spawn(command, args, options) { - const child = crossSpawn(command, args, options) - child.kill = kill - - return child -} diff --git a/lib/spawn-win32.js b/lib/spawn-win32.js deleted file mode 100644 index 3743a1d..0000000 --- a/lib/spawn-win32.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @module spawn-win32 - * @author Toru Nagashima - * @copyright 2015 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Requirements -//------------------------------------------------------------------------------ - -const crossSpawn = require("cross-spawn") - -//------------------------------------------------------------------------------ -// Helpers -//------------------------------------------------------------------------------ - -/** - * Kills the new process and its sub processes forcibly. - * @this ChildProcess - * @returns {void} - */ -function kill() { - crossSpawn("taskkill", ["/F", "/T", "/PID", this.pid]) -} - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Launches a new process with the given command. - * This is almost same as `child_process.spawn`. - * - * This returns a `ChildProcess` instance. - * `kill` method of the instance kills the new process and its sub processes forcibly. - * - * @param {string} command - The command to run. - * @param {string[]} args - List of string arguments. - * @param {object} options - Options. - * @returns {ChildProcess} A ChildProcess instance of new process. - * @private - */ -module.exports = function spawn(command, args, options) { - const child = crossSpawn(command, args, options) - child.kill = kill - - return child -} diff --git a/lib/spawn.js b/lib/spawn.js deleted file mode 100644 index 1392817..0000000 --- a/lib/spawn.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @module spawn - * @author Toru Nagashima - * @copyright 2015 Toru Nagashima. All rights reserved. - * See LICENSE file in root directory for full license. - */ -"use strict" - -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ - -/** - * Launches a new process with the given command. - * This is {@link ./spawn-posix.js:spawn} or {@link ./spawn-win32.js:spawn} - * @private - */ -module.exports = require( - process.platform === "win32" ? "./spawn-win32" : "./spawn-posix" -) diff --git a/package.json b/package.json index 36f6f66..2886687 100644 --- a/package.json +++ b/package.json @@ -3,22 +3,21 @@ "version": "4.1.2", "description": "A CLI tool to run multiple npm-scripts in parallel or sequential.", "bin": { - "run-p": "bin/run-p/index.js", - "run-s": "bin/run-s/index.js", - "npm-run-all": "bin/npm-run-all/index.js" + "run-p": "dist/run-p.js", + "run-s": "dist/run-s.js", + "npm-run-all": "dist/npm-run-all.js" }, - "main": "lib/index.js", + "main": "dist/index.js", "files": [ - "bin", - "lib", - "docs" + "dist" ], "engines": { - "node": ">= 4" + "node": ">=6.5.0" }, "scripts": { "_mocha": "mocha \"test/*.js\" --compilers js:babel-register --timeout 60000", - "clean": "rimraf .nyc_output coverage jsdoc \"test-workspace/{build,test.txt}\"", + "build": "rimraf .temp dist && tsc && rollup -c", + "clean": "rimraf .nyc_output .temp coverage dist \"test-workspace/{build,test.txt}\"", "docs": "jsdoc -c jsdoc.json", "lint": "eslint bin lib scripts test \"test-workspace/tasks/*.js\"", "pretest": "node scripts/make-slink.js && npm run lint", @@ -29,32 +28,43 @@ "codecov": "nyc report -r lcovonly && codecov" }, "dependencies": { - "ansi-styles": "^3.2.0", - "chalk": "^2.1.0", - "cross-spawn": "^5.1.0", - "memorystream": "^0.3.1", + "abort-controller": "^1.0.2", + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "fs-extra": "^6.0.1", "minimatch": "^3.0.4", + "p-queue": "^2.4.2", "ps-tree": "^1.1.0", - "read-pkg": "^3.0.0", "shell-quote": "^1.6.1", "string.prototype.padend": "^3.0.0" }, "devDependencies": { - "@types/node": "^4.2.20", - "babel-plugin-transform-async-to-generator": "^6.24.1", - "babel-preset-power-assert": "^1.0.0", - "babel-register": "^6.26.0", - "codecov": "^2.3.0", - "eslint": "^4.5.0", - "eslint-config-mysticatea": "^12.0.0", - "fs-extra": "^4.0.2", - "jsdoc": "^3.5.4", - "mocha": "^3.5.0", - "nyc": "^11.1.0", - "p-queue": "^2.2.0", - "power-assert": "^1.4.4", - "rimraf": "^2.6.1", - "yarn": "^1.2.1" + "@babel/core": "^7.0.0-beta.46", + "@babel/plugin-syntax-dynamic-import": "^7.0.0-beta.46", + "@babel/preset-env": "^7.0.0-beta.46", + "@types/ansi-styles": "^3.2.0", + "@types/cross-spawn": "^6.0.0", + "@types/fs-extra": "^5.0.2", + "@types/minimatch": "^3.0.3", + "@types/mocha": "^5.2.0", + "@types/node": "^10.0.8", + "@types/p-queue": "^2.3.1", + "@types/shell-quote": "^1.6.0", + "codecov": "^3.0.2", + "eslint": "^4.19.1", + "eslint-plugin-mysticatea": "^5.0.0-beta.8", + "mocha": "^5.1.1", + "nyc": "^11.7.3", + "rimraf": "^2.6.2", + "rollup": "^0.58.2", + "rollup-plugin-babel": "^4.0.0-beta.4", + "rollup-plugin-json": "^3.0.0", + "rollup-plugin-node-resolve": "^3.3.0", + "rollup-plugin-sourcemaps": "^0.4.2", + "ts-node": "^6.0.3", + "typescript": "^2.8.3", + "yarn": "^1.6.0" }, "repository": "mysticatea/npm-run-all", "keywords": [ diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..ec8daf0 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,52 @@ +/** + * @author Toru Nagashima <https://github.com/mysticatea> + * See LICENSE file in root directory for full license. + */ +import fs from "fs-extra" +import babel from "rollup-plugin-babel" +import json from "rollup-plugin-json" +import nodeResolve from "rollup-plugin-node-resolve" +import sourcemaps from "rollup-plugin-sourcemaps" + +const { dependencies } = fs.readJSONSync("package.json") + +export default { + experimentalCodeSplitting: true, + experimentalDynamicImport: true, + external: Object.keys(dependencies).concat(["path", "readline", "stream"]), + + input: [ + ".temp/bin/npm-run-all.js", + ".temp/bin/run-s.js", + ".temp/bin/run-p.js", + ".temp/lib/index.js", + ], + output: { + dir: "dist", + format: "cjs", + sourcemap: true, + strict: true, + banner: `/*! @author Toru Nagashima <https://github.com/mysticatea> */\n`, + }, + + plugins: [ + json(), + nodeResolve(), + babel({ + babelrc: false, + plugins: ["@babel/plugin-syntax-dynamic-import"], + presets: [ + [ + "@babel/preset-env", + { + modules: false, + targets: { + node: "6.5.0", + }, + }, + ], + ], + }), + sourcemaps(), + ], +} diff --git a/src/bin/cli-parameter-parser.ts b/src/bin/cli-parameter-parser.ts new file mode 100644 index 0000000..49ff0de --- /dev/null +++ b/src/bin/cli-parameter-parser.ts @@ -0,0 +1,193 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import { Options } from "../lib" + +/*eslint-disable no-process-env */ + +const OVERWRITE_OPTION = /^--([^:]+?):([^=]+?)(?:=(.+))?$/ +const CONFIG_OPTION = /^--([^=]+?)(?:=(.+))$/ +const PACKAGE_CONFIG_PATTERN = /^npm_package_config_(.+)$/ +const CONCAT_OPTIONS = /^-[clnprs]+$/ + +/** + * Overwrites a specified package config. + */ +function overwriteConfig( + config: { [key: string]: { [key: string]: string } }, + packageName: string, + variable: string, + value: string, +): void { + const scope = config[packageName] || (config[packageName] = {}) + scope[variable] = value +} + +/** + * Creates a package config object. + * This checks `process.env` and creates the default value. + */ +function createPackageConfig(): { [key: string]: { [key: string]: string } } { + const retv: { [key: string]: { [key: string]: string } } = {} + const packageName = process.env.npm_package_name + if (!packageName) { + return retv + } + + for (const key of Object.keys(process.env)) { + const m = PACKAGE_CONFIG_PATTERN.exec(key) + if (m != null) { + overwriteConfig(retv, packageName, m[1], process.env[key] as string) + } + } + + return retv +} + +/** + * Common CLI parameters of npm-run-all, run-s, and run-p. + */ +export default class CLIParameterParser implements Options { + protected args: string[] + protected index = 0 + + public patterns: string[] = [] + + public config: { [key: string]: string } = {} + public packageConfig = createPackageConfig() + public arguments: string[] = [] + public parallel: boolean + public silent = process.env.npm_config_loglevel === "silent" + public continueOnError = false + public printLabel = false + public printName = false + public race = false + public aggregateOutput = false + public maxParallel = 1 + public npmPath: string | undefined = undefined + + /** Initialize this parser */ + public constructor(args: string[], parallel = false) { + this.args = args + this.parallel = parallel + + Object.defineProperties(this, { + args: { enumerable: false }, + index: { enumerable: false }, + patterns: { enumerable: false }, + }) + + while (this.index < this.args.length) { + this.next() + } + + if (!this.parallel) { + if (this.aggregateOutput) { + throw new Error( + "Invalid Option: --aggregate-output (without parallel)", + ) + } + if (this.race) { + throw new Error(`Invalid Option: --race (without parallel)`) + } + if (this.maxParallel !== 0) { + throw new Error( + "Invalid Option: --max-parallel (without parallel)", + ) + } + } + } + + //eslint-disable-next-line complexity, require-jsdoc + protected next(): void { + const arg = this.args[this.index++] + switch (arg) { + case "--": + this.arguments = this.args.slice(this.index) + this.index = this.args.length + break + + case "--aggregate-output": + this.aggregateOutput = true + break + + case "--color": + case "--no-color": + // passthrough for chalk package. + break + + case "-c": + case "--continue-on-error": + this.continueOnError = true + break + + case "-l": + case "--print-label": + this.printLabel = true + break + + case "-n": + case "--print-name": + this.printName = true + break + + case "-r": + case "--race": + this.race = true + break + + case "-s": + case "--silent": + this.silent = true + break + + case "--max-parallel": { + const value = this.args[this.index++] + this.maxParallel = parseInt(value, 10) + if ( + !Number.isFinite(this.maxParallel) || + this.maxParallel <= 0 + ) { + throw new Error(`Invalid Option: --max-parallel ${value}`) + } + break + } + + case "--npm-path": + this.npmPath = this.args[this.index++] + break + + default: { + let matched = null + if ((matched = OVERWRITE_OPTION.exec(arg))) { + overwriteConfig( + this.packageConfig, + matched[1], + matched[2], + matched[3] || this.args[this.index++], + ) + } else if ((matched = CONFIG_OPTION.exec(arg))) { + this.config[matched[1]] = matched[2] + } else if (CONCAT_OPTIONS.test(arg)) { + this.args.splice( + this.index, + 0, + ...arg + .slice(1) + .split("") + .map(c => `-${c}`), + ) + } else if (arg[0] === "-") { + throw new Error(`Invalid Option: ${arg}`) + } else { + this.patterns.push(arg) + } + + break + } + } + } +} + +/*eslint-enable */ diff --git a/src/bin/npm-run-all.ts b/src/bin/npm-run-all.ts new file mode 100644 index 0000000..252a543 --- /dev/null +++ b/src/bin/npm-run-all.ts @@ -0,0 +1,144 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import PQueue from "p-queue" +import { version } from "../../package.json" +import runScripts from "../lib" +import CLIParameterParserBase from "./cli-parameter-parser" + +/** + * CLI parameter parser. + */ +class CLIParameterParser extends CLIParameterParserBase { + public groups: { patterns: string[]; parallel: boolean }[] = [ + { patterns: this.patterns, parallel: false }, + ] + + /** Initialize this parser. */ + public constructor(args: string[]) { + super(args) + Object.defineProperty(this, "groups", { enumerable: false }) + } + + /** Interprit the next argument */ + protected next(): void { + const arg = this.args[this.index] + switch (arg) { + case "-s": + case "--sequential": + case "--serial": { + const patterns = (this.patterns = []) + this.index += 1 + this.groups.push({ patterns, parallel: false }) + break + } + + case "-p": + case "--parallel": { + const patterns = (this.patterns = []) + this.index += 1 + this.parallel = true + this.groups.push({ patterns, parallel: true }) + break + } + + default: + super.next() + } + } +} + +/*eslint-disable no-console, no-process-exit */ +;(async (args: string[]) => { + const arg = args[0] + + if (arg === undefined || arg === "-h" || arg === "--help") { + console.log(` +Usage: + $ npm-run-all [--help | -h | --version | -v] + $ npm-run-all [scripts] [OPTIONS] + + Run given npm-scripts in parallel or sequential. + + <scripts> : A list of npm-scripts' names and Glob-like patterns. + +Options: + --aggregate-output - - - Avoid interleaving output by delaying printing of + each command's output until it has finished. + -c, --continue-on-error - Set the flag to continue executing + other/subsequent scripts even if a task threw an + error. 'npm-run-all' itself will exit with + non-zero code if one or more scripts threw + error(s). + --max-parallel <number> - Set the maximum number of parallelism. Default is + unlimited. + --npm-path <string> - - - Set the path to npm. Default is the value of + environment variable npm_execpath. + If the variable is not defined, then it's "npm". + In this case, the "npm" command must be found in + environment variable PATH. + -l, --print-label - - - - Set the flag to print the task name as a prefix + on each line of output. Tools in scripts may stop + coloring their output if this option was given. + -n, --print-name - - - - Set the flag to print the task name before + running each task. + -p, --parallel <scripts> - Run a group of scripts in parallel. + e.g. 'npm-run-all -p foo bar' is similar to + 'npm run foo & npm run bar'. + -r, --race - - - - - - - Set the flag to kill all scripts when a task + finished with zero. This option is valid only + with 'parallel' option. + -s, --sequential <scripts> Run a group of scripts sequentially. + --serial <scripts> e.g. 'npm-run-all -s foo bar' is similar to + 'npm run foo && npm run bar'. + '--serial' is a synonym of '--sequential'. + --silent - - - - - - - - Set 'silent' to the log level of npm. + +Examples: + $ npm-run-all --serial clean lint build:** + $ npm-run-all --parallel watch:** + $ npm-run-all clean lint --parallel "build:** -- --watch" + $ npm-run-all -l -p start-server start-browser start-electron + +See Also: + https://github.com/mysticatea/npm-run-all#readme +`) + process.exitCode = arg === undefined ? 1 : 0 + return + } + if (arg === "-v" || arg === "--version") { + console.log(`v${version}`) + return + } + + // https://github.com/mysticatea/npm-run-all/issues/105 + // Avoid MaxListenersExceededWarnings. + process.stdout.setMaxListeners(0) + process.stderr.setMaxListeners(0) + process.stdin.setMaxListeners(0) + + // Main + const queue = new PQueue({ concurrency: 1 }) + const options = new CLIParameterParser(args) + + await queue.addAll( + options.groups.map(g => () => + runScripts( + g.patterns, + Object.assign({}, options, { parallel: g.parallel }), + ), + ), + ) +})(process.argv.slice(2)).then( + () => { + // I'm not sure why, but maybe the process never exits on Git Bash (MINGW64) + process.exit(0) + }, + error => { + console.error(error.stack) + process.exit(1) + }, +) + +/*eslint-enable */ diff --git a/src/bin/package.json.d.ts b/src/bin/package.json.d.ts new file mode 100644 index 0000000..879d981 --- /dev/null +++ b/src/bin/package.json.d.ts @@ -0,0 +1,8 @@ +/** + * @author Toru Nagashima <https://github.com/mysticatea> + * See LICENSE file in root directory for full license. + */ + +declare module "*/package.json" { + export const version: string +} diff --git a/bin/run-p/help.js b/src/bin/run-p.ts similarity index 57% rename from bin/run-p/help.js rename to src/bin/run-p.ts index 873568f..d9f2df2 100644 --- a/bin/run-p/help.js +++ b/src/bin/run-p.ts @@ -1,38 +1,32 @@ /** * @author Toru Nagashima - * @copyright 2016 Toru Nagashima. All rights reserved. * See LICENSE file in root directory for full license. */ -"use strict" +import { version } from "../../package.json" +import runScripts from "../lib" +import CLIParameterParser from "./cli-parameter-parser" -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ +/*eslint-disable no-console, no-process-exit */ +;(async (args: string[]) => { + const arg = args[0] -/** - * Print a help text. - * - * @param {stream.Writable} output - A writable stream to print. - * @returns {Promise} Always a fulfilled promise. - * @private - */ -module.exports = function printHelp(output) { - output.write(` + if (arg === undefined || arg === "-h" || arg === "--help") { + console.log(` Usage: $ run-p [--help | -h | --version | -v] - $ run-p [OPTIONS] <tasks> + $ run-p [OPTIONS] <scripts> Run given npm-scripts in parallel. - <tasks> : A list of npm-scripts' names and Glob-like patterns. + <scripts> : A list of npm-scripts' names and Glob-like patterns. Options: --aggregate-output - - - Avoid interleaving output by delaying printing of each command's output until it has finished. - -c, --continue-on-error - Set the flag to continue executing other tasks + -c, --continue-on-error - Set the flag to continue executing other scripts even if a task threw an error. 'run-p' itself - will exit with non-zero code if one or more tasks - threw error(s). + will exit with non-zero code if one or more + scripts threw error(s). --max-parallel <number> - Set the maximum number of parallelism. Default is unlimited. --npm-path <string> - - - Set the path to npm. Default is the value of @@ -41,11 +35,11 @@ Options: In this case, the "npm" command must be found in environment variable PATH. -l, --print-label - - - - Set the flag to print the task name as a prefix - on each line of output. Tools in tasks may stop + on each line of output. Tools in scripts may stop coloring their output if this option was given. -n, --print-name - - - - Set the flag to print the task name before running each task. - -r, --race - - - - - - - Set the flag to kill all tasks when a task + -r, --race - - - - - - - Set the flag to kill all scripts when a task finished with zero. -s, --silent - - - - - - Set 'silent' to the log level of npm. @@ -61,6 +55,32 @@ Examples: See Also: https://github.com/mysticatea/npm-run-all#readme `) + process.exitCode = arg === undefined ? 1 : 0 + return + } + if (arg === "-v" || arg === "--version") { + console.log(`v${version}`) + return + } + + // https://github.com/mysticatea/npm-run-all/issues/105 + // Avoid MaxListenersExceededWarnings. + process.stdout.setMaxListeners(0) + process.stderr.setMaxListeners(0) + process.stdin.setMaxListeners(0) + + // Main + const options = new CLIParameterParser(args, true) + await runScripts(options.patterns, options) +})(process.argv.slice(2)).then( + () => { + // I'm not sure why, but maybe the process never exits on Git Bash (MINGW64) + process.exit(0) + }, + error => { + console.error(error.stack) + process.exit(1) + }, +) - return Promise.resolve(null) -} +/*eslint-enable */ diff --git a/bin/run-s/help.js b/src/bin/run-s.ts similarity index 51% rename from bin/run-s/help.js rename to src/bin/run-s.ts index 6dfa6a1..9f5ddce 100644 --- a/bin/run-s/help.js +++ b/src/bin/run-s.ts @@ -1,43 +1,37 @@ /** * @author Toru Nagashima - * @copyright 2016 Toru Nagashima. All rights reserved. * See LICENSE file in root directory for full license. */ -"use strict" +import { version } from "../../package.json" +import runScripts from "../lib" +import CLIParameterParser from "./cli-parameter-parser" -//------------------------------------------------------------------------------ -// Public Interface -//------------------------------------------------------------------------------ +/*eslint-disable no-console, no-process-exit */ +;(async (args: string[]) => { + const arg = args[0] -/** - * Print a help text. - * - * @param {stream.Writable} output - A writable stream to print. - * @returns {Promise} Always a fulfilled promise. - * @private - */ -module.exports = function printHelp(output) { - output.write(` + if (arg === undefined || arg === "-h" || arg === "--help") { + console.log(` Usage: $ run-s [--help | -h | --version | -v] - $ run-s [OPTIONS] <tasks> + $ run-s [OPTIONS] <scripts> Run given npm-scripts sequentially. - <tasks> : A list of npm-scripts' names and Glob-like patterns. + <scripts> : A list of npm-scripts' names and Glob-like patterns. Options: -c, --continue-on-error - Set the flag to continue executing subsequent - tasks even if a task threw an error. 'run-s' + scripts even if a task threw an error. 'run-s' itself will exit with non-zero code if one or - more tasks threw error(s). + more scripts threw error(s). --npm-path <string> - - - Set the path to npm. Default is the value of environment variable npm_execpath. If the variable is not defined, then it's "npm." In this case, the "npm" command must be found in environment variable PATH. -l, --print-label - - - - Set the flag to print the task name as a prefix - on each line of output. Tools in tasks may stop + on each line of output. Tools in scripts may stop coloring their output if this option was given. -n, --print-name - - - - Set the flag to print the task name before running each task. @@ -55,6 +49,32 @@ Examples: See Also: https://github.com/mysticatea/npm-run-all#readme `) + process.exitCode = arg === undefined ? 1 : 0 + return + } + if (arg === "-v" || arg === "--version") { + console.log(`v${version}`) + return + } + + // https://github.com/mysticatea/npm-run-all/issues/105 + // Avoid MaxListenersExceededWarnings. + process.stdout.setMaxListeners(0) + process.stderr.setMaxListeners(0) + process.stdin.setMaxListeners(0) + + // Main + const options = new CLIParameterParser(args, false) + await runScripts(options.patterns, options) +})(process.argv.slice(2)).then( + () => { + // I'm not sure why, but maybe the process never exits on Git Bash (MINGW64) + process.exit(0) + }, + error => { + console.error(error.stack) + process.exit(1) + }, +) - return Promise.resolve(null) -} +/*eslint-enable */ diff --git a/src/lib/aggregate-stream.ts b/src/lib/aggregate-stream.ts new file mode 100644 index 0000000..a92393e --- /dev/null +++ b/src/lib/aggregate-stream.ts @@ -0,0 +1,27 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import stream from "stream" + +/** + * The writable stream to aggregate data. + */ +export class AggregateStream extends stream.Writable { + private chunks: Buffer[] = [] + + /** + * The aggregated data. + */ + public result() { + return Buffer.concat(this.chunks).toString() + } + + /** + * Handle a written data. + */ + public _write(chunk: Buffer, _encoding: string, callback: Function): void { + this.chunks.push(chunk) + callback(null) + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..8a54579 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,366 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import { AbortController, AbortSignal } from "abort-controller" +import PQueue from "p-queue" +import shellQuote from "shell-quote" +import { runScript, RunScriptOptions } from "./scripts/run" +import { AggregateStream } from "./aggregate-stream" +import { readPackageInfo, PackageInfo } from "./package-info" +import { ScriptError } from "./script-error" +import { getMatchedScriptNames } from "./script-match" +import { ScriptResult } from "./script-result" + +const ARGS_PATTERN = /\{(!)?([*@]|\d+)([^}]+)?}/g + +/** + * Normalized options. + */ +interface NormalizedOptions extends RunScriptOptions { + aggregateOutput: boolean + continueOnError: boolean + maxParallel: number + race: boolean + signal: AbortSignal | null +} + +/** + * Converts a given value to an array. + */ +function ensureArray(x: string | string[] | null | undefined): string[] { + if (x == null) { + return [] + } + return Array.isArray(x) ? x : [x] +} + +/** + * Replaces argument placeholders (such as `{1}`) by arguments. + */ +function applyArguments(patterns: string[], args: string[]): string[] { + const defaults = Object.create(null) + + return patterns.map(pattern => + pattern.replace( + ARGS_PATTERN, + ( + whole: string, + indirectionMark: string, + id: string, + options: string, + ): string => { + if (indirectionMark != null) { + throw Error(`Invalid Placeholder: ${whole}`) + } + if (id === "@") { + return shellQuote.quote(args) + } + if (id === "*") { + return shellQuote.quote([args.join(" ")]) + } + + const position = parseInt(id, 10) + if (position >= 1 && position <= args.length) { + return shellQuote.quote([args[position - 1]]) + } + + // Address default values + if (options != null) { + const prefix = options.slice(0, 2) + + if (prefix === ":=") { + defaults[id] = shellQuote.quote([options.slice(2)]) + return defaults[id] + } + if (prefix === ":-") { + return shellQuote.quote([options.slice(2)]) + } + + throw Error(`Invalid Placeholder: ${whole}`) + } + if (defaults[id] != null) { + return defaults[id] + } + + return "" + }, + ), + ) +} + +/** + * Parse patterns. + * In parsing process, it replaces argument placeholders (such as `{1}`) by arguments. + */ +function parsePatterns( + patternOrPatterns: string | string[], + args: string[], +): string[] { + const patterns = ensureArray(patternOrPatterns) + const hasPlaceholder = patterns.some(pattern => ARGS_PATTERN.test(pattern)) + + return hasPlaceholder ? applyArguments(patterns, args) : patterns +} + +/** + * Converts a given config object to an `--:=` style option array. + */ +function toOverwriteOptions(config: { + [key: string]: { [key: string]: string } +}): string[] { + const options = [] + + for (const packageName of Object.keys(config)) { + const packageConfig = config[packageName] + + for (const variableName of Object.keys(packageConfig)) { + const value = packageConfig[variableName] + + options.push(`--${packageName}:${variableName}=${value}`) + } + } + + return options +} + +/** + * Converts a given config object to an `--a=b` style option array. + */ +function toConfigOptions(config: { [key: string]: any }): string[] { + return Object.keys(config).map(key => `--${key}=${config[key]}`) +} + +/** + * Gets the maximum length. + */ +function maxLength(length: number, name: string): number { + return Math.max(name.length, length) +} + +/** + * Normalize options. + */ +//eslint-disable-next-line complexity, require-jsdoc +async function normalizeOptions( + patternOrPatterns: string | string[], + options?: Options, +): Promise<{ scriptNames: string[]; config: NormalizedOptions }> { + const stdin = (options && options.stdin) || null + const stdout = (options && options.stdout) || null + const stderr = (options && options.stderr) || null + const taskList = (options && options.taskList) || null + const config = (options && options.config) || {} + const packageConfig = (options && options.packageConfig) || {} + const ddArgs = (options && options.arguments) || [] + const parallel = Boolean(options && options.parallel) + const silent = Boolean(options && options.silent) + const continueOnError = Boolean(options && options.continueOnError) + const printLabel = Boolean(options && options.printLabel) + const printName = Boolean(options && options.printName) + const race = Boolean(options && options.race) + const maxParallel = parallel + ? (options && options.maxParallel) || Number.POSITIVE_INFINITY + : 1 + const aggregateOutput = Boolean(options && options.aggregateOutput) + const npmPath = (options && options.npmPath) || null + const signal = (options && options.signal) || null + const patterns = parsePatterns(patternOrPatterns, ddArgs) + + if (taskList != null && Array.isArray(taskList) === false) { + throw new Error("Invalid options.taskList") + } + if (typeof maxParallel !== "number" || !(maxParallel >= 0)) { + throw new Error("Invalid options.maxParallel") + } + if (!parallel && aggregateOutput) { + throw new Error( + "Invalid options.aggregateOutput; It requires options.parallel", + ) + } + if (!parallel && race) { + throw new Error("Invalid options.race; It requires options.parallel") + } + + const args = ([] as string[]).concat( + silent ? ["--silent"] : [], + packageConfig ? toOverwriteOptions(packageConfig) : [], + config ? toConfigOptions(config) : [], + ) + const packageInfo: PackageInfo = + taskList != null ? { taskList, rawData: {} } : await readPackageInfo() + const scriptNames = getMatchedScriptNames(packageInfo.taskList, patterns) + const labelWidth = scriptNames.reduce(maxLength, 0) + + return { + scriptNames, + config: { + stdin, + stdout, + stderr, + args, + continueOnError, + aggregateOutput, + race, + maxParallel, + npmPath, + signal, + scriptHeader: printName, + scriptLabel: { + enabled: printLabel, + width: labelWidth, + lastPrefix: "", + lastIsLinebreak: true, + }, + packageInfo, + }, + } +} + +/** + * Create the child abort controller of a given signal. + */ +function inheritSignal( + signal: AbortSignal | null, +): AbortController & { dispose(): void } { + const ac = new AbortController() as AbortController & { dispose(): void } + ac.dispose = () => { + // do nothing. + } + + if (signal) { + if (signal.aborted) { + ac.abort() + } else { + const onAbort = function(): void { + signal.removeEventListener("abort", onAbort) + ac.abort() + } + + signal.addEventListener("abort", onAbort) + ac.dispose = () => { + signal.removeEventListener("abort", onAbort) + } + } + } + + return ac +} + +/** + * Wrap for the aggregateOutput option. + * @param options The options. + * @param f The main logic. + */ +function wrapToAggregateOutput( + options: NormalizedOptions, + f: (options: NormalizedOptions) => Promise<ScriptResult>, +): () => Promise<ScriptResult> { + if (options.aggregateOutput && options.stdout) { + const stdout = options.stdout + return async () => { + const thisOptions = Object.assign({}, options, { + stdout: new AggregateStream(), + }) + try { + return await f(thisOptions) + } finally { + try { + stdout.write(thisOptions.stdout.result()) + } catch (_err) { + // ignore. + } + } + } + } + return () => f(options) +} + +/** + * Runs npm-scripts which are matched with given patterns. + */ +export default async function runScripts( + patternOrPatterns: string | string[], + options?: Options, +): Promise<ScriptResult[]> { + const { scriptNames, config } = await normalizeOptions( + patternOrPatterns, + options, + ) + if (scriptNames.length === 0) { + return [] + } + + const queue = new PQueue({ concurrency: config.maxParallel }) + const ac = inheritSignal(config.signal) + try { + let error: Error | null = null + + // Run scripts. + const results = await queue.addAll( + scriptNames.map(name => + wrapToAggregateOutput(config, async thisOptions => { + try { + const ret = await runScript( + name, + thisOptions, + ac.signal, + ) + + // Aborts all scripts if it's an error. + if (ret.code) { + error = error || new ScriptError(ret, results) + if (!config.continueOnError) { + ac.abort() + } + } + + // Aborts all scripts if options.race is true. + if (config.race && !ret.code) { + ac.abort() + } + + return ret + } catch (e) { + error = error || e + if (!config.continueOnError) { + ac.abort() + } + return { name, code: undefined } + } + }), + ), + ) + + if (error) { + throw error + } + return results + } finally { + ac.dispose() + } +} +Object.assign(runScripts, { AbortController, AbortSignal, default: runScripts }) + +/** + * Options. + */ +export interface Options { + stdin?: NodeJS.ReadableStream + stdout?: NodeJS.WritableStream & { isTTY?: boolean } + stderr?: NodeJS.WritableStream & { isTTY?: boolean } + taskList?: string[] + config?: { [key: string]: string } + packageConfig?: { [key: string]: { [key: string]: string } } + arguments?: string[] + parallel?: boolean + silent?: boolean + continueOnError?: boolean + printLabel?: boolean + printName?: boolean + race?: boolean + aggregateOutput?: boolean + maxParallel?: number + npmPath?: string + signal?: AbortSignal +} diff --git a/src/lib/package-info.ts b/src/lib/package-info.ts new file mode 100644 index 0000000..685e57d --- /dev/null +++ b/src/lib/package-info.ts @@ -0,0 +1,37 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import path from "path" +import fs from "fs-extra" + +/** + * Ensure x is an object. + */ +//eslint-disable-next-line require-jsdoc, mysticatea/ts/no-explicit-any +function ensureObject(x: any): { [key: string]: any } { + return typeof x === "object" && x !== null ? x : {} +} + +/** + * Information of package.json. + */ +export interface PackageInfo { + filePath?: string + taskList: string[] + + //eslint-disable-next-line mysticatea/ts/no-explicit-any + rawData: { [key: string]: any } +} + +/** + * Reads the package.json in the current directory. + */ +export async function readPackageInfo(): Promise<PackageInfo> { + const filePath = path.join(process.cwd(), "package.json") + const rawData = ensureObject(await fs.readJSON(filePath)) + const scripts = ensureObject(rawData.scripts) + const taskList = Object.keys(scripts) + + return { filePath, taskList, rawData } +} diff --git a/src/lib/script-error.ts b/src/lib/script-error.ts new file mode 100644 index 0000000..b0e980b --- /dev/null +++ b/src/lib/script-error.ts @@ -0,0 +1,22 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import { ScriptResult } from "./script-result" + +/** + * Error object with some additional info. + */ +export class ScriptError extends Error { + public causeResult: ScriptResult + public allResults: ScriptResult[] + + /** + * Initialize this error. + */ + public constructor(causeResult: ScriptResult, allResults: ScriptResult[]) { + super(`"${causeResult.name}" exited with ${causeResult.code}.`) + this.causeResult = causeResult + this.allResults = allResults + } +} diff --git a/src/lib/script-match.ts b/src/lib/script-match.ts new file mode 100644 index 0000000..537a6da --- /dev/null +++ b/src/lib/script-match.ts @@ -0,0 +1,115 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import { Minimatch } from "minimatch" + +const COLON_OR_SLASH = /[:/]/g +const CONVERT_MAP = { ":": "/", "/": ":" } + +/** + * Swaps ":" and "/", in order to use ":" as the separator in minimatch. + */ +function swapColonAndSlash(s: string): string { + return s.replace( + COLON_OR_SLASH, + matched => CONVERT_MAP[matched as ":" | "/"], + ) +} + +/** + * The filter of glob-like pattern. + */ +class PatternFilter { + public readonly name: string + public readonly args: string + public readonly match: (name: string) => boolean + + /** + * Create new filter. + */ + public static new(pattern: string): PatternFilter { + return new PatternFilter(pattern) + } + + /** + * Initialize this filter. + */ + public constructor(pattern: string) { + const trimmed = pattern.trim() + const spacePos = trimmed.indexOf(" ") + const name = spacePos < 0 ? trimmed : trimmed.slice(0, spacePos) + const matcher = new Minimatch(swapColonAndSlash(name)) + + this.name = name + this.args = spacePos < 0 ? "" : trimmed.slice(spacePos) + this.match = matcher.match.bind(matcher) + } +} + +/** + * The set to remove overlapped task. + */ +class ScriptSet { + private nameMap: { + [name: string]: Set<string> | undefined + } = Object.create(null) + public commands: string[] = [] + + /** + * Adds a command (a pattern) into this set if it's not overlapped. + * "Overlapped" is meaning that the command was added from a different source. + */ + public add(command: string, nameOfSourcePattern: string): void { + const names = + this.nameMap[command] || (this.nameMap[command] = new Set<string>()) + if (names.has(nameOfSourcePattern)) { + this.commands.push(command) + } + names.add(nameOfSourcePattern) + } +} + +/** + * Enumerates tasks which matches with given patterns. + */ +export function getMatchedScriptNames( + taskList: string[], + patterns: string[], +): string[] { + const filters = patterns.map(PatternFilter.new) + const candidates = taskList.map(swapColonAndSlash) + const scriptSet = new ScriptSet() + const unknownSet = new Set<string>() + + // Take tasks while keep the order of patterns. + for (const filter of filters) { + let found = false + + for (const candidate of candidates) { + if (filter.match(candidate)) { + found = true + scriptSet.add( + swapColonAndSlash(candidate) + filter.args, + filter.name, + ) + } + } + + // Built-in tasks should be allowed. + if (!found && (filter.name === "restart" || filter.name === "env")) { + scriptSet.add(filter.name + filter.args, filter.name) + found = true + } + if (!found) { + unknownSet.add(filter.name) + } + } + + if (unknownSet.size > 0) { + throw new Error( + `Script not found: "${Array.from(unknownSet).join('", ')}"`, + ) + } + return scriptSet.commands +} diff --git a/src/lib/script-result.ts b/src/lib/script-result.ts new file mode 100644 index 0000000..e1e79a2 --- /dev/null +++ b/src/lib/script-result.ts @@ -0,0 +1,12 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ + +/** + * The result of scripts. + */ +export interface ScriptResult { + name: string + code: number | undefined +} diff --git a/src/lib/scripts/label.ts b/src/lib/scripts/label.ts new file mode 100644 index 0000000..8729b80 --- /dev/null +++ b/src/lib/scripts/label.ts @@ -0,0 +1,64 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import stream from "stream" + +const ALL_BR = /\n/g + +/** + * Shared state in multiple streams. + */ +export interface ScriptLabelTransformState { + lastPrefix: string + lastIsLinebreak: boolean +} + +/** + * The transform stream to insert a specific prefix. + * + * Several streams can exist for the same output stream. + * This stream will insert the prefix if the last output came from other instance. + * To do that, this stream is using a shared state object. + */ +export class ScriptLabelTransform extends stream.Transform { + private readonly prefix: string + private readonly state: ScriptLabelTransformState + + /** + * Initialize this stream. + */ + public constructor(prefix: string, state: ScriptLabelTransformState) { + super() + this.prefix = prefix + this.state = state + } + + /** + * Transforms the output chunk. + */ + public _transform( + chunk: any, //eslint-disable-line mysticatea/ts/no-explicit-any + _encoding: string, + callback: stream.TransformCallback, + ) { + const prefix = this.prefix + const nPrefix = `\n${prefix}` + const state = this.state + const firstPrefix = state.lastIsLinebreak + ? prefix + : state.lastPrefix !== prefix + ? "\n" + : /* otherwise */ "" + const prefixed = `${firstPrefix}${chunk}`.replace(ALL_BR, nPrefix) + const index = prefixed.indexOf( + prefix, + Math.max(0, prefixed.length - prefix.length), + ) + + state.lastPrefix = prefix + state.lastIsLinebreak = index !== -1 + + callback(undefined, index !== -1 ? prefixed.slice(0, index) : prefixed) + } +} diff --git a/src/lib/scripts/name.ts b/src/lib/scripts/name.ts new file mode 100644 index 0000000..c16b032 --- /dev/null +++ b/src/lib/scripts/name.ts @@ -0,0 +1,35 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import ansiStyles from "ansi-styles" +import { PackageInfo } from "../package-info" + +/** + * Creates the header text for a given script name. + */ +export function createScriptHeader( + nameAndArgs: string, + packageInfo: PackageInfo, + isTTY: boolean, +): string { + const color = isTTY ? ansiStyles.gray : { open: "", close: "" } + if (!packageInfo) { + return `\n${color.open}> ${nameAndArgs}${color.close}\n\n` + } + + const index = nameAndArgs.indexOf(" ") + const scriptName = index === -1 ? nameAndArgs : nameAndArgs.slice(0, index) + const args = index === -1 ? "" : nameAndArgs.slice(index + 1) + const rawData = packageInfo.rawData + const name = rawData.name + const version = rawData.version + const scriptBody = rawData.scripts && rawData.scripts[scriptName] + const packagePath = packageInfo.filePath + + return ` +${color.open}> ${name}@${version} ${scriptName} ${packagePath}${color.close} +${color.open}> ${scriptBody} ${args}${color.close} + +` +} diff --git a/src/lib/scripts/run.ts b/src/lib/scripts/run.ts new file mode 100644 index 0000000..644df4d --- /dev/null +++ b/src/lib/scripts/run.ts @@ -0,0 +1,238 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import { ChildProcess } from "child_process" +import path from "path" +import { AbortSignal } from "abort-controller" +import chalk from "chalk" +import { parse as parseArgs } from "shell-quote" +import padEnd from "string.prototype.padend" +import { PackageInfo } from "../package-info" +import { ScriptResult } from "../script-result" +import { ScriptLabelTransform, ScriptLabelTransformState } from "./label" +import { createScriptHeader } from "./name" +import { spawn } from "./spawn" + +// Color definitions +type Color = (...text: string[]) => string +const colors: Color[] = [ + chalk.cyan, + chalk.green, + chalk.magenta, + chalk.yellow, + chalk.red, +] +const defaultColor = ((s: string) => s) as Color + +// Data to rotate colors +let colorIndex = 0 +const scriptNamesToColors = new Map<string, Color>() + +/** + * Select a color from given script name. + */ +function selectColor(name: string): typeof colors[0] { + let color = scriptNamesToColors.get(name) + if (!color) { + color = colors[colorIndex] + colorIndex = (colorIndex + 1) % colors.length + scriptNamesToColors.set(name, color) + } + return color +} + +/** + * Wraps stdout/stderr with a transform stream to add the script name as prefix. + */ +function wrapLabeling( + name: string, + outputStream: (NodeJS.WritableStream & { isTTY?: boolean }) | null, + labelOptions: ScriptLabelOptions, +): NodeJS.WritableStream | null { + if (outputStream == null || !labelOptions.enabled) { + return outputStream + } + + const label = padEnd(name, labelOptions.width) + const color = outputStream.isTTY ? selectColor(name) : defaultColor + const prefix = color(`[${label}] `) + const transform = new ScriptLabelTransform(prefix, labelOptions) + + transform.pipe(outputStream) + + return transform +} + +/** + * Converts a given stream to an option for `child_process.spawn`. + */ +function detectStreamKind< + T extends + | (NodeJS.WritableStream & { isTTY?: boolean }) + | (NodeJS.ReadableStream & { isTTY?: boolean }) +>(targetStream: T | null, std: T): T | "ignore" | "pipe" { + if (targetStream == null) { + return "ignore" + } + if (targetStream !== std) { + return "pipe" + } + + // For the workaround of https://github.com/nodejs/node/issues/5620 + if (!std.isTTY) { + return "pipe" + } + + return targetStream +} + +/** + * Normalize spawn arguments. + * @param scriptName The script name to run. + * @param npmPath0 The path to npm or yarn. + * @param additionalArgs The additional arguments to exec. + * @returns The normalized arguments. + */ +function normalizeSpawnArgs( + scriptName: string, + npmPath0: string | null, + additionalArgs: string[], +): { execPath: string; execArgs: string[] } { + const npmPath = npmPath0 || process.env.npm_execpath || "npm" //eslint-disable-line no-process-env + const npmPathIsJs = /^\.m?js$/.test(path.extname(npmPath)) + const execPath = npmPathIsJs ? process.execPath : npmPath + const isYarn = path.basename(npmPath).startsWith("yarn") + const execArgs = ["run"] + + if (npmPathIsJs) { + execArgs.unshift(npmPath) + } + + if (!isYarn) { + Array.prototype.push.apply(execArgs, additionalArgs) + } else if (additionalArgs.indexOf("--silent") !== -1) { + execArgs.push("--silent") + } + + Array.prototype.push.apply(execArgs, parseArgs(scriptName)) + + return { execPath, execArgs } +} + +/** + * Options for `--label` option. + */ +export interface ScriptLabelOptions extends ScriptLabelTransformState { + enabled: boolean + width: number +} + +/** + * Options for runScript(). + */ +export interface RunScriptOptions { + stdin: NodeJS.ReadableStream | null + stdout: (NodeJS.WritableStream & { isTTY?: boolean }) | null + stderr: (NodeJS.WritableStream & { isTTY?: boolean }) | null + args: string[] + packageInfo: PackageInfo + scriptHeader: boolean + scriptLabel: ScriptLabelOptions + npmPath: string | null +} + +/** + * Run a npm-script of a given name. + * The return value is a promise which has an extra method: `abort()`. + * The `abort()` kills the child process to run the npm-script. + */ +export function runScript( + name: string, + options: RunScriptOptions, + signal: AbortSignal, +): Promise<ScriptResult> { + if (signal.aborted) { + return Promise.resolve<ScriptResult>({ name, code: undefined }) + } + return new Promise<ScriptResult>(async (resolve, reject) => { + let cp: ChildProcess | null = null + let error: Error | null = null + + const stdin = options.stdin + const stdout = wrapLabeling(name, options.stdout, options.scriptLabel) + const stderr = wrapLabeling(name, options.stderr, options.scriptLabel) + const stdinKind = detectStreamKind(stdin, process.stdin) + const stdoutKind = detectStreamKind(stdout, process.stdout) + const stderrKind = detectStreamKind(stderr, process.stderr) + + // Print script name. + if (options.scriptHeader && stdout && options.stdout) { + stdout.write( + createScriptHeader( + name, + options.packageInfo, + Boolean(options.stdout.isTTY), + ), + ) + } + + // Register to abort signal. + //eslint-disable-next-line require-jsdoc + function onAbort(): void { + if (cp) { + cp.kill() + } + } + + signal.addEventListener("abort", onAbort) + + // Execute. + const { execPath, execArgs } = normalizeSpawnArgs( + name, + options.npmPath, + options.args, + ) + const execOptions = { stdio: [stdinKind, stdoutKind, stderrKind] } + cp = await spawn(execPath, execArgs, execOptions) + + // Piping stdio. + if (stdinKind === "pipe" && stdin) { + stdin.pipe(cp.stdin) + } + if (stdoutKind === "pipe" && stdout) { + cp.stdout.pipe(stdout, { end: false }) + } + if (stderrKind === "pipe" && stderr) { + cp.stderr.pipe(stderr, { end: false }) + } + + // Register handlers. + cp.on("error", (e: Error) => { + error = e + }) + cp.on("close", (code: number | undefined) => { + signal.removeEventListener("abort", onAbort) + + if (cp) { + if (stdinKind === "pipe" && stdin) { + stdin.unpipe(cp.stdin) + } + if (stdoutKind === "pipe" && stdout) { + cp.stdout.unpipe(stdout) + } + if (stderrKind === "pipe" && stderr) { + cp.stderr.unpipe(stderr) + } + cp.removeAllListeners() + cp = null + } + + if (error) { + reject(error) + } else { + resolve({ name, code }) + } + }) + }) +} diff --git a/src/lib/scripts/spawn.ts b/src/lib/scripts/spawn.ts new file mode 100644 index 0000000..dad544f --- /dev/null +++ b/src/lib/scripts/spawn.ts @@ -0,0 +1,22 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import { ChildProcess, SpawnOptions } from "child_process" + +const implPromise = + process.platform === "win32" + ? import("./spawn/win32") + : import("./spawn/posix") + +/** + * Spawn a child process. + * This is almost same as `child_process.spawn`, but the `kill` method kills all descendant processes. + */ +export async function spawn( + command: string, + args: string[], + options: SpawnOptions, +): Promise<ChildProcess> { + return (await implPromise).spawn(command, args, options) +} diff --git a/src/lib/scripts/spawn/posix.ts b/src/lib/scripts/spawn/posix.ts new file mode 100644 index 0000000..b341ac0 --- /dev/null +++ b/src/lib/scripts/spawn/posix.ts @@ -0,0 +1,41 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import { ChildProcess, SpawnOptions } from "child_process" +import spawn0 from "cross-spawn" +import getDescendentProcessInfo from "ps-tree" + +/** + * Kill child processes recursively for POSIX. + */ +function kill(this: ChildProcess): void { + getDescendentProcessInfo(this.pid, (err, descendent) => { + if (err || !descendent) { + return + } + + for (const child of descendent) { + try { + process.kill(Number(child.PID)) + } catch (_err) { + // ignore. + } + } + }) +} + +/** + * Spawn a child process. + * This is almost same as `child_process.spawn`, but the `kill` method kills all descendant processes. + */ +export function spawn( + command: string, + args: string[], + options: SpawnOptions, +): ChildProcess { + const child = spawn0(command, args, options) + child.kill = kill + + return child +} diff --git a/src/lib/scripts/spawn/win32.ts b/src/lib/scripts/spawn/win32.ts new file mode 100644 index 0000000..3d8e4a0 --- /dev/null +++ b/src/lib/scripts/spawn/win32.ts @@ -0,0 +1,73 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +import { ChildProcess, SpawnOptions } from "child_process" +import readline from "readline" +import spawn0 from "cross-spawn" + +// Catch SIGINT. +let rl: readline.ReadLine | null = null +process.on("newListener", type => { + if (type === "SIGINT" && process.listenerCount("SIGINT") === 1) { + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + rl.on("SIGINT", process.emit.bind(process, "SIGINT")) + } +}) +process.on("removeListener", type => { + if (type === "SIGINT" && rl && process.listenerCount("SIGINT") === 0) { + rl.close() + rl = null + } +}) + +/** + * Register a given child process to transfar SIGINT. + */ +function register(cp: ChildProcess): void { + /*eslint-disable require-jsdoc */ + function onSIGINT(): void { + cp.kill() + } + + function onDead(): void { + process.removeListener("SIGINT", onSIGINT) + cp.removeListener("exit", onDead) + cp.removeListener("error", onDead) + } + /*eslint-enable require-jsdoc */ + + process.on("SIGINT", onSIGINT) + cp.on("exit", onDead) + cp.on("error", onDead) +} + +/** + * Kill child processes recursively for Windows. + */ +function kill(this: ChildProcess): void { + spawn0("taskkill", ["/F", "/T", "/PID", String(this.pid)]) +} + +/** + * Spawn a child process. + * This is almost same as `child_process.spawn`, but the `kill` method kills all descendant processes. + */ +export function spawn( + command: string, + args: string[], + options: SpawnOptions, +): ChildProcess { + const child = spawn0(command, args, options) + child.kill = kill + + // If stdin is not inherited, transfar signals to child process. + if (options.stdio && options.stdio[0] !== process.stdin) { + register(child) + } + + return child +} diff --git a/test/.eslintrc.json b/test/.eslintrc.json deleted file mode 100644 index 407983a..0000000 --- a/test/.eslintrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "mysticatea/mocha", - "rules": { - "node/no-unsupported-features": ["error", { - "ignores": ["asyncAwait"] - }] - } -} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fc4b656 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "baseUrl": "typings", + "declaration": false, + "diagnostics": false, + "emitBOM": false, + "inlineSources": true, + "lib": [ + "es2015" + ], + "module": "esnext", + "moduleResolution": "node", + "newLine": "LF", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noStrictGenericChecks": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": ".temp", + "pretty": true, + "sourceMap": true, + "sourceRoot": "src", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "target": "es2018", + "locale": "en" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/typings/abort-controller/index.d.ts b/typings/abort-controller/index.d.ts new file mode 100644 index 0000000..5c1dd34 --- /dev/null +++ b/typings/abort-controller/index.d.ts @@ -0,0 +1,37 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +declare module "abort-controller" { + interface AbortController { + readonly signal: AbortSignal + abort(): void + } + + interface AbortSignal { + readonly aborted: boolean + onabort: Function | null + addEventListener( + type: string, + listener: Function, + options?: + | boolean + | { capture?: boolean; once?: boolean; passive?: boolean }, + ): void + removeEventListener( + type: string, + listener: Function, + options?: boolean | { capture?: boolean }, + ): void + } + + export const AbortController: { + prototype: AbortController + new (): AbortController + } + + export const AbortSignal: { + prototype: AbortSignal + new (): AbortSignal + } +} diff --git a/typings/ps-tree/index.d.ts b/typings/ps-tree/index.d.ts new file mode 100644 index 0000000..0971220 --- /dev/null +++ b/typings/ps-tree/index.d.ts @@ -0,0 +1,16 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +declare module "ps-tree" { + function psTree( + pid: number | string, + callback: (err: null | null, children: { PID: string }[]) => void, + ): void + + namespace psTree { + + } + + export = psTree +} diff --git a/typings/string.prototype.padend/index.d.ts b/typings/string.prototype.padend/index.d.ts new file mode 100644 index 0000000..2ca6e9a --- /dev/null +++ b/typings/string.prototype.padend/index.d.ts @@ -0,0 +1,13 @@ +/** + * @author Toru Nagashima + * See LICENSE file in root directory for full license. + */ +declare module "string.prototype.padend" { + function padEnd(s: string, maxLength: number, fillString?: string): string + + namespace padEnd { + + } + + export = padEnd +}