@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 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
- * 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).
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
- return await callback();
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; rolling on data)
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 - Timeout in ms or duration string; 0 disables.
38
- * Async `run()` uses a rolling timeout that resets on stdout/stderr data.
39
- * `runSync()` passes it to `spawnSync`, where it is absolute: the process is
40
- * killed after the full duration no matter how much output it produces.
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
- * Retrieves child PIDs of a given parent PID using pgrep -P.
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 childrenOf = (pid) => new Promise((resolve, reject) => {
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
- * - **Rolling timeout**: `options.timeout` (ms/'2s'); resets on stdout/stderr data. SIGTERM on expiry.
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).
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`, 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).
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
- /** Timeout in ms; 0 disables. Rolling in `run()`, absolute in `runSync()`. */
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. 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().
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 stderr), rolling-timeout expiry,
141
- * forced kill, or spawn failure.
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 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
- }
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
- if (ms > 0) resetTimeout();
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 (options.detached) {
213
+ if (publicDetached) {
213
214
  setTimeout(() => {
214
- if (timeoutId !== null) clearTimeout(timeoutId);
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 (timeoutId !== null) clearTimeout(timeoutId);
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 ${ms}ms.`));
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}: ${stderr}`));
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 process and its children (via pgrep).
254
+ * Terminates the whole process group (negative-PID kill).
253
255
  *
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.
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
- let killed = [];
271
+ const killed = [];
268
272
  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; }
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 getter and
30
- * freezes the value for that command — "defaults captured at creation time".
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
- * 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`.
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
- 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
- };
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
@@ -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.32",
5
+ "version": "1.2.0",
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",
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
- * Despite the historical name, this is currently a thin wrapper: no new async
141
- * context or fresh callstack is created the callback runs in the current
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
  */
@@ -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; rolling on data)
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
- * - Timeout in ms or duration string; 0 disables.
36
- * Async `run()` uses a rolling timeout that resets on stdout/stderr data.
37
- * `runSync()` passes it to `spawnSync`, where it is absolute: the process is
38
- * killed after the full duration no matter how much output it produces.
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
  /**
@@ -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
- * - **Rolling timeout**: `options.timeout` (ms/'2s'); resets on stdout/stderr data. SIGTERM on expiry.
17
- * Async `run()` only the timeout is stripped from the spawn options so Node's
18
- * native absolute spawn timeout never interferes. `runSync()` instead passes the
19
- * timeout to `spawnSync`, whose semantics are absolute (kills after the full
20
- * duration, regardless of output).
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`, resolves early (~1s) and unrefs. Unless the
24
- * caller explicitly set `stdio`, SHDispatch forces `stdio: 'ignore'` for detached
25
- * runs, because open pipes keep the parent's event loop alive and defeat detachment.
26
- * - **Kill**: Terminates process + direct children via pgrep (requires procps; grandchildren survive).
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 stderr), rolling-timeout expiry,
56
- * forced kill, or spawn failure.
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 process and its children (via pgrep).
75
+ * Terminates the whole process group (negative-PID kill).
61
76
  *
62
- * Requires `pgrep` (procps) at runtime. Only direct children are
63
- * discovered grandchildren and deeper descendants survive. Best-effort:
64
- * pgrep failures resolve to an empty child list instead of rejecting.
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[]>;
@@ -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 getter and
8
- * freezes the value for that command — "defaults captured at creation time".
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
- * An explicit `SH.cwd = dir` assignment takes precedence over `process.cwd()`
12
- * (it only redirects SH commands; it does not `chdir` the process). `cd()`
13
- * clears the override again, so the most recent of the two always wins.
14
- *
15
- * `maxBuffer` and `detached` are initialized explicitly so every key the `SH`
16
- * proxy exposes ({@link defaultOptionKeys}) also has a defined value here.
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.