@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 +174 -149
- package/lib/SHDispatch.js +29 -26
- package/lib/SHExecute.js +300 -235
- package/lib/internal.js +179 -0
- package/package.json +3 -3
- package/types/SH.d.ts +74 -34
- package/types/SHDispatch.d.ts +14 -30
- package/types/SHExecute.d.ts +41 -13
- package/types/internal.d.ts +91 -0
- package/TODO.md +0 -8
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
|
|
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
|
|
52
|
+
* Generator that yields retry delay durations in milliseconds.
|
|
66
53
|
*
|
|
67
|
-
* @typedef {
|
|
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
|
-
*
|
|
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 = (
|
|
80
|
-
if (
|
|
81
|
-
const type = Object.prototype.toString.call(
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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|
|
|
334
|
-
* @param {
|
|
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
|
-
|
|
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;
|
|
37
|
-
* - `maxBuffer`: `512000` (500
|
|
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 -
|
|
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 = { ...
|
|
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('
|
|
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
|
-
|
|
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
|
|