mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge branch 'main' into feat/add-unit-testing
This commit is contained in:
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