@j-o-r/sh 1.1.31 → 1.2.0

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, optionScope } 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,24 +189,39 @@ 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, running it in a fresh async
193
+ * context (decision D2).
194
+ *
195
+ * Real isolation via `AsyncLocalStorage`: the callback runs inside a new
196
+ * scope whose `SH.*` default-option assignments are scoped to the block. Any
197
+ * `SH.timeout = …`, `SH.cwd = …`, etc. made inside the callback apply only to
198
+ * commands created within it and are discarded when the block ends — they do
199
+ * not leak to the enclosing scope. Nested `within()` blocks inherit their
200
+ * parent's scoped defaults but never leak their own outward.
228
201
  *
229
- * Useful for parallel operations without nesting.
202
+ * Note: `cd()` is a genuine process-wide operation (`process.chdir`), so it is
203
+ * not scoped by `within()`; use `SH.cwd = dir` for a scoped working directory.
230
204
  *
231
205
  * @param {() => Promise<any>} callback - Async function to execute.
232
206
  * @returns {Promise<any>} Result of callback.
233
207
  * @example
234
208
  * const results = await within(async () => {
209
+ * SH.timeout = 5000; // scoped to this block
235
210
  * return Promise.all([SH`sleep 1; echo 1`.run(), sleep(2)]);
236
211
  * });
237
212
  */
238
213
  const within = async (callback) => {
239
- return await callback();
240
- }
214
+ const parent = optionScope.getStore();
215
+ const store = { ...parent };
216
+ return optionScope.run(store, callback);
217
+ };
241
218
 
242
219
  /**
243
220
  * Reads entire stdin as UTF-8 string. Resolves to empty string if TTY (no pipe).
244
221
  *
222
+ * Side effect: permanently sets the `process.stdin` encoding to utf8 and
223
+ * switches it to flowing mode.
224
+ *
245
225
  * @returns {Promise<string>} Stdin content, or an empty string if TTY.
246
226
  * @example
247
227
  * const input = await readIn(); // Use in piped scripts
@@ -254,12 +234,31 @@ const readIn = async () => {
254
234
  buf += chunk;
255
235
  }
256
236
  return buf;
257
- }
237
+ };
238
+
239
+ /**
240
+ * Minimum stdin chunk length (containing a newline) that {@link userIn}
241
+ * treats as a paste instead of typed input.
242
+ */
243
+ const PASTE_MIN_CHUNK = 4;
244
+
245
+ /**
246
+ * Debounce delay after the last 'line' event or paste before {@link userIn}
247
+ * auto-resolves with the accumulated input.
248
+ */
249
+ const SUBMIT_DEBOUNCE_MS = 50;
258
250
 
259
251
  /**
260
252
  * Prompts user for input on stdin with custom prompt.
261
253
  *
262
- * Supports aborting input collection.
254
+ * Submit heuristics (non-obvious): each 'line' event is accumulated into a
255
+ * multi-line buffer and (re)starts a debounce timer
256
+ * ({@link SUBMIT_DEBOUNCE_MS}); when the timer fires without further input,
257
+ * the promise auto-resolves with the accumulated text plus any partially
258
+ * typed readline line. A paste — a stdin chunk longer than
259
+ * {@link PASTE_MIN_CHUNK} chars containing a newline — (re)starts the same
260
+ * timer, so pasted multi-line text resolves as one input. `abort()` resolves
261
+ * the promise with `undefined`.
263
262
  *
264
263
  * @param {string} prompt - Prompt text to display.
265
264
  * @returns {AbortableInput} Object with input Promise and abort function.
@@ -269,51 +268,49 @@ const readIn = async () => {
269
268
  * // abort(); // Cancel anytime
270
269
  */
271
270
  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
