@j-o-r/sh 1.1.29 → 1.1.31

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/README.md CHANGED
@@ -13,7 +13,7 @@ Execute shell commands from JavaScript on Linux.
13
13
  - Utils: `sleep`, `retry`, `expBackoff`, `cd`, `parseArgs`, `userIn`, `readIn`.
14
14
  - Testing: `new Test().add('name', () => assert(...)).run()`.
15
15
  - Async leak detection: `AsyncTracker` via `async_hooks`.
16
- - Safe interpolation; `bashEscape`; full JSDoc.
16
+ - Raw-by-default interpolation with explicit `bashEscape` quoting for untrusted argument values; full JSDoc.
17
17
 
18
18
  No runtime deps. ESM-only (ES2020+).
19
19
 
@@ -35,6 +35,35 @@ const out = await SH`ls -la`.run();
35
35
  console.log(out); // Captured stdout (trimmed)
36
36
  ```
37
37
 
38
+ ### Interpolation: raw by default
39
+
40
+ Interpolated values are inserted into the shell command as raw shell source. This
41
+ is convenient for trusted shell fragments such as flags, pipes, redirects, and
42
+ compound expressions, but it is **not safe for untrusted input**.
43
+
44
+ ```js
45
+ import { SH, bashEscape } from '@j-o-r/sh';
46
+
47
+ const flags = ['-l', '-a'];
48
+ await SH`ls ${flags}`.run(); // Executes: ls -l -a
49
+
50
+ const fragment = 'printf ok; printf done';
51
+ await SH`${fragment}`.run(); // Raw shell syntax is intentional
52
+
53
+ const userInput = 'name; rm -rf /';
54
+ await SH`printf '%s\n' ${bashEscape(userInput)}`.run();
55
+ // Prints the value literally instead of executing `rm`.
56
+ ```
57
+
58
+ `bashEscape(value)` returns one POSIX-shell-quoted argument. It preserves empty
59
+ strings, whitespace, quotes, semicolons, glob characters, tabs, and newlines.
60
+ For arrays of untrusted values, quote each element yourself:
61
+
62
+ ```js
63
+ const args = ['two words', 'semi;colon', "quote's"];
64
+ await SH`printf '%s\n' ${args.map(bashEscape)}`.run();
65
+ ```
66
+
38
67
  ### Chaining & Options
39
68
 
40
69
  ```js
@@ -140,9 +169,9 @@ Full JSDoc in `lib/*.js`. Key exports:
140
169
  | `Test` | Test runner |
141
170
  | `AsyncTracker` | Async leak detector |
142
171
  | `parseArgs(argv)` | CLI parser |
143
- | `bashEscape(str)` | Shell-safe string |
172
+ | `bashEscape(value)` | Quote one value as a POSIX shell argument for safe interpolation |
144
173
 
145
- See [types/index.d.ts](types/index.d.ts) for TS defs.
174
+ See [types/SH.d.ts](types/SH.d.ts) for TS defs.
146
175
 
147
176
  ## Development
148
177
 
package/TODO.md CHANGED
@@ -1,18 +1,8 @@
1
1
  # TODOs for @j-o-r/sh project
2
2
 
3
- ## High Priority (pending)
4
- - [ ] Review and commit recent changes to lib/ files and types/ (from git status: modified AsyncTracker.js, SH.js, etc.)
5
- - [*] Generate or improve API documentation using types/*.d.ts files
3
+ ## lib/SH.js review findings (2026-06-26)
6
4
 
7
5
  ## In Progress
8
- - [ ]
9
-
10
- ## Later (future tasks)
11
- - [ ] Create separate docs folder with full API reference
12
6
 
13
7
  ## Done
14
- - [x] Initial project setup from package.json and README.md inspection (2026-04-15)
15
- - [x] Update README.md to include documentation for recent changes in AsyncTracker and Test features (2026-04-15)
16
- - [x] Add more examples to README.md for SHDispatch methods and utilities like retry and expBackoff (2026-04-15)
17
- - [x] Reference general SSH example in README.md (demo-interactive-ssh.js not found in scenarios/) (2026-04-15)
18
- - [x] Reduce SHExecute maxBuffer default from 40MB to 1MB, update all docs/JSDoc mentioning it. Make sure global SH.maxBuffer works. (2026-04-15)
8
+
package/lib/SH.js CHANGED
@@ -33,10 +33,12 @@ import Test from './Test.js'
33
33
  import AsyncTracker from './AsyncTracker.js'
34
34
 
35
35
  /**
36
- * @typedef {Object.<string, string>} ArgsObject
37
- * @property {string[]} _ - Array of strings, unnamed parameters
38
- * @property {string} [key: string] - Any string key maps to a string value
39
- * @description Parsed parameters result.
36
+ * Parsed command-line arguments.
37
+ *
38
+ * Named options map to their string value, or `true` when no value is supplied.
39
+ * Bare positional arguments are collected in `_`.
40
+ *
41
+ * @typedef {{_: string[]} & Object.<string, string|true|string[]>} ArgsObject
40
42
  */
