@j-o-r/sh 1.1.28 → 1.1.31

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/SHDispatch.js CHANGED
@@ -1,56 +1,72 @@
1
- import SHExec from './SHExecute.js';
1
+ import SHExecute from './SHExecute.js';
2
+
2
3
  /**
3
4
  * @typedef {Object} SpawnSyncResponse
4
- * @property {number|null} status - Exit code of the child process (null if terminated by signal).
5
- * @property {string|null} signal - Name of the terminating signal, if any.
6
- * @property {(string|Buffer|null)[]} output - [stdin, stdout, stderr] per Node's SpawnSyncReturns.
7
- * @property {number} pid - PID of the spawned process.
8
- * @property {string|Buffer|null} stdout - Stdout collected (type depends on encoding option).
9
- * @property {string|Buffer|null} stderr - Stderr collected (type depends on encoding option).
5
+ * @property {number|null} status - Exit code (null if signal).
6
+ * @property {string|null} signal - Terminating signal.
7
+ * @property {(string|Buffer|null)[]} output - [stdin, stdout, stderr].
8
+ * @property {number} pid - Process ID.
9
+ * @property {string|Buffer|null} stdout - Captured stdout.
10
+ * @property {string|Buffer|null} stderr - Captured stderr.
10
11
  */
12
+
11
13
  /**
12
- * @typedef {Object} SHOptions
13
- * @property {string} [cwd] - Current working directory of the child process.
14
- * @property {Object} [env] - Environment key-value pairs.
15
- * @property {string} [argv0] - Explicitly set the value of `argv[0]` sent to the child process.
16
- * @property {boolean} [detached=false] - If true, the child will be a process group leader.
17
- * @property {number} [uid] - Sets the user identity of the process.
18
- * @property {number} [gid] - Sets the group identity of the process.
19
- * @property {StdioOptions|StdioOption} [stdio='pipe'] - Child's stdio configuration.
20
- * @property {boolean|string} [shell='bash'] - If string or true, runs the command via that shell ('bash' if true).
21
- * @property {number} [timeout=0] - Milliseconds before sending SIGTERM (0 = no timeout).
22
- * @property {string|Buffer|Uint8Array} [input] - Optional stdin payload for spawnSync. Use .run(payload) for async.
14
+ * @typedef {('pipe' | 'ignore' | 'inherit' | number)} StdioOption
15
+ * @description Stdio config for a stream:
16
+ * - `'pipe'`: Pipe to parent.
17
+ * - `'ignore'`: Discard.
18
+ * - `'inherit'`: From/to parent.
19
+ * - `number`: FD.
23
20
  */
21
+
24
22
  /**
25
- * @typedef {('pipe' | 'ignore' | 'inherit' | number)} StdioOption
26
- * @description Defines the stdio configuration for each of the standard streams.
27
- *
28
- * - 'pipe' creates a pipe between the child process and the parent process.
29
- * The parent end of the pipe is exposed as a property on the `ChildProcess` object.
30
- * - 'ignore' indicates that the child process's corresponding stdio file descriptor will be ignored.
31
- * - 'inherit' passes the corresponding stdio stream to/from the child process.
32
- * - Stream object to be used for the stdio stream.
33
- * - Positive integer representing a file descriptor to be used for the stdio stream.
23
+ * @typedef {Array<StdioOption> | StdioOption} StdioOptions
24
+ * @description Stdio array [stdin, stdout, stderr] or single value (applied to all).
25
+ * @example ['inherit', 'pipe', 'pipe'] // Default: inherit stdin, pipe out/err
34
26
  */
