@j-o-r/sh 1.1.21 → 1.1.22

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/SHExecute.js CHANGED
@@ -54,20 +54,32 @@ class SHExecute {
54
54
  #command = '';
55
55
  /** @type {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} */
56
56
  #options = {};
57
- #stdout = '';
58
- #stderr = '';
57
+ #stdoutChunks = [];
58
+ #stderrChunks = [];
59
+ // Track sizes to avoid unbounded memory growth and RangeError on join/toString
60
+ #stdoutLen = 0;
61
+ #stderrLen = 0;
62
+ // Max bytes to buffer in memory (per stream). Remaining data is ignored.
63
+ #maxBuffer = 40 * 1024 * 1024; // default 40MB
64
+ #truncated = { stdout: false, stderr: false };
59
65
  /**
60
66
  * @param {string} command - linux command to be executed
61
67
  * @param {string} prefix - command prefix (bash, sh etc.)
62
- * @param {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} options
68
+ * @param {import('child_process').SpawnOptions & { maxBuffer?: number }} options
63
69
  */
64
70
  constructor(command, prefix, options = {}) {
65
71
  this.#prefix = prefix;
66
72
  this.#command = command;
67
- this.#options = options;
73
+ // Extract our custom option to avoid passing unknown keys to spawn/spawnSync
74
+ const { maxBuffer, ...spawnOpts } = options ?? {};
75
+ this.#maxBuffer = Number.isFinite(maxBuffer) && maxBuffer > 0 ? maxBuffer : this.#maxBuffer;
76
+ this.#options = spawnOpts;
68
77
  this.#proc = null;
69
- this.#stdout = '';
70
- this.#stderr = '';
78
+ this.#stdoutChunks = [];
79
+ this.#stderrChunks = [];
80
+ this.#stdoutLen = 0;
81
+ this.#stderrLen = 0;
82
+ this.#truncated = { stdout: false, stderr: false };
71
83
  }
72
84
  /**
73
85
  * @param {string} [payload] - data to write
@@ -78,10 +90,15 @@ class SHExecute {
78
90
  throw new Error('Argument is not a string');
79
91
  }
80
92
  /** @type {import('node:child_process').SpawnSyncOptions} */
81
- const options = this.#options;
93
+ const options = { ...this.#options };
82
94
  // pipe need to be set on stdin when posting a payload
83
95
  // @ts-ignore
84
- if (payload) options['stdio'][0] = 'pipe';
96
+ if (payload) {
97
+ // clone/ensure stdio array exists
98
+ const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
99
+ stdio[0] = 'pipe';
100
+ options.stdio = stdio;
101
+ }
85
102
  const input = payload || undefined;
86
103
  if (input) {
87
104
  options.input = input
@@ -97,17 +114,37 @@ class SHExecute {
97
114
  throw new Error('Argument is not a string');
98
115
  }
99
116
  /** @type {import('node:child_process').SpawnOptions} */
100
- const options = this.#options;
117
+ const options = { ...this.#options };
101
118
  // pipe need to be set on stdin when posting a payload
102
119
  // @ts-ignore
120
+
103
121
  if (payload) options.stdio[0] = 'pipe';
122
+
123
+ // reset buffers for each run
124
+ this.#stdoutChunks = [];
125
+ this.#stderrChunks = [];
126
+ this.#stdoutLen = 0;
127
+ this.#stderrLen = 0;
128
+ this.#truncated = { stdout: false, stderr: false };
129
+
104
130
  this.#proc = spawn(this.#prefix, [this.#command], options);
105
- this.#proc.stdout?.on('data', (data) => {
106
- this.#stdout += data;
131
+ this.#proc.stdout?.on('data', (chunk) => {
132
+ this.#stdoutLen += chunk.length;
133
+ if (this.#stdoutLen <= this.#maxBuffer) {
134
+ this.#stdoutChunks.push(chunk);
135
+ } else {
136
+ this.#truncated.stdout = true;
137
+ // ignore further data to cap memory; alternatively implement a ring buffer
138
+ }
107
139
  });
108
140
 
109
- this.#proc.stderr?.on('data', (data) => {
110
- this.#stderr += data;
141
+ this.#proc.stderr?.on('data', (chunk) => {
142
+ this.#stderrLen += chunk.length;
143
+ if (this.#stderrLen <= this.#maxBuffer) {
144
+ this.#stderrChunks.push(chunk);
145
+ } else {
146
+ this.#truncated.stderr = true;
147
+ }
111
148
  });