- };
271
+ let resolvePromise;
272
+ const input = new Promise((resolve) => { resolvePromise = resolve; });
273
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
274
+ let buffer = '';
275
+ let timer;
276
+ /** @param {string} chunk */
277
+ const onData = (chunk) => {
278
+ if (chunk.length > PASTE_MIN_CHUNK && chunk.includes('\n')) {
279
+ clearTimeout(timer);
280
+ timer = setTimeout(resolveFunc, SUBMIT_DEBOUNCE_MS);
281
+ }
282
+ };
283
+
284
+ const resolveFunc = () => {
285
+ let fullText = buffer;
286
+ if (rl.line) fullText += rl.line;
287
+ cleanup();
288
+ resolvePromise(fullText);
289
+ };
290
+
291
+ const cleanup = () => {
292
+ process.stdin.removeListener('data', onData);
293
+ rl.removeAllListeners('line');
294
+ clearTimeout(timer);
295
+ rl.close();
296
+ };
297
+ rl.on('line', (line) => {
298
+ buffer += line + '\n';
299
+ clearTimeout(timer);
300
+ timer = setTimeout(resolveFunc, SUBMIT_DEBOUNCE_MS);
301
+ });
302
+ process.stdin.on('data', onData);
303
+
304
+ rl.setPrompt(prompt);
305
+ rl.prompt();
306
+
307
+ return {
308
+ input,
309
+ abort: () => {
310
+ cleanup();
311
+ resolvePromise();
312
+ }
313
+ };
317
314
  };
318
315
 
319
316
  /**
@@ -327,11 +324,16 @@ const isDelayGenerator = (value) => value != null && typeof value == 'object' &&
327
324
  /**
328
325
  * Retries an async function up to N times with optional delays.
329
326
  *
330
- * Delay can be fixed ('1s'), generator-like (expBackoff()), or none.
327
+ * Delay precedence: when `delayOrCallback` is a function it is the retried
328
+ * callback and there is no delay between attempts. Otherwise it is a static
329
+ * duration ('1s', 100) or a delay generator (e.g. {@link expBackoff}), and
330
+ * `callback` is the retried function. A generator yield of `undefined`
331
+ * (exhausted generator) or `0` means "no further delay" — the next attempt
332
+ * runs immediately.
331
333
  *
332
334
  * @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.
335
+ * @param {string|number|Iterator<number|string>|(() => (Promise<any>|any))} delayOrCallback - Delay, delay generator, or the callback when no separate callback is supplied.
336
+ * @param {() => (Promise<any>|any)} [callback] - Callback to retry when a delay is supplied.
335
337
  * @returns {Promise<any>} Successful result.
336
338
  * @throws {Error} If arguments are invalid, or the last callback error if all attempts fail.
337
339
  * @example
@@ -381,7 +383,7 @@ const retry = async (count, delayOrCallback, callback) => {
381
383
  }
382
384
  }
383
385
  }
384
- }
386
+ };
385
387
 
386
388
  /**
387
389
  * Sleeps for a specified duration.
@@ -395,19 +397,27 @@ const sleep = (duration) => {
395
397
  return new Promise((resolve) => {
396
398
  setTimeout(resolve, parseDuration(duration));
397
399
  });
398
- }
400
+ };
399
401
 
400
402
  /**
401
403
  * Changes the current working directory.
402
404
  *
405
+ * Affects the cwd of subsequent `SH` commands: default options capture
406
+ * `process.cwd()` lazily when each command is created. Also clears any
407
+ * `SH.cwd` override, so the most recent cwd change always wins.
408
+ *
409
+ * This is a genuine process-wide operation (`process.chdir`), so it is NOT
410
+ * scoped by {@link within}. Use `SH.cwd = dir` inside a `within()` block for a
411
+ * scoped working directory.
412
+ *
403
413
  * @param {string} dir - Path to new directory.
404
414
  * @example
405
415
  * cd('/tmp');
406
416
  */
407
417
  const cd = (dir) => {
408
- // @ts-ignore
409
418
  process.chdir(dir);
410
- }
419
+ clearCwdOverride();
420
+ };
411
421
 
