@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/.editorconfig +21 -0
- package/README.md +32 -3
- package/TODO.md +349 -12
- package/lib/SH.js +274 -208
- package/lib/SHDispatch.js +48 -32
- package/lib/SHExecute.js +233 -228
- package/lib/internal.js +113 -0
- package/package.json +2 -2
- package/types/SH.d.ts +109 -50
- package/types/SHDispatch.d.ts +49 -28
- package/types/SHExecute.d.ts +21 -10
- package/types/internal.d.ts +62 -0
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* @typedef {
|
|
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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* @
|
|
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
|
-
*
|
|
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 = (
|
|
78
|
-
if (
|
|
79
|
-
const type = Object.prototype.toString.call(
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
88
|
+
if (o == null) return false;
|
|
89
|
+
return Object.hasOwn(o, p);
|
|
93
90
|
};
|
|
94
91
|
|
|
95
92
|
/**
|
|
96
|
-
*
|
|
93
|
+
* Quotes a value as one POSIX shell argument.
|
|
97
94
|
*
|
|
98
|
-
*
|
|
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 {
|
|
101
|
-
* @returns {
|
|
102
|
-
* @
|
|
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
|
|
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
|
-
*
|
|
109
|
+
* Proxy traps for the SH template tag and global option defaults.
|
|
121
110
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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:
|
|
167
|
-
* - Other values: String(value)
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
},
|
|
189
|
+
}, defaultOptionHandler);
|
|
210
190
|
|
|
211
191
|
/**
|
|
212
|
-
*
|
|
192
|
+
* Awaits `callback()` and returns its result.
|
|
213
193
|
*
|
|
214
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
};
|
|
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
|
|
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 -
|
|
310
|
-
* @param {string|number|
|
|
311
|
-
* @param {
|
|
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}
|
|
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,
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
if (typeof
|
|
324
|
-
|
|
343
|
+
|
|
344
|
+
if (typeof delayOrCallback == 'function') {
|
|
345
|
+
retryCallback = delayOrCallback;
|
|
325
346
|
}
|
|
326
347
|
else {
|
|
327
|
-
if (typeof
|
|
328
|
-
|
|
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(
|
|
360
|
+
delayStatic = parseDuration(delayOrCallback);
|
|
332
361
|
}
|
|
333
|
-
assert(b);
|
|
334
|
-
callback = b;
|
|
335
362
|
}
|
|
336
|
-
|
|
337
|
-
let attempt =
|
|
338
|
-
while (count-- > 0) {
|
|
339
|
-
attempt++;
|
|
363
|
+
|
|
364
|
+
for (let attempt = 1; attempt <= count; attempt++) {
|
|
340
365
|
try {
|
|
341
|
-
return await
|
|
366
|
+
return await retryCallback();
|
|
342
367
|
}
|
|
343
368
|
catch (err) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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 =
|
|
495
|
+
const key = arg.startsWith('--') ? arg.substring(2) : arg.substring(1);
|
|
430
496
|
if (seenKeys.has(key)) {
|
|
431
|
-
throw new Error(`Duplicate argument: ${
|
|
497
|
+
throw new Error(`Duplicate argument: ${arg}`);
|
|
432
498
|
}
|
|
433
499
|
seenKeys.add(key);
|
|
500
|
+
/** @type {string|true} */
|
|
434
501
|
let value;
|
|
435
|
-
if (
|
|
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(
|
|
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
|
+
};
|