@j-o-r/sh 1.1.28 → 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/lib/SH.js CHANGED
@@ -25,7 +25,7 @@
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'.
27
27
  // Modified by: jorrit.duin+sh[AT]gmail.com
28
- /** @type {assert} */
28
+
29
29
  import assert from 'node:assert';
30
30
  import readline from 'node:readline/promises';
31
31
  import SHDispatch from './SHDispatch.js';
@@ -33,40 +33,49 @@ import Test from './Test.js'
33
33
  import AsyncTracker from './AsyncTracker.js'
34
34
 
35
35
  /**
36
- * @typedef {Object.<string, string>} ArgsObject
37
- * @property {string} [key: string] - Any string key maps to a string value
38
- * @property {string[]} _ - Array of strings, unnamed parameters
39
- * @description Parsed parameters result.
40
- */
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
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
+
41
54
  /**
42
- * @typedef {Function} RejectCallback
43
- * @param {Error} error - The error object passed to the callback.
44
- */
55
+ * @typedef {import('./SHDispatch.js').SHOptions} SHOptions
56
+ */
57
+
45
58
  /**
46
- * @typedef {Function} ResolveCallback
47
- * @param {any} [param] - Optional callback any value
48
- */
59
+ * @typedef {Object} AbortableInput
60
+ * @property {Promise<string|void>} input - Promise that resolves to user input or void if aborted.
61
+ * @property {() => void} abort - Function to abort input collection.
62
+ */
63
+
49
64
  /**
50
- * Creates a new SHDispatch object that represents a command to be executed.
51
- *
52
- * @typedef {Function} Shell
53
- * @property {function(Array, ...*): ProcessPromise} execute - The function to execute the command.
54
- *
55
- * @param {Array} pieces - An array of string literals from a template literal.
56
- * @param {...*} args - The values to be interpolated into the string literals.
57
- * @returns {SHDispatch} Trigger for the command.
58
- * @throws {Error} Throws an error if any of the string literals in `pieces` is undefined.
59
- *
60
- * @example
61
- * const command = await SH`echo 'Hello, world!'`.run();
62
- */
65
+ * Generator-like object that yields retry delay durations.
66
+ *
67
+ * @typedef {Iterator<number|string>} ExpBackoffGenerator
68
+ */
63
69
 
