groove-dev 0.16.2 → 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 +110 -33
- package/node_modules/@groove-dev/gui/dist/assets/{index-DeXW9EFU.js → index-B_VHpncx.js} +19 -19
- package/node_modules/@groove-dev/gui/dist/index.html +1 -1
- package/node_modules/@groove-dev/gui/src/components/EditorTabs.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/FileTree.jsx +18 -8
- 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 +110 -33
- package/packages/gui/dist/assets/{index-DeXW9EFU.js → index-B_VHpncx.js} +19 -19
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/src/components/EditorTabs.jsx +2 -2
- package/packages/gui/src/components/FileTree.jsx +18 -8
- package/packages/gui/src/components/Terminal.jsx +29 -12
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>GROOVE</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-B_VHpncx.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-BhjOFLBc.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -30,7 +30,7 @@ export default function EditorTabs() {
|
|
|
30
30
|
...styles.tab,
|
|
31
31
|
color: isActive ? 'var(--text-bright)' : 'var(--text-primary)',
|
|
32
32
|
borderBottom: isActive ? '1px solid var(--accent)' : '1px solid transparent',
|
|
33
|
-
background: isActive ? 'var(--bg-
|
|
33
|
+
background: isActive ? 'var(--bg-base)' : 'transparent',
|
|
34
34
|
}}
|
|
35
35
|
>
|
|
36
36
|
{isDirty && <span style={styles.dirtyDot} />}
|
|
@@ -62,7 +62,7 @@ const styles = {
|
|
|
62
62
|
cursor: 'pointer', whiteSpace: 'nowrap',
|
|
63
63
|
fontSize: 11, fontFamily: 'var(--font)',
|
|
64
64
|
transition: 'background 0.08s',
|
|
65
|
-
borderRight: '1px solid
|
|
65
|
+
borderRight: '1px solid rgba(75, 82, 99, 0.4)',
|
|
66
66
|
flexShrink: 0,
|
|
67
67
|
},
|
|
68
68
|
tabName: {
|
|
@@ -111,11 +111,9 @@ function ContextMenu({ x, y, entry, onClose, onAction }) {
|
|
|
111
111
|
const isDir = entry?.type === 'dir';
|
|
112
112
|
|
|
113
113
|
const items = [
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
{ sep: true },
|
|
118
|
-
] : []),
|
|
114
|
+
{ label: 'New File', action: isDir ? 'newFileIn' : 'newFileSibling' },
|
|
115
|
+
{ label: 'New Folder', action: isDir ? 'newDirIn' : 'newDirSibling' },
|
|
116
|
+
{ sep: true },
|
|
119
117
|
{ label: 'Rename', action: 'rename' },
|
|
120
118
|
{ label: 'Delete', action: 'delete', danger: true },
|
|
121
119
|
];
|
|
@@ -219,7 +217,6 @@ export default function FileTree() {
|
|
|
219
217
|
switch (action) {
|
|
220
218
|
case 'newFileIn':
|
|
221
219
|
setCreating({ type: 'file', parentPath: entry.path });
|
|
222
|
-
// Ensure dir is expanded
|
|
223
220
|
setExpandedDirs((prev) => {
|
|
224
221
|
const next = new Set(prev);
|
|
225
222
|
next.add(entry.path);
|
|
@@ -236,6 +233,12 @@ export default function FileTree() {
|
|
|
236
233
|
return next;
|
|
237
234
|
});
|
|
238
235
|
break;
|
|
236
|
+
case 'newFileSibling':
|
|
237
|
+
setCreating({ type: 'file', parentPath: parentDir });
|
|
238
|
+
break;
|
|
239
|
+
case 'newDirSibling':
|
|
240
|
+
setCreating({ type: 'dir', parentPath: parentDir });
|
|
241
|
+
break;
|
|
239
242
|
case 'rename':
|
|
240
243
|
setRenamingPath(entry.path);
|
|
241
244
|
setRenameValue(entry.name);
|
|
@@ -300,14 +303,21 @@ export default function FileTree() {
|
|
|
300
303
|
title="New File"
|
|
301
304
|
style={styles.toolbarBtn}
|
|
302
305
|
>
|
|
303
|
-
|
|
306
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3">
|
|
307
|
+
<path d="M3 1.5h6l4 4V14a.5.5 0 01-.5.5h-9A.5.5 0 013 14V2a.5.5 0 01.5-.5z"/>
|
|
308
|
+
<path d="M9 1.5V5.5h4"/>
|
|
309
|
+
<path d="M8 8.5v4M6 10.5h4" strokeLinecap="round"/>
|
|
310
|
+
</svg>
|
|
304
311
|
</button>
|
|
305
312
|
<button
|
|
306
313
|
onClick={() => setCreating({ type: 'dir', parentPath: '' })}
|
|
307
314
|
title="New Folder"
|
|
308
315
|
style={styles.toolbarBtn}
|
|
309
316
|
>
|
|
310
|
-
|
|
317
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3">
|
|
318
|
+
<path d="M1.5 3.5h4l1.5 1.5H14a.5.5 0 01.5.5v8a.5.5 0 01-.5.5H2a.5.5 0 01-.5-.5V4z"/>
|
|
319
|
+
<path d="M8 8v3M6.5 9.5h3" strokeLinecap="round"/>
|
|
320
|
+
</svg>
|
|
311
321
|
</button>
|
|
312
322
|
</div>
|
|
313
323
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GROOVE GUI — Embedded Terminal (xterm.js)
|
|
2
2
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
3
|
|
|
4
|
-
import React, { useRef, useEffect
|
|
4
|
+
import React, { useRef, useEffect } from 'react';
|
|
5
5
|
import { Terminal as XTerm } from '@xterm/xterm';
|
|
6
6
|
import { FitAddon } from '@xterm/addon-fit';
|
|
7
7
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
@@ -13,9 +13,10 @@ export default function Terminal({ visible }) {
|
|
|
13
13
|
const termRef = useRef(null);
|
|
14
14
|
const fitAddonRef = useRef(null);
|
|
15
15
|
const sessionIdRef = useRef(null);
|
|
16
|
+
const spawnedRef = useRef(false);
|
|
16
17
|
const ws = useGrooveStore((s) => s.ws);
|
|
17
18
|
|
|
18
|
-
// Create
|
|
19
|
+
// Create xterm instance on mount
|
|
19
20
|
useEffect(() => {
|
|
20
21
|
if (!containerRef.current) return;
|
|
21
22
|
|
|
@@ -55,7 +56,11 @@ export default function Terminal({ visible }) {
|
|
|
55
56
|
term.loadAddon(webLinksAddon);
|
|
56
57
|
|
|
57
58
|
term.open(containerRef.current);
|
|
58
|
-
|
|
59
|
+
|
|
60
|
+
// Fit after a frame so container has final dimensions
|
|
61
|
+
requestAnimationFrame(() => {
|
|
62
|
+
try { fitAddon.fit(); } catch { /* ignore */ }
|
|
63
|
+
});
|
|
59
64
|
|
|
60
65
|
termRef.current = term;
|
|
61
66
|
fitAddonRef.current = fitAddon;
|
|
@@ -64,19 +69,24 @@ export default function Terminal({ visible }) {
|
|
|
64
69
|
term.dispose();
|
|
65
70
|
termRef.current = null;
|
|
66
71
|
fitAddonRef.current = null;
|
|
72
|
+
spawnedRef.current = false;
|
|
67
73
|
};
|
|
68
74
|
}, []);
|
|
69
75
|
|
|
70
|
-
// Spawn shell session when ws
|
|
76
|
+
// Spawn shell session when ws + term are ready
|
|
71
77
|
useEffect(() => {
|
|
72
|
-
if (!ws || ws.readyState !== 1 || !termRef.current) return;
|
|
78
|
+
if (!ws || ws.readyState !== 1 || !termRef.current || spawnedRef.current) return;
|
|
73
79
|
|
|
74
80
|
const term = termRef.current;
|
|
81
|
+
spawnedRef.current = true;
|
|
75
82
|
|
|
76
|
-
//
|
|
77
|
-
ws.send(JSON.stringify({
|
|
83
|
+
// Send spawn with initial dimensions
|
|
84
|
+
ws.send(JSON.stringify({
|
|
85
|
+
type: 'terminal:spawn',
|
|
86
|
+
cols: term.cols || 120,
|
|
87
|
+
rows: term.rows || 30,
|
|
88
|
+
}));
|
|
78
89
|
|
|
79
|
-
// Listen for terminal messages
|
|
80
90
|
const handler = (event) => {
|
|
81
91
|
try {
|
|
82
92
|
const msg = JSON.parse(event.data);
|
|
@@ -93,31 +103,38 @@ export default function Terminal({ visible }) {
|
|
|
93
103
|
|
|
94
104
|
ws.addEventListener('message', handler);
|
|
95
105
|
|
|
96
|
-
// Forward keystrokes
|
|
106
|
+
// Forward keystrokes
|
|
97
107
|
const inputDisposable = term.onData((data) => {
|
|
98
108
|
if (sessionIdRef.current && ws.readyState === 1) {
|
|
99
109
|
ws.send(JSON.stringify({ type: 'terminal:input', id: sessionIdRef.current, data }));
|
|
100
110
|
}
|
|
101
111
|
});
|
|
102
112
|
|
|
113
|
+
// Forward resize events
|
|
114
|
+
const resizeDisposable = term.onResize(({ cols, rows }) => {
|
|
115
|
+
if (sessionIdRef.current && ws.readyState === 1) {
|
|
116
|
+
ws.send(JSON.stringify({ type: 'terminal:resize', id: sessionIdRef.current, cols, rows }));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
103
120
|
return () => {
|
|
104
121
|
ws.removeEventListener('message', handler);
|
|
105
122
|
inputDisposable.dispose();
|
|
106
|
-
|
|
123
|
+
resizeDisposable.dispose();
|
|
107
124
|
if (sessionIdRef.current && ws.readyState === 1) {
|
|
108
125
|
ws.send(JSON.stringify({ type: 'terminal:kill', id: sessionIdRef.current }));
|
|
109
126
|
}
|
|
110
127
|
sessionIdRef.current = null;
|
|
128
|
+
spawnedRef.current = false;
|
|
111
129
|
};
|
|
112
130
|
}, [ws]);
|
|
113
131
|
|
|
114
132
|
// Refit on visibility change
|
|
115
133
|
useEffect(() => {
|
|
116
134
|
if (visible && fitAddonRef.current) {
|
|
117
|
-
// Small delay so container has its final size
|
|
118
135
|
const timer = setTimeout(() => {
|
|
119
136
|
try { fitAddonRef.current.fit(); } catch { /* ignore */ }
|
|
120
|
-
},
|
|
137
|
+
}, 80);
|
|
121
138
|
return () => clearTimeout(timer);
|
|
122
139
|
}
|
|
123
140
|
}, [visible]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.4",
|
|
4
4
|
"description": "Open-source agent orchestration layer for AI coding tools. GUI dashboard, multi-agent coordination, zero cold-start (Journalist), infinite sessions (adaptive context rotation), AI Project Manager, Quick Launch. Works with Claude Code, Codex, Gemini CLI, Ollama.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -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,38 +1,116 @@
|
|
|
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;
|
|
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;
|
|
102
|
+
}
|
|
26
103
|
|
|
27
|
-
const proc = spawn(
|
|
104
|
+
const proc = spawn(this._python, ['-u', '-c', PTY_HELPER], {
|
|
28
105
|
cwd,
|
|
29
106
|
env: {
|
|
30
107
|
...process.env,
|
|
31
108
|
TERM: 'xterm-256color',
|
|
32
109
|
COLORTERM: 'truecolor',
|
|
33
110
|
LANG: process.env.LANG || 'en_US.UTF-8',
|
|
34
|
-
|
|
35
|
-
|
|
111
|
+
GROOVE_SHELL: shell,
|
|
112
|
+
COLS: String(cols),
|
|
113
|
+
ROWS: String(rows),
|
|
36
114
|
},
|
|
37
115
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
116
|
});
|
|
@@ -40,14 +118,12 @@ export class TerminalManager {
|
|
|
40
118
|
const session = { proc, ws, id };
|
|
41
119
|
this.sessions.set(id, session);
|
|
42
120
|
|
|
43
|
-
// Relay stdout → WS
|
|
44
121
|
proc.stdout.on('data', (data) => {
|
|
45
122
|
if (ws.readyState === 1) {
|
|
46
123
|
ws.send(JSON.stringify({ type: 'terminal:output', id, data: data.toString('utf8') }));
|
|
47
124
|
}
|
|
48
125
|
});
|
|
49
126
|
|
|
50
|
-
// Relay stderr → WS (merge with stdout)
|
|
51
127
|
proc.stderr.on('data', (data) => {
|
|
52
128
|
if (ws.readyState === 1) {
|
|
53
129
|
ws.send(JSON.stringify({ type: 'terminal:output', id, data: data.toString('utf8') }));
|
|
@@ -71,18 +147,19 @@ export class TerminalManager {
|
|
|
71
147
|
return id;
|
|
72
148
|
}
|
|
73
149
|
|
|
74
|
-
/**
|
|
75
|
-
* Write input to a terminal session.
|
|
76
|
-
*/
|
|
77
150
|
write(id, data) {
|
|
78
151
|
const session = this.sessions.get(id);
|
|
79
152
|
if (!session || !session.proc.stdin.writable) return;
|
|
80
153
|
session.proc.stdin.write(data);
|
|
81
154
|
}
|
|
82
155
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
|
|
86
163
|
kill(id) {
|
|
87
164
|
const session = this.sessions.get(id);
|
|
88
165
|
if (!session) return;
|
|
@@ -95,18 +172,12 @@ export class TerminalManager {
|
|
|
95
172
|
this.sessions.delete(id);
|
|
96
173
|
}
|
|
97
174
|
|
|
98
|
-
/**
|
|
99
|
-
* Kill all sessions (daemon shutdown).
|
|
100
|
-
*/
|
|
101
175
|
killAll() {
|
|
102
176
|
for (const [id] of this.sessions) {
|
|
103
177
|
this.kill(id);
|
|
104
178
|
}
|
|
105
179
|
}
|
|
106
180
|
|
|
107
|
-
/**
|
|
108
|
-
* Clean up sessions for a disconnected WS client.
|
|
109
|
-
*/
|
|
110
181
|
cleanupClient(ws) {
|
|
111
182
|
for (const [id, session] of this.sessions) {
|
|
112
183
|
if (session.ws === ws) {
|
|
@@ -116,14 +187,20 @@ export class TerminalManager {
|
|
|
116
187
|
}
|
|
117
188
|
|
|
118
189
|
_detectShell() {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return process.env.SHELL;
|
|
122
|
-
}
|
|
123
|
-
const candidates = ['/bin/zsh', '/bin/bash', '/bin/sh'];
|
|
124
|
-
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']) {
|
|
125
192
|
if (existsSync(sh)) return sh;
|
|
126
193
|
}
|
|
127
194
|
return 'sh';
|
|
128
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
|
+
}
|
|
129
206
|
}
|