@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 +32 -3
- package/TODO.md +2 -12
- package/lib/SH.js +151 -98
- package/lib/SHDispatch.js +28 -14
- package/package.json +2 -2
- package/types/SH.d.ts +56 -27
- package/types/SHDispatch.d.ts +51 -13
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
|
-
-
|
|
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(
|
|
172
|
+
| `bashEscape(value)` | Quote one value as a POSIX shell argument for safe interpolation |
|
|
144
173
|
|
|
145
|
-
See [types/
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* @
|
|
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
|
|
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
|
|
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 (
|
|
109
|
+
if (!Number.isFinite(d) || d < 0)
|
|
107
110
|
throw new Error(`Invalid duration: "${d}".`);
|
|
108
111
|
return d;
|
|
109
112
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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(`
|
|
120
|
+
throw new Error(`Invalid duration type: "${jsType(d)}".`);
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
/**
|
|
120
|
-
*
|
|
124
|
+
* Quotes a value as one POSIX shell argument.
|
|
121
125
|
*
|
|
122
|
-
*
|
|
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 {
|
|
125
|
-
* @returns {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
|
-
/**
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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:
|
|
167
|
-
* - Other values: String(value)
|
|
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
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
|
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 -
|
|
310
|
-
* @param {string|number|ExpBackoffGenerator|Function}
|
|
311
|
-
* @param {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}
|
|
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,
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
if (typeof
|
|
324
|
-
|
|
349
|
+
|
|
350
|
+
if (typeof delayOrCallback == 'function') {
|
|
351
|
+
retryCallback = delayOrCallback;
|
|
325
352
|
}
|
|
326
353
|
else {
|
|
327
|
-
if (typeof
|
|
328
|
-
|
|
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(
|
|
366
|
+
delayStatic = parseDuration(delayOrCallback);
|
|
332
367
|
}
|
|
333
|
-
assert(b);
|
|
334
|
-
callback = b;
|
|
335
368
|
}
|
|
336
|
-
|
|
337
|
-
let attempt =
|
|
338
|
-
while (count-- > 0) {
|
|
339
|
-
attempt++;
|
|
369
|
+
|
|
370
|
+
for (let attempt = 1; attempt <= count; attempt++) {
|
|
340
371
|
try {
|
|
341
|
-
return await
|
|
372
|
+
return await retryCallback();
|
|
342
373
|
}
|
|
343
374
|
catch (err) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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 =
|
|
482
|
+
const key = arg.startsWith('--') ? arg.substring(2) : arg.substring(1);
|
|
430
483
|
if (seenKeys.has(key)) {
|
|
431
|
-
throw new Error(`Duplicate argument: ${
|
|
484
|
+
throw new Error(`Duplicate argument: ${arg}`);
|
|
432
485
|
}
|
|
433
486
|
seenKeys.add(key);
|
|
434
487
|
let value;
|
|
435
|
-
if (
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 {
|
|
57
|
-
* @param {Partial<
|
|
58
|
-
* @returns {
|
|
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
|
-
|
|
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<
|
|
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
|
|
116
|
+
* Updates options/prefix by merging into the dispatch instance's current options.
|
|
106
117
|
*
|
|
107
|
-
*
|
|
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<
|
|
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
|
-
|
|
133
|
+
const nextOptions = { ...options };
|
|
134
|
+
if (nextOptions.stdio && typeof nextOptions.stdio === 'string') {
|
|
121
135
|
// convert stdio to array
|
|
122
|
-
const io =
|
|
123
|
-
|
|
136
|
+
const io = nextOptions.stdio;
|
|
137
|
+
nextOptions.stdio = Array(3).fill(io);
|
|
124
138
|
}
|
|
125
|
-
this.#options = mergeOptions(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
25
|
-
* - Other values: String(value)
|
|
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
|
|
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 -
|
|
64
|
-
* @param {string|number|ExpBackoffGenerator|Function}
|
|
65
|
-
* @param {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}
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* @
|
|
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
|
-
*
|
|
206
|
+
* Quotes a value as one POSIX shell argument.
|
|
184
207
|
*
|
|
185
|
-
*
|
|
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 {
|
|
188
|
-
* @returns {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:
|
|
219
|
+
export function bashEscape(x: unknown): string;
|
|
191
220
|
export { Test, assert, AsyncTracker };
|
package/types/SHDispatch.d.ts
CHANGED
|
@@ -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<
|
|
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<
|
|
91
|
+
constructor(cmd: string, options?: Partial<SHOptions>, prefix?: string);
|
|
49
92
|
/**
|
|
50
|
-
* Updates options/prefix
|
|
93
|
+
* Updates options/prefix by merging into the dispatch instance's current options.
|
|
51
94
|
*
|
|
52
|
-
*
|
|
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<
|
|
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<
|
|
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
|
-
}
|