groove-dev 0.16.3 → 0.16.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/node_modules/@groove-dev/daemon/src/index.js +4 -1
- package/node_modules/@groove-dev/daemon/src/terminal-pty.js +119 -54
- package/node_modules/@groove-dev/gui/dist/assets/{index-CFeltwTB.js → index-B_VHpncx.js} +22 -22
- package/node_modules/@groove-dev/gui/dist/index.html +1 -1
- package/node_modules/@groove-dev/gui/src/components/Terminal.jsx +29 -12
- package/package.json +1 -1
- package/packages/daemon/src/index.js +4 -1
- package/packages/daemon/src/terminal-pty.js +119 -54
- package/packages/gui/dist/assets/{index-CFeltwTB.js → index-B_VHpncx.js} +22 -22
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/src/components/Terminal.jsx +29 -12
|
@@ -174,13 +174,16 @@ export class Daemon {
|
|
|
174
174
|
break;
|
|
175
175
|
// Terminal
|
|
176
176
|
case 'terminal:spawn': {
|
|
177
|
-
const id = this.terminalManager.spawn(ws, { cwd: msg.cwd });
|
|
177
|
+
const id = this.terminalManager.spawn(ws, { cwd: msg.cwd, cols: msg.cols, rows: msg.rows });
|
|
178
178
|
ws.send(JSON.stringify({ type: 'terminal:spawned', id }));
|
|
179
179
|
break;
|
|
180
180
|
}
|
|
181
181
|
case 'terminal:input':
|
|
182
182
|
if (msg.id && msg.data) this.terminalManager.write(msg.id, msg.data);
|
|
183
183
|
break;
|
|
184
|
+
case 'terminal:resize':
|
|
185
|
+
if (msg.id && msg.rows && msg.cols) this.terminalManager.resize(msg.id, msg.rows, msg.cols);
|
|
186
|
+
break;
|
|
184
187
|
case 'terminal:kill':
|
|
185
188
|
if (msg.id) this.terminalManager.kill(msg.id);
|
|
186
189
|
break;
|
|
@@ -1,66 +1,129 @@
|
|
|
1
1
|
// GROOVE — Terminal PTY Manager (shell sessions over WebSocket)
|
|
2
2
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
3
|
|
|
4
|
-
import { spawn } from 'child_process';
|
|
4
|
+
import { spawn, execFileSync } from 'child_process';
|
|
5
5
|
import { existsSync } from 'fs';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
// Python helper that creates a real PTY and relays I/O through stdin/stdout pipes.
|
|
8
|
+
// The shell sees a genuine TTY — prompts, colors, line editing, tab completion all work.
|
|
9
|
+
const PTY_HELPER = `
|
|
10
|
+
import pty, os, sys, select, signal, struct, fcntl, termios, errno
|
|
11
|
+
|
|
12
|
+
master, slave = pty.openpty()
|
|
13
|
+
|
|
14
|
+
cols = int(os.environ.get('COLS', '120'))
|
|
15
|
+
rows = int(os.environ.get('ROWS', '30'))
|
|
16
|
+
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
|
|
17
|
+
|
|
18
|
+
pid = os.fork()
|
|
19
|
+
if pid == 0:
|
|
20
|
+
os.setsid()
|
|
21
|
+
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
|
|
22
|
+
os.dup2(slave, 0)
|
|
23
|
+
os.dup2(slave, 1)
|
|
24
|
+
os.dup2(slave, 2)
|
|
25
|
+
os.close(master)
|
|
26
|
+
os.close(slave)
|
|
27
|
+
shell = os.environ.get('GROOVE_SHELL', os.environ.get('SHELL', '/bin/bash'))
|
|
28
|
+
os.execvp(shell, [shell, '-l'])
|
|
29
|
+
|
|
30
|
+
os.close(slave)
|
|
31
|
+
|
|
32
|
+
def resize(sig, frame):
|
|
33
|
+
pass
|
|
34
|
+
signal.signal(signal.SIGWINCH, resize)
|
|
35
|
+
|
|
36
|
+
flags = fcntl.fcntl(0, fcntl.F_GETFL)
|
|
37
|
+
fcntl.fcntl(0, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
while True:
|
|
41
|
+
rlist = select.select([0, master], [], [], 0.05)[0]
|
|
42
|
+
if 0 in rlist:
|
|
43
|
+
try:
|
|
44
|
+
data = os.read(0, 4096)
|
|
45
|
+
if not data: break
|
|
46
|
+
# Resize command: ESC ] 7 ; <rows> ; <cols> BEL
|
|
47
|
+
if b'\\x1b]7;' in data:
|
|
48
|
+
idx = data.index(b'\\x1b]7;')
|
|
49
|
+
end = data.index(b'\\x07', idx)
|
|
50
|
+
params = data[idx+4:end].decode().split(';')
|
|
51
|
+
if len(params) == 2:
|
|
52
|
+
r, c = int(params[0]), int(params[1])
|
|
53
|
+
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', r, c, 0, 0))
|
|
54
|
+
os.kill(pid, signal.SIGWINCH)
|
|
55
|
+
rest = data[:idx] + data[end+1:]
|
|
56
|
+
if rest:
|
|
57
|
+
os.write(master, rest)
|
|
58
|
+
else:
|
|
59
|
+
os.write(master, data)
|
|
60
|
+
except OSError as e:
|
|
61
|
+
if e.errno != errno.EAGAIN: break
|
|
62
|
+
if master in rlist:
|
|
63
|
+
try:
|
|
64
|
+
data = os.read(master, 4096)
|
|
65
|
+
if not data: break
|
|
66
|
+
sys.stdout.buffer.write(data)
|
|
67
|
+
sys.stdout.buffer.flush()
|
|
68
|
+
except OSError: break
|
|
69
|
+
# Check child
|
|
70
|
+
try:
|
|
71
|
+
p, status = os.waitpid(pid, os.WNOHANG)
|
|
72
|
+
if p != 0: break
|
|
73
|
+
except ChildProcessError: break
|
|
74
|
+
except: pass
|
|
75
|
+
finally:
|
|
76
|
+
try: os.kill(pid, signal.SIGTERM)
|
|
77
|
+
except: pass
|
|
78
|
+
`.trim();
|
|
79
|
+
|
|
12
80
|
export class TerminalManager {
|
|
13
81
|
constructor(daemon) {
|
|
14
82
|
this.daemon = daemon;
|
|
15
|
-
this.sessions = new Map();
|
|
83
|
+
this.sessions = new Map();
|
|
16
84
|
this.counter = 0;
|
|
85
|
+
this._python = this._findPython();
|
|
17
86
|
}
|
|
18
87
|
|
|
19
|
-
/**
|
|
20
|
-
* Spawn a new shell session connected to a specific WebSocket client.
|
|
21
|
-
*/
|
|
22
88
|
spawn(ws, options = {}) {
|
|
23
89
|
const id = `term-${++this.counter}`;
|
|
24
90
|
const shell = this._detectShell();
|
|
25
91
|
const cwd = options.cwd || this.daemon.projectDir;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
if (process.platform === 'darwin') {
|
|
39
|
-
proc = spawn('script', ['-q', '/dev/null', shell, '-l'], {
|
|
40
|
-
cwd,
|
|
41
|
-
env,
|
|
42
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
43
|
-
});
|
|
44
|
-
} else {
|
|
45
|
-
// Linux (util-linux script)
|
|
46
|
-
proc = spawn('script', ['-qfc', `${shell} -l`, '/dev/null'], {
|
|
47
|
-
cwd,
|
|
48
|
-
env,
|
|
49
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
50
|
-
});
|
|
92
|
+
const cols = options.cols || 120;
|
|
93
|
+
const rows = options.rows || 30;
|
|
94
|
+
|
|
95
|
+
if (!this._python) {
|
|
96
|
+
ws.send(JSON.stringify({
|
|
97
|
+
type: 'terminal:output', id,
|
|
98
|
+
data: '\r\n\x1b[31mTerminal requires Python 3 (python3 not found in PATH)\x1b[0m\r\n',
|
|
99
|
+
}));
|
|
100
|
+
ws.send(JSON.stringify({ type: 'terminal:exit', id, code: 1 }));
|
|
101
|
+
return id;
|
|
51
102
|
}
|
|
52
103
|
|
|
104
|
+
const proc = spawn(this._python, ['-u', '-c', PTY_HELPER], {
|
|
105
|
+
cwd,
|
|
106
|
+
env: {
|
|
107
|
+
...process.env,
|
|
108
|
+
TERM: 'xterm-256color',
|
|
109
|
+
COLORTERM: 'truecolor',
|
|
110
|
+
LANG: process.env.LANG || 'en_US.UTF-8',
|
|
111
|
+
GROOVE_SHELL: shell,
|
|
112
|
+
COLS: String(cols),
|
|
113
|
+
ROWS: String(rows),
|
|
114
|
+
},
|
|
115
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
116
|
+
});
|
|
117
|
+
|
|
53
118
|
const session = { proc, ws, id };
|
|
54
119
|
this.sessions.set(id, session);
|
|
55
120
|
|
|
56
|
-
// Relay stdout → WS
|
|
57
121
|
proc.stdout.on('data', (data) => {
|
|
58
122
|
if (ws.readyState === 1) {
|
|
59
123
|
ws.send(JSON.stringify({ type: 'terminal:output', id, data: data.toString('utf8') }));
|
|
60
124
|
}
|
|
61
125
|
});
|
|
62
126
|
|
|
63
|
-
// Relay stderr → WS (merge with stdout)
|
|
64
127
|
proc.stderr.on('data', (data) => {
|
|
65
128
|
if (ws.readyState === 1) {
|
|
66
129
|
ws.send(JSON.stringify({ type: 'terminal:output', id, data: data.toString('utf8') }));
|
|
@@ -84,18 +147,19 @@ export class TerminalManager {
|
|
|
84
147
|
return id;
|
|
85
148
|
}
|
|
86
149
|
|
|
87
|
-
/**
|
|
88
|
-
* Write input to a terminal session.
|
|
89
|
-
*/
|
|
90
150
|
write(id, data) {
|
|
91
151
|
const session = this.sessions.get(id);
|
|
92
152
|
if (!session || !session.proc.stdin.writable) return;
|
|
93
153
|
session.proc.stdin.write(data);
|
|
94
154
|
}
|
|
95
155
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
156
|
+
resize(id, rows, cols) {
|
|
157
|
+
const session = this.sessions.get(id);
|
|
158
|
+
if (!session || !session.proc.stdin.writable) return;
|
|
159
|
+
// Send resize command via the custom escape sequence
|
|
160
|
+
session.proc.stdin.write(`\x1b]7;${rows};${cols}\x07`);
|
|
161
|
+
}
|
|
162
|
+
|
|
99
163
|
kill(id) {
|
|
100
164
|
const session = this.sessions.get(id);
|
|
101
165
|
if (!session) return;
|
|
@@ -108,18 +172,12 @@ export class TerminalManager {
|
|
|
108
172
|
this.sessions.delete(id);
|
|
109
173
|
}
|
|
110
174
|
|
|
111
|
-
/**
|
|
112
|
-
* Kill all sessions (daemon shutdown).
|
|
113
|
-
*/
|
|
114
175
|
killAll() {
|
|
115
176
|
for (const [id] of this.sessions) {
|
|
116
177
|
this.kill(id);
|
|
117
178
|
}
|
|
118
179
|
}
|
|
119
180
|
|
|
120
|
-
/**
|
|
121
|
-
* Clean up sessions for a disconnected WS client.
|
|
122
|
-
*/
|
|
123
181
|
cleanupClient(ws) {
|
|
124
182
|
for (const [id, session] of this.sessions) {
|
|
125
183
|
if (session.ws === ws) {
|
|
@@ -129,13 +187,20 @@ export class TerminalManager {
|
|
|
129
187
|
}
|
|
130
188
|
|
|
131
189
|
_detectShell() {
|
|
132
|
-
if (process.env.SHELL && existsSync(process.env.SHELL))
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
const candidates = ['/bin/zsh', '/bin/bash', '/bin/sh'];
|
|
136
|
-
for (const sh of candidates) {
|
|
190
|
+
if (process.env.SHELL && existsSync(process.env.SHELL)) return process.env.SHELL;
|
|
191
|
+
for (const sh of ['/bin/zsh', '/bin/bash', '/bin/sh']) {
|
|
137
192
|
if (existsSync(sh)) return sh;
|
|
138
193
|
}
|
|
139
194
|
return 'sh';
|
|
140
195
|
}
|
|
196
|
+
|
|
197
|
+
_findPython() {
|
|
198
|
+
for (const cmd of ['python3', 'python']) {
|
|
199
|
+
try {
|
|
200
|
+
const v = execFileSync(cmd, ['--version'], { encoding: 'utf8', timeout: 3000 }).trim();
|
|
201
|
+
if (v.startsWith('Python 3')) return cmd;
|
|
202
|
+
} catch { /* not found */ }
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
141
206
|
}
|