@j-o-r/sh 1.1.32 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/SH.js +20 -8
- package/lib/SHDispatch.js +6 -5
- package/lib/SHExecute.js +133 -73
- package/lib/internal.js +91 -25
- package/package.json +1 -1
- package/types/SH.d.ts +16 -6
- package/types/SHDispatch.d.ts +6 -5
- package/types/SHExecute.d.ts +33 -16
- package/types/internal.d.ts +39 -10
- package/.editorconfig +0 -21
- package/TODO.md +0 -355
package/lib/SH.js
CHANGED
|
@@ -31,7 +31,7 @@ import readline from 'node:readline/promises';
|
|
|
31
31
|
import SHDispatch from './SHDispatch.js';
|
|
32
32
|
import Test from './Test.js';
|
|
33
33
|
import AsyncTracker from './AsyncTracker.js';
|
|
34
|
-
import { defaultOptions, defaultOptionKeys, clearCwdOverride, parseDuration } from './internal.js';
|
|
34
|
+
import { defaultOptions, defaultOptionKeys, clearCwdOverride, parseDuration, optionScope } from './internal.js';
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Parsed command-line arguments.
|
|
@@ -189,23 +189,31 @@ const SH = new Proxy(function(pieces, ...args) {
|
|
|
189
189
|
}, defaultOptionHandler);
|
|
190
190
|
|
|
191
191
|
/**
|
|
192
|
-
* Awaits `callback()` and returns its result
|
|
192
|
+
* Awaits `callback()` and returns its result, running it in a fresh async
|
|
193
|
+
* context (decision D2).
|
|
193
194
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
195
|
+
* Real isolation via `AsyncLocalStorage`: the callback runs inside a new
|
|
196
|
+
* scope whose `SH.*` default-option assignments are scoped to the block. Any
|
|
197
|
+
* `SH.timeout = …`, `SH.cwd = …`, etc. made inside the callback apply only to
|
|
198
|
+
* commands created within it and are discarded when the block ends — they do
|
|
199
|
+
* not leak to the enclosing scope. Nested `within()` blocks inherit their
|
|
200
|
+
* parent's scoped defaults but never leak their own outward.
|
|
201
|
+
*
|
|
202
|
+
* Note: `cd()` is a genuine process-wide operation (`process.chdir`), so it is
|
|
203
|
+
* not scoped by `within()`; use `SH.cwd = dir` for a scoped working directory.
|
|
199
204
|
*
|
|
200
205
|
* @param {() => Promise<any>} callback - Async function to execute.
|
|
201
206
|
* @returns {Promise<any>} Result of callback.
|
|
202
207
|
* @example
|
|
203
208
|
* const results = await within(async () => {
|
|
209
|
+
* SH.timeout = 5000; // scoped to this block
|
|
204
210
|
* return Promise.all([SH`sleep 1; echo 1`.run(), sleep(2)]);
|
|
205
211
|
* });
|
|
206
212
|
*/
|
|
207
213
|
const within = async (callback) => {
|
|
208
|
-
|
|
214
|
+
const parent = optionScope.getStore();
|
|
215
|
+
const store = { ...parent };
|
|
216
|
+
return optionScope.run(store, callback);
|
|
209
217
|
};
|
|
210
218
|
|
|
211
219
|
/**
|
|
@@ -398,6 +406,10 @@ const sleep = (duration) => {
|
|
|
398
406
|
* `process.cwd()` lazily when each command is created. Also clears any
|
|
399
407
|
* `SH.cwd` override, so the most recent cwd change always wins.
|
|
400
408
|
*
|
|
409
|
+
* This is a genuine process-wide operation (`process.chdir`), so it is NOT
|
|
410
|
+
* scoped by {@link within}. Use `SH.cwd = dir` inside a `within()` block for a
|
|
411
|
+
* scoped working directory.
|
|
412
|
+
*
|
|
401
413
|
* @param {string} dir - Path to new directory.
|
|
402
414
|
* @example
|
|
403
415
|
* cd('/tmp');
|
package/lib/SHDispatch.js
CHANGED
|
@@ -24,7 +24,7 @@ import { defaultOptions } from './internal.js';
|
|
|
24
24
|
* - `env`: `process.env`
|
|
25
25
|
* - `shell`: `'bash'` (string/true → shell; false → no-shell `/usr/bin/env -S`)
|
|
26
26
|
* - `stdio`: `['inherit', 'pipe', 'pipe']`
|
|
27
|
-
* - `timeout`: `0` (no timeout;
|
|
27
|
+
* - `timeout`: `0` (no timeout; absolute wall-clock cap when set)
|
|
28
28
|
* - `maxBuffer`: `512000` (500 KiB per stream in SHExecute)
|
|
29
29
|
*
|
|
30
30
|
* Prefix (`.options(undefined, prefix)`) only for shell mode.
|
|
@@ -34,10 +34,11 @@ import { defaultOptions } from './internal.js';
|
|
|
34
34
|
* @property {NodeJS.ProcessEnv} env - Environment for spawned commands.
|
|
35
35
|
* @property {string|boolean} shell - Shell executable, true for bash, or false for no-shell mode.
|
|
36
36
|
* @property {StdioOptions} stdio - Stdio config passed to child_process.
|
|
37
|
-
* @property {number|string} timeout -
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* @property {number|string} timeout - Absolute wall-clock timeout in ms or
|
|
38
|
+
* duration string; 0 disables. The process is killed after the full duration
|
|
39
|
+
* no matter how much output it produces, in BOTH `run()` and `runSync()`.
|
|
40
|
+
* This matches Node's native `spawn`/`spawnSync` `timeout` semantics; SH is
|
|
41
|
+
* not meant to run long-lived services, so there is no rolling/idle reset.
|
|
41
42
|
* @property {number} [maxBuffer] - Maximum buffered bytes per stdout/stderr stream.
|
|
42
43
|
* @property {boolean} [detached] - Run process detached and resolve early (~1s).
|
|
43
44
|
* Unless stdio is explicitly set for the command, stdio is forced to
|
package/lib/SHExecute.js
CHANGED
|
@@ -2,31 +2,10 @@ import { spawnSync, spawn } from 'node:child_process';
|
|
|
2
2
|
import { DEFAULT_MAX_BUFFER, parseDuration } from './internal.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Best-effort: any pgrep failure resolves to an empty list so callers
|
|
8
|
-
* (notably `kill()`) always settle instead of hanging.
|
|
9
|
-
*
|
|
10
|
-
* @param {number} pid - Parent PID.
|
|
11
|
-
* @returns {Promise<number[]>} Array of child PIDs.
|
|
5
|
+
* Grace period (ms) before escalating SIGTERM → SIGKILL for a process group
|
|
6
|
+
* that is still alive after a graceful SIGTERM (i.e. it trapped/ignored it).
|
|
12
7
|
*/
|
|
13
|
-
const
|
|
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);
|
|
29
|
-
});
|
|
8
|
+
const ESCALATION_GRACE_MS = 1000;
|
|
30
9
|
|
|
31
10
|
/**
|
|
32
11
|
* @typedef {import('./SHDispatch.js').SHOptions} SHExecuteOptions
|
|
@@ -42,17 +21,27 @@ const childrenOf = (pid) => new Promise((resolve, reject) => {
|
|
|
42
21
|
* Key features:
|
|
43
22
|
* - **Shell mode**: If `options.shell` is string/true, runs `${prefix}; ${command}` via shell ('bash' default).
|
|
44
23
|
* - **No-shell mode**: Uses `/usr/bin/env -S ${command}` for direct exec (ignores prefix).
|
|
45
|
-
* - **
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
24
|
+
* - **Timeouts**: `options.timeout` (ms/'2s') is an absolute wall-clock timeout —
|
|
25
|
+
* the process is killed after the full duration regardless of output, in both
|
|
26
|
+
* `run()` and `runSync()`. It is stripped from the spawn options so Node's
|
|
27
|
+
* native absolute spawn timeout never interferes with the custom timer (which
|
|
28
|
+
* reports a clear `Process timed out after <N>ms.` error and tears down the
|
|
29
|
+
* whole process group via `kill()`). `runSync()` passes it to `spawnSync`, whose
|
|
30
|
+
* native semantics are the same (absolute).
|
|
31
|
+
* - **Process-group teardown**: The child is always spawned as a new process-group
|
|
32
|
+
* leader (`detached: true` in the spawn options, independent of the public
|
|
33
|
+
* `detached` option). `kill()` sends the signal to the whole group via a
|
|
34
|
+
* negative PID, tearing down the entire tree (children, grandchildren, …) in
|
|
35
|
+
* one shot — no `pgrep` discovery needed. A graceful SIGTERM is escalated to
|
|
36
|
+
* SIGKILL after {@link ESCALATION_GRACE_MS} for processes that ignore SIGTERM.
|
|
50
37
|
* - **Buffering**: Captures stdout/stderr up to `maxBuffer` (512000 bytes / 500 KiB default); appends truncation markers.
|
|
51
38
|
* - **Payload**: `run(payload)` writes string to stdin (forces pipe).
|
|
52
|
-
* - **Detached**: If `options.detached
|
|
53
|
-
* caller explicitly set `stdio`, SHDispatch forces
|
|
54
|
-
* runs, because open pipes keep the parent's
|
|
55
|
-
*
|
|
39
|
+
* - **Detached**: If the public `options.detached` is set, resolves early (~1s)
|
|
40
|
+
* and unrefs. Unless the caller explicitly set `stdio`, SHDispatch forces
|
|
41
|
+
* `stdio: 'ignore'` for detached runs, because open pipes keep the parent's
|
|
42
|
+
* event loop alive and defeat detachment.
|
|
43
|
+
* - **Kill**: Terminates the whole process group (negative-PID kill) and
|
|
44
|
+
* escalates SIGTERM → SIGKILL after a grace period.
|
|
56
45
|
*
|
|
57
46
|
* @example
|
|
58
47
|
* const exec = new SHExecute('ls', 'set -euo pipefail', { timeout: '5s' });
|
|
@@ -73,8 +62,10 @@ class SHExecute {
|
|
|
73
62
|
#maxBuffer = DEFAULT_MAX_BUFFER; // 512000 bytes (500 KiB) per stream
|
|
74
63
|
#truncated = { stdout: false, stderr: false };
|
|
75
64
|
#timedOut = false;
|
|
76
|
-
/**
|
|
65
|
+
/** Absolute wall-clock timeout in ms; 0 disables. Never resets on output. */
|
|
77
66
|
#timeout = 0;
|
|
67
|
+
/** Pending SIGTERM→SIGKILL escalation timer (set by `kill('SIGTERM')`). */
|
|
68
|
+
#escalationTimer = null;
|
|
78
69
|
|
|
79
70
|
/**
|
|
80
71
|
* @param {string} command - Command to execute.
|
|
@@ -84,9 +75,9 @@ class SHExecute {
|
|
|
84
75
|
constructor(command, prefix, options = {}) {
|
|
85
76
|
this.#prefix = prefix;
|
|
86
77
|
this.#command = command;
|
|
87
|
-
// maxBuffer and timeout are SH-level options, not spawn options.
|
|
88
|
-
// particular must not reach spawn(): since Node 15.5 it triggers a
|
|
89
|
-
// absolute timeout that would defeat the
|
|
78
|
+
// maxBuffer and timeout are SH-level options, not spawn options.
|
|
79
|
+
// timeout in particular must not reach spawn(): since Node 15.5 it triggers a
|
|
80
|
+
// native absolute timeout that would defeat the custom timer in run().
|
|
90
81
|
const { maxBuffer, timeout, ...spawnOpts } = options ?? {};
|
|
91
82
|
this.#maxBuffer = Number.isFinite(maxBuffer) && maxBuffer > 0 ? maxBuffer : this.#maxBuffer;
|
|
92
83
|
this.#timeout = parseDuration(timeout ?? 0);
|
|
@@ -96,6 +87,10 @@ class SHExecute {
|
|
|
96
87
|
/**
|
|
97
88
|
* Synchronous execution.
|
|
98
89
|
*
|
|
90
|
+
* Process-group teardown does not apply here: `spawnSync` blocks until the
|
|
91
|
+
* process exits or its native absolute `timeout` fires, so there is no async
|
|
92
|
+
* kill to escalate. Grandchildren may survive a `spawnSync` timeout.
|
|
93
|
+
*
|
|
99
94
|
* @param {string} [payload] - Stdin data (forces pipe).
|
|
100
95
|
* @returns {import('child_process').SpawnSyncReturns<Buffer>}
|
|
101
96
|
* @throws {Error} Invalid payload type.
|
|
@@ -107,8 +102,9 @@ class SHExecute {
|
|
|
107
102
|
}
|
|
108
103
|
/** @type {import('node:child_process').SpawnSyncOptions} */
|
|
109
104
|
const options = { ...this.#options, shell: false };
|
|
105
|
+
// spawnSync supports timeout natively; semantics are absolute (no rolling
|
|
106
|
+
// reset), matching the async run().
|
|
110
107
|
if (this.#timeout > 0) {
|
|
111
|
-
// spawnSync supports timeout natively; semantics are absolute (no rolling reset).
|
|
112
108
|
options.timeout = this.#timeout;
|
|
113
109
|
}
|
|
114
110
|
if (payload) {
|
|
@@ -137,16 +133,22 @@ class SHExecute {
|
|
|
137
133
|
* @param {string} [payload] - Stdin data (forces pipe).
|
|
138
134
|
* @returns {Promise<string>} Trimmed UTF-8 stdout (+ truncation marker if exceeded).
|
|
139
135
|
* Rejects with an Error on command failure (message includes the exit code,
|
|
140
|
-
* or the signal name for signal kills, plus
|
|
141
|
-
*
|
|
136
|
+
* or the signal name for signal kills, plus any already-received stdout and
|
|
137
|
+
* stderr), timeout expiry, or forced kill. On timeout/forced-kill the
|
|
138
|
+
* already-received stdout/stderr is preserved in the error message.
|
|
142
139
|
*/
|
|
143
140
|
run(payload) {
|
|
144
141
|
this.#forcedKill = false;
|
|
145
142
|
if (payload && typeof payload !== 'string') {
|
|
146
143
|
throw new Error('Argument is not a string');
|
|
147
144
|
}
|
|
145
|
+
// The public `detached` option only controls the early-resolve (~1s)
|
|
146
|
+
// behavior below. The spawn flag is always forced to `detached: true` so
|
|
147
|
+
// the child becomes a new process-group leader and the whole tree can be
|
|
148
|
+
// torn down via a negative-PID kill.
|
|
149
|
+
const publicDetached = this.#options.detached;
|
|
148
150
|
/** @type {import('child_process').SpawnOptions} */
|
|
149
|
-
const options = { ...this.#options, shell: false };
|
|
151
|
+
const options = { ...this.#options, shell: false, detached: true };
|
|
150
152
|
if (payload) {
|
|
151
153
|
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
|
|
152
154
|
stdio[0] = 'pipe';
|
|
@@ -161,6 +163,7 @@ class SHExecute {
|
|
|
161
163
|
this.#stderrLen = 0;
|
|
162
164
|
this.#truncated = { stdout: false, stderr: false };
|
|
163
165
|
this.#timedOut = false;
|
|
166
|
+
this.#escalationTimer = null;
|
|
164
167
|
|
|
165
168
|
const shellOpt = this.#options?.shell;
|
|
166
169
|
if (shellOpt) {
|
|
@@ -173,19 +176,19 @@ class SHExecute {
|
|
|
173
176
|
|
|
174
177
|
if (payload) this.#proc.stdin?.end(payload);
|
|
175
178
|
|
|
176
|
-
const
|
|
177
|
-
let
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
179
|
+
const timeoutMs = this.#timeout;
|
|
180
|
+
let timeoutTimer = null;
|
|
181
|
+
/** @type {number} The timeout (ms) that actually fired, for the error message. */
|
|
182
|
+
let firedMs = 0;
|
|
183
|
+
|
|
184
|
+
const killOnTimeout = (ms) => {
|
|
185
|
+
firedMs = ms;
|
|
186
|
+
this.#timedOut = true;
|
|
187
|
+
this.kill('SIGTERM').catch(() => {});
|
|
186
188
|
};
|
|
187
189
|
|
|
188
|
-
|
|
190
|
+
// Timeout is absolute: never reset on output.
|
|
191
|
+
if (timeoutMs > 0) timeoutTimer = setTimeout(() => killOnTimeout(timeoutMs), timeoutMs);
|
|
189
192
|
|
|
190
193
|
this.#proc.stdout?.on('data', (chunk) => {
|
|
191
194
|
this.#stdoutLen += chunk.length;
|
|
@@ -195,7 +198,6 @@ class SHExecute {
|
|
|
195
198
|
} else {
|
|
196
199
|
this.#truncated.stdout = true;
|
|
197
200
|
}
|
|
198
|
-
resetTimeout();
|
|
199
201
|
});
|
|
200
202
|
|
|
201
203
|
this.#proc.stderr?.on('data', (chunk) => {
|
|
@@ -205,13 +207,12 @@ class SHExecute {
|
|
|
205
207
|
} else {
|
|
206
208
|
this.#truncated.stderr = true;
|
|
207
209
|
}
|
|
208
|
-
resetTimeout();
|
|
209
210
|
});
|
|
210
211
|
|
|
211
212
|
return new Promise((resolve, reject) => {
|
|
212
|
-
if (
|
|
213
|
+
if (publicDetached) {
|
|
213
214
|
setTimeout(() => {
|
|
214
|
-
if (
|
|
215
|
+
if (timeoutTimer !== null) clearTimeout(timeoutTimer);
|
|
215
216
|
resolve('');
|
|
216
217
|
// #proc may already be null when kill() ran within the 1s window.
|
|
217
218
|
this.#proc?.unref();
|
|
@@ -219,15 +220,19 @@ class SHExecute {
|
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
this.#proc.on('close', (code, signal) => {
|
|
222
|
-
if (
|
|
223
|
+
if (timeoutTimer !== null) clearTimeout(timeoutTimer);
|
|
224
|
+
if (this.#escalationTimer !== null) {
|
|
225
|
+
clearTimeout(this.#escalationTimer);
|
|
226
|
+
this.#escalationTimer = null;
|
|
227
|
+
}
|
|
223
228
|
// Check timeout first: the timeout path kills with SIGTERM, which sets
|
|
224
229
|
// #forcedKill in the same tick; #forcedKill must not mask the timeout.
|
|
225
230
|
if (this.#timedOut) {
|
|
226
|
-
reject(new Error(`Process timed out after ${
|
|
231
|
+
reject(new Error(this.#errorMessage(`Process timed out after ${firedMs}ms.`)));
|
|
227
232
|
return;
|
|
228
233
|
}
|
|
229
234
|
if (this.#forcedKill) {
|
|
230
|
-
reject(new Error('Process killed (forced).'));
|
|
235
|
+
reject(new Error(this.#errorMessage('Process killed (forced).')));
|
|
231
236
|
return;
|
|
232
237
|
}
|
|
233
238
|
if (code === 0) {
|
|
@@ -236,11 +241,8 @@ class SHExecute {
|
|
|
236
241
|
if (this.#truncated.stdout) stdout += '\n[stdout truncated]\n';
|
|
237
242
|
resolve(stdout);
|
|
238
243
|
} 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
244
|
const reason = code === null ? `signal ${signal}` : `code ${code}`;
|
|
243
|
-
reject(new Error(`Command failed with ${reason}
|
|
245
|
+
reject(new Error(this.#errorMessage(`Command failed with ${reason}.`)));
|
|
244
246
|
}
|
|
245
247
|
});
|
|
246
248
|
|
|
@@ -249,14 +251,16 @@ class SHExecute {
|
|
|
249
251
|
}
|
|
250
252
|
|
|
251
253
|
/**
|
|
252
|
-
* Terminates
|
|
254
|
+
* Terminates the whole process group (negative-PID kill).
|
|
253
255
|
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* pgrep
|
|
256
|
+
* The child is spawned as a process-group leader, so a negative-PID kill
|
|
257
|
+
* tears down the entire tree (children, grandchildren, …) in one shot — no
|
|
258
|
+
* `pgrep` discovery required. A graceful SIGTERM is escalated to SIGKILL
|
|
259
|
+
* after {@link ESCALATION_GRACE_MS} for processes that ignore SIGTERM.
|
|
260
|
+
* Best-effort: an already-exited group (ESRCH) is treated as a no-op.
|
|
257
261
|
*
|
|
258
262
|
* @param {number | string} [signal='SIGTERM'] - Signal to send.
|
|
259
|
-
* @returns {Promise<number[]>} Killed PIDs.
|
|
263
|
+
* @returns {Promise<number[]>} Killed PIDs (the group-leader PID).
|
|
260
264
|
* @throws {Error} No process/PID.
|
|
261
265
|
*/
|
|
262
266
|
async kill(signal = 'SIGTERM') {
|
|
@@ -264,18 +268,74 @@ class SHExecute {
|
|
|
264
268
|
if (!this.#proc.pid) throw new Error('The process pid is undefined.');
|
|
265
269
|
this.#forcedKill = true;
|
|
266
270
|
const pid = this.#proc.pid;
|
|
267
|
-
|
|
271
|
+
const killed = [];
|
|
268
272
|
try {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
273
|
+
// Negative PID = the whole process group.
|
|
274
|
+
try { process.kill(-pid, signal); killed.push(pid); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
275
|
+
// Escalate SIGTERM → SIGKILL after a grace period for processes that ignore SIGTERM.
|
|
276
|
+
if (signal === 'SIGTERM') {
|
|
277
|
+
this.#scheduleEscalation(pid);
|
|
272
278
|
}
|
|
273
|
-
try { process.kill(pid, signal); killed.push(pid); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
274
279
|
} finally {
|
|
275
280
|
this.#proc = null;
|
|
276
281
|
}
|
|
277
282
|
return killed;
|
|
278
283
|
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Schedules a SIGKILL for the process group after {@link ESCALATION_GRACE_MS}
|
|
287
|
+
* if it is still alive (i.e. it ignored the earlier SIGTERM).
|
|
288
|
+
*
|
|
289
|
+
* @private
|
|
290
|
+
* @param {number} pid - Process-group leader PID.
|
|
291
|
+
*/
|
|
292
|
+
#scheduleEscalation(pid) {
|
|
293
|
+
this.#escalationTimer = setTimeout(() => {
|
|
294
|
+
this.#escalationTimer = null;
|
|
295
|
+
try {
|
|
296
|
+
// Signal 0 probes whether the group still exists (ESRCH = gone).
|
|
297
|
+
process.kill(-pid, 0);
|
|
298
|
+
// Still alive — it ignored SIGTERM; force-kill the whole group.
|
|
299
|
+
process.kill(-pid, 'SIGKILL');
|
|
300
|
+
} catch (e) {
|
|
301
|
+
// ESRCH: the group already died from SIGTERM — nothing to escalate.
|
|
302
|
+
// Other errors (e.g. EPERM) are best-effort; teardown stays non-fatal.
|
|
303
|
+
}
|
|
304
|
+
}, ESCALATION_GRACE_MS);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Builds the already-received stdout/stderr (with truncation markers) as a
|
|
309
|
+
* readable `[stdout]`/`[stderr]` block, so content received before a
|
|
310
|
+
* timeout/kill/failure is preserved in the error message.
|
|
311
|
+
*
|
|
312
|
+
* @private
|
|
313
|
+
* @returns {string} Formatted output block, or '' when nothing was received.
|
|
314
|
+
*/
|
|
315
|
+
#formatOutput() {
|
|
316
|
+
const parts = [];
|
|
317
|
+
const stdoutBuf = this.#stdoutChunks.length ? Buffer.concat(this.#stdoutChunks) : Buffer.alloc(0);
|
|
318
|
+
let stdout = stdoutBuf.toString('utf8');
|
|
319
|
+
if (this.#truncated.stdout) stdout += '\n[stdout truncated]\n';
|
|
320
|
+
if (stdout) parts.push(`[stdout]\n${stdout}`);
|
|
321
|
+
const stderrBuf = this.#stderrChunks.length ? Buffer.concat(this.#stderrChunks) : Buffer.alloc(0);
|
|
322
|
+
let stderr = stderrBuf.toString('utf8');
|
|
323
|
+
if (this.#truncated.stderr) stderr += '\n[stderr truncated]\n';
|
|
324
|
+
if (stderr) parts.push(`[stderr]\n${stderr}`);
|
|
325
|
+
return parts.join('\n');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Prefixes an error message with the received-output block when present.
|
|
330
|
+
*
|
|
331
|
+
* @private
|
|
332
|
+
* @param {string} prefix - The primary error message (e.g. 'Process timed out after 500ms.').
|
|
333
|
+
* @returns {string} `prefix` alone, or `prefix` + the formatted output block.
|
|
334
|
+
*/
|
|
335
|
+
#errorMessage(prefix) {
|
|
336
|
+
const output = this.#formatOutput();
|
|
337
|
+
return output ? `${prefix}\n${output}` : prefix;
|
|
338
|
+
}
|
|
279
339
|
}
|
|
280
340
|
|
|
281
341
|
export default SHExecute;
|
package/lib/internal.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* Not part of the public API; nothing here is re-exported from `lib/SH.js`.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
13
|
* Explicit default `cwd` override, set via `SH.cwd = dir`.
|
|
12
14
|
*
|
|
@@ -20,41 +22,105 @@ let cwdOverride;
|
|
|
20
22
|
*/
|
|
21
23
|
const DEFAULT_MAX_BUFFER = 500 * 1024;
|
|
22
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Global (non-scoped) default option values, excluding the lazy `cwd`.
|
|
27
|
+
*
|
|
28
|
+
* `env` is a live reference to `process.env` by default. These are the values
|
|
29
|
+
* read/written by the `SH` proxy when no `within()` scope is active.
|
|
30
|
+
*
|
|
31
|
+
* @type {Omit<import('./SHDispatch.js').SHOptions, 'cwd'>}
|
|
32
|
+
*/
|
|
33
|
+
const globalDefaults = {
|
|
34
|
+
env: process.env,
|
|
35
|
+
shell: 'bash',
|
|
36
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
37
|
+
timeout: 0, // absolute wall-clock timeout; 0 disables
|
|
38
|
+
maxBuffer: DEFAULT_MAX_BUFFER,
|
|
39
|
+
detached: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Per-`within()`-block option overrides.
|
|
44
|
+
*
|
|
45
|
+
* A plain object keyed by option name. A key present in the store shadows the
|
|
46
|
+
* global default for every command created inside the `within()` block; a key
|
|
47
|
+
* absent from the store inherits the enclosing scope (or the global default).
|
|
48
|
+
*
|
|
49
|
+
* @typedef {Object<string, any>} OptionScopeStore
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* AsyncLocalStorage backing `within()` scoping (decision D2).
|
|
54
|
+
*
|
|
55
|
+
* `within()` runs its callback inside a fresh store seeded with a shallow copy
|
|
56
|
+
* of the enclosing store, so nested blocks inherit their parent's scoped
|
|
57
|
+
* values but never leak their own assignments outward. `getDefault`/`setDefault`
|
|
58
|
+
* consult the current store first and fall back to the global defaults.
|
|
59
|
+
*
|
|
60
|
+
* @type {AsyncLocalStorage<OptionScopeStore>}
|
|
61
|
+
*/
|
|
62
|
+
const optionScope = new AsyncLocalStorage();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Reads the effective default for a key, honoring the current `within()` scope.
|
|
66
|
+
*
|
|
67
|
+
* A key present in the active scope store shadows the global default; `cwd`
|
|
68
|
+
* additionally falls back to the lazy `process.cwd()` resolution.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} key - Option key.
|
|
71
|
+
* @returns {any} Effective default value.
|
|
72
|
+
*/
|
|
73
|
+
const getDefault = (key) => {
|
|
74
|
+
const store = optionScope.getStore();
|
|
75
|
+
if (store && store[key] !== undefined) return store[key];
|
|
76
|
+
if (key === 'cwd') return cwdOverride ?? process.cwd();
|
|
77
|
+
return globalDefaults[key];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Writes a default, scoping to the current `within()` block when active.
|
|
82
|
+
*
|
|
83
|
+
* Inside a `within()` block the assignment lands in the block's store and is
|
|
84
|
+
* discarded when the block ends; outside it mutates the global default.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} key - Option key.
|
|
87
|
+
* @param {any} value - New default value.
|
|
88
|
+
*/
|
|
89
|
+
const setDefault = (key, value) => {
|
|
90
|
+
const store = optionScope.getStore();
|
|
91
|
+
if (store) { store[key] = value; return; }
|
|
92
|
+
if (key === 'cwd') { cwdOverride = value; return; }
|
|
93
|
+
globalDefaults[key] = value;
|
|
94
|
+
};
|
|
95
|
+
|
|
23
96
|
/**
|
|
24
97
|
* Default options applied to all SH commands unless overridden.
|
|
25
98
|
*
|
|
26
99
|
* `cwd` is intentionally lazy: the getter resolves `process.cwd()` at read
|
|
27
100
|
* time, so commands created after a `cd()` run in the new directory instead of
|
|
28
101
|
* the directory the process was started in. `SHDispatch` spreads these
|
|
29
|
-
* defaults (`{ ...defaultOptions }`) per command, which invokes the
|
|
30
|
-
* freezes the
|
|
102
|
+
* defaults (`{ ...defaultOptions }`) per command, which invokes the getters and
|
|
103
|
+
* freezes the values for that command — "defaults captured at creation time".
|
|
31
104
|
* Do not "optimize" this into a static snapshot.
|
|
32
105
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* `
|
|
38
|
-
*
|
|
39
|
-
* This is behavior-neutral: `SHExecute` already fell back to
|
|
40
|
-
* `DEFAULT_MAX_BUFFER` for unset/invalid values and truthy-checks `detached`.
|
|
106
|
+
* Every getter/setter is scope-aware: inside a `within()` block it reads/writes
|
|
107
|
+
* the block's scoped override (see {@link optionScope}); outside it reads/writes
|
|
108
|
+
* the global default. An explicit `SH.cwd = dir` assignment takes precedence
|
|
109
|
+
* over `process.cwd()` (it only redirects SH commands; it does not `chdir` the
|
|
110
|
+
* process). `cd()` clears the override again, so the most recent of the two
|
|
111
|
+
* always wins.
|
|
41
112
|
*
|
|
42
113
|
* @type {import('./SHDispatch.js').SHOptions}
|
|
43
114
|
*/
|
|
44
|
-
const defaultOptions = {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
stdio: ['inherit', 'pipe', 'pipe'],
|
|
54
|
-
timeout: 0, // when 0 there is no timeout
|
|
55
|
-
maxBuffer: DEFAULT_MAX_BUFFER,
|
|
56
|
-
detached: false,
|
|
57
|
-
};
|
|
115
|
+
const defaultOptions = {};
|
|
116
|
+
for (const key of ['cwd', ...Object.keys(globalDefaults)]) {
|
|
117
|
+
Object.defineProperty(defaultOptions, key, {
|
|
118
|
+
enumerable: true,
|
|
119
|
+
configurable: true,
|
|
120
|
+
get() { return getDefault(key); },
|
|
121
|
+
set(value) { setDefault(key, value); }
|
|
122
|
+
});
|
|
123
|
+
}
|
|
58
124
|
|
|
59
125
|
/**
|
|
60
126
|
* Known global option keys exposed by the `SH` proxy in `lib/SH.js`.
|
|
@@ -110,4 +176,4 @@ const parseDuration = (d) => {
|
|
|
110
176
|
throw new Error(`Invalid duration type: "${d === null ? 'Null' : typeof d}".`);
|
|
111
177
|
};
|
|
112
178
|
|
|
113
|
-
export { defaultOptions, defaultOptionKeys, clearCwdOverride, DEFAULT_MAX_BUFFER, parseDuration };
|
|
179
|
+
export { defaultOptions, defaultOptionKeys, clearCwdOverride, DEFAULT_MAX_BUFFER, parseDuration, optionScope };
|
package/package.json
CHANGED
package/types/SH.d.ts
CHANGED
|
@@ -69,6 +69,10 @@ export const SH: any;
|
|
|
69
69
|
* `process.cwd()` lazily when each command is created. Also clears any
|
|
70
70
|
* `SH.cwd` override, so the most recent cwd change always wins.
|
|
71
71
|
*
|
|
72
|
+
* This is a genuine process-wide operation (`process.chdir`), so it is NOT
|
|
73
|
+
* scoped by {@link within}. Use `SH.cwd = dir` inside a `within()` block for a
|
|
74
|
+
* scoped working directory.
|
|
75
|
+
*
|
|
72
76
|
* @param {string} dir - Path to new directory.
|
|
73
77
|
* @example
|
|
74
78
|
* cd('/tmp');
|
|
@@ -135,18 +139,24 @@ export function readIn(): Promise<string>;
|
|
|
135
139
|
*/
|
|
136
140
|
export function userIn(prompt: string): AbortableInput;
|
|
137
141
|
/**
|
|
138
|
-
* Awaits `callback()` and returns its result
|
|
142
|
+
* Awaits `callback()` and returns its result, running it in a fresh async
|
|
143
|
+
* context (decision D2).
|
|
144
|
+
*
|
|
145
|
+
* Real isolation via `AsyncLocalStorage`: the callback runs inside a new
|
|
146
|
+
* scope whose `SH.*` default-option assignments are scoped to the block. Any
|
|
147
|
+
* `SH.timeout = …`, `SH.cwd = …`, etc. made inside the callback apply only to
|
|
148
|
+
* commands created within it and are discarded when the block ends — they do
|
|
149
|
+
* not leak to the enclosing scope. Nested `within()` blocks inherit their
|
|
150
|
+
* parent's scoped defaults but never leak their own outward.
|
|
139
151
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* one. It is kept as an intent marker for grouping async work (and for API
|
|
143
|
-
* compatibility); real isolation via AsyncLocalStorage is a pending decision
|
|
144
|
-
* (see the project TODO, decision D2).
|
|
152
|
+
* Note: `cd()` is a genuine process-wide operation (`process.chdir`), so it is
|
|
153
|
+
* not scoped by `within()`; use `SH.cwd = dir` for a scoped working directory.
|
|
145
154
|
*
|
|
146
155
|
* @param {() => Promise<any>} callback - Async function to execute.
|
|
147
156
|
* @returns {Promise<any>} Result of callback.
|
|
148
157
|
* @example
|
|
149
158
|
* const results = await within(async () => {
|
|
159
|
+
* SH.timeout = 5000; // scoped to this block
|
|
150
160
|
* return Promise.all([SH`sleep 1; echo 1`.run(), sleep(2)]);
|
|
151
161
|
* });
|
|
152
162
|
*/
|
package/types/SHDispatch.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type StdioOptions = Array<StdioOption> | StdioOption;
|
|
|
9
9
|
* - `env`: `process.env`
|
|
10
10
|
* - `shell`: `'bash'` (string/true → shell; false → no-shell `/usr/bin/env -S`)
|
|
11
11
|
* - `stdio`: `['inherit', 'pipe', 'pipe']`
|
|
12
|
-
* - `timeout`: `0` (no timeout;
|
|
12
|
+
* - `timeout`: `0` (no timeout; absolute wall-clock cap when set)
|
|
13
13
|
* - `maxBuffer`: `512000` (500 KiB per stream in SHExecute)
|
|
14
14
|
*
|
|
15
15
|
* Prefix (`.options(undefined, prefix)`) only for shell mode.
|
|
@@ -32,10 +32,11 @@ export type SHOptions = {
|
|
|
32
32
|
*/
|
|
33
33
|
stdio: StdioOptions;
|
|
34
34
|
/**
|
|
35
|
-
* -
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
35
|
+
* - Absolute wall-clock timeout in ms or
|
|
36
|
+
* duration string; 0 disables. The process is killed after the full duration
|
|
37
|
+
* no matter how much output it produces, in BOTH `run()` and `runSync()`.
|
|
38
|
+
* This matches Node's native `spawn`/`spawnSync` `timeout` semantics; SH is
|
|
39
|
+
* not meant to run long-lived services, so there is no rolling/idle reset.
|
|
39
40
|
*/
|
|
40
41
|
timeout: number | string;
|
|
41
42
|
/**
|
package/types/SHExecute.d.ts
CHANGED
|
@@ -13,17 +13,27 @@ export type SHExecuteOptions = import("./SHDispatch.js").SHOptions;
|
|
|
13
13
|
* Key features:
|
|
14
14
|
* - **Shell mode**: If `options.shell` is string/true, runs `${prefix}; ${command}` via shell ('bash' default).
|
|
15
15
|
* - **No-shell mode**: Uses `/usr/bin/env -S ${command}` for direct exec (ignores prefix).
|
|
16
|
-
* - **
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* - **Timeouts**: `options.timeout` (ms/'2s') is an absolute wall-clock timeout —
|
|
17
|
+
* the process is killed after the full duration regardless of output, in both
|
|
18
|
+
* `run()` and `runSync()`. It is stripped from the spawn options so Node's
|
|
19
|
+
* native absolute spawn timeout never interferes with the custom timer (which
|
|
20
|
+
* reports a clear `Process timed out after <N>ms.` error and tears down the
|
|
21
|
+
* whole process group via `kill()`). `runSync()` passes it to `spawnSync`, whose
|
|
22
|
+
* native semantics are the same (absolute).
|
|
23
|
+
* - **Process-group teardown**: The child is always spawned as a new process-group
|
|
24
|
+
* leader (`detached: true` in the spawn options, independent of the public
|
|
25
|
+
* `detached` option). `kill()` sends the signal to the whole group via a
|
|
26
|
+
* negative PID, tearing down the entire tree (children, grandchildren, …) in
|
|
27
|
+
* one shot — no `pgrep` discovery needed. A graceful SIGTERM is escalated to
|
|
28
|
+
* SIGKILL after {@link ESCALATION_GRACE_MS} for processes that ignore SIGTERM.
|
|
21
29
|
* - **Buffering**: Captures stdout/stderr up to `maxBuffer` (512000 bytes / 500 KiB default); appends truncation markers.
|
|
22
30
|
* - **Payload**: `run(payload)` writes string to stdin (forces pipe).
|
|
23
|
-
* - **Detached**: If `options.detached
|
|
24
|
-
* caller explicitly set `stdio`, SHDispatch forces
|
|
25
|
-
* runs, because open pipes keep the parent's
|
|
26
|
-
*
|
|
31
|
+
* - **Detached**: If the public `options.detached` is set, resolves early (~1s)
|
|
32
|
+
* and unrefs. Unless the caller explicitly set `stdio`, SHDispatch forces
|
|
33
|
+
* `stdio: 'ignore'` for detached runs, because open pipes keep the parent's
|
|
34
|
+
* event loop alive and defeat detachment.
|
|
35
|
+
* - **Kill**: Terminates the whole process group (negative-PID kill) and
|
|
36
|
+
* escalates SIGTERM → SIGKILL after a grace period.
|
|
27
37
|
*
|
|
28
38
|
* @example
|
|
29
39
|
* const exec = new SHExecute('ls', 'set -euo pipefail', { timeout: '5s' });
|
|
@@ -39,6 +49,10 @@ declare class SHExecute {
|
|
|
39
49
|
/**
|
|
40
50
|
* Synchronous execution.
|
|
41
51
|
*
|
|
52
|
+
* Process-group teardown does not apply here: `spawnSync` blocks until the
|
|
53
|
+
* process exits or its native absolute `timeout` fires, so there is no async
|
|
54
|
+
* kill to escalate. Grandchildren may survive a `spawnSync` timeout.
|
|
55
|
+
*
|
|
42
56
|
* @param {string} [payload] - Stdin data (forces pipe).
|
|
43
57
|
* @returns {import('child_process').SpawnSyncReturns<Buffer>}
|
|
44
58
|
* @throws {Error} Invalid payload type.
|
|
@@ -52,19 +66,22 @@ declare class SHExecute {
|
|
|
52
66
|
* @param {string} [payload] - Stdin data (forces pipe).
|
|
53
67
|
* @returns {Promise<string>} Trimmed UTF-8 stdout (+ truncation marker if exceeded).
|
|
54
68
|
* Rejects with an Error on command failure (message includes the exit code,
|
|
55
|
-
* or the signal name for signal kills, plus
|
|
56
|
-
*
|
|
69
|
+
* or the signal name for signal kills, plus any already-received stdout and
|
|
70
|
+
* stderr), timeout expiry, or forced kill. On timeout/forced-kill the
|
|
71
|
+
* already-received stdout/stderr is preserved in the error message.
|
|
57
72
|
*/
|
|
58
73
|
run(payload?: string): Promise<string>;
|
|
59
74
|
/**
|
|
60
|
-
* Terminates
|
|
75
|
+
* Terminates the whole process group (negative-PID kill).
|
|
61
76
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* pgrep
|
|
77
|
+
* The child is spawned as a process-group leader, so a negative-PID kill
|
|
78
|
+
* tears down the entire tree (children, grandchildren, …) in one shot — no
|
|
79
|
+
* `pgrep` discovery required. A graceful SIGTERM is escalated to SIGKILL
|
|
80
|
+
* after {@link ESCALATION_GRACE_MS} for processes that ignore SIGTERM.
|
|
81
|
+
* Best-effort: an already-exited group (ESRCH) is treated as a no-op.
|
|
65
82
|
*
|
|
66
83
|
* @param {number | string} [signal='SIGTERM'] - Signal to send.
|
|
67
|
-
* @returns {Promise<number[]>} Killed PIDs.
|
|
84
|
+
* @returns {Promise<number[]>} Killed PIDs (the group-leader PID).
|
|
68
85
|
* @throws {Error} No process/PID.
|
|
69
86
|
*/
|
|
70
87
|
kill(signal?: number | string): Promise<number[]>;
|
package/types/internal.d.ts
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-`within()`-block option overrides.
|
|
3
|
+
*
|
|
4
|
+
* A plain object keyed by option name. A key present in the store shadows the
|
|
5
|
+
* global default for every command created inside the `within()` block; a key
|
|
6
|
+
* absent from the store inherits the enclosing scope (or the global default).
|
|
7
|
+
*/
|
|
8
|
+
export type OptionScopeStore = {
|
|
9
|
+
[x: string]: any;
|
|
10
|
+
};
|
|
1
11
|
/**
|
|
2
12
|
* Default options applied to all SH commands unless overridden.
|
|
3
13
|
*
|
|
4
14
|
* `cwd` is intentionally lazy: the getter resolves `process.cwd()` at read
|
|
5
15
|
* time, so commands created after a `cd()` run in the new directory instead of
|
|
6
16
|
* the directory the process was started in. `SHDispatch` spreads these
|
|
7
|
-
* defaults (`{ ...defaultOptions }`) per command, which invokes the
|
|
8
|
-
* freezes the
|
|
17
|
+
* defaults (`{ ...defaultOptions }`) per command, which invokes the getters and
|
|
18
|
+
* freezes the values for that command — "defaults captured at creation time".
|
|
9
19
|
* Do not "optimize" this into a static snapshot.
|
|
10
20
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
* This is behavior-neutral: `SHExecute` already fell back to
|
|
18
|
-
* `DEFAULT_MAX_BUFFER` for unset/invalid values and truthy-checks `detached`.
|
|
21
|
+
* Every getter/setter is scope-aware: inside a `within()` block it reads/writes
|
|
22
|
+
* the block's scoped override (see {@link optionScope}); outside it reads/writes
|
|
23
|
+
* the global default. An explicit `SH.cwd = dir` assignment takes precedence
|
|
24
|
+
* over `process.cwd()` (it only redirects SH commands; it does not `chdir` the
|
|
25
|
+
* process). `cd()` clears the override again, so the most recent of the two
|
|
26
|
+
* always wins.
|
|
19
27
|
*
|
|
20
28
|
* @type {import('./SHDispatch.js').SHOptions}
|
|
21
29
|
*/
|
|
@@ -60,3 +68,24 @@ export const DEFAULT_MAX_BUFFER: number;
|
|
|
60
68
|
* @throws {Error} If the duration type or format is invalid.
|
|
61
69
|
*/
|
|
62
70
|
export function parseDuration(d: number | string): number;
|
|
71
|
+
/**
|
|
72
|
+
* Per-`within()`-block option overrides.
|
|
73
|
+
*
|
|
74
|
+
* A plain object keyed by option name. A key present in the store shadows the
|
|
75
|
+
* global default for every command created inside the `within()` block; a key
|
|
76
|
+
* absent from the store inherits the enclosing scope (or the global default).
|
|
77
|
+
*
|
|
78
|
+
* @typedef {Object<string, any>} OptionScopeStore
|
|
79
|
+
*/
|
|
80
|
+
/**
|
|
81
|
+
* AsyncLocalStorage backing `within()` scoping (decision D2).
|
|
82
|
+
*
|
|
83
|
+
* `within()` runs its callback inside a fresh store seeded with a shallow copy
|
|
84
|
+
* of the enclosing store, so nested blocks inherit their parent's scoped
|
|
85
|
+
* values but never leak their own assignments outward. `getDefault`/`setDefault`
|
|
86
|
+
* consult the current store first and fall back to the global defaults.
|
|
87
|
+
*
|
|
88
|
+
* @type {AsyncLocalStorage<OptionScopeStore>}
|
|
89
|
+
*/
|
|
90
|
+
export const optionScope: AsyncLocalStorage<OptionScopeStore>;
|
|
91
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
package/.editorconfig
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
root = true
|
|
2
|
-
|
|
3
|
-
# Project standard: tab indentation for JavaScript (STRUCT-3).
|
|
4
|
-
# lib/** is fully conformant; some scenarios/*.js demos predate this
|
|
5
|
-
# standard and are intentionally left as-is (no mass reformat there).
|
|
6
|
-
[*]
|
|
7
|
-
indent_style = tab
|
|
8
|
-
indent_size = tab
|
|
9
|
-
charset = utf-8
|
|
10
|
-
end_of_line = lf
|
|
11
|
-
trim_trailing_whitespace = true
|
|
12
|
-
insert_final_newline = true
|
|
13
|
-
|
|
14
|
-
[*.{json,yml,yaml}]
|
|
15
|
-
indent_style = space
|
|
16
|
-
indent_size = 2
|
|
17
|
-
|
|
18
|
-
[*.md]
|
|
19
|
-
indent_style = space
|
|
20
|
-
indent_size = 2
|
|
21
|
-
trim_trailing_whitespace = false
|
package/TODO.md
DELETED
|
@@ -1,355 +0,0 @@
|
|
|
1
|
-
# TODOs for @j-o-r/sh project
|
|
2
|
-
|
|
3
|
-
## lib/SH.js review findings (2026-06-26)
|
|
4
|
-
|
|
5
|
-
Source: full code review of `lib/SH.js`, `lib/SHDispatch.js`, `lib/SHExecute.js`.
|
|
6
|
-
Work through Pass 1 first (behavioral bugs), then Pass 2 (docs/typedefs), then Pass 3 (structure/style).
|
|
7
|
-
Each item lists: file(s), problem, fix, acceptance criteria. Check the Decision Points section before
|
|
8
|
-
implementing anything marked with a D-number.
|
|
9
|
-
|
|
10
|
-
### Context for the implementing agent
|
|
11
|
-
|
|
12
|
-
- Package: `@j-o-r/sh` v1.1.31, ESM (`"type": "module"`), Node >= 20, zero runtime dependencies.
|
|
13
|
-
- Layering: `lib/SH.js` (public API: template tag, helpers, re-exports) → `lib/SHDispatch.js`
|
|
14
|
-
(per-command option merging, chaining) → `lib/SHExecute.js` (spawn/spawnSync wrapper).
|
|
15
|
-
- Public typings are **generated from JSDoc**: `npm run types` runs `tsc -p tsc.json` into `types/`.
|
|
16
|
-
Any JSDoc error becomes a public API type error. Always regenerate types after doc changes.
|
|
17
|
-
- Tests: `npm test` → `scenarios/sh.js`. Add scenario coverage for every Pass 1 fix.
|
|
18
|
-
- Do not change public API signatures unless a Decision point explicitly approves it.
|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
### Pass 1 — Behavioral fixes (ALL DONE 2026-07-28)
|
|
23
|
-
|
|
24
|
-
#### FIX-1 (P1) — DONE 2026-07-28: `cd()` does not affect subsequent `SH` commands (stale cwd)
|
|
25
|
-
|
|
26
|
-
- **Files:** `lib/SH.js` (`defaultOptions.cwd = process.cwd()` captured at module load; `cd()` only
|
|
27
|
-
calls `process.chdir()`), `lib/SHDispatch.js` (duplicated `defaultSHOptions` with a second snapshot).
|
|
28
|
-
- **Symptom:** `cd('/tmp'); await SH`pwd`.run()` still executes in the directory the process was
|
|
29
|
-
started in.
|
|
30
|
-
- **Root cause:** `process.cwd()` is snapshotted once at module load, and `mergeOptions` copies that
|
|
31
|
-
stale value into every dispatch instance.
|
|
32
|
-
- **Fix:**
|
|
33
|
-
1. Create a single shared defaults source — new file `lib/internal.js` exporting `defaultOptions`
|
|
34
|
-
(see STRUCT-1). Delete the duplicated `defaultSHOptions` in `lib/SHDispatch.js`.
|
|
35
|
-
2. Make `cwd` lazy, so it reflects the cwd at command-creation time, not module-load time:
|
|
36
|
-
```js
|
|
37
|
-
const defaultOptions = {
|
|
38
|
-
get cwd() { return process.cwd(); },
|
|
39
|
-
env: process.env,
|
|
40
|
-
shell: 'bash',
|
|
41
|
-
stdio: ['inherit', 'pipe', 'pipe'],
|
|
42
|
-
timeout: 0,
|
|
43
|
-
};
|
|
44
|
-
```
|
|
45
|
-
Note: `SHDispatch.options()` spreads `{ ...options }`, which invokes the getter and freezes the
|
|
46
|
-
value per dispatch instance. That is correct and intended ("defaults captured when the command
|
|
47
|
-
was created") — a `new SHDispatch` is created per `SH`cmd`` evaluation, i.e. after any `cd()`.
|
|
48
|
-
Do not "optimize" this away.
|
|
49
|
-
3. Alternative (acceptable but less informative): omit `cwd` from defaults entirely; `spawn` then
|
|
50
|
-
uses the current process cwd at spawn time. If chosen, keep a `SH.cwd` readback working somehow.
|
|
51
|
-
- **Acceptance:** new scenario: `cd(tmpdir)` then `await SH`pwd`.run()` resolves to that tmpdir.
|
|
52
|
-
|
|
53
|
-
#### FIX-2 (P1) — DONE 2026-07-28: Rolling timeout is defeated by Node's native spawn timeout
|
|
54
|
-
|
|
55
|
-
- **File:** `lib/SHExecute.js`, constructor: `const { maxBuffer, ...spawnOpts } = options ?? {};`
|
|
56
|
-
strips `maxBuffer` but leaves `timeout` inside `spawnOpts`, which is passed to `spawn()`.
|
|
57
|
-
- **Root cause:** since Node 15.5, `spawn` accepts `timeout` and kills the process after an
|
|
58
|
-
*absolute* duration, regardless of output. This breaks the documented rolling timeout
|
|
59
|
-
(reset on stdout/stderr data) and produces a misleading `Command failed with code null` rejection.
|
|
60
|
-
- **Fix:** also destructure `timeout` out of the options; store it in a private field (e.g.
|
|
61
|
-
`#timeout`) and use it exclusively for the rolling timer in `run()`. In `runSync()`, re-add
|
|
62
|
-
`timeout` to the `spawnSync` options (supported natively; semantics are absolute — document this
|
|
63
|
-
sync/async difference in the `SHOptions` typedef).
|
|
64
|
-
- **Acceptance:** scenario with a command that emits output continuously for longer than `timeout`
|
|
65
|
-
and exits 0 → succeeds; a command that goes silent → killed ~`timeout` ms after its last output.
|
|
66
|
-
|
|
67
|
-
#### FIX-3 (P1) — DONE 2026-07-28: Timeout rejection reports the wrong error message
|
|
68
|
-
|
|
69
|
-
- **File:** `lib/SHExecute.js`, `run()` close handler: `this.#forcedKill` is checked **before**
|
|
70
|
-
`this.#timedOut`.
|
|
71
|
-
- **Root cause:** the timeout path sets `#timedOut = true` and immediately calls `kill('SIGTERM')`,
|
|
72
|
-
which sets `#forcedKill = true` in the same tick. The forced-kill branch always wins, so
|
|
73
|
-
`Process timed out after ${ms}ms.` is unreachable — timeouts report `Process killed (forced).`.
|
|
74
|
-
- **Fix:** check `#timedOut` before `#forcedKill` in the close handler.
|
|
75
|
-
- **Acceptance:** timeout rejection message matches `/timed out after \d+ms/`.
|
|
76
|
-
|
|
77
|
-
#### FIX-4 (P1) — DONE 2026-07-28: `childrenOf()` can hang `kill()` forever
|
|
78
|
-
|
|
79
|
-
- **File:** `lib/SHExecute.js`, `childrenOf()`: the `close` handler resolves for pgrep exit codes
|
|
80
|
-
`0` and `1` only. Exit codes 2 (syntax error) / 3 (fatal error) never settle the promise, and
|
|
81
|
-
`SHDispatch.kill()` awaits it indefinitely.
|
|
82
|
-
- **Fix:** add a final `else resolve([]);` (kill is already best-effort; an unexpected pgrep failure
|
|
83
|
-
should not wedge the library).
|
|
84
|
-
- **Acceptance:** code inspection + comment; `kill()` is guaranteed to settle on all pgrep outcomes.
|
|
85
|
-
|
|
86
|
-
#### FIX-5 (P1) — DONE 2026-07-28: Detached mode — null deref crash + incomplete detachment
|
|
87
|
-
|
|
88
|
-
- **File:** `lib/SHExecute.js`, `run()` detached branch.
|
|
89
|
-
- **Bug A:** the 1-second `setTimeout` callback calls `this.#proc.unref()` unguarded. If `kill()`
|
|
90
|
-
runs within that window, `#proc` is `null` → `TypeError` crashes the process.
|
|
91
|
-
**Fix:** `this.#proc?.unref();`.
|
|
92
|
-
- **Bug B:** with the default piped stdio, the parent's pipe handles keep the event loop alive even
|
|
93
|
-
after `unref()`, so the parent cannot actually exit early. **Fix:** when `options.detached` is
|
|
94
|
-
true and the user did not explicitly provide `stdio`, force `stdio: 'ignore'`; if the user did
|
|
95
|
-
provide stdio, keep it and document that pipes keep the parent alive. Update the class JSDoc
|
|
96
|
-
("Detached" bullet) accordingly.
|
|
97
|
-
- **Acceptance:** detached + immediate `kill()` does not crash; JSDoc states the stdio behavior.
|
|
98
|
-
|
|
99
|
-
#### FIX-6 (P2) — DONE 2026-07-28: `parseArgs` mishandles lone `-` and `--`
|
|
100
|
-
|
|
101
|
-
- **File:** `lib/SH.js`. `isOptionArg('-')` is true → produces an empty-string key `''` and consumes
|
|
102
|
-
the next token as its value. `--` (conventional end-of-options marker) also becomes key `''`.
|
|
103
|
-
- **Fix:** treat lone `-` as a positional (stdin convention). Treat `--` as end-of-options
|
|
104
|
-
terminator: all following tokens go into `_` (see D4). Update the `parseArgs` JSDoc.
|
|
105
|
-
- **Acceptance:** `parseArgs(['-', '--port', '1'])` → `{ port: '1', _: ['-'] }`;
|
|
106
|
-
`parseArgs(['--', '--x'])` → `{ _: ['--x'] }`.
|
|
107
|
-
|
|
108
|
-
---
|
|
109
|
-
|
|
110
|
-
### Pass 2 — JSDoc / typedef correctness (ALL DONE 2026-07-28)
|
|
111
|
-
|
|
112
|
-
- **DOC-1** — DONE 2026-07-28: Remove dead typedefs in `lib/SH.js`: `RejectCallback`, `ResolveCallback`, and the
|
|
113
|
-
`SHOptions` re-typedef. Repoint `lib/SHExecute.js`'s `SHExecuteOptions` to
|
|
114
|
-
`import('./SHDispatch.js').SHOptions` directly and drop the redundant `& { maxBuffer?: number }`
|
|
115
|
-
(`maxBuffer` is already optional in `SHOptions`). Regenerate types; grep for dangling references.
|
|
116
|
-
- **DOC-2** — DONE 2026-07-28: Fix inaccurate typedefs in `lib/SH.js`: `ExpBackoffGenerator` — `expBackoff` only
|
|
117
|
-
yields numbers, so use `Generator<number, void, unknown>` (if string-yielding generators must stay
|
|
118
|
-
valid for `retry`, type retry's param as `Iterator<number|string>` explicitly instead);
|
|
119
|
-
`ArgsObject` — remove `string[]` (duplicates throw; arrays are never produced);
|
|
120
|
-
`SH` tag — `@param {...unknown[]} args` → `{...unknown}`.
|
|
121
|
-
- **DOC-3** — DONE 2026-07-28: `jsType` (`lib/SH.js`): rename param `fn` → `value`, type `unknown`; fix the
|
|
122
|
-
null-prototype crash (`fn.constructor?.name ?? 'Object'`); fix the doc — `jsType(null)` returns
|
|
123
|
-
`'Null'`, and `undefined` is special-cased to `'undefined'`.
|
|
124
|
-
- **DOC-4** — DONE 2026-07-28: `within` (`lib/SH.js`): implementation is `await callback()` (a no-op wrapper) but the
|
|
125
|
-
doc promises "a new async context (fresh callstack)". Reword the doc now; real isolation is
|
|
126
|
-
Decision D2.
|
|
127
|
-
- **DOC-5** — DONE 2026-07-28: `maxBuffer` default doc drift: actual default is `512000` (`500 * 1024`). Fix
|
|
128
|
-
`lib/SHExecute.js` class JSDoc ("1MB default") and `SHExecuteOptions` typedef ("default 1MB") —
|
|
129
|
-
both wrong. Standardize on `512000 (500 KiB)` everywhere; ideally one constant in
|
|
130
|
-
`lib/internal.js` (STRUCT-1).
|
|
131
|
-
- **DOC-6** — DONE 2026-07-28: `userIn` (`lib/SH.js`): document the non-obvious submit heuristics — 'line' event +
|
|
132
|
-
50 ms debounce auto-resolve, paste detection via `chunk.length > 4 && chunk.includes('\n')`,
|
|
133
|
-
multi-line accumulation, `abort()` resolving with `undefined`. Lift magic numbers into named
|
|
134
|
-
constants (e.g. `PASTE_MIN_CHUNK = 4`, `SUBMIT_DEBOUNCE_MS = 50`).
|
|
135
|
-
- **DOC-7** — DONE 2026-07-28: `readIn` (`lib/SH.js`): document the side effect — permanently sets
|
|
136
|
-
`process.stdin` encoding to utf8 and switches it to flowing mode.
|
|
137
|
-
- **DOC-8** — DONE 2026-07-28: `retry` (`lib/SH.js`): replace `{Function}` param types with explicit signatures
|
|
138
|
-
(e.g. `{() => (Promise<any>|any)}`); document delay precedence and that an exhausted generator
|
|
139
|
-
means "no further delay".
|
|
140
|
-
- **DOC-9** — DONE 2026-07-28: `lib/SHDispatch.js`: remove the dead `SpawnSyncResponse` typedef; change constructor
|
|
141
|
-
error `'Undefined command'` → `'Invalid or empty command'`; document that `run()` replaces
|
|
142
|
-
`#proc`, so a second concurrent `run()` makes the first unkillable via `kill()`.
|
|
143
|
-
- **DOC-10** — DONE 2026-07-28: `lib/SHExecute.js`: async `run()` says `@throws` — use "rejects with" wording;
|
|
144
|
-
document the `pgrep` (procps) runtime dependency of `kill()` and the direct-children-only
|
|
145
|
-
limitation (grandchildren survive); fix the failure message for signal kills (`code` is `null`,
|
|
146
|
-
printing "code null") — include `signal` in the message. Structured errors: see D3.
|
|
147
|
-
- **DOC-11** — DONE 2026-07-28: `SH` tag JSDoc (`lib/SH.js`): the inline `` `SH.timeout = 5000; SH`cmd`` `` breaks
|
|
148
|
-
markdown rendering (nested backticks) — move it into an `@example` block; cross-link the
|
|
149
|
-
"defaults captured at creation time" note from `SHDispatch`.
|
|
150
|
-
- **DOC-12** — DONE 2026-07-28: `hasProp` (`lib/SH.js`): replace "(code-safe)" with "safe for null-prototype objects
|
|
151
|
-
and objects shadowing `hasOwnProperty`". Optionally simplify to `Object.hasOwn` (Node >= 16.9;
|
|
152
|
-
engines require >= 20).
|
|
153
|
-
|
|
154
|
-
---
|
|
155
|
-
|
|
156
|
-
### Pass 3 — Structure & style
|
|
157
|
-
|
|
158
|
-
- **STRUCT-1** — DONE 2026-07-28: Single source of truth — create `lib/internal.js` exporting `defaultOptions`
|
|
159
|
-
(with lazy `cwd`, see FIX-1) and one shared `parseDuration`. Import it from `SH.js`,
|
|
160
|
-
`SHDispatch.js`, `SHExecute.js`. This removes the duplicated `defaultSHOptions`
|
|
161
|
-
(`lib/SHDispatch.js`) and the divergent second `parseDuration` (`lib/SHExecute.js` — currently
|
|
162
|
-
accepts bare `'100'` and `null → 0`, while `lib/SH.js`'s throws). Unified semantics: finite
|
|
163
|
-
numbers >= 0 (ms); strings `'Nms'`, `'Ns'`, or bare `'N'` (= ms, preserving SHExecute's current
|
|
164
|
-
leniency — document it); descriptive errors from the SH.js version; `null`/`undefined` handled at
|
|
165
|
-
call sites (`timeout ?? 0`).
|
|
166
|
-
- **STRUCT-2** — DONE 2026-08-05: Proxy symmetry (`lib/SH.js`): the `set` trap accepted any
|
|
167
|
-
property but `get` exposed only `defaultOptionKeys`, so `SH.foo = 1; SH.foo` === `undefined`.
|
|
168
|
-
Implemented D1 (throw). Aligned `defaultOptionKeys` with the defaults object by deriving it
|
|
169
|
-
from `defaultOptions` and initializing `maxBuffer`/`detached` in the defaults.
|
|
170
|
-
- **STRUCT-3** — DONE 2026-08-05: Formatting unified: tabs everywhere in `lib/` (`SH.js` mixed
|
|
171
|
-
and `SHExecute.js` 2-space converted), semicolon drift fixed in `lib/SH.js`. `.editorconfig`
|
|
172
|
-
added (tabs for JS; 2-space for JSON/MD/YAML). One commit, **no** logic changes (proven by
|
|
173
|
-
`git diff -w`: only added semicolons). Scenario files intentionally not mass-reformatted.
|
|
174
|
-
- **STRUCT-4** — DONE 2026-08-05: `@ts-ignore` audit: `parseArgs` return — replaced with an
|
|
175
|
-
`ArgsObject`-typed declaration; `cd`'s `@ts-ignore` on `process.chdir` — verified with tsc and
|
|
176
|
-
removed; reason comments added to every remaining `@ts-ignore` (lib + scenarios).
|
|
177
|
-
|
|
178
|
-
---
|
|
179
|
-
|
|
180
|
-
### Decision points (confirm with maintainer first)
|
|
181
|
-
|
|
182
|
-
- **D1 — proxy `set` for unknown keys:** (a) throw `TypeError` listing known keys (catches typos
|
|
183
|
-
like `SH.timout = 1`; breaking for anyone setting custom props), or (b) store custom keys in a
|
|
184
|
-
separate `Map` and read them back (function props like `name` must not be shadowed).
|
|
185
|
-
Recommended: (a).
|
|
186
|
-
**DECIDED 2026-08-05: (a)** — throw a TypeError listing the known keys (implemented in STRUCT-2).
|
|
187
|
-
- **D2 — `within()`:** implement real isolation via `AsyncLocalStorage` (larger change; would also
|
|
188
|
-
enable per-call default scoping) or keep the wrapper and just fix the docs (DOC-4).
|
|
189
|
-
Recommended short-term: docs only.
|
|
190
|
-
- **D3 — structured failure errors:** reject with an error carrying `code`/`signal`/`stdout`/
|
|
191
|
-
`stderr` (zx `ProcessOutput` precedent). Public API change — defer to next major. Minimal fix for
|
|
192
|
-
now is in DOC-10.
|
|
193
|
-
- **D4 — `--` semantics in `parseArgs`:** standard end-of-options terminator (recommended) vs
|
|
194
|
-
treating `--` as a positional. Affects FIX-6.
|
|
195
|
-
**DECIDED 2026-07-28: terminator** (as encoded in the FIX-6 acceptance criteria).
|
|
196
|
-
- **D5 — API-surface trim:** re-exporting `node:assert`, `Test`, `AsyncTracker`, plus generic utils
|
|
197
|
-
(`jsType`, `hasProp`, `parseArgs`) blurs the "lean shell bridge" mission stated in the file
|
|
198
|
-
header. Consider deprecating the `assert` re-export. Non-blocking.
|
|
199
|
-
|
|
200
|
-
---
|
|
201
|
-
|
|
202
|
-
### Verification checklist (run before moving items to Done)
|
|
203
|
-
|
|
204
|
-
- [x] `npm test` passes, including new scenarios for FIX-1 … FIX-6 and STRUCT-2 (30/30; FIX-4 covered by code inspection per its acceptance criteria).
|
|
205
|
-
- [x] `npm run types` regenerates `types/` with zero errors (new `types/internal.d.ts`).
|
|
206
|
-
- [x] `grep -rn "RejectCallback\|ResolveCallback\|SpawnSyncResponse" lib/ types/` returns nothing
|
|
207
|
-
after DOC-1 / DOC-9 (verified 2026-07-28).
|
|
208
|
-
- [x] Manual smoke: `cd` + `pwd`; chatty command with rolling timeout; timeout error message;
|
|
209
|
-
detached + immediate kill; `parseArgs` with `-` and `--` — all automated as the
|
|
210
|
-
FIX-1…FIX-6 scenario block in `scenarios/sh.js`.
|
|
211
|
-
|
|
212
|
-
## In Progress
|
|
213
|
-
|
|
214
|
-
## Done
|
|
215
|
-
|
|
216
|
-
### Pass 1 — completed 2026-07-28
|
|
217
|
-
|
|
218
|
-
- **FIX-1** — `lib/internal.js` created as the single defaults source with a lazy `cwd`
|
|
219
|
-
getter. Design refinement vs the sketched getter-only snippet: a setter keeps `SH.cwd = dir`
|
|
220
|
-
working (a getter-only accessor would throw on assignment in strict mode), and `cd()` clears
|
|
221
|
-
the override via `clearCwdOverride()` — the most recent of `cd()` / `SH.cwd =`
|
|
222
|
-
always wins. Duplicated `defaultSHOptions` removed from `lib/SHDispatch.js`. Scenario 24.
|
|
223
|
-
- **FIX-2** — `timeout` destructured out of the spawn options into `#timeout`
|
|
224
|
-
(`lib/SHExecute.js`), used only for the rolling timer in `run()`; `runSync()`
|
|
225
|
-
re-adds it to `spawnSync` (native, absolute). Sync/async difference documented in the
|
|
226
|
-
`SHOptions` typedef. Scenario 25 (chatty command ~2s survives a 500 ms rolling timeout).
|
|
227
|
-
- **FIX-3** — `#timedOut` is checked before `#forcedKill` in the close handler.
|
|
228
|
-
Scenario 25 asserts `/timed out after 400ms/`.
|
|
229
|
-
- **FIX-4** — `childrenOf()` gained a final `else resolve([])` (pgrep exit 2/3) with
|
|
230
|
-
comment; `kill()` settles on all pgrep outcomes. Acceptance was code inspection — no scenario.
|
|
231
|
-
- **FIX-5** — `this.#proc?.unref()`; `SHDispatch.run()` forces `stdio: 'ignore'`
|
|
232
|
-
when `detached` and stdio was not explicitly provided (tracked via `#stdioProvided`);
|
|
233
|
-
JSDoc updated (`SHExecute` class bullet, `SHOptions.detached`). Scenarios 26–27.
|
|
234
|
-
- **FIX-6** — `isOptionArg` requires length > 1 (lone `-` → positional / option value);
|
|
235
|
-
`--` terminates option parsing (D4: terminator). `parseArgs` JSDoc + examples updated.
|
|
236
|
-
Scenario 28; standalone `scenarios/parse_args.js` still passes (6/6).
|
|
237
|
-
|
|
238
|
-
Validation run: `node --check` on all changed files OK; `npm test` 29/29;
|
|
239
|
-
`npm run types` exit 0; `node scenarios/parse_args.js` 6/6.
|
|
240
|
-
|
|
241
|
-
### Pass 2 — completed 2026-07-28
|
|
242
|
-
|
|
243
|
-
- **DOC-1** — Dead `RejectCallback`/`ResolveCallback`/`SHOptions` re-typedefs removed from
|
|
244
|
-
`lib/SH.js`; `SHExecuteOptions` repointed to `import('./SHDispatch.js').SHOptions`
|
|
245
|
-
(redundant `& { maxBuffer?: number }` dropped).
|
|
246
|
-
- **DOC-2** — `ArgsObject` no longer includes `string[]`; `ExpBackoffGenerator` is
|
|
247
|
-
`Generator<number, void, unknown>` (kept alive via `@returns` on `expBackoff`;
|
|
248
|
-
`retry` types its delay param as `Iterator<number|string>`); SH tag `{...unknown}`.
|
|
249
|
-
- **DOC-3** — `jsType` param renamed `fn` → `value` (`unknown`), null-prototype
|
|
250
|
-
crash fixed (`constructor?.name ?? 'Object'`), docs cover `'Null'`/`'undefined'`.
|
|
251
|
-
- **DOC-4** — `within` doc reworded: thin wrapper, no isolation; D2 referenced.
|
|
252
|
-
- **DOC-5** — `DEFAULT_MAX_BUFFER` constant in `lib/internal.js` (500 * 1024); `SHExecute`
|
|
253
|
-
uses it; all docs standardized on `512000 (500 KiB)`.
|
|
254
|
-
- **DOC-6** — `userIn` heuristics documented; `PASTE_MIN_CHUNK`/`SUBMIT_DEBOUNCE_MS` constants.
|
|
255
|
-
- **DOC-7** — `readIn` stdin encoding/flowing side effect documented.
|
|
256
|
-
- **DOC-8** — `retry` params typed with explicit signatures; delay precedence + exhausted-generator
|
|
257
|
-
semantics documented.
|
|
258
|
-
- **DOC-9** — `SpawnSyncResponse` typedef removed; constructor error now `'Invalid or empty
|
|
259
|
-
command'`; `run()` documents the concurrent-run unkillable caveat.
|
|
260
|
-
- **DOC-10** — async `run()` uses rejects-with wording; `kill()` documents the procps/`pgrep`
|
|
261
|
-
dependency and direct-children-only limit; signal kills now report `signal <SIG>` instead of
|
|
262
|
-
`code null`.
|
|
263
|
-
- **DOC-11** — nested-backtick inline moved into `@example`; cross-link to `SHDispatch#options`.
|
|
264
|
-
- **DOC-12** — `hasProp` doc reworded; body simplified to `Object.hasOwn`.
|
|
265
|
-
|
|
266
|
-
Validation run: `node --check` OK on all changed files; `npm test` 29/29; `npm run types`
|
|
267
|
-
exit 0; dead-typedef grep clean; `SH: any` in `types/SH.d.ts` unchanged (pre-existing).
|
|
268
|
-
|
|
269
|
-
### STRUCT-1 (Pass 3) — completed 2026-07-28
|
|
270
|
-
|
|
271
|
-
- **STRUCT-1** — `parseDuration` unified in `lib/internal.js` alongside `defaultOptions`.
|
|
272
|
-
The divergent copies in `lib/SH.js` (strict) and `lib/SHExecute.js` (lenient) were
|
|
273
|
-
deleted; both now import from `./internal.js` (`SHExecute`'s now-dead `isFinitePosInt`
|
|
274
|
-
helper removed too). Unified semantics as specified: finite numbers >= 0 (ms); strings
|
|
275
|
-
`'Nms'`, `'Ns'`, or bare `'N'` (= ms, leniency documented in the JSDoc); descriptive
|
|
276
|
-
SH.js-style errors; `null`/`undefined` handled at call sites (`SHExecute` already had
|
|
277
|
-
`timeout ?? 0`). Scenario 13 in `scenarios/sh.js` updated to the unified semantics —
|
|
278
|
-
it previously asserted the old strict SH.js behavior by rejecting bare `'5'`; renamed to
|
|
279
|
-
'duration parsing' and extended with bare-string acceptance coverage. Minor deviation:
|
|
280
|
-
the invalid-type error message uses `typeof` instead of `jsType` (avoids an
|
|
281
|
-
internal↔SH import cycle; e.g. 'boolean' instead of 'Boolean' — no test relied on it).
|
|
282
|
-
|
|
283
|
-
Validation run: `node --check` on all changed files OK; `npm test` 29/29;
|
|
284
|
-
`npm run types` exit 0 (`types/internal.d.ts` now exports `parseDuration`);
|
|
285
|
-
`node scenarios/parse_args.js` 6/6.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
### STRUCT-2 (Pass 3) — completed 2026-08-05
|
|
289
|
-
|
|
290
|
-
- **STRUCT-2** — D1 implemented as decided: the `SH` proxy `set` trap now throws a `TypeError`
|
|
291
|
-
listing the known option keys when an unknown key is assigned (typo guard, e.g.
|
|
292
|
-
`SH.timout = 1`), instead of silently storing it in `defaultOptions` — where the `get` trap
|
|
293
|
-
would not read it back (`SH.foo = 1; SH.foo` === `undefined`). Reads of unknown keys are
|
|
294
|
-
unchanged (they fall through to the function target, usually `undefined`). Key-set alignment:
|
|
295
|
-
the hard-coded `defaultOptionKeys` list in `lib/SH.js` was replaced by `defaultOptionKeys`
|
|
296
|
-
exported from `lib/internal.js`, derived as `new Set(Object.keys(defaultOptions))` so the set
|
|
297
|
-
can never drift from the defaults again; `maxBuffer` and `detached` are now initialized in
|
|
298
|
-
`defaultOptions` (`DEFAULT_MAX_BUFFER` and `false`). This is behavior-neutral: `SHExecute`
|
|
299
|
-
already fell back to the same maxBuffer constant for unset/invalid values and truthy-checks
|
|
300
|
-
`detached`. `DEFAULT_MAX_BUFFER` moved above `defaultOptions` to satisfy TDZ.
|
|
301
|
-
Scenario 29 in `scenarios/sh.js` ('STRUCT-2: SH proxy throws on unknown option keys (D1)').
|
|
302
|
-
|
|
303
|
-
Validation run: `node --check` on all changed files OK; `npm test` 30/30;
|
|
304
|
-
`npm run types` exit 0; `node scenarios/parse_args.js` 6/6; manual smoke of the TypeError
|
|
305
|
-
message and the initialized `SH.maxBuffer`/`SH.detached` readbacks OK.
|
|
306
|
-
|
|
307
|
-
### STRUCT-4 (Pass 3) — completed 2026-08-05
|
|
308
|
-
|
|
309
|
-
- **STRUCT-4** — `@ts-ignore` audit. Method: every suppression was re-verified against
|
|
310
|
-
`tsc --noEmit --checkJs` (stricter than the project gate — `tsc.json` covers `lib/**` only
|
|
311
|
-
and does not set `checkJs`, so the project's `npm run types` never exercised these).
|
|
312
|
-
- `parseArgs` (`lib/SH.js`): the return-site suppression moved to the declaration with the
|
|
313
|
-
prescribed typing — `const result = /** @type {ArgsObject} */ ({ _: [] });`. Deviation from
|
|
314
|
-
the sketched fix: the plain annotation form only relocates TS2322 to the declaration,
|
|
315
|
-
because `_: string[]` is fundamentally incompatible with the `string|true` index
|
|
316
|
-
signature — `_` is the intended exception, so one reasoned `@ts-ignore` remains at the
|
|
317
|
-
declaration. Also annotated `/** @type {string|true} */ let value;` (the `true` literal
|
|
318
|
-
otherwise widens to `boolean`). Net checkJs effect: 3 pre-existing parseArgs errors
|
|
319
|
-
removed, zero introduced.
|
|
320
|
-
- Removed as unneeded (no error even under `checkJs`): both `userIn` readline suppressions
|
|
321
|
-
and `cd`'s `process.chdir` suppression.
|
|
322
|
-
- Kept with reason comments: the two `options.stdio = stdio` suppressions in
|
|
323
|
-
`lib/SHExecute.js` (the `['pipe', 'pipe', 'pipe']` literal widens to `string[]`, which
|
|
324
|
-
`StdioOptions` rejects).
|
|
325
|
-
- Reason comments added to the 7 suppressions in `scenarios/test.js` /
|
|
326
|
-
`scenarios/asynctracker.js` (beyond the TODO's lib-only wording; those files are not
|
|
327
|
-
covered by `tsc.json` at all).
|
|
328
|
-
|
|
329
|
-
Validation run: `node --check` on all changed files OK; `npm test` 30/30; `npm run types`
|
|
330
|
-
exit 0 (regenerated types byte-identical); `node scenarios/parse_args.js` 6/6;
|
|
331
|
-
`node scenarios/asynctracker.js` 6/6; `node scenarios/test.js` shows its 2 intentional
|
|
332
|
-
self-test errors (verified pre-existing on HEAD); `tsc --noEmit --checkJs` delta vs the
|
|
333
|
-
pre-audit baseline: 0 errors introduced, 3 removed.
|
|
334
|
-
|
|
335
|
-
### STRUCT-3 (Pass 3) — completed 2026-08-05
|
|
336
|
-
|
|
337
|
-
- **STRUCT-3** — Formatting unified with **no logic changes**:
|
|
338
|
-
- `.editorconfig` added: tab indentation as the project standard for JS (2-space for
|
|
339
|
-
JSON/MD/YAML; trailing-whitespace trimming, final newline, LF, UTF-8).
|
|
340
|
-
- `lib/SHExecute.js`: 217 lines converted 2-space → tabs (whole file).
|
|
341
|
-
- `lib/SH.js`: 41 space-indented lines (userIn/hasProp regions) converted to tabs; 6 lines
|
|
342
|
-
trailing-whitespace trimmed; semicolon drift fixed — 2 imports and 7 closing braces
|
|
343
|
-
(`defaultOptionHandler`, `within`, `readIn`, `retry`, `sleep`, `cd`, export list).
|
|
344
|
-
- No-logic-change proof: `git diff -w lib/` shows only the added semicolons;
|
|
345
|
-
`git diff -w lib/SHExecute.js` is empty. Multi-line template literals (whose inner
|
|
346
|
-
whitespace is significant) exist only in scenario files, which were deliberately left
|
|
347
|
-
as-is.
|
|
348
|
-
- Deviation note: `scenarios/*.js` indentation drift (a few 2-space and 4-space demo files)
|
|
349
|
-
was left untouched — outside the TODO's lib-focused scope, and several scenarios launch
|
|
350
|
-
interactive programs so they can't be regression-tested here. `.editorconfig` governs
|
|
351
|
-
future edits.
|
|
352
|
-
|
|
353
|
-
Validation run: `node --check` on all changed files OK; `npm test` 30/30; `npm run types`
|
|
354
|
-
exit 0 (types byte-identical); `node scenarios/parse_args.js` 6/6;
|
|
355
|
-
`node scenarios/asynctracker.js` 6/6; `node scenarios/test.js` 2 intentional self-test errors.
|