@j-o-r/sh 1.1.31 → 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/TODO.md +347 -0
- package/lib/SH.js +161 -148
- package/lib/SHDispatch.js +27 -25
- package/lib/SHExecute.js +233 -228
- package/lib/internal.js +113 -0
- package/package.json +3 -3
- package/types/SH.d.ts +64 -34
- package/types/SHDispatch.d.ts +12 -29
- package/types/SHExecute.d.ts +21 -10
- package/types/internal.d.ts +62 -0
package/lib/SHExecute.js
CHANGED
|
@@ -1,58 +1,37 @@
|
|
|
1
1
|
import { spawnSync, spawn } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Validates if a number is a finite positive integer (including 0).
|
|
5
|
-
*
|
|
6
|
-
* @param {unknown} n - Value to check.
|
|
7
|
-
* @returns {boolean} True if finite non-negative integer.
|
|
8
|
-
*/
|
|
9
|
-
const isFinitePosInt = (n) => Number.isFinite(n) && n >= 0;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Parses a human-readable duration into milliseconds.
|
|
13
|
-
*
|
|
14
|
-
* Supports: numbers (ms), '250ms', '2s'. null/undefined → 0; invalid → throws.
|
|
15
|
-
*
|
|
16
|
-
* @param {number|string|null|undefined} d - Duration input.
|
|
17
|
-
* @returns {number} ms.
|
|
18
|
-
* @throws {Error} Invalid format/type.
|
|
19
|
-
*/
|
|
20
|
-
const parseDuration = (d) => {
|
|
21
|
-
if (typeof d === 'number') return isFinitePosInt(d) ? d : (() => { throw new Error('Invalid duration'); })();
|
|
22
|
-
if (typeof d === 'string') {
|
|
23
|
-
const m = d.match(/^(\d+)(ms|s)?$/);
|
|
24
|
-
if (!m) throw new Error('Invalid duration string');
|
|
25
|
-
const n = Number(m[1]);
|
|
26
|
-
return m[2] === 's' ? n * 1000 : n;
|
|
27
|
-
}
|
|
28
|
-
if (d == null) return 0;
|
|
29
|
-
throw new Error('Invalid duration type');
|
|
30
|
-
};
|
|
2
|
+
import { DEFAULT_MAX_BUFFER, parseDuration } from './internal.js';
|
|
31
3
|
|
|
32
4
|
/**
|
|
33
5
|
* Retrieves child PIDs of a given parent PID using pgrep -P.
|
|
34
6
|
*
|
|
7
|
+
* Best-effort: any pgrep failure resolves to an empty list so callers
|
|
8
|
+
* (notably `kill()`) always settle instead of hanging.
|
|
9
|
+
*
|
|
35
10
|
* @param {number} pid - Parent PID.
|
|
36
11
|
* @returns {Promise<number[]>} Array of child PIDs.
|
|
37
12
|
*/
|
|
38
13
|
const childrenOf = (pid) => new Promise((resolve, reject) => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
14
|
+
const p = spawn('pgrep', ['-P', String(pid)], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
15
|
+
const out = [];
|
|
16
|
+
p.stdout.on('data', (chunk) => out.push(chunk));
|
|
17
|
+
p.on('close', (code) => {
|
|
18
|
+
if (code === 0) {
|
|
19
|
+
const ids = Buffer.concat(out).toString('utf8').trim().split(/\s+/).map(Number).filter(Boolean);
|
|
20
|
+
resolve(ids);
|
|
21
|
+
} else if (code === 1) {
|
|
22
|
+
resolve([]); // no children or error
|
|
23
|
+
} else {
|
|
24
|
+
// pgrep failed (2: syntax error, 3: fatal error); kill stays best-effort.
|
|
25
|
+
resolve([]);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
p.on('error', reject);
|
|
51
29
|
});
|
|
52
30
|
|
|
53
31
|
/**
|
|
54
|
-
* @typedef {import('
|
|
55
|
-
* @description
|
|
32
|
+
* @typedef {import('./SHDispatch.js').SHOptions} SHExecuteOptions
|
|
33
|
+
* @description Options accepted by SHExecute — the shared SHOptions; `maxBuffer`
|
|
34
|
+
* (bytes per stream) defaults to 512000 (500 KiB).
|
|
56
35
|
*/
|
|
57
36
|
|
|
58
37
|
/**
|
|
@@ -64,213 +43,239 @@ const childrenOf = (pid) => new Promise((resolve, reject) => {
|
|
|
64
43
|
* - **Shell mode**: If `options.shell` is string/true, runs `${prefix}; ${command}` via shell ('bash' default).
|
|
65
44
|
* - **No-shell mode**: Uses `/usr/bin/env -S ${command}` for direct exec (ignores prefix).
|
|
66
45
|
* - **Rolling timeout**: `options.timeout` (ms/'2s'); resets on stdout/stderr data. SIGTERM on expiry.
|
|
67
|
-
*
|
|
46
|
+
* Async `run()` only — the timeout is stripped from the spawn options so Node's
|
|
47
|
+
* native absolute spawn timeout never interferes. `runSync()` instead passes the
|
|
48
|
+
* timeout to `spawnSync`, whose semantics are absolute (kills after the full
|
|
49
|
+
* duration, regardless of output).
|
|
50
|
+
* - **Buffering**: Captures stdout/stderr up to `maxBuffer` (512000 bytes / 500 KiB default); appends truncation markers.
|
|
68
51
|
* - **Payload**: `run(payload)` writes string to stdin (forces pipe).
|
|
69
|
-
* - **Detached**: If `options.detached`, resolves early (~1s) and unrefs.
|
|
70
|
-
*
|
|
52
|
+
* - **Detached**: If `options.detached`, resolves early (~1s) and unrefs. Unless the
|
|
53
|
+
* caller explicitly set `stdio`, SHDispatch forces `stdio: 'ignore'` for detached
|
|
54
|
+
* runs, because open pipes keep the parent's event loop alive and defeat detachment.
|
|
55
|
+
* - **Kill**: Terminates process + direct children via pgrep (requires procps; grandchildren survive).
|
|
71
56
|
*
|
|
72
57
|
* @example
|
|
73
58
|
* const exec = new SHExecute('ls', 'set -euo pipefail', { timeout: '5s' });
|
|
74
59
|
* const out = await exec.run();
|
|
75
60
|
*/
|
|
76
61
|
class SHExecute {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
62
|
+
#forcedKill = false;
|
|
63
|
+
/** @type {import('child_process').ChildProcess | null} */
|
|
64
|
+
#proc = null;
|
|
65
|
+
#prefix = '';
|
|
66
|
+
#command = '';
|
|
67
|
+
/** @type {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} */
|
|
68
|
+
#options = {};
|
|
69
|
+
#stdoutChunks = [];
|
|
70
|
+
#stderrChunks = [];
|
|
71
|
+
#stdoutLen = 0;
|
|
72
|
+
#stderrLen = 0;
|
|
73
|
+
#maxBuffer = DEFAULT_MAX_BUFFER; // 512000 bytes (500 KiB) per stream
|
|
74
|
+
#truncated = { stdout: false, stderr: false };
|
|
75
|
+
#timedOut = false;
|
|
76
|
+
/** Timeout in ms; 0 disables. Rolling in `run()`, absolute in `runSync()`. */
|
|
77
|
+
#timeout = 0;
|
|
91
78
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} command - Command to execute.
|
|
81
|
+
* @param {string} prefix - Shell prelude (e.g., 'set -euo pipefail'); ignored in no-shell.
|
|
82
|
+
* @param {SHExecuteOptions} [options] - Spawn options + maxBuffer/timeout.
|
|
83
|
+
*/
|
|
84
|
+
constructor(command, prefix, options = {}) {
|
|
85
|
+
this.#prefix = prefix;
|
|
86
|
+
this.#command = command;
|
|
87
|
+
// maxBuffer and timeout are SH-level options, not spawn options. timeout in
|
|
88
|
+
// particular must not reach spawn(): since Node 15.5 it triggers a native
|
|
89
|
+
// absolute timeout that would defeat the rolling timeout in run().
|
|
90
|
+
const { maxBuffer, timeout, ...spawnOpts } = options ?? {};
|
|
91
|
+
this.#maxBuffer = Number.isFinite(maxBuffer) && maxBuffer > 0 ? maxBuffer : this.#maxBuffer;
|
|
92
|
+
this.#timeout = parseDuration(timeout ?? 0);
|
|
93
|
+
this.#options = spawnOpts;
|
|
94
|
+
}
|
|
104
95
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Synchronous execution.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} [payload] - Stdin data (forces pipe).
|
|
100
|
+
* @returns {import('child_process').SpawnSyncReturns<Buffer>}
|
|
101
|
+
* @throws {Error} Invalid payload type.
|
|
102
|
+
*/
|
|
103
|
+
runSync(payload) {
|
|
104
|
+
this.#forcedKill = false;
|
|
105
|
+
if (payload && typeof payload !== 'string') {
|
|
106
|
+
throw new Error('Argument is not a string');
|
|
107
|
+
}
|
|
108
|
+
/** @type {import('node:child_process').SpawnSyncOptions} */
|
|
109
|
+
const options = { ...this.#options, shell: false };
|
|
110
|
+
if (this.#timeout > 0) {
|
|
111
|
+
// spawnSync supports timeout natively; semantics are absolute (no rolling reset).
|
|
112
|
+
options.timeout = this.#timeout;
|
|
113
|
+
}
|
|
114
|
+
if (payload) {
|
|
115
|
+
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
|
|
116
|
+
stdio[0] = 'pipe';
|
|
117
|
+
// @ts-ignore — stdio widens to string[] via the ['pipe', 'pipe', 'pipe'] literal; runtime values are valid StdioOptions.
|
|
118
|
+
options.stdio = stdio;
|
|
119
|
+
options.input = payload;
|
|
120
|
+
}
|
|
126
121
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
122
|
+
const shellOpt = this.#options?.shell;
|
|
123
|
+
if (shellOpt) {
|
|
124
|
+
const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
|
|
125
|
+
const cmd = this.#prefix ? `${this.#prefix}; ${this.#command}` : this.#command;
|
|
126
|
+
return spawnSync(sh, ['-c', cmd], options);
|
|
127
|
+
}
|
|
128
|
+
// no-shell mode
|
|
129
|
+
return spawnSync('/usr/bin/env', ['-S', this.#command], options);
|
|
130
|
+
}
|
|
136
131
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Asynchronous execution with buffering/timeout/kill.
|
|
134
|
+
*
|
|
135
|
+
* Resolves stdout (trimmed) on success.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} [payload] - Stdin data (forces pipe).
|
|
138
|
+
* @returns {Promise<string>} Trimmed UTF-8 stdout (+ truncation marker if exceeded).
|
|
139
|
+
* Rejects with an Error on command failure (message includes the exit code,
|
|
140
|
+
* or the signal name for signal kills, plus stderr), rolling-timeout expiry,
|
|
141
|
+
* forced kill, or spawn failure.
|
|
142
|
+
*/
|
|
143
|
+
run(payload) {
|
|
144
|
+
this.#forcedKill = false;
|
|
145
|
+
if (payload && typeof payload !== 'string') {
|
|
146
|
+
throw new Error('Argument is not a string');
|
|
147
|
+
}
|
|
148
|
+
/** @type {import('child_process').SpawnOptions} */
|
|
149
|
+
const options = { ...this.#options, shell: false };
|
|
150
|
+
if (payload) {
|
|
151
|
+
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
|
|
152
|
+
stdio[0] = 'pipe';
|
|
153
|
+
// @ts-ignore — stdio widens to string[] via the ['pipe', 'pipe', 'pipe'] literal; runtime values are valid StdioOptions.
|
|
154
|
+
options.stdio = stdio;
|
|
155
|
+
}
|
|
159
156
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
157
|
+
// reset buffers for each run
|
|
158
|
+
this.#stdoutChunks = [];
|
|
159
|
+
this.#stderrChunks = [];
|
|
160
|
+
this.#stdoutLen = 0;
|
|
161
|
+
this.#stderrLen = 0;
|
|
162
|
+
this.#truncated = { stdout: false, stderr: false };
|
|
163
|
+
this.#timedOut = false;
|
|
167
164
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
165
|
+
const shellOpt = this.#options?.shell;
|
|
166
|
+
if (shellOpt) {
|
|
167
|
+
const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
|
|
168
|
+
const cmd = this.#prefix ? `${this.#prefix}; ${this.#command}` : this.#command;
|
|
169
|
+
this.#proc = spawn(sh, ['-c', cmd], options);
|
|
170
|
+
} else {
|
|
171
|
+
this.#proc = spawn('/usr/bin/env', ['-S', this.#command], options);
|
|
172
|
+
}
|
|
176
173
|
|
|
177
|
-
|
|
174
|
+
if (payload) this.#proc.stdin?.end(payload);
|
|
178
175
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
176
|
+
const ms = this.#timeout;
|
|
177
|
+
let timeoutId = null;
|
|
178
|
+
const resetTimeout = () => {
|
|
179
|
+
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
180
|
+
if (ms > 0) {
|
|
181
|
+
timeoutId = setTimeout(() => {
|
|
182
|
+
this.#timedOut = true;
|
|
183
|
+
this.kill('SIGTERM').catch(() => {});
|
|
184
|
+
}, ms);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
190
187
|
|
|
191
|
-
|
|
188
|
+
if (ms > 0) resetTimeout();
|
|
192
189
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
190
|
+
this.#proc.stdout?.on('data', (chunk) => {
|
|
191
|
+
this.#stdoutLen += chunk.length;
|
|
192
|
+
// prevent TOKEN bombs
|
|
193
|
+
if (this.#stdoutLen <= this.#maxBuffer) {
|
|
194
|
+
this.#stdoutChunks.push(chunk);
|
|
195
|
+
} else {
|
|
196
|
+
this.#truncated.stdout = true;
|
|
197
|
+
}
|
|
198
|
+
resetTimeout();
|
|
199
|
+
});
|
|
203
200
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
201
|
+
this.#proc.stderr?.on('data', (chunk) => {
|
|
202
|
+
this.#stderrLen += chunk.length;
|
|
203
|
+
if (this.#stderrLen <= this.#maxBuffer) {
|
|
204
|
+
this.#stderrChunks.push(chunk);
|
|
205
|
+
} else {
|
|
206
|
+
this.#truncated.stderr = true;
|
|
207
|
+
}
|
|
208
|
+
resetTimeout();
|
|
209
|
+
});
|
|
213
210
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
if (options.detached) {
|
|
213
|
+
setTimeout(() => {
|
|
214
|
+
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
215
|
+
resolve('');
|
|
216
|
+
// #proc may already be null when kill() ran within the 1s window.
|
|
217
|
+
this.#proc?.unref();
|
|
218
|
+
}, 1000);
|
|
219
|
+
}
|
|
222
220
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
221
|
+
this.#proc.on('close', (code, signal) => {
|
|
222
|
+
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
223
|
+
// Check timeout first: the timeout path kills with SIGTERM, which sets
|
|
224
|
+
// #forcedKill in the same tick; #forcedKill must not mask the timeout.
|
|
225
|
+
if (this.#timedOut) {
|
|
226
|
+
reject(new Error(`Process timed out after ${ms}ms.`));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (this.#forcedKill) {
|
|
230
|
+
reject(new Error('Process killed (forced).'));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (code === 0) {
|
|
234
|
+
const stdoutBuf = this.#stdoutChunks.length ? Buffer.concat(this.#stdoutChunks) : Buffer.alloc(0);
|
|
235
|
+
let stdout = stdoutBuf.toString('utf8').trim();
|
|
236
|
+
if (this.#truncated.stdout) stdout += '\n[stdout truncated]\n';
|
|
237
|
+
resolve(stdout);
|
|
238
|
+
} else {
|
|
239
|
+
const stderrBuf = this.#stderrChunks.length ? Buffer.concat(this.#stderrChunks) : Buffer.alloc(0);
|
|
240
|
+
let stderr = stderrBuf.toString('utf8');
|
|
241
|
+
if (this.#truncated.stderr) stderr += '\n[stderr truncated]\n';
|
|
242
|
+
const reason = code === null ? `signal ${signal}` : `code ${code}`;
|
|
243
|
+
reject(new Error(`Command failed with ${reason}: ${stderr}`));
|
|
244
|
+
}
|
|
245
|
+
});
|
|
245
246
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
this.#proc.on('error', (err) => reject(err));
|
|
248
|
+
});
|
|
249
|
+
}
|
|
249
250
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
251
|
+
/**
|
|
252
|
+
* Terminates process and its children (via pgrep).
|
|
253
|
+
*
|
|
254
|
+
* Requires `pgrep` (procps) at runtime. Only direct children are
|
|
255
|
+
* discovered — grandchildren and deeper descendants survive. Best-effort:
|
|
256
|
+
* pgrep failures resolve to an empty child list instead of rejecting.
|
|
257
|
+
*
|
|
258
|
+
* @param {number | string} [signal='SIGTERM'] - Signal to send.
|
|
259
|
+
* @returns {Promise<number[]>} Killed PIDs.
|
|
260
|
+
* @throws {Error} No process/PID.
|
|
261
|
+
*/
|
|
262
|
+
async kill(signal = 'SIGTERM') {
|
|
263
|
+
if (!this.#proc) throw new Error('Trying to kill a process without creating one.');
|
|
264
|
+
if (!this.#proc.pid) throw new Error('The process pid is undefined.');
|
|
265
|
+
this.#forcedKill = true;
|
|
266
|
+
const pid = this.#proc.pid;
|
|
267
|
+
let killed = [];
|
|
268
|
+
try {
|
|
269
|
+
const kids = await childrenOf(pid).catch(() => []);
|
|
270
|
+
for (const k of kids) {
|
|
271
|
+
try { process.kill(k, signal); killed.push(k); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
272
|
+
}
|
|
273
|
+
try { process.kill(pid, signal); killed.push(pid); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
274
|
+
} finally {
|
|
275
|
+
this.#proc = null;
|
|
276
|
+
}
|
|
277
|
+
return killed;
|
|
278
|
+
}
|
|
274
279
|
}
|
|
275
280
|
|
|
276
281
|
export default SHExecute;
|
package/lib/internal.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared internals for the SH library.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for the default command options used by the `SH`
|
|
5
|
+
* template tag (`lib/SH.js`) and `SHDispatch` (`lib/SHDispatch.js`), and for
|
|
6
|
+
* the shared `parseDuration` used by `lib/SH.js` and `lib/SHExecute.js`.
|
|
7
|
+
* Not part of the public API; nothing here is re-exported from `lib/SH.js`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Explicit default `cwd` override, set via `SH.cwd = dir`.
|
|
12
|
+
*
|
|
13
|
+
* @type {string | undefined}
|
|
14
|
+
*/
|
|
15
|
+
let cwdOverride;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Default maximum buffered bytes per stdout/stderr stream: 512000 (500 KiB).
|
|
19
|
+
* Used by `SHExecute` when `options.maxBuffer` is unset or invalid.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_MAX_BUFFER = 500 * 1024;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default options applied to all SH commands unless overridden.
|
|
25
|
+
*
|
|
26
|
+
* `cwd` is intentionally lazy: the getter resolves `process.cwd()` at read
|
|
27
|
+
* time, so commands created after a `cd()` run in the new directory instead of
|
|
28
|
+
* the directory the process was started in. `SHDispatch` spreads these
|
|
29
|
+
* defaults (`{ ...defaultOptions }`) per command, which invokes the getter and
|
|
30
|
+
* freezes the value for that command — "defaults captured at creation time".
|
|
31
|
+
* Do not "optimize" this into a static snapshot.
|
|
32
|
+
*
|
|
33
|
+
* An explicit `SH.cwd = dir` assignment takes precedence over `process.cwd()`
|
|
34
|
+
* (it only redirects SH commands; it does not `chdir` the process). `cd()`
|
|
35
|
+
* clears the override again, so the most recent of the two always wins.
|
|
36
|
+
*
|
|
37
|
+
* `maxBuffer` and `detached` are initialized explicitly so every key the `SH`
|
|
38
|
+
* proxy exposes ({@link defaultOptionKeys}) also has a defined value here.
|
|
39
|
+
* This is behavior-neutral: `SHExecute` already fell back to
|
|
40
|
+
* `DEFAULT_MAX_BUFFER` for unset/invalid values and truthy-checks `detached`.
|
|
41
|
+
*
|
|
42
|
+
* @type {import('./SHDispatch.js').SHOptions}
|
|
43
|
+
*/
|
|
44
|
+
const defaultOptions = {
|
|
45
|
+
get cwd() {
|
|
46
|
+
return cwdOverride ?? process.cwd();
|
|
47
|
+
},
|
|
48
|
+
set cwd(value) {
|
|
49
|
+
cwdOverride = value;
|
|
50
|
+
},
|
|
51
|
+
env: process.env,
|
|
52
|
+
shell: 'bash',
|
|
53
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
54
|
+
timeout: 0, // when 0 there is no timeout
|
|
55
|
+
maxBuffer: DEFAULT_MAX_BUFFER,
|
|
56
|
+
detached: false,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Known global option keys exposed by the `SH` proxy in `lib/SH.js`.
|
|
61
|
+
*
|
|
62
|
+
* Derived from the own keys of {@link defaultOptions}, so the key set can
|
|
63
|
+
* never drift from the defaults. (The previous hard-coded list in `lib/SH.js`
|
|
64
|
+
* readable-exposed `maxBuffer`/`detached`, which were uninitialized in the
|
|
65
|
+
* defaults.) The proxy routes reads/writes for these keys to
|
|
66
|
+
* `defaultOptions` and throws a `TypeError` on writes to any other key
|
|
67
|
+
* (decision D1).
|
|
68
|
+
*
|
|
69
|
+
* @type {Set<string>}
|
|
70
|
+
*/
|
|
71
|
+
const defaultOptionKeys = new Set(Object.keys(defaultOptions));
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Clears the `SH.cwd` override so the lazy `cwd` getter follows
|
|
75
|
+
* `process.cwd()` again. Called by `cd()` after a successful
|
|
76
|
+
* `process.chdir()`.
|
|
77
|
+
*/
|
|
78
|
+
const clearCwdOverride = () => {
|
|
79
|
+
cwdOverride = undefined;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parses a human-readable duration into milliseconds.
|
|
84
|
+
*
|
|
85
|
+
* Accepts finite non-negative numbers (milliseconds) and strings in the exact
|
|
86
|
+
* forms `'Nms'`, `'Ns'`, or a bare `'N'`. The bare-number form is treated as
|
|
87
|
+
* milliseconds on purpose: it preserves the leniency of the former `SHExecute`
|
|
88
|
+
* parser, so e.g. `timeout: '100'` keeps working.
|
|
89
|
+
*
|
|
90
|
+
* `null`/`undefined` are not special-cased here; call sites that allow an
|
|
91
|
+
* absent duration handle it themselves (e.g. `parseDuration(timeout ?? 0)`).
|
|
92
|
+
*
|
|
93
|
+
* @param {number|string} d - Duration as number (ms) or string ('5s', '100ms', '100').
|
|
94
|
+
* @returns {number} Duration in milliseconds.
|
|
95
|
+
* @throws {Error} If the duration type or format is invalid.
|
|
96
|
+
*/
|
|
97
|
+
const parseDuration = (d) => {
|
|
98
|
+
if (typeof d == 'number') {
|
|
99
|
+
if (!Number.isFinite(d) || d < 0)
|
|
100
|
+
throw new Error(`Invalid duration: "${d}".`);
|
|
101
|
+
return d;
|
|
102
|
+
}
|
|
103
|
+
if (typeof d == 'string') {
|
|
104
|
+
const match = d.match(/^(\d+)(ms|s)?$/);
|
|
105
|
+
if (!match)
|
|
106
|
+
throw new Error(`Unknown duration: "${d}".`);
|
|
107
|
+
const amount = Number(match[1]);
|
|
108
|
+
return match[2] == 's' ? amount * 1000 : amount;
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`Invalid duration type: "${d === null ? 'Null' : typeof d}".`);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export { defaultOptions, defaultOptionKeys, clearCwdOverride, DEFAULT_MAX_BUFFER, parseDuration };
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@j-o-r/sh",
|
|
3
3
|
"author": "Jorrit Duin <j-o-r@duin.work>",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "1.1.
|
|
5
|
+
"version": "1.1.32",
|
|
6
6
|
"description": "Execute shell commands on Linux-based systems from javascript",
|
|
7
7
|
"main": "lib/SH.js",
|
|
8
8
|
"types": "types/SH.d.ts",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"license": "Apache License, Version 2.0",
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@types/node": "
|
|
25
|
+
"@types/node": "*"
|
|
26
26
|
},
|
|
27
27
|
"bugs": {
|
|
28
28
|
"url": "https://codeberg.org/duin/sh/issues"
|
|
@@ -49,4 +49,4 @@
|
|
|
49
49
|
"process-promise",
|
|
50
50
|
"process-output"
|
|
51
51
|
]
|
|
52
|
-
}
|
|
52
|
+
}
|