extracted terminal to a widget
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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 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 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 {
|
||||
// Clone oh-my-zsh on first run (host mode)
|
||||
const proc = Bun.spawn({
|
||||
cmd: ['git', 'clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath],
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
await proc.exited;
|
||||
}
|
||||
}
|
||||
|
||||
// Install starship on host if missing (not needed in Docker)
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
let term = null;
|
||||
let initialized = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (term) {
|
||||
try {
|
||||
term.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
term = null;
|
||||
};
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(typeof data === 'string' ? data : data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'init' && !initialized) {
|
||||
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 \$ `;
|
||||
|
||||
try {
|
||||
await ensureUserFiles(homeDir);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
term = pty.spawn(shell.command, shell.args ?? [], {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
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;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
|
||||
term.onData((output) => {
|
||||
sendJson(ws, { type: 'output', data: output });
|
||||
});
|
||||
|
||||
term.onExit(() => {
|
||||
sendJson(ws, { type: 'exit' });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!term) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'input':
|
||||
term.write(msg.data ?? '');
|
||||
break;
|
||||
case 'resize':
|
||||
if (msg.cols > 0 && msg.rows > 0) term.resize(msg.cols, msg.rows);
|
||||
break;
|
||||
case 'cwd':
|
||||
if (msg.path) term.write(`cd ${JSON.stringify(msg.path)}\r`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`[terminal-sidecar] listening on ${host}:${port}`);
|
||||
});
|
||||
Reference in New Issue
Block a user