Files
platform/src/servers/api/terminal/pty-sidecar.mjs
T
2026-02-19 18:01:08 +00:00

254 lines
7.1 KiB
JavaScript

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<string, { term: import('node-pty').IPty, buffer: string, cols: number, rows: number, ws: import('ws').WebSocket | null, initConfig: object }>} */
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}`);
});