412
422
  /**
413
423
  * Generator for exponential backoff delays with jitter.
@@ -416,6 +426,7 @@ const cd = (dir) => {
416
426
  * @param {string} [max='60s'] - Max backoff duration.
417
427
  * @param {string} [rand='100ms'] - Max jitter.
418
428
  * @yields {number} Next backoff ms.
429
+ * @returns {ExpBackoffGenerator} Infinite generator of backoff delays in ms.
419
430
  * @example
420
431
  * const backoff = expBackoff();
421
432
  * await sleep(backoff.next().value);
@@ -444,12 +455,15 @@ const isNegativeNumberArg = (arg) => /^-(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i
444
455
  * Checks whether a token should be parsed as an option.
445
456
  *
446
457
  * Negative numeric values are intentionally excluded so calls like
447
- * `parseArgs(['--n', '-1'])` parse `-1` as the value for `--n`.
458
+ * `parseArgs(['--n', '-1'])` parse `-1` as the value for `--n`. A lone `-`
459
+ * is not an option either; it is parsed as a positional (the stdin
460
+ * convention). The `--` end-of-options terminator is handled separately in
461
+ * {@link parseArgs}.
448
462
  *
449
463
  * @param {string} arg - CLI argument token.
450
464
  * @returns {boolean} True when the token should be parsed as an option.
451
465
  */
452
- const isOptionArg = (arg) => arg.startsWith('-') && !isNegativeNumberArg(arg);
466
+ const isOptionArg = (arg) => arg.length > 1 && arg.startsWith('-') && !isNegativeNumberArg(arg);
453
467
 
454
468
  /**
455
469
  * Parses CLI arguments into an object.
@@ -457,7 +471,9 @@ const isOptionArg = (arg) => arg.startsWith('-') && !isNegativeNumberArg(arg);
457
471
  * Supports --key value, -k value (no shorts grouped).
458
472
  * Bares go to _[]. Duplicates error. No = syntax.
459
473
  * Negative number tokens such as -1, -0.5, and -1e3 are parsed as values or
460
- * positionals, not options.
474
+ * positionals, not options. A lone `-` is a positional (stdin convention).
475
+ * `--` is the conventional end-of-options terminator: every token after it
476
+ * goes into `_`, even option-looking ones.
461
477
  *
462
478
  * Defaults to process.argv.slice(2).
463
479
  *
@@ -467,14 +483,23 @@ const isOptionArg = (arg) => arg.startsWith('-') && !isNegativeNumberArg(arg);
467
483
  * @example
468
484
  * parseArgs(['--port', '8080', 'file.txt']); // { port: '8080', _: ['file.txt'] }
469
485
  * parseArgs(['--n', '-1']); // { n: '-1', _: [] }
486
+ * parseArgs(['-', '--port', '1']); // { port: '1', _: ['-'] }
487
+ * parseArgs(['--', '--x']); // { _: ['--x'] }
470
488
  */
