From fcf0c9f983c21f262597de623a0c7008afa19e96 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Thu, 26 Feb 2026 19:52:43 -0500 Subject: [PATCH] feat: add HostConsole component for interactive terminal access and integrate into EditorLayout --- .dockerignore | 8 + Dockerfile | 7 +- backend/package-lock.json | 17 ++ backend/package.json | 1 + backend/src/index.ts | 26 +++ backend/src/services/HostTerminalService.ts | 59 +++++++ frontend/src/components/EditorLayout.tsx | 29 ++-- frontend/src/components/HostConsole.tsx | 177 ++++++++++++++++++++ 8 files changed, 309 insertions(+), 15 deletions(-) create mode 100644 .dockerignore create mode 100644 backend/src/services/HostTerminalService.ts create mode 100644 frontend/src/components/HostConsole.tsx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..750540e6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +**/node_modules +**/dist +.git +.gitignore +plans/ +Product Requirements Document +*.md +.DS_Store diff --git a/Dockerfile b/Dockerfile index 9c18d0b8..f55ce223 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,9 @@ FROM node:20-alpine AS backend-builder WORKDIR /app/backend +# Install build dependencies for node-pty native modules +RUN apk add --no-cache python3 make g++ + # Copy backend package files COPY backend/package*.json ./ @@ -35,8 +38,8 @@ RUN npm run build # Stage 3: Production FROM node:20-alpine -# Install Docker CLI and Docker Compose CLI -RUN apk add --no-cache docker-cli docker-cli-compose +# Install Docker CLI, Docker Compose CLI, and Bash for Host Console +RUN apk add --no-cache docker-cli docker-cli-compose bash WORKDIR /app diff --git a/backend/package-lock.json b/backend/package-lock.json index ce4d1ad5..e8d0b29e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "dockerode": "^4.0.9", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", + "node-pty": "^1.1.0", "systeminformation": "^5.31.1", "ws": "^8.19.0", "yaml": "^2.8.2" @@ -1813,6 +1814,22 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/nodemon": { "version": "3.1.13", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.13.tgz", diff --git a/backend/package.json b/backend/package.json index f078bf81..494b22bd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,6 +35,7 @@ "dockerode": "^4.0.9", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", + "node-pty": "^1.1.0", "systeminformation": "^5.31.1", "ws": "^8.19.0", "yaml": "^2.8.2" diff --git a/backend/src/index.ts b/backend/src/index.ts index 2500e066..26c02a68 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -13,6 +13,8 @@ import si from 'systeminformation'; import http from 'http'; import { spawn, exec } from 'child_process'; import { promisify } from 'util'; +import path from 'path'; +import { HostTerminalService } from './services/HostTerminalService'; const execAsync = promisify(exec); @@ -220,6 +222,7 @@ server.on('upgrade', async (req, socket, head) => { // Check if this is a stack logs WebSocket request const url = req.url || ''; const logsMatch = url.match(/^\/api\/stacks\/([^/]+)\/logs$/); + const hostConsoleMatch = url.match(/^\/api\/system\/host-console/); if (logsMatch) { // Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs @@ -235,6 +238,29 @@ server.on('upgrade', async (req, socket, head) => { } } }); + } else if (hostConsoleMatch) { + const hostConsoleWss = new WebSocket.Server({ noServer: true }); + hostConsoleWss.handleUpgrade(req, socket, head, (ws) => { + let targetDirectory = fileSystemService.getBaseDir(); + try { + const reqUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`); + const stackParam = reqUrl.searchParams.get('stack'); + if (stackParam) { + targetDirectory = path.join(targetDirectory, stackParam); + } + } catch (e) { + // ignore parsing error, fallback to base dir + } + try { + HostTerminalService.spawnTerminal(ws, targetDirectory); + } catch (error) { + console.error('Failed to spawn host terminal:', error); + if (ws.readyState === WebSocket.OPEN) { + ws.send(`Error spawning terminal: ${(error as Error).message}\r\n`); + ws.close(); + } + } + }); } else { // Generic terminal WebSocket wss.handleUpgrade(req, socket, head, (ws) => { diff --git a/backend/src/services/HostTerminalService.ts b/backend/src/services/HostTerminalService.ts new file mode 100644 index 00000000..ce38cbfe --- /dev/null +++ b/backend/src/services/HostTerminalService.ts @@ -0,0 +1,59 @@ +import * as os from 'os'; +import * as pty from 'node-pty'; +import { WebSocket } from 'ws'; +import { execSync } from 'child_process'; + +function getUnixShell() { + try { + execSync('which bash', { stdio: 'ignore' }); + return 'bash'; + } catch { + return 'sh'; + } +} + +export class HostTerminalService { + static spawnTerminal(ws: WebSocket, targetDirectory: string) { + const shell = os.platform() === 'win32' ? 'powershell.exe' : getUnixShell(); + + const ptyProcess = pty.spawn(shell, [], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: targetDirectory, + env: process.env as Record, + }); + + ptyProcess.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data); // Raw-Down protocol + } + }); + + ws.on('message', (message: string) => { + try { + const parsed = JSON.parse(message); // JSON-Up protocol + if (parsed.type === 'input') { + ptyProcess.write(parsed.payload); + } else if (parsed.type === 'resize') { + ptyProcess.resize(parsed.cols, parsed.rows); + } + } catch (e) { + console.error('Failed to parse Host terminal message:', e); + } + }); + + ws.on('close', () => { + console.log('Host terminal WebSocket closed, cleaning up PTY process'); + ptyProcess.kill(); + }); + + // Handle PTY process exit + ptyProcess.onExit(({ exitCode, signal }) => { + console.log(`Host terminal PTY process exited with code ${exitCode} and signal ${signal}`); + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + }); + } +} diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 1febc80b..509c751c 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -4,6 +4,7 @@ import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; import HomeDashboard from './HomeDashboard'; import BashExecModal from './BashExecModal'; +import HostConsole from './HostConsole'; import MaintenanceModal from './MaintenanceModal'; import { Button } from './ui/button'; import { Input } from './ui/input'; @@ -69,7 +70,7 @@ export default function EditorLayout() { } return true; // Default to dark mode }); - const [showConsole, setShowConsole] = useState(true); + const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console'>('dashboard'); const [isEditing, setIsEditing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [stackStatuses, setStackStatuses] = useState({}); @@ -200,6 +201,7 @@ export default function EditorLayout() { const res = await apiFetch(`/stacks/${filename}`); const text = await res.text(); setSelectedFile(filename); + setActiveView('editor'); setContent(text || ''); setOriginalContent(text || ''); @@ -610,6 +612,7 @@ export default function EditorLayout() { setEnvExists(false); setContainers([]); setIsEditing(false); + setActiveView('dashboard'); }} title="Go to Home Dashboard" > @@ -618,10 +621,10 @@ export default function EditorLayout() { {/* Console Toggle */} + +
+
+
+
+ ); +}