@j-o-r/sh 1.1.29 → 1.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.editorconfig ADDED
@@ -0,0 +1,21 @@
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/README.md CHANGED
@@ -13,7 +13,7 @@ Execute shell commands from JavaScript on Linux.
13
13
  - Utils: `sleep`, `retry`, `expBackoff`, `cd`, `parseArgs`, `userIn`, `readIn`.
14
14
  - Testing: `new Test().add('name', () => assert(...)).run()`.
15
15
  - Async leak detection: `AsyncTracker` via `async_hooks`.
16
- - Safe interpolation; `bashEscape`; full JSDoc.
16
+ - Raw-by-default interpolation with explicit `bashEscape` quoting for untrusted argument values; full JSDoc.
17
17
 
18
18
  No runtime deps. ESM-only (ES2020+).
19
19
 
@@ -35,6 +35,35 @@ const out = await SH`ls -la`.run();
35
35
  console.log(out); // Captured stdout (trimmed)
36
36
  ```
37
37
 
38
+ ### Interpolation: raw by default
39
+
40
+ Interpolated values are inserted into the shell command as raw shell source. This
41
+ is convenient for trusted shell fragments such as flags, pipes, redirects, and
42
+ compound expressions, but it is **not safe for untrusted input**.
43
+
44
+ ```js
45
+ import { SH, bashEscape } from '@j-o-r/sh';
46
+
47
+ const flags = ['-l', '-a'];
48
+ await SH`ls ${flags}`.run(); // Executes: ls -l -a
49
+
50
+ const fragment = 'printf ok; printf done';
51
+ await SH`${fragment}`.run(); // Raw shell syntax is intentional
52
+
53
+ const userInput = 'name; rm -rf /';
54
+ await SH`printf '%s\n' ${bashEscape(userInput)}`.run();
55
+ // Prints the value literally instead of executing `rm`.
56
+ ```
57
+
58
+ `bashEscape(value)` returns one POSIX-shell-quoted argument. It preserves empty
59
+ strings, whitespace, quotes, semicolons, glob characters, tabs, and newlines.
60
+ For arrays of untrusted values, quote each element yourself:
61
+
62
+ ```js
63
+ const args = ['two words', 'semi;colon', "quote's"];
64
+ await SH`printf '%s\n' ${args.map(bashEscape)}`.run();
65
+ ```
66
+
38
67
  ### Chaining & Options
39
68
 
40
69
  ```js
@@ -140,9 +169,9 @@ Full JSDoc in `lib/*.js`. Key exports:
140
169
  | `Test` | Test runner |
141
170
  | `AsyncTracker` | Async leak detector |
142
171
  | `parseArgs(argv)` | CLI parser |
143
- | `bashEscape(str)` | Shell-safe string |
172
+ | `bashEscape(value)` | Quote one value as a POSIX shell argument for safe interpolation |
144
173
 
145
- See [types/index.d.ts](types/index.d.ts) for TS defs.
174
+ See [types/SH.d.ts](types/SH.d.ts) for TS defs.
146
175
 
147
176
  ## Development
148
177
 
package/TODO.md CHANGED
@@ -1,18 +1,355 @@
1
1
  # TODOs for @j-o-r/sh project
2
2
 
3
- ## High Priority (pending)
4
- - [ ] Review and commit recent changes to lib/ files and types/ (from git status: modified AsyncTracker.js, SH.js, etc.)
5
- - [*] Generate or improve API documentation using types/*.d.ts files
3
+ ## lib/SH.js review findings (2026-06-26)
6
4
 
7
- ## In Progress
8
- - [ ]
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)
9
181
 
10
- ## Later (future tasks)
11
- - [ ] Create separate docs folder with full API reference
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
12
213
 
13
214
  ## Done
14
- - [x] Initial project setup from package.json and README.md inspection (2026-04-15)
15
- - [x] Update README.md to include documentation for recent changes in AsyncTracker and Test features (2026-04-15)
16
- - [x] Add more examples to README.md for SHDispatch methods and utilities like retry and expBackoff (2026-04-15)
17
- - [x] Reference general SSH example in README.md (demo-interactive-ssh.js not found in scenarios/) (2026-04-15)
18
- - [x] Reduce SHExecute maxBuffer default from 40MB to 1MB, update all docs/JSDoc mentioning it. Make sure global SH.maxBuffer works. (2026-04-15)
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.