@j-o-r/sh 1.1.29 → 1.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/SH.js CHANGED
@@ -11,16 +11,16 @@
11
11
  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
- //
14
+ //
15
15
  //
16
16
  // Original Source: zx
17
17
  // Link to Original Source: https://github.com/google/zx
18
- // Reason for Using This Code:
19
- // The core functionality of this code is highly beneficial. However, certain parts of the original code
20
- // were overwriting the global namespace with core libraries and variables. This was causing conflicts with
18
+ // Reason for Using This Code:
19
+ // The core functionality of this code is highly beneficial. However, certain parts of the original code
20
+ // were overwriting the global namespace with core libraries and variables. This was causing conflicts with
21
21
  // other packages (for instance, fetch) and introducing unexpected elements into my code base.
22
22
  // The main $/SH method is all there is left, with barebone Promises and readable code.
23
- // Changes Made:
23
+ // Changes Made:
24
24
  // - The code has been or is being reformatted to comply with ES2020 standards.
25
25
  // - Some methods were added and existing ones were modified or deleted to enhance usability.
26
26
  // - The namespace has been changed from '$' to 'SH'.
@@ -29,28 +29,17 @@
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'
34
-
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.
40
- */
32
+ import Test from './Test.js';
33
+ import AsyncTracker from './AsyncTracker.js';
34
+ import { defaultOptions, defaultOptionKeys, clearCwdOverride, parseDuration } from './internal.js';
41
35
 
42
36
  /**
43
- * @typedef {Function} RejectCallback
44
- * @param {Error} error - The error object passed to the callback.
45
- */
46
-
47
- /**
48
- * @typedef {Function} ResolveCallback
49
- * @param {any} [param] - Optional callback any value
50
- */
51
-
52
- /**
53
- * @typedef {import('./SHDispatch.js').SHOptions} SHOptions
37
+ * Parsed command-line arguments.
38
+ *
39
+ * Named options map to their string value, or `true` when no value is supplied.
40
+ * Bare positional arguments are collected in `_`.
41
+ *
42
+ * @typedef {{_: string[]} & Object.<string, string|true>} ArgsObject
54
43
  */
55
44
 
56
45
  /**
@@ -60,124 +49,126 @@ import AsyncTracker from './AsyncTracker.js'
60
49
  */
61
50
 
62
51
  /**
63
- * @typedef {Object} ExpBackoffGenerator
64
- * @generator
65
- * @yields {number} Backoff time in ms.
52
+ * Generator that yields retry delay durations in milliseconds.
53
+ *
54
+ * @typedef {Generator<number, void, unknown>} ExpBackoffGenerator
66
55
  */
67
56
 
68
57
  /**
69
58
  * Utility to determine the JavaScript type of a value.
70
59
  *
71
- * @param {any} fn - Any value to inspect.
60
+ * Uses `Object.prototype.toString`, so `jsType(null)` returns `'Null'`;
61
+ * `undefined` is special-cased to `'undefined'`. Plain objects report their
62
+ * constructor name, or `'Object'` when there is none (null-prototype objects).
63
+ *
64
+ * @param {unknown} value - Any value to inspect.
72
65
  * @returns {string} The "real" object type name (e.g., 'Array', 'Promise') or primitive typeof.
73
66
  * @example
74
67
  * jsType([]); // 'Array'
75
68
  * jsType(Promise.resolve()); // 'Promise'
69
+ * jsType(null); // 'Null'
70
+ * jsType(undefined); // 'undefined'
76
71
  */
