@j-o-r/sh 1.1.29 → 1.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +21 -0
- package/README.md +32 -3
- package/TODO.md +349 -12
- package/lib/SH.js +274 -208
- package/lib/SHDispatch.js +48 -32
- package/lib/SHExecute.js +233 -228
- package/lib/internal.js +113 -0
- package/package.json +2 -2
- package/types/SH.d.ts +109 -50
- package/types/SHDispatch.d.ts +49 -28
- package/types/SHExecute.d.ts +21 -10
- package/types/internal.d.ts +62 -0
package/.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
|
-
-
|
|
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(
|
|
172
|
+
| `bashEscape(value)` | Quote one value as a POSIX shell argument for safe interpolation |
|
|
144
173
|
|
|
145
|
-
See [types/
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
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.
|