27
+
35
28
  /**
36
- * @typedef {Array<StdioOption>|StdioOption} StdioOptions
37
- * @description
38
- * Configures the stdio streams for the child process. This can be an array or a single StdioOption.
39
- *
40
- * Array Form: Specify the configuration for [stdin, stdout, stderr].
41
- * - If array length is more than 3, additional positions correspond to extra streams.
42
- * Single Value: This value will be applied to stdin, stdout, and stderr.
43
- *
44
- * Examples:
45
- * - ['pipe', 'pipe', 'ignore']: Pipe stdin and stdout, ignore stderr.
46
- * - 'inherit': Inherit all stdio streams from the parent.
29
+ * Core options for SH/SHDispatch.
30
+ *
31
+ * Defaults (merged from global SH):
32
+ * - `cwd`: `process.cwd()`
33
+ * - `env`: `process.env`
34
+ * - `shell`: `'bash'` (string/true → shell; false → no-shell `/usr/bin/env -S`)
35
+ * - `stdio`: `['inherit', 'pipe', 'pipe']`
36
+ * - `timeout`: `0` (no timeout; rolling on data)
37
+ * - `maxBuffer`: `512000` (500 kb per stream in SHExecute)
38
+ *
39
+ * Prefix (`.options(undefined, prefix)`) only for shell mode.
40
+ *
41
+ * @typedef {Object} SHOptions
42
+ * @property {string} cwd - Working directory for spawned commands.
43
+ * @property {NodeJS.ProcessEnv} env - Environment for spawned commands.
44
+ * @property {string|boolean} shell - Shell executable, true for bash, or false for no-shell mode.
45
+ * @property {StdioOptions} stdio - Stdio config passed to child_process.
46
+ * @property {number|string} timeout - Rolling timeout in ms or duration string; 0 disables timeout.
47
+ * @property {number} [maxBuffer] - Maximum buffered bytes per stdout/stderr stream.
48
+ * @property {boolean} [detached] - Run process detached and resolve early.
49
+ * @example { timeout: '5s', stdio: 'inherit', shell: false, maxBuffer: 10 * 1024}
47
50
  */
51
+
52
+ /** @type {SHOptions} */
53
+ const defaultSHOptions = {
54
+ cwd: process.cwd(),
55
+ env: process.env,
56
+ shell: 'bash',
57
+ stdio: ['inherit', 'pipe', 'pipe'],
58
+ timeout: 0,
59
+ };
60
+
48
61
  /**
49
- * Merge property values while maintaining the fixed set of props from the predefined object
50
- * @param {SHOptions} predefined - options
51
- * @param {SHOptions} options
52
- * @returns {SHOptions}
53
- */
62
+ * Merges user options into predefined defaults (non-destructive).
63
+ *
64
+ * Only copies defined keys.
65
+ *
66
+ * @param {SHOptions} predefined - Base options.
67
+ * @param {Partial<SHOptions>} options - Overrides.
68
+ * @returns {SHOptions} Merged options.
69
+ */
54
70
  const mergeOptions = (predefined, options) => {
55
71
  const mergedObj = { ...predefined };
56
72
 
@@ -61,94 +77,106 @@ const mergeOptions = (predefined, options) => {
61
77
  }
62
78
 
63
79
  return mergedObj;
64
- }
80
+ };
65
81
 
66
82
  /**
67
- * SHOptions — effective defaults and semantics used by SHDispatch/SHExecute
83
+ * High-level command dispatcher.
84
+ *
85
+ * Created by {@link SH`cmd`}; chain `.options()` then `.run()`.
68
86
  *
69
- * Defaults:
70
- * - cwd: process.cwd()
71
- * - env: process.env
72
- * - shell: 'bash' (string). If a string, that shell is used. If true, 'bash' is used. If false/undefined, no shell is used.
73
- * - stdio: ['inherit', 'pipe', 'pipe'] — inherit stdin, capture stdout/stderr
74
- * - timeout: 0 — no timeout
75
- * - maxBuffer?: number — optional (bytes per stream). Passed through to SHExecute.
87
+ * Delegates to {@link SHExecute} for exec/timeout/buffer/kill.
76
88
  *
77
- * Notes:
78
- * - The prefix (see options(prefix)) is only applied when a shell is used; it is ignored in no-shell mode.
79
- * - Each call to options() resets to the defaults and merges the provided options; it does not accumulate from prior calls.
89
+ * @example
90
+ * const dispatch = SH`ls -la`.options({ timeout: '2s' });
91
+ * const out = await dispatch.run();
80
92
  */
