@j-o-r/sh 1.1.29 → 1.1.32
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/.editorconfig +21 -0
- package/README.md +32 -3
- package/TODO.md +349 -12
- package/lib/SH.js +274 -208
- package/lib/SHDispatch.js +48 -32
- package/lib/SHExecute.js +233 -228
- package/lib/internal.js +113 -0
- package/package.json +2 -2
- package/types/SH.d.ts +109 -50
- package/types/SHDispatch.d.ts +49 -28
- package/types/SHExecute.d.ts +21 -10
- package/types/internal.d.ts +62 -0
package/lib/SHDispatch.js
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
import SHExecute from './SHExecute.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @typedef {Object} SpawnSyncResponse
|
|
5
|
-
* @property {number|null} status - Exit code (null if signal).
|
|
6
|
-
* @property {string|null} signal - Terminating signal.
|
|
7
|
-
* @property {(string|Buffer|null)[]} output - [stdin, stdout, stderr].
|
|
8
|
-
* @property {number} pid - Process ID.
|
|
9
|
-
* @property {string|Buffer|null} stdout - Captured stdout.
|
|
10
|
-
* @property {string|Buffer|null} stderr - Captured stderr.
|
|
11
|
-
*/
|
|
2
|
+
import { defaultOptions } from './internal.js';
|
|
12
3
|
|
|
13
4
|
/**
|
|
14
5
|
* @typedef {('pipe' | 'ignore' | 'inherit' | number)} StdioOption
|
|
@@ -34,28 +25,34 @@ import SHExecute from './SHExecute.js';
|
|
|
34
25
|
* - `shell`: `'bash'` (string/true → shell; false → no-shell `/usr/bin/env -S`)
|
|
35
26
|
* - `stdio`: `['inherit', 'pipe', 'pipe']`
|
|
36
27
|
* - `timeout`: `0` (no timeout; rolling on data)
|
|
37
|
-
* - `maxBuffer`: `512000` (500
|
|
28
|
+
* - `maxBuffer`: `512000` (500 KiB per stream in SHExecute)
|
|
38
29
|
*
|
|
39
30
|
* Prefix (`.options(undefined, prefix)`) only for shell mode.
|
|
40
31
|
*
|
|
32
|
+
* @typedef {Object} SHOptions
|
|
33
|
+
* @property {string} cwd - Working directory for spawned commands.
|
|
34
|
+
* @property {NodeJS.ProcessEnv} env - Environment for spawned commands.
|
|
35
|
+
* @property {string|boolean} shell - Shell executable, true for bash, or false for no-shell mode.
|
|
36
|
+
* @property {StdioOptions} stdio - Stdio config passed to child_process.
|
|
37
|
+
* @property {number|string} timeout - Timeout in ms or duration string; 0 disables.
|
|
38
|
+
* Async `run()` uses a rolling timeout that resets on stdout/stderr data.
|
|
39
|
+
* `runSync()` passes it to `spawnSync`, where it is absolute: the process is
|
|
40
|
+
* killed after the full duration no matter how much output it produces.
|
|
41
|
+
* @property {number} [maxBuffer] - Maximum buffered bytes per stdout/stderr stream.
|
|
42
|
+
* @property {boolean} [detached] - Run process detached and resolve early (~1s).
|
|
43
|
+
* Unless stdio is explicitly set for the command, stdio is forced to
|
|
44
|
+
* `'ignore'`, because open pipes would keep the parent's event loop alive.
|
|
41
45
|
* @example { timeout: '5s', stdio: 'inherit', shell: false, maxBuffer: 10 * 1024}
|
|
42
46
|
*/
|
|
43
|
-
const SHOptions = {
|
|
44
|
-
cwd: process.cwd(),
|
|
45
|
-
env: process.env,
|
|
46
|
-
shell: 'bash',
|
|
47
|
-
stdio: ['inherit', 'pipe', 'pipe'],
|
|
48
|
-
timeout: 0,
|
|
49
|
-
};
|
|
50
47
|
|
|
51
48
|
/**
|
|
52
49
|
* Merges user options into predefined defaults (non-destructive).
|
|
53
50
|
*
|
|
54
51
|
* Only copies defined keys.
|
|
55
52
|
*
|
|
56
|
-
* @param {
|
|
57
|
-
* @param {Partial<
|
|
58
|
-
* @returns {
|
|
53
|
+
* @param {SHOptions} predefined - Base options.
|
|
54
|
+
* @param {Partial<SHOptions>} options - Overrides.
|
|
55
|
+
* @returns {SHOptions} Merged options.
|
|
59
56
|
*/
|
|
60
57
|
const mergeOptions = (predefined, options) => {
|
|
61
58
|
const mergedObj = { ...predefined };
|
|
@@ -83,30 +80,35 @@ const mergeOptions = (predefined, options) => {
|
|
|
83
80
|
class SHDispatch {
|
|
84
81
|
#prefix = '';
|
|
85
82
|
#cmd = '';
|
|
86
|
-
|
|
83
|
+
/** @type {SHOptions} */
|
|
84
|
+
#options = { ...defaultOptions };
|
|
87
85
|
/** @type {SHExecute | null} */
|
|
88
86
|
#proc = null;
|
|
87
|
+
/** Whether the user explicitly supplied `stdio` for this command. */
|
|
88
|
+
#stdioProvided = false;
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
91
|
* @param {string} cmd - Command string.
|
|
92
|
-
* @param {Partial<
|
|
92
|
+
* @param {Partial<SHOptions>} [options] - Initial options.
|
|
93
93
|
* @param {string} [prefix] - Shell prefix (e.g., 'set -euo pipefail').
|
|
94
94
|
* @throws {Error} Invalid/empty cmd.
|
|
95
95
|
*/
|
|
96
96
|
constructor(cmd, options = {}, prefix) {
|
|
97
97
|
if (typeof cmd !== 'string' || cmd === '') {
|
|
98
|
-
throw new Error('
|
|
98
|
+
throw new Error('Invalid or empty command');
|
|
99
99
|
}
|
|
100
100
|
this.#cmd = cmd;
|
|
101
101
|
this.options(options, prefix);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
* Updates options/prefix
|
|
105
|
+
* Updates options/prefix by merging into the dispatch instance's current options.
|
|
106
106
|
*
|
|
107
|
-
*
|
|
107
|
+
* This preserves global `SH.*` defaults captured when the command was created
|
|
108
|
+
* and only overrides options explicitly supplied for this command. Strings for
|
|
109
|
+
* `stdio` are expanded to `[value, value, value]`.
|
|
108
110
|
*
|
|
109
|
-
* @param {Partial<
|
|
111
|
+
* @param {Partial<SHOptions>} [options] - New options.
|
|
110
112
|
* @param {string} [prefix] - New prefix.
|
|
111
113
|
* @returns {SHDispatch} Self for chaining.
|
|
112
114
|
*/
|
|
@@ -117,23 +119,37 @@ class SHDispatch {
|
|
|
117
119
|
if (!options) {
|
|
118
120
|
return this;
|
|
119
121
|
}
|
|
120
|
-
|
|
122
|
+
const nextOptions = { ...options };
|
|
123
|
+
if (nextOptions.stdio !== undefined) {
|
|
124
|
+
this.#stdioProvided = true;
|
|
125
|
+
}
|
|
126
|
+
if (nextOptions.stdio && typeof nextOptions.stdio === 'string') {
|
|
121
127
|
// convert stdio to array
|
|
122
|
-
const io =
|
|
123
|
-
|
|
128
|
+
const io = nextOptions.stdio;
|
|
129
|
+
nextOptions.stdio = Array(3).fill(io);
|
|
124
130
|
}
|
|
125
|
-
this.#options = mergeOptions(
|
|
131
|
+
this.#options = mergeOptions(this.#options, nextOptions);
|
|
126
132
|
return this;
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
/**
|
|
130
136
|
* Async run: Captures stdout; rejects on error/timeout.
|
|
131
137
|
*
|
|
138
|
+
* Replaces the internal process handle: calling `run()` again while a
|
|
139
|
+
* previous run is still active makes that first process unkillable via
|
|
140
|
+
* `kill()`.
|
|
141
|
+
*
|
|
132
142
|
* @param {string} [payload] - Stdin payload.
|
|
133
143
|
* @returns {Promise<string>} Stdout.
|
|
134
144
|
*/
|
|
135
145
|
run(payload) {
|
|
136
|
-
|
|
146
|
+
// Detached processes must not keep piped stdio: open pipe handles hold
|
|
147
|
+
// the parent's event loop alive and defeat detachment. Force 'ignore'
|
|
148
|
+
// unless the user explicitly chose a stdio setup for this command.
|
|
149
|
+
const options = this.#options.detached && !this.#stdioProvided
|
|
150
|
+
? { ...this.#options, stdio: 'ignore' }
|
|
151
|
+
: this.#options;
|
|
152
|
+
this.#proc = new SHExecute(this.#cmd, this.#prefix, options);
|
|
137
153
|
return this.#proc.run(payload);
|
|
138
154
|
}
|
|
139
155
|
|
package/lib/SHExecute.js
CHANGED
|
@@ -1,58 +1,37 @@
|
|
|
1
1
|
import { spawnSync, spawn } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Validates if a number is a finite positive integer (including 0).
|
|
5
|
-
*
|
|
6
|
-
* @param {unknown} n - Value to check.
|
|
7
|
-
* @returns {boolean} True if finite non-negative integer.
|
|
8
|
-
*/
|
|
9
|
-
const isFinitePosInt = (n) => Number.isFinite(n) && n >= 0;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Parses a human-readable duration into milliseconds.
|
|
13
|
-
*
|
|
14
|
-
* Supports: numbers (ms), '250ms', '2s'. null/undefined → 0; invalid → throws.
|
|
15
|
-
*
|
|
16
|
-
* @param {number|string|null|undefined} d - Duration input.
|
|
17
|
-
* @returns {number} ms.
|
|
18
|
-
* @throws {Error} Invalid format/type.
|
|
19
|
-
*/
|
|
20
|
-
const parseDuration = (d) => {
|
|
21
|
-
if (typeof d === 'number') return isFinitePosInt(d) ? d : (() => { throw new Error('Invalid duration'); })();
|
|
22
|
-
if (typeof d === 'string') {
|
|
23
|
-
const m = d.match(/^(\d+)(ms|s)?$/);
|
|
24
|
-
if (!m) throw new Error('Invalid duration string');
|
|
25
|
-
const n = Number(m[1]);
|
|
26
|
-
return m[2] === 's' ? n * 1000 : n;
|
|
27
|
-
}
|
|
28
|
-
if (d == null) return 0;
|
|
29
|
-
throw new Error('Invalid duration type');
|
|
30
|
-
};
|
|
2
|
+
import { DEFAULT_MAX_BUFFER, parseDuration } from './internal.js';
|
|
31
3
|
|
|
32
4
|
/**
|
|
33
5
|
* Retrieves child PIDs of a given parent PID using pgrep -P.
|
|
34
6
|
*
|
|
7
|
+
* Best-effort: any pgrep failure resolves to an empty list so callers
|
|
8
|
+
* (notably `kill()`) always settle instead of hanging.
|
|
9
|
+
*
|
|
35
10
|
* @param {number} pid - Parent PID.
|
|
36
11
|
* @returns {Promise<number[]>} Array of child PIDs.
|
|
37
12
|
*/
|
|
38
13
|
const childrenOf = (pid) => new Promise((resolve, reject) => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
14
|
+
const p = spawn('pgrep', ['-P', String(pid)], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
15
|
+
const out = [];
|
|
16
|
+
p.stdout.on('data', (chunk) => out.push(chunk));
|
|
17
|
+
p.on('close', (code) => {
|
|
18
|
+
if (code === 0) {
|
|
19
|
+
const ids = Buffer.concat(out).toString('utf8').trim().split(/\s+/).map(Number).filter(Boolean);
|
|
20
|
+
resolve(ids);
|
|
21
|
+
} else if (code === 1) {
|
|
22
|
+
resolve([]); // no children or error
|
|
23
|
+
} else {
|
|
24
|
+
// pgrep failed (2: syntax error, 3: fatal error); kill stays best-effort.
|
|
25
|
+
resolve([]);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
p.on('error', reject);
|
|
51
29
|
});
|
|
52
30
|
|
|
53
31
|
/**
|
|
54
|
-
* @typedef {import('
|
|
55
|
-
* @description
|
|
32
|
+
* @typedef {import('./SHDispatch.js').SHOptions} SHExecuteOptions
|
|
33
|
+
* @description Options accepted by SHExecute — the shared SHOptions; `maxBuffer`
|
|
34
|
+
* (bytes per stream) defaults to 512000 (500 KiB).
|
|
56
35
|
*/
|
|
57
36
|
|
|
58
37
|
/**
|
|
@@ -64,213 +43,239 @@ const childrenOf = (pid) => new Promise((resolve, reject) => {
|
|
|
64
43
|
* - **Shell mode**: If `options.shell` is string/true, runs `${prefix}; ${command}` via shell ('bash' default).
|
|
65
44
|
* - **No-shell mode**: Uses `/usr/bin/env -S ${command}` for direct exec (ignores prefix).
|
|
66
45
|
* - **Rolling timeout**: `options.timeout` (ms/'2s'); resets on stdout/stderr data. SIGTERM on expiry.
|
|
67
|
-
*
|
|
46
|
+
* Async `run()` only — the timeout is stripped from the spawn options so Node's
|
|
47
|
+
* native absolute spawn timeout never interferes. `runSync()` instead passes the
|
|
48
|
+
* timeout to `spawnSync`, whose semantics are absolute (kills after the full
|
|
49
|
+
* duration, regardless of output).
|
|
50
|
+
* - **Buffering**: Captures stdout/stderr up to `maxBuffer` (512000 bytes / 500 KiB default); appends truncation markers.
|
|
68
51
|
* - **Payload**: `run(payload)` writes string to stdin (forces pipe).
|
|
69
|
-
* - **Detached**: If `options.detached`, resolves early (~1s) and unrefs.
|
|
70
|
-
*
|
|
52
|
+
* - **Detached**: If `options.detached`, resolves early (~1s) and unrefs. Unless the
|
|
53
|
+
* caller explicitly set `stdio`, SHDispatch forces `stdio: 'ignore'` for detached
|
|
54
|
+
* runs, because open pipes keep the parent's event loop alive and defeat detachment.
|
|
55
|
+
* - **Kill**: Terminates process + direct children via pgrep (requires procps; grandchildren survive).
|
|
71
56
|
*
|
|
72
57
|
* @example
|
|
73
58
|
* const exec = new SHExecute('ls', 'set -euo pipefail', { timeout: '5s' });
|
|
74
59
|
* const out = await exec.run();
|
|
75
60
|
*/
|
|
76
61
|
class SHExecute {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
62
|
+
#forcedKill = false;
|
|
63
|
+
/** @type {import('child_process').ChildProcess | null} */
|
|
64
|
+
#proc = null;
|
|
65
|
+
#prefix = '';
|
|
66
|
+
#command = '';
|
|
67
|
+
/** @type {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} */
|
|
68
|
+
#options = {};
|
|
69
|
+
#stdoutChunks = [];
|
|
70
|
+
#stderrChunks = [];
|
|
71
|
+
#stdoutLen = 0;
|
|
72
|
+
#stderrLen = 0;
|
|
73
|
+
#maxBuffer = DEFAULT_MAX_BUFFER; // 512000 bytes (500 KiB) per stream
|
|
74
|
+
#truncated = { stdout: false, stderr: false };
|
|
75
|
+
#timedOut = false;
|
|
76
|
+
/** Timeout in ms; 0 disables. Rolling in `run()`, absolute in `runSync()`. */
|
|
77
|
+
#timeout = 0;
|
|
91
78
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} command - Command to execute.
|
|
81
|
+
* @param {string} prefix - Shell prelude (e.g., 'set -euo pipefail'); ignored in no-shell.
|
|
82
|
+
* @param {SHExecuteOptions} [options] - Spawn options + maxBuffer/timeout.
|
|
83
|
+
*/
|
|
84
|
+
constructor(command, prefix, options = {}) {
|
|
85
|
+
this.#prefix = prefix;
|
|
86
|
+
this.#command = command;
|
|
87
|
+
// maxBuffer and timeout are SH-level options, not spawn options. timeout in
|
|
88
|
+
// particular must not reach spawn(): since Node 15.5 it triggers a native
|
|
89
|
+
// absolute timeout that would defeat the rolling timeout in run().
|
|
90
|
+
const { maxBuffer, timeout, ...spawnOpts } = options ?? {};
|
|
91
|
+
this.#maxBuffer = Number.isFinite(maxBuffer) && maxBuffer > 0 ? maxBuffer : this.#maxBuffer;
|
|
92
|
+
this.#timeout = parseDuration(timeout ?? 0);
|
|
93
|
+
this.#options = spawnOpts;
|
|
94
|
+
}
|
|
104
95
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Synchronous execution.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} [payload] - Stdin data (forces pipe).
|
|
100
|
+
* @returns {import('child_process').SpawnSyncReturns<Buffer>}
|
|
101
|
+
* @throws {Error} Invalid payload type.
|
|
102
|
+
*/
|
|
103
|
+
runSync(payload) {
|
|
104
|
+
this.#forcedKill = false;
|
|
105
|
+
if (payload && typeof payload !== 'string') {
|
|
106
|
+
throw new Error('Argument is not a string');
|
|
107
|
+
}
|
|
108
|
+
/** @type {import('node:child_process').SpawnSyncOptions} */
|
|
109
|
+
const options = { ...this.#options, shell: false };
|
|
110
|
+
if (this.#timeout > 0) {
|
|
111
|
+
// spawnSync supports timeout natively; semantics are absolute (no rolling reset).
|
|
112
|
+
options.timeout = this.#timeout;
|
|
113
|
+
}
|
|
114
|
+
if (payload) {
|
|
115
|
+
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
|
|
116
|
+
stdio[0] = 'pipe';
|
|
117
|
+
// @ts-ignore — stdio widens to string[] via the ['pipe', 'pipe', 'pipe'] literal; runtime values are valid StdioOptions.
|
|
118
|
+
options.stdio = stdio;
|
|
119
|
+
options.input = payload;
|
|
120
|
+
}
|
|
126
121
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
122
|
+
const shellOpt = this.#options?.shell;
|
|
123
|
+
if (shellOpt) {
|
|
124
|
+
const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
|
|
125
|
+
const cmd = this.#prefix ? `${this.#prefix}; ${this.#command}` : this.#command;
|
|
126
|
+
return spawnSync(sh, ['-c', cmd], options);
|
|
127
|
+
}
|
|
128
|
+
// no-shell mode
|
|
129
|
+
return spawnSync('/usr/bin/env', ['-S', this.#command], options);
|
|
130
|
+
}
|
|
136
131
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Asynchronous execution with buffering/timeout/kill.
|
|
134
|
+
*
|
|
135
|
+
* Resolves stdout (trimmed) on success.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} [payload] - Stdin data (forces pipe).
|
|
138
|
+
* @returns {Promise<string>} Trimmed UTF-8 stdout (+ truncation marker if exceeded).
|
|
139
|
+
* Rejects with an Error on command failure (message includes the exit code,
|
|
140
|
+
* or the signal name for signal kills, plus stderr), rolling-timeout expiry,
|
|
141
|
+
* forced kill, or spawn failure.
|
|
142
|
+
*/
|
|
143
|
+
run(payload) {
|
|
144
|
+
this.#forcedKill = false;
|
|
145
|
+
if (payload && typeof payload !== 'string') {
|
|
146
|
+
throw new Error('Argument is not a string');
|
|
147
|
+
}
|
|
148
|
+
/** @type {import('child_process').SpawnOptions} */
|
|
149
|
+
const options = { ...this.#options, shell: false };
|
|
150
|
+
if (payload) {
|
|
151
|
+
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
|
|
152
|
+
stdio[0] = 'pipe';
|
|
153
|
+
// @ts-ignore — stdio widens to string[] via the ['pipe', 'pipe', 'pipe'] literal; runtime values are valid StdioOptions.
|
|
154
|
+
options.stdio = stdio;
|
|
155
|
+
}
|
|
159
156
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
157
|
+
// reset buffers for each run
|
|
158
|
+
this.#stdoutChunks = [];
|
|
159
|
+
this.#stderrChunks = [];
|
|
160
|
+
this.#stdoutLen = 0;
|
|
161
|
+
this.#stderrLen = 0;
|
|
162
|
+
this.#truncated = { stdout: false, stderr: false };
|
|
163
|
+
this.#timedOut = false;
|
|
167
164
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
165
|
+
const shellOpt = this.#options?.shell;
|
|
166
|
+
if (shellOpt) {
|
|
167
|
+
const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
|
|
168
|
+
const cmd = this.#prefix ? `${this.#prefix}; ${this.#command}` : this.#command;
|
|
169
|
+
this.#proc = spawn(sh, ['-c', cmd], options);
|
|
170
|
+
} else {
|
|
171
|
+
this.#proc = spawn('/usr/bin/env', ['-S', this.#command], options);
|
|
172
|
+
}
|
|
176
173
|
|
|
177
|
-
|
|
174
|
+
if (payload) this.#proc.stdin?.end(payload);
|
|
178
175
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
176
|
+
const ms = this.#timeout;
|
|
177
|
+
let timeoutId = null;
|
|
178
|
+
const resetTimeout = () => {
|
|
179
|
+
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
180
|
+
if (ms > 0) {
|
|
181
|
+
timeoutId = setTimeout(() => {
|
|
182
|
+
this.#timedOut = true;
|
|
183
|
+
this.kill('SIGTERM').catch(() => {});
|
|
184
|
+
}, ms);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
190
187
|
|
|
191
|
-
|
|
188
|
+
if (ms > 0) resetTimeout();
|
|
192
189
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
190
|
+
this.#proc.stdout?.on('data', (chunk) => {
|
|
191
|
+
this.#stdoutLen += chunk.length;
|
|
192
|
+
// prevent TOKEN bombs
|
|
193
|
+
if (this.#stdoutLen <= this.#maxBuffer) {
|
|
194
|
+
this.#stdoutChunks.push(chunk);
|
|
195
|
+
} else {
|
|
196
|
+
this.#truncated.stdout = true;
|
|
197
|
+
}
|
|
198
|
+
resetTimeout();
|
|
199
|
+
});
|
|
203
200
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
201
|
+
this.#proc.stderr?.on('data', (chunk) => {
|
|
202
|
+
this.#stderrLen += chunk.length;
|
|
203
|
+
if (this.#stderrLen <= this.#maxBuffer) {
|
|
204
|
+
this.#stderrChunks.push(chunk);
|
|
205
|
+
} else {
|
|
206
|
+
this.#truncated.stderr = true;
|
|
207
|
+
}
|
|
208
|
+
resetTimeout();
|
|
209
|
+
});
|
|
213
210
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
if (options.detached) {
|
|
213
|
+
setTimeout(() => {
|
|
214
|
+
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
215
|
+
resolve('');
|
|
216
|
+
// #proc may already be null when kill() ran within the 1s window.
|
|
217
|
+
this.#proc?.unref();
|
|
218
|
+
}, 1000);
|
|
219
|
+
}
|
|
222
220
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
221
|
+
this.#proc.on('close', (code, signal) => {
|
|
222
|
+
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
223
|
+
// Check timeout first: the timeout path kills with SIGTERM, which sets
|
|
224
|
+
// #forcedKill in the same tick; #forcedKill must not mask the timeout.
|
|
225
|
+
if (this.#timedOut) {
|
|
226
|
+
reject(new Error(`Process timed out after ${ms}ms.`));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (this.#forcedKill) {
|
|
230
|
+
reject(new Error('Process killed (forced).'));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (code === 0) {
|
|
234
|
+
const stdoutBuf = this.#stdoutChunks.length ? Buffer.concat(this.#stdoutChunks) : Buffer.alloc(0);
|
|
235
|
+
let stdout = stdoutBuf.toString('utf8').trim();
|
|
236
|
+
if (this.#truncated.stdout) stdout += '\n[stdout truncated]\n';
|
|
237
|
+
resolve(stdout);
|
|
238
|
+
} else {
|
|
239
|
+
const stderrBuf = this.#stderrChunks.length ? Buffer.concat(this.#stderrChunks) : Buffer.alloc(0);
|
|
240
|
+
let stderr = stderrBuf.toString('utf8');
|
|
241
|
+
if (this.#truncated.stderr) stderr += '\n[stderr truncated]\n';
|
|
242
|
+
const reason = code === null ? `signal ${signal}` : `code ${code}`;
|
|
243
|
+
reject(new Error(`Command failed with ${reason}: ${stderr}`));
|
|
244
|
+
}
|
|
245
|
+
});
|
|
245
246
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
this.#proc.on('error', (err) => reject(err));
|
|
248
|
+
});
|
|
249
|
+
}
|
|
249
250
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
251
|
+
/**
|
|
252
|
+
* Terminates process and its children (via pgrep).
|
|
253
|
+
*
|
|
254
|
+
* Requires `pgrep` (procps) at runtime. Only direct children are
|
|
255
|
+
* discovered — grandchildren and deeper descendants survive. Best-effort:
|
|
256
|
+
* pgrep failures resolve to an empty child list instead of rejecting.
|
|
257
|
+
*
|
|
258
|
+
* @param {number | string} [signal='SIGTERM'] - Signal to send.
|
|
259
|
+
* @returns {Promise<number[]>} Killed PIDs.
|
|
260
|
+
* @throws {Error} No process/PID.
|
|
261
|
+
*/
|
|
262
|
+
async kill(signal = 'SIGTERM') {
|
|
263
|
+
if (!this.#proc) throw new Error('Trying to kill a process without creating one.');
|
|
264
|
+
if (!this.#proc.pid) throw new Error('The process pid is undefined.');
|
|
265
|
+
this.#forcedKill = true;
|
|
266
|
+
const pid = this.#proc.pid;
|
|
267
|
+
let killed = [];
|
|
268
|
+
try {
|
|
269
|
+
const kids = await childrenOf(pid).catch(() => []);
|
|
270
|
+
for (const k of kids) {
|
|
271
|
+
try { process.kill(k, signal); killed.push(k); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
272
|
+
}
|
|
273
|
+
try { process.kill(pid, signal); killed.push(pid); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
274
|
+
} finally {
|
|
275
|
+
this.#proc = null;
|
|
276
|
+
}
|
|
277
|
+
return killed;
|
|
278
|
+
}
|
|
274
279
|
}
|
|
275
280
|
|
|
276
281
|
export default SHExecute;
|