Skip to content

Commit 2224bb3

Browse files
fix: detect npm.cmd on Windows in setup prerequisite check (#2)
* fix: detect npm.cmd on Windows in setup prerequisite check On Windows/Git Bash, npm may only be available as npm.cmd and not directly as npm. Falls back to npm.cmd when the plain npm command fails. Also separates the error messages for node and npm so the user knows exactly which tool is missing instead of both being reported as "Node.js 18+". * fix: add explicit utf-8 encoding to write_text calls in setup.py Prevents UnicodeEncodeError on systems where the default locale is not UTF-8 (e.g., Windows with cp1252). Also syncs package-lock.json with updated dependency tree. * feat: add Windows ConPTY support to terminal backend Replace Unix-only PTY (pty/fcntl/termios) with a platform-aware implementation that uses pywinpty on Windows and the existing PTY stack on Unix. All session lifecycle operations (spawn, kill, resize, WebSocket I/O) now branch on sys.platform. Adds pywinpty>=3.0.3 as a conditional Windows dependency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dbeedc8 commit 2224bb3

3 files changed

Lines changed: 173 additions & 83 deletions

File tree

dashboard/backend/routes/terminal.py

Lines changed: 151 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
"""Terminal endpoint — WebSocket PTY for real terminal sessions."""
22

33
import os
4-
import pty
5-
import select
6-
import signal
7-
import struct
8-
import fcntl
9-
import termios
10-
import subprocess
4+
import sys
115
import json
126
import threading
137
import time
@@ -22,6 +16,18 @@
2216
bp = Blueprint("terminal", __name__)
2317
sock = Sock()
2418

19+
_WINDOWS = sys.platform == "win32"
20+
21+
if _WINDOWS:
22+
from winpty import PtyProcess
23+
else:
24+
import pty
25+
import select
26+
import signal
27+
import struct
28+
import fcntl
29+
import termios
30+
2531
# Store active sessions: {session_id: {pid, fd}}
2632
sessions = {}
2733
session_counter = 0
@@ -94,7 +100,7 @@ def _parse_session_file(filepath, max_lines=20):
94100

95101

96102
def _spawn_pty(cmd, cwd):
97-
"""Spawn a process in a PTY and return (pid, fd)."""
103+
"""Spawn a process in a PTY and return (pid, fd). Unix only."""
98104
env = os.environ.copy()
99105
env["TERM"] = "xterm-256color"
100106
env["COLUMNS"] = "120"
@@ -109,22 +115,34 @@ def _spawn_pty(cmd, cwd):
109115

110116

111117
def _set_winsize(fd, rows, cols):
112-
"""Set terminal window size."""
118+
"""Set terminal window size. Unix only."""
113119
winsize = struct.pack("HHHH", rows, cols, 0, 0)
114120
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
115121

116122

123+
def _spawn_conpty(cmd, cwd):
124+
"""Spawn a process in a Windows ConPTY and return a PtyProcess."""
125+
env = os.environ.copy()
126+
env["TERM"] = "xterm-256color"
127+
return PtyProcess.spawn(cmd, cwd=str(cwd), env=env, dimensions=(40, 120))
128+
129+
117130
@bp.route("/api/terminal/sessions")
118131
@login_required
119132
def list_sessions():
120133
"""List active terminal sessions."""
121134
result = []
122135
for sid, info in sessions.items():
123-
try:
124-
os.kill(info["pid"], 0) # Check if alive
125-
result.append({"id": sid, "cmd": info["cmd"], "alive": True})
126-
except OSError:
127-
result.append({"id": sid, "cmd": info["cmd"], "alive": False})
136+
if _WINDOWS:
137+
proc = info.get("process")
138+
alive = proc.isalive() if proc else False
139+
else:
140+
try:
141+
os.kill(info["pid"], 0)
142+
alive = True
143+
except OSError:
144+
alive = False
145+
result.append({"id": sid, "cmd": info["cmd"], "alive": alive})
128146
return {"sessions": result}
129147

130148

@@ -184,15 +202,19 @@ def create_session():
184202
return {"error": "Session not found"}, 404
185203
cmd.extend(["--resume", resume_id])
186204
elif cmd_type == "shell":
187-
cmd = [os.environ.get("SHELL", "/bin/zsh")]
205+
cmd = ["cmd.exe"] if _WINDOWS else [os.environ.get("SHELL", "/bin/zsh")]
188206
else:
189207
cmd = ["claude", "--dangerously-skip-permissions"]
190208

191209
try:
192-
pid, fd = _spawn_pty(cmd, WORKSPACE)
193210
session_counter += 1
194211
sid = f"term-{session_counter}"
195-
sessions[sid] = {"pid": pid, "fd": fd, "cmd": " ".join(cmd)}
212+
if _WINDOWS:
213+
proc = _spawn_conpty(cmd, WORKSPACE)
214+
sessions[sid] = {"process": proc, "cmd": " ".join(cmd)}
215+
else:
216+
pid, fd = _spawn_pty(cmd, WORKSPACE)
217+
sessions[sid] = {"pid": pid, "fd": fd, "cmd": " ".join(cmd)}
196218
return {"id": sid, "cmd": " ".join(cmd)}
197219
except Exception as e:
198220
return {"error": str(e)}, 500
@@ -206,8 +228,11 @@ def kill_session(session_id):
206228
if not info:
207229
return {"error": "Session not found"}, 404
208230
try:
209-
os.kill(info["pid"], signal.SIGTERM)
210-
os.close(info["fd"])
231+
if _WINDOWS:
232+
info["process"].terminate(force=True)
233+
else:
234+
os.kill(info["pid"], signal.SIGTERM)
235+
os.close(info["fd"])
211236
except OSError:
212237
pass
213238
del sessions[session_id]
@@ -231,73 +256,127 @@ def terminal_ws(ws, session_id):
231256
ws.send(json.dumps({"error": "Session not found"}))
232257
return
233258

234-
fd = info["fd"]
235-
236-
# Set non-blocking
237-
import fcntl
238-
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
239-
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
259+
if _WINDOWS:
260+
proc = info["process"]
240261

241-
def read_output():
242-
"""Read from PTY and send to WebSocket."""
243-
while True:
244-
try:
245-
r, _, _ = select.select([fd], [], [], 0.1)
246-
if r:
247-
data = os.read(fd, 4096)
262+
def read_output():
263+
"""Read from ConPTY and send to WebSocket."""
264+
while True:
265+
try:
266+
if not proc.isalive():
267+
break
268+
data = proc.read(4096)
248269
if data:
249-
ws.send(data.decode("utf-8", errors="replace"))
270+
ws.send(data if isinstance(data, str) else data.decode("utf-8", errors="replace"))
250271
else:
251-
break
252-
except (OSError, EOFError):
253-
break
272+
time.sleep(0.01)
273+
except Exception:
274+
break
275+
try:
276+
ws.send("\r\n[Process exited]\r\n")
254277
except Exception:
255-
break
278+
pass
279+
280+
reader = threading.Thread(target=read_output, daemon=True)
281+
reader.start()
282+
256283
try:
257-
ws.send("\r\n[Process exited]\r\n")
284+
while True:
285+
try:
286+
msg = ws.receive(timeout=300)
287+
except Exception:
288+
break
289+
290+
if msg is None:
291+
break
292+
293+
if isinstance(msg, str) and msg.startswith('{"resize":'):
294+
try:
295+
data = json.loads(msg)
296+
proc.setwinsize(data["resize"]["rows"], data["resize"]["cols"])
297+
except Exception:
298+
pass
299+
continue
300+
301+
if isinstance(msg, str) and msg == '{"ping":true}':
302+
try:
303+
ws.send('{"pong":true}')
304+
except Exception:
305+
pass
306+
continue
307+
308+
try:
309+
proc.write(msg if isinstance(msg, str) else msg.decode("utf-8"))
310+
except Exception:
311+
break
258312
except Exception:
259313
pass
314+
finally:
315+
reader.join(timeout=1)
260316

261-
# Start reader thread
262-
reader = threading.Thread(target=read_output, daemon=True)
263-
reader.start()
317+
else:
318+
fd = info["fd"]
319+
320+
# Set non-blocking
321+
import fcntl
322+
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
323+
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
264324

265-
# Main loop: read from WebSocket, write to PTY
266-
try:
267-
while True:
325+
def read_output():
326+
"""Read from PTY and send to WebSocket."""
327+
while True:
328+
try:
329+
r, _, _ = select.select([fd], [], [], 0.1)
330+
if r:
331+
data = os.read(fd, 4096)
332+
if data:
333+
ws.send(data.decode("utf-8", errors="replace"))
334+
else:
335+
break
336+
except (OSError, EOFError):
337+
break
338+
except Exception:
339+
break
268340
try:
269-
msg = ws.receive(timeout=300) # 5 min timeout
341+
ws.send("\r\n[Process exited]\r\n")
270342
except Exception:
271-
break
343+
pass
272344

273-
if msg is None:
274-
break
345+
reader = threading.Thread(target=read_output, daemon=True)
346+
reader.start()
275347

276-
# Handle resize messages
277-
if isinstance(msg, str) and msg.startswith('{"resize":'):
348+
try:
349+
while True:
278350
try:
279-
data = json.loads(msg)
280-
rows = data["resize"]["rows"]
281-
cols = data["resize"]["cols"]
282-
_set_winsize(fd, rows, cols)
351+
msg = ws.receive(timeout=300)
283352
except Exception:
284-
pass
285-
continue
353+
break
354+
355+
if msg is None:
356+
break
357+
358+
if isinstance(msg, str) and msg.startswith('{"resize":'):
359+
try:
360+
data = json.loads(msg)
361+
rows = data["resize"]["rows"]
362+
cols = data["resize"]["cols"]
363+
_set_winsize(fd, rows, cols)
364+
except Exception:
365+
pass
366+
continue
367+
368+
if isinstance(msg, str) and msg == '{"ping":true}':
369+
try:
370+
ws.send('{"pong":true}')
371+
except Exception:
372+
pass
373+
continue
286374

287-
# Handle ping/keepalive
288-
if isinstance(msg, str) and msg == '{"ping":true}':
289375
try:
290-
ws.send('{"pong":true}')
291-
except Exception:
292-
pass
293-
continue
294-
295-
# Write input to PTY
296-
try:
297-
os.write(fd, msg.encode("utf-8") if isinstance(msg, str) else msg)
298-
except OSError:
299-
break
300-
except Exception:
301-
pass
302-
finally:
303-
reader.join(timeout=1)
376+
os.write(fd, msg.encode("utf-8") if isinstance(msg, str) else msg)
377+
except OSError:
378+
break
379+
except Exception:
380+
pass
381+
finally:
382+
reader.join(timeout=1)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies = [
1515
"requests>=2.31",
1616
"pyyaml>=6.0",
1717
"flask-sock>=0.7",
18+
"pywinpty>=3.0.3; sys_platform == 'win32'",
1819
]
1920

2021
[tool.uv]

setup.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,22 @@ def check_prerequisites():
6969
errors.append("node")
7070

7171
# npm
72+
npm_cmd = "npm"
7273
try:
73-
result = subprocess.run(["npm", "--version"], capture_output=True, text=True, timeout=5)
74-
if result.returncode == 0:
75-
print(f" {GREEN}{RESET} npm: {DIM}v{result.stdout.strip()}{RESET}")
76-
else:
77-
errors.append("npm")
74+
result = subprocess.run([npm_cmd, "--version"], capture_output=True, text=True, timeout=5)
75+
if result.returncode != 0:
76+
raise FileNotFoundError
77+
print(f" {GREEN}{RESET} npm: {DIM}v{result.stdout.strip()}{RESET}")
7878
except (FileNotFoundError, subprocess.TimeoutExpired):
79-
errors.append("npm")
79+
try:
80+
npm_cmd = "npm.cmd"
81+
result = subprocess.run([npm_cmd, "--version"], capture_output=True, text=True, timeout=5)
82+
if result.returncode == 0:
83+
print(f" {GREEN}{RESET} npm: {DIM}v{result.stdout.strip()}{RESET}")
84+
else:
85+
errors.append("npm")
86+
except (FileNotFoundError, subprocess.TimeoutExpired):
87+
errors.append("npm")
8088

8189
print()
8290

@@ -88,8 +96,10 @@ def check_prerequisites():
8896
if "uv" in errors:
8997
print(f" {RED}{RESET} uv (Python package manager) — {BOLD}https://docs.astral.sh/uv/{RESET}")
9098
print(f" {DIM}curl -LsSf https://astral.sh/uv/install.sh | sh{RESET}")
91-
if "node" in errors or "npm" in errors:
99+
if "node" in errors:
92100
print(f" {RED}{RESET} Node.js 18+ — {BOLD}https://nodejs.org{RESET}")
101+
if "npm" in errors:
102+
print(f" {RED}{RESET} npm not found (Node.js installed but npm missing from PATH) — {BOLD}https://nodejs.org{RESET}")
93103
print()
94104
print(f" {YELLOW}Install the missing tools and run setup again.{RESET}")
95105
sys.exit(1)
@@ -333,7 +343,7 @@ def copy_env_example(config: dict):
333343
print(f" {GREEN}{RESET} Created .env from .env.example")
334344
else:
335345
print(f" {YELLOW}!{RESET} .env.example not found, creating empty .env")
336-
dst.write_text("# EvoNexus Environment Variables\n# Fill in your API keys below\n\n")
346+
dst.write_text("# EvoNexus Environment Variables\n# Fill in your API keys below\n\n", encoding="utf-8")
337347

338348

339349
def copy_routines_config(config: dict):
@@ -346,7 +356,7 @@ def copy_routines_config(config: dict):
346356
if src.exists():
347357
shutil.copy2(src, dst)
348358
else:
349-
dst.write_text("# EvoNexus Routines — edit schedules here\n# See ROUTINES.md for documentation\n\ndaily: []\nweekly: []\nmonthly: []\n")
359+
dst.write_text("# EvoNexus Routines — edit schedules here\n# See ROUTINES.md for documentation\n\ndaily: []\nweekly: []\nmonthly: []\n", encoding="utf-8")
350360
print(f" {GREEN}{RESET} Created config/routines.yaml")
351361

352362

@@ -407,7 +417,7 @@ def main():
407417
# workspace.yaml
408418
config_dir = WORKSPACE / "config"
409419
config_dir.mkdir(exist_ok=True)
410-
(config_dir / "workspace.yaml").write_text(generate_workspace_yaml(config))
420+
(config_dir / "workspace.yaml").write_text(generate_workspace_yaml(config), encoding="utf-8")
411421
print(f" {GREEN}{RESET} Generated config/workspace.yaml")
412422

413423
# .env
@@ -418,7 +428,7 @@ def main():
418428

419429
# CLAUDE.md
420430
claude_md = generate_claude_md(config)
421-
(WORKSPACE / "CLAUDE.md").write_text(claude_md)
431+
(WORKSPACE / "CLAUDE.md").write_text(claude_md, encoding="utf-8")
422432
print(f" {GREEN}{RESET} Generated CLAUDE.md")
423433

424434
# Folders

0 commit comments

Comments
 (0)