teen_process 3.0.6 → 4.0.1
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/build/lib/exec.d.ts +32 -107
- package/build/lib/exec.d.ts.map +1 -1
- package/build/lib/exec.js +60 -103
- package/build/lib/exec.js.map +1 -1
- package/build/lib/helpers.d.ts +1 -12
- package/build/lib/helpers.d.ts.map +1 -1
- package/build/lib/helpers.js +17 -13
- package/build/lib/helpers.js.map +1 -1
- package/build/lib/index.d.ts +4 -6
- package/build/lib/index.d.ts.map +1 -1
- package/build/lib/index.js +7 -43
- package/build/lib/index.js.map +1 -1
- package/build/lib/subprocess.d.ts +106 -61
- package/build/lib/subprocess.d.ts.map +1 -1
- package/build/lib/subprocess.js +145 -143
- package/build/lib/subprocess.js.map +1 -1
- package/build/lib/types.d.ts +79 -0
- package/build/lib/types.d.ts.map +1 -0
- package/build/lib/types.js +3 -0
- package/build/lib/types.js.map +1 -0
- package/build/test/circular-buffer-specs.d.ts +2 -0
- package/build/test/circular-buffer-specs.d.ts.map +1 -0
- package/build/test/circular-buffer-specs.js +40 -0
- package/build/test/circular-buffer-specs.js.map +1 -0
- package/build/test/exec-specs.d.ts +2 -0
- package/build/test/exec-specs.d.ts.map +1 -0
- package/build/test/exec-specs.js +167 -0
- package/build/test/exec-specs.js.map +1 -0
- package/build/test/fixtures/bigbuffer.d.ts +3 -0
- package/build/test/fixtures/bigbuffer.d.ts.map +1 -0
- package/build/test/fixtures/bigbuffer.js +13 -0
- package/build/test/fixtures/bigbuffer.js.map +1 -0
- package/build/test/helpers.d.ts +3 -0
- package/build/test/helpers.d.ts.map +1 -0
- package/build/test/helpers.js +15 -0
- package/build/test/helpers.js.map +1 -0
- package/build/test/subproc-specs.d.ts +2 -0
- package/build/test/subproc-specs.d.ts.map +1 -0
- package/build/test/subproc-specs.js +414 -0
- package/build/test/subproc-specs.js.map +1 -0
- package/build/tsconfig.tsbuildinfo +1 -0
- package/lib/circular-buffer.ts +1 -1
- package/lib/exec.ts +158 -0
- package/lib/helpers.ts +44 -0
- package/lib/index.ts +8 -0
- package/lib/subprocess.ts +353 -0
- package/lib/types.ts +95 -0
- package/package.json +5 -5
- package/index.js +0 -1
- package/lib/exec.js +0 -191
- package/lib/helpers.js +0 -38
- package/lib/index.js +0 -9
- package/lib/subprocess.js +0 -329
package/lib/exec.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
import {quote} from 'shell-quote';
|
|
3
|
+
import B from 'bluebird';
|
|
4
|
+
import _ from 'lodash';
|
|
5
|
+
import {formatEnoent} from './helpers';
|
|
6
|
+
import {CircularBuffer, MAX_BUFFER_SIZE} from './circular-buffer';
|
|
7
|
+
import type {
|
|
8
|
+
TeenProcessExecOptions,
|
|
9
|
+
ExecResult,
|
|
10
|
+
BufferProp,
|
|
11
|
+
TeenProcessExecError,
|
|
12
|
+
StreamName
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Spawns a child process and collects its output.
|
|
18
|
+
*
|
|
19
|
+
* This is a promisified version of Node's spawn that collects stdout and stderr,
|
|
20
|
+
* handles timeouts, and provides error context.
|
|
21
|
+
*
|
|
22
|
+
* @template T - The options type extending TeenProcessExecOptions
|
|
23
|
+
* @param cmd - The command to execute
|
|
24
|
+
* @param args - Array of arguments to pass to the command (default: [])
|
|
25
|
+
* @param originalOpts - Execution options including timeout, encoding, environment, etc.
|
|
26
|
+
* @returns Promise resolving to an object with stdout, stderr, and exit code
|
|
27
|
+
*
|
|
28
|
+
* @throws {TeenProcessExecError} When the process exits with non-zero code or times out
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* // Simple execution
|
|
33
|
+
* const {stdout, stderr, code} = await exec('ls', ['-la']);
|
|
34
|
+
*
|
|
35
|
+
* // With timeout and custom encoding
|
|
36
|
+
* const result = await exec('long-running-cmd', [], {
|
|
37
|
+
* timeout: 5000,
|
|
38
|
+
* encoding: 'utf8',
|
|
39
|
+
* cwd: '/custom/path'
|
|
40
|
+
* });
|
|
41
|
+
*
|
|
42
|
+
* // Return output as Buffer
|
|
43
|
+
* const {stdout} = await exec('cat', ['image.png'], {isBuffer: true});
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export async function exec<T extends TeenProcessExecOptions = TeenProcessExecOptions>(
|
|
47
|
+
cmd: string,
|
|
48
|
+
args: string[] = [],
|
|
49
|
+
originalOpts: T = {} as T,
|
|
50
|
+
): Promise<ExecResult<BufferProp<T>>> {
|
|
51
|
+
// get a quoted representation of the command for error strings
|
|
52
|
+
const rep = quote([cmd, ...args]);
|
|
53
|
+
|
|
54
|
+
const defaults: TeenProcessExecOptions = {
|
|
55
|
+
timeout: undefined,
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
killSignal: 'SIGTERM',
|
|
58
|
+
cwd: undefined,
|
|
59
|
+
env: process.env,
|
|
60
|
+
ignoreOutput: false,
|
|
61
|
+
stdio: 'inherit',
|
|
62
|
+
isBuffer: false,
|
|
63
|
+
shell: undefined,
|
|
64
|
+
logger: undefined,
|
|
65
|
+
maxStdoutBufferSize: MAX_BUFFER_SIZE,
|
|
66
|
+
maxStderrBufferSize: MAX_BUFFER_SIZE,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const opts = _.defaults({}, originalOpts, defaults) as T;
|
|
70
|
+
const isBuffer = Boolean(opts.isBuffer);
|
|
71
|
+
|
|
72
|
+
return await new B<ExecResult<BufferProp<T>>>((resolve, reject) => {
|
|
73
|
+
const proc = spawn(cmd, args, {cwd: opts.cwd, env: opts.env, shell: opts.shell});
|
|
74
|
+
const stdoutBuffer = new CircularBuffer(opts.maxStdoutBufferSize);
|
|
75
|
+
const stderrBuffer = new CircularBuffer(opts.maxStderrBufferSize);
|
|
76
|
+
let timer: NodeJS.Timeout | null = null;
|
|
77
|
+
|
|
78
|
+
proc.on('error', async (err: NodeJS.ErrnoException) => {
|
|
79
|
+
let error = err;
|
|
80
|
+
if (error.code === 'ENOENT') {
|
|
81
|
+
error = await formatEnoent(error, cmd, opts.cwd?.toString());
|
|
82
|
+
}
|
|
83
|
+
reject(error);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (proc.stdin) {
|
|
87
|
+
proc.stdin.on('error', (err: NodeJS.ErrnoException) => {
|
|
88
|
+
reject(new Error(`Standard input '${err.syscall}' error: ${err.stack}`));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const handleStream = (streamType: StreamName, buffer: CircularBuffer) => {
|
|
93
|
+
const stream = proc[streamType];
|
|
94
|
+
if (!stream) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
stream.on('error', (err: NodeJS.ErrnoException) => {
|
|
99
|
+
reject(new Error(`${_.capitalize(streamType)} '${err.syscall}' error: ${err.stack}`));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (opts.ignoreOutput) {
|
|
103
|
+
// https://github.com/nodejs/node/issues/4236
|
|
104
|
+
stream.on('data', () => {});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
stream.on('data', (chunk: Buffer) => {
|
|
109
|
+
buffer.add(chunk);
|
|
110
|
+
if (opts.logger?.debug && _.isFunction(opts.logger.debug)) {
|
|
111
|
+
opts.logger.debug(chunk.toString());
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
handleStream('stdout', stdoutBuffer);
|
|
117
|
+
handleStream('stderr', stderrBuffer);
|
|
118
|
+
|
|
119
|
+
function getStdio<U extends boolean>(
|
|
120
|
+
wantBuffer: U,
|
|
121
|
+
): U extends true ? {stdout: Buffer; stderr: Buffer} : {stdout: string; stderr: string} {
|
|
122
|
+
const stdout = wantBuffer ? stdoutBuffer.value() : stdoutBuffer.value().toString(opts.encoding);
|
|
123
|
+
const stderr = wantBuffer ? stderrBuffer.value() : stderrBuffer.value().toString(opts.encoding);
|
|
124
|
+
return {stdout, stderr} as U extends true
|
|
125
|
+
? {stdout: Buffer; stderr: Buffer}
|
|
126
|
+
: {stdout: string; stderr: string};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
proc.on('close', (code: number | null) => {
|
|
130
|
+
if (timer) {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
}
|
|
133
|
+
const {stdout, stderr} = getStdio(isBuffer);
|
|
134
|
+
if (code === 0) {
|
|
135
|
+
resolve({stdout, stderr, code} as ExecResult<BufferProp<T>>);
|
|
136
|
+
} else {
|
|
137
|
+
const err = Object.assign(new Error(`Command '${rep}' exited with code ${code}`), {
|
|
138
|
+
stdout,
|
|
139
|
+
stderr,
|
|
140
|
+
code,
|
|
141
|
+
}) as TeenProcessExecError;
|
|
142
|
+
reject(err);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (opts.timeout) {
|
|
147
|
+
timer = setTimeout(() => {
|
|
148
|
+
const {stdout, stderr} = getStdio(isBuffer);
|
|
149
|
+
const err = Object.assign(
|
|
150
|
+
new Error(`Command '${rep}' timed out after ${opts.timeout}ms`),
|
|
151
|
+
{stdout, stderr, code: null},
|
|
152
|
+
) as TeenProcessExecError;
|
|
153
|
+
reject(err);
|
|
154
|
+
proc.kill(opts.killSignal ?? 'SIGTERM');
|
|
155
|
+
}, opts.timeout);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
package/lib/helpers.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Enhances ENOENT errors from spawn with descriptive messages.
|
|
6
|
+
*
|
|
7
|
+
* This is an internal helper that mutates the error object to provide context about:
|
|
8
|
+
* - Invalid working directory paths
|
|
9
|
+
* - Missing executables in PATH
|
|
10
|
+
*
|
|
11
|
+
* @param error - The original ENOENT error from spawn
|
|
12
|
+
* @param cmd - The command that was attempted to execute
|
|
13
|
+
* @param cwd - The working directory used (if any)
|
|
14
|
+
* @returns The same error object with an enhanced message
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
export async function formatEnoent(
|
|
19
|
+
error: NodeJS.ErrnoException,
|
|
20
|
+
cmd: string,
|
|
21
|
+
cwd?: string,
|
|
22
|
+
): Promise<NodeJS.ErrnoException> {
|
|
23
|
+
if (cwd) {
|
|
24
|
+
try {
|
|
25
|
+
const stat = await fs.stat(cwd);
|
|
26
|
+
if (!stat.isDirectory()) {
|
|
27
|
+
error.message = `The working directory '${cwd}' of '${cmd}' is not a valid folder path`;
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
30
|
+
} catch (e) {
|
|
31
|
+
const err = e as NodeJS.ErrnoException;
|
|
32
|
+
if (err.code === 'ENOENT') {
|
|
33
|
+
error.message = `The working directory '${cwd}' of '${cmd}' does not exist`;
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const curDir = path.resolve(cwd ?? process.cwd());
|
|
40
|
+
const pathMsg = process.env.PATH ?? 'which is not defined for the process';
|
|
41
|
+
error.message = `'${cmd}' executable is not found neither in the process working folder (${curDir}) ` +
|
|
42
|
+
`nor in any folders specified in the PATH environment variable (${pathMsg})`;
|
|
43
|
+
return error;
|
|
44
|
+
}
|
package/lib/index.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
import type {ChildProcess} from 'node:child_process';
|
|
3
|
+
import {EventEmitter} from 'node:events';
|
|
4
|
+
import B from 'bluebird';
|
|
5
|
+
import {quote} from 'shell-quote';
|
|
6
|
+
import _ from 'lodash';
|
|
7
|
+
import {formatEnoent} from './helpers';
|
|
8
|
+
import {createInterface} from 'node:readline';
|
|
9
|
+
import type {Readable} from 'node:stream';
|
|
10
|
+
import type {
|
|
11
|
+
SubProcessOptions,
|
|
12
|
+
StartDetector,
|
|
13
|
+
TIsBufferOpts,
|
|
14
|
+
StreamName
|
|
15
|
+
} from './types';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A wrapper around Node's spawn that provides event-driven process management.
|
|
19
|
+
*
|
|
20
|
+
* Extends EventEmitter to provide real-time output streaming and lifecycle events.
|
|
21
|
+
*
|
|
22
|
+
* @template TSubProcessOptions - Options type extending SubProcessOptions
|
|
23
|
+
*
|
|
24
|
+
* @fires SubProcess#output - Emitted when stdout or stderr receives data
|
|
25
|
+
* @fires SubProcess#line-stdout - Emitted for each line of stdout
|
|
26
|
+
* @fires SubProcess#line-stderr - Emitted for each line of stderr
|
|
27
|
+
* @fires SubProcess#lines-stdout - Legacy event emitting stdout lines (deprecated)
|
|
28
|
+
* @fires SubProcess#lines-stderr - Legacy event emitting stderr lines (deprecated)
|
|
29
|
+
* @fires SubProcess#stream-line - Emitted for combined stdout/stderr lines
|
|
30
|
+
* @fires SubProcess#exit - Emitted when process exits
|
|
31
|
+
* @fires SubProcess#stop - Emitted when process is stopped intentionally
|
|
32
|
+
* @fires SubProcess#die - Emitted when process dies unexpectedly with non-zero code
|
|
33
|
+
* @fires SubProcess#end - Emitted when process ends normally with code 0
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* const proc = new SubProcess('tail', ['-f', 'logfile.txt']);
|
|
38
|
+
*
|
|
39
|
+
* proc.on('output', (stdout, stderr) => {
|
|
40
|
+
* console.log('Output:', stdout);
|
|
41
|
+
* });
|
|
42
|
+
*
|
|
43
|
+
* proc.on('line-stdout', (line) => {
|
|
44
|
+
* console.log('Line:', line);
|
|
45
|
+
* });
|
|
46
|
+
*
|
|
47
|
+
* await proc.start();
|
|
48
|
+
* // ... later
|
|
49
|
+
* await proc.stop();
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export class SubProcess<
|
|
53
|
+
TSubProcessOptions extends SubProcessOptions = SubProcessOptions,
|
|
54
|
+
> extends EventEmitter {
|
|
55
|
+
private proc: ChildProcess | null;
|
|
56
|
+
private args: string[];
|
|
57
|
+
private cmd: string;
|
|
58
|
+
private opts: TSubProcessOptions;
|
|
59
|
+
private expectingExit: boolean;
|
|
60
|
+
private readonly rep: string;
|
|
61
|
+
|
|
62
|
+
constructor(cmd: string, args: string[] = [], opts?: TSubProcessOptions) {
|
|
63
|
+
super();
|
|
64
|
+
|
|
65
|
+
if (!cmd) {
|
|
66
|
+
throw new Error('Command is required');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!_.isString(cmd)) {
|
|
70
|
+
throw new Error('Command must be a string');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!_.isArray(args)) {
|
|
74
|
+
throw new Error('Args must be an array');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.cmd = cmd;
|
|
78
|
+
this.args = args;
|
|
79
|
+
this.proc = null;
|
|
80
|
+
this.opts = opts ?? ({} as TSubProcessOptions);
|
|
81
|
+
this.expectingExit = false;
|
|
82
|
+
|
|
83
|
+
this.rep = quote([cmd, ...args]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get isRunning(): boolean {
|
|
87
|
+
return !!this.proc;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Starts the subprocess and waits for it to be ready.
|
|
92
|
+
*
|
|
93
|
+
* @param startDetector - Function to detect when process is ready, number for delay in ms,
|
|
94
|
+
* boolean true to detach immediately, or null for default behavior
|
|
95
|
+
* @param timeoutMs - Maximum time to wait for process to start (in ms), or boolean true to detach
|
|
96
|
+
* @param detach - Whether to detach the process (requires 'detached' option)
|
|
97
|
+
*
|
|
98
|
+
* @throws {Error} When process fails to start or times out
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```typescript
|
|
102
|
+
* // Wait for any output
|
|
103
|
+
* await proc.start();
|
|
104
|
+
*
|
|
105
|
+
* // Wait 100ms then continue
|
|
106
|
+
* await proc.start(100);
|
|
107
|
+
*
|
|
108
|
+
* // Wait for specific output
|
|
109
|
+
* await proc.start((stdout) => stdout.includes('Server ready'));
|
|
110
|
+
*
|
|
111
|
+
* // With timeout
|
|
112
|
+
* await proc.start(null, 5000);
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
async start(
|
|
116
|
+
startDetector: StartDetector<TSubProcessOptions> | number | boolean | null = null,
|
|
117
|
+
timeoutMs: number | boolean | null = null,
|
|
118
|
+
detach: boolean = false,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
let startDelay = 10;
|
|
121
|
+
|
|
122
|
+
const genericStartDetector: StartDetector<TSubProcessOptions> = (stdout, stderr) => stdout || stderr;
|
|
123
|
+
let detector: StartDetector<TSubProcessOptions> | null = null;
|
|
124
|
+
|
|
125
|
+
if (startDetector === null) {
|
|
126
|
+
detector = genericStartDetector;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (_.isNumber(startDetector)) {
|
|
130
|
+
startDelay = startDetector;
|
|
131
|
+
detector = null;
|
|
132
|
+
} else if (_.isFunction(startDetector)) {
|
|
133
|
+
detector = startDetector;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (_.isBoolean(startDetector) && startDetector) {
|
|
137
|
+
if (!this.opts.detached) {
|
|
138
|
+
throw new Error(`Unable to detach process that is not started with 'detached' option`);
|
|
139
|
+
}
|
|
140
|
+
detach = true;
|
|
141
|
+
detector = genericStartDetector;
|
|
142
|
+
} else if (_.isBoolean(timeoutMs) && timeoutMs) {
|
|
143
|
+
if (!this.opts.detached) {
|
|
144
|
+
throw new Error(`Unable to detach process that is not started with 'detached' option`);
|
|
145
|
+
}
|
|
146
|
+
detach = true;
|
|
147
|
+
timeoutMs = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return await new B<void>((resolve, reject) => {
|
|
151
|
+
this.proc = spawn(this.cmd, this.args, this.opts);
|
|
152
|
+
|
|
153
|
+
const handleOutput = (streams: {
|
|
154
|
+
stdout: TSubProcessOptions extends TIsBufferOpts ? Buffer : string;
|
|
155
|
+
stderr: TSubProcessOptions extends TIsBufferOpts ? Buffer : string;
|
|
156
|
+
}) => {
|
|
157
|
+
const {stdout, stderr} = streams;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
if (detector && detector(stdout, stderr)) {
|
|
161
|
+
detector = null;
|
|
162
|
+
resolve();
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {
|
|
165
|
+
reject(e as Error);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.emit('output', stdout, stderr);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
this.proc.on('error', async (err: NodeJS.ErrnoException) => {
|
|
172
|
+
this.proc?.removeAllListeners('exit');
|
|
173
|
+
this.proc?.kill('SIGINT');
|
|
174
|
+
|
|
175
|
+
let error = err;
|
|
176
|
+
if (error.code === 'ENOENT') {
|
|
177
|
+
error = await formatEnoent(error, this.cmd, this.opts?.cwd?.toString());
|
|
178
|
+
}
|
|
179
|
+
reject(error);
|
|
180
|
+
|
|
181
|
+
this.proc?.unref();
|
|
182
|
+
this.proc = null;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const handleStreamLines = (streamName: StreamName, input: Readable) => {
|
|
186
|
+
const rl = createInterface({input});
|
|
187
|
+
rl.on('line', (line) => {
|
|
188
|
+
if (this.listenerCount(`lines-${streamName}`)) {
|
|
189
|
+
this.emit(`lines-${streamName}`, [line]);
|
|
190
|
+
}
|
|
191
|
+
this.emit(`line-${streamName}`, line);
|
|
192
|
+
if (this.listenerCount('stream-line')) {
|
|
193
|
+
this.emitLines(streamName, line);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const isBuffer = Boolean(this.opts.isBuffer);
|
|
199
|
+
const encoding = this.opts.encoding || 'utf8';
|
|
200
|
+
|
|
201
|
+
if (this.proc.stdout) {
|
|
202
|
+
this.proc.stdout.on('data', (chunk: Buffer) =>
|
|
203
|
+
handleOutput({
|
|
204
|
+
stdout: (isBuffer ? chunk : chunk.toString(encoding)) as TSubProcessOptions extends TIsBufferOpts
|
|
205
|
+
? Buffer
|
|
206
|
+
: string,
|
|
207
|
+
stderr: (isBuffer ? Buffer.alloc(0) : '') as TSubProcessOptions extends TIsBufferOpts ? Buffer : string,
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
handleStreamLines('stdout', this.proc.stdout);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (this.proc.stderr) {
|
|
214
|
+
this.proc.stderr.on('data', (chunk: Buffer) =>
|
|
215
|
+
handleOutput({
|
|
216
|
+
stdout: (isBuffer ? Buffer.alloc(0) : '') as TSubProcessOptions extends TIsBufferOpts ? Buffer : string,
|
|
217
|
+
stderr: (isBuffer ? chunk : chunk.toString(encoding)) as TSubProcessOptions extends TIsBufferOpts
|
|
218
|
+
? Buffer
|
|
219
|
+
: string,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
handleStreamLines('stderr', this.proc.stderr);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
this.proc.on('exit', (code: number | null, signal: NodeJS.Signals | null) => {
|
|
226
|
+
this.emit('exit', code, signal);
|
|
227
|
+
|
|
228
|
+
let event: 'stop' | 'die' | 'end' = this.expectingExit ? 'stop' : 'die';
|
|
229
|
+
if (!this.expectingExit && code === 0) {
|
|
230
|
+
event = 'end';
|
|
231
|
+
}
|
|
232
|
+
this.emit(event, code, signal);
|
|
233
|
+
|
|
234
|
+
this.proc = null;
|
|
235
|
+
this.expectingExit = false;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (!detector) {
|
|
239
|
+
setTimeout(() => resolve(), startDelay);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (_.isNumber(timeoutMs)) {
|
|
243
|
+
setTimeout(() => {
|
|
244
|
+
reject(new Error(`The process did not start within ${timeoutMs}ms (cmd: '${this.rep}')`));
|
|
245
|
+
}, timeoutMs);
|
|
246
|
+
}
|
|
247
|
+
}).finally(() => {
|
|
248
|
+
if (detach && this.proc) {
|
|
249
|
+
this.proc.unref();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Stops the running subprocess by sending a signal.
|
|
256
|
+
*
|
|
257
|
+
* @param signal - Signal to send to the process (default: 'SIGTERM')
|
|
258
|
+
* @param timeout - Maximum time to wait for process to exit in ms (default: 10000)
|
|
259
|
+
*
|
|
260
|
+
* @throws {Error} When process is not running or doesn't exit within timeout
|
|
261
|
+
*
|
|
262
|
+
* @example
|
|
263
|
+
* ```typescript
|
|
264
|
+
* // Graceful stop with SIGTERM
|
|
265
|
+
* await proc.stop();
|
|
266
|
+
*
|
|
267
|
+
* // Force kill with SIGKILL
|
|
268
|
+
* await proc.stop('SIGKILL');
|
|
269
|
+
*
|
|
270
|
+
* // Custom timeout
|
|
271
|
+
* await proc.stop('SIGTERM', 5000);
|
|
272
|
+
* ```
|
|
273
|
+
*/
|
|
274
|
+
async stop(signal: NodeJS.Signals = 'SIGTERM', timeout = 10000): Promise<void> {
|
|
275
|
+
if (!this.isRunning) {
|
|
276
|
+
throw new Error(`Can't stop process; it's not currently running (cmd: '${this.rep}')`);
|
|
277
|
+
}
|
|
278
|
+
return await new B<void>((resolve, reject) => {
|
|
279
|
+
this.proc?.on('close', () => resolve());
|
|
280
|
+
this.expectingExit = true;
|
|
281
|
+
this.proc?.kill(signal);
|
|
282
|
+
setTimeout(() => {
|
|
283
|
+
reject(new Error(`Process didn't end after ${timeout}ms (cmd: '${this.rep}')`));
|
|
284
|
+
}, timeout).unref();
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Waits for the process to exit and validates its exit code.
|
|
290
|
+
*
|
|
291
|
+
* @param allowedExitCodes - Array of acceptable exit codes (default: [0])
|
|
292
|
+
* @returns Promise resolving to the exit code
|
|
293
|
+
*
|
|
294
|
+
* @throws {Error} When process is not running or exits with disallowed code
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* ```typescript
|
|
298
|
+
* // Wait for successful exit (code 0)
|
|
299
|
+
* const code = await proc.join();
|
|
300
|
+
*
|
|
301
|
+
* // Allow multiple exit codes
|
|
302
|
+
* const code = await proc.join([0, 1, 2]);
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
async join(allowedExitCodes: number[] = [0]): Promise<number | null> {
|
|
306
|
+
if (!this.isRunning) {
|
|
307
|
+
throw new Error(`Cannot join process; it is not currently running (cmd: '${this.rep}')`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return await new B<number | null>((resolve, reject) => {
|
|
311
|
+
this.proc?.on('exit', (code: number | null) => {
|
|
312
|
+
if (code !== null && !allowedExitCodes.includes(code)) {
|
|
313
|
+
reject(new Error(`Process ended with exitcode ${code} (cmd: '${this.rep}')`));
|
|
314
|
+
} else {
|
|
315
|
+
resolve(code);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Detaches the process so it continues running independently.
|
|
323
|
+
*
|
|
324
|
+
* The process must have been created with the 'detached' option.
|
|
325
|
+
* Once detached, the process will not be killed when the parent exits.
|
|
326
|
+
*
|
|
327
|
+
* @throws {Error} When process was not created with 'detached' option
|
|
328
|
+
*/
|
|
329
|
+
detachProcess(): void {
|
|
330
|
+
if (!this.opts.detached) {
|
|
331
|
+
throw new Error(`Unable to detach process that is not started with 'detached' option`);
|
|
332
|
+
}
|
|
333
|
+
if (this.proc) {
|
|
334
|
+
this.proc.unref();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
get pid(): number | null {
|
|
339
|
+
return this.proc?.pid ?? null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private emitLines(streamName: StreamName, lines: Iterable<string> | string): void {
|
|
343
|
+
const doEmit = (line: string) => this.emit('stream-line', `[${streamName.toUpperCase()}] ${line}`);
|
|
344
|
+
|
|
345
|
+
if (_.isString(lines)) {
|
|
346
|
+
doEmit(lines);
|
|
347
|
+
} else {
|
|
348
|
+
for (const line of lines) {
|
|
349
|
+
doEmit(line);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
package/lib/types.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type {SpawnOptions, SpawnOptionsWithoutStdio} from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal logger interface used by teen_process for debug output.
|
|
5
|
+
*/
|
|
6
|
+
export type TeenProcessLogger = {
|
|
7
|
+
/** Called for each debug message emitted by exec/SubProcess. */
|
|
8
|
+
debug: (...args: any[]) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extra options supported by teen_process on top of Node's SpawnOptions.
|
|
13
|
+
*/
|
|
14
|
+
export interface TeenProcessProps {
|
|
15
|
+
/** Ignore and discard all child stdout/stderr (reduces memory use). */
|
|
16
|
+
ignoreOutput?: boolean;
|
|
17
|
+
/** When true, exec/SubProcess emits Buffers; otherwise strings (default). */
|
|
18
|
+
isBuffer?: boolean;
|
|
19
|
+
/** Optional logger for streaming debug output. */
|
|
20
|
+
logger?: TeenProcessLogger;
|
|
21
|
+
/** Maximum bytes retained in the stdout circular buffer. */
|
|
22
|
+
maxStdoutBufferSize?: number;
|
|
23
|
+
/** Maximum bytes retained in the stderr circular buffer. */
|
|
24
|
+
maxStderrBufferSize?: number;
|
|
25
|
+
/** Encoding used when returning string output (default: 'utf8'). */
|
|
26
|
+
encoding?: BufferEncoding;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Options accepted by exec (SpawnOptions + teen_process props). */
|
|
30
|
+
export type TeenProcessExecOptions = SpawnOptions & TeenProcessProps;
|
|
31
|
+
|
|
32
|
+
/** exec result when isBuffer is false/undefined (string output). */
|
|
33
|
+
export type TeenProcessExecStringResult = {
|
|
34
|
+
stdout: string;
|
|
35
|
+
stderr: string;
|
|
36
|
+
code: number | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** exec result when isBuffer is true (Buffer output). */
|
|
40
|
+
export type TeenProcessExecBufferResult = {
|
|
41
|
+
stdout: Buffer;
|
|
42
|
+
stderr: Buffer;
|
|
43
|
+
code: number | null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Additional properties attached to exec errors. */
|
|
47
|
+
export type TeenProcessExecErrorProps = {
|
|
48
|
+
stdout: string;
|
|
49
|
+
stderr: string;
|
|
50
|
+
code: number | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Error thrown by exec on non-zero exit or timeout. */
|
|
54
|
+
export type TeenProcessExecError = Error & TeenProcessExecErrorProps;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extracts the isBuffer property from options, normalizing undefined/false to false.
|
|
58
|
+
*/
|
|
59
|
+
export type BufferProp<T extends {isBuffer?: boolean}> = T['isBuffer'] extends true ? true : false;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolves to the correct exec result shape based on buffer mode.
|
|
63
|
+
* Defaults to string output when isBuffer is false/undefined.
|
|
64
|
+
*/
|
|
65
|
+
export type ExecResult<T extends boolean> = T extends true
|
|
66
|
+
? TeenProcessExecBufferResult
|
|
67
|
+
: TeenProcessExecStringResult;
|
|
68
|
+
|
|
69
|
+
/** Supported stdio stream names. */
|
|
70
|
+
export type StreamName = 'stdout' | 'stderr';
|
|
71
|
+
|
|
72
|
+
/** Additional SubProcess-only options. */
|
|
73
|
+
export type SubProcessCustomOptions = {
|
|
74
|
+
/** When true, SubProcess emits Buffers instead of strings. */
|
|
75
|
+
isBuffer?: boolean;
|
|
76
|
+
/** Encoding used when isBuffer is false. */
|
|
77
|
+
encoding?: BufferEncoding;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Options accepted by SubProcess (extends spawn options). */
|
|
81
|
+
export type SubProcessOptions = SubProcessCustomOptions & SpawnOptionsWithoutStdio;
|
|
82
|
+
|
|
83
|
+
/** Helper type representing SubProcess buffer mode. */
|
|
84
|
+
export type TIsBufferOpts = {
|
|
85
|
+
isBuffer: true;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Function that detects when a subprocess has started.
|
|
90
|
+
* Receives stdout/stderr as Buffer when isBuffer is true, otherwise strings.
|
|
91
|
+
*/
|
|
92
|
+
export type StartDetector<T extends SubProcessOptions> = (
|
|
93
|
+
stdout: T extends TIsBufferOpts ? Buffer : string,
|
|
94
|
+
stderr?: T extends TIsBufferOpts ? Buffer : string,
|
|
95
|
+
) => unknown;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "teen_process",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "A grown up version of Node's spawn/exec",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"child_process",
|
|
@@ -20,15 +20,15 @@
|
|
|
20
20
|
},
|
|
21
21
|
"license": "Apache-2.0",
|
|
22
22
|
"author": "Appium Contributors",
|
|
23
|
-
"main": "index.js",
|
|
23
|
+
"main": "build/lib/index.js",
|
|
24
|
+
"types": "build/lib/index.d.ts",
|
|
24
25
|
"bin": {},
|
|
25
26
|
"directories": {
|
|
26
27
|
"lib": "lib"
|
|
27
28
|
},
|
|
28
29
|
"files": [
|
|
29
|
-
"
|
|
30
|
-
"lib"
|
|
31
|
-
"build/lib"
|
|
30
|
+
"build",
|
|
31
|
+
"lib"
|
|
32
32
|
],
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsc -b",
|
package/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
module.exports = require('./build/lib');
|