@j-o-r/sh 0.0.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/sh.js CHANGED
@@ -1,3 +1,262 @@
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} 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
+ #proc;
49
+ #command = '';
50
+ #options = {};
51
+ #stdout = '';
52
+ #stderr = '';
53
+ constructor(command, options = {}) {
54
+ this.#command = command;
55
+ this.#options = options;
56
+ this.#proc = null;
57
+ this.#stdout = '';
58
+ this.#stderr = '';
59
+ }
60
+ runSync () {
61
+ let { cwd, shell, env, stdio } = this.#options;
62
+ return spawnSync(this.#options.prefix, [this.#command], {
63
+ cwd,
64
+ shell,
65
+ stdio,
66
+ windowsHide: true,
67
+ env,
68
+ });
69
+ }
70
+ /**
71
+ * @param {string} [payload] - data to write
72
+ * @retuns {Promise<string>}
73
+ */
74
+ run(payload) {
75
+ if (payload && typeof payload !== 'string') {
76
+ throw new Error('Argument is not a string');
77
+ }
78
+ let { cwd, shell, env, stdio } = this.#options;
79
+ if (payload) stdio = ['pipe', 'pipe', 'pipe'];
80
+ this.#proc = spawn(this.#options.prefix, [this.#command], {
81
+ cwd,
82
+ shell,
83
+ stdio,
84
+ windowsHide: true,
85
+ env,
86
+ });
87
+
88
+ this.#proc.stdout?.on('data', (data) => {
89
+ this.#stdout += data;
90
+ });
91
+
92
+ this.#proc.stderr?.on('data', (data) => {
93
+ this.#stderr += data;
94
+ });
95
+ if (payload) {
96
+ this.#proc.stdin.write(payload);
97
+ this.#proc.stdin.end();
98
+ }
99
+ return new Promise((resolve, reject) => {
100
+ this.#proc.on('close', (code) => {
101
+ if (code === 0) {
102
+ resolve(this.#stdout.trim());
103
+ } else {
104
+ reject(new Error(`${code}: ${this.#command} "${this.#stderr.trim()}"`));
105
+ }
106
+ });
107
+
108
+ this.#proc.on('error', (err) => {
109
+ reject(err);
110
+ });
111
+ });
112
+ }
113
+ /**
114
+ * @returns {Promise<number[]>}
115
+ */
116
+ async kill(signal = 'SIGTERM') {
117
+ if (!this.#proc) throw new Error('Trying to kill a process without creating one.');
118
+ if (!this.#proc.pid) throw new Error('The process pid is undefined.');
119
+
120
+ return killProcesses(this.#proc.pid, signal);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * @typedef {Object} SpawnSyncResponse
126
+ * @property {number} status - The exit code of the child process. A value of `0` indicates success.
127
+ * @property {Buffer|null} signal - The signal used to terminate the process, if any.
128
+ * @property {Array<string|null>} output - An array containing the standard output and standard error of the child process.
129
+ * @property {number} pid - The process ID of the child process.
130
+ * @property {Buffer|null} stdout - The standard output of the child process.
131
+ * @property {Buffer|null} stderr - The standard error of the child process.
132
+ */
133
+ /**
134
+ * Default options for the execution environment.
135
+ *
136
+ * @typedef {Object} SHOptions
137
+ * @property {string} [cwd] - The current working directory.
138
+ * @property {NodeJS.ProcessEnv} [env] - The environment variables.
139
+ * @property {string} [shell] - The shell to use for execution.
140
+ * @property {string} [prefix] - The prefix commands to ensure a safe execution environment.
141
+ * @property {StdioOption|StdioOptions} [stdio] - The stdio configuration.
142
+ */
143
+ /**
144
+ * @typedef {('pipe' | 'ignore' | 'inherit' | number)} StdioOption
145
+ * @description Defines the stdio configuration for each of the standard streams.
146
+ *
147
+ * - 'pipe' creates a pipe between the child process and the parent process.
148
+ * The parent end of the pipe is exposed as a property on the `ChildProcess` object.
149
+ * - 'ignore' indicates that the child process's corresponding stdio file descriptor will be ignored.
150
+ * - 'inherit' passes the corresponding stdio stream to/from the child process.
151
+ * - Stream object to be used for the stdio stream.
152
+ * - Positive integer representing a file descriptor to be used for the stdio stream.
153
+ */
154
+ /**
155
+ * @typedef {Array<StdioOption>|StdioOption} StdioOptions
156
+ * @description
157
+ * Configures the stdio streams for the child process. This can be an array or a single StdioOption.
158
+ *
159
+ * Array Form: Specify the configuration for [stdin, stdout, stderr].
160
+ * - If array length is more than 3, additional positions correspond to extra streams.
161
+ * Single Value: This value will be applied to stdin, stdout, and stderr.
162
+ *
163
+ * Examples:
164
+ * - ['pipe', 'pipe', 'ignore']: Pipe stdin and stdout, ignore stderr.
165
+ * - 'inherit': Inherit all stdio streams from the parent.
166
+ */
167
+ /**
168
+ * 'Code Safe' has own prop
169
+ *
170
+ * @param {any} o - object to examine
171
+ * @param {string} p - property to look for
172
+ * @returns {boolean}
173
+ */
174
+ const hasProp = (o, p) => {
175
+ if (typeof o === 'undefined') {
176
+ return false;
177
+ }
178
+ return Object.prototype.hasOwnProperty.call(o, p);
179
+ };
180
+
181
+ /**
182
+ * Merge property values while maintaining the fixed set of props in the original object
183
+ * @param {object} predefined - original object
184
+ * @param {object} options - object with new values
185
+ * @retuns {object}
186
+ */
187
+ const mergeOptions = (predefined, options) => {
188
+ // Extract the keys from the predefined object
189
+ const keys = Object.keys(predefined);
190
+
191
+ // Use reduce to accumulate only the predefined properties from sourceObj
192
+ const mergedObj = keys.reduce((acc, key) => {
193
+ if (hasProp(options, key)) {
194
+ acc[key] = options[key];
195
+ } else {
196
+ acc[key] = predefined[key];
197
+ }
198
+ return acc;
199
+ }, {});
200
+
201
+ return mergedObj;
202
+ };
203
+
204
+
205
+ /** @type {SHOptions} */
206
+ const defaultOptions = {
207
+ cwd: process.cwd(),
208
+ env: process.env,
209
+ shell: 'bash',
210
+ prefix: 'set -euo pipefail;/usr/bin/env',
211
+ stdio: ['inherit', 'pipe', 'pipe']
212
+ };
213
+
214
+
215
+
216
+ class SHDispatch {
217
+ #cmd = '';
218
+ #options = {};
219
+ #proc;
220
+ /**
221
+ * @param {string} cmd - cmd to execute
222
+ */
223
+ constructor(cmd) {
224
+ this.#cmd = cmd;
225
+ this.#options = defaultOptions;
226
+ }
227
+ /**
228
+ * @param {SHOptions} options
229
+ * @returns {SHDispatch}
230
+ */
231
+ options(options) {
232
+ this.#options = mergeOptions(defaultOptions, options);
233
+ return this;
234
+ }
235
+ /**
236
+ * @param {string} [payload]
237
+ * @returns {Promise<string>}
238
+ */
239
+ run(payload) {
240
+ this.#proc = new SHExecute(this.#cmd, this.#options);
241
+ return this.#proc.run(payload);
242
+ }
243
+
244
+ /**
245
+ * Works for screen takeovers like editors
246
+ * @returns {SpawnSyncResponse}
247
+ */
248
+ runSync() {
249
+ // @ts-ignore
250
+ return new SHExecute(this.#cmd, this.#options).runSync();
251
+ }
252
+ async kill() {
253
+ try {
254
+ await this.#proc.kill();
255
+ } catch (_e) {}
256
+ this.#proc = undefined;
257
+ }
258
+ }
259
+
1
260
  // Copyright 2021 Google LLC
2
261
  //
3
262
  // Licensed under the Apache License, Version 2.0 (the "License");
@@ -19,64 +278,85 @@
19
278
  // The core functionality of this code is highly beneficial. However, certain parts of the original code
20
279
  // were overwriting the global namespace with core libraries and variables. This was causing conflicts with
21
280
  // other packages (for instance, fetch) and introducing unexpected elements into my code base.
281
+ // The main $/SH method is all there is left, with barebone Promises and readable code.
22
282
  // Changes Made:
23
283
  // - The code has been or is being reformatted to comply with ES2020 standards.
24
- // - Some methods were added and existing ones were modified to enhance usability.
284
+ // - Some methods were added and existing ones were modified or deleted to enhance usability.
285
+ // - Most methods were deleted,
25
286
  // - The namespace has been changed from '$' to 'SH'.
26
287
  // Modified by: jorrit.duin+sh[AT]gmail.com
27
288
 
28
- import assert from 'node:assert';
29
- import { AsyncLocalStorage } from 'node:async_hooks';
30
- import { log, parseDuration, quote, quotePowerShell, } from './utils.js';
31
- import ProcessPromise from './ProcessPromise.js';
32
- const storage = new AsyncLocalStorage();
33
289
 
34
- const defaults = {
35
- processCwd: '',
36
- verbose: false,
37
- env: {},
38
- shell: 'bash',
39
- prefix: '',
40
- };
41
- defaults.prefix = 'set -euo pipefail;/usr/bin/env';
42
-
43
- /**
44
- * Escape CLI arguments
45
- * @param {string[]} arg
46
- * @retruns {string}
47
- */
48
- const sanitizeArg = (arg) => {
49
- const s = `${arg}`;
50
- if (process.platform == 'win32') {
51
- return quotePowerShell(s)
52
- }
53
- return quote(s);
54
- }
55
290
 
56
- const getStore = () => {
57
- return storage.getStore() || defaults;
58
- }
59
291
  /**
60
- * Creates a new ProcessPromise object that represents a command to be executed.
292
+ * Creates a new SHDispatch object that represents a command to be executed.
61
293
  *
62
294
  * @typedef {Function} Shell
63
295
  * @property {function(Array, ...*): ProcessPromise} execute - The function to execute the command.
64
- * @property {boolean} verbose - A property to control verbosity.
65
296
  *
66
297
  * @param {Array} pieces - An array of string literals from a template literal.
67
298
  * @param {...*} args - The values to be interpolated into the string literals.
68
- * @returns {ProcessPromise} A ProcessPromise object that represents the command.
299
+ * @returns {SHDispatch} Trigger for the command.
69
300
  * @throws {Error} Throws an error if any of the string literals in `pieces` is undefined.
70
301
  *
71
302
  * @example
72
- * const command = await SH`echo 'Hello, world!'`;
303
+ * const command = await SH`echo 'Hello, world!'`.run();
73
304
  */
74
- /** @type {Shell & { (pieces: TemplateStringsArray, ...args: any[]): ProcessPromise }} */
75
- // @ts-ignore
305
+
306
+ /**
307
+ * escape paramater commands
308
+ * @param {string} arg
309
+ * @returns {string}
310
+ */
311
+ const quote = (arg) => {
312
+ if (/^[a-z0-9/_.\-@:=]+$/i.test(arg) || arg === '') {
313
+ return arg;
314
+ }
315
+ return (`'` +
316
+ arg
317
+ .replace(/\\/g, '\\\\')
318
+ .replace(/'/g, "\\'")
319
+ .replace(/\f/g, '\\f')
320
+ .replace(/\n/g, '\\n')
321
+ .replace(/\r/g, '\\r')
322
+ .replace(/\t/g, '\\t')
323
+ .replace(/\v/g, '\\v')
324
+ .replace(/\0/g, '\\0') +
325
+ `'`);
326
+ };
327
+ /**
328
+ * Escape CLI arguments
329
+ * @param {string[]} arg
330
+ * @retruns {string}
331
+ */
332
+ const sanitizeArg = (arg) => {
333
+ const s = `${arg}`;
334
+ return quote(s);
335
+ };
336
+
337
+ /**
338
+ * 4ms, 5s || 5
339
+ * @param {number|string} d
340
+ * @returns {number}
341
+ */
342
+ const parseDuration = (d) => {
343
+ if (typeof d == 'number') {
344
+ if (isNaN(d) || d < 0)
345
+ throw new Error(`Invalid duration: "${d}".`);
346
+ return d;
347
+ }
348
+ else if (/\d+s/.test(d)) {
349
+ return +d.slice(0, -1) * 1000;
350
+ }
351
+ else if (/\d+ms/.test(d)) {
352
+ return +d.slice(0, -2);
353
+ }
354
+ throw new Error(`Unknown duration: "${d}".`);
355
+ };
356
+ /** @type {Shell & { (pieces: TemplateStringsArray, ...args: *): SHDispatch }} */
76
357
  const SH = new Proxy(function(pieces, ...args) {
77
- const from = new Error().stack.split(/^\s*at\s/m)[2].trim();
78
358
  if (pieces.some((p) => p == undefined)) {
79
- throw new Error(`Malformed command at ${from}`);
359
+ throw new Error(`Malformed command ${pieces}`);
80
360
  }
81
361
  let cmd = pieces[0], i = 0;
82
362
  while (i < args.length) {
@@ -89,29 +369,8 @@ const SH = new Proxy(function(pieces, ...args) {
89
369
  }
90
370
  cmd += s + pieces[++i];
91
371
  }
92
- let resolve, reject;
93
-
94
- const promise = new ProcessPromise((...args) => ([resolve, reject] = args));
95
- // re-add the environment
96
- defaults.processCwd = process.cwd();
97
- defaults.env = process.env,
98
- promise._bind(cmd, from, resolve, reject, getStore());
99
- // Postpone run to allow promise configuration.
100
- setImmediate(() => promise.isHalted || promise.run());
101
- return promise;
102
- }, {
103
- // this will get and set from:
104
- // defaults OR storage (@see within());
105
- set(_, key, value) {
106
- const target = key in Function.prototype ? _ : getStore();
107
- Reflect.set(target, key, value);
108
- return true;
109
- },
110
- get(_, key) {
111
- const target = key in Function.prototype ? _ : getStore();
112
- return Reflect.get(target, key);
113
- },
114
- });
372
+ return new SHDispatch(cmd);
373
+ }, {});
115
374
 
116
375
  /**
117
376
  * Create a async context in an sync block
@@ -119,32 +378,40 @@ const SH = new Proxy(function(pieces, ...args) {
119
378
  * @example
120
379
  * const p = within(async () => {
121
380
  * const res = await Promise.all([
122
- * SH`sleep 1; echo 1`,
123
- * SH`sleep 2; echo 2`,
381
+ * SH`sleep 1; echo 1`.run(),
382
+ * SH`sleep 2; echo 2`.run(),
124
383
  * sleep(2),
125
- * SH`sleep 3; echo 3`
384
+ * SH`sleep 3; echo 3`.run()
126
385
  * ]);
127
386
  */
128
387
  const within = (callback) => {
129
- // @ts-ignore
130
- return storage.run({ ...getStore() }, callback);
131
- }
388
+ (async () => {
389
+ return await callback()
390
+ })();
391
+ };
132
392
  /**
133
- * This function reads the standard input (stdin) for the current process.
134
- * It is used to get piped content into a script.
393
+ * This function reads the standard input (stdin) from the current process.
135
394
  * @example
136
395
  * const content = await stdin();
137
396
  */
138
- const stdin = async () => {
397
+ const readIn = async () => {
139
398
  let buf = '';
140
399
  process.stdin.setEncoding('utf8');
141
400
  for await (const chunk of process.stdin) {
142
401
  buf += chunk;
143
402
  }
144
403
  return buf;
145
- }
404
+ };
405
+
146
406
  /**
147
- * This function retries a command a specified number of times.
407
+ * Retries a given asynchronous function a specified number of times with optional delays between attempts.
408
+ *
409
+ * @param {number} count - The number of retry attempts.
410
+ * @param {string|expBackoff|Function} a - Either a delay duration as a string, a delay generator object, or the callback function.
411
+ * @param {Function} [b] - The callback function to retry, required if `a` is not a function.
412
+ * @returns {Promise<*>} - The result of the callback function if it succeeds within the retry attempts.
413
+ * @throws {Error} - The last error encountered if all retry attempts fail.
414
+ *
148
415
  * @example
149
416
  * // Retry a command 3 times
150
417
  * const p = await retry(3, () => SH`curl -s https://flipwrsi`);
@@ -156,12 +423,11 @@ const stdin = async () => {
156
423
  * const p = await retry(3, expBackoff(), () => SH`curl -s https://flipwrsi`);
157
424
  */
158
425
  const retry = async (count, a, b) => {
159
- const total = count;
426
+ // const total = count;
160
427
  let callback;
161
428
  let delayStatic = 0;
162
429
  let delayGen;
163
430
  // @ts-ignore
164
- const verbose = SH.verbose;
165
431
  if (typeof a == 'function') {
166
432
  callback = a;
167
433
  }
@@ -176,9 +442,7 @@ const retry = async (count, a, b) => {
176
442
  callback = b;
177
443
  }
178
444
  let lastErr;
179
- let attempt = 0;
180
445
  while (count-- > 0) {
181
- attempt++;
182
446
  try {
183
447
  return await callback();
184
448
  }
@@ -187,13 +451,6 @@ const retry = async (count, a, b) => {
187
451
  if (delayStatic > 0)
188
452
  delay = delayStatic;
189
453
  if (delayGen) delay = delayGen.next().value;
190
- log({
191
- verbose,
192
- kind: 'retry',
193
- error: ' FAIL ' +
194
- ` Attempt: ${attempt}${total == Infinity ? '' : `/${total}`}` +
195
- (delay > 0 ? `; next in ${delay}ms` : ''),
196
- });
197
454
  lastErr = err;
198
455
  if (count == 0)
199
456
  break;
@@ -202,34 +459,36 @@ const retry = async (count, a, b) => {
202
459
  }
203
460
  }
204
461
  throw lastErr;
205
- }
462
+ };
206
463
  /**
207
464
  * This function pauses or "sleeps" code execution for a specified duration.
208
465
  * @param {string|number} duration - The duration to pause execution for, e.g., '100ms' or '3s'.
209
466
  *
210
467
  * @example
211
468
  *
212
- * const res = await Promise.all([
213
- * SH`sleep 2; echo 2`, // Sleep for 2 seconds
214
- * sleep(2), // Sleep for 2 seconds
215
- * SH`sleep 3; echo 3` // Sleep for 3 seconds
216
- * ]);
469
+ * const res = await sleep('5s');
217
470
  */
218
471
  const sleep = (duration) => {
219
472
  return new Promise((resolve) => {
220
473
  setTimeout(resolve, parseDuration(duration));
221
474
  });
222
- }
475
+ };
223
476
  /**
224
477
  * Change working directory
225
478
  * @param {string} dir
226
479
  */
227
480
  const cd = (dir) => {
228
481
  // @ts-ignore
229
- const verbose = SH.verbose;
230
- log({ kind: 'cd', dir, verbose });
231
482
  process.chdir(dir);
232
- }
483
+ };
484
+ /**
485
+ * Generates an exponential backoff time with a random jitter.
486
+ *
487
+ * @generator
488
+ * @param {string} [max='60s'] - The maximum backoff time in a human-readable format (e.g., '60s' for 60 seconds).
489
+ * @param {string} [rand='100ms'] - The maximum random jitter time in a human-readable format (e.g., '100ms' for 100 milliseconds).
490
+ * @yields {number} The backoff time in milliseconds.
491
+ */
233
492
  function* expBackoff(max = '60s', rand = '100ms') {
234
493
  const maxMs = parseDuration(max);
235
494
  const randMs = parseDuration(rand);
@@ -239,16 +498,5 @@ function* expBackoff(max = '60s', rand = '100ms') {
239
498
  yield Math.min(2 ** n++, maxMs) + ms;
240
499
  }
241
500
  }
242
- // const syncCwd = () => {
243
- // if (SH['processCwd'] != process.cwd())
244
- // process.chdir(SH['processCwd']);
245
- // }
246
- export {
247
- SH,
248
- cd,
249
- sleep,
250
- retry,
251
- stdin,
252
- within,
253
- expBackoff,
254
- }
501
+
502
+ export { SH, cd, expBackoff, readIn, retry, sleep, within };
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": "0.0.3",
5
+ "version": "1.0.0",
6
6
  "description": "Execute shell commands on Linux-based systems from javascript",
7
7
  "main": "lib/sh.js",
8
8
  "engines": {
@@ -10,10 +10,11 @@
10
10
  },
11
11
  "scripts": {
12
12
  "test": "test/sh.js",
13
+ "compile": "npx rollup src/sh.js --generatedCode es2015 --file lib/sh.js --format es --name SH && npm run types",
13
14
  "publish": "npm run release && npm publish --access public",
14
15
  "release": "npm pack --pack-destination=release",
15
- "clear:types": "rm lib/*.d.ts",
16
- "types": "tsc lib/*.js --declaration --allowJs --emitDeclarationOnly"
16
+ "types": "tsc lib/*.js --module nodenext --moduleResolution nodenext --declaration --allowJs --emitDeclarationOnly --outDir lib/",
17
+ "clear:types": "rm lib/*.d.ts"
17
18
  },
18
19
  "repository": {
19
20
  "type": "git",
@@ -1,44 +0,0 @@
1
- /// <reference types="node" />
2
- export default ProcessOutput;
3
- /**
4
- * The ProcessPromise returns a ProcessOutput even when it fails, by rejecting it.
5
- * The extension of the Error class is implemented to ensure compatibility when an Error is expected upon rejection.
6
- */
7
- declare class ProcessOutput extends Error {
8
- /**
9
- * @param {number} code - exit code
10
- * @param {string} signal - SIGTERM ...
11
- * @param {string} stdout - std reponse string
12
- * @param {string} stderr - error reponse string
13
- * @param {string} combined - stderr + stdout
14
- * @param {string} message - Error message
15
- */
16
- constructor(code: number, signal: string, stdout?: string, stderr?: string, combined?: string, message?: string);
17
- /**
18
- * This string represents the standard output (stdout) from the child process.
19
- * @returns {string}
20
- */
21
- get stdout(): string;
22
- /**
23
- * This string represents the error output (stderr) from the child process.
24
- * @returns {string}
25
- */
26
- get stderr(): string;
27
- /**
28
- * This represents the exit code returned by the external process.
29
- * @returns {number} The exit
30
- */
31
- get exitCode(): number;
32
- /**
33
- * This represents the exit signal, for example, "SIGTERM", received from the child process.
34
- * @returns {string} The exit signal from the child
35
- */
36
- get signal(): string;
37
- /**
38
- * This method is used for debugging purposes. It displays the current state of the object
39
- * when passed to the console.log function.
40
- */
41
- [inspect.custom](): string;
42
- #private;
43
- }
44
- import { inspect } from 'node:util';