471
489
  const parseArgs = (args) => {
472
490
  if (!args) args = process.argv.slice(2);
473
- const result = { _: [] };
491
+ // @ts-ignore '_: string[]' is the intended exception to the 'string|true' index signature of ArgsObject.
492
+ const result = /** @type {ArgsObject} */ ({ _: [] });
474
493
  const seenKeys = new Set();
475
494
  for (let i = 0; i < args.length; i++) {
476
495
  const arg = args[i];
477
496
 
497
+ if (arg === '--') {
498
+ // End-of-options terminator: all remaining tokens are positionals.
499
+ result._.push(...args.slice(i + 1));
500
+ break;
501
+ }
502
+
478
503
  if (isOptionArg(arg)) {
479
504
  if (arg.startsWith('-') && !arg.startsWith('--') && arg.length > 2) {
480
505
  throw new Error(`Invalid argument: ${arg}. Use '--' for long options.`);
@@ -484,6 +509,7 @@ const parseArgs = (args) => {
484
509
  throw new Error(`Duplicate argument: ${arg}`);
485
510
  }
486
511
  seenKeys.add(key);
512
+ /** @type {string|true} */
487
513
  let value;
488
514
  if (i + 1 < args.length && !isOptionArg(args[i + 1])) {
489
515
  value = args[i + 1];
@@ -496,7 +522,6 @@ const parseArgs = (args) => {
496
522
  result._.push(arg);
497
523
  }
498
524
  }
499
- // @ts-ignore
500
525
  return result;
501
526
  };
502
527
 
@@ -516,4 +541,4 @@ export {
516
541
  assert,
517
542
  AsyncTracker,
518
543
  bashEscape
519
- }
544
+ };
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
@@ -33,8 +24,8 @@ import SHExecute from './SHExecute.js';
33
24
  * - `env`: `process.env`
34
25
  * - `shell`: `'bash'` (string/true → shell; false → no-shell `/usr/bin/env -S`)
35
26
  * - `stdio`: `['inherit', 'pipe', 'pipe']`
36
- * - `timeout`: `0` (no timeout; rolling on data)
37
- * - `maxBuffer`: `512000` (500 kb per stream in SHExecute)
27
+ * - `timeout`: `0` (no timeout; absolute wall-clock cap when set)
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,18 @@ 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 - Absolute wall-clock timeout in ms or
38
+ * duration string; 0 disables. The process is killed after the full duration
39
+ * no matter how much output it produces, in BOTH `run()` and `runSync()`.
40
+ * This matches Node's native `spawn`/`spawnSync` `timeout` semantics; SH is
41
+ * not meant to run long-lived services, so there is no rolling/idle reset.
47
42
  * @property {number} [maxBuffer] - Maximum buffered bytes per stdout/stderr stream.
48
- * @property {boolean} [detached] - Run process detached and resolve early.
43
+ * @property {boolean} [detached] - Run process detached and resolve early (~1s).
44
+ * Unless stdio is explicitly set for the command, stdio is forced to
45
+ * `'ignore'`, because open pipes would keep the parent's event loop alive.
49
46
  * @example { timeout: '5s', stdio: 'inherit', shell: false, maxBuffer: 10 * 1024}
50
47
  */
51
48
 
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
49
  /**
62
50
  * Merges user options into predefined defaults (non-destructive).
63
51
  *
@@ -94,9 +82,11 @@ class SHDispatch {
94
82
  #prefix = '';
95
83
  #cmd = '';
96
84
  /** @type {SHOptions} */
97
- #options = { ...defaultSHOptions };
85
+ #options = { ...defaultOptions };
98
86
  /** @type {SHExecute | null} */
99
87
  #proc = null;
88
+ /** Whether the user explicitly supplied `stdio` for this command. */
89
+ #stdioProvided = false;
100
90
 
101
91
  /**
102
92
  * @param {string} cmd - Command string.
@@ -106,7 +96,7 @@ class SHDispatch {
106
96
  */
107
97
  constructor(cmd, options = {}, prefix) {
108
98
  if (typeof cmd !== 'string' || cmd === '') {
109
- throw new Error('Undefined command');
99
+ throw new Error('Invalid or empty command');
110
100
  }
111
101
  this.#cmd = cmd;
112
102
  this.options(options, prefix);
@@ -131,6 +121,9 @@ class SHDispatch {
131
121
  return this;
132
122
  }
133
123
  const nextOptions = { ...options };
124
+ if (nextOptions.stdio !== undefined) {
125
+ this.#stdioProvided = true;
126
+ }
134
127
  if (nextOptions.stdio && typeof nextOptions.stdio === 'string') {
135
128
  // convert stdio to array
136
129
  const io = nextOptions.stdio;
@@ -143,11 +136,21 @@ class SHDispatch {
143
136
  /**
144
137
  * Async run: Captures stdout; rejects on error/timeout.
145
138
  *
139
+ * Replaces the internal process handle: calling `run()` again while a
140
+ * previous run is still active makes that first process unkillable via
141
+ * `kill()`.
142
+ *
146
143
  * @param {string} [payload] - Stdin payload.
147
144
  * @returns {Promise<string>} Stdout.
148
145
  */
149
146
  run(payload) {
150
- this.#proc = new SHExecute(this.#cmd, this.#prefix, this.#options);
147
+ // Detached processes must not keep piped stdio: open pipe handles hold
148
+ // the parent's event loop alive and defeat detachment. Force 'ignore'
149
+ // unless the user explicitly chose a stdio setup for this command.
150
+ const options = this.#options.detached && !this.#stdioProvided
151
+ ? { ...this.#options, stdio: 'ignore' }
152
+ : this.#options;
153
+ this.#proc = new SHExecute(this.#cmd, this.#prefix, options);
151
154
  return this.#proc.run(payload);
152
155
  }
153
156