@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/SH.js CHANGED
@@ -11,16 +11,16 @@
11
11
  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
- //
14
+ //
15
15
  //
16
16
  // Original Source: zx
17
17
  // Link to Original Source: https://github.com/google/zx
18
- // Reason for Using This Code:
19
- // The core functionality of this code is highly beneficial. However, certain parts of the original code
20
- // were overwriting the global namespace with core libraries and variables. This was causing conflicts with
18
+ // Reason for Using This Code:
19
+ // The core functionality of this code is highly beneficial. However, certain parts of the original code
20
+ // were overwriting the global namespace with core libraries and variables. This was causing conflicts with
21
21
  // other packages (for instance, fetch) and introducing unexpected elements into my code base.
22
22
  // The main $/SH method is all there is left, with barebone Promises and readable code.
23
- // Changes Made:
23
+ // Changes Made:
24
24
  // - The code has been or is being reformatted to comply with ES2020 standards.
25
25
  // - Some methods were added and existing ones were modified or deleted to enhance usability.
26
26
  // - The namespace has been changed from '$' to 'SH'.
@@ -29,8 +29,9 @@
29
29
  import assert from 'node:assert';
30
30
  import readline from 'node:readline/promises';
31
31
  import SHDispatch from './SHDispatch.js';
32
- import Test from './Test.js'
33
- import AsyncTracker from './AsyncTracker.js'
32
+ import Test from './Test.js';
33
+ import AsyncTracker from './AsyncTracker.js';
34
+ import { defaultOptions, defaultOptionKeys, clearCwdOverride, parseDuration } from './internal.js';
34
35
 
35
36
  /**
36
37
  * Parsed command-line arguments.
@@ -38,21 +39,7 @@ import AsyncTracker from './AsyncTracker.js'
38
39
  * Named options map to their string value, or `true` when no value is supplied.
39
40
  * Bare positional arguments are collected in `_`.
40
41
  *
41
- * @typedef {{_: string[]} & Object.<string, string|true|string[]>} ArgsObject
42
- */
43
-
44
- /**
45
- * @typedef {Function} RejectCallback
46
- * @param {Error} error - The error object passed to the callback.
47
- */
48
-
49
- /**
50
- * @typedef {Function} ResolveCallback
51
- * @param {any} [param] - Optional callback any value
52
- */
53
-
54
- /**
55
- * @typedef {import('./SHDispatch.js').SHOptions} SHOptions
42
+ * @typedef {{_: string[]} & Object.<string, string|true>} ArgsObject
56
43
  */
57
44
 
58
45
  /**
@@ -62,64 +49,46 @@ import AsyncTracker from './AsyncTracker.js'
62
49
  */
63
50
 
64
51
  /**
65
- * Generator-like object that yields retry delay durations.
52
+ * Generator that yields retry delay durations in milliseconds.
66
53
  *
67
- * @typedef {Iterator<number|string>} ExpBackoffGenerator
54
+ * @typedef {Generator<number, void, unknown>} ExpBackoffGenerator
68
55
  */
69
56
 
70
57
  /**
71
58
  * Utility to determine the JavaScript type of a value.
72
59
  *
73
- * @param {any} fn - Any value to inspect.
60
+ * Uses `Object.prototype.toString`, so `jsType(null)` returns `'Null'`;
61
+ * `undefined` is special-cased to `'undefined'`. Plain objects report their
62
+ * constructor name, or `'Object'` when there is none (null-prototype objects).
63
+ *
64
+ * @param {unknown} value - Any value to inspect.
74
65
  * @returns {string} The "real" object type name (e.g., 'Array', 'Promise') or primitive typeof.
75
66
  * @example
76
67
  * jsType([]); // 'Array'
77
68
  * jsType(Promise.resolve()); // 'Promise'
69
+ * jsType(null); // 'Null'
70
+ * jsType(undefined); // 'undefined'
78
71
  */
