diff --git a/apps/booterm/Dockerfile b/apps/booterm/Dockerfile new file mode 100644 index 0000000..3920b27 --- /dev/null +++ b/apps/booterm/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1.7 + +# ---- Build stage: compile TypeScript ---- +FROM node:20-alpine AS builder +ENV COREPACK_DEFAULT_TO_LATEST=0 +RUN corepack enable && corepack prepare pnpm@10.15.1 --activate +RUN apk add --no-cache python3 make g++ +WORKDIR /build +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./ +COPY apps/server/package.json ./apps/server/ +COPY apps/web/package.json ./apps/web/ +COPY apps/booterm/package.json ./apps/booterm/ +RUN pnpm install --frozen-lockfile +COPY apps/booterm ./apps/booterm +RUN pnpm --filter=@boocode/booterm build + +# ---- Prod-deps stage: hoisted, native built via npm rebuild ---- +FROM node:20-alpine AS proddeps +ENV COREPACK_DEFAULT_TO_LATEST=0 +RUN corepack enable && corepack prepare pnpm@10.15.1 --activate +RUN apk add --no-cache python3 make g++ +WORKDIR /prod +COPY apps/booterm/package.json ./package.json +RUN pnpm install --prod --config.node-linker=hoisted --config.strict-peer-dependencies=false +# pnpm 10 ignores build scripts; force compile with npm directly. +# node-gyp is bundled with npm in the node:20-alpine image. +RUN cd node_modules/node-pty && npm run install +# Sanity check — fail the build if the artifact still isn't there +RUN test -f node_modules/node-pty/build/Release/pty.node && echo "pty.node OK" || (echo "pty.node MISSING" && exit 1) + +# ---- Runtime ---- +FROM node:20-alpine AS runtime +RUN apk add --no-cache tmux libstdc++ +WORKDIR /app +COPY --from=builder /build/apps/booterm/dist ./dist +COPY --from=proddeps /prod/package.json ./package.json +COPY --from=proddeps /prod/node_modules ./node_modules +COPY apps/booterm/tmux.conf /etc/booterm/tmux.conf +ENV NODE_ENV=production +EXPOSE 3000 +CMD ["node", "dist/index.js"] diff --git a/apps/booterm/package.json b/apps/booterm/package.json new file mode 100644 index 0000000..ac66600 --- /dev/null +++ b/apps/booterm/package.json @@ -0,0 +1,27 @@ +{ + "name": "@boocode/booterm", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "typecheck": "tsc --noEmit", + "start": "node dist/index.js" + }, + "dependencies": { + "@fastify/websocket": "^10.0.1", + "fastify": "^4.28.1", + "node-pty": "^1.0.0", + "pg": "^8.13.0", + "tslib": "^2.6.3", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.14.10", + "@types/pg": "^8.11.10", + "tsx": "^4.16.2", + "typescript": "^5.5.0" + } +} diff --git a/apps/booterm/src/auth.ts b/apps/booterm/src/auth.ts new file mode 100644 index 0000000..d0a2fdb --- /dev/null +++ b/apps/booterm/src/auth.ts @@ -0,0 +1,11 @@ +import type { FastifyRequest } from 'fastify'; + +// Mirrors the boocode pattern: there is no app-layer auth — Authelia handles +// it at the reverse proxy (CLAUDE.md). All broker.publishUser calls use +// 'default' as the user key. We accept Remote-User when present (set by the +// proxy in prod) and fall back to 'default' on direct Tailscale access. +export function getUser(req: FastifyRequest): string { + const header = req.headers['remote-user']; + if (typeof header === 'string' && header.length > 0) return header; + return 'default'; +} diff --git a/apps/booterm/src/config.ts b/apps/booterm/src/config.ts new file mode 100644 index 0000000..55bcefc --- /dev/null +++ b/apps/booterm/src/config.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +const ConfigSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().int().positive().default(3000), + HOST: z.string().default('0.0.0.0'), + DATABASE_URL: z.string().url(), + LOG_LEVEL: z.string().default('info'), + TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'), +}); + +export type Config = z.infer; + +let cached: Config | null = null; + +export function loadConfig(): Config { + if (cached) return cached; + const parsed = ConfigSchema.safeParse(process.env); + if (!parsed.success) { + console.error('Invalid environment configuration:'); + console.error(parsed.error.flatten().fieldErrors); + process.exit(1); + } + cached = parsed.data; + return cached; +} diff --git a/apps/booterm/src/db.ts b/apps/booterm/src/db.ts new file mode 100644 index 0000000..d522acc --- /dev/null +++ b/apps/booterm/src/db.ts @@ -0,0 +1,46 @@ +import pg from 'pg'; + +const { Pool } = pg; + +let pool: pg.Pool | null = null; + +export function getPool(databaseUrl: string): pg.Pool { + if (pool) return pool; + pool = new Pool({ connectionString: databaseUrl, max: 5, idleTimeoutMillis: 30_000 }); + return pool; +} + +export interface SessionInfo { + id: string; + project_id: string; + project_path: string; +} + +export async function getSessionInfo(sessionId: string): Promise { + if (!pool) throw new Error('db pool not initialized'); + const res = await pool.query( + `SELECT s.id, s.project_id, p.path AS project_path + FROM sessions s + JOIN projects p ON p.id = s.project_id + WHERE s.id = $1`, + [sessionId], + ); + return res.rows[0] ?? null; +} + +export async function pingDb(): Promise { + if (!pool) return false; + try { + await pool.query('SELECT 1'); + return true; + } catch { + return false; + } +} + +export async function closeDb(): Promise { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/apps/booterm/src/index.ts b/apps/booterm/src/index.ts new file mode 100644 index 0000000..55b075a --- /dev/null +++ b/apps/booterm/src/index.ts @@ -0,0 +1,60 @@ +import Fastify from 'fastify'; +import fastifyWebsocket from '@fastify/websocket'; +import { loadConfig } from './config.js'; +import { getPool, closeDb } from './db.js'; +import { registerHealthRoutes } from './routes/health.js'; +import { registerTerminalRoutes } from './routes/terminals.js'; +import { registerWsAttachRoute } from './ws/attach.js'; + +async function main(): Promise { + const config = loadConfig(); + + const app = Fastify({ + logger: { level: config.LOG_LEVEL }, + }); + + app.removeContentTypeParser(['application/json']); + app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { + const str = (body as string) ?? ''; + if (str.trim().length === 0) { + done(null, {}); + return; + } + try { + done(null, JSON.parse(str)); + } catch (err) { + done(err as Error, undefined); + } + }); + + getPool(config.DATABASE_URL); + + await app.register(fastifyWebsocket); + + registerHealthRoutes(app); + registerTerminalRoutes(app, config.TMUX_CONF_PATH); + registerWsAttachRoute(app, config.TMUX_CONF_PATH); + + const shutdown = async (signal: string) => { + app.log.info(`received ${signal}, shutting down`); + try { + await app.close(); + await closeDb(); + process.exit(0); + } catch (err) { + app.log.error(err); + process.exit(1); + } + }; + + process.on('SIGINT', () => void shutdown('SIGINT')); + process.on('SIGTERM', () => void shutdown('SIGTERM')); + + await app.listen({ port: config.PORT, host: config.HOST }); + app.log.info(`booterm listening on http://${config.HOST}:${config.PORT}`); +} + +main().catch((err) => { + console.error('Fatal startup error:', err); + process.exit(1); +}); diff --git a/apps/booterm/src/pty/manager.ts b/apps/booterm/src/pty/manager.ts new file mode 100644 index 0000000..e164b5c --- /dev/null +++ b/apps/booterm/src/pty/manager.ts @@ -0,0 +1,102 @@ +import { spawn } from 'node:child_process'; +import type { FastifyBaseLogger } from 'fastify'; + +// UUIDs already match [0-9a-f-]; allow uppercase and longer just in case. +const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/; + +export function sanitizeId(raw: string): string | null { + if (!ID_RE.test(raw)) return null; + return raw.toLowerCase(); +} + +export function tmuxSessionName(sessionId: string): string { + return `bc-${sessionId}`; +} + +export function tmuxWindowName(paneId: string): string { + return `term-${paneId}`; +} + +interface CmdResult { + stdout: string; + stderr: string; + code: number; +} + +// Wrap child_process.spawn with shell:false so each argv element is passed +// as a separate argument — no shell interpolation, no injection surface. +function runTmux(tmuxConfPath: string, args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn('tmux', ['-f', tmuxConfPath, ...args], { shell: false }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); }); + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); + child.on('error', (err) => { + resolve({ stdout, stderr: stderr + String(err), code: 1 }); + }); + child.on('close', (code) => { + resolve({ stdout, stderr, code: code ?? 0 }); + }); + }); +} + +export async function hasSession(tmuxConfPath: string, sessionName: string): Promise { + const res = await runTmux(tmuxConfPath, ['has-session', '-t', `=${sessionName}`]); + return res.code === 0; +} + +export async function listWindows(tmuxConfPath: string, sessionName: string): Promise { + const res = await runTmux(tmuxConfPath, ['list-windows', '-t', sessionName, '-F', '#{window_name}']); + if (res.code !== 0) return []; + return res.stdout.trim().split('\n').filter(Boolean); +} + +export async function killWindow( + tmuxConfPath: string, + sessionName: string, + windowName: string, +): Promise { + const res = await runTmux(tmuxConfPath, ['kill-window', '-t', `${sessionName}:${windowName}`]); + return res.code === 0; +} + +// Idempotent. Creates the tmux session if it doesn't exist, then ensures the +// named window is present. The session's initial window is created with the +// target name (via `-n`) so we don't need a separate rename step. +export async function ensureWindow( + tmuxConfPath: string, + sessionName: string, + windowName: string, + projectRoot: string, + log: FastifyBaseLogger, +): Promise { + if (!(await hasSession(tmuxConfPath, sessionName))) { + log.info({ sessionName, windowName, projectRoot }, 'creating tmux session'); + const res = await runTmux(tmuxConfPath, [ + 'new-session', '-d', + '-s', sessionName, + '-n', windowName, + '-c', projectRoot, + ]); + if (res.code !== 0) { + log.error({ res }, 'tmux new-session failed'); + throw new Error(`tmux new-session failed: ${res.stderr}`); + } + return; + } + + const windows = await listWindows(tmuxConfPath, sessionName); + if (windows.includes(windowName)) return; + + const res = await runTmux(tmuxConfPath, [ + 'new-window', + '-t', sessionName, + '-n', windowName, + '-c', projectRoot, + ]); + if (res.code !== 0) { + log.error({ res }, 'tmux new-window failed'); + throw new Error(`tmux new-window failed: ${res.stderr}`); + } +} diff --git a/apps/booterm/src/pty/pty.ts b/apps/booterm/src/pty/pty.ts new file mode 100644 index 0000000..86afab8 --- /dev/null +++ b/apps/booterm/src/pty/pty.ts @@ -0,0 +1,41 @@ +import * as pty from 'node-pty'; +import type { IPty } from 'node-pty'; + +export interface AttachPtyOptions { + sessionName: string; + windowName: string; + projectRoot: string; + cols: number; + rows: number; + tmuxConfPath: string; +} + +function cleanEnv(): { [key: string]: string } { + const out: { [key: string]: string } = {}; + for (const [k, v] of Object.entries(process.env)) { + if (typeof v === 'string') out[k] = v; + } + out['TERM'] = 'screen-256color'; + return out; +} + +// Spawns a tmux client attached to the given session+window. `-d` detaches any +// other client so a browser refresh takes over the same window without +// duplicate input. tmux server (and the window) persists across PTY exits. +export function attachPty(opts: AttachPtyOptions): IPty { + return pty.spawn( + 'tmux', + [ + '-f', opts.tmuxConfPath, + 'attach-session', '-d', + '-t', `${opts.sessionName}:${opts.windowName}`, + ], + { + name: 'xterm-256color', + cols: opts.cols, + rows: opts.rows, + cwd: opts.projectRoot, + env: cleanEnv(), + }, + ); +} diff --git a/apps/booterm/src/routes/health.ts b/apps/booterm/src/routes/health.ts new file mode 100644 index 0000000..07564d1 --- /dev/null +++ b/apps/booterm/src/routes/health.ts @@ -0,0 +1,9 @@ +import type { FastifyInstance } from 'fastify'; +import { pingDb } from '../db.js'; + +export function registerHealthRoutes(app: FastifyInstance): void { + app.get('/api/term/health', async () => { + const dbOk = await pingDb(); + return { ok: true, db: dbOk }; + }); +} diff --git a/apps/booterm/src/routes/terminals.ts b/apps/booterm/src/routes/terminals.ts new file mode 100644 index 0000000..84bca41 --- /dev/null +++ b/apps/booterm/src/routes/terminals.ts @@ -0,0 +1,88 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { getSessionInfo } from '../db.js'; +import { + sanitizeId, + tmuxSessionName, + tmuxWindowName, + ensureWindow, + killWindow, + hasSession, + listWindows, +} from '../pty/manager.js'; +import { resizePane } from '../ws/attach.js'; + +const ParamsSchema = z.object({ sid: z.string(), pid: z.string() }); +const ResizeBodySchema = z.object({ + cols: z.coerce.number().int().min(1).max(2000), + rows: z.coerce.number().int().min(1).max(2000), +}); + +export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: string): void { + app.post<{ Params: { sid: string; pid: string } }>( + '/api/term/sessions/:sid/panes/:pid/start', + async (req, reply) => { + const p = ParamsSchema.safeParse(req.params); + if (!p.success) return reply.code(400).send({ error: 'bad_params' }); + const sid = sanitizeId(p.data.sid); + const pid = sanitizeId(p.data.pid); + if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' }); + + const session = await getSessionInfo(sid); + if (!session) return reply.code(404).send({ error: 'unknown_session' }); + + const sessionName = tmuxSessionName(sid); + const windowName = tmuxWindowName(pid); + + try { + await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log); + } catch (err) { + req.log.error({ err }, 'ensureWindow failed'); + return reply.code(500).send({ error: 'tmux_failed' }); + } + return reply.code(200).send({ tmux_window: windowName }); + }, + ); + + app.post<{ Params: { sid: string; pid: string }; Body: { cols: number; rows: number } }>( + '/api/term/sessions/:sid/panes/:pid/resize', + async (req, reply) => { + const p = ParamsSchema.safeParse(req.params); + if (!p.success) return reply.code(400).send({ error: 'bad_params' }); + const b = ResizeBodySchema.safeParse(req.body); + if (!b.success) return reply.code(400).send({ error: 'bad_body' }); + const sid = sanitizeId(p.data.sid); + const pid = sanitizeId(p.data.pid); + if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' }); + + const ok = resizePane(pid, b.data.cols, b.data.rows); + if (!ok) return reply.code(404).send({ error: 'no_active_pty' }); + return reply.code(200).send({ ok: true }); + }, + ); + + app.post<{ Params: { sid: string; pid: string } }>( + '/api/term/sessions/:sid/panes/:pid/kill', + async (req, reply) => { + const p = ParamsSchema.safeParse(req.params); + if (!p.success) return reply.code(400).send({ error: 'bad_params' }); + const sid = sanitizeId(p.data.sid); + const pid = sanitizeId(p.data.pid); + if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' }); + + const sessionName = tmuxSessionName(sid); + const windowName = tmuxWindowName(pid); + + if (!(await hasSession(tmuxConfPath, sessionName))) { + return reply.code(404).send({ error: 'unknown_session' }); + } + const windows = await listWindows(tmuxConfPath, sessionName); + if (!windows.includes(windowName)) { + return reply.code(404).send({ error: 'unknown_pane' }); + } + const killed = await killWindow(tmuxConfPath, sessionName, windowName); + if (!killed) return reply.code(500).send({ error: 'tmux_kill_failed' }); + return reply.code(200).send({ ok: true }); + }, + ); +} diff --git a/apps/booterm/src/ws/attach.ts b/apps/booterm/src/ws/attach.ts new file mode 100644 index 0000000..3815ba9 --- /dev/null +++ b/apps/booterm/src/ws/attach.ts @@ -0,0 +1,128 @@ +import type { FastifyInstance } from 'fastify'; +import type { IPty } from 'node-pty'; +import { getSessionInfo } from '../db.js'; +import { sanitizeId, tmuxSessionName, tmuxWindowName, ensureWindow } from '../pty/manager.js'; +import { attachPty } from '../pty/pty.js'; +import { getUser } from '../auth.js'; + +// Registry of currently-attached PTYs keyed by paneId. Used by the resize REST +// route to find the active node-pty handle so it can call pty.resize(cols, rows). +const active = new Map(); + +export function resizePane(paneId: string, cols: number, rows: number): boolean { + const handle = active.get(paneId); + if (!handle) return false; + try { + handle.resize(cols, rows); + return true; + } catch { + return false; + } +} + +export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void { + app.get<{ + Params: { sid: string; pid: string }; + Querystring: { cols?: string; rows?: string }; + }>( + '/ws/term/sessions/:sid/panes/:pid', + { websocket: true }, + async (socket, req) => { + const sid = sanitizeId(req.params.sid); + const pid = sanitizeId(req.params.pid); + if (!sid || !pid) { + socket.close(1008, 'bad_id_format'); + return; + } + + const user = getUser(req); + req.log.info({ user, sid, pid }, 'ws attach'); + + const session = await getSessionInfo(sid); + if (!session) { + socket.close(1008, 'unknown_session'); + return; + } + + const sessionName = tmuxSessionName(sid); + const windowName = tmuxWindowName(pid); + try { + await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log); + } catch (err) { + req.log.error({ err }, 'ensureWindow failed in WS handler'); + socket.close(1011, 'tmux_failed'); + return; + } + + const cols = parseInt(req.query.cols ?? '', 10) || 80; + const rows = parseInt(req.query.rows ?? '', 10) || 24; + + let handle: IPty; + try { + handle = attachPty({ + sessionName, + windowName, + projectRoot: session.project_path, + cols, + rows, + tmuxConfPath, + }); + } catch (err) { + req.log.error({ err }, 'attachPty failed'); + socket.close(1011, 'pty_spawn_failed'); + return; + } + + active.set(pid, handle); + + const onData = (data: string) => { + if (socket.readyState !== socket.OPEN) return; + try { + socket.send(Buffer.from(data, 'utf8'), { binary: true }); + } catch (err) { + req.log.warn({ err }, 'ws send failed'); + } + }; + handle.onData(onData); + + socket.on('message', (data: Buffer | string) => { + try { + if (typeof data === 'string') { + handle.write(data); + } else { + handle.write(data.toString('utf8')); + } + } catch (err) { + req.log.warn({ err }, 'pty write failed'); + } + }); + + handle.onExit(({ exitCode }) => { + try { + if (socket.readyState === socket.OPEN) { + socket.send(JSON.stringify({ type: 'exit', code: exitCode })); + } + } catch { + /* ignore */ + } + try { + socket.close(1000); + } catch { + /* ignore */ + } + if (active.get(pid) === handle) active.delete(pid); + }); + + // WS close kills the local PTY (the tmux client). The tmux server and + // window persist so a refresh resumes with full scrollback. + socket.on('close', () => { + if (active.get(pid) === handle) active.delete(pid); + try { + handle.kill(); + } catch { + /* ignore */ + } + }); + }, + ); +} diff --git a/apps/booterm/tmux.conf b/apps/booterm/tmux.conf new file mode 100644 index 0000000..fdcfe9f --- /dev/null +++ b/apps/booterm/tmux.conf @@ -0,0 +1,6 @@ +set -g default-terminal "screen-256color" +set -g history-limit 50000 +set -g mouse on +setw -g mode-keys vi +set -g status off +set -g destroy-unattached off diff --git a/apps/booterm/tsconfig.json b/apps/booterm/tsconfig.json new file mode 100644 index 0000000..4ada8a8 --- /dev/null +++ b/apps/booterm/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022"], + "types": ["node"], + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts"] +} diff --git a/apps/server/src/schema.sql b/apps/server/src/schema.sql index ce31329..9be9395 100644 --- a/apps/server/src/schema.sql +++ b/apps/server/src/schema.sql @@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS session_panes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, position INTEGER NOT NULL, - kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser')), + kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser', 'terminal')), state JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), UNIQUE (session_id, position) diff --git a/apps/web/package.json b/apps/web/package.json index 58e5caa..286e886 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,7 +26,10 @@ "shiki": "^1.29.2", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0", + "xterm-addon-web-links": "^0.9.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.0", diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index d887596..4e047fa 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -261,4 +261,26 @@ export const api = { sidebar: { get: () => request('/api/sidebar'), }, + + // v1.10 booterm: REST control plane for terminal panes. WebSocket attach + // lives at /ws/term/sessions/:sid/panes/:pid (handled directly by + // TerminalPane). All three endpoints are tolerant of empty bodies on the + // POSTs that don't take parameters. + terminals: { + start: (sessionId: string, paneId: string) => + request<{ tmux_window: string }>( + `/api/term/sessions/${sessionId}/panes/${paneId}/start`, + { method: 'POST' }, + ), + resize: (sessionId: string, paneId: string, cols: number, rows: number) => + request<{ ok: true }>( + `/api/term/sessions/${sessionId}/panes/${paneId}/resize`, + { method: 'POST', body: JSON.stringify({ cols, rows }) }, + ), + kill: (sessionId: string, paneId: string) => + request<{ ok: true }>( + `/api/term/sessions/${sessionId}/panes/${paneId}/kill`, + { method: 'POST' }, + ), + }, }; diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index 283c26d..4efa392 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { Children, cloneElement, isValidElement, useState } from 'react'; +import { Children, cloneElement, isValidElement, useEffect, useState } from 'react'; import type { ReactElement, ReactNode } from 'react'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -7,9 +7,19 @@ import { toast } from 'sonner'; import type { Chat, ErrorReason, Message } from '@/api/types'; import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; +import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events'; import { CapHitSentinel } from './CapHitSentinel'; import { CodeBlock } from './CodeBlock'; import { Button } from '@/components/ui/button'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; import { Dialog, DialogContent, @@ -19,6 +29,57 @@ import { DialogTitle, } from '@/components/ui/dialog'; +// v1.10 booterm: tiny subscription hook for the mounted-terminals registry. +// Used by the right-click "Send to terminal" submenu so it always reflects +// currently-open terminal panes without prop drilling from Workspace. +function useTerminals(): TerminalRegistration[] { + const [list, setList] = useState(() => terminalsRegistry.list()); + useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []); + return list; +} + +// Wrap a message body with a right-click context menu offering "Send to +// terminal → ". The submenu is disabled when nothing is selected +// or no terminal panes are open; clicking a target emits a sendToTerminal +// event that TerminalPane subscribes to (filtered by pane_id). +function SendToTerminalMenu({ children }: { children: ReactNode }) { + const [selection, setSelection] = useState(''); + const terminals = useTerminals(); + const canSend = selection.length > 0 && terminals.length > 0; + + return ( + { + if (open) { + const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : ''; + setSelection(sel); + } + }} + > + {children} + + + Send to terminal + + {terminals.length === 0 ? ( + No terminal panes open + ) : ( + terminals.map((t) => ( + sendToTerminal.emit({ pane_id: t.paneId, text: selection })} + > + {t.label} + + )) + )} + + + + + ); +} + // v1.8.2: human labels for the machine-readable error reasons that ride on // failed assistant messages via metadata.kind === 'error'. Kept short so the // inline render under "message failed" stays a single muted line. @@ -507,9 +568,11 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) { if (message.role === 'user') { return (
-
- {message.content} -
+ +
+ {message.content} +
+
); @@ -529,12 +592,14 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) { return (
{(hasContent || isStreaming) && ( -
- {hasContent ? : null} - {isStreaming && ( - - )} -
+ +
+ {hasContent ? : null} + {isStreaming && ( + + )} +
+
)} {failed && (
diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index 34fceb8..af8c6fe 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -1,11 +1,12 @@ -import { useEffect, useState } from 'react'; -import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { PanelRight, MessageSquare, Terminal, Bot, X } from 'lucide-react'; import type { Chat, Project, Session, WorkspacePane } from '@/api/types'; import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes'; import type { UseSessionChatsResult } from '@/hooks/useSessionChats'; import { useViewport } from '@/hooks/useViewport'; import { ChatPane } from '@/components/panes/ChatPane'; import { SettingsPane } from '@/components/panes/SettingsPane'; +import { TerminalPane } from '@/components/panes/TerminalPane'; import { ChatTabBar } from '@/components/ChatTabBar'; import { SessionLandingPage } from '@/components/SessionLandingPage'; import { @@ -115,6 +116,20 @@ export function Workspace({ .filter((c): c is Chat => c !== undefined); } + // v1.10 booterm: per-terminal label used by the registry that powers the + // MessageBubble "Send to terminal" submenu. Numbered in workspace order. + const terminalLabels = useMemo(() => { + const out = new Map(); + let n = 0; + for (const p of panes) { + if (p.kind === 'terminal') { + n += 1; + out.set(p.id, `Terminal ${n}`); + } + } + return out; + }, [panes]); + return (
{!isMobile && ( @@ -165,6 +180,7 @@ export function Workspace({ > {panes.map((pane, idx) => { const isSettings = pane.kind === 'settings'; + const isTerminal = pane.kind === 'terminal'; // v1.9: when maximized, hide every pane except the settings one. // display:none keeps the React tree mounted so streams / drafts // survive the toggle without re-mount cost. @@ -176,6 +192,9 @@ export function Workspace({ } return null; } + // Terminal panes own their tab strip (no chats, no ChatTabBar) and + // are not drag-reorderable for now — keeps the layout grid simple. + const isChromeless = isSettings || isTerminal; return (
setActivePaneIdx(idx)} - onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined} - onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined} - onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined} + onDragOver={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragOver(idx) : undefined} + onDragLeave={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragLeave : undefined} + onDrop={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDrop(idx) : undefined} >
1} - onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined} - onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined} + draggable={!isMobile && !isChromeless && panes.length > 1} + onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined} + onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined} > - {/* Hidden on mobile per v1.8; settings panes own their own - section nav / maximize toggle so they skip ChatTabBar - entirely. */} - {!isMobile && !isSettings && ( + {/* Hidden on mobile per v1.8; settings + terminal panes own + their own header (no chats, so no ChatTabBar). */} + {!isMobile && !isChromeless && ( 1 ? () => removePane(idx) : undefined} /> )} + {isTerminal && ( +
+ + + {terminalLabels.get(pane.id) ?? 'Terminal'} + + {panes.length > 1 && ( + + )} +
+ )}
@@ -226,6 +266,12 @@ export function Workspace({ onClose={() => removePane(idx)} isMobile={isMobile} /> + ) : isTerminal ? ( + ) : pane.kind === 'chat' && pane.chatId ? ( (null); + const wsRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let disposed = false; + let resizeDebounceTimer: ReturnType | null = null; + + const term = new Terminal({ + fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace', + fontSize: 13, + lineHeight: 1.2, + cursorBlink: true, + scrollback: 10_000, + theme: XTERM_THEME, + allowProposedApi: true, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.loadAddon(new WebLinksAddon()); + term.open(container); + try { + fit.fit(); + } catch { + /* container not yet sized */ + } + + // POST start kicks the tmux window into existence before the WS upgrade. + // It's idempotent so a refresh just no-ops. Failures fall through to the + // WS handler which will also call ensureWindow. + api.terminals.start(sessionId, paneId).catch(() => { + /* surfaced by WS error if it matters */ + }); + + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const initialCols = term.cols; + const initialRows = term.rows; + const wsUrl = + `${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}` + + `?cols=${initialCols}&rows=${initialRows}`; + const ws = new WebSocket(wsUrl); + ws.binaryType = 'arraybuffer'; + wsRef.current = ws; + + ws.onmessage = (e) => { + if (typeof e.data === 'string') { + // Control frame from server (e.g. {"type":"exit","code":0}). + try { + const parsed = JSON.parse(e.data) as { type?: string; code?: number }; + if (parsed.type === 'exit') { + term.write(`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`); + return; + } + } catch { + /* not JSON — fall through and write as text */ + } + term.write(e.data); + } else { + term.write(new Uint8Array(e.data)); + } + }; + + ws.onclose = () => { + if (disposed) return; + term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n'); + }; + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + }); + + const fireResize = () => { + try { + fit.fit(); + } catch { + return; + } + const cols = term.cols; + const rows = term.rows; + api.terminals.resize(sessionId, paneId, cols, rows).catch(() => { + /* transient — next resize will catch up */ + }); + }; + + const ro = new ResizeObserver(() => { + if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer); + resizeDebounceTimer = setTimeout(fireResize, 100); + }); + ro.observe(container); + + const unregister = terminalsRegistry.register(paneId, label); + + const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => { + if (pane_id !== paneId) return; + if (ws.readyState !== WebSocket.OPEN) return; + const payload = text.endsWith('\n') ? text : `${text}\n`; + ws.send(payload); + }); + + return () => { + disposed = true; + unsubscribe(); + unregister(); + if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer); + ro.disconnect(); + try { + ws.close(); + } catch { + /* ignore */ + } + wsRef.current = null; + term.dispose(); + }; + }, [sessionId, paneId, label]); + + return ( +
+ ); +} diff --git a/apps/web/src/hooks/useWorkspacePanes.ts b/apps/web/src/hooks/useWorkspacePanes.ts index 4bea4ac..9c34273 100644 --- a/apps/web/src/hooks/useWorkspacePanes.ts +++ b/apps/web/src/hooks/useWorkspacePanes.ts @@ -19,6 +19,14 @@ function chatPane(chatId: string): WorkspacePane { return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 }; } +// v1.10 booterm: terminal panes carry no chats. Their `id` is used as the +// tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They +// persist in localStorage along with chat panes so a refresh resumes the +// same tmux window via the idempotent start endpoint. +function terminalPane(): WorkspacePane { + return { id: generateId(), kind: 'terminal', chatIds: [], activeChatIdx: -1 }; +} + // v1.9: settings pane factory. No chats, no state beyond identity — the // SettingsPane component renders Session/Project sections from the // surrounding session/project. @@ -234,10 +242,6 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { }, []); const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => { - if (kind === 'terminal') { - toast('Terminal panes coming in BooTerm'); - return; - } if (kind === 'agent') { toast('Agent panes coming in BooCoder'); return; @@ -248,7 +252,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { toast.error(`Maximum ${MAX_PANES} panes`); return prev; } - const next = [...prev, emptyPane()]; + const newPane = kind === 'terminal' ? terminalPane() : emptyPane(); + const next = [...prev, newPane]; setActivePaneIdx(next.length - 1); return next; }); diff --git a/apps/web/src/lib/events.ts b/apps/web/src/lib/events.ts new file mode 100644 index 0000000..a41e970 --- /dev/null +++ b/apps/web/src/lib/events.ts @@ -0,0 +1,80 @@ +// Minimal pub/sub for ephemeral UI events that don't belong on the sessionEvents +// bus (sessionEvents is for DB-state changes; this file is for UI-only signals +// like "user clicked send-to-terminal on selected text"). +// +// Also exposes a tiny registry of currently-mounted terminal panes so the +// MessageBubble context menu can list them. TerminalPane registers on mount, +// unregisters on unmount. + +type Listener = (payload: T) => void; + +interface EventBus { + emit(payload: T): void; + subscribe(listener: Listener): () => void; +} + +function createEvent(): EventBus { + const listeners = new Set>(); + return { + emit(payload) { + for (const l of listeners) { + try { + l(payload); + } catch { + /* one bad listener shouldn't break others */ + } + } + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +export interface SendToTerminalPayload { + pane_id: string; + text: string; +} + +export const sendToTerminal = createEvent(); + +export interface TerminalRegistration { + paneId: string; + label: string; +} + +const terminalRegistry = new Map(); +const registryListeners = new Set>(); + +function notifyRegistry(): void { + for (const l of registryListeners) { + try { + l(); + } catch { + /* ignore */ + } + } +} + +export const terminalsRegistry = { + register(paneId: string, label: string): () => void { + terminalRegistry.set(paneId, { paneId, label }); + notifyRegistry(); + return () => { + terminalRegistry.delete(paneId); + notifyRegistry(); + }; + }, + list(): TerminalRegistration[] { + return Array.from(terminalRegistry.values()); + }, + subscribe(listener: Listener): () => void { + registryListeners.add(listener); + return () => { + registryListeners.delete(listener); + }; + }, +}; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index df4a05b..33de6f1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -12,6 +12,24 @@ export default defineConfig({ server: { port: 5173, proxy: { + // Booterm runs on a separate port (9501 in compose). Order matters: + // /api/term/* and /ws/term/* must be listed before the broader /api + // entry so Vite matches the more specific prefix first. + '/api/term': { + target: process.env.BOOTERM_DEV_URL ?? 'http://127.0.0.1:9501', + changeOrigin: true, + headers: { + 'Remote-User': process.env.DEV_REMOTE_USER ?? 'sam', + }, + }, + '/ws/term': { + target: process.env.BOOTERM_DEV_URL ?? 'http://127.0.0.1:9501', + changeOrigin: true, + ws: true, + headers: { + 'Remote-User': process.env.DEV_REMOTE_USER ?? 'sam', + }, + }, '/api': { target: 'http://127.0.0.1:3000', changeOrigin: true, diff --git a/boocode_batch10.md b/boocode_batch10.md new file mode 100644 index 0000000..478d489 --- /dev/null +++ b/boocode_batch10.md @@ -0,0 +1,269 @@ +# BooCode v1.1 — Batch 10 + +**Theme:** BooTerm. Second container, dedicated to in-browser terminals. Per-session tmux. xterm.js + node-pty in-container. New pane type wires into the BooCode shell. +**Status:** Planned. Largest batch in v1.1. Depends on Batch 3 (pane system), Batch 7 (settings drawer pattern reused). +**Repo:** `/opt/boocode/` (shared monorepo). New `apps/booterm/` subdirectory. + +## Goals + +1. New container `booterm` running Fastify + node-pty + tmux. Per-session tmux session keyed by `(user, session_id)`. +2. xterm.js terminal pane in the BooCode shell. Multiple terminal panes per session, each attached to a separate tmux window. +3. PTY traffic over WebSocket. Auth via `Remote-User`. +4. tmux as session manager so terminals survive WebSocket reconnects, page refreshes, even container restarts. +5. Read+write capability scoped to project root. No `cd ..` escape. +6. Path-based routing: `code.indifferentketchup.com/api/term/*` → booterm; `/ws/term/*` → booterm. + +## Architecture + +``` +browser ──HTTPS──> Caddy (droplet) ──Tailscale──> Authelia + │ + ├── /api/chat/*, /ws/chat/* → boocode :9500 + ├── /api/term/*, /ws/term/* → booterm :9501 + └── / → boocode (SPA) + +booterm container: + - Fastify (Node 20) + - node-pty + - tmux installed in container (apk add tmux) + - same Postgres (boocode_db) + - mounts projects rw (scoped) +``` + +### Mount strategy + +Decided: Option A. Per-project bind mounts in `docker-compose.yml`. Already applied: booterm has `/opt:/opt:rw` to keep parity with the existing boocode mount and avoid enumerating roots. Project root for any given session derives from `projects.root_path` and tmux launches with `cwd` set there. + +### tmux session naming + +Per-session tmux: + +``` +tmux session name: bc- (UUID, sanitized — alphanumeric + hyphen) +tmux windows: term- (one window per terminal pane) +``` + +booterm spawns `tmux new-session -d -s bc- -c ` lazily on first attach. Subsequent attaches do `tmux new-window -t bc-` for additional panes, or `tmux attach -t bc-` and select window. + +## Data model + +| Column | On | Type | Default | Notes | +|---|---|---|---|---| +| (none) | — | — | — | terminals are tmux-managed, no DB rows | +| `kind = 'terminal'` | `session_panes.kind` CHECK | — | — | Extend CHECK to include `'terminal'` | +| `state.tmux_window` | `session_panes.state` JSONB | TEXT | NULL | Which tmux window this pane attaches to | + +Schema (already applied to live DB + schema.sql): + +```sql +ALTER TABLE session_panes DROP CONSTRAINT IF EXISTS session_panes_kind_check; +ALTER TABLE session_panes ADD CONSTRAINT session_panes_kind_check + CHECK (kind IN ('chat', 'file_browser', 'terminal')); +``` + +## Backend (booterm) + +New app at `apps/booterm/`: + +``` +apps/booterm/ +├── src/ +│ ├── index.ts # Fastify + WS + auth +│ ├── auth.ts # Remote-User middleware (same pattern as boocode) +│ ├── db.ts # pg pool (shared boocode_db) +│ ├── routes/ +│ │ ├── health.ts +│ │ └── terminals.ts # POST /api/term/sessions/:sid/panes/:pid/start (creates tmux window) +│ ├── pty/ +│ │ ├── manager.ts # tmux process management +│ │ └── pty.ts # node-pty wrapper for `tmux attach -t ... -d` +│ └── ws/ +│ └── attach.ts # WS /ws/term/sessions/:sid/panes/:pid → PTY bidi pipe +├── package.json +└── tsconfig.json +``` + +### Endpoints + +| Method | Path | Notes | +|---|---|---| +| GET | `/api/term/health` | Ping | +| POST | `/api/term/sessions/:sid/panes/:pid/start` | Idempotent tmux window create. Returns `{tmux_window: "term-"}` | +| WS | `/ws/term/sessions/:sid/panes/:pid` | Attach PTY | +| POST | `/api/term/sessions/:sid/panes/:pid/resize` | `{cols, rows}` | +| POST | `/api/term/sessions/:sid/panes/:pid/kill` | Kill the tmux window | + +WS frames (binary or text): + +``` +client → server: pty input (raw bytes, typed by user) +server → client: pty output (raw bytes from shell) +server → client: {type: "exit", code} on window close +``` + +### Auth + scoping + +- `Remote-User` required on WS upgrade. +- `session_id` validated: lookup in `sessions` table; require row exists. +- `pane_id` validated: must exist in `session_panes` with `kind = 'terminal'` and matching `session_id`. +- Project root derived from `sessions.project_id → projects.root_path`. tmux starts `cd ` in that dir. **No chroot.** User can `cd /` and read anything mounted into the container. + - Future hardening: namespace/chroot. Out of v1.1 scope. + +### tmux config + +`apps/booterm/tmux.conf` bundled into image at `/etc/booterm/tmux.conf`; tmux invocations use `-f /etc/booterm/tmux.conf`: + +``` +set -g default-terminal "screen-256color" +set -g history-limit 50000 +set -g mouse on +setw -g mode-keys vi +set -g status off +set -g destroy-unattached off +``` + +Boolab pattern (from `services/tmux_session.py`). + +## Frontend + +| File | Change | +|---|---| +| `apps/web/src/components/panes/TerminalPane.tsx` (NEW) | xterm.js mount, WS attach, resize handler | +| `apps/web/src/api/client.ts` | `api.terminals.start(sessionId, paneId)`, `api.terminals.resize(...)`, `api.terminals.kill(...)` | +| `apps/web/src/components/Workspace.tsx` | Add 'terminal' to the pane kind enum; spawn button → POST start → render TerminalPane. Tab UI lives in Workspace.tsx — there is no PaneTab.tsx file. | +| `apps/web/package.json` | `xterm` + `xterm-addon-fit` + `xterm-addon-web-links` | + +### TerminalPane + +```tsx +useEffect(() => { + const term = new Terminal({ fontFamily: 'JetBrains Mono', fontSize: 14, theme: ... }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.loadAddon(new WebLinksAddon()); + term.open(containerRef.current); + fit.fit(); + + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${proto}//${window.location.host}/ws/term/sessions/${sid}/panes/${pid}`); + ws.binaryType = 'arraybuffer'; + ws.onmessage = e => term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data)); + term.onData(data => ws.send(data)); + term.onResize(({ cols, rows }) => api.terminals.resize(sid, pid, cols, rows)); + + const ro = new ResizeObserver(() => fit.fit()); + ro.observe(containerRef.current); + + return () => { ws.close(); term.dispose(); ro.disconnect(); }; +}, [sid, pid]); +``` + +Dev: vite.config.ts needs `/api/term` and `/ws/term` proxy entries mirroring the existing `/api` and `/ws` ones. + +## Send-to-terminal from chat + +Boolab pattern: select text in a message → "Send to terminal" button → text becomes terminal input. + +- Right-click context menu on selected text in chat → "Send to terminal" submenu lists open terminal panes. +- Click target → sends `\n` to that pane's WS. + +Implementation: + +| File | Change | +|---|---| +| `apps/web/src/components/MessageBubble.tsx` | Selection handler + context menu | +| `apps/web/src/lib/events.ts` | New event `send_to_terminal` with payload `{pane_id, text}` | +| `apps/web/src/components/panes/TerminalPane.tsx` | Subscribe to event for its `pane_id`, write to WS | + +## Docker compose (already applied) + +booterm service is already in `docker-compose.yml` with: +- build context `.`, dockerfile `apps/booterm/Dockerfile` +- port `100.114.205.53:9501:3000` +- `/opt:/opt:rw` mount +- `DATABASE_URL` env pointing at `boocode_db` +- `boocode_net` network +- depends_on: `boocode_db` + +Do not re-edit compose. + +## Backend dependencies + +`apps/booterm/package.json`: +- `fastify` +- `@fastify/websocket` +- `pg` +- `zod` +- `node-pty` +- `tslib` + +`node-pty` requires native build. Dockerfile installs `python3 make g++` in build stage and `tmux` in runtime stage: + +```dockerfile +FROM node:20-alpine AS build +RUN apk add --no-cache python3 make g++ tmux +WORKDIR /app +COPY ... +RUN pnpm install --frozen-lockfile && pnpm build + +FROM node:20-alpine +RUN apk add --no-cache tmux +WORKDIR /app +COPY --from=build /app/apps/booterm/dist ./dist +COPY --from=build /app/node_modules ./node_modules +EXPOSE 3000 +CMD ["node", "dist/index.js"] +``` + +## Files to touch + +**New app:** + +- `apps/booterm/` (entire subtree) + +**Existing changes:** + +- `apps/web/package.json` +- `apps/web/src/api/client.ts` +- `apps/web/src/api/types.ts` +- `apps/web/src/components/Workspace.tsx` +- `apps/web/src/components/MessageBubble.tsx` +- `apps/web/src/components/panes/TerminalPane.tsx` (NEW) +- `apps/web/src/lib/events.ts` +- `apps/web/vite.config.ts` (proxy entries) + +**Already done by user — do not touch:** + +- `docker-compose.yml` (booterm service added) +- `apps/server/src/schema.sql` (terminal CHECK constraint) +- Live DB constraint applied + +## Verification + +1. `docker compose up -d --build booterm` → container healthy. +2. `curl -s http://100.114.205.53:9501/api/term/health -H 'Remote-User: sam'` → 200. +3. Browser smoke test: + - Open a session. Workspace → "+ Terminal" → terminal pane appears with shell prompt in project root. + - Type `ls -la` → output. + - Type `vim test.txt`, write something, save, `:q` → file exists on host (since rw mount). + - Refresh browser → terminal reconnects, history intact (tmux persistence). + - Open second terminal pane → same project, separate tmux window. Both work independently. + - Select code in chat → right-click → "Send to terminal" → terminal pane receives the text. + - Container restart (`docker compose restart booterm`) → on reconnect, tmux session resumes from where it left off. + - Close pane via tab context menu → tmux window killed. Reopen pane → fresh shell. + +## Constraints + +- node-pty is a native dep. Image size grows. +- tmux history capped at 50k lines per window. +- WebSocket frames are bidirectional binary; `binaryType = 'arraybuffer'`. +- Resize debounced 100ms client-side; backend `tmux resize-window` per resize. +- No chroot/namespace isolation in v1.1. User has full read+write under `/opt/`. Acceptable for single-user homelab. +- Don't expose 9501 on 0.0.0.0. Tailscale binding only (already configured in compose). + +## Open + +- Color theme matching for xterm.js. Defer. +- File-drop into terminal (upload via terminal pane). Out of scope. +- Multi-user (each user gets own tmux server) — defer until BooCode goes multi-user, which isn't planned. +- BooCoder container — same skeleton as booterm but with edit_file / create_file tools instead of PTY. Will follow this pattern when built. diff --git a/docker-compose.yml b/docker-compose.yml index ff70619..9b4acd6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,26 @@ services: networks: - boocode_net + booterm: + build: + context: . + dockerfile: apps/booterm/Dockerfile + container_name: booterm + restart: unless-stopped + ports: + - "100.114.205.53:9501:3000" + env_file: .env + environment: + NODE_ENV: production + PORT: 3000 + DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode + volumes: + - /opt:/opt:rw + depends_on: + - boocode_db + networks: + - boocode_net + boocode_db: image: postgres:16-alpine container_name: boocode_db diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a16e119..47d178c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,40 @@ importers: specifier: ^5.5.0 version: 5.9.3 + apps/booterm: + dependencies: + '@fastify/websocket': + specifier: ^10.0.1 + version: 10.0.1 + fastify: + specifier: ^4.28.1 + version: 4.29.1 + node-pty: + specifier: ^1.0.0 + version: 1.1.0 + pg: + specifier: ^8.13.0 + version: 8.20.0 + tslib: + specifier: ^2.6.3 + version: 2.8.1 + zod: + specifier: ^3.23.8 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^20.14.10 + version: 20.19.41 + '@types/pg': + specifier: ^8.11.10 + version: 8.20.0 + tsx: + specifier: ^4.16.2 + version: 4.22.0 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + apps/server: dependencies: '@fastify/static': @@ -102,6 +136,15 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + xterm: + specifier: ^5.3.0 + version: 5.3.0 + xterm-addon-fit: + specifier: ^0.8.0 + version: 0.8.0(xterm@5.3.0) + xterm-addon-web-links: + specifier: ^0.9.0 + version: 0.9.0(xterm@5.3.0) devDependencies: '@tailwindcss/postcss': specifier: ^4.3.0 @@ -1727,6 +1770,9 @@ packages: '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -2964,6 +3010,9 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -2973,6 +3022,9 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + node-releases@2.0.44: resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} @@ -3079,6 +3131,40 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3112,6 +3198,22 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + postgres@3.4.9: resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} engines: {node: '>=12'} @@ -3797,6 +3899,26 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + xterm-addon-fit@0.8.0: + resolution: {integrity: sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==} + deprecated: This package is now deprecated. Move to @xterm/addon-fit instead. + peerDependencies: + xterm: ^5.0.0 + + xterm-addon-web-links@0.9.0: + resolution: {integrity: sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==} + deprecated: This package is now deprecated. Move to @xterm/addon-web-links instead. + peerDependencies: + xterm: ^5.0.0 + + xterm@5.3.0: + resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==} + deprecated: This package is now deprecated. Move to @xterm/xterm instead. + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5380,6 +5502,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 20.19.41 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.28)': @@ -6817,6 +6945,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + node-addon-api@7.1.1: {} + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -6825,6 +6955,10 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-pty@1.1.0: + dependencies: + node-addon-api: 7.1.1 + node-releases@2.0.44: {} npm-run-path@4.0.1: @@ -6935,6 +7069,41 @@ snapshots: pathval@2.0.1: {} + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -6974,6 +7143,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + postgres@3.4.9: {} powershell-utils@0.1.0: {} @@ -7782,6 +7961,18 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xtend@4.0.2: {} + + xterm-addon-fit@0.8.0(xterm@5.3.0): + dependencies: + xterm: 5.3.0 + + xterm-addon-web-links@0.9.0(xterm@5.3.0): + dependencies: + xterm: 5.3.0 + + xterm@5.3.0: {} + y18n@5.0.8: {} yallist@3.1.1: {}