@j-o-r/sh 1.1.31 → 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/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;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Shared internals for the SH library.
3
+ *
4
+ * Single source of truth for the default command options used by the `SH`
5
+ * template tag (`lib/SH.js`) and `SHDispatch` (`lib/SHDispatch.js`), and for
6
+ * the shared `parseDuration` used by `lib/SH.js` and `lib/SHExecute.js`.
7
+ * Not part of the public API; nothing here is re-exported from `lib/SH.js`.
8
+ */
9
+
10
+ /**
11
+ * Explicit default `cwd` override, set via `SH.cwd = dir`.
12
+ *
13
+ * @type {string | undefined}
14
+ */
15
+ let cwdOverride;
16
+
17
+ /**
18
+ * Default maximum buffered bytes per stdout/stderr stream: 512000 (500 KiB).
19
+ * Used by `SHExecute` when `options.maxBuffer` is unset or invalid.
20
+ */
21
+ const DEFAULT_MAX_BUFFER = 500 * 1024;
22
+
23
+ /**
24
+ * Default options applied to all SH commands unless overridden.
25
+ *
26
+ * `cwd` is intentionally lazy: the getter resolves `process.cwd()` at read
27
+ * time, so commands created after a `cd()` run in the new directory instead of
28
+ * the directory the process was started in. `SHDispatch` spreads these
29
+ * defaults (`{ ...defaultOptions }`) per command, which invokes the getter and
30
+ * freezes the value for that command — "defaults captured at creation time".
31
+ * Do not "optimize" this into a static snapshot.
32
+ *
33
+ * An explicit `SH.cwd = dir` assignment takes precedence over `process.cwd()`
34
+ * (it only redirects SH commands; it does not `chdir` the process). `cd()`
35
+ * clears the override again, so the most recent of the two always wins.
36
+ *
37
+ * `maxBuffer` and `detached` are initialized explicitly so every key the `SH`
38
+ * proxy exposes ({@link defaultOptionKeys}) also has a defined value here.
39
+ * This is behavior-neutral: `SHExecute` already fell back to
40
+ * `DEFAULT_MAX_BUFFER` for unset/invalid values and truthy-checks `detached`.
41
+ *
42
+ * @type {import('./SHDispatch.js').SHOptions}
43
+ */
44
+ const defaultOptions = {
45
+ get cwd() {
46
+ return cwdOverride ?? process.cwd();
47
+ },
48
+ set cwd(value) {
49
+ cwdOverride = value;
50
+ },
51
+ env: process.env,
52
+ shell: 'bash',
53
+ stdio: ['inherit', 'pipe', 'pipe'],
54
+ timeout: 0, // when 0 there is no timeout
55
+ maxBuffer: DEFAULT_MAX_BUFFER,
56
+ detached: false,
57
+ };
58
+
59
+ /**
60
+ * Known global option keys exposed by the `SH` proxy in `lib/SH.js`.
61
+ *
62
+ * Derived from the own keys of {@link defaultOptions}, so the key set can
63
+ * never drift from the defaults. (The previous hard-coded list in `lib/SH.js`
64
+ * readable-exposed `maxBuffer`/`detached`, which were uninitialized in the
65
+ * defaults.) The proxy routes reads/writes for these keys to
66
+ * `defaultOptions` and throws a `TypeError` on writes to any other key
67
+ * (decision D1).
68
+ *
69
+ * @type {Set<string>}
70
+ */
71
+ const defaultOptionKeys = new Set(Object.keys(defaultOptions));
72
+
73
+ /**
74
+ * Clears the `SH.cwd` override so the lazy `cwd` getter follows
75
+ * `process.cwd()` again. Called by `cd()` after a successful
76
+ * `process.chdir()`.
77
+ */
78
+ const clearCwdOverride = () => {
79
+ cwdOverride = undefined;
80
+ };
81
+
82
+ /**
83
+ * Parses a human-readable duration into milliseconds.
84
+ *
85
+ * Accepts finite non-negative numbers (milliseconds) and strings in the exact
86
+ * forms `'Nms'`, `'Ns'`, or a bare `'N'`. The bare-number form is treated as
87
+ * milliseconds on purpose: it preserves the leniency of the former `SHExecute`
88
+ * parser, so e.g. `timeout: '100'` keeps working.
89
+ *
90
+ * `null`/`undefined` are not special-cased here; call sites that allow an
91
+ * absent duration handle it themselves (e.g. `parseDuration(timeout ?? 0)`).
92
+ *
93
+ * @param {number|string} d - Duration as number (ms) or string ('5s', '100ms', '100').
94
+ * @returns {number} Duration in milliseconds.
95
+ * @throws {Error} If the duration type or format is invalid.
96
+ */
97
+ const parseDuration = (d) => {
98
+ if (typeof d == 'number') {
99
+ if (!Number.isFinite(d) || d < 0)
100
+ throw new Error(`Invalid duration: "${d}".`);
101
+ return d;
102
+ }
103
+ if (typeof d == 'string') {
104
+ const match = d.match(/^(\d+)(ms|s)?$/);
105
+ if (!match)
106
+ throw new Error(`Unknown duration: "${d}".`);
107
+ const amount = Number(match[1]);
108
+ return match[2] == 's' ? amount * 1000 : amount;
109
+ }
110
+ throw new Error(`Invalid duration type: "${d === null ? 'Null' : typeof d}".`);
111
+ };
112
+
113
+ export { defaultOptions, defaultOptionKeys, clearCwdOverride, DEFAULT_MAX_BUFFER, parseDuration };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@j-o-r/sh",
3
3
  "author": "Jorrit Duin <j-o-r@duin.work>",
4
4
  "type": "module",
5
- "version": "1.1.31",
5
+ "version": "1.1.32",
6
6
  "description": "Execute shell commands on Linux-based systems from javascript",
7
7
  "main": "lib/SH.js",
8
8
  "types": "types/SH.d.ts",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "license": "Apache License, Version 2.0",
24
24
  "devDependencies": {
25
- "@types/node": "^22.10.10"
25
+ "@types/node": "*"
26
26
  },
27
27
  "bugs": {
28
28
  "url": "https://codeberg.org/duin/sh/issues"
@@ -49,4 +49,4 @@
49
49
  "process-promise",
50
50
  "process-output"
51
51
  ]
52
- }
52
+ }