@j-o-r/sh 1.0.2 → 1.0.4

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/README.md CHANGED
@@ -59,7 +59,7 @@ The `SH` method accepts a template literal string enclosed in backticks as its a
59
59
  ### Additional Utilities
60
60
 
61
61
  The module also provides additional utilities for common tasks:
62
-
62
+ - `args(command)`: Parsing a command from a string into an arguments array
63
63
  - `cd(dir)`: Change the working directory.
64
64
  - `sleep(duration)`: Pause execution for a specified duration.
65
65
  - `retry(count, interval, callback)`: Retry a command a specified number of times with an optional interval.
@@ -92,6 +92,13 @@ This class is returned by the `SH` function. Here's a summary of its methods and
92
92
  console.log(res);
93
93
  ```
94
94
 
95
+ - Create a command from a string
96
+ ```javascript
97
+ const command = args("uname -r");
98
+ const content = await SH`${command}`.run();
99
+ console.log(content);
100
+ ```
101
+
95
102
  - Async context with multiple commands and sleep:
96
103
  ```javascript
97
104
  within(async () => {
package/lib/SH.js ADDED
@@ -0,0 +1,296 @@
1
+ // Copyright 2021 Google LLC
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // https://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ //
16
+ // Original Source: zx
17
+ // Link to Original Source: https://github.com/google/zx
18
+ // Reason for Using This Code:
19
+ // The core functionality of this code is highly beneficial. However, certain parts of the original code
20
+ // were overwriting the global namespace with core libraries and variables. This was causing conflicts with
21
+ // other packages (for instance, fetch) and introducing unexpected elements into my code base.
22
+ // The main $/SH method is all there is left, with barebone Promises and readable code.
23
+ // Changes Made:
24
+ // - The code has been or is being reformatted to comply with ES2020 standards.
25
+ // - Some methods were added and existing ones were modified or deleted to enhance usability.
26
+ // - Most methods were deleted,
27
+ // - The namespace has been changed from '$' to 'SH'.
28
+ // Modified by: jorrit.duin+sh[AT]gmail.com
29
+ import assert from 'node:assert';
30
+ import SHDispatch from './SHDispatch.js';
31
+
32
+
33
+ /**
34
+ * Creates a new SHDispatch object that represents a command to be executed.
35
+ *
36
+ * @typedef {Function} Shell
37
+ * @property {function(Array, ...*): ProcessPromise} execute - The function to execute the command.
38
+ *
39
+ * @param {Array} pieces - An array of string literals from a template literal.
40
+ * @param {...*} args - The values to be interpolated into the string literals.
41
+ * @returns {SHDispatch} Trigger for the command.
42
+ * @throws {Error} Throws an error if any of the string literals in `pieces` is undefined.
43
+ *
44
+ * @example
45
+ * const command = await SH`echo 'Hello, world!'`.run();
46
+ */
47
+
48
+ /**
49
+ * escape paramater commands
50
+ * @param {string} arg
51
+ * @returns {string}
52
+ */
53
+ const quote = (arg) => {
54
+ if (/^[a-z0-9/_.\-@:=]+$/i.test(arg) || arg === '') {
55
+ return arg;
56
+ }
57
+ return (`'` +
58
+ arg
59
+ .replace(/\\/g, '\\\\')
60
+ .replace(/'/g, "\\'")
61
+ .replace(/\f/g, '\\f')
62
+ .replace(/\n/g, '\\n')
63
+ .replace(/\r/g, '\\r')
64
+ .replace(/\t/g, '\\t')
65
+ .replace(/\v/g, '\\v')
66
+ .replace(/\0/g, '\\0') +
67
+ `'`);
68
+ }
69
+ /**
70
+ * Escape CLI arguments
71
+ * @param {string[]} arg
72
+ * @retruns {string}
73
+ */
74
+ const sanitizeArg = (arg) => {
75
+ const s = `${arg}`;
76
+ return quote(s);
77
+ }
78
+
79
+ /**
80
+ * 4ms, 5s || 5
81
+ * @param {number|string} d
82
+ * @returns {number}
83
+ */
84
+ const parseDuration = (d) => {
85
+ if (typeof d == 'number') {
86
+ if (isNaN(d) || d < 0)
87
+ throw new Error(`Invalid duration: "${d}".`);
88
+ return d;
89
+ }
90
+ else if (/\d+s/.test(d)) {
91
+ return +d.slice(0, -1) * 1000;
92
+ }
93
+ else if (/\d+ms/.test(d)) {
94
+ return +d.slice(0, -2);
95
+ }
96
+ throw new Error(`Unknown duration: "${d}".`);
97
+ }
98
+ /** @type {Shell & { (pieces: TemplateStringsArray, ...args: *): SHDispatch }} */
99
+ const SH = new Proxy(function(pieces, ...args) {
100
+ if (pieces.some((p) => p == undefined)) {
101
+ throw new Error(`Malformed command ${pieces}`);
102
+ }
103
+ let cmd = pieces[0], i = 0;
104
+ while (i < args.length) {
105
+ let s;
106
+ if (Array.isArray(args[i])) {
107
+ s = args[i].map((x) => sanitizeArg(x)).join(' ');
108
+ }
109
+ else {
110
+ s = sanitizeArg(args[i]);
111
+ }
112
+ cmd += s + pieces[++i];
113
+ }
114
+ return new SHDispatch(cmd);
115
+ }, {});
116
+ /**
117
+ * Splits a command string into an array of arguments, handling quoted strings.
118
+ * This fixes a problem when a command is like this: SH`${command}`
119
+ *
120
+ * @param {string} command - The command string to split.
121
+ * @returns {string[]} - The array of command arguments.
122
+ */
123
+ const args = (command) => {
124
+ const args = [];
125
+ let currentArg = '';
126
+ let insideQuotes = false;
127
+ let quoteChar = null;
128
+
129
+ for (let i = 0; i < command.length; i++) {
130
+ const char = command[i];
131
+
132
+ if (char === '"' || char === "'") {
133
+ if (insideQuotes && quoteChar === char && command[i - 1] !== '\\') {
134
+ insideQuotes = false;
135
+ quoteChar = null;
136
+ } else if (!insideQuotes) {
137
+ insideQuotes = true;
138
+ quoteChar = char;
139
+ } else {
140
+ currentArg += char;
141
+ }
142
+ } else if (char === ' ' && !insideQuotes) {
143
+ if (currentArg.length > 0) {
144
+ args.push(currentArg);
145
+ currentArg = '';
146
+ }
147
+ } else {
148
+ currentArg += char;
149
+ }
150
+ }
151
+
152
+ if (currentArg.length > 0) {
153
+ args.push(currentArg);
154
+ }
155
+
156
+ return args.map(arg => arg.replace(/\\(['"])/g, '$1'));
157
+ }
158
+ /**
159
+ * Create a async context in an sync block
160
+ * @param {function} callback - async function
161
+ * @example
162
+ * const p = within(async () => {
163
+ * const res = await Promise.all([
164
+ * SH`sleep 1; echo 1`.run(),
165
+ * SH`sleep 2; echo 2`.run(),
166
+ * sleep(2),
167
+ * SH`sleep 3; echo 3`.run()
168
+ * ]);
169
+ */
170
+ const within = (callback) => {
171
+ (async () => {
172
+ return await callback()
173
+ })()
174
+ }
175
+ /**
176
+ * This function reads the standard input (stdin) from the current process.
177
+ * @example
178
+ * const content = await stdin();
179
+ */
180
+ const readIn = async () => {
181
+ let buf = '';
182
+ process.stdin.setEncoding('utf8');
183
+ for await (const chunk of process.stdin) {
184
+ buf += chunk;
185
+ }
186
+ return buf;
187
+ }
188
+
189
+ /**
190
+ * Retries a given asynchronous function a specified number of times with optional delays between attempts.
191
+ *
192
+ * @param {number} count - The number of retry attempts.
193
+ * @param {string|expBackoff|Function} a - Either a delay duration as a string, a delay generator object, or the callback function.
194
+ * @param {Function} [b] - The callback function to retry, required if `a` is not a function.
195
+ * @returns {Promise<*>} - The result of the callback function if it succeeds within the retry attempts.
196
+ * @throws {Error} - The last error encountered if all retry attempts fail.
197
+ *
198
+ * @example
199
+ * // Retry a command 3 times
200
+ * const p = await retry(3, () => SH`curl -s https://flipwrsi`);
201
+ *
202
+ * // Retry a command 3 times with an interval of 1 second between each try
203
+ * const p = await retry(3, '1s', () => SH`curl -s https://flipwrsi`);
204
+ *
205
+ * // Retry a command 3 times with irregular intervals using exponential backoff
206
+ * const p = await retry(3, expBackoff(), () => SH`curl -s https://flipwrsi`);
207
+ */
208
+ const retry = async (count, a, b) => {
209
+ // const total = count;
210
+ let callback;
211
+ let delayStatic = 0;
212
+ let delayGen;
213
+ // @ts-ignore
214
+ if (typeof a == 'function') {
215
+ callback = a;
216
+ }
217
+ else {
218
+ if (typeof a == 'object') {
219
+ delayGen = a;
220
+ }
221
+ else {
222
+ delayStatic = parseDuration(a);
223
+ }
224
+ assert(b);
225
+ callback = b;
226
+ }
227
+ let lastErr;
228
+ let attempt = 0;
229
+ while (count-- > 0) {
230
+ attempt++;
231
+ try {
232
+ return await callback();
233
+ }
234
+ catch (err) {
235
+ let delay = 0;
236
+ if (delayStatic > 0)
237
+ delay = delayStatic;
238
+ // @ts-ignore
239
+ if (delayGen) delay = delayGen.next().value;
240
+ lastErr = err;
241
+ if (count == 0)
242
+ break;
243
+ if (delay)
244
+ await sleep(delay);
245
+ }
246
+ }
247
+ throw lastErr;
248
+ }
249
+ /**
250
+ * This function pauses or "sleeps" code execution for a specified duration.
251
+ * @param {string|number} duration - The duration to pause execution for, e.g., '100ms' or '3s'.
252
+ *
253
+ * @example
254
+ *
255
+ * const res = await sleep('5s');
256
+ */
257
+ const sleep = (duration) => {
258
+ return new Promise((resolve) => {
259
+ setTimeout(resolve, parseDuration(duration));
260
+ });
261
+ }
262
+ /**
263
+ * Change working directory
264
+ * @param {string} dir
265
+ */
266
+ const cd = (dir) => {
267
+ // @ts-ignore
268
+ process.chdir(dir);
269
+ }
270
+ /**
271
+ * Generates an exponential backoff time with a random jitter.
272
+ *
273
+ * @generator
274
+ * @param {string} [max='60s'] - The maximum backoff time in a human-readable format (e.g., '60s' for 60 seconds).
275
+ * @param {string} [rand='100ms'] - The maximum random jitter time in a human-readable format (e.g., '100ms' for 100 milliseconds).
276
+ * @yields {number} The backoff time in milliseconds.
277
+ */
278
+ function* expBackoff(max = '60s', rand = '100ms') {
279
+ const maxMs = parseDuration(max);
280
+ const randMs = parseDuration(rand);
281
+ let n = 1;
282
+ while (true) {
283
+ const ms = Math.floor(Math.random() * randMs);
284
+ yield Math.min(2 ** n++, maxMs) + ms;
285
+ }
286
+ }
287
+ export {
288
+ SH,
289
+ args,
290
+ cd,
291
+ sleep,
292
+ retry,
293
+ readIn,
294
+ within,
295
+ expBackoff,
296
+ }
@@ -0,0 +1,151 @@
1
+ import SHExec from './SHExecute.js';
2
+ /**
3
+ * @typedef {Object} SpawnSyncResponse
4
+ * @property {number} status - The exit code of the child process. A value of `0` indicates success.
5
+ * @property {Buffer|null} signal - The signal used to terminate the process, if any.
6
+ * @property {Array<string|null>} output - An array containing the standard output and standard error of the child process.
7
+ * @property {number} pid - The process ID of the child process.
8
+ * @property {Buffer|null} stdout - The standard output of the child process.
9
+ * @property {Buffer|null} stderr - The standard error of the child process.
10
+ */
11
+ /**
12
+ * Default options for the execution environment.
13
+ *
14
+ * @typedef {Object} SHOptions
15
+ * @property {string} [cwd] - The current working directory.
16
+ * @property {NodeJS.ProcessEnv} [env] - The environment variables.
17
+ * @property {string} [shell] - The shell to use for execution.
18
+ * @property {string} [prefix] - The prefix commands to ensure a safe execution environment. e.g: prefix: 'set -euo pipefail;/usr/bin/env',
19
+ * @property {StdioOptions|StdioOption} [stdio] - The stdio configuration.
20
+ * @property {number} [timeout] - default 20000: a timeout error is triggerd when a process execution time is exceeded, 0 is no timeout
21
+ */
22
+ /**
23
+ * @typedef {('pipe' | 'ignore' | 'inherit' | number)} StdioOption
24
+ * @description Defines the stdio configuration for each of the standard streams.
25
+ *
26
+ * - 'pipe' creates a pipe between the child process and the parent process.
27
+ * The parent end of the pipe is exposed as a property on the `ChildProcess` object.
28
+ * - 'ignore' indicates that the child process's corresponding stdio file descriptor will be ignored.
29
+ * - 'inherit' passes the corresponding stdio stream to/from the child process.
30
+ * - Stream object to be used for the stdio stream.
31
+ * - Positive integer representing a file descriptor to be used for the stdio stream.
32
+ */
33
+ /**
34
+ * @typedef {Array<StdioOption>|StdioOption} StdioOptions
35
+ * @description
36
+ * Configures the stdio streams for the child process. This can be an array or a single StdioOption.
37
+ *
38
+ * Array Form: Specify the configuration for [stdin, stdout, stderr].
39
+ * - If array length is more than 3, additional positions correspond to extra streams.
40
+ * Single Value: This value will be applied to stdin, stdout, and stderr.
41
+ *
42
+ * Examples:
43
+ * - ['pipe', 'pipe', 'ignore']: Pipe stdin and stdout, ignore stderr.
44
+ * - 'inherit': Inherit all stdio streams from the parent.
45
+ */
46
+ /**
47
+ * 'Code Safe' has own prop
48
+ *
49
+ * @param {any} o - object to examine
50
+ * @param {string} p - property to look for
51
+ * @returns {boolean}
52
+ */
53
+ const hasProp = (o, p) => {
54
+ if (typeof o === 'undefined') {
55
+ return false;
56
+ }
57
+ return Object.prototype.hasOwnProperty.call(o, p);
58
+ };
59
+
60
+ /**
61
+ * Merge property values while maintaining the fixed set of props in the original object
62
+ * @param {SHOptions} predefined - original object
63
+ * @param {SHOptions} options - object with new values
64
+ * @returns {SHOptions}
65
+ */
66
+ const mergeOptions = (predefined, options) => {
67
+ // Extract the keys from the predefined object
68
+ const keys = Object.keys(predefined);
69
+
70
+ // Use reduce to accumulate only the predefined properties from sourceObj
71
+ const mergedObj = keys.reduce((acc, key) => {
72
+ if (hasProp(options, key)) {
73
+ acc[key] = options[key];
74
+ } else {
75
+ acc[key] = predefined[key];
76
+ }
77
+ return acc;
78
+ }, {});
79
+
80
+ return mergedObj;
81
+ }
82
+
83
+
84
+ /** @type {SHOptions} */
85
+ const defaultOptions = {
86
+ cwd: process.cwd(),
87
+ env: process.env,
88
+ shell: 'bash',
89
+ prefix: '/usr/bin/env',
90
+ stdio: ['inherit', 'pipe', 'pipe'],
91
+ timeout: 10000 // when 0 there is no timeout
92
+ };
93
+
94
+
95
+
96
+ class SHDispatch {
97
+ #cmd = '';
98
+ #options = {};
99
+ /**
100
+ * @type {SHExec}
101
+ */
102
+ #proc;
103
+ /**
104
+ * @param {string} cmd - cmd to execute
105
+ */
106
+ constructor(cmd) {
107
+ this.#cmd = cmd;
108
+ this.#options = defaultOptions
109
+ }
110
+ /**
111
+ * @param {SHOptions} options
112
+ * @returns {SHDispatch}
113
+ */
114
+ options(options) {
115
+ if (options.stdio && typeof options.stdio === 'string') {
116
+ // convert stdio to array
117
+ // This sets the default io values
118
+ // but can be overwritten when having a payload
119
+ const io = options.stdio;
120
+ options.stdio = Array(3).fill(io);
121
+ }
122
+ this.#options = mergeOptions(defaultOptions, options)
123
+ return this;
124
+ }
125
+ /**
126
+ * @param {string} [payload]
127
+ * @returns {Promise<string>}
128
+ */
129
+ run(payload) {
130
+ this.#proc = new SHExec(this.#cmd, this.#options);
131
+ return this.#proc.run(payload);
132
+ }
133
+
134
+ /**
135
+ * Works for screen takeovers like editors
136
+ * @param {string} [payload]
137
+ * @returns {SpawnSyncResponse}
138
+ */
139
+ runSync(payload) {
140
+ // @ts-ignore
141
+ return new SHExec(this.#cmd, this.#options).runSync(payload);
142
+ }
143
+ async kill() {
144
+ try {
145
+ await this.#proc.kill();
146
+ } catch (_e) { }
147
+ this.#proc = undefined;
148
+ }
149
+ }
150
+
151
+ export default SHDispatch
@@ -0,0 +1,154 @@
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
+ /**
49
+ * @type {import('child_process').ChildProcess}
50
+ */
51
+ #proc;
52
+ #command = '';
53
+ #options = {};
54
+ #stdout = '';
55
+ #stderr = '';
56
+ /**
57
+ * @param {string} command - linux command to be executed
58
+ * @param {import('./SHDispatch').SHOptions} [options] - ChildProcess options
59
+ */
60
+ constructor(command, options = {}) {
61
+ this.#command = command;
62
+ this.#options = options;
63
+ this.#proc = null;
64
+ this.#stdout = '';
65
+ this.#stderr = '';
66
+ }
67
+ /**
68
+ * @param {string} [payload] - data to write
69
+ * @retuns {Promise<object>}
70
+ */
71
+ runSync(payload) {
72
+ if (payload && typeof payload !== 'string') {
73
+ throw new Error('Argument is not a string');
74
+ }
75
+ let { cwd, shell, env, stdio } = this.#options;
76
+ // pipe need to be set on stdin when posting a payload
77
+ if (payload) stdio[0] = 'pipe';
78
+ const input = payload || undefined;
79
+ return spawnSync(this.#options.prefix, [this.#command], {
80
+ cwd,
81
+ shell,
82
+ stdio,
83
+ windowsHide: true,
84
+ env,
85
+ input
86
+ });
87
+ }
88
+ /**
89
+ * @param {string} [payload] - data to write
90
+ * @retuns {Promise<string>}
91
+ */
92
+ run(payload) {
93
+ let to = 0;
94
+ if (payload && typeof payload !== 'string') {
95
+ throw new Error('Argument is not a string');
96
+ }
97
+ if (this.#options.timeout) {
98
+ to = this.#options.timeout;
99
+ }
100
+ let { cwd, shell, env, stdio } = this.#options;
101
+ // pipe need to be set on stdin when posting a payload
102
+ if (payload) stdio[0] = 'pipe';
103
+ this.#proc = spawn(this.#options.prefix, [this.#command], {
104
+ cwd,
105
+ shell,
106
+ stdio,
107
+ windowsHide: true,
108
+ env,
109
+ });
110
+ this.#proc.stdout?.on('data', (data) => {
111
+ this.#stdout += data;
112
+ });
113
+
114
+ this.#proc.stderr?.on('data', (data) => {
115
+ this.#stderr += data;
116
+ });
117
+ if (payload) {
118
+ this.#proc.stdin.end(payload);
119
+ }
120
+ return new Promise((resolve, reject) => {
121
+ let timeout;
122
+ if (to > 0) {
123
+ timeout = setTimeout(async () => {
124
+ this.#proc.kill();
125
+ reject(new Error(`Process timed out: ${this.#command}`));
126
+ }, to); // options.timeout
127
+ }
128
+ this.#proc.on('close', (code) => {
129
+ if (timeout) clearTimeout(timeout);
130
+ if (code === 0) {
131
+ resolve(this.#stdout.trim());
132
+ } else {
133
+ reject(new Error(`${code}: ${this.#command} "${this.#stderr.trim()}"`));
134
+ }
135
+ });
136
+
137
+ this.#proc.on('error', (err) => {
138
+ reject(err);
139
+ });
140
+ });
141
+ }
142
+ /**
143
+ * @returns {Promise<number[]>}
144
+ */
145
+ async kill(signal = 'SIGTERM') {
146
+ if (!this.#proc) throw new Error('Trying to kill a process without creating one.');
147
+ if (!this.#proc.pid) throw new Error('The process pid is undefined.');
148
+
149
+ return killProcesses(this.#proc.pid, signal);
150
+ }
151
+ }
152
+
153
+ export default SHExecute;
154
+
package/package.json CHANGED
@@ -2,19 +2,19 @@
2
2
  "name": "@j-o-r/sh",
3
3
  "author": "Jorrit Duin <j-o-r@duin.work>",
4
4
  "type": "module",
5
- "version": "1.0.2",
5
+ "version": "1.0.4",
6
6
  "description": "Execute shell commands on Linux-based systems from javascript",
7
- "main": "lib/sh.js",
7
+ "main": "lib/SH.js",
8
+ "types": "types/SH.d.ts",
8
9
  "engines": {
9
10
  "node": ">=20.0.0"
10
11
  },
11
12
  "scripts": {
12
13
  "test": "test/sh.js",
13
- "compile": "npx rollup src/sh.js --generatedCode es2015 --file lib/sh.js --format es --name SH && npm run types",
14
14
  "publish": "npm run release && npm publish --access public",
15
15
  "release": "npm pack --pack-destination=release",
16
- "types": "tsc lib/*.js --module nodenext --moduleResolution nodenext --declaration --allowJs --emitDeclarationOnly --outDir lib/",
17
- "clear:types": "rm lib/*.d.ts"
16
+ "types": "tsc",
17
+ "clear:types": "rm types/*.d.ts"
18
18
  },
19
19
  "repository": {
20
20
  "type": "git",
@@ -22,10 +22,7 @@
22
22
  },
23
23
  "license": "Apache License, Version 2.0",
24
24
  "dependencies": {},
25
- "devDependencies": {
26
- "@types/node": "^20.8.10",
27
- "uvu": "^0.5.6"
28
- },
25
+ "devDependencies": {},
29
26
  "bugs": {
30
27
  "url": "https://codeberg.org/duin/sh/issues"
31
28
  },
@@ -49,4 +46,4 @@
49
46
  "process-promise",
50
47
  "process-output"
51
48
  ]
52
- }
49
+ }