@moxxy/plugin-terminal 0.27.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/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { definePlugin } from '@moxxy/sdk';
2
+ import { buildTerminalSurface, buildTerminalTool, closeAllTerminals } from './terminal.js';
3
+
4
+ export {
5
+ buildTerminalSurface,
6
+ buildTerminalTool,
7
+ closeAllTerminals,
8
+ } from './terminal.js';
9
+ export { createTerminalProcess, type TerminalProcess } from './pty.js';
10
+
11
+ export function buildTerminalPlugin() {
12
+ return definePlugin({
13
+ name: '@moxxy/plugin-terminal',
14
+ version: '0.0.0',
15
+ surfaces: [buildTerminalSurface()],
16
+ tools: [buildTerminalTool()],
17
+ hooks: {
18
+ onShutdown: () => {
19
+ // Kill the shared shell(s) with the session so no orphan PTY lingers.
20
+ closeAllTerminals();
21
+ },
22
+ },
23
+ });
24
+ }
25
+
26
+ export const terminalPlugin = buildTerminalPlugin();
27
+
28
+ export default terminalPlugin;
@@ -0,0 +1,285 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { makeExecutable, resolveNodePtyModule, TerminalProcessImpl } from './pty.js';
7
+
8
+ const MAX_SCROLLBACK = 200_000;
9
+
10
+ /** A minimal stand-in for the piped child the impl wires its stdout listeners
11
+ * onto, so we can feed `emitData` without spawning a real shell. */
12
+ type FakeStdin = EventEmitter & { write(d?: string): void; end(): void };
13
+ function fakeChild(stdinWrite?: (d?: string) => void): {
14
+ stdout: EventEmitter;
15
+ stderr: EventEmitter;
16
+ stdin: FakeStdin;
17
+ proc: EventEmitter & { stdin: FakeStdin };
18
+ } {
19
+ const proc = new EventEmitter() as EventEmitter & {
20
+ stdout: EventEmitter;
21
+ stderr: EventEmitter;
22
+ stdin: FakeStdin;
23
+ };
24
+ proc.stdout = new EventEmitter();
25
+ proc.stderr = new EventEmitter();
26
+ const stdin = new EventEmitter() as FakeStdin;
27
+ stdin.write = stdinWrite ?? (() => {});
28
+ stdin.end = () => {};
29
+ proc.stdin = stdin;
30
+ return { stdout: proc.stdout, stderr: proc.stderr, stdin, proc };
31
+ }
32
+
33
+ describe('resolveNodePtyModule (degrade on a malformed optional dep)', () => {
34
+ it('accepts a namespace with a callable spawn', () => {
35
+ const mod = { spawn: () => ({}) };
36
+ expect(resolveNodePtyModule(mod)).toBe(mod);
37
+ });
38
+
39
+ it('accepts a CJS-interop module whose spawn lives on .default', () => {
40
+ const def = { spawn: () => ({}) };
41
+ expect(resolveNodePtyModule({ default: def })).toBe(def);
42
+ });
43
+
44
+ it('rejects a module whose default lacks spawn (degrade to pipe, no later throw)', () => {
45
+ // A partially-shimmed module: a `default` object with no spawn. The old code
46
+ // returned it and let `pty.spawn(...)` blow up with "is not a function".
47
+ expect(resolveNodePtyModule({ default: { notSpawn: 1 } })).toBeNull();
48
+ });
49
+
50
+ it('rejects a non-function spawn', () => {
51
+ expect(resolveNodePtyModule({ spawn: 'nope' })).toBeNull();
52
+ expect(resolveNodePtyModule({ default: { spawn: 42 } })).toBeNull();
53
+ });
54
+
55
+ it('rejects null / undefined / primitive module shapes without throwing', () => {
56
+ expect(resolveNodePtyModule(null)).toBeNull();
57
+ expect(resolveNodePtyModule(undefined)).toBeNull();
58
+ expect(resolveNodePtyModule('node-pty')).toBeNull();
59
+ expect(resolveNodePtyModule(123)).toBeNull();
60
+ expect(resolveNodePtyModule({})).toBeNull();
61
+ });
62
+ });
63
+
64
+ describe('makeExecutable (node-pty spawn-helper repair)', () => {
65
+ let dir: string | null = null;
66
+ afterEach(() => {
67
+ if (dir) rmSync(dir, { recursive: true, force: true });
68
+ dir = null;
69
+ });
70
+
71
+ it('adds the executable bit to a file that lacks it', () => {
72
+ dir = mkdtempSync(join(tmpdir(), 'moxxy-pty-'));
73
+ const file = join(dir, 'spawn-helper');
74
+ writeFileSync(file, 'binary');
75
+ // Simulate the stripped-bit state that breaks node-pty's posix_spawnp.
76
+ const noExec = statSync(file).mode;
77
+ expect(noExec & 0o111).toBe(0);
78
+
79
+ expect(makeExecutable(file)).toBe(true);
80
+ expect(statSync(file).mode & 0o111).not.toBe(0);
81
+ });
82
+
83
+ it('is idempotent on an already-executable file', () => {
84
+ dir = mkdtempSync(join(tmpdir(), 'moxxy-pty-'));
85
+ const file = join(dir, 'spawn-helper');
86
+ writeFileSync(file, 'binary', { mode: 0o755 });
87
+ expect(makeExecutable(file)).toBe(true);
88
+ expect(statSync(file).mode & 0o111).not.toBe(0);
89
+ });
90
+
91
+ it('returns false (no throw) for a missing file', () => {
92
+ expect(makeExecutable(join(tmpdir(), 'moxxy-does-not-exist', 'spawn-helper'))).toBe(false);
93
+ });
94
+ });
95
+
96
+ describe('TerminalProcessImpl scrollback (hysteresis trim)', () => {
97
+ it('keeps scrollback bounded to the cap and returns the true tail', () => {
98
+ const { stdout, proc } = fakeChild();
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ const term = new TerminalProcessImpl('pipe', null, proc as any);
101
+
102
+ // Emit far more than the cap in many small chunks (the saturated path that
103
+ // used to copy the whole cap on every chunk).
104
+ let written = '';
105
+ const chunk = 'x'.repeat(1000);
106
+ for (let i = 0; i < 500; i += 1) {
107
+ // Mark each chunk's last char so we can verify the exact tail.
108
+ const tagged = chunk.slice(0, -6) + i.toString().padStart(6, '0');
109
+ written += tagged;
110
+ stdout.emit('data', Buffer.from(tagged, 'utf8'));
111
+ }
112
+
113
+ const sb = term.scrollback();
114
+ // Observable tail is exactly the last MAX_SCROLLBACK chars of all output —
115
+ // byte-identical to the old `(buffer + d).slice(-MAX_SCROLLBACK)` semantics.
116
+ expect(sb.length).toBe(MAX_SCROLLBACK);
117
+ expect(sb).toBe(written.slice(-MAX_SCROLLBACK));
118
+ });
119
+
120
+ it('returns the whole buffer untouched while under the cap', () => {
121
+ const { stdout, proc } = fakeChild();
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
123
+ const term = new TerminalProcessImpl('pipe', null, proc as any);
124
+ stdout.emit('data', Buffer.from('hello ', 'utf8'));
125
+ stdout.emit('data', Buffer.from('world', 'utf8'));
126
+ expect(term.scrollback()).toBe('hello world');
127
+ });
128
+ });
129
+
130
+ describe('TerminalProcessImpl exit lifecycle', () => {
131
+ it('emitExit fires listeners exactly once and flips alive (idempotent)', () => {
132
+ const { proc } = fakeChild();
133
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
134
+ const term = new TerminalProcessImpl('pipe', null, proc as any);
135
+ const codes: number[] = [];
136
+ term.onExit((c) => codes.push(c));
137
+ expect(term.alive).toBe(true);
138
+
139
+ // A real child can emit both 'exit' and 'error'; only the first must win.
140
+ proc.emit('exit', 7);
141
+ proc.emit('exit', 9);
142
+ proc.emit('error', new Error('late'));
143
+
144
+ expect(codes).toEqual([7]);
145
+ expect(term.alive).toBe(false);
146
+ });
147
+
148
+ it('kill() emits exit once and is a no-op after the process already died', () => {
149
+ const { proc } = fakeChild();
150
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
+ const term = new TerminalProcessImpl('pipe', null, proc as any);
152
+ const codes: number[] = [];
153
+ term.onExit((c) => codes.push(c));
154
+
155
+ term.kill();
156
+ term.kill();
157
+ proc.emit('exit', 3); // already dead — ignored
158
+
159
+ expect(codes).toEqual([0]);
160
+ expect(term.alive).toBe(false);
161
+ });
162
+
163
+ it('a throwing data listener does not break delivery to the others', () => {
164
+ const { stdout, proc } = fakeChild();
165
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
166
+ const term = new TerminalProcessImpl('pipe', null, proc as any);
167
+ const seen: string[] = [];
168
+ term.onData(() => {
169
+ throw new Error('bad viewer');
170
+ });
171
+ term.onData((d) => seen.push(d));
172
+
173
+ stdout.emit('data', Buffer.from('payload', 'utf8'));
174
+ expect(seen).toEqual(['payload']);
175
+ // The buffer still accumulates despite the throwing listener.
176
+ expect(term.scrollback()).toBe('payload');
177
+ });
178
+
179
+ it('write() after exit is a no-op (does not throw)', () => {
180
+ const { proc } = fakeChild();
181
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
182
+ const term = new TerminalProcessImpl('pipe', null, proc as any);
183
+ proc.emit('exit', 0);
184
+ expect(() => term.write('ignored')).not.toThrow();
185
+ });
186
+
187
+ it('write() swallows a synchronous EPIPE from a stdin pipe that closed before exit fired', () => {
188
+ // The child has exited (stdin closed) but the async 'exit' event has not yet
189
+ // flipped `alive` — write() must not let the broken-pipe throw escape.
190
+ const { proc } = fakeChild(() => {
191
+ throw Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
192
+ });
193
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
194
+ const term = new TerminalProcessImpl('pipe', null, proc as any);
195
+ expect(term.alive).toBe(true); // exit hasn't fired yet
196
+ expect(() => term.write('still typing')).not.toThrow();
197
+ });
198
+
199
+ it('an async error on stdin never goes unhandled (a no-op handler is attached)', () => {
200
+ const { stdin, proc } = fakeChild();
201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
+ new TerminalProcessImpl('pipe', null, proc as any);
203
+ // Without a registered 'error' listener, EventEmitter would re-throw here.
204
+ expect(() => stdin.emit('error', new Error('broken pipe'))).not.toThrow();
205
+ });
206
+
207
+ it('unsubscribe stops further data delivery', () => {
208
+ const { stdout, proc } = fakeChild();
209
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
210
+ const term = new TerminalProcessImpl('pipe', null, proc as any);
211
+ const seen: string[] = [];
212
+ const unsub = term.onData((d) => seen.push(d));
213
+ stdout.emit('data', Buffer.from('a', 'utf8'));
214
+ unsub();
215
+ stdout.emit('data', Buffer.from('b', 'utf8'));
216
+ expect(seen).toEqual(['a']);
217
+ });
218
+ });
219
+
220
+ describe('TerminalProcessImpl kill() terminates the child process tree', () => {
221
+ afterEach(() => {
222
+ vi.restoreAllMocks();
223
+ vi.useRealTimers();
224
+ });
225
+
226
+ it('signals the whole process GROUP (negative pid) then escalates to SIGKILL', () => {
227
+ if (process.platform === 'win32') return; // POSIX group-kill path only
228
+ vi.useFakeTimers();
229
+ const { proc } = fakeChild();
230
+ // Give the fake a pid + a kill so the group-signal path is exercised.
231
+ (proc as unknown as { pid: number }).pid = 4242;
232
+ (proc as unknown as { kill: (s?: string) => boolean }).kill = () => true;
233
+
234
+ const killed: Array<{ target: number; signal: string }> = [];
235
+ vi.spyOn(process, 'kill').mockImplementation(((pid: number, signal?: string | number) => {
236
+ killed.push({ target: pid, signal: String(signal) });
237
+ return true;
238
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
239
+ }) as any);
240
+
241
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
242
+ const term = new TerminalProcessImpl('pipe', null, proc as any);
243
+ term.kill();
244
+
245
+ // SIGTERM goes to the GROUP: negative pid.
246
+ expect(killed).toContainEqual({ target: -4242, signal: 'SIGTERM' });
247
+ // After the grace window, SIGKILL also targets the group.
248
+ vi.advanceTimersByTime(5_000);
249
+ expect(killed).toContainEqual({ target: -4242, signal: 'SIGKILL' });
250
+ });
251
+
252
+ it('falls back to signaling just the child when the group signal fails', () => {
253
+ if (process.platform === 'win32') return;
254
+ vi.useFakeTimers(); // keep the escalation timer from firing real process.kill after restore
255
+ const { proc } = fakeChild();
256
+ (proc as unknown as { pid: number }).pid = 99;
257
+ const childSignals: string[] = [];
258
+ (proc as unknown as { kill: (s?: string) => boolean }).kill = (s?: string) => {
259
+ childSignals.push(String(s));
260
+ return true;
261
+ };
262
+ vi.spyOn(process, 'kill').mockImplementation((() => {
263
+ throw new Error('ESRCH'); // no such process group
264
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
265
+ }) as any);
266
+
267
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
268
+ const term = new TerminalProcessImpl('pipe', null, proc as any);
269
+ expect(() => term.kill()).not.toThrow();
270
+ // Group signal threw → fell back to child.kill('SIGTERM').
271
+ expect(childSignals).toContain('SIGTERM');
272
+ });
273
+
274
+ it('warns once when listeners grow past the leak threshold and never crashes', () => {
275
+ const { proc } = fakeChild();
276
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
277
+ const term = new TerminalProcessImpl('pipe', null, proc as any);
278
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
279
+ // Attach far more than the threshold (64) without ever unsubscribing — the
280
+ // "viewer that never closed" leak the warning is meant to surface.
281
+ for (let i = 0; i < 100; i += 1) term.onData(() => {});
282
+ expect(warn).toHaveBeenCalledTimes(1);
283
+ expect(warn.mock.calls[0]?.[0]).toContain('listeners');
284
+ });
285
+ });