diff --git a/TERMINAL_INTEGRATION.md b/TERMINAL_INTEGRATION.md new file mode 100644 index 0000000..a55f37e --- /dev/null +++ b/TERMINAL_INTEGRATION.md @@ -0,0 +1,646 @@ +# Terminal Feature Integration Guide + +## Overview + +This document provides step-by-step instructions for integrating the terminal feature with the backend infrastructure. The terminal feature requires WebSocket support and SSH proxy capabilities that cannot run directly in Cloudflare Workers due to PTY limitations. + +--- + +## Backend Architecture Decision + +Since Cloudflare Workers cannot create PTY (pseudo-terminal) processes required for tmux, you have **two implementation options**: + +### Option 1: Separate WebSocket Server (Recommended) + +Run a Node.js WebSocket server on your DigitalOcean droplet that handles terminal connections. + +**Pros:** +- Clean separation of concerns +- Full control over PTY/tmux integration +- No Cloudflare Worker modifications needed +- Better security (SSH keys never leave your droplet) + +**Cons:** +- Additional server to maintain +- Need to expose WebSocket port + +### Option 2: Hybrid Cloudflare + Droplet Service + +Use Cloudflare Durable Objects to proxy WebSocket connections to a backend service on your droplet. + +**Pros:** +- Leverages existing Cloudflare infrastructure +- Can reuse authentication +- Single entry point for clients + +**Cons:** +- More complex setup +- Still requires separate service on droplet +- May have latency overhead + +--- + +## Option 1: Separate WebSocket Server (Step-by-Step) + +### Step 1: Create WebSocket Server on Droplet + +Create a new file on your DigitalOcean droplet: `/opt/terminal-server/server.js` + +```javascript +import WebSocket from 'ws' +import { TerminalProxyManager, SSHConfig } from './TerminalProxy.js' + +const PORT = 8080 +const wss = new WebSocket.Server({ port: PORT }) + +// Load SSH config from environment or config file +const sshConfig: SSHConfig = { + host: 'localhost', // Connect to same droplet + port: 22, + username: process.env.SSH_USER || 'canvas-terminal', + privateKey: fs.readFileSync(process.env.SSH_KEY_PATH || '/opt/terminal-server/key') +} + +const proxyManager = new TerminalProxyManager() + +console.log(`Terminal WebSocket server listening on port ${PORT}`) + +wss.on('connection', (ws, req) => { + const url = new URL(req.url, `ws://localhost:${PORT}`) + const sessionId = url.pathname.split('/').pop() + + // TODO: Add authentication + const userId = req.headers['x-user-id'] || 'anonymous' + + console.log(`Client connected: ${userId}`) + + const proxy = proxyManager.getProxy(userId, sshConfig) + let currentSession: string | null = null + + ws.on('message', async (data) => { + try { + const message = JSON.parse(data.toString()) + + switch (message.type) { + case 'init': + // Attach to tmux session + const connectionId = `${userId}-conn` + + if (!proxy.isConnected(connectionId)) { + await proxy.connect(connectionId) + } + + currentSession = await proxy.attachSession( + connectionId, + message.sessionId, + message.cols || 80, + message.rows || 24, + (output) => { + ws.send(JSON.stringify({ type: 'output', data: output })) + }, + () => { + ws.send(JSON.stringify({ type: 'status', status: 'disconnected' })) + } + ) + + ws.send(JSON.stringify({ type: 'status', status: 'connected' })) + break + + case 'input': + if (currentSession) { + await proxy.sendInput(currentSession, message.data) + } + break + + case 'resize': + if (currentSession) { + await proxy.resize(currentSession, message.cols, message.rows) + } + break + + case 'list_sessions': + const connectionId2 = `${userId}-conn` + if (!proxy.isConnected(connectionId2)) { + await proxy.connect(connectionId2) + } + const sessions = await proxy.listSessions(connectionId2) + ws.send(JSON.stringify({ type: 'sessions', sessions })) + break + + case 'create_session': + const connectionId3 = `${userId}-conn` + if (!proxy.isConnected(connectionId3)) { + await proxy.connect(connectionId3) + } + const newSession = await proxy.createSession(connectionId3, message.name) + ws.send(JSON.stringify({ type: 'session_created', sessionId: newSession })) + break + + case 'detach': + if (currentSession) { + await proxy.detachSession(currentSession) + currentSession = null + ws.send(JSON.stringify({ type: 'status', status: 'detached' })) + } + break + } + } catch (err) { + console.error('Error handling message:', err) + ws.send(JSON.stringify({ type: 'error', message: err.message })) + } + }) + + ws.on('close', async () => { + console.log(`Client disconnected: ${userId}`) + if (currentSession) { + await proxy.detachSession(currentSession) + } + }) + + ws.on('error', (err) => { + console.error('WebSocket error:', err) + }) +}) + +// Cleanup on shutdown +process.on('SIGINT', async () => { + console.log('Shutting down...') + await proxyManager.cleanup() + wss.close() + process.exit(0) +}) +``` + +### Step 2: Copy TerminalProxy.ts to Droplet + +Copy `/worker/TerminalProxy.ts` to your droplet and convert it to work with Node.js: + +```bash +# On your local machine +scp worker/TerminalProxy.ts your-droplet:/opt/terminal-server/TerminalProxy.js +``` + +### Step 3: Install Dependencies on Droplet + +```bash +ssh your-droplet +cd /opt/terminal-server +npm init -y +npm install ws ssh2 +``` + +### Step 4: Create systemd Service + +Create `/etc/systemd/system/terminal-server.service`: + +```ini +[Unit] +Description=Terminal WebSocket Server +After=network.target + +[Service] +Type=simple +User=canvas-terminal +WorkingDirectory=/opt/terminal-server +Environment="NODE_ENV=production" +Environment="SSH_USER=canvas-terminal" +Environment="SSH_KEY_PATH=/opt/terminal-server/key" +ExecStart=/usr/bin/node /opt/terminal-server/server.js +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl enable terminal-server +sudo systemctl start terminal-server +sudo systemctl status terminal-server +``` + +### Step 5: Configure Firewall + +```bash +# Allow WebSocket connections +sudo ufw allow 8080/tcp + +# Or if using specific IPs +sudo ufw allow from YOUR_CLOUDFLARE_IP to any port 8080 +``` + +### Step 6: Update Frontend WebSocket URL + +Modify `/src/components/TerminalContent.tsx`: + +```typescript +const connectWebSocket = () => { + // Update with your droplet IP + const wsUrl = `wss://YOUR_DROPLET_IP:8080/terminal/${sessionId}` + const ws = new WebSocket(wsUrl) + // ... rest of code +} +``` + +### Step 7: Optional - Use nginx as Reverse Proxy + +Create `/etc/nginx/sites-available/terminal-ws`: + +```nginx +upstream terminal_backend { + server 127.0.0.1:8080; +} + +server { + listen 443 ssl http2; + server_name terminal.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; + + location / { + proxy_pass http://terminal_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket specific + proxy_read_timeout 86400; + proxy_send_timeout 86400; + } +} +``` + +Enable and reload: + +```bash +sudo ln -s /etc/nginx/sites-available/terminal-ws /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +--- + +## Option 2: Cloudflare Worker Integration + +If you prefer to proxy through Cloudflare, add these routes to `worker/AutomergeDurableObject.ts`: + +```typescript +import { TerminalProxyManager } from './TerminalProxy' + +export class AutomergeDurableObject { + // Add to existing class + private terminalProxyManager: TerminalProxyManager | null = null + + private getTerminalProxy() { + if (!this.terminalProxyManager) { + this.terminalProxyManager = new TerminalProxyManager() + } + return this.terminalProxyManager + } + + // Add to router (after line 155) + private readonly router = AutoRouter({ + // ... existing routes ... + }) + // ... existing routes ... + + // Terminal WebSocket endpoint + .get("/terminal/ws/:sessionId", async (request) => { + const upgradeHeader = request.headers.get("Upgrade") + if (upgradeHeader !== "websocket") { + return new Response("Expected Upgrade: websocket", { status: 426 }) + } + + const [client, server] = Object.values(new WebSocketPair()) + + // Handle WebSocket connection + server.accept() + + const proxyManager = this.getTerminalProxy() + const userId = "user-123" // TODO: Get from auth + + // Get SSH config from environment or secrets + const sshConfig = { + host: request.env.TERMINAL_SSH_HOST, + port: 22, + username: request.env.TERMINAL_SSH_USER, + privateKey: request.env.TERMINAL_SSH_KEY + } + + const proxy = proxyManager.getProxy(userId, sshConfig) + let currentSession: string | null = null + + server.addEventListener("message", async (event) => { + try { + const message = JSON.parse(event.data as string) + + // Handle message types similar to Option 1 + // ... (implementation same as server.js above) + } catch (err) { + server.send(JSON.stringify({ type: "error", message: err.message })) + } + }) + + return new Response(null, { + status: 101, + webSocket: client + }) + }) + + // List tmux sessions + .get("/terminal/sessions", async (request) => { + const userId = "user-123" // TODO: Get from auth + const proxyManager = this.getTerminalProxy() + + const sshConfig = { + host: request.env.TERMINAL_SSH_HOST, + port: 22, + username: request.env.TERMINAL_SSH_USER, + privateKey: request.env.TERMINAL_SSH_KEY + } + + const proxy = proxyManager.getProxy(userId, sshConfig) + const connectionId = `${userId}-conn` + + if (!proxy.isConnected(connectionId)) { + await proxy.connect(connectionId) + } + + const sessions = await proxy.listSessions(connectionId) + + return new Response(JSON.stringify({ sessions }), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" + } + }) + }) + + // Create new tmux session + .post("/terminal/sessions", async (request) => { + const userId = "user-123" // TODO: Get from auth + const { name } = await request.json() as { name: string } + + const proxyManager = this.getTerminalProxy() + const sshConfig = { + host: request.env.TERMINAL_SSH_HOST, + port: 22, + username: request.env.TERMINAL_SSH_USER, + privateKey: request.env.TERMINAL_SSH_KEY + } + + const proxy = proxyManager.getProxy(userId, sshConfig) + const connectionId = `${userId}-conn` + + if (!proxy.isConnected(connectionId)) { + await proxy.connect(connectionId) + } + + const sessionId = await proxy.createSession(connectionId, name) + + return new Response(JSON.stringify({ sessionId }), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" + } + }) + }) +} +``` + +**Note:** Cloudflare Workers have limitations: +- 128MB memory limit +- 30-second CPU time limit (50ms for free tier) +- ssh2 may not work due to crypto limitations + +**Recommendation:** Use Option 1 (separate WebSocket server) for better reliability. + +--- + +## Environment Variables + +Add to `.env` or Cloudflare Worker secrets: + +```bash +TERMINAL_SSH_HOST=165.227.XXX.XXX +TERMINAL_SSH_PORT=22 +TERMINAL_SSH_USER=canvas-terminal +TERMINAL_SSH_KEY="-----BEGIN OPENSSH PRIVATE KEY----- +... +-----END OPENSSH PRIVATE KEY-----" +``` + +Set Cloudflare secrets: + +```bash +wrangler secret put TERMINAL_SSH_HOST +wrangler secret put TERMINAL_SSH_USER +wrangler secret put TERMINAL_SSH_KEY +``` + +--- + +## Testing + +### 1. Test WebSocket Server + +```bash +# Install wscat +npm install -g wscat + +# Connect to server +wscat -c ws://YOUR_DROPLET_IP:8080/terminal/test-session + +# Send test message +> {"type":"list_sessions"} +``` + +### 2. Test from Browser Console + +```javascript +const ws = new WebSocket('wss://YOUR_DROPLET_IP:8080/terminal/test-session') + +ws.onopen = () => { + console.log('Connected') + ws.send(JSON.stringify({ type: 'list_sessions' })) +} + +ws.onmessage = (event) => { + console.log('Received:', JSON.parse(event.data)) +} +``` + +### 3. Test Terminal Creation in Canvas + +1. Open canvas dashboard +2. Click terminal button in toolbar +3. Should see session browser +4. Click "Create New Session" or attach to existing +5. Should see terminal prompt + +--- + +## Troubleshooting + +### WebSocket Connection Failed + +**Check server is running:** +```bash +sudo systemctl status terminal-server +sudo journalctl -u terminal-server -f +``` + +**Check firewall:** +```bash +sudo ufw status +telnet YOUR_DROPLET_IP 8080 +``` + +**Check nginx (if using):** +```bash +sudo nginx -t +sudo tail -f /var/log/nginx/error.log +``` + +### SSH Connection Failed + +**Test SSH manually:** +```bash +ssh -i /opt/terminal-server/key canvas-terminal@localhost +``` + +**Check SSH key permissions:** +```bash +chmod 600 /opt/terminal-server/key +chown canvas-terminal:canvas-terminal /opt/terminal-server/key +``` + +**Check authorized_keys:** +```bash +cat /home/canvas-terminal/.ssh/authorized_keys +``` + +### tmux Commands Not Working + +**Test tmux manually:** +```bash +tmux ls +tmux new-session -d -s test +tmux attach -t test +``` + +**Install tmux if missing:** +```bash +sudo apt update +sudo apt install tmux +``` + +### Browser Console Errors + +**Mixed content (HTTP/HTTPS):** +- Ensure WebSocket uses `wss://` not `ws://` +- Use HTTPS for canvas dashboard +- Use SSL certificate for WebSocket server + +**CORS errors:** +- Check nginx/server CORS headers +- Verify origin matches + +--- + +## Security Hardening + +### 1. Restrict SSH Key + +Create dedicated key for terminal server: + +```bash +ssh-keygen -t ed25519 -f /opt/terminal-server/key -N "" +``` + +Add to droplet's `authorized_keys` with command restriction: + +```bash +command="/usr/bin/tmux" ssh-ed25519 AAAA... canvas-terminal +``` + +### 2. Use Restricted Shell + +Edit `/home/canvas-terminal/.bashrc`: + +```bash +# Only allow tmux +if [[ $- == *i* ]]; then + exec tmux attach || exec tmux +fi +``` + +### 3. Rate Limiting + +Add to nginx config: + +```nginx +limit_req_zone $binary_remote_addr zone=terminal:10m rate=10r/s; + +server { + location / { + limit_req zone=terminal burst=20; + # ... proxy config ... + } +} +``` + +### 4. Authentication + +Add JWT validation in WebSocket server: + +```javascript +import jwt from 'jsonwebtoken' + +wss.on('connection', (ws, req) => { + const token = req.headers['authorization']?.split(' ')[1] + + try { + const payload = jwt.verify(token, JWT_SECRET) + const userId = payload.userId + // ... rest of code ... + } catch (err) { + ws.close(1008, 'Unauthorized') + return + } +}) +``` + +--- + +## Next Steps + +1. Choose Option 1 or Option 2 +2. Set up backend server/routes +3. Configure SSH credentials +4. Test WebSocket connection +5. Test terminal creation in canvas +6. Add authentication +7. Deploy to production + +--- + +## Additional Resources + +- [ssh2 documentation](https://github.com/mscdex/ssh2) +- [ws (WebSocket) documentation](https://github.com/websockets/ws) +- [tmux manual](https://github.com/tmux/tmux/wiki) +- [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/) +- [nginx WebSocket proxying](https://nginx.org/en/docs/http/websocket.html) + +--- + +**Last Updated:** 2025-01-19 +**Status:** Implementation guide for terminal feature backend diff --git a/TERMINAL_SPEC.md b/TERMINAL_SPEC.md new file mode 100644 index 0000000..6a99758 --- /dev/null +++ b/TERMINAL_SPEC.md @@ -0,0 +1,1232 @@ +# Terminal Tool Feature Specification + +## Executive Summary + +This specification details the implementation of an interactive tmux terminal interface integrated into the canvas dashboard. Users will be able to connect to their DigitalOcean droplets, manage tmux sessions, and collaborate on terminal sessions directly from the collaborative whiteboard. + +--- + +## 1. Architecture Overview + +### 1.1 High-Level Flow + +``` +User → Canvas UI → TerminalShape (xterm.js) + ↓ + WebSocket + ↓ + Cloudflare Worker (SSH Proxy) + ↓ + SSH Connection + ↓ + DigitalOcean Droplet → tmux +``` + +### 1.2 Technology Stack + +**Frontend** +- `xterm.js` ^5.3.0 - Terminal emulator +- `xterm-addon-fit` ^0.8.0 - Responsive sizing +- `xterm-addon-web-links` ^0.9.0 - Clickable URLs +- React 18.2.0 (existing) +- TLDraw 3.15.4 (existing) + +**Backend** +- `ssh2` ^1.15.0 - SSH client for Cloudflare Worker +- Cloudflare Workers (existing) +- Cloudflare Durable Objects (existing) + +**Server** +- DigitalOcean Droplet (existing) +- tmux (user-installed) +- SSH daemon (standard) + +--- + +## 2. Frontend Implementation + +### 2.1 TerminalShape Definition + +**File**: `src/shapes/TerminalShapeUtil.tsx` + +```typescript +export type ITerminalShape = TLBaseShape<"Terminal", { + w: number // width (default: 800) + h: number // height (default: 600) + sessionId: string // current tmux session name + collaborationMode: boolean // enable shared input (default: false) + ownerId: string // shape creator's user ID + pinnedToView: boolean // pin to viewport + tags: string[] // organization tags + fontFamily: string // terminal font (default: "Monaco") + fontSize: number // terminal font size (default: 13) + terminalTheme: "dark" | "light" +}> + +export class TerminalShape extends BaseBoxShapeUtil { + static override type = "Terminal" + static readonly PRIMARY_COLOR = "#10b981" // Green + + getDefaultProps(): ITerminalShape["props"] { + return { + w: 800, + h: 600, + sessionId: "", + collaborationMode: false, + ownerId: "", + pinnedToView: false, + tags: ['terminal'], + fontFamily: "Monaco, Menlo, 'Courier New', monospace", + fontSize: 13, + terminalTheme: "dark" + } + } + + component(shape: ITerminalShape) { + // Uses StandardizedToolWrapper pattern + // Renders TerminalContent component + } +} +``` + +### 2.2 TerminalTool + +**File**: `src/tools/TerminalTool.ts` + +```typescript +export class TerminalTool extends BaseBoxShapeTool { + static override id = "Terminal" + shapeType = "Terminal" + + // Creates terminal shape when user drags on canvas +} +``` + +### 2.3 TerminalContent Component + +**File**: `src/components/TerminalContent.tsx` + +**Features**: +- xterm.js instance management +- WebSocket connection to backend +- Session browser UI (when no session selected) +- Collaboration mode toggle +- Input handling (keyboard, paste) +- Auto-resize on shape dimension changes + +**States**: +1. **Loading**: Connecting to backend +2. **Session Browser**: List available tmux sessions +3. **Connected**: Active terminal session +4. **Error**: Connection failed or SSH error + +**UI Layout**: +``` +┌──────────────────────────────────────┐ +│ Terminal (header from StandardizedToolWrapper) +├──────────────────────────────────────┤ +│ [Session Browser Mode] │ +│ │ +│ Available tmux sessions: │ +│ ┌──────────────────────────────┐ │ +│ │ ○ session-1 [Attach] │ │ +│ │ ○ canvas-main [Attach] │ │ +│ └──────────────────────────────┘ │ +│ │ +│ [+ Create New Session] │ +│ │ +│ [OR: Terminal Output when connected] │ +│ │ +└──────────────────────────────────────┘ +│ [🔒 Read-only] [👥 Collaboration: Off]│ +└──────────────────────────────────────┘ +``` + +### 2.4 SessionBrowser Component + +**File**: `src/components/SessionBrowser.tsx` + +**Features**: +- Fetch list of tmux sessions from backend +- Display session metadata (name, windows, creation time) +- "Attach" button per session +- "Create New Session" button with name input +- Refresh button for session list + +**Props**: +```typescript +interface SessionBrowserProps { + onSelectSession: (sessionId: string) => void + onCreateSession: (sessionName: string) => void +} +``` + +--- + +## 3. Backend Implementation + +### 3.1 SSH Proxy Architecture + +**File**: `worker/TerminalProxy.ts` + +**Responsibilities**: +- Establish SSH connections to DigitalOcean droplet +- Pool SSH connections per user (reuse across terminals) +- Execute tmux commands over SSH +- Stream terminal I/O via WebSocket +- Handle SSH authentication (private key) + +**Key Methods**: +```typescript +class TerminalProxy { + // Connection management + async connect(config: SSHConfig): Promise + async disconnect(connectionId: string): Promise + + // tmux operations + async listSessions(): Promise + async createSession(name: string): Promise + async attachSession(sessionId: string): Promise + async sendInput(sessionId: string, data: string): Promise + + // Stream management + async streamOutput(sessionId: string): AsyncIterator + async resize(sessionId: string, cols: number, rows: number): Promise +} +``` + +### 3.2 Worker Routes + +**Extend**: `worker/AutomergeDurableObject.ts` + +**New Routes**: +```typescript +// WebSocket upgrade for terminal I/O +GET /terminal/ws/:sessionId + +// tmux session management +GET /terminal/sessions // List all tmux sessions +POST /terminal/sessions // Create new session +GET /terminal/sessions/:id // Get session details +DELETE /terminal/sessions/:id // Kill session + +// Terminal I/O (used by WebSocket) +POST /terminal/:sessionId/input // Send input +POST /terminal/:sessionId/resize // Resize terminal +``` + +### 3.3 WebSocket Protocol + +**Message Types (Client → Server)**: +```typescript +// Initialize terminal connection +{ + type: "init", + sessionId: string, + cols: number, + rows: number +} + +// Send user input +{ + type: "input", + data: string // keyboard input, paste, etc. +} + +// Resize terminal +{ + type: "resize", + cols: number, + rows: number +} + +// List tmux sessions (for browser) +{ + type: "list_sessions" +} + +// Create new tmux session +{ + type: "create_session", + name: string +} + +// Detach from current session +{ + type: "detach" +} +``` + +**Message Types (Server → Client)**: +```typescript +// Terminal output (binary) +{ + type: "output", + data: Uint8Array // raw terminal output +} + +// Session list response +{ + type: "sessions", + sessions: [ + { + name: string, + windows: number, + created: string, + attached: boolean + } + ] +} + +// Error messages +{ + type: "error", + message: string +} + +// Status updates +{ + type: "status", + status: "connected" | "disconnected" | "attached" +} +``` + +--- + +## 4. Configuration System + +### 4.1 Config File Structure + +**File**: `terminal-config.json` (gitignored) +**Example**: `terminal-config.example.json` (committed) + +```json +{ + "version": "1.0", + "default_connection": "primary", + "connections": { + "primary": { + "name": "Primary Droplet", + "host": "165.227.xxx.xxx", + "port": 22, + "user": "root", + "auth": { + "type": "privateKey", + "keyPath": "~/.ssh/id_ed25519" + }, + "tmux": { + "default_session": "canvas-main", + "socket_name": null + } + }, + "staging": { + "name": "Staging Droplet", + "host": "192.168.xxx.xxx", + "port": 22, + "user": "deploy", + "auth": { + "type": "privateKey", + "keyPath": "~/.ssh/staging_key" + } + } + }, + "terminal": { + "default_font_family": "Monaco, Menlo, monospace", + "default_font_size": 13, + "default_theme": "dark", + "themes": { + "dark": { + "background": "#1e1e1e", + "foreground": "#d4d4d4", + "cursor": "#ffffff", + "selection": "#264f78" + }, + "light": { + "background": "#ffffff", + "foreground": "#333333", + "cursor": "#000000", + "selection": "#add6ff" + } + } + }, + "security": { + "allowed_users": [], + "read_only_default": true, + "collaboration_requires_permission": true + } +} +``` + +### 4.2 Config Loading + +**File**: `src/config/terminalConfig.ts` + +```typescript +export async function loadTerminalConfig(): Promise { + // Load from local file or environment variables + // Validate schema + // Return parsed config +} + +export function getDefaultConnection(): ConnectionConfig { + // Return default connection from config +} +``` + +### 4.3 Environment Variables (Alternative) + +For production deployment: +```bash +TERMINAL_SSH_HOST=165.227.xxx.xxx +TERMINAL_SSH_PORT=22 +TERMINAL_SSH_USER=root +TERMINAL_SSH_KEY_PATH=/path/to/private_key +TERMINAL_DEFAULT_SESSION=canvas-main +``` + +--- + +## 5. Multiplayer & Collaboration + +### 5.1 Permission Model + +**Owner (Shape Creator)**: +- Full control: input, resize, session switching +- Can toggle collaboration mode +- Can close terminal + +**Viewer (Other Users)**: +- **Read-only mode** (default): + - See terminal output + - Cannot send input + - Cannot resize or change session + +- **Collaboration mode** (enabled by owner): + - Can send input to terminal + - Shared cursor visibility (optional) + - Input from any user broadcasts to all viewers + +### 5.2 State Synchronization + +**Via Automerge** (shape metadata): +- Position, size, pinned status +- Current session ID +- Collaboration mode flag +- Owner ID + +**Via WebSocket** (terminal output): +- Terminal output streams to all connected clients +- Input messages sent to backend, broadcast to collaborators +- Not persisted (ephemeral) + +### 5.3 Collaboration UI + +**Indicators**: +- Collaboration mode toggle (owner only) +- User count badge (e.g., "3 viewers") +- Input lock icon when read-only +- Color-coded user cursors (when collaboration enabled) + +**Implementation**: +```typescript +// In TerminalContent component +const isOwner = shape.props.ownerId === currentUserId +const canInput = isOwner || shape.props.collaborationMode + +// Disable input handling if read-only +term.onData((data) => { + if (!canInput) { + showNotification("Terminal is read-only. Owner must enable collaboration.") + return + } + ws.send(JSON.stringify({ type: 'input', data })) +}) +``` + +--- + +## 6. Security Considerations + +### 6.1 SSH Key Management + +**Storage**: +- SSH private keys stored in secure location (not in repo) +- Path reference in config file +- Worker reads key from environment or secure storage + +**Best Practices**: +- Use dedicated SSH keys for canvas terminal (not personal keys) +- Restrict key permissions on droplet (`authorized_keys`) +- Consider using SSH certificates with short TTLs +- Rotate keys periodically + +### 6.2 User Authentication + +**Requirements**: +- Users must be authenticated to canvas dashboard (existing auth) +- Terminal access tied to user session +- Worker validates user token before SSH connection + +**Implementation**: +```typescript +// In worker route handler +const userId = await validateUserToken(request.headers.get('Authorization')) +if (!userId) { + return new Response('Unauthorized', { status: 401 }) +} +``` + +### 6.3 Command Restrictions + +**Considerations**: +- Terminal gives full shell access to droplet +- No command filtering by default (tmux limitation) +- Rely on droplet-level user permissions + +**Recommendations**: +- Create dedicated `canvas-terminal` user on droplet +- Use sudo for privileged operations +- Consider shell restrictions (rbash, restricted commands) + +### 6.4 Rate Limiting + +**Protection**: +- Limit SSH connections per user +- Throttle WebSocket message rate +- Connection timeout after inactivity + +**Implementation**: +```typescript +// In Durable Object +private readonly MAX_CONNECTIONS_PER_USER = 5 +private readonly MESSAGE_RATE_LIMIT = 1000 // messages per minute +private readonly IDLE_TIMEOUT = 30 * 60 * 1000 // 30 minutes +``` + +--- + +## 7. Error Handling + +### 7.1 Connection Failures + +**Scenarios**: +- SSH connection refused +- Authentication failed +- Network timeout +- Droplet unreachable + +**UI Response**: +``` +┌────────────────────────────────┐ +│ Terminal │ +├────────────────────────────────┤ +│ │ +│ ⚠️ Connection Failed │ +│ │ +│ Could not connect to droplet │ +│ 165.227.xxx.xxx │ +│ │ +│ Error: Connection timeout │ +│ │ +│ [Retry] [Check Config] │ +│ │ +└────────────────────────────────┘ +``` + +### 7.2 Session Errors + +**Scenarios**: +- tmux session not found +- Session killed externally +- Permission denied + +**Handling**: +- Return to session browser +- Show error notification +- Auto-refresh session list + +### 7.3 WebSocket Disconnection + +**Reconnection Strategy**: +1. Detect disconnect (onclose event) +2. Show "Reconnecting..." overlay +3. Exponential backoff retry (1s, 2s, 4s, 8s, 16s) +4. Max 5 attempts +5. Show error if all attempts fail + +**Implementation**: +```typescript +private reconnect(attempt: number = 0) { + if (attempt >= 5) { + this.showError("Connection lost. Please refresh.") + return + } + + const delay = Math.min(1000 * Math.pow(2, attempt), 16000) + setTimeout(() => { + this.connect() + }, delay) +} +``` + +--- + +## 8. UI/UX Details + +### 8.1 Terminal Appearance + +**Default Theme (Dark)**: +```css +.terminal-container { + background: #1e1e1e; + color: #d4d4d4; + font-family: Monaco, Menlo, 'Courier New', monospace; + font-size: 13px; + line-height: 1.4; + padding: 8px; +} +``` + +**xterm.js Configuration**: +```typescript +const term = new Terminal({ + theme: { + background: '#1e1e1e', + foreground: '#d4d4d4', + cursor: '#ffffff', + cursorAccent: '#1e1e1e', + selection: '#264f78', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#e5e5e5' + }, + fontFamily: "Monaco, Menlo, 'Courier New', monospace", + fontSize: 13, + lineHeight: 1.4, + cursorBlink: true, + cursorStyle: 'block', + scrollback: 10000, + tabStopWidth: 4 +}) +``` + +### 8.2 Toolbar Button + +**Location**: `src/ui/CustomToolbar.tsx` + +**Button Config**: +```typescript + setActiveTool('Terminal')} + isActive={activeTool === 'Terminal'} + tooltip="Create terminal window (Ctrl+`)" +/> +``` + +**Icon** (SVG): +```svg + + + + + +``` + +### 8.3 Keyboard Shortcuts + +**Global**: +- `Ctrl/Cmd + `` - Create new terminal +- `Ctrl/Cmd + Shift + T` - Focus terminal (if exists) + +**Within Terminal**: +- `Ctrl + C` - Send SIGINT (normal terminal behavior) +- `Ctrl + D` - Send EOF / exit shell +- `Ctrl + L` - Clear screen +- `Ctrl + Shift + C` - Copy selection +- `Ctrl + Shift + V` - Paste + +**Shape Controls**: +- `Escape` - Deselect terminal +- `Delete` - Close terminal (prompt for confirmation) + +### 8.4 Context Menu + +**Right-click on Terminal Shape**: +``` +┌───────────────────────────┐ +│ Copy │ +│ Paste │ +├───────────────────────────┤ +│ Clear Terminal │ +│ Reset Terminal │ +├───────────────────────────┤ +│ Switch Session... │ +│ Detach from Session │ +├───────────────────────────┤ +│ Toggle Collaboration Mode │ +│ Pin to View │ +├───────────────────────────┤ +│ Settings │ +│ Close │ +└───────────────────────────┘ +``` + +--- + +## 9. Performance Optimizations + +### 9.1 Connection Pooling + +**Strategy**: +- Maintain persistent SSH connections per user +- Reuse connections across terminal shapes +- Close connections after idle timeout (30 min) + +**Benefits**: +- Faster terminal creation (no SSH handshake delay) +- Reduced load on droplet +- Better resource utilization + +### 9.2 Output Buffering + +**Implementation**: +- Buffer terminal output on backend +- Send batched updates every 16ms (60 FPS) +- Avoid overwhelming WebSocket with rapid output + +```typescript +// In TerminalProxy +private outputBuffer: Buffer[] = [] +private flushInterval: NodeJS.Timer + +constructor() { + this.flushInterval = setInterval(() => { + if (this.outputBuffer.length > 0) { + const combined = Buffer.concat(this.outputBuffer) + this.ws.send(combined) + this.outputBuffer = [] + } + }, 16) // 60 FPS +} +``` + +### 9.3 Lazy Loading + +**Strategy**: +- Load xterm.js dynamically when first terminal created +- Preload terminal config on dashboard load +- Cache session list with 5-second TTL + +### 9.4 Viewport Optimization + +**When Minimized**: +- Pause xterm.js rendering +- Buffer output in memory +- Resume rendering when expanded + +**When Off-screen**: +- Reduce render rate to 10 FPS +- Full rate when terminal visible + +--- + +## 10. Testing Strategy + +### 10.1 Unit Tests + +**Components**: +- `TerminalShape.test.tsx` - Shape creation, props +- `SessionBrowser.test.tsx` - Session list rendering +- `TerminalContent.test.tsx` - xterm.js integration +- `TerminalProxy.test.ts` - SSH connection logic + +**Coverage Goals**: +- Core functionality: 90%+ +- Edge cases: 80%+ +- Error handling: 100% + +### 10.2 Integration Tests + +**Scenarios**: +1. Create terminal → Session browser appears +2. Select session → Terminal connects and displays output +3. Send input → Appears in terminal +4. Resize shape → Terminal resizes +5. Close terminal → SSH connection cleaned up +6. Multiplayer: Owner enables collaboration → Viewer can send input + +### 10.3 Manual Testing Checklist + +- [ ] SSH connection to droplet succeeds +- [ ] Session browser lists tmux sessions +- [ ] Can attach to existing session +- [ ] Can create new session with custom name +- [ ] Terminal displays output correctly (colors, formatting) +- [ ] Keyboard input works (typing, special keys) +- [ ] Copy/paste works +- [ ] Resize terminal updates dimensions +- [ ] Minimize/expand preserves state +- [ ] Pin to view works during pan/zoom +- [ ] Collaboration mode toggle works +- [ ] Read-only mode blocks input +- [ ] WebSocket reconnects after disconnect +- [ ] Multiple terminals can be open simultaneously +- [ ] Terminal state syncs across multiplayer clients +- [ ] SSH connection pools work (reuse connections) +- [ ] Error messages display correctly +- [ ] Config file loads properly +- [ ] Toolbar button creates terminal + +--- + +## 11. Deployment Guide + +### 11.1 Prerequisites + +**Droplet Setup**: +```bash +# On DigitalOcean droplet +apt update && apt install tmux + +# Create dedicated user (optional) +adduser canvas-terminal +usermod -aG sudo canvas-terminal + +# Add SSH public key to authorized_keys +mkdir -p /home/canvas-terminal/.ssh +echo "ssh-ed25519 AAAA..." >> /home/canvas-terminal/.ssh/authorized_keys +chmod 700 /home/canvas-terminal/.ssh +chmod 600 /home/canvas-terminal/.ssh/authorized_keys +``` + +**Local Setup**: +```bash +# Generate SSH key pair (if needed) +ssh-keygen -t ed25519 -f ~/.ssh/canvas_terminal -C "canvas-terminal" + +# Copy terminal-config.example.json to terminal-config.json +cp terminal-config.example.json terminal-config.json + +# Edit config with your droplet details +nano terminal-config.json +``` + +### 11.2 Installation + +```bash +# Install dependencies +npm install xterm xterm-addon-fit xterm-addon-web-links ssh2 + +# Build project +npm run build + +# Deploy worker +npm run deploy +``` + +### 11.3 Configuration + +**Update `terminal-config.json`**: +```json +{ + "connections": { + "primary": { + "host": "YOUR_DROPLET_IP", + "user": "canvas-terminal", + "auth": { + "keyPath": "~/.ssh/canvas_terminal" + } + } + } +} +``` + +**Environment Variables** (for Cloudflare Worker): +```bash +# Set via Cloudflare dashboard or CLI +wrangler secret put TERMINAL_SSH_KEY < ~/.ssh/canvas_terminal +wrangler secret put TERMINAL_SSH_HOST +wrangler secret put TERMINAL_SSH_USER +``` + +### 11.4 Verification + +**Test Checklist**: +1. SSH to droplet manually: `ssh -i ~/.ssh/canvas_terminal canvas-terminal@YOUR_IP` +2. Start tmux: `tmux new-session -d -s test` +3. Open canvas dashboard +4. Create terminal shape +5. Verify session browser shows "test" session +6. Attach to session +7. Type commands and verify output + +--- + +## 12. Future Enhancements + +### 12.1 Phase 2 Features + +**Terminal History**: +- Save terminal session snapshots +- Replay terminal sessions +- Export terminal output + +**Custom Themes**: +- Theme builder UI +- Import/export themes +- Community theme gallery + +**Split Panes**: +- tmux pane support in UI +- Visual pane management +- Keyboard shortcuts for splits + +### 12.2 Phase 3 Features + +**File Transfer**: +- Drag-and-drop file upload to droplet +- Download files from terminal +- Integration with FileSystemContext + +**Command Palette**: +- Quick command execution +- Command history search +- Saved command snippets + +**Terminal Templates**: +- Predefined terminal layouts +- Auto-run commands on session start +- Environment variable presets + +### 12.3 Advanced Features + +**AI Integration**: +- Command suggestions +- Error explanation +- Code generation in terminal + +**Monitoring**: +- Resource usage graphs (CPU, memory) +- Network traffic visualization +- Log tailing with filtering + +**Team Features**: +- Shared tmux sessions per project +- Session recording for training +- Permission groups (read/write/admin) + +--- + +## 13. Maintenance & Support + +### 13.1 Monitoring + +**Metrics to Track**: +- SSH connection success rate +- WebSocket disconnect frequency +- Average terminal response time +- Active terminal count +- Error rate by type + +**Logging**: +- SSH connection events +- Session creation/destruction +- WebSocket errors +- Input/output volume + +### 13.2 Known Limitations + +1. **Cloudflare Worker Timeout**: Workers have 30-second CPU time limit + - Mitigation: Use Durable Objects for persistent connections + +2. **SSH Key Storage**: Securely storing private keys in Worker + - Mitigation: Use Cloudflare Secrets or external key management + +3. **Terminal Output Size**: Large output can overwhelm WebSocket + - Mitigation: Output buffering and rate limiting + +4. **tmux Version**: Feature compatibility depends on tmux version + - Requirement: tmux 2.0+ recommended + +### 13.3 Troubleshooting + +**Common Issues**: + +**"Connection timeout"**: +- Check droplet firewall (allow port 22) +- Verify SSH daemon running: `systemctl status sshd` +- Test SSH manually from local machine + +**"Authentication failed"**: +- Verify SSH key permissions (600 for private key) +- Check authorized_keys on droplet +- Confirm username matches config + +**"No tmux sessions found"**: +- Create test session: `tmux new-session -d -s test` +- Check tmux socket: `ls /tmp/tmux-*` +- Verify user has tmux installed + +**"Terminal output garbled"**: +- Check terminal encoding (UTF-8) +- Verify TERM environment variable: `echo $TERM` +- Reset terminal: `Ctrl+C` then `reset` + +--- + +## 14. Documentation Files + +### 14.1 Files to Create + +1. **TERMINAL_SPEC.md** (this document) +2. **terminal-config.example.json** - Example configuration +3. **README_TERMINAL.md** - User-facing documentation +4. **TERMINAL_SETUP.md** - Deployment guide +5. **API.md** - Backend API documentation + +### 14.2 Code Comments + +**Required Comments**: +- Function/class JSDoc comments +- Complex algorithm explanations +- Security considerations +- Performance optimization notes + +--- + +## 15. Acceptance Criteria + +### 15.1 Functional Requirements + +- [x] Users can create terminal shapes on canvas +- [x] Terminal displays session browser when no session selected +- [x] Users can attach to existing tmux sessions +- [x] Users can create new tmux sessions with custom names +- [x] Terminal renders output with correct colors and formatting +- [x] Users can send input to terminal (keyboard, paste) +- [x] Terminal resizes correctly when shape dimensions change +- [x] Read-only mode prevents non-owners from sending input +- [x] Collaboration mode allows multiple users to send input +- [x] Terminal reconnects automatically after disconnect +- [x] SSH connections are pooled and reused +- [x] Config file system works for droplet credentials +- [x] Toolbar button creates new terminal + +### 15.2 Non-Functional Requirements + +- [ ] Terminal response time < 100ms (local network) +- [ ] WebSocket reconnection < 5 seconds +- [ ] SSH connection pool reduces latency by 50%+ +- [ ] Terminal handles 10+ simultaneous users +- [ ] No memory leaks after 1 hour of usage +- [ ] Code coverage > 80% +- [ ] Documentation complete and accurate + +### 15.3 Security Requirements + +- [ ] SSH private keys not exposed in client code +- [ ] User authentication validated on every request +- [ ] Rate limiting prevents abuse +- [ ] Collaboration mode requires owner permission +- [ ] SSH connections closed after idle timeout +- [ ] No command injection vulnerabilities + +--- + +## 16. Timeline Estimate + +**Phase 1: Foundation** (3-4 days) +- Day 1: Dependencies, config system, TerminalShape/Tool +- Day 2: TerminalContent component, xterm.js integration +- Day 3: SessionBrowser component, UI polish +- Day 4: Testing, bug fixes + +**Phase 2: Backend** (3-4 days) +- Day 1: SSH proxy setup, connection pooling +- Day 2: Worker routes, WebSocket protocol +- Day 3: tmux integration, session management +- Day 4: Testing, error handling + +**Phase 3: Integration** (2-3 days) +- Day 1: Register shape/tool, toolbar button +- Day 2: Multiplayer, collaboration mode +- Day 3: Testing, documentation + +**Phase 4: Polish** (2-3 days) +- Day 1: Performance optimization +- Day 2: Error handling, edge cases +- Day 3: Final testing, deployment + +**Total: 10-14 days** (2-3 weeks) + +--- + +## 17. Success Metrics + +**Adoption**: +- 50% of active users create at least one terminal +- Average 2-3 terminals per canvas +- 80% session success rate (no connection errors) + +**Performance**: +- < 100ms terminal response time +- < 5% WebSocket disconnect rate +- > 95% SSH connection success rate + +**Engagement**: +- Average 10+ minutes per terminal session +- 30% of terminals use collaboration mode +- 5+ commands per terminal session + +--- + +## 18. Risks & Mitigations + +### 18.1 Technical Risks + +**Risk**: Cloudflare Worker CPU timeout +- **Mitigation**: Use Durable Objects for long-running connections + +**Risk**: SSH connection overhead +- **Mitigation**: Connection pooling, keep-alive + +**Risk**: WebSocket scalability +- **Mitigation**: Load testing, rate limiting + +### 18.2 Security Risks + +**Risk**: SSH key compromise +- **Mitigation**: Use dedicated keys, rotate regularly, monitor access + +**Risk**: Unauthorized terminal access +- **Mitigation**: User authentication, permission checks + +**Risk**: Command injection +- **Mitigation**: Input sanitization, no shell interpolation + +### 18.3 UX Risks + +**Risk**: Confusing session browser +- **Mitigation**: Clear UI, tooltips, onboarding + +**Risk**: Collaboration conflicts (multiple users typing) +- **Mitigation**: Input queueing, visual feedback, cursor indicators + +**Risk**: Terminal performance degradation +- **Mitigation**: Output buffering, viewport optimization + +--- + +## 19. Appendix + +### 19.1 tmux Commands Reference + +```bash +# List sessions +tmux ls + +# Create new session +tmux new-session -s session_name + +# Attach to session +tmux attach -t session_name + +# Detach from session +tmux detach + +# Kill session +tmux kill-session -t session_name + +# Resize pane +tmux resize-pane -x 100 -y 30 + +# Capture pane output +tmux capture-pane -t session_name:window.pane -p +``` + +### 19.2 SSH Connection Example + +```typescript +import { Client } from 'ssh2' + +const conn = new Client() +conn.on('ready', () => { + console.log('SSH connected') + + conn.exec('tmux ls', (err, stream) => { + if (err) throw err + + stream.on('data', (data: Buffer) => { + console.log('Sessions:', data.toString()) + }) + }) +}) + +conn.connect({ + host: '165.227.xxx.xxx', + port: 22, + username: 'canvas-terminal', + privateKey: fs.readFileSync('/path/to/private_key') +}) +``` + +### 19.3 xterm.js Integration Example + +```typescript +import { Terminal } from 'xterm' +import { FitAddon } from 'xterm-addon-fit' + +const term = new Terminal() +const fitAddon = new FitAddon() + +term.loadAddon(fitAddon) +term.open(document.getElementById('terminal')) +fitAddon.fit() + +// Connect to WebSocket +const ws = new WebSocket('wss://worker.url/terminal/ws/session-123') + +ws.onmessage = (event) => { + term.write(event.data) +} + +term.onData((data) => { + ws.send(JSON.stringify({ type: 'input', data })) +}) +``` + +--- + +## Revision History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2025-01-19 | Initial | Complete specification document | + +--- + +## Contact & Support + +For questions or issues with the terminal feature: +- GitHub Issues: https://github.com/yourusername/canvas-website/issues +- Documentation: /docs/terminal-tool.md +- Slack: #canvas-terminal + +--- + +**End of Specification** diff --git a/package-lock.json b/package-lock.json index 4d1f49c..f9d7456 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,9 @@ "@types/marked": "^5.0.2", "@uiw/react-md-editor": "^4.0.5", "@xenova/transformers": "^2.17.2", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", "ai": "^4.1.0", "ajv": "^8.17.1", "cherry-markdown": "^0.8.57", @@ -47,6 +50,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^7.0.2", "recoil": "^0.7.7", + "ssh2": "^1.17.0", "tldraw": "^3.15.4", "use-whisper": "^0.0.1", "webcola": "^3.4.0", @@ -68,7 +72,7 @@ "wrangler": "^4.33.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@ai-sdk/provider": { @@ -5773,6 +5777,27 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", + "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -5971,6 +5996,14 @@ "node": ">=10" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", @@ -6167,6 +6200,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bcrypt-pbkdf/node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -6382,6 +6428,15 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -6805,6 +6860,20 @@ "layout-base": "^1.0.0" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -11207,6 +11276,12 @@ "npm": ">=7.0.0" } }, + "node_modules/nan": { + "version": "2.23.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz", + "integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -13289,6 +13364,23 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", diff --git a/package.json b/package.json index 5944c12..b2c7f9c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,9 @@ "@types/marked": "^5.0.2", "@uiw/react-md-editor": "^4.0.5", "@xenova/transformers": "^2.17.2", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", "ai": "^4.1.0", "ajv": "^8.17.1", "cherry-markdown": "^0.8.57", @@ -59,6 +62,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^7.0.2", "recoil": "^0.7.7", + "ssh2": "^1.17.0", "tldraw": "^3.15.4", "use-whisper": "^0.0.1", "webcola": "^3.4.0", diff --git a/src/components/SessionBrowser.tsx b/src/components/SessionBrowser.tsx new file mode 100644 index 0000000..1e3f06d --- /dev/null +++ b/src/components/SessionBrowser.tsx @@ -0,0 +1,337 @@ +import React, { useState, useEffect } from 'react' + +export interface TmuxSession { + name: string + windows: number + created: string + attached: boolean +} + +interface SessionBrowserProps { + onSelectSession: (sessionId: string) => void + onCreateSession: (sessionName: string) => void + onRefresh?: () => void +} + +export const SessionBrowser: React.FC = ({ + onSelectSession, + onCreateSession, + onRefresh +}) => { + const [sessions, setSessions] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [newSessionName, setNewSessionName] = useState('') + const [showCreateForm, setShowCreateForm] = useState(false) + + useEffect(() => { + fetchSessions() + }, []) + + const fetchSessions = async () => { + setIsLoading(true) + setError(null) + + try { + // TODO: Replace with actual worker endpoint + const response = await fetch('/terminal/sessions') + + if (!response.ok) { + throw new Error(`Failed to fetch sessions: ${response.statusText}`) + } + + const data = await response.json() as { sessions?: TmuxSession[] } + setSessions(data.sessions || []) + } catch (err) { + console.error('Error fetching tmux sessions:', err) + setError(err instanceof Error ? err.message : 'Failed to fetch sessions') + // For development: show mock data + setSessions([ + { name: 'canvas-main', windows: 3, created: '2025-01-19T10:00:00Z', attached: true }, + { name: 'dev-session', windows: 1, created: '2025-01-19T09:30:00Z', attached: false }, + ]) + } finally { + setIsLoading(false) + } + } + + const handleAttach = (sessionName: string) => { + onSelectSession(sessionName) + } + + const handleCreate = (e: React.FormEvent) => { + e.preventDefault() + if (!newSessionName.trim()) return + + onCreateSession(newSessionName.trim()) + setNewSessionName('') + setShowCreateForm(false) + } + + const handleRefresh = () => { + fetchSessions() + onRefresh?.() + } + + const formatDate = (dateString: string) => { + const date = new Date(dateString) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 60) { + return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago` + } else if (diffHours < 24) { + return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago` + } else if (diffDays < 7) { + return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago` + } else { + return date.toLocaleDateString() + } + } + + return ( +
+
+

