pty-spawn 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Hiroki Osame <hiroki.osame@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # pty-spawn
2
+
3
+ Spawn a PTY process and `await` it like a promise. Built on [`node-pty`](https://github.com/microsoft/node-pty).
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install pty-spawn
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { spawn, waitFor } from 'pty-spawn'
15
+
16
+ const subprocess = spawn('node', ['server.js'])
17
+
18
+ // Wait for specific output
19
+ await waitFor(subprocess, output => output.includes('Listening on port 3000'))
20
+
21
+ // Interact
22
+ subprocess.stdin.write('quit\n')
23
+
24
+ // Await final result
25
+ const result = await subprocess
26
+ console.log(result.output)
27
+ ```
28
+
29
+ ## Why?
30
+
31
+ `node-pty` gives you low-level event wiring. `pty-spawn` wraps it into a single awaitable object:
32
+
33
+ | Without `pty-spawn` | With `pty-spawn` |
34
+ | --- | --- |
35
+ | Wire up promise + event cleanup | `await subprocess` |
36
+ | Buffer output + poll + handle exit/timeout | `waitFor(subprocess, predicate)` |
37
+ | Abort/exit race conditions | Late abort never overrides a clean exit |
38
+ | Manual output accumulation loop | `subprocess.output` (live string) |
39
+
40
+ ## API
41
+
42
+ ### `spawn(file, args?, options?)`
43
+
44
+ Spawns a PTY process. Returns a [`Subprocess`](#subprocess).
45
+
46
+ ```ts
47
+ const subprocess = spawn('node', ['server.js'], { timeout: 5000 })
48
+
49
+ // Without args
50
+ const subprocess = spawn('bash', { window: { cols: 120 } })
51
+ ```
52
+
53
+ #### Options
54
+
55
+ All [`node-pty` options](https://github.com/microsoft/node-pty) are supported, plus:
56
+
57
+ | Option | Type | Default | Description |
58
+ | --- | --- | --- | --- |
59
+ | `window` | `{ cols?, rows? }` | — | PTY window size |
60
+ | `timeout` | `number` | `0` (disabled) | Auto-abort after _N_ ms |
61
+ | `signal` | `AbortSignal` | — | Abort control |
62
+ | `reject` | `boolean` | `true` | Reject on non-zero exit, signal, or abort. When `false`, always resolves |
63
+
64
+ ### Subprocess
65
+
66
+ A `Promise<Result>` with control properties attached.
67
+
68
+ #### Properties
69
+
70
+ | Property | Type | Description |
71
+ | --- | --- | --- |
72
+ | `pid` | `number` | Process ID |
73
+ | `output` | `string` | Accumulated output (live — grows as the process writes) |
74
+
75
+ #### `stdin.write(data)`
76
+
77
+ Write to the process stdin.
78
+
79
+ ```ts
80
+ subprocess.stdin.write('hello\n')
81
+ ```
82
+
83
+ #### `kill(signal?, options?)`
84
+
85
+ Terminate the process and wait for exit.
86
+
87
+ ```ts
88
+ await subprocess.kill()
89
+ await subprocess.kill('SIGTERM')
90
+ await subprocess.kill('SIGTERM', { forceKill: 3000 })
91
+ await subprocess.kill({ forceKill: 3000 })
92
+ ```
93
+
94
+ `forceKill` escalates to `SIGKILL` after the given milliseconds if the process traps the initial signal. Safe to call after exit.
95
+
96
+ #### `resize(cols, rows)`
97
+
98
+ Resize the PTY window. Safe to call after exit.
99
+
100
+ #### Async iteration
101
+
102
+ The subprocess itself is `AsyncIterable<string>` — stream output chunks in real time:
103
+
104
+ ```ts
105
+ for await (const chunk of subprocess) {
106
+ process.stdout.write(chunk)
107
+ }
108
+ ```
109
+
110
+ #### Async disposal
111
+
112
+ Supports [`await using`](https://github.com/tc39/proposal-explicit-resource-management) for automatic cleanup:
113
+
114
+ ```ts
115
+ {
116
+ await using subprocess = spawn('node', ['server.js'])
117
+ // killed when scope exits
118
+ }
119
+ ```
120
+
121
+ ### Result
122
+
123
+ `await subprocess` resolves with:
124
+
125
+ | Property | Type | Description |
126
+ | --- | --- | --- |
127
+ | `output` | `string` | All terminal output |
128
+ | `exitCode` | `number` | Process exit code |
129
+ | `signalName` | `string?` | Signal name if terminated (e.g. `'SIGTERM'`) |
130
+ | `file` | `string` | Spawned file path |
131
+ | `args` | `string[]` | Arguments passed |
132
+ | `durationMs` | `number` | Wall-clock duration in ms |
133
+
134
+ > [!NOTE]
135
+ > PTYs combine stdout and stderr into a single stream. `output` contains everything the process wrote to the terminal.
136
+
137
+ ### SubprocessError
138
+
139
+ Non-zero exit or signal termination rejects with `SubprocessError`, which extends `Error` and includes all `Result` fields.
140
+
141
+ ```ts
142
+ import { spawn, SubprocessError } from 'pty-spawn'
143
+
144
+ try {
145
+ await subprocess
146
+ } catch (error) {
147
+ if (error instanceof SubprocessError) {
148
+ error.exitCode // e.g. 1
149
+ error.signalName // e.g. 'SIGTERM'
150
+ error.output // captured output
151
+ }
152
+ }
153
+ ```
154
+
155
+ Abort and timeout behavior:
156
+ - `timeout` and `signal` abort the process, rejecting with `SubprocessError`
157
+ - `error.cause` contains the underlying reason (e.g. `TimeoutError`)
158
+ - If the process exits cleanly before the abort fires, success wins the race
159
+
160
+ ### `waitFor(subprocess, predicate, options?)`
161
+
162
+ Wait for output to satisfy a predicate.
163
+
164
+ ```ts
165
+ await waitFor(subprocess, output => output.includes('Ready'))
166
+
167
+ // With timeout
168
+ await waitFor(subprocess, output => output.includes('Ready'), {
169
+ signal: AbortSignal.timeout(5000)
170
+ })
171
+ ```
172
+
173
+ The predicate receives accumulated output (since `waitFor` was called) and can be async. Calls are serialized — a slow predicate won't run concurrently.
174
+
175
+ | Option | Type | Description |
176
+ | --- | --- | --- |
177
+ | `signal` | `AbortSignal` | Abort control (use `AbortSignal.timeout()` for timeouts) |
178
+
179
+ ## Inspiration
180
+
181
+ Thanks to [execa](https://github.com/sindresorhus/execa) and [nano-spawn](https://github.com/sindresorhus/nano-spawn) for the inspiration. They proved that `await spawn(...)` is the right primitive for child processes — pty-spawn brings that model to pseudo-terminals.
@@ -0,0 +1,59 @@
1
+ import { IPtyForkOptions } from 'node-pty';
2
+
3
+ type Result = {
4
+ output: string;
5
+ exitCode: number;
6
+ signalName?: string;
7
+ file: string;
8
+ args: readonly string[];
9
+ durationMs: number;
10
+ };
11
+ type WindowOptions = {
12
+ cols?: number;
13
+ rows?: number;
14
+ };
15
+ type Options = Omit<IPtyForkOptions, 'cols' | 'rows'> & {
16
+ window?: WindowOptions;
17
+ signal?: AbortSignal;
18
+ timeout?: number;
19
+ reject?: boolean;
20
+ };
21
+ declare class SubprocessError extends Error implements Result {
22
+ output: string;
23
+ exitCode: number;
24
+ signalName?: string;
25
+ file: string;
26
+ args: readonly string[];
27
+ durationMs: number;
28
+ constructor(message: string, { cause, ...result }: Result & {
29
+ cause?: unknown;
30
+ });
31
+ }
32
+ type KillOptions = {
33
+ forceKill?: number;
34
+ };
35
+ type Subprocess = Promise<Result> & {
36
+ readonly pid: number;
37
+ readonly output: string;
38
+ kill: {
39
+ (signal?: string, options?: KillOptions): Promise<void>;
40
+ (options?: KillOptions): Promise<void>;
41
+ };
42
+ resize: (cols: number, rows: number) => void;
43
+ stdin: {
44
+ write: (data: string) => void;
45
+ };
46
+ [Symbol.asyncIterator]: () => AsyncIterator<string>;
47
+ [Symbol.asyncDispose]: () => Promise<void>;
48
+ };
49
+ declare function spawn(file: string, args: readonly string[], options?: Options): Subprocess;
50
+ declare function spawn(file: string, options?: Options): Subprocess;
51
+
52
+ type WaitForOptions = {
53
+ signal?: AbortSignal;
54
+ };
55
+ type WaitForPredicate = (output: string) => boolean | Promise<boolean>;
56
+ declare const waitFor: (subprocess: Subprocess, predicate: WaitForPredicate, { signal, }?: WaitForOptions) => Promise<void>;
57
+
58
+ export { SubprocessError, spawn, waitFor };
59
+ export type { KillOptions, Options, Result, Subprocess, WaitForOptions, WaitForPredicate, WindowOptions };
package/dist/index.mjs ADDED
@@ -0,0 +1,271 @@
1
+ import { EventEmitter, on } from 'node:events';
2
+ import { constants } from 'node:os';
3
+ import { spawn as spawn$1 } from 'node-pty';
4
+
5
+ const getSignalName = (signal) => {
6
+ if (signal === void 0) {
7
+ return void 0;
8
+ }
9
+ for (const [name, number] of Object.entries(constants.signals)) {
10
+ if (number === signal) {
11
+ return name;
12
+ }
13
+ }
14
+ return void 0;
15
+ };
16
+ const createAbortSignal = (userSignal, timeout) => {
17
+ if (!userSignal && timeout <= 0) {
18
+ return void 0;
19
+ }
20
+ if (!userSignal) {
21
+ return AbortSignal.timeout(timeout);
22
+ }
23
+ if (timeout <= 0) {
24
+ return userSignal;
25
+ }
26
+ return AbortSignal.any([userSignal, AbortSignal.timeout(timeout)]);
27
+ };
28
+ class SubprocessError extends Error {
29
+ output;
30
+ exitCode;
31
+ signalName;
32
+ file;
33
+ args;
34
+ durationMs;
35
+ constructor(message, { cause, ...result }) {
36
+ super(message, { cause });
37
+ this.name = "SubprocessError";
38
+ Object.assign(this, result);
39
+ }
40
+ }
41
+ function spawn(file, argsOrOptions = [], maybeOptions = {}) {
42
+ const args = Array.isArray(argsOrOptions) ? [...argsOrOptions] : [];
43
+ const options = Array.isArray(argsOrOptions) ? maybeOptions : argsOrOptions;
44
+ const {
45
+ window,
46
+ signal: userSignal,
47
+ timeout = 0,
48
+ reject: shouldReject = true,
49
+ ...ptyOptions
50
+ } = options;
51
+ if (!Number.isFinite(timeout) || timeout < 0) {
52
+ throw new TypeError("options.timeout must be a non-negative finite number.");
53
+ }
54
+ const startedAt = Date.now();
55
+ const ptyProcess = spawn$1(file, args, {
56
+ ...ptyOptions,
57
+ cols: window?.cols,
58
+ rows: window?.rows
59
+ });
60
+ const signal = createAbortSignal(userSignal, timeout);
61
+ const emitter = new EventEmitter();
62
+ let output = "";
63
+ let exitCode;
64
+ let exitSignalName;
65
+ let lastDataAt = 0;
66
+ let settled = false;
67
+ let abortedBeforeExit = false;
68
+ let settleQuietTimer;
69
+ let settleMaxTimer;
70
+ const quietMs = 50;
71
+ const maxMs = 3e3;
72
+ let resolveResult;
73
+ let rejectResult;
74
+ const resultPromise = new Promise((resolve, reject) => {
75
+ resolveResult = resolve;
76
+ rejectResult = reject;
77
+ });
78
+ const safeKill = (killSignal) => {
79
+ try {
80
+ ptyProcess.kill(killSignal);
81
+ } catch {
82
+ }
83
+ };
84
+ const settle = (code) => {
85
+ if (settled) {
86
+ return;
87
+ }
88
+ settled = true;
89
+ if (settleQuietTimer) {
90
+ clearTimeout(settleQuietTimer);
91
+ }
92
+ if (settleMaxTimer) {
93
+ clearTimeout(settleMaxTimer);
94
+ }
95
+ signal?.removeEventListener("abort", onAbort);
96
+ const result = {
97
+ output,
98
+ exitCode: code,
99
+ signalName: exitSignalName,
100
+ file,
101
+ args,
102
+ durationMs: Date.now() - startedAt
103
+ };
104
+ if (!shouldReject) {
105
+ resolveResult(result);
106
+ return;
107
+ }
108
+ if (signal?.aborted && abortedBeforeExit) {
109
+ rejectResult(new SubprocessError("Subprocess aborted.", {
110
+ ...result,
111
+ cause: signal.reason
112
+ }));
113
+ return;
114
+ }
115
+ if (code !== 0 || exitSignalName) {
116
+ const message = exitSignalName ? `Subprocess terminated by signal ${exitSignalName}.` : `Subprocess exited with code ${code}.`;
117
+ rejectResult(new SubprocessError(message, result));
118
+ return;
119
+ }
120
+ resolveResult(result);
121
+ };
122
+ const scheduleSettle = () => {
123
+ if (exitCode === void 0) {
124
+ return;
125
+ }
126
+ const elapsedSinceLastData = lastDataAt === 0 ? 0 : Date.now() - lastDataAt;
127
+ const quietDelayMs = Math.max(0, quietMs - elapsedSinceLastData);
128
+ if (settleQuietTimer) {
129
+ clearTimeout(settleQuietTimer);
130
+ }
131
+ const code = exitCode;
132
+ settleQuietTimer = setTimeout(() => {
133
+ settle(code);
134
+ }, quietDelayMs);
135
+ };
136
+ const onAbort = () => {
137
+ if (exitCode === void 0) {
138
+ abortedBeforeExit = true;
139
+ }
140
+ safeKill();
141
+ };
142
+ if (signal?.aborted) {
143
+ onAbort();
144
+ } else {
145
+ signal?.addEventListener("abort", onAbort, { once: true });
146
+ }
147
+ ptyProcess.onData((data) => {
148
+ lastDataAt = Date.now();
149
+ output += data;
150
+ emitter.emit("data", data);
151
+ if (exitCode !== void 0) {
152
+ scheduleSettle();
153
+ }
154
+ });
155
+ ptyProcess.onExit(({ exitCode: nextExitCode, signal: exitSignal }) => {
156
+ exitCode = nextExitCode;
157
+ exitSignalName = getSignalName(exitSignal);
158
+ emitter.emit("exit", nextExitCode);
159
+ scheduleSettle();
160
+ settleMaxTimer = setTimeout(() => {
161
+ settle(nextExitCode);
162
+ }, maxMs);
163
+ });
164
+ const iterateOutput = async function* iterateOutput2() {
165
+ if (exitCode !== void 0) {
166
+ await resultPromise;
167
+ return;
168
+ }
169
+ const iteratorAbort = new AbortController();
170
+ const onIteratorExit = () => {
171
+ iteratorAbort.abort();
172
+ };
173
+ emitter.once("exit", onIteratorExit);
174
+ if (exitCode !== void 0) {
175
+ iteratorAbort.abort();
176
+ }
177
+ try {
178
+ for await (const [chunk] of on(emitter, "data", { signal: iteratorAbort.signal })) {
179
+ yield chunk;
180
+ }
181
+ } catch (error) {
182
+ if (!iteratorAbort.signal.aborted) {
183
+ throw error;
184
+ }
185
+ } finally {
186
+ emitter.off("exit", onIteratorExit);
187
+ await resultPromise;
188
+ }
189
+ };
190
+ const kill = async (signalOrOptions, killOptions) => {
191
+ const killSignal = typeof signalOrOptions === "string" ? signalOrOptions : void 0;
192
+ const { forceKill } = typeof signalOrOptions === "object" ? signalOrOptions : killOptions ?? {};
193
+ safeKill(killSignal);
194
+ const forceKillTimer = forceKill === void 0 ? void 0 : setTimeout(() => safeKill("SIGKILL"), forceKill);
195
+ await resultPromise.catch(() => {
196
+ });
197
+ if (forceKillTimer) {
198
+ clearTimeout(forceKillTimer);
199
+ }
200
+ };
201
+ const subprocess = Object.assign(resultPromise, {
202
+ pid: ptyProcess.pid,
203
+ kill,
204
+ resize: (cols, rows) => {
205
+ try {
206
+ ptyProcess.resize(cols, rows);
207
+ } catch {
208
+ }
209
+ },
210
+ stdin: {
211
+ write: (data) => {
212
+ ptyProcess.write(data);
213
+ }
214
+ },
215
+ [Symbol.asyncIterator]: iterateOutput,
216
+ [Symbol.asyncDispose]: kill
217
+ });
218
+ Object.defineProperty(subprocess, "output", {
219
+ get: () => output,
220
+ enumerable: true,
221
+ configurable: true
222
+ });
223
+ return subprocess;
224
+ }
225
+
226
+ const waitFor = async (subprocess, predicate, {
227
+ signal
228
+ } = {}) => {
229
+ if (signal?.aborted) {
230
+ throw signal.reason;
231
+ }
232
+ let output = "";
233
+ const iterator = subprocess[Symbol.asyncIterator]();
234
+ let removeAbortListener;
235
+ const abortPromise = signal ? new Promise((_resolve, reject) => {
236
+ const handler = () => reject(signal.reason);
237
+ signal.addEventListener("abort", handler, { once: true });
238
+ removeAbortListener = () => signal.removeEventListener("abort", handler);
239
+ }) : void 0;
240
+ abortPromise?.catch(() => {
241
+ });
242
+ try {
243
+ while (true) {
244
+ const next = abortPromise ? await Promise.race([iterator.next(), abortPromise]) : await iterator.next();
245
+ if (next.done) {
246
+ break;
247
+ }
248
+ output += next.value;
249
+ if (await predicate(output)) {
250
+ return;
251
+ }
252
+ if (signal?.aborted) {
253
+ throw signal.reason;
254
+ }
255
+ }
256
+ } catch (error) {
257
+ if (signal?.aborted) {
258
+ throw signal.reason;
259
+ }
260
+ throw error;
261
+ } finally {
262
+ removeAbortListener?.();
263
+ }
264
+ const exitCode = await subprocess.then((result) => result.exitCode).catch((error) => error.exitCode);
265
+ throw new Error(
266
+ `Process exited with code ${exitCode} before waitFor predicate was satisfied.
267
+ Last output: ${JSON.stringify(output.slice(-200))}`
268
+ );
269
+ };
270
+
271
+ export { SubprocessError, spawn, waitFor };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "pty-spawn",
3
+ "version": "1.0.0",
4
+ "description": "Tiny pseudo-terminal spawning for humans",
5
+ "keywords": [
6
+ "pty",
7
+ "terminal",
8
+ "spawn",
9
+ "subprocess",
10
+ "node-pty"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": "privatenumber/pty-spawn",
14
+ "funding": "https://github.com/privatenumber/pty-spawn?sponsor=1",
15
+ "author": {
16
+ "name": "Hiroki Osame",
17
+ "email": "hiroki.osame@gmail.com"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "type": "module",
23
+ "exports": {
24
+ "types": "./dist/index.d.mts",
25
+ "default": "./dist/index.mjs"
26
+ },
27
+ "imports": {
28
+ "#pty-spawn": {
29
+ "dev": "./src/index.ts",
30
+ "default": "./dist/index.mjs"
31
+ }
32
+ },
33
+ "engines": {
34
+ "node": ">=20.20.0"
35
+ },
36
+ "dependencies": {
37
+ "node-pty": "1.2.0-beta.10"
38
+ }
39
+ }