in-parallel-lit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/package.json +43 -0
- package/src/bin.js +36 -0
- package/src/index.js +195 -0
- package/src/lib/get-signal-num.js +32 -0
- package/src/lib/get-stream-kind.js +13 -0
- package/src/lib/in-parallel-error.js +8 -0
- package/src/lib/kill-pids.js +110 -0
- package/src/lib/memory-writable.js +69 -0
- package/src/lib/prefix-transform.js +33 -0
- package/src/lib/remove-from-arr.js +11 -0
- package/src/lib/select-color.js +67 -0
- package/src/lib/spawn.js +20 -0
- package/src/lib/wrap-stream-with-label.js +20 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Joel Voss
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# in-parallel-lit
|
|
2
|
+
|
|
3
|
+
A CLI tool to run multiple processes in parallel.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- [Node v16+][install-node]
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
$ npm i -g in-parallel-lit
|
|
13
|
+
# or
|
|
14
|
+
$ yarn global add in-parallel-lit
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
$ npx in-parallel "ping google.com -c 3" "ping 127.0.0.1 -c 3"
|
|
21
|
+
|
|
22
|
+
# Prints:
|
|
23
|
+
# -------
|
|
24
|
+
# [ping 127.0.0.1 -c 3] PING 127.0.0.1 (127.0.0.1): 56 data bytes
|
|
25
|
+
# [ping 127.0.0.1 -c 3] 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.044 ms
|
|
26
|
+
# [ping google.com -c 3] PING google.com (142.250.181.238): 56 data bytes
|
|
27
|
+
# [ping google.com -c 3] 64 bytes from 142.250.181.238: icmp_seq=0 ttl=56 time=30.401 ms
|
|
28
|
+
# [ping 127.0.0.1 -c 3] 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.037 ms
|
|
29
|
+
# [ping google.com -c 3] 64 bytes from 142.250.181.238: icmp_seq=1 ttl=56 time=50.207 ms
|
|
30
|
+
# [ping 127.0.0.1 -c 3] 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.188 ms
|
|
31
|
+
# [ping 127.0.0.1 -c 3]
|
|
32
|
+
# [ping 127.0.0.1 -c 3] --- 127.0.0.1 ping statistics ---
|
|
33
|
+
# [ping 127.0.0.1 -c 3] 3 packets transmitted, 3 packets received, 0.0% packet loss
|
|
34
|
+
# [ping 127.0.0.1 -c 3] round-trip min/avg/max/stddev = 0.037/0.090/0.188/0.070 ms
|
|
35
|
+
# [ping google.com -c 3] 64 bytes from 142.250.181.238: icmp_seq=2 ttl=56 time=29.115 ms
|
|
36
|
+
# [ping google.com -c 3]
|
|
37
|
+
# [ping google.com -c 3] --- google.com ping statistics ---
|
|
38
|
+
# [ping google.com -c 3] 3 packets transmitted, 3 packets received, 0.0% packet loss
|
|
39
|
+
# [ping google.com -c 3] round-trip min/avg/max/stddev = 29.115/36.574/50.207/9.654 ms
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Development
|
|
43
|
+
|
|
44
|
+
(1) Install dependencies
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
$ npm i
|
|
48
|
+
# or
|
|
49
|
+
$ yarn
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
(2) Run initial validation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
$ ./Taskfile.sh validate
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
(3) Start developing. See [`./Taskfile.sh`](./Taskfile.sh) for more tasks to
|
|
59
|
+
help you develop.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
_This project was set up by @jvdx/core_
|
|
64
|
+
|
|
65
|
+
[install-node]: https://github.com/nvm-sh/nvm
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "in-parallel-lit",
|
|
3
|
+
"description": "Run multiple processes in parallel",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"author": "Joel Voss <mail@joelvoss.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">= 14"
|
|
10
|
+
},
|
|
11
|
+
"main": "src/index.js",
|
|
12
|
+
"bin": {
|
|
13
|
+
"in-parallel": "src/bin.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "./Taskfile.sh start",
|
|
21
|
+
"test": "./Taskfile.sh test"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"cross-spawn": "^7.0.3",
|
|
25
|
+
"mri": "^1.2.0",
|
|
26
|
+
"sade": "^1.8.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@jvdx/core": "^2.16.0"
|
|
30
|
+
},
|
|
31
|
+
"prettier": "@jvdx/prettier-config",
|
|
32
|
+
"prettierIgnore": [
|
|
33
|
+
"tests/",
|
|
34
|
+
"dist/"
|
|
35
|
+
],
|
|
36
|
+
"eslintConfig": {
|
|
37
|
+
"extends": "@jvdx/eslint-config"
|
|
38
|
+
},
|
|
39
|
+
"eslintIgnore": [
|
|
40
|
+
"tests/",
|
|
41
|
+
"dist/"
|
|
42
|
+
]
|
|
43
|
+
}
|
package/src/bin.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import sade from 'sade';
|
|
4
|
+
import { readPackageUp } from 'readpkg-lit';
|
|
5
|
+
import { prog } from './index.js';
|
|
6
|
+
|
|
7
|
+
async function run(argv) {
|
|
8
|
+
const { packageJson } = await readPackageUp();
|
|
9
|
+
|
|
10
|
+
// NOTE(joel): Avoid `MaxListenersExceededWarnings`.
|
|
11
|
+
process.stdout.setMaxListeners(0);
|
|
12
|
+
process.stderr.setMaxListeners(0);
|
|
13
|
+
process.stdin.setMaxListeners(0);
|
|
14
|
+
|
|
15
|
+
sade('in-parallel', true)
|
|
16
|
+
.version(packageJson.version)
|
|
17
|
+
.describe(packageJson.description)
|
|
18
|
+
.option(
|
|
19
|
+
'-c, --continue-on-error',
|
|
20
|
+
`Set the flag to continue executing other/subsequent tasks even if a task threw an error. 'in-parallel' itself will exit with non-zero code if one or more tasks threw error(s).`,
|
|
21
|
+
)
|
|
22
|
+
.option(
|
|
23
|
+
'--max-parallel',
|
|
24
|
+
`Set the maximum number of parallelism. Default is unlimited.`,
|
|
25
|
+
0,
|
|
26
|
+
)
|
|
27
|
+
.option(
|
|
28
|
+
'--aggregate-output',
|
|
29
|
+
`Avoid interleaving output by delaying printing of each command's output until it has finished.`,
|
|
30
|
+
false,
|
|
31
|
+
)
|
|
32
|
+
.action(opts => prog(opts, process))
|
|
33
|
+
.parse(argv);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
run(process.argv);
|
package/src/index.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { InParallelError } from './lib/in-parallel-error.js';
|
|
2
|
+
import { spawn } from './lib/spawn.js';
|
|
3
|
+
import { MemoryWritable } from './lib/memory-writable.js';
|
|
4
|
+
import { getSignalNumber } from './lib/get-signal-num.js';
|
|
5
|
+
import { removeFromArr } from './lib/remove-from-arr.js';
|
|
6
|
+
import { wrapStreamWithLabel } from './lib/wrap-stream-with-label.js';
|
|
7
|
+
import { getStreamKind } from './lib/get-stream-kind.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* prog represents the main program logic.
|
|
11
|
+
* @param {{[key: string]: any, _: string[]}} opts
|
|
12
|
+
* @param {NodeJS.process} proc
|
|
13
|
+
* @returns {Promise<void>}
|
|
14
|
+
*/
|
|
15
|
+
export function prog(opts, proc) {
|
|
16
|
+
const { _: tasks, ...options } = opts;
|
|
17
|
+
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
let results = [];
|
|
20
|
+
let queue = [];
|
|
21
|
+
let promises = [];
|
|
22
|
+
let error = null;
|
|
23
|
+
let aborted = false;
|
|
24
|
+
|
|
25
|
+
// NOTE(joel): Resolve if no tasks are passed.
|
|
26
|
+
if (tasks.length === 0) return done();
|
|
27
|
+
|
|
28
|
+
// NOTE(joel): Pre-build result and queue arays.
|
|
29
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
30
|
+
results.push({ name: tasks[i], code: undefined });
|
|
31
|
+
queue.push({ name: tasks[i], index: i });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function done() {
|
|
35
|
+
if (error == null) return resolve(results);
|
|
36
|
+
return reject(error);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function abort() {
|
|
40
|
+
if (aborted) return;
|
|
41
|
+
|
|
42
|
+
aborted = true;
|
|
43
|
+
if (promises.length === 0) return done();
|
|
44
|
+
for (const p of promises) {
|
|
45
|
+
p.abort();
|
|
46
|
+
}
|
|
47
|
+
return Promise.all(promises).then(done, reject);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function next() {
|
|
51
|
+
if (aborted) return;
|
|
52
|
+
|
|
53
|
+
// NOTE(joel): Return without resolving if the queue is empty.
|
|
54
|
+
if (queue.length === 0) {
|
|
55
|
+
if (promises.length === 0) return done();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const originalOutputStream = proc.stdout;
|
|
60
|
+
const optionsClone = {
|
|
61
|
+
...Object.assign({}, options),
|
|
62
|
+
stdout: proc.stdout,
|
|
63
|
+
stderr: proc.stderr,
|
|
64
|
+
stdin: proc.stdin,
|
|
65
|
+
};
|
|
66
|
+
const writer = new MemoryWritable();
|
|
67
|
+
|
|
68
|
+
if (options['aggregate-output']) {
|
|
69
|
+
optionsClone.stdout = writer;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const task = queue.shift();
|
|
73
|
+
const promise = runTask(task.name, optionsClone);
|
|
74
|
+
|
|
75
|
+
promises.push(promise);
|
|
76
|
+
promise.then(
|
|
77
|
+
result => {
|
|
78
|
+
removeFromArr(promises, promise);
|
|
79
|
+
|
|
80
|
+
if (aborted) return;
|
|
81
|
+
|
|
82
|
+
if (options['aggregate-output']) {
|
|
83
|
+
originalOutputStream.write(writer.toString());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// NOTE(joel): Check if the task failed as a result of a signal, and
|
|
87
|
+
// amend the exit code as a result.
|
|
88
|
+
if (result.code === null && result.signal !== null) {
|
|
89
|
+
// NOTE(joel): An exit caused by a signal must return a status code
|
|
90
|
+
// of 128 plus the value of the signal code.
|
|
91
|
+
// @see https://nodejs.org/api/process.html#process_exit_codes
|
|
92
|
+
result.code = 128 + getSignalNumber(result.signal);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// NOTE(joel): Save the result.
|
|
96
|
+
results[task.index].code = result.code;
|
|
97
|
+
|
|
98
|
+
// NOTE(joel): Aborts all tasks if it's an error.
|
|
99
|
+
if (result.code) {
|
|
100
|
+
error = new InParallelError(result, results);
|
|
101
|
+
if (!options['continue-on-error']) {
|
|
102
|
+
return abort();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
next();
|
|
107
|
+
},
|
|
108
|
+
err => {
|
|
109
|
+
removeFromArr(promises, promise);
|
|
110
|
+
if (!options['continue-on-error']) {
|
|
111
|
+
error = err;
|
|
112
|
+
return abort();
|
|
113
|
+
}
|
|
114
|
+
next();
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let end = tasks.length;
|
|
120
|
+
if (
|
|
121
|
+
typeof options['max-parallel'] === 'number' &&
|
|
122
|
+
options['max-parallel'] > 0
|
|
123
|
+
) {
|
|
124
|
+
end = Math.min(tasks.length, options['max-parallel']);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < end; ++i) {
|
|
128
|
+
next();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @typedef {Object} RunTaskOptions
|
|
137
|
+
* @prop {stream.Readable} stdin
|
|
138
|
+
* @prop {stream.Writable} stdout
|
|
139
|
+
* @prop {stream.Writable} stderr
|
|
140
|
+
*/
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* runTask executes a single task as a child process.
|
|
144
|
+
* @param {string} name
|
|
145
|
+
* @param {RunTaskOptions} opts
|
|
146
|
+
* @returns {Promise<{name: string, code: number, signal: string}>}
|
|
147
|
+
*/
|
|
148
|
+
function runTask(name, opts) {
|
|
149
|
+
let proc = null;
|
|
150
|
+
|
|
151
|
+
const task = new Promise((resolve, reject) => {
|
|
152
|
+
const stdin = opts.stdin;
|
|
153
|
+
const stdout = wrapStreamWithLabel(opts.stdout, name);
|
|
154
|
+
const stderr = wrapStreamWithLabel(opts.stderr, name);
|
|
155
|
+
|
|
156
|
+
const stdinKind = getStreamKind(stdin, process.stdin);
|
|
157
|
+
const stdoutKind = getStreamKind(stdout, process.stdout);
|
|
158
|
+
const stderrKind = getStreamKind(stderr, process.stderr);
|
|
159
|
+
|
|
160
|
+
const [spawnName, ...spawnArgs] = name.split(' ');
|
|
161
|
+
|
|
162
|
+
proc = spawn(spawnName, spawnArgs, {
|
|
163
|
+
stdio: [stdinKind, stdoutKind, stderrKind],
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Piping stdio.
|
|
167
|
+
if (stdinKind === 'pipe') {
|
|
168
|
+
stdin.pipe(proc.stdin);
|
|
169
|
+
}
|
|
170
|
+
if (stdoutKind === 'pipe') {
|
|
171
|
+
proc.stdout.pipe(stdout, { end: false });
|
|
172
|
+
}
|
|
173
|
+
if (stderrKind === 'pipe') {
|
|
174
|
+
proc.stderr.pipe(stderr, { end: false });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Register
|
|
178
|
+
proc.on('error', err => {
|
|
179
|
+
proc = null;
|
|
180
|
+
return reject(err);
|
|
181
|
+
});
|
|
182
|
+
proc.on('close', (code, signal) => {
|
|
183
|
+
proc = null;
|
|
184
|
+
return resolve({ name, code, signal });
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
task.abort = () => {
|
|
189
|
+
if (proc == null) return;
|
|
190
|
+
proc.kill();
|
|
191
|
+
proc = null;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return task;
|
|
195
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const signals = {
|
|
2
|
+
SIGABRT: 6,
|
|
3
|
+
SIGALRM: 14,
|
|
4
|
+
SIGBUS: 10,
|
|
5
|
+
SIGCHLD: 20,
|
|
6
|
+
SIGCONT: 19,
|
|
7
|
+
SIGFPE: 8,
|
|
8
|
+
SIGHUP: 1,
|
|
9
|
+
SIGILL: 4,
|
|
10
|
+
SIGINT: 2,
|
|
11
|
+
SIGKILL: 9,
|
|
12
|
+
SIGPIPE: 13,
|
|
13
|
+
SIGQUIT: 3,
|
|
14
|
+
SIGSEGV: 11,
|
|
15
|
+
SIGSTOP: 17,
|
|
16
|
+
SIGTERM: 15,
|
|
17
|
+
SIGTRAP: 5,
|
|
18
|
+
SIGTSTP: 18,
|
|
19
|
+
SIGTTIN: 21,
|
|
20
|
+
SIGTTOU: 22,
|
|
21
|
+
SIGUSR1: 30,
|
|
22
|
+
SIGUSR2: 31,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Converts a signal name to a number.
|
|
27
|
+
* @param {string} signal
|
|
28
|
+
* @returns {number}
|
|
29
|
+
*/
|
|
30
|
+
export function getSignalNumber(signal) {
|
|
31
|
+
return signals[signal] || 0;
|
|
32
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts a given stream to an option for `child_process.spawn`.
|
|
3
|
+
* @param {stream.Readable|stream.Writable|null} stream
|
|
4
|
+
* @param {process.stdin|process.stdout|process.stderr} std
|
|
5
|
+
* @returns {string|stream.Readable|stream.Writable}
|
|
6
|
+
*/
|
|
7
|
+
export function getStreamKind(stream, std) {
|
|
8
|
+
if (stream == null) return 'ignore';
|
|
9
|
+
// NOTE(joel): `|| !std.isTTY` is needed for the workaround of
|
|
10
|
+
// https://github.com/nodejs/node/issues/5620
|
|
11
|
+
if (stream !== std || !std.isTTY) return 'pipe';
|
|
12
|
+
return stream;
|
|
13
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import crossSpawn from 'cross-spawn';
|
|
3
|
+
|
|
4
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wrap crossSpawn in a Promise.
|
|
8
|
+
* @param {string} cmd
|
|
9
|
+
* @param {Array<string|number>} args
|
|
10
|
+
* @param {any} options
|
|
11
|
+
*/
|
|
12
|
+
function crossSpawnPromise(cmd, args, options) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
let stdout = '';
|
|
15
|
+
let stderr = '';
|
|
16
|
+
const ch = crossSpawn(cmd, args, options);
|
|
17
|
+
|
|
18
|
+
ch.stdout.on('data', d => {
|
|
19
|
+
stdout += d.toString();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
ch.stderr.on('data', d => {
|
|
23
|
+
stderr += d.toString();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
ch.on('error', err => reject(err));
|
|
27
|
+
|
|
28
|
+
ch.on('close', code => {
|
|
29
|
+
if (stderr) return reject(stderr);
|
|
30
|
+
if (code !== 0) return reject(`${cmd} exited with code ${code}`);
|
|
31
|
+
return resolve(stdout);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Kills a process by ID and all its subprocesses.
|
|
40
|
+
* @param {number} pid
|
|
41
|
+
*/
|
|
42
|
+
export async function killPids(pid, platform = process.platform) {
|
|
43
|
+
if (platform === 'win32') {
|
|
44
|
+
crossSpawn('taskkill', ['/F', '/T', '/PID', pid]);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// NOTE(joel): Example of stdout:
|
|
50
|
+
// PPID PID
|
|
51
|
+
// 1 430
|
|
52
|
+
// 430 432
|
|
53
|
+
// 1 727
|
|
54
|
+
// 1 7166
|
|
55
|
+
let stdout = await crossSpawnPromise('ps', ['-A', '-o', 'ppid,pid']);
|
|
56
|
+
stdout = stdout.split(os.EOL);
|
|
57
|
+
|
|
58
|
+
let pidExists = false;
|
|
59
|
+
const pidTree = {};
|
|
60
|
+
for (let i = 1; i < stdout.length; i++) {
|
|
61
|
+
stdout[i] = stdout[i].trim();
|
|
62
|
+
if (!stdout[i]) continue;
|
|
63
|
+
|
|
64
|
+
stdout[i] = stdout[i].split(/\s+/);
|
|
65
|
+
stdout[i][0] = parseInt(stdout[i][0], 10); // PPID
|
|
66
|
+
stdout[i][1] = parseInt(stdout[i][1], 10); // PID
|
|
67
|
+
|
|
68
|
+
// NOTE(joel): Make sure our pid is part of the `ps` output.
|
|
69
|
+
if (
|
|
70
|
+
(!pidExists && stdout[i][1] === pid) ||
|
|
71
|
+
(!pidExists && stdout[i][0] === pid)
|
|
72
|
+
) {
|
|
73
|
+
pidExists = true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// NOTE(joel): Build the adiacency Hash Map (pid -> [children of pid])
|
|
77
|
+
if (pidTree[stdout[i][0]]) {
|
|
78
|
+
pidTree[stdout[i][0]].push(stdout[i][1]);
|
|
79
|
+
} else {
|
|
80
|
+
pidTree[stdout[i][0]] = [stdout[i][1]];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// NOTE(joel): No matching pid found.
|
|
85
|
+
if (!pidExists) return;
|
|
86
|
+
|
|
87
|
+
// NOTE(joel): Starting by the `pid` provided, traverse the tree using
|
|
88
|
+
// the adiacency Hash Map until the whole subtree is visited. Each pid
|
|
89
|
+
// encountered while visiting is added to the pids array.
|
|
90
|
+
let idx = 0;
|
|
91
|
+
const pids = [pid];
|
|
92
|
+
while (idx < pids.length) {
|
|
93
|
+
let curpid = pids[idx++];
|
|
94
|
+
if (!pidTree[curpid]) continue;
|
|
95
|
+
|
|
96
|
+
for (let j = 0; j < pidTree[curpid].length; j++) {
|
|
97
|
+
pids.push(pidTree[curpid][j]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
delete pidTree[curpid];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// NOTE(joel): Finally, kill all pids.
|
|
104
|
+
for (const pid of pids) {
|
|
105
|
+
process.kill(pid);
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
/* Silence is golden */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Writable } from 'node:stream';
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
+
|
|
4
|
+
const noop = () => {};
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Writable stream that can hold written chunks.
|
|
8
|
+
*/
|
|
9
|
+
export class MemoryWritable extends Writable {
|
|
10
|
+
queue = [];
|
|
11
|
+
data = [];
|
|
12
|
+
|
|
13
|
+
constructor(data = null) {
|
|
14
|
+
super();
|
|
15
|
+
|
|
16
|
+
// NOTE(joel): Ensure `data` is an array.
|
|
17
|
+
this.data = Array.isArray(data) ? data : [data];
|
|
18
|
+
|
|
19
|
+
this.queue = [];
|
|
20
|
+
for (let chunk of this.data) {
|
|
21
|
+
if (chunk == null) continue;
|
|
22
|
+
|
|
23
|
+
// NOTE(joel): Ensure each chunk is a Buffer.
|
|
24
|
+
if (!(chunk instanceof Buffer)) {
|
|
25
|
+
chunk = Buffer.from(chunk);
|
|
26
|
+
}
|
|
27
|
+
this.queue.push(chunk);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_write(chunk, enc, cb = noop) {
|
|
32
|
+
let decoder = null;
|
|
33
|
+
try {
|
|
34
|
+
decoder = enc && enc !== 'buffer' ? new StringDecoder(enc) : null;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return cb(err);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let decodedChunk = decoder != null ? decoder.write(chunk) : chunk;
|
|
40
|
+
this.queue.push(decodedChunk);
|
|
41
|
+
cb();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_getQueueSize() {
|
|
45
|
+
let size = 0;
|
|
46
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
47
|
+
size += this.queue[i].length;
|
|
48
|
+
}
|
|
49
|
+
return size;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
toString() {
|
|
53
|
+
let str = '';
|
|
54
|
+
for (const chunk of this.queue) {
|
|
55
|
+
str += chunk;
|
|
56
|
+
}
|
|
57
|
+
return str;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
toBuffer() {
|
|
61
|
+
const buffer = Buffer.alloc(this._getQueueSize());
|
|
62
|
+
let currentOffset = 0;
|
|
63
|
+
for (const chunk of this.queue) {
|
|
64
|
+
chunk.copy(buffer, currentOffset);
|
|
65
|
+
currentOffset += chunk.length;
|
|
66
|
+
}
|
|
67
|
+
return buffer;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Transform } from 'node:stream';
|
|
2
|
+
|
|
3
|
+
const ALL_BR = /\n/g;
|
|
4
|
+
|
|
5
|
+
export class PrefixTransform extends Transform {
|
|
6
|
+
constructor(prefix) {
|
|
7
|
+
super();
|
|
8
|
+
this.prefix = prefix;
|
|
9
|
+
this.lastPrefix = null;
|
|
10
|
+
this.lastIsLinebreak = true;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
_transform(chunk, _enc, cb) {
|
|
14
|
+
const firstPrefix = this.lastIsLinebreak
|
|
15
|
+
? this.prefix
|
|
16
|
+
: this.lastPrefix !== this.prefix
|
|
17
|
+
? '\n'
|
|
18
|
+
: '';
|
|
19
|
+
const prefixed = `${firstPrefix}${chunk}`.replace(
|
|
20
|
+
ALL_BR,
|
|
21
|
+
`\n${this.prefix}`,
|
|
22
|
+
);
|
|
23
|
+
const index = prefixed.indexOf(
|
|
24
|
+
this.prefix,
|
|
25
|
+
Math.max(0, prefixed.length - this.prefix.length),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
this.lastPrefix = this.prefix;
|
|
29
|
+
this.lastIsLinebreak = index !== -1;
|
|
30
|
+
|
|
31
|
+
cb(null, index !== -1 ? prefixed.slice(0, index) : prefixed);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
let FORCE_COLOR;
|
|
2
|
+
let NODE_DISABLE_COLORS;
|
|
3
|
+
let NO_COLOR;
|
|
4
|
+
let TERM;
|
|
5
|
+
let isTTY = true;
|
|
6
|
+
|
|
7
|
+
if (typeof process !== 'undefined') {
|
|
8
|
+
({ FORCE_COLOR, NODE_DISABLE_COLORS, NO_COLOR, TERM } = process.env);
|
|
9
|
+
isTTY = process.stdout && process.stdout.isTTY;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const enabled =
|
|
13
|
+
!NODE_DISABLE_COLORS &&
|
|
14
|
+
NO_COLOR == null &&
|
|
15
|
+
TERM !== 'dumb' &&
|
|
16
|
+
((FORCE_COLOR != null && FORCE_COLOR !== '0') || isTTY);
|
|
17
|
+
|
|
18
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns functions that wrap a given string with color char codes.
|
|
22
|
+
* @param {number} x
|
|
23
|
+
* @param {number} y
|
|
24
|
+
* @returns {(txt: string) => string}
|
|
25
|
+
*/
|
|
26
|
+
function createColor(x, y) {
|
|
27
|
+
const rgx = new RegExp(`\\x1b\\[${y}m`, 'g');
|
|
28
|
+
const open = `\x1b[${x}m`;
|
|
29
|
+
const close = `\x1b[${y}m`;
|
|
30
|
+
|
|
31
|
+
return function (txt) {
|
|
32
|
+
if (!enabled || txt == null) return txt;
|
|
33
|
+
return (
|
|
34
|
+
open +
|
|
35
|
+
(~('' + txt).indexOf(close) ? txt.replace(rgx, close + open) : txt) +
|
|
36
|
+
close
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
42
|
+
|
|
43
|
+
const colors = [
|
|
44
|
+
createColor(36, 39), // cyan
|
|
45
|
+
createColor(32, 39), // green
|
|
46
|
+
createColor(35, 39), // magenta
|
|
47
|
+
createColor(33, 39), // yellow
|
|
48
|
+
createColor(31, 39), // red
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
let colorIndex = 0;
|
|
52
|
+
const taskNamesToColors = new Map();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Select a color from given task name.
|
|
56
|
+
* @param {string} taskName
|
|
57
|
+
* @returns {(txt: string) => string}
|
|
58
|
+
*/
|
|
59
|
+
export function selectColor(taskName) {
|
|
60
|
+
let color = taskNamesToColors.get(taskName);
|
|
61
|
+
if (color == null) {
|
|
62
|
+
color = colors[colorIndex];
|
|
63
|
+
colorIndex = (colorIndex + 1) % colors.length;
|
|
64
|
+
taskNamesToColors.set(taskName, color);
|
|
65
|
+
}
|
|
66
|
+
return color;
|
|
67
|
+
}
|
package/src/lib/spawn.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import crossSpawn from 'cross-spawn';
|
|
2
|
+
import { killPids } from './kill-pids.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Launches a new process with the given command.
|
|
6
|
+
* This is almost same as `child_process.spawn`, but it adds a `kill` method
|
|
7
|
+
* that kills the instance process and its sub processes.
|
|
8
|
+
* @param {string} command
|
|
9
|
+
* @param {string[]} args
|
|
10
|
+
* @param {object} options
|
|
11
|
+
* @returns {ChildProcess}
|
|
12
|
+
*/
|
|
13
|
+
export function spawn(command, args, options) {
|
|
14
|
+
const child = crossSpawn(command, args, options);
|
|
15
|
+
child.kill = function kill() {
|
|
16
|
+
killPids(this.pid);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return child;
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { selectColor } from './select-color.js';
|
|
2
|
+
import { PrefixTransform } from './prefix-transform.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wraps stdout/stderr with a transform stream to add the task name as prefix.
|
|
6
|
+
* @param {stream.Writable} source
|
|
7
|
+
* @param {string} label
|
|
8
|
+
* @returns {stream.Writable}
|
|
9
|
+
*/
|
|
10
|
+
export function wrapStreamWithLabel(source, label) {
|
|
11
|
+
if (source == null) return source;
|
|
12
|
+
|
|
13
|
+
const color = source.isTTY ? selectColor(label) : x => x;
|
|
14
|
+
const prefix = color(`[${label}] `);
|
|
15
|
+
const target = new PrefixTransform(prefix);
|
|
16
|
+
|
|
17
|
+
target.pipe(source);
|
|
18
|
+
|
|
19
|
+
return target;
|
|
20
|
+
}
|