64
70
  /**
65
- * Determine a javascript type
66
- *
67
- * @param {any} fn - Any let type
68
- * @returns {string} The "real" object / typeof name
69
- */
71
+ * Utility to determine the JavaScript type of a value.
72
+ *
73
+ * @param {any} fn - Any value to inspect.
74
+ * @returns {string} The "real" object type name (e.g., 'Array', 'Promise') or primitive typeof.
75
+ * @example
76
+ * jsType([]); // 'Array'
77
+ * jsType(Promise.resolve()); // 'Promise'
78
+ */
70
79
  const jsType = (fn) => {
71
80
  if (fn === undefined) return 'undefined';
72
81
  const type = Object.prototype.toString.call(fn).slice(8, -1);
@@ -74,59 +83,60 @@ const jsType = (fn) => {
74
83
  };
75
84
 
76
85
  /**
77
- * 'Code Safe' has own prop
78
- *
79
- * @param {any} o - object to examine
80
- * @param {string} p - property to look for
81
- * @returns {boolean}
82
- */
86
+ * Checks if an object has a specific own property (code-safe).
87
+ *
88
+ * @param {any} o - Object to examine.
89
+ * @param {string} p - Property name to check.
90
+ * @returns {boolean} True if the object has the own property.
91
+ */
83
92
  const hasProp = (o, p) => {
84
93
  if (o == null) return false;
85
94
  return Object.prototype.hasOwnProperty.call(o, p);
86
95
  };
96
+
87
97
  /**
88
- * 4ms, 5s || 5
89
- * @param {number|string} d
90
- * @returns {number}
91
- */
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
+ */
92
107
  const parseDuration = (d) => {
93
108
  if (typeof d == 'number') {
94
- if (isNaN(d) || d < 0)
109
+ if (!Number.isFinite(d) || d < 0)
95
110
  throw new Error(`Invalid duration: "${d}".`);
96
111
  return d;
97
112
  }
98
- else if (/\d+s/.test(d)) {
99
- return +d.slice(0, -1) * 1000;
100
- }
101
- else if (/\d+ms/.test(d)) {
102
- return +d.slice(0, -2);
113
+ if (typeof d == 'string') {
114
+ const match = d.match(/^(\d+)(ms|s)$/);
115
+ if (!match)
116
+ throw new Error(`Unknown duration: "${d}".`);
117
+ const amount = Number(match[1]);
118
+ return match[2] == 's' ? amount * 1000 : amount;
103
119
  }
104
- throw new Error(`Unknown duration: "${d}".`);
105
- }
106
- /**
107
- * bash escape a string suitable as an argument on the commandline
108
- * for javascript code
109
- * @param {string} x
110
- * @returns {string}
111
- */
112
- const bashEscape = (x) => {
113
- let str = String(x).trim();
114
- // the trick is to double escape escape vars first
115
- str = str.replace(/\\/g, '\\\\');
116
- // then add escaping for oddities
117
- // Replace literal escape sequences like \n, \t, \r in quoted strings
118
- str = str.replace(/(['"])\\(.)\1/g, '$1\\\\$2$1');
119
- // escape $ (is a BASH var)
120
- str = str.replace(/\$/g, '\\$');
121
- // Escape backticks
122
- str = str.replace(/`/g, '\\`');
123
- // Escape quotes
124
- str = str.replace(/"/g, '\\"');
125
- return str;
120
+ throw new Error(`Invalid duration type: "${jsType(d)}".`);
126
121
  }
127
122
 
123
+ /**
124
+ * Quotes a value as one POSIX shell argument.
125
+ *
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.
131
+ *
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();
136
+ */
137
+ const bashEscape = (x) => `'${String(x).replace(/'/g, `'\\''`)}'`;
128
138
 
129
- /** @type {import('./SHDispatch.js').SHOptions} */
139
+ /** Default options applied to all SH commands unless overridden. */
130
140
  const defaultOptions = {
131
141
  cwd: process.cwd(),
132
142
  env: process.env,
@@ -135,30 +145,66 @@ const defaultOptions = {
135
145
  timeout: 0, // when 0 there is no timeout
136
146
  };
137
147
 
138
- const setHandler = {
139
- set(target, prop, value) {
140
- defaultOptions[prop] = value; // Allow setting on target
141
- // console.log(`Set ${String(prop)} = ${value}`);
142
- // console.log(defaultOptions);
143
- return true;
144
- }
145
- }
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
+ ]);
146
158
 
147
159
  /**
148
- * A template-tag that returns an SHDispatch.
149
- * @typedef {(pieces: TemplateStringsArray, ...args: unknown[]) => SHDispatch} SHTag
150
- */
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
+ }
177
+ }
178
+
151
179
  /**
152
- * SH template tag
153
- *
154
- * Interpolation rules:
155
- * - Arrays: each element is String(x).trim(), with newlines/carriage returns/tabs escaped; if an element contains shell metacharacters or spaces, it is wrapped in single-quotes and internal single-quotes are escaped as '\''.
156
- * - Non-array values: currently coerced with String(value) and inserted as-is (NOT shell-escaped). Be careful when interpolating untrusted input.
157
- *
158
- * @type {Shell & SHTag & defaultOptions}
159
- * Returns an SHDispatch that can be configured via .options() and executed with .run() / .runSync().
160
- * @returns {SHDispatch}
161
- */
180
+ * Template tag for building and executing shell commands.
181
+ *
182
+ * Returns an {@link SHDispatch} instance for configuration (.options()) and execution (.run(), .runSync()).
183
+ *
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.
191
+ *
192
+ * SH acts as both template tag and options setter: `SH.timeout = 5000; SH`cmd``
193
+ *
194
+ * @param {TemplateStringsArray} pieces - String literals from template.
195
+ * @param {...unknown[]} args - Values to interpolate.
196
+ * @returns {SHDispatch} Command dispatcher.
197
+ * @throws {Error} If pieces contain undefined.
198
+ * @example
199
+ * const cmd = SH`echo ${'Hello'}`;
200
+ * await cmd.run(); // Executes: echo Hello
201
+ *
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();
207
+ */
162
208
  const SH = new Proxy(function(pieces, ...args) {
163
209
  if (pieces.some((p) => p == undefined)) {
164
210
  throw new Error(`Malformed command ${pieces}`);
@@ -168,57 +214,40 @@ const SH = new Proxy(function(pieces, ...args) {
168
214
  let s;
169
215
 
170
216
  if (Array.isArray(args[i])) {
171
- // @ts-ignore
172
- s = args[i].map(/** @param {string} x */(x) => {
173
- // Trim every element
174
- let str = String(x).trim();
175
- str = str.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
176
- // If the string contains special characters, wrap in single quotes
177
- if (str.match(/[ "'$`(){}[\]]/)) {
178
- // Escape single quotes within the string
179
- return `'${str.replace(/'/g, `'\\''`)}'`;
180
- }
181
- return str;
182
- }).join(' ');
217
+ s = args[i].map((x) => String(x)).join(' ');
183
218
  } else {
184
219
  s = String(args[i]);
185
220
  }
186
221
  cmd += s + pieces[++i];
187
222
  }
188
223
  return new SHDispatch(cmd, defaultOptions);
189
- }, setHandler);
190
-
224
+ }, defaultOptionHandler);
191
225
 
192
226
  /**
193
- * Create a async/sync context in new execution callstack
194
- * @param {function} callback - async/sync function
195
- * @example
196
- * const p = within(async () => {
197
- * const res = await Promise.all([
198
- * SH`sleep 1; echo 1`.run(),
199
- * SH`sleep 2; echo 2`.run(),
200
- * sleep(2),
201
- * SH`sleep 3; echo 3`.run()
202
- * ]);
203
- * return 'res';
204
- * });
205
- */
227
+ * Executes a callback in a new async context (fresh callstack).
228
+ *
229
+ * Useful for parallel operations without nesting.
230
+ *
231
+ * @param {() => Promise<any>} callback - Async function to execute.
232
+ * @returns {Promise<any>} Result of callback.
233
+ * @example
234
+ * const results = await within(async () => {
235
+ * return Promise.all([SH`sleep 1; echo 1`.run(), sleep(2)]);
236
+ * });
237
+ */
206
238
  const within = async (callback) => {
207
239
  return await callback();
208
240
  }
209
241
 
210
242
  /**
211
- * This function reads the standard input (stdin) from the current process.
212
- * @returns {Promise<string>}
213
- * @example
214
- * const content = await readIn();
215
- */
216
- /**
217
- * Read entire stdin as UTF-8. If stdin is a TTY, resolves to an empty string.
218
- * @returns {Promise<string|undefined>}
243
+ * Reads entire stdin as UTF-8 string. Resolves to empty string if TTY (no pipe).
244
+ *
245
+ * @returns {Promise<string>} Stdin content, or an empty string if TTY.
246
+ * @example
247
+ * const input = await readIn(); // Use in piped scripts
219
248
  */
220
249
  const readIn = async () => {
221
- if (process.stdin.isTTY) return; // nothing was piped
250
+ if (process.stdin.isTTY) return '';
222
251
  let buf = '';
223
252
  process.stdin.setEncoding('utf8');
224
253
  for await (const chunk of process.stdin) {
@@ -227,17 +256,18 @@ const readIn = async () => {
227
256
  return buf;
228
257
  }
229
258
 
230
-
231
259
  /**
232
- * Get user input from the command line (stdin)
233
- * void when input has been aborted
234
- * @param {string} prompt - prompt or question
235
- * @returns {{input: Promise<string|void>, abort: function(): void}}
236
- * @example
237
- * const user = userIn('Question: ');
238
- * const content = await user.input
239
- * // user.abort()
240
- */
260
+ * Prompts user for input on stdin with custom prompt.
261
+ *
262
+ * Supports aborting input collection.
263
+ *
264
+ * @param {string} prompt - Prompt text to display.
265
+ * @returns {AbortableInput} Object with input Promise and abort function.
266
+ * @example
267
+ * const {input, abort} = userIn('Enter name: ');
268
+ * const name = await input;
269
+ * // abort(); // Cancel anytime
270
+ */
241
271
  const userIn = (prompt) => {
242
272
  let resolvePromise;
243
273
  const input = new Promise((resolve) => { resolvePromise = resolve; });
@@ -285,94 +315,110 @@ const userIn = (prompt) => {
285
315
  }
286
316
  };
287
317
  };
318
+
288
319
  /**
289
- * Retries a given asynchronous function a specified number of times with optional delays between attempts.
290
- *
291
- * @param {number} count - The number of retry attempts.
292
- * @param {string|number|expBackoff|Function} a - Either a delay duration as a string, a delay generator object, or the callback function.
293
- * @param {Function} [b] - The callback function to retry, required if `a` is not a function.
294
- * @returns {Promise<*>} - The result of the callback function if it succeeds within the retry attempts.
295
- * @throws {Error} - The last error encountered if all retry attempts fail.
296
- *
297
- * @example
298
- * // Retry a command 3 times
299
- * const p = await retry(3, () => SH`curl -s https://flipwrsi`);
300
- *
301
- * // Retry a command 3 times with an interval of 1 second between each try
302
- * const p = await retry(3, '1s', () => SH`curl -s https://flipwrsi`);
303
- *
304
- * // Retry a command 3 times with irregular intervals using exponential backoff
305
- * const p = await retry(3, expBackoff(), () => SH`curl -s https://flipwrsi`);
306
- */
307
- const retry = async (count, a, b) => {
308
- // const total = count;
309
- let callback;
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
+
327
+ /**
328
+ * Retries an async function up to N times with optional delays.
329
+ *
330
+ * Delay can be fixed ('1s'), generator-like (expBackoff()), or none.
331
+ *
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.
335
+ * @returns {Promise<any>} Successful result.
336
+ * @throws {Error} If arguments are invalid, or the last callback error if all attempts fail.
337
+ * @example
338
+ * await retry(3, '1s', () => SH`curl http://unreliable`.run());
339
+ * await retry(3, expBackoff(), () => flakyOp());
340
+ */
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;
310
347
  let delayStatic = 0;
311
348
  let delayGen;
312
- // @ts-ignore
313
- if (typeof a == 'function') {
314
- callback = a;
349
+
350
+ if (typeof delayOrCallback == 'function') {
351
+ retryCallback = delayOrCallback;
315
352
  }
316
353
  else {
317
- if (typeof a == 'object') {
318
- delayGen = a;
354
+ if (typeof callback != 'function') {
355
+ throw new Error(`Invalid retry callback: expected a function.`);
356
+ }
357
+ retryCallback = callback;
358
+
359
+ if (isDelayGenerator(delayOrCallback)) {
360
+ delayGen = delayOrCallback;
361
+ }
362
+ else if (delayOrCallback != null && typeof delayOrCallback == 'object') {
363
+ throw new Error(`Invalid retry delay generator: expected an object with next().`);
319
364
  }
320
365
  else {
321
- delayStatic = parseDuration(a);
366
+ delayStatic = parseDuration(delayOrCallback);
322
367
  }
323
- assert(b);
324
- callback = b;
325
368
  }
326
- let lastErr;
327
- let attempt = 0;
328
- while (count-- > 0) {
329
- attempt++;
369
+
370
+ for (let attempt = 1; attempt <= count; attempt++) {
330
371
  try {
331
- return await callback();
372
+ return await retryCallback();
332
373
  }
333
374
  catch (err) {
334
- let delay = 0;
335
- if (delayStatic > 0)
336
- delay = delayStatic;
337
- // @ts-ignore
338
- if (delayGen) delay = delayGen.next().value;
339
- lastErr = err;
340
- if (count == 0)
341
- break;
342
- if (delay)
375
+ if (attempt == count) {
376
+ throw err;
377
+ }
378
+ const delay = delayGen ? delayGen.next().value : delayStatic;
379
+ if (delay) {
343
380
  await sleep(delay);
381
+ }
344
382
  }
345
383
  }
346
- throw lastErr;
347
384
  }
385
+
348
386
  /**
349
- * This function pauses or "sleeps" code execution for a specified duration.
350
- * @param {string|number} duration - The duration to pause execution for, e.g., '100ms' or '3s'.
351
- *
352
- * @example
353
- *
354
- * await sleep('5s');
355
- */
387
+ * Sleeps for a specified duration.
388
+ *
389
+ * @param {string|number} duration - Duration as '5s', '100ms', or ms number.
390
+ * @returns {Promise<void>}
391
+ * @example
392
+ * await sleep('2s');
393
+ */
356
394
  const sleep = (duration) => {
357
395
  return new Promise((resolve) => {
358
396
  setTimeout(resolve, parseDuration(duration));
359
397
  });
360
398
  }
399
+
361
400
  /**
362
- * Change working directory
363
- * @param {string} dir
364
- */
401
+ * Changes the current working directory.
402
+ *
403
+ * @param {string} dir - Path to new directory.
404
+ * @example
405
+ * cd('/tmp');
406
+ */
365
407
  const cd = (dir) => {
366
408
  // @ts-ignore
367
409
  process.chdir(dir);
368
410
  }
411
+
369
412
  /**
370
- * Generates an exponential backoff time with a random jitter.
413
+ * Generator for exponential backoff delays with jitter.
371
414
  *
372
415
  * @generator
373
- * @param {string} [max='60s'] - The maximum backoff time in a human-readable format (e.g., '60s' for 60 seconds).
374
- * @param {string} [rand='100ms'] - The maximum random jitter time in a human-readable format (e.g., '100ms' for 100 milliseconds).
375
- * @yields {number} The backoff time in milliseconds.
416
+ * @param {string} [max='60s'] - Max backoff duration.
417
+ * @param {string} [rand='100ms'] - Max jitter.
418
+ * @yields {number} Next backoff ms.
419
+ * @example
420
+ * const backoff = expBackoff();
421
+ * await sleep(backoff.next().value);
376
422
  */
377
423
  function* expBackoff(max = '60s', rand = '100ms') {
378
424
  const maxMs = parseDuration(max);
@@ -383,52 +429,63 @@ function* expBackoff(max = '60s', rand = '100ms') {
383
429
  yield Math.min(2 ** n++, maxMs) + ms;
384
430
  }
385
431
  }
432
+
386
433
  /**
387
- * Parses command-line arguments into an object.
388
- *
389
- * The function recognizes arguments that start with two dashes (`--`) or one dash (`-`) as keys,
390
- * and the subsequent value (if not another key) as the corresponding value.
391
- * If a key does not have a value, it defaults to `true`.
392
- * All unrecognized arguments are collected in an array under the `_` property.
393
- *
394
- * @param {string[]} [args] - An array of command-line arguments (process.argv.slice(2)).
395
- * @returns {ArgsObject} An object where:
396
- * - If args is not passed, process.argv.slice(2) will be the default
397
- * - Each key corresponds to an argument that starts with `--` or `-`,
398
- * - The value is either the next argument or `true` if no value is provided,
399
- * - The `_` property contains an array of unbound arguments.
400
- */
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
+
401
443
  /**
402
- * Parse command-line args into an object.
444
+ * Checks whether a token should be parsed as an option.
403
445
  *
404
- * Supported:
405
- * - --key value, -k value (no grouped short flags)
406
- * - Bare values collected under `_.`
407
- * - Duplicate keys throw an error; keys without a following value become true.
446
+ * Negative numeric values are intentionally excluded so calls like
447
+ * `parseArgs(['--n', '-1'])` parse `-1` as the value for `--n`.
408
448
  *
409
- * Not supported:
410
- * - --key=value syntax
411
- * - Grouped short flags like -abc
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
+
454
+ /**
455
+ * Parses CLI arguments into an object.
456
+ *
457
+ * Supports --key value, -k value (no shorts grouped).
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.
412
461
  *
413
- * @param {string[]} [args]
462
+ * Defaults to process.argv.slice(2).
463
+ *
464
+ * @param {string[]} [args=process.argv.slice(2)] - Args array.
414
465
  * @returns {ArgsObject}
466
+ * @throws {Error} On invalid/dupe args.
467
+ * @example
468
+ * parseArgs(['--port', '8080', 'file.txt']); // { port: '8080', _: ['file.txt'] }
469
+ * parseArgs(['--n', '-1']); // { n: '-1', _: [] }
415
470
  */
416
471
  const parseArgs = (args) => {
417
472
  if (!args) args = process.argv.slice(2);
418
473
  const result = { _: [] };
419
474
  const seenKeys = new Set();
420
475
  for (let i = 0; i < args.length; i++) {
421
- if (args[i].startsWith('--') || args[i].startsWith('-')) {
422
- if (args[i].startsWith('-') && !args[i].startsWith('--') && args[i].length > 2) {
423
- throw new Error(`Invalid argument: ${args[i]}. Use '--' for long options.`);
476
+ const arg = args[i];
477
+
478
+ if (isOptionArg(arg)) {
479
+ if (arg.startsWith('-') && !arg.startsWith('--') && arg.length > 2) {
480
+ throw new Error(`Invalid argument: ${arg}. Use '--' for long options.`);
424
481
  }
425
- const key = args[i].startsWith('--') ? args[i].substring(2) : args[i].substring(1);
482
+ const key = arg.startsWith('--') ? arg.substring(2) : arg.substring(1);
426
483
  if (seenKeys.has(key)) {
427
- throw new Error(`Duplicate argument: ${args[i]}`);
484
+ throw new Error(`Duplicate argument: ${arg}`);
428
485
  }
429
486
  seenKeys.add(key);
430
487
  let value;
431
- if (args[i + 1] && !args[i + 1].startsWith('-')) {
488
+ if (i + 1 < args.length && !isOptionArg(args[i + 1])) {
432
489
  value = args[i + 1];
433
490
  i++; // Skip next element as it is a value
434
491
  } else {
@@ -436,7 +493,7 @@ const parseArgs = (args) => {
436
493
  }
437
494
  result[key] = value;
438
495
  } else {
439
- result._.push(args[i]);
496
+ result._.push(arg);
440
497
  }
441
498
  }
442
499
  // @ts-ignore