merge v1.10-booterm
This commit is contained in:
41
apps/booterm/Dockerfile
Normal file
41
apps/booterm/Dockerfile
Normal file
@@ -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"]
|
||||||
27
apps/booterm/package.json
Normal file
27
apps/booterm/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/booterm/src/auth.ts
Normal file
11
apps/booterm/src/auth.ts
Normal file
@@ -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';
|
||||||
|
}
|
||||||
26
apps/booterm/src/config.ts
Normal file
26
apps/booterm/src/config.ts
Normal file
@@ -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<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
46
apps/booterm/src/db.ts
Normal file
46
apps/booterm/src/db.ts
Normal file
@@ -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<SessionInfo | null> {
|
||||||
|
if (!pool) throw new Error('db pool not initialized');
|
||||||
|
const res = await pool.query<SessionInfo>(
|
||||||
|
`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<boolean> {
|
||||||
|
if (!pool) return false;
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDb(): Promise<void> {
|
||||||
|
if (pool) {
|
||||||
|
await pool.end();
|
||||||
|
pool = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/booterm/src/index.ts
Normal file
60
apps/booterm/src/index.ts
Normal file
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
102
apps/booterm/src/pty/manager.ts
Normal file
102
apps/booterm/src/pty/manager.ts
Normal file
@@ -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<CmdResult> {
|
||||||
|
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<boolean> {
|
||||||
|
const res = await runTmux(tmuxConfPath, ['has-session', '-t', `=${sessionName}`]);
|
||||||
|
return res.code === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWindows(tmuxConfPath: string, sessionName: string): Promise<string[]> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/booterm/src/pty/pty.ts
Normal file
41
apps/booterm/src/pty/pty.ts
Normal file
@@ -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(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/booterm/src/routes/health.ts
Normal file
9
apps/booterm/src/routes/health.ts
Normal file
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
88
apps/booterm/src/routes/terminals.ts
Normal file
88
apps/booterm/src/routes/terminals.ts
Normal file
@@ -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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
128
apps/booterm/src/ws/attach.ts
Normal file
128
apps/booterm/src/ws/attach.ts
Normal file
@@ -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<string, IPty>();
|
||||||
|
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
6
apps/booterm/tmux.conf
Normal file
6
apps/booterm/tmux.conf
Normal file
@@ -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
|
||||||
15
apps/booterm/tsconfig.json
Normal file
15
apps/booterm/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS session_panes (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
position INTEGER NOT NULL,
|
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 '{}',
|
state JSONB NOT NULL DEFAULT '{}',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
UNIQUE (session_id, position)
|
UNIQUE (session_id, position)
|
||||||
|
|||||||
@@ -26,7 +26,10 @@
|
|||||||
"shiki": "^1.29.2",
|
"shiki": "^1.29.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
|
|||||||
@@ -261,4 +261,26 @@ export const api = {
|
|||||||
sidebar: {
|
sidebar: {
|
||||||
get: () => request<SidebarResponse>('/api/sidebar'),
|
get: () => request<SidebarResponse>('/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' },
|
||||||
|
),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 type { ReactElement, ReactNode } from 'react';
|
||||||
import Markdown from 'react-markdown';
|
import Markdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
@@ -7,9 +7,19 @@ import { toast } from 'sonner';
|
|||||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||||
import { CapHitSentinel } from './CapHitSentinel';
|
import { CapHitSentinel } from './CapHitSentinel';
|
||||||
import { CodeBlock } from './CodeBlock';
|
import { CodeBlock } from './CodeBlock';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from '@/components/ui/context-menu';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -19,6 +29,57 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} 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 → <pane name>". 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 (
|
||||||
|
<ContextMenu
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : '';
|
||||||
|
setSelection(sel);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuSub>
|
||||||
|
<ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
|
||||||
|
<ContextMenuSubContent>
|
||||||
|
{terminals.length === 0 ? (
|
||||||
|
<ContextMenuItem disabled>No terminal panes open</ContextMenuItem>
|
||||||
|
) : (
|
||||||
|
terminals.map((t) => (
|
||||||
|
<ContextMenuItem
|
||||||
|
key={t.paneId}
|
||||||
|
onSelect={() => sendToTerminal.emit({ pane_id: t.paneId, text: selection })}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</ContextMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ContextMenuSubContent>
|
||||||
|
</ContextMenuSub>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// v1.8.2: human labels for the machine-readable error reasons that ride on
|
// 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
|
// failed assistant messages via metadata.kind === 'error'. Kept short so the
|
||||||
// inline render under "message failed" stays a single muted line.
|
// 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') {
|
if (message.role === 'user') {
|
||||||
return (
|
return (
|
||||||
<div className="group flex flex-col items-end gap-1">
|
<div className="group flex flex-col items-end gap-1">
|
||||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
<SendToTerminalMenu>
|
||||||
{message.content}
|
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
||||||
</div>
|
{message.content}
|
||||||
|
</div>
|
||||||
|
</SendToTerminalMenu>
|
||||||
<ActionRow message={message} />
|
<ActionRow message={message} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -529,12 +592,14 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="group flex flex-col gap-2">
|
<div className="group flex flex-col gap-2">
|
||||||
{(hasContent || isStreaming) && (
|
{(hasContent || isStreaming) && (
|
||||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
<SendToTerminalMenu>
|
||||||
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||||
{isStreaming && (
|
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
||||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
{isStreaming && (
|
||||||
)}
|
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
</SendToTerminalMenu>
|
||||||
)}
|
)}
|
||||||
{failed && (
|
{failed && (
|
||||||
<div className="text-xs text-destructive">
|
<div className="text-xs text-destructive">
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
import { PanelRight, MessageSquare, Terminal, Bot, X } from 'lucide-react';
|
||||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||||
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
import { ChatPane } from '@/components/panes/ChatPane';
|
import { ChatPane } from '@/components/panes/ChatPane';
|
||||||
import { SettingsPane } from '@/components/panes/SettingsPane';
|
import { SettingsPane } from '@/components/panes/SettingsPane';
|
||||||
|
import { TerminalPane } from '@/components/panes/TerminalPane';
|
||||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||||
import {
|
import {
|
||||||
@@ -115,6 +116,20 @@ export function Workspace({
|
|||||||
.filter((c): c is Chat => c !== undefined);
|
.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<string, string>();
|
||||||
|
let n = 0;
|
||||||
|
for (const p of panes) {
|
||||||
|
if (p.kind === 'terminal') {
|
||||||
|
n += 1;
|
||||||
|
out.set(p.id, `Terminal ${n}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [panes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
@@ -165,6 +180,7 @@ export function Workspace({
|
|||||||
>
|
>
|
||||||
{panes.map((pane, idx) => {
|
{panes.map((pane, idx) => {
|
||||||
const isSettings = pane.kind === 'settings';
|
const isSettings = pane.kind === 'settings';
|
||||||
|
const isTerminal = pane.kind === 'terminal';
|
||||||
// v1.9: when maximized, hide every pane except the settings one.
|
// v1.9: when maximized, hide every pane except the settings one.
|
||||||
// display:none keeps the React tree mounted so streams / drafts
|
// display:none keeps the React tree mounted so streams / drafts
|
||||||
// survive the toggle without re-mount cost.
|
// survive the toggle without re-mount cost.
|
||||||
@@ -176,6 +192,9 @@ export function Workspace({
|
|||||||
}
|
}
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={pane.id}
|
key={pane.id}
|
||||||
@@ -187,19 +206,18 @@ export function Workspace({
|
|||||||
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
||||||
)}
|
)}
|
||||||
onClick={() => setActivePaneIdx(idx)}
|
onClick={() => setActivePaneIdx(idx)}
|
||||||
onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
onDragOver={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||||
onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
|
onDragLeave={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||||
onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
onDrop={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
draggable={!isMobile && !isSettings && panes.length > 1}
|
draggable={!isMobile && !isChromeless && panes.length > 1}
|
||||||
onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||||
onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
|
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||||
>
|
>
|
||||||
{/* Hidden on mobile per v1.8; settings panes own their own
|
{/* Hidden on mobile per v1.8; settings + terminal panes own
|
||||||
section nav / maximize toggle so they skip ChatTabBar
|
their own header (no chats, so no ChatTabBar). */}
|
||||||
entirely. */}
|
{!isMobile && !isChromeless && (
|
||||||
{!isMobile && !isSettings && (
|
|
||||||
<ChatTabBar
|
<ChatTabBar
|
||||||
pane={pane}
|
pane={pane}
|
||||||
tabs={chatsForPane(pane)}
|
tabs={chatsForPane(pane)}
|
||||||
@@ -214,6 +232,28 @@ export function Workspace({
|
|||||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isTerminal && (
|
||||||
|
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||||
|
<Terminal size={12} className="text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||||
|
</span>
|
||||||
|
{panes.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removePane(idx);
|
||||||
|
}}
|
||||||
|
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
aria-label="Close terminal pane"
|
||||||
|
title="Close terminal pane"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
@@ -226,6 +266,12 @@ export function Workspace({
|
|||||||
onClose={() => removePane(idx)}
|
onClose={() => removePane(idx)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
) : isTerminal ? (
|
||||||
|
<TerminalPane
|
||||||
|
sessionId={sessionId}
|
||||||
|
paneId={pane.id}
|
||||||
|
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||||
|
/>
|
||||||
) : pane.kind === 'chat' && pane.chatId ? (
|
) : pane.kind === 'chat' && pane.chatId ? (
|
||||||
<ChatPane
|
<ChatPane
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
|
|||||||
167
apps/web/src/components/panes/TerminalPane.tsx
Normal file
167
apps/web/src/components/panes/TerminalPane.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Terminal } from 'xterm';
|
||||||
|
import { FitAddon } from 'xterm-addon-fit';
|
||||||
|
import { WebLinksAddon } from 'xterm-addon-web-links';
|
||||||
|
import 'xterm/css/xterm.css';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { sendToTerminal, terminalsRegistry } from '@/lib/events';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessionId: string;
|
||||||
|
paneId: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal dark theme. xterm.js renders against its own canvas; CSS variables
|
||||||
|
// don't reach it, so we hardcode. Matches the obsidian-dark base in spirit.
|
||||||
|
const XTERM_THEME = {
|
||||||
|
background: '#0b0f14',
|
||||||
|
foreground: '#d6deeb',
|
||||||
|
cursor: '#82aaff',
|
||||||
|
selectionBackground: '#1d3b53',
|
||||||
|
black: '#011627',
|
||||||
|
red: '#ef5350',
|
||||||
|
green: '#22da6e',
|
||||||
|
yellow: '#c5e478',
|
||||||
|
blue: '#82aaff',
|
||||||
|
magenta: '#c792ea',
|
||||||
|
cyan: '#7fdbca',
|
||||||
|
white: '#d6deeb',
|
||||||
|
brightBlack: '#575656',
|
||||||
|
brightRed: '#ef5350',
|
||||||
|
brightGreen: '#22da6e',
|
||||||
|
brightYellow: '#ffeb95',
|
||||||
|
brightBlue: '#82aaff',
|
||||||
|
brightMagenta: '#c792ea',
|
||||||
|
brightCyan: '#7fdbca',
|
||||||
|
brightWhite: '#ffffff',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TerminalPane({ sessionId, paneId, label }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let resizeDebounceTimer: ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full h-full bg-[#0b0f14] overflow-hidden"
|
||||||
|
data-testid="terminal-pane"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,14 @@ function chatPane(chatId: string): WorkspacePane {
|
|||||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
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
|
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
||||||
// SettingsPane component renders Session/Project sections from the
|
// SettingsPane component renders Session/Project sections from the
|
||||||
// surrounding session/project.
|
// surrounding session/project.
|
||||||
@@ -234,10 +242,6 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
|
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
|
||||||
if (kind === 'terminal') {
|
|
||||||
toast('Terminal panes coming in BooTerm');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (kind === 'agent') {
|
if (kind === 'agent') {
|
||||||
toast('Agent panes coming in BooCoder');
|
toast('Agent panes coming in BooCoder');
|
||||||
return;
|
return;
|
||||||
@@ -248,7 +252,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const next = [...prev, emptyPane()];
|
const newPane = kind === 'terminal' ? terminalPane() : emptyPane();
|
||||||
|
const next = [...prev, newPane];
|
||||||
setActivePaneIdx(next.length - 1);
|
setActivePaneIdx(next.length - 1);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|||||||
80
apps/web/src/lib/events.ts
Normal file
80
apps/web/src/lib/events.ts
Normal file
@@ -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<T> = (payload: T) => void;
|
||||||
|
|
||||||
|
interface EventBus<T> {
|
||||||
|
emit(payload: T): void;
|
||||||
|
subscribe(listener: Listener<T>): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEvent<T>(): EventBus<T> {
|
||||||
|
const listeners = new Set<Listener<T>>();
|
||||||
|
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<SendToTerminalPayload>();
|
||||||
|
|
||||||
|
export interface TerminalRegistration {
|
||||||
|
paneId: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalRegistry = new Map<string, TerminalRegistration>();
|
||||||
|
const registryListeners = new Set<Listener<void>>();
|
||||||
|
|
||||||
|
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>): () => void {
|
||||||
|
registryListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
registryListeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -12,6 +12,24 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
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': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:3000',
|
target: 'http://127.0.0.1:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
269
boocode_batch10.md
Normal file
269
boocode_batch10.md
Normal file
@@ -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-<session_id> (UUID, sanitized — alphanumeric + hyphen)
|
||||||
|
tmux windows: term-<pane_id> (one window per terminal pane)
|
||||||
|
```
|
||||||
|
|
||||||
|
booterm spawns `tmux new-session -d -s bc-<sid> -c <project_root>` lazily on first attach. Subsequent attaches do `tmux new-window -t bc-<sid>` for additional panes, or `tmux attach -t bc-<sid>` 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-<pid>"}` |
|
||||||
|
| 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 <root>` 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 `<text>\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.
|
||||||
@@ -19,6 +19,26 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- boocode_net
|
- 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:
|
boocode_db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: boocode_db
|
container_name: boocode_db
|
||||||
|
|||||||
191
pnpm-lock.yaml
generated
191
pnpm-lock.yaml
generated
@@ -12,6 +12,40 @@ importers:
|
|||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.9.3
|
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:
|
apps/server:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/static':
|
'@fastify/static':
|
||||||
@@ -102,6 +136,15 @@ importers:
|
|||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 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:
|
devDependencies:
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.3.0
|
specifier: ^4.3.0
|
||||||
@@ -1727,6 +1770,9 @@ packages:
|
|||||||
'@types/node@20.19.41':
|
'@types/node@20.19.41':
|
||||||
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
|
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
|
||||||
|
|
||||||
|
'@types/pg@8.20.0':
|
||||||
|
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
||||||
|
|
||||||
'@types/prop-types@15.7.15':
|
'@types/prop-types@15.7.15':
|
||||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||||
|
|
||||||
@@ -2964,6 +3010,9 @@ packages:
|
|||||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
react-dom: ^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:
|
node-domexception@1.0.0:
|
||||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||||
engines: {node: '>=10.5.0'}
|
engines: {node: '>=10.5.0'}
|
||||||
@@ -2973,6 +3022,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
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:
|
node-releases@2.0.44:
|
||||||
resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==}
|
resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==}
|
||||||
|
|
||||||
@@ -3079,6 +3131,40 @@ packages:
|
|||||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||||
engines: {node: '>= 14.16'}
|
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:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -3112,6 +3198,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
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:
|
postgres@3.4.9:
|
||||||
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
|
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3797,6 +3899,26 @@ packages:
|
|||||||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||||
engines: {node: '>=20'}
|
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:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -5380,6 +5502,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
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/prop-types@15.7.15': {}
|
||||||
|
|
||||||
'@types/react-dom@18.3.7(@types/react@18.3.28)':
|
'@types/react-dom@18.3.7(@types/react@18.3.28)':
|
||||||
@@ -6817,6 +6945,8 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(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-domexception@1.0.0: {}
|
||||||
|
|
||||||
node-fetch@3.3.2:
|
node-fetch@3.3.2:
|
||||||
@@ -6825,6 +6955,10 @@ snapshots:
|
|||||||
fetch-blob: 3.2.0
|
fetch-blob: 3.2.0
|
||||||
formdata-polyfill: 4.0.10
|
formdata-polyfill: 4.0.10
|
||||||
|
|
||||||
|
node-pty@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
node-addon-api: 7.1.1
|
||||||
|
|
||||||
node-releases@2.0.44: {}
|
node-releases@2.0.44: {}
|
||||||
|
|
||||||
npm-run-path@4.0.1:
|
npm-run-path@4.0.1:
|
||||||
@@ -6935,6 +7069,41 @@ snapshots:
|
|||||||
|
|
||||||
pathval@2.0.1: {}
|
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: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.2: {}
|
picomatch@2.3.2: {}
|
||||||
@@ -6974,6 +7143,16 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.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: {}
|
postgres@3.4.9: {}
|
||||||
|
|
||||||
powershell-utils@0.1.0: {}
|
powershell-utils@0.1.0: {}
|
||||||
@@ -7782,6 +7961,18 @@ snapshots:
|
|||||||
is-wsl: 3.1.1
|
is-wsl: 3.1.1
|
||||||
powershell-utils: 0.1.0
|
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: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user