112
149
  if (payload) {
113
150
  this.#proc.stdin.end(payload);
@@ -123,14 +160,20 @@ class SHExecute {
123
160
  this.#proc.on('close', (code) => {
124
161
  if (this.#forcedKill) {
125
162
  // Resolve without content
126
- resolve();
163
+ resolve('');
127
164
  return;
128
165
  }
129
166
  // Detached does not closes with an exitcode
130
167
  if (code === 0 || code === null || typeof (code) === 'undefined') {
131
- resolve(this.#stdout.trim());
168
+ const stdoutBuf = this.#stdoutChunks.length ? Buffer.concat(this.#stdoutChunks) : Buffer.alloc(0);
169
+ let stdout = stdoutBuf.toString('utf8').trim();
170
+ if (this.#truncated.stdout) stdout += '\n[stdout truncated]\n';
171
+ resolve(stdout);
132
172
  } else {
133
- reject(new Error(`${code}: ${this.#command} "${this.#stderr.trim()}"`));
173
+ const stderrBuf = this.#stderrChunks.length ? Buffer.concat(this.#stderrChunks) : Buffer.alloc(0);
174
+ let stderr = stderrBuf.toString('utf8').trim();
175
+ if (this.#truncated.stderr) stderr += '\n[stderr truncated]\n';
176
+ reject(new Error(`${code}: ${this.#command} "${stderr}"`));
134
177
  }
135
178
  });
136
179
 
@@ -165,4 +208,3 @@ class SHExecute {
165
208
  }
166
209
 
167
210
  export default SHExecute;
168
-
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.21",
5
+ "version": "1.1.22",
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/tmp.js ADDED
@@ -0,0 +1,169 @@
1
+ import { spawnSync, spawn, exec } from 'node:child_process';
2
+
3
+ /**
4
+ * Kills a process and all child processes of a given process ID in Linux/Posix.
5
+ * @param {number} processPid - The process ID.
6
+ * @param {string|number} signal - Signal to send.
7
+ * @retruns {Promise<number[]>} array with killed pid numbers
8
+ */
9
+ const killProcesses = (processPid, signal) => {
10
+ const killed = [];
11
+ return new Promise((resolve, reject) => {
12
+ // Command to get child PIDs of the given process
13
+ const cmd = `pgrep -P ${processPid}`;
14
+ exec(cmd, (error, stdout, stderr) => {
15
+ if (error) {
16
+ reject(error);
17
+ return;
18
+ }
19
+ if (stderr) {
20
+ reject(new Error(stderr));
21
+ return;
22
+ }
23
+ const pids = stdout.split(/\r?\n/).filter(pid => pid) || [];
24
+ // Kill each child process
25
+ try {
26
+ for (const pid of pids) {
27
+ process.kill(parseInt(pid), signal);
28
+ killed.push(parseInt(pid));
29
+ }
30
+ } catch (err) {
31
+ reject(err);
32
+ return;
33
+ }
34
+ // Kill the parent process after all child processes have been killed
35
+ try {
36
+ process.kill(processPid, signal);
37
+ killed.push(processPid);
38
+ } catch (err) {
39
+ reject(err);
40
+ return;
41
+ }
42
+ resolve(killed);
43
+ });
44
+ });
45
+ }
46
+
47
+ class SHExecute {
48
+ #forcedKill = false;
49
+ /**
50
+ * @type {import('child_process').ChildProcess}
51
+ */
52
+ #proc;
53
+ #prefix = '';
54
+ #command = '';
55
+ /** @type {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} */
56
+ #options = {};
57
+ #stdoutChunks = [];
58
+ #stderrChunks = [];
59
+ /**
60
+ * @param {string} command - linux command to be executed
61
+ * @param {string} prefix - command prefix (bash, sh etc.)
62
+ * @param {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} options
63
+ */
64
+ constructor(command, prefix, options = {}) {
65
+ this.#prefix = prefix;
66
+ this.#command = command;
67
+ this.#options = options;
68
+ this.#proc = null;
69
+ this.#stdoutChunks = [];
70
+ this.#stderrChunks = [];
71
+ }
72
+ /**
73
+ * @param {string} [payload] - data to write
74
+ * @retuns {Promise<object>}
75
+ */
76
+ runSync(payload) {
77
+ if (payload && typeof payload !== 'string') {
78
+ throw new Error('Argument is not a string');
79
+ }
80
+ /** @type {import('node:child_process').SpawnSyncOptions} */
81
+ const options = this.#options;
82
+ // pipe need to be set on stdin when posting a payload
83
+ // @ts-ignore
84
+ if (payload) options['stdio'][0] = 'pipe';
85
+ const input = payload || undefined;
86
+ if (input) {
87
+ options.input = input
88
+ }
89
+ return spawnSync(this.#prefix, [this.#command], options);
90
+ }
91
+ /**
92
+ * @param {string} [payload] - data to write
93
+ * @retuns {Promise<string>}
94
+ */
95
+ run(payload) {
96
+ if (payload && typeof payload !== 'string') {
97
+ throw new Error('Argument is not a string');
98
+ }
99
+ /** @type {import('node:child_process').SpawnOptions} */
100
+ const options = this.#options;
101
+ // pipe need to be set on stdin when posting a payload
102
+ // @ts-ignore
103
+ if (payload) options.stdio[0] = 'pipe';
104
+ this.#proc = spawn(this.#prefix, [this.#command], options);
105
+ this.#proc.stdout?.on('data', (data) => {
106
+ this.#stdoutChunks.push(data);
107
+ });
108
+
109
+ this.#proc.stderr?.on('data', (data) => {
110
+ this.#stderrChunks.push(data);
111
+ });
112
+ if (payload) {
113
+ this.#proc.stdin.end(payload);
114
+ }
115
+ return new Promise((resolve, reject) => {
116
+ // https://nodejs.org/docs/latest/api/child_process.html#optionsdetached
117
+ if (options.detached) {
118
+ setTimeout(() => {
119
+ resolve('');
120
+ this.#proc.unref();
121
+ }, 1000);
122
+ }
123
+ this.#proc.on('close', (code) => {
124
+ if (this.#forcedKill) {
125
+ // Resolve without content
126
+ resolve();
127
+ return;
128
+ }
129
+ // Detached does not closes with an exitcode
130
+ if (code === 0 || code === null || typeof (code) === 'undefined') {
131
+ const stdout = this.#stdoutChunks.map(chunk => chunk.toString()).join('').trim();
132
+ resolve(stdout);
133
+ } else {
134
+ const stderr = this.#stderrChunks.map(chunk => chunk.toString()).join('').trim();
135
+ reject(new Error(`${code}: \${this.#command} "${stderr}"`));
136
+ }
137
+ });
138
+
139
+ this.#proc.on('error', (err) => {
140
+ reject(err);
141
+ });
142
+ });
143
+ }
144
+ /**
145
+ * Kill this process and possible child processes
146
+ * @param {number | string} signal - kill signal
147
+ * @returns {Promise<number[]>}
148
+ */
149
+ async kill(signal = 'SIGTERM') {
150
+ if (!this.#proc) throw new Error('Trying to kill a process without creating one.');
151
+ if (!this.#proc.pid) throw new Error('The process pid is undefined.');
152
+ this.#forcedKill = true;
153
+ let res = [];
154
+ try {
155
+ // Try to kill pid 'childs'
156
+ res = await killProcesses(this.#proc.pid, signal);
157
+ } catch (_e) { }
158
+ if (!res.includes(this.#proc.pid)) {
159
+ // Kill self if I am not allready killed
160
+ res.push(this.#proc.pid);
161
+ // @ts-ignore
162
+ this.#proc.kill(signal);
163
+ }
164
+ this.#proc = undefined;
165
+ return res;
166
+ }
167
+ }
168
+
169
+ export default SHExecute;
@@ -3,9 +3,11 @@ declare class SHExecute {
3
3
  /**
4
4
  * @param {string} command - linux command to be executed
5
5
  * @param {string} prefix - command prefix (bash, sh etc.)
6
- * @param {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} options
6
+ * @param {import('child_process').SpawnOptions & { maxBuffer?: number }} options
7
7
  */
8
- constructor(command: string, prefix: string, options?: import("child_process").SpawnOptions | import("child_process").SpawnSyncOptions);
8
+ constructor(command: string, prefix: string, options?: import("child_process").SpawnOptions & {
9
+ maxBuffer?: number;
10
+ });
9
11
  /**
10
12
  * @param {string} [payload] - data to write
11
13
  * @retuns {Promise<object>}