81
-
82
-
83
93
  class SHDispatch {
84
- // #prefix = 'set -euo pipefail;/usr/bin/env'
85
- // #prefix = 'set -euo pipefail'
86
94
  #prefix = '';
87
95
  #cmd = '';
88
- #options = {};
89
- /**
90
- * @type {SHExec}
91
- */
92
- #proc;
96
+ /** @type {SHOptions} */
97
+ #options = { ...defaultSHOptions };
98
+ /** @type {SHExecute | null} */
99
+ #proc = null;
100
+
93
101
  /**
94
- * @param {string} cmd - cmd to execute
95
- * @param {SHOptions} options
96
- * @param {string} [prefix] - command prefix e.g (default) '/usr/bin/env'
97
- */
98
- constructor(cmd, options, prefix) {
102
+ * @param {string} cmd - Command string.
103
+ * @param {Partial<SHOptions>} [options] - Initial options.
104
+ * @param {string} [prefix] - Shell prefix (e.g., 'set -euo pipefail').
105
+ * @throws {Error} Invalid/empty cmd.
106
+ */
107
+ constructor(cmd, options = {}, prefix) {
99
108
  if (typeof cmd !== 'string' || cmd === '') {
100
109
  throw new Error('Undefined command');
101
110
  }
102
111
  this.#cmd = cmd;
103
112
  this.options(options, prefix);
104
113
  }
114
+
105
115
  /**
106
- * @param {SHOptions} [options]
107
- * @param {string} [prefix] - command prefix e.g (default) '/usr/bin/env'
108
- * @returns {SHDispatch}
109
- */
116
+ * Updates options/prefix by merging into the dispatch instance's current options.
117
+ *
118
+ * This preserves global `SH.*` defaults captured when the command was created
119
+ * and only overrides options explicitly supplied for this command. Strings for
120
+ * `stdio` are expanded to `[value, value, value]`.
121
+ *
122
+ * @param {Partial<SHOptions>} [options] - New options.
123
+ * @param {string} [prefix] - New prefix.
124
+ * @returns {SHDispatch} Self for chaining.
125
+ */
110
126
  options(options, prefix) {
111
- // this.#options = defaultOptions
112
127
  if (typeof prefix === 'string') {
113
128
  this.#prefix = prefix;
114
129
  }
115
130
  if (!options) {
116
131
  return this;
117
132
  }
118
- if (options.stdio && typeof options.stdio === 'string') {
133
+ const nextOptions = { ...options };
134
+ if (nextOptions.stdio && typeof nextOptions.stdio === 'string') {
119
135
  // convert stdio to array
120
- // This sets the default io values
121
- // but can be overwritten when having a payload
122
- const io = options.stdio;
123
- options.stdio = Array(3).fill(io);
136
+ const io = nextOptions.stdio;
137
+ nextOptions.stdio = Array(3).fill(io);
124
138
  }
125
- this.#options = mergeOptions(this.#options, options);
139
+ this.#options = mergeOptions(this.#options, nextOptions);
126
140
  return this;
127
141
  }
128
142
 
129
- /**
130
- * @param {string} [payload]
131
- * @returns {Promise<string>}
132
- */
143
+ /**
144
+ * Async run: Captures stdout; rejects on error/timeout.
145
+ *
146
+ * @param {string} [payload] - Stdin payload.
147
+ * @returns {Promise<string>} Stdout.
148
+ */
133
149
  run(payload) {
134
- this.#proc = new SHExec(this.#cmd, this.#prefix, this.#options);
150
+ this.#proc = new SHExecute(this.#cmd, this.#prefix, this.#options);
135
151
  return this.#proc.run(payload);
136
152
  }
137
153
 
138
- /**
139
- * Works for terminal screen takeovers like editors
140
- * @param {string} [payload]
141
- * @returns {import('child_process').SpawnSyncReturns}
142
- */
154
+ /**
155
+ * Sync run: Full Node SpawnSyncReturns.
156
+ *
157
+ * Good for TTY takeovers (e.g., vim).
158
+ *
159
+ * @param {string} [payload] - Stdin payload.
160
+ * @returns {import('child_process').SpawnSyncReturns<Buffer>}
161
+ */
143
162
  runSync(payload) {
144
- return new SHExec(this.#cmd, this.#prefix, this.#options).runSync(payload);
163
+ return new SHExecute(this.#cmd, this.#prefix, this.#options).runSync(payload);
145
164
  }
165
+
166
+ /**
167
+ * Kills running process + children.
168
+ *
169
+ * @param {number | string} [signal='SIGTERM'] - Signal.
170
+ * @returns {Promise<number[]>} Killed PIDs.
171
+ */
146
172
  async kill(signal = 'SIGTERM') {
147
173
  let res = [];
148
- res = await this.#proc.kill(signal);
149
- this.#proc = undefined;
174
+ if (this.#proc) {
175
+ res = await this.#proc.kill(signal);
176
+ this.#proc = null;
177
+ }
150
178
  return res;
151
179
  }
152
180
  }
153
181
 
154
- export default SHDispatch
182
+ export default SHDispatch;
package/lib/SHExecute.js CHANGED
@@ -1,8 +1,22 @@
1
1
  import { spawnSync, spawn } from 'node:child_process';
2
2
 
3
- // Local helpers
3
+ /**
4
+ * Validates if a number is a finite positive integer (including 0).
5
+ *
6
+ * @param {unknown} n - Value to check.
7
+ * @returns {boolean} True if finite non-negative integer.
8
+ */
4
9
  const isFinitePosInt = (n) => Number.isFinite(n) && n >= 0;
5
- // Parse duration: accepts numbers (ms) or strings like "250", "250ms", or "2s". null/undefined yields 0; negatives or invalid forms throw.
10
+
11
+ /**
12
+ * Parses a human-readable duration into milliseconds.
13
+ *
14
+ * Supports: numbers (ms), '250ms', '2s'. null/undefined → 0; invalid → throws.
15
+ *
16
+ * @param {number|string|null|undefined} d - Duration input.
17
+ * @returns {number} ms.
18
+ * @throws {Error} Invalid format/type.
19
+ */
6
20
  const parseDuration = (d) => {
7
21
  if (typeof d === 'number') return isFinitePosInt(d) ? d : (() => { throw new Error('Invalid duration'); })();
8
22
  if (typeof d === 'string') {
@@ -15,7 +29,12 @@ const parseDuration = (d) => {
15
29
  throw new Error('Invalid duration type');
16
30
  };
17
31
 
18
- // Kill a process tree using pgrep without going through a shell
32
+ /**
33
+ * Retrieves child PIDs of a given parent PID using pgrep -P.
34
+ *
35
+ * @param {number} pid - Parent PID.
36
+ * @returns {Promise<number[]>} Array of child PIDs.
37
+ */
19
38
  const childrenOf = (pid) => new Promise((resolve, reject) => {
20
39
  const p = spawn('pgrep', ['-P', String(pid)], { stdio: ['ignore', 'pipe', 'ignore'] });
21
40
  const out = [];
@@ -32,21 +51,32 @@ const childrenOf = (pid) => new Promise((resolve, reject) => {
32
51
  });
33
52
 
34
53
  /**
35
- * SHExecute
36
- * Low-level process runner used by SHDispatch.
54
+ * @typedef {import('../SH.js').SHOptions & { maxBuffer?: number }} SHExecuteOptions
55
+ * @description Extended options for SHExecute: adds `maxBuffer` (bytes per stream, default 1MB).
56
+ */
57
+
58
+ /**
59
+ * Low-level process executor for shell commands.
60
+ *
61
+ * Used internally by {@link SHDispatch}.
62
+ *
63
+ * Key features:
64
+ * - **Shell mode**: If `options.shell` is string/true, runs `${prefix}; ${command}` via shell ('bash' default).
65
+ * - **No-shell mode**: Uses `/usr/bin/env -S ${command}` for direct exec (ignores prefix).
66
+ * - **Rolling timeout**: `options.timeout` (ms/'2s'); resets on stdout/stderr data. SIGTERM on expiry.
67
+ * - **Buffering**: Captures stdout/stderr up to `maxBuffer` (1MB default); appends truncation markers.
68
+ * - **Payload**: `run(payload)` writes string to stdin (forces pipe).
69
+ * - **Detached**: If `options.detached`, resolves early (~1s) and unrefs.
70
+ * - **Kill**: Terminates process + children via pgrep.
37
71
  *
38
- * Features:
39
- * - Optional shell execution: if options.shell is a string (e.g., 'bash') or true, runs `${prefix}; ${command}` via that shell; otherwise runs without a shell using `/usr/bin/env -S`.
40
- * - Timeout: options.timeout may be a number (ms) or string like '1500ms' or '2s'. On timeout, run() rejects with a descriptive error.
41
- * - Buffering: Captures stdout/stderr up to maxBuffer bytes per stream (default 40 MiB). Appends "[stdout truncated]" / "[stderr truncated]" markers if exceeded.
42
- * - Payload: Passing a payload writes it to stdin and forces stdin to be a pipe.
43
- * - Detached mode: If options.detached is true, run() resolves to '' after ~1s and unrefs the process.
44
- * - Kill support: kill(signal) attempts to terminate the process (and some children) and causes run() to reject with "Process killed (forced).".
72
+ * @example
73
+ * const exec = new SHExecute('ls', 'set -euo pipefail', { timeout: '5s' });
74
+ * const out = await exec.run();
45
75
  */
46
76
  class SHExecute {
47
77
  #forcedKill = false;
48
- /** @type {import('child_process').ChildProcess} */
49
- #proc;
78
+ /** @type {import('child_process').ChildProcess | null} */
79
+ #proc = null;
50
80
  #prefix = '';
51
81
  #command = '';
52
82
  /** @type {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} */
@@ -55,14 +85,14 @@ class SHExecute {
55
85
  #stderrChunks = [];
56
86
  #stdoutLen = 0;
57
87
  #stderrLen = 0;
58
- #maxBuffer = 40 * 1024 * 1024; // 40MB per stream
88
+ #maxBuffer = 500 * 1024; // 500KB per stream
59
89
  #truncated = { stdout: false, stderr: false };
60
90
  #timedOut = false;
61
91
 
62
92
  /**
63
- * @param {string} command - linux command to be executed
64
- * @param {string} prefix - command prefix (shell prelude, e.g. 'set -euo pipefail')
65
- * @param {import('child_process').SpawnOptions & { maxBuffer?: number }} options
93
+ * @param {string} command - Command to execute.
94
+ * @param {string} prefix - Shell prelude (e.g., 'set -euo pipefail'); ignored in no-shell.
95
+ * @param {SHExecuteOptions} [options] - Spawn options + maxBuffer/timeout.
66
96
  */
67
97
  constructor(command, prefix, options = {}) {
68
98
  this.#prefix = prefix;
@@ -70,18 +100,14 @@ class SHExecute {
70
100
  const { maxBuffer, ...spawnOpts } = options ?? {};
71
101
  this.#maxBuffer = Number.isFinite(maxBuffer) && maxBuffer > 0 ? maxBuffer : this.#maxBuffer;
72
102
  this.#options = spawnOpts;
73
- this.#proc = null;
74
- this.#stdoutChunks = [];
75
- this.#stderrChunks = [];
76
- this.#stdoutLen = 0;
77
- this.#stderrLen = 0;
78
- this.#truncated = { stdout: false, stderr: false };
79
- this.#timedOut = false;
80
103
  }
81
104
 
82
105
  /**
83
- * @param {string} [payload] - data to write
84
- * @returns {import('child_process').SpawnSyncReturns}
106
+ * Synchronous execution.
107
+ *
108
+ * @param {string} [payload] - Stdin data (forces pipe).
109
+ * @returns {import('child_process').SpawnSyncReturns<Buffer>}
110
+ * @throws {Error} Invalid payload type.
85
111
  */
86
112
  runSync(payload) {
87
113
  this.#forcedKill = false;
@@ -109,15 +135,20 @@ class SHExecute {
109
135
  }
110
136
 
111
137
  /**
112
- * @param {string} [payload] - data to write
113
- * @returns {Promise<string>}
138
+ * Asynchronous execution with buffering/timeout/kill.
139
+ *
140
+ * Resolves stdout (trimmed) on success; rejects on error/timeout/kill.
141
+ *
142
+ * @param {string} [payload] - Stdin data (forces pipe).
143
+ * @returns {Promise<string>} Trimmed UTF-8 stdout (+ truncation marker if exceeded).
144
+ * @throws {Error} Command failure (incl. code, stderr), timeout, kill, spawn error.
114
145
  */
115
146
  run(payload) {
116
147
  this.#forcedKill = false;
117
148
  if (payload && typeof payload !== 'string') {
118
149
  throw new Error('Argument is not a string');
119
150
  }
120
- /** @type {import('node:child_process').SpawnOptions} */
151
+ /** @type {import('child_process').SpawnOptions} */
121
152
  const options = { ...this.#options, shell: false };
122
153
  if (payload) {
123
154
  const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
@@ -147,20 +178,27 @@ class SHExecute {
147
178
 
148
179
  const ms = parseDuration(this.#options?.timeout ?? 0);
149
180
  let timeoutId = null;
150
- if (ms > 0) {
151
- timeoutId = setTimeout(() => {
152
- this.#timedOut = true;
153
- this.kill('SIGTERM').catch(() => {});
154
- }, ms);
155
- }
181
+ const resetTimeout = () => {
182
+ if (timeoutId !== null) clearTimeout(timeoutId);
183
+ if (ms > 0) {
184
+ timeoutId = setTimeout(() => {
185
+ this.#timedOut = true;
186
+ this.kill('SIGTERM').catch(() => {});
187
+ }, ms);
188
+ }
189
+ };
190
+
191
+ if (ms > 0) resetTimeout();
156
192
 
157
193
  this.#proc.stdout?.on('data', (chunk) => {
158
194
  this.#stdoutLen += chunk.length;
195
+ // prevent TOKEN bombs
159
196
  if (this.#stdoutLen <= this.#maxBuffer) {
160
197
  this.#stdoutChunks.push(chunk);
161
198
  } else {
162
199
  this.#truncated.stdout = true;
163
200
  }
201
+ resetTimeout();
164
202
  });
165
203
 
166
204
  this.#proc.stderr?.on('data', (chunk) => {
@@ -170,18 +208,20 @@ class SHExecute {
170
208
  } else {
171
209
  this.#truncated.stderr = true;
172
210
  }
211
+ resetTimeout();
173
212
  });
174
213
 
175
214
  return new Promise((resolve, reject) => {
176
215
  if (options.detached) {
177
216
  setTimeout(() => {
217
+ if (timeoutId !== null) clearTimeout(timeoutId);
178
218
  resolve('');
179
219
  this.#proc.unref();
180
220
  }, 1000);
181
221
  }
182
222
 
183
223
  this.#proc.on('close', (code, signal) => {
184
- if (timeoutId) clearTimeout(timeoutId);
224
+ if (timeoutId !== null) clearTimeout(timeoutId);
185
225
  if (this.#forcedKill) {
186
226
  reject(new Error('Process killed (forced).'));
187
227
  return;
@@ -208,9 +248,11 @@ class SHExecute {
208
248
  }
209
249
 
210
250
  /**
211
- * Kill this process and possible child processes
212
- * @param {number | string} signal - kill signal
213
- * @returns {Promise<number[]>}
251
+ * Terminates process and its children (via pgrep).
252
+ *
253
+ * @param {number | string} [signal='SIGTERM'] - Signal to send.
254
+ * @returns {Promise<number[]>} Killed PIDs.
255
+ * @throws {Error} No process/PID.
214
256
  */
215
257
  async kill(signal = 'SIGTERM') {
216
258
  if (!this.#proc) throw new Error('Trying to kill a process without creating one.');
@@ -225,7 +267,7 @@ class SHExecute {
225
267
  }
226
268
  try { process.kill(pid, signal); killed.push(pid); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
227
269
  } finally {
228
- this.#proc = undefined;
270
+ this.#proc = null;
229
271
  }
230
272
  return killed;
231
273
  }