@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/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 kb (per stream in SHExecute))
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 {typeof SHOptions} predefined - Base options.
57
- * @param {Partial<typeof SHOptions>} options - Overrides.
58
- * @returns {typeof SHOptions} Merged options.
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
- #options = SHOptions;
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<typeof SHOptions>} [options] - Initial options.
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('Undefined command');
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; resets to defaults + user overrides (non-cumulative).
105
+ * Updates options/prefix by merging into the dispatch instance's current options.
106
106
  *
107
- * Strings for `stdio` → array fill.
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<typeof SHOptions>} [options] - New options.
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
- if (options.stdio && typeof options.stdio === 'string') {
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 = options.stdio;
123
- options.stdio = Array(3).fill(io);
128
+ const io = nextOptions.stdio;
129
+ nextOptions.stdio = Array(3).fill(io);
124
130
  }
125
- this.#options = mergeOptions(SHOptions, options);
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
- this.#proc = new SHExecute(this.#cmd, this.#prefix, this.#options);
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
- const p = spawn('pgrep', ['-P', String(pid)], { stdio: ['ignore', 'pipe', 'ignore'] });
40
- const out = [];
41
- p.stdout.on('data', (chunk) => out.push(chunk));
42
- p.on('close', (code) => {
43
- if (code === 0) {
44
- const ids = Buffer.concat(out).toString('utf8').trim().split(/\s+/).map(Number).filter(Boolean);
45
- resolve(ids);
46
- } else if (code === 1) {
47
- resolve([]); // no children or error
48
- }
49
- });
50
- p.on('error', reject);
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('../SH.js').SHOptions & { maxBuffer?: number }} SHExecuteOptions
55
- * @description Extended options for SHExecute: adds `maxBuffer` (bytes per stream, default 1MB).
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
- * - **Buffering**: Captures stdout/stderr up to `maxBuffer` (1MB default); appends truncation markers.
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
- * - **Kill**: Terminates process + children via pgrep.
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
- #forcedKill = false;
78
- /** @type {import('child_process').ChildProcess | null} */
79
- #proc = null;
80
- #prefix = '';
81
- #command = '';
82
- /** @type {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} */
83
- #options = {};
84
- #stdoutChunks = [];
85
- #stderrChunks = [];
86
- #stdoutLen = 0;
87
- #stderrLen = 0;
88
- #maxBuffer = 500 * 1024; // 500KB per stream
89
- #truncated = { stdout: false, stderr: false };
90
- #timedOut = false;
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
- * @param {string} command - Command to execute.
94
- * @param {string} prefix - Shell prelude (e.g., 'set -euo pipefail'); ignored in no-shell.
95
- * @param {SHExecuteOptions} [options] - Spawn options + maxBuffer/timeout.
96
- */
97
- constructor(command, prefix, options = {}) {
98
- this.#prefix = prefix;
99
- this.#command = command;
100
- const { maxBuffer, ...spawnOpts } = options ?? {};
101
- this.#maxBuffer = Number.isFinite(maxBuffer) && maxBuffer > 0 ? maxBuffer : this.#maxBuffer;
102
- this.#options = spawnOpts;
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
- * Synchronous execution.
107
- *
108
- * @param {string} [payload] - Stdin data (forces pipe).
109
- * @returns {import('child_process').SpawnSyncReturns<Buffer>}
110
- * @throws {Error} Invalid payload type.
111
- */
112
- runSync(payload) {
113
- this.#forcedKill = false;
114
- if (payload && typeof payload !== 'string') {
115
- throw new Error('Argument is not a string');
116
- }
117
- /** @type {import('node:child_process').SpawnSyncOptions} */
118
- const options = { ...this.#options, shell: false };
119
- if (payload) {
120
- const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
121
- stdio[0] = 'pipe';
122
- // @ts-ignore
123
- options.stdio = stdio;
124
- options.input = payload;
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
- const shellOpt = this.#options?.shell;
128
- if (shellOpt) {
129
- const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
130
- const cmd = this.#prefix ? `${this.#prefix}; ${this.#command}` : this.#command;
131
- return spawnSync(sh, ['-c', cmd], options);
132
- }
133
- // no-shell mode
134
- return spawnSync('/usr/bin/env', ['-S', this.#command], options);
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
- * Asynchronous execution with buffering/timeout/kill.
139
- *
140
- * Resolves stdout (trimmed) on success; rejects on error/timeout/kill.
141
- *
142
- * @param {string} [payload] - Stdin data (forces pipe).
143
- * @returns {Promise<string>} Trimmed UTF-8 stdout (+ truncation marker if exceeded).
144
- * @throws {Error} Command failure (incl. code, stderr), timeout, kill, spawn error.
145
- */
146
- run(payload) {
147
- this.#forcedKill = false;
148
- if (payload && typeof payload !== 'string') {
149
- throw new Error('Argument is not a string');
150
- }
151
- /** @type {import('child_process').SpawnOptions} */
152
- const options = { ...this.#options, shell: false };
153
- if (payload) {
154
- const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
155
- stdio[0] = 'pipe';
156
- // @ts-ignore
157
- options.stdio = stdio;
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
- // reset buffers for each run
161
- this.#stdoutChunks = [];
162
- this.#stderrChunks = [];
163
- this.#stdoutLen = 0;
164
- this.#stderrLen = 0;
165
- this.#truncated = { stdout: false, stderr: false };
166
- this.#timedOut = false;
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
- const shellOpt = this.#options?.shell;
169
- if (shellOpt) {
170
- const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
171
- const cmd = this.#prefix ? `${this.#prefix}; ${this.#command}` : this.#command;
172
- this.#proc = spawn(sh, ['-c', cmd], options);
173
- } else {
174
- this.#proc = spawn('/usr/bin/env', ['-S', this.#command], options);
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
- if (payload) this.#proc.stdin?.end(payload);
174
+ if (payload) this.#proc.stdin?.end(payload);
178
175
 
179
- const ms = parseDuration(this.#options?.timeout ?? 0);
180
- let timeoutId = null;
181
- const resetTimeout = () => {
182
- if (timeoutId !== null) clearTimeout(timeoutId);
183
- if (ms > 0) {
184
- timeoutId = setTimeout(() => {
185
- this.#timedOut = true;
186
- this.kill('SIGTERM').catch(() => {});
187
- }, ms);
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
- if (ms > 0) resetTimeout();
188
+ if (ms > 0) resetTimeout();
192
189
 
193
- this.#proc.stdout?.on('data', (chunk) => {
194
- this.#stdoutLen += chunk.length;
195
- // prevent TOKEN bombs
196
- if (this.#stdoutLen <= this.#maxBuffer) {
197
- this.#stdoutChunks.push(chunk);
198
- } else {
199
- this.#truncated.stdout = true;
200
- }
201
- resetTimeout();
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
- this.#proc.stderr?.on('data', (chunk) => {
205
- this.#stderrLen += chunk.length;
206
- if (this.#stderrLen <= this.#maxBuffer) {
207
- this.#stderrChunks.push(chunk);
208
- } else {
209
- this.#truncated.stderr = true;
210
- }
211
- resetTimeout();
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
- return new Promise((resolve, reject) => {
215
- if (options.detached) {
216
- setTimeout(() => {
217
- if (timeoutId !== null) clearTimeout(timeoutId);
218
- resolve('');
219
- this.#proc.unref();
220
- }, 1000);
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
- this.#proc.on('close', (code, signal) => {
224
- if (timeoutId !== null) clearTimeout(timeoutId);
225
- if (this.#forcedKill) {
226
- reject(new Error('Process killed (forced).'));
227
- return;
228
- }
229
- if (this.#timedOut) {
230
- reject(new Error(`Process timed out after ${ms}ms.`));
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
- reject(new Error(`Command failed with code ${code}: ${stderr}`));
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
- this.#proc.on('error', (err) => reject(err));
247
- });
248
- }
247
+ this.#proc.on('error', (err) => reject(err));
248
+ });
249
+ }
249
250
 
250
- /**
251
- * Terminates process and its children (via pgrep).
252
- *
253
- * @param {number | string} [signal='SIGTERM'] - Signal to send.
254
- * @returns {Promise<number[]>} Killed PIDs.
255
- * @throws {Error} No process/PID.
256
- */
257
- async kill(signal = 'SIGTERM') {
258
- if (!this.#proc) throw new Error('Trying to kill a process without creating one.');
259
- if (!this.#proc.pid) throw new Error('The process pid is undefined.');
260
- this.#forcedKill = true;
261
- const pid = this.#proc.pid;
262
- let killed = [];
263
- try {
264
- const kids = await childrenOf(pid).catch(() => []);
265
- for (const k of kids) {
266
- try { process.kill(k, signal); killed.push(k); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
267
- }
268
- try { process.kill(pid, signal); killed.push(pid); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
269
- } finally {
270
- this.#proc = null;
271
- }
272
- return killed;
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;