77
- const jsType = (fn) => {
78
- if (fn === undefined) return 'undefined';
79
- const type = Object.prototype.toString.call(fn).slice(8, -1);
80
- return type === 'Object' ? fn.constructor.name : type;
72
+ const jsType = (value) => {
73
+ if (value === undefined) return 'undefined';
74
+ const type = Object.prototype.toString.call(value).slice(8, -1);
75
+ // Null-prototype objects have no constructor; fall back to 'Object'.
76
+ return type === 'Object' ? (/** @type {any} */ (value).constructor?.name ?? 'Object') : type;
81
77
  };
82
78
 
83
79
  /**
84
- * Checks if an object has a specific own property (code-safe).
80
+ * Checks if an object has a specific own property. Safe for null-prototype
81
+ * objects and objects shadowing `hasOwnProperty`.
85
82
  *
86
83
  * @param {any} o - Object to examine.
87
84
  * @param {string} p - Property name to check.
88
85
  * @returns {boolean} True if the object has the own property.
89
86
  */
90
87
  const hasProp = (o, p) => {
91
- if (o == null) return false;
92
- return Object.prototype.hasOwnProperty.call(o, p);
88
+ if (o == null) return false;
89
+ return Object.hasOwn(o, p);
93
90
  };
94
91
 
95
92
  /**
96
- * Parses a human-readable duration string or number into milliseconds.
93
+ * Quotes a value as one POSIX shell argument.
97
94
  *
98
- * Supports: '5s' (5000ms), '100ms' (100ms), plain numbers (ms).
95
+ * The `SH` template tag interpolates values as raw shell source by default. Use
96
+ * this helper when a JavaScript value must be passed as data instead of shell
97
+ * syntax. The returned string is single-quoted and preserves the exact string
98
+ * value, including leading/trailing whitespace, empty strings, quotes,
99
+ * semicolons, glob characters, tabs, and newlines.
99
100
  *
100
- * @param {number|string} d - Duration as number (ms) or string ('5s', '100ms').
101
- * @returns {number} Duration in milliseconds.
102
- * @throws {Error} If invalid duration format.
101
+ * @param {unknown} x - Value to quote as a single shell argument.
102
+ * @returns {string} POSIX-shell-quoted argument string.
103
+ * @example
104
+ * await SH`printf '%s\n' ${bashEscape(userInput)}`.run();
103
105
  */
104
- const parseDuration = (d) => {
105
- if (typeof d == 'number') {
106
- if (isNaN(d) || d < 0)
107
- throw new Error(`Invalid duration: "${d}".`);
108
- return d;
109
- }
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);
115
- }
116
- throw new Error(`Unknown duration: "${d}".`);
117
- }
106
+ const bashEscape = (x) => `'${String(x).replace(/'/g, `'\\''`)}'`;
118
107
 
119
108
  /**
120
- * Escapes a string for safe use as a bash command-line argument.
109
+ * Proxy traps for the SH template tag and global option defaults.
121
110
  *
122
- * Handles quotes, backticks, $, newlines, etc.
123
- *
124
- * @param {string} x - Input string to escape.
125
- * @returns {string} Bash-escaped string.
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`).
126
118
  */
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
- }
142
-
143
- /** Default options applied to all SH commands unless overridden. */
144
- const defaultOptions = {
145
- cwd: process.cwd(),
146
- env: process.env,
147
- shell: 'bash',
148
- stdio: ['inherit', 'pipe', 'pipe'],
149
- timeout: 0, // when 0 there is no timeout
119
+ const defaultOptionHandler = {
120
+ get(target, prop, receiver) {
121
+ if (defaultOptionKeys.has(prop)) {
122
+ return defaultOptions[prop];
123
+ }
124
+ return Reflect.get(target, prop, receiver);
125
+ },
126
+ set(target, prop, value) {
127
+ if (!defaultOptionKeys.has(prop)) {
128
+ throw new TypeError(`Unknown SH option: "${String(prop)}". Known options: ${[...defaultOptionKeys].join(', ')}.`);
129
+ }
130
+ defaultOptions[prop] = value;
131
+ return true;
132
+ }
150
133
  };
151
134
 
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
- }
158
- }
159
-
160
135
  /**
161
136
  * Template tag for building and executing shell commands.
162
137
  *
163
138
  * Returns an {@link SHDispatch} instance for configuration (.options()) and execution (.run(), .runSync()).
164
139
  *
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).
140
+ * Interpolation rules are raw-by-default:
141
+ * - Arrays: Each element is converted with `String(value)` and joined with one space.
142
+ * - Other values: `String(value)` is inserted directly into the shell source.
168
143
  *
169
- * SH acts as both template tag and options setter: `SH.timeout = 5000; SH`cmd``
144
+ * Raw interpolation allows trusted shell fragments such as pipes, redirects, and
145
+ * command separators. It is not safe for untrusted input. Wrap untrusted values
146
+ * with {@link bashEscape} before interpolating them.
147
+ *
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).
170
154
  *
171
155
  * @param {TemplateStringsArray} pieces - String literals from template.
172
- * @param {...unknown[]} args - Values to interpolate.
156
+ * @param {...unknown} args - Values to interpolate.
173
157
  * @returns {SHDispatch} Command dispatcher.
174
158
  * @throws {Error} If pieces contain undefined.
175
159
  * @example
176
160
  * const cmd = SH`echo ${'Hello'}`;
177
161
  * await cmd.run(); // Executes: echo Hello
178
162
  *
179
- * // Array interpolation
180
- * SH`ls ${['-la', '/']}`.run(); // ls '-la' '/'
163
+ * // Array interpolation is raw and joins with spaces
164
+ * SH`ls ${['-la', '/']}`.run(); // Executes: ls -la /
165
+ *
166
+ * // Quote untrusted values explicitly
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();
181
172
  */