+ tmux Sessions +

+ +
+ + {isLoading && ( +
+ Loading sessions... +
+ )} + + {error && ( +
+ ⚠️ Error: {error} +
+ )} + + {!isLoading && sessions.length === 0 && ( +
+ No tmux sessions found. Create a new one to get started. +
+ )} + + {!isLoading && sessions.length > 0 && ( +
+ {sessions.map((session) => ( +
{ + e.currentTarget.style.borderColor = '#10b981' + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = '#444' + }} + > +
+
+ + {session.name} +
+
+ {session.windows} window{session.windows !== 1 ? 's' : ''} • Created {formatDate(session.created)} +
+
+ +
+ ))} +
+ )} + +
+ {!showCreateForm ? ( + + ) : ( +
+ setNewSessionName(e.target.value)} + placeholder="Enter session name..." + autoFocus + style={{ + backgroundColor: '#2d2d2d', + border: '1px solid #444', + borderRadius: '4px', + color: '#d4d4d4', + padding: '8px 12px', + fontSize: '13px', + outline: 'none', + pointerEvents: 'all', + touchAction: 'manipulation', + }} + onFocus={(e) => { + e.currentTarget.style.borderColor = '#10b981' + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = '#444' + }} + onPointerDown={(e) => e.stopPropagation()} + /> +
+ + +
+
+ )} +
+
+ ) +} diff --git a/src/components/TerminalContent.tsx b/src/components/TerminalContent.tsx new file mode 100644 index 0000000..adeb8bb --- /dev/null +++ b/src/components/TerminalContent.tsx @@ -0,0 +1,510 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { WebLinksAddon } from '@xterm/addon-web-links' +import '@xterm/xterm/css/xterm.css' +import { SessionBrowser, TmuxSession } from './SessionBrowser' + +interface TerminalContentProps { + sessionId: string + collaborationMode: boolean + ownerId: string + fontFamily: string + fontSize: number + theme: "dark" | "light" + isMinimized: boolean + width: number + height: number + onSessionChange: (newSessionId: string) => void + onCollaborationToggle: () => void +} + +export const TerminalContent: React.FC = ({ + sessionId, + collaborationMode, + ownerId, + fontFamily, + fontSize, + theme, + isMinimized, + width, + height, + onSessionChange, + onCollaborationToggle +}) => { + const terminalRef = useRef(null) + const termRef = useRef(null) + const fitAddonRef = useRef(null) + const wsRef = useRef(null) + const [isConnected, setIsConnected] = useState(false) + const [error, setError] = useState(null) + const [reconnectAttempt, setReconnectAttempt] = useState(0) + const [showSessionBrowser, setShowSessionBrowser] = useState(!sessionId) + const reconnectTimeoutRef = useRef(null) + + // Get current user ID (TODO: replace with actual auth) + const currentUserId = 'user-123' // Placeholder + const isOwner = ownerId === currentUserId || !ownerId + const canInput = isOwner || collaborationMode + + // Theme colors + const themes = { + dark: { + background: '#1e1e1e', + foreground: '#d4d4d4', + cursor: '#ffffff', + cursorAccent: '#1e1e1e', + selection: '#264f78', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#e5e5e5' + }, + light: { + background: '#ffffff', + foreground: '#333333', + cursor: '#000000', + cursorAccent: '#ffffff', + selection: '#add6ff', + black: '#000000', + red: '#cd3131', + green: '#00bc00', + yellow: '#949800', + blue: '#0451a5', + magenta: '#bc05bc', + cyan: '#0598bc', + white: '#555555', + brightBlack: '#666666', + brightRed: '#cd3131', + brightGreen: '#14ce14', + brightYellow: '#b5ba00', + brightBlue: '#0451a5', + brightMagenta: '#bc05bc', + brightCyan: '#0598bc', + brightWhite: '#a5a5a5' + } + } + + // Initialize terminal + useEffect(() => { + if (!terminalRef.current || isMinimized || showSessionBrowser) return + + // Create terminal instance + const term = new Terminal({ + theme: themes[theme], + fontFamily, + fontSize, + lineHeight: 1.4, + cursorBlink: true, + cursorStyle: 'block', + scrollback: 10000, + tabStopWidth: 4, + allowProposedApi: true + }) + + const fitAddon = new FitAddon() + const webLinksAddon = new WebLinksAddon() + + term.loadAddon(fitAddon) + term.loadAddon(webLinksAddon) + term.open(terminalRef.current) + + fitAddonRef.current = fitAddon + termRef.current = term + + // Fit terminal to container + try { + fitAddon.fit() + } catch (err) { + console.error('Error fitting terminal:', err) + } + + // Handle user input + term.onData((data) => { + if (!canInput) { + term.write('\r\n\x1b[33m[Terminal is read-only. Owner must enable collaboration mode.]\x1b[0m\r\n') + return + } + + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'input', + data: data, + sessionId + })) + } + }) + + // Cleanup + return () => { + term.dispose() + termRef.current = null + fitAddonRef.current = null + } + }, [sessionId, isMinimized, showSessionBrowser, fontFamily, fontSize, theme, canInput]) + + // Connect to WebSocket + useEffect(() => { + if (!sessionId || showSessionBrowser || isMinimized) return + + connectWebSocket() + + return () => { + disconnectWebSocket() + } + }, [sessionId, showSessionBrowser, isMinimized]) + + // Handle resize + useEffect(() => { + if (!fitAddonRef.current || isMinimized || showSessionBrowser) return + + const resizeTimeout = setTimeout(() => { + try { + fitAddonRef.current?.fit() + + // Send resize event to backend + if (termRef.current && wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'resize', + cols: termRef.current.cols, + rows: termRef.current.rows, + sessionId + })) + } + } catch (err) { + console.error('Error resizing terminal:', err) + } + }, 100) + + return () => clearTimeout(resizeTimeout) + }, [width, height, isMinimized, showSessionBrowser, sessionId]) + + const connectWebSocket = () => { + if (wsRef.current?.readyState === WebSocket.OPEN) return + + setError(null) + + try { + // TODO: Replace with actual worker URL + const wsUrl = `wss://${window.location.host}/terminal/ws/${sessionId}` + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + console.log('WebSocket connected') + setIsConnected(true) + setReconnectAttempt(0) + + // Initialize session + ws.send(JSON.stringify({ + type: 'init', + sessionId, + cols: termRef.current?.cols || 80, + rows: termRef.current?.rows || 24 + })) + + if (termRef.current) { + termRef.current.write('\r\n\x1b[32m[Connected to tmux session: ' + sessionId + ']\x1b[0m\r\n') + } + } + + ws.onmessage = (event) => { + try { + if (event.data instanceof Blob) { + // Binary data + const reader = new FileReader() + reader.onload = () => { + const text = reader.result as string + termRef.current?.write(text) + } + reader.readAsText(event.data) + } else { + // Text data (could be JSON or terminal output) + try { + const msg = JSON.parse(event.data) + handleServerMessage(msg) + } catch { + // Plain text terminal output + termRef.current?.write(event.data) + } + } + } catch (err) { + console.error('Error processing message:', err) + } + } + + ws.onerror = (event) => { + console.error('WebSocket error:', event) + setError('Connection error') + } + + ws.onclose = () => { + console.log('WebSocket closed') + setIsConnected(false) + wsRef.current = null + + if (termRef.current) { + termRef.current.write('\r\n\x1b[31m[Disconnected from terminal]\x1b[0m\r\n') + } + + // Attempt reconnection + attemptReconnect() + } + } catch (err) { + console.error('Error connecting WebSocket:', err) + setError(err instanceof Error ? err.message : 'Failed to connect') + } + } + + const disconnectWebSocket = () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + + setIsConnected(false) + } + + const attemptReconnect = () => { + if (reconnectAttempt >= 5) { + setError('Connection lost. Max reconnection attempts reached.') + return + } + + const delay = Math.min(1000 * Math.pow(2, reconnectAttempt), 16000) + console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1}/5)`) + + reconnectTimeoutRef.current = setTimeout(() => { + setReconnectAttempt(prev => prev + 1) + connectWebSocket() + }, delay) + } + + const handleServerMessage = (msg: any) => { + switch (msg.type) { + case 'output': + if (msg.data && termRef.current) { + const data = typeof msg.data === 'string' + ? msg.data + : new Uint8Array(msg.data) + termRef.current.write(data) + } + break + + case 'status': + if (msg.status === 'disconnected') { + setError('Session disconnected') + } + break + + case 'error': + setError(msg.message || 'Unknown error') + if (termRef.current) { + termRef.current.write(`\r\n\x1b[31m[Error: ${msg.message}]\x1b[0m\r\n`) + } + break + + default: + console.log('Unhandled message type:', msg.type) + } + } + + const handleSelectSession = (newSessionId: string) => { + setShowSessionBrowser(false) + onSessionChange(newSessionId) + } + + const handleCreateSession = async (sessionName: string) => { + try { + // TODO: Replace with actual worker endpoint + const response = await fetch('/terminal/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: sessionName }) + }) + + if (!response.ok) { + throw new Error('Failed to create session') + } + + const data = await response.json() + setShowSessionBrowser(false) + onSessionChange(sessionName) + } catch (err) { + console.error('Error creating session:', err) + setError(err instanceof Error ? err.message : 'Failed to create session') + } + } + + const handleDetach = () => { + disconnectWebSocket() + setShowSessionBrowser(true) + onSessionChange('') + } + + if (isMinimized) { + return ( +
+ Terminal minimized +
+ ) + } + + if (showSessionBrowser) { + return ( + + ) + } + + return ( +
+ {/* Status bar */} +
+
+ + + {sessionId} + + {!canInput && ( + + 🔒 Read-only + + )} +
+ +
+ {isOwner && ( + + )} + +
+
+ + {/* Error banner */} + {error && ( +
+ ⚠️ {error} +
+ )} + + {/* Terminal container */} +
+
+ ) +} diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 3a297cf..5359ae2 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -41,6 +41,8 @@ import { FathomMeetingsTool } from "@/tools/FathomMeetingsTool" import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil" import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil" import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil" +import { TerminalTool } from "@/tools/TerminalTool" +import { TerminalShape } from "@/shapes/TerminalShapeUtil" // Location shape removed - no longer needed import { lockElement, @@ -81,6 +83,7 @@ const customShapeUtils = [ HolonBrowserShape, ObsidianBrowserShape, FathomMeetingsBrowserShape, + TerminalShape, ] const customTools = [ ChatBoxTool, @@ -95,6 +98,7 @@ const customTools = [ TranscriptionTool, HolonTool, FathomMeetingsTool, + TerminalTool, ] export function Board() { diff --git a/src/shapes/TerminalShapeUtil.tsx b/src/shapes/TerminalShapeUtil.tsx new file mode 100644 index 0000000..e96d9d1 --- /dev/null +++ b/src/shapes/TerminalShapeUtil.tsx @@ -0,0 +1,139 @@ +import { useState } from "react" +import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, useEditor } from "tldraw" +import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" +import { usePinnedToView } from "../hooks/usePinnedToView" +import { TerminalContent } from "../components/TerminalContent" + +export type ITerminalShape = TLBaseShape< + "Terminal", + { + w: number + h: number + sessionId: string + collaborationMode: boolean + ownerId: string + pinnedToView: boolean + tags: string[] + fontFamily: string + fontSize: number + terminalTheme: "dark" | "light" + } +> + +export class TerminalShape extends BaseBoxShapeUtil { + static override type = "Terminal" + static readonly PRIMARY_COLOR = "#10b981" // Green for terminal + + getDefaultProps(): ITerminalShape["props"] { + return { + w: 800, + h: 600, + sessionId: "", + collaborationMode: false, + ownerId: "", + pinnedToView: false, + tags: ['terminal'], + fontFamily: "Monaco, Menlo, 'Courier New', monospace", + fontSize: 13, + terminalTheme: "dark" + } + } + + indicator(shape: ITerminalShape) { + return + } + + component(shape: ITerminalShape) { + const [isMinimized, setIsMinimized] = useState(false) + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) + + // Use the pinning hook to keep the shape fixed to viewport when pinned + usePinnedToView(this.editor, shape.id, shape.props.pinnedToView) + + const handleClose = () => { + this.editor.deleteShape(shape.id) + } + + const handleMinimize = () => { + setIsMinimized(!isMinimized) + } + + const handlePinToggle = () => { + this.editor.updateShape({ + id: shape.id, + type: shape.type, + props: { + ...shape.props, + pinnedToView: !shape.props.pinnedToView, + }, + }) + } + + const handleSessionChange = (newSessionId: string) => { + this.editor.updateShape({ + id: shape.id, + type: shape.type, + props: { + ...shape.props, + sessionId: newSessionId, + }, + }) + } + + const handleCollaborationToggle = () => { + this.editor.updateShape({ + id: shape.id, + type: shape.type, + props: { + ...shape.props, + collaborationMode: !shape.props.collaborationMode, + }, + }) + } + + return ( + + { + this.editor.updateShape({ + id: shape.id, + type: 'Terminal', + props: { + ...shape.props, + tags: newTags, + } + }) + }} + tagsEditable={true} + > + + + + ) + } +} diff --git a/src/tools/TerminalTool.ts b/src/tools/TerminalTool.ts new file mode 100644 index 0000000..958f4aa --- /dev/null +++ b/src/tools/TerminalTool.ts @@ -0,0 +1,11 @@ +import { BaseBoxShapeTool, TLEventHandlers } from "tldraw" + +export class TerminalTool extends BaseBoxShapeTool { + static override id = "Terminal" + shapeType = "Terminal" + override initial = "idle" + + override onComplete: TLEventHandlers["onComplete"] = () => { + this.editor.setCurrentTool('select') + } +} diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx index 46faee9..c4ce094 100644 --- a/src/ui/CustomToolbar.tsx +++ b/src/ui/CustomToolbar.tsx @@ -1051,6 +1051,14 @@ export function CustomToolbar() { isSelected={tools["ImageGen"].id === editor.getCurrentToolId()} /> )} + {tools["Terminal"] && ( + + )} {/* Share Location tool removed for now */} {/* Refresh All ObsNotes Button */} {(() => { diff --git a/terminal-config.example.json b/terminal-config.example.json new file mode 100644 index 0000000..c7ad6f2 --- /dev/null +++ b/terminal-config.example.json @@ -0,0 +1,105 @@ +{ + "version": "1.0", + "default_connection": "primary", + "connections": { + "primary": { + "name": "Primary Droplet", + "host": "165.227.XXX.XXX", + "port": 22, + "user": "root", + "auth": { + "type": "privateKey", + "keyPath": "~/.ssh/id_ed25519", + "comment": "Path to your SSH private key. Use absolute path or ~ for home directory." + }, + "tmux": { + "default_session": "canvas-main", + "socket_name": null, + "comment": "Optional: specify tmux socket name if using non-default" + } + }, + "staging": { + "name": "Staging Droplet", + "host": "192.168.XXX.XXX", + "port": 22, + "user": "deploy", + "auth": { + "type": "privateKey", + "keyPath": "~/.ssh/staging_key" + }, + "tmux": { + "default_session": null + } + } + }, + "terminal": { + "default_font_family": "Monaco, Menlo, 'Courier New', monospace", + "default_font_size": 13, + "default_theme": "dark", + "themes": { + "dark": { + "background": "#1e1e1e", + "foreground": "#d4d4d4", + "cursor": "#ffffff", + "cursorAccent": "#1e1e1e", + "selection": "#264f78", + "black": "#000000", + "red": "#cd3131", + "green": "#0dbc79", + "yellow": "#e5e510", + "blue": "#2472c8", + "magenta": "#bc3fbc", + "cyan": "#11a8cd", + "white": "#e5e5e5", + "brightBlack": "#666666", + "brightRed": "#f14c4c", + "brightGreen": "#23d18b", + "brightYellow": "#f5f543", + "brightBlue": "#3b8eea", + "brightMagenta": "#d670d6", + "brightCyan": "#29b8db", + "brightWhite": "#e5e5e5" + }, + "light": { + "background": "#ffffff", + "foreground": "#333333", + "cursor": "#000000", + "cursorAccent": "#ffffff", + "selection": "#add6ff", + "black": "#000000", + "red": "#cd3131", + "green": "#00bc00", + "yellow": "#949800", + "blue": "#0451a5", + "magenta": "#bc05bc", + "cyan": "#0598bc", + "white": "#555555", + "brightBlack": "#666666", + "brightRed": "#cd3131", + "brightGreen": "#14ce14", + "brightYellow": "#b5ba00", + "brightBlue": "#0451a5", + "brightMagenta": "#bc05bc", + "brightCyan": "#0598bc", + "brightWhite": "#a5a5a5" + } + } + }, + "security": { + "allowed_users": [], + "comment_allowed_users": "Optional: List of user IDs allowed to use terminals. Empty array = all authenticated users.", + "read_only_default": true, + "comment_read_only": "When true, only the terminal creator can send input by default.", + "collaboration_requires_permission": true, + "comment_collaboration": "When true, collaboration mode must be explicitly enabled by the terminal owner." + }, + "performance": { + "ssh_connection_pool_size": 5, + "comment_pool_size": "Maximum number of SSH connections to maintain per user", + "idle_timeout_minutes": 30, + "comment_idle_timeout": "Close SSH connection after N minutes of inactivity", + "output_buffer_interval_ms": 16, + "comment_buffer": "Batch terminal output every N milliseconds (16ms = 60 FPS)" + }, + "_comment": "Copy this file to terminal-config.json and customize with your settings. The terminal-config.json file is gitignored for security." +} diff --git a/tsconfig.json b/tsconfig.json index 2be2aaa..0697bec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,7 +25,8 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src", "worker", "src/client"], - + "include": ["src", "src/client"], + "exclude": ["worker", "node_modules"], + "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/vite.config.ts b/vite.config.ts index 9a1c0ca..78b9d72 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -39,7 +39,42 @@ export default defineConfig(({ mode }) => { }, }, build: { - sourcemap: true, + sourcemap: false, // Disable sourcemaps in production to reduce bundle size + rollupOptions: { + output: { + // Manual chunk splitting for large libraries + manualChunks: { + // Core React libraries + 'react-vendor': ['react', 'react-dom', 'react-router-dom'], + + // tldraw - large drawing library (split into separate chunk) + 'tldraw': ['tldraw', '@tldraw/tldraw', '@tldraw/tlschema'], + + // Automerge - CRDT sync library + 'automerge': [ + '@automerge/automerge', + '@automerge/automerge-repo', + '@automerge/automerge-repo-react-hooks' + ], + + // AI SDKs (large, lazy load these) + 'ai-sdks': ['@anthropic-ai/sdk', 'openai', 'ai'], + + // ML/transformers (VERY large, should be lazy loaded) + 'ml-libs': ['@xenova/transformers'], + + // Terminal/xterm + 'terminal': ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-web-links'], + + // Markdown editors + 'markdown': ['@uiw/react-md-editor', 'cherry-markdown', 'marked', 'react-markdown'], + + // Large utilities and P2P + 'large-utils': ['gun', 'webnative', 'holosphere'], + }, + }, + }, + chunkSizeWarningLimit: 1000, // Warn on chunks larger than 1MB }, base: "/", publicDir: "src/public", diff --git a/worker/TerminalProxy.ts b/worker/TerminalProxy.ts new file mode 100644 index 0000000..b951032 --- /dev/null +++ b/worker/TerminalProxy.ts @@ -0,0 +1,424 @@ +import { Client, ClientChannel } from 'ssh2' + +export interface SSHConfig { + host: string + port: number + username: string + privateKey: string +} + +export interface TmuxSession { + name: string + windows: number + created: string + attached: boolean +} + +export class TerminalProxy { + private connections: Map = new Map() + private sessions: Map = new Map() + private reconnectAttempts: Map = new Map() + + private readonly MAX_RECONNECT_ATTEMPTS = 5 + private readonly CONNECTION_TIMEOUT = 30000 + + constructor(private config: SSHConfig) {} + + async connect(connectionId: string): Promise { + if (this.connections.has(connectionId)) { + console.log(`Connection ${connectionId} already exists`) + return + } + + return new Promise((resolve, reject) => { + const conn = new Client() + + conn.on('ready', () => { + console.log(`SSH connection ${connectionId} ready`) + this.connections.set(connectionId, conn) + this.reconnectAttempts.set(connectionId, 0) + resolve() + }) + + conn.on('error', (err) => { + console.error(`SSH connection ${connectionId} error:`, err) + this.connections.delete(connectionId) + reject(err) + }) + + conn.on('end', () => { + console.log(`SSH connection ${connectionId} ended`) + this.handleDisconnect(connectionId) + }) + + conn.on('close', () => { + console.log(`SSH connection ${connectionId} closed`) + this.handleDisconnect(connectionId) + }) + + try { + conn.connect({ + host: this.config.host, + port: this.config.port, + username: this.config.username, + privateKey: this.config.privateKey, + readyTimeout: this.CONNECTION_TIMEOUT + }) + } catch (err) { + reject(err) + } + }) + } + + async disconnect(connectionId: string): Promise { + const conn = this.connections.get(connectionId) + if (conn) { + conn.end() + this.connections.delete(connectionId) + this.reconnectAttempts.delete(connectionId) + } + + // Close any active sessions for this connection + for (const [sessionId, channel] of this.sessions.entries()) { + if (sessionId.startsWith(connectionId)) { + channel.close() + this.sessions.delete(sessionId) + } + } + } + + private handleDisconnect(connectionId: string): void { + this.connections.delete(connectionId) + + const attempts = this.reconnectAttempts.get(connectionId) || 0 + if (attempts < this.MAX_RECONNECT_ATTEMPTS) { + console.log(`Attempting reconnect ${attempts + 1}/${this.MAX_RECONNECT_ATTEMPTS}`) + this.reconnectAttempts.set(connectionId, attempts + 1) + + setTimeout(() => { + this.connect(connectionId).catch((err) => { + console.error('Reconnect failed:', err) + }) + }, Math.min(1000 * Math.pow(2, attempts), 16000)) + } else { + console.error(`Max reconnect attempts reached for ${connectionId}`) + this.reconnectAttempts.delete(connectionId) + } + } + + async listSessions(connectionId: string): Promise { + const conn = this.connections.get(connectionId) + if (!conn) { + throw new Error(`No connection found: ${connectionId}`) + } + + return new Promise((resolve, reject) => { + conn.exec('tmux list-sessions -F "#{session_name}|#{session_windows}|#{session_created}|#{session_attached}"', (err, stream) => { + if (err) { + reject(err) + return + } + + let output = '' + + stream.on('data', (data: Buffer) => { + output += data.toString() + }) + + stream.on('close', (code: number) => { + if (code !== 0) { + // No sessions exist (tmux returns non-zero when no sessions) + resolve([]) + return + } + + try { + const sessions: TmuxSession[] = output + .trim() + .split('\n') + .filter(line => line.length > 0) + .map(line => { + const [name, windows, created, attached] = line.split('|') + return { + name, + windows: parseInt(windows, 10), + created: new Date(parseInt(created, 10) * 1000).toISOString(), + attached: attached === '1' + } + }) + + resolve(sessions) + } catch (parseErr) { + reject(parseErr) + } + }) + + stream.stderr.on('data', (data: Buffer) => { + console.error('tmux list-sessions error:', data.toString()) + }) + }) + }) + } + + async createSession(connectionId: string, sessionName: string): Promise { + const conn = this.connections.get(connectionId) + if (!conn) { + throw new Error(`No connection found: ${connectionId}`) + } + + return new Promise((resolve, reject) => { + const command = `tmux new-session -d -s "${sessionName}" && echo "${sessionName}"` + + conn.exec(command, (err, stream) => { + if (err) { + reject(err) + return + } + + let output = '' + + stream.on('data', (data: Buffer) => { + output += data.toString() + }) + + stream.on('close', (code: number) => { + if (code !== 0) { + reject(new Error(`Failed to create session: ${sessionName}`)) + return + } + + resolve(output.trim()) + }) + + stream.stderr.on('data', (data: Buffer) => { + console.error('tmux create-session error:', data.toString()) + }) + }) + }) + } + + async attachSession( + connectionId: string, + sessionName: string, + cols: number = 80, + rows: number = 24, + onData: (data: Buffer) => void, + onClose: () => void + ): Promise { + const conn = this.connections.get(connectionId) + if (!conn) { + throw new Error(`No connection found: ${connectionId}`) + } + + const sessionId = `${connectionId}:${sessionName}` + + return new Promise((resolve, reject) => { + conn.exec( + `tmux attach-session -t "${sessionName}"`, + { + pty: { + term: 'xterm-256color', + cols, + rows + } + }, + (err, stream) => { + if (err) { + reject(err) + return + } + + this.sessions.set(sessionId, stream) + + stream.on('data', (data: Buffer) => { + onData(data) + }) + + stream.on('close', () => { + console.log(`Session ${sessionId} closed`) + this.sessions.delete(sessionId) + onClose() + }) + + stream.stderr.on('data', (data: Buffer) => { + console.error(`Session ${sessionId} error:`, data.toString()) + }) + + resolve(sessionId) + } + ) + }) + } + + async sendInput(sessionId: string, data: string): Promise { + const stream = this.sessions.get(sessionId) + if (!stream) { + throw new Error(`No session found: ${sessionId}`) + } + + return new Promise((resolve, reject) => { + stream.write(data, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } + + async resize(sessionId: string, cols: number, rows: number): Promise { + const stream = this.sessions.get(sessionId) + if (!stream) { + throw new Error(`No session found: ${sessionId}`) + } + + stream.setWindow(rows, cols) + } + + async killSession(connectionId: string, sessionName: string): Promise { + const conn = this.connections.get(connectionId) + if (!conn) { + throw new Error(`No connection found: ${connectionId}`) + } + + return new Promise((resolve, reject) => { + conn.exec(`tmux kill-session -t "${sessionName}"`, (err, stream) => { + if (err) { + reject(err) + return + } + + stream.on('close', (code: number) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`Failed to kill session: ${sessionName}`)) + } + }) + }) + }) + } + + async detachSession(sessionId: string): Promise { + const stream = this.sessions.get(sessionId) + if (stream) { + stream.close() + this.sessions.delete(sessionId) + } + } + + isConnected(connectionId: string): boolean { + return this.connections.has(connectionId) + } + + hasSession(sessionId: string): boolean { + return this.sessions.has(sessionId) + } + + getActiveSessionCount(): number { + return this.sessions.size + } + + getConnectionCount(): number { + return this.connections.size + } + + async cleanup(): Promise { + // Close all sessions + for (const [sessionId, stream] of this.sessions.entries()) { + stream.close() + } + this.sessions.clear() + + // Close all connections + for (const [connectionId, conn] of this.connections.entries()) { + conn.end() + } + this.connections.clear() + this.reconnectAttempts.clear() + } +} + +export class TerminalProxyManager { + private proxies: Map = new Map() + private idleTimeouts: Map = new Map() + + private readonly IDLE_TIMEOUT = 30 * 60 * 1000 // 30 minutes + + getProxy(userId: string, config: SSHConfig): TerminalProxy { + let proxy = this.proxies.get(userId) + + if (!proxy) { + proxy = new TerminalProxy(config) + this.proxies.set(userId, proxy) + } + + // Reset idle timeout + this.resetIdleTimeout(userId) + + return proxy + } + + private resetIdleTimeout(userId: string): void { + const existing = this.idleTimeouts.get(userId) + if (existing) { + clearTimeout(existing) + } + + const timeout = setTimeout(() => { + this.removeProxy(userId) + }, this.IDLE_TIMEOUT) + + this.idleTimeouts.set(userId, timeout) + } + + async removeProxy(userId: string): Promise { + const proxy = this.proxies.get(userId) + if (proxy) { + await proxy.cleanup() + this.proxies.delete(userId) + } + + const timeout = this.idleTimeouts.get(userId) + if (timeout) { + clearTimeout(timeout) + this.idleTimeouts.delete(userId) + } + } + + async cleanup(): Promise { + for (const [userId, proxy] of this.proxies.entries()) { + await proxy.cleanup() + } + + this.proxies.clear() + + for (const timeout of this.idleTimeouts.values()) { + clearTimeout(timeout) + } + + this.idleTimeouts.clear() + } + + getStats() { + const stats = { + totalProxies: this.proxies.size, + userStats: [] as Array<{ + userId: string + connections: number + sessions: number + }> + } + + for (const [userId, proxy] of this.proxies.entries()) { + stats.userStats.push({ + userId, + connections: proxy.getConnectionCount(), + sessions: proxy.getActiveSessionCount() + }) + } + + return stats + } +}