mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Merge branch 'main' into feat/add-unit-testing
This commit is contained in:
@@ -30,9 +30,11 @@ import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
|
||||
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
|
||||
import { createWorkspaceRoutes } from "./routes/workspace.js";
|
||||
import { createTemplatesRoutes } from "./routes/templates.js";
|
||||
import { createTerminalRoutes, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired } from "./routes/terminal.js";
|
||||
import { AgentService } from "./services/agent-service.js";
|
||||
import { FeatureLoader } from "./services/feature-loader.js";
|
||||
import { AutoModeService } from "./services/auto-mode-service.js";
|
||||
import { getTerminalService } from "./services/terminal-service.js";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -116,13 +118,34 @@ app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
|
||||
app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
|
||||
app.use("/api/workspace", createWorkspaceRoutes());
|
||||
app.use("/api/templates", createTemplatesRoutes());
|
||||
app.use("/api/terminal", createTerminalRoutes());
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
// WebSocket server for streaming events
|
||||
const wss = new WebSocketServer({ server, path: "/api/events" });
|
||||
// WebSocket servers using noServer mode for proper multi-path support
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const terminalWss = new WebSocketServer({ noServer: true });
|
||||
const terminalService = getTerminalService();
|
||||
|
||||
// Handle HTTP upgrade requests manually to route to correct WebSocket server
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const { pathname } = new URL(request.url || "", `http://${request.headers.host}`);
|
||||
|
||||
if (pathname === "/api/events") {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, request);
|
||||
});
|
||||
} else if (pathname === "/api/terminal/ws") {
|
||||
terminalWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
terminalWss.emit("connection", ws, request);
|
||||
});
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Events WebSocket connection handler
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
console.log("[WebSocket] Client connected");
|
||||
|
||||
@@ -144,15 +167,153 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Track WebSocket connections per session
|
||||
const terminalConnections: Map<string, Set<WebSocket>> = new Map();
|
||||
|
||||
// Terminal WebSocket connection handler
|
||||
terminalWss.on("connection", (ws: WebSocket, req: import("http").IncomingMessage) => {
|
||||
// Parse URL to get session ID and token
|
||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||
const sessionId = url.searchParams.get("sessionId");
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
|
||||
|
||||
// Check if terminal is enabled
|
||||
if (!isTerminalEnabled()) {
|
||||
console.log("[Terminal WS] Terminal is disabled");
|
||||
ws.close(4003, "Terminal access is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate token if password is required
|
||||
if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) {
|
||||
console.log("[Terminal WS] Invalid or missing token");
|
||||
ws.close(4001, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
console.log("[Terminal WS] No session ID provided");
|
||||
ws.close(4002, "Session ID required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if session exists
|
||||
const session = terminalService.getSession(sessionId);
|
||||
if (!session) {
|
||||
console.log(`[Terminal WS] Session ${sessionId} not found`);
|
||||
ws.close(4004, "Session not found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
|
||||
|
||||
// Track this connection
|
||||
if (!terminalConnections.has(sessionId)) {
|
||||
terminalConnections.set(sessionId, new Set());
|
||||
}
|
||||
terminalConnections.get(sessionId)!.add(ws);
|
||||
|
||||
// Subscribe to terminal data
|
||||
const unsubscribeData = terminalService.onData((sid, data) => {
|
||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "data", data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to terminal exit
|
||||
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
|
||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "exit", exitCode }));
|
||||
ws.close(1000, "Session ended");
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on("message", (message) => {
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
|
||||
switch (msg.type) {
|
||||
case "input":
|
||||
// Write user input to terminal
|
||||
terminalService.write(sessionId, msg.data);
|
||||
break;
|
||||
|
||||
case "resize":
|
||||
// Resize terminal
|
||||
if (msg.cols && msg.rows) {
|
||||
terminalService.resize(sessionId, msg.cols, msg.rows);
|
||||
}
|
||||
break;
|
||||
|
||||
case "ping":
|
||||
// Respond to ping
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Terminal WS] Error processing message:", error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log(`[Terminal WS] Client disconnected from session ${sessionId}`);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
|
||||
// Remove from connections tracking
|
||||
const connections = terminalConnections.get(sessionId);
|
||||
if (connections) {
|
||||
connections.delete(ws);
|
||||
if (connections.size === 0) {
|
||||
terminalConnections.delete(sessionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
});
|
||||
|
||||
// Send initial connection success
|
||||
ws.send(JSON.stringify({
|
||||
type: "connected",
|
||||
sessionId,
|
||||
shell: session.shell,
|
||||
cwd: session.cwd,
|
||||
}));
|
||||
|
||||
// Send scrollback buffer to replay previous output
|
||||
const scrollback = terminalService.getScrollback(sessionId);
|
||||
if (scrollback && scrollback.length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: "scrollback",
|
||||
data: scrollback,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
server.listen(PORT, () => {
|
||||
const terminalStatus = isTerminalEnabled()
|
||||
? (isTerminalPasswordRequired() ? "enabled (password protected)" : "enabled")
|
||||
: "disabled";
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Automaker Backend Server ║
|
||||
╠═══════════════════════════════════════════════════════╣
|
||||
║ HTTP API: http://localhost:${PORT} ║
|
||||
║ WebSocket: ws://localhost:${PORT}/api/events ║
|
||||
║ Terminal: ws://localhost:${PORT}/api/terminal/ws ║
|
||||
║ Health: http://localhost:${PORT}/api/health ║
|
||||
║ Terminal: ${terminalStatus.padEnd(37)}║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
@@ -160,6 +321,7 @@ server.listen(PORT, () => {
|
||||
// Graceful shutdown
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("SIGTERM received, shutting down...");
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
console.log("Server closed");
|
||||
process.exit(0);
|
||||
@@ -168,6 +330,7 @@ process.on("SIGTERM", () => {
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
console.log("SIGINT received, shutting down...");
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
console.log("Server closed");
|
||||
process.exit(0);
|
||||
|
||||
@@ -355,6 +355,9 @@ Format your response as markdown. Be specific and actionable.`;
|
||||
} else if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||
console.log("[SpecRegeneration] Received success result");
|
||||
responseText = (msg as any).result || responseText;
|
||||
} else if ((msg as { type: string }).type === "error") {
|
||||
console.error("[SpecRegeneration] ❌ Received error message from stream:");
|
||||
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
@@ -502,6 +505,9 @@ Generate 5-15 features that build on each other logically.`;
|
||||
} else if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||
console.log("[SpecRegeneration] Received success result for features");
|
||||
responseText = (msg as any).result || responseText;
|
||||
} else if ((msg as { type: string }).type === "error") {
|
||||
console.error("[SpecRegeneration] ❌ Received error message from feature stream:");
|
||||
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
|
||||
312
apps/server/src/routes/terminal.ts
Normal file
312
apps/server/src/routes/terminal.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Terminal routes with password protection
|
||||
*
|
||||
* Provides REST API for terminal session management and authentication.
|
||||
* WebSocket connections for real-time I/O are handled separately in index.ts.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from "express";
|
||||
import { getTerminalService } from "../services/terminal-service.js";
|
||||
|
||||
// Read env variables lazily to ensure dotenv has loaded them
|
||||
function getTerminalPassword(): string | undefined {
|
||||
return process.env.TERMINAL_PASSWORD;
|
||||
}
|
||||
|
||||
function getTerminalEnabledConfig(): boolean {
|
||||
return process.env.TERMINAL_ENABLED !== "false"; // Enabled by default
|
||||
}
|
||||
|
||||
// In-memory session tokens (would use Redis in production)
|
||||
const validTokens: Map<string, { createdAt: Date; expiresAt: Date }> = new Map();
|
||||
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
/**
|
||||
* Generate a secure random token
|
||||
*/
|
||||
function generateToken(): string {
|
||||
return `term-${Date.now()}-${Math.random().toString(36).substr(2, 15)}${Math.random().toString(36).substr(2, 15)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired tokens
|
||||
*/
|
||||
function cleanupExpiredTokens(): void {
|
||||
const now = new Date();
|
||||
validTokens.forEach((data, token) => {
|
||||
if (data.expiresAt < now) {
|
||||
validTokens.delete(token);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up expired tokens every 5 minutes
|
||||
setInterval(cleanupExpiredTokens, 5 * 60 * 1000);
|
||||
|
||||
/**
|
||||
* Validate a terminal session token
|
||||
*/
|
||||
export function validateTerminalToken(token: string | undefined): boolean {
|
||||
if (!token) return false;
|
||||
|
||||
const tokenData = validTokens.get(token);
|
||||
if (!tokenData) return false;
|
||||
|
||||
if (tokenData.expiresAt < new Date()) {
|
||||
validTokens.delete(token);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if terminal requires password
|
||||
*/
|
||||
export function isTerminalPasswordRequired(): boolean {
|
||||
return !!getTerminalPassword();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if terminal is enabled
|
||||
*/
|
||||
export function isTerminalEnabled(): boolean {
|
||||
return getTerminalEnabledConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal authentication middleware
|
||||
* Checks for valid session token if password is configured
|
||||
*/
|
||||
export function terminalAuthMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
// Check if terminal is enabled
|
||||
if (!getTerminalEnabledConfig()) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Terminal access is disabled",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If no password configured, allow all requests
|
||||
if (!getTerminalPassword()) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for session token
|
||||
const token =
|
||||
(req.headers["x-terminal-token"] as string) ||
|
||||
(req.query.token as string);
|
||||
|
||||
if (!validateTerminalToken(token)) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: "Terminal authentication required",
|
||||
passwordRequired: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export function createTerminalRoutes(): Router {
|
||||
const router = Router();
|
||||
const terminalService = getTerminalService();
|
||||
|
||||
/**
|
||||
* GET /api/terminal/status
|
||||
* Get terminal status (enabled, password required, platform info)
|
||||
*/
|
||||
router.get("/status", (_req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
enabled: getTerminalEnabledConfig(),
|
||||
passwordRequired: !!getTerminalPassword(),
|
||||
platform: terminalService.getPlatformInfo(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/terminal/auth
|
||||
* Authenticate with password to get a session token
|
||||
*/
|
||||
router.post("/auth", (req, res) => {
|
||||
if (!getTerminalEnabledConfig()) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Terminal access is disabled",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalPassword = getTerminalPassword();
|
||||
|
||||
// If no password required, return immediate success
|
||||
if (!terminalPassword) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
authenticated: true,
|
||||
passwordRequired: false,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { password } = req.body;
|
||||
|
||||
if (!password || password !== terminalPassword) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: "Invalid password",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate session token
|
||||
const token = generateToken();
|
||||
const now = new Date();
|
||||
validTokens.set(token, {
|
||||
createdAt: now,
|
||||
expiresAt: new Date(now.getTime() + TOKEN_EXPIRY_MS),
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
authenticated: true,
|
||||
token,
|
||||
expiresIn: TOKEN_EXPIRY_MS,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/terminal/logout
|
||||
* Invalidate a session token
|
||||
*/
|
||||
router.post("/logout", (req, res) => {
|
||||
const token =
|
||||
(req.headers["x-terminal-token"] as string) ||
|
||||
req.body.token;
|
||||
|
||||
if (token) {
|
||||
validTokens.delete(token);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Apply terminal auth middleware to all routes below
|
||||
router.use(terminalAuthMiddleware);
|
||||
|
||||
/**
|
||||
* GET /api/terminal/sessions
|
||||
* List all active terminal sessions
|
||||
*/
|
||||
router.get("/sessions", (_req, res) => {
|
||||
const sessions = terminalService.getAllSessions();
|
||||
res.json({
|
||||
success: true,
|
||||
data: sessions,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/terminal/sessions
|
||||
* Create a new terminal session
|
||||
*/
|
||||
router.post("/sessions", (req, res) => {
|
||||
try {
|
||||
const { cwd, cols, rows, shell } = req.body;
|
||||
|
||||
const session = terminalService.createSession({
|
||||
cwd,
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
shell,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: session.id,
|
||||
cwd: session.cwd,
|
||||
shell: session.shell,
|
||||
createdAt: session.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Terminal] Error creating session:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to create terminal session",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/terminal/sessions/:id
|
||||
* Kill a terminal session
|
||||
*/
|
||||
router.delete("/sessions/:id", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const killed = terminalService.killSession(id);
|
||||
|
||||
if (!killed) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "Session not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/terminal/sessions/:id/resize
|
||||
* Resize a terminal session
|
||||
*/
|
||||
router.post("/sessions/:id/resize", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { cols, rows } = req.body;
|
||||
|
||||
if (!cols || !rows) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "cols and rows are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const resized = terminalService.resize(id, cols, rows);
|
||||
|
||||
if (!resized) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "Session not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
401
apps/server/src/services/terminal-service.ts
Normal file
401
apps/server/src/services/terminal-service.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Terminal Service
|
||||
*
|
||||
* Manages PTY (pseudo-terminal) sessions using node-pty.
|
||||
* Supports cross-platform shell detection including WSL.
|
||||
*/
|
||||
|
||||
import * as pty from "node-pty";
|
||||
import { EventEmitter } from "events";
|
||||
import * as os from "os";
|
||||
import * as fs from "fs";
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||
|
||||
// Throttle output to prevent overwhelming WebSocket under heavy load
|
||||
const OUTPUT_THROTTLE_MS = 16; // ~60fps max update rate
|
||||
const OUTPUT_BATCH_SIZE = 8192; // Max bytes to send per batch
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
pty: pty.IPty;
|
||||
cwd: string;
|
||||
createdAt: Date;
|
||||
shell: string;
|
||||
scrollbackBuffer: string; // Store recent output for replay on reconnect
|
||||
outputBuffer: string; // Pending output to be flushed
|
||||
flushTimeout: NodeJS.Timeout | null; // Throttle timer
|
||||
}
|
||||
|
||||
export interface TerminalOptions {
|
||||
cwd?: string;
|
||||
shell?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
type DataCallback = (sessionId: string, data: string) => void;
|
||||
type ExitCallback = (sessionId: string, exitCode: number) => void;
|
||||
|
||||
export class TerminalService extends EventEmitter {
|
||||
private sessions: Map<string, TerminalSession> = new Map();
|
||||
private dataCallbacks: Set<DataCallback> = new Set();
|
||||
private exitCallbacks: Set<ExitCallback> = new Set();
|
||||
|
||||
/**
|
||||
* Detect the best shell for the current platform
|
||||
*/
|
||||
detectShell(): { shell: string; args: string[] } {
|
||||
const platform = os.platform();
|
||||
|
||||
// Check if running in WSL
|
||||
if (platform === "linux" && this.isWSL()) {
|
||||
// In WSL, prefer the user's configured shell or bash
|
||||
const userShell = process.env.SHELL || "/bin/bash";
|
||||
if (fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ["--login"] };
|
||||
}
|
||||
return { shell: "/bin/bash", args: ["--login"] };
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case "win32": {
|
||||
// Windows: prefer PowerShell, fall back to cmd
|
||||
const pwsh = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
|
||||
const pwshCore = "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
|
||||
|
||||
if (fs.existsSync(pwshCore)) {
|
||||
return { shell: pwshCore, args: [] };
|
||||
}
|
||||
if (fs.existsSync(pwsh)) {
|
||||
return { shell: pwsh, args: [] };
|
||||
}
|
||||
return { shell: "cmd.exe", args: [] };
|
||||
}
|
||||
|
||||
case "darwin": {
|
||||
// macOS: prefer user's shell, then zsh, then bash
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ["--login"] };
|
||||
}
|
||||
if (fs.existsSync("/bin/zsh")) {
|
||||
return { shell: "/bin/zsh", args: ["--login"] };
|
||||
}
|
||||
return { shell: "/bin/bash", args: ["--login"] };
|
||||
}
|
||||
|
||||
case "linux":
|
||||
default: {
|
||||
// Linux: prefer user's shell, then bash, then sh
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ["--login"] };
|
||||
}
|
||||
if (fs.existsSync("/bin/bash")) {
|
||||
return { shell: "/bin/bash", args: ["--login"] };
|
||||
}
|
||||
return { shell: "/bin/sh", args: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if running inside WSL (Windows Subsystem for Linux)
|
||||
*/
|
||||
isWSL(): boolean {
|
||||
try {
|
||||
// Check /proc/version for Microsoft/WSL indicators
|
||||
if (fs.existsSync("/proc/version")) {
|
||||
const version = fs.readFileSync("/proc/version", "utf-8").toLowerCase();
|
||||
return version.includes("microsoft") || version.includes("wsl");
|
||||
}
|
||||
// Check for WSL environment variable
|
||||
if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform info for the client
|
||||
*/
|
||||
getPlatformInfo(): {
|
||||
platform: string;
|
||||
isWSL: boolean;
|
||||
defaultShell: string;
|
||||
arch: string;
|
||||
} {
|
||||
const { shell } = this.detectShell();
|
||||
return {
|
||||
platform: os.platform(),
|
||||
isWSL: this.isWSL(),
|
||||
defaultShell: shell,
|
||||
arch: os.arch(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and resolve a working directory path
|
||||
*/
|
||||
private resolveWorkingDirectory(requestedCwd?: string): string {
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// If no cwd requested, use home
|
||||
if (!requestedCwd) {
|
||||
return homeDir;
|
||||
}
|
||||
|
||||
// Clean up the path
|
||||
let cwd = requestedCwd.trim();
|
||||
|
||||
// Fix double slashes at start (but not for Windows UNC paths)
|
||||
if (cwd.startsWith("//") && !cwd.startsWith("//wsl")) {
|
||||
cwd = cwd.slice(1);
|
||||
}
|
||||
|
||||
// Check if path exists and is a directory
|
||||
try {
|
||||
const stat = fs.statSync(cwd);
|
||||
if (stat.isDirectory()) {
|
||||
return cwd;
|
||||
}
|
||||
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
|
||||
return homeDir;
|
||||
} catch {
|
||||
console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`);
|
||||
return homeDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new terminal session
|
||||
*/
|
||||
createSession(options: TerminalOptions = {}): TerminalSession {
|
||||
const id = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const { shell: detectedShell, args: shellArgs } = this.detectShell();
|
||||
const shell = options.shell || detectedShell;
|
||||
|
||||
// Validate and resolve working directory
|
||||
const cwd = this.resolveWorkingDirectory(options.cwd);
|
||||
|
||||
// Build environment with some useful defaults
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
COLORTERM: "truecolor",
|
||||
TERM_PROGRAM: "automaker-terminal",
|
||||
...options.env,
|
||||
};
|
||||
|
||||
console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
|
||||
const ptyProcess = pty.spawn(shell, shellArgs, {
|
||||
name: "xterm-256color",
|
||||
cols: options.cols || 80,
|
||||
rows: options.rows || 24,
|
||||
cwd,
|
||||
env,
|
||||
});
|
||||
|
||||
const session: TerminalSession = {
|
||||
id,
|
||||
pty: ptyProcess,
|
||||
cwd,
|
||||
createdAt: new Date(),
|
||||
shell,
|
||||
scrollbackBuffer: "",
|
||||
outputBuffer: "",
|
||||
flushTimeout: null,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
|
||||
// Flush buffered output to clients (throttled)
|
||||
const flushOutput = () => {
|
||||
if (session.outputBuffer.length === 0) return;
|
||||
|
||||
// Send in batches if buffer is large
|
||||
let dataToSend = session.outputBuffer;
|
||||
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
|
||||
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
|
||||
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE);
|
||||
// Schedule another flush for remaining data
|
||||
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
|
||||
} else {
|
||||
session.outputBuffer = "";
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
|
||||
this.dataCallbacks.forEach((cb) => cb(id, dataToSend));
|
||||
this.emit("data", id, dataToSend);
|
||||
};
|
||||
|
||||
// Forward data events with throttling
|
||||
ptyProcess.onData((data) => {
|
||||
// Append to scrollback buffer
|
||||
session.scrollbackBuffer += data;
|
||||
// Trim if too large (keep the most recent data)
|
||||
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
|
||||
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
|
||||
}
|
||||
|
||||
// Buffer output for throttled delivery
|
||||
session.outputBuffer += data;
|
||||
|
||||
// Schedule flush if not already scheduled
|
||||
if (!session.flushTimeout) {
|
||||
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle exit
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
|
||||
this.sessions.delete(id);
|
||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
|
||||
this.emit("exit", id, exitCode);
|
||||
});
|
||||
|
||||
console.log(`[Terminal] Session ${id} created successfully`);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write data to a terminal session
|
||||
*/
|
||||
write(sessionId: string, data: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.warn(`[Terminal] Session ${sessionId} not found`);
|
||||
return false;
|
||||
}
|
||||
session.pty.write(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a terminal session
|
||||
*/
|
||||
resize(sessionId: string, cols: number, rows: number): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.warn(`[Terminal] Session ${sessionId} not found for resize`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
session.pty.resize(cols, rows);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a terminal session
|
||||
*/
|
||||
killSession(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// Clean up flush timeout
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
session.pty.kill();
|
||||
this.sessions.delete(sessionId);
|
||||
console.log(`[Terminal] Session ${sessionId} killed`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session by ID
|
||||
*/
|
||||
getSession(sessionId: string): TerminalSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scrollback buffer for a session (for replay on reconnect)
|
||||
*/
|
||||
getScrollback(sessionId: string): string | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return session?.scrollbackBuffer || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions
|
||||
*/
|
||||
getAllSessions(): Array<{
|
||||
id: string;
|
||||
cwd: string;
|
||||
createdAt: Date;
|
||||
shell: string;
|
||||
}> {
|
||||
return Array.from(this.sessions.values()).map((s) => ({
|
||||
id: s.id,
|
||||
cwd: s.cwd,
|
||||
createdAt: s.createdAt,
|
||||
shell: s.shell,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to data events
|
||||
*/
|
||||
onData(callback: DataCallback): () => void {
|
||||
this.dataCallbacks.add(callback);
|
||||
return () => this.dataCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to exit events
|
||||
*/
|
||||
onExit(callback: ExitCallback): () => void {
|
||||
this.exitCallbacks.add(callback);
|
||||
return () => this.exitCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all sessions
|
||||
*/
|
||||
cleanup(): void {
|
||||
console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`);
|
||||
this.sessions.forEach((session, id) => {
|
||||
try {
|
||||
// Clean up flush timeout
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
}
|
||||
session.pty.kill();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
this.sessions.delete(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let terminalService: TerminalService | null = null;
|
||||
|
||||
export function getTerminalService(): TerminalService {
|
||||
if (!terminalService) {
|
||||
terminalService = new TerminalService();
|
||||
}
|
||||
return terminalService;
|
||||
}
|
||||
Reference in New Issue
Block a user