import http from 'node:http'; import { existsSync } from 'node:fs'; import { cp, mkdir } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { WebSocketServer } from 'ws'; import * as pty from 'node-pty'; const __dirname = dirname(fileURLToPath(import.meta.url)); const isDocker = existsSync('/opt/terminal-templates/.zshrc'); const templateDir = isDocker ? '/opt/terminal-templates' : join(__dirname, 'templates'); const ohMyZshSource = isDocker ? '/opt/oh-my-zsh' : null; const port = Number(process.env.TERMINAL_PTY_PORT ?? '5337'); const host = process.env.TERMINAL_PTY_HOST ?? '127.0.0.1'; const BUFFER_MAX = 50 * 1024; /** @type {Map} */ const sessions = new Map(); const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('terminal-sidecar'); }); const wss = new WebSocketServer({ server }); const sendJson = (ws, msg) => { try { ws.send(JSON.stringify(msg)); } catch { // ignore } }; const ensureUserFiles = async (homeDir) => { await mkdir(homeDir, { recursive: true }); await mkdir(join(homeDir, '.config'), { recursive: true }); await mkdir(join(homeDir, '.local', 'bin'), { recursive: true }); const zshrcPath = join(homeDir, '.zshrc'); if (!existsSync(zshrcPath)) { await cp(join(templateDir, '.zshrc'), zshrcPath); } const tmuxconfPath = join(homeDir, '.tmux.conf'); if (!existsSync(tmuxconfPath)) { await cp(join(templateDir, '.tmux.conf'), tmuxconfPath); } const starshipPath = join(homeDir, '.config', 'starship-officer.toml'); if (!existsSync(starshipPath)) { await cp(join(templateDir, 'starship-officer.toml'), starshipPath); } const ohMyZshPath = join(homeDir, '.oh-my-zsh'); if (!existsSync(ohMyZshPath)) { if (ohMyZshSource && existsSync(ohMyZshSource)) { await cp(ohMyZshSource, ohMyZshPath, { recursive: true }); } else { const proc = Bun.spawn({ cmd: ['git', 'clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath], stdout: 'ignore', stderr: 'ignore', }); await proc.exited; } } if (!isDocker) { const starshipBin = join(homeDir, '.local', 'bin', 'starship'); if (!existsSync(starshipBin)) { const installProc = Bun.spawn({ cmd: ['sh', '-c', 'curl -sS https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"'], env: { ...process.env, HOME: homeDir }, stdout: 'ignore', stderr: 'ignore', }); await installProc.exited; } } }; const appendBuffer = (session, data) => { session.buffer += data; if (session.buffer.length > BUFFER_MAX) { session.buffer = session.buffer.slice(-BUFFER_MAX); } }; wss.on('connection', (ws) => { let currentSessionId = null; ws.on('message', async (data) => { let msg; try { msg = JSON.parse(typeof data === 'string' ? data : data.toString()); } catch { return; } if (msg.type === 'init') { const sessionId = msg.sessionId; if (!sessionId) return; currentSessionId = sessionId; const existing = sessions.get(sessionId); console.log(`[sidecar] init sessionId=${sessionId} existing=${!!existing} total=${sessions.size}`); if (existing) { // Evict old WS if still attached if (existing.ws && existing.ws !== ws) { sendJson(existing.ws, { type: 'detached' }); try { existing.ws.close(); } catch { // ignore } } existing.ws = ws; // Replay buffer if (existing.buffer.length > 0) { sendJson(ws, { type: 'output', data: existing.buffer }); } // Resize PTY to new client dimensions const cols = msg.cols ?? existing.cols; const rows = msg.rows ?? existing.rows; if (cols > 0 && rows > 0 && (cols !== existing.cols || rows !== existing.rows)) { existing.cols = cols; existing.rows = rows; try { existing.term.resize(cols, rows); } catch { // ignore } } return; } // New session — spawn PTY const shell = msg.shell ?? { command: '/bin/bash', args: ['-i'] }; const cwd = msg.cwd ?? process.cwd(); const homeDir = msg.homeDir ?? process.cwd(); const userLabel = msg.userLabel ?? 'officer'; const prompt = `${userLabel} in %~ %# `; const bashPrompt = `${userLabel} \\w \\$ `; const cols = msg.cols ?? 80; const rows = msg.rows ?? 24; try { await ensureUserFiles(homeDir); } catch { // ignore } let term; try { term = pty.spawn(shell.command, shell.args ?? [], { name: 'xterm-256color', cols, rows, cwd, env: { ...process.env, HOME: homeDir, ZDOTDIR: homeDir, ZSH: `${homeDir}/.oh-my-zsh`, SHELL: shell.command, USER: userLabel, LOGNAME: userLabel, OFFICER_TERMINAL_USER: userLabel, PROMPT: prompt, PS1: bashPrompt, TERM: 'xterm-256color', }, }); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to start terminal'; sendJson(ws, { type: 'output', data: `\r\n[Terminal error] ${message}\r\n` }); sendJson(ws, { type: 'exit' }); return; } const session = { term, buffer: '', cols, rows, ws, initConfig: { shell, cwd, homeDir, userLabel }, }; sessions.set(sessionId, session); term.onData((output) => { appendBuffer(session, output); if (session.ws) { sendJson(session.ws, { type: 'output', data: output }); } }); term.onExit(() => { if (session.ws) { sendJson(session.ws, { type: 'exit' }); } sessions.delete(sessionId); }); return; } // Route other messages to current session if (!currentSessionId) return; const session = sessions.get(currentSessionId); if (!session) return; switch (msg.type) { case 'input': session.term.write(msg.data ?? ''); break; case 'resize': if (msg.cols > 0 && msg.rows > 0) { session.cols = msg.cols; session.rows = msg.rows; session.term.resize(msg.cols, msg.rows); } break; case 'cwd': if (msg.path) session.term.write(`cd ${JSON.stringify(msg.path)}\r`); break; } }); ws.on('close', () => { // Detach WS from session — do NOT kill PTY if (currentSessionId) { const session = sessions.get(currentSessionId); if (session && session.ws === ws) { session.ws = null; } } }); }); server.listen(port, host, () => { console.log(`[terminal-sidecar] listening on ${host}:${port}`); });