79
- const jsType = (fn) => {
80
- if (fn === undefined) return 'undefined';
81
- const type = Object.prototype.toString.call(fn).slice(8, -1);
82
- return type === 'Object' ? fn.constructor.name : type;
72
+ const jsType = (value) => {
73
+ if (value === undefined) return 'undefined';
74
+ const type = Object.prototype.toString.call(value).slice(8, -1);
75
+ // Null-prototype objects have no constructor; fall back to 'Object'.
76
+ return type === 'Object' ? (/** @type {any} */ (value).constructor?.name ?? 'Object') : type;
83
77
  };
84
78
 
85
79
  /**
86
- * Checks if an object has a specific own property (code-safe).
80
+ * Checks if an object has a specific own property. Safe for null-prototype
81
+ * objects and objects shadowing `hasOwnProperty`.
87
82
  *
88
83
  * @param {any} o - Object to examine.
89
84
  * @param {string} p - Property name to check.
90
85
  * @returns {boolean} True if the object has the own property.
91
86
  */
92
87
  const hasProp = (o, p) => {
93
- if (o == null) return false;
94
- return Object.prototype.hasOwnProperty.call(o, p);
88
+ if (o == null) return false;
89
+ return Object.hasOwn(o, p);
95
90
  };
96
91
 
97
- /**
98
- * Parses a human-readable duration string or number into milliseconds.
99
- *
100
- * Supports finite non-negative numbers as milliseconds, plus exact string forms
101
- * with units: '5s' (5000ms) and '100ms' (100ms).
102
- *
103
- * @param {number|string} d - Duration as number (ms) or string ('5s', '100ms').
104
- * @returns {number} Duration in milliseconds.
105
- * @throws {Error} If the duration type or format is invalid.
106
- */
107
- const parseDuration = (d) => {
108
- if (typeof d == 'number') {
109
- if (!Number.isFinite(d) || d < 0)
110
- throw new Error(`Invalid duration: "${d}".`);
111
- return d;
112
- }
113
- if (typeof d == 'string') {
114
- const match = d.match(/^(\d+)(ms|s)$/);
115
- if (!match)
116
- throw new Error(`Unknown duration: "${d}".`);
117
- const amount = Number(match[1]);
118
- return match[2] == 's' ? amount * 1000 : amount;
119
- }
120
- throw new Error(`Invalid duration type: "${jsType(d)}".`);
121
- }
122
-
123
92
  /**
124
93
  * Quotes a value as one POSIX shell argument.
125
94
  *
@@ -136,32 +105,16 @@ const parseDuration = (d) => {
136
105
  */
137
106
  const bashEscape = (x) => `'${String(x).replace(/'/g, `'\\''`)}'`;
138
107
 
139
- /** Default options applied to all SH commands unless overridden. */
140
- const defaultOptions = {
141
- cwd: process.cwd(),
142
- env: process.env,
143
- shell: 'bash',
144
- stdio: ['inherit', 'pipe', 'pipe'],
145
- timeout: 0, // when 0 there is no timeout
146
- };
147
-
148
- /** Known global option properties exposed for `SH.option` readback. */
149
- const defaultOptionKeys = new Set([
150
- 'cwd',
151
- 'env',
152
- 'shell',
153
- 'stdio',
154
- 'timeout',
155
- 'maxBuffer',
156
- 'detached',
157
- ]);
158
-
159
108
  /**
160
109
  * Proxy traps for the SH template tag and global option defaults.
161
110
  *
162
- * Assignments keep the existing compatibility behavior by storing every property
163
- * in defaultOptions. Reads only expose known default option keys, so unknown
164
- * property access continues to behave like a normal function proxy read.
111
+ * Reads and writes are symmetric: only the known default option keys
112
+ * (`defaultOptionKeys`, derived from `defaultOptions` in `lib/internal.js`)
113
+ * are routed to the shared defaults. Assigning an unknown key throws a
114
+ * `TypeError` listing the known keys (decision D1) — this catches typos like
115
+ * `SH.timout = 1` instead of silently storing junk keys that read back as
116
+ * `undefined` and leak into every command's options. Reads of unknown keys
117
+ * keep normal function-proxy behavior (usually `undefined`).
165
118
  */
166
119
  const defaultOptionHandler = {
167
120
  get(target, prop, receiver) {
@@ -171,10 +124,13 @@ const defaultOptionHandler = {
171
124
  return Reflect.get(target, prop, receiver);
172
125
  },
173
126
  set(target, prop, value) {
127
+ if (!defaultOptionKeys.has(prop)) {
128
+ throw new TypeError(`Unknown SH option: "${String(prop)}". Known options: ${[...defaultOptionKeys].join(', ')}.`);
129
+ }
174
130
  defaultOptions[prop] = value;
175
131
  return true;
176
132
  }
177
- }
133
+ };
178
134
 
179
135
  /**
180
136
  * Template tag for building and executing shell commands.
@@ -189,10 +145,15 @@ const defaultOptionHandler = {
189
145
  * command separators. It is not safe for untrusted input. Wrap untrusted values
190
146
  * with {@link bashEscape} before interpolating them.
191
147
  *
192
- * SH acts as both template tag and options setter: `SH.timeout = 5000; SH`cmd``
148
+ * SH also acts as a global options setter (see the last example). Global
149
+ * defaults are captured when each command is created, not when it runs — see
150
+ * the "defaults captured at creation time" note in {@link SHDispatch#options}.
151
+ * Only the known option keys (cwd, env, shell, stdio, timeout, maxBuffer,
152
+ * detached) may be assigned; assigning anything else throws a TypeError
153
+ * listing the known keys (typo guard, decision D1).
193
154
  *
194
155
  * @param {TemplateStringsArray} pieces - String literals from template.
195
- * @param {...unknown[]} args - Values to interpolate.
156
+ * @param {...unknown} args - Values to interpolate.
196
157
  * @returns {SHDispatch} Command dispatcher.
197
158
  * @throws {Error} If pieces contain undefined.
198
159
  * @example
@@ -204,6 +165,10 @@ const defaultOptionHandler = {
204
165
  *
205
166
  * // Quote untrusted values explicitly
206
167
  * SH`printf '%s\n' ${bashEscape('semi; colon')}`.run();
168
+ *
169
+ * // Set global defaults for subsequently created commands
170
+ * SH.timeout = 5000;
171
+ * await SH`cmd`.run();
207
172
  */
208
173
  const SH = new Proxy(function(pieces, ...args) {
209
174
  if (pieces.some((p) => p == undefined)) {
@@ -212,7 +177,7 @@ const SH = new Proxy(function(pieces, ...args) {
212
177
  let cmd = pieces[0], i = 0;
213
178
  while (i < args.length) {
214
179
  let s;
215
-
180
+
216
181
  if (Array.isArray(args[i])) {
217
182
  s = args[i].map((x) => String(x)).join(' ');
218
183
  } else {
@@ -224,9 +189,13 @@ const SH = new Proxy(function(pieces, ...args) {
224
189
  }, defaultOptionHandler);
225
190
 
226
191
  /**
227
- * Executes a callback in a new async context (fresh callstack).
192
+ * Awaits `callback()` and returns its result.
228
193
  *
229
- * Useful for parallel operations without nesting.
194
+ * Despite the historical name, this is currently a thin wrapper: no new async
195
+ * context or fresh callstack is created — the callback runs in the current
196
+ * one. It is kept as an intent marker for grouping async work (and for API
197
+ * compatibility); real isolation via AsyncLocalStorage is a pending decision
198
+ * (see the project TODO, decision D2).
230
199
  *
231
200
  * @param {() => Promise<any>} callback - Async function to execute.
232
201
  * @returns {Promise<any>} Result of callback.
@@ -237,11 +206,14 @@ const SH = new Proxy(function(pieces, ...args) {
237
206
  */
238
207
  const within = async (callback) => {
239
208
  return await callback();
240
- }
209
+ };
241
210
 
242
211
  /**
243
212
  * Reads entire stdin as UTF-8 string. Resolves to empty string if TTY (no pipe).
244
213
  *
214
+ * Side effect: permanently sets the `process.stdin` encoding to utf8 and
215
+ * switches it to flowing mode.
216
+ *
245
217
  * @returns {Promise<string>} Stdin content, or an empty string if TTY.
246
218
  * @example
247
219
  * const input = await readIn(); // Use in piped scripts
@@ -254,12 +226,31 @@ const readIn = async () => {
254
226
  buf += chunk;
255
227
  }
256
228
  return buf;
257
- }
229
+ };
230
+
231
+ /**
232
+ * Minimum stdin chunk length (containing a newline) that {@link userIn}
233
+ * treats as a paste instead of typed input.
234
+ */
235
+ const PASTE_MIN_CHUNK = 4;
236
+
237
+ /**
238
+ * Debounce delay after the last 'line' event or paste before {@link userIn}
239
+ * auto-resolves with the accumulated input.
240
+ */
241
+ const SUBMIT_DEBOUNCE_MS = 50;
258
242
 
259
243
  /**
260
244
  * Prompts user for input on stdin with custom prompt.
261
245
  *
262
- * Supports aborting input collection.
246
+ * Submit heuristics (non-obvious): each 'line' event is accumulated into a
247
+ * multi-line buffer and (re)starts a debounce timer
248
+ * ({@link SUBMIT_DEBOUNCE_MS}); when the timer fires without further input,
249
+ * the promise auto-resolves with the accumulated text plus any partially
250
+ * typed readline line. A paste — a stdin chunk longer than
251
+ * {@link PASTE_MIN_CHUNK} chars containing a newline — (re)starts the same
252
+ * timer, so pasted multi-line text resolves as one input. `abort()` resolves
253
+ * the promise with `undefined`.
263
254
  *
264
255
  * @param {string} prompt - Prompt text to display.
265
256
  * @returns {AbortableInput} Object with input Promise and abort function.
@@ -269,51 +260,49 @@ const readIn = async () => {
269
260
  * // abort(); // Cancel anytime
270
261
  */
271
262
  const userIn = (prompt) => {
272
- let resolvePromise;
273
- const input = new Promise((resolve) => { resolvePromise = resolve; });
274
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
275
- let buffer = '';
276
- let timer;
277
- /** @param {string} chunk */
278
- const onData = (chunk) => {
279
- if (chunk.length > 4 && chunk.includes('\n')) {
280
- clearTimeout(timer);
281
- timer = setTimeout(resolveFunc, 50);
282
- }
283
- };
284
-
285
- const resolveFunc = () => {
286
- let fullText = buffer;
287
- if (rl.line) fullText += rl.line;
288
- cleanup();
289
- resolvePromise(fullText);
290
- };
291
-
292
- const cleanup = () => {
293
- process.stdin.removeListener('data', onData);
294
- // @ts-ignore
295
- rl.removeAllListeners('line');
296
- clearTimeout(timer);
297
- rl.close();
298
- };
299
- // @ts-ignore
300
- rl.on('line', (line) => {
301
- buffer += line + '\n';
302
- clearTimeout(timer);
303
- timer = setTimeout(resolveFunc, 50);
304
- });
305
- process.stdin.on('data', onData);
306
-
307
- rl.setPrompt(prompt);
308
- rl.prompt();
309
-
310
- return {
311
- input,
312
- abort: () => {
313
- cleanup();
314
- resolvePromise();
315
- }
316
- };
263
+ let resolvePromise;
264
+ const input = new Promise((resolve) => { resolvePromise = resolve; });
265
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
266
+ let buffer = '';
267
+ let timer;
268
+ /** @param {string} chunk */
269
+ const onData = (chunk) => {
270
+ if (chunk.length > PASTE_MIN_CHUNK && chunk.includes('\n')) {
271
+ clearTimeout(timer);
272
+ timer = setTimeout(resolveFunc, SUBMIT_DEBOUNCE_MS);
273
+ }
274
+ };
275
+
276
+ const resolveFunc = () => {
277
+ let fullText = buffer;
278
+ if (rl.line) fullText += rl.line;
279
+ cleanup();
280
+ resolvePromise(fullText);
281
+ };
282
+
283
+ const cleanup = () => {
284
+ process.stdin.removeListener('data', onData);
285
+ rl.removeAllListeners('line');
286
+ clearTimeout(timer);
287
+ rl.close();
288
+ };
289
+ rl.on('line', (line) => {
290
+ buffer += line + '\n';
291
+ clearTimeout(timer);
292
+ timer = setTimeout(resolveFunc, SUBMIT_DEBOUNCE_MS);
293
+ });
294
+ process.stdin.on('data', onData);
295
+
296
+ rl.setPrompt(prompt);
297
+ rl.prompt();
298
+
299
+ return {
300
+ input,
301
+ abort: () => {
302
+ cleanup();
303
+ resolvePromise();
304
+ }
305
+ };
317
306
  };
318
307
 
319
308
  /**
@@ -327,11 +316,16 @@ const isDelayGenerator = (value) => value != null && typeof value == 'object' &&
327
316
  /**
328
317
  * Retries an async function up to N times with optional delays.
329
318
  *
330
- * Delay can be fixed ('1s'), generator-like (expBackoff()), or none.
319
+ * Delay precedence: when `delayOrCallback` is a function it is the retried
320
+ * callback and there is no delay between attempts. Otherwise it is a static
321
+ * duration ('1s', 100) or a delay generator (e.g. {@link expBackoff}), and
322
+ * `callback` is the retried function. A generator yield of `undefined`
323
+ * (exhausted generator) or `0` means "no further delay" — the next attempt
324
+ * runs immediately.
331
325
  *
332
326
  * @param {number} count - Positive integer number of attempts.
333
- * @param {string|number|ExpBackoffGenerator|Function} delayOrCallback - Delay, delay generator, or callback if no separate callback is supplied.
334
- * @param {Function} [callback] - Callback to retry when a delay is supplied.
327
+ * @param {string|number|Iterator<number|string>|(() => (Promise<any>|any))} delayOrCallback - Delay, delay generator, or the callback when no separate callback is supplied.
328
+ * @param {() => (Promise<any>|any)} [callback] - Callback to retry when a delay is supplied.
335
329
  * @returns {Promise<any>} Successful result.
336
330
  * @throws {Error} If arguments are invalid, or the last callback error if all attempts fail.
337
331
  * @example
@@ -381,7 +375,7 @@ const retry = async (count, delayOrCallback, callback) => {
381
375
  }
382
376
  }
383
377
  }
384
- }
378
+ };
385
379
 
386
380
  /**
387
381
  * Sleeps for a specified duration.
@@ -395,19 +389,23 @@ const sleep = (duration) => {
395
389
  return new Promise((resolve) => {
396
390
  setTimeout(resolve, parseDuration(duration));
397
391
  });
398
- }
392
+ };
399
393
 
400
394
  /**
401
395
  * Changes the current working directory.
402
396
  *
397
+ * Affects the cwd of subsequent `SH` commands: default options capture
398
+ * `process.cwd()` lazily when each command is created. Also clears any
399
+ * `SH.cwd` override, so the most recent cwd change always wins.
400
+ *
403
401
  * @param {string} dir - Path to new directory.
404
402
  * @example
405
403
  * cd('/tmp');
406
404
  */
407
405
  const cd = (dir) => {
408
- // @ts-ignore
409
406
  process.chdir(dir);
410
- }
407
+ clearCwdOverride();
408
+ };
411
409
 
412
410
  /**
413
411
  * Generator for exponential backoff delays with jitter.
@@ -416,6 +414,7 @@ const cd = (dir) => {
416
414
  * @param {string} [max='60s'] - Max backoff duration.
417
415
  * @param {string} [rand='100ms'] - Max jitter.
418
416
  * @yields {number} Next backoff ms.
417
+ * @returns {ExpBackoffGenerator} Infinite generator of backoff delays in ms.
419
418
  * @example
420
419
  * const backoff = expBackoff();
421
420
  * await sleep(backoff.next().value);
@@ -444,12 +443,15 @@ const isNegativeNumberArg = (arg) => /^-(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i
444
443
  * Checks whether a token should be parsed as an option.
445
444
  *
446
445
  * Negative numeric values are intentionally excluded so calls like
447
- * `parseArgs(['--n', '-1'])` parse `-1` as the value for `--n`.
446
+ * `parseArgs(['--n', '-1'])` parse `-1` as the value for `--n`. A lone `-`
447
+ * is not an option either; it is parsed as a positional (the stdin
448
+ * convention). The `--` end-of-options terminator is handled separately in
449
+ * {@link parseArgs}.
448
450
  *
449
451
  * @param {string} arg - CLI argument token.
450
452
  * @returns {boolean} True when the token should be parsed as an option.
451
453
  */
452
- const isOptionArg = (arg) => arg.startsWith('-') && !isNegativeNumberArg(arg);
454
+ const isOptionArg = (arg) => arg.length > 1 && arg.startsWith('-') && !isNegativeNumberArg(arg);
453
455
 
454
456
  /**
455
457
  * Parses CLI arguments into an object.
@@ -457,7 +459,9 @@ const isOptionArg = (arg) => arg.startsWith('-') && !isNegativeNumberArg(arg);
457
459
  * Supports --key value, -k value (no shorts grouped).
458
460
  * Bares go to _[]. Duplicates error. No = syntax.
459
461
  * Negative number tokens such as -1, -0.5, and -1e3 are parsed as values or
460
- * positionals, not options.
462
+ * positionals, not options. A lone `-` is a positional (stdin convention).
463
+ * `--` is the conventional end-of-options terminator: every token after it
464
+ * goes into `_`, even option-looking ones.
461
465
  *
462
466
  * Defaults to process.argv.slice(2).
463
467
  *
@@ -467,14 +471,23 @@ const isOptionArg = (arg) => arg.startsWith('-') && !isNegativeNumberArg(arg);
467
471
  * @example
468
472
  * parseArgs(['--port', '8080', 'file.txt']); // { port: '8080', _: ['file.txt'] }
469
473
  * parseArgs(['--n', '-1']); // { n: '-1', _: [] }
474
+ * parseArgs(['-', '--port', '1']); // { port: '1', _: ['-'] }
475
+ * parseArgs(['--', '--x']); // { _: ['--x'] }
470
476
  */
471
477
  const parseArgs = (args) => {
472
478
  if (!args) args = process.argv.slice(2);
473
- const result = { _: [] };
479
+ // @ts-ignore '_: string[]' is the intended exception to the 'string|true' index signature of ArgsObject.
480
+ const result = /** @type {ArgsObject} */ ({ _: [] });
474
481
  const seenKeys = new Set();
475
482
  for (let i = 0; i < args.length; i++) {
476
483
  const arg = args[i];
477
484
 
485
+ if (arg === '--') {
486
+ // End-of-options terminator: all remaining tokens are positionals.
487
+ result._.push(...args.slice(i + 1));
488
+ break;
489
+ }
490
+
478
491
  if (isOptionArg(arg)) {
479
492
  if (arg.startsWith('-') && !arg.startsWith('--') && arg.length > 2) {
480
493
  throw new Error(`Invalid argument: ${arg}. Use '--' for long options.`);
@@ -484,6 +497,7 @@ const parseArgs = (args) => {
484
497
  throw new Error(`Duplicate argument: ${arg}`);
485
498
  }
486
499
  seenKeys.add(key);
500
+ /** @type {string|true} */
487
501
  let value;
488
502
  if (i + 1 < args.length && !isOptionArg(args[i + 1])) {
489
503
  value = args[i + 1];
@@ -496,7 +510,6 @@ const parseArgs = (args) => {
496
510
  result._.push(arg);
497
511
  }
498
512
  }
499
- // @ts-ignore
500
513
  return result;
501
514
  };
502
515
 
@@ -516,4 +529,4 @@ export {
516
529
  assert,
517
530
  AsyncTracker,
518
531
  bashEscape
519
- }
532
+ };
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,7 +25,7 @@ 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
  *
@@ -43,21 +34,17 @@ import SHExecute from './SHExecute.js';
43
34
  * @property {NodeJS.ProcessEnv} env - Environment for spawned commands.
44
35
  * @property {string|boolean} shell - Shell executable, true for bash, or false for no-shell mode.
45
36
  * @property {StdioOptions} stdio - Stdio config passed to child_process.
46
- * @property {number|string} timeout - Rolling timeout in ms or duration string; 0 disables timeout.
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.
47
41
  * @property {number} [maxBuffer] - Maximum buffered bytes per stdout/stderr stream.
48
- * @property {boolean} [detached] - Run process detached and resolve early.
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.
49
45
  * @example { timeout: '5s', stdio: 'inherit', shell: false, maxBuffer: 10 * 1024}
50
46
  */
51
47
 
52
- /** @type {SHOptions} */
53
- const defaultSHOptions = {
54
- cwd: process.cwd(),
55
- env: process.env,
56
- shell: 'bash',
57
- stdio: ['inherit', 'pipe', 'pipe'],
58
- timeout: 0,
59
- };
60
-
61
48
  /**
62
49
  * Merges user options into predefined defaults (non-destructive).
63
50
  *
@@ -94,9 +81,11 @@ class SHDispatch {
94
81
  #prefix = '';
95
82
  #cmd = '';
96
83
  /** @type {SHOptions} */
97
- #options = { ...defaultSHOptions };
84
+ #options = { ...defaultOptions };
98
85
  /** @type {SHExecute | null} */
99
86
  #proc = null;
87
+ /** Whether the user explicitly supplied `stdio` for this command. */
88
+ #stdioProvided = false;
100
89
 
101
90
  /**
102
91
  * @param {string} cmd - Command string.
@@ -106,7 +95,7 @@ class SHDispatch {
106
95
  */
107
96
  constructor(cmd, options = {}, prefix) {
108
97
  if (typeof cmd !== 'string' || cmd === '') {
109
- throw new Error('Undefined command');
98
+ throw new Error('Invalid or empty command');
110
99
  }
111
100
  this.#cmd = cmd;
112
101
  this.options(options, prefix);
@@ -131,6 +120,9 @@ class SHDispatch {
131
120
  return this;
132
121
  }
133
122
  const nextOptions = { ...options };
123
+ if (nextOptions.stdio !== undefined) {
124
+ this.#stdioProvided = true;
125
+ }
134
126
  if (nextOptions.stdio && typeof nextOptions.stdio === 'string') {
135
127
  // convert stdio to array
136
128
  const io = nextOptions.stdio;
@@ -143,11 +135,21 @@ class SHDispatch {
143
135
  /**
144
136
  * Async run: Captures stdout; rejects on error/timeout.
145
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
+ *
146
142
  * @param {string} [payload] - Stdin payload.
147
143
  * @returns {Promise<string>} Stdout.
148
144
  */
149
145
  run(payload) {
150
- 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);
151
153
  return this.#proc.run(payload);
152
154
  }
153
155