41
43
 
42
44
  /**
@@ -60,9 +62,9 @@ import AsyncTracker from './AsyncTracker.js'
60
62
  */
61
63
 
62
64
  /**
63
- * @typedef {Object} ExpBackoffGenerator
64
- * @generator
65
- * @yields {number} Backoff time in ms.
65
+ * Generator-like object that yields retry delay durations.
66
+ *
67
+ * @typedef {Iterator<number|string>} ExpBackoffGenerator
66
68
  */
67
69
 
68
70
  /**
@@ -95,50 +97,44 @@ const hasProp = (o, p) => {
95
97
  /**
96
98
  * Parses a human-readable duration string or number into milliseconds.
97
99
  *
98
- * Supports: '5s' (5000ms), '100ms' (100ms), plain numbers (ms).
100
+ * Supports finite non-negative numbers as milliseconds, plus exact string forms
101
+ * with units: '5s' (5000ms) and '100ms' (100ms).
99
102
  *
100
103
  * @param {number|string} d - Duration as number (ms) or string ('5s', '100ms').
101
104
  * @returns {number} Duration in milliseconds.
102
- * @throws {Error} If invalid duration format.
105
+ * @throws {Error} If the duration type or format is invalid.
103
106
  */
104
107
  const parseDuration = (d) => {
105
108
  if (typeof d == 'number') {
106
- if (isNaN(d) || d < 0)
109
+ if (!Number.isFinite(d) || d < 0)
107
110
  throw new Error(`Invalid duration: "${d}".`);
108
111
  return d;
109
112
  }
110
- else if (/\d+s/.test(d)) {
111
- return +d.slice(0, -1) * 1000;
112
- }
113
- else if (/\d+ms/.test(d)) {
114
- return +d.slice(0, -2);
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;
115
119
  }
116
- throw new Error(`Unknown duration: "${d}".`);
120
+ throw new Error(`Invalid duration type: "${jsType(d)}".`);
117
121
  }
118
122
 
119
123
  /**
120
- * Escapes a string for safe use as a bash command-line argument.
124
+ * Quotes a value as one POSIX shell argument.
121
125
  *
122
- * Handles quotes, backticks, $, newlines, etc.
126
+ * The `SH` template tag interpolates values as raw shell source by default. Use
127
+ * this helper when a JavaScript value must be passed as data instead of shell
128
+ * syntax. The returned string is single-quoted and preserves the exact string
129
+ * value, including leading/trailing whitespace, empty strings, quotes,
130
+ * semicolons, glob characters, tabs, and newlines.
123
131
  *
124
- * @param {string} x - Input string to escape.
125
- * @returns {string} Bash-escaped string.
132
+ * @param {unknown} x - Value to quote as a single shell argument.
133
+ * @returns {string} POSIX-shell-quoted argument string.
134
+ * @example
135
+ * await SH`printf '%s\n' ${bashEscape(userInput)}`.run();
126
136
  */