182
173
  const SH = new Proxy(function(pieces, ...args) {
183
174
  if (pieces.some((p) => p == undefined)) {
@@ -186,32 +177,25 @@ const SH = new Proxy(function(pieces, ...args) {
186
177
  let cmd = pieces[0], i = 0;
187
178
  while (i < args.length) {
188
179
  let s;
189
-
180
+
190
181
  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(' ');
182
+ s = args[i].map((x) => String(x)).join(' ');
203
183
  } else {
204
184
  s = String(args[i]);
205
185
  }
206
186
  cmd += s + pieces[++i];
207
187
  }
208
188
  return new SHDispatch(cmd, defaultOptions);
209
- }, setHandler);
189
+ }, defaultOptionHandler);
210
190
 
211
191
  /**
212
- * Executes a callback in a new async context (fresh callstack).
192
+ * Awaits `callback()` and returns its result.
213
193
  *
214
- * Useful for parallel operations without nesting.
194
+ * Despite the historical name, this is currently a thin wrapper: no new async
195
+ * context or fresh callstack is created — the callback runs in the current
196
+ * one. It is kept as an intent marker for grouping async work (and for API
197
+ * compatibility); real isolation via AsyncLocalStorage is a pending decision
198
+ * (see the project TODO, decision D2).
215
199
  *
216
200
  * @param {() => Promise<any>} callback - Async function to execute.
217
201
  * @returns {Promise<any>} Result of callback.
@@ -222,29 +206,51 @@ const SH = new Proxy(function(pieces, ...args) {
222
206
  */
223
207
  const within = async (callback) => {
224
208
  return await callback();
225
- }
209
+ };
226
210
 
227
211
  /**
228
212
  * Reads entire stdin as UTF-8 string. Resolves to empty string if TTY (no pipe).
229
213
  *
230
- * @returns {Promise<string|undefined>} Stdin content or undefined if TTY.
214
+ * Side effect: permanently sets the `process.stdin` encoding to utf8 and
215
+ * switches it to flowing mode.
216
+ *
217
+ * @returns {Promise<string>} Stdin content, or an empty string if TTY.
231
218
  * @example
232
219
  * const input = await readIn(); // Use in piped scripts
233
220
  */
234
221
  const readIn = async () => {
235
- if (process.stdin.isTTY) return;
222
+ if (process.stdin.isTTY) return '';
236
223
  let buf = '';
237
224
  process.stdin.setEncoding('utf8');
238
225
  for await (const chunk of process.stdin) {
239
226
  buf += chunk;
240
227
  }
241
228
  return buf;
242
- }
229
+ };
230
+
231
+ /**
232
+ * Minimum stdin chunk length (containing a newline) that {@link userIn}
233
+ * treats as a paste instead of typed input.
234
+ */
235
+ const PASTE_MIN_CHUNK = 4;
236
+
237
+ /**
238
+ * Debounce delay after the last 'line' event or paste before {@link userIn}
239
+ * auto-resolves with the accumulated input.
240
+ */
241
+ const SUBMIT_DEBOUNCE_MS = 50;
243
242
 
244
243
  /**
245
244
  * Prompts user for input on stdin with custom prompt.
246
245
  *
247
- * Supports aborting input collection.
246
+ * Submit heuristics (non-obvious): each 'line' event is accumulated into a
247
+ * multi-line buffer and (re)starts a debounce timer
248
+ * ({@link SUBMIT_DEBOUNCE_MS}); when the timer fires without further input,
249
+ * the promise auto-resolves with the accumulated text plus any partially
250
+ * typed readline line. A paste — a stdin chunk longer than
251
+ * {@link PASTE_MIN_CHUNK} chars containing a newline — (re)starts the same
252
+ * timer, so pasted multi-line text resolves as one input. `abort()` resolves
253
+ * the promise with `undefined`.
248
254
  *
249
255
  * @param {string} prompt - Prompt text to display.
250
256
  * @returns {AbortableInput} Object with input Promise and abort function.
@@ -254,107 +260,122 @@ const readIn = async () => {
254
260
  * // abort(); // Cancel anytime
255
261
  */
