diff --git a/History.md b/History.md index 24cf8e2..f804542 100644 --- a/History.md +++ b/History.md @@ -1,3 +1,8 @@ +### v3.2.0 + - [#31](https://github.com/darkguy2008/parallelshell/issues/31) Add + `-n, --npm ` to run npm scripts with wildcard matching, + mixed with ordinary commands. Validate all selections before launching. + ### v3.1.0 - [#40](https://github.com/darkguy2008/parallelshell/issues/40) Add `-t, --timeout ` to stop remaining commands after a deadline, diff --git a/README.md b/README.md index 21e2652..adb019c 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,30 @@ Available options: -v, --verbose verbose logging -w, --wait will not close sibling processes on error -t, --timeout stop remaining commands after the deadline +-n, --npm run matching npm scripts from package.json ``` +Use `-n` (or `--npm`) before each npm script name or pattern: + +```bash +parallelshell -n "build:*" "echo ordinary command" -n "test:{unit,integration}" +``` + +Scripts are read from `package.json` in the current directory and run through +`npm run`, including npm's pre/post lifecycle scripts. npm must be on PATH. +Exact script names take precedence over patterns. Patterns use minimatch syntax: +`*`, `?`, character classes, braces and extended globs such as `build:+(js|css)`. +Matching is case-sensitive, includes names starting with a dot and treats leading +`#` and `!` literally. `/` separates pattern segments; use `**` across segments. +Quote patterns with double quotes so they work on Windows and are not expanded +by your shell. Matches follow package.json order; repeated selections run again. +All selections are validated before any command starts. Missing values, unmatched +patterns and missing or invalid package.json files exit with code 1. + +This shorthand uses the existing shutdown behavior. The npm-script shutdown +limitation on Debian/Ubuntu tracked in #22 remains unchanged. + Use `--timeout` (or `-t`) to limit the overall run, including when `--wait` is enabled: diff --git a/index.js b/index.js index 2bb684c..f18d5ab 100755 --- a/index.js +++ b/index.js @@ -2,6 +2,9 @@ 'use strict'; var spawn = require('child_process').spawn; +var spawnNpm = require('cross-spawn'); +var fs = require('fs'); +var minimatch = require('minimatch'); var path = require('path'); var signals = require('constants'); var SIGNAL_EXIT_CODE_BASE = 128; @@ -13,12 +16,36 @@ var MAX_TIMEOUT_MS = Math.pow(2, 31) - 1; var WINDOWS = process.platform === 'win32'; var commandPrefix = WINDOWS ? '' : 'exec '; -var children, args, wait, cmds, verbose, timeout, timer, i ,len; +var children, args, wait, cmds, verbose, timeout, timer, scripts, i ,len; cmds = []; args = process.argv.slice(2); for (i = 0, len = args.length; i < len; i++) { if (args[i][0] === '-') { switch (args[i]) { + case '-n': + case '--npm': + var pattern = args[++i]; + if (!pattern || pattern[0] === '-') { + console.error('--npm requires a script name or pattern'); + process.exit(1); + } + try { + if (!scripts) { + scripts = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).scripts; + if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) throw new Error('package.json must contain a scripts object'); + } + var names = Object.keys(scripts); + var matches = Object.prototype.hasOwnProperty.call(scripts, pattern) ? [pattern] : minimatch.match(names, pattern, { dot: true, nocomment: true, nonegate: true, allowWindowsEscape: true }); + if (!matches.length) throw new Error('no npm scripts match ' + JSON.stringify(pattern)); + matches.forEach(function (name) { + if (typeof scripts[name] !== 'string') throw new Error('npm script ' + JSON.stringify(name) + ' must be a string'); + cmds.push({ script: name }); + }); + } catch (error) { + console.error('--npm: ' + error.message); + process.exit(1); + } + break; case '-t': case '--timeout': timeout = Number(args[++i]) * MILLISECONDS_PER_SECOND; @@ -41,6 +68,7 @@ for (i = 0, len = args.length; i < len; i++) { console.log('-v, --verbose verbose logging') console.log('-w, --wait will not close sibling processes on error') console.log('-t, --timeout stop remaining commands after the deadline'); + console.log('-n, --npm run matching npm scripts from package.json'); process.exit(); break; } @@ -134,10 +162,20 @@ FORWARDED_SIGNALS.forEach(function (signal) { }); children = cmds.map(function (cmd) { - var command = commandPrefix + cmd; - var child = spawn(command, { - shell: true, + var command = cmd.script === undefined ? commandPrefix + cmd : 'npm run -- ' + JSON.stringify(cmd.script); + var options = { stdio: ['pipe', process.stdout, process.stderr] + }; + var child; + if (cmd.script === undefined) { + options.shell = true; + child = spawn(command, options); + } else { + child = spawnNpm('npm', ['run', '--', cmd.script], options); + } + child.on('error', function (error) { + console.error(error.message); + childClose.call(this, 1); }).on('close', childClose); child.cmd = command; return child; diff --git a/package.json b/package.json index 825ca3b..c1138ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parallelshell", - "version": "3.1.0", + "version": "3.2.0", "description": "Invoke multiple commands, running in parallel", "homepage": "https://github.com/darkguy2008/parallelshell", "repository": { @@ -27,6 +27,10 @@ ], "author": "DARKGuy ", "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.6", + "minimatch": "^3.1.5" + }, "devDependencies": { "chai": "^4.0.2", "coffee-script": "^1.12.6", diff --git a/test/index.coffee b/test/index.coffee index 277fc1c..222bb5b 100644 --- a/test/index.coffee +++ b/test/index.coffee @@ -43,6 +43,7 @@ usageInfo = """ -v, --verbose verbose logging -w, --wait will not close sibling processes on error -t, --timeout stop remaining commands after the deadline +-n, --npm run matching npm scripts from package.json """ + "\n" spawned = [] @@ -115,9 +116,116 @@ afterEach -> for directory in triggerDirectories.splice(0) trigger = path.join directory, TRIGGER_NAME fs.unlinkSync trigger if fs.existsSync trigger + manifest = path.join directory, "package.json" + fs.unlinkSync manifest if fs.existsSync manifest + npmLog = path.join directory, "npm-debug.log" + fs.unlinkSync npmLog if fs.existsSync npmLog fs.rmdirSync directory describe "parallelshell", -> + describe "npm scripts", -> + scriptNames = ["build:js", "build:css", "build:html", "test:js", ".hidden", "#hash", "!bang", "literal*", "two words", "quote\"name", "amp&name", "percent%PATH%", "nested/build/js"] + + project = (scripts) -> + directory = path.dirname newTrigger() + fs.writeFileSync path.join(directory, "package.json"), JSON.stringify {name: "parallelshell-fixture", version: "1.0.0", scripts} + directory + + npmProject = -> + scripts = {} + for name in scriptNames + scripts[name] = "node -e \"console.log('script '+process.env.npm_lifecycle_event)\"" + project scripts + + runScripts = (args...) -> spawnParallelshellWith {cwd: npmProject()}, args... + + ["-n", "--npm"].forEach (flag) -> + it "should run an exact npm script with #{flag}", -> + ps = runScripts flag, "build:js" + ps.exited.then (result) -> + result.code.should.equal 0, ps.output + ps.errorOutput + outputLines(ps).should.include "script build:js" + + patterns = { + "build:*": ["build:js", "build:css", "build:html"] + "*:js": ["build:js", "test:js"] + "b*:j?": ["build:js"] + "build:{js,css}": ["build:js", "build:css"] + "build:[ch]*": ["build:css", "build:html"] + "build:+(js|css)": ["build:js", "build:css"] + "literal*": ["literal*"] + ".*": [".hidden"] + "#hash": ["#hash"] + "!bang": ["!bang"] + "two words": ["two words"] + "quote\"name": ["quote\"name"] + "amp&name": ["amp&name"] + "percent%PATH%": ["percent%PATH%"] + "nested/**": ["nested/build/js"] + } + Object.keys(patterns).forEach (pattern) -> + it "should expand #{pattern} against script names", -> + ps = runScripts "-n", pattern + ps.exited.then (result) -> + result.code.should.equal 0, ps.output + ps.errorOutput + outputLines(ps).filter((line) -> line.indexOf("script ") == 0).sort().should.deep.equal patterns[pattern].map((name) -> "script #{name}").sort() + + it "should mix repeated npm options with ordinary commands and existing options", -> + ps = runScripts "-w", "-n", "build:js", "echo ordinary", "--npm", "build:css", "-t", String(COMPLETION_TIMEOUT_SECONDS) + ps.exited.then (result) -> + result.code.should.equal 0 + outputLines(ps).should.include.members ["ordinary", "script build:js", "script build:css"] + + it "should preserve explicitly repeated scripts", -> + ps = runScripts "-n", "build:js", "-n", "build:js" + ps.exited.then (result) -> + result.code.should.equal 0 + outputLines(ps).filter((line) -> line == "script build:js").length.should.equal 2 + + it "should preserve npm pre and post lifecycle scripts", -> + ps = spawnParallelshellWith {cwd: project({prebuild: "echo lifecycle-pre", build: "echo lifecycle-main", postbuild: "echo lifecycle-post"})}, "-n", "build" + ps.exited.then (result) -> + result.code.should.equal 0 + outputLines(ps).filter((line) -> line.indexOf("lifecycle-") == 0).should.deep.equal ["lifecycle-pre", "lifecycle-main", "lifecycle-post"] + + it "should preserve npm failure status", -> + ps = spawnParallelshellWith {cwd: project({fail: exitProcess(FAILURE_EXIT_CODE)})}, "-n", "fail" + ps.exited.then (result) -> result.code.should.not.equal 0 + + [undefined, "", "-w", "missing", "missing:*"].forEach (pattern) -> + it "should reject #{pattern} before launching any command", -> + args = ["echo ordinary", "-n", "build:js", "-n"] + args.push pattern if pattern != undefined + ps = runScripts args... + ps.exited.then (result) -> + result.code.should.equal 1 + ps.output.should.equal "" + ps.errorOutput.should.contain "--npm" + + [null, [], {bad: 42}].forEach (scripts) -> + it "should reject missing or invalid script definitions #{JSON.stringify scripts}", -> + ps = spawnParallelshellWith {cwd: project(scripts)}, "echo ordinary", "-n", "bad" + ps.exited.then (result) -> + result.code.should.equal 1 + ps.output.should.equal "" + ps.errorOutput.should.contain "--npm" + + it "should reject invalid package JSON before launching commands", -> + directory = project {} + fs.writeFileSync path.join(directory, "package.json"), "{" + ps = spawnParallelshellWith {cwd: directory}, "echo ordinary", "-n", "build" + ps.exited.then (result) -> + result.code.should.equal 1 + ps.output.should.equal "" + ps.errorOutput.should.contain "--npm" + + it "should reject a missing package.json before launching commands", -> + ps = spawnParallelshellWith {cwd: path.dirname(newTrigger())}, "echo ordinary", "-n", "build" + ps.exited.then (result) -> + result.code.should.equal 1 + ps.output.should.equal "" + ps.errorOutput.should.contain "--npm" + ["-t", "--timeout"].forEach (flag) -> it "should stop running commands at the deadline with #{flag}", -> ps = spawnParallelshell flag, String(TIMEOUT_SECONDS), succeedingProcess, waitingProcess(), waitingProcess()