127
- const bashEscape = (x) => {
128
- let str = String(x).trim();
129
- // the trick is to double escape escape vars first
130
- str = str.replace(/\\/g, '\\\\');
131
- // then add escaping for oddities
132
- // Replace literal escape sequences like \n, \t, \r in quoted strings
133
- str = str.replace(/(['"])\\(.)\1/g, '$1\\\\$2$1');
134
- // escape $ (is a BASH var)
135
- str = str.replace(/\$/g, '\\$');
136
- // Escape backticks
137
- str = str.replace(/`/g, '\\`');
138
- // Escape quotes
139
- str = str.replace(/"/g, '\\"');
140
- return str;
141
- }
137
+ const bashEscape = (x) => `'${String(x).replace(/'/g, `'\\''`)}'`;
142
138
 
143
139
  /** Default options applied to all SH commands unless overridden. */
144
140
  const defaultOptions = {
@@ -149,12 +145,35 @@ const defaultOptions = {
149
145
  timeout: 0, // when 0 there is no timeout
150
146
  };
151
147
 
152
- /** Proxy set trap for dynamically updating defaultOptions via SH.prop = value */
153
- const setHandler = {
154
- set(target, prop, value) {
155
- defaultOptions[prop] = value;
156
- return true;
157
- }
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
+ /**
160
+ * Proxy traps for the SH template tag and global option defaults.
161
+ *
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.
165
+ */
166
+ const defaultOptionHandler = {
167
+ get(target, prop, receiver) {
168
+ if (defaultOptionKeys.has(prop)) {
169
+ return defaultOptions[prop];
170
+ }
171
+ return Reflect.get(target, prop, receiver);
172
+ },
173
+ set(target, prop, value) {
174
+ defaultOptions[prop] = value;
175
+ return true;
176
+ }
158
177
  }
159
178
 
160
179
  /**
@@ -162,9 +181,13 @@ const setHandler = {
162
181
  *
163
182
  * Returns an {@link SHDispatch} instance for configuration (.options()) and execution (.run(), .runSync()).
164
183
  *
165
- * Interpolation rules:
166
- * - Arrays: Elements trimmed, newlines/tabs escaped, shell-special chars single-quoted with '\'' escape.
167
- * - Other values: String(value) inserted as-is (NOT auto-escaped; use {@link bashEscape} for safety).
184
+ * Interpolation rules are raw-by-default:
185
+ * - Arrays: Each element is converted with `String(value)` and joined with one space.
186
+ * - Other values: `String(value)` is inserted directly into the shell source.
187
+ *
188
+ * Raw interpolation allows trusted shell fragments such as pipes, redirects, and
189
+ * command separators. It is not safe for untrusted input. Wrap untrusted values
190
+ * with {@link bashEscape} before interpolating them.
168
191
  *
169
192
  * SH acts as both template tag and options setter: `SH.timeout = 5000; SH`cmd``
170
193
  *
@@ -176,8 +199,11 @@ const setHandler = {
176
199
  * const cmd = SH`echo ${'Hello'}`;
177
200
  * await cmd.run(); // Executes: echo Hello
178
201
  *
179
- * // Array interpolation
180
- * SH`ls ${['-la', '/']}`.run(); // ls '-la' '/'
202
+ * // Array interpolation is raw and joins with spaces
203
+ * SH`ls ${['-la', '/']}`.run(); // Executes: ls -la /
204
+ *
205
+ * // Quote untrusted values explicitly
206
+ * SH`printf '%s\n' ${bashEscape('semi; colon')}`.run();
181
207
  */
182
208
  const SH = new Proxy(function(pieces, ...args) {
183
209
  if (pieces.some((p) => p == undefined)) {
@@ -188,25 +214,14 @@ const SH = new Proxy(function(pieces, ...args) {
188
214
  let s;
189
215
 
190
216
  if (Array.isArray(args[i])) {
191
- // @ts-ignore
192
- s = args[i].map(/** @param {string} x */(x) => {
193
- // Trim every element
194
- let str = String(x).trim();
195
- str = str.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
196
- // If the string contains special characters, wrap in single quotes
197
- if (str.match(/[ "'$`(){}[\]]/)) {
198
- // Escape single quotes within the string
199
- return `'${str.replace(/'/g, `'\\''`)}'`;
200
- }
201
- return str;
202
- }).join(' ');
217
+ s = args[i].map((x) => String(x)).join(' ');
203
218
  } else {
204
219
  s = String(args[i]);
205
220
  }
206
221
  cmd += s + pieces[++i];
207
222
  }
208
223
  return new SHDispatch(cmd, defaultOptions);
209
- }, setHandler);
224
+ }, defaultOptionHandler);
210
225
 
211
226
  /**
212
227
  * Executes a callback in a new async context (fresh callstack).
@@ -227,12 +242,12 @@ const within = async (callback) => {
227
242
  /**
228
243
  * Reads entire stdin as UTF-8 string. Resolves to empty string if TTY (no pipe).
229
244
  *
230
- * @returns {Promise<string|undefined>} Stdin content or undefined if TTY.
245
+ * @returns {Promise<string>} Stdin content, or an empty string if TTY.
231
246
  * @example
232
247
  * const input = await readIn(); // Use in piped scripts
233
248
  */
234
249
  const readIn = async () => {
235
- if (process.stdin.isTTY) return;
250
+ if (process.stdin.isTTY) return '';
236
251
  let buf = '';
237
252
  process.stdin.setEncoding('utf8');
238
253
  for await (const chunk of process.stdin) {
@@ -301,59 +316,71 @@ const userIn = (prompt) => {
301
316
  };
302
317
  };
303
318
 
319
+ /**
320
+ * Checks whether a value is a generator-like retry delay source.
321
+ *
322
+ * @param {unknown} value - Value to inspect.
323
+ * @returns {boolean} True when value has a callable `next()` method.
324
+ */
325
+ const isDelayGenerator = (value) => value != null && typeof value == 'object' && typeof value.next == 'function';
326
+
304
327
  /**
305
328
  * Retries an async function up to N times with optional delays.
306
329
  *
307
- * Delay can be fixed ('1s'), generator (expBackoff()), or none.
330
+ * Delay can be fixed ('1s'), generator-like (expBackoff()), or none.
308
331
  *
309
- * @param {number} count - Number of attempts.
310
- * @param {string|number|ExpBackoffGenerator|Function} delayOrFn - Delay or callback if no separate fn.
311
- * @param {Function} [fn] - Callback to retry (if delayOrFn not function).
332
+ * @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.
312
335
  * @returns {Promise<any>} Successful result.
313
- * @throws {Error} Last error if all attempts fail.
336
+ * @throws {Error} If arguments are invalid, or the last callback error if all attempts fail.
314
337
  * @example
315
338
  * await retry(3, '1s', () => SH`curl http://unreliable`.run());
316
339
  * await retry(3, expBackoff(), () => flakyOp());
317
340
  */
318
- const retry = async (count, a, b) => {
319
- let callback;
341
+ const retry = async (count, delayOrCallback, callback) => {
342
+ if (!Number.isInteger(count) || count < 1) {
343
+ throw new Error(`Invalid retry count: "${count}". Expected a positive integer.`);
344
+ }
345
+
346
+ let retryCallback;
320
347
  let delayStatic = 0;
321
348
  let delayGen;
322
- // @ts-ignore
323
- if (typeof a == 'function') {
324
- callback = a;
349
+
350
+ if (typeof delayOrCallback == 'function') {
351
+ retryCallback = delayOrCallback;
325
352
  }
326
353
  else {
327
- if (typeof a == 'object') {
328
- delayGen = a;
354
+ if (typeof callback != 'function') {
355
+ throw new Error(`Invalid retry callback: expected a function.`);
356
+ }
357
+ retryCallback = callback;
358
+
359
+ if (isDelayGenerator(delayOrCallback)) {
360
+ delayGen = delayOrCallback;
361
+ }
362
+ else if (delayOrCallback != null && typeof delayOrCallback == 'object') {
363
+ throw new Error(`Invalid retry delay generator: expected an object with next().`);
329
364
  }
330
365
  else {
331
- delayStatic = parseDuration(a);
366
+ delayStatic = parseDuration(delayOrCallback);
332
367
  }
333
- assert(b);
334
- callback = b;
335
368
  }
336
- let lastErr;
337
- let attempt = 0;
338
- while (count-- > 0) {
339
- attempt++;
369
+
370
+ for (let attempt = 1; attempt <= count; attempt++) {
340
371
  try {
341
- return await callback();
372
+ return await retryCallback();
342
373
  }
343
374
  catch (err) {
344
- let delay = 0;
345
- if (delayStatic > 0)
346
- delay = delayStatic;
347
- // @ts-ignore
348
- if (delayGen) delay = delayGen.next().value;
349
- lastErr = err;
350
- if (count == 0)
351
- break;
352
- if (delay)
375
+ if (attempt == count) {
376
+ throw err;
377
+ }
378
+ const delay = delayGen ? delayGen.next().value : delayStatic;
379
+ if (delay) {
353
380
  await sleep(delay);
381
+ }
354
382
  }
355
383
  }
356
- throw lastErr;
357
384
  }
358
385
 
359
386
  /**
@@ -403,11 +430,34 @@ function* expBackoff(max = '60s', rand = '100ms') {
403
430
  }
404
431
  }
405
432
 
433
+ /**
434
+ * Matches negative number tokens that should be parsed as values, not options.
435
+ *
436
+ * Supports decimal and exponent forms such as `-1`, `-0.5`, `-.5`, and `-1e3`.
437
+ *
438
+ * @param {string} arg - CLI argument token.
439
+ * @returns {boolean} True when the token is a negative numeric value.
440
+ */
441
+ const isNegativeNumberArg = (arg) => /^-(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(arg);
442
+
443
+ /**
444
+ * Checks whether a token should be parsed as an option.
445
+ *
446
+ * Negative numeric values are intentionally excluded so calls like
447
+ * `parseArgs(['--n', '-1'])` parse `-1` as the value for `--n`.
448
+ *
449
+ * @param {string} arg - CLI argument token.
450
+ * @returns {boolean} True when the token should be parsed as an option.
451
+ */
452
+ const isOptionArg = (arg) => arg.startsWith('-') && !isNegativeNumberArg(arg);
453
+
406
454
  /**
407
455
  * Parses CLI arguments into an object.
408
456
  *
409
457
  * Supports --key value, -k value (no shorts grouped).
410
458
  * Bares go to _[]. Duplicates error. No = syntax.
459
+ * Negative number tokens such as -1, -0.5, and -1e3 are parsed as values or
460
+ * positionals, not options.
411
461
  *
412
462
  * Defaults to process.argv.slice(2).
413
463
  *
@@ -416,23 +466,26 @@ function* expBackoff(max = '60s', rand = '100ms') {
416
466
  * @throws {Error} On invalid/dupe args.
417
467
  * @example
418
468
  * parseArgs(['--port', '8080', 'file.txt']); // { port: '8080', _: ['file.txt'] }
469
+ * parseArgs(['--n', '-1']); // { n: '-1', _: [] }
419
470
  */
420
471
  const parseArgs = (args) => {
421
472
  if (!args) args = process.argv.slice(2);
422
473
  const result = { _: [] };
423
474
  const seenKeys = new Set();
424
475
  for (let i = 0; i < args.length; i++) {
425
- if (args[i].startsWith('--') || args[i].startsWith('-')) {
426
- if (args[i].startsWith('-') && !args[i].startsWith('--') && args[i].length > 2) {
427
- throw new Error(`Invalid argument: ${args[i]}. Use '--' for long options.`);
476
+ const arg = args[i];
477
+
478
+ if (isOptionArg(arg)) {
479
+ if (arg.startsWith('-') && !arg.startsWith('--') && arg.length > 2) {
480
+ throw new Error(`Invalid argument: ${arg}. Use '--' for long options.`);
428
481
  }
429
- const key = args[i].startsWith('--') ? args[i].substring(2) : args[i].substring(1);
482
+ const key = arg.startsWith('--') ? arg.substring(2) : arg.substring(1);
430
483
  if (seenKeys.has(key)) {
431
- throw new Error(`Duplicate argument: ${args[i]}`);
484
+ throw new Error(`Duplicate argument: ${arg}`);
432
485
  }
433
486
  seenKeys.add(key);
434
487
  let value;
435
- if (args[i + 1] && !args[i + 1].startsWith('-')) {
488
+ if (i + 1 < args.length && !isOptionArg(args[i + 1])) {
436
489
  value = args[i + 1];
437
490
  i++; // Skip next element as it is a value
438
491
  } else {
@@ -440,7 +493,7 @@ const parseArgs = (args) => {
440
493
  }
441
494
  result[key] = value;
442
495
  } else {
443
- result._.push(args[i]);
496
+ result._.push(arg);
444
497
  }
445
498
  }
446
499
  // @ts-ignore
package/lib/SHDispatch.js CHANGED
@@ -34,13 +34,23 @@ import SHExecute from './SHExecute.js';
34
34
  * - `shell`: `'bash'` (string/true → shell; false → no-shell `/usr/bin/env -S`)
35
35
  * - `stdio`: `['inherit', 'pipe', 'pipe']`
36
36
  * - `timeout`: `0` (no timeout; rolling on data)
37
- * - `maxBuffer`: `512000` (500 kb (per stream in SHExecute))
37
+ * - `maxBuffer`: `512000` (500 kb per stream in SHExecute)
38
38
  *
39
39
  * Prefix (`.options(undefined, prefix)`) only for shell mode.
40
40
  *
41
+ * @typedef {Object} SHOptions
42
+ * @property {string} cwd - Working directory for spawned commands.
43
+ * @property {NodeJS.ProcessEnv} env - Environment for spawned commands.
44
+ * @property {string|boolean} shell - Shell executable, true for bash, or false for no-shell mode.
45
+ * @property {StdioOptions} stdio - Stdio config passed to child_process.
46
+ * @property {number|string} timeout - Rolling timeout in ms or duration string; 0 disables timeout.
47
+ * @property {number} [maxBuffer] - Maximum buffered bytes per stdout/stderr stream.
48
+ * @property {boolean} [detached] - Run process detached and resolve early.
41
49
  * @example { timeout: '5s', stdio: 'inherit', shell: false, maxBuffer: 10 * 1024}
42
50
  */
43
- const SHOptions = {
51
+
52
+ /** @type {SHOptions} */
53
+ const defaultSHOptions = {
44
54
  cwd: process.cwd(),
45
55
  env: process.env,
46
56
  shell: 'bash',
@@ -53,9 +63,9 @@ const SHOptions = {
53
63
  *
54
64
  * Only copies defined keys.
55
65
  *
56
- * @param {typeof SHOptions} predefined - Base options.
57
- * @param {Partial<typeof SHOptions>} options - Overrides.
58
- * @returns {typeof SHOptions} Merged options.
66
+ * @param {SHOptions} predefined - Base options.
67
+ * @param {Partial<SHOptions>} options - Overrides.
68
+ * @returns {SHOptions} Merged options.
59
69
  */
60
70
  const mergeOptions = (predefined, options) => {
61
71
  const mergedObj = { ...predefined };
@@ -83,13 +93,14 @@ const mergeOptions = (predefined, options) => {
83
93
  class SHDispatch {
84
94
  #prefix = '';
85
95
  #cmd = '';
86
- #options = SHOptions;
96
+ /** @type {SHOptions} */
97
+ #options = { ...defaultSHOptions };
87
98
  /** @type {SHExecute | null} */
88
99
  #proc = null;
89
100
 
90
101
  /**
91
102
  * @param {string} cmd - Command string.
92
- * @param {Partial<typeof SHOptions>} [options] - Initial options.
103
+ * @param {Partial<SHOptions>} [options] - Initial options.
93
104
  * @param {string} [prefix] - Shell prefix (e.g., 'set -euo pipefail').
94
105
  * @throws {Error} Invalid/empty cmd.
95
106
  */
@@ -102,11 +113,13 @@ class SHDispatch {
102
113
  }
103
114
 
104
115
  /**
105
- * Updates options/prefix; resets to defaults + user overrides (non-cumulative).
116
+ * Updates options/prefix by merging into the dispatch instance's current options.
106
117
  *
107
- * Strings for `stdio` array fill.
118
+ * This preserves global `SH.*` defaults captured when the command was created
119
+ * and only overrides options explicitly supplied for this command. Strings for
120
+ * `stdio` are expanded to `[value, value, value]`.
108
121
  *
109
- * @param {Partial<typeof SHOptions>} [options] - New options.
122
+ * @param {Partial<SHOptions>} [options] - New options.
110
123
  * @param {string} [prefix] - New prefix.
111
124
  * @returns {SHDispatch} Self for chaining.
112
125
  */
@@ -117,12 +130,13 @@ class SHDispatch {
117
130
  if (!options) {
118
131
  return this;
119
132
  }
120
- if (options.stdio && typeof options.stdio === 'string') {
133
+ const nextOptions = { ...options };
134
+ if (nextOptions.stdio && typeof nextOptions.stdio === 'string') {
121
135
  // convert stdio to array
122
- const io = options.stdio;
123
- options.stdio = Array(3).fill(io);
136
+ const io = nextOptions.stdio;
137
+ nextOptions.stdio = Array(3).fill(io);
124
138
  }
125
- this.#options = mergeOptions(SHOptions, options);
139
+ this.#options = mergeOptions(this.#options, nextOptions);
126
140
  return this;
127
141
  }
128
142
 
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.29",
5
+ "version": "1.1.31",
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",
@@ -49,4 +49,4 @@
49
49
  "process-promise",
50
50
  "process-output"
51
51
  ]
52
- }
52
+ }
package/types/SH.d.ts CHANGED
@@ -1,5 +1,13 @@
1
+ /**
2
+ * Parsed command-line arguments.
3
+ *
4
+ * Named options map to their string value, or `true` when no value is supplied.
5
+ * Bare positional arguments are collected in `_`.
6
+ */
1
7
  export type ArgsObject = {
2
- [x: string]: string;
8
+ _: string[];
9
+ } & {
10
+ [x: string]: string | true | string[];
3
11
  };
4
12
  export type RejectCallback = Function;
5
13
  export type ResolveCallback = Function;
@@ -14,15 +22,22 @@ export type AbortableInput = {
14
22
  */
15
23
  abort: () => void;
16
24
  };
17
- export type ExpBackoffGenerator = Object;
25
+ /**
26
+ * Generator-like object that yields retry delay durations.
27
+ */
28
+ export type ExpBackoffGenerator = Iterator<number | string>;
18
29
  /**
19
30
  * Template tag for building and executing shell commands.
20
31
  *
21
32
  * Returns an {@link SHDispatch} instance for configuration (.options()) and execution (.run(), .runSync()).
22
33
  *
23
- * Interpolation rules:
24
- * - Arrays: Elements trimmed, newlines/tabs escaped, shell-special chars single-quoted with '\'' escape.
25
- * - Other values: String(value) inserted as-is (NOT auto-escaped; use {@link bashEscape} for safety).
34
+ * Interpolation rules are raw-by-default:
35
+ * - Arrays: Each element is converted with `String(value)` and joined with one space.
36
+ * - Other values: `String(value)` is inserted directly into the shell source.
37
+ *
38
+ * Raw interpolation allows trusted shell fragments such as pipes, redirects, and
39
+ * command separators. It is not safe for untrusted input. Wrap untrusted values
40
+ * with {@link bashEscape} before interpolating them.
26
41
  *
27
42
  * SH acts as both template tag and options setter: `SH.timeout = 5000; SH`cmd``
28
43
  *
@@ -34,8 +49,11 @@ export type ExpBackoffGenerator = Object;
34
49
  * const cmd = SH`echo ${'Hello'}`;
35
50
  * await cmd.run(); // Executes: echo Hello
36
51
  *
37
- * // Array interpolation
38
- * SH`ls ${['-la', '/']}`.run(); // ls '-la' '/'
52
+ * // Array interpolation is raw and joins with spaces
53
+ * SH`ls ${['-la', '/']}`.run(); // Executes: ls -la /
54
+ *
55
+ * // Quote untrusted values explicitly
56
+ * SH`printf '%s\n' ${bashEscape('semi; colon')}`.run();
39
57
  */
40
58
  export const SH: any;
41
59
  /**
@@ -58,26 +76,26 @@ export function sleep(duration: string | number): Promise<void>;
58
76
  /**
59
77
  * Retries an async function up to N times with optional delays.
60
78
  *
61
- * Delay can be fixed ('1s'), generator (expBackoff()), or none.
79
+ * Delay can be fixed ('1s'), generator-like (expBackoff()), or none.
62
80
  *
63
- * @param {number} count - Number of attempts.
64
- * @param {string|number|ExpBackoffGenerator|Function} delayOrFn - Delay or callback if no separate fn.
65
- * @param {Function} [fn] - Callback to retry (if delayOrFn not function).
81
+ * @param {number} count - Positive integer number of attempts.
82
+ * @param {string|number|ExpBackoffGenerator|Function} delayOrCallback - Delay, delay generator, or callback if no separate callback is supplied.
83
+ * @param {Function} [callback] - Callback to retry when a delay is supplied.
66
84
  * @returns {Promise<any>} Successful result.
67
- * @throws {Error} Last error if all attempts fail.
85
+ * @throws {Error} If arguments are invalid, or the last callback error if all attempts fail.
68
86
  * @example
69
87
  * await retry(3, '1s', () => SH`curl http://unreliable`.run());
70
88
  * await retry(3, expBackoff(), () => flakyOp());
71
89
  */
72
- export function retry(count: number, a: any, b: any): Promise<any>;
90
+ export function retry(count: number, delayOrCallback: string | number | ExpBackoffGenerator | Function, callback?: Function): Promise<any>;
73
91
  /**
74
92
  * Reads entire stdin as UTF-8 string. Resolves to empty string if TTY (no pipe).
75
93
  *
76
- * @returns {Promise<string|undefined>} Stdin content or undefined if TTY.
94
+ * @returns {Promise<string>} Stdin content, or an empty string if TTY.
77
95
  * @example
78
96
  * const input = await readIn(); // Use in piped scripts
79
97
  */
80
- export function readIn(): Promise<string | undefined>;
98
+ export function readIn(): Promise<string>;
81
99
  /**
82
100
  * Prompts user for input on stdin with custom prompt.
83
101
  *
@@ -121,6 +139,8 @@ export function expBackoff(max?: string, rand?: string): Generator<number, void,
121
139
  *
122
140
  * Supports --key value, -k value (no shorts grouped).
123
141
  * Bares go to _[]. Duplicates error. No = syntax.
142
+ * Negative number tokens such as -1, -0.5, and -1e3 are parsed as values or
143
+ * positionals, not options.
124
144
  *
125
145
  * Defaults to process.argv.slice(2).
126
146
  *
@@ -129,13 +149,16 @@ export function expBackoff(max?: string, rand?: string): Generator<number, void,
129
149
  * @throws {Error} On invalid/dupe args.
130
150
  * @example
131
151
  * parseArgs(['--port', '8080', 'file.txt']); // { port: '8080', _: ['file.txt'] }
152
+ * parseArgs(['--n', '-1']); // { n: '-1', _: [] }
132
153
  */
133
154
  export function parseArgs(args?: string[]): ArgsObject;
134
155
  /**
135
- * @typedef {Object.<string, string>} ArgsObject
136
- * @property {string[]} _ - Array of strings, unnamed parameters
137
- * @property {string} [key: string] - Any string key maps to a string value
138
- * @description Parsed parameters result.
156
+ * Parsed command-line arguments.
157
+ *
158
+ * Named options map to their string value, or `true` when no value is supplied.
159
+ * Bare positional arguments are collected in `_`.
160
+ *
161
+ * @typedef {{_: string[]} & Object.<string, string|true|string[]>} ArgsObject
139
162
  */
140
163
  /**
141
164
  * @typedef {Function} RejectCallback
@@ -154,9 +177,9 @@ export function parseArgs(args?: string[]): ArgsObject;
154
177
  * @property {() => void} abort - Function to abort input collection.
155
178
  */
156
179
  /**
157
- * @typedef {Object} ExpBackoffGenerator
158
- * @generator
159
- * @yields {number} Backoff time in ms.
180
+ * Generator-like object that yields retry delay durations.
181
+ *
182
+ * @typedef {Iterator<number|string>} ExpBackoffGenerator
160
183
  */
161
184
  /**
162
185
  * Utility to determine the JavaScript type of a value.
@@ -180,12 +203,18 @@ import Test from './Test.js';
180
203
  import assert from 'node:assert';
181
204
  import AsyncTracker from './AsyncTracker.js';
182
205
  /**
183
- * Escapes a string for safe use as a bash command-line argument.
206
+ * Quotes a value as one POSIX shell argument.
184
207
  *
185
- * Handles quotes, backticks, $, newlines, etc.
208
+ * The `SH` template tag interpolates values as raw shell source by default. Use
209
+ * this helper when a JavaScript value must be passed as data instead of shell
210
+ * syntax. The returned string is single-quoted and preserves the exact string
211
+ * value, including leading/trailing whitespace, empty strings, quotes,
212
+ * semicolons, glob characters, tabs, and newlines.
186
213
  *
187
- * @param {string} x - Input string to escape.
188
- * @returns {string} Bash-escaped string.
214
+ * @param {unknown} x - Value to quote as a single shell argument.
215
+ * @returns {string} POSIX-shell-quoted argument string.
216
+ * @example
217
+ * await SH`printf '%s\n' ${bashEscape(userInput)}`.run();
189
218
  */
190
- export function bashEscape(x: string): string;
219
+ export function bashEscape(x: unknown): string;
191
220
  export { Test, assert, AsyncTracker };
@@ -27,6 +27,49 @@ export type SpawnSyncResponse = {
27
27
  };
28
28
  export type StdioOption = ("pipe" | "ignore" | "inherit" | number);
29
29
  export type StdioOptions = Array<StdioOption> | StdioOption;
30
+ /**
31
+ * Core options for SH/SHDispatch.
32
+ *
33
+ * Defaults (merged from global SH):
34
+ * - `cwd`: `process.cwd()`
35
+ * - `env`: `process.env`
36
+ * - `shell`: `'bash'` (string/true → shell; false → no-shell `/usr/bin/env -S`)
37
+ * - `stdio`: `['inherit', 'pipe', 'pipe']`
38
+ * - `timeout`: `0` (no timeout; rolling on data)
39
+ * - `maxBuffer`: `512000` (500 kb per stream in SHExecute)
40
+ *
41
+ * Prefix (`.options(undefined, prefix)`) only for shell mode.
42
+ */
43
+ export type SHOptions = {
44
+ /**
45
+ * - Working directory for spawned commands.
46
+ */
47
+ cwd: string;
48
+ /**
49
+ * - Environment for spawned commands.
50
+ */
51
+ env: NodeJS.ProcessEnv;
52
+ /**
53
+ * - Shell executable, true for bash, or false for no-shell mode.
54
+ */
55
+ shell: string | boolean;
56
+ /**
57
+ * - Stdio config passed to child_process.
58
+ */
59
+ stdio: StdioOptions;
60
+ /**
61
+ * - Rolling timeout in ms or duration string; 0 disables timeout.
62
+ */
63
+ timeout: number | string;
64
+ /**
65
+ * - Maximum buffered bytes per stdout/stderr stream.
66
+ */
67
+ maxBuffer?: number | undefined;
68
+ /**
69
+ * - Run process detached and resolve early.
70
+ */
71
+ detached?: boolean | undefined;
72
+ };
30
73
  /**
31
74
  * High-level command dispatcher.
32
75
  *
@@ -41,21 +84,23 @@ export type StdioOptions = Array<StdioOption> | StdioOption;
41
84
  declare class SHDispatch {
42
85
  /**
43
86
  * @param {string} cmd - Command string.
44
- * @param {Partial<typeof SHOptions>} [options] - Initial options.
87
+ * @param {Partial<SHOptions>} [options] - Initial options.
45
88
  * @param {string} [prefix] - Shell prefix (e.g., 'set -euo pipefail').
46
89
  * @throws {Error} Invalid/empty cmd.
47
90
  */
48
- constructor(cmd: string, options?: Partial<typeof SHOptions>, prefix?: string);
91
+ constructor(cmd: string, options?: Partial<SHOptions>, prefix?: string);
49
92
  /**
50
- * Updates options/prefix; resets to defaults + user overrides (non-cumulative).
93
+ * Updates options/prefix by merging into the dispatch instance's current options.
51
94
  *
52
- * Strings for `stdio` array fill.
95
+ * This preserves global `SH.*` defaults captured when the command was created
96
+ * and only overrides options explicitly supplied for this command. Strings for
97
+ * `stdio` are expanded to `[value, value, value]`.
53
98
  *
54
- * @param {Partial<typeof SHOptions>} [options] - New options.
99
+ * @param {Partial<SHOptions>} [options] - New options.
55
100
  * @param {string} [prefix] - New prefix.
56
101
  * @returns {SHDispatch} Self for chaining.
57
102
  */
58
- options(options?: Partial<typeof SHOptions>, prefix?: string): SHDispatch;
103
+ options(options?: Partial<SHOptions>, prefix?: string): SHDispatch;
59
104
  /**
60
105
  * Async run: Captures stdout; rejects on error/timeout.
61
106
  *
@@ -81,10 +126,3 @@ declare class SHDispatch {
81
126
  kill(signal?: number | string): Promise<number[]>;
82
127
  #private;
83
128
  }
84
- declare namespace SHOptions {
85
- let cwd: string;
86
- let env: NodeJS.ProcessEnv;
87
- let shell: string;
88
- let stdio: string[];
89
- let timeout: number;
90
- }