@j-o-r/sh 1.1.21 → 1.1.23
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/lib/SH.js +30 -4
- package/lib/SHDispatch.js +27 -12
- package/lib/SHExecute.js +230 -162
- package/lib/Test.js +9 -9
- package/package.json +1 -1
- package/types/SH.d.ts +20 -8
- package/types/SHDispatch.d.ts +16 -16
- package/types/SHExecute.d.ts +31 -17
package/lib/SH.js
CHANGED
|
@@ -81,10 +81,8 @@ const jsType = (fn) => {
|
|
|
81
81
|
* @returns {boolean}
|
|
82
82
|
*/
|
|
83
83
|
const hasProp = (o, p) => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
return Object.prototype.hasOwnProperty.call(o, p);
|
|
84
|
+
if (o == null) return false;
|
|
85
|
+
return Object.prototype.hasOwnProperty.call(o, p);
|
|
88
86
|
};
|
|
89
87
|
/**
|
|
90
88
|
* 4ms, 5s || 5
|
|
@@ -112,6 +110,15 @@ const parseDuration = (d) => {
|
|
|
112
110
|
/**
|
|
113
111
|
* @type {Shell & SHTag}
|
|
114
112
|
*/
|
|
113
|
+
/**
|
|
114
|
+
* SH template tag
|
|
115
|
+
*
|
|
116
|
+
* Interpolation rules:
|
|
117
|
+
* - Arrays: each element is String(x).trim(), with newlines/carriage returns/tabs escaped; if an element contains shell metacharacters or spaces, it is wrapped in single-quotes and internal single-quotes are escaped as '\''.
|
|
118
|
+
* - Non-array values: currently coerced with String(value) and inserted as-is (NOT shell-escaped). Be careful when interpolating untrusted input.
|
|
119
|
+
*
|
|
120
|
+
* Returns an SHDispatch that can be configured via .options() and executed with .run() / .runSync().
|
|
121
|
+
*/
|
|
115
122
|
const SH = new Proxy(function(pieces, ...args) {
|
|
116
123
|
if (pieces.some((p) => p == undefined)) {
|
|
117
124
|
throw new Error(`Malformed command ${pieces}`);
|
|
@@ -165,6 +172,10 @@ const within = async (callback) => {
|
|
|
165
172
|
* @example
|
|
166
173
|
* const content = await readIn();
|
|
167
174
|
*/
|
|
175
|
+
/**
|
|
176
|
+
* Read entire stdin as UTF-8. If stdin is a TTY, resolves to an empty string.
|
|
177
|
+
* @returns {Promise<string>}
|
|
178
|
+
*/
|
|
168
179
|
const readIn = async () => {
|
|
169
180
|
if (process.stdin.isTTY) return ''; // nothing was piped
|
|
170
181
|
let buf = '';
|
|
@@ -345,6 +356,21 @@ function* expBackoff(max = '60s', rand = '100ms') {
|
|
|
345
356
|
* - The value is either the next argument or `true` if no value is provided,
|
|
346
357
|
* - The `_` property contains an array of unbound arguments.
|
|
347
358
|
*/
|
|
359
|
+
/**
|
|
360
|
+
* Parse command-line args into an object.
|
|
361
|
+
*
|
|
362
|
+
* Supported:
|
|
363
|
+
* - --key value, -k value (no grouped short flags)
|
|
364
|
+
* - Bare values collected under `_.`
|
|
365
|
+
* - Duplicate keys throw an error; keys without a following value become true.
|
|
366
|
+
*
|
|
367
|
+
* Not supported:
|
|
368
|
+
* - --key=value syntax
|
|
369
|
+
* - Grouped short flags like -abc
|
|
370
|
+
*
|
|
371
|
+
* @param {string[]} [args]
|
|
372
|
+
* @returns {ArgsObject}
|
|
373
|
+
*/
|
|
348
374
|
const parseArgs = (args) => {
|
|
349
375
|
if (!args) args = process.argv.slice(2);
|
|
350
376
|
const result = { _: [] };
|
package/lib/SHDispatch.js
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
import SHExec from './SHExecute.js';
|
|
2
2
|
/**
|
|
3
3
|
* @typedef {Object} SpawnSyncResponse
|
|
4
|
-
* @property {number} status -
|
|
5
|
-
* @property {
|
|
6
|
-
* @property {
|
|
7
|
-
* @property {number} pid -
|
|
8
|
-
* @property {Buffer|null} stdout -
|
|
9
|
-
* @property {Buffer|null} stderr -
|
|
4
|
+
* @property {number|null} status - Exit code of the child process (null if terminated by signal).
|
|
5
|
+
* @property {string|null} signal - Name of the terminating signal, if any.
|
|
6
|
+
* @property {(string|Buffer|null)[]} output - [stdin, stdout, stderr] per Node's SpawnSyncReturns.
|
|
7
|
+
* @property {number} pid - PID of the spawned process.
|
|
8
|
+
* @property {string|Buffer|null} stdout - Stdout collected (type depends on encoding option).
|
|
9
|
+
* @property {string|Buffer|null} stderr - Stderr collected (type depends on encoding option).
|
|
10
10
|
*/
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
12
|
* @typedef {Object} SHOptions
|
|
13
13
|
* @property {string} [cwd] - Current working directory of the child process.
|
|
14
14
|
* @property {Object} [env] - Environment key-value pairs.
|
|
15
|
-
* @property {
|
|
15
|
+
* @property {string} [argv0] - Explicitly set the value of `argv[0]` sent to the child process.
|
|
16
16
|
* @property {boolean} [detached=false] - If true, the child will be a process group leader.
|
|
17
17
|
* @property {number} [uid] - Sets the user identity of the process.
|
|
18
18
|
* @property {number} [gid] - Sets the group identity of the process.
|
|
19
19
|
* @property {StdioOptions|StdioOption} [stdio='pipe'] - Child's stdio configuration.
|
|
20
|
-
* @property {boolean|string} [shell=
|
|
21
|
-
* @property {number} [timeout=0] -
|
|
22
|
-
* @property {string|Buffer|
|
|
20
|
+
* @property {boolean|string} [shell='bash'] - If string or true, runs the command via that shell ('bash' if true).
|
|
21
|
+
* @property {number} [timeout=0] - Milliseconds before sending SIGTERM (0 = no timeout).
|
|
22
|
+
* @property {string|Buffer|Uint8Array} [input] - Optional stdin payload for spawnSync. Use .run(payload) for async.
|
|
23
23
|
*/
|
|
24
24
|
/**
|
|
25
25
|
* @typedef {('pipe' | 'ignore' | 'inherit' | number)} StdioOption
|
|
@@ -64,6 +64,21 @@ const mergeOptions = (predefined, options) => {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/** @type {SHOptions} */
|
|
67
|
+
/**
|
|
68
|
+
* SHOptions — effective defaults and semantics used by SHDispatch/SHExecute
|
|
69
|
+
*
|
|
70
|
+
* Defaults:
|
|
71
|
+
* - cwd: process.cwd()
|
|
72
|
+
* - env: process.env
|
|
73
|
+
* - shell: 'bash' (string). If a string, that shell is used. If true, 'bash' is used. If false/undefined, no shell is used.
|
|
74
|
+
* - stdio: ['inherit', 'pipe', 'pipe'] — inherit stdin, capture stdout/stderr
|
|
75
|
+
* - timeout: 0 — no timeout
|
|
76
|
+
* - maxBuffer?: number — optional (bytes per stream). Passed through to SHExecute.
|
|
77
|
+
*
|
|
78
|
+
* Notes:
|
|
79
|
+
* - The prefix (see options(prefix)) is only applied when a shell is used; it is ignored in no-shell mode.
|
|
80
|
+
* - Each call to options() resets to the defaults and merges the provided options; it does not accumulate from prior calls.
|
|
81
|
+
*/
|
|
67
82
|
const defaultOptions = {
|
|
68
83
|
cwd: process.cwd(),
|
|
69
84
|
env: process.env,
|
|
@@ -76,7 +91,7 @@ const defaultOptions = {
|
|
|
76
91
|
|
|
77
92
|
class SHDispatch {
|
|
78
93
|
// #prefix = 'set -euo pipefail;/usr/bin/env'
|
|
79
|
-
#prefix = 'set -euo pipefail
|
|
94
|
+
#prefix = 'set -euo pipefail'
|
|
80
95
|
#cmd = '';
|
|
81
96
|
#options = {};
|
|
82
97
|
/**
|
package/lib/SHExecute.js
CHANGED
|
@@ -1,168 +1,236 @@
|
|
|
1
|
-
import { spawnSync, spawn
|
|
1
|
+
import { spawnSync, spawn} from 'node:child_process';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
process.kill(processPid, signal);
|
|
37
|
-
killed.push(processPid);
|
|
38
|
-
} catch (err) {
|
|
39
|
-
reject(err);
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
resolve(killed);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
}
|
|
3
|
+
// Local helpers
|
|
4
|
+
const isFinitePosInt = (n) => Number.isFinite(n) && n >= 0;
|
|
5
|
+
// Parse duration: accepts numbers (ms) or strings like "250", "250ms", or "2s". null/undefined yields 0; negatives or invalid forms throw.
|
|
6
|
+
const parseDuration = (d) => {
|
|
7
|
+
if (typeof d === 'number') return isFinitePosInt(d) ? d : (() => { throw new Error('Invalid duration'); })();
|
|
8
|
+
if (typeof d === 'string') {
|
|
9
|
+
const m = d.match(/^(\d+)(ms|s)?$/);
|
|
10
|
+
if (!m) throw new Error('Invalid duration string');
|
|
11
|
+
const n = Number(m[1]);
|
|
12
|
+
return m[2] === 's' ? n * 1000 : n;
|
|
13
|
+
}
|
|
14
|
+
if (d == null) return 0;
|
|
15
|
+
throw new Error('Invalid duration type');
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Kill a process tree using pgrep without going through a shell
|
|
19
|
+
const childrenOf = (pid) => new Promise((resolve, reject) => {
|
|
20
|
+
const p = spawn('pgrep', ['-P', String(pid)], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
21
|
+
const out = [];
|
|
22
|
+
p.stdout.on('data', (b) => out.push(b));
|
|
23
|
+
p.on('close', (code) => {
|
|
24
|
+
if (code === 0) {
|
|
25
|
+
const ids = Buffer.concat(out).toString('utf8').trim().split(/\s+/).map(Number).filter(Boolean);
|
|
26
|
+
resolve(ids);
|
|
27
|
+
} else if (code === 1) {
|
|
28
|
+
resolve([]); // no children
|
|
29
|
+
} else {
|
|
30
|
+
reject(new Error(`pgrep -P ${pid} failed with code ${code}`));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
p.on('error', reject);
|
|
34
|
+
});
|
|
46
35
|
|
|
36
|
+
/**
|
|
37
|
+
* SHExecute
|
|
38
|
+
* Low-level process runner used by SHDispatch.
|
|
39
|
+
*
|
|
40
|
+
* Features:
|
|
41
|
+
* - Optional shell execution: if options.shell is a string (e.g., 'bash') or true, runs `${prefix}; ${command}` via that shell; otherwise runs without a shell using `/usr/bin/env -S`.
|
|
42
|
+
* - Timeout: options.timeout may be a number (ms) or string like '1500ms' or '2s'. On timeout, run() rejects with a descriptive error.
|
|
43
|
+
* - Buffering: Captures stdout/stderr up to maxBuffer bytes per stream (default 40 MiB). Appends "[stdout truncated]" / "[stderr truncated]" markers if exceeded.
|
|
44
|
+
* - Payload: Passing a payload writes it to stdin and forces stdin to be a pipe.
|
|
45
|
+
* - Detached mode: If options.detached is true, run() resolves to '' after ~1s and unrefs the process.
|
|
46
|
+
* - Kill support: kill(signal) attempts to terminate the process (and some children) and causes run() to reject with "Process killed (forced).".
|
|
47
|
+
*/
|
|
47
48
|
class SHExecute {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
49
|
+
#forcedKill = false;
|
|
50
|
+
/** @type {import('child_process').ChildProcess} */
|
|
51
|
+
#proc;
|
|
52
|
+
#prefix = '';
|
|
53
|
+
#command = '';
|
|
54
|
+
/** @type {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} */
|
|
55
|
+
#options = {};
|
|
56
|
+
#stdoutChunks = [];
|
|
57
|
+
#stderrChunks = [];
|
|
58
|
+
#stdoutLen = 0;
|
|
59
|
+
#stderrLen = 0;
|
|
60
|
+
#maxBuffer = 40 * 1024 * 1024; // 40MB per stream
|
|
61
|
+
#truncated = { stdout: false, stderr: false };
|
|
62
|
+
#timedOut = false;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} command - linux command to be executed
|
|
66
|
+
* @param {string} prefix - command prefix (shell prelude, e.g. 'set -euo pipefail')
|
|
67
|
+
* @param {import('child_process').SpawnOptions & { maxBuffer?: number }} options
|
|
68
|
+
*/
|
|
69
|
+
constructor(command, prefix, options = {}) {
|
|
70
|
+
this.#prefix = prefix;
|
|
71
|
+
this.#command = command;
|
|
72
|
+
const { maxBuffer, ...spawnOpts } = options ?? {};
|
|
73
|
+
this.#maxBuffer = Number.isFinite(maxBuffer) && maxBuffer > 0 ? maxBuffer : this.#maxBuffer;
|
|
74
|
+
this.#options = spawnOpts;
|
|
75
|
+
this.#proc = null;
|
|
76
|
+
this.#stdoutChunks = [];
|
|
77
|
+
this.#stderrChunks = [];
|
|
78
|
+
this.#stdoutLen = 0;
|
|
79
|
+
this.#stderrLen = 0;
|
|
80
|
+
this.#truncated = { stdout: false, stderr: false };
|
|
81
|
+
this.#timedOut = false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} [payload] - data to write
|
|
86
|
+
* @returns {import('child_process').SpawnSyncReturns}
|
|
87
|
+
*/
|
|
88
|
+
runSync(payload) {
|
|
89
|
+
this.#forcedKill = false;
|
|
90
|
+
if (payload && typeof payload !== 'string') {
|
|
91
|
+
throw new Error('Argument is not a string');
|
|
92
|
+
}
|
|
93
|
+
/** @type {import('node:child_process').SpawnSyncOptions} */
|
|
94
|
+
const options = { ...this.#options, shell: false };
|
|
95
|
+
if (payload) {
|
|
96
|
+
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
|
|
97
|
+
stdio[0] = 'pipe';
|
|
98
|
+
// @ts-ignore
|
|
99
|
+
options.stdio = stdio;
|
|
100
|
+
options.input = payload;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const shellOpt = this.#options?.shell;
|
|
104
|
+
if (shellOpt) {
|
|
105
|
+
const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
|
|
106
|
+
const cmd = `${this.#prefix}; ${this.#command}`;
|
|
107
|
+
return spawnSync(sh, ['-c', cmd], options);
|
|
108
|
+
}
|
|
109
|
+
// no-shell mode
|
|
110
|
+
return spawnSync('/usr/bin/env', ['-S', this.#command], options);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {string} [payload] - data to write
|
|
115
|
+
* @returns {Promise<string>}
|
|
116
|
+
*/
|
|
117
|
+
run(payload) {
|
|
118
|
+
this.#forcedKill = false;
|
|
119
|
+
if (payload && typeof payload !== 'string') {
|
|
120
|
+
throw new Error('Argument is not a string');
|
|
121
|
+
}
|
|
122
|
+
/** @type {import('node:child_process').SpawnOptions} */
|
|
123
|
+
const options = { ...this.#options, shell: false };
|
|
124
|
+
if (payload) {
|
|
125
|
+
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
|
|
126
|
+
stdio[0] = 'pipe';
|
|
127
|
+
// @ts-ignore
|
|
128
|
+
options.stdio = stdio;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// reset buffers for each run
|
|
132
|
+
this.#stdoutChunks = [];
|
|
133
|
+
this.#stderrChunks = [];
|
|
134
|
+
this.#stdoutLen = 0;
|
|
135
|
+
this.#stderrLen = 0;
|
|
136
|
+
this.#truncated = { stdout: false, stderr: false };
|
|
137
|
+
this.#timedOut = false;
|
|
138
|
+
|
|
139
|
+
const shellOpt = this.#options?.shell;
|
|
140
|
+
if (shellOpt) {
|
|
141
|
+
const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
|
|
142
|
+
const cmd = `${this.#prefix}; ${this.#command}`;
|
|
143
|
+
this.#proc = spawn(sh, ['-c', cmd], options);
|
|
144
|
+
} else {
|
|
145
|
+
this.#proc = spawn('/usr/bin/env', ['-S', this.#command], options);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (payload) this.#proc.stdin?.end(payload);
|
|
149
|
+
|
|
150
|
+
const ms = parseDuration(this.#options?.timeout ?? 0);
|
|
151
|
+
let timeoutId = null;
|
|
152
|
+
if (ms > 0) {
|
|
153
|
+
timeoutId = setTimeout(() => {
|
|
154
|
+
this.#timedOut = true;
|
|
155
|
+
this.kill('SIGTERM').catch(() => {});
|
|
156
|
+
}, ms);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.#proc.stdout?.on('data', (chunk) => {
|
|
160
|
+
this.#stdoutLen += chunk.length;
|
|
161
|
+
if (this.#stdoutLen <= this.#maxBuffer) {
|
|
162
|
+
this.#stdoutChunks.push(chunk);
|
|
163
|
+
} else {
|
|
164
|
+
this.#truncated.stdout = true;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
this.#proc.stderr?.on('data', (chunk) => {
|
|
169
|
+
this.#stderrLen += chunk.length;
|
|
170
|
+
if (this.#stderrLen <= this.#maxBuffer) {
|
|
171
|
+
this.#stderrChunks.push(chunk);
|
|
172
|
+
} else {
|
|
173
|
+
this.#truncated.stderr = true;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
if (options.detached) {
|
|
179
|
+
setTimeout(() => {
|
|
180
|
+
resolve('');
|
|
181
|
+
this.#proc.unref();
|
|
182
|
+
}, 1000);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.#proc.on('close', (code, signal) => {
|
|
186
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
187
|
+
if (this.#forcedKill) {
|
|
188
|
+
reject(new Error('Process killed (forced).'));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (this.#timedOut) {
|
|
192
|
+
reject(new Error(`Process timed out after ${ms}ms.`));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (code === 0) {
|
|
196
|
+
const stdoutBuf = this.#stdoutChunks.length ? Buffer.concat(this.#stdoutChunks) : Buffer.alloc(0);
|
|
197
|
+
let stdout = stdoutBuf.toString('utf8').trim();
|
|
198
|
+
if (this.#truncated.stdout) stdout += '\n[stdout truncated]\n';
|
|
199
|
+
resolve(stdout);
|
|
200
|
+
} else {
|
|
201
|
+
const stderrBuf = this.#stderrChunks.length ? Buffer.concat(this.#stderrChunks) : Buffer.alloc(0);
|
|
202
|
+
let stderr = stderrBuf.toString('utf8').trim();
|
|
203
|
+
if (this.#truncated.stderr) stderr += '\n[stderr truncated]\n';
|
|
204
|
+
reject(new Error(`Exit ${code ?? 'null'}${signal ? `, signal ${signal}` : ''}: ${this.#command}${stderr ? ` :: ${stderr}` : ''}`));
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
this.#proc.on('error', (err) => reject(err));
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Kill this process and possible child processes
|
|
214
|
+
* @param {number | string} signal - kill signal
|
|
215
|
+
* @returns {Promise<number[]>}
|
|
216
|
+
*/
|
|
217
|
+
async kill(signal = 'SIGTERM') {
|
|
218
|
+
if (!this.#proc) throw new Error('Trying to kill a process without creating one.');
|
|
219
|
+
if (!this.#proc.pid) throw new Error('The process pid is undefined.');
|
|
220
|
+
this.#forcedKill = true;
|
|
221
|
+
const pid = this.#proc.pid;
|
|
222
|
+
let killed = [];
|
|
223
|
+
try {
|
|
224
|
+
const kids = await childrenOf(pid).catch(() => []);
|
|
225
|
+
for (const k of kids) {
|
|
226
|
+
try { process.kill(k, signal); killed.push(k); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
227
|
+
}
|
|
228
|
+
try { process.kill(pid, signal); killed.push(pid); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
229
|
+
} finally {
|
|
230
|
+
this.#proc = undefined;
|
|
231
|
+
}
|
|
232
|
+
return killed;
|
|
233
|
+
}
|
|
165
234
|
}
|
|
166
235
|
|
|
167
236
|
export default SHExecute;
|
|
168
|
-
|
package/lib/Test.js
CHANGED
|
@@ -64,7 +64,7 @@ class Test {
|
|
|
64
64
|
*/
|
|
65
65
|
#errors = [];
|
|
66
66
|
/** verbosed **/
|
|
67
|
-
#
|
|
67
|
+
#quiet = false;
|
|
68
68
|
/** Timeout in ms to settle async code blocks called from sync methods */
|
|
69
69
|
#TO = SETTLE_ASYNC;
|
|
70
70
|
/**
|
|
@@ -72,7 +72,7 @@ class Test {
|
|
|
72
72
|
*/
|
|
73
73
|
constructor(quiet = false) {
|
|
74
74
|
if (quiet) {
|
|
75
|
-
this.#
|
|
75
|
+
this.#quiet = true;
|
|
76
76
|
}
|
|
77
77
|
// Track for unresolved promises
|
|
78
78
|
this.#promiseTracker = new AsyncTracker();
|
|
@@ -149,7 +149,7 @@ class Test {
|
|
|
149
149
|
this.#reports[i] = { description: cb.description, duration, executed };
|
|
150
150
|
let error;
|
|
151
151
|
try {
|
|
152
|
-
if (!this.#
|
|
152
|
+
if (!this.#quiet) process.stdout.write(`${i}. ${cb.description} `);
|
|
153
153
|
await Promise.resolve(cb.callback());
|
|
154
154
|
executed = true;
|
|
155
155
|
if (type === 'Function') {
|
|
@@ -167,7 +167,7 @@ class Test {
|
|
|
167
167
|
error = e;
|
|
168
168
|
}
|
|
169
169
|
duration = getDuration(start);
|
|
170
|
-
if (!this.#
|
|
170
|
+
if (!this.#quiet && !error) process.stdout.write(`(duration: ${duration} ms)\n`);
|
|
171
171
|
this.#reports[i].duration = duration;
|
|
172
172
|
this.#reports[i].executed = executed;
|
|
173
173
|
if (error) {
|
|
@@ -198,14 +198,14 @@ class Test {
|
|
|
198
198
|
|
|
199
199
|
}
|
|
200
200
|
const errors = this.#errors.length;
|
|
201
|
-
if (!this.#
|
|
201
|
+
if (!this.#quiet) {
|
|
202
202
|
console.log('--------------------------------------------------');
|
|
203
203
|
console.log(`Total: ${tests} tests, executed: ${executed} in ${duration} ms - errors: ${errors}`);
|
|
204
204
|
if (tests !== executed) {
|
|
205
|
-
if (!this.#
|
|
205
|
+
if (!this.#quiet) console.log('** Not all tests have been executed **');
|
|
206
206
|
return { tests, executed, duration, errors };
|
|
207
207
|
}
|
|
208
|
-
// Report on unresolved promises when NOT
|
|
208
|
+
// Report on unresolved promises when NOT quiet
|
|
209
209
|
// This must be on a next tick because the current Promise (where this code is in) is not resolved yet
|
|
210
210
|
nextTick(() => {
|
|
211
211
|
const count = this.#promiseTracker.report(true);
|
|
@@ -235,9 +235,9 @@ class Test {
|
|
|
235
235
|
// A global error is an error catched outside the callstack of the test
|
|
236
236
|
const ERR = outside ? 'GLOBAL_ERROR' : 'ERROR'
|
|
237
237
|
if (this.#currentTest > -1) {
|
|
238
|
-
if (!this.#
|
|
238
|
+
if (!this.#quiet) process.stdout.write(`\n`);
|
|
239
239
|
// Register this error
|
|
240
|
-
// Always print out errors despite #
|
|
240
|
+
// Always print out errors despite #quiet
|
|
241
241
|
const description = this.#reports[this.#currentTest].description;
|
|
242
242
|
const executed = this.#reports[this.#currentTest].executed;
|
|
243
243
|
if (executed) {
|
package/package.json
CHANGED
package/types/SH.d.ts
CHANGED
|
@@ -11,14 +11,7 @@ export type Shell = Function;
|
|
|
11
11
|
* A template-tag that returns an SHDispatch.
|
|
12
12
|
*/
|
|
13
13
|
export type SHTag = (pieces: TemplateStringsArray, ...args: unknown[]) => SHDispatch;
|
|
14
|
-
|
|
15
|
-
* A template-tag that returns an SHDispatch.
|
|
16
|
-
* @typedef {(pieces: TemplateStringsArray, ...args: unknown[]) => SHDispatch} SHTag
|
|
17
|
-
*/
|
|
18
|
-
/**
|
|
19
|
-
* @type {Shell & SHTag}
|
|
20
|
-
*/
|
|
21
|
-
export const SH: Shell & SHTag;
|
|
14
|
+
export function SH(pieces: any, ...args: any[]): SHDispatch;
|
|
22
15
|
/**
|
|
23
16
|
* Change working directory
|
|
24
17
|
* @param {string} dir
|
|
@@ -59,6 +52,10 @@ export function retry(count: number, a: string | number | typeof expBackoff | Fu
|
|
|
59
52
|
* @example
|
|
60
53
|
* const content = await readIn();
|
|
61
54
|
*/
|
|
55
|
+
/**
|
|
56
|
+
* Read entire stdin as UTF-8. If stdin is a TTY, resolves to an empty string.
|
|
57
|
+
* @returns {Promise<string>}
|
|
58
|
+
*/
|
|
62
59
|
export function readIn(): Promise<string>;
|
|
63
60
|
/**
|
|
64
61
|
* Get user input from the command line (stdin)
|
|
@@ -113,6 +110,21 @@ export function expBackoff(max?: string, rand?: string): Generator<number, void,
|
|
|
113
110
|
* - The value is either the next argument or `true` if no value is provided,
|
|
114
111
|
* - The `_` property contains an array of unbound arguments.
|
|
115
112
|
*/
|
|
113
|
+
/**
|
|
114
|
+
* Parse command-line args into an object.
|
|
115
|
+
*
|
|
116
|
+
* Supported:
|
|
117
|
+
* - --key value, -k value (no grouped short flags)
|
|
118
|
+
* - Bare values collected under `_.`
|
|
119
|
+
* - Duplicate keys throw an error; keys without a following value become true.
|
|
120
|
+
*
|
|
121
|
+
* Not supported:
|
|
122
|
+
* - --key=value syntax
|
|
123
|
+
* - Grouped short flags like -abc
|
|
124
|
+
*
|
|
125
|
+
* @param {string[]} [args]
|
|
126
|
+
* @returns {ArgsObject}
|
|
127
|
+
*/
|
|
116
128
|
export function parseArgs(args?: string[]): ArgsObject;
|
|
117
129
|
/**
|
|
118
130
|
* @typedef {Object.<string, string>} ArgsObject
|
package/types/SHDispatch.d.ts
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
export default SHDispatch;
|
|
2
2
|
export type SpawnSyncResponse = {
|
|
3
3
|
/**
|
|
4
|
-
* -
|
|
4
|
+
* - Exit code of the child process (null if terminated by signal).
|
|
5
5
|
*/
|
|
6
|
-
status: number;
|
|
6
|
+
status: number | null;
|
|
7
7
|
/**
|
|
8
|
-
* -
|
|
8
|
+
* - Name of the terminating signal, if any.
|
|
9
9
|
*/
|
|
10
|
-
signal:
|
|
10
|
+
signal: string | null;
|
|
11
11
|
/**
|
|
12
|
-
* -
|
|
12
|
+
* - [stdin, stdout, stderr] per Node's SpawnSyncReturns.
|
|
13
13
|
*/
|
|
14
|
-
output:
|
|
14
|
+
output: (string | Buffer | null)[];
|
|
15
15
|
/**
|
|
16
|
-
* -
|
|
16
|
+
* - PID of the spawned process.
|
|
17
17
|
*/
|
|
18
18
|
pid: number;
|
|
19
19
|
/**
|
|
20
|
-
* -
|
|
20
|
+
* - Stdout collected (type depends on encoding option).
|
|
21
21
|
*/
|
|
22
|
-
stdout: Buffer | null;
|
|
22
|
+
stdout: string | Buffer | null;
|
|
23
23
|
/**
|
|
24
|
-
* -
|
|
24
|
+
* - Stderr collected (type depends on encoding option).
|
|
25
25
|
*/
|
|
26
|
-
stderr: Buffer | null;
|
|
26
|
+
stderr: string | Buffer | null;
|
|
27
27
|
};
|
|
28
28
|
export type SHOptions = {
|
|
29
29
|
/**
|
|
@@ -37,7 +37,7 @@ export type SHOptions = {
|
|
|
37
37
|
/**
|
|
38
38
|
* - Explicitly set the value of `argv[0]` sent to the child process.
|
|
39
39
|
*/
|
|
40
|
-
argv0?: string |
|
|
40
|
+
argv0?: string | undefined;
|
|
41
41
|
/**
|
|
42
42
|
* - If true, the child will be a process group leader.
|
|
43
43
|
*/
|
|
@@ -55,17 +55,17 @@ export type SHOptions = {
|
|
|
55
55
|
*/
|
|
56
56
|
stdio?: number | "pipe" | "ignore" | "inherit" | StdioOption[] | undefined;
|
|
57
57
|
/**
|
|
58
|
-
* - If true, runs command
|
|
58
|
+
* - If string or true, runs the command via that shell ('bash' if true).
|
|
59
59
|
*/
|
|
60
60
|
shell?: string | boolean | undefined;
|
|
61
61
|
/**
|
|
62
|
-
* -
|
|
62
|
+
* - Milliseconds before sending SIGTERM (0 = no timeout).
|
|
63
63
|
*/
|
|
64
64
|
timeout?: number | undefined;
|
|
65
65
|
/**
|
|
66
|
-
* -
|
|
66
|
+
* - Optional stdin payload for spawnSync. Use .run(payload) for async.
|
|
67
67
|
*/
|
|
68
|
-
input?: string |
|
|
68
|
+
input?: string | Uint8Array<ArrayBufferLike> | Buffer<ArrayBufferLike> | undefined;
|
|
69
69
|
};
|
|
70
70
|
export type StdioOption = ("pipe" | "ignore" | "inherit" | number);
|
|
71
71
|
export type StdioOptions = Array<StdioOption> | StdioOption;
|
package/types/SHExecute.d.ts
CHANGED
|
@@ -1,26 +1,40 @@
|
|
|
1
1
|
export default SHExecute;
|
|
2
|
+
/**
|
|
3
|
+
* SHExecute
|
|
4
|
+
* Low-level process runner used by SHDispatch.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Optional shell execution: if options.shell is a string (e.g., 'bash') or true, runs `${prefix}; ${command}` via that shell; otherwise runs without a shell using `/usr/bin/env -S`.
|
|
8
|
+
* - Timeout: options.timeout may be a number (ms) or string like '1500ms' or '2s'. On timeout, run() rejects with a descriptive error.
|
|
9
|
+
* - Buffering: Captures stdout/stderr up to maxBuffer bytes per stream (default 40 MiB). Appends "[stdout truncated]" / "[stderr truncated]" markers if exceeded.
|
|
10
|
+
* - Payload: Passing a payload writes it to stdin and forces stdin to be a pipe.
|
|
11
|
+
* - Detached mode: If options.detached is true, run() resolves to '' after ~1s and unrefs the process.
|
|
12
|
+
* - Kill support: kill(signal) attempts to terminate the process (and some children) and causes run() to reject with "Process killed (forced).".
|
|
13
|
+
*/
|
|
2
14
|
declare class SHExecute {
|
|
3
15
|
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
constructor(command: string, prefix: string, options?: import("child_process").SpawnOptions
|
|
16
|
+
* @param {string} command - linux command to be executed
|
|
17
|
+
* @param {string} prefix - command prefix (shell prelude, e.g. 'set -euo pipefail')
|
|
18
|
+
* @param {import('child_process').SpawnOptions & { maxBuffer?: number }} options
|
|
19
|
+
*/
|
|
20
|
+
constructor(command: string, prefix: string, options?: import("child_process").SpawnOptions & {
|
|
21
|
+
maxBuffer?: number;
|
|
22
|
+
});
|
|
9
23
|
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
runSync(payload?: string): import("child_process").SpawnSyncReturns<
|
|
24
|
+
* @param {string} [payload] - data to write
|
|
25
|
+
* @returns {import('child_process').SpawnSyncReturns}
|
|
26
|
+
*/
|
|
27
|
+
runSync(payload?: string): import("child_process").SpawnSyncReturns<any>;
|
|
14
28
|
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
run(payload?: string): Promise<
|
|
29
|
+
* @param {string} [payload] - data to write
|
|
30
|
+
* @returns {Promise<string>}
|
|
31
|
+
*/
|
|
32
|
+
run(payload?: string): Promise<string>;
|
|
19
33
|
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
34
|
+
* Kill this process and possible child processes
|
|
35
|
+
* @param {number | string} signal - kill signal
|
|
36
|
+
* @returns {Promise<number[]>}
|
|
37
|
+
*/
|
|
24
38
|
kill(signal?: number | string): Promise<number[]>;
|
|
25
39
|
#private;
|
|
26
40
|
}
|