256
262
  const userIn = (prompt) => {
257
- let resolvePromise;
258
- const input = new Promise((resolve) => { resolvePromise = resolve; });
259
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
260
- let buffer = '';
261
- let timer;
262
- /** @param {string} chunk */
263
- const onData = (chunk) => {
264
- if (chunk.length > 4 && chunk.includes('\n')) {
265
- clearTimeout(timer);
266
- timer = setTimeout(resolveFunc, 50);
267
- }
268
- };
269
-
270
- const resolveFunc = () => {
271
- let fullText = buffer;
272
- if (rl.line) fullText += rl.line;
273
- cleanup();
274
- resolvePromise(fullText);
275
- };
276
-
277
- const cleanup = () => {
278
- process.stdin.removeListener('data', onData);
279
- // @ts-ignore
280
- rl.removeAllListeners('line');
281
- clearTimeout(timer);
282
- rl.close();
283
- };
284
- // @ts-ignore
285
- rl.on('line', (line) => {
286
- buffer += line + '\n';
287
- clearTimeout(timer);
288
- timer = setTimeout(resolveFunc, 50);
289
- });
290
- process.stdin.on('data', onData);
291
-
292
- rl.setPrompt(prompt);
293
- rl.prompt();
294
-
295
- return {
296
- input,
297
- abort: () => {
298
- cleanup();
299
- resolvePromise();
300
- }
301
- };
263
+ let resolvePromise;
264
+ const input = new Promise((resolve) => { resolvePromise = resolve; });
265
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
266
+ let buffer = '';
267
+ let timer;
268
+ /** @param {string} chunk */
269
+ const onData = (chunk) => {
270
+ if (chunk.length > PASTE_MIN_CHUNK && chunk.includes('\n')) {
271
+ clearTimeout(timer);
272
+ timer = setTimeout(resolveFunc, SUBMIT_DEBOUNCE_MS);
273
+ }
274
+ };
275
+
276
+ const resolveFunc = () => {
277
+ let fullText = buffer;
278
+ if (rl.line) fullText += rl.line;
279
+ cleanup();
280
+ resolvePromise(fullText);
281
+ };
282
+
283
+ const cleanup = () => {
284
+ process.stdin.removeListener('data', onData);
285
+ rl.removeAllListeners('line');
286
+ clearTimeout(timer);
287
+ rl.close();
288
+ };
289
+ rl.on('line', (line) => {
290
+ buffer += line + '\n';
291
+ clearTimeout(timer);
292
+ timer = setTimeout(resolveFunc, SUBMIT_DEBOUNCE_MS);
293
+ });
294
+ process.stdin.on('data', onData);
295
+
296
+ rl.setPrompt(prompt);
297
+ rl.prompt();
298
+
299
+ return {
300
+ input,
301
+ abort: () => {
302
+ cleanup();
303
+ resolvePromise();
304
+ }
305
+ };
302
306
  };
303
307
 
308
+ /**
309
+ * Checks whether a value is a generator-like retry delay source.
310
+ *
311
+ * @param {unknown} value - Value to inspect.
312
+ * @returns {boolean} True when value has a callable `next()` method.
313
+ */
314
+ const isDelayGenerator = (value) => value != null && typeof value == 'object' && typeof value.next == 'function';
315
+
304
316
  /**
305
317
  * Retries an async function up to N times with optional delays.
306
318
  *
307
- * Delay can be fixed ('1s'), generator (expBackoff()), or none.
319
+ * Delay precedence: when `delayOrCallback` is a function it is the retried
320
+ * callback and there is no delay between attempts. Otherwise it is a static
321
+ * duration ('1s', 100) or a delay generator (e.g. {@link expBackoff}), and
322
+ * `callback` is the retried function. A generator yield of `undefined`
323
+ * (exhausted generator) or `0` means "no further delay" — the next attempt
324
+ * runs immediately.
308
325
  *
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).
326
+ * @param {number} count - Positive integer number of attempts.
327
+ * @param {string|number|Iterator<number|string>|(() => (Promise<any>|any))} delayOrCallback - Delay, delay generator, or the callback when no separate callback is supplied.
328
+ * @param {() => (Promise<any>|any)} [callback] - Callback to retry when a delay is supplied.
312
329
  * @returns {Promise<any>} Successful result.
313
- * @throws {Error} Last error if all attempts fail.
330
+ * @throws {Error} If arguments are invalid, or the last callback error if all attempts fail.
314
331
  * @example
315
332
  * await retry(3, '1s', () => SH`curl http://unreliable`.run());
316
333
  * await retry(3, expBackoff(), () => flakyOp());
317
334
  */
318
- const retry = async (count, a, b) => {
319
- let callback;
335
+ const retry = async (count, delayOrCallback, callback) => {
336
+ if (!Number.isInteger(count) || count < 1) {
337
+ throw new Error(`Invalid retry count: "${count}". Expected a positive integer.`);
338
+ }
339
+
340
+ let retryCallback;
320
341
  let delayStatic = 0;
321
342
  let delayGen;
322
- // @ts-ignore
323
- if (typeof a == 'function') {
324
- callback = a;
343
+
344
+ if (typeof delayOrCallback == 'function') {
345
+ retryCallback = delayOrCallback;
325
346
  }
326
347
  else {
327
- if (typeof a == 'object') {
328
- delayGen = a;
348
+ if (typeof callback != 'function') {
349
+ throw new Error(`Invalid retry callback: expected a function.`);
350
+ }
351
+ retryCallback = callback;
352
+
353
+ if (isDelayGenerator(delayOrCallback)) {
354
+ delayGen = delayOrCallback;
355
+ }
356
+ else if (delayOrCallback != null && typeof delayOrCallback == 'object') {
357
+ throw new Error(`Invalid retry delay generator: expected an object with next().`);
329
358
  }
330
359
  else {
331
- delayStatic = parseDuration(a);
360
+ delayStatic = parseDuration(delayOrCallback);
332
361
  }
333
- assert(b);
334
- callback = b;
335
362
  }
336
- let lastErr;
337
- let attempt = 0;
338
- while (count-- > 0) {
339
- attempt++;
363
+
364
+ for (let attempt = 1; attempt <= count; attempt++) {
340
365
  try {
341
- return await callback();
366
+ return await retryCallback();
342
367
  }
343
368
  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)
369
+ if (attempt == count) {
370
+ throw err;
371
+ }
372
+ const delay = delayGen ? delayGen.next().value : delayStatic;
373
+ if (delay) {
353
374
  await sleep(delay);
375
+ }
354
376
  }
355
377
  }
356
- throw lastErr;
357
- }
378
+ };
358
379
 
359
380
  /**
360
381
  * Sleeps for a specified duration.
@@ -368,19 +389,23 @@ const sleep = (duration) => {
368
389
  return new Promise((resolve) => {
369
390
  setTimeout(resolve, parseDuration(duration));
370
391
  });
371
- }
392
+ };
372
393
 
373
394
  /**
374
395
  * Changes the current working directory.
375
396
  *
397
+ * Affects the cwd of subsequent `SH` commands: default options capture
398
+ * `process.cwd()` lazily when each command is created. Also clears any
399
+ * `SH.cwd` override, so the most recent cwd change always wins.
400
+ *
376
401
  * @param {string} dir - Path to new directory.
377
402
  * @example
378
403
  * cd('/tmp');
379
404
  */
380
405
  const cd = (dir) => {
381
- // @ts-ignore
382
406
  process.chdir(dir);
383
- }
407
+ clearCwdOverride();
408
+ };
384
409
 
385
410
  /**
386
411
  * Generator for exponential backoff delays with jitter.
@@ -389,6 +414,7 @@ const cd = (dir) => {
389
414
  * @param {string} [max='60s'] - Max backoff duration.
390
415
  * @param {string} [rand='100ms'] - Max jitter.
391
416
  * @yields {number} Next backoff ms.
417
+ * @returns {ExpBackoffGenerator} Infinite generator of backoff delays in ms.
392
418
  * @example
393
419
  * const backoff = expBackoff();
394
420
  * await sleep(backoff.next().value);
@@ -403,11 +429,39 @@ function* expBackoff(max = '60s', rand = '100ms') {
403
429
  }
404
430
  }
405
431
 
432
+ /**
433
+ * Matches negative number tokens that should be parsed as values, not options.
434
+ *
435
+ * Supports decimal and exponent forms such as `-1`, `-0.5`, `-.5`, and `-1e3`.
436
+ *
437
+ * @param {string} arg - CLI argument token.
438
+ * @returns {boolean} True when the token is a negative numeric value.
439
+ */
440
+ const isNegativeNumberArg = (arg) => /^-(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(arg);
441
+
442
+ /**
443
+ * Checks whether a token should be parsed as an option.
444
+ *
445
+ * Negative numeric values are intentionally excluded so calls like
446
+ * `parseArgs(['--n', '-1'])` parse `-1` as the value for `--n`. A lone `-`
447
+ * is not an option either; it is parsed as a positional (the stdin
448
+ * convention). The `--` end-of-options terminator is handled separately in
449
+ * {@link parseArgs}.
450
+ *
451
+ * @param {string} arg - CLI argument token.
452
+ * @returns {boolean} True when the token should be parsed as an option.
453
+ */
454
+ const isOptionArg = (arg) => arg.length > 1 && arg.startsWith('-') && !isNegativeNumberArg(arg);
455
+
406
456
  /**
407
457
  * Parses CLI arguments into an object.
408
458
  *
409
459
  * Supports --key value, -k value (no shorts grouped).
410
460
  * Bares go to _[]. Duplicates error. No = syntax.
461
+ * Negative number tokens such as -1, -0.5, and -1e3 are parsed as values or
462
+ * positionals, not options. A lone `-` is a positional (stdin convention).
463
+ * `--` is the conventional end-of-options terminator: every token after it
464
+ * goes into `_`, even option-looking ones.
411
465
  *
412
466
  * Defaults to process.argv.slice(2).
413
467
  *
@@ -416,23 +470,36 @@ function* expBackoff(max = '60s', rand = '100ms') {
416
470
  * @throws {Error} On invalid/dupe args.
417
471
  * @example
418
472
  * parseArgs(['--port', '8080', 'file.txt']); // { port: '8080', _: ['file.txt'] }
473
+ * parseArgs(['--n', '-1']); // { n: '-1', _: [] }
474
+ * parseArgs(['-', '--port', '1']); // { port: '1', _: ['-'] }
475
+ * parseArgs(['--', '--x']); // { _: ['--x'] }
419
476
  */
420
477
  const parseArgs = (args) => {
421
478
  if (!args) args = process.argv.slice(2);
422
- const result = { _: [] };
479
+ // @ts-ignore '_: string[]' is the intended exception to the 'string|true' index signature of ArgsObject.
480
+ const result = /** @type {ArgsObject} */ ({ _: [] });
423
481
  const seenKeys = new Set();
424
482
  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.`);
483
+ const arg = args[i];
484
+
485
+ if (arg === '--') {
486
+ // End-of-options terminator: all remaining tokens are positionals.
487
+ result._.push(...args.slice(i + 1));
488
+ break;
489
+ }
490
+
491
+ if (isOptionArg(arg)) {
492
+ if (arg.startsWith('-') && !arg.startsWith('--') && arg.length > 2) {
493
+ throw new Error(`Invalid argument: ${arg}. Use '--' for long options.`);
428
494
  }
429
- const key = args[i].startsWith('--') ? args[i].substring(2) : args[i].substring(1);
495
+ const key = arg.startsWith('--') ? arg.substring(2) : arg.substring(1);
430
496
  if (seenKeys.has(key)) {
431
- throw new Error(`Duplicate argument: ${args[i]}`);
497
+ throw new Error(`Duplicate argument: ${arg}`);
432
498
  }
433
499
  seenKeys.add(key);
500
+ /** @type {string|true} */
434
501
  let value;
435
- if (args[i + 1] && !args[i + 1].startsWith('-')) {
502
+ if (i + 1 < args.length && !isOptionArg(args[i + 1])) {
436
503
  value = args[i + 1];
437
504
  i++; // Skip next element as it is a value
438
505
  } else {
@@ -440,10 +507,9 @@ const parseArgs = (args) => {
440
507
  }
441
508
  result[key] = value;
442
509
  } else {
443
- result._.push(args[i]);
510
+ result._.push(arg);
444
511
  }
445
512
  }
446
- // @ts-ignore
447
513
  return result;
448
514
  };
449
515
 
@@ -463,4 +529,4 @@ export {
463
529
  assert,
464
530
  AsyncTracker,
465
531
  bashEscape